Compare commits

...
Author SHA1 Message Date
bincxz 017606bd61 Keep note picker selection and untitled labels consistent 2026-09-07 14:21:13 +08:00
bincxz c8e4346193 Keep imported long note identities readable 2026-09-07 14:08:20 +08:00
bincxz d6e47d18a5 Retain mixed attachment context in compact note replay 2026-09-07 13:57:00 +08:00
bincxz 64c4ce9e9d Preserve compact note requests and announce picker selection 2026-09-07 13:49:14 +08:00
bincxz 69c5ccdb4b Bound agent note reads and add resumable literal search 2026-09-07 13:39:20 +08:00
bincxz 0e43e80555 fix(ai): keep accepted note references within replay space 2026-09-07 13:01:16 +08:00
bincxz d7ec12037e refactor(ai): share one instruction across note references 2026-09-07 12:53:58 +08:00
bincxz 43a35a5e62 fix(ai): retain note references ahead of replay prose 2026-09-07 12:52:21 +08:00
bincxz d78738e96d fix(ai): gate note references on reader availability 2026-09-07 12:45:58 +08:00
bincxz d8975c6fb2 refactor(ai): keep note mentions as lightweight references 2026-09-07 12:34:17 +08:00
bincxz 979dccc0b3 fix(ai): protect continuation before pruning note payloads 2026-09-07 12:18:27 +08:00
bincxz fd1b433940 fix(ai): reject carriage returns in note references 2026-09-07 12:04:00 +08:00
bincxz 432cf8d323 fix(ai): retain draft state binding in panel host 2026-09-07 11:51:18 +08:00
bincxz 4f3f72116e fix(ai): reuse normalized note search fields while typing 2026-09-07 11:39:00 +08:00
bincxz d6b47b5363 fix(ai): apply note attachment budgets synchronously 2026-09-07 11:29:47 +08:00
netcatty-bot 7acc67bef2 fix: address Codex review on PR #3290 2026-09-06 17:34:07 +00:00
netcatty-bot 9bf5108f4c fix: address Codex review on PR #3290 2026-09-06 17:05:33 +00:00
netcatty-bot 7683341270 fix: address Codex review on PR #3290 2026-09-06 16:41:50 +00:00
netcatty-bot 758dc26641 fix: address Codex review on PR #3290 2026-09-06 15:57:11 +00:00
netcatty-bot 3153158b12 fix: address Codex review on PR #3290 2026-09-06 15:32:54 +00:00
netcatty-bot 89c23e4453 fix: address Codex review on PR #3290 2026-09-06 15:07:31 +00:00
netcatty-bot 3a41b6f462 fix: address Codex review on PR #3290 2026-09-06 14:34:32 +00:00
netcatty-bot a6d326664e fix: address Codex review on PR #3290 2026-09-06 14:08:05 +00:00
netcatty-bot d6a553691c fix: address Codex review on PR #3290 2026-09-06 13:34:52 +00:00
netcatty-bot 2783684d10 fix: address Codex review on PR #3290 2026-09-06 13:03:00 +00:00
netcatty-bot 6adef4c680 fix: address Codex review on PR #3290 2026-09-06 12:32:52 +00:00
netcatty-bot 3820635809 fix: address Codex review on PR #3290 2026-09-06 12:01:57 +00:00
netcatty-bot f99b150381 fix: address Codex review on PR #3290 2026-09-06 11:31:41 +00:00
netcatty-bot 882852ac73 fix: address Codex review on PR #3290 2026-09-06 11:05:55 +00:00
netcatty-bot 21c6d59b2f fix: address Codex review on PR #3290 2026-09-06 10:35:20 +00:00
netcatty-bot 5d3abd14d8 fix: address Codex review on PR #3290 2026-09-06 10:05:51 +00:00
netcatty-bot 400e7ed81d fix: address Codex review on PR #3290 2026-09-06 09:36:58 +00:00
netcatty-bot 0a083fe8b0 fix: address Codex review on PR #3290 2026-09-06 09:04:39 +00:00
netcatty-bot 3579a5c3d4 fix: address Codex review on PR #3290 2026-09-06 08:40:55 +00:00
netcatty-bot 452c2df34c fix: address Codex review on PR #3290 2026-09-06 08:00:29 +00:00
netcatty-bot b0c272a207 fix: address Codex review on PR #3290 2026-09-06 07:32:54 +00:00
netcatty-bot a60f75731b fix: address Codex review on PR #3290 2026-09-06 07:12:02 +00:00
netcatty-bot f2b4632931 fix(#3289): automated AI fix 2026-09-06 06:45:05 +00:00
陈大猫 c9d22b3b4d fix(sftp): preserve latest pause across delayed transfer controls (#3284)
* fix(sftp): preserve newer pause decisions across delayed transfer controls

* fix(sftp): stop obsolete recovery after held transfers settle

* fix(sftp): treat superseded cross-window controls as no-ops

* fix(sftp): preserve latest folder control in pause watcher

* fix(sftp): reconcile superseded controls across windows

* test(sftp): expect authoritative action on superseded pause

* fix(sftp): distinguish mixed resume outcomes and IPC failures

* docs(sftp): publish audit findings and validation boundaries

* docs(sftp): record final publication and restart validation

* fix(sftp): reflect partial resumes and reconcile remote watcher controls

* fix(sftp): preserve resolved resume failures in partial folders
2026-09-06 14:13:49 +08:00
陈大猫 2dea078c6d fix(sftp): preserve shared SSH transport when channel opens abort (#3288) 2026-09-06 13:29:08 +08:00
陈大猫 446e82180b fix(sftp): settle folder children before history compaction removes their rows (#3287)
* fix(sftp): observe folder child completion before history compaction

* fix(sftp): carry current child identity through recovery events

* fix(sftp): admit recovered children before waiting for completion

* test(sftp): keep independent audit regressions mergeable

* fix(sftp): resume retained pauses without bypassing newer controls
2026-09-06 13:29:05 +08:00
陈大猫 2eaddf0baf fix(sftp): refuse ambiguous or replaced resume endpoints (#3285) 2026-09-06 13:29:02 +08:00
陈大猫 7b964ead21 perf(terminal): skip painting inactive terminal panes (#3283)
* perf(terminal): stop painting inactive panes without pausing output

* fix(terminal): clear cached split offsets when parking panes
2026-09-06 10:56:35 +08:00
Atirnaandbincxz 0ea8fa4943 fix(port-forwarding): reconnect auto-start rules after inactive status (#3279)
* fix(port-forwarding): reconnect auto-start rules after inactive status

* fix(port-forwarding): honor current auto-start setting during reconnect

* fix(port-forwarding): ignore retries from replaced connections

* fix(port-forwarding): preserve exhausted retries after close

* fix(port-forwarding): schedule one retry per failed connection

* fix(port-forwarding): retain pending retry status after repeated errors

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-05 23:01:41 +08:00
7228af73bf fix(ai): keep agent wrapper out of bash history to stop arrow-key residue (#3267)
* fix(#3265): automated AI fix

* fix: address Codex review on PR #3267

* fix(ai): reliably remove wrapper history entries

* fix(ai): avoid interactive zsh history expansion in cleanup

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-05 22:38:03 +08:00
HLAIA 3626601eef fix(terminal): accept WindowsApps execution aliases in shell discovery (#3281) 2026-09-05 22:27:26 +08:00
81ec019892 fix(ai): detect the active terminal shell before command execution (#3262)
* fix(#3261): automated AI fix

* fix: address Codex review on PR #3262

* fix: address Codex review on PR #3262

* fix: address Codex review on PR #3262

* fix: address Codex review on PR #3262

* fix: address Codex review on PR #3262

* fix: address Codex review on PR #3262

* fix: address Codex review on PR #3262

* fix: address Codex review on PR #3262

* fix: address Codex review on PR #3262

* fix: address Codex review on PR #3262

* fix: address Codex review on PR #3262

* fix(ai): probe the active PTY shell before choosing command syntax

* fix(ai): keep shell probing invisible and tolerate unavailable sh

* fix(ai): separate probe output when terminal echo is disabled

* fix(ai): retain hidden probe lines through delayed prompt flushes

* fix(ci): use Debian security endpoint for arm64 dependencies

* fix(ci): fall back between official Debian security mirrors

* fix(ci): retain snapshot fallback for missing Bullseye packages

* fix(ai): hide delayed partial probe completion markers

* fix(ai): bound multiline probe prompt suppression by job lifecycle

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-05 14:55:57 +08:00
陈大猫andnetcatty-bot 1151b31a42 fix: prevent Notes autocomplete popup clipping (#3277)
* fix: keep note autocomplete popups outside clipped code blocks

* fix: address Codex review on PR #3277

* fix: constrain note completion width to its pane

* fix: retain owning pane colors on note tooltips

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-09-05 14:31:28 +08:00
20b12a861c fix(terminal): color the viewport in-frame on bulk keyword-highlight bypass (#3272)
* fix(#3271): automated AI fix

* fix: address Codex review on PR #3272

* fix: address Codex review on PR #3272

* fix: address Codex review on PR #3272

* fix: address Codex review on PR #3272

* fix: address Codex review on PR #3272

* fix: address Codex review on PR #3272

* fix(terminal): defer viewport coloring during history replay

* fix(terminal): skip extra bulk repaint while reading history

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-05 13:13:56 +08:00
陈大猫 f70f6e4a5f Merge pull request #3275 from Meow-Emperor/feature/broadcast-mode-for-multiple-tabs
Feature/broadcast mode for multiple tabs
2026-09-05 13:04:43 +08:00
Netcatty-Botandnetcatty-bot a789ec1b9d fix(#3266): automated AI fix (#3268)
Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-09-05 12:57:30 +08:00
bincxz 2d9890a92e fix: disarm global broadcast when orphan group disappears 2026-09-05 12:53:39 +08:00
Meow-Emperor 1ef7b026a6 Enable global broadcast for orphan terminal tabs
Allow global broadcast hotkeys for visible orphan sessions and centralize target resolution with coverage for workspace, global, and direct-target modes.
2026-09-05 01:06:36 +08:00
Meow-Emperor f9eded887f Fix global broadcast for orphan sessions
Keep stored boolean updates pure and propagate global broadcast state through terminal panes so orphan sessions can toggle and receive broadcasts correctly.
2026-09-05 00:11:52 +08:00
Meow-Emperor 701e35dac0 feat: 支持多标签直接启用广播模式
- 新增全局广播状态管理(globalBroadcastEnabled)
- 修改广播按钮显示逻辑:工作区内或孤立标签数 ≥2 时显示
- 修改广播输入处理:支持工作区广播和全局广播两种模式
- 工作区广播优先级高于全局广播,两种模式互不干扰
- 孤立标签(未加入工作区)可独立启用全局广播功能
2026-09-04 21:08:04 +08:00
陈大猫 09cb03c88c Merge pull request #3246 from binaricat/ai/issue-3244-33598498608
fix(window): restore main window position when a secondary display returns after lock
2026-09-03 15:23:09 +08:00
JasonWen 5c5fa4f27e fix(ai): make the command blocklist shell-aware
Select default command-safety rules from the live session shell so legal
PowerShell subexpressions are allowed and PowerShell-native dangerous
commands are blocked. Untouched pre-shell-aware lists gain the new
defaults on read.

Closes #3256
2026-09-03 15:21:45 +08:00
bincxz c91a57f315 fix(windows): drop stale recovery after a post-unlock user move 2026-09-03 15:13:01 +08:00
bincxz 81c3feba0b fix(windows): retry display restore when unlock follows a locked relocation 2026-09-03 15:07:28 +08:00
bincxz 1be02e5c01 fix(windows): scope display recovery to lock and sleep
The original bug is: lock + display sleep moves a secondary-display window
to the primary, and unlock never puts it back. Review follow-ups had grown
a general display-identity machine far beyond that.

Restore only while the session is locked/asleep, or shortly after unlock.
Keep user moves authoritative. Drop Win+Shift destination tracking and
multi-unknown-display correlation.
2026-09-03 14:59:28 +08:00
bincxz 41a35824aa fix(windows): preserve recovery through late display ordering 2026-09-03 14:47:18 +08:00
bincxz 438a5f0652 fix(windows): retry resolved display recovery 2026-09-03 14:30:54 +08:00
bincxz 16b2a20220 fix(windows): preserve explicit display intent 2026-09-03 14:22:40 +08:00
bincxz 2f4726c8ce fix(windows): harden display recovery ordering 2026-09-03 14:06:40 +08:00
陈大猫 c9b40d98c2 Merge pull request #3245 from binaricat/feat/3240-sftp-ux-and-folder-scan
feat(sftp): bookmarks, compact list, dual-pane entry, and faster folder scans
2026-09-03 14:06:23 +08:00
bincxz 9fca48a927 fix(windows): safely assign transient display changes 2026-09-03 13:56:09 +08:00
JasonWenandbincxz 3411d7f08b fix(ai): avoid idle PowerShell clear corruption (#3253)
* fix(ai): avoid idle PowerShell clear corruption

* fix(ai): preserve safe PowerShell input clearing

* fix(ai): support all PowerShell edit modes

* fix(ai): clear PowerShell Vi suffix input

* fix(ai): clear multiline PowerShell input

* fix(ai): support legacy PowerShell Vi mode

* fix(ai): wait for PowerShell prompt before next exec

* fix(ai): guard delayed prompt recovery

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-03 13:48:43 +08:00
bincxz 82e62c8c1d fix(sftp): preserve bookmark scope in actions 2026-09-03 13:42:51 +08:00
bincxz 48780fcd09 fix(sftp): route bookmark changes by scope 2026-09-03 13:36:14 +08:00
bincxz 116e70a206 fix(windows): correlate replaced display payloads 2026-09-03 13:34:31 +08:00
bincxz 56e593898c fix(windows): harden display recovery ordering 2026-09-03 13:26:57 +08:00
bincxz 636f6c29ab fix(windows): preserve split display recovery bursts 2026-09-03 13:17:19 +08:00
bincxz d2e584f00d fix(windows): preserve post-return placement 2026-09-03 13:07:37 +08:00
bincxz 0b8df00e31 fix(sftp): guard replacement and focus fallback 2026-09-03 13:04:58 +08:00
bincxz dd9577ce75 fix(sftp): fail closed and restore bookmark focus 2026-09-03 12:59:13 +08:00
bincxz 8becc81e7a fix(windows): harden delayed display recovery 2026-09-03 12:57:14 +08:00
bincxz 6b6b37c7a1 fix(sftp): isolate bookmark removal confirmation 2026-09-03 12:44:11 +08:00
bincxz d62dfac977 fix(windows): distinguish display recovery intent 2026-09-03 12:38:25 +08:00
bincxz cfbda2a23f fix(sftp): confirm current bookmark removal 2026-09-03 12:26:02 +08:00
bincxz d2ed328907 fix(windows): preserve display recovery intent 2026-09-03 12:18:57 +08:00
bincxz 52ec1edab8 fix(sftp): use in-app bookmark removal dialog 2026-09-03 12:14:27 +08:00
陈大猫 f3f89edad5 Merge pull request #3251 from binaricat/ai/issue-3249-33607924553
fix(ai): run Responses API turns stateless to stop rs_ item replay errors
2026-09-03 11:55:10 +08:00
bincxz 3e03f439ec fix: safely correlate returning displays 2026-09-03 11:53:13 +08:00
陈大猫 5a05da0b4a Merge pull request #3260 from binaricat/ai/issue-3259-33708519760
fix(vault): sync managed ssh_config with latest host edits
2026-09-03 11:45:52 +08:00
bincxz efaaaebd29 fix(ai): distinguish Chat history during API switches 2026-09-03 11:38:48 +08:00
bincxz 969da6aa88 fix(sftp): apply saved folder upload concurrency 2026-09-03 11:38:38 +08:00
bincxz 8bf63ed89e fix: follow stabilized returning displays 2026-09-03 11:36:41 +08:00
bincxz 2308d133c2 fix(ai): preserve Chat tool history on Responses switch 2026-09-03 11:35:39 +08:00
bincxz d607c81b5b fix(ai): handle metadata-free Responses model switches 2026-09-03 11:31:53 +08:00
bincxz 4fc97ce96e fix(sftp): honor concurrency for ordinary uploads 2026-09-03 11:29:18 +08:00
bincxz f828e0d479 fix(ai): classify Responses replay metadata precisely 2026-09-03 11:27:53 +08:00
bincxz 3162c2c375 fix: preserve unknown display recovery 2026-09-03 11:25:11 +08:00
bincxz e7b59ad768 fix(ai): trim stored history at tool-safe boundaries 2026-09-03 11:20:12 +08:00
bincxz 1de1221707 fix: preserve delayed display recovery 2026-09-03 11:17:04 +08:00
bincxz b285200798 fix(ai): keep compaction boundaries aligned after trimming 2026-09-03 11:15:28 +08:00
bincxz 02f5729ab7 fix(ai): discard compacted replay ciphertext 2026-09-03 11:11:14 +08:00
陈大猫 74c1cd063b Merge pull request #3250 from binaricat/ai/issue-3248-33607677408
fix(workspace): auto-name merged tab workspaces from both tab labels
2026-09-03 11:08:35 +08:00
bincxz 044a95b831 test: protect manual moves after display return 2026-09-03 11:07:23 +08:00
bincxz 8e05923ca2 fix(ai): scope legacy replay pruning to Responses 2026-09-03 11:05:44 +08:00
bincxz 73ed3e02ed fix: retry recovery after display stabilization 2026-09-03 11:04:21 +08:00
bincxz c0a46cc926 fix(ai): sanitize cross-model replay and compaction 2026-09-03 11:02:08 +08:00
bincxz b2c72d607b fix(sftp): add accessible bookmark reordering 2026-09-03 10:58:14 +08:00
netcatty-bot b83f3ee17a fix(#3259): automated AI fix 2026-09-03 02:56:54 +00:00
陈大猫 689fcfd4e4 Merge pull request #3257 from JasonWenTheFox/fix/windows-host-os-metadata
fix(vault): make host OS metadata settable end to end
2026-09-03 10:56:13 +08:00
bincxz 0fb15b99c5 fix: match displays across transient identifiers 2026-09-03 10:55:07 +08:00
bincxz c6c769acc3 perf(ai): prune continuations in one pass 2026-09-03 10:54:19 +08:00
bincxz 0b8ba35da7 fix(sftp): restore split view for host entry 2026-09-03 10:53:45 +08:00
bincxz 47bef3c9d9 fix(vault): expose host operating system in editor 2026-09-03 10:51:45 +08:00
bincxz 2faa30d79b fix(sftp): apply shared transfer limit everywhere 2026-09-03 10:50:48 +08:00
bincxz d19ddce4ff fix(ai): preserve newest continuation within long chats 2026-09-03 10:49:19 +08:00
bincxz ae26219fdd fix(sftp): align transfer concurrency defaults 2026-09-03 10:46:58 +08:00
bincxz 35076fea5d fix: limit display recovery to Windows 2026-09-03 10:45:18 +08:00
bincxz 192c7043c1 fix: handle transient display identifiers 2026-09-03 10:43:20 +08:00
bincxz 2ff47eafce fix(ai): prioritize active chat continuation 2026-09-03 10:40:49 +08:00
bincxz 2538da9891 fix: distinguish manual moves after unlock 2026-09-03 10:40:35 +08:00
bincxz 7440e66ea8 fix(ai): retain newest replay data when pruning chats 2026-09-03 10:37:21 +08:00
bincxz e84ef25720 fix(sftp): complete issue 3240 workflows 2026-09-03 10:35:09 +08:00
bincxz 56deb86151 fix: honor immediate post-unlock window moves 2026-09-03 10:32:32 +08:00
bincxz 2189f6c820 test(workspace): cover merged title boundaries 2026-09-03 10:31:47 +08:00
bincxz 7e3960f561 fix(ai): preserve newest chat under tight quotas 2026-09-03 10:29:06 +08:00
bincxz acb29e0791 fix(ai): make session quota retries converge 2026-09-03 10:26:43 +08:00
bincxz a6683e23ab fix: respect post-unlock window moves 2026-09-03 10:26:09 +08:00
bincxz d350f2746a test(workspace): cover merged title lifecycle 2026-09-03 10:24:41 +08:00
bincxz 89dab1394d fix(ai): keep stateless Responses replay data intact 2026-09-03 10:22:29 +08:00
bincxz 0326881d85 fix: preserve display recovery through lock teardown 2026-09-03 10:20:37 +08:00
JW 309fe73492 fix(vault): make host OS metadata settable end to end
Hosts were hardcoded to os 'linux' at every creation and update entry
point, so Windows hosts were always reported as linux by the AI
environment (get_environment) and nothing could correct them.

- buildVaultHostFromDraft parses draft.os with a strict allowlist
  (linux/windows/macos, common aliases included) and defaults to linux
- applyVaultHostUpdate accepts os with the same validation
- the AI host.update field list and the MCP vault host tool schemas
  expose os so agents can set and correct it
2026-09-03 03:41:10 +08:00
netcatty-bot cc0fc9ee19 fix: address Codex review on PR #3246 2026-09-02 16:09:27 +00:00
netcatty-bot c628cb233c fix: address Codex review on PR #3246 2026-09-02 15:39:40 +00:00
netcatty-bot 5f3a1eaed6 fix: address Codex review on PR #3246 2026-09-02 15:19:14 +00:00
netcatty-bot 5d10f09a0a fix: address Codex review on PR #3246 2026-09-02 14:58:20 +00:00
netcatty-bot 4abbdd9e48 fix: address Codex review on PR #3246 2026-09-02 14:28:20 +00:00
netcatty-bot 6ff65334d4 fix: address Codex review on PR #3246 2026-09-02 13:57:51 +00:00
netcatty-bot 2c23f445ac fix: address Codex review on PR #3246 2026-09-02 13:35:57 +00:00
netcatty-bot e3f56204b2 fix: address Codex review on PR #3246 2026-09-02 13:14:48 +00:00
netcatty-bot dcc3e0718f fix: address Codex review on PR #3246 2026-09-02 12:47:36 +00:00
netcatty-bot 3936d8b7f3 fix: address Codex review on PR #3251 2026-09-02 12:07:00 +00:00
netcatty-bot b0481e7da0 fix: address Codex review on PR #3246 2026-09-02 12:04:59 +00:00
netcatty-bot e911792287 fix: address Codex review on PR #3246 2026-09-02 11:46:01 +00:00
陈大猫 ceb89d65aa Merge pull request #3247 from binaricat/codex/fix-2974-shift-enter-negotiation
[Bug] Preserve modified Enter keys in local Windows TUIs
2026-09-02 19:36:04 +08:00
netcatty-bot 1025d93ae3 fix: address Codex review on PR #3251 2026-09-02 11:35:00 +00:00
netcatty-bot 3b6f185866 fix: address Codex review on PR #3246 2026-09-02 11:17:07 +00:00
netcatty-bot 8d4fde0340 fix: address Codex review on PR #3251 2026-09-02 10:58:59 +00:00
netcatty-bot 9c61e46635 fix: address Codex review on PR #3246 2026-09-02 10:56:43 +00:00
netcatty-bot f4987550f1 fix: address Codex review on PR #3246 2026-09-02 10:31:01 +00:00
netcatty-bot 6ad422b1ea fix: address Codex review on PR #3251 2026-09-02 10:30:39 +00:00
netcatty-bot 24b8d8e05d fix: address Codex review on PR #3250 2026-09-02 10:28:40 +00:00
netcatty-bot 2487e954ee fix: address Codex review on PR #3251 2026-09-02 10:03:22 +00:00
netcatty-bot f66c098c9b fix: address Codex review on PR #3250 2026-09-02 09:59:49 +00:00
netcatty-bot 8332aa2dbd fix: address Codex review on PR #3246 2026-09-02 09:58:58 +00:00
bincxz 7451fed016 fix(terminal): preserve modified keys in Windows ConPTY 2026-09-02 17:58:52 +08:00
netcatty-bot 908cc687cb fix: address Codex review on PR #3251 2026-09-02 09:37:34 +00:00
netcatty-bot 47667e80f5 fix: address Codex review on PR #3250 2026-09-02 09:36:22 +00:00
netcatty-bot da8b13471f fix: address Codex review on PR #3246 2026-09-02 09:35:02 +00:00
netcatty-bot 2d6183c440 fix: address Codex review on PR #3251 2026-09-02 09:07:42 +00:00
netcatty-bot a352ca3836 fix: address Codex review on PR #3250 2026-09-02 09:02:06 +00:00
netcatty-bot 4fad9b8dcf fix: address Codex review on PR #3246 2026-09-02 08:59:57 +00:00
netcatty-bot 2569c6c3c4 fix: address Codex review on PR #3246 2026-09-02 08:40:57 +00:00
netcatty-bot 0ecd06b2f6 fix(#3249): automated AI fix 2026-09-02 08:33:19 +00:00
netcatty-bot 1f52f6f399 fix(#3248): automated AI fix 2026-09-02 08:30:19 +00:00
netcatty-bot 606c53d7fa fix: address Codex review on PR #3246 2026-09-02 08:05:18 +00:00
bincxz f2ec2a4842 fix(terminal): respect Shift+Enter negotiation 2026-09-02 16:01:10 +08:00
netcatty-bot 0556e493f8 fix: address Codex review on PR #3246 2026-09-02 07:39:15 +00:00
bincxz 4435f763bd fix(sftp): ignore IME composition Enter when renaming bookmarks
CJK IMEs use Enter to accept a candidate. Do not treat that keydown as
a rename submit while composition is active.
2026-09-02 15:24:53 +08:00
bincxz 5ae1df350d fix(sftp): rename bookmarks inline instead of window.prompt
Electron throws on window.prompt, so Manage → Rename did nothing.
Use an in-list input with Enter/blur commit and Escape cancel.
2026-09-02 15:17:13 +08:00
bincxz 74b77ddec1 fix(sftp): always plan Open SFTP instead of guessing first visit
Empty pane arrays are not a first-visit signal; they also happen after
the user closes every tab. Route every Open SFTP request through the
planner, and do not forceNewTab on an empty side.
2026-09-02 15:07:11 +08:00
bincxz 2084ede4db fix(sftp): restore local-left after all left SFTP tabs are closed
The empty-left shortcut was racing first-visit auto-connect, but it also
fired after the user closed every left tab. Open SFTP then skipped the
planner and could replace the right pane. Only skip the planner when
both sides are still empty.
2026-09-02 14:59:14 +08:00
netcatty-bot ae59c32888 fix: address Codex review on PR #3246 2026-09-02 06:58:09 +00:00
bincxz 1f75c20446 fix(sftp): reconnect the matching dead dual-pane tab
Keep host identity on failed panes and prefer that tab over an unrelated
idle pane so Open SFTP does not revive the wrong connection.
2026-09-02 14:47:04 +08:00
bincxz 7062dcb4a4 fix(sftp): reconnect failed dual-pane tabs from Open SFTP
A matching hostId in error or disconnected state is not a live session.
Treat only connected/connecting panes as reusable so Open SFTP reconnects
in place instead of selecting a dead tab.
2026-09-02 14:43:40 +08:00
netcatty-bot 0a3d001f47 fix(#3244): automated AI fix 2026-09-02 06:38:18 +00:00
bincxz 26b9027d41 fix(sftp): keep ordinary directory identities in local-tree cycle detection
Skipping realpath on plain directories also dropped them from the ancestor
set, so a symlink back to a non-root ancestor duplicated that subtree.
Compose the resolved parent path with the child name instead, and still
realpath directory links.
2026-09-02 14:35:30 +08:00
bincxz 25b79a6947 feat(sftp): tighten bookmarks, compact list, dual-pane entry, and folder scans
Address the SFTP UX list in #3240: wider bookmark management with
confirm/reorder/rename, a compact file-list density, a host-list shortcut
into the existing local/remote dual pane, faster local folder discovery,
and higher small-file upload concurrency.

Tree view already supports double-click to enter directories, so that
item is left as-is.
2026-09-02 14:26:14 +08:00
陈大猫 a1fce3ad7c Merge pull request #3238 from ice616/feat/auto-launch-at-login
feat(settings): 支持开机自启动并最小化到系统托盘
2026-09-02 13:30:55 +08:00
陈大猫 7db5a21e3e Merge pull request #3241 from binaricat/fix/sftp-sudo-i-drag-and-reconnect
fix: elevate /root drops after sudo -i
2026-09-02 13:12:59 +08:00
bincxz 10f1c02c5c fix(sftp): use resolved identity username for /root elevation
Keychain identities can change after the host copied username. Use the
same resolveHostAuth username as sudo autofill so a stale host.username
does not skip or force elevation.
2026-09-02 11:58:09 +08:00
bincxz ffb4d94aec fix(sftp): use resolved identity passwords for /root drops
Keychain identities clear host.password. Reuse the same host-auth
resolution as sudo autofill so those saved identity passwords can
elevate a terminal drop into /root.
2026-09-02 11:53:00 +08:00
bincxz b818db5f0c fix(sftp): elevate /root drops and allow sudo reconnect fallback
After sudo -i, terminal drag into /root uses the host's saved password for
SFTP sudo. If no usable password is saved, the drop fails with guidance to
enable host Sudo elevation or return to the user directory.

Automatic SFTP reconnect still prefers the originating session, then falls
through to a fresh open so a dead channel after sudo -i can re-elevate.
2026-09-02 11:46:27 +08:00
陈大猫 ce1cdab559 Merge pull request #3239 from binaricat/codex/fix-sftp-drag-drop-routes
fix: make terminal drag uploads route-safe
2026-09-02 11:37:37 +08:00
bincxz d79643d31c fix(sftp): wait for origin focus before drop cancel or bind
Do not treat a lagging split-pane focus as the user leaving the drop
origin, and do not bind a Mosh/ET drop to the still-focused SSH session.
First-open follow treats a missing SSH id as bound to the live focused
terminal so a probe from A cannot apply after focus moves to B.
2026-09-02 11:17:22 +08:00
bincxz 4bb8a224b6 fix(sftp): reject follow probes after focused session changes
A follow sync that started probing session A must not navigate after
focus moves to session B. Capture the expected session before the
await and reuse the same guard already used for go-to-terminal-cwd.
2026-09-02 10:24:49 +08:00
netcatty-bot 8788803fb5 fix: address Codex review on PR #3239 2026-09-01 15:49:34 +00:00
bincxz a7342ff7b8 fix(sftp): drop extra drag-upload routing machinery
Keep confirmed active-shell destinations, originating-session bind, sudo
fidelity, and the longer ZMODEM start wait. Remove the dormant queue,
routeSessionId identity, and source-regex contracts that only locked
that extra routing in.
2026-09-01 22:54:16 +08:00
bincxz e5f2519433 fix(sftp): preserve queued upload routes 2026-09-01 22:33:00 +08:00
netcatty-bot 6e56fe5a8d fix: address Codex review on PR #3239 2026-09-01 13:58:44 +00:00
netcatty-bot 2c35040e79 fix: address Codex review on PR #3239 2026-09-01 13:35:51 +00:00
netcatty-bot 55ceaa3290 fix: address Codex review on PR #3239 2026-09-01 13:13:23 +00:00
netcatty-bot 74abfe28ef fix: address Codex review on PR #3239 2026-09-01 12:48:51 +00:00
axe006andClaude Sonnet 5 d92406a9fd fix(auto-launch): 补齐跨窗口同步、修正连续切换的陈旧响应竞态
针对 PR #3238 的 2 条 Codex review 意见修复:

- application/state/settingsIpcSync.ts / useSettingsState.ts:新增
  STORAGE_KEY_AUTO_LAUNCH_ENABLED 的跨窗口广播处理。AppLockGate 会
  在每个窗口(主窗口、托盘面板、终端弹窗等)都挂载一份
  useSettingsState,即使开机自启动开关只在设置窗口的 UI 里展示和
  交互。此前该键缺失在 settingsIpcSync.ts 的广播处理里,其他窗口
  收到设置窗口推送的 notifySettingsChanged 广播后不会更新自己
  内存里的 autoLaunchEnabled,与项目里几乎所有其它布尔类设置的
  跨窗口同步约定不一致。新增结构断言测试验证键处理与端到端接线
  都已补齐。

- application/state/systemSettingsEffects.ts:新增
  autoLaunchWriteGenerationRef,推送 effect 的写入响应只在"没有
  更新的写入请求已经发起"时才被采纳。此前连续快速切换开关会启动
  多个并发的 bridge.setAutoLaunch 调用,每个回调只跟自己请求时的
  闭包值比较,不知道是否已经有更晚的请求覆盖了它——一个陈旧请求
  的延迟响应(比如报告了与自己请求不符的状态)可能在更新的请求
  已经成功落地之后才到达,用过时的"纠正"把已经确认生效的最新
  状态覆盖掉。新增单测覆盖乱序到达、连续三次切换等场景。

新增/更新单测覆盖上述两个场景,199 项相关测试通过。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 20:12:36 +08:00
bincxz ccacb69bb9 fix(sftp): bind terminal drops to active route 2026-09-01 19:58:55 +08:00
axe006andClaude Sonnet 5 7c419f41de fix(auto-launch): 修正写入失败回滚、挂载竞态、隐藏启动焦点竞态
针对 PR #3238 的 3 条 Codex review 意见修复:

- electron/autoLaunch.cjs:setAutoLaunchEnabled 写入失败时,success
  字段改为反映"回退读取本身是否成功"(即返回的 enabled 值是否可信),
  而不是"写入操作是否成功",与 getAutoLaunchEnabled 已有的语义保持
  一致。此前写入失败一律标记 success:false,导致渲染进程的推送
  effect 直接丢弃回退读取到的真实状态(哪怕这次读取本身成功),
  乐观开关状态停留在"已开启",即使操作系统层面从未真正创建
  登录项。同时修正未支持平台分支的 success 语义(enabled:false
  是确定事实,不是未知状态),与 getAutoLaunchEnabled 对齐。

- application/state/systemSettingsEffects.ts:新增
  autoLaunchWriteStartedRef,挂载时的水合请求还在等待期间,如果
  用户手动切换了开关(触发推送 effect 发起真实写入),水合的
  陈旧响应落地时会跳过应用,不再覆盖用户刚做的选择——此前会用
  这个陈旧值覆盖用户状态,并连锁触发推送 effect 把陈旧值写回
  操作系统,实质上撤销用户刚成功完成的操作。

- electron/main.cjs:second-instance(含 open-terminal-path 无效
  路径兜底分支)与 activate(macOS Dock 重新打开/点击)三处调用
  createAndShowMainWindow() 后新增 focusMainWindow() 跟随调用。
  consumeColdStartHiddenLaunch 是一次性锁存标志,谁先调用
  createAndShowMainWindow() 谁就消费掉它——app.whenReady() 之后、
  默认冷启动流程真正调用 createAndShowMainWindow() 之前还有若干
  await(如应用锁设置加载),期间到达的真实第二实例或 Dock 重新
  打开事件可能抢先消费隐藏标志,创建一个隐藏窗口且没有后续动作
  让它显示,导致用户显式的重新打开操作看起来毫无反应。深链接和
  打开终端路径的四处调用点已经在创建后无条件调用 focusMainWindow()
  (因此不受影响),这次是给另外三处缺失该调用的地方补上。

新增/更新单测覆盖上述三个场景,194 项相关测试通过。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 19:49:27 +08:00
axe006andClaude Sonnet 5 151364ce4b fix(auto-launch): 修正 macOS 待审批状态误触发禁用与 Linux 文案
针对 PR #3238 的 2 条 Codex review 意见修复:

- electron/autoLaunch.cjs:resolveEffectiveLoginState 新增对 macOS
  status: "requires-approval" 的处理。macOS 13+ 用 SMAppService
  注册登录项后,用户在"系统设置"里批准前会处于待审批状态,此时
  openAtLogin 报告 false,即使注册本身已经成功。此前代码把这个
  false 当作真实状态返回给渲染进程,推送 effect 一看到与用户刚
  请求的值不一致就会立刻发起相反的写入,在用户还没来得及批准前
  就把这个待审批的登录项注销掉。现在待审批状态统一视为 enabled,
  与用户请求保持一致,后续读取会在用户批准或系统丢弃待审批请求后
  自然反映真实状态。

- i18n(en/zh-CN/zh-TW/ru):unsupportedDesc 文案此前只提到"开发
  环境下不支持",但 isAutoLaunchSupported 在打包后的 Linux 构建
  (AppImage/deb/rpm/pacman)上同样会返回 false,对 Linux 用户
  显示这句话是错误的。改为同时覆盖平台不支持和开发环境两种情况
  的中性表述。

新增单测覆盖 macOS 待审批场景,189 项相关测试通过。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 19:19:15 +08:00
axe006andClaude Sonnet 5 e6df190b98 fix(auto-launch): 隐藏启动的崩溃保护与读取失败状态误覆盖问题
针对 PR #3238 的 2 条 Codex review 意见修复:

- electron/main.cjs:隐藏启动成功后不再传 windowShown: !startHidden,
  改为固定传 true。processErrorGuards.cjs 的 hasShownMainWindow 是
  一次性锁存标志,只要传 false 就会让运行时保护在整个进程生命周期
  内永远处于"启动期严格模式"——任何非网络类未捕获异常都会被判定
  为致命错误并可能杀掉进程。隐藏启动时 createWindow() 已经成功
  创建窗口并加载完页面,是一次完整的启动,不是失败,理应解除
  严格模式。新增结构断言测试覆盖成功/失败两条分支。

- electron/autoLaunch.cjs:getAutoLaunchEnabled 读取失败时新增
  success: false 字段,区分"确认关闭"与"状态未知"。此前失败时
  返回的 { enabled: false, supported: true } 与正常读到关闭状态
  完全无法区分,渲染进程挂载时读取一次失败就会把本地缓存的
  true 覆盖成 false,并触发相邻的推送 effect 把这个"假关闭"
  写回操作系统,可能误删一个原本有效的登录项。

- application/state/systemSettingsEffects.ts:挂载水合与推送两个
  effect 都改为通过新导出的纯函数 isAutoLaunchResultTrustworthy
  判断,success:false 时保留原状态、不发起写入。该函数已用普通
  单测覆盖(react-test-renderer 在当前环境未安装,无法测试完整
  hook 生命周期,故将判断逻辑抽成不依赖 React 的纯函数单独验证)。

新增/更新单测覆盖上述场景,177 项相关测试通过。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 19:01:30 +08:00
axe006andClaude Sonnet 5 e5c750398d fix(auto-launch): 修正 macOS 隐藏启动检测与 Windows 有效状态判定
针对 PR #3238 的 2 条 Codex review 意见修复,均已用仓库内绑定的
node_modules/electron/electron.d.ts 核实:

- wasLaunchedHidden 的 macOS 分支改用 wasOpenedAtLogin:openAsHidden/
  wasOpenedAsHidden 在 Electron 类型声明里标注为 @deprecated 且
  "does not work on macOS 13 and up",继续用它会导致 macOS 13+ 上
  真实的开机自启动被误判为非隐藏启动,窗口被错误显示。
  wasOpenedAtLogin 未被标记弃用,且 Netcatty 只在"开机自启动并
  最小化"这一个场景下才会注册 macOS 登录项,因此把开机启动本身
  当作隐藏启动的判定依据是安全的。

- resolveEffectiveLoginState 不再使用 executableWillLaunchAtLogin:
  该字段按文档明确会忽略 args,只要该可执行文件以*任意*参数被
  设置为登录启动就返回 true,与 buildLoginItemQueryOptions 按
  path+args 精确查询的语义相悖,会被同一程序的其他登录项注册
  (如历史遗留的无参数项)污染成假阳性。改为在 launchItems 数组
  里查找 path+args 完全匹配的那一条目,读取其 enabled 字段
  (该字段按文档明确是针对具体条目、反映任务管理器"启动应用"
  开关状态的)。

新增/更新单测覆盖上述场景,155 项相关测试通过。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 18:47:54 +08:00
axe006andClaude Sonnet 5 d4bfe02dc4 fix(auto-launch): 查询登录项状态时带上注册时的 path/args
针对 PR #3238 的 Codex review 意见修复:getAutoLaunchEnabled 和
setAutoLaunchEnabled 写入后的校验查询此前调用
app.getLoginItemSettings() 不带任何参数,而按 Electron 的行为
约定,openAtLogin/executableWillLaunchAtLogin 只针对查询时传入的
同一个 path+args 组合生效。写入时始终使用 path: execPath、
args: ['--hidden'](开启时),查询时不带参数会查到不匹配的登录项,
导致 Windows 上开启开关后立刻读回 enabled: false,UI 又把开关
弹回关闭状态。

新增 buildLoginItemQueryOptions 统一查询参数构造,getAutoLaunchEnabled
和 setAutoLaunchEnabled 的验证查询均已接入。新增/更新单测覆盖,
152 项相关测试通过。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 18:39:32 +08:00
axe006andClaude Sonnet 5 ee47c9e15c fix(auto-launch): 修复隐藏启动托盘保活、macOS 检测与跨平台支持
针对 PR #3238 的 4 条 Codex review 意见逐一修复:

- 隐藏启动时新增 pinTrayForHiddenLaunch/releaseHiddenLaunchTrayPin
  机制:托盘在窗口首次显示前始终保持存在,不再被渲染进程挂载时
  下发的 close-to-tray=false 提前销毁,避免开机自启+关闭托盘最小化
  组合下出现无窗口无托盘的僵尸进程。窗口首次 show 后自动释放钉住,
  交还给用户的正常 close-to-tray 偏好。
- wasLaunchedHidden 补充 macOS 检测:--hidden 启动参数只在 Windows
  生效(openAsHidden 是 macOS 专属设置,不会体现在 argv 里),macOS
  上改为读取 app.getLoginItemSettings().wasOpenedAsHidden。
- isAutoLaunchSupported 收紧为仅 macOS/Windows:Linux 打包(AppImage/
  deb/rpm/pacman)没有对应的系统登录项钩子,setLoginItemSettings 在
  该平台上静默失效,之前会误报"支持"。
- 新增 resolveEffectiveLoginState:Windows 上以
  executableWillLaunchAtLogin(考虑任务管理器"启动应用"里被禁用的
  情况)作为真实生效状态,而非只看 openAtLogin(仅反映注册表项
  是否存在)。

新增/更新单测覆盖以上全部场景,共 149 项相关测试通过。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 18:35:21 +08:00
axe006 0068180204 Merge branch 'main' of https://github.com/binaricat/Netcatty into feat/auto-launch-at-login 2026-09-01 18:24:53 +08:00
axe006andClaude Sonnet 5 513b2d66af feat(settings): 支持开机自启动并最小化到系统托盘
在「设置 → 系统 → 网络代理」卡片上方新增开机自启动开关,开启后
系统启动时自动运行 Netcatty,并隐藏到系统托盘(不占用任务栏)。

- 新增 electron/autoLaunch.cjs 封装 app.setLoginItemSettings,
  通过 --hidden 启动参数标记本次是登录项冷启动;开发环境
  (process.defaultApp)下判定为不支持,避免误操作系统登录项。
- 主窗口首次冷启动读取该标志后跳过 win.show(),并在托管桥注册
  完成后强制创建托盘图标(不依赖用户是否开启「关闭时最小化到
  托盘」),确保隐藏窗口始终可被找到;后续任何窗口复用/深链接/
  托盘点击路径不受影响。
- 开关真实状态以主进程 app.getLoginItemSettings() 为准,设置页
  挂载时向主进程查询校准,而非只信任本地缓存。
- 新增单测覆盖 autoLaunch 纯逻辑及 startHidden 透传/托盘保底
  行为,过程中发现并修复一处因未走依赖注入直接 require 模块
  导致的隐患。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 18:16:42 +08:00
Netcatty-Botandnetcatty-bot 715487b2a0 fix(sftp): keep side panel directory and filter across terminal tab switches (#3232)
* fix(#3230): automated AI fix

* fix: address Codex review on PR #3232

* fix: address Codex review on PR #3232

* fix: address Codex review on PR #3232

* fix: address Codex review on PR #3232

* fix: address Codex review on PR #3232

* fix: address Codex review on PR #3232

* fix: address Codex review on PR #3232

* fix: address Codex review on PR #3232

* fix: address Codex review on PR #3232

* fix: address Codex review on PR #3232

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-09-01 16:46:21 +08:00
陈大猫 8f34d2a31f fix(ssh): identify concurrent terminal joins (#3234) 2026-09-01 14:37:28 +08:00
陈大猫andnetcatty-bot fb034f08bb fix(sftp): keep bulk queues and large-file resume responsive (#3226)
* fix(sftp): keep bulk queues and large-file resume responsive

* fix(sftp): preserve resume compatibility and timeout cleanup

* test(sftp): verify fixture cleanup in fresh environments

* fix: address Codex review on PR #3226

* fix(sftp): remove blocking periodic history persistence

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-08-31 19:27:41 +08:00
陈大猫 39d7c38a6a fix(ai): preserve resumed terminal chats after workspace merge (#3225) 2026-08-31 15:03:01 +08:00
Netcatty-Bot e8c291373b fix(terminal): add duplicate-session option that opens a fresh connection (#3222)
Add Duplicate Session as an explicit fresh-login alternative to Copy Tab. Keep authenticated transport reuse for Copy Tab and double-clicks, preserve fresh intent across session restoration, and avoid sending a previous target's directory command to a fresh bastion login.

Verified with focused and full tests, lint/build, independent local reviews, and real Electron menu operations against a local SSH server.

Fixes #3221.
2026-08-31 14:28:16 +08:00
陈大猫 162b028d39 fix(cli): release consumed second-instance argument copies (#3223)
Release consumed second-instance argument buffers after deriving connection requests from an independent ordered copy. This closes the late password-retention finding on #3220 without changing connection or Explorer path routing.

Verified with regression tests, the full local suite, lint/build, two independent local reviews, and a real two-process Electron SSH connection that checks credential delivery and removal of both transport copies.

Related to #3218 and #3220.
2026-08-31 12:47:34 +08:00
2d494e794e fix(ssh): connect PuTTY-style CLI launches into the running instance (#3220)
* fix(#3218): automated AI fix

* fix: address Codex review on PR #3220

* fix(ssh): permanently cancel disabled scheme launch requests

* fix(ssh): release plaintext launch snapshot after handoff

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-08-31 12:06:15 +08:00
Netcatty-Bot 8f2c51cbcd fix(tray): restore full panel height through the app lock wrapper (#3209)
Restore full height through the AppLockGate background wrapper, which otherwise lets the tray content collapse to about half of its window height. Preserve the existing lock-screen interaction restrictions.

Add real Electron regression coverage for repeated opening, session lists, lock/unlock, short windows, and Escape. Run it on macOS and Windows, including 100% and 200% device scale on Windows, and trigger it for the relevant renderer changes.

Fixes #3181
2026-08-31 11:44:08 +08:00
陈大猫 b71c75ed07 Merge pull request #3212 from binaricat/ai/issue-3211-33254121557
fix(sftp): add breadcrumb go-to-root button beside home icon
2026-08-31 10:36:18 +08:00
bincxz 03a8c4921b fix(sftp): keep root navigation enabled for double-slash paths 2026-08-31 10:22:57 +08:00
陈大猫 8df0965066 Merge pull request #3215 from binaricat/codex/fix-3205-notes-comparison-markdown
fix(notes): render numeric comparisons without changing source
2026-08-31 10:14:15 +08:00
github-actions[bot]andgithub-actions[bot] 75eefa50c3 chore: update star history charts [skip ci] (#3216)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-31 00:10:52 +00:00
陈大猫 e052f22148 Merge pull request #3208 from binaricat/ai/issue-3206-33245376344
fix(terminal-worker): keep one stray async error from killing every session
2026-08-30 23:08:13 +08:00
bincxz 46aa537c0e test(notes): provide the browser image element constructor 2026-08-30 22:59:52 +08:00
bincxz ccb63165c9 fix(terminal-worker): fail all errors before startup completes 2026-08-30 22:54:30 +08:00
bincxz 611f9cca3e fix(notes): render numeric comparisons without changing source 2026-08-30 22:45:47 +08:00
netcatty-bot 3adf86bcd9 fix(#3211): automated AI fix 2026-08-29 13:24:14 +00:00
netcatty-bot a20065a6a8 fix: address Codex review on PR #3208 2026-08-29 10:00:43 +00:00
netcatty-bot 76fa1c7a2d fix(#3206): automated AI fix 2026-08-29 09:38:14 +00:00
陈大猫 b11dc42a10 Merge pull request #3045 from binaricat/feat/2988-mobaconf-password-import
feat(vault): import saved MobaXterm passwords from .mobaconf
2026-08-29 15:44:29 +08:00
bincxz 13dedcf17a fix(vault): reject Moba AES plaintext truncated at an interior NUL
A wrong master password can decrypt to a NUL mid-buffer and leave a short
printable UTF-8 prefix. Treat only a trailing NUL pad as a C string so
vectors like wrong25 no longer save garbage host passwords.
2026-08-29 15:28:44 +08:00
bincxz 4d685557b1 fix(vault): reject wrong MobaXterm master passwords
AES-CFB has no authentication, so a mistyped master password can decrypt
to printable Latin-1 junk that was previously saved as a host password.
Require valid UTF-8 plaintext, fail closed if any ciphertext does not
decrypt, and keep leading or trailing whitespace in the master password.
2026-08-29 15:22:14 +08:00
陈大猫 f11378afbc Merge pull request #3203 from binaricat/fix/2295-stored-key-reconnect-decrypt-race
fix(vault): wait for stored-key decrypt before reconnect auth
2026-08-29 14:55:49 +08:00
陈大猫 5b0f949101 Merge pull request #3204 from binaricat/fix/3061-autocomplete-popup-follow-wrap
fix(terminal): keep autocomplete popup on the wrapped command start
2026-08-29 14:51:11 +08:00
陈大猫 9de90a6a02 Merge pull request #3202 from binaricat/fix/2954-sftp-replace-preserve-mode
fix(sftp): restore remote mode after replace upload
2026-08-29 14:48:52 +08:00
陈大猫 8ce19700fa Merge pull request #3200 from binaricat/fix/2629-strip-last-connected-from-sync
fix(sync): stop SSH connects from dirtying WebDAV auto-sync
2026-08-29 14:48:46 +08:00
netcatty-bot ae2014ddf5 fix: address Codex review on PR #3203 2026-08-29 06:45:15 +00:00
bincxz be63f6de88 fix: address Codex review on PR #3204 2026-08-29 14:39:21 +08:00
bincxz 23d66e5d43 fix: address Codex review on PR #3200
Sanitize lastConnectedAt in convergent equality, change summaries, and
v1-to-v2 conversion so legacy telemetry cannot block migration or leak
into CRDT state.
2026-08-29 14:38:01 +08:00
bincxz 35d0b16e21 fix: address Codex review on PR #3202
Bound the post-commit remote chmod with the same best-effort timeout
used for destination mtime stamps. A stalled chmod after promotion
now warns and lets completion/cleanup continue instead of pinning
the transfer, SFTP lease, and admission slot.
2026-08-29 14:35:20 +08:00
bincxz cf84e61451 fix(terminal): keep autocomplete popup on the wrapped command start
Fixes #3061
2026-08-29 14:21:46 +08:00
bincxz 5d2e15eab6 fix: address Codex review on PR #3200
Normalize lastConnectedAt on every merge side and restore this device's
Recently Connected timestamps when applying a sanitized cloud payload.
2026-08-29 14:19:24 +08:00
bincxz 8ecced6d32 fix: address Codex review on PR #3203 2026-08-29 14:18:09 +08:00
bincxz 7e51b94686 fix: address Codex review on PR #3202
Drop the extra pre-transaction mode stat so cancel can settle and
existing call-count tests keep their first stat as the planner/final
size check. Restore replace mode from the transactional upload plan
snapshot instead, and hang the in-place cancel regression on the
post-OPEN size verification.
2026-08-29 14:15:53 +08:00
netcatty-bot a5e61ca1d3 fix: address Codex review on PR #3200 2026-08-29 06:15:02 +00:00
陈大猫 467e548ad8 Merge pull request #3201 from binaricat/fix/1193-disable-live-preview-on-network-devices
fix(terminal): skip autocomplete live preview on network devices
2026-08-29 14:10:07 +08:00
bincxz c3bc173aa5 fix(vault): wait for stored-key decrypt before reconnect auth
Fixes #2295
2026-08-29 14:04:39 +08:00
bincxz cc2bdb5b7e fix(sftp): restore remote mode after replace upload
Replace uploads recreate the remote file via fastPut / pipelined WRITE
with umask defaults, which dropped executable and other mode bits.
Capture the existing remote mode before the transfer and chmod it back
on the published final path after success.

Fixes #2954
2026-08-29 14:00:04 +08:00
bincxz 0c9a22bed5 fix(sync): stop SSH connects from dirtying WebDAV auto-sync
Strip lastConnectedAt from cloud sync hosts so Recently Connected updates
do not change the auto-sync hash. Local backups still keep the timestamp.
2026-08-29 13:52:17 +08:00
bincxzandCursor d1136a4987 feat(vault): import saved MobaXterm passwords from .mobaconf
Decrypt master-password and legacy SessionP credentials during MobaXterm
import so migrating hosts do not require re-entering passwords.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 11:44:18 +08:00
387 changed files with 26952 additions and 2231 deletions

No files matched your search

+19
View File
@@ -40,6 +40,10 @@ on:
pull_request:
paths:
- ".github/workflows/build.yml"
- "components/AppLockGate.tsx"
- "components/TrayPanel.tsx"
- "index.tsx"
- "index.css"
- "build/**"
- "electron-builder.config.cjs"
- "electron/**"
@@ -76,6 +80,7 @@ on:
- "scripts/rebuildPatchedNodePty.cjs"
- "scripts/resolve-et-bin-release.cjs"
- "scripts/resolve-mosh-bin-release.cjs"
- "scripts/tray-panel-layout.live.test.cjs"
- "scripts/verify-linux-*.sh"
- "scripts/xterm-macos-column-selection.live.test.cjs"
- "skills/**"
@@ -318,6 +323,15 @@ jobs:
APPLE_TEAM_ID: ${{ matrix.name == 'macos' && secrets.APPLE_TEAM_ID || '' }}
run: npm run ${{ matrix.pack_script }}
- name: Test tray panel layout
run: npm run test:tray-panel-layout
- name: Test tray panel layout at 200% scale on Windows
if: matrix.name == 'windows'
env:
NETCATTY_TRAY_LAYOUT_SCALE: "2"
run: npm run test:tray-panel-layout
- name: Upload artifacts
uses: actions/upload-artifact@v7
with:
@@ -681,6 +695,11 @@ jobs:
shell: bash
run: |
set -euo pipefail
# Security CDN endpoints can lack files advertised in their indexes.
# Fall back per file, with the final Bullseye security snapshot for packages only.
# Live signed indexes still select versions and verify every download.
printf 'http://deb.debian.org/debian-security\tpriority:1\nhttp://security.debian.org/debian-security\tpriority:2\nhttp://snapshot.debian.org/archive/debian-security/20260901T000000Z/\tpriority:3 type:deb\n' > /etc/apt/security-mirrors.txt
sed -i 's|http://deb.debian.org/debian-security|mirror+file:/etc/apt/security-mirrors.txt|g' /etc/apt/sources.list
apt-get -o Acquire::Retries=4 update
apt-get -o Acquire::Retries=4 install -y curl build-essential python3 git libfuse2 file rpm \
libarchive-tools \
+3
View File
@@ -52,6 +52,9 @@ jobs:
- name: Test
run: npm test
- name: Test terminal background rendering
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox --use-angle=swiftshader --enable-unsafe-swiftshader scripts/terminal-background-rendering.live.test.cjs
- name: Test terminal keyword highlight performance
env:
NETCATTY_TERMINAL_PERF_SHOW_WINDOW: "1"
@@ -315,6 +315,30 @@ test('pane zoom hotkey delegates to the active in-app magnification surface', ()
assert.equal(toggles, 1);
});
test('broadcast hotkey toggles global mode for an active orphan tab', () => {
let globalToggles = 0;
let workspaceToggles = 0;
executeHotkeyActionImpl(() => ({
activeTabStore: { getActiveTabId: () => 'orphan-1' },
editorTabs: [],
orderedTabs: ['orphan-1', 'orphan-2'],
settings: { showSftpTab: true, shellOnlyTabNumberShortcuts: false },
toEditorTabId: (id: string) => id,
sessions: [
{ id: 'orphan-1' },
{ id: 'orphan-2' },
],
workspaces: [],
canUseGlobalBroadcast: true,
toggleBroadcast: () => { workspaceToggles += 1; },
toggleGlobalBroadcast: () => { globalToggles += 1; },
}), 'broadcast', {} as KeyboardEvent);
assert.equal(globalToggles, 1);
assert.equal(workspaceToggles, 0);
});
test('move-focus shortcut cannot send input behind a magnified pane', () => {
let moveCalls = 0;
executeHotkeyActionImpl(() => ({
+45 -2
View File
@@ -1,8 +1,10 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { copySessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, splitSessionWithCurrentShellImpl } from "./AppHandlers";
import { copySessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, duplicateSessionWithCurrentShellImpl, splitSessionWithCurrentShellImpl } from "./AppHandlers";
import { createCopiedTerminalSessionClone } from "../state/terminalConnectionReuse";
import type { TerminalSession } from "../../domain/models";
type CloneOpts = { localShellType?: string; inheritedCwd?: string };
type CloneOpts = { localShellType?: string; inheritedCwd?: string; reuseConnection?: boolean };
type Calls = {
copy?: { id: string; opts: CloneOpts };
split?: { id: string; dir: string; opts: CloneOpts };
@@ -55,6 +57,47 @@ test("live tracked cwd is preferred over the probe", async () => {
assert.equal(calls.probed, false, "must not probe when live cwd is known");
});
for (const protocol of ["ssh", undefined] as const) {
for (const liveCwd of ["/srv/old-target", undefined]) {
test(`duplicate SSH session does not capture or inject the old target directory (${protocol}, ${liveCwd})`, async () => {
const source: TerminalSession = {
id: "src", hostId: "bastion", hostLabel: "Bastion", hostname: "bastion.test",
username: "alice", protocol, status: "connected", lastCwd: "/saved/old-target",
};
let cwdReads = 0;
let bridgeReads = 0;
const { getCtx, calls } = ctxFactory({
sessions: [source],
getSessionRestoreCwd: () => { cwdReads += 1; return liveCwd; },
netcattyBridge: { get: () => { bridgeReads += 1; return {}; } },
});
await duplicateSessionWithCurrentShellImpl(getCtx, "src");
assert.equal(calls.copy?.id, "src");
assert.equal(calls.copy?.opts.reuseConnection, false);
assert.equal(calls.copy?.opts.inheritedCwd, undefined);
assert.equal(cwdReads, 0, "fresh remote login must not read the previous target's directory");
assert.equal(bridgeReads, 0, "fresh remote login must not probe the previous target");
const clone = createCopiedTerminalSessionClone(source, {
id: "duplicate",
inheritedCwd: calls.copy?.opts.inheritedCwd,
reuseConnection: calls.copy?.opts.reuseConnection,
});
assert.equal(clone.requireFreshConnection, true);
assert.equal(clone.pendingInitialCwd, undefined);
});
}
}
test("duplicate local session retains the current working directory", async () => {
const { getCtx, calls } = ctxFactory({
sessions: [{ id: "src", protocol: "local", status: "connected", localStartDir: "/home/alice" }],
getSessionRestoreCwd: () => "/home/alice/project",
});
await duplicateSessionWithCurrentShellImpl(getCtx, "src");
assert.equal(calls.copy?.opts.inheritedCwd, "/home/alice/project");
assert.equal(calls.probed, false);
});
test("network device (by deviceType) is never probed", async () => {
const { getCtx, calls } = ctxFactory({
hostById: new Map([["h1", { id: "h1", deviceType: "network" }]]),
+30 -5
View File
@@ -551,17 +551,37 @@ export async function splitSessionWithCurrentShellImpl(getCtx: AppContextGetter,
});
}
export async function copySessionWithCurrentShellImpl(getCtx: AppContextGetter, sessionId: string) {
const { classifyLocalShellType, copySession, discoveredShells, resolveShellSetting, terminalSettings } = getCtx();
export async function copySessionWithCurrentShellImpl(
getCtx: AppContextGetter,
sessionId: string,
options?: { reuseConnection?: boolean },
) {
const { classifyLocalShellType, copySession, discoveredShells, resolveShellSetting, sessions, terminalSettings } = getCtx();
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
const inheritedCwd = await captureCtxInheritedCwd(getCtx, sessionId);
// A fresh remote login may stop at a bastion's host-selection prompt. Do
// not probe the old target or send its directory command to the new login.
// Local duplicates still open in the source terminal's current directory.
const inheritCwd = options?.reuseConnection !== false
|| sessions?.find((session: { id: string }) => session.id === sessionId)?.protocol === "local";
const inheritedCwd = inheritCwd ? await captureCtxInheritedCwd(getCtx, sessionId) : undefined;
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : '';
return copySession(sessionId, {
localShellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, userAgent),
inheritedCwd,
reuseConnection: options?.reuseConnection,
});
}
/**
* "Duplicate session": clone the tab but open a brand-new connection instead of
* multiplexing over the source's established SSH channel (fresh auth; with a
* bastion/jump host this restarts the bastion login so the user can pick a new
* target host).
*/
export async function duplicateSessionWithCurrentShellImpl(getCtx: AppContextGetter, sessionId: string) {
return copySessionWithCurrentShellImpl(getCtx, sessionId, { reuseConnection: false });
}
export async function copyWorkspaceWithCurrentShellImpl(getCtx: AppContextGetter, workspaceId: string) {
const { classifyLocalShellType, collectSessionIds, copyWorkspace, discoveredShells, resolveShellSetting, terminalSettings, workspaces } = getCtx();
const workspace = workspaces.find((w: { id: string }) => w.id === workspaceId);
@@ -702,7 +722,7 @@ export async function closeTabsBatchImpl(getCtx: AppContextGetter, targetIds: st
}
export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string, e: KeyboardEvent) {
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, closePluginViewTab, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isPluginViewTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, sftpPaneMagnificationRef, splitSessionWithCurrentShell, systemInfoRef, terminalPaneMagnificationRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces } = getCtx();
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, canUseGlobalBroadcast, closePluginViewTab, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isPluginViewTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, sftpPaneMagnificationRef, splitSessionWithCurrentShell, systemInfoRef, terminalPaneMagnificationRef, toEditorTabId, toggleBroadcast, toggleGlobalBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces } = getCtx();
{
const shortcutTabs = buildNumberShortcutTabTargets({
showSftpTab: settings.showSftpTab ?? true,
@@ -896,11 +916,16 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
toggleSidePanelRef.current?.();
break;
case 'broadcast': {
// Toggle broadcast mode for the active workspace
// Workspace tabs keep their local broadcast state; orphan tabs share
// the global state when at least two visible orphan tabs exist.
const currentId = activeTabStore.getActiveTabId();
const activeWs = workspaces.find(w => w.id === currentId);
if (activeWs) {
toggleBroadcast(activeWs.id);
} else if (sessions.some((session: TerminalSession) => (
session.id === currentId && !session.workspaceId
)) && canUseGlobalBroadcast) {
toggleGlobalBroadcast();
}
break;
}
+9 -3
View File
@@ -90,7 +90,7 @@ 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, markForwardedNativeShortcutEvent } from './AppHandlers';
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, duplicateSessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl, markForwardedNativeShortcutEvent } from './AppHandlers';
type OpenSessionInNewWindowPayload = {
title?: string;
@@ -503,12 +503,13 @@ export function AppSideEffects() {
importVaultData: importDataFromString,
importPortForwardingRules,
onSettingsApplied: settings.rehydrateAllFromStorage,
}),
}, { currentHosts: hosts }),
translateProtectiveBackupFailure: (message) =>
t('cloudSync.localBackups.protectiveBackupFailed', { message }),
}),
[
buildCurrentSyncPayload,
hosts,
importDataFromString,
importPortForwardingRules,
settings.rehydrateAllFromStorage,
@@ -526,7 +527,7 @@ export function AppSideEffects() {
importVaultData: importDataFromString,
importPortForwardingRules,
onSettingsApplied: settings.rehydrateAllFromStorage,
});
}, { currentHosts: hosts });
await commitReplica();
},
translateProtectiveBackupFailure: (message) =>
@@ -534,6 +535,7 @@ export function AppSideEffects() {
}),
[
buildCurrentSyncPayload,
hosts,
importDataFromString,
importPortForwardingRules,
settings.rehydrateAllFromStorage,
@@ -957,6 +959,8 @@ export function AppSideEffects() {
const copySessionWithCurrentShell = useCallback((sessionId: string) => { return copySessionWithCurrentShellImpl(() => ({ classifyLocalShellType, copySession, discoveredShells, getSessionRestoreCwd, hostById, terminalHosts, netcattyBridge, resolveShellSetting, sessionId, sessions, terminalSettings }), sessionId); }, [copySession, terminalSettings, discoveredShells, sessions, getSessionRestoreCwd, hostById, terminalHosts]);
const duplicateSessionWithCurrentShell = useCallback((sessionId: string) => { return duplicateSessionWithCurrentShellImpl(() => ({ classifyLocalShellType, copySession, discoveredShells, getSessionRestoreCwd, hostById, terminalHosts, netcattyBridge, resolveShellSetting, sessionId, sessions, terminalSettings }), sessionId); }, [copySession, terminalSettings, discoveredShells, sessions, getSessionRestoreCwd, hostById, terminalHosts]);
const copyWorkspaceWithCurrentShell = useCallback((workspaceId: string) => { return copyWorkspaceWithCurrentShellImpl(() => ({ classifyLocalShellType, collectSessionIds, copyWorkspace, discoveredShells, getSessionRestoreCwd, hostById, terminalHosts, netcattyBridge, resolveShellSetting, sessions, terminalSettings, workspaces }), workspaceId); }, [copyWorkspace, terminalSettings, discoveredShells, sessions, workspaces, getSessionRestoreCwd, hostById, terminalHosts]);
const copySessionToNewWindowWithCurrentShell = useCallback((sessionId: string) => { return copySessionToNewWindowWithCurrentShellImpl(() => ({ classifyLocalShellType, discoveredShells, netcattyBridge, resolveShellSetting, sessions, terminalSettings, t, toast }), sessionId); }, [sessions, terminalSettings, discoveredShells, t]);
@@ -1869,6 +1873,7 @@ export function AppSideEffects() {
// Terminal glue
closeTabsBatch,
copySessionWithCurrentShell,
duplicateSessionWithCurrentShell,
copyWorkspaceWithCurrentShell,
copySessionToNewWindowWithCurrentShell,
createWorkspaceFromTargets: createWorkspaceFromEffectiveTargets,
@@ -1944,6 +1949,7 @@ export function AppSideEffects() {
unmanageSource,
closeTabsBatch,
copySessionWithCurrentShell,
duplicateSessionWithCurrentShell,
copyWorkspaceWithCurrentShell,
copySessionToNewWindowWithCurrentShell,
createWorkspaceFromEffectiveTargets,
+7 -2
View File
@@ -260,7 +260,7 @@ function AppViewInner({ domains }: AppViewProps) {
const {
addShellHistoryEntry, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
clearAndRemoveSource, clearAndRemoveSources, closeLogView, closeSession, closeTabsBatch, closeWorkspace, commitPluginImporterData, commitVaultImportTransaction, commitVaultGroupMutation, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell, copyWorkspaceWithCurrentShell,
clearAndRemoveSource, clearAndRemoveSources, closeLogView, closeSession, closeTabsBatch, closeWorkspace, commitPluginImporterData, commitVaultImportTransaction, commitVaultGroupMutation, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell, duplicateSessionWithCurrentShell, copyWorkspaceWithCurrentShell,
convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts,
customGroups, currentTerminalTheme, deepLinkHostDraft, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
followAppTerminalTheme,
@@ -268,7 +268,7 @@ function AppViewInner({ domains }: AppViewProps) {
handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit,
handleOpenHostFromVaultNote, handleOpenQuickSwitcher, handleOpenSettings, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect,
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleUpdateHostFromTerminal,
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isGlobalBroadcastEnabled, onToggleGlobalBroadcast, canUseGlobalBroadcast, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
resolveEmptyVaultConflict, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
@@ -503,6 +503,7 @@ function AppViewInner({ domains }: AppViewProps) {
onCloseSession={closeSession}
onRenameSession={startSessionRename}
onCopySession={copySessionWithCurrentShell}
onDuplicateSession={duplicateSessionWithCurrentShell}
onCopySessionToNewWindow={copySessionToNewWindowWithCurrentShell}
onEditHost={handleEditHostFromOverlay}
onRenameWorkspace={startWorkspaceRename}
@@ -724,6 +725,7 @@ function AppViewInner({ domains }: AppViewProps) {
onReorderWorkspaceSessions={reorderWorkspaceSessions}
onReorderTabs={reorderWorkTabs}
onCopySession={copySessionWithCurrentShell}
onDuplicateSession={duplicateSessionWithCurrentShell}
onCopySessionToNewWindow={copySessionToNewWindowWithCurrentShell}
onSplitSession={splitSessionWithCurrentShell}
onConnectToHost={handleConnectToHost}
@@ -735,6 +737,9 @@ function AppViewInner({ domains }: AppViewProps) {
onCreateLocalTerminal={handleCreateLocalTerminal}
isBroadcastEnabled={isBroadcastEnabled}
onToggleBroadcast={toggleBroadcast}
isGlobalBroadcastEnabled={isGlobalBroadcastEnabled}
onToggleGlobalBroadcast={onToggleGlobalBroadcast}
canUseGlobalBroadcast={canUseGlobalBroadcast}
updateHosts={updateTerminalHosts}
updateSnippets={updateSnippets}
updateSnippetPackages={updateSnippetPackages}
+5
View File
@@ -180,6 +180,7 @@ export function TerminalHost() {
copySessionWithCurrentShell: handlers.copySessionWithCurrentShell,
copyWorkspaceWithCurrentShell: handlers.copyWorkspaceWithCurrentShell,
copySessionToNewWindowWithCurrentShell: handlers.copySessionToNewWindowWithCurrentShell,
duplicateSessionWithCurrentShell: handlers.duplicateSessionWithCurrentShell,
closeWorkspace: sessionActions?.closeWorkspace,
createWorkspaceFromSessions: sessionActions?.createWorkspaceFromSessions,
createWorkspaceFromTargets: handlers.createWorkspaceFromTargets,
@@ -206,6 +207,8 @@ export function TerminalHost() {
updateTerminalHosts: handlers.updateTerminalHosts,
hotkeyScheme: terminalSettings.hotkeyScheme,
isBroadcastEnabled: sessionActions?.isBroadcastEnabled,
isGlobalBroadcastEnabled: sessionActions?.isGlobalBroadcastEnabled,
canUseGlobalBroadcast: sessionActions?.canUseGlobalBroadcast,
keyBindings: terminalSettings.keyBindings,
openNoteRequest: local.openNoteRequest,
portForwardingRules: local.portForwardingRules,
@@ -237,6 +240,8 @@ export function TerminalHost() {
terminalSettings: terminalSettings.terminalSettings,
terminalThemeId: terminalSettings.terminalThemeId,
toggleBroadcast: sessionActions?.toggleBroadcast,
toggleGlobalBroadcast: sessionActions?.toggleGlobalBroadcast,
onToggleGlobalBroadcast: sessionActions?.toggleGlobalBroadcast,
toggleScriptsSidePanelRef: handlers.toggleScriptsSidePanelRef,
toggleSidePanelRef: handlers.toggleSidePanelRef,
terminalPaneMagnificationRef: handlers.terminalPaneMagnificationRef,
@@ -70,6 +70,9 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
reorderTabs,
toggleBroadcast,
isBroadcastEnabled,
toggleGlobalBroadcast,
isGlobalBroadcastEnabled,
canUseGlobalBroadcast,
copySession,
copyWorkspace,
createSessionFromCloneSource,
@@ -147,6 +150,9 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
reorderTabs,
toggleBroadcast,
isBroadcastEnabled,
toggleGlobalBroadcast,
isGlobalBroadcastEnabled,
canUseGlobalBroadcast,
copySession,
copyWorkspace,
createSessionFromCloneSource,
@@ -175,8 +181,10 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
getOrderedWorkTabs,
getSessionRestoreCwd,
isBroadcastEnabled,
isGlobalBroadcastEnabled,
moveFocusInWorkspace,
openLogView,
canUseGlobalBroadcast,
removeSessionFromWorkspace,
renameSessionInline,
reorderTabs,
@@ -193,6 +201,7 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
submitSessionRename,
submitWorkspaceRename,
toggleBroadcast,
toggleGlobalBroadcast,
toggleWorkspaceViewMode,
updateSessionCodingCliProvider,
updateSessionDynamicTitle,
+7
View File
@@ -497,6 +497,13 @@ export const enAiMessages: Messages = {
'ai.chat.menuFiles': 'Files',
'ai.chat.menuImage': 'Image',
'ai.chat.menuMentionHost': 'Mention Host',
'ai.chat.menuMentionNote': 'Mention Note',
'ai.chat.mentionNoteSearch': 'Search notes…',
'ai.chat.mentionNoteEmpty': 'No matching notes',
'ai.chat.mentionNoteUnavailable': 'This agent cannot read Vault notes in the current connection mode.',
'ai.chat.mentionNoteTooMany': 'These notes cannot all be referenced together. Please select fewer notes.',
'ai.chat.mentionNoteInvalid': '"{{title}}" could not be attached: the note has an invalid identifier.',
'ai.chat.untitledNote': 'Untitled note',
'ai.chat.menuUserSkills': 'User Skills',
'ai.chat.menuSlashCommands': 'Slash Commands',
'ai.chat.slashCommands': 'Slash commands',
+7
View File
@@ -197,6 +197,12 @@ export const enCoreMessages: Messages = {
'settings.appLock.validation.mismatch': 'The new passwords do not match.',
'settings.appLock.validation.incorrect': 'The current lock password is incorrect.',
// Settings > System > Auto Launch
'settings.autoLaunch.title': 'Launch at Login',
'settings.autoLaunch.enabled': 'Launch at system startup',
'settings.autoLaunch.enabledDesc': 'Automatically start Netcatty when you log in, hidden in the system tray.',
'settings.autoLaunch.unsupportedDesc': 'Not available on this platform or in a development build.',
// Settings > System > Network Proxy
'settings.system.networkProxy.title': 'Network Proxy',
'settings.system.networkProxy.description': 'HTTP(S) proxy for cloud sync and AI providers. This is separate from SSH ProxyJump / ProxyCommand profiles.',
@@ -1076,6 +1082,7 @@ Highlight the focused split pane:
// Vault hosts header/actions
'vault.hosts.search.placeholder': 'Find a host or ssh user@hostname / ssh -p 2222 user@hostname...',
'vault.hosts.connect': 'Connect',
'vault.hosts.openSftp': 'Open SFTP',
'vault.view.grid': 'Grid',
'vault.view.list': 'List',
'vault.view.tree': 'Tree',
+4
View File
@@ -117,6 +117,9 @@ export const enTerminalMessages: Messages = {
'terminal.dragDrop.notConnected': 'Cannot drop files - terminal is not connected',
'terminal.dragDrop.errorTitle': 'Drop Error',
'terminal.dragDrop.errorMessage': 'Failed to process dropped files',
'terminal.dragDrop.destinationUnknown': 'Could not determine the current terminal folder. Enable directory tracking, or open SFTP and choose an upload folder first.',
'terminal.dragDrop.uploadCancelled': 'The upload was cancelled because the terminal connection changed or could not be reused. Drop the files again after reconnecting.',
'terminal.dragDrop.needsSudoElevation': 'This folder is not writable as the login user. Enable Sudo elevation in host settings, or go back to your user directory and drop again.',
'terminal.search.placeholder': 'Search...',
'terminal.search.noResults': 'No results',
'terminal.search.prevMatch': 'Previous match (Shift+Enter)',
@@ -630,6 +633,7 @@ export const enTerminalMessages: Messages = {
'tabs.logPrefix': 'Log:',
'tabs.logLocal': 'Local',
'tabs.copyTab': 'Copy Tab',
'tabs.duplicateSession': 'Duplicate Session',
'tabs.copyTabToNewWindow': 'Copy Tab to New Window',
'tabs.copyTabToNewWindowFailed': 'Failed to open tab in a new window',
'tabs.closeOthers': 'Close Others',
+20
View File
@@ -148,6 +148,10 @@ export const enVaultMessages: Messages = {
'vault.import.mobaxterm.utf8Desc': 'Use for files saved or converted as UTF-8.',
'vault.import.mobaxterm.gb18030': 'Chinese Windows',
'vault.import.mobaxterm.gb18030Desc': 'Use for legacy files exported on Chinese Windows.',
'vault.import.mobaxterm.masterPassword': 'Master password (optional)',
'vault.import.mobaxterm.masterPasswordPlaceholder': 'Enter the MobaXterm master password',
'vault.import.mobaxterm.masterPasswordHint':
'Needed to import saved passwords from .mobaconf or MobaXterm.ini. Leave blank to import sessions only.',
'vault.import.sshConfig.chooseMode': 'Choose how to import your SSH config file.',
'vault.import.sshConfig.modeQuestion': 'How would you like to import?',
'vault.import.sshConfig.importOnly': 'Import Only',
@@ -269,6 +273,12 @@ export const enVaultMessages: Messages = {
'sftp.bookmark.addGlobal': '+Global',
'sftp.bookmark.addGlobalTooltip': 'Save as global bookmark (shared across all hosts)',
'sftp.bookmark.empty': 'No bookmarks yet',
'sftp.bookmark.removeConfirm': 'Remove bookmark {path}?',
'sftp.bookmark.manage': 'Manage',
'sftp.bookmark.rename': 'Rename bookmark',
'sftp.bookmark.done': 'Done',
'sftp.bookmark.moveUp': 'Move {label} up',
'sftp.bookmark.moveDown': 'Move {label} down',
'sftp.columns.name': 'Name',
'sftp.columns.modified': 'Modified',
'sftp.columns.size': 'Size',
@@ -309,6 +319,10 @@ export const enVaultMessages: Messages = {
'sftp.viewMode.tree': 'Tree view',
'sftp.viewMode.switchToList': 'Switch to list view',
'sftp.viewMode.switchToTree': 'Switch to tree view',
'sftp.listDensity.compact': 'Compact list',
'sftp.listDensity.comfortable': 'Comfortable list',
'sftp.listDensity.switchToCompact': 'Switch to compact list',
'sftp.listDensity.switchToComfortable': 'Switch to comfortable list',
'sftp.tree.loadError': 'Failed to load directory',
'sftp.tree.loading': 'Loading...',
'sftp.kind.folder': 'Folder',
@@ -404,6 +418,7 @@ export const enVaultMessages: Messages = {
'sftp.encoding.utf8': 'UTF-8',
'sftp.encoding.gb18030': 'GB18030',
'sftp.goHome': 'Go to home',
'sftp.goRoot': 'Go to root',
'sftp.folderName': 'Folder name',
'sftp.folderName.placeholder': 'Enter folder name',
'sftp.fileName': 'File name',
@@ -695,6 +710,11 @@ export const enVaultMessages: Messages = {
'hostDetails.section.address': 'Address',
'hostDetails.hostname.placeholder': 'IP or Hostname',
'hostDetails.section.general': 'General',
'hostDetails.os.title': 'Operating System',
'hostDetails.os.desc': 'Used for environment reporting and shell-aware behavior.',
'hostDetails.os.linux': 'Linux',
'hostDetails.os.windows': 'Windows',
'hostDetails.os.macos': 'macOS',
'hostDetails.section.sftp': 'SFTP Settings',
'hostDetails.sftp.fileProtocol': 'File Protocol',
'hostDetails.sftp.fileProtocol.desc': 'Auto tries SFTP, then falls back to SCP mode when the SFTP subsystem is unavailable (e.g. some NAS). Force SCP for hosts that only support SCP.',
+7
View File
@@ -497,6 +497,13 @@ export const esAiMessages: Messages = {
'ai.chat.menuFiles': 'Archivos',
'ai.chat.menuImage': 'Imagen',
'ai.chat.menuMentionHost': 'Mencionar host',
'ai.chat.menuMentionNote': 'Mencionar nota',
'ai.chat.mentionNoteSearch': 'Buscar notas…',
'ai.chat.mentionNoteEmpty': 'Sin notas coincidentes',
'ai.chat.mentionNoteUnavailable': 'Este agente no puede leer notas de la bóveda en el modo de conexión actual.',
'ai.chat.mentionNoteTooMany': 'No se pueden referenciar todas estas notas juntas. Selecciona menos notas.',
'ai.chat.mentionNoteInvalid': 'No se pudo adjuntar «{{title}}»: la nota tiene un identificador no válido.',
'ai.chat.untitledNote': 'Nota sin título',
'ai.chat.menuUserSkills': 'Habilidades de usuario',
'ai.chat.menuSlashCommands': 'Comandos de barra',
'ai.chat.slashCommands': 'Comandos de barra',
+1
View File
@@ -1040,6 +1040,7 @@ Resalta el panel dividido enfocado:
// Vault hosts header/actions
'vault.hosts.search.placeholder': 'Busca un host o escribe ssh usuario@hostname / ssh -p 2222 usuario@hostname...',
'vault.hosts.connect': 'Conectar',
'vault.hosts.openSftp': 'Abrir SFTP',
'vault.view.grid': 'Cuadrícula',
'vault.view.list': 'Lista',
'vault.view.tree': 'Árbol',
+4
View File
@@ -117,6 +117,9 @@ export const esTerminalMessages: Messages = {
'terminal.dragDrop.notConnected': 'No se pueden soltar archivos: la terminal no está conectada',
'terminal.dragDrop.errorTitle': 'Error al soltar',
'terminal.dragDrop.errorMessage': 'No se pudieron procesar los archivos soltados',
'terminal.dragDrop.destinationUnknown': 'No se pudo determinar la carpeta actual del terminal. Active el seguimiento de directorios o abra SFTP y elija primero una carpeta de carga.',
'terminal.dragDrop.uploadCancelled': 'La carga se canceló porque la conexión del terminal cambió o no se pudo reutilizar. Vuelva a soltar los archivos después de reconectar.',
'terminal.dragDrop.needsSudoElevation': 'Esta carpeta no es escribible con el usuario de inicio de sesión. Active la elevación Sudo en la configuración del host, o vuelva al directorio de usuario y suelte de nuevo.',
'terminal.search.placeholder': 'Buscar...',
'terminal.search.noResults': 'Sin resultados',
'terminal.search.prevMatch': 'Coincidencia anterior (Shift+Enter)',
@@ -630,6 +633,7 @@ export const esTerminalMessages: Messages = {
'tabs.logPrefix': 'Registro:',
'tabs.logLocal': 'Local',
'tabs.copyTab': 'Copiar pestaña',
'tabs.duplicateSession': 'Duplicar sesión',
'tabs.copyTabToNewWindow': 'Copiar pestaña a una nueva ventana',
'tabs.copyTabToNewWindowFailed': 'No se pudo abrir la pestaña en una nueva ventana',
'tabs.closeOthers': 'Cerrar las demás',
+20
View File
@@ -148,6 +148,10 @@ export const esVaultMessages: Messages = {
'vault.import.mobaxterm.utf8Desc': 'Úsalo para archivos guardados o convertidos como UTF-8.',
'vault.import.mobaxterm.gb18030': 'Windows en chino',
'vault.import.mobaxterm.gb18030Desc': 'Úsalo para archivos antiguos exportados en Windows en chino.',
'vault.import.mobaxterm.masterPassword': 'Contraseña maestra (opcional)',
'vault.import.mobaxterm.masterPasswordPlaceholder': 'Introduce la contraseña maestra de MobaXterm',
'vault.import.mobaxterm.masterPasswordHint':
'Hace falta para importar las contraseñas guardadas de .mobaconf o MobaXterm.ini. Déjalo vacío para importar solo las sesiones.',
'vault.import.sshConfig.chooseMode': 'Elige cómo importar tu archivo de configuración SSH.',
'vault.import.sshConfig.modeQuestion': '¿Cómo te gustaría importar?',
'vault.import.sshConfig.importOnly': 'Solo importar',
@@ -269,6 +273,12 @@ export const esVaultMessages: Messages = {
'sftp.bookmark.addGlobal': '+Global',
'sftp.bookmark.addGlobalTooltip': 'Guardar como marcador global (compartido entre todos los hosts)',
'sftp.bookmark.empty': 'Aún no hay marcadores',
'sftp.bookmark.removeConfirm': '¿Quitar el marcador {path}?',
'sftp.bookmark.manage': 'Administrar',
'sftp.bookmark.rename': 'Renombrar marcador',
'sftp.bookmark.done': 'Listo',
'sftp.bookmark.moveUp': 'Mover {label} hacia arriba',
'sftp.bookmark.moveDown': 'Mover {label} hacia abajo',
'sftp.columns.name': 'Nombre',
'sftp.columns.modified': 'Modificado',
'sftp.columns.size': 'Tamaño',
@@ -309,6 +319,10 @@ export const esVaultMessages: Messages = {
'sftp.viewMode.tree': 'Vista de árbol',
'sftp.viewMode.switchToList': 'Cambiar a vista de lista',
'sftp.viewMode.switchToTree': 'Cambiar a vista de árbol',
'sftp.listDensity.compact': 'Lista compacta',
'sftp.listDensity.comfortable': 'Lista cómoda',
'sftp.listDensity.switchToCompact': 'Cambiar a lista compacta',
'sftp.listDensity.switchToComfortable': 'Cambiar a lista cómoda',
'sftp.tree.loadError': 'No se pudo cargar el directorio',
'sftp.tree.loading': 'Cargando...',
'sftp.kind.folder': 'Carpeta',
@@ -404,6 +418,7 @@ export const esVaultMessages: Messages = {
'sftp.encoding.utf8': 'UTF-8',
'sftp.encoding.gb18030': 'GB18030',
'sftp.goHome': 'Ir al inicio',
'sftp.goRoot': 'Ir a la raíz',
'sftp.folderName': 'Nombre de la carpeta',
'sftp.folderName.placeholder': 'Ingresa el nombre de la carpeta',
'sftp.fileName': 'Nombre del archivo',
@@ -695,6 +710,11 @@ export const esVaultMessages: Messages = {
'hostDetails.section.address': 'Dirección',
'hostDetails.hostname.placeholder': 'IP o nombre de host',
'hostDetails.section.general': 'General',
'hostDetails.os.title': 'Sistema operativo',
'hostDetails.os.desc': 'Se usa para informar del entorno y adaptar el comportamiento del shell.',
'hostDetails.os.linux': 'Linux',
'hostDetails.os.windows': 'Windows',
'hostDetails.os.macos': 'macOS',
'hostDetails.section.sftp': 'Configuración de SFTP',
'hostDetails.sftp.fileProtocol': 'Protocolo de archivos',
'hostDetails.sftp.fileProtocol.desc': 'Auto intenta SFTP y luego recurre al modo SCP cuando el subsistema SFTP no está disponible (p. ej., algunos NAS). Fuerza SCP para hosts que solo admiten SCP.',
+7
View File
@@ -449,6 +449,13 @@ export const ruAiMessages: Messages = {
'ai.chat.menuFiles': 'Файлы',
'ai.chat.menuImage': 'Изображение',
'ai.chat.menuMentionHost': 'Упомянуть хост',
'ai.chat.menuMentionNote': 'Упомянуть заметку',
'ai.chat.mentionNoteSearch': 'Поиск заметок…',
'ai.chat.mentionNoteEmpty': 'Нет подходящих заметок',
'ai.chat.mentionNoteUnavailable': 'Этот агент не может читать заметки хранилища в текущем режиме подключения.',
'ai.chat.mentionNoteTooMany': 'Невозможно сослаться на все эти заметки одновременно. Выберите меньше заметок.',
'ai.chat.mentionNoteInvalid': '«{{title}}» не удалось прикрепить: у заметки недопустимый идентификатор.',
'ai.chat.untitledNote': 'Заметка без названия',
'ai.chat.menuUserSkills': 'Пользовательские навыки',
'ai.chat.menuSlashCommands': 'Команды /',
'ai.chat.slashCommands': 'Команды /',
+6
View File
@@ -196,6 +196,12 @@ export const ruCoreMessages: Messages = {
'settings.appLock.validation.mismatch': 'Новые пароли не совпадают.',
'settings.appLock.validation.incorrect': 'Текущий пароль блокировки неверен.',
// Settings > System > Auto Launch
'settings.autoLaunch.title': 'Запуск при входе в систему',
'settings.autoLaunch.enabled': 'Запускать при старте системы',
'settings.autoLaunch.enabledDesc': 'Автоматически запускать Netcatty при входе в систему, свернутым в системный трей.',
'settings.autoLaunch.unsupportedDesc': 'Недоступно на этой платформе или в сборке для разработки.',
// Settings > System > Network Proxy
'settings.system.networkProxy.title': 'Сетевой прокси',
'settings.system.networkProxy.description': 'HTTP(S)-прокси для облачной синхронизации и AI-провайдеров. Не связан с SSH ProxyJump / ProxyCommand.',
+4
View File
@@ -138,6 +138,9 @@ export const ruTerminalMessages: Messages = {
'terminal.dragDrop.notConnected': 'Нельзя перетащить файлы — терминал не подключён',
'terminal.dragDrop.errorTitle': 'Ошибка перетаскивания',
'terminal.dragDrop.errorMessage': 'Не удалось обработать перетащенные файлы',
'terminal.dragDrop.destinationUnknown': 'Не удалось определить текущую папку терминала. Включите отслеживание каталогов или сначала откройте SFTP и выберите папку загрузки.',
'terminal.dragDrop.uploadCancelled': 'Загрузка отменена: соединение терминала изменилось или его не удалось повторно использовать. После переподключения перетащите файлы снова.',
'terminal.dragDrop.needsSudoElevation': 'Эта папка недоступна для записи пользователю входа. Включите повышение прав Sudo в настройках хоста или вернитесь в домашний каталог и перетащите файлы снова.',
'terminal.search.placeholder': 'Поиск...',
'terminal.search.noResults': 'Ничего не найдено',
'terminal.search.prevMatch': 'Предыдущее совпадение (Shift+Enter)',
@@ -645,6 +648,7 @@ export const ruTerminalMessages: Messages = {
'tabs.logPrefix': 'Журнал:',
'tabs.logLocal': 'Локальный',
'tabs.copyTab': 'Копировать вкладку',
'tabs.duplicateSession': 'Дублировать сессию',
'tabs.copyTabToNewWindow': 'Копировать вкладку в новое окно',
'tabs.copyTabToNewWindowFailed': 'Не удалось открыть вкладку в новом окне',
'tabs.closeOthers': 'Закрыть остальные',
+21
View File
@@ -95,6 +95,7 @@ export const ruVaultMessages: Messages = {
// Vault hosts header/actions
'vault.hosts.search.placeholder': 'Найти хост или ssh user@hostname / ssh -p 2222 user@hostname...',
'vault.hosts.connect': 'Подключиться',
'vault.hosts.openSftp': 'Открыть SFTP',
'vault.view.grid': 'Сетка',
'vault.view.list': 'Список',
'vault.view.tree': 'Дерево',
@@ -185,6 +186,10 @@ export const ruVaultMessages: Messages = {
'vault.import.mobaxterm.utf8Desc': 'Для файлов, сохранённых или преобразованных в UTF-8.',
'vault.import.mobaxterm.gb18030': 'Китайская Windows',
'vault.import.mobaxterm.gb18030Desc': 'Для старых файлов, экспортированных в китайской Windows.',
'vault.import.mobaxterm.masterPassword': 'Мастер-пароль (необязательно)',
'vault.import.mobaxterm.masterPasswordPlaceholder': 'Введите мастер-пароль MobaXterm',
'vault.import.mobaxterm.masterPasswordHint':
'Нужен, чтобы импортировать сохранённые пароли из .mobaconf или MobaXterm.ini. Оставьте пустым, чтобы импортировать только сеансы.',
'vault.import.sshConfig.chooseMode': 'Выберите, как импортировать ваш файл SSH-конфига.',
'vault.import.sshConfig.modeQuestion': 'Как вы хотите выполнить импорт?',
'vault.import.sshConfig.importOnly': 'Только импорт',
@@ -306,6 +311,12 @@ export const ruVaultMessages: Messages = {
'sftp.bookmark.addGlobal': '+Глобальная',
'sftp.bookmark.addGlobalTooltip': 'Сохранить как глобальную закладку (общую для всех хостов)',
'sftp.bookmark.empty': 'Пока нет закладок',
'sftp.bookmark.removeConfirm': 'Удалить закладку {path}?',
'sftp.bookmark.manage': 'Управление',
'sftp.bookmark.rename': 'Переименовать закладку',
'sftp.bookmark.done': 'Готово',
'sftp.bookmark.moveUp': 'Переместить {label} вверх',
'sftp.bookmark.moveDown': 'Переместить {label} вниз',
'sftp.columns.name': 'Имя',
'sftp.columns.modified': 'Изменён',
'sftp.columns.size': 'Размер',
@@ -346,6 +357,10 @@ export const ruVaultMessages: Messages = {
'sftp.viewMode.tree': 'Дерево',
'sftp.viewMode.switchToList': 'Переключиться на список',
'sftp.viewMode.switchToTree': 'Переключиться на дерево',
'sftp.listDensity.compact': 'Компактный список',
'sftp.listDensity.comfortable': 'Обычный список',
'sftp.listDensity.switchToCompact': 'Переключиться на компактный список',
'sftp.listDensity.switchToComfortable': 'Переключиться на обычный список',
'sftp.tree.loadError': 'Не удалось загрузить каталог',
'sftp.tree.loading': 'Загрузка...',
'sftp.kind.folder': 'Папка',
@@ -440,6 +455,7 @@ export const ruVaultMessages: Messages = {
'sftp.encoding.utf8': 'UTF-8',
'sftp.encoding.gb18030': 'GB18030',
'sftp.goHome': 'Перейти в домашний каталог',
'sftp.goRoot': 'Перейти в корень',
'sftp.folderName': 'Имя папки',
'sftp.folderName.placeholder': 'Введите имя папки',
'sftp.fileName': 'Имя файла',
@@ -731,6 +747,11 @@ export const ruVaultMessages: Messages = {
'hostDetails.section.address': 'Адрес',
'hostDetails.hostname.placeholder': 'IP или имя хоста',
'hostDetails.section.general': 'Общие',
'hostDetails.os.title': 'Операционная система',
'hostDetails.os.desc': 'Используется для сведений о среде и адаптации командной оболочки.',
'hostDetails.os.linux': 'Linux',
'hostDetails.os.windows': 'Windows',
'hostDetails.os.macos': 'macOS',
'hostDetails.section.sftp': 'Настройки SFTP',
'hostDetails.sftp.fileProtocol': 'Протокол файлов',
'hostDetails.sftp.fileProtocol.desc': 'Авто сначала пробует SFTP, затем SCP-режим, если подсистема SFTP недоступна. Принудительный SCP — для хостов только с SCP.',
@@ -24,6 +24,9 @@ const KEYS = [
"vault.import.progress.persistFailed",
"vault.import.progress.rollbackFailed",
"vault.import.sshConfig.managedDestinationHint",
"vault.import.mobaxterm.masterPassword",
"vault.import.mobaxterm.masterPasswordPlaceholder",
"vault.import.mobaxterm.masterPasswordHint",
] as const;
test("bulk vault import and group-selection copy exists in every locale", () => {
+7
View File
@@ -497,6 +497,13 @@ export const zhCNAiMessages: Messages = {
'ai.chat.menuFiles': '文件',
'ai.chat.menuImage': '图片',
'ai.chat.menuMentionHost': '提及主机',
'ai.chat.menuMentionNote': '提及笔记',
'ai.chat.mentionNoteSearch': '搜索笔记…',
'ai.chat.mentionNoteEmpty': '没有匹配的笔记',
'ai.chat.mentionNoteUnavailable': '当前连接方式下,此 AI 无法读取保险箱笔记。',
'ai.chat.mentionNoteTooMany': '这些笔记无法一起引用,请减少选择。',
'ai.chat.mentionNoteInvalid': '无法附加「{{title}}」:笔记的标识符无效。',
'ai.chat.untitledNote': '未命名笔记',
'ai.chat.menuUserSkills': '用户 Skills',
'ai.chat.menuSlashCommands': '快捷命令',
'ai.chat.slashCommands': '快捷命令',
+22
View File
@@ -194,6 +194,12 @@ export const zhCNCoreMessages: Messages = {
'settings.appLock.validation.mismatch': '两次输入的新密码不一致。',
'settings.appLock.validation.incorrect': '当前锁定密码不正确。',
// Settings > System > Auto Launch
'settings.autoLaunch.title': '开机自启动',
'settings.autoLaunch.enabled': '开机自动启动',
'settings.autoLaunch.enabledDesc': '系统启动时自动运行 Netcatty,并隐藏到系统托盘。',
'settings.autoLaunch.unsupportedDesc': '当前平台不支持此功能,或处于开发环境。',
// Settings > System > Network Proxy
'settings.system.networkProxy.title': '网络代理',
'settings.system.networkProxy.description': '用于云同步与 AI 服务商的 HTTP(S) 代理。与 SSH 的 ProxyJump / ProxyCommand 配置无关。',
@@ -712,6 +718,7 @@ export const zhCNCoreMessages: Messages = {
// Vault hosts header/actions
'vault.hosts.search.placeholder': '查找主机或 ssh user@hostname / ssh -p 2222 user@hostname…',
'vault.hosts.connect': '连接',
'vault.hosts.openSftp': '打开 SFTP',
'vault.view.grid': '网格',
'vault.view.list': '列表',
'vault.view.tree': '树形',
@@ -804,6 +811,10 @@ export const zhCNCoreMessages: Messages = {
'vault.import.mobaxterm.utf8Desc': '适用于已保存或转换为 UTF-8 的文件。',
'vault.import.mobaxterm.gb18030': '中文 Windows',
'vault.import.mobaxterm.gb18030Desc': '适用于中文 Windows 上导出的旧版文件。',
'vault.import.mobaxterm.masterPassword': '主密码(可选)',
'vault.import.mobaxterm.masterPasswordPlaceholder': '输入 MobaXterm 主密码',
'vault.import.mobaxterm.masterPasswordHint':
'导入 .mobaconf 或 MobaXterm.ini 中保存的密码时需要填写。留空则只导入会话。',
'vault.import.sshConfig.chooseMode': '选择如何导入你的 SSH config 文件。',
'vault.import.sshConfig.modeQuestion': '你希望如何导入?',
'vault.import.sshConfig.importOnly': '仅导入',
@@ -893,6 +904,12 @@ export const zhCNCoreMessages: Messages = {
'sftp.bookmark.addGlobal': '+全局',
'sftp.bookmark.addGlobalTooltip': '保存为全局收藏(所有主机共享)',
'sftp.bookmark.empty': '暂无收藏路径',
'sftp.bookmark.removeConfirm': '删除收藏 {path}?',
'sftp.bookmark.manage': '管理',
'sftp.bookmark.rename': '重命名收藏',
'sftp.bookmark.done': '完成',
'sftp.bookmark.moveUp': '上移 {label}',
'sftp.bookmark.moveDown': '下移 {label}',
'sftp.columns.name': '名称',
'sftp.columns.configure': '选择显示的列',
'sftp.columns.modified': '修改时间',
@@ -933,6 +950,10 @@ export const zhCNCoreMessages: Messages = {
'sftp.viewMode.tree': '树形视图',
'sftp.viewMode.switchToList': '切换到列表视图',
'sftp.viewMode.switchToTree': '切换到树形视图',
'sftp.listDensity.compact': '紧凑列表',
'sftp.listDensity.comfortable': '宽松列表',
'sftp.listDensity.switchToCompact': '切换到紧凑列表',
'sftp.listDensity.switchToComfortable': '切换到宽松列表',
'sftp.tree.loadError': '加载目录失败',
'sftp.tree.loading': '加载中...',
'sftp.kind.folder': '文件夹',
@@ -1027,6 +1048,7 @@ export const zhCNCoreMessages: Messages = {
'sftp.encoding.utf8': 'UTF-8',
'sftp.encoding.gb18030': 'GB18030',
'sftp.goHome': '返回主目录',
'sftp.goRoot': '回到根目录',
'sftp.folderName': '文件夹名称',
'sftp.folderName.placeholder': '输入文件夹名称',
'sftp.fileName': '文件名称',
@@ -718,6 +718,7 @@ export const zhCNTerminalMessages: Messages = {
'tabs.logPrefix': '日志:',
'tabs.logLocal': '本地',
'tabs.copyTab': '复制标签页',
'tabs.duplicateSession': '复制会话',
'tabs.copyTabToNewWindow': '复制标签页到新窗口',
'tabs.copyTabToNewWindowFailed': '无法在新窗口打开标签页',
'tabs.closeOthers': '关闭其他标签',
+8
View File
@@ -133,6 +133,11 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.section.address': '地址',
'hostDetails.hostname.placeholder': 'IP 或 主机名',
'hostDetails.section.general': '通用',
'hostDetails.os.title': '操作系统',
'hostDetails.os.desc': '用于环境信息和适配对应的命令行行为。',
'hostDetails.os.linux': 'Linux',
'hostDetails.os.windows': 'Windows',
'hostDetails.os.macos': 'macOS',
'hostDetails.section.sftp': 'SFTP 设置',
'hostDetails.sftp.fileProtocol': '文件协议',
'hostDetails.sftp.fileProtocol.desc': '自动优先 SFTP;当服务器无 SFTP 子系统时回退到 SCP 模式(如部分 NAS)。仅支持 SCP 的主机可强制 SCP。',
@@ -519,6 +524,9 @@ export const zhCNVaultMessages: Messages = {
'terminal.dragDrop.notConnected': '无法拖放文件 - 终端未连接',
'terminal.dragDrop.errorTitle': '拖放错误',
'terminal.dragDrop.errorMessage': '处理拖放文件失败',
'terminal.dragDrop.destinationUnknown': '无法确定当前终端目录。请启用目录跟踪,或先打开 SFTP 选择上传目录。',
'terminal.dragDrop.uploadCancelled': '终端连接已变化或无法继续复用,本次上传已取消。请重新连接后再次拖放文件。',
'terminal.dragDrop.needsSudoElevation': '当前目录登录用户无法写入。请在主机设置中开启 Sudo 提权,或回到用户目录后再拖放。',
'terminal.search.placeholder': '搜索…',
'terminal.search.noResults': '无结果',
'terminal.search.prevMatch': '上一个匹配 (Shift+Enter)',
+7
View File
@@ -496,6 +496,13 @@ export const zhTWAiMessages: Messages = {
'ai.chat.menuFiles': '檔案',
'ai.chat.menuImage': '圖片',
'ai.chat.menuMentionHost': '提及主機',
'ai.chat.menuMentionNote': '提及筆記',
'ai.chat.mentionNoteSearch': '搜尋筆記…',
'ai.chat.mentionNoteEmpty': '沒有符合的筆記',
'ai.chat.mentionNoteUnavailable': '目前連線方式下,此 AI 無法讀取保險箱筆記。',
'ai.chat.mentionNoteTooMany': '這些筆記無法一起引用,請減少選擇。',
'ai.chat.mentionNoteInvalid': '無法附加「{{title}}」:筆記的識別碼無效。',
'ai.chat.untitledNote': '未命名筆記',
'ai.chat.menuUserSkills': '使用者 Skills',
'ai.chat.menuSlashCommands': '快捷指令',
'ai.chat.slashCommands': '快捷指令',
+22
View File
@@ -197,6 +197,12 @@ export const zhTWCoreMessages: Messages = {
'settings.appLock.validation.mismatch': '兩次輸入的新密碼不一致。',
'settings.appLock.validation.incorrect': '目前的鎖定密碼不正確。',
// Settings > System > Auto Launch
'settings.autoLaunch.title': '開機自動啟動',
'settings.autoLaunch.enabled': '開機時自動啟動',
'settings.autoLaunch.enabledDesc': '系統啟動時自動執行 Netcatty,並隱藏到系統匣。',
'settings.autoLaunch.unsupportedDesc': '目前平台不支援此功能,或處於開發環境。',
// Settings > System > Network Proxy
'settings.system.networkProxy.title': '網路代理',
'settings.system.networkProxy.description': '用於雲端同步與 AI 服務商的 HTTP(S) 代理。與 SSH 的 ProxyJump / ProxyCommand 設定無關。',
@@ -715,6 +721,7 @@ export const zhTWCoreMessages: Messages = {
// Vault hosts header/actions
'vault.hosts.search.placeholder': '查詢主機或 ssh user@hostname / ssh -p 2222 user@hostname…',
'vault.hosts.connect': '連線',
'vault.hosts.openSftp': '開啟 SFTP',
'vault.view.grid': '網格',
'vault.view.list': '列表',
'vault.view.tree': '樹形',
@@ -807,6 +814,10 @@ export const zhTWCoreMessages: Messages = {
'vault.import.mobaxterm.utf8Desc': '適用於已儲存或轉換為 UTF-8 的檔案。',
'vault.import.mobaxterm.gb18030': '中文 Windows',
'vault.import.mobaxterm.gb18030Desc': '適用於中文 Windows 上匯出的舊版檔案。',
'vault.import.mobaxterm.masterPassword': '主密碼(選填)',
'vault.import.mobaxterm.masterPasswordPlaceholder': '輸入 MobaXterm 主密碼',
'vault.import.mobaxterm.masterPasswordHint':
'匯入 .mobaconf 或 MobaXterm.ini 中已儲存的密碼時需要填寫。留空則只匯入工作階段。',
'vault.import.sshConfig.chooseMode': '選擇如何匯入你的 SSH config 檔案。',
'vault.import.sshConfig.modeQuestion': '你希望如何匯入?',
'vault.import.sshConfig.importOnly': '僅匯入',
@@ -896,6 +907,12 @@ export const zhTWCoreMessages: Messages = {
'sftp.bookmark.addGlobal': '+全域',
'sftp.bookmark.addGlobalTooltip': '儲存為全域收藏(所有主機共享)',
'sftp.bookmark.empty': '暫無收藏路徑',
'sftp.bookmark.removeConfirm': '刪除收藏 {path}?',
'sftp.bookmark.manage': '管理',
'sftp.bookmark.rename': '重新命名收藏',
'sftp.bookmark.done': '完成',
'sftp.bookmark.moveUp': '上移 {label}',
'sftp.bookmark.moveDown': '下移 {label}',
'sftp.columns.name': '名稱',
'sftp.columns.configure': '選擇顯示的欄位',
'sftp.columns.modified': '修改時間',
@@ -936,6 +953,10 @@ export const zhTWCoreMessages: Messages = {
'sftp.viewMode.tree': '樹形檢視',
'sftp.viewMode.switchToList': '切換到列表檢視',
'sftp.viewMode.switchToTree': '切換到樹形檢視',
'sftp.listDensity.compact': '緊湊列表',
'sftp.listDensity.comfortable': '寬鬆列表',
'sftp.listDensity.switchToCompact': '切換到緊湊列表',
'sftp.listDensity.switchToComfortable': '切換到寬鬆列表',
'sftp.tree.loadError': '載入目錄失敗',
'sftp.tree.loading': '載入中...',
'sftp.kind.folder': '資料夾',
@@ -1030,6 +1051,7 @@ export const zhTWCoreMessages: Messages = {
'sftp.encoding.utf8': 'UTF-8',
'sftp.encoding.gb18030': 'GB18030',
'sftp.goHome': '返回主目錄',
'sftp.goRoot': '回到根目錄',
'sftp.folderName': '資料夾名稱',
'sftp.folderName.placeholder': '輸入資料夾名稱',
'sftp.fileName': '檔案名稱',
@@ -718,6 +718,7 @@ export const zhTWTerminalMessages: Messages = {
'tabs.logPrefix': '日誌:',
'tabs.logLocal': '本機',
'tabs.copyTab': '複製分頁',
'tabs.duplicateSession': '複製工作階段',
'tabs.copyTabToNewWindow': '複製分頁到新視窗',
'tabs.copyTabToNewWindowFailed': '無法在新視窗開啟分頁',
'tabs.closeOthers': '關閉其他分頁',
+8
View File
@@ -132,6 +132,11 @@ export const zhTWVaultMessages: Messages = {
'hostDetails.section.address': '位址',
'hostDetails.hostname.placeholder': 'IP 或 主機名',
'hostDetails.section.general': '通用',
'hostDetails.os.title': '作業系統',
'hostDetails.os.desc': '用於環境資訊和配合對應的命令列行為。',
'hostDetails.os.linux': 'Linux',
'hostDetails.os.windows': 'Windows',
'hostDetails.os.macos': 'macOS',
'hostDetails.section.sftp': 'SFTP 設定',
'hostDetails.sftp.fileProtocol': '檔案協定',
'hostDetails.sftp.fileProtocol.desc': '自動優先 SFTP;當伺服器無 SFTP 子系統時改用 SCP 模式(如部分 NAS)。僅支援 SCP 的主機可強制 SCP。',
@@ -519,6 +524,9 @@ export const zhTWVaultMessages: Messages = {
'terminal.dragDrop.notConnected': '無法拖放檔案 - 終端未連線',
'terminal.dragDrop.errorTitle': '拖放錯誤',
'terminal.dragDrop.errorMessage': '處理拖放檔案失敗',
'terminal.dragDrop.destinationUnknown': '無法確定目前終端目錄。請啟用目錄追蹤,或先開啟 SFTP 選擇上傳目錄。',
'terminal.dragDrop.uploadCancelled': '終端連線已變更或無法繼續重用,本次上傳已取消。請重新連線後再次拖放檔案。',
'terminal.dragDrop.needsSudoElevation': '目前目錄登入使用者無法寫入。請在主機設定中開啟 Sudo 提權,或回到使用者目錄後再拖放。',
'terminal.search.placeholder': '搜尋…',
'terminal.search.noResults': '無結果',
'terminal.search.prevMatch': '上一個相符 (Shift+Enter)',
+464
View File
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { AISession } from '../../infrastructure/ai/types';
import {
cleanupClosedTerminalSessions,
cleanupDeletedAIChatSessions,
@@ -47,3 +48,466 @@ test('orphan cleanup keeps durable Catty output while explicit deletion removes
});
}
});
function makeSession(id: string, updatedAt: number, messages: unknown[]): AISession {
return {
id,
title: id,
agentId: 'agent',
scope: { type: 'terminal', targetId: 't' },
messages: messages as never,
createdAt: updatedAt,
updatedAt,
} as never;
}
test('serializeSessionsForStorage strips oldest ciphertext before dropping visible sessions', async () => {
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
const ciphertext = 'gAAAA'.repeat(50000); // ~250 KB of ciphertext per message
const messages = () => [{
id: 'm',
role: 'assistant' as const,
content: 'hello',
timestamp: 0,
providerContinuation: {
reasoningParts: [{ text: '', providerOptions: { openai: { reasoningEncryptedContent: ciphertext } } }],
},
}];
const sessions = [
makeSession('newest', 3, messages()),
makeSession('older', 2, messages()),
makeSession('oldest', 1, messages()),
];
const hasCiphertext = (result: { sessions: AISession[] }) =>
result.sessions.some(s => s.messages.some(m =>
m.providerContinuation?.reasoningParts?.some(p => typeof p.providerOptions?.openai?.reasoningEncryptedContent === 'string')));
// Removing replay-only ciphertext from the oldest session fits the budget,
// so every visible chat remains available after restart.
const withOldestCiphertextStripped = serializeSessionsForStorage(sessions, 600 * 1024);
assert.deepEqual(
withOldestCiphertextStripped.sessions.map(s => s.id),
['newest', 'older', 'oldest'],
);
assert.equal(hasCiphertext(withOldestCiphertextStripped), true);
assert.ok(withOldestCiphertextStripped.json.length <= 600 * 1024);
// Tight budget that even a single session exceeds: ciphertext stripped but
// the visible conversation content survives.
const withCiphertextStripped = serializeSessionsForStorage(sessions, 220 * 1024);
assert.ok(withCiphertextStripped.json.length <= 220 * 1024);
assert.equal(hasCiphertext(withCiphertextStripped), false);
assert.deepEqual(withCiphertextStripped.sessions.map(s => s.id), ['newest', 'older', 'oldest']);
assert.equal(withCiphertextStripped.sessions[0].messages[0].content, 'hello');
});
test('serializeSessionsForStorage keeps new ciphertext when an oversized old chat must be dropped', async () => {
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
const ciphertext = 'gAAAA'.repeat(20 * 1024); // ~100 KB
const newest = makeSession('newest', 2, [{
id: 'new-message',
role: 'assistant',
content: 'new visible chat',
timestamp: 2,
providerContinuation: {
source: { providerConfigId: 'p', providerType: 'openai', modelId: 'm' },
reasoningParts: [{ text: '', providerOptions: { openai: { reasoningEncryptedContent: ciphertext } } }],
},
}]);
const oversizedOld = makeSession('oldest', 1, [{
id: 'old-message',
role: 'user',
content: 'x'.repeat(300 * 1024),
timestamp: 1,
}]);
const result = serializeSessionsForStorage([oversizedOld, newest], 200 * 1024);
assert.deepEqual(result.sessions.map(session => session.id), ['newest']);
assert.equal(
result.sessions[0].messages[0].providerContinuation
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
ciphertext,
);
assert.ok(result.json.length <= 200 * 1024);
});
test('serializeSessionsForStorage prioritizes a usable newest chat over older visible history', async () => {
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
const ciphertext = 'gAAAA'.repeat(20 * 1024); // ~100 KB
const newest = makeSession('newest', 2, [{
id: 'new-message',
role: 'assistant',
content: 'new visible chat',
timestamp: 2,
providerContinuation: {
source: { providerConfigId: 'p', providerType: 'openai', modelId: 'm' },
reasoningParts: [{ text: '', providerOptions: { openai: { reasoningEncryptedContent: ciphertext } } }],
},
}]);
const olderVisible = makeSession('oldest', 1, [{
id: 'old-message',
role: 'user',
content: 'x'.repeat(100 * 1024),
timestamp: 1,
}]);
const result = serializeSessionsForStorage([olderVisible, newest], 150 * 1024);
assert.deepEqual(result.sessions.map(session => session.id), ['newest']);
assert.equal(
result.sessions[0].messages[0].providerContinuation
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
ciphertext,
);
assert.ok(result.json.length <= 150 * 1024);
});
test('serializeSessionsForStorage keeps the newest replayable turn in one oversized chat', async () => {
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
const ciphertext = 'gAAAA'.repeat(20 * 1024); // ~100 KB per reasoning turn
const reasoning = (itemId: string) => ({
source: { providerConfigId: 'p', providerType: 'openai', modelId: 'm' },
reasoningParts: [{
text: '',
providerOptions: { openai: { itemId, reasoningEncryptedContent: ciphertext } },
}],
});
const session = makeSession('active', 1, [
{
id: 'assistant-old',
role: 'assistant',
content: 'old turn',
timestamp: 1,
providerContinuation: reasoning('rs_old'),
toolCalls: [{ id: 'call-old', name: 'terminal_execute', arguments: { command: 'pwd' } }],
},
{
id: 'tool-old',
role: 'tool',
content: '',
timestamp: 2,
toolResults: [{ toolCallId: 'call-old', content: '/tmp' }],
},
{
id: 'assistant-new',
role: 'assistant',
content: 'new turn',
timestamp: 3,
providerContinuation: reasoning('rs_new'),
toolCalls: [{ id: 'call-new', name: 'terminal_execute', arguments: { command: 'ls' } }],
},
{
id: 'tool-new',
role: 'tool',
content: '',
timestamp: 4,
toolResults: [{ toolCallId: 'call-new', content: 'file.txt' }],
},
]);
const result = serializeSessionsForStorage([session], 150 * 1024);
assert.ok(result.json.length <= 150 * 1024);
assert.deepEqual(result.sessions[0].messages.map(message => message.id), [
'assistant-old',
'tool-old',
'assistant-new',
'tool-new',
]);
assert.equal(
result.sessions[0].messages[0].providerContinuation
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
undefined,
);
assert.equal(
result.sessions[0].messages[2].providerContinuation
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
ciphertext,
);
});
test('serializeSessionsForStorage drops compacted ciphertext before protecting the newest chat', async () => {
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
const ciphertext = 'gAAAA'.repeat(20 * 1024); // ~100 KB per reasoning turn
const compactedMessages = Array.from({ length: 17 }, (_, index) => ({
id: `compacted-${index}`,
role: 'assistant' as const,
content: `compacted turn ${index}`,
timestamp: index,
providerContinuation: {
reasoningParts: [{
text: '',
providerOptions: {
openai: { itemId: `rs_${index}`, reasoningEncryptedContent: ciphertext },
},
}],
},
}));
const newest = {
...makeSession('newest', 2, [
...compactedMessages,
{
id: 'recent',
role: 'assistant',
content: 'recent turn',
timestamp: 18,
providerContinuation: {
reasoningParts: [{
text: '',
providerOptions: {
openai: { itemId: 'rs_recent', reasoningEncryptedContent: ciphertext },
},
}],
},
},
]),
contextCompaction: {
summary: 'The first 17 turns were summarized.',
compactedMessageCount: 17,
},
};
const olderVisible = makeSession('older', 1, [{
id: 'older-visible',
role: 'user',
content: 'x'.repeat(600 * 1024),
timestamp: 1,
}]);
const result = serializeSessionsForStorage([olderVisible, newest]);
assert.deepEqual(result.sessions.map(session => session.id), ['newest', 'older']);
assert.ok(result.json.length <= 2 * 1024 * 1024);
const persistedNewest = result.sessions[0];
for (const message of persistedNewest.messages.slice(0, 17)) {
assert.equal(
message.providerContinuation
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
undefined,
);
}
assert.equal(
persistedNewest.messages[17].providerContinuation
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
ciphertext,
);
});
test('serializeSessionsForStorage shifts the compaction boundary when trimming old messages', async () => {
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
const ciphertext = 'enc'.repeat(100);
const messages = Array.from({ length: 250 }, (_, index) => ({
id: `message-${index}`,
role: 'assistant' as const,
content: `turn ${index}`,
timestamp: index,
providerContinuation: {
reasoningParts: [{
text: '',
providerOptions: {
openai: { itemId: `rs_${index}`, reasoningEncryptedContent: ciphertext },
},
}],
},
}));
const session = {
...makeSession('trimmed', 1, messages),
contextCompaction: {
summary: 'The first 100 messages were summarized.',
compactedMessageCount: 100,
},
};
const result = serializeSessionsForStorage([session]);
const persisted = result.sessions[0];
assert.equal(persisted.messages.length, 200);
assert.equal(persisted.messages[0].id, 'message-50');
assert.equal(persisted.contextCompaction?.compactedMessageCount, 50);
assert.equal(
persisted.messages[49].providerContinuation
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
undefined,
);
assert.equal(
persisted.messages[50].providerContinuation
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
ciphertext,
);
});
test('serializeSessionsForStorage does not trim between a tool call and its result', async () => {
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
const messages = Array.from({ length: 250 }, (_, index) => ({
id: `message-${index}`,
role: 'user' as const,
content: `turn ${index}`,
timestamp: index,
}));
messages[49] = {
id: 'assistant-call',
role: 'assistant',
content: 'Running it.',
timestamp: 49,
toolCalls: [{ id: 'call-1', name: 'terminal_execute', arguments: { command: 'pwd' } }],
} as never;
messages[50] = {
id: 'tool-result',
role: 'tool',
content: '',
timestamp: 50,
toolResults: [{ toolCallId: 'call-1', content: '/tmp' }],
} as never;
const session = {
...makeSession('tool-boundary', 1, messages),
contextCompaction: {
summary: 'The first 10 messages were summarized.',
compactedMessageCount: 10,
},
};
const result = serializeSessionsForStorage([session]);
const persisted = result.sessions[0];
assert.equal(persisted.messages.length, 201);
assert.equal(persisted.messages[0].id, 'assistant-call');
assert.equal(persisted.messages[1].id, 'tool-result');
assert.equal(persisted.contextCompaction?.compactedMessageCount, 0);
});
test('writeSessionsForStorage retries below nominal budgets after a quota failure', async () => {
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
const writes: string[] = [];
let stored: string | undefined;
const previousLocalStorage = globalThis.localStorage;
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: () => null,
setItem: (_key: string, value: string) => {
writes.push(value);
if (value.length > 350 * 1024) {
throw new DOMException('quota exceeded', 'QuotaExceededError');
}
stored = value;
},
removeItem: () => {},
},
});
try {
// The retry loop must attempt progressively smaller payloads (here the
// ciphertext stripping at the tighter budget) before reporting failure.
const huge = 'x'.repeat(260 * 1024);
const ciphertext = 'gAAAA'.repeat(30 * 1024); // ~150 KB
const sessions = [makeSession('s', 1, [{
id: 'm',
role: 'user' as const,
content: huge,
timestamp: 0,
providerContinuation: {
reasoningParts: [{ text: '', providerOptions: { openai: { reasoningEncryptedContent: ciphertext } } }],
},
}])];
assert.equal(writeSessionsForStorage(sessions), true);
assert.equal(writes.length, 2);
assert.equal(stored, writes[1]);
assert.ok(writes[writes.length - 1].length < writes[0].length);
} finally {
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: previousLocalStorage,
});
}
});
test('writeSessionsForStorage reduces several small sessions to fit a sub-512 KB quota', async () => {
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
const writes: string[] = [];
let stored: string | undefined;
const previousLocalStorage = globalThis.localStorage;
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: () => null,
setItem: (_key: string, value: string) => {
writes.push(value);
if (value.length > 300 * 1024) {
throw new DOMException('quota exceeded', 'QuotaExceededError');
}
stored = value;
},
removeItem: () => {},
},
});
try {
const sessions = Array.from({ length: 10 }, (_, index) => makeSession(
`session-${index}`,
10 - index,
[{
id: `message-${index}`,
role: 'user' as const,
content: 'x'.repeat(40 * 1024),
timestamp: index,
}],
));
assert.equal(writeSessionsForStorage(sessions), true);
assert.ok(writes[0].length > 300 * 1024);
assert.ok(stored);
assert.ok(stored.length <= 300 * 1024);
assert.ok((JSON.parse(stored) as AISession[]).length < sessions.length);
} finally {
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: previousLocalStorage,
});
}
});
test('writeSessionsForStorage makes a final attempt with only the newest session', async () => {
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
const writes: string[] = [];
let stored: string | undefined;
const previousLocalStorage = globalThis.localStorage;
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: () => null,
setItem: (_key: string, value: string) => {
writes.push(value);
if (value.length > 50 * 1024) {
throw new DOMException('quota exceeded', 'QuotaExceededError');
}
stored = value;
},
removeItem: () => {},
},
});
try {
const sessions = Array.from({ length: 10 }, (_, index) => makeSession(
`session-${index}`,
10 - index,
[{
id: `message-${index}`,
role: 'user' as const,
content: 'x'.repeat(40 * 1024),
timestamp: index,
}],
));
assert.equal(writeSessionsForStorage(sessions), true);
assert.equal(writes.length, 6);
assert.ok(stored);
const persisted = JSON.parse(stored) as AISession[];
assert.deepEqual(persisted.map(session => session.id), ['session-0']);
assert.ok(stored.length <= 50 * 1024);
} finally {
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: previousLocalStorage,
});
}
});
+256 -5
View File
@@ -10,6 +10,8 @@ import type {
AIPermissionMode,
AIToolIntegrationMode,
} from '../../infrastructure/ai/types';
import type { ProviderContinuationOptions } from '../../infrastructure/ai/providerContinuation';
import { findSafeChatMessageCompactionSplitIndex } from '../../infrastructure/ai/contextCompaction';
import {
bumpDraftMutationVersionState,
bumpDraftUploadGenerationState,
@@ -112,10 +114,7 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
if (nextSessionCleanup.sessions !== currentSessions) {
setLatestAISessionsSnapshot(nextSessionCleanup.sessions);
localStorageAdapter.write(
STORAGE_KEY_AI_SESSIONS,
pruneSessionsForStorage(nextSessionCleanup.sessions),
);
writeSessionsForStorage(nextSessionCleanup.sessions);
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
}
@@ -181,6 +180,91 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
const MAX_STORED_SESSIONS = 50;
/** Maximum number of messages per session when persisting to localStorage. */
const MAX_SESSION_MESSAGES = 200;
/**
* Byte budget for the serialized sessions JSON. The localStorage quota is
* ~5-10 MB across all keys, and Responses reasoning ciphertext can add tens
* of KB per turn, so keep the sessions blob well under the quota with
* headroom for the rest of the app's storage keys.
*/
const MAX_SESSIONS_JSON_BYTES = 2 * 1024 * 1024;
/** Retry budgets used when the primary budget still fails to persist. */
const RETRY_SESSIONS_JSON_BYTES = [
1024 * 1024,
512 * 1024,
256 * 1024,
128 * 1024,
] as const;
/**
* Remove `reasoningEncryptedContent` ciphertext from a message's persisted
* continuation. The ciphertext exists so stateless Responses turns can replay
* prior reasoning items; it is also by far the largest per-message payload.
* When storage pressure forces it, dropping the ciphertext keeps the visible
* conversation intact at the cost of reasoning replay for affected messages.
*/
function stripReasoningEncryptedContent(
options: ProviderContinuationOptions,
): ProviderContinuationOptions | undefined {
const hasCiphertext = Object.values(options).some(
providerOptions => typeof providerOptions?.reasoningEncryptedContent === 'string',
);
if (!hasCiphertext) return options;
const stripped: ProviderContinuationOptions = {};
for (const [provider, providerOptions] of Object.entries(options)) {
const rest = { ...providerOptions };
delete rest.reasoningEncryptedContent;
if (Object.keys(rest).length) stripped[provider] = rest;
}
return Object.keys(stripped).length ? stripped : undefined;
}
function stripEncryptedReasoningFromMessage(message: AISession['messages'][number]) {
const continuation = message.providerContinuation;
if (!continuation?.reasoningParts) return message;
let changed = false;
const parts = continuation.reasoningParts.map(part => {
if (!part.providerOptions) return part;
const providerOptions = stripReasoningEncryptedContent(part.providerOptions);
if (providerOptions === part.providerOptions) return part;
changed = true;
return providerOptions ? { text: part.text, providerOptions } : { text: part.text };
});
if (!changed) return message;
return {
...message,
providerContinuation: {
...continuation,
reasoningParts: parts,
},
};
}
function stripEncryptedReasoningFromSession(session: AISession): AISession {
let changed = false;
const messages = session.messages.map(message => {
const stripped = stripEncryptedReasoningFromMessage(message);
if (stripped !== message) changed = true;
return stripped;
});
return changed ? { ...session, messages } : session;
}
function stripCompactedEncryptedReasoningFromSession(session: AISession): AISession {
const compactedMessageCount = Math.min(
session.messages.length,
Math.max(0, session.contextCompaction?.compactedMessageCount ?? 0),
);
if (compactedMessageCount === 0) return session;
let changed = false;
const messages = session.messages.map((message, index) => {
if (index >= compactedMessageCount) return message;
const stripped = stripEncryptedReasoningFromMessage(message);
if (stripped !== message) changed = true;
return stripped;
});
return changed ? { ...session, messages } : session;
}
/**
* Prune sessions before writing to localStorage to prevent hitting the
@@ -196,12 +280,179 @@ export function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
const limited = sorted.slice(0, MAX_STORED_SESSIONS);
return limited.map(s => {
if (s.messages.length > MAX_SESSION_MESSAGES) {
return { ...s, messages: s.messages.slice(-MAX_SESSION_MESSAGES) };
// Do not start the retained tail with a tool result whose assistant call
// was just trimmed. Reuse the same tool-safe boundary logic as context
// compaction, even if that retains a few more than the nominal cap.
const removedMessageCount = findSafeChatMessageCompactionSplitIndex(
s.messages,
MAX_SESSION_MESSAGES,
);
const contextCompaction = s.contextCompaction
? {
...s.contextCompaction,
compactedMessageCount: Math.max(
0,
s.contextCompaction.compactedMessageCount - removedMessageCount,
),
}
: undefined;
return {
...s,
messages: s.messages.slice(removedMessageCount),
...(contextCompaction ? { contextCompaction } : {}),
};
}
return s;
});
}
/**
* Serialize sessions for localStorage under a byte budget, escalating pruning
* as needed. Returns the JSON to persist plus the (possibly) further-pruned
* sessions that JSON represents.
*/
export function serializeSessionsForStorage(
sessions: AISession[],
budgetBytes: number = MAX_SESSIONS_JSON_BYTES,
): { json: string; sessions: AISession[] } {
const serialized = pruneSessionsForStorage(sessions).map(rawSession => {
// These messages are replaced by the durable summary before every later
// request, so their replay ciphertext can never be used again. Remove it
// before deciding whether the newest session deserves full protection;
// otherwise dead metadata can evict other visible conversations.
const session = stripCompactedEncryptedReasoningFromSession(rawSession);
const ciphertextMessages = session.messages.flatMap((message, index) => {
const strippedMessage = stripEncryptedReasoningFromMessage(message);
if (strippedMessage === message) return [];
return [{
index,
strippedMessage,
jsonLengthDelta: JSON.stringify(strippedMessage).length - JSON.stringify(message).length,
}];
});
const strippedSession = stripEncryptedReasoningFromSession(session);
const json = JSON.stringify(session);
return {
session,
json,
strippedSession,
strippedJson: strippedSession === session
? json
: JSON.stringify(strippedSession),
ciphertextMessages,
};
});
// Preserve the newest session's full continuation whenever it can fit by
// itself. That session is the one the user is most likely continuing now;
// older visible history must not make its next tool turn unreplayable.
const protectNewestContinuation = serialized.length > 0
&& serialized[0].json.length + 2 <= budgetBytes;
// Determine how many sessions can fit using the smallest representation for
// older sessions while reserving the newest session's full representation
// when possible. This also prevents an old, oversized visible chat that must
// be dropped anyway from causing newer replay ciphertext to be stripped.
let minimalLength = 2
+ serialized.reduce((total, entry, index) => (
total + (protectNewestContinuation && index === 0 ? entry.json.length : entry.strippedJson.length)
), 0)
+ Math.max(0, serialized.length - 1);
while (minimalLength > budgetBytes && serialized.length > 1) {
const removed = serialized.pop();
if (!removed) break;
minimalLength -= removed.strippedJson.length + 1;
}
// Then keep full continuation data for the retained sessions and remove
// replay-only ciphertext from the oldest retained sessions only as needed,
// never touching the protected newest session. Within one session, remove
// it one message at a time from oldest to newest so a long active chat can
// retain its most recent replayable tool exchange when that still fits.
let jsonLength = 2 + serialized.reduce((total, entry) => total + entry.json.length, 0)
+ Math.max(0, serialized.length - 1);
const firstStrippableIndex = protectNewestContinuation ? 1 : 0;
for (
let index = serialized.length - 1;
index >= firstStrippableIndex && jsonLength > budgetBytes;
index -= 1
) {
const current = serialized[index];
let strippedMessageCount = 0;
while (jsonLength > budgetBytes && strippedMessageCount < current.ciphertextMessages.length) {
jsonLength += current.ciphertextMessages[strippedMessageCount].jsonLengthDelta;
strippedMessageCount += 1;
}
if (strippedMessageCount > 0) {
const strippedMessagesByIndex = new Map(
current.ciphertextMessages
.slice(0, strippedMessageCount)
.map(entry => [entry.index, entry.strippedMessage] as const),
);
const nextSession = {
...current.session,
messages: current.session.messages.map((message, messageIndex) => (
strippedMessagesByIndex.get(messageIndex) ?? message
)),
};
const nextJson = JSON.stringify(nextSession);
const projectedJsonLength = current.json.length
+ current.ciphertextMessages
.slice(0, strippedMessageCount)
.reduce((total, entry) => total + entry.jsonLengthDelta, 0);
jsonLength += nextJson.length - projectedJsonLength;
serialized[index] = {
...current,
session: nextSession,
json: nextJson,
};
}
}
return {
json: `[${serialized.map(entry => entry.json).join(',')}]`,
sessions: serialized.map(entry => entry.session),
};
}
/**
* Persist sessions to localStorage with byte-budgeted pruning and retries.
* Returns true when the write succeeded; a false result means the payload
* could not be persisted even after escalation (it stays memory-only).
*/
export function writeSessionsForStorage(sessions: AISession[]): boolean {
let previousLength = Number.POSITIVE_INFINITY;
for (const configuredBudget of [MAX_SESSIONS_JSON_BYTES, ...RETRY_SESSIONS_JSON_BYTES]) {
// A real quota failure means the next attempt must be smaller even when
// the failed payload was already below the nominal retry budget. Reduce
// materially on each bounded retry so several small sessions can be
// removed before the retry sequence is exhausted.
const reducedBudget = Number.isFinite(previousLength)
? Math.floor(previousLength * 0.75)
: configuredBudget;
const budget = Math.min(configuredBudget, reducedBudget);
if (budget < 2) break;
const candidate = serializeSessionsForStorage(sessions, budget);
if (candidate.json.length >= previousLength) continue;
if (localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, candidate.json)) return true;
previousLength = candidate.json.length;
}
// Last resort: attempt the smallest representation this serializer can
// produce (the newest session with replay-only ciphertext removed). A very
// small amount of shared quota may still be enough to preserve that chat.
const minimalCandidate = serializeSessionsForStorage(sessions, 0);
if (
minimalCandidate.json.length < previousLength
&& localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, minimalCandidate.json)
) {
return true;
}
console.warn(
'[AIState] Failed to persist AI sessions within the storage quota; recent chat history may not survive a restart.',
);
return false;
}
export let latestAISessionsSnapshot: AISession[] | null = null;
export let latestAIActiveSessionMapSnapshot: Record<string, string | null> | null = null;
export let latestAIDraftsByScopeSnapshot: DraftsByScope | null = null;
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { migrateLegacyCommandBlocklist } from '../../domain/commandBlocklist';
import commandBlocklistTable from '../../lib/commandBlocklist.json';
const legacyDefaults = [
...commandBlocklistTable.common,
...commandBlocklistTable.posixNative,
...commandBlocklistTable.posix,
];
test('untouched legacy defaults gain the PowerShell group once', () => {
assert.deepEqual(
migrateLegacyCommandBlocklist(legacyDefaults),
[...legacyDefaults, ...commandBlocklistTable.powershell],
);
});
test('customized legacy settings preserve removed defaults', () => {
const customized = legacyDefaults.slice(1);
assert.deepEqual(migrateLegacyCommandBlocklist(customized), customized);
assert.deepEqual(migrateLegacyCommandBlocklist([]), []);
});
test('legacy settings keep user additions while gaining new defaults', () => {
const customized = [...legacyDefaults, 'company-forbidden-command'];
assert.deepEqual(
migrateLegacyCommandBlocklist(customized),
[...customized, ...commandBlocklistTable.powershell],
);
});
test('a list that already contains a PowerShell default is left unchanged', () => {
const alreadyUpgraded = [...legacyDefaults, commandBlocklistTable.powershell[0]];
assert.deepEqual(migrateLegacyCommandBlocklist(alreadyUpgraded), alreadyUpgraded);
});
@@ -0,0 +1,26 @@
import { migrateLegacyCommandBlocklist } from '../../domain/commandBlocklist';
import { DEFAULT_COMMAND_BLOCKLIST } from '../../infrastructure/ai/types';
import { STORAGE_KEY_AI_COMMAND_BLOCKLIST } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
export function persistCommandBlocklistSetting(blocklist: string[]): boolean {
return localStorageAdapter.write(STORAGE_KEY_AI_COMMAND_BLOCKLIST, blocklist);
}
export function readCommandBlocklistSetting(): string[] {
const stored = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
if (stored != null && !Array.isArray(stored)) {
return [...DEFAULT_COMMAND_BLOCKLIST];
}
const current = stored ?? [...DEFAULT_COMMAND_BLOCKLIST];
const migrated = stored == null ? current : migrateLegacyCommandBlocklist(current);
if (
stored == null
|| migrated.length !== current.length
|| migrated.some((pattern, index) => pattern !== current[index])
) {
persistCommandBlocklistSetting(migrated);
}
return migrated;
}
@@ -68,6 +68,9 @@ export type SessionSnapshotActions = Pick<
| 'reorderTabs'
| 'toggleBroadcast'
| 'isBroadcastEnabled'
| 'toggleGlobalBroadcast'
| 'isGlobalBroadcastEnabled'
| 'canUseGlobalBroadcast'
| 'copySession'
| 'copyWorkspace'
| 'createSessionFromCloneSource'
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
/**
* useSettingsIpcSync is a React hook (useEffect-driven); without
* react-test-renderer available in this environment we can't mount it and
* assert on live cross-window broadcast behavior. These structural checks
* instead verify the source wiring end-to-end: every other window's
* useSettingsState instance (mounted via AppLockGate, even though the
* auto-launch toggle only ever renders in the Settings window) must consume
* the STORAGE_KEY_AUTO_LAUNCH_ENABLED broadcast, matching the established
* pattern used by every other synchronized boolean setting in this file.
*/
test('settingsIpcSync handles the auto-launch broadcast key like other synced booleans', () => {
const source = fs.readFileSync(new URL('./settingsIpcSync.ts', import.meta.url), 'utf8');
assert.match(
source,
/STORAGE_KEY_AUTO_LAUNCH_ENABLED/,
'must import the storage key',
);
assert.match(
source,
/if \(key === STORAGE_KEY_AUTO_LAUNCH_ENABLED && typeof value === 'boolean'\) \{\s*[\s\S]*?setAutoLaunchEnabled\(/,
'must apply the broadcast value to local state, same as e.g. STORAGE_KEY_GLOBAL_HOTKEY_ENABLED',
);
assert.match(
source,
/setAutoLaunchEnabled: Dispatch<SetStateAction<boolean>>/,
'the setter prop must be declared on the params interface',
);
// The dependency array of the effect must list the setter, or the
// listener could close over a stale setter reference after a re-render.
const effectStart = source.indexOf('useEffect(() => {');
const depsStart = source.indexOf('}, [', effectStart);
assert.ok(effectStart > -1 && depsStart > effectStart, 'expected to find the sync effect and its deps array');
const depsSlice = source.slice(depsStart, source.indexOf(']);', depsStart));
assert.match(depsSlice, /setAutoLaunchEnabled/, 'setAutoLaunchEnabled must be listed in the effect deps array');
});
test('useSettingsState wires the real autoLaunchEnabled setter into useSettingsIpcSync (not a no-op)', () => {
const source = fs.readFileSync(new URL('./useSettingsState.ts', import.meta.url), 'utf8');
const hookCallStart = source.indexOf('useSettingsIpcSync({');
assert.notEqual(hookCallStart, -1, 'expected to find the useSettingsIpcSync(...) call');
const hookCallEnd = source.indexOf('});', hookCallStart);
const callSlice = source.slice(hookCallStart, hookCallEnd);
assert.match(
callSlice,
/setAutoLaunchEnabled,/,
'useSettingsIpcSync must receive the real setAutoLaunchEnabled state setter, ' +
'or a broadcast from another window silently does nothing in this one',
);
});
+11
View File
@@ -4,6 +4,7 @@ import { parseCustomKeyBindingsStorageRecord } from '../../domain/customKeyBindi
import { resolveSupportedLocale } from '../../infrastructure/config/i18n';
import {
STORAGE_KEY_ACCENT_MODE,
STORAGE_KEY_AUTO_LAUNCH_ENABLED,
STORAGE_KEY_AUTO_UPDATE_ENABLED,
STORAGE_KEY_COLOR,
STORAGE_KEY_CUSTOM_CSS,
@@ -93,6 +94,7 @@ interface UseSettingsIpcSyncParams {
setWindowOpacity: (raw: unknown) => void;
setAppIconVariant: Dispatch<SetStateAction<AppIconVariant>>;
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
setAutoLaunchEnabled: Dispatch<SetStateAction<boolean>>;
setHttpNetworkProxy: Dispatch<SetStateAction<HttpNetworkProxySettings>>;
setSftpAutoOpenSidebar: Dispatch<SetStateAction<boolean>>;
setSftpFollowTerminalCwd: Dispatch<SetStateAction<boolean>>;
@@ -138,6 +140,7 @@ export function useSettingsIpcSync({
setWindowOpacity,
setAppIconVariant,
setAutoUpdateEnabled,
setAutoLaunchEnabled,
setHttpNetworkProxy,
setSftpAutoOpenSidebar,
setSftpFollowTerminalCwd,
@@ -270,6 +273,13 @@ export function useSettingsIpcSync({
if (key === STORAGE_KEY_AUTO_UPDATE_ENABLED && typeof value === 'boolean') {
setAutoUpdateEnabled((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_AUTO_LAUNCH_ENABLED && typeof value === 'boolean') {
// Peer windows (main, tray panel, terminal popups) all mount
// useSettingsState via AppLockGate even though the toggle is only
// ever rendered in the Settings window — keep their in-memory copy
// in sync so it's not stale if anything reads it later.
setAutoLaunchEnabled((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_HTTP_NETWORK_PROXY) {
const next = normalizeHttpNetworkProxySettings(value);
setHttpNetworkProxy((prev) => (
@@ -330,6 +340,7 @@ export function useSettingsIpcSync({
applyIncomingCustomKeyBindings,
mergeIncomingTerminalSettings,
setAutoUpdateEnabled,
setAutoLaunchEnabled,
setHttpNetworkProxy,
setEditorWordWrapState,
setFollowAppTerminalThemeState,
@@ -0,0 +1,33 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
createSftpBookmark,
moveSftpBookmark,
renameSftpBookmark,
} from "./bookmarkHelpers.ts";
test("moveSftpBookmark reorders by id and ignores unknown ids", () => {
const bookmarks = [
{ id: "a", path: "/a" },
{ id: "b", path: "/b" },
{ id: "c", path: "/c" },
];
assert.deepEqual(
moveSftpBookmark(bookmarks, "c", "a").map((item) => item.id),
["c", "a", "b"],
);
assert.equal(moveSftpBookmark(bookmarks, "missing", "a"), bookmarks);
assert.equal(moveSftpBookmark(bookmarks, "a", "a"), bookmarks);
});
test("renameSftpBookmark updates a trimmed label", () => {
const bookmarks = [
createSftpBookmark("/var/www"),
createSftpBookmark("/etc"),
];
const renamed = renameSftpBookmark(bookmarks, bookmarks[0].id, " Web ");
assert.equal(renamed[0].label, "Web");
assert.equal(renamed[1].label, bookmarks[1].label);
assert.equal(renameSftpBookmark(bookmarks, bookmarks[0].id, " "), bookmarks);
});
+26
View File
@@ -21,3 +21,29 @@ export function createSftpBookmark(
...(global ? { global: true } : {}),
};
}
export function moveSftpBookmark<T extends { id: string }>(
bookmarks: T[],
fromId: string,
toId: string,
): T[] {
const from = bookmarks.findIndex((bookmark) => bookmark.id === fromId);
const to = bookmarks.findIndex((bookmark) => bookmark.id === toId);
if (from < 0 || to < 0 || from === to) return bookmarks;
const next = bookmarks.slice();
const [item] = next.splice(from, 1);
next.splice(to, 0, item);
return next;
}
export function renameSftpBookmark<T extends { id: string; label: string }>(
bookmarks: T[],
id: string,
label: string,
): T[] {
const nextLabel = label.trim();
if (!nextLabel) return bookmarks;
return bookmarks.map((bookmark) => (
bookmark.id === id ? { ...bookmark, label: nextLabel } : bookmark
));
}
@@ -39,6 +39,108 @@ const host = (id: string, label: string, hostname = label): Host => ({
protocol: "ssh",
} as Host);
for (const retainedStatus of [undefined, "interrupted", "failed", "paused"] as const) {
for (const newerPause of retainedStatus === "paused" ? [false, true] : [false]) {
const batchExistingIdentity = retainedStatus !== undefined;
test(`superseded folder child settles when its completion was compacted into the parent: retained=${retainedStatus ?? "none"}, newerPause=${newerPause}`, async (t) => {
const { sftpTransferCenterStore } = await import("../sftpTransferCenterStore");
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
});
const originalGet = netcattyBridge.get;
let abort = false;
let running: Promise<unknown> | undefined;
let childId: string | undefined;
const parent: TransferTask = {
id: "superseded-compacted-folder", fileName: "folder",
sourcePath: "/local/folder", targetPath: "/remote/folder",
sourceConnectionId: "local", targetConnectionId: "expired-sftp",
targetHostId: "h1", direction: "upload", status: "transferring",
totalBytes: 1, transferredBytes: 0, speed: 0, startTime: Date.now(),
isDirectory: true, progressMode: "files", reconnectRequired: true,
};
t.after(async () => {
abort = true;
await running;
netcattyBridge.get = originalGet;
sftpTransferCenterStore.patchTask(parent.id, { status: "completed" });
sftpTransferCenterStore.dismiss(parent.id);
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
resetDedicatedSessionOpenGateForTests();
});
const persisted: TransferTask = {
...parent, id: "retained-child", parentTaskId: parent.id, isDirectory: false,
sourcePath: "/local/folder/a.bin", targetPath: "/remote/folder/a.bin",
totalBytes: 10, sourceLastModified: 1, status: retainedStatus ?? "interrupted",
directoryEntryIndex: 0, directoryEntryIdentity: "a".repeat(64),
};
const { createDedicatedResumeChildUpdateBatcher } = await import("../../app/dedicatedResumeProgress");
// Exercise the real large-history batching branch with one relevant retained row.
const childBatcher = createDedicatedResumeChildUpdateBatcher({
getTaskCount: () => 4096,
hasTask: (id) => id === persisted.id,
upsertTasks: (updates) => sftpTransferCenterStore.upsertTasks(updates),
});
sftpTransferCenterStore.publishOwner("compacted-owner", [parent, ...(batchExistingIdentity ? [persisted] : [])]);
(netcattyBridge as { get: () => unknown }).get = () => ({
openSftp: async () => "dedicated-sftp", closeSftp: async () => {},
listLocalTree: async () => [{
localPath: "/local/folder/a.bin", relativePath: "a.bin", type: "file", size: 10, lastModified: 1,
}],
mkdirSftp: async () => {},
statLocal: async () => {
if (newerPause) sftpTransferCenterStore.patchTask(persisted.id, { status: "paused", lifecycleEpoch: 9 });
return { size: 10, lastModified: 1 };
},
startStreamTransfer: async (options: {
transferId: string; parentTaskId?: string; directoryEntryIndex?: number; directoryEntryIdentity?: string;
}) => {
childId = options.transferId;
// Main-process lifecycle events carry the stream's metadata, not queued UI updates.
sftpTransferCenterStore.ingestBackgroundEvent({
type: "started", transferId: childId, lifecycleEpoch: 0,
parentTaskId: options.parentTaskId,
directoryEntryIndex: options.directoryEntryIndex,
directoryEntryIdentity: options.directoryEntryIdentity,
});
// A newer same-id owner completes before this older invocation rejoins.
sftpTransferCenterStore.ingestBackgroundEvent({
type: "completed", transferId: childId, transferred: 10, totalBytes: 10, lifecycleEpoch: 0,
});
return { superseded: true };
},
});
running = resumeTransferWithDedicatedSession(parent, {
hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [],
}, undefined, {
children: batchExistingIdentity ? [persisted] : [], shouldAbort: () => abort,
onChildUpdate: (child) => batchExistingIdentity
? childBatcher.push(child)
: sftpTransferCenterStore.publishOwner("compacted-owner", [parent, child]),
});
const result = await Promise.race([
running,
new Promise<"still-waiting">((resolve) => setTimeout(() => resolve("still-waiting"), 450)),
]);
if (newerPause) {
assert.equal(result, "still-waiting", "a later pause must still hold fresh recovery");
assert.equal(childId, undefined, "newer paused child must not start");
assert.equal(sftpTransferCenterStore.getTask(persisted.id)?.lifecycleEpoch, 9);
return;
}
assert.notEqual(result, "still-waiting", "completed compacted child must not leave its folder waiting forever");
assert.ok(childId);
assert.equal(sftpTransferCenterStore.getTask(childId), undefined, "completed row was compacted");
assert.equal(sftpTransferCenterStore.getTask(parent.id)?.directoryResumeCheckpoint?.completedEntries, 1);
assert.notEqual(result, "still-waiting", "completed compacted child must not leave its folder waiting forever");
assert.equal((result as { success: boolean }).success, true);
});
}
}
test("resolveDirectoryResumeTargetRoot prefers staged replace path", () => {
assert.equal(
resolveDirectoryResumeTargetRoot({
@@ -97,10 +199,11 @@ test("persisted resume child lookup handles 50,000 retained rows in linear time"
assert.ok(Date.now() - startedAt < 2_000, "50k retained-child lookup should stay linear");
});
test("resolveHostForTransferEndpoint prefers id then label", () => {
test("resolveHostForTransferEndpoint requires recorded id and supports unique legacy names", () => {
const hosts = [host("id-1", "CI-Build-01", "ci-01.example")];
assert.equal(resolveHostForTransferEndpoint(hosts, "id-1", "other")?.id, "id-1");
assert.equal(resolveHostForTransferEndpoint(hosts, "missing", "CI-Build-01")?.id, "id-1");
assert.equal(resolveHostForTransferEndpoint(hosts, "missing", "CI-Build-01"), null);
assert.equal(resolveHostForTransferEndpoint(hosts, undefined, "CI-Build-01")?.id, "id-1");
assert.equal(resolveHostForTransferEndpoint(hosts, undefined, "ci-01.example")?.id, "id-1");
assert.equal(resolveHostForTransferEndpoint(hosts, "missing", "gone"), null);
});
@@ -1998,3 +2101,33 @@ test("corrupted replace-stage history cannot delete an unrelated directory", asy
resetDedicatedSessionOpenGateForTests();
}
});
for (const scenario of ["deleted-id", "ambiguous-legacy-label"] as const) {
test(`restart upload does not select a different server by ${scenario}`, async (t) => {
const originalGet = netcattyBridge.get;
t.after(() => { netcattyBridge.get = originalGet; });
const opened: string[] = [];
let uploads = 0;
(netcattyBridge as { get: () => unknown }).get = () => ({
openSftp: async (options: { hostname: string }) => { opened.push(options.hostname); return "new-channel"; },
closeSftp: async () => {},
statLocal: async () => ({ size: 100, lastModified: 123 }),
startStreamTransfer: async () => { uploads++; return { transferId: "wrong-host-resume" }; },
});
const hosts = scenario === "deleted-id"
? [host("different-id", "production", "different.example")]
: [host("first-id", "production", "first.example"), host("second-id", "production", "second.example")];
const result = await resumeTransferWithDedicatedSession({
id: "wrong-host-resume", fileName: "deployment.zip",
sourcePath: "/fixture/deployment.zip", targetPath: "/srv/deployment.zip",
sourceConnectionId: "local", targetConnectionId: "right-expired",
targetHostId: scenario === "deleted-id" ? "deleted-original-id" : undefined,
targetHostLabel: "production", direction: "upload", status: "interrupted",
totalBytes: 100, transferredBytes: 20, checkpointBytes: 20,
speed: 0, startTime: 1, isDirectory: false, reconnectRequired: true,
}, {hosts, keys: [], identities: []});
assert.equal(result.success, false, "resume must stop instead of selecting another endpoint by display name");
assert.deepEqual(opened, [], "must not authenticate to an unproven replacement host");
assert.equal(uploads, 0);
});
}
@@ -1,3 +1,4 @@
import { runTransferAndWaitForOwner } from "./waitForTransferOwner";
import type { Host, Identity, KnownHost, SSHKey, TerminalSettings, TransferTask } from "../../../domain/models";
import { validateTransferResumeSource } from "../../../domain/sftpTransferCenter";
import { STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY } from "../../../infrastructure/config/storageKeys";
@@ -95,8 +96,8 @@ export function resetDedicatedSessionOpenGateForTests(): void {
}
/**
* Resolve a vault host for a transfer endpoint. Prefer stable id; fall back to
* label/hostname when the id is stale after vault edits or older task records.
* Resolve the recorded endpoint, never a same-named replacement. Legacy tasks
* without an id may use label/hostname only when exactly one host matches.
*/
export function resolveHostForTransferEndpoint(
hosts: readonly Host[],
@@ -104,16 +105,16 @@ export function resolveHostForTransferEndpoint(
hostLabel?: string,
): Host | null {
if (hostId) {
const byId = hosts.find((host) => host.id === hostId);
if (byId) return byId;
return hosts.find((host) => host.id === hostId) ?? null;
}
const needle = (hostLabel || "").trim().toLowerCase();
if (!needle) return null;
return hosts.find((host) => {
const matches = hosts.filter((host) => {
const label = (host.label || "").trim().toLowerCase();
const hostname = (host.hostname || "").trim().toLowerCase();
return label === needle || hostname === needle;
}) ?? null;
});
return matches.length === 1 ? matches[0] : null;
}
export type OpenTransferSftpSessionOptions = {
@@ -1000,6 +1001,11 @@ async function resumeDirectoryWithDedicatedSession(
onProgress?: (progress: DedicatedResumeProgress) => void,
options?: DedicatedResumeOptions,
): Promise<DedicatedResumeResult> {
// Fresh recovery authorizes only pauses already present at its entry. Keep
// row references so a newer cross-window pause invalidates that permission.
const pausedAtResume = new Map(sftpTransferCenterStore.getSnapshot().tasks
.filter((child) => child.parentTaskId === parent.id && child.status === "paused")
.map((child) => [child.id, child]));
const bridge = netcattyBridge.get();
if (!bridge?.startStreamTransfer) {
return { success: false, error: "Transfer bridge unavailable" };
@@ -1247,8 +1253,13 @@ async function resumeDirectoryWithDedicatedSession(
if (options?.shouldAbort?.()) throw new Error("Transfer cancelled");
options?.onChildUpdate?.(childBase);
const streamResult = await bridge.startStreamTransfer!({
const streamResult = await runTransferAndWaitForOwner(childBase, () => bridge.startStreamTransfer!({
transferId: childId,
// Lifecycle events must carry the current identity even while
// large-history renderer child updates remain batched.
parentTaskId: childBase.parentTaskId,
directoryEntryIndex: childBase.directoryEntryIndex,
directoryEntryIdentity: childBase.directoryEntryIdentity,
sourcePath: file.sourcePath,
targetPath: file.targetPath,
sourceType,
@@ -1267,19 +1278,9 @@ async function resumeDirectoryWithDedicatedSession(
uploadCheckpointBytes: childBase.uploadCheckpointBytes,
sourceFingerprint: childBase.sourceFingerprint,
skipAdmission: true,
});
}), () => options?.shouldAbort?.() === true, pausedAtResume.get(childId));
if (streamResult?.superseded === true) {
for (;;) {
if (options?.shouldAbort?.()) throw new Error("Transfer cancelled");
const latest = sftpTransferCenterStore.getTask(childBase.id);
const status = latest?.status;
if (status === "completed") break;
if (status === "failed") throw new Error(latest?.error || "Transfer failed");
if (status === "cancelled") throw new Error("Transfer cancelled");
await new Promise((resolve) => setTimeout(resolve, 200));
}
} else if (streamResult?.error || streamResult?.cancelled) {
if (streamResult?.error || streamResult?.cancelled) {
throw new Error(streamResult.error || "Transfer cancelled");
}
@@ -51,6 +51,46 @@ function createHost(initial: TransferTask[], bridge?: TransferControlHost["getBr
};
}
test("an older pause response must not resume a newer pause", async (t) => {
t.after(resetTransferPauseLatchesForTests);
let finishFirstPause!: (value: { success: boolean; lifecycleEpoch: number }) => void;
let calls = 0;
let backendPaused = true;
const { host, getTasks } = createHost([makeTask("overlapping-pause")], () => ({
pauseTransfer: async () => {
backendPaused = true;
if (++calls === 1) return new Promise((resolve) => { finishFirstPause = resolve; });
return { success: true, lifecycleEpoch: 2 };
},
resumeTransfer: async () => {
backendPaused = false;
return { success: true, lifecycleEpoch: 3 };
},
}));
const first = softPauseTransfer(host, "overlapping-pause");
await softPauseTransfer(host, "overlapping-pause");
finishFirstPause({ success: true, lifecycleEpoch: 1 });
await first;
assert.equal(getTasks()[0].status, "paused");
assert.equal(backendPaused, true, "late pause acknowledgement must not restart file writes");
});
test("a delayed resume response must not repaint a newer pause", async (t) => {
t.after(resetTransferPauseLatchesForTests);
let finishResume!: (value: { success: boolean; lifecycleEpoch: number }) => void;
const { host, getTasks } = createHost([makeTask("resume-then-pause", "paused")], () => ({
resumeTransfer: () => new Promise((resolve) => { finishResume = resolve; }),
pauseTransfer: async () => ({ success: true, lifecycleEpoch: 3 }),
}));
const resume = softResumeTransfer(host, "resume-then-pause");
await softPauseTransfer(host, "resume-then-pause");
finishResume({ success: true, lifecycleEpoch: 2 });
await resume;
assert.equal(isTransferPauseLatched("resume-then-pause"), true);
assert.equal(getTasks()[0].status, "paused", "latest user intent must win over an older response");
assert.equal(getTasks()[0].lifecycleEpoch, 3);
});
test("softPauseTransfer latches and paints paused for a live directory walk without a panel", async () => {
resetTransferPauseLatchesForTests();
resetTransferWalkRegistryForTests();
@@ -245,3 +285,218 @@ test("directory softResume stamps bridge epoch only on successIds; queued siblin
resetTransferPauseLatchesForTests();
resetTransferWalkRegistryForTests();
});
for (const isDirectory of [false, true]) {
for (const newerLocalPause of [false, true]) {
test(`cross-window resume releases ${isDirectory ? "folder" : "file"} latches unless local pause is newer: ${newerLocalPause}`, async (t) => {
t.after(resetTransferPauseLatchesForTests);
let finish!: (result: { success: boolean; superseded: true; supersededBy: "resume" }) => void;
let pauses = 0;
const id = `remote-resume-${isDirectory}-${newerLocalPause}`;
const initial: TransferTask[] = [{ ...makeTask(id), isDirectory }];
if (isDirectory) initial.push({ ...makeTask(`${id}-child`), parentTaskId: id });
const { host, getTasks } = createHost(initial, () => ({
pauseTransfer: () => ++pauses === 1
? new Promise((resolve) => { finish = resolve; })
: Promise.resolve({ success: true, lifecycleEpoch: 9 }),
}));
const pending = softPauseTransfer(host, id);
host.setTasks(getTasks().map(task => isDirectory && task.id === id ? task : ({ ...task, status: "transferring", lifecycleEpoch: 8 })));
if (newerLocalPause) await softPauseTransfer(host, id);
finish({ success: false, superseded: true, supersededBy: "resume" });
await pending;
await new Promise((resolve) => setImmediate(resolve));
assert.equal(isTransferPauseLatched(id), newerLocalPause, "authoritative resume must release the old root pause barrier");
assert.equal(getTasks().find(task => task.id === id)?.status, newerLocalPause ? "paused" : "transferring");
if (isDirectory) assert.equal(isTransferPauseLatched(`${id}-child`), newerLocalPause);
});
}
}
for (const action of ["pause", "resume"] as const) {
test(`superseded cross-window ${action} preserves authoritative paused state`, async (t) => {
t.after(resetTransferPauseLatchesForTests);
let finish!: (result: { success: boolean; superseded: boolean }) => void;
const deferred = () => new Promise<{ success: boolean; superseded: boolean }>((resolve) => { finish = resolve; });
const { host, getTasks } = createHost([makeTask(`cross-window-${action}`, action === "pause" ? "transferring" : "paused")], () => ({ pauseTransfer: deferred, resumeTransfer: deferred }));
const id = getTasks()[0].id;
const operation = action === "pause" ? softPauseTransfer(host, id) : softResumeTransfer(host, id);
// A global event from another window changes lifecycle, not this window's local control epoch.
host.setTasks(getTasks().map(task => ({ ...task, status: "paused", lifecycleEpoch: 8 })));
finish({ success: false, superseded: true });
const result = await operation;
assert.equal(getTasks()[0].status, "paused");
assert.equal(getTasks()[0].lifecycleEpoch, 8);
if (action === "pause") assert.equal(isTransferPauseLatched(id), true);
else assert.deepEqual(result, { handled: true }, "obsolete response must not trigger dedicated recovery");
});
}
for (const isDirectory of [false, true]) {
test(`remote pause restores released ${isDirectory ? "folder" : "file"} barriers after stale resume`, async (t) => {
t.after(resetTransferPauseLatchesForTests);
const id = `remote-pause-${isDirectory}`;
const tasks: TransferTask[] = [{ ...makeTask(id, "paused"), isDirectory }];
if (isDirectory) tasks.push({ ...makeTask(`${id}-child`, "paused"), parentTaskId: id });
const { host, getTasks } = createHost(tasks, () => ({
resumeTransfer: async () => ({ success: false, superseded: true, supersededBy: "pause" }),
}));
assert.deepEqual(await softResumeTransfer(host, id), { handled: true });
assert.equal(isTransferPauseLatched(id), true);
if (isDirectory) assert.equal(isTransferPauseLatched(`${id}-child`), true);
assert.equal(getTasks()[0].status, "paused");
});
}
test("a child-only remote resume does not release the folder pause", async (t) => {
t.after(resetTransferPauseLatchesForTests);
const { host } = createHost([
{ ...makeTask("mixed-root"), isDirectory: true },
{ ...makeTask("mixed-one"), parentTaskId: "mixed-root" },
{ ...makeTask("mixed-two"), parentTaskId: "mixed-root" },
], () => ({ pauseTransfer: async id => id === "mixed-one"
? { success: false, superseded: true, supersededBy: "resume" }
: { success: true } }));
await softPauseTransfer(host, "mixed-root");
await new Promise(resolve => setImmediate(resolve));
assert.equal(isTransferPauseLatched("mixed-root"), true);
assert.equal(isTransferPauseLatched("mixed-two"), true);
});
test("directory resume joins successful and remotely resumed children", async (t) => {
t.after(resetTransferPauseLatchesForTests);
const id = "mixed-success-resume";
const { host, getTasks } = createHost([
{ ...makeTask(id, "paused"), isDirectory: true },
{ ...makeTask(`${id}-one`, "paused"), parentTaskId: id },
{ ...makeTask(`${id}-two`, "paused"), parentTaskId: id },
], () => ({ resumeTransfer: async childId => childId === `${id}-one`
? { success: true, lifecycleEpoch: 3 }
: { success: false, superseded: true, supersededBy: "resume" } }));
assert.deepEqual(await softResumeTransfer(host, id), { handled: true });
assert.equal(getTasks().find(task => task.id === id)?.status, "transferring");
assert.equal(isTransferPauseLatched(id), false);
});
for (const newerPause of [false, true]) {
test(`directory live resume rejection is ignored only when superseded: ${newerPause}`, async (t) => {
t.after(resetTransferPauseLatchesForTests);
t.after(resetTransferWalkRegistryForTests);
const id = `rejected-folder-resume-${newerPause}`;
registerTransferWalk(id);
let rejectResume!: (error: Error) => void;
let backendPaused = true;
const { host, getTasks } = createHost([
{ ...makeTask(id, "paused"), isDirectory: true },
{ ...makeTask(`${id}-child`, "paused"), parentTaskId: id },
], () => ({
resumeTransfer: () => new Promise((_, reject) => { rejectResume = reject; }),
pauseTransfer: async () => { backendPaused = true; return { success: true, lifecycleEpoch: 4 }; },
}));
const running = softResumeTransfer(host, id);
if (newerPause) await softPauseTransfer(host, id);
rejectResume(new Error("resume transport disconnected"));
const result = await running;
assert.equal(result.handled, newerPause);
if (!newerPause) assert.match(result.reason || "", /resume transport disconnected/);
assert.equal(backendPaused, true);
assert.equal(getTasks().find(task => task.id === id)?.status, "paused");
if (newerPause) assert.equal(isTransferPauseLatched(id), true);
});
}
for (const newerResume of [false, true]) {
test(`partial folder resume rejection reports running and paused children unless superseded: ${newerResume}`, async (t) => {
t.after(resetTransferPauseLatchesForTests);
const id = `partial-reject-${newerResume}`;
const successfulId = `${id}-one`;
let rejectResume!: (error: Error) => void;
let successfulBackendPaused = true;
let round = 0;
let rollbackCalls = 0;
const { host, getTasks } = createHost([
{ ...makeTask(id, "paused"), isDirectory: true },
{ ...makeTask(successfulId, "paused"), parentTaskId: id },
{ ...makeTask(`${id}-two`, "paused"), parentTaskId: id },
], () => ({
resumeTransfer: async childId => {
if (childId === successfulId) { successfulBackendPaused = false; return { success: true }; }
if (round > 0) return { success: true };
return new Promise((_, reject) => { rejectResume = reject; });
},
pauseTransfer: async childId => {
rollbackCalls++;
if (childId === successfulId) successfulBackendPaused = true;
return { success: true };
},
}));
const running = softResumeTransfer(host, id);
if (newerResume) { round++; await softResumeTransfer(host, id); }
rejectResume(new Error("second child IPC rejected"));
const result = await running;
assert.equal(result.handled, true);
assert.equal(successfulBackendPaused, false, "successful child keeps running after partial resume");
assert.equal(rollbackCalls, 0, "partial reporting must not introduce compensating controls");
assert.equal(getTasks()[0].status, "transferring", "root must report the successful child still running");
assert.equal(isTransferPauseLatched(id), false);
const rejected = getTasks().find(task => task.id === `${id}-two`);
assert.equal(rejected?.status, newerResume ? "transferring" : "paused");
assert.equal(isTransferPauseLatched(`${id}-two`), !newerResume);
if (!newerResume) assert.match(rejected?.error || "", /second child IPC rejected/);
});
}
for (const resolvedFailure of [false, true]) {
test(`remote-resumed child remains visibly running when sibling resume fails: resolved=${resolvedFailure}`, async (t) => {
t.after(resetTransferPauseLatchesForTests);
const id = "remote-partial-reject";
const runningId = `${id}-one`;
const rejectedId = `${id}-two`;
const { host, getTasks } = createHost([
{ ...makeTask(id, "paused"), isDirectory: true },
{ ...makeTask(runningId, "paused"), parentTaskId: id },
{ ...makeTask(rejectedId, "paused"), parentTaskId: id },
], () => ({ resumeTransfer: async childId => {
if (childId === rejectedId) {
if (resolvedFailure) return { success: false, reason: "sibling resume rejected" };
throw new Error("sibling resume rejected");
}
host.setTasks(getTasks().map(task => task.id === runningId ? { ...task, status: "transferring", lifecycleEpoch: 8 } : task));
return { success: false, superseded: true, supersededBy: "resume" };
} }));
assert.equal((await softResumeTransfer(host, id)).handled, true);
assert.equal(getTasks()[0].status, "transferring");
assert.equal(isTransferPauseLatched(id), false);
assert.equal(getTasks().find(task => task.id === runningId)?.status, "transferring");
assert.equal(getTasks().find(task => task.id === runningId)?.lifecycleEpoch, 8);
assert.equal(getTasks().find(task => task.id === rejectedId)?.status, "paused");
assert.equal(isTransferPauseLatched(rejectedId), true);
assert.match(getTasks().find(task => task.id === rejectedId)?.error || "", /sibling resume rejected/);
});
}
for (const newerResume of [false, true]) {
test(`resolved verification failure holds live folder unless newer resume won: ${newerResume}`, async (t) => {
t.after(resetTransferPauseLatchesForTests);
t.after(resetTransferWalkRegistryForTests);
const id = `resolved-verification-${newerResume}`;
registerTransferWalk(id);
let finish!: (value: { success: boolean; reason: string }) => void;
let calls = 0;
const { host, getTasks } = createHost([
{ ...makeTask(id, "paused"), isDirectory: true },
{ ...makeTask(`${id}-child`, "paused"), parentTaskId: id },
], () => ({ resumeTransfer: () => ++calls === 1
? new Promise(resolve => { finish = resolve; }) : Promise.resolve({ success: true }) }));
const running = softResumeTransfer(host, id);
if (newerResume) await softResumeTransfer(host, id);
finish({ success: false, reason: "Could not verify the source file for resume" });
const result = await running;
assert.equal(result.handled, newerResume);
if (!newerResume) assert.match(result.reason || "", /verify the source file/);
assert.equal(getTasks()[0].status, newerResume ? "transferring" : "paused");
assert.equal(isTransferPauseLatched(id), !newerResume);
assert.equal(isTransferPauseLatched(`${id}-child`), !newerResume);
});
}
@@ -20,6 +20,7 @@ import {
isTransferControlEpochCurrent,
} from "./transferControlEpoch";
import {
isTransferOrRootPauseLatched,
latchTransferPauseTree,
releaseTransferPauseTree,
} from "./transferPauseLatch";
@@ -28,6 +29,9 @@ import { isTransferWalkInFlight } from "./transferWalkRegistry";
export type TransferControlBridge = {
pauseTransfer?: (id: string) => Promise<{
success: boolean;
/** A newer control in another window owns the authoritative state. */
superseded?: boolean;
supersededBy?: "pause" | "resume" | "cancel";
reason?: string;
checkpointBytes?: number;
resumeStage?: TransferTask["resumeStage"];
@@ -39,12 +43,67 @@ export type TransferControlBridge = {
}>;
resumeTransfer?: (id: string) => Promise<{
success: boolean;
/** A newer control in another window owns the authoritative state. */
superseded?: boolean;
supersededBy?: "pause" | "resume" | "cancel";
reason?: string;
lifecycleEpoch?: number;
}>;
cancelTransfer?: (id: string) => Promise<unknown>;
};
function wasSuperseded(result: { success?: boolean; superseded?: boolean } | undefined | null): boolean {
return result?.superseded === true;
}
type SupersededControlResult = {
success?: boolean;
superseded?: boolean;
supersededBy?: "pause" | "resume" | "cancel";
};
export function reconcileSupersededControls(
host: TransferControlHost,
taskId: string,
childIds: string[],
backendIds: string[],
results: ReadonlyArray<SupersededControlResult | undefined>,
epoch: number,
requestedAction: "pause" | "resume",
controlTaskId = taskId,
): void {
if (!isTransferControlEpochCurrent(controlTaskId, epoch)) return;
const task = host.getTasks().find((candidate) => candidate.id === taskId);
if (!task || ["completed", "cancelled", "failed"].includes(task.status)) return;
const apply = (id: string, descendants: string[], action: "pause" | "resume" | "cancel") => {
if (action === "resume") releaseTransferPauseTree(id, descendants);
else latchTransferPauseTree(id, descendants);
for (const affectedId of [id, ...descendants]) {
try {
if (action === "resume") globalSftpTransferScheduler.resume(affectedId);
else globalSftpTransferScheduler.pause(affectedId);
} catch { /* best-effort */ }
}
};
const decisions = results.map((result) => result?.superseded
? result.supersededBy
: result?.success ? requestedAction : undefined);
// A child-only resume does not establish a folder-wide decision. Require all
// relevant children to agree, or an already-authoritative resumed root row.
if (decisions.length > 0 && decisions.every((action) => action === "resume")) {
apply(taskId, childIds, "resume");
host.setTasks(host.getTasks().map((row) => row.id === taskId ? { ...row, status: "transferring" } : row));
} else if (decisions.length > 0 && decisions.every((action) => action === "pause")) {
apply(taskId, childIds, "pause");
host.setTasks(host.getTasks().map((row) => row.id === taskId ? { ...row, status: "paused" } : row));
} else if (task.status === "transferring" && decisions.includes("resume")) {
apply(taskId, [], "resume");
}
decisions.forEach((action, index) => {
if (action) apply(backendIds[index], [], action);
});
}
/** Prefer the highest bridge lifecycleEpoch from successful pause/resume results. */
function maxBridgeLifecycleEpoch(
results: ReadonlyArray<{ success?: boolean; lifecycleEpoch?: number } | undefined | null>,
@@ -170,14 +229,23 @@ export async function softPauseTransfer(
}
const backendIds = treeIds.length > 0 ? treeIds : [taskId];
// An obsolete pause can be superseded by another pause or cancellation,
// not only by resume. Compensate only while the tree still wants to run.
const undoObsoletePause = async (id: string) => {
const live = host.getTasks().find((candidate) => candidate.id === taskId);
if (!live || ["completed", "cancelled", "failed", "interrupted"].includes(live.status)) return;
if (isTransferOrRootPauseLatched(taskId, id)) return;
try { await bridge!.resumeTransfer?.(id); } catch { /* best-effort */ }
};
const pauseOne = async (id: string) => {
let result = await bridge!.pauseTransfer?.(id)
?? { success: false, reason: "Pause unavailable" };
const maxAttempts = task.isDirectory ? 4 : 16;
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
if (wasSuperseded(result)) return result;
if (!isTransferControlEpochCurrent(taskId, pauseEpoch)) {
if (result.success) {
try { await bridge!.resumeTransfer?.(id); } catch { /* best-effort */ }
await undoObsoletePause(id);
}
return { success: false, reason: "Pause superseded by resume" };
}
@@ -186,6 +254,9 @@ export async function softPauseTransfer(
return result;
}
await new Promise((resolve) => setTimeout(resolve, 40));
if (!isTransferControlEpochCurrent(taskId, pauseEpoch)) {
return { success: false, reason: "Pause superseded by newer control" };
}
result = await bridge!.pauseTransfer?.(id)
?? { success: false, reason: "Pause unavailable" };
}
@@ -200,17 +271,22 @@ export async function softPauseTransfer(
return { id, result: { success: false, reason: "Pause superseded by resume" } };
}
const result = await pauseOne(id);
if (wasSuperseded(result)) return { id, result };
const live = host.getTasks().find((candidate) => candidate.id === taskId);
const userResumed = !pauseStillCurrent()
|| !live
|| (live.status !== "paused" && live.status !== "pausing");
if (userResumed) {
try { await bridge!.resumeTransfer?.(id); } catch { /* best-effort */ }
await undoObsoletePause(id);
return { id, result: { success: false, reason: "Pause superseded by resume" } };
}
return { id, result };
}));
if (!pauseStillCurrent()) return;
if (pauseResults.some(({ result }) => wasSuperseded(result))) {
reconcileSupersededControls(host, taskId, childIds, backendIds, pauseResults.map(({ result }) => result), pauseEpoch, "pause");
return;
}
const after = host.getTasks().find((candidate) => candidate.id === taskId);
if (!after || after.status === "cancelled") return;
if (after.status !== "paused" && after.status !== "pausing") return;
@@ -259,6 +335,10 @@ export async function softPauseTransfer(
id,
result: await pauseOne(id),
})));
if (pauseResults.some(({ result }) => wasSuperseded(result))) {
reconcileSupersededControls(host, taskId, childIds, backendIds, pauseResults.map(({ result }) => result), pauseEpoch, "pause");
return "noop";
}
const afterLivePause = host.getTasks().find((candidate) => candidate.id === taskId);
if (afterLivePause?.status === "cancelled") {
releaseTransferPauseTree(taskId, childIds);
@@ -271,7 +351,7 @@ export async function softPauseTransfer(
if (userAlreadyResumed) {
for (const { id, result } of pauseResults) {
if (result?.success) {
try { await bridge.resumeTransfer?.(id); } catch { /* best-effort */ }
await undoObsoletePause(id);
}
}
return "noop";
@@ -284,7 +364,7 @@ export async function softPauseTransfer(
if (!isTransferControlEpochCurrent(taskId, pauseEpoch)) {
for (const { id, result } of pauseResults) {
if (result?.success) {
try { await bridge.resumeTransfer?.(id); } catch { /* best-effort */ }
await undoObsoletePause(id);
}
}
return "noop";
@@ -367,8 +447,9 @@ export async function softPauseTransfer(
bridgeResults: pauseResults.map((row) => row.result),
});
for (const id of rollback.bridgeIdsToResume) {
try { await bridge.resumeTransfer?.(id); } catch { /* best-effort */ }
await undoObsoletePause(id);
}
if (!isTransferControlEpochCurrent(taskId, pauseEpoch)) return "noop";
const hard = pauseResults.find(({ result }) =>
result && !result.success && !isBenignPauseMiss(result.reason),
)?.result;
@@ -428,7 +509,7 @@ export async function softResumeTransfer(
: [taskId, ...childIds.filter((id) => id !== taskId)];
// Supersede in-flight soft-drain / pauseWatch only — not a bridge lifecycle stamp.
bumpTransferControlEpoch(taskId);
const resumeEpoch = bumpTransferControlEpoch(taskId);
releaseTransferPauseTree(taskId, releaseIds);
for (const id of [taskId, ...releaseIds]) {
try { globalSftpTransferScheduler.resume(id); } catch { /* best-effort */ }
@@ -438,17 +519,50 @@ export async function softResumeTransfer(
try { bridge = host.getBridge(); } catch { bridge = undefined; }
const resumeIds = treeIds.length > 0 ? treeIds : [taskId];
const results = await Promise.all(resumeIds.map(async (id) =>
bridge?.resumeTransfer?.(id) ?? { success: false, reason: "Resume unavailable" },
));
const failedIds = new Set<string>();
const results = await Promise.all(resumeIds.map(async (id) => {
try {
return await bridge?.resumeTransfer?.(id) ?? { success: false, reason: "Resume unavailable" };
} catch (error) {
// Rejections must pass the same stale-control check as ordinary failures.
failedIds.add(id);
return { success: false, reason: error instanceof Error && error.message ? error.message : "Resume request failed" };
}
}));
const after = host.getTasks().find((candidate) => candidate.id === taskId);
if (after?.status === "cancelled") return { handled: true };
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return { handled: true };
if (!after || ["completed", "cancelled", "failed"].includes(after.status)) return { handled: true };
results.forEach((result, index) => {
// A missing stream can belong to queued directory work. Verification and
// other explicit failures still own a paused stream and must remain held.
const benignMiss = /^(?:Transfer is no longer active|not active|Resume unavailable)$/i.test(result.reason || "");
if (!result.success && !result.superseded && !benignMiss) failedIds.add(resumeIds[index]);
});
if (results.some(wasSuperseded)) {
reconcileSupersededControls(host, taskId, releaseIds, resumeIds, results, resumeEpoch, "resume");
}
const successIds = resumeIds.filter((_, index) => results[index]?.success);
const effectiveRunning = results.some((result) => result?.success || (result?.superseded && result.supersededBy === "resume"));
const failedIndex = resumeIds.findIndex((id) => failedIds.has(id));
const failureReason = failedIndex >= 0 ? results[failedIndex]?.reason || "Resume request failed" : undefined;
if (failedIds.size > 0) {
// A partial resume stays visibly active; hold only failed children. Do not
// hide successfully running streams behind a paused root barrier.
const heldIds = effectiveRunning ? [...failedIds] : [taskId, ...failedIds];
for (const id of heldIds) {
latchTransferPauseTree(id, []);
try { globalSftpTransferScheduler.pause(id); } catch { /* best-effort */ }
}
if (!effectiveRunning) return { handled: false, reason: failureReason };
}
if (results.some(wasSuperseded) && failedIds.size === 0) {
return { handled: true };
}
const walkAlive = isTransferWalkInFlight(taskId);
// Directory walk can continue after unlatch without bridge resume on every child.
// Single-file must not claim success when every bridge resume fails (stuck bar).
if (successIds.length === 0) {
if (!effectiveRunning) {
if (task.isDirectory && walkAlive) {
host.setTasks(paintTreeStatus(
host.getTasks(),
@@ -491,6 +605,12 @@ export async function softResumeTransfer(
return candidate;
}
if (failedIds.has(candidate.id)) {
return { ...candidate, status: "paused" as const, speed: 0, error: failureReason };
}
const candidateResult = results[resumeIds.indexOf(candidate.id)];
if (candidate.id !== taskId && candidateResult?.superseded) return candidate;
// Parent: transferring. Prefer bridge epoch; never wipe to undefined or a
// late pause fanout re-applies "paused" (acceptsLifecycle treats missing as any).
if (candidate.id === taskId) {
@@ -502,7 +622,7 @@ export async function softResumeTransfer(
return {
...candidate,
status: "transferring" as const,
error: undefined,
error: failureReason,
reconnectRequired: false,
pauseUnavailableReason: undefined,
phase: undefined,
@@ -4,6 +4,11 @@ import test from "node:test";
import {
createGlobalSftpTransferScheduler,
} from "./globalTransferScheduler";
import { resolveProgressiveFolderUploadConcurrency } from "../../../lib/progressiveFolderUpload";
import {
DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY,
resolveSftpTransferConcurrency,
} from "./transferConcurrency";
test("scheduler limits each remote host independently", async () => {
const scheduler = createGlobalSftpTransferScheduler();
@@ -28,6 +33,40 @@ test("scheduler limits each remote host independently", async () => {
assert.equal(maxActive, 2);
});
test("progressive folder uploads use six slots by default and preserve a saved limit", async () => {
const displayedDefault = resolveSftpTransferConcurrency(() => null);
const effectiveDefault = resolveProgressiveFolderUploadConcurrency(null);
assert.equal(displayedDefault, DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY);
assert.equal(effectiveDefault, displayedDefault);
assert.equal(effectiveDefault, 6);
assert.equal(resolveProgressiveFolderUploadConcurrency(3), 3);
assert.equal(resolveProgressiveFolderUploadConcurrency(99), 6);
const scheduler = createGlobalSftpTransferScheduler();
const releases: Array<() => void> = [];
let active = 0;
let maxActive = 0;
const jobs = Array.from({ length: 8 }, (_, index) => scheduler.run(
"progressive",
`file-${index}`,
["host:one"],
() => null,
async () => {
active += 1;
maxActive = Math.max(maxActive, active);
await new Promise<void>((resolve) => releases.push(resolve));
active -= 1;
},
));
while (releases.length < 6) await new Promise((resolve) => setImmediate(resolve));
assert.equal(maxActive, 6);
releases.splice(0).forEach((release) => release());
while (releases.length < 2) await new Promise((resolve) => setImmediate(resolve));
releases.splice(0).forEach((release) => release());
await Promise.all(jobs);
});
test("scheduler alternates owners when both have queued work", async () => {
const scheduler = createGlobalSftpTransferScheduler();
const order: string[] = [];
@@ -84,3 +123,49 @@ test("queued work stays paused until resumed and can be cancelled", async () =>
await paused;
assert.deepEqual(order, ["b1"]);
});
test("enqueuing a large blocked batch does not repeatedly inspect the existing queue", async () => {
const scheduler = createGlobalSftpTransferScheduler();
let release: (() => void) | undefined;
let limitReads = 0;
const readLimit = () => { limitReads += 1; return 1; };
const blocker = scheduler.run("panel", "active", ["host"], readLimit, () => (
new Promise<void>((resolve) => { release = resolve; })
));
const count = 2_000;
const completed: number[] = [];
const jobs = Array.from({ length: count }, (_, index) => scheduler.run(
"panel", `queued-${index}`, ["host"], readLimit,
async () => { completed.push(index); },
));
await new Promise((resolve) => setImmediate(resolve));
const readsWhileBlocked = limitReads;
release?.();
await Promise.all([blocker, ...jobs]);
assert.deepEqual(completed, Array.from({ length: count }, (_, index) => index));
assert.ok(readsWhileBlocked <= count * 3,
`a blocked batch should be inspected linearly, got ${readsWhileBlocked} limit reads for ${count} files`);
});
test("large batches of immediately completed files yield to user input", async () => {
const scheduler = createGlobalSftpTransferScheduler();
let completed = 0;
const count = 1_000;
const inputTurn = new Promise<number>((resolve) => setTimeout(() => resolve(completed), 0));
const jobs = Array.from({ length: count }, (_, index) => scheduler.run(
"panel", `tiny-${index}`, ["host"], () => 2, async () => { completed += 1; },
));
const completedAtInput = await inputTurn;
await Promise.all(jobs);
assert.ok(completedAtInput < count, "input must run before the entire batch drains");
assert.equal(completed, count);
});
test("a synchronous job failure releases its slot for queued work", async () => {
const scheduler = createGlobalSftpTransferScheduler();
const failed = scheduler.run("panel", "failed", ["host"], () => 1, () => { throw new Error("read failed"); });
const next = scheduler.run("panel", "next", ["host"], () => 1, async () => "completed");
await assert.rejects(failed, /read failed/);
assert.equal(await next, "completed");
});
@@ -1,3 +1,5 @@
import { resolveSftpTransferConcurrency } from "../../../domain/sftpTransferConcurrency";
type LimitReader = () => number | null | undefined;
interface ScheduledJob<T> {
@@ -20,9 +22,7 @@ export interface GlobalSftpTransferScheduler {
}
function normalizeLimit(value: number | null | undefined): number {
return Number.isInteger(value) && value !== undefined && value !== null && value >= 1 && value <= 16
? value
: 2;
return resolveSftpTransferConcurrency(() => value);
}
export function getSftpTransferResourceKeys(input: {
@@ -44,6 +44,25 @@ export function createGlobalSftpTransferScheduler(): GlobalSftpTransferScheduler
let lastOwnerId: string | null = null;
let prioritySequence = 0;
const pausedJobs = new Map<string, ScheduledJob<unknown>>();
let pumpScheduled = false;
let startsSinceYield = 0;
const schedulePump = () => {
if (pumpScheduled || queue.length === 0) return;
pumpScheduled = true;
const run = () => {
pumpScheduled = false;
pump();
};
// Coalesce a discovery/enqueue burst into one scan. Periodically yield to
// input/paint as well, including when many tiny jobs resolve immediately.
if (startsSinceYield >= 64) {
startsSinceYield = 0;
setTimeout(run, 0);
} else {
queueMicrotask(run);
}
};
const normalizeResourceKeys = (keys: readonly string[]) => [...new Set(keys.length > 0 ? keys : ["local"])];
const canRun = (job: ScheduledJob<unknown>) => {
@@ -60,24 +79,31 @@ export function createGlobalSftpTransferScheduler(): GlobalSftpTransferScheduler
const pump = () => {
while (queue.length > 0) {
const runnable = queue.map((job, index) => ({ job, index })).filter(({ job }) => canRun(job));
if (runnable.length === 0) return;
const highestPriority = runnable.reduce((max, { job }) => Math.max(max, job.priority), 0);
const prioritizedIndexes = queue
.map((job, index) => ({ job, index }))
.filter(({ job }) => job.priority === highestPriority && canRun(job));
const alternate = lastOwnerId === null
? undefined
: prioritizedIndexes.find(({ job }) => job.ownerId !== lastOwnerId);
const index = alternate?.index ?? prioritizedIndexes[0]?.index ?? 0;
let index = -1;
for (let candidateIndex = 0; candidateIndex < queue.length; candidateIndex += 1) {
const candidate = queue[candidateIndex];
if (!canRun(candidate)) continue;
const selected = queue[index];
if (!selected || candidate.priority > selected.priority || (
candidate.priority === selected.priority
&& selected.ownerId === lastOwnerId
&& candidate.ownerId !== lastOwnerId
)) index = candidateIndex;
}
if (index < 0) return;
const [job] = queue.splice(index, 1);
if (!job) return;
adjustActive(job, 1);
lastOwnerId = job.ownerId;
void job.work().then(job.resolve, job.reject).finally(() => {
startsSinceYield += 1;
void (async () => job.work())().then(job.resolve, job.reject).finally(() => {
adjustActive(job, -1);
pump();
schedulePump();
});
if (startsSinceYield >= 64) {
schedulePump();
return;
}
}
};
@@ -85,7 +111,8 @@ export function createGlobalSftpTransferScheduler(): GlobalSftpTransferScheduler
run<T>(ownerId: string, taskId: string, resourceKeys: readonly string[], readLimit: LimitReader, work: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
queue.push({ ownerId, taskId, resourceKeys: normalizeResourceKeys(resourceKeys), priority: 0, readLimit, work, resolve, reject } as ScheduledJob<unknown>);
pump();
if (queue.length === 1 && !pumpScheduled && startsSinceYield < 64) pump();
else schedulePump();
});
},
prioritize(taskId: string) {
@@ -93,7 +120,7 @@ export function createGlobalSftpTransferScheduler(): GlobalSftpTransferScheduler
if (!job) return;
prioritySequence += 1;
job.priority = prioritySequence;
pump();
schedulePump();
},
pause(taskId: string) {
const index = queue.findIndex((job) => job.taskId === taskId);
@@ -108,7 +135,7 @@ export function createGlobalSftpTransferScheduler(): GlobalSftpTransferScheduler
if (!job) return false;
pausedJobs.delete(taskId);
queue.push(job);
pump();
schedulePump();
return true;
},
cancel(taskId: string) {
@@ -117,7 +144,7 @@ export function createGlobalSftpTransferScheduler(): GlobalSftpTransferScheduler
if (!job) return false;
pausedJobs.delete(taskId);
job.reject(new Error("Transfer cancelled"));
pump();
schedulePump();
return true;
},
};
+11 -1
View File
@@ -2,7 +2,7 @@ import { useCallback, useMemo, useSyncExternalStore } from "react";
import type { SftpBookmark } from "../../../domain/models";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
import { STORAGE_KEY_SFTP_LOCAL_BOOKMARKS } from "../../../infrastructure/config/storageKeys";
import { createSftpBookmark } from "./bookmarkHelpers";
import { createSftpBookmark, moveSftpBookmark, renameSftpBookmark } from "./bookmarkHelpers";
type Listener = () => void;
@@ -74,10 +74,20 @@ export const useLocalSftpBookmarks = ({
setLocalSftpBookmarks((prev) => prev.filter((b) => b.id !== id));
}, []);
const reorderBookmark = useCallback((fromId: string, toId: string) => {
setLocalSftpBookmarks((prev) => moveSftpBookmark(prev, fromId, toId));
}, []);
const renameBookmark = useCallback((id: string, label: string) => {
setLocalSftpBookmarks((prev) => renameSftpBookmark(prev, id, label));
}, []);
return {
bookmarks,
isCurrentPathBookmarked,
toggleBookmark,
deleteBookmark,
reorderBookmark,
renameBookmark,
};
};
@@ -0,0 +1,28 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
consumePendingDualPaneSftpRequest,
requestOpenDualPaneSftp,
resetDualPaneSftpOpenStore,
subscribeDualPaneSftpOpen,
} from "./sftpDualPaneOpenStore.ts";
test("requestOpenDualPaneSftp stores a pending request when nothing is listening", () => {
resetDualPaneSftpOpenStore();
requestOpenDualPaneSftp("host-1");
assert.deepEqual(consumePendingDualPaneSftpRequest(), { hostId: "host-1", seq: 1 });
assert.equal(consumePendingDualPaneSftpRequest(), null);
});
test("requestOpenDualPaneSftp delivers live to subscribers instead of queueing", () => {
resetDualPaneSftpOpenStore();
const received: string[] = [];
const unsubscribe = subscribeDualPaneSftpOpen((request) => {
received.push(request.hostId);
});
requestOpenDualPaneSftp("host-2");
assert.deepEqual(received, ["host-2"]);
assert.equal(consumePendingDualPaneSftpRequest(), null);
unsubscribe();
});
@@ -0,0 +1,45 @@
import { activeTabStore } from "../activeTabStore";
export type DualPaneSftpRequest = {
hostId: string;
seq: number;
};
type Listener = (request: DualPaneSftpRequest) => void;
const listeners = new Set<Listener>();
let pending: DualPaneSftpRequest | null = null;
let seq = 0;
export function requestOpenDualPaneSftp(hostId: string): DualPaneSftpRequest {
const request: DualPaneSftpRequest = { hostId, seq: ++seq };
if (listeners.size > 0) {
pending = null;
for (const listener of listeners) listener(request);
} else {
pending = request;
}
if (typeof globalThis.window !== "undefined") {
activeTabStore.setActiveTabId("sftp");
}
return request;
}
export function consumePendingDualPaneSftpRequest(): DualPaneSftpRequest | null {
const current = pending;
pending = null;
return current;
}
export function subscribeDualPaneSftpOpen(listener: Listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function resetDualPaneSftpOpenStore() {
pending = null;
listeners.clear();
seq = 0;
}
@@ -0,0 +1,67 @@
import { useCallback, useSyncExternalStore } from "react";
import {
DEFAULT_SFTP_LIST_DENSITY,
getNextSftpListDensity,
parseSftpListDensity,
type SftpListDensity,
} from "../../../domain/sftpListDensity";
import { STORAGE_KEY_SFTP_LIST_DENSITY } from "../../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
type Listener = () => void;
const listeners = new Set<Listener>();
let snapshot: SftpListDensity = parseSftpListDensity(
localStorageAdapter.readString(STORAGE_KEY_SFTP_LIST_DENSITY),
);
function emit() {
for (const listener of listeners) listener();
}
export function subscribeSftpListDensity(listener: Listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function getSftpListDensitySnapshot(): SftpListDensity {
return snapshot;
}
export function setSftpListDensity(next: SftpListDensity) {
const density = parseSftpListDensity(next);
if (density === snapshot) return;
snapshot = density;
localStorageAdapter.writeString(STORAGE_KEY_SFTP_LIST_DENSITY, density);
emit();
}
export function toggleSftpListDensity() {
setSftpListDensity(getNextSftpListDensity(snapshot));
}
if (typeof window !== "undefined") {
window.addEventListener("storage", (event) => {
if (event.key !== STORAGE_KEY_SFTP_LIST_DENSITY) return;
snapshot = parseSftpListDensity(event.newValue);
emit();
});
}
export function useSftpListDensity() {
const density = useSyncExternalStore(
subscribeSftpListDensity,
getSftpListDensitySnapshot,
() => DEFAULT_SFTP_LIST_DENSITY,
);
const setDensity = useCallback((next: SftpListDensity) => {
setSftpListDensity(next);
}, []);
const toggleDensity = useCallback(() => {
toggleSftpListDensity();
}, []);
return { density, setDensity, toggleDensity };
}
@@ -12,9 +12,9 @@ import {
runSftpTransferWorkers,
} from "./transferConcurrency";
test("defaults folder file transfers to two concurrent files", () => {
test("defaults folder file transfers to six concurrent files", () => {
assert.equal(resolveSftpTransferConcurrency(() => null), DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY);
assert.equal(DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY, 2);
assert.equal(DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY, 6);
});
test("defaults directory listing fanout to four concurrent readdirs", () => {
@@ -34,6 +34,7 @@ test("defaults skip-unchanged to enabled", () => {
test("keeps explicit folder transfer concurrency within the supported range", () => {
assert.equal(resolveSftpTransferConcurrency(() => 1), 1);
assert.equal(resolveSftpTransferConcurrency(() => 16), 16);
assert.equal(resolveSftpTransferConcurrency(() => 1.5), DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY);
assert.equal(resolveSftpTransferConcurrency(() => 0), DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY);
assert.equal(resolveSftpTransferConcurrency(() => 17), DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY);
});
@@ -51,18 +52,18 @@ test("keeps directory listing concurrency within the supported range", () => {
);
});
test("limits default multi-file transfer scheduling to two concurrent workers", async () => {
test("limits default multi-file transfer scheduling to six concurrent workers", async () => {
let active = 0;
let maxActive = 0;
await runSftpTransferWorkers([1, 2, 3, 4], () => null, async () => {
await runSftpTransferWorkers([1, 2, 3, 4, 5, 6, 7], () => null, async () => {
active += 1;
maxActive = Math.max(maxActive, active);
await new Promise((resolve) => setTimeout(resolve, 5));
active -= 1;
});
assert.equal(maxActive, 2);
assert.equal(maxActive, 6);
});
test("runBoundedConcurrency respects an explicit limit", async () => {
+8 -12
View File
@@ -1,6 +1,11 @@
export const DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY = 2;
export const MIN_SFTP_FILE_TRANSFER_CONCURRENCY = 1;
export const MAX_SFTP_FILE_TRANSFER_CONCURRENCY = 16;
import { resolveSftpTransferConcurrency } from "../../../domain/sftpTransferConcurrency";
export {
DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY,
MAX_SFTP_FILE_TRANSFER_CONCURRENCY,
MIN_SFTP_FILE_TRANSFER_CONCURRENCY,
resolveSftpTransferConcurrency,
} from "../../../domain/sftpTransferConcurrency";
/**
* Bounded parallel directory listings while walking a folder tree.
@@ -16,15 +21,6 @@ export const MAX_SFTP_DIRECTORY_LISTING_CONCURRENCY = 8;
/** Default on: skip size+mtime matches like rsync's generator. */
export const DEFAULT_SFTP_SKIP_UNCHANGED = true;
export function resolveSftpTransferConcurrency(readStoredValue: () => number | null | undefined): number {
const stored = readStoredValue();
return stored != null &&
stored >= MIN_SFTP_FILE_TRANSFER_CONCURRENCY &&
stored <= MAX_SFTP_FILE_TRANSFER_CONCURRENCY
? stored
: DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY;
}
export function resolveSftpDirectoryListingConcurrency(
readStoredValue?: () => number | null | undefined,
): number {
@@ -41,6 +41,106 @@ const rootTask = (): TransferTask => ({
progressMode: "files",
});
for (const newestAction of ["pause", "cancel", "resume", "remote-resume", "remote-child-resume"] as const) {
test(`directory pause watcher respects a newer ${newestAction}`, async () => {
const { bumpTransferControlEpoch, resetTransferControlEpochsForTests } = await import("./transferControlEpoch");
const { latchTransferPauseTree, isTransferPauseLatched, resetTransferPauseLatchesForTests } = await import("./transferPauseLatch");
const previousWindow = (globalThis as { window?: unknown }).window;
const previousLocalStorage = (globalThis as { localStorage?: unknown }).localStorage;
const previousActEnvironment = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const root = { ...rootTask(), id: `watch-root-${newestAction}` };
let tasks: TransferTask[] = [root];
const transfersRef = { current: tasks };
const cancelledTasksRef = { current: new Set<string>() };
const pausedTasksRef = { current: new Set<string>() };
let pauseCalls = 0;
let finishPause!: (value: { success: boolean; superseded?: boolean; supersededBy?: "resume" }) => void;
let finishTransfer!: (value: { error?: string }) => void;
let pauseStarted!: () => void;
const pauseGate = new Promise<void>((resolve) => { pauseStarted = resolve; });
const transferGate = new Promise<{ error?: string }>((resolve) => { finishTransfer = resolve; });
let resumeCalls = 0;
let childId = "";
(globalThis as { window?: unknown }).window = { netcatty: {
mkdirLocal: async () => undefined,
statLocal: async () => ({ type: "directory" }),
startStreamTransfer: (options: { transferId: string }) => {
childId = options.transferId;
// Reproduce a stream arming during the parent's initial pause round.
bumpTransferControlEpoch(root.id);
latchTransferPauseTree(root.id, [childId]);
if (newestAction === "remote-resume" || newestAction === "remote-child-resume") { pausedTasksRef.current.add(root.id); pausedTasksRef.current.add(childId); }
return transferGate;
},
pauseTransfer: () => {
pauseCalls++;
pauseStarted();
return new Promise<{ success: boolean; superseded?: boolean; supersededBy?: "resume" }>((resolve) => { finishPause = resolve; });
},
resumeTransfer: async () => { resumeCalls++; return { success: true }; },
} };
(globalThis as { localStorage?: unknown }).localStorage = {
getItem: () => null, setItem: () => undefined, removeItem: () => undefined,
};
let operations: ReturnType<typeof useSftpDirectoryTransferOps> | undefined;
let renderer: ReactTestRenderer | null = null;
let running: Promise<unknown> | undefined;
const Probe = () => {
operations = useSftpDirectoryTransferOps({
ownerId: `watch-owner-${newestAction}`, cancelledTasksRef,
pausedTasksRef,
waitUntilTransferResumed: async () => undefined,
activeChildIdsRef: { current: new Map() }, transfersRef,
setTransfers: (update) => {
tasks = typeof update === "function" ? update(tasks) : update;
transfersRef.current = tasks;
},
listLocalFiles: async () => [], listRemoteFiles: async () => [fileEntry("file.txt")],
});
return null;
};
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
assert.ok(operations);
running = operations.transferDirectory(root, "source-sftp", null, false, true, "auto", "auto", root.id);
await pauseGate;
if (newestAction === "remote-resume" || newestAction === "remote-child-resume") {
// Another window's resumed event updates rows without bumping this local epoch.
transfersRef.current = tasks = tasks.map(task => ({ ...task, status: task.id === root.id && newestAction === "remote-child-resume" ? "paused" : "transferring", lifecycleEpoch: 8 }));
finishPause({ success: false, superseded: true, supersededBy: "resume" });
await new Promise((resolve) => setTimeout(resolve, 110));
assert.equal(pauseCalls, 1, "superseded watcher must not re-pause the resumed backend");
assert.equal(isTransferPauseLatched(root.id), newestAction === "remote-child-resume");
assert.equal(isTransferPauseLatched(childId), false);
assert.equal(pausedTasksRef.current.has(root.id), newestAction === "remote-child-resume");
assert.equal(pausedTasksRef.current.has(childId), false);
return;
}
bumpTransferControlEpoch(root.id);
if (newestAction === "pause") latchTransferPauseTree(root.id, [childId]);
else {
if (newestAction === "cancel") cancelledTasksRef.current.add(root.id);
resetTransferPauseLatchesForTests();
}
finishPause({ success: true });
await new Promise((resolve) => setImmediate(resolve));
assert.equal(resumeCalls, newestAction === "resume" ? 1 : 0, "compensation must follow the latest decision");
} finally {
pausedTasksRef.current.clear();
resetTransferPauseLatchesForTests();
finishPause?.({ success: true });
finishTransfer({});
await running?.catch(() => {});
await act(async () => { renderer?.unmount(); });
resetTransferControlEpochsForTests();
(globalThis as { window?: unknown }).window = previousWindow;
(globalThis as { localStorage?: unknown }).localStorage = previousLocalStorage;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
}
test("directory transfer discovers each directory once with bounded listing concurrency", async () => {
const previousWindow = (globalThis as { window?: unknown }).window;
const previousLocalStorage = (globalThis as { localStorage?: unknown }).localStorage;
@@ -233,3 +333,74 @@ test("live directory download rejects a Windows backslash traversal entry", asyn
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
test("live folder settles a superseded child whose completed row was compacted", async () => {
const { sftpTransferCenterStore } = await import("../sftpTransferCenterStore");
const previousWindow = (globalThis as { window?: unknown }).window;
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
const previousActEnvironment = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
});
const root = { ...rootTask(), id: "live-compacted-root", startTime: Date.now() };
let tasks: TransferTask[] = [root];
const transfersRef = { current: tasks };
const cancelledTasksRef = { current: new Set<string>() };
let childId: string | undefined;
(globalThis as { window?: unknown }).window = { netcatty: {
mkdirLocal: async () => undefined,
statLocal: async () => ({ type: "directory" }),
startStreamTransfer: async (options: { transferId: string }) => {
childId = options.transferId;
sftpTransferCenterStore.publishOwner("live-compacted-owner", tasks);
// The winning invocation has already completed; history compaction drops its row.
sftpTransferCenterStore.ingestBackgroundEvent({
type: "completed", transferId: childId, transferred: 1, totalBytes: 1, lifecycleEpoch: 0,
});
return { superseded: true };
},
} };
let operations: ReturnType<typeof useSftpDirectoryTransferOps> | undefined;
let renderer: ReactTestRenderer | null = null;
let running: Promise<number> | undefined;
const Probe = () => {
operations = useSftpDirectoryTransferOps({
ownerId: "live-compacted-owner", cancelledTasksRef,
pausedTasksRef: { current: new Set() }, waitUntilTransferResumed: async () => undefined,
activeChildIdsRef: { current: new Map() }, transfersRef,
setTransfers: (update) => {
tasks = typeof update === "function" ? update(tasks) : update;
transfersRef.current = tasks;
},
listLocalFiles: async () => [], listRemoteFiles: async () => [fileEntry("file.txt")],
});
return null;
};
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
assert.ok(operations);
running = operations.transferDirectory(root, "source-sftp", null, false, true, "auto", "auto", root.id);
const result = await Promise.race([
running,
new Promise<"still-waiting">((resolve) => setTimeout(() => resolve("still-waiting"), 450)),
]);
assert.ok(childId);
assert.equal(sftpTransferCenterStore.getTask(childId), undefined);
assert.equal(sftpTransferCenterStore.getTask(root.id)?.directoryResumeCheckpoint?.completedEntries, 1);
assert.notEqual(result, "still-waiting", "compacted completion must settle the live folder");
assert.equal(result, 0);
} finally {
cancelledTasksRef.current.add(root.id);
await running?.catch(() => {});
await act(async () => { renderer?.unmount(); });
sftpTransferCenterStore.patchTask(root.id, { status: "completed" });
sftpTransferCenterStore.dismiss(root.id);
(globalThis as { window?: unknown }).window = previousWindow;
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
+29 -29
View File
@@ -1,3 +1,5 @@
import { reconcileSupersededControls } from "./globalSftpTransferControl";
import { runTransferAndWaitForOwner } from "./waitForTransferOwner";
import { useCallback, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
import type { Host, SftpFileEntry, SftpFilenameEncoding, TransferStatus, TransferTask } from "../../../domain/models";
import {
@@ -412,7 +414,11 @@ export function useSftpDirectoryTransferOps({
}
// Await the invoke result — cancel resolves with { error } and may
// not fire onComplete/onError after preload clears listeners.
const transferPromise = netcattyBridge.require().startStreamTransfer!(options);
const transferPromise = runTransferAndWaitForOwner(
task,
() => netcattyBridge.require().startStreamTransfer!(options),
() => isCancelledLocalOrGlobal(cancelledTasksRef, rootTaskId, task.id),
);
// Streams that arm after the parent pause round never receive the
// initial pauseTransfer. Keep pausing while the folder is latched.
// Capture epoch per attempt so Resume (epoch bump) undoes a late pause.
@@ -427,12 +433,33 @@ export function useSftpDirectoryTransferOps({
)
) {
const epochAtAttempt = getTransferControlEpoch(rootTaskId);
const childEpochAtAttempt = getTransferControlEpoch(task.id);
try {
const result = await netcattyBridge.get()?.pauseTransfer?.(task.id);
// Resume won while we were awaiting pause — undo.
if (
result?.superseded && result.supersededBy === "resume"
&& isTransferControlEpochCurrent(rootTaskId, epochAtAttempt)
&& getTransferControlEpoch(task.id) === childEpochAtAttempt
&& !isCancelledLocalOrGlobal(cancelledTasksRef, rootTaskId, task.id)
) {
const rootAlreadyRunning = transfersRef.current.find((row) => row.id === rootTaskId)?.status === "transferring";
reconcileSupersededControls({
getTasks: () => transfersRef.current,
setTasks: (next) => setTransfers(next),
getBridge: () => netcattyBridge.get(),
}, rootAlreadyRunning ? rootTaskId : task.id, rootAlreadyRunning ? [task.id] : [], [task.id], [result],
epochAtAttempt, "pause", rootTaskId);
if (rootAlreadyRunning) pausedTasksRef.current.delete(rootTaskId);
pausedTasksRef.current.delete(task.id);
break;
}
// Compensate only if the newest decision still wants this file running.
// A new pause or cancellation also changes the epoch.
if (
result?.success
&& !isTransferControlEpochCurrent(rootTaskId, epochAtAttempt)
&& !isPauseLatched(rootTaskId, task.id)
&& !isCancelledLocalOrGlobal(cancelledTasksRef, rootTaskId, task.id)
) {
try {
await netcattyBridge.get()?.resumeTransfer?.(task.id);
@@ -450,33 +477,6 @@ export function useSftpDirectoryTransferOps({
watchPaused = false;
await pauseWatch.catch(() => {});
}
// Same-id retry stole ownership while OPEN was pending. The live
// owner still drives progress/completion events; this invoke must
// not mark the child completed or failed (Codex P2 on b17f64e9).
if (result?.superseded === true) {
// Wait for the live same-id owner only — no fixed deadline so
// long transfers are not failed while still running (Codex P2).
for (;;) {
if (
cancelledTasksRef.current.has(task.id)
|| cancelledTasksRef.current.has(rootTaskId)
) {
throw new Error("Transfer cancelled");
}
const latest = sftpTransferCenterStore.getTask(task.id)
?? transfersRef.current.find((candidate) => candidate.id === task.id);
const status = latest?.status;
if (status === "completed") break;
if (status === "failed") {
throw new Error(latest?.error || "Transfer failed");
}
if (status === "cancelled") {
throw new Error("Transfer cancelled");
}
await new Promise((resolve) => setTimeout(resolve, 200));
}
break;
}
if (result?.error || result?.cancelled) {
throw new Error(result.error || "Transfer cancelled");
}
@@ -0,0 +1,171 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { TransferTask } from "../../../domain/models";
import { createSftpTransferCenterStore } from "../sftpTransferCenterStore";
function child(): TransferTask {
return {
id: "child", parentTaskId: "root", directoryEntryIndex: 0, directoryEntryIdentity: "a".repeat(64),
sourcePath: "/source/a", targetPath: "/target/a", fileName: "a",
sourceConnectionId: "local", targetConnectionId: "remote", direction: "upload",
totalBytes: 1, transferredBytes: 0, speed: 0, startTime: 0, isDirectory: false, status: "transferring",
};
}
for (const status of ["completed", "failed", "cancelled"] as const) {
test(`settlement observation retains exact ${status} before history pruning`, () => {
const store = createSftpTransferCenterStore();
const task = child();
store.upsertTasks([{ ...task, id: "root", parentTaskId: undefined, isDirectory: true }, task]);
const observation = store.observeTaskSettlement(task);
store.patchTask(task.id, { status, error: status === "failed" ? "write failed" : undefined });
assert.equal(observation.read()?.status, status);
if (status === "completed") assert.equal(store.getTask(task.id), undefined);
observation.dispose();
assert.equal(observation.read(), undefined);
});
}
test("another file reusing an id and index is not completion evidence", () => {
const store = createSftpTransferCenterStore();
const task = child();
store.upsertTasks([task]);
const observation = store.observeTaskSettlement(task);
store.upsertTasks([{ ...task, directoryEntryIdentity: "b".repeat(64), status: "completed" }]);
assert.equal(observation.read(), undefined);
observation.dispose();
});
test("a disposed observer receives no later completion", () => {
const store = createSftpTransferCenterStore();
const task = child();
store.upsertTasks([task]);
const observation = store.observeTaskSettlement(task);
observation.dispose();
store.patchTask(task.id, { status: "completed" });
assert.equal(observation.read(), undefined);
});
test("explicit dispatch refreshes failed identity without overwriting checkpoints", () => {
const store = createSftpTransferCenterStore();
const task = { ...child(), status: "failed" as const, checkpointBytes: 7 };
store.upsertTasks([task]);
const next = { ...task, directoryEntryIdentity: "b".repeat(64), checkpointBytes: 0 };
assert.equal(store.admitTaskRun(next), "ready");
assert.equal(store.getTask(task.id)?.status, "transferring");
assert.equal(store.getTask(task.id)?.directoryEntryIdentity, next.directoryEntryIdentity);
assert.equal(store.getTask(task.id)?.checkpointBytes, 7);
});
for (const status of ["cancelled", "completed", "paused", "pausing"] as const) {
test(`dispatch cannot revive a retained ${status} row`, () => {
const store = createSftpTransferCenterStore();
const task = child();
store.upsertTasks([
{ ...task, id: "root", parentTaskId: undefined },
{ ...task, status },
]);
assert.equal(store.admitTaskRun(task), status === "pausing" ? "paused" : status);
assert.equal(store.getTask(task.id)?.status, status);
});
}
test("dispatch preserves active lifecycle guards and avoids replacing unchanged rows", () => {
const store = createSftpTransferCenterStore();
const task = { ...child(), lifecycleEpoch: 9 };
store.upsertTasks([task]);
const before = store.getTask(task.id);
assert.equal(store.admitTaskRun(task), "ready");
assert.equal(store.getTask(task.id), before);
assert.equal(store.admitTaskRun({ ...task, directoryEntryIdentity: "b".repeat(64) }), "ready");
assert.equal(store.getTask(task.id)?.lifecycleEpoch, 9);
});
test("dispatch rejects a later pause or cancellation before changing identity", async (t) => {
const { latchTransferPause, resetTransferPauseLatchesForTests } = await import("./transferPauseLatch");
const { markTransferCancelledTree, settleTransferCancelTree } = await import("./transferCancelLatch");
const store = createSftpTransferCenterStore();
const task = { ...child(), status: "failed" as const };
store.upsertTasks([task]);
t.after(() => { resetTransferPauseLatchesForTests(); settleTransferCancelTree("root", [task.id]); });
latchTransferPause("root");
assert.equal(store.admitTaskRun(task), "paused");
resetTransferPauseLatchesForTests();
markTransferCancelledTree("root", [task.id]);
assert.equal(store.admitTaskRun(task), "cancelled");
assert.equal(store.getTask(task.id)?.status, "failed");
});
for (const nextStatus of ["transferring", "completed", "cancelled"] as const) {
test(`dispatch waits through a later pause until ${nextStatus}`, async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
const task = { ...child(), id: `paused-admission-${nextStatus}`, parentTaskId: undefined };
store.upsertTasks([{ ...task, status: "paused" }]);
let starts = 0;
let abort = false;
const running = runTransferAndWaitForOwner(task, async () => { starts += 1; return {}; }, () => abort);
// Attach rejection handling before the cancellation event is delivered.
const settled = running.then(() => "completed", (error: Error) => error.message);
t.after(async () => { abort = true; await settled; store.dismiss(task.id); });
await new Promise((resolve) => setTimeout(resolve, 30));
assert.equal(starts, 0);
assert.equal(store.getTask(task.id)?.status, "paused");
if (nextStatus !== "cancelled") store.patchTask(task.id, { status: "transferring", lifecycleEpoch: 1 });
if (nextStatus === "cancelled") {
const { markTransferCancelledTree, settleTransferCancelTree } = await import("./transferCancelLatch");
markTransferCancelledTree(task.id, []);
t.after(() => settleTransferCancelTree(task.id, []));
} else store.patchTask(task.id, { status: nextStatus });
const outcome = await Promise.race([settled, new Promise((resolve) => setTimeout(() => resolve("still-waiting"), 1000))]);
assert.equal(outcome, nextStatus === "cancelled" ? "Transfer cancelled" : "completed");
assert.equal(starts, nextStatus === "transferring" ? 1 : 0);
});
}
test("completed dispatch consumes only exact identity and never restarts", async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
const task = { ...child(), id: "completed-admission", parentTaskId: undefined };
store.upsertTasks([{ ...task, status: "completed" }]);
t.after(() => store.dismiss(task.id));
const start = async () => { assert.fail("completed transfer must not restart"); };
await runTransferAndWaitForOwner(task, start, () => false);
await assert.rejects(runTransferAndWaitForOwner({ ...task, directoryEntryIdentity: "b".repeat(64) }, start, () => false), /identity changed/);
assert.equal(store.getTask(task.id)?.status, "completed");
});
test("a resumed retry does not inherit failed settlement captured while admission was paused", async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
const { latchTransferPause, resetTransferPauseLatchesForTests } = await import("./transferPauseLatch");
const task = { ...child(), id: "failed-paused-admission", parentTaskId: undefined };
store.upsertTasks([{ ...task, status: "failed", error: "previous attempt failed" }]);
latchTransferPause(task.id);
let abort = false;
const running = runTransferAndWaitForOwner(task, async () => {
store.patchTask(task.id, { status: "completed" });
return { superseded: true };
}, () => abort);
const settled = running.then(() => "completed", (error: Error) => error.message);
t.after(async () => { abort = true; resetTransferPauseLatchesForTests(); await settled; store.dismiss(task.id); });
// An unrelated lifecycle publication captures the previous failed row while waiting.
store.upsertTasks([{ ...child(), id: "unrelated-admission", parentTaskId: undefined }]);
t.after(() => store.dismiss("unrelated-admission"));
resetTransferPauseLatchesForTests();
assert.equal(await Promise.race([settled, new Promise((resolve) => setTimeout(() => resolve("still-waiting"), 1000))]), "completed");
});
test("fresh directory recovery authorizes only an unchanged retained pause under an active parent", () => {
const store = createSftpTransferCenterStore();
const task = child();
store.upsertTasks([{ ...task, id: "root", parentTaskId: undefined, isDirectory: true, status: "pending" }, { ...task, status: "paused", lifecycleEpoch: 4 }]);
const paused = store.getTask(task.id)!;
assert.equal(store.admitTaskRun(task), "paused", "ordinary live dispatch must respect cross-window pause");
assert.equal(store.admitTaskRun(task, paused), "ready");
store.patchTask(task.id, { status: "paused", lifecycleEpoch: 5 });
assert.equal(store.admitTaskRun(task, paused), "paused", "newer child pause invalidates fresh recovery permission");
const currentPause = store.getTask(task.id)!;
store.patchTask("root", { status: "paused", lifecycleEpoch: 6 });
assert.equal(store.admitTaskRun(task, currentPause), "paused", "parent pause still wins");
});
@@ -102,6 +102,49 @@ test("assertUploadEndpointUnchanged allows same-host reconnect with new connecti
));
});
test("a strict upload pin rejects a same-endpoint connection replacement", () => {
const map = new Map<string, string>([
["conn-old", "key-a"],
["conn-new", "key-a"],
]);
const expected = captureUploadEndpoint(
pane({ id: "t", connectionId: "conn-old", hostId: "host-a" }).connection!,
map,
{ pinConnectionId: true },
);
assert.throws(() => assertUploadEndpointUnchanged(
pane({ id: "t", connectionId: "conn-new", hostId: "host-a" }).connection!,
expected,
map,
), /Upload target changed/);
});
test("a slow strict upload probe cannot continue after its tab is rebound", async () => {
const map = new Map<string, string>([
["conn-old", "key-a"],
["conn-new", "key-a"],
]);
let livePane = pane({ id: "t", connectionId: "conn-old", hostId: "host-a" });
const expected = captureUploadEndpoint(livePane.connection!, map, {
pinConnectionId: true,
});
let resolveProbe!: () => void;
const probe = new Promise<void>((resolve) => { resolveProbe = resolve; });
let uploadCreated = false;
const start = async () => {
await probe;
assertUploadEndpointUnchanged(livePane.connection!, expected, map);
uploadCreated = true;
};
const pending = start();
livePane = pane({ id: "t", connectionId: "conn-new", hostId: "host-a" });
resolveProbe();
await assert.rejects(pending, /Upload target changed/);
assert.equal(uploadCreated, false);
});
test("carried endpoint pin rejects a retargeted tab mid multi-folder upload", () => {
// Simulate paste-time pin for host-a; later folder call resolves tab on host-b.
const mapAtPaste = new Map<string, string>([["conn-1", "key-a"]]);
@@ -4,11 +4,14 @@ export type UploadEndpointPin = {
isLocal: boolean;
hostId: string | null;
cacheKey: string | null;
/** When present, reconnecting the same tab is still a target change. */
connectionId?: string;
};
export function captureUploadEndpoint(
connection: NonNullable<SftpPane["connection"]>,
connectionCacheKeyMap: Map<string, string>,
options?: { pinConnectionId?: boolean },
): UploadEndpointPin {
return {
isLocal: connection.isLocal,
@@ -16,6 +19,7 @@ export function captureUploadEndpoint(
cacheKey: connection.isLocal
? "local"
: (connectionCacheKeyMap.get(connection.id) ?? null),
...(options?.pinConnectionId ? { connectionId: connection.id } : undefined),
};
}
@@ -24,6 +28,9 @@ export function assertUploadEndpointUnchanged(
expected: UploadEndpointPin,
connectionCacheKeyMap: Map<string, string>,
): void {
if (expected.connectionId && connection.id !== expected.connectionId) {
throw new Error("Upload target changed before the transfer started");
}
if (connection.isLocal !== expected.isLocal) {
throw new Error("Upload target changed before the transfer started");
}
@@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import test, { after } from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { usePendingSftpUploadRebind } from "./usePendingSftpUploadRebind";
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
after(() => { actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment; });
test("a repeated drop can share a slow strict connect without losing completion", async () => {
let resolveConnect!: () => void;
const sharedConnect = new Promise<void>((resolve) => {
resolveConnect = resolve;
});
let latest: ReturnType<typeof usePendingSftpUploadRebind> | null = null;
let renderer: ReactTestRenderer | null = null;
function Probe() {
latest = usePendingSftpUploadRebind();
return null;
}
await act(async () => {
renderer = create(React.createElement(Probe));
});
act(() => {
latest!.start({
requestId: "drop-1",
previousConnectionId: "old-connection",
connect: () => sharedConnect,
});
latest!.start({
requestId: "drop-2",
previousConnectionId: "connecting-connection",
connect: () => sharedConnect,
});
latest!.bindTarget("drop-2", {
tabId: "new-tab",
connectionId: "new-connection",
});
});
assert.equal(latest!.startedRequestIdRef.current, "drop-2");
assert.equal(latest!.settledRequestId, null);
assert.deepEqual(latest!.barrierRef.current, {
requestId: "drop-2",
previousConnectionId: "connecting-connection",
targetTabId: "new-tab",
targetConnectionId: "new-connection",
});
await act(async () => {
resolveConnect();
await sharedConnect;
});
assert.equal(latest!.settledRequestId, "drop-2");
act(() => renderer!.unmount());
});
@@ -0,0 +1,91 @@
import { useCallback, useRef, useState } from "react";
export interface PendingSftpUploadRebindBarrier {
requestId: string;
previousConnectionId: string | null;
targetTabId?: string;
targetConnectionId?: string;
}
export interface PendingSftpUploadRebindTarget {
tabId: string;
connectionId: string;
}
interface StartPendingSftpUploadRebindParams {
requestId: string;
previousConnectionId: string | null;
connect: () => Promise<void>;
}
/**
* Tracks the strict SFTP connect attempt owned by the latest terminal drop.
* A later drop may share the same in-flight connect promise, so completion is
* tracked by request generation instead of relying only on connection-id churn.
*/
export function usePendingSftpUploadRebind() {
const startedRequestIdRef = useRef<string | null>(null);
const barrierRef = useRef<PendingSftpUploadRebindBarrier | null>(null);
const generationRef = useRef(0);
const [settledRequestId, setSettledRequestId] = useState<string | null>(null);
const start = useCallback((params: StartPendingSftpUploadRebindParams) => {
const generation = generationRef.current + 1;
generationRef.current = generation;
startedRequestIdRef.current = params.requestId;
barrierRef.current = {
requestId: params.requestId,
previousConnectionId: params.previousConnectionId,
};
setSettledRequestId(null);
const settle = () => {
if (
generationRef.current === generation
&& startedRequestIdRef.current === params.requestId
) {
setSettledRequestId(params.requestId);
}
};
void params.connect().then(settle, settle);
}, []);
const bindTarget = useCallback((
requestId: string,
target: PendingSftpUploadRebindTarget,
) => {
const barrier = barrierRef.current;
if (
!barrier
|| barrier.requestId !== requestId
|| startedRequestIdRef.current !== requestId
) return;
barrierRef.current = {
...barrier,
targetTabId: target.tabId,
targetConnectionId: target.connectionId,
};
}, []);
const clearBarrier = useCallback((requestId?: string) => {
if (requestId && barrierRef.current?.requestId !== requestId) return;
barrierRef.current = null;
}, []);
const reset = useCallback(() => {
generationRef.current += 1;
startedRequestIdRef.current = null;
barrierRef.current = null;
setSettledRequestId(null);
}, []);
return {
barrierRef,
bindTarget,
clearBarrier,
reset,
settledRequestId,
start,
startedRequestIdRef,
};
}
@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import test, { after } from "node:test";
import React, { Suspense } from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { useSftpBrowseConnectionLifecycle } from "./useSftpBrowseConnectionLifecycle";
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
after(() => { actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment; });
test("an uncommitted hidden render cannot invalidate live browse connections", async () => {
const never = new Promise<void>(() => {});
let lifecycleRef: ReturnType<typeof useSftpBrowseConnectionLifecycle> | null = null;
let renderer: ReactTestRenderer | null = null;
function Probe({ interactive }: { interactive: boolean }) {
const nextRef = useSftpBrowseConnectionLifecycle(interactive);
if (!interactive) throw never;
lifecycleRef = nextRef;
return null;
}
await act(async () => {
renderer = create(React.createElement(
Suspense,
{ fallback: null },
React.createElement(Probe, { interactive: true }),
));
});
assert.deepEqual(lifecycleRef!.current, { generation: 0, interactive: true });
await act(async () => {
renderer!.update(React.createElement(
Suspense,
{ fallback: null },
React.createElement(Probe, { interactive: false }),
));
});
assert.deepEqual(lifecycleRef!.current, { generation: 0, interactive: true });
act(() => renderer!.unmount());
});
@@ -0,0 +1,26 @@
import { useLayoutEffect, useRef } from "react";
import type { MutableRefObject } from "react";
export interface SftpBrowseConnectionLifecycle {
generation: number;
interactive: boolean;
}
export function useSftpBrowseConnectionLifecycle(
interactive: boolean,
): MutableRefObject<SftpBrowseConnectionLifecycle> {
const lifecycleRef = useRef<SftpBrowseConnectionLifecycle>({
generation: 0,
interactive,
});
useLayoutEffect(() => {
if (lifecycleRef.current.interactive === interactive) return;
lifecycleRef.current = {
generation: lifecycleRef.current.generation + 1,
interactive,
};
}, [interactive]);
return lifecycleRef;
}
+458 -10
View File
@@ -3,20 +3,37 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import type { Host } from "../../../domain/models";
import type { SftpPane } from "./types";
import {
applyToLiveSftpTabSide,
beginSftpTabConnectRequest,
buildSftpConnectInFlightKey,
buildSftpHomeDirCandidates,
clearSftpConnectInFlightForTab,
closeSftpTabLifecycle,
runSftpConnectOnceByKey,
createSftpConnectionId,
createPinnedReconnectSideResolver,
finishSftpTabConnectRequest,
invalidateSftpTabConnectRequest,
isSftpHostKeySessionCurrent,
isSftpTabConnectRequestCurrent,
openSftpConnectionOnce,
openSftpWithSessionPreference,
rejectHostKeyVerificationRequest,
registerOpenedSftpSession,
resolveSftpPaneEndpointKey,
resolveSftpReconnectAttempt,
takeSftpConnectionMetadataForClose,
releaseSftpConnectionMetadata,
resolvePinnedReconnectSide,
resolveSftpReconnectOptions,
resolveSftpReconnectSchedule,
resolveSftpReconnectHost,
runSftpTabDisconnectIfLatest,
settleFailedSftpConnectIfCurrent,
} from "./useSftpConnections.ts";
test("home dir candidates prefer user home then root", () => {
@@ -54,6 +71,18 @@ test("runSftpConnectOnceByKey shares an in-flight connect for the same tab and e
assert.equal(inFlight.size, 0);
});
test("disconnect detaches only that tab's in-flight connects", () => {
const pending = Promise.resolve();
const inFlight = new Map<string, Promise<void>>([
[`tab-a\u0000host-a\u0000`, pending],
[`tab-b\u0000host-a\u0000`, pending],
]);
clearSftpConnectInFlightForTab(inFlight, "tab-a");
assert.deepEqual([...inFlight.keys()], ["tab-b\u0000host-a\u0000"]);
});
test("buildSftpConnectInFlightKey uses the allocated tab id for forced new tabs", () => {
const first = buildSftpConnectInFlightKey({
side: "left",
@@ -75,6 +104,260 @@ test("buildSftpConnectInFlightKey uses the allocated tab id for forced new tabs"
assert.notEqual(first, second);
});
test("moving an in-flight reconnect does not create a second connection", () => {
const base = {
tabId: "moving-tab",
targetConnectionKey: "host-key",
sourceSessionId: "ssh-session-1",
initialPath: "/home",
};
assert.equal(
buildSftpConnectInFlightKey({ ...base, side: "left" }),
buildSftpConnectInFlightKey({ ...base, side: "right" }),
);
});
test("the newest request for a moved tab wins after an older close finishes", () => {
const requests = new Map<string, symbol>();
const oldRequest = beginSftpTabConnectRequest(requests, "tab-moving");
const newRequest = beginSftpTabConnectRequest(requests, "tab-moving");
assert.equal(isSftpTabConnectRequestCurrent(requests, "tab-moving", oldRequest), false);
assert.equal(isSftpTabConnectRequestCurrent(requests, "tab-moving", newRequest), true);
finishSftpTabConnectRequest(requests, "tab-moving", oldRequest);
assert.equal(isSftpTabConnectRequestCurrent(requests, "tab-moving", newRequest), true);
invalidateSftpTabConnectRequest(requests, "tab-moving");
assert.equal(isSftpTabConnectRequestCurrent(requests, "tab-moving", newRequest), false);
});
test("a slow disconnect cannot clear a newer connection on the same tab", async () => {
const requests = new Map<string, symbol>();
let releaseClose: (() => void) | undefined;
let clears = 0;
const disconnect = runSftpTabDisconnectIfLatest({
requests,
tabId: "tab-a",
disconnect: async () => new Promise<void>((resolve) => {
releaseClose = resolve;
}),
clear: () => {
clears += 1;
},
});
await Promise.resolve();
const newerConnect = beginSftpTabConnectRequest(requests, "tab-a");
finishSftpTabConnectRequest(requests, "tab-a", newerConnect);
releaseClose?.();
assert.equal(await disconnect, false);
assert.equal(clears, 0);
});
test("a slow disconnect clears a tab after it moves to the other pane", async () => {
const requests = new Map<string, symbol>();
let leftTabs: ReadonlyArray<{ id: string }> = [{ id: "tab-moving" }];
let rightTabs: ReadonlyArray<{ id: string }> = [];
let releaseClose: (() => void) | undefined;
let clearedSide: "left" | "right" | null = null;
const disconnect = runSftpTabDisconnectIfLatest({
requests,
tabId: "tab-moving",
disconnect: async () => new Promise<void>((resolve) => {
releaseClose = resolve;
}),
clear: () => {
applyToLiveSftpTabSide({
requestedSide: "left",
tabId: "tab-moving",
leftTabs,
rightTabs,
apply: (side) => {
clearedSide = side;
},
});
},
});
await Promise.resolve();
leftTabs = [];
rightTabs = [{ id: "tab-moving" }];
releaseClose?.();
assert.equal(await disconnect, true);
assert.equal(clearedSide, "right");
});
test("closing a tab removes it before a slow connection release", async () => {
const requests = new Map<string, symbol>();
beginSftpTabConnectRequest(requests, "tab-a");
const pending = Promise.resolve();
const inFlight = new Map<string, Promise<void>>([
["tab-a\u0000host-a", pending],
["tab-b\u0000host-b", pending],
]);
const connectedHosts = new Map<string, Host | "local">([["tab-a", "local"]]);
let releaseClose: (() => void) | undefined;
let closedSide: "left" | "right" | null = null;
const closing = closeSftpTabLifecycle({
requestedSide: "left",
tabId: "tab-a",
leftTabs: [{ id: "tab-a" }],
rightTabs: [],
connectRequests: requests,
connectInFlight: inFlight,
connectedHosts,
closeTab: (side) => {
closedSide = side;
},
releaseConnection: async () => new Promise<void>((resolve) => {
releaseClose = resolve;
}),
});
assert.equal(closedSide, "left");
assert.equal(requests.has("tab-a"), false);
assert.deepEqual([...inFlight.keys()], ["tab-b\u0000host-b"]);
assert.equal(connectedHosts.has("tab-a"), false);
releaseClose?.();
await closing;
});
test("a slow failed-request cleanup cannot overwrite a newer successful connection", async () => {
let current = true;
let releaseClose: (() => void) | undefined;
let failuresWritten = 0;
const settlement = settleFailedSftpConnectIfCurrent({
isCurrent: () => current,
close: async () => new Promise<void>((resolve) => {
releaseClose = resolve;
}),
updateFailure: () => {
failuresWritten += 1;
},
});
await Promise.resolve();
current = false;
releaseClose?.();
assert.equal(await settlement, false);
assert.equal(failuresWritten, 0);
});
test("stale host-key prompts do not belong to a replacement connection", () => {
const requests = new Map<string, symbol>();
const oldToken = beginSftpTabConnectRequest(requests, "tab-a");
const oldOwner = { tabId: "tab-a", connectRequestToken: oldToken };
assert.equal(isSftpHostKeySessionCurrent(requests, oldOwner), true);
beginSftpTabConnectRequest(requests, "tab-a");
assert.equal(isSftpHostKeySessionCurrent(requests, oldOwner), false);
});
test("reconnect state only follows the endpoint that originally failed", () => {
assert.equal(resolveSftpReconnectAttempt({
isPinnedBackgroundReconnect: false,
previousPaneReconnecting: true,
previousConnectionKey: "host-a",
targetConnectionKey: "host-a",
}), true);
assert.equal(resolveSftpReconnectAttempt({
isPinnedBackgroundReconnect: false,
previousPaneReconnecting: true,
previousConnectionKey: "host-a",
targetConnectionKey: "host-b",
}), false);
assert.equal(resolveSftpReconnectAttempt({
isPinnedBackgroundReconnect: true,
initialPath: "/srv/app",
previousPaneReconnecting: false,
previousConnectionKey: null,
targetConnectionKey: "host-a",
}), true);
});
test("reconnect keeps its endpoint identity after released connection metadata is gone", () => {
const hostA = {
id: "host-a",
label: "A",
hostname: "a.example",
port: 22,
username: "alice",
protocol: "ssh",
} as Host;
const hostB = {
id: "host-b",
label: "B",
hostname: "b.example",
port: 22,
username: "bob",
protocol: "ssh",
} as Host;
const pane = {
id: "tab-a",
reconnecting: true,
connection: {
id: "released-connection-a",
hostId: hostA.id,
hostLabel: hostA.label,
isLocal: false,
status: "disconnected",
currentPath: "/srv/app",
},
} as SftpPane;
const previousConnectionKey = resolveSftpPaneEndpointKey({
connection: pane.connection,
cachedConnectionKey: null,
connectedHost: hostA,
});
const hostAKey = resolveSftpPaneEndpointKey({
connection: pane.connection,
cachedConnectionKey: null,
connectedHost: hostA,
});
const hostBKey = resolveSftpPaneEndpointKey({
connection: {
...pane.connection!,
hostId: hostB.id,
},
cachedConnectionKey: null,
connectedHost: hostB,
});
assert.equal(resolveSftpReconnectAttempt({
isPinnedBackgroundReconnect: false,
previousPaneReconnecting: pane.reconnecting,
previousConnectionKey,
targetConnectionKey: hostAKey!,
}), true);
assert.equal(resolveSftpReconnectAttempt({
isPinnedBackgroundReconnect: false,
previousPaneReconnecting: pane.reconnecting,
previousConnectionKey,
targetConnectionKey: hostBKey!,
}), false);
});
test("forced terminal rebinds never merge into an older in-flight route", () => {
const base = {
side: "left" as const,
tabId: "tab-1",
targetConnectionKey: "host-key",
initialPath: "/srv/app",
};
const oldRoute = buildSftpConnectInFlightKey(base);
const newRouteDrop = buildSftpConnectInFlightKey({
...base,
connectRequestKey: "drop-route-b",
});
assert.notEqual(oldRoute, newRouteDrop);
});
const openOptions = {
sessionId: "sftp-request-1",
hostname: "192.168.9.138",
@@ -127,6 +410,33 @@ test("openSftpWithSessionPreference falls back to normal SFTP when session reuse
assert.deepEqual(calls, ["openForSession:ssh-session-1", "openSftp:sftp-request-1"]);
});
test("strict source-session reuse never dials a different route after reuse fails", async () => {
const calls: string[] = [];
let receivedOptions: NetcattySSHOptions | undefined;
await assert.rejects(
openSftpWithSessionPreference({
bridge: {
openSftpForSession: async (sessionId: string, options?: NetcattySSHOptions) => {
calls.push(`openForSession:${sessionId}`);
receivedOptions = options;
throw new Error("channel unavailable");
},
openSftp: async () => {
calls.push("openSftp");
return "fresh-sftp";
},
},
sourceSessionId: "ssh-session-1",
requireSourceSessionReuse: true,
openOptions,
}),
/channel unavailable/,
);
assert.deepEqual(calls, ["openForSession:ssh-session-1"]);
assert.equal(receivedOptions?.requireExactSourceSession, true);
});
test("openSftpWithSessionPreference tries session reuse for sudo SFTP before fresh auth", async () => {
const calls: string[] = [];
let passedOptions: NetcattySSHOptions | undefined;
@@ -156,14 +466,6 @@ test("openSftpWithSessionPreference tries session reuse for sudo SFTP before fre
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({
@@ -248,6 +550,51 @@ test("release metadata closes the backend before a failed connection is retained
assert.equal(cacheKeys.size, 0);
});
test("a connection that finishes after its owner unmounts is closed instead of registered", async () => {
const sftpSessions = new Map<string, string>();
const closed: string[] = [];
const notified: string[] = [];
const disposedRef = { current: true };
const registered = await registerOpenedSftpSession({
disposedRef,
connectionId: "connection-late",
sftpId: "sftp-late",
sftpSessions,
closeSftp: async (sftpId) => { closed.push(sftpId); },
onRemoteSessionClosed: (sftpId) => { notified.push(sftpId); },
});
assert.equal(registered, false);
assert.equal(sftpSessions.size, 0);
assert.deepEqual(closed, ["sftp-late"]);
assert.deepEqual(notified, ["sftp-late"]);
});
test("a connection that finishes after browse parking is closed instead of registered", async () => {
const sftpSessions = new Map<string, string>();
const closed: string[] = [];
const lifecycle = { generation: 1, interactive: true };
const openedGeneration = lifecycle.generation;
lifecycle.generation += 1;
lifecycle.interactive = false;
const registered = await registerOpenedSftpSession({
disposedRef: { current: false },
canRegister: () => (
lifecycle.interactive && lifecycle.generation === openedGeneration
),
connectionId: "connection-parked",
sftpId: "sftp-parked",
sftpSessions,
closeSftp: async (sftpId) => { closed.push(sftpId); },
});
assert.equal(registered, false);
assert.equal(sftpSessions.size, 0);
assert.deepEqual(closed, ["sftp-parked"]);
});
test("releaseSftpConnectionMetadata notifies after a remote session closes", async () => {
const sessions = new Map([["connection-1", "sftp-1"]]);
const cacheKeys = new Map([["connection-1", "cache-1"]]);
@@ -301,7 +648,7 @@ test("rejectHostKeyVerificationRequest rejects an orphaned verification", () =>
assert.deepEqual(responses, [["hostkey-1", false, false]]);
});
test("pinned reconnect side resolver follows moves across async boundaries", async () => {
test("a newly allocated forced-connect tab follows moves across async boundaries", async () => {
let leftTabs: ReadonlyArray<{ id: string }> = [{ id: "tab-1" }];
let rightTabs: ReadonlyArray<{ id: string }> = [];
const resolveSide = createPinnedReconnectSideResolver(
@@ -322,3 +669,104 @@ test("pinned reconnect side resolver follows moves across async boundaries", asy
rightTabs = [];
assert.equal(resolveSide(), "right");
});
test("a just-allocated connect tab uses its known side until React state commits", () => {
let leftTabs: ReadonlyArray<{ id: string }> = [];
let rightTabs: ReadonlyArray<{ id: string }> = [];
const resolveSide = createPinnedReconnectSideResolver(
"left",
"new-tab",
() => leftTabs,
() => rightTabs,
);
assert.equal(resolveSide(), "left");
leftTabs = [{ id: "new-tab" }];
assert.equal(resolveSide(), "left");
leftTabs = [];
rightTabs = [{ id: "new-tab" }];
assert.equal(resolveSide(), "right");
});
test("reconnect uses the active tab host instead of the side's previous host", () => {
const hostA = {
id: "host-a",
label: "A",
hostname: "a.example",
port: 22,
username: "alice",
protocol: "ssh",
} as Host;
const hostB = {
id: "host-b",
label: "B",
hostname: "b.example",
port: 22,
username: "bob",
protocol: "ssh",
} as Host;
const activePane = {
id: "tab-a-moved-right",
connection: {
id: "connection-a",
hostId: "host-a",
hostLabel: "A",
isLocal: false,
status: "disconnected",
currentPath: "/home/alice",
},
} as SftpPane;
assert.equal(resolveSftpReconnectHost({
pane: activePane,
lastHost: hostB,
connectedHostByTabId: new Map([[activePane.id, hostA]]),
hosts: [hostA, hostB],
}), hostA);
});
test("a reconnecting tab moved to the other pane still schedules recovery", () => {
const movedPane = {
id: "tab-a",
reconnecting: true,
connection: {
id: "connection-a",
hostId: "host-a",
hostLabel: "A",
isLocal: false,
status: "disconnected",
currentPath: "/home/alice",
},
} as SftpPane;
assert.deepEqual(resolveSftpReconnectSchedule({
requestedSide: "left",
pane: movedPane,
leftTabs: [],
rightTabs: [movedPane],
}), { side: "right", tabId: "tab-a" });
});
test("automatic reconnect retries the source session then allows a fresh open", () => {
const pane = {
id: "tab-a",
connection: {
id: "connection-a",
hostId: "host-a",
hostLabel: "A",
isLocal: false,
status: "disconnected",
currentPath: "/home/alice",
sourceSessionId: "ssh-a",
},
} as SftpPane;
assert.deepEqual(resolveSftpReconnectOptions(pane), {
tabId: "tab-a",
sourceSessionId: "ssh-a",
});
assert.equal(
"requireSourceSessionReuse" in resolveSftpReconnectOptions(pane),
false,
);
});
+538 -130
View File
@@ -34,10 +34,16 @@ interface UseSftpConnectionsParams {
navSeqRef: MutableRefObject<{ left: number; right: number }>;
dirCacheRef: MutableRefObject<DirectoryListingCache>;
sftpSessionsRef: MutableRefObject<Map<string, string>>;
disposedRef: MutableRefObject<boolean>;
browseConnectionLifecycleRef: MutableRefObject<{
generation: number;
interactive: boolean;
}>;
lastConnectedHostRef: MutableRefObject<{ left: Host | "local" | null; right: Host | "local" | null }>;
connectionCacheKeyMapRef: MutableRefObject<Map<string, string>>;
connectedHostByTabIdRef: MutableRefObject<Map<string, Host | "local">>;
reconnectingRef: MutableRefObject<{ left: boolean; right: boolean }>;
connectInFlightRef: MutableRefObject<Map<string, Promise<void>>>;
connectRequestByTabIdRef: MutableRefObject<Map<string, symbol>>;
makeCacheKey: (connectionId: string, path: string, encoding?: SftpFilenameEncoding) => string;
clearCacheForConnection: (connectionId: string) => void;
createEmptyPane: (id?: string, showHiddenFiles?: boolean) => SftpPane;
@@ -54,6 +60,12 @@ export interface SftpConnectOptions {
tabId?: string;
onTabCreated?: (tabId: string) => void;
sourceSessionId?: string;
/** Fail instead of opening a new route when the requested terminal transport cannot be reused. */
requireSourceSessionReuse?: boolean;
/** Prevent a forced terminal-drop route rebind from sharing an older open. */
connectRequestKey?: string;
/** Identifies the exact tab and connection created for a route-bound request. */
onConnectionCreated?: (target: { tabId: string; connectionId: string }) => void;
}
type SftpOpenBridge = Pick<NetcattyBridge, "openSftp"> &
@@ -62,6 +74,7 @@ type SftpOpenBridge = Pick<NetcattyBridge, "openSftp"> &
interface OpenSftpWithSessionPreferenceParams {
bridge: SftpOpenBridge | null | undefined;
sourceSessionId?: string;
requireSourceSessionReuse?: boolean;
openOptions: NetcattySSHOptions;
}
@@ -97,6 +110,30 @@ export async function releaseSftpConnectionMetadata(params: {
params.onRemoteSessionClosed?.(sftpId);
}
/** Register a completed open, or close it immediately if its owner unmounted. */
export async function registerOpenedSftpSession(params: {
disposedRef: MutableRefObject<boolean>;
canRegister?: () => boolean;
connectionId: string;
sftpId: string;
sftpSessions: Map<string, string>;
closeSftp: (sftpId: string) => Promise<unknown>;
onRemoteSessionClosed?: (sftpId: string) => void;
}): Promise<boolean> {
if (!params.disposedRef.current && (params.canRegister?.() ?? true)) {
params.sftpSessions.set(params.connectionId, params.sftpId);
return true;
}
try {
await params.closeSftp(params.sftpId);
} catch {
// Best-effort: backend owner cleanup remains the final safety net.
}
params.onRemoteSessionClosed?.(params.sftpId);
return false;
}
/** Hardcoded home-path candidates when SSH exec / listable realpath fail. */
export function buildSftpHomeDirCandidates(username?: string | null): string[] {
if (username === "root") return ["/root"];
@@ -115,13 +152,20 @@ export function createSftpConnectionId(
export async function openSftpWithSessionPreference({
bridge,
sourceSessionId,
requireSourceSessionReuse = false,
openOptions,
}: OpenSftpWithSessionPreferenceParams): Promise<string> {
if (!bridge?.openSftp) throw new Error("SFTP bridge unavailable");
if (requireSourceSessionReuse && (!sourceSessionId || !bridge.openSftpForSession)) {
throw new Error("The requested terminal connection is no longer available");
}
if (sourceSessionId && bridge.openSftpForSession) {
try {
return await bridge.openSftpForSession(sourceSessionId, openOptions);
} catch {
return await bridge.openSftpForSession(sourceSessionId, requireSourceSessionReuse
? { ...openOptions, requireExactSourceSession: true }
: openOptions);
} catch (error) {
if (requireSourceSessionReuse) throw error;
// Fall through to the existing SFTP open path so users still get a usable
// file browser when the live SSH transport cannot provide an SFTP channel.
}
@@ -161,12 +205,18 @@ export function createPinnedReconnectSideResolver(
getLeftTabs: () => ReadonlyArray<{ id: string }>,
getRightTabs: () => ReadonlyArray<{ id: string }>,
): () => "left" | "right" {
let lastResolvedSide = resolvePinnedReconnectSide(
requestedSide,
tabId,
getLeftTabs(),
getRightTabs(),
);
let lastResolvedSide = requestedSide;
try {
lastResolvedSide = resolvePinnedReconnectSide(
requestedSide,
tabId,
getLeftTabs(),
getRightTabs(),
);
} catch {
// A newly allocated tab is not visible in React refs until the state update
// commits. Its requested side is authoritative during that brief window.
}
return () => {
try {
lastResolvedSide = resolvePinnedReconnectSide(
@@ -183,6 +233,202 @@ export function createPinnedReconnectSideResolver(
};
}
export function applyToLiveSftpTabSide(params: {
requestedSide: "left" | "right";
tabId: string;
leftTabs: ReadonlyArray<{ id: string }>;
rightTabs: ReadonlyArray<{ id: string }>;
apply: (side: "left" | "right") => void;
}): boolean {
try {
const liveSide = resolvePinnedReconnectSide(
params.requestedSide,
params.tabId,
params.leftTabs,
params.rightTabs,
);
params.apply(liveSide);
return true;
} catch {
return false;
}
}
export function resolveSftpReconnectSchedule(params: {
requestedSide: "left" | "right";
pane: SftpPane | null | undefined;
leftTabs: ReadonlyArray<{ id: string }>;
rightTabs: ReadonlyArray<{ id: string }>;
}): { side: "left" | "right"; tabId: string } | null {
if (!params.pane?.reconnecting) return null;
try {
return {
side: resolvePinnedReconnectSide(
params.requestedSide,
params.pane.id,
params.leftTabs,
params.rightTabs,
),
tabId: params.pane.id,
};
} catch {
return null;
}
}
/** Retry the last terminal transport, then fall through to a fresh/sudo open. */
export function resolveSftpReconnectOptions(
pane: SftpPane,
): SftpConnectOptions {
const sourceSessionId = pane.connection?.sourceSessionId;
return {
tabId: pane.id,
...(sourceSessionId ? { sourceSessionId } : undefined),
};
}
export function resolveSftpReconnectAttempt(params: {
isPinnedBackgroundReconnect: boolean;
initialPath?: string;
previousPaneReconnecting?: boolean;
previousConnectionKey: string | null;
targetConnectionKey: string;
}): boolean {
if (params.isPinnedBackgroundReconnect) return !!params.initialPath;
return Boolean(
params.previousPaneReconnecting
&& params.previousConnectionKey === params.targetConnectionKey,
);
}
export function resolveSftpPaneEndpointKey(params: {
connection: SftpConnection | null | undefined;
cachedConnectionKey?: string | null;
connectedHost?: Host | "local" | null;
}): string | null {
const { connection } = params;
if (!connection) return null;
if (connection.isLocal) return "local";
if (params.cachedConnectionKey) return params.cachedConnectionKey;
const connectedHost = params.connectedHost;
if (!connectedHost || connectedHost === "local" || connectedHost.id !== connection.hostId) {
return null;
}
return buildCacheKey(
connectedHost.id,
connectedHost.hostname,
connectedHost.port,
connectedHost.protocol,
connectedHost.sftpSudo,
connectedHost.username,
connectedHost.sftpFileProtocol,
);
}
export function beginSftpTabConnectRequest(
requests: Map<string, symbol>,
tabId: string,
): symbol {
const token = Symbol(tabId);
requests.set(tabId, token);
return token;
}
export function isSftpTabConnectRequestCurrent(
requests: ReadonlyMap<string, symbol>,
tabId: string,
token: symbol,
): boolean {
return requests.get(tabId) === token;
}
export function finishSftpTabConnectRequest(
requests: Map<string, symbol>,
tabId: string,
token: symbol,
): void {
if (isSftpTabConnectRequestCurrent(requests, tabId, token)) {
requests.delete(tabId);
}
}
export function invalidateSftpTabConnectRequest(
requests: Map<string, symbol>,
tabId: string,
): void {
requests.delete(tabId);
}
export function isSftpHostKeySessionCurrent(
requests: ReadonlyMap<string, symbol>,
owner: { tabId: string; connectRequestToken: symbol },
): boolean {
return isSftpTabConnectRequestCurrent(
requests,
owner.tabId,
owner.connectRequestToken,
);
}
export async function settleFailedSftpConnectIfCurrent(params: {
isCurrent: () => boolean;
close: () => Promise<void>;
updateFailure: () => void;
}): Promise<boolean> {
if (!params.isCurrent()) {
await params.close();
return false;
}
await params.close();
if (!params.isCurrent()) return false;
params.updateFailure();
return true;
}
export async function runSftpTabDisconnectIfLatest(params: {
requests: Map<string, symbol>;
tabId: string;
disconnect: () => Promise<void>;
clear: () => void;
}): Promise<boolean> {
const token = beginSftpTabConnectRequest(params.requests, params.tabId);
try {
await params.disconnect();
if (!isSftpTabConnectRequestCurrent(params.requests, params.tabId, token)) {
return false;
}
params.clear();
return true;
} finally {
finishSftpTabConnectRequest(params.requests, params.tabId, token);
}
}
export async function closeSftpTabLifecycle(params: {
requestedSide: "left" | "right";
tabId: string;
leftTabs: ReadonlyArray<{ id: string }>;
rightTabs: ReadonlyArray<{ id: string }>;
connectRequests: Map<string, symbol>;
connectInFlight: Map<string, Promise<void>>;
connectedHosts: { delete: (tabId: string) => boolean };
closeTab: (side: "left" | "right") => void;
releaseConnection: () => Promise<void>;
}): Promise<void> {
invalidateSftpTabConnectRequest(params.connectRequests, params.tabId);
clearSftpConnectInFlightForTab(params.connectInFlight, params.tabId);
params.connectedHosts.delete(params.tabId);
applyToLiveSftpTabSide({
requestedSide: params.requestedSide,
tabId: params.tabId,
leftTabs: params.leftTabs,
rightTabs: params.rightTabs,
apply: params.closeTab,
});
await params.releaseConnection();
}
export function runSftpConnectOnceByKey(
inFlight: Map<string, Promise<void>>,
key: string,
@@ -200,24 +446,68 @@ export function runSftpConnectOnceByKey(
return promise;
}
export function clearSftpConnectInFlightForTab(
inFlight: Map<string, Promise<void>>,
tabId: string,
): void {
const prefix = `${tabId}\u0000`;
for (const key of inFlight.keys()) {
if (key.startsWith(prefix)) inFlight.delete(key);
}
}
export function buildSftpConnectInFlightKey(params: {
side: "left" | "right";
tabId: string;
targetConnectionKey: string;
sourceSessionId?: string;
requireSourceSessionReuse?: boolean;
initialPath?: string;
forceNewTab?: boolean;
browseLifecycleGeneration?: number;
connectRequestKey?: string;
}): string {
return [
params.side,
params.tabId,
params.targetConnectionKey,
params.sourceSessionId ?? "",
params.requireSourceSessionReuse ? "strict-source" : "",
params.initialPath ?? "",
params.forceNewTab ? "force-new-tab" : "",
params.browseLifecycleGeneration ?? 0,
params.connectRequestKey ?? "",
].join("\u0000");
}
/** Resolve reconnect identity from the active tab before side-wide history. */
export function resolveSftpReconnectHost(params: {
pane: SftpPane | null | undefined;
lastHost: Host | "local" | null;
connectedHostByTabId: ReadonlyMap<string, Host | "local">;
hosts: ReadonlyArray<Host>;
}): Host | "local" | null {
const connection = params.pane?.connection;
if (!connection) return params.lastHost;
const tabHost = params.pane
? params.connectedHostByTabId.get(params.pane.id) ?? null
: null;
if (connection.isLocal) {
return tabHost === "local" ? tabHost : "local";
}
if (tabHost && tabHost !== "local" && tabHost.id === connection.hostId) {
return tabHost;
}
const vaultHost = params.hosts.find((candidate) => candidate.id === connection.hostId) ?? null;
if (vaultHost) return vaultHost;
if (
params.lastHost
&& params.lastHost !== "local"
&& params.lastHost.id === connection.hostId
) return params.lastHost;
return null;
}
interface UseSftpConnectionsResult {
connect: (side: "left" | "right", host: Host | "local", options?: SftpConnectOptions) => Promise<void>;
disconnect: (side: "left" | "right") => Promise<void>;
@@ -234,6 +524,19 @@ type HostKeyVerificationRequest = SftpHostKeyInfo & {
sessionId?: string;
};
type ActiveHostKeySession = {
side: "left" | "right";
tabId: string;
connectRequestToken: symbol;
};
type PendingHostKeyVerification = SftpHostKeyVerificationState & {
requestId: string;
sessionId: string;
tabId: string;
connectRequestToken: symbol;
};
const toSftpHostKeyInfo = (request: HostKeyVerificationRequest): SftpHostKeyInfo => ({
hostname: request.hostname,
port: request.port || 22,
@@ -271,10 +574,13 @@ export const useSftpConnections = ({
navSeqRef,
dirCacheRef,
sftpSessionsRef,
disposedRef,
browseConnectionLifecycleRef,
lastConnectedHostRef,
connectionCacheKeyMapRef,
connectedHostByTabIdRef,
reconnectingRef,
connectInFlightRef,
connectRequestByTabIdRef,
makeCacheKey,
clearCacheForConnection,
createEmptyPane,
@@ -289,12 +595,11 @@ export const useSftpConnections = ({
const getHostCredentials = useSftpHostCredentials({ hosts, keys, identities, knownHosts, terminalSettings });
const { listLocalFiles, listRemoteFiles } = useSftpDirectoryListing();
const [hostKeyVerification, setHostKeyVerification] = useState<SftpHostKeyVerificationState | null>(null);
const hostKeyVerificationRef = useRef<(SftpHostKeyVerificationState & { requestId: string; sessionId: string }) | null>(null);
const activeHostKeySessionsRef = useRef<Map<string, { side: "left" | "right"; tabId: string }>>(new Map());
const connectInFlightRef = useRef<Map<string, Promise<void>>>(new Map());
const hostKeyVerificationRef = useRef<PendingHostKeyVerification | null>(null);
const activeHostKeySessionsRef = useRef<Map<string, ActiveHostKeySession>>(new Map());
const setPendingHostKeyVerification = useCallback((
next: (SftpHostKeyVerificationState & { requestId: string; sessionId: string }) | null,
next: PendingHostKeyVerification | null,
) => {
hostKeyVerificationRef.current = next;
setHostKeyVerification(next ? {
@@ -309,6 +614,10 @@ export const useSftpConnections = ({
if (!sessionId) return;
const activeSession = activeHostKeySessionsRef.current.get(sessionId);
if (!activeSession) return;
if (!isSftpHostKeySessionCurrent(connectRequestByTabIdRef.current, activeSession)) {
rejectHostKeyVerificationRequest(netcattyBridge.get(), request.requestId);
return;
}
const hostKeyInfo = toSftpHostKeyInfo(request);
const logLine = request.status === "changed"
@@ -334,6 +643,8 @@ export const useSftpConnections = ({
setPendingHostKeyVerification({
requestId: request.requestId,
sessionId,
tabId: activeSession.tabId,
connectRequestToken: activeSession.connectRequestToken,
hostKeyInfo,
progressLogs: [logLine],
});
@@ -342,11 +653,17 @@ export const useSftpConnections = ({
return () => {
dispose?.();
};
}, [leftTabsRef, rightTabsRef, setPendingHostKeyVerification, updateTab]);
}, [connectRequestByTabIdRef, leftTabsRef, rightTabsRef, setPendingHostKeyVerification, updateTab]);
const respondToHostKeyVerification = useCallback((accept: boolean, addToKnownHosts = false) => {
const pending = hostKeyVerificationRef.current;
if (!pending) return;
const owner = activeHostKeySessionsRef.current.get(pending.sessionId);
if (!owner || !isSftpHostKeySessionCurrent(connectRequestByTabIdRef.current, owner)) {
rejectHostKeyVerificationRequest(netcattyBridge.get(), pending.requestId);
setPendingHostKeyVerification(null);
return;
}
if (accept && addToKnownHosts) {
onAddKnownHost?.(createKnownHostFromSftpHostKeyInfo(pending.hostKeyInfo));
}
@@ -356,7 +673,7 @@ export const useSftpConnections = ({
addToKnownHosts,
);
setPendingHostKeyVerification(null);
}, [onAddKnownHost, setPendingHostKeyVerification]);
}, [connectRequestByTabIdRef, onAddKnownHost, setPendingHostKeyVerification]);
const rejectHostKeyVerification = useCallback(() => {
respondToHostKeyVerification(false);
@@ -373,13 +690,13 @@ export const useSftpConnections = ({
const connect = useCallback(
async (requestedSide: "left" | "right", host: Host | "local", options?: SftpConnectOptions) => {
// Follow pinned tabs that were dragged to the other side mid-reconnect.
const resolveTargetSide = createPinnedReconnectSideResolver(
const resolveRequestedSide = createPinnedReconnectSideResolver(
requestedSide,
options?.tabId,
() => leftTabsRef.current.tabs,
() => rightTabsRef.current.tabs,
);
const side = resolveTargetSide();
const side = resolveRequestedSide();
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
let activeTabId: string | null = null;
@@ -401,18 +718,21 @@ export const useSftpConnections = ({
if (!activeTabId) return;
// Once a tab has been selected or allocated, follow that exact tab if it
// is dragged across panes while the asynchronous connect is in flight.
const resolveTargetSide = createPinnedReconnectSideResolver(
side,
activeTabId,
() => leftTabsRef.current.tabs,
() => rightTabsRef.current.tabs,
);
// Pinned reconnect of a non-active tab must not clobber the active tab's
// lastConnectedHost / reconnecting recovery state on this side.
const isPinnedBackgroundReconnect =
!!options?.tabId
&& !!sideTabs.activeTabId
&& options.tabId !== sideTabs.activeTabId;
const clearSideReconnecting = () => {
if (!isPinnedBackgroundReconnect) {
reconnectingRef.current[side] = false;
}
};
const getTargetPaneEarly = () => {
const targetSide = resolveTargetSide();
const tabs = targetSide === "left" ? leftTabsRef.current.tabs : rightTabsRef.current.tabs;
@@ -426,13 +746,16 @@ export const useSftpConnections = ({
// auto-reconnect can land back where the user was browsing instead of home.
// Do not inherit path across endpoints (including same hostId with different
// hostname/port/user) if a reconnect flag is still set while switching.
const previousConnection = getTargetPaneEarly()?.connection;
const previousPane = getTargetPaneEarly();
const previousConnection = previousPane?.connection;
const previousPath = previousConnection?.currentPath;
const previousConnectionKey = !previousConnection
? null
: previousConnection.isLocal
? "local"
: (connectionCacheKeyMapRef.current.get(previousConnection.id) ?? null);
const previousConnectionKey = resolveSftpPaneEndpointKey({
connection: previousConnection,
cachedConnectionKey: previousConnection
? connectionCacheKeyMapRef.current.get(previousConnection.id)
: null,
connectedHost: connectedHostByTabIdRef.current.get(activeTabId) ?? null,
});
const targetConnectionKey = host === "local"
? "local"
: buildCacheKey(
@@ -444,18 +767,14 @@ export const useSftpConnections = ({
host.username,
host.sftpFileProtocol,
);
if (
!isPinnedBackgroundReconnect
&& reconnectingRef.current[side]
&& previousConnectionKey
&& previousConnectionKey !== targetConnectionKey
) {
clearSideReconnecting();
}
// Background pin reconnects resume via options.initialPath, not the side-wide flag.
const isReconnectAttempt = isPinnedBackgroundReconnect
? !!options?.initialPath
: reconnectingRef.current[side];
// Reconnect state belongs to the exact tab, not the side it occupies.
const isReconnectAttempt = resolveSftpReconnectAttempt({
isPinnedBackgroundReconnect,
initialPath: options?.initialPath,
previousPaneReconnecting: previousPane?.reconnecting,
previousConnectionKey,
targetConnectionKey,
});
const sameEndpointReconnect =
isReconnectAttempt
&& !!previousPath
@@ -474,12 +793,35 @@ export const useSftpConnections = ({
tabId: activeTabId,
targetConnectionKey,
sourceSessionId: host === "local" ? undefined : options?.sourceSessionId,
requireSourceSessionReuse: host === "local" ? false : options?.requireSourceSessionReuse,
initialPath: effectiveInitialPath,
forceNewTab: options?.forceNewTab,
browseLifecycleGeneration: browseConnectionLifecycleRef.current.generation,
connectRequestKey: options?.connectRequestKey,
});
return runSftpConnectOnceByKey(connectInFlightRef.current, connectInFlightKey, async () => {
const connectRequestToken = beginSftpTabConnectRequest(
connectRequestByTabIdRef.current,
activeTabId,
);
const isConnectRequestCurrent = () => isSftpTabConnectRequestCurrent(
connectRequestByTabIdRef.current,
activeTabId,
connectRequestToken,
);
const pendingHostKey = hostKeyVerificationRef.current;
if (
pendingHostKey?.tabId === activeTabId
&& pendingHostKey.connectRequestToken !== connectRequestToken
) {
rejectHostKeyVerificationRequest(netcattyBridge.get(), pendingHostKey.requestId);
setPendingHostKeyVerification(null);
}
try {
const browseLifecycleGeneration = browseConnectionLifecycleRef.current.generation;
const connectionId = createSftpConnectionId(side);
options?.onConnectionCreated?.({ tabId: activeTabId, connectionId });
navSeqRef.current[side] += 1;
const connectRequestId = navSeqRef.current[side];
@@ -489,12 +831,14 @@ export const useSftpConnections = ({
return tabs.find((tab) => tab.id === activeTabId) ?? null;
};
const isTargetConnectionCurrent = () => {
if (!isConnectRequestCurrent()) return false;
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) => {
if (!isConnectRequestCurrent()) return false;
const connection = getTargetPane()?.connection;
if (!connection) return navSeqRef.current[side] === connectRequestId;
return connection?.id === connectionId && connection.currentPath === path;
@@ -553,8 +897,17 @@ export const useSftpConnections = ({
}
}
if (!isConnectRequestCurrent()) {
await closeSftpSessionForConnection();
return;
}
if (host === "local") {
let homeDir = await netcattyBridge.get()?.getHomeDir?.();
if (!isConnectRequestCurrent()) {
await closeSftpSessionForConnection();
return;
}
if (!homeDir) {
const isWindows = navigator.platform.toLowerCase().includes("win");
homeDir = isWindows ? "C:\\Users\\damao" : "/Users/damao";
@@ -592,7 +945,6 @@ export const useSftpConnections = ({
files,
timestamp: Date.now(),
});
clearSideReconnecting();
updateTargetTab((prev) => ({
...prev,
files,
@@ -601,7 +953,6 @@ export const useSftpConnections = ({
}));
} catch (err) {
if (!isTargetConnectionAtPath(startPath)) return;
clearSideReconnecting();
updateTargetTab((prev) => ({
...prev,
error: err instanceof Error ? err.message : "Failed to list directory",
@@ -627,6 +978,7 @@ export const useSftpConnections = ({
);
const sourceSessionId = options?.sourceSessionId;
const requireSourceSessionReuse = options?.requireSourceSessionReuse === true;
const connection: SftpConnection = {
id: connectionId,
@@ -640,6 +992,7 @@ export const useSftpConnections = ({
// non-interactive (loading=true) with stale cached files visible —
// no worse than the previous UX of always showing a spinner.
reusedConnection: !!sourceSessionId,
sourceSessionId: requireSourceSessionReuse ? sourceSessionId : undefined,
fileProtocol: host.sftpFileProtocol ?? 'auto',
};
@@ -650,16 +1003,20 @@ export const useSftpConnections = ({
// The cached file list is shown as a preview, but the pane stays
// non-interactive until the SFTP session is actually established.
loading: true,
reconnecting: prev.reconnecting,
reconnecting: isReconnectAttempt,
error: null,
connectionLogs: [],
files: prev.reconnecting ? prev.files : (sharedHostCache?.files ?? []),
files: isReconnectAttempt ? prev.files : (sharedHostCache?.files ?? []),
filenameEncoding, // Reset encoding for new connection
}));
// Subscribe to SFTP connection progress events for auth logging
const sftpSessionId = `sftp-${connectionId}`;
activeHostKeySessionsRef.current.set(sftpSessionId, { side, tabId: activeTabId });
activeHostKeySessionsRef.current.set(sftpSessionId, {
side,
tabId: activeTabId,
connectRequestToken,
});
let unsubSftpProgress: (() => void) | undefined;
const bridge = netcattyBridge.get();
if (bridge?.onSftpConnectionProgress) {
@@ -713,6 +1070,7 @@ export const useSftpConnections = ({
const sftpId = await openSftpConnectionOnce({
bridge,
sourceSessionId,
requireSourceSessionReuse,
openOptions: {
sessionId: sftpSessionId,
...credentials,
@@ -721,7 +1079,19 @@ export const useSftpConnections = ({
if (!sftpId) throw new Error("Failed to open SFTP session");
sftpSessionsRef.current.set(connectionId, sftpId);
const registered = await registerOpenedSftpSession({
disposedRef,
canRegister: () => (
browseConnectionLifecycleRef.current.interactive
&& browseConnectionLifecycleRef.current.generation === browseLifecycleGeneration
),
connectionId,
sftpId,
sftpSessions: sftpSessionsRef.current,
closeSftp: async (openedSftpId) => bridge.closeSftp(openedSftpId),
onRemoteSessionClosed: notifyRemoteSessionClosed,
});
if (!registered) return;
if (!isTargetConnectionCurrent()) {
await closeSftpSessionForConnection();
return;
@@ -863,8 +1233,6 @@ export const useSftpConnections = ({
filenameEncoding,
});
clearSideReconnecting();
updateTargetTab((prev) => ({
...prev,
connection: prev.connection
@@ -882,31 +1250,29 @@ export const useSftpConnections = ({
connectionLogs: [], // Clear after successful connect to avoid replay during navigation
}));
} catch (err) {
if (!isTargetConnectionCurrent()) {
await closeSftpSessionForConnection();
return;
}
// A backend may already be open when initial directory discovery
// fails. Never leave that handle mapped behind an error pane.
await closeSftpSessionForConnection();
clearSideReconnecting();
updateTargetTab((prev) => ({
...prev,
connection: prev.connection
? {
...prev.connection,
status: "error",
error: err instanceof Error ? err.message : "Connection failed",
}
: null,
files: isReconnectAttempt ? [] : prev.files,
selectedFiles: isReconnectAttempt ? new Set<string>() : prev.selectedFiles,
error: isReconnectAttempt
? "sftp.error.reconnectFailed"
: (err instanceof Error ? err.message : "Connection failed"),
loading: false,
reconnecting: false,
}));
await settleFailedSftpConnectIfCurrent({
isCurrent: isTargetConnectionCurrent,
close: closeSftpSessionForConnection,
updateFailure: () => {
updateTargetTab((prev) => ({
...prev,
connection: prev.connection
? {
...prev.connection,
status: "error",
error: err instanceof Error ? err.message : "Connection failed",
}
: null,
files: isReconnectAttempt ? [] : prev.files,
selectedFiles: isReconnectAttempt ? new Set<string>() : prev.selectedFiles,
error: isReconnectAttempt
? "sftp.error.reconnectFailed"
: (err instanceof Error ? err.message : "Connection failed"),
loading: false,
reconnecting: false,
}));
},
});
} finally {
activeHostKeySessionsRef.current.delete(sftpSessionId);
if (hostKeyVerificationRef.current?.sessionId === sftpSessionId) {
@@ -915,6 +1281,13 @@ export const useSftpConnections = ({
unsubSftpProgress?.();
}
}
} finally {
finishSftpTabConnectRequest(
connectRequestByTabIdRef.current,
activeTabId,
connectRequestToken,
);
}
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -928,6 +1301,7 @@ export const useSftpConnections = ({
listLocalFiles,
listRemoteFiles,
setPendingHostKeyVerification,
browseConnectionLifecycleRef,
],
);
@@ -950,56 +1324,72 @@ export const useSftpConnections = ({
useEffect(() => {
const reconnectTimers: number[] = [];
/** Prefer lastConnectedHostRef; fall back to vault host via connection.hostId. */
const resolveReconnectHost = (side: "left" | "right"): Host | "local" | null => {
const lastHost = lastConnectedHostRef.current[side];
if (lastHost) return lastHost;
const connection = getActivePane(side)?.connection;
if (!connection) return null;
if (connection.isLocal) {
lastConnectedHostRef.current[side] = "local";
return "local";
}
if (!connection.hostId) return null;
const host = hosts.find((candidate) => candidate.id === connection.hostId) ?? null;
const resolveReconnectHost = (
side: "left" | "right",
pane: SftpPane | null = getActivePane(side),
): Host | "local" | null => {
const host = resolveSftpReconnectHost({
pane,
lastHost: lastConnectedHostRef.current[side],
connectedHostByTabId: connectedHostByTabIdRef.current,
hosts,
});
if (host) {
// Seed the ref so later refresh/session-error paths do not depend on tab races.
// Keep legacy side-wide recovery state aligned with the active tab.
lastConnectedHostRef.current[side] = host;
}
return host;
};
const scheduleReconnect = (side: "left" | "right") => {
if (!reconnectingRef.current[side]) return;
const host = resolveReconnectHost(side);
if (!host) return;
const scheduleReconnect = (requestedSide: "left" | "right", pane: SftpPane | null) => {
const schedule = resolveSftpReconnectSchedule({
requestedSide,
pane,
leftTabs: leftTabsRef.current.tabs,
rightTabs: rightTabsRef.current.tabs,
});
if (!schedule) return;
const timer = window.setTimeout(() => {
if (!reconnectingRef.current[side]) return;
void connect(side, host);
const livePane = [
...leftTabsRef.current.tabs,
...rightTabsRef.current.tabs,
].find((candidate) => candidate.id === schedule.tabId) ?? null;
const liveSchedule = resolveSftpReconnectSchedule({
requestedSide: schedule.side,
pane: livePane,
leftTabs: leftTabsRef.current.tabs,
rightTabs: rightTabsRef.current.tabs,
});
if (!liveSchedule) return;
const host = resolveReconnectHost(liveSchedule.side, livePane);
if (!host) return;
if (!livePane) return;
void connect(
liveSchedule.side,
host,
resolveSftpReconnectOptions(livePane),
);
}, 1000);
reconnectTimers.push(timer);
};
if (leftPane.reconnecting && reconnectingRef.current.left) {
scheduleReconnect("left");
}
if (rightPane.reconnecting && reconnectingRef.current.right) {
scheduleReconnect("right");
}
scheduleReconnect("left", leftPane);
scheduleReconnect("right", rightPane);
return () => {
reconnectTimers.forEach((timer) => window.clearTimeout(timer));
};
}, [
leftPane.reconnecting,
rightPane.reconnecting,
leftPane,
rightPane,
leftTabsRef,
rightTabsRef,
connect,
connectedHostByTabIdRef,
getActivePane,
hosts,
lastConnectedHostRef,
reconnectingRef,
]);
const disconnect = useCallback(
@@ -1010,33 +1400,51 @@ export const useSftpConnections = ({
if (!pane || !activeTabId) return;
navSeqRef.current[side] += 1;
const sftpId = pane.connection
? takeSftpConnectionMetadataForClose({
connectionId: pane.connection.id,
sftpSessions: sftpSessionsRef.current,
connectionCacheKeys: connectionCacheKeyMapRef.current,
clearCacheForConnection,
})
: undefined;
reconnectingRef.current[side] = false;
lastConnectedHostRef.current[side] = null;
connectedHostByTabIdRef.current.delete(activeTabId);
if (pane.connection && !pane.connection.isLocal) {
if (sftpId) {
try {
await netcattyBridge.get()?.closeSftp(sftpId);
} catch {
// Ignore errors when closing SFTP session during disconnect
await runSftpTabDisconnectIfLatest({
requests: connectRequestByTabIdRef.current,
tabId: activeTabId,
disconnect: async () => {
clearSftpConnectInFlightForTab(connectInFlightRef.current, activeTabId);
const pendingHostKey = hostKeyVerificationRef.current;
if (pendingHostKey?.tabId === activeTabId) {
rejectHostKeyVerificationRequest(netcattyBridge.get(), pendingHostKey.requestId);
setPendingHostKeyVerification(null);
}
notifyRemoteSessionClosed(sftpId);
}
}
navSeqRef.current[side] += 1;
updateTab(side, activeTabId, () => createEmptyPane(activeTabId, pane.showHiddenFiles));
const sftpId = pane.connection
? takeSftpConnectionMetadataForClose({
connectionId: pane.connection.id,
sftpSessions: sftpSessionsRef.current,
connectionCacheKeys: connectionCacheKeyMapRef.current,
clearCacheForConnection,
})
: undefined;
lastConnectedHostRef.current[side] = null;
connectedHostByTabIdRef.current.delete(activeTabId);
if (pane.connection && !pane.connection.isLocal && sftpId) {
try {
await netcattyBridge.get()?.closeSftp(sftpId);
} catch {
// Ignore errors when closing SFTP session during disconnect
}
notifyRemoteSessionClosed(sftpId);
}
},
clear: () => {
applyToLiveSftpTabSide({
requestedSide: side,
tabId: activeTabId,
leftTabs: leftTabsRef.current.tabs,
rightTabs: rightTabsRef.current.tabs,
apply: (liveSide) => {
updateTab(liveSide, activeTabId, () => createEmptyPane(activeTabId, pane.showHiddenFiles));
},
});
},
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[getActivePane, clearCacheForConnection, updateTab],
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { FileConflict, FileConflictAction, Host, TransferStatus, SftpFilenameEncoding } from "../../../domain/models";
import { getSftpConflictTypeKey } from "../../../domain/sftpConflict";
import { resolveSftpTransferConcurrency } from "../../../domain/sftpTransferConcurrency";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { logger } from "../../../lib/logger";
import { notify } from "../../notification";
@@ -105,6 +106,11 @@ import {
waitWhileTransferOrRootPaused,
} from "./transferPauseLatch";
const readSftpFileTransferConcurrency = (): number =>
resolveSftpTransferConcurrency(
() => localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY),
);
type UploadConflictResolver = {
resolve: (action: FileConflictAction) => void;
setDefault: (action: FileConflictAction) => void;
@@ -833,7 +839,10 @@ export const useSftpExternalOperations = (
// Create upload bridge that wraps netcattyBridge.
// Pass connect-time Host so pooled stream uploads open the pinned endpoint
// (session hostname/port/user overrides), not the vault entry by hostId alone.
const createUploadBridge = useCallback((connectHost?: Host): UploadBridge => {
const createUploadBridge = useCallback((
connectHost?: Host,
readTransferConcurrency = () => localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY),
): UploadBridge => {
const bridge = netcattyBridge.get();
return {
managesTransferLifecycle: Boolean(
@@ -896,7 +905,7 @@ export const useSftpExternalOperations = (
ownerId,
options.transferId,
getSftpTransferResourceKeys(options),
() => localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY),
readTransferConcurrency,
async () => {
let lease: { sftpId: string; release: () => void; discard: () => void } | null = null;
try {
@@ -1031,7 +1040,10 @@ export const useSftpExternalOperations = (
const uploadPaneId = livePane.id;
const liveTargetPath = targetPath || livePane.connection.currentPath;
const connectHost = resolveUploadConnectHost(uploadPaneId, livePane.connection.isLocal);
const uploadBridge = createUploadBridge(connectHost);
const uploadBridge = createUploadBridge(
connectHost,
readSftpFileTransferConcurrency,
);
const liveCallbacks = livePane.connection.id === pane.connection.id
&& liveTargetPath === uploadTargetPath
? callbacks
@@ -1068,6 +1080,7 @@ export const useSftpExternalOperations = (
targetPath: liveTargetPath,
sftpId,
targetHostId: livePane.connection.isLocal ? undefined : livePane.connection.hostId,
fileTransferConcurrency: readSftpFileTransferConcurrency(),
isLocal: livePane.connection.isLocal,
bridge: uploadBridge,
joinPath,
@@ -1246,6 +1259,7 @@ export const useSftpExternalOperations = (
targetPath: liveTargetPath,
sftpId: uploadSftpId,
targetHostId: livePane.connection!.isLocal ? undefined : livePane.connection!.hostId,
fileTransferConcurrency: readSftpFileTransferConcurrency(),
isLocal: livePane.connection!.isLocal,
bridge: uploadBridge,
joinPath,
@@ -1375,6 +1389,7 @@ export const useSftpExternalOperations = (
targetPath: uploadTargetPath,
sftpId: uploadSftpId,
targetHostId: livePane.connection!.isLocal ? undefined : livePane.connection!.hostId,
fileTransferConcurrency: readSftpFileTransferConcurrency(),
isLocal: livePane.connection!.isLocal,
bridge: uploadBridge,
joinPath,
@@ -1595,6 +1610,7 @@ export const useSftpExternalOperations = (
targetPath: uploadTargetPath,
sftpId: uploadSftpId,
targetHostId: livePane.connection!.isLocal ? undefined : livePane.connection!.hostId,
fileTransferConcurrency: readSftpFileTransferConcurrency(),
isLocal: livePane.connection!.isLocal,
bridge: uploadBridge,
joinPath,
@@ -1682,6 +1698,7 @@ export const useSftpExternalOperations = (
connectionId?: string;
tabId?: string;
endpointPin?: UploadEndpointPin;
strictConnectionPin?: boolean;
},
): Promise<UploadResult[]> => {
// Pin before any await so tab switches cannot retarget the upload.
@@ -1697,6 +1714,7 @@ export const useSftpExternalOperations = (
const originatingEndpoint = options?.endpointPin ?? captureUploadEndpoint(
originatingPane.connection,
connectionCacheKeyMapRef.current,
{ pinConnectionId: options?.strictConnectionPin },
);
assertUploadEndpointUnchanged(
originatingPane.connection,
@@ -1778,6 +1796,7 @@ export const useSftpExternalOperations = (
targetPath: uploadTargetPath,
sftpId: uploadSftpId,
targetHostId: livePane.connection!.isLocal ? undefined : livePane.connection!.hostId,
fileTransferConcurrency: readSftpFileTransferConcurrency(),
isLocal: livePane.connection!.isLocal,
bridge: directUploadBridge,
joinPath,
@@ -92,7 +92,14 @@ export interface SftpExternalOperationsResult {
uploadExternalEntries: (
side: "left" | "right",
entries: DropEntry[],
options?: { targetPath?: string; connectionId?: string; tabId?: string; endpointPin?: UploadEndpointPin },
options?: {
targetPath?: string;
connectionId?: string;
tabId?: string;
endpointPin?: UploadEndpointPin;
/** Fail closed if the selected tab is rebound before upload work starts. */
strictConnectionPin?: boolean;
},
) => Promise<UploadResult[]>;
cancelExternalUpload: (taskId?: string) => Promise<void>;
selectApplication: () => Promise<{ path: string; name: string } | null>;
@@ -0,0 +1,554 @@
import assert from "node:assert/strict";
import test, { after } from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import type { Host } from "../../../domain/models";
import { useSftpFollowTerminalCwd } from "./useSftpFollowTerminalCwd";
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
after(() => { actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment; });
const host = {
id: "host-1",
label: "Host",
hostname: "example.com",
port: 22,
username: "alice",
protocol: "ssh",
} as Host;
test("first-open follow uses a fresh terminal cwd instead of the cached home path", async () => {
const requestedOptions: unknown[] = [];
const navigatedPaths: string[] = [];
const connection = {
id: "conn-1",
hostId: "host-1",
currentPath: "/home/alice",
status: "connected",
isLocal: false,
};
const sftpRef = {
current: {
leftPane: { connection, loading: false },
navigateTo: async (_side: "left", path: string, options?: { shouldApply?: () => boolean }) => {
if (options?.shouldApply && !options.shouldApply()) return "aborted" as const;
navigatedPaths.push(path);
connection.currentPath = path;
return "reached" as const;
},
},
};
let renderer: ReactTestRenderer | null = null;
function Probe() {
useSftpFollowTerminalCwd({
activeSessionId: "session-1",
activeTerminalCwd: "/home/alice",
canFollowTerminalCwd: true,
connectionId: connection.id,
connectionIsLocal: connection.isLocal,
connectionLoading: false,
connectionPath: connection.currentPath,
connectionStatus: connection.status,
effectiveFollowTerminalCwd: true,
followTerminalCwdHost: host,
hasActiveWork: false,
isVisible: true,
ownerPanelOpen: true,
onGetTerminalCwd: async (options) => {
requestedOptions.push(options);
return "/root/releases";
},
onPendingFollowOverride: () => {},
sftpRef,
});
return null;
}
await act(async () => {
renderer = create(React.createElement(Probe));
await new Promise((resolve) => setImmediate(resolve));
});
assert.deepEqual(requestedOptions, [{
preferFreshBackend: true,
allowRendererFallback: false,
requireActiveShellCwd: true,
}]);
assert.deepEqual(navigatedPaths, ["/root/releases"]);
await act(async () => renderer?.unmount());
});
test("first-open follow does not use a stale cached cwd while the fresh probe is pending", async () => {
let resolveCwd: ((cwd: string) => void) | null = null;
const navigatedPaths: string[] = [];
const connection = {
id: "conn-1",
hostId: "host-1",
currentPath: "/srv/manual",
status: "connected",
isLocal: false,
};
const sftpRef = {
current: {
leftPane: { connection, loading: false },
navigateTo: async (_side: "left", path: string) => {
navigatedPaths.push(path);
connection.currentPath = path;
return "reached" as const;
},
},
};
let renderer: ReactTestRenderer | null = null;
function Probe() {
useSftpFollowTerminalCwd({
activeSessionId: "session-1",
activeTerminalCwd: "/home/alice",
canFollowTerminalCwd: true,
connectionId: connection.id,
connectionIsLocal: connection.isLocal,
connectionLoading: false,
connectionPath: connection.currentPath,
connectionStatus: connection.status,
effectiveFollowTerminalCwd: true,
followTerminalCwdHost: host,
hasActiveWork: false,
isVisible: true,
ownerPanelOpen: true,
onGetTerminalCwd: () => new Promise<string>((resolve) => { resolveCwd = resolve; }),
onPendingFollowOverride: () => {},
sftpRef,
});
return null;
}
await act(async () => {
renderer = create(React.createElement(Probe));
await new Promise((resolve) => setImmediate(resolve));
});
assert.deepEqual(navigatedPaths, []);
await act(async () => {
resolveCwd?.("/root/releases");
await new Promise((resolve) => setImmediate(resolve));
});
assert.deepEqual(navigatedPaths, ["/root/releases"]);
await act(async () => renderer?.unmount());
});
test("an in-flight first-open probe cannot navigate after Mosh focus changes", async () => {
let resolveCwd: ((cwd: string) => void) | null = null;
const navigatedPaths: string[] = [];
const connection = {
id: "conn-1",
hostId: "host-1",
currentPath: "/home/alice",
status: "connected",
isLocal: false,
};
const sftpRef = {
current: {
leftPane: { connection, loading: false },
navigateTo: async (_side: "left", path: string, options?: { shouldApply?: () => boolean }) => {
if (options?.shouldApply && !options.shouldApply()) return "aborted" as const;
navigatedPaths.push(path);
return "reached" as const;
},
},
};
let focusedSessionId: string | null = "mosh-a";
let renderer: ReactTestRenderer | null = null;
const onGetTerminalCwd = () => new Promise<string>((resolve) => { resolveCwd = resolve; });
function Probe() {
useSftpFollowTerminalCwd({
activeSessionId: null,
focusedSessionId,
activeTerminalCwd: "/home/alice",
canFollowTerminalCwd: true,
connectionId: connection.id,
connectionIsLocal: connection.isLocal,
connectionLoading: false,
connectionPath: connection.currentPath,
connectionStatus: connection.status,
effectiveFollowTerminalCwd: true,
followTerminalCwdHost: host,
hasActiveWork: false,
isVisible: true,
ownerPanelOpen: true,
onGetTerminalCwd,
onPendingFollowOverride: () => {},
sftpRef,
});
return null;
}
await act(async () => {
renderer = create(React.createElement(Probe));
await new Promise((resolve) => setImmediate(resolve));
});
assert.ok(resolveCwd);
focusedSessionId = "mosh-b";
await act(async () => renderer?.update(React.createElement(Probe)));
await act(async () => {
resolveCwd?.("/root/from-mosh-a");
await new Promise((resolve) => setImmediate(resolve));
});
assert.deepEqual(navigatedPaths, []);
await act(async () => renderer?.unmount());
});
test("an in-flight first-open probe cannot move a pane after a hidden tab is restored", async () => {
let resolveCwd: ((cwd: string) => void) | null = null;
const navigatedPaths: string[] = [];
const connection = {
id: "conn-1",
hostId: "host-1",
currentPath: "/home/alice",
status: "connected",
isLocal: false,
};
const sftpRef = {
current: {
leftPane: { connection, loading: false },
navigateTo: async (_side: "left", path: string) => {
navigatedPaths.push(path);
return "reached" as const;
},
},
};
let visible = true;
let renderer: ReactTestRenderer | null = null;
function Probe() {
useSftpFollowTerminalCwd({
activeSessionId: "session-1",
activeTerminalCwd: "/home/alice",
canFollowTerminalCwd: true,
connectionId: connection.id,
connectionIsLocal: connection.isLocal,
connectionLoading: false,
connectionPath: connection.currentPath,
connectionStatus: connection.status,
effectiveFollowTerminalCwd: true,
followTerminalCwdHost: host,
hasActiveWork: false,
isVisible: visible,
ownerPanelOpen: true,
onGetTerminalCwd: () => new Promise<string>((resolve) => { resolveCwd = resolve; }),
onPendingFollowOverride: () => {},
sftpRef,
});
return null;
}
await act(async () => {
renderer = create(React.createElement(Probe));
await new Promise((resolve) => setImmediate(resolve));
});
assert.ok(resolveCwd);
visible = false;
await act(async () => renderer?.update(React.createElement(Probe)));
visible = true;
await act(async () => renderer?.update(React.createElement(Probe)));
await act(async () => {
resolveCwd?.("/root/releases");
await new Promise((resolve) => setImmediate(resolve));
});
assert.deepEqual(navigatedPaths, []);
await act(async () => renderer?.unmount());
});
test("an in-flight first-open probe cannot overwrite a manually browsed path", async () => {
let resolveCwd: ((cwd: string) => void) | null = null;
const navigatedPaths: string[] = [];
const connection = {
id: "conn-1",
hostId: "host-1",
currentPath: "/home/alice",
status: "connected",
isLocal: false,
};
const sftpRef = {
current: {
leftPane: { connection, loading: false },
navigateTo: async (_side: "left", path: string) => {
navigatedPaths.push(path);
return "reached" as const;
},
},
};
let renderer: ReactTestRenderer | null = null;
function Probe() {
useSftpFollowTerminalCwd({
activeSessionId: "session-1",
activeTerminalCwd: "/home/alice",
canFollowTerminalCwd: true,
connectionId: connection.id,
connectionIsLocal: connection.isLocal,
connectionLoading: false,
connectionPath: connection.currentPath,
connectionStatus: connection.status,
effectiveFollowTerminalCwd: true,
followTerminalCwdHost: host,
hasActiveWork: false,
isVisible: true,
ownerPanelOpen: true,
onGetTerminalCwd: () => new Promise<string>((resolve) => { resolveCwd = resolve; }),
onPendingFollowOverride: () => {},
sftpRef,
});
return null;
}
await act(async () => {
renderer = create(React.createElement(Probe));
await new Promise((resolve) => setImmediate(resolve));
});
assert.ok(resolveCwd);
connection.currentPath = "/srv/manual";
await act(async () => {
resolveCwd?.("/root/releases");
await new Promise((resolve) => setImmediate(resolve));
});
assert.deepEqual(navigatedPaths, []);
await act(async () => renderer?.unmount());
});
test("manual go-to-terminal-cwd cannot move a replacement connection", async () => {
let resolveCwd: ((cwd: string) => void) | null = null;
const navigatedPaths: string[] = [];
let activeSessionId = "session-1";
let connection = {
id: "conn-1",
hostId: "host-1",
currentPath: "/home/alice",
status: "connected",
isLocal: false,
};
const sftpRef = {
current: {
leftPane: { connection, loading: false },
navigateTo: async (_side: "left", path: string, options?: { shouldApply?: () => boolean }) => {
if (options?.shouldApply && !options.shouldApply()) return "aborted" as const;
navigatedPaths.push(path);
return "reached" as const;
},
},
};
let actions: ReturnType<typeof useSftpFollowTerminalCwd> | null = null;
let renderer: ReactTestRenderer | null = null;
function Probe() {
actions = useSftpFollowTerminalCwd({
activeSessionId,
activeTerminalCwd: null,
canFollowTerminalCwd: false,
connectionId: connection.id,
connectionIsLocal: connection.isLocal,
connectionLoading: false,
connectionPath: connection.currentPath,
connectionStatus: connection.status,
effectiveFollowTerminalCwd: false,
followTerminalCwdHost: host,
hasActiveWork: false,
isVisible: true,
ownerPanelOpen: true,
onGetTerminalCwd: () => new Promise<string>((resolve) => { resolveCwd = resolve; }),
onPendingFollowOverride: () => {},
sftpRef,
});
return null;
}
await act(async () => { renderer = create(React.createElement(Probe)); });
assert.ok(actions);
let navigationPromise: Promise<void> | undefined;
await act(async () => {
navigationPromise = actions?.handleGoToTerminalCwd();
await new Promise((resolve) => setImmediate(resolve));
});
assert.ok(resolveCwd);
activeSessionId = "session-2";
connection = { ...connection, id: "conn-2" };
sftpRef.current.leftPane.connection = connection;
await act(async () => renderer?.update(React.createElement(Probe)));
await act(async () => {
resolveCwd?.("/root/releases");
await navigationPromise;
});
assert.deepEqual(navigatedPaths, []);
await act(async () => renderer?.unmount());
});
test("only trusted live terminal cwd recovery resumes follow after initial probes are exhausted", async () => {
const navigatedPaths: string[] = [];
const connection = {
id: "conn-1",
hostId: "host-1",
currentPath: "/srv/manual",
status: "connected",
isLocal: false,
};
const sftpRef = {
current: {
leftPane: { connection, loading: false },
navigateTo: async (_side: "left", path: string) => {
navigatedPaths.push(path);
connection.currentPath = path;
return "reached" as const;
},
},
};
let activeTerminalCwd = "/home/alice";
let activeTerminalCwdTrusted = false;
let probeCalls = 0;
let renderer: ReactTestRenderer | null = null;
function Probe() {
useSftpFollowTerminalCwd({
activeSessionId: "session-1",
activeTerminalCwd,
activeTerminalCwdTrusted,
canFollowTerminalCwd: true,
connectionId: connection.id,
connectionIsLocal: connection.isLocal,
connectionLoading: false,
connectionPath: connection.currentPath,
connectionStatus: connection.status,
effectiveFollowTerminalCwd: true,
followTerminalCwdHost: host,
hasActiveWork: false,
isVisible: true,
ownerPanelOpen: true,
onGetTerminalCwd: async () => {
probeCalls += 1;
return null;
},
onPendingFollowOverride: () => {},
sftpRef,
});
return null;
}
await act(async () => {
renderer = create(React.createElement(Probe));
await new Promise((resolve) => setImmediate(resolve));
});
for (let attempt = 1; attempt < 3; attempt += 1) {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 300));
});
}
assert.equal(probeCalls, 3);
assert.deepEqual(navigatedPaths, []);
activeTerminalCwd = null;
await act(async () => renderer?.update(React.createElement(Probe)));
activeTerminalCwd = "/home/alice";
await act(async () => renderer?.update(React.createElement(Probe)));
assert.deepEqual(navigatedPaths, [], "an untrusted restored snapshot must stay blocked");
activeTerminalCwd = "/srv/new";
activeTerminalCwdTrusted = true;
await act(async () => {
renderer?.update(React.createElement(Probe));
await new Promise((resolve) => setImmediate(resolve));
});
assert.deepEqual(navigatedPaths, ["/srv/new"]);
await act(async () => renderer?.unmount());
});
test("an in-flight follow probe cannot navigate after the focused session changes", async () => {
const navigatedPaths: string[] = [];
const connection = {
id: "conn-1",
hostId: "host-1",
currentPath: "/home/alice",
status: "connected",
isLocal: false,
};
const sftpRef = {
current: {
leftPane: { connection, loading: false },
navigateTo: async (_side: "left", path: string, options?: { shouldApply?: () => boolean }) => {
if (options?.shouldApply && !options.shouldApply()) return "aborted" as const;
navigatedPaths.push(path);
connection.currentPath = path;
return "reached" as const;
},
},
};
let activeSessionId = "session-a";
let activeTerminalCwd: string | null = "/home/alice";
let resolveCwd: ((cwd: string) => void) | null = null;
let deferProbe = false;
let renderer: ReactTestRenderer | null = null;
const onGetTerminalCwd = () => {
if (!deferProbe) return Promise.resolve("/root/session-a");
return new Promise<string>((resolve) => { resolveCwd = resolve; });
};
function Probe() {
useSftpFollowTerminalCwd({
activeSessionId,
activeTerminalCwd,
canFollowTerminalCwd: true,
connectionId: connection.id,
connectionIsLocal: connection.isLocal,
connectionLoading: false,
connectionPath: connection.currentPath,
connectionStatus: connection.status,
effectiveFollowTerminalCwd: true,
followTerminalCwdHost: host,
hasActiveWork: false,
isVisible: true,
ownerPanelOpen: true,
onGetTerminalCwd,
onPendingFollowOverride: () => {},
sftpRef,
});
return null;
}
await act(async () => {
renderer = create(React.createElement(Probe));
await new Promise((resolve) => setImmediate(resolve));
});
assert.deepEqual(navigatedPaths, ["/root/session-a"]);
deferProbe = true;
activeTerminalCwd = null;
await act(async () => {
renderer?.update(React.createElement(Probe));
await new Promise((resolve) => setImmediate(resolve));
});
assert.ok(resolveCwd);
activeSessionId = "session-b";
await act(async () => renderer?.update(React.createElement(Probe)));
await act(async () => {
resolveCwd?.("/root/from-session-a");
await new Promise((resolve) => setImmediate(resolve));
});
assert.deepEqual(navigatedPaths, ["/root/session-a"]);
await act(async () => renderer?.unmount());
});
@@ -0,0 +1,565 @@
import {
useCallback,
useEffect,
useRef,
useState,
type MutableRefObject,
} from "react";
import {
isFollowOriginStillCurrent,
shouldApplyFollowTerminalCwdSyncResult,
shouldClearBlockedFollowOnReach,
shouldFollowTerminalCwdNavigate,
shouldInvalidateFollowBookkeepingOnCwdChange,
shouldLatchInitialFollowInterruption,
shouldReleaseInitialFollowSyncAttempt,
shouldResetInitialFollowTerminalCwdSync,
type SftpFollowTerminalCwdBlock,
} from "../../../domain/sftpFollowTerminalCwd";
import type { Host } from "../../../types";
import type { SftpNavigateOptions, SftpNavigateResult } from "./useSftpPaneActions";
type FollowConnection = {
id: string;
currentPath?: string | null;
status: string;
isLocal?: boolean;
};
type SftpFollowApi = {
leftPane: {
connection?: FollowConnection | null;
loading: boolean;
};
navigateTo: (
side: "left" | "right",
path: string,
options?: SftpNavigateOptions,
) => Promise<SftpNavigateResult>;
};
type GetTerminalCwd = (options?: {
preferFreshBackend?: boolean;
allowRendererFallback?: boolean;
requireActiveShellCwd?: boolean;
}) => Promise<string | null>;
type InitialFollowConnection = {
id: string;
currentPath?: string | null;
status: string;
isLocal?: boolean;
};
type InitialFollowSyncOptions = {
expectedConnectionId: string;
staleTerminalCwd?: string | null;
getFreshTerminalCwd: () => Promise<string | null | undefined>;
isEligible: () => boolean;
getConnection: () => InitialFollowConnection | null | undefined;
navigate: (
cwd: string,
shouldApply: () => boolean,
) => Promise<"reached" | "failed" | "aborted" | "superseded">;
setHandled: (value: SftpFollowTerminalCwdBlock) => void;
setBlocked: (value: SftpFollowTerminalCwdBlock | null) => void;
};
/** Run one guarded first-open sync. False means the caller may retry. */
const runInitialFollowTerminalCwdSync = async ({
expectedConnectionId,
staleTerminalCwd,
getFreshTerminalCwd,
isEligible,
getConnection,
navigate,
setHandled,
setBlocked,
}: InitialFollowSyncOptions): Promise<boolean> => {
const cwd = await getFreshTerminalCwd();
if (!cwd || !isEligible()) return false;
const live = getConnection();
if (!live || live.id !== expectedConnectionId || live.status !== "connected" || live.isLocal) {
return false;
}
setHandled({
connectionId: expectedConnectionId,
terminalCwd: staleTerminalCwd && staleTerminalCwd !== cwd ? staleTerminalCwd : cwd,
});
if (live.currentPath === cwd) return true;
const navigateResult = await navigate(cwd, isEligible);
if (!isEligible()) return false;
const current = getConnection();
if (!current || current.id !== expectedConnectionId || current.status !== "connected") {
return false;
}
if (navigateResult === "failed") {
setBlocked({ connectionId: expectedConnectionId, terminalCwd: cwd });
return true;
}
if (navigateResult === "reached") {
setBlocked(null);
return true;
}
return navigateResult === "superseded";
};
type UseSftpFollowTerminalCwdOptions = {
activeSessionId?: string | null;
/** Focused terminal including Mosh/ET, used when SSH reuse id is absent. */
focusedSessionId?: string | null;
activeTerminalCwd: string | null;
activeTerminalCwdTrusted?: boolean;
canFollowTerminalCwd: boolean;
connectionId: string | null;
connectionIsLocal?: boolean;
connectionLoading: boolean;
connectionPath: string | null;
connectionStatus?: string;
effectiveFollowTerminalCwd: boolean;
followTerminalCwdHost: Host | null;
hasActiveWork: boolean;
isVisible: boolean;
ownerPanelOpen: boolean;
onGetTerminalCwd?: GetTerminalCwd;
onPendingFollowOverride: (override: { hostId: string; value: boolean }) => void;
onSftpFollowTerminalCwdChange?: (enabled: boolean, host?: Host | null) => void;
sftpRef: MutableRefObject<SftpFollowApi>;
};
export type SftpFollowTerminalCwdActions = {
handleGoToTerminalCwd: () => Promise<void>;
handleToggleFollowTerminalCwd: () => void;
};
const MAX_INITIAL_FOLLOW_ATTEMPTS = 3;
const INITIAL_FOLLOW_RETRY_DELAY_MS = 250;
/** Owns terminal-cwd follow state so the SFTP view remains presentation-only. */
export function useSftpFollowTerminalCwd({
activeSessionId,
focusedSessionId = null,
activeTerminalCwd,
activeTerminalCwdTrusted = false,
canFollowTerminalCwd,
connectionId,
connectionIsLocal,
connectionLoading,
connectionPath,
connectionStatus,
effectiveFollowTerminalCwd,
followTerminalCwdHost,
hasActiveWork,
isVisible,
ownerPanelOpen,
onGetTerminalCwd,
onPendingFollowOverride,
onSftpFollowTerminalCwdChange,
sftpRef,
}: UseSftpFollowTerminalCwdOptions): SftpFollowTerminalCwdActions {
const blockedFollowRef = useRef<SftpFollowTerminalCwdBlock | null>(null);
const handledFollowRef = useRef<SftpFollowTerminalCwdBlock | null>(null);
const followSyncGenerationRef = useRef(0);
const effectiveFollowTerminalCwdRef = useRef(effectiveFollowTerminalCwd);
const canFollowTerminalCwdRef = useRef(canFollowTerminalCwd);
const activeTerminalCwdRef = useRef(activeTerminalCwd);
const activeSessionIdRef = useRef(activeSessionId);
const focusedSessionIdRef = useRef(focusedSessionId);
const connectionIdRef = useRef(connectionId);
const isVisibleRef = useRef(isVisible);
const ownerPanelOpenRef = useRef(ownerPanelOpen);
const hasActiveWorkRef = useRef(hasActiveWork);
const initialFollowReadyConnectionRef = useRef<string | null>(null);
effectiveFollowTerminalCwdRef.current = effectiveFollowTerminalCwd;
canFollowTerminalCwdRef.current = canFollowTerminalCwd;
activeTerminalCwdRef.current = activeTerminalCwd;
activeSessionIdRef.current = activeSessionId;
focusedSessionIdRef.current = focusedSessionId;
connectionIdRef.current = connectionId;
isVisibleRef.current = isVisible;
ownerPanelOpenRef.current = ownerPanelOpen;
hasActiveWorkRef.current = hasActiveWork;
const invalidateInFlightFollowSync = useCallback(() => {
followSyncGenerationRef.current += 1;
blockedFollowRef.current = null;
handledFollowRef.current = null;
}, []);
useEffect(() => {
invalidateInFlightFollowSync();
}, [connectionId, followTerminalCwdHost?.id, invalidateInFlightFollowSync]);
const lastLiveTerminalCwdRef = useRef<string | null>(activeTerminalCwd);
const lastLiveTerminalCwdTrustedRef = useRef(activeTerminalCwdTrusted);
useEffect(() => {
const cwdInvalidates = shouldInvalidateFollowBookkeepingOnCwdChange({
nextCwd: activeTerminalCwd,
lastCwd: lastLiveTerminalCwdRef.current,
isVisible,
});
const trustInvalidates = activeTerminalCwd !== null
&& activeTerminalCwdTrusted !== lastLiveTerminalCwdTrustedRef.current;
if (!cwdInvalidates && !trustInvalidates) return;
lastLiveTerminalCwdRef.current = activeTerminalCwd;
lastLiveTerminalCwdTrustedRef.current = activeTerminalCwdTrusted;
if (activeTerminalCwd && activeTerminalCwdTrusted && connectionIdRef.current) {
// A concrete live cwd is stronger evidence than the exhausted initial
// backend probes. Let the ordinary follow path resume immediately.
initialFollowReadyConnectionRef.current = connectionIdRef.current;
}
invalidateInFlightFollowSync();
}, [activeTerminalCwd, activeTerminalCwdTrusted, invalidateInFlightFollowSync, isVisible]);
useEffect(() => {
if (effectiveFollowTerminalCwd) return;
invalidateInFlightFollowSync();
}, [effectiveFollowTerminalCwd, invalidateInFlightFollowSync]);
useEffect(() => {
const blockedFollow = blockedFollowRef.current;
if (shouldClearBlockedFollowOnReach(
blockedFollow,
connectionId,
connectionPath,
connectionLoading,
)) {
blockedFollowRef.current = null;
handledFollowRef.current = blockedFollow;
}
}, [connectionId, connectionLoading, connectionPath]);
const handleGoToTerminalCwd = useCallback(async () => {
if (!onGetTerminalCwd) return;
const expectedConnectionId = sftpRef.current.leftPane.connection?.id ?? null;
const expectedSessionId = focusedSessionIdRef.current ?? activeSessionIdRef.current ?? null;
const syncGeneration = followSyncGenerationRef.current;
if (!expectedConnectionId) return;
const shouldApply = () => (
syncGeneration === followSyncGenerationRef.current
&& isFollowOriginStillCurrent({
expectedOriginId: expectedSessionId,
liveOriginId: focusedSessionIdRef.current ?? activeSessionIdRef.current ?? null,
})
&& connectionIdRef.current === expectedConnectionId
&& sftpRef.current.leftPane.connection?.id === expectedConnectionId
&& sftpRef.current.leftPane.connection?.status === "connected"
);
const cwd = await onGetTerminalCwd({
preferFreshBackend: true,
allowRendererFallback: false,
requireActiveShellCwd: true,
});
if (!cwd) return;
if (!shouldApply()) return;
const navigateResult = await sftpRef.current.navigateTo("left", cwd, { shouldApply });
if (navigateResult !== "reached" || !shouldApply()) return;
blockedFollowRef.current = null;
const connection = sftpRef.current.leftPane.connection;
if (connection?.id) {
handledFollowRef.current = { connectionId: connection.id, terminalCwd: cwd };
}
}, [onGetTerminalCwd, sftpRef]);
const syncFollowToTerminalCwd = useCallback(async () => {
if (!onGetTerminalCwd || !effectiveFollowTerminalCwd || !canFollowTerminalCwd) return;
const liveConnectionId = sftpRef.current.leftPane.connection?.id ?? null;
if (!liveConnectionId || initialFollowReadyConnectionRef.current !== liveConnectionId) return;
const syncGeneration = followSyncGenerationRef.current;
const expectedSessionId = focusedSessionIdRef.current ?? activeSessionIdRef.current ?? null;
const expectedConnectionIdAtStart = connectionIdRef.current ?? liveConnectionId;
const usesLiveTerminalCwd = Boolean(activeTerminalCwd && activeTerminalCwdTrusted);
let terminalCwd = usesLiveTerminalCwd ? activeTerminalCwd : null;
if (!terminalCwd) {
terminalCwd = await onGetTerminalCwd({
preferFreshBackend: true,
allowRendererFallback: false,
requireActiveShellCwd: true,
});
}
if (!terminalCwd) return;
if (!shouldApplyFollowTerminalCwdSyncResult({
syncGeneration,
currentGeneration: followSyncGenerationRef.current,
followEnabled: effectiveFollowTerminalCwdRef.current,
canFollow: canFollowTerminalCwdRef.current,
expectedSessionId,
liveSessionId: focusedSessionIdRef.current ?? activeSessionIdRef.current ?? null,
expectedConnectionId: expectedConnectionIdAtStart,
liveConnectionId: connectionIdRef.current,
paneConnectionId: sftpRef.current.leftPane.connection?.id ?? null,
})) return;
const connection = sftpRef.current.leftPane.connection;
if (!shouldFollowTerminalCwdNavigate({
followEnabled: effectiveFollowTerminalCwdRef.current,
isVisible,
terminalCwd,
currentPath: connection?.currentPath,
connectionId: connection?.id,
hasActiveWork,
isConnected: Boolean(connection && !connection.isLocal && connection.status === "connected"),
blockedFollow: blockedFollowRef.current,
handledFollow: handledFollowRef.current,
})) {
if (
connection?.id
&& !connection.isLocal
&& connection.status === "connected"
&& connection.currentPath === terminalCwd
) {
handledFollowRef.current = { connectionId: connection.id, terminalCwd };
}
return;
}
const expectedConnectionId = connection?.id ?? null;
const shouldApplyCurrentFollowSync = () => shouldApplyFollowTerminalCwdSyncResult({
syncGeneration,
currentGeneration: followSyncGenerationRef.current,
followEnabled: effectiveFollowTerminalCwdRef.current,
canFollow: canFollowTerminalCwdRef.current,
expectedSessionId,
liveSessionId: focusedSessionIdRef.current ?? activeSessionIdRef.current ?? null,
expectedConnectionId,
liveConnectionId: connectionIdRef.current,
paneConnectionId: sftpRef.current.leftPane.connection?.id ?? null,
expectedTerminalCwd: terminalCwd,
liveTerminalCwd: activeTerminalCwdRef.current,
requireLiveTerminalCwd: usesLiveTerminalCwd,
});
const navigateResult = await sftpRef.current.navigateTo("left", terminalCwd, {
shouldApply: shouldApplyCurrentFollowSync,
});
if (!shouldApplyCurrentFollowSync()) return;
const currentConnection = sftpRef.current.leftPane.connection;
if (!currentConnection || currentConnection.id !== connection?.id) return;
if (navigateResult === "failed") {
blockedFollowRef.current = { connectionId: currentConnection.id, terminalCwd };
} else if (navigateResult === "superseded") {
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd };
} else if (navigateResult === "reached") {
blockedFollowRef.current = null;
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd };
}
}, [
activeTerminalCwd,
activeTerminalCwdTrusted,
canFollowTerminalCwd,
effectiveFollowTerminalCwd,
hasActiveWork,
isVisible,
onGetTerminalCwd,
sftpRef,
]);
const handleToggleFollowTerminalCwd = useCallback(() => {
const nextEnabled = !effectiveFollowTerminalCwd;
invalidateInFlightFollowSync();
if (followTerminalCwdHost?.id) {
onPendingFollowOverride({ hostId: followTerminalCwdHost.id, value: nextEnabled });
}
onSftpFollowTerminalCwdChange?.(nextEnabled, followTerminalCwdHost);
}, [
effectiveFollowTerminalCwd,
followTerminalCwdHost,
invalidateInFlightFollowSync,
onPendingFollowOverride,
onSftpFollowTerminalCwdChange,
]);
useEffect(() => {
if (!effectiveFollowTerminalCwd || !canFollowTerminalCwd || !isVisible || hasActiveWork) return;
void syncFollowToTerminalCwd();
}, [
activeTerminalCwd,
activeTerminalCwdTrusted,
canFollowTerminalCwd,
connectionId,
connectionIsLocal,
connectionStatus,
effectiveFollowTerminalCwd,
hasActiveWork,
isVisible,
syncFollowToTerminalCwd,
]);
const initialFollowSyncedConnRef = useRef<string | null>(null);
const initialFollowRetryRef = useRef<{ connectionId: string | null; attempts: number }>({
connectionId: null,
attempts: 0,
});
const initialFollowRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const initialFollowInterruptedRef = useRef(false);
const initialFollowProbeSeqRef = useRef(0);
const initialFollowProbeAttemptRef = useRef<number | null>(null);
const initialFollowMountedRef = useRef(true);
const [initialFollowRetryNonce, setInitialFollowRetryNonce] = useState(0);
useEffect(() => {
initialFollowMountedRef.current = true;
return () => {
initialFollowMountedRef.current = false;
if (initialFollowRetryTimerRef.current) clearTimeout(initialFollowRetryTimerRef.current);
};
}, []);
useEffect(() => {
if (initialFollowProbeAttemptRef.current === null) return;
if (!shouldLatchInitialFollowInterruption({ isVisible, ownerPanelOpen })) return;
initialFollowInterruptedRef.current = true;
}, [isVisible, ownerPanelOpen]);
useEffect(() => {
if (isVisible || !ownerPanelOpen || !initialFollowRetryTimerRef.current) return;
clearTimeout(initialFollowRetryTimerRef.current);
initialFollowRetryTimerRef.current = null;
}, [isVisible, ownerPanelOpen]);
useEffect(() => {
if (!shouldResetInitialFollowTerminalCwdSync({
isVisible,
ownerPanelOpen,
connectionId,
trackedConnectionId: initialFollowRetryRef.current.connectionId,
})) return;
initialFollowSyncedConnRef.current = null;
initialFollowReadyConnectionRef.current = null;
initialFollowInterruptedRef.current = false;
initialFollowRetryRef.current = { connectionId, attempts: 0 };
followSyncGenerationRef.current += 1;
initialFollowProbeAttemptRef.current = null;
if (initialFollowRetryTimerRef.current) {
clearTimeout(initialFollowRetryTimerRef.current);
initialFollowRetryTimerRef.current = null;
}
}, [connectionId, isVisible, ownerPanelOpen]);
useEffect(() => {
if (!effectiveFollowTerminalCwd || !canFollowTerminalCwd || !isVisible || hasActiveWork) return;
const connection = sftpRef.current.leftPane.connection;
if (!connection || connection.isLocal || connection.status !== "connected" || !connection.id) return;
if (initialFollowSyncedConnRef.current === connection.id) return;
if (initialFollowRetryRef.current.connectionId !== connection.id) {
initialFollowRetryRef.current = { connectionId: connection.id, attempts: 0 };
}
if (initialFollowRetryRef.current.attempts >= MAX_INITIAL_FOLLOW_ATTEMPTS) return;
initialFollowRetryRef.current.attempts += 1;
initialFollowSyncedConnRef.current = connection.id;
const expectedConnectionId = connection.id;
const expectedPanePath = connection.currentPath ?? null;
const expectedSessionId = focusedSessionId ?? activeSessionId ?? null;
const staleTerminalCwd = activeTerminalCwdRef.current;
const syncGeneration = followSyncGenerationRef.current;
let navigationStarted = false;
const followCurrentlyEligible = () => (
initialFollowMountedRef.current
&& effectiveFollowTerminalCwdRef.current
&& canFollowTerminalCwdRef.current
&& isVisibleRef.current
&& !hasActiveWorkRef.current
&& isFollowOriginStillCurrent({
expectedOriginId: expectedSessionId,
liveOriginId: focusedSessionIdRef.current ?? activeSessionIdRef.current ?? null,
})
&& sftpRef.current.leftPane.connection?.id === expectedConnectionId
&& (
navigationStarted
|| (sftpRef.current.leftPane.connection?.currentPath ?? null) === expectedPanePath
)
&& !sftpRef.current.leftPane.connection?.isLocal
&& sftpRef.current.leftPane.connection?.status === "connected"
);
const followStillEligible = () => (
syncGeneration === followSyncGenerationRef.current
&& !initialFollowInterruptedRef.current
&& followCurrentlyEligible()
);
const clearAttemptAndRetry = () => {
if (initialFollowInterruptedRef.current) return;
if (
initialFollowSyncedConnRef.current === expectedConnectionId
&& !shouldReleaseInitialFollowSyncAttempt({
isVisible: isVisibleRef.current,
ownerPanelOpen: ownerPanelOpenRef.current,
})
) return;
if (
!initialFollowMountedRef.current
|| !followCurrentlyEligible()
|| initialFollowRetryRef.current.attempts >= MAX_INITIAL_FOLLOW_ATTEMPTS
) {
if (initialFollowSyncedConnRef.current === expectedConnectionId) {
initialFollowSyncedConnRef.current = null;
}
return;
}
if (initialFollowRetryTimerRef.current) clearTimeout(initialFollowRetryTimerRef.current);
initialFollowRetryTimerRef.current = setTimeout(() => {
initialFollowRetryTimerRef.current = null;
if (!shouldReleaseInitialFollowSyncAttempt({
isVisible: isVisibleRef.current,
ownerPanelOpen: ownerPanelOpenRef.current,
})) return;
if (initialFollowSyncedConnRef.current === expectedConnectionId) {
initialFollowSyncedConnRef.current = null;
}
setInitialFollowRetryNonce((value) => value + 1);
}, INITIAL_FOLLOW_RETRY_DELAY_MS);
};
initialFollowProbeSeqRef.current += 1;
const probeAttempt = initialFollowProbeSeqRef.current;
initialFollowProbeAttemptRef.current = probeAttempt;
void runInitialFollowTerminalCwdSync({
expectedConnectionId,
staleTerminalCwd,
getFreshTerminalCwd: () => onGetTerminalCwd?.({
preferFreshBackend: true,
allowRendererFallback: false,
requireActiveShellCwd: true,
}),
isEligible: followStillEligible,
getConnection: () => sftpRef.current.leftPane.connection,
navigate: (cwd, shouldApply) => {
navigationStarted = true;
return sftpRef.current.navigateTo("left", cwd, { shouldApply });
},
setHandled: (value) => { handledFollowRef.current = value; },
setBlocked: (value) => { blockedFollowRef.current = value; },
}).then((completed) => {
const isCurrentAttempt = initialFollowProbeAttemptRef.current === probeAttempt;
if (isCurrentAttempt) initialFollowProbeAttemptRef.current = null;
if (completed && isCurrentAttempt) {
initialFollowReadyConnectionRef.current = expectedConnectionId;
}
if (!completed && isCurrentAttempt) clearAttemptAndRetry();
});
}, [
activeSessionId,
focusedSessionId,
activeTerminalCwd,
canFollowTerminalCwd,
connectionId,
connectionIsLocal,
connectionStatus,
effectiveFollowTerminalCwd,
hasActiveWork,
initialFollowRetryNonce,
isVisible,
onGetTerminalCwd,
sftpRef,
]);
return { handleGoToTerminalCwd, handleToggleFollowTerminalCwd };
}
+3 -7
View File
@@ -39,7 +39,6 @@ interface UseSftpPaneActionsParams {
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
lastConnectedHostRef: React.MutableRefObject<{ left: Host | "local" | null; right: Host | "local" | null }>;
connectionCacheKeyMapRef: React.MutableRefObject<Map<string, string>>;
reconnectingRef: React.MutableRefObject<{ left: boolean; right: boolean }>;
makeCacheKey: (connectionId: string, path: string, encoding?: SftpFilenameEncoding) => string;
clearCacheForConnection: (connectionId: string) => void;
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
@@ -98,7 +97,6 @@ export const useSftpPaneActions = ({
sftpSessionsRef,
lastConnectedHostRef,
connectionCacheKeyMapRef,
reconnectingRef,
makeCacheKey,
clearCacheForConnection,
listLocalFiles,
@@ -464,8 +462,7 @@ export const useSftpPaneActions = ({
|| pane.connection.isLocal
|| (!pane.connection.isLocal && pane.connection.hostId)
);
if (canReconnect && !reconnectingRef.current[side]) {
reconnectingRef.current[side] = true;
if (canReconnect) {
updateActiveTab(side, (prev) => ({
...prev,
reconnecting: true,
@@ -486,8 +483,7 @@ export const useSftpPaneActions = ({
// when they switch back to that tab.
if (options?.tabId) return;
const lastHost = lastConnectedHostRef.current[side];
if (lastHost && !reconnectingRef.current[side]) {
reconnectingRef.current[side] = true;
if (lastHost) {
updateActiveTab(side, (prev) => ({
...prev,
reconnecting: true,
@@ -501,7 +497,7 @@ export const useSftpPaneActions = ({
}
}
},
[getActivePane, leftTabsRef, rightTabsRef, navigateTo, updateActiveTab, lastConnectedHostRef, reconnectingRef, sftpSessionsRef],
[getActivePane, leftTabsRef, rightTabsRef, navigateTo, updateActiveTab, lastConnectedHostRef, sftpSessionsRef],
);
const navigateUp = useCallback(
@@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import test, { after } from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { useSftpSessionCleanup } from "./useSftpSessionCleanup";
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
after(() => { actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment; });
test("cleanup marks the SFTP owner disposed before a late open can register", async () => {
const sessionsRef = { current: new Map<string, string>() };
let disposedRef: { current: boolean } | null = null;
let renderer: ReactTestRenderer | null = null;
function Probe() {
disposedRef = useSftpSessionCleanup(sessionsRef);
return null;
}
await act(async () => {
renderer = create(React.createElement(Probe));
});
assert.equal(disposedRef?.current, false);
await act(async () => renderer?.unmount());
assert.equal(disposedRef?.current, true);
});
@@ -1,19 +1,27 @@
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import type { MutableRefObject } from "react";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
export const useSftpSessionCleanup = (sftpSessionsRef: MutableRefObject<Map<string, string>>) => {
export const useSftpSessionCleanup = (
sftpSessionsRef: MutableRefObject<Map<string, string>>,
): MutableRefObject<boolean> => {
const disposedRef = useRef(false);
useEffect(() => {
disposedRef.current = false;
const sessionsRef = sftpSessionsRef.current;
return () => {
sessionsRef.forEach(async (sftpId) => {
try {
await netcattyBridge.get()?.closeSftp(sftpId);
} catch {
// Ignore errors when closing SFTP sessions during cleanup
}
});
disposedRef.current = true;
const sftpIds = [...sessionsRef.values()];
sessionsRef.clear();
for (const sftpId of sftpIds) {
void netcattyBridge.get()?.closeSftp(sftpId).catch(() => {
// Ignore errors when closing SFTP sessions during cleanup.
});
}
};
}, [sftpSessionsRef]);
return disposedRef;
};
+1 -16
View File
@@ -13,7 +13,6 @@ interface UseSftpSessionErrorsParams {
) => void;
navSeqRef: MutableRefObject<{ left: number; right: number }>;
lastConnectedHostRef: MutableRefObject<{ left: Host | "local" | null; right: Host | "local" | null }>;
reconnectingRef: MutableRefObject<{ left: boolean; right: boolean }>;
releaseConnection: (connectionId: string) => Promise<void>;
}
@@ -42,7 +41,6 @@ export const useSftpSessionErrors = ({
updateActiveTab,
navSeqRef,
lastConnectedHostRef,
reconnectingRef,
releaseConnection,
}: UseSftpSessionErrorsParams) =>
useCallback(
@@ -64,22 +62,10 @@ export const useSftpSessionErrors = ({
connection: pane.connection,
});
if (canReconnect && !reconnectingRef.current[side]) {
if (canReconnect) {
// Keep the connection object (host identity + path) so the UI does not
// collapse into the empty host picker when listing root (or any path)
// fails with a transient session error on some servers.
reconnectingRef.current[side] = true;
updateActiveTab(side, (prev) => ({
...prev,
reconnecting: true,
loading: false,
error: "sftp.error.connectionLostReconnecting",
}));
return;
}
if (canReconnect && reconnectingRef.current[side]) {
// Already reconnecting — keep connection, avoid blank host picker.
updateActiveTab(side, (prev) => ({
...prev,
reconnecting: true,
@@ -108,7 +94,6 @@ export const useSftpSessionErrors = ({
updateActiveTab,
navSeqRef,
lastConnectedHostRef,
reconnectingRef,
releaseConnection,
],
);
@@ -0,0 +1,58 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createEmptyPane } from "./types.ts";
import {
closeSftpTabStateImmediately,
removeSftpTabFromState,
} from "./useSftpTabsState.ts";
test("closing the active SFTP tab selects its nearest remaining neighbor", () => {
const left = createEmptyPane("left");
const middle = createEmptyPane("middle");
const right = createEmptyPane("right");
assert.deepEqual(removeSftpTabFromState({
tabs: [left, middle, right],
activeTabId: middle.id,
}, middle.id), {
tabs: [left, right],
activeTabId: right.id,
});
});
test("closing a background SFTP tab preserves the active tab", () => {
const active = createEmptyPane("active");
const background = createEmptyPane("background");
assert.deepEqual(removeSftpTabFromState({
tabs: [active, background],
activeTabId: active.id,
}, background.id), {
tabs: [active],
activeTabId: active.id,
});
});
test("closing a tab updates the live ref before React commits the state update", () => {
const closing = createEmptyPane("closing");
const remaining = createEmptyPane("remaining");
const initial = {
tabs: [closing, remaining],
activeTabId: closing.id,
};
const tabsRef = { current: initial };
let queuedUpdate: ((prev: typeof initial) => typeof initial) | undefined;
closeSftpTabStateImmediately({
tabsRef,
tabId: closing.id,
setTabs: (update) => {
queuedUpdate = update;
},
});
assert.deepEqual(tabsRef.current.tabs, [remaining]);
assert.equal(tabsRef.current.activeTabId, remaining.id);
assert.deepEqual(queuedUpdate?.(initial), tabsRef.current);
});
+41 -18
View File
@@ -37,6 +37,39 @@ interface SftpTabsState {
const EMPTY_SELECTION = new Set<string>();
export function removeSftpTabFromState(prev: SftpSideTabs, tabId: string): SftpSideTabs {
const tabIndex = prev.tabs.findIndex((tab) => tab.id === tabId);
if (tabIndex === -1) return prev;
let activeTabId: string | null = null;
if (prev.tabs.length > 1) {
if (prev.activeTabId === tabId) {
const nextIndex = tabIndex < prev.tabs.length - 1 ? tabIndex + 1 : tabIndex - 1;
activeTabId = prev.tabs[nextIndex]?.id ?? null;
} else {
activeTabId = prev.activeTabId;
}
}
return {
tabs: prev.tabs.filter((tab) => tab.id !== tabId),
activeTabId,
};
}
export function closeSftpTabStateImmediately(params: {
tabsRef: { current: SftpSideTabs };
tabId: string;
setTabs: (updater: (prev: SftpSideTabs) => SftpSideTabs) => void;
}): void {
params.tabsRef.current = removeSftpTabFromState(params.tabsRef.current, params.tabId);
params.setTabs((prev) => {
const next = removeSftpTabFromState(prev, params.tabId);
params.tabsRef.current = next;
return next;
});
}
export const useSftpTabsState = ({
defaultShowHiddenFiles = false,
}: {
@@ -154,24 +187,14 @@ export const useSftpTabsState = ({
const closeTab = useCallback(
(side: "left" | "right", tabId: string) => {
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
setTabs((prev) => {
const tabIndex = prev.tabs.findIndex((t) => t.id === tabId);
if (tabIndex === -1) return prev;
let newActiveTabId: string | null = null;
if (prev.tabs.length > 1) {
if (prev.activeTabId === tabId) {
const nextIndex = tabIndex < prev.tabs.length - 1 ? tabIndex + 1 : tabIndex - 1;
newActiveTabId = prev.tabs[nextIndex]?.id || null;
} else {
newActiveTabId = prev.activeTabId;
}
}
return {
tabs: prev.tabs.filter((t) => t.id !== tabId),
activeTabId: newActiveTabId,
};
const tabsRef = side === "left" ? leftTabsRef : rightTabsRef;
// Make the removal visible to in-flight SFTP work immediately, before
// React commits the state update, so a late connection cannot register
// itself to a tab the user has already closed.
closeSftpTabStateImmediately({
tabsRef,
tabId,
setTabs,
});
},
[],
+16
View File
@@ -7,6 +7,7 @@ import {
getSftpBreadcrumbSegments,
getSftpFilterAfterPathChange,
getSftpFilterAfterPathChangeError,
getSftpPathRoot,
isConcreteTransferTargetPath,
isSftpDescendantPath,
isWindowsRoot,
@@ -323,3 +324,18 @@ test("SFTP filter restores when changed-directory navigation fails", () => {
test("SFTP filter preserves in-flight edits when same-directory refresh fails", () => {
assert.equal(getSftpFilterAfterPathChangeError(false, "log", "typed-while-loading"), "typed-while-loading");
});
test("getSftpPathRoot resolves the filesystem root for breadcrumb navigation", () => {
assert.equal(getSftpPathRoot("/var/www"), "/");
assert.equal(getSftpPathRoot("/"), "/");
assert.equal(getSftpPathRoot("//srv/share/logs"), "/");
assert.equal(getSftpPathRoot("C:\\Users\\alice"), "C:\\");
assert.equal(getSftpPathRoot("D:/"), "D:\\");
assert.equal(getSftpPathRoot("\\\\server\\share\\folder"), "\\\\server\\share");
// Forward-slash //host/share stays POSIX unless UNC is explicitly accepted.
assert.equal(getSftpPathRoot("//srv/share/logs"), "/");
assert.equal(
getSftpPathRoot("//srv/share/logs", { acceptForwardSlashUnc: true }),
"\\\\srv\\share",
);
});
+17
View File
@@ -212,6 +212,23 @@ const normalizeWindowsRoot = (path: string): string => {
return normalized;
};
/**
* Filesystem root for the given path, used by the breadcrumb "go to root" affordance:
* "/" on POSIX panes, the drive root (C:\) or UNC share root on Windows panes.
* Returns null when no root can be derived (e.g. a relative Windows path).
*/
export const getSftpPathRoot = (
path: string,
options?: SftpWindowsPathOptions,
): string | null => {
if (!isWindowsPath(path, options)) return "/";
const normalized = path.replace(/\//g, "\\");
const uncRoot = getWindowsUncRoot(normalized, options);
if (uncRoot) return uncRoot;
const drive = normalized.match(/^[A-Za-z]:/);
return drive ? `${drive[0]}\\` : null;
};
export const isWindowsRoot = (
path: string,
options?: SftpWindowsPathOptions,
@@ -0,0 +1,41 @@
import type { TransferTask } from "../../../domain/models";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
type StreamResult = { error?: string; cancelled?: boolean; superseded?: boolean } | undefined;
/** A live waiter owns bounded settlement evidence; persisted history stays compact. */
export async function runTransferAndWaitForOwner(
task: TransferTask,
start: () => Promise<StreamResult>,
shouldAbort: () => boolean,
pausedAtResume?: TransferTask,
): Promise<StreamResult> {
// Register before admission/start: an owner may finish while dispatch waits for resume.
let observation = sftpTransferCenterStore.observeTaskSettlement(task);
try {
for (;;) {
if (shouldAbort()) throw new Error("Transfer cancelled");
const admission = sftpTransferCenterStore.admitTaskRun(task, pausedAtResume);
if (admission === "cancelled") throw new Error("Transfer cancelled");
if (admission === "completed" || observation.read()?.status === "completed") return {};
if (admission === "conflict") throw new Error("Transfer identity changed before dispatch");
if (admission === "ready") break;
await new Promise((resolve) => setTimeout(resolve, 200));
}
// Admission begins a new attempt; discard any previous failed settlement.
observation.dispose();
observation = sftpTransferCenterStore.observeTaskSettlement(task);
const result = await start();
if (!result?.superseded) return result;
for (;;) {
if (shouldAbort()) throw new Error("Transfer cancelled");
const latest = observation.read();
if (latest?.status === "completed") return { ...result, superseded: false };
if (latest?.status === "failed") throw new Error(latest.error || "Transfer failed");
if (latest?.status === "cancelled") throw new Error("Transfer cancelled");
await new Promise((resolve) => setTimeout(resolve, 200));
}
} finally {
observation.dispose();
}
}
@@ -3762,3 +3762,108 @@ test("hard directory resume failure falls back to bounded fresh retry history",
assert.equal(retainedParent?.checkpointBytes, 0);
assert.ok(snapshot.length <= 200, `failed directory history must stay bounded, got ${snapshot.length}`);
});
test("held dedicated file resume cannot overwrite a subsequent pause", async (t) => {
const store = createSftpTransferCenterStore();
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
let releaseRun!: () => void;
let started!: () => void;
let releaseResume!: (result: { success: boolean; lifecycleEpoch: number }) => void;
const runGate = new Promise<void>((resolve) => { releaseRun = resolve; });
const startGate = new Promise<void>((resolve) => { started = resolve; });
const resumeGate = new Promise<{ success: boolean; lifecycleEpoch: number }>((resolve) => { releaseResume = resolve; });
t.after(async () => {
releaseRun();
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
const { resetTransferPauseLatchesForTests } = await import("./sftp/transferPauseLatch");
resetTransferPauseLatchesForTests();
});
Object.defineProperty(globalThis, "window", { configurable: true, value: { netcatty: {
pauseTransfer: async () => ({ success: true, checkpointBytes: 2, lifecycleEpoch: 3 }),
resumeTransfer: () => resumeGate,
} } });
store.publishOwner("dedicated-resume", [{
...makeTask("held-file", "interrupted"),
ownerId: "dedicated-resume", targetHostId: "host-a", reconnectRequired: true,
}]);
store.setDedicatedResumeHandler(async () => {
store.patchTask("held-file", { status: "transferring", reconnectRequired: false });
started();
await runGate;
return { success: true };
});
const first = store.resume("held-file");
await startGate;
await store.pause("held-file");
const second = store.resume("held-file");
await store.pause("held-file");
releaseResume({ success: true, lifecycleEpoch: 2 });
await new Promise((resolve) => setImmediate(resolve));
const row = store.getSnapshot().tasks.find((task) => task.id === "held-file");
releaseRun();
await Promise.all([first, second]);
assert.equal(row?.status, "paused", "held transfer must respect the latest pause");
assert.equal(row?.lifecycleEpoch, 3);
});
for (const outcome of ["rejected", "stream-gone"] as const) {
test(`held file ${outcome} resume cannot restart after a newer pause during wind-down`, async (t) => {
const store = createSftpTransferCenterStore();
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
let releaseRun!: () => void;
let started!: () => void;
let resolveResume!: (result: { success: boolean; reason: string }) => void;
let rejectResume!: (error: Error) => void;
let resumeCalls = 0;
const runGate = new Promise<void>((resolve) => { releaseRun = resolve; });
const startGate = new Promise<void>((resolve) => { started = resolve; });
const resumeGate = new Promise<{ success: boolean; reason: string }>((resolve, reject) => {
resolveResume = resolve;
rejectResume = reject;
});
t.after(async () => {
releaseRun();
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
const { resetTransferPauseLatchesForTests } = await import("./sftp/transferPauseLatch");
resetTransferPauseLatchesForTests();
});
Object.defineProperty(globalThis, "window", { configurable: true, value: { netcatty: {
pauseTransfer: async () => ({ success: true, checkpointBytes: 2, lifecycleEpoch: 3 }),
resumeTransfer: () => ++resumeCalls === 1
? resumeGate
: Promise.resolve({ success: true, lifecycleEpoch: 4 }),
} } });
const id = `held-file-${outcome}`;
store.publishOwner("dedicated-resume", [{
...makeTask(id, "interrupted"),
ownerId: "dedicated-resume", targetHostId: "host-a", reconnectRequired: true,
}]);
store.setDedicatedResumeHandler(async () => {
store.patchTask(id, { status: "transferring", reconnectRequired: false });
started();
await runGate;
return { success: false, error: "Transfer cancelled" };
});
const first = store.resume(id);
await startGate;
await store.pause(id);
const second = store.resume(id);
await new Promise((resolve) => setImmediate(resolve));
if (outcome === "stream-gone") {
resolveResume({ success: false, reason: "Transfer is no longer active" });
await new Promise((resolve) => setImmediate(resolve));
}
await store.pause(id);
if (outcome === "rejected") {
rejectResume(new Error("worker channel unavailable"));
await new Promise((resolve) => setImmediate(resolve));
}
releaseRun();
await Promise.all([first, second]);
assert.equal(store.getSnapshot().tasks.find((task) => task.id === id)?.status, "paused");
assert.equal(resumeCalls, 1, "obsolete resume must not issue another resume after the held run ends");
});
}
+112 -37
View File
@@ -28,6 +28,7 @@ import {
} from "./sftp/transferControlEpoch";
import { isTransferWalkInFlight } from "./sftp/transferWalkRegistry";
import {
isTransferOrRootCancelled,
markTransferCancelledTree,
settleTransferCancelTree,
} from "./sftp/transferCancelLatch";
@@ -96,6 +97,10 @@ export interface SftpTransferCenterStore {
getBadgeSnapshot(): { count: number; hasAttention: boolean };
/** Single task row — same object identity until that row is patched. */
getTask(taskId: string): TransferTask | undefined;
/** Observe exact task settlement before completed child rows are compacted. */
observeTaskSettlement(task: TransferTask): { read(): TransferTask | undefined; dispose(): void };
/** Publish an explicit dispatch identity without flushing large child-history batches. */
admitTaskRun(task: TransferTask, pausedAtResume?: TransferTask): "ready" | "paused" | "completed" | "cancelled" | "conflict";
getOwnerTasks(ownerId: string): TransferTask[];
publishOwner(ownerId: string, tasks: readonly TransferTask[]): void;
registerOwner(ownerId: string, controls: SftpTransferOwnerControls): () => void;
@@ -398,6 +403,15 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
let snapshotDirty = false;
const listeners = new Set<Listener>();
const progressListeners = new Set<Listener>();
const settlementObservers = new Map<string, Set<{ expected: TransferTask; settled?: TransferTask }>>();
const matchesObservedTask = (expected: TransferTask, candidate: TransferTask) => (
expected.id === candidate.id
&& expected.sourcePath === candidate.sourcePath
&& expected.targetPath === candidate.targetPath
&& expected.parentTaskId === candidate.parentTaskId
&& expected.directoryEntryIndex === candidate.directoryEntryIndex
&& expected.directoryEntryIdentity === candidate.directoryEntryIdentity
);
const refreshBadgeSnapshot = () => {
const next = buildBadgeSnapshot(tasks);
if (
@@ -609,6 +623,14 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
// live renderer walk keeps its controls until TransferRuntime.runWalk
// settles; everything else can release its whole tree here.
settleFinishedTransferControlState(tasks, tasks);
if (settlementObservers.size > 0) {
for (const task of tasks) {
if (!TERMINAL_OWNER_STATUSES.has(task.status)) continue;
for (const observer of settlementObservers.get(task.id) ?? []) {
if (!observer.settled && matchesObservedTask(observer.expected, task)) observer.settled = task;
}
}
}
const beforePrune = tasks;
tasks = pruneSftpTransferHistory(tasks);
const retainedIds = new Set(tasks.map((task) => task.id));
@@ -1307,6 +1329,70 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
},
getSnapshot: () => ensureSnapshot(),
getBadgeSnapshot: () => badgeSnapshot,
admitTaskRun(incoming, pausedAtResume) {
const rootId = incoming.parentTaskId ?? incoming.id;
if (isTransferOrRootCancelled(rootId, incoming.id)) return "cancelled";
const parent = incoming.parentTaskId ? tasks.find((task) => task.id === rootId) : undefined;
if (parent?.status === "cancelled") return "cancelled";
const index = tasks.findIndex((task) => task.id === incoming.id);
const existing = index < 0 ? undefined : tasks[index];
if (existing && (existing.sourcePath !== incoming.sourcePath || existing.targetPath !== incoming.targetPath
|| existing.parentTaskId !== incoming.parentTaskId)) return "conflict";
if (existing?.status === "cancelled") return "cancelled";
if (existing?.status === "completed") {
return matchesObservedTask(incoming, existing) ? "completed" : "conflict";
}
const resumesUnchangedPause = existing === pausedAtResume
&& existing?.status === "paused"
&& (parent?.status === "pending" || parent?.status === "transferring");
if (isTransferOrRootPauseLatched(rootId, incoming.id)
|| parent?.status === "paused" || parent?.status === "pausing"
|| (existing?.status === "paused" && !resumesUnchangedPause) || existing?.status === "pausing") return "paused";
if (parent?.status === "completed") return "conflict";
if (!existing) return "ready";
if (existing.status === "transferring"
&& existing.directoryEntryIndex === incoming.directoryEntryIndex
&& existing.directoryEntryIdentity === incoming.directoryEntryIdentity) return "ready";
// Explicit retry may leave failed/interrupted and replace its manifest identity.
// Do not overwrite byte checkpoints with a delayed renderer snapshot.
const next = tasks.slice();
next[index] = {
...existing,
directoryEntryIndex: incoming.directoryEntryIndex,
directoryEntryIdentity: incoming.directoryEntryIdentity,
...(existing.status === "transferring" ? {} : {
status: "transferring" as const,
lifecycleEpoch: undefined,
error: undefined,
endTime: undefined,
reconnectRequired: false,
}),
};
tasks = next;
emitProgress(false);
return "ready";
},
observeTaskSettlement(expected) {
const observer: { expected: TransferTask; settled?: TransferTask } = { expected: { ...expected } };
const observers = settlementObservers.get(expected.id) ?? new Set();
observers.add(observer);
settlementObservers.set(expected.id, observers);
let disposed = false;
return {
read() {
if (disposed) return undefined;
if (observer.settled) return observer.settled;
const current = tasks.find((task) => task.id === expected.id);
return current && matchesObservedTask(observer.expected, current) ? current : undefined;
},
dispose() {
disposed = true;
observer.settled = undefined;
observers.delete(observer);
if (observers.size === 0) settlementObservers.delete(expected.id);
},
};
},
getTask(taskId) {
return tasks.find((task) => task.id === taskId);
},
@@ -1671,7 +1757,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
// Always clear process-global latches + control epoch so walks wake and
// late soft-drain / pauseWatch cannot re-pause streams. Do not stamp
// control epoch as task.lifecycleEpoch (bridge-aligned only).
bumpTransferControlEpoch(taskId);
let resumeEpoch = bumpTransferControlEpoch(taskId);
releaseTransferPauseTree(taskId, childIds);
if (task.ownerId === "dedicated-resume" && task.isDirectory) {
@@ -1693,64 +1779,53 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
try { await bridge?.clearPendingTransferCancel?.(id); } catch { /* best-effort */ }
}
try { await bridge?.clearPendingTransferCancel?.(taskId); } catch { /* best-effort */ }
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return existing;
return resumeInvocations.get(taskId) ?? startFresh();
}
try {
const resumeIds = [taskId, ...childIds.filter((id) => id !== taskId)];
const results = await Promise.all(resumeIds.map(async (id) =>
netcattyBridge.get()?.resumeTransfer?.(id) ?? { success: false },
));
const after = tasks.find((candidate) => candidate.id === taskId);
if (after?.status === "cancelled") return existing;
// Only rejoin when at least one backend stream actually resumed.
// Empty/all-fail must not paint transferring over a dead held run.
const successIds = resumeIds.filter((_, index) => results[index]?.success);
if (successIds.length > 0) {
const resumed = new Set(successIds);
// Align with softResumeTransfer: prefer bridge lifecycleEpoch; clear
// if omitted so main-process progress is not stale-dropped.
let bridgeEpoch: number | undefined;
for (let index = 0; index < results.length; index += 1) {
if (!results[index]?.success) continue;
const epoch = (results[index] as { lifecycleEpoch?: number } | undefined)?.lifecycleEpoch;
if (!Number.isFinite(epoch)) continue;
bridgeEpoch = bridgeEpoch === undefined
? (epoch as number)
: Math.max(bridgeEpoch, epoch as number);
}
tasks = tasks.map((candidate) => {
if (candidate.id === taskId || resumed.has(candidate.id)) {
return {
...candidate,
status: "transferring" as const,
error: undefined,
reconnectRequired: false,
pauseUnavailableReason: undefined,
phase: undefined,
lifecycleEpoch: bridgeEpoch,
};
}
return candidate;
});
// Reuse the live control path: held dedicated transfers must obey
// the same ordering and per-child lifecycle rules as panel jobs.
const softOperation = softResumeTransfer({
getTasks: () => tasks,
setTasks: (next) => { tasks = next; emit(); },
getBridge: defaultTransferControlBridge,
}, taskId);
resumeEpoch = getTransferControlEpoch(taskId);
const soft = await softOperation;
if (soft.handled) return existing;
const streamGone = /no longer active|not active|not found|session is no longer|Resume unavailable|Transfer not found/i
.test(soft.reason ?? "");
if (!streamGone) {
tasks = tasks.map((candidate) => candidate.id === taskId ? {
...candidate,
status: "paused" as const,
speed: 0,
phase: undefined,
error: soft.reason,
} : candidate);
emit();
return existing;
}
} catch {
// Fall through to await + restart.
}
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return existing;
try {
await existing;
} catch { /* previous aborted */ }
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return existing;
return resumeInvocations.get(taskId) ?? startFresh();
}
// After demotion to interrupted/attention/failed while work unwinds:
// wait then re-invoke (do not rejoin a dying canceling promise).
if (task && (task.status === "interrupted" || task.status === "attention" || task.status === "failed")) {
const resumeEpoch = getTransferControlEpoch(taskId);
try {
await existing;
} catch { /* previous aborted */ }
if (getTransferControlEpoch(taskId) !== resumeEpoch) return existing;
return resumeInvocations.get(taskId) ?? startFresh();
}
return existing;
+3
View File
@@ -4,6 +4,7 @@ export type SidePanelLiveSnapshot = {
sftpActiveHost: Host | null;
activeTerminalSessionIdForSftp: string | null;
activeTerminalCwd: string | null;
activeTerminalCwdTrusted: boolean;
activeWorkspace: Workspace | undefined;
activeTerminalSessionForSystem: TerminalSession | null;
activeSystemSessionHost: Host | null;
@@ -25,6 +26,7 @@ const EMPTY_SNAPSHOT: SidePanelLiveSnapshot = {
sftpActiveHost: null,
activeTerminalSessionIdForSftp: null,
activeTerminalCwd: null,
activeTerminalCwdTrusted: false,
activeWorkspace: undefined,
activeTerminalSessionForSystem: null,
activeSystemSessionHost: null,
@@ -50,6 +52,7 @@ function liveSnapshotEqual(a: SidePanelLiveSnapshot, b: SidePanelLiveSnapshot):
return a.sftpActiveHost === b.sftpActiveHost
&& a.activeTerminalSessionIdForSftp === b.activeTerminalSessionIdForSftp
&& a.activeTerminalCwd === b.activeTerminalCwd
&& a.activeTerminalCwdTrusted === b.activeTerminalCwdTrusted
&& a.activeWorkspace === b.activeWorkspace
&& a.activeTerminalSessionForSystem === b.activeTerminalSessionForSystem
&& a.activeSystemSessionHost === b.activeSystemSessionHost
@@ -0,0 +1,191 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { isAutoLaunchResultTrustworthy } from './systemSettingsEffects.ts';
test('isAutoLaunchResultTrustworthy trusts a successful read/write result', () => {
assert.equal(isAutoLaunchResultTrustworthy({ success: true }), true);
});
test('isAutoLaunchResultTrustworthy rejects a transient failure result', () => {
assert.equal(
isAutoLaunchResultTrustworthy({ success: false }),
false,
'success:false means the OS state is unknown, not confirmed disabled',
);
});
test('isAutoLaunchResultTrustworthy defaults to trusting a result with no success field', () => {
// Defensive default for any future/alternate caller that omits the field
// entirely — only an explicit false should withhold trust.
assert.equal(isAutoLaunchResultTrustworthy({}), true);
});
/**
* Minimal model of the hydration effect's decision: given a getAutoLaunch()
* result, should it overwrite the cached autoLaunchEnabled value? This is
* the exact regression scenario from the review — a transient read failure
* must not overwrite a cached `true` with a "successful-looking" `false`,
* which would otherwise cascade into an unwanted disable write via the
* adjacent push effect.
*/
function simulateHydration(cachedEnabled: boolean, result: { success: boolean; enabled: boolean }): boolean {
if (!isAutoLaunchResultTrustworthy(result)) return cachedEnabled;
return result.enabled;
}
test('a transient read failure during hydration keeps the cached enabled value', () => {
const next = simulateHydration(true, { success: false, enabled: false });
assert.equal(next, true, 'must not silently flip a cached true to false on an unrelated read failure');
});
test('a successful read during hydration applies the real OS state, even when it flips the cache', () => {
const next = simulateHydration(true, { success: true, enabled: false });
assert.equal(next, false, 'a confirmed read must still win over a stale cache');
});
/**
* Model of the actual hook: a mount-time hydration request and a
* user-triggered write can race. This mirrors the real ordering the review
* flagged — hydration starts, the user toggles before it resolves (which
* starts a real write), then the stale hydration response arrives.
*/
function simulateMountToggleRace(
initialCachedEnabled: boolean,
userToggleTo: boolean,
staleHydrationResult: { success: boolean; enabled: boolean },
): boolean {
let state = initialCachedEnabled;
let writeStarted = false;
// Hydration request is issued here (pending) — resolution happens later.
// The user toggles before hydration resolves; this is what the real push
// effect does: apply optimistically and flag that a real write started.
state = userToggleTo;
writeStarted = true;
// The stale hydration response now arrives.
if (!writeStarted && isAutoLaunchResultTrustworthy(staleHydrationResult)) {
state = staleHydrationResult.enabled;
}
return state;
}
test('a user toggle during in-flight hydration is not clobbered by the stale hydration response', () => {
const result = simulateMountToggleRace(false, true, { success: true, enabled: false });
assert.equal(
result,
true,
'the stale response (enabled:false, read before the user toggled) must not overwrite the user\'s fresh true — ' +
'otherwise the adjacent push effect reacts to the overwrite and disables an item the user just enabled',
);
});
test('a user toggle to false during in-flight hydration is not clobbered by a stale enabled:true response', () => {
const result = simulateMountToggleRace(true, false, { success: true, enabled: true });
assert.equal(result, false, 'symmetric case: disabling must also win over a stale hydration response');
});
test('hydration still applies normally when it resolves before any write starts', () => {
// No race: writeStarted stays false throughout, so trustworthy hydration
// results must still be applied — the guard must not disable hydration
// unconditionally, only once a real write has actually begun.
let state = false;
let writeStarted = false;
const result = { success: true, enabled: true };
if (!writeStarted && isAutoLaunchResultTrustworthy(result)) {
state = result.enabled;
}
assert.equal(state, true);
void writeStarted;
});
/**
* Model of the push effect's own overlap guard: rapid double (or triple)
* toggles start multiple bridge.setAutoLaunch(...) calls that can resolve
* out of order. Each response is only reconciled into state if no newer
* request has started since — this is the exact scenario the review
* flagged: "each callback closes over its render's autoLaunchEnabled value
* ... without checking whether a newer request exists".
*/
class AutoLaunchWriteSimulator {
state: boolean;
private generation = 0;
constructor(initial: boolean) {
this.state = initial;
}
/** Simulates the user toggling the switch, starting a new write. */
startWrite(requestedValue: boolean): { requestGeneration: number; requestedValue: boolean } {
this.state = requestedValue;
this.generation += 1;
return { requestGeneration: this.generation, requestedValue };
}
/** Simulates that write's bridge.setAutoLaunch(...) response arriving. */
resolveWrite(request: { requestGeneration: number; requestedValue: boolean }, result: { success: boolean; enabled: boolean }) {
if (this.generation !== request.requestGeneration) return; // superseded — ignore
if (!isAutoLaunchResultTrustworthy(result)) return;
if (result.enabled !== request.requestedValue) this.state = result.enabled;
}
}
test('a stale write response is ignored once a newer request has started', () => {
const sim = new AutoLaunchWriteSimulator(false);
const first = sim.startWrite(true); // user enables
const second = sim.startWrite(false); // user immediately disables again, before `first` resolves
assert.equal(sim.state, false, 'the latest toggle is reflected optimistically right away');
// `first`'s response arrives late, reporting a mismatch against ITS OWN
// request (e.g. macOS pending approval reported enabled:false for a
// requested true) — without the generation guard this would incorrectly
// "correct" state to false right as `second` is still in flight, and
// with a mismatch reported the OTHER way it could just as easily stomp
// a subsequent true.
sim.resolveWrite(first, { success: true, enabled: false });
assert.equal(sim.state, false, 'the stale response for the superseded first request must not touch state');
// `second`'s own (current) response arrives and is applied normally.
sim.resolveWrite(second, { success: true, enabled: false });
assert.equal(sim.state, false);
});
test('a stale write response would have incorrectly overridden a later successful write, without the guard', () => {
const sim = new AutoLaunchWriteSimulator(false);
const first = sim.startWrite(true); // request enable — this one will report a mismatch
const second = sim.startWrite(true); // user toggles off and back on again quickly; also requests true
assert.equal(sim.state, true);
// second's response arrives first (network/OS timing is not ordered) and
// confirms the real, current state.
sim.resolveWrite(second, { success: true, enabled: true });
assert.equal(sim.state, true);
// first's stale response finally arrives, reporting a mismatch (its own
// request was true but the OS said false at the time) — must not undo
// the now-confirmed true state from the newer request.
sim.resolveWrite(first, { success: true, enabled: false });
assert.equal(
sim.state,
true,
'a delayed response from a superseded request must not overwrite the newer, already-confirmed state',
);
});
test('the only in-flight write still reconciles a genuine mismatch normally', () => {
const sim = new AutoLaunchWriteSimulator(false);
const only = sim.startWrite(true);
sim.resolveWrite(only, { success: true, enabled: false }); // e.g. blocked by Windows Startup Apps
assert.equal(sim.state, false, 'with no overlap, a real mismatch must still correct the optimistic toggle');
});
@@ -2,6 +2,7 @@ import { useEffect, useRef, type MutableRefObject } from 'react';
import {
STORAGE_KEY_AUTO_UPDATE_ENABLED,
STORAGE_KEY_CLOSE_TO_TRAY,
STORAGE_KEY_AUTO_LAUNCH_ENABLED,
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
STORAGE_KEY_WINDOW_OPACITY,
@@ -24,11 +25,24 @@ import {
type WindowOpacityRecord,
} from './windowOpacitySync';
/**
* A getAutoLaunch/setAutoLaunch IPC result is only safe to apply to state
* when the main process could actually determine the OS's login-item state.
* success:false means "unknown" (a transient read/write failure), not a
* confirmed disabled state — applying enabled:false in that case would
* overwrite a valid cached value and, via the push effect, cascade into an
* unwanted disable write that could remove a working login item.
*/
export function isAutoLaunchResultTrustworthy(result: { success?: boolean }): boolean {
return result.success !== false;
}
interface UseSystemSettingsEffectsParams {
enabled?: boolean;
toggleWindowHotkey: string;
globalHotkeyEnabled: boolean;
closeToTray: boolean;
autoLaunchEnabled: boolean;
windowOpacityRecord: WindowOpacityRecord;
windowOpacityMutationSourceRef: MutableRefObject<WindowOpacityMutationSource>;
appIconVariant: AppIconVariant;
@@ -38,6 +52,8 @@ interface UseSystemSettingsEffectsParams {
setHotkeyRegistrationError: (error: string | null) => void;
setAutoUpdateEnabled: (enabled: boolean | ((prev: boolean) => boolean)) => void;
setAppIconVariant: (variant: AppIconVariant | ((prev: AppIconVariant) => AppIconVariant)) => void;
setAutoLaunchEnabled: (enabled: boolean | ((prev: boolean) => boolean)) => void;
setAutoLaunchSupported: (supported: boolean) => void;
notifySettingsChanged: (key: string, value: unknown) => void;
}
@@ -46,6 +62,7 @@ export function useSystemSettingsEffects({
toggleWindowHotkey,
globalHotkeyEnabled,
closeToTray,
autoLaunchEnabled,
windowOpacityRecord,
windowOpacityMutationSourceRef,
appIconVariant,
@@ -55,9 +72,26 @@ export function useSystemSettingsEffects({
setHotkeyRegistrationError,
setAutoUpdateEnabled,
setAppIconVariant,
setAutoLaunchEnabled,
setAutoLaunchSupported,
notifySettingsChanged,
}: UseSystemSettingsEffectsParams) {
const appIconApplyRequestIdRef = useRef(0);
// True once the push effect has issued a real OS write (i.e. after mount,
// when persistMountedRef is set — see below). If the user toggles
// auto-launch while the mount-time hydration read is still in flight, the
// hydration effect must not let its now-stale response overwrite that
// choice, or the adjacent push effect reacts to the overwrite and issues
// the opposite OS write right after the user's own request lands.
const autoLaunchWriteStartedRef = useRef(false);
// Incremented on every push-effect invocation. If the user toggles again
// before an in-flight bridge.setAutoLaunch(...) resolves, that older
// call's response is stale by the time it arrives — comparing its own
// request against its own result is self-consistent but says nothing
// about whether a newer request has since superseded it, so a delayed
// response could otherwise "correct" state the user has already moved
// past (undoing a toggle they made after this specific request started).
const autoLaunchWriteGenerationRef = useRef(0);
// Persist and sync toggle window hotkey setting
useEffect(() => {
@@ -139,6 +173,61 @@ export function useSystemSettingsEffects({
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
}, [enabled, closeToTray, notifySettingsChanged, persistMountedRef]);
// Hydrate auto-launch from the main process on mount — the OS login item
// is the real source of truth (the user may have toggled it outside the
// app), localStorage is only an optimistic cache for first paint.
useEffect(() => {
if (!enabled) return;
const bridge = netcattyBridge.get();
if (!bridge?.getAutoLaunch) return;
let cancelled = false;
bridge.getAutoLaunch().then((result) => {
if (cancelled) return;
setAutoLaunchSupported(result.supported);
// The user (or an already-issued write) may have moved state on while
// this request was in flight — a stale hydration response must not
// clobber it, or the push effect below reacts to the clobber and
// fires an unwanted OS write right after the user's own request.
if (autoLaunchWriteStartedRef.current) return;
if (!isAutoLaunchResultTrustworthy(result)) return;
setAutoLaunchEnabled(result.enabled);
localStorageAdapter.writeString(STORAGE_KEY_AUTO_LAUNCH_ENABLED, result.enabled ? 'true' : 'false');
}).catch((err) => {
console.warn('[AutoLaunch] Failed to read login item state:', err);
});
return () => {
cancelled = true;
};
// Runs once per mount — this is a one-shot hydration, not a sync loop.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled]);
// Push auto-launch changes to the main process and cache the result.
useEffect(() => {
if (!enabled) return;
if (!persistMountedRef.current) return;
const bridge = netcattyBridge.get();
if (!bridge?.setAutoLaunch) return;
// A real write is now in flight — permanently disqualify the mount-time
// hydration effect above from applying a (now possibly stale) response.
autoLaunchWriteStartedRef.current = true;
const requestGeneration = ++autoLaunchWriteGenerationRef.current;
bridge.setAutoLaunch(autoLaunchEnabled).then((result) => {
setAutoLaunchSupported(result.supported);
// The user toggled again before this request resolved — a newer
// request (and its own response, once it arrives) is authoritative;
// reconciling this stale one could undo a choice made after it fired.
if (autoLaunchWriteGenerationRef.current !== requestGeneration) return;
if (!isAutoLaunchResultTrustworthy(result)) return;
if (result.enabled !== autoLaunchEnabled) setAutoLaunchEnabled(result.enabled);
localStorageAdapter.writeString(STORAGE_KEY_AUTO_LAUNCH_ENABLED, result.enabled ? 'true' : 'false');
}).catch((err) => {
console.warn('[AutoLaunch] Failed to update login item state:', err);
});
notifySettingsChanged(STORAGE_KEY_AUTO_LAUNCH_ENABLED, autoLaunchEnabled);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, autoLaunchEnabled]);
// Persist and apply app-level HTTP(S) network proxy (cloud sync / AI)
useEffect(() => {
if (!enabled) return;
@@ -7,6 +7,7 @@ import {
createCopiedTerminalSessionClone,
createSplitTerminalSessionClone,
isTerminalSessionEligibleForSftpReuse,
resolveSftpReuseSourceSessionId,
} from "./terminalConnectionReuse";
const session = (overrides: Partial<TerminalSession> = {}): TerminalSession => ({
@@ -33,6 +34,18 @@ test("SSH sessions stay SFTP-linkable while reconnecting", () => {
assert.equal(isTerminalSessionEligibleForSftpReuse(session({ etEnabled: true })), false);
});
test("Mosh and ET drops keep their origin but do not request SSH session reuse", () => {
assert.equal(resolveSftpReuseSourceSessionId(session(), "session-1"), "session-1");
assert.equal(
resolveSftpReuseSourceSessionId(session({ moshEnabled: true }), "session-1"),
undefined,
);
assert.equal(
resolveSftpReuseSourceSessionId(session({ etEnabled: true }), "session-1"),
undefined,
);
});
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);
@@ -85,6 +98,50 @@ test("copy session clones reuse SSH sources and preserve serial config", () => {
assert.deepEqual(copied.serialConfig, { path: "/dev/tty.usbserial", baudRate: 115200 });
});
test("copy session clones open a fresh connection when reuseConnection is false", () => {
const fresh = createCopiedTerminalSessionClone(session(), { id: "copy-fresh", reuseConnection: false });
assert.equal(fresh.reuseConnectionFromSessionId, undefined);
// The explicit fresh flag must survive: without it the bridge treats the
// clone as eligible for general endpoint reuse and can still borrow the
// source's live authenticated transport.
assert.equal(fresh.requireFreshConnection, true);
// Local sources never had reuse to drop — behavior is unchanged.
assert.equal(
createCopiedTerminalSessionClone(session({ protocol: "local" }), { id: "copy-local-fresh", reuseConnection: false }).reuseConnectionFromSessionId,
undefined,
);
});
test("copy and split clones default to no fresh-connection requirement", () => {
assert.equal(createCopiedTerminalSessionClone(session(), { id: "copy-default" }).requireFreshConnection, undefined);
assert.equal(createSplitTerminalSessionClone(session(), { id: "split-default" }).requireFreshConnection, undefined);
assert.equal(
createCopiedTerminalSessionClone(session(), { id: "copy-reuse", reuseConnection: true }).requireFreshConnection,
undefined,
);
});
test("fresh SSH clones discard inherited directories even when a caller supplies one", () => {
for (const protocol of ["ssh", undefined] as const) {
const clone = createCopiedTerminalSessionClone(session({ protocol }), {
id: "fresh-no-cwd", reuseConnection: false, inheritedCwd: "/srv/old-target",
});
assert.equal(clone.pendingInitialCwd, undefined);
assert.equal(clone.requireFreshConnection, true);
}
const localClone = createCopiedTerminalSessionClone(session({ protocol: "local" }), {
id: "local-cwd", reuseConnection: false, inheritedCwd: "/home/alice/project",
});
assert.equal(localClone.localStartDir, "/home/alice/project");
});
test("copy session clones default to connection reuse when reuseConnection is unset", () => {
assert.equal(
createCopiedTerminalSessionClone(session(), { id: "copy-default" }).reuseConnectionFromSessionId,
"session-1",
);
});
test("split and copy session clones preserve local start directory", () => {
const source = session({
protocol: "local",
+32 -3
View File
@@ -1,7 +1,12 @@
import type { TerminalSession } from "../../domain/models";
type SftpReuseCandidate = Pick<
TerminalSession,
"protocol" | "moshEnabled" | "etEnabled"
>;
/** SSH (non-mosh/et) sessions that can back SFTP once the transport is up. */
export function isTerminalSessionEligibleForSftpReuse(session: TerminalSession): boolean {
export function isTerminalSessionEligibleForSftpReuse(session: SftpReuseCandidate): boolean {
return (
(session.protocol === "ssh" || session.protocol === undefined)
&& !session.moshEnabled
@@ -9,6 +14,15 @@ export function isTerminalSessionEligibleForSftpReuse(session: TerminalSession):
);
}
export function resolveSftpReuseSourceSessionId(
session: SftpReuseCandidate,
sessionId: string,
): string | undefined {
return isTerminalSessionEligibleForSftpReuse(session)
? sessionId
: undefined;
}
export function canReuseTerminalConnection(session: TerminalSession): boolean {
return isTerminalSessionEligibleForSftpReuse(session) && session.status === "connected";
}
@@ -18,6 +32,12 @@ type CloneSessionOptions = {
localShellType?: TerminalSession["shellType"];
workspaceId?: string;
inheritedCwd?: string;
/**
* When false, the clone never reuses the source's established SSH channel and
* instead opens a brand-new connection (fresh auth; e.g. a new bastion login).
* Defaults to true (reuse when possible).
*/
reuseConnection?: boolean;
};
function getClonedShellType(
@@ -36,7 +56,8 @@ function createTerminalSessionClone(
// pendingInitialCwd for telnet/serial/mosh/et would be dead, never-cleared
// state (those protocols don't track cwd, so it's never consumed or cleared).
const injectsInheritedCwd =
(session.protocol === "ssh" || session.protocol === undefined)
options.reuseConnection !== false
&& (session.protocol === "ssh" || session.protocol === undefined)
&& !session.moshEnabled
&& !session.etEnabled;
const clonedSession: TerminalSession = {
@@ -64,7 +85,15 @@ function createTerminalSessionClone(
fontSizeOverride: session.fontSizeOverride,
...(session.ephemeralHost ? { ephemeralHost: true } : {}),
...(injectsInheritedCwd && options.inheritedCwd ? { pendingInitialCwd: options.inheritedCwd } : {}),
reuseConnectionFromSessionId: canReuseTerminalConnection(session) ? session.id : undefined,
// Clearing `reuseConnectionFromSessionId` only disables source-specific
// reuse; without an explicit fresh-connection flag the bridge would still
// be eligible for general endpoint/idle transport reuse (e.g. borrowing
// the source's live transport). Flag the clone so the starter sends
// `reuseTransport: false` and the session truly dials fresh.
...(options.reuseConnection === false ? { requireFreshConnection: true } : {}),
reuseConnectionFromSessionId: options.reuseConnection === false
? undefined
: (canReuseTerminalConnection(session) ? session.id : undefined),
};
if (options.workspaceId) {
+5 -1
View File
@@ -3,7 +3,7 @@ import test from 'node:test';
import { terminalCwdStore } from './terminalCwdStore.ts';
test('terminalCwdStore bumps version only when cwd changes', () => {
test('terminalCwdStore bumps version only when cwd or provenance changes', () => {
const versions: number[] = [];
const unsubscribe = terminalCwdStore.subscribe(() => {
versions.push(terminalCwdStore.getVersion());
@@ -20,6 +20,10 @@ test('terminalCwdStore bumps version only when cwd changes', () => {
assert.equal(terminalCwdStore.setCwd('s1', '/var'), true);
assert.equal(terminalCwdStore.getCwd('s1'), '/var');
assert.equal(terminalCwdStore.setCwd('s1', '/var', 'backend-strict'), true);
assert.equal(terminalCwdStore.getSource('s1'), 'backend-strict');
assert.equal(terminalCwdStore.setCwd('s1', '/var', 'backend-strict'), false);
terminalCwdStore.prune(new Set());
assert.equal(terminalCwdStore.getCwd('s1'), null);
+17 -2
View File
@@ -1,5 +1,7 @@
type Listener = () => void;
export type TerminalCwdSource = "osc7" | "backend-strict" | "backend" | "snapshot" | "stale" | "unknown";
/**
* Live terminal CWD map + version token.
* OSC 7 / cwd probes update this store without setState on TerminalLayer,
@@ -7,6 +9,7 @@ type Listener = () => void;
*/
class TerminalCwdStore {
private cwdBySession = new Map<string, string>();
private sourceBySession = new Map<string, TerminalCwdSource>();
private version = 0;
private listeners = new Set<Listener>();
@@ -17,6 +20,11 @@ class TerminalCwdStore {
return this.cwdBySession.get(sessionId) ?? null;
};
getSource = (sessionId: string | null | undefined): TerminalCwdSource | undefined => {
if (!sessionId) return undefined;
return this.sourceBySession.get(sessionId);
};
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
@@ -24,15 +32,21 @@ class TerminalCwdStore {
};
};
setCwd(sessionId: string, cwd: string | null): boolean {
setCwd(sessionId: string, cwd: string | null, source?: TerminalCwdSource): boolean {
const current = this.cwdBySession.get(sessionId) ?? null;
const currentSource = this.sourceBySession.get(sessionId);
const next = cwd && cwd.trim().length > 0 ? cwd : null;
if (current === next) return false;
const nextSource = next
? (source ?? (current === next ? currentSource : undefined) ?? "unknown")
: undefined;
if (current === next && currentSource === nextSource) return false;
if (next) {
this.cwdBySession.set(sessionId, next);
this.sourceBySession.set(sessionId, nextSource!);
} else {
this.cwdBySession.delete(sessionId);
this.sourceBySession.delete(sessionId);
}
this.version += 1;
for (const listener of this.listeners) {
@@ -46,6 +60,7 @@ class TerminalCwdStore {
for (const sessionId of this.cwdBySession.keys()) {
if (!validSessionIds.has(sessionId)) {
this.cwdBySession.delete(sessionId);
this.sourceBySession.delete(sessionId);
changed = true;
}
}
@@ -7,6 +7,7 @@ import {
createTerminalSelectionAttachment,
decodeTerminalSelectionAttachment,
} from "./terminalSelectionAttachment.ts";
import { createVaultNoteAttachment, formatVaultNoteReferences } from "./vaultNoteAttachment.ts";
test("createTerminalSelectionAttachment returns null for blank selections", () => {
assert.equal(createTerminalSelectionAttachment(" \n\t"), null);
@@ -50,3 +51,13 @@ test("buildPromptWithTerminalSelectionAttachments supports terminal-only prompts
`[Terminal selection: ${attachment.filename}]\nsystemctl status nginx`,
);
});
test("buildPromptWithTerminalSelectionAttachments keeps empty Vault note blocks", () => {
const attachment = createVaultNoteAttachment({ id: "note-1", title: "Runbook" });
assert.ok(attachment);
assert.equal(
buildPromptWithTerminalSelectionAttachments("add deployment steps to this note", [attachment]),
`add deployment steps to this note\n\n${formatVaultNoteReferences([attachment])}`,
);
});
@@ -1,4 +1,8 @@
import type { ChatMessageAttachment, UploadedFile } from "../../infrastructure/ai/types";
import {
formatVaultNoteReferences,
isVaultNoteAttachment,
} from "./vaultNoteAttachment";
export const TERMINAL_SELECTION_ATTACHMENT_MEDIA_TYPE = "text/plain";
@@ -81,6 +85,16 @@ export function isTerminalSelectionAttachment(
return attachment.terminalSelection === true;
}
/**
* Attachments whose text is inlined into the prompt instead of being sent as
* file parts: terminal selections and Vault note mentions.
*/
export function isInlineTextAttachment(
attachment: Pick<ChatMessageAttachment | UploadedFile, "terminalSelection" | "vaultNoteId">,
): boolean {
return isTerminalSelectionAttachment(attachment) || isVaultNoteAttachment(attachment);
}
export function buildPromptWithTerminalSelectionAttachments(
prompt: string,
attachments: Array<ChatMessageAttachment | UploadedFile>,
@@ -95,7 +109,11 @@ export function buildPromptWithTerminalSelectionAttachments(
})
.filter((block): block is string => block !== null);
if (terminalBlocks.length === 0) return prompt;
if (!prompt.trim()) return terminalBlocks.join("").trimStart();
return `${prompt}${terminalBlocks.join("")}`;
const notes = attachments.filter(isVaultNoteAttachment);
const noteBlocks = notes.length ? [`\n\n${formatVaultNoteReferences(notes)}`] : [];
const blocks = [...terminalBlocks, ...noteBlocks];
if (blocks.length === 0) return prompt;
if (!prompt.trim()) return blocks.join("").trimStart();
return `${prompt}${blocks.join("")}`;
}
+45
View File
@@ -8,6 +8,7 @@ import {
uploadFromDataTransfer,
uploadFromFileList,
} from "../../lib/uploadService.ts";
import { DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY } from "../../domain/sftpTransferConcurrency.ts";
function createDataTransfer(files: File[]): DataTransfer {
return {
@@ -197,6 +198,50 @@ test("uploads picked folder files with their relative directory structure", asyn
]);
});
test("ordinary multi-file uploads use the shared transfer concurrency", async () => {
const countInitiallyStarted = async (fileTransferConcurrency?: number) => {
const started: string[] = [];
let releaseAll!: () => void;
const gate = new Promise<void>((resolve) => { releaseAll = resolve; });
const files = Array.from({ length: DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY + 2 }, (_, index) => {
const file = new File([`file-${index}`], `file-${index}.txt`);
Object.defineProperty(file, "path", { value: `/local/file-${index}.txt` });
return file;
});
const uploading = uploadFromFileList(
files,
{
targetPath: "/target",
sftpId: "sftp-1",
fileTransferConcurrency,
isLocal: false,
bridge: {
mkdirSftp: async () => {},
startStreamTransfer: async ({ targetPath }) => {
started.push(targetPath);
await gate;
return { transferId: targetPath };
},
},
joinPath: (base, name) => `${base}/${name}`,
},
);
await new Promise((resolve) => setImmediate(resolve));
const initiallyStarted = started.length;
releaseAll();
await uploading;
return initiallyStarted;
};
assert.equal(
await countInitiallyStarted(),
DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY,
);
assert.equal(await countInitiallyStarted(3), 3);
});
test("compression remains enabled for DataTransfer folder uploads that have a conflict resolver", async (t) => {
const starts: string[] = [];
installCompressedUploadBridge(t, {
@@ -0,0 +1,57 @@
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 { STORAGE_KEY_AI_COMMAND_BLOCKLIST } from '../../infrastructure/config/storageKeys.ts';
import { useAISettingsState } from './useAISettingsState.ts';
test('command blocklist storage events refresh the mounted AI settings state', async () => {
const store = new Map<string, string>();
const fakeStorage = {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => store.set(key, String(value)),
removeItem: (key: string) => store.delete(key),
};
const fakeWindow = new EventTarget() as EventTarget & { netcatty?: unknown };
const globals = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
localStorage?: typeof fakeStorage;
window?: typeof fakeWindow;
};
const previousActEnvironment = globals.IS_REACT_ACT_ENVIRONMENT;
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
globals.IS_REACT_ACT_ENVIRONMENT = true;
Object.defineProperty(globalThis, 'localStorage', { value: fakeStorage, configurable: true });
Object.defineProperty(globalThis, 'window', { value: fakeWindow, configurable: true });
let currentBlocklist: string[] = [];
let renderer: ReactTestRenderer | null = null;
const Probe = () => {
currentBlocklist = useAISettingsState().commandBlocklist;
return null;
};
try {
await act(async () => {
renderer = create(React.createElement(Probe));
});
fakeStorage.setItem(STORAGE_KEY_AI_COMMAND_BLOCKLIST, JSON.stringify(['company-blocked-command']));
const event = new Event('storage');
Object.defineProperty(event, 'key', { value: STORAGE_KEY_AI_COMMAND_BLOCKLIST });
await act(async () => {
fakeWindow.dispatchEvent(event);
});
assert.deepEqual(currentBlocklist, ['company-blocked-command']);
} finally {
await act(async () => {
renderer?.unmount();
});
globals.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
if (previousLocalStorage) Object.defineProperty(globalThis, 'localStorage', previousLocalStorage);
else delete globals.localStorage;
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow);
else delete globals.window;
}
});
+7 -8
View File
@@ -28,7 +28,6 @@ import type {
WebSearchConfig,
} from '../../infrastructure/ai/types';
import {
DEFAULT_COMMAND_BLOCKLIST,
DEFAULT_COMMAND_TIMEOUT_SECONDS,
DEFAULT_RESPONSE_IDLE_TIMEOUT_SECONDS,
normalizeCommandTimeoutSeconds,
@@ -38,6 +37,10 @@ import { removeProviderReferences } from './aiProviderCleanup';
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
import { getAIBridge } from './aiStateSnapshots';
import { useStoredBoolean } from './useStoredBoolean';
import {
persistCommandBlocklistSetting,
readCommandBlocklistSetting,
} from './commandBlocklistSettings';
function readPermissionMode(): AIPermissionMode {
const stored = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
@@ -69,9 +72,7 @@ export function useAISettingsState() {
const [defaultAgentId, setDefaultAgentIdRaw] = useState<string>(() =>
localStorageAdapter.readString(STORAGE_KEY_AI_DEFAULT_AGENT) ?? 'catty'
);
const [commandBlocklist, setCommandBlocklistRaw] = useState<string[]>(() =>
localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST) ?? [...DEFAULT_COMMAND_BLOCKLIST]
);
const [commandBlocklist, setCommandBlocklistRaw] = useState<string[]>(readCommandBlocklistSetting);
const [commandTimeout, setCommandTimeoutRaw] = useState<number>(() =>
normalizeCommandTimeoutSeconds(
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? DEFAULT_COMMAND_TIMEOUT_SECONDS,
@@ -173,7 +174,7 @@ export function useAISettingsState() {
const setCommandBlocklist = useCallback((value: string[]) => {
setCommandBlocklistRaw(value);
localStorageAdapter.write(STORAGE_KEY_AI_COMMAND_BLOCKLIST, value);
persistCommandBlocklistSetting(value);
getAIBridge()?.aiMcpSetCommandBlocklist?.(value);
}, []);
@@ -249,9 +250,7 @@ export function useAISettingsState() {
setDefaultAgentIdRaw(localStorageAdapter.readString(STORAGE_KEY_AI_DEFAULT_AGENT) ?? 'catty');
break;
case STORAGE_KEY_AI_COMMAND_BLOCKLIST: {
const list = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
if (list != null && !Array.isArray(list)) break;
const blocklist = list ?? [...DEFAULT_COMMAND_BLOCKLIST];
const blocklist = readCommandBlocklistSetting();
setCommandBlocklistRaw(blocklist);
getAIBridge()?.aiMcpSetCommandBlocklist?.(blocklist);
break;
+11 -15
View File
@@ -37,7 +37,6 @@ import type {
WebSearchConfig,
} from '../../infrastructure/ai/types';
import {
DEFAULT_COMMAND_BLOCKLIST,
DEFAULT_COMMAND_TIMEOUT_SECONDS,
DEFAULT_RESPONSE_IDLE_TIMEOUT_SECONDS,
normalizeCommandTimeoutSeconds,
@@ -69,7 +68,7 @@ import {
latestAIDraftsByScopeSnapshot,
latestAIPanelViewByScopeSnapshot,
latestAISessionsSnapshot,
pruneSessionsForStorage,
writeSessionsForStorage,
setLatestAIActiveSessionMapSnapshot,
setLatestAIDraftsByScopeSnapshot,
setLatestAIPanelViewByScopeSnapshot,
@@ -78,6 +77,10 @@ import {
type PanelViewByScope,
} from './aiStateSnapshots';
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
import {
persistCommandBlocklistSetting,
readCommandBlocklistSetting,
} from './commandBlocklistSettings';
import {
handoffDissolvedWorkspaceAIScope,
retargetWorkspaceActiveChatAfterMemberLoss,
@@ -140,9 +143,7 @@ export function useAIState() {
);
// ── Safety Settings ──
const [commandBlocklist, setCommandBlocklistRaw] = useState<string[]>(() =>
localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST) ?? [...DEFAULT_COMMAND_BLOCKLIST]
);
const [commandBlocklist, setCommandBlocklistRaw] = useState<string[]>(readCommandBlocklistSetting);
const [commandTimeout, setCommandTimeoutRaw] = useState<number>(() =>
normalizeCommandTimeoutSeconds(
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? DEFAULT_COMMAND_TIMEOUT_SECONDS,
@@ -423,7 +424,7 @@ export function useAIState() {
const setCommandBlocklist = useCallback((value: string[]) => {
setCommandBlocklistRaw(value);
localStorageAdapter.write(STORAGE_KEY_AI_COMMAND_BLOCKLIST, value);
persistCommandBlocklistSetting(value);
// Sync to MCP Server bridge so SDK agents also respect the blocklist
const bridge = getAIBridge();
bridge?.aiMcpSetCommandBlocklist?.(value);
@@ -503,12 +504,7 @@ export function useAIState() {
setDefaultAgentIdRaw(localStorageAdapter.readString(STORAGE_KEY_AI_DEFAULT_AGENT) ?? 'catty');
break;
case STORAGE_KEY_AI_COMMAND_BLOCKLIST: {
const list = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
if (list != null && !Array.isArray(list)) {
console.warn('[useAIState] Cross-window sync: AI_COMMAND_BLOCKLIST is not an array, skipping');
break;
}
const blocklist = list ?? [...DEFAULT_COMMAND_BLOCKLIST];
const blocklist = readCommandBlocklistSetting();
setCommandBlocklistRaw(blocklist);
getAIBridge()?.aiMcpSetCommandBlocklist?.(blocklist);
break;
@@ -627,7 +623,7 @@ export function useAIState() {
// ── Sync initial safety settings to MCP Server on mount ──
useEffect(() => {
const bridge = getAIBridge();
const initialBlocklist = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST) ?? [...DEFAULT_COMMAND_BLOCKLIST];
const initialBlocklist = readCommandBlocklistSetting();
bridge?.aiMcpSetCommandBlocklist?.(initialBlocklist);
const initialTimeout = normalizeCommandTimeoutSeconds(
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? DEFAULT_COMMAND_TIMEOUT_SECONDS,
@@ -650,7 +646,7 @@ export function useAIState() {
// ── Session CRUD ──
const persistSessions = useCallback((next: AISession[]) => {
localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, pruneSessionsForStorage(next));
writeSessionsForStorage(next);
}, []);
// Debounced version of persistSessions for high-frequency updates (e.g. streaming)
@@ -661,7 +657,7 @@ export function useAIState() {
if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
persistTimerRef.current = setTimeout(() => {
if (!mountedRef.current) return; // Skip writes after unmount
localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, pruneSessionsForStorage(sessionsRef.current));
writeSessionsForStorage(sessionsRef.current);
persistTimerRef.current = null;
}, 500);
}, []);
+3 -2
View File
@@ -30,6 +30,7 @@ import {
getEffectivePortForwardingRulesForSync,
hasCloudSyncEntityData,
hasMeaningfulCloudSyncData,
sanitizeHostsForSync,
sanitizePortForwardingRulesForSync,
shouldPromptCloudVaultRecovery,
} from '../syncPayload';
@@ -145,7 +146,7 @@ const isRestoreInProgress = (): boolean => {
const getSyncPayloadDataHash = (payload: SyncPayload): string => {
return JSON.stringify({
hosts: payload.hosts,
hosts: sanitizeHostsForSync(payload.hosts),
keys: payload.keys,
identities: payload.identities,
proxyProfiles: payload.proxyProfiles,
@@ -264,7 +265,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
const getSyncSnapshot = useCallback(() => {
return {
hosts: config.hosts,
hosts: sanitizeHostsForSync(config.hosts),
keys: config.keys,
identities: config.identities,
proxyProfiles: config.proxyProfiles,
+148 -1
View File
@@ -1,8 +1,13 @@
import assert from "node:assert/strict";
import test from "node:test";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { JSDOM } from "jsdom";
import type { Host } from "../../domain/models.ts";
import { haveSameManagedSshAgentFields } from "./useManagedSourceSync.ts";
import { STORAGE_KEY_MANAGED_SOURCES } from "../../infrastructure/config/storageKeys.ts";
import { isVaultImportLockHeld } from "./vaultManagedImportLock.ts";
import { haveSameManagedSshAgentFields, useManagedSourceSync } from "./useManagedSourceSync.ts";
const host: Host = {
id: "host-1",
@@ -54,3 +59,145 @@ test("managed SSH source comparison tracks identity file paths", () => {
identityFilePaths: [...withPaths.identityFilePaths].reverse(),
}), false);
});
type TestHost = Host & { managedSourceId?: string };
const managedHost = (label: string): TestHost => ({
...host,
label,
managedSourceId: "source-1",
});
const managedSource = {
id: "source-1",
type: "ssh_config" as const,
filePath: "/home/tester/.ssh/config",
groupName: "Managed",
lastSyncedAt: 0,
};
function createFakeLockManager() {
const tails = new Map<string, Promise<unknown>>();
return {
request<T>(name: string, callback: () => Promise<T>): Promise<T> {
const previous = (tails.get(name) ?? Promise.resolve()) as Promise<unknown>;
const run = previous.then(callback, callback) as Promise<T>;
tails.set(name, run.then(() => undefined, () => undefined));
return run;
},
};
}
test("managed sync writes the latest persisted hosts, not the pre-edit snapshot", async () => {
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});
const globalAny = globalThis as Record<string, unknown>;
const saved = {
window: globalAny.window,
document: globalAny.document,
localStorage: globalAny.localStorage,
navigator: Object.getOwnPropertyDescriptor(globalThis, "navigator"),
actEnv: (globalAny as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT,
};
Object.defineProperty(globalThis, "window", { configurable: true, value: dom.window });
Object.defineProperty(globalThis, "document", { configurable: true, value: dom.window.document });
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: dom.window.localStorage });
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: { locks: createFakeLockManager() },
});
Object.defineProperty(globalAny, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
const container = dom.window.document.createElement("div");
dom.window.document.body.appendChild(container);
try {
dom.window.localStorage.setItem(
STORAGE_KEY_MANAGED_SOURCES,
JSON.stringify([managedSource]),
);
// Simulate updateHosts: the React state already holds the edited alias,
// but the encrypted disk write is still queued and only lands later.
const hostBeforeEdit = managedHost("aaaaaaaa");
const hostAfterEdit = managedHost("bbbbbbbb");
let persistedHosts: TestHost[] = [hostBeforeEdit];
let resolveCommit!: () => void;
const commit = new Promise<void>((resolve) => { resolveCommit = resolve; });
setTimeout(() => {
persistedHosts = [hostAfterEdit];
resolveCommit();
}, 20);
const onReadPersistedHosts = async (): Promise<Host[]> => {
// Mirrors the real readPersistedHosts semantics: while the vault lock is
// held, only the encrypt phase is drained (the disk write queued behind
// that lock is not visible yet); outside the lock it also waits for the
// queued disk write before reading.
if (isVaultImportLockHeld("vault")) return persistedHosts;
await commit;
return persistedHosts;
};
let written = "";
(dom.window as unknown as Record<string, unknown>).netcatty = {
readLocalFile: async () => new TextEncoder().encode(
"# BEGIN NETCATTY MANAGED - DO NOT EDIT THIS BLOCK\n"
+ "Host aaaaaaaa\n HostName managed.example.com\n"
+ "# END NETCATTY MANAGED\n",
).buffer,
writeLocalFile: async (_filePath: string, buffer: ArrayBuffer) => {
written = new TextDecoder().decode(buffer);
},
};
function Harness() {
useManagedSourceSync({
hosts: [hostAfterEdit],
managedSources: [managedSource],
onUpdateManagedSources: () => {},
onReadPersistedHosts,
});
return null;
}
const root = createRoot(container);
await act(async () => {
root.render(React.createElement(Harness));
});
for (let i = 0; i < 250 && !written; i += 1) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
await act(async () => root.unmount());
assert.ok(written, "managed sync never wrote the ssh_config file");
assert.ok(written.includes("Host bbbbbbbb"), "sync wrote a stale alias");
assert.ok(!written.includes("Host aaaaaaaa"), "sync wrote the pre-edit alias");
} finally {
container.remove();
dom.window.close();
for (const key of ["window", "document", "localStorage"] as const) {
if (key in globalAny) {
Object.defineProperty(globalThis, key, {
configurable: true,
enumerable: true,
writable: true,
value: saved[key],
});
} else {
delete globalAny[key];
}
}
if (saved.navigator) {
Object.defineProperty(globalThis, "navigator", saved.navigator);
} else {
delete (globalThis as { navigator?: unknown }).navigator;
}
Object.defineProperty(globalAny, "IS_REACT_ACT_ENVIRONMENT", {
configurable: true,
value: saved.actEnv,
});
}
});
@@ -156,6 +156,14 @@ export const useManagedSourceSync = ({
const syncManagedSource = useCallback(
async (source: ManagedSource): Promise<{ sourceId: string; success: boolean }> => {
// Drain any in-flight Vault host write BEFORE taking the vault lock.
// updateHosts commits its encrypted write under the vault lock, so if this
// sync acquires the lock first, readPersistedHosts runs while the lock is
// held and only waits for the encrypt phase — returning the previous host
// snapshot and writing the stale alias back to the file (lagging one edit
// behind, see issue #3259). Waiting outside the lock also waits for the
// queued disk write, so the read below sees the just-edited hosts.
await onReadPersistedHosts();
return withVaultImportLock("vault", async () => {
const persistedSources = localStorageAdapter.read<ManagedSource[]>(
STORAGE_KEY_MANAGED_SOURCES,
@@ -385,7 +385,10 @@ export const usePortForwardingAutoStart = ({
return startPortForward(rule, host, resolveEffectiveHosts(hostsRef.current), keysRef.current, identitiesRef.current, onStatusChange, true, terminalSettingsRef.current, knownHostsRef.current);
};
setReconnectCallback(handleReconnect);
setReconnectCallback(handleReconnect, (ruleId) => isPortForwardingAutoStartEnabled(
localStorageAdapter.read<PortForwardingRule[]>(STORAGE_KEY_PORT_FORWARDING) ?? [],
ruleId,
));
return () => {
setReconnectCallback(null);
};
@@ -0,0 +1,99 @@
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 { useSessionState } from "./useSessionState.ts";
type SessionState = ReturnType<typeof useSessionState>;
test("global broadcast availability follows visible orphan session count", async () => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
const eventTarget = new EventTarget() as EventTarget & Record<string, unknown>;
Object.assign(eventTarget, {
setTimeout,
clearTimeout,
});
Object.defineProperty(globalThis, "window", {
configurable: true,
value: eventTarget,
});
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
let state: SessionState | null = null;
let renderer: ReactTestRenderer | null = null;
const current = (): SessionState => {
assert.ok(state);
return state;
};
function Probe() {
state = useSessionState({ persistSessionRestore: false });
return null;
}
try {
await act(async () => {
renderer = create(React.createElement(Probe));
});
assert.equal(current().orphanSessions.length, 0);
assert.equal(current().canUseGlobalBroadcast, false);
assert.equal(current().isGlobalBroadcastEnabled, false);
await act(async () => {
current().createLocalTerminal({ shellName: "01" });
});
assert.equal(current().orphanSessions.length, 1);
assert.equal(current().canUseGlobalBroadcast, false);
await act(async () => {
current().createLocalTerminal({ shellName: "02" });
});
assert.equal(current().orphanSessions.length, 2);
assert.equal(current().canUseGlobalBroadcast, true);
await act(async () => {
current().toggleGlobalBroadcast();
});
assert.equal(current().isGlobalBroadcastEnabled, true);
await act(async () => {
current().toggleGlobalBroadcast();
});
assert.equal(current().isGlobalBroadcastEnabled, false);
await act(async () => { current().toggleGlobalBroadcast(); });
const closedId = current().orphanSessions[1].id;
await act(async () => { current().closeSession(closedId); });
assert.equal(current().canUseGlobalBroadcast, false);
assert.equal(current().isGlobalBroadcastEnabled, false);
await act(async () => { current().createLocalTerminal({ shellName: "03" }); });
assert.equal(current().canUseGlobalBroadcast, true);
assert.equal(current().isGlobalBroadcastEnabled, false);
await act(async () => { current().toggleGlobalBroadcast(); });
const [first, second] = current().orphanSessions;
await act(async () => {
current().createWorkspaceFromSessions(first.id, second.id, "right");
});
assert.equal(current().canUseGlobalBroadcast, false);
assert.equal(current().isGlobalBroadcastEnabled, false);
await act(async () => {
current().removeSessionFromWorkspace(second.id);
});
assert.equal(current().canUseGlobalBroadcast, true);
assert.equal(current().isGlobalBroadcastEnabled, false);
} finally {
await act(async () => {
renderer?.unmount();
});
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
}
});
+93 -4
View File
@@ -59,7 +59,12 @@ import type { SessionRestorePayload } from '../../domain/sessionRestore';
import { resolveRestorePreviousSessionSetting } from './sessionRestoreSettings';
import type { CodingCliProviderId } from '../../domain/codingCliProviders';
import { normalizeCodingCliDynamicTitleForStorage } from '../../domain/codingCliTitleParse';
import { DEFAULT_WORKSPACE_TITLE } from '../../domain/sessionTabTitle';
import {
DEFAULT_WORKSPACE_TITLE,
buildMergedWorkspaceTitle,
buildMergedWorkspaceTitleFromSessions,
getSessionConnectionLabel,
} from '../../domain/sessionTabTitle';
import { cleanupClosedTerminalSessions } from './aiStateSnapshots';
export function addWorkspaceIfMissing(
@@ -228,6 +233,8 @@ export const useSessionState = ({
const [tabOrder, setTabOrder] = useState<string[]>(initialRestoreState.tabOrder);
// Broadcast mode: stores workspace IDs that have broadcast enabled
const [broadcastWorkspaceIds, setBroadcastWorkspaceIds] = useState<Set<string>>(new Set());
// Global broadcast mode: enables broadcast for all orphan sessions (not in workspaces)
const [globalBroadcastEnabled, setGlobalBroadcastEnabled] = useState<boolean>(false);
// Log views: stores open log replay tabs
const [logViews, setLogViews] = useState<LogView[]>([]);
const [restorePreviousSessionRevision, setRestorePreviousSessionRevision] = useState(0);
@@ -260,6 +267,41 @@ export const useSessionState = ({
}
}, [initialRestoreState.activeTabId]);
// Keep generated merged-workspace titles (e.g. "01/02" after dragging tab
// "02" onto "01") synchronized with the workspace's current membership and
// each pane's connection label. `autoTitle: false` makes
// resolveWorkspaceTabLabel treat the composed title as a user-chosen name,
// so without this pass a pane rename or a pane being added/removed would
// leave the label stale. Runs off session changes and only touches
// workspaces still flagged `generatedTitle` (a user rename clears it).
useEffect(() => {
setWorkspaces(prev => {
let changed = false;
const next = prev.map(ws => {
if (!ws.generatedTitle) return ws;
// Order members by the workspace's pane tree (base/join or split
// layout), not by global session creation order — merging a newer tab
// onto an older one must keep the merged title as created (e.g.
// "02/01"), while `sessions.filter` would rewrite it to "01/02".
const rootIds = collectSessionIds(ws.root);
const members = [
...rootIds
.map(id => sessions.find(s => s.id === id))
.filter((s): s is TerminalSession => Boolean(s)),
// Safety net for transient membership: any session assigned to this
// workspace whose pane has not landed in `root` yet keeps its
// previous (creation-order) position after the pane-tree members.
...sessions.filter(s => s.workspaceId === ws.id && !rootIds.includes(s.id)),
];
const recomputed = buildMergedWorkspaceTitleFromSessions(members) ?? DEFAULT_WORKSPACE_TITLE;
if (recomputed === ws.title) return ws;
changed = true;
return { ...ws, title: recomputed };
});
return changed ? next : prev;
});
}, [sessions]);
useEffect(() => {
const handleRestorePreviousSessionChanged = (key?: string) => {
if (key !== STORAGE_KEY_RESTORE_PREVIOUS_SESSION) return;
@@ -622,11 +664,16 @@ export const useSessionState = ({
}, []);
const submitWorkspaceRename = useCallback((workspaceId?: string, name?: string) => {
// A user rename takes ownership of the title: clear `generatedTitle` so a
// previously synthesized merged title ("01/02") stops being auto-updated.
const applyRename = (w: Workspace, trimmed: string): Workspace => (
{ ...w, title: trimmed, autoTitle: false, generatedTitle: false }
);
if (workspaceId !== undefined && name !== undefined) {
const trimmed = name.trim();
if (!trimmed) return;
setWorkspaces(prev => prev.map(w => (
w.id === workspaceId ? { ...w, title: trimmed, autoTitle: false } : w
w.id === workspaceId ? applyRename(w, trimmed) : w
)));
return;
}
@@ -637,7 +684,7 @@ export const useSessionState = ({
setWorkspaceRenameTarget(prevTarget => {
if (!prevTarget) return prevTarget;
setWorkspaces(prev => prev.map(w => w.id === prevTarget.id ? { ...w, title: trimmed, autoTitle: false } : w));
setWorkspaces(prev => prev.map(w => w.id === prevTarget.id ? applyRename(w, trimmed) : w));
return null;
});
@@ -729,7 +776,29 @@ export const useSessionState = ({
hint: SplitHint
) => {
if (!hint || baseSessionId === joiningSessionId) return;
const newWorkspace = createWorkspaceEntity(baseSessionId, joiningSessionId, hint);
// Name the merged workspace after both tabs' connection labels (e.g.
// "01/02") so the window title and the rename dialog show the merged
// identity instead of the generic "Workspace". Falls back to the default
// auto title when neither tab has a label. The explicit `autoTitle: false`
// makes tab labels use this composed name too; `generatedTitle: true`
// marks it as synthesized so it keeps tracking membership/renames (see the
// generated-title sync effect below) instead of going stale.
// Compose the title from the created pane-tree order (not base/join
// order): for left/top merges `createWorkspaceEntity` places the joining
// pane before the base pane in `root`, and the generated-title sync
// effect above recomputes from `root` order — building the initial title
// in tree order keeps it stable instead of renaming itself right after
// creation.
const created = createWorkspaceEntity(baseSessionId, joiningSessionId, hint);
const treeLabels = collectSessionIds(created.root).map((id) =>
getSessionConnectionLabel(
sessionsRef.current.find(s => s.id === id) ?? { customName: '', hostLabel: '' },
),
);
const mergedTitle = buildMergedWorkspaceTitle(treeLabels[0] ?? '', treeLabels[1] ?? '');
const newWorkspace = mergedTitle
? { ...created, title: mergedTitle, autoTitle: false, generatedTitle: true }
: created;
setSessions((prevSessions) => {
const base = prevSessions.find((s) => s.id === baseSessionId);
@@ -1054,6 +1123,12 @@ export const useSessionState = ({
}, [setActiveTabId]);
const orphanSessions = useMemo(() => sessions.filter(s => !s.workspaceId && !s.hiddenFromTabs), [sessions]);
const canUseGlobalBroadcast = orphanSessions.length >= 2;
// Losing the group must disarm broadcast before another tab can join it.
if (!canUseGlobalBroadcast && globalBroadcastEnabled) {
setGlobalBroadcastEnabled(false);
}
const openLogView = useCallback((log: ConnectionLog) => {
const tabId = getLogViewTabId(log);
@@ -1075,6 +1150,8 @@ export const useSessionState = ({
const copySession = useCallback((sessionId: string, options?: {
localShellType?: TerminalSession['shellType'];
inheritedCwd?: string;
/** When false, open a brand-new connection instead of reusing the source's SSH channel. */
reuseConnection?: boolean;
}) => {
// Pre-allocate the new id outside the updater so StrictMode's
// double-invocation of the functional updater doesn't mint two ids.
@@ -1090,6 +1167,7 @@ export const useSessionState = ({
id: newSessionId,
localShellType: options?.localShellType,
inheritedCwd: options?.inheritedCwd,
reuseConnection: options?.reuseConnection,
});
// Schedule the activeTab + tabOrder updates only when creation
@@ -1202,6 +1280,14 @@ export const useSessionState = ({
return broadcastWorkspaceIds.has(workspaceId);
}, [broadcastWorkspaceIds]);
// Toggle global broadcast mode for orphan sessions
const toggleGlobalBroadcast = useCallback(() => {
setGlobalBroadcastEnabled(prev => !prev);
}, []);
// Global broadcast enabled state - direct boolean value
const isGlobalBroadcastEnabled = globalBroadcastEnabled;
const baseWorkTabIds = useMemo(() => [
...orphanSessions.map(s => s.id),
...workspaces.map(w => w.id),
@@ -1325,9 +1411,12 @@ export const useSessionState = ({
moveFocusInWorkspace,
runSnippet,
orphanSessions,
canUseGlobalBroadcast,
// Broadcast mode
toggleBroadcast,
isBroadcastEnabled,
toggleGlobalBroadcast,
isGlobalBroadcastEnabled,
orderedTabs,
getOrderedWorkTabs,
reorderTabs,
@@ -0,0 +1,144 @@
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 { useSessionState } from "./useSessionState.ts";
type SessionState = ReturnType<typeof useSessionState>;
test("merged tab titles follow the workspace lifecycle until the user renames it", async (t) => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
const eventTarget = new EventTarget() as EventTarget & Record<string, unknown>;
Object.assign(eventTarget, {
setTimeout,
clearTimeout,
});
Object.defineProperty(globalThis, "window", {
configurable: true,
value: eventTarget,
});
t.after(() => {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
});
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
t.after(() => {
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
});
let state: SessionState | null = null;
let renderer: ReactTestRenderer | null = null;
const current = (): SessionState => {
assert.ok(state);
return state;
};
const workspace = (workspaceId: string) => {
const found = current().workspaces.find((candidate) => candidate.id === workspaceId);
assert.ok(found);
return found;
};
function Probe() {
state = useSessionState({ persistSessionRestore: false });
return null;
}
await act(async () => {
renderer = create(React.createElement(Probe));
});
let firstSessionId = "";
let secondSessionId = "";
await act(async () => {
firstSessionId = current().createLocalTerminal({ shellName: "01" });
secondSessionId = current().createLocalTerminal({ shellName: "02" });
});
await act(async () => {
current().createWorkspaceFromSessions(firstSessionId, secondSessionId, {
direction: "vertical",
position: "right",
targetSessionId: firstSessionId,
});
});
const workspaceId = current().workspaces[0]?.id;
assert.ok(workspaceId);
assert.equal(workspace(workspaceId).title, "01/02");
assert.equal(workspace(workspaceId).autoTitle, false);
assert.equal(workspace(workspaceId).generatedTitle, true);
await act(async () => {
current().startWorkspaceRename(workspaceId);
});
assert.equal(current().workspaceRenameValue, "01/02");
await act(async () => {
current().resetWorkspaceRename();
current().renameSessionInline(secondSessionId, "prod");
});
assert.equal(workspace(workspaceId).title, "01/prod");
let thirdSessionId = "";
await act(async () => {
thirdSessionId = current().createLocalTerminal({ shellName: "03" });
});
await act(async () => {
current().addSessionToWorkspace(workspaceId, thirdSessionId, {
direction: "vertical",
position: "right",
targetSessionId: secondSessionId,
});
});
assert.equal(workspace(workspaceId).title, "01/prod/03");
await act(async () => {
current().removeSessionFromWorkspace(thirdSessionId);
});
assert.equal(workspace(workspaceId).title, "01/prod");
const workspaceIdsBeforeCopy = new Set(current().workspaces.map((candidate) => candidate.id));
await act(async () => {
current().copyWorkspace(workspaceId);
});
const copiedWorkspace = current().workspaces.find(
(candidate) => !workspaceIdsBeforeCopy.has(candidate.id),
);
assert.ok(copiedWorkspace);
assert.equal(copiedWorkspace.title, "01/prod");
assert.equal(copiedWorkspace.autoTitle, false);
assert.equal(copiedWorkspace.generatedTitle, true);
await act(async () => {
current().submitWorkspaceRename(workspaceId, "Pinned");
current().renameSessionInline(firstSessionId, "ops");
});
assert.equal(workspace(workspaceId).title, "Pinned");
assert.equal(workspace(workspaceId).generatedTitle, false);
let leftBaseSessionId = "";
let leftJoiningSessionId = "";
await act(async () => {
leftBaseSessionId = current().createLocalTerminal({ shellName: "04" });
leftJoiningSessionId = current().createLocalTerminal({ shellName: "05" });
});
await act(async () => {
current().createWorkspaceFromSessions(leftBaseSessionId, leftJoiningSessionId, {
direction: "vertical",
position: "left",
targetSessionId: leftBaseSessionId,
});
});
const leftWorkspace = current().workspaces.find((candidate) => candidate.title === "05/04");
assert.ok(leftWorkspace);
assert.equal(leftWorkspace.generatedTitle, true);
await act(async () => {
renderer?.unmount();
});
});
+15
View File
@@ -67,6 +67,7 @@ import {
STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED,
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
STORAGE_KEY_CLOSE_TO_TRAY,
STORAGE_KEY_AUTO_LAUNCH_ENABLED,
STORAGE_KEY_HTTP_NETWORK_PROXY,
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
STORAGE_KEY_WINDOW_OPACITY,
@@ -539,6 +540,13 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
if (stored === null) return true;
return stored === 'true';
});
// Optimistic cache for first paint; useSystemSettingsEffects hydrates this
// from app.getLoginItemSettings() (the real source of truth) on mount.
const [autoLaunchEnabled, setAutoLaunchEnabled] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_AUTO_LAUNCH_ENABLED);
return stored === 'true';
});
const [autoLaunchSupported, setAutoLaunchSupported] = useState<boolean>(true);
const [httpNetworkProxy, setHttpNetworkProxyState] = useState<HttpNetworkProxySettings>(() => {
const stored = localStorageAdapter.read<unknown>(STORAGE_KEY_HTTP_NETWORK_PROXY);
return normalizeHttpNetworkProxySettings(stored ?? DEFAULT_HTTP_NETWORK_PROXY);
@@ -1184,6 +1192,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setWindowOpacity: applyIncomingWindowOpacity,
setAppIconVariant,
setAutoUpdateEnabled,
setAutoLaunchEnabled,
setHttpNetworkProxy,
setSftpAutoOpenSidebar,
setSftpFollowTerminalCwd,
@@ -1747,6 +1756,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
toggleWindowHotkey,
globalHotkeyEnabled,
closeToTray,
autoLaunchEnabled,
windowOpacityRecord,
windowOpacityMutationSourceRef,
appIconVariant,
@@ -1756,6 +1766,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setHotkeyRegistrationError,
setAutoUpdateEnabled,
setAppIconVariant,
setAutoLaunchEnabled,
setAutoLaunchSupported,
notifySettingsChanged,
});
@@ -2101,6 +2113,9 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setToggleWindowHotkey,
closeToTray,
setCloseToTray,
autoLaunchEnabled,
setAutoLaunchEnabled,
autoLaunchSupported,
httpNetworkProxy,
setHttpNetworkProxy,
autoUpdateEnabled,
+34 -25
View File
@@ -18,11 +18,13 @@ import {
joinPath,
} from "./sftp/utils";
import { useSftpTabsState } from "./sftp/useSftpTabsState";
import { useSftpBrowseConnectionLifecycle } from "./sftp/useSftpBrowseConnectionLifecycle";
import { isSessionError } from "./sftp/errors";
import { useSftpExternalOperations } from "./sftp/useSftpExternalOperations";
import { useSftpTransfers } from "./sftp/useSftpTransfers";
import { useSftpPaneActions } from "./sftp/useSftpPaneActions";
import {
closeSftpTabLifecycle,
releaseSftpConnectionMetadata,
useSftpConnections,
} from "./sftp/useSftpConnections";
@@ -181,12 +183,6 @@ export const useSftpState = (
return null;
}, [leftTabsRef, rightTabsRef]);
// Ref to track pending reconnections to avoid multiple reconnect attempts
const reconnectingRef = useRef<{ left: boolean; right: boolean }>({
left: false,
right: false,
});
// Map connectionId → cache key, set at connect time so each tab's
// navigateTo can use the correct cache key even when multiple tabs
// share the same hostId with different session-time overrides.
@@ -195,6 +191,8 @@ export const useSftpState = (
// Full Host used when each tab connected (includes session-time overrides).
// Tab ids are stable across reconnect; connection ids are not.
const connectedHostByTabIdRef = useRef<Map<string, Host | "local">>(new Map());
const connectInFlightRef = useRef<Map<string, Promise<void>>>(new Map());
const connectRequestByTabIdRef = useRef<Map<string, symbol>>(new Map());
// Full endpoint key captured at connect time (hostId:hostname:port:…).
const getConnectionCacheKey = useCallback((connectionId: string) => {
@@ -234,20 +232,29 @@ export const useSftpState = (
const closeTabAndClearHost = useCallback(async (side: "left" | "right", tabId: string) => {
const pane = getPaneByTabId(tabId);
connectedHostByTabIdRef.current.delete(tabId);
if (pane?.connection) {
await releaseSftpTabConnection({
connectionId: pane.connection.id,
isLocal: pane.connection.isLocal,
sftpSessions: sftpSessionsRef.current,
connectionCacheKeys: connectionCacheKeyMapRef.current,
clearCacheForConnection,
closeSftp: async (sftpId) => netcattyBridge.get()?.closeSftp(sftpId),
onRemoteSessionClosed: (sftpId) => forgetExternalEditTempsForSftpRef.current(sftpId),
});
}
closeTab(side, tabId);
}, [clearCacheForConnection, closeTab, getPaneByTabId]);
await closeSftpTabLifecycle({
requestedSide: side,
tabId,
leftTabs: leftTabsRef.current.tabs,
rightTabs: rightTabsRef.current.tabs,
connectRequests: connectRequestByTabIdRef.current,
connectInFlight: connectInFlightRef.current,
connectedHosts: connectedHostByTabIdRef.current,
closeTab: (liveSide) => closeTab(liveSide, tabId),
releaseConnection: async () => {
if (!pane?.connection) return;
await releaseSftpTabConnection({
connectionId: pane.connection.id,
isLocal: pane.connection.isLocal,
sftpSessions: sftpSessionsRef.current,
connectionCacheKeys: connectionCacheKeyMapRef.current,
clearCacheForConnection,
closeSftp: async (sftpId) => netcattyBridge.get()?.closeSftp(sftpId),
onRemoteSessionClosed: (sftpId) => forgetExternalEditTempsForSftpRef.current(sftpId),
});
},
});
}, [clearCacheForConnection, closeTab, getPaneByTabId, leftTabsRef, rightTabsRef]);
const releaseConnection = useCallback(async (connectionId: string) => {
await releaseSftpConnectionMetadata({
@@ -267,11 +274,10 @@ export const useSftpState = (
updateActiveTab,
navSeqRef,
lastConnectedHostRef,
reconnectingRef,
releaseConnection,
});
useSftpSessionCleanup(sftpSessionsRef);
const sftpDisposedRef = useSftpSessionCleanup(sftpSessionsRef);
useSftpFileWatch(options);
// Transfer channel pool (max concurrent sftpIds per host, short idle reuse).
@@ -398,6 +404,8 @@ export const useSftpState = (
);
/** True after browse channels were soft-closed while this owner stayed mounted. */
const interactive = options?.interactive !== false;
const browseConnectionLifecycleRef = useSftpBrowseConnectionLifecycle(interactive);
const browseParkedRef = useRef(false);
const browseLifecycleGenRef = useRef(0);
@@ -430,10 +438,13 @@ export const useSftpState = (
navSeqRef,
dirCacheRef,
sftpSessionsRef,
disposedRef: sftpDisposedRef,
browseConnectionLifecycleRef,
lastConnectedHostRef,
connectionCacheKeyMapRef,
connectedHostByTabIdRef,
reconnectingRef,
connectInFlightRef,
connectRequestByTabIdRef,
makeCacheKey,
clearCacheForConnection,
createEmptyPane: createPane,
@@ -474,7 +485,6 @@ export const useSftpState = (
sftpSessionsRef,
lastConnectedHostRef,
connectionCacheKeyMapRef,
reconnectingRef,
makeCacheKey,
clearCacheForConnection,
listLocalFiles,
@@ -637,7 +647,6 @@ export const useSftpState = (
// 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;
+103
View File
@@ -0,0 +1,103 @@
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 { useStoredBoolean } from "./useStoredBoolean.ts";
const STORAGE_KEY = "netcatty:test:stored-boolean";
test("functional updates stay pure when another consumer shares the storage key", async () => {
const values = new Map<string, string>([[STORAGE_KEY, "false"]]);
const fakeStorage = {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
};
const fakeWindow = new EventTarget();
const globals = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
localStorage?: typeof fakeStorage;
window?: typeof fakeWindow;
};
const previousActEnvironment = globals.IS_REACT_ACT_ENVIRONMENT;
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
const previousDispatchEvent = Object.getOwnPropertyDescriptor(globalThis, "dispatchEvent");
const previousCustomEvent = Object.getOwnPropertyDescriptor(globalThis, "CustomEvent");
let renderer: ReactTestRenderer | null = null;
let toggle: (() => void) | null = null;
let parentValue = false;
let childValue = false;
const errors: string[] = [];
const originalConsoleError = console.error;
Object.defineProperty(globalThis, "localStorage", { value: fakeStorage, configurable: true });
Object.defineProperty(globalThis, "window", { value: fakeWindow, configurable: true });
Object.defineProperty(globalThis, "dispatchEvent", {
value: fakeWindow.dispatchEvent.bind(fakeWindow),
configurable: true,
});
Object.defineProperty(globalThis, "CustomEvent", {
value: class TestCustomEvent<T> extends Event implements CustomEvent<T> {
readonly detail: T;
constructor(type: string, init?: CustomEventInit<T>) {
super(type);
this.detail = init?.detail as T;
}
initCustomEvent(): void {
// Deprecated browser API required by the CustomEvent interface.
}
},
configurable: true,
});
globals.IS_REACT_ACT_ENVIRONMENT = true;
console.error = (...args: unknown[]) => {
errors.push(args.map(String).join(" "));
};
const Parent = () => {
[parentValue] = useStoredBoolean(STORAGE_KEY);
return React.createElement(Child);
};
const Child = () => {
const [value, setValue] = useStoredBoolean(STORAGE_KEY);
childValue = value;
toggle = () => setValue((previous) => !previous);
return null;
};
try {
await act(async () => {
renderer = create(React.createElement(React.StrictMode, null, React.createElement(Parent)));
});
await act(async () => {
toggle?.();
});
assert.equal(parentValue, true);
assert.equal(childValue, true);
assert.equal(values.get(STORAGE_KEY), "true");
assert.equal(
errors.some((message) => message.includes("Cannot update a component")
&& message.includes("while rendering a different component")),
false,
);
} finally {
console.error = originalConsoleError;
await act(async () => {
renderer?.unmount();
});
globals.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
else delete globals.localStorage;
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else delete globals.window;
if (previousDispatchEvent) Object.defineProperty(globalThis, "dispatchEvent", previousDispatchEvent);
else delete (globalThis as typeof globalThis & { dispatchEvent?: unknown }).dispatchEvent;
if (previousCustomEvent) Object.defineProperty(globalThis, "CustomEvent", previousCustomEvent);
else delete (globalThis as typeof globalThis & { CustomEvent?: unknown }).CustomEvent;
}
});
Loaded 100 of 387 files, more files were not shown because too many files have changed in this diff. Show more