Compare commits

...
Author SHA1 Message Date
bincxz 39d4fcfee6 fix(terminal): select every history preview row 2026-09-07 03:49:35 +08:00
bincxz fd0792d83d fix(terminal): fit wide history rows without changing copied text 2026-09-07 03:36:33 +08:00
bincxz fdb948267d fix(terminal): align history preview with rendered cell dimensions 2026-09-07 03:17:17 +08:00
cabdcdf31c fix(logs): adaptive light/dark theme for HTML session logs (#3264)
* fix(#3263): automated AI fix

* fix(logs): keep adaptive HTML colors readable

* fix(logs): adapt extended foreground colors to light pages

* perf(logs): calculate light foreground colors without iteration

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-06 15:11:13 +08:00
陈大猫andnetcatty-bot e2a9e54a90 fix(sftp): preserve concurrent files during local publication (#3286)
* fix(sftp): preserve concurrent files during local publication

* fix(sftp): preserve metadata during fallback restoration

* fix: address Codex review on PR #3286

* fix(sftp): prepare timestamps before publishing local downloads

* fix: address Codex review on PR #3286

* fix(sftp): preserve file access through restricted-mode publication

* fix(sftp): retry timestamps when preparation fails

* fix(sftp): verify unreadable destination recovery before replacement

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-09-06 14:23:15 +08: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
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
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
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
201 changed files with 13155 additions and 975 deletions

No files matched your search

+5
View File
@@ -695,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(() => ({
+7 -2
View File
@@ -722,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,
@@ -916,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;
}
+4 -1
View File
@@ -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,
@@ -737,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}
+4
View File
@@ -207,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,
@@ -238,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,
+1
View File
@@ -1082,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',
+15
View File
@@ -273,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',
@@ -313,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',
@@ -700,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.',
+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',
+15
View File
@@ -273,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',
@@ -313,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',
@@ -700,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.',
+16
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': 'Дерево',
@@ -310,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': 'Размер',
@@ -350,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': 'Папка',
@@ -736,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.',
+11
View File
@@ -718,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': '树形',
@@ -903,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': '修改时间',
@@ -943,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': '文件夹',
+5
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。',
+11
View File
@@ -721,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': '樹形',
@@ -906,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': '修改時間',
@@ -946,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': '資料夾',
+5
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。',
+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,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[] = [];
@@ -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: {
+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");
});
@@ -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,
@@ -1780,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,
@@ -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;
+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);
}, []);
+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");
}
});
+90 -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);
@@ -1205,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),
@@ -1328,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();
});
});
+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;
}
});
+19 -12
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
/**
@@ -17,30 +17,37 @@ export const useStoredBoolean = (
const stored = localStorageAdapter.readBoolean(storageKey);
return stored ?? fallback;
});
const valueRef = useRef(value);
const setAndPersist = useCallback((next: boolean | ((prev: boolean) => boolean)) => {
setValue((prev) => {
const resolved = typeof next === "function" ? next(prev) : next;
localStorageAdapter.writeBoolean(storageKey, resolved);
// Notify other same-window consumers
window.dispatchEvent(
new CustomEvent("stored-boolean-change", { detail: { key: storageKey, value: resolved } }),
);
return resolved;
});
// Resolve functional updates before scheduling React state. The updater
// must stay pure because React may evaluate it while rendering.
const resolved = typeof next === "function" ? next(valueRef.current) : next;
valueRef.current = resolved;
setValue(resolved);
localStorageAdapter.writeBoolean(storageKey, resolved);
// Notify other same-window consumers outside the state updater.
window.dispatchEvent(
new CustomEvent("stored-boolean-change", { detail: { key: storageKey, value: resolved } }),
);
}, [storageKey]);
useEffect(() => {
// Sync from other components in the same window
const handleCustom = (e: Event) => {
const { key, value: newValue } = (e as CustomEvent).detail;
if (key === storageKey) setValue(newValue);
if (key === storageKey) {
valueRef.current = newValue;
setValue(newValue);
}
};
// Sync from other windows
const handleStorage = (e: StorageEvent) => {
if (e.key === storageKey) {
const stored = localStorageAdapter.readBoolean(storageKey);
setValue(stored ?? fallback);
const nextValue = stored ?? fallback;
valueRef.current = nextValue;
setValue(nextValue);
}
};
window.addEventListener("stored-boolean-change", handleCustom);
+83
View File
@@ -5,6 +5,7 @@ import type { SyncPayload } from "../domain/sync.ts";
import type { KnownHost } from "../domain/models.ts";
import type { SyncableVaultData } from "./syncPayload.ts";
import { parseTerminalFontSizeRecord } from "./state/terminalFontSizeSync.ts";
import commandBlocklistTable from "../lib/commandBlocklist.json";
type LocalStorageMock = {
clear(): void;
@@ -56,6 +57,7 @@ const {
const storageKeys = await import("../infrastructure/config/storageKeys.ts");
const { SYNC_STORAGE_KEYS } = await import("../domain/sync.ts");
const { localStorageAdapter } = await import("../infrastructure/persistence/localStorageAdapter.ts");
const { readCommandBlocklistSetting } = await import("./state/commandBlocklistSettings.ts");
const knownHost = (id = "kh-1"): KnownHost => ({
id,
@@ -642,6 +644,87 @@ test("applySyncPayload restores AI configuration settings", async () => {
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION), "false");
});
test("applySyncPayload migrates an untouched legacy command blocklist", async () => {
const legacyDefaults = [
...commandBlocklistTable.common,
...commandBlocklistTable.posixNative,
...commandBlocklistTable.posix,
];
await applySyncPayload({
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
settings: {
ai: {
commandBlocklist: legacyDefaults,
},
},
syncedAt: 1,
} as SyncPayload, { importVaultData: () => {} });
assert.deepEqual(
JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_COMMAND_BLOCKLIST)!),
[...legacyDefaults, ...commandBlocklistTable.powershell],
);
assert.deepEqual(
readCommandBlocklistSetting(),
[...legacyDefaults, ...commandBlocklistTable.powershell],
);
});
test("command blocklist read migrates an untouched local legacy list", () => {
const legacyDefaults = [
...commandBlocklistTable.common,
...commandBlocklistTable.posixNative,
...commandBlocklistTable.posix,
];
const currentDefaults = [...legacyDefaults, ...commandBlocklistTable.powershell];
assert.deepEqual(readCommandBlocklistSetting(), currentDefaults);
assert.deepEqual(
JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_COMMAND_BLOCKLIST)!),
currentDefaults,
);
localStorage.setItem(
storageKeys.STORAGE_KEY_AI_COMMAND_BLOCKLIST,
JSON.stringify([...legacyDefaults, "old-client-user-rule"]),
);
assert.deepEqual(readCommandBlocklistSetting(), [
...legacyDefaults,
"old-client-user-rule",
...commandBlocklistTable.powershell,
]);
});
test("applySyncPayload preserves a customized PowerShell blocklist", async () => {
const currentCustomized = [
...commandBlocklistTable.common,
...commandBlocklistTable.posixNative,
...commandBlocklistTable.posix,
...commandBlocklistTable.powershell.slice(1),
];
await applySyncPayload({
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
settings: {
ai: {
commandBlocklist: currentCustomized,
},
},
syncedAt: 1,
} as SyncPayload, { importVaultData: () => {} });
assert.deepEqual(readCommandBlocklistSetting(), currentCustomized);
});
test("applySyncPayload encrypts synced plaintext AI API keys before saving locally", async () => {
Object.defineProperty(globalThis, "window", {
value: {
+12 -3
View File
@@ -28,6 +28,7 @@ import {
sanitizeHostsForSync,
type SyncPayload,
} from '../domain/sync';
import { migrateLegacyCommandBlocklist } from '../domain/commandBlocklist';
import { migrateHostsFromLegacyLineTimestamps } from '../domain/host';
import { toPersistedPortForwardingRules } from '../domain/portForwardingPersistence';
import {
@@ -43,6 +44,10 @@ import { localStorageAdapter } from '../infrastructure/persistence/localStorageA
import { decryptField, encryptField } from '../infrastructure/persistence/secureFieldAdapter';
import { sanitizeQuickMessages } from '../infrastructure/ai/quickMessages';
import { emitAIStateChanged } from './state/aiStateEvents';
import {
persistCommandBlocklistSetting,
readCommandBlocklistSetting,
} from './state/commandBlocklistSettings';
import { rehydrateGlobalSftpBookmarks } from './state/sftp/globalSftpBookmarks';
import {
nextTerminalFontSizeSyncVersion,
@@ -572,8 +577,10 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
// externalAgents intentionally not collected: command/args/env are device-local.
const defaultAgentId = localStorageAdapter.readString(STORAGE_KEY_AI_DEFAULT_AGENT);
if (defaultAgentId != null) ai.defaultAgentId = defaultAgentId;
const commandBlocklist = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
if (Array.isArray(commandBlocklist)) ai.commandBlocklist = commandBlocklist;
const storedCommandBlocklist = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
if (Array.isArray(storedCommandBlocklist)) {
ai.commandBlocklist = readCommandBlocklistSetting();
}
const commandTimeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT);
if (commandTimeout != null && Number.isFinite(commandTimeout)) ai.commandTimeout = commandTimeout;
const responseIdleTimeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT);
@@ -824,7 +831,9 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
// externalAgents intentionally not applied: device-local. Legacy snapshots
// that still carry an `externalAgents` field are silently ignored.
if (ai.defaultAgentId != null) localStorageAdapter.writeString(STORAGE_KEY_AI_DEFAULT_AGENT, ai.defaultAgentId);
if (ai.commandBlocklist != null) localStorageAdapter.write(STORAGE_KEY_AI_COMMAND_BLOCKLIST, ai.commandBlocklist);
if (ai.commandBlocklist != null) {
persistCommandBlocklistSetting(migrateLegacyCommandBlocklist(ai.commandBlocklist));
}
if (ai.commandTimeout != null) localStorageAdapter.writeNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT, ai.commandTimeout);
if (ai.responseIdleTimeout != null) {
localStorageAdapter.writeNumber(STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT, ai.responseIdleTimeout);
@@ -11,6 +11,7 @@ import {
applyEffectiveHostAuthMethodSelection,
detachEffectiveHostIdentity,
HOST_AUTH_METHOD_CHOICES,
HOST_OS_CHOICES,
HostDetailsConnectionSections,
removeSelectedHostCredential,
shouldForceAuthMethodReselect,
@@ -111,6 +112,28 @@ test("color and icon settings render for non-Linux hosts", () => {
assert.match(markup, /hostDetails\.icon\.manualLabel/);
});
test("host operating system can be selected in the ordinary host editor", () => {
const markup = renderConnectionSections({ os: "windows" });
assert.deepEqual(
HOST_OS_CHOICES.map(([value, labelKey]) => [value, labelKey]),
[
["linux", "hostDetails.os.linux"],
["windows", "hostDetails.os.windows"],
["macos", "hostDetails.os.macos"],
],
);
assert.match(markup, /hostDetails\.os\.title/);
assert.match(markup, /role="combobox"[^>]*aria-label="hostDetails\.os\.title"/);
assert.match(markup, />hostDetails\.os\.windows<\/span>/);
const source = fs.readFileSync(
path.join(path.dirname(fileURLToPath(import.meta.url)), "HostDetailsConnectionSections.tsx"),
"utf8",
);
assert.match(source, /onValueChange=\{\(value\) => update\("os", value as Host\["os"\]\)\}/);
});
test("host credentials expose automatic and password-only choices", () => {
const markup = renderConnectionSections({
authMethod: "auto",
@@ -23,6 +23,12 @@ export const HOST_AUTH_METHOD_CHOICES = [
["certificate", "hostDetails.auth.certificate"],
] as const;
export const HOST_OS_CHOICES = [
["linux", "hostDetails.os.linux"],
["windows", "hostDetails.os.windows"],
["macos", "hostDetails.os.macos"],
] as const;
export function shouldForceAuthMethodReselect(
nextMethod: "auto" | "password" | "key" | "certificate",
currentMethod: "auto" | "password" | "key" | "certificate",
@@ -125,6 +131,12 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
const selectedAuthMethodLabel = t(
HOST_AUTH_METHOD_CHOICES.find(([value]) => value === resolvedAuthMethod)?.[1] ?? "hostDetails.auth.auto",
);
const resolvedHostOs = HOST_OS_CHOICES.some(([value]) => value === form.os)
? form.os as Host["os"]
: "linux";
const selectedHostOsLabel = t(
HOST_OS_CHOICES.find(([value]) => value === resolvedHostOs)?.[1] ?? "hostDetails.os.linux",
);
const effectiveEtEnabled = form.etEnabled ?? groupDefaults?.etEnabled;
const effectiveProtocol = form.protocol ?? groupDefaults?.protocol;
@@ -140,6 +152,31 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
onChange={(e) => update("hostname", e.target.value)}
className="h-10 w-full"
/>
<HostDetailsSettingRow
label={t("hostDetails.os.title")}
hint={t("hostDetails.os.desc")}
>
<Select
value={resolvedHostOs}
onValueChange={(value) => update("os", value as Host["os"])}
>
<SelectTrigger
className="h-8 w-32 gap-2"
aria-label={t("hostDetails.os.title")}
>
<span className="min-w-0 flex-1 truncate text-left">
{selectedHostOsLabel}
</span>
</SelectTrigger>
<SelectContent>
{HOST_OS_CHOICES.map(([value, labelKey]) => (
<SelectItem key={value} value={value}>
{t(labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
</HostDetailsSettingRow>
</HostDetailsSection>
<HostDetailsSection
+7
View File
@@ -10,6 +10,13 @@ test("main SftpView keeps browse sessions across top-tab switches", () => {
assert.doesNotMatch(source, /interactive:\s*isActive/);
});
test("Open SFTP always restores dual-pane layout through the planner", () => {
const source = readFileSync(new URL("./SftpView.tsx", import.meta.url), "utf8");
assert.match(source, /setMagnifiedSide\(null\);\s*applyDualPaneSftpOpen/);
assert.match(source, /applyDualPaneSftpOpen/);
assert.doesNotMatch(source, /firstVisitBothEmpty/);
});
test("SFTP magnification overlays one side while preserving the original two-pane geometry", async () => {
const { resolveTwoPaneMagnificationStyle } = await import("../domain/paneMagnification.ts");
+54
View File
@@ -20,6 +20,7 @@ import { activeTabStore as globalActiveTabStore, useIsSftpActive } from "../appl
import { useSftpState } from "../application/state/useSftpState";
import { useSftpBackend } from "../application/state/useSftpBackend";
import { getParentPath, isConcreteTransferTargetPath } from "../application/state/sftp/utils";
import { buildCacheKey } from "../application/state/sftp/sharedRemoteHostCache";
import { HotkeyScheme, KeyBinding, TerminalSession } from "../domain/models";
import {
getPaneMagnificationShortcutLabel,
@@ -27,6 +28,11 @@ import {
type PaneMagnificationController,
} from "../domain/paneMagnification";
import { listSftpConnectedHosts, resolveSftpTransferSourceSessionId, sftpPickerSessionsEqual } from "../domain/sftpConnectedHosts";
import { applyDualPaneSftpOpen, dualPaneTabFromPane } from "../domain/sftpDualPaneOpen";
import {
consumePendingDualPaneSftpRequest,
subscribeDualPaneSftpOpen,
} from "../application/state/sftp/sftpDualPaneOpenStore";
import { logger } from "../lib/logger";
import { useRenderTracker } from "../lib/useRenderTracker";
import { cn } from "../lib/utils";
@@ -193,6 +199,54 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
// without needing to re-create when sftp changes
const sftpRef = useRef(sftp);
sftpRef.current = sftp;
const effectiveHostsRef = useRef(effectiveHosts);
effectiveHostsRef.current = effectiveHosts;
useEffect(() => {
const toTabs = (panes: Array<{
id: string;
connection: { id: string; isLocal?: boolean; hostId?: string | null; status?: string | null } | null;
}>, getEndpointKey: (connectionId: string) => string | null) => panes.map(
(pane) => dualPaneTabFromPane(
pane,
pane.connection ? getEndpointKey(pane.connection.id) : null,
),
);
const applyRequest = (request: { hostId: string } | null) => {
if (!request) return;
const host = effectiveHostsRef.current.find((candidate) => candidate.id === request.hostId);
if (!host) return;
const current = sftpRef.current;
const hostEndpointKey = buildCacheKey(
host.id,
host.hostname,
host.port,
host.protocol,
host.sftpSudo,
host.username,
host.sftpFileProtocol,
);
// External "Open SFTP" promises a visible local-left / host-right pair.
// Clear a stale single-pane magnification before selecting or connecting.
setMagnifiedSide(null);
applyDualPaneSftpOpen(
{
leftTabs: toTabs(current.leftTabs.tabs, current.getConnectionCacheKey),
rightTabs: toTabs(current.rightTabs.tabs, current.getConnectionCacheKey),
selectTab: current.selectTab,
connect: (side, nextHost, options) => {
void current.connect(side, nextHost === "local" ? "local" : host, options);
},
},
host,
hostEndpointKey,
);
};
applyRequest(consumePendingDualPaneSftpRequest());
return subscribeDualPaneSftpOpen(applyRequest);
}, []);
// Register this useSftpState's writeTextFileByConnection with the bridge so
// the editor tab's save path can reach the active SFTP session. The bridge
+4
View File
@@ -212,6 +212,7 @@ import {
import {
releaseTerminalFlowBeforeHibernate,
} from "./terminal/runtime/terminalSessionAttachment";
import { resetTerminalLineTimestamps } from "./terminal/runtime/terminalLineTimestamps";
import {
flushPendingTerminalWritesBeforeHibernate,
hasPendingTerminalWrites,
@@ -1631,6 +1632,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
if (term) {
term.reset();
// The buffer was just wiped; drop ledger stamps anchored to the old
// rows so the reconnect gutter cannot paint stale timestamps.
resetTerminalLineTimestamps(term);
if (payload.snapshot) {
await new Promise<void>((resolve) => term.write(payload.snapshot, resolve));
}
+20
View File
@@ -46,6 +46,9 @@ const baseProps = {
onSetWorkspaceFocusedSession: () => {},
isBroadcastEnabled: () => false,
onToggleBroadcast: () => {},
isGlobalBroadcastEnabled: false,
canUseGlobalBroadcast: false,
onToggleGlobalBroadcast: () => {},
updateSnippets: () => {},
updateSnippetPackages: () => {},
onSplitSession: () => {},
@@ -121,6 +124,23 @@ test("TerminalLayer re-renders when broadcast state changes", () => {
);
});
test("TerminalLayer re-renders when global broadcast state changes", () => {
assert.equal(
terminalLayerAreEqual(
baseProps as never,
{ ...baseProps, isGlobalBroadcastEnabled: true } as never,
),
false,
);
assert.equal(
terminalLayerAreEqual(
baseProps as never,
{ ...baseProps, canUseGlobalBroadcast: true } as never,
),
false,
);
});
test("TerminalLayer re-renders when terminal side panel auto-open settings change", () => {
assert.equal(
terminalLayerAreEqual(
+22 -14
View File
@@ -67,6 +67,7 @@ import { ThemeSidePanel } from './terminal/ThemeSidePanel';
import { focusTerminalSessionInput } from './terminal/focusTerminalSession';
import { TerminalComposeBar } from './terminal/TerminalComposeBar';
import { resolveTerminalFontSizeUpdateTarget } from './terminalLayer/terminalFontSizeUpdate';
import { resolveTerminalBroadcastTargetIds } from '../domain/terminalBroadcast';
import {
AUTO_RUN_SNIPPET_LINE_DELAY_MS,
shouldDelayAutoRunSnippetInput,
@@ -246,6 +247,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onCreateLocalTerminal,
isBroadcastEnabled,
onToggleBroadcast,
isGlobalBroadcastEnabled,
onToggleGlobalBroadcast,
canUseGlobalBroadcast,
updateHosts,
updateSnippets,
updateSnippetPackages,
@@ -1049,27 +1053,25 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const sessionHostsMapRef = useRef(sessionHostsMap);
sessionHostsMapRef.current = sessionHostsMap;
// Handle broadcast input - write to all other sessions in the source workspace.
// Resolve the active broadcast mode, then write to its target sessions.
const handleBroadcastInput = useCallback((
data: string,
sourceSessionId: string,
options?: TerminalBroadcastInputOptions,
) => {
const directTargetIds = options?.kittyKeyboardTargetSessionIds;
const sourceSession = directTargetIds
? undefined
: sessionsRef.current.find((session) => session.id === sourceSessionId);
const workspaceId = sourceSession?.workspaceId;
if (!directTargetIds && !workspaceId) return [];
const directTargetSet = directTargetIds ? new Set(directTargetIds) : null;
const targetSessionIds = resolveTerminalBroadcastTargetIds({
sessions: sessionsRef.current,
sourceSessionId,
globalBroadcastEnabled: isGlobalBroadcastEnabled,
directTargetSessionIds: options?.kittyKeyboardTargetSessionIds,
});
if (targetSessionIds.length === 0) return [];
const targetSessionIdSet = new Set(targetSessionIds);
const deliveredSessionIds: string[] = [];
for (const session of sessionsRef.current) {
if (directTargetSet) {
if (!directTargetSet.has(session.id)) continue;
} else if (session.workspaceId !== workspaceId || session.id === sourceSessionId) {
continue;
}
if (!targetSessionIdSet.has(session.id)) continue;
if (!canUseDirectSessionWriteFallback(session)) continue;
if (options?.kittyKeyboardInput) {
@@ -1107,7 +1109,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
deliveredSessionIds.push(session.id);
}
return deliveredSessionIds;
}, [terminalBackend]);
}, [terminalBackend, isGlobalBroadcastEnabled]);
const handleCommandSubmitted = useCallback((command: string, _hostId: string, _hostLabel: string, sessionId: string) => {
codingCliSignalController.handleCommandSubmitted(sessionId, command);
@@ -1297,6 +1299,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onToggleBroadcastRef.current = onToggleBroadcast;
const workspaceBroadcastHandlersRef = useRef<Map<string, () => void>>(new Map());
const onToggleGlobalBroadcastRef = useRef(onToggleGlobalBroadcast);
onToggleGlobalBroadcastRef.current = onToggleGlobalBroadcast;
const mountedSftpTabIds = useMemo(
() => Array.from(sftpHostForTab.keys()),
[sftpHostForTab],
@@ -2298,6 +2303,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
restoreTerminalCwd,
identities,
isBroadcastEnabled,
isGlobalBroadcastEnabled,
canUseGlobalBroadcast,
isComposeBarOpen,
keyBindings,
keys,
@@ -2334,6 +2341,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onSplitSession,
onSplitSessionRef,
onToggleBroadcastRef,
onToggleGlobalBroadcastRef,
onToggleWorkspaceViewMode,
onToggleWorkspaceViewModeRef,
onUpdateHost,
@@ -0,0 +1,18 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const source = fs.readFileSync(
path.join(path.dirname(fileURLToPath(import.meta.url)), "HostTreeContextMenus.tsx"),
"utf8",
);
test("host context menu offers dual-pane SFTP without extra prop drilling", () => {
assert.match(source, /OpenDualPaneSftpMenuItem/);
assert.match(source, /requestOpenDualPaneSftp/);
assert.match(source, /vault\.hosts\.openSftp/);
assert.match(source, /useSettingsChromeStore/);
assert.match(source, /!showSftpTab\s*\|\|\s*!canOpenDualPaneSftp/);
});
+16 -1
View File
@@ -1,9 +1,12 @@
import { Copy, FileSymlink, Folder, FolderOpen, Monitor, Pencil, Plus, Server, Settings2 } from 'lucide-react';
import { Copy, FileSymlink, Files, Folder, FolderOpen, Monitor, Pencil, Plus, Server, Settings2 } from 'lucide-react';
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { requestOpenDualPaneSftp } from '../../application/state/sftp/sftpDualPaneOpenStore';
import { useSettingsChromeStore } from '../../application/state/settingsChromeStore';
import { sanitizeHost } from '../../domain/host';
import { isPluginHostProtocol } from '../../domain/pluginConnection';
import { canOpenDualPaneSftp } from '../../domain/sftpDualPaneOpen';
import type { Host } from '../../types';
import { ContextMenuContent, ContextMenuItem, ContextMenuShortcut } from '../ui/context-menu';
import { collectOwnedPluginMenus, comparePluginMenus, usePluginContributions } from '../../application/state/usePluginContributions';
@@ -19,6 +22,17 @@ export interface HostTreeHostContextMenuHandlers {
onDeleteHost: (host: Host) => void;
}
export const OpenDualPaneSftpMenuItem: React.FC<{ host: Host }> = ({ host }) => {
const { t } = useI18n();
const { showSftpTab } = useSettingsChromeStore();
if (!showSftpTab || !canOpenDualPaneSftp(host)) return null;
return (
<ContextMenuItem onClick={() => requestOpenDualPaneSftp(host.id)}>
<Files className="mr-2 h-4 w-4" /> {t('vault.hosts.openSftp')}
</ContextMenuItem>
);
};
export const HostTreeHostContextMenuContent: React.FC<
HostTreeHostContextMenuHandlers & { host: Host }
> = ({
@@ -50,6 +64,7 @@ export const HostTreeHostContextMenuContent: React.FC<
<ContextMenuItem onClick={() => onConnect(safeHost)}>
<Monitor className="mr-2 h-4 w-4" /> {t('vault.hosts.connect')}
</ContextMenuItem>
<OpenDualPaneSftpMenuItem host={safeHost} />
{onEditHost && (
<ContextMenuItem onClick={() => onEditHost(host)}>
<Settings2 className="mr-2 h-4 w-4" /> {t('terminal.layer.hostTree.editHost')}
@@ -367,8 +367,15 @@ test("note editor registers a code block editor for pasted fenced code", () => {
source,
/codeBlockPlugin\([^)]*\),\s*codeMirrorPlugin\(\{\s*codeBlockLanguages:/s,
);
assert.match(source, /codeMirrorExtensions:\s*NOTE_CODE_MIRROR_EXTENSIONS/);
assert.match(source, /codeMirrorExtensions:\s*\[\s*\.\.\.NOTE_CODE_MIRROR_EXTENSIONS,/);
assert.match(source, /syntaxHighlighting\(noteCodeHighlightStyle\)/);
// Tooltips mount on body to escape clipped code blocks, so their placement
// must be bounded to this editor instead of the whole window.
assert.match(
source,
/createNoteCodeTooltipExtensions\(\s*typeof document === "undefined" \? undefined : document\.body,/,
);
assert.match(source, /\(\) => containerRef\.current,/);
});
test("note editor enables image plugin for remote markdown images", () => {
+11 -1
View File
@@ -63,6 +63,7 @@ import {
} from "./noteClipboardPaste";
import { annotateNoteImageSizes } from "./noteImageLayout";
import { renderNoteMathFormula } from "./noteMathRenderer";
import { createNoteCodeTooltipExtensions } from "./noteCodeTooltips";
import {
EMPTY_ACTIVE_FORMATS,
type ActiveTextFormats,
@@ -1203,7 +1204,16 @@ export const InlineMarkdownEditor = React.memo(
codeBlockPlugin({ defaultCodeBlockLanguage: "" }),
codeMirrorPlugin({
codeBlockLanguages: NOTE_CODE_BLOCK_LANGUAGES,
codeMirrorExtensions: NOTE_CODE_MIRROR_EXTENSIONS,
codeMirrorExtensions: [
...NOTE_CODE_MIRROR_EXTENSIONS,
...createNoteCodeTooltipExtensions(
typeof document === "undefined" ? undefined : document.body,
// Tooltips escape clipped code blocks via the body parent; bound
// their placement to this notes editor so a completion near a pane
// edge flips or shrinks instead of rendering over an adjacent pane.
() => containerRef.current,
),
],
}),
markdownShortcutPlugin(),
noteEditorDialogBridgePlugin({
+153
View File
@@ -0,0 +1,153 @@
import assert from "node:assert/strict";
import test from "node:test";
import { JSDOM } from "jsdom";
import { EditorState } from "@codemirror/state";
import { EditorView, showTooltip } from "@codemirror/view";
import { syncNoteCodeTooltipStyle, createNoteCodeTooltipExtensions, getNoteTooltipSpace } from "./noteCodeTooltips";
const stubRect = (element: Element, top: number, left: number, right: number, bottom: number) => {
element.getBoundingClientRect = () => ({
top,
left,
right,
bottom,
width: right - left,
height: bottom - top,
x: left,
y: top,
toJSON: () => ({}),
} as DOMRect);
};
test("note tooltips escape clipped code blocks and disappear with their editor", () => {
const dom = new JSDOM('<div id="note" style="overflow:hidden;height:20px"></div>', {
pretendToBeVisual: true,
});
const keys = ["window", "document", "MutationObserver", "requestAnimationFrame", "cancelAnimationFrame"] as const;
const previous = keys.map((key) => Object.getOwnPropertyDescriptor(globalThis, key));
for (const key of keys) {
const value = dom.window[key];
Object.defineProperty(globalThis, key, {
configurable: true,
value: typeof value === "function" && key.includes("AnimationFrame") ? value.bind(dom.window) : value,
});
}
let view: EditorView | undefined;
try {
const parent = dom.window.document.querySelector("#note") as HTMLElement;
view = new EditorView({
parent,
state: EditorState.create({
doc: "con",
extensions: [
...createNoteCodeTooltipExtensions(dom.window.document.body),
showTooltip.of({
pos: 0,
create() {
const tooltip = dom.window.document.createElement("div");
tooltip.textContent = "const";
return { dom: tooltip };
},
}),
],
}),
});
const tooltip = dom.window.document.querySelector(".cm-tooltip")!;
assert.ok(tooltip);
assert.equal(parent.contains(tooltip), false);
assert.equal(tooltip.parentElement?.parentElement, dom.window.document.body);
for (const themeClass of view.themeClasses.split(" ")) {
assert.ok(tooltip.parentElement?.classList.contains(themeClass));
}
view.dom.style.setProperty("--popover", "120 50% 20%");
view.dom.style.setProperty("--accent", "120 70% 40%");
syncNoteCodeTooltipStyle(view, { top: 0, left: 100, right: 300, bottom: 400 });
assert.equal((tooltip as HTMLElement).style.getPropertyValue("--popover"), "120 50% 20%");
assert.equal((tooltip as HTMLElement).style.getPropertyValue("--accent"), "120 70% 40%");
assert.equal((tooltip as HTMLElement).style.getPropertyValue("--note-tooltip-width"), "200px");
view.dom.style.setProperty("--popover", "240 50% 20%");
view.dom.style.removeProperty("--accent");
syncNoteCodeTooltipStyle(view, { top: 0, left: 100, right: 250, bottom: 400 });
assert.equal((tooltip as HTMLElement).style.getPropertyValue("--popover"), "240 50% 20%");
assert.equal((tooltip as HTMLElement).style.getPropertyValue("--accent"), "");
assert.equal((tooltip as HTMLElement).style.getPropertyValue("--note-tooltip-width"), "150px");
view.destroy();
view = undefined;
assert.equal(dom.window.document.querySelector(".cm-tooltip"), null);
} finally {
view?.destroy();
keys.forEach((key, index) => {
const descriptor = previous[index];
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
});
dom.window.close();
}
});
test("note tooltip space is bounded by clipping pane ancestors", () => {
const dom = new JSDOM('<div id="pane" style="overflow:hidden"><div id="note"></div></div>');
const pane = dom.window.document.querySelector("#pane") as HTMLElement;
const note = dom.window.document.querySelector("#note") as HTMLElement;
stubRect(pane, 100, 200, 600, 500);
// The notes editor extends below its pane; a tooltip must not follow it out.
stubRect(note, 150, 250, 550, 700);
const realGetComputedStyle = dom.window.getComputedStyle.bind(dom.window);
dom.window.getComputedStyle = ((element: Element) => ({
...realGetComputedStyle(element),
overflowX: element === pane ? "hidden" : "visible",
overflowY: element === pane ? "hidden" : "visible",
})) as typeof dom.window.getComputedStyle;
try {
assert.deepEqual(getNoteTooltipSpace(note, dom.window.document), {
top: 150,
left: 250,
right: 550,
bottom: 500,
});
} finally {
dom.window.close();
}
});
test("note tooltip space honors paint containment without overflow clipping", () => {
const dom = new JSDOM(
'<style>#pane{contain:strict}</style><div id="pane"><div id="note"></div></div>',
);
const pane = dom.window.document.querySelector("#pane") as HTMLElement;
const note = dom.window.document.querySelector("#note") as HTMLElement;
stubRect(pane, 0, 0, 800, 600);
stubRect(note, 50, -50, 850, 900);
const realGetComputedStyle = dom.window.getComputedStyle.bind(dom.window);
dom.window.getComputedStyle = ((element: Element) => ({
...realGetComputedStyle(element),
overflowX: "visible",
overflowY: "visible",
contain: element === pane ? "strict" : "none",
})) as typeof dom.window.getComputedStyle;
try {
assert.deepEqual(getNoteTooltipSpace(note, dom.window.document), {
top: 50,
left: 0,
right: 800,
bottom: 600,
});
} finally {
dom.window.close();
}
});
test("note tooltip space falls back to window bounds when the editor is unmounted", () => {
const dom = new JSDOM("<div></div>");
const detached = dom.window.document.createElement("div");
try {
assert.deepEqual(getNoteTooltipSpace(detached, dom.window.document), {
top: 0,
left: 0,
right: dom.window.document.documentElement.clientWidth,
bottom: dom.window.document.documentElement.clientHeight,
});
} finally {
dom.window.close();
}
});
+128
View File
@@ -0,0 +1,128 @@
import { Prec } from "@codemirror/state";
import { EditorView, getTooltip, showTooltip, tooltips } from "@codemirror/view";
export interface NoteTooltipSpace {
top: number;
left: number;
right: number;
bottom: number;
}
/** True when the element clips horizontally overflowing descendants. */
const clipsHorizontally = (style: CSSStyleDeclaration): boolean =>
style.overflowX !== "visible" ||
/(^|\s)(strict|content|paint)(\s|$)/.test(style.contain || "");
/** True when the element clips vertically overflowing descendants. Paint
* containment (strict/content/paint) also clips and captures fixed
* descendants even when overflow is visible. */
const clipsVertically = (style: CSSStyleDeclaration): boolean =>
style.overflowY !== "visible" ||
/(^|\s)(strict|content|paint)(\s|$)/.test(style.contain || "");
/**
* Viewport-space bounds CodeMirror may place note tooltips in: the owning
* notes region (`boundsRoot`) shrunk by every clipping ancestor (terminal
* side-panel pane hosts, ScrollArea viewports). Tooltips are mounted on
* `document.body` so they escape clipped code blocks; bounding their space
* to the notes pane keeps a completion from rendering over an adjacent
* split pane. Without a mounted `boundsRoot`, falls back to the window
* bounds CodeMirror would use by default.
*/
export const getNoteTooltipSpace = (
boundsRoot: HTMLElement | null | undefined,
doc: Document,
): NoteTooltipSpace => {
if (!boundsRoot || !boundsRoot.isConnected) {
const docElt = doc.documentElement;
return { top: 0, left: 0, right: docElt.clientWidth, bottom: docElt.clientHeight };
}
const rootRect = boundsRoot.getBoundingClientRect();
let space: NoteTooltipSpace = {
top: rootRect.top,
left: rootRect.left,
right: rootRect.right,
bottom: rootRect.bottom,
};
const defaultView = boundsRoot.ownerDocument.defaultView;
if (!defaultView) return space;
for (let node: Element | null = boundsRoot; node; node = node.parentElement) {
const style = defaultView.getComputedStyle(node);
const rect = node.getBoundingClientRect();
if (clipsHorizontally(style)) {
space = {
...space,
left: Math.max(space.left, rect.left),
right: Math.min(space.right, rect.right),
};
}
if (clipsVertically(style)) {
space = {
...space,
top: Math.max(space.top, rect.top),
bottom: Math.min(space.bottom, rect.bottom),
};
}
}
return space;
};
export const syncNoteCodeTooltipStyle = (view: EditorView, space: NoteTooltipSpace): void => {
const width = `${Math.max(0, space.right - space.left)}px`;
const paneStyle = view.dom.ownerDocument.defaultView?.getComputedStyle(view.dom);
const tokens = ["--popover", "--popover-foreground", "--border", "--accent", "--accent-foreground"];
for (const tooltip of view.state.facet(showTooltip)) {
if (!tooltip) continue;
const dom = getTooltip(view, tooltip)?.dom;
if (!dom) continue;
dom.style.setProperty("--note-tooltip-width", width);
// Detached tooltips must retain terminal-side-panel theme overrides.
for (const token of tokens) {
const value = paneStyle?.getPropertyValue(token).trim();
if (value) dom.style.setProperty(token, value);
else dom.style.removeProperty(token);
}
}
};
export const createNoteCodeTooltipExtensions = (
parent: HTMLElement | undefined,
getBoundsRoot?: () => HTMLElement | null,
) => [
// Code blocks and their enclosing note panes can clip editor descendants.
Prec.highest(
tooltips({
parent,
// Tooltips escape those clips via the global parent, so constrain their
// placement to the owning notes pane instead of the whole window.
tooltipSpace: (view: EditorView) => {
const space = getNoteTooltipSpace(getBoundsRoot?.(), view.dom.ownerDocument);
// CodeMirror constrains coordinates and height, but not width. Set
// the available width; its ResizeObserver remeasures after resizing.
syncNoteCodeTooltipStyle(view, space);
return space;
},
}),
),
// CodeMirror carries this theme's scope onto its detached tooltip container.
// Keep these rules local to Notes and use the application's existing colors.
Prec.highest(EditorView.theme({
".cm-tooltip": {
backgroundColor: "hsl(var(--popover))",
color: "hsl(var(--popover-foreground))",
border: "1px solid hsl(var(--border))",
},
".cm-tooltip-autocomplete": {
maxWidth: "var(--note-tooltip-width, 95vw)",
boxSizing: "border-box",
},
".cm-tooltip.cm-tooltip-autocomplete > ul": {
minWidth: "min(250px, max(0px, calc(var(--note-tooltip-width, 95vw) - 2px)))",
maxWidth: "min(700px, max(0px, calc(var(--note-tooltip-width, 95vw) - 2px)))",
},
".cm-tooltip-autocomplete > ul > li[aria-selected]": {
backgroundColor: "hsl(var(--accent))",
color: "hsl(var(--accent-foreground))",
},
})),
];
+14 -3
View File
@@ -7,6 +7,11 @@ import React, { memo, useCallback } from 'react';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { cn } from '../../lib/utils';
import { SftpFileEntry } from '../../types';
import {
sftpFileRowDensityClass,
sftpFileRowIconDensityClass,
type SftpListDensity,
} from '../../domain/sftpListDensity';
import { buildSftpColumnTemplate, formatBytes, formatDate, getFileIcon, isNavigableDirectory, type ColumnWidths, type SftpColumnVisibility } from './utils';
interface SftpFileRowProps {
@@ -24,6 +29,7 @@ interface SftpFileRowProps {
onDragOver: (entry: SftpFileEntry, e: React.DragEvent) => void;
onDragLeave: () => void;
onDrop: (entry: SftpFileEntry, e: React.DragEvent) => void;
density?: SftpListDensity;
}
const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
@@ -41,6 +47,7 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
onDragOver,
onDragLeave,
onDrop,
density = "comfortable",
}) => {
const isParentDir = entry.name === '..';
// A symlink pointing to a directory behaves like a directory (navigable, accepts drops)
@@ -81,8 +88,10 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
onDrop={handleDrop}
onClick={handleSelect}
onDoubleClick={handleOpen}
data-sftp-list-density={density}
className={cn(
"px-4 py-2 items-center cursor-pointer text-sm",
"items-center cursor-pointer",
sftpFileRowDensityClass(density),
isSelectionVisible
? "bg-accent text-accent-foreground hover:bg-accent"
: "hover:bg-accent/50",
@@ -90,9 +99,10 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
)}
style={{ display: 'grid', gridTemplateColumns: buildSftpColumnTemplate(columnWidths, visibleColumns) }}
>
<div className="flex items-center gap-3 min-w-0">
<div className={cn("flex items-center min-w-0", density === "compact" ? "gap-2" : "gap-3")}>
<div className={cn(
"h-7 w-7 rounded flex items-center justify-center shrink-0 relative",
"rounded flex items-center justify-center shrink-0 relative",
sftpFileRowIconDensityClass(density),
isSelectionVisible
? "bg-accent-foreground/10 text-accent-foreground"
: isNavDir
@@ -168,6 +178,7 @@ const areEqual = (prev: SftpFileRowProps, next: SftpFileRowProps): boolean => {
// Compare callbacks - important for ".." entry which has static properties
if (prev.onOpen !== next.onOpen) return false;
if (prev.onSelect !== next.onSelect) return false;
if (prev.density !== next.density) return false;
const prevEntry = prev.entry;
const nextEntry = next.entry;
return (
+5
View File
@@ -19,6 +19,7 @@ import { buildSftpColumnTemplate, isNavigableDirectory, isSftpColumnMenuKey } fr
import { isKnownBinaryFile } from "../../lib/sftpFileUtils";
import { isExtractableArchive } from "../../domain/sftpArchive";
import { SftpFileRow } from "./SftpFileRow";
import type { SftpListDensity } from "../../domain/sftpListDensity";
import { SftpColumnMenuItems } from "./SftpColumnMenuItems";
import { getSftpVirtualListScrollTop } from "../../domain/sftpVirtualList";
import {
@@ -77,6 +78,7 @@ interface SftpPaneFileListProps {
openDeleteConfirm: (targets: string[]) => void;
rowHeight: number;
visibleRows: { entry: SftpFileEntry; index: number; top: number }[];
listDensity?: SftpListDensity;
}
const SftpErrorWithLogs: React.FC<{
@@ -164,6 +166,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
openDeleteConfirm,
rowHeight,
visibleRows,
listDensity = "comfortable",
}) => {
const {
columnWidths,
@@ -288,6 +291,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
onDragOver={handleEntryDragOver}
onDragLeave={handleRowDragLeave}
onDrop={handleEntryDrop}
density={listDensity}
/>
</ContextMenuTrigger>
{entry.name !== ".." && (
@@ -497,6 +501,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
openRenameDialog,
pane.connection,
pane.selectedFiles,
listDensity,
setShowNewFolderDialog,
setShowNewFileDialog,
t,
@@ -2,7 +2,7 @@ import test from "node:test";
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
test("selecting an inline SFTP bookmark closes its popover", async () => {
test("inline bookmark actions navigate and confirm current-path removal", async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
pretendToBeVisual: true,
url: "http://localhost",
@@ -48,85 +48,116 @@ test("selecting an inline SFTP bookmark closes its popover", async () => {
const { default: React, act } = await import("react");
const { createRoot } = await import("react-dom/client");
const { I18nProvider } = await import("../../application/i18n/I18nProvider.tsx");
const { SftpPaneToolbar } = await import("./SftpPaneToolbar.tsx");
const rootNode = window.document.getElementById("root");
assert.ok(rootNode);
const root = createRoot(rootNode);
const navigatedPaths: string[] = [];
const deletedBookmarkIds: string[] = [];
let toggleBookmarkCalls = 0;
const waitForFocusRestore = async () => {
await act(async () => {
await new Promise((resolve) => window.setTimeout(resolve, 20));
});
};
const setBookmarkPlacement = async (placement: "show" | "collapse") => {
window.localStorage.setItem("netcatty_sftp_toolbar_layout_v1", JSON.stringify({
order: ["bookmark"],
placement: { bookmark: placement },
}));
await act(async () => {
window.dispatchEvent(new window.StorageEvent("storage", {
key: "netcatty_sftp_toolbar_layout_v1",
}));
});
};
const findRemovalDialog = () => Array.from(window.document.querySelectorAll("h2"))
.find((heading) => heading.textContent?.trim() === "Remove bookmark")
?.closest<HTMLElement>('[role="dialog"]') ?? null;
const renderToolbar = async (currentPath: string, currentPathBookmarked: boolean) => {
await act(async () => {
const toolbar = React.createElement(SftpPaneToolbar, {
t: (key, params) => ({
"sftp.bookmark.list": "Bookmarked paths",
"sftp.bookmark.remove": "Remove bookmark",
"sftp.bookmark.removeConfirm": `Remove bookmark ${params?.path ?? ""}?`,
"sftp.viewMode.switchToTree": "Switch to tree view",
}[key] ?? key),
pane: {
id: "pane-1",
connection: {
id: "conn-1",
hostId: "host-1",
name: "Example",
currentPath,
homeDir: "/home/app",
isLocal: false,
},
files: [],
loading: false,
reconnecting: false,
error: null,
connectionLogs: [],
selectedFiles: new Set(),
filter: "",
filenameEncoding: "auto",
showHiddenFiles: false,
transferMutationToken: 0,
},
onNavigateTo: () => {},
onSetFilter: () => {},
onSetFilenameEncoding: () => {},
onRefresh: () => {},
showFilterBar: false,
setShowFilterBar: () => {},
filterInputRef: { current: null },
isEditingPath: false,
editingPathValue: "",
setEditingPathValue: () => {},
setShowPathSuggestions: () => {},
showPathSuggestions: false,
setPathSuggestionIndex: () => {},
pathSuggestions: [],
pathSuggestionIndex: -1,
pathInputRef: { current: null },
pathDropdownRef: { current: null },
handlePathBlur: () => {},
handlePathKeyDown: () => {},
handlePathDoubleClick: () => {},
handlePathSubmit: () => {},
getNextUntitledName: () => "untitled",
setNewFileName: () => {},
setFileNameError: () => {},
setShowNewFileDialog: () => {},
setShowNewFolderDialog: () => {},
setNewFolderName: () => {},
bookmarks: [
{ id: "bm-current", path: "/home/app", label: "App root" },
{ id: "bm-1", path: "/srv/www", label: "Web root" },
],
isCurrentPathBookmarked: currentPathBookmarked,
onToggleBookmark: () => {
toggleBookmarkCalls += 1;
},
onAddGlobalBookmark: () => {},
isCurrentPathGlobalBookmarked: false,
onNavigateToBookmark: (path) => navigatedPaths.push(path),
onDeleteBookmark: (bookmark) => deletedBookmarkIds.push(bookmark.id),
showHiddenFiles: false,
onToggleShowHiddenFiles: () => {},
viewMode: "list",
onSetViewMode: () => {},
});
root.render(React.createElement(I18nProvider, { locale: "en" }, toolbar));
});
};
try {
await act(async () => {
root.render(
React.createElement(SftpPaneToolbar, {
t: (key) => ({
"sftp.bookmark.list": "Bookmarked paths",
"sftp.bookmark.remove": "Remove bookmark",
"sftp.viewMode.switchToTree": "Switch to tree view",
}[key] ?? key),
pane: {
id: "pane-1",
connection: {
id: "conn-1",
hostId: "host-1",
name: "Example",
currentPath: "/home/app",
homeDir: "/home/app",
isLocal: false,
},
files: [],
loading: false,
reconnecting: false,
error: null,
connectionLogs: [],
selectedFiles: new Set(),
filter: "",
filenameEncoding: "auto",
showHiddenFiles: false,
transferMutationToken: 0,
},
onNavigateTo: () => {},
onSetFilter: () => {},
onSetFilenameEncoding: () => {},
onRefresh: () => {},
showFilterBar: false,
setShowFilterBar: () => {},
filterInputRef: { current: null },
isEditingPath: false,
editingPathValue: "",
setEditingPathValue: () => {},
setShowPathSuggestions: () => {},
showPathSuggestions: false,
setPathSuggestionIndex: () => {},
pathSuggestions: [],
pathSuggestionIndex: -1,
pathInputRef: { current: null },
pathDropdownRef: { current: null },
handlePathBlur: () => {},
handlePathKeyDown: () => {},
handlePathDoubleClick: () => {},
handlePathSubmit: () => {},
getNextUntitledName: () => "untitled",
setNewFileName: () => {},
setFileNameError: () => {},
setShowNewFileDialog: () => {},
setShowNewFolderDialog: () => {},
setNewFolderName: () => {},
bookmarks: [{ id: "bm-1", path: "/srv/www", label: "Web root" }],
isCurrentPathBookmarked: false,
onToggleBookmark: () => {},
onAddGlobalBookmark: () => {},
isCurrentPathGlobalBookmarked: false,
onNavigateToBookmark: (path) => navigatedPaths.push(path),
onDeleteBookmark: () => {},
showHiddenFiles: false,
onToggleShowHiddenFiles: () => {},
viewMode: "list",
onSetViewMode: () => {},
}),
);
});
await renderToolbar("/home/app", true);
const trigger = window.document.querySelector<HTMLButtonElement>(
let trigger = window.document.querySelector<HTMLButtonElement>(
'button[aria-label="Bookmarked paths"]',
);
assert.ok(trigger);
@@ -146,6 +177,134 @@ test("selecting an inline SFTP bookmark closes its popover", async () => {
false,
"bookmark popover should close after selecting a path",
);
await act(async () => trigger.click());
const removeCurrentPath = Array.from(window.document.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Remove bookmark",
);
assert.ok(removeCurrentPath, "current-path bookmark removal should be visible");
removeCurrentPath.focus();
await act(async () => removeCurrentPath.click());
let dialog = findRemovalDialog();
assert.ok(dialog, "current-path removal should open an in-app dialog");
assert.match(dialog.textContent ?? "", /Remove bookmark \/home\/app\?/);
assert.equal(
Array.from(window.document.querySelectorAll("button")).some((button) =>
button.textContent?.includes("Web root"),
),
false,
"the bookmark popover must close before the dialog opens",
);
assert.equal(toggleBookmarkCalls, 0, "opening the dialog must not remove the bookmark");
const cancel = Array.from(dialog.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Cancel",
);
assert.ok(cancel);
await setBookmarkPlacement("collapse");
const overflowTriggerAfterLayoutChange = window.document.querySelector<HTMLButtonElement>(
'button[data-toolbar-overflow-trigger="true"]',
);
assert.ok(overflowTriggerAfterLayoutChange);
await act(async () => cancel.click());
await waitForFocusRestore();
assert.equal(findRemovalDialog(), null);
assert.equal(toggleBookmarkCalls, 0, "cancelling must preserve the bookmark");
assert.equal(
window.document.activeElement,
overflowTriggerAfterLayoutChange,
"cancel should fall back to the newly mounted overflow trigger",
);
await setBookmarkPlacement("show");
trigger = window.document.querySelector<HTMLButtonElement>(
'button[aria-label="Bookmarked paths"]',
);
assert.ok(trigger);
await act(async () => trigger.click());
const removeCurrentPathAfterCancel = Array.from(
window.document.querySelectorAll("button"),
).find((button) => button.textContent?.trim() === "Remove bookmark");
assert.ok(removeCurrentPathAfterCancel);
await act(async () => removeCurrentPathAfterCancel.click());
dialog = findRemovalDialog();
assert.ok(dialog, "the removal dialog should reopen after cancellation");
await renderToolbar("/home/other", false);
dialog = findRemovalDialog();
assert.ok(dialog, "changing directory must not replace the pending removal target");
assert.match(dialog.textContent ?? "", /Remove bookmark \/home\/app\?/);
assert.doesNotMatch(dialog.textContent ?? "", /\/home\/other/);
const confirm = Array.from(dialog.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Remove bookmark",
);
assert.ok(confirm);
await act(async () => confirm.click());
assert.deepEqual(deletedBookmarkIds, ["bm-current"]);
assert.equal(toggleBookmarkCalls, 0, "confirmation must not toggle the new current path");
assert.equal(findRemovalDialog(), null);
await waitForFocusRestore();
assert.equal(window.document.activeElement, trigger, "confirm should restore the bookmark trigger");
await act(async () => trigger.click());
const webRootRow = Array.from(
window.document.querySelectorAll<HTMLElement>("[data-bookmark-scope]"),
).find((element) => element.textContent?.includes("Web root"));
const removeWebRoot = webRootRow?.querySelector<HTMLButtonElement>(
'button[aria-label="Remove bookmark"]',
);
assert.ok(removeWebRoot);
await act(async () => removeWebRoot.click());
dialog = findRemovalDialog();
assert.ok(dialog, "row removal should use the shared in-app dialog");
assert.match(dialog.textContent ?? "", /Remove bookmark \/srv\/www\?/);
assert.equal(webRootRow.isConnected, false, "row removal must close the bookmark popover");
const confirmWebRoot = Array.from(dialog.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Remove bookmark",
);
assert.ok(confirmWebRoot);
await act(async () => confirmWebRoot.click());
assert.deepEqual(deletedBookmarkIds, ["bm-current", "bm-1"]);
await waitForFocusRestore();
assert.equal(window.document.activeElement, trigger, "row removal should restore the bookmark trigger");
await setBookmarkPlacement("collapse");
const overflowTrigger = window.document.querySelector<HTMLButtonElement>(
'button[data-toolbar-overflow-trigger="true"]',
);
assert.ok(overflowTrigger, "collapsed bookmark should expose the overflow trigger");
await act(async () => overflowTrigger.click());
const nestedBookmarkTrigger = window.document.querySelector<HTMLButtonElement>(
'button[aria-label="Bookmarked paths"]',
);
assert.ok(nestedBookmarkTrigger, "overflow should expose the nested bookmark trigger");
await act(async () => nestedBookmarkTrigger.click());
const overflowWebRootRow = Array.from(
window.document.querySelectorAll<HTMLElement>("[data-bookmark-scope]"),
).find((element) => element.textContent?.includes("Web root"));
const removeOverflowWebRoot = overflowWebRootRow?.querySelector<HTMLButtonElement>(
'button[aria-label="Remove bookmark"]',
);
assert.ok(removeOverflowWebRoot);
removeOverflowWebRoot.focus();
await act(async () => removeOverflowWebRoot.click());
dialog = findRemovalDialog();
assert.ok(dialog, "overflow row removal should open the shared dialog");
const cancelOverflowRemoval = Array.from(dialog.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Cancel",
);
assert.ok(cancelOverflowRemoval);
await act(async () => cancelOverflowRemoval.click());
await waitForFocusRestore();
assert.equal(
window.document.activeElement,
overflowTrigger,
"overflow removal should restore the persistent overflow trigger",
);
await act(async () => {
await new Promise((resolve) => window.setTimeout(resolve, 300));
});
} finally {
await act(async () => root.unmount());
dom.window.close();
+85 -1
View File
@@ -8,8 +8,11 @@ import { renderToStaticMarkup } from "react-dom/server";
import {
getSftpBookmarkButtonLabelKey,
getSftpBookmarkIdentity,
getNextSftpViewMode,
copySftpCurrentPathToClipboard,
canReorderSftpBookmark,
getSftpBookmarkMoveTargets,
getNextSftpToolbarDisplayPath,
getSftpViewModeToggleTarget,
getSftpViewModeToggleLabelKey,
@@ -32,6 +35,11 @@ test("single SFTP view-mode button toggles to the other mode", () => {
assert.equal(getNextSftpViewMode("tree"), "list");
});
test("SFTP toolbar includes compact list density next to view mode", () => {
assert.match(toolbarSource, /"listDensity"/);
assert.match(toolbarSource, /getSftpListDensityToggleLabelKey/);
});
test("narrow SFTP toolbar spills non-pinned show items into overflow without changing hide/collapse", () => {
const shown = ["bookmark", "copyPath", "viewMode", "filter", "newFolder", "newFile", "refresh"];
const collapsed = ["encoding"];
@@ -491,6 +499,82 @@ test("toolbar display path keeps the previous confirmed path while loading the s
);
});
test("bookmark delete asks for confirmation inside the app", () => {
assert.doesNotMatch(toolbarSource, /window\.confirm/);
assert.doesNotMatch(toolbarSource, /confirmRemoveSftpBookmark/);
assert.match(toolbarSource, /import \{ ConfirmDialog \} from "\.\.\/ui\/confirm-dialog"/);
assert.match(toolbarSource, /<ConfirmDialog/);
assert.match(toolbarSource, /sftp\.bookmark\.removeConfirm/);
assert.match(toolbarSource, /path: pendingBookmarkRemoval\.path/);
});
test("bookmark rename does not use window.prompt", () => {
assert.doesNotMatch(toolbarSource, /window\.prompt/);
assert.match(toolbarSource, /event\.nativeEvent\.isComposing/);
});
test("bookmark drag ordering stays within global or location scope", () => {
const bookmarks = [
{ id: "gbm-1", path: "/global-a", label: "Global A", global: true },
{ id: "gbm-2", path: "/global-b", label: "Global B", global: true },
{ id: "bm-1", path: "/host-a", label: "Host A" },
{ id: "bm-2", path: "/host-b", label: "Host B" },
];
assert.equal(canReorderSftpBookmark(bookmarks[0], bookmarks[1]), true);
assert.equal(canReorderSftpBookmark(bookmarks[2], bookmarks[3]), true);
assert.equal(canReorderSftpBookmark(bookmarks[0], bookmarks[2]), false);
assert.equal(canReorderSftpBookmark(undefined, bookmarks[2]), false);
assert.deepEqual(getSftpBookmarkMoveTargets(bookmarks, bookmarks[0]), {
previous: null,
next: bookmarks[1],
});
assert.deepEqual(getSftpBookmarkMoveTargets(bookmarks, bookmarks[2]), {
previous: null,
next: bookmarks[3],
});
});
test("bookmark operations keep global and location entries distinct when ids collide", () => {
const global = { id: "shared-id", path: "/global", label: "Global", global: true };
const location = { id: "shared-id", path: "/location", label: "Location" };
const nextLocation = { id: "next", path: "/next", label: "Next" };
const bookmarks = [global, location, nextLocation];
assert.notEqual(getSftpBookmarkIdentity(global), getSftpBookmarkIdentity(location));
assert.deepEqual(getSftpBookmarkMoveTargets(bookmarks, location), {
previous: null,
next: nextLocation,
});
assert.equal(canReorderSftpBookmark(location, nextLocation), true);
assert.equal(canReorderSftpBookmark(global, location), false);
});
test("bookmark manage mode exposes keyboard reorder controls without a dead path button", () => {
const markup = renderToStaticMarkup(
React.createElement(SftpBookmarkList, {
bookmarks: [
{ id: "bm-1", path: "/srv/a", label: "First" },
{ id: "bm-2", path: "/srv/b", label: "Second" },
],
managing: true,
onNavigateToBookmark: () => {},
onRequestDeleteBookmark: () => {},
onReorderBookmark: () => {},
t: (key: string, params?: Record<string, unknown>) => ({
"sftp.bookmark.moveUp": `Move ${params?.label ?? ""} up`,
"sftp.bookmark.moveDown": `Move ${params?.label ?? ""} down`,
"sftp.bookmark.remove": "Remove bookmark",
}[key] ?? key),
}),
);
assert.match(markup, /aria-label="Move First up"[^>]*disabled/);
assert.match(markup, /aria-label="Move First down"/);
assert.match(markup, /aria-label="Move Second up"/);
assert.match(markup, /aria-label="Move Second down"[^>]*disabled/);
assert.doesNotMatch(markup, /<button[^>]*>\s*<div class="text-xs font-medium truncate">First/);
});
test("bookmark list renders saved paths as selectable rows", () => {
const markup = renderToStaticMarkup(
React.createElement(
@@ -502,7 +586,7 @@ test("bookmark list renders saved paths as selectable rows", () => {
React.createElement(SftpBookmarkList, {
bookmarks: [{ id: "bm-1", path: "/srv/www", label: "Web root" }],
onNavigateToBookmark: () => {},
onDeleteBookmark: () => {},
onRequestDeleteBookmark: () => {},
t: (key: string) => ({
"sftp.bookmark.remove": "Remove bookmark",
}[key] ?? key),
+385 -62
View File
@@ -1,9 +1,15 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Bookmark, Check, ClipboardCopy, Eye, EyeOff, FilePlus, Folder, FolderPlus, FolderSync, Globe, Home, Languages, List, ListTree, RefreshCw, Search, Terminal, TerminalSquare, Trash2, X } from "lucide-react";
import { ArrowDown, ArrowUp, Bookmark, Check, ClipboardCopy, Eye, EyeOff, FilePlus, Folder, FolderPlus, FolderSync, Globe, GripVertical, Home, Languages, List, ListTree, Pencil, RefreshCw, Rows3, Search, Terminal, TerminalSquare, Trash2, X } from "lucide-react";
import {
getNextSftpListDensity,
getSftpListDensityToggleLabelKey,
type SftpListDensity,
} from "../../domain/sftpListDensity";
import { useToolbarItemLayout } from "../../application/state/useToolbarItemLayout";
import type { ToolbarItemLayoutDefaults } from "../../domain/toolbarItemLayout";
import { STORAGE_KEY_SFTP_TOOLBAR_LAYOUT } from "../../infrastructure/config/storageKeys";
import { Button } from "../ui/button";
import { ConfirmDialog } from "../ui/confirm-dialog";
import { Input } from "../ui/input";
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from "../ui/popover";
import { cn } from "../../lib/utils";
@@ -33,6 +39,7 @@ export const SFTP_TOOLBAR_ITEM_IDS = [
"followTerminalCwd",
"copyPath",
"viewMode",
"listDensity",
"filter",
"encoding",
"newFolder",
@@ -52,6 +59,7 @@ export const SFTP_TOOLBAR_LAYOUT_DEFAULTS: ToolbarItemLayoutDefaults = {
followTerminalCwd: "show",
copyPath: "show",
viewMode: "show",
listDensity: "show",
filter: "show",
encoding: "collapse",
newFolder: "show",
@@ -74,6 +82,7 @@ export const SFTP_TOOLBAR_NARROW_INLINE_IDS = new Set<SftpToolbarItemId>([
"followTerminalCwd",
"copyPath",
"viewMode",
"listDensity",
"filter",
]);
@@ -215,7 +224,11 @@ interface SftpPaneToolbarProps {
onAddGlobalBookmark: (path: string) => void;
isCurrentPathGlobalBookmarked: boolean;
onNavigateToBookmark: (path: string) => void;
onDeleteBookmark: (id: string) => void;
onDeleteBookmark: (bookmark: SftpBookmark) => void;
onReorderBookmark?: (from: SftpBookmark, to: SftpBookmark) => void;
onRenameBookmark?: (bookmark: SftpBookmark, label: string) => void;
listDensity?: SftpListDensity;
onSetListDensity?: (density: SftpListDensity) => void;
showHiddenFiles: boolean;
onToggleShowHiddenFiles?: () => void;
onGoToTerminalCwd?: () => void;
@@ -229,63 +242,249 @@ interface SftpPaneToolbarProps {
interface SftpBookmarkListProps {
bookmarks: SftpBookmark[];
managing?: boolean;
onNavigateToBookmark: (path: string) => void;
onDeleteBookmark: (id: string) => void;
onRequestDeleteBookmark: (bookmark: SftpBookmark) => void;
onReorderBookmark?: (from: SftpBookmark, to: SftpBookmark) => void;
onRenameBookmark?: (bookmark: SftpBookmark, label: string) => void;
t: (key: string, params?: Record<string, unknown>) => string;
}
const SFTP_BOOKMARK_DRAG_TYPE = "text/netcatty-sftp-bookmark";
export const getSftpBookmarkIdentity = (bookmark: SftpBookmark): string => (
`${bookmark.global ? "global" : "location"}:${bookmark.id}`
);
export const canReorderSftpBookmark = (
source: SftpBookmark | undefined,
target: SftpBookmark | undefined,
): boolean => {
return !!source && !!target && !!source.global === !!target.global;
};
export const getSftpBookmarkMoveTargets = (
bookmarks: SftpBookmark[],
bookmark: SftpBookmark,
): { previous: SftpBookmark | null; next: SftpBookmark | null } => {
const index = bookmarks.findIndex((candidate) => (
candidate.id === bookmark.id && !!candidate.global === !!bookmark.global
));
if (index < 0) return { previous: null, next: null };
const isGlobal = !!bookmarks[index]?.global;
let previous: SftpBookmark | null = null;
let next: SftpBookmark | null = null;
for (let candidate = index - 1; candidate >= 0; candidate -= 1) {
if (!!bookmarks[candidate]?.global === isGlobal) {
previous = bookmarks[candidate]!;
break;
}
}
for (let candidate = index + 1; candidate < bookmarks.length; candidate += 1) {
if (!!bookmarks[candidate]?.global === isGlobal) {
next = bookmarks[candidate]!;
break;
}
}
return { previous, next };
};
export const SftpBookmarkList: React.FC<SftpBookmarkListProps> = ({
bookmarks,
managing = false,
onNavigateToBookmark,
onDeleteBookmark,
onRequestDeleteBookmark,
onReorderBookmark,
onRenameBookmark,
t,
}) => (
bookmarks.length > 0 ? (
<div className="max-h-48 overflow-auto py-1">
{bookmarks.map((bm) => (
<div
key={bm.id}
className="flex items-center gap-1 px-2 py-1 hover:bg-secondary/60 group"
>
{bm.global && (
<Globe size={10} className="shrink-0 text-primary" />
)}
<Tooltip>
<TooltipTrigger asChild>
<PopoverClose asChild>
<button
type="button"
className="flex-1 text-left text-xs truncate font-mono"
onClick={() => onNavigateToBookmark(bm.path)}
>
{bm.label}
<span className="ml-1.5 text-muted-foreground text-[10px]">{bm.path}</span>
</button>
</PopoverClose>
</TooltipTrigger>
<TooltipContent>{bm.path}</TooltipContent>
</Tooltip>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 opacity-0 group-hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-1 shrink-0 text-muted-foreground hover:text-destructive"
aria-label={t("sftp.bookmark.remove")}
onClick={(e) => {
e.stopPropagation();
onDeleteBookmark(bm.id);
}}
}) => {
const [draggingBookmark, setDraggingBookmark] = useState<SftpBookmark | null>(null);
const [renamingBookmark, setRenamingBookmark] = useState<SftpBookmark | null>(null);
const [renameDraft, setRenameDraft] = useState("");
if (bookmarks.length === 0) {
return (
<div className="p-3 text-xs text-muted-foreground text-center">
{t("sftp.bookmark.empty")}
</div>
);
}
return (
<div className="max-h-64 overflow-auto py-1 min-w-[18rem]">
{bookmarks.map((bm, index) => {
const bookmarkIdentity = getSftpBookmarkIdentity(bm);
const startsHostGroup = index > 0 && !!bookmarks[index - 1]?.global && !bm.global;
const moveTargets = getSftpBookmarkMoveTargets(bookmarks, bm);
const isDragging = draggingBookmark?.id === bm.id
&& !!draggingBookmark.global === !!bm.global;
const isRenaming = renamingBookmark?.id === bm.id
&& !!renamingBookmark.global === !!bm.global;
const pathContent = (
<>
<div className="text-xs font-medium truncate">{bm.label}</div>
<div className="text-[10px] text-muted-foreground font-mono break-all leading-tight">
{bm.path}
</div>
</>
);
const pathDisplay = managing ? (
<div className="flex-1 min-w-0 text-left">{pathContent}</div>
) : (
<button
type="button"
className="flex-1 min-w-0 text-left"
onClick={() => onNavigateToBookmark(bm.path)}
>
<Trash2 size={10} />
</Button>
</div>
))}
{pathContent}
</button>
);
return (
<div
key={bookmarkIdentity}
className={cn(
"flex items-start gap-1 px-2 py-1.5 hover:bg-secondary/60 group",
startsHostGroup && "mt-1 border-t border-border/60 pt-2",
isDragging && "opacity-60",
)}
data-bookmark-scope={bm.global ? "global" : "location"}
draggable={managing && !isRenaming}
onDragStart={(event) => {
if (!managing) return;
setDraggingBookmark(bm);
event.dataTransfer.setData(SFTP_BOOKMARK_DRAG_TYPE, bookmarkIdentity);
event.dataTransfer.effectAllowed = "move";
}}
onDragOver={(event) => {
if (
!managing
|| !draggingBookmark
|| (draggingBookmark.id === bm.id && !!draggingBookmark.global === !!bm.global)
|| !canReorderSftpBookmark(draggingBookmark, bm)
) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
}}
onDrop={(event) => {
event.preventDefault();
const transferredIdentity = event.dataTransfer.getData(SFTP_BOOKMARK_DRAG_TYPE);
const source = bookmarks.find((candidate) => (
getSftpBookmarkIdentity(candidate) === transferredIdentity
)) ?? draggingBookmark;
setDraggingBookmark(null);
if (
source
&& (source.id !== bm.id || !!source.global !== !!bm.global)
&& canReorderSftpBookmark(source, bm)
) onReorderBookmark?.(source, bm);
}}
onDragEnd={() => setDraggingBookmark(null)}
>
{managing && (
<GripVertical
size={12}
className="mt-1 shrink-0 text-muted-foreground cursor-grab"
aria-hidden="true"
/>
)}
{bm.global && (
<Globe size={10} className="mt-1 shrink-0 text-primary" />
)}
{managing && onRenameBookmark && isRenaming ? (
<Input
autoFocus
value={renameDraft}
aria-label={t("sftp.bookmark.rename")}
className="h-6 flex-1 min-w-0 text-xs"
onClick={(event) => event.stopPropagation()}
onChange={(event) => setRenameDraft(event.target.value)}
onKeyDown={(event) => {
if (event.nativeEvent.isComposing) return;
if (event.key === "Enter") {
event.preventDefault();
onRenameBookmark(bm, renameDraft);
setRenamingBookmark(null);
}
if (event.key === "Escape") {
event.preventDefault();
setRenamingBookmark(null);
}
}}
onBlur={() => {
onRenameBookmark(bm, renameDraft);
setRenamingBookmark(null);
}}
/>
) : managing ? pathDisplay : (
<PopoverClose asChild>{pathDisplay}</PopoverClose>
)}
{managing && onReorderBookmark && (
<>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 shrink-0 text-muted-foreground"
aria-label={t("sftp.bookmark.moveUp", { label: bm.label })}
disabled={!moveTargets.previous}
onClick={(event) => {
event.stopPropagation();
if (moveTargets.previous) onReorderBookmark(bm, moveTargets.previous);
}}
>
<ArrowUp size={10} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 shrink-0 text-muted-foreground"
aria-label={t("sftp.bookmark.moveDown", { label: bm.label })}
disabled={!moveTargets.next}
onClick={(event) => {
event.stopPropagation();
if (moveTargets.next) onReorderBookmark(bm, moveTargets.next);
}}
>
<ArrowDown size={10} />
</Button>
</>
)}
{managing && onRenameBookmark && !isRenaming && (
<Button
variant="ghost"
size="icon"
className="h-5 w-5 shrink-0 text-muted-foreground"
aria-label={t("sftp.bookmark.rename")}
onClick={(event) => {
event.stopPropagation();
setRenamingBookmark(bm);
setRenameDraft(bm.label);
}}
>
<Pencil size={10} />
</Button>
)}
<Button
variant="ghost"
size="icon"
className={cn(
"h-5 w-5 shrink-0 text-muted-foreground hover:text-destructive",
managing ? "opacity-100" : "opacity-0 group-hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-1",
)}
aria-label={t("sftp.bookmark.remove")}
onClick={(event) => {
event.stopPropagation();
onRequestDeleteBookmark(bm);
}}
>
<Trash2 size={10} />
</Button>
</div>
);
})}
</div>
) : (
<div className="p-3 text-xs text-muted-foreground text-center">
{t("sftp.bookmark.empty")}
</div>
)
);
);
};
const menuItemClass =
"flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors w-full text-left";
@@ -327,6 +526,10 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
isCurrentPathGlobalBookmarked,
onNavigateToBookmark,
onDeleteBookmark,
onReorderBookmark,
onRenameBookmark,
listDensity = "comfortable",
onSetListDensity,
showHiddenFiles,
onToggleShowHiddenFiles,
onGoToTerminalCwd,
@@ -339,6 +542,11 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
}) => {
const [displayPath, setDisplayPath] = useState(pane.connection?.currentPath ?? "");
const [filterDraft, setFilterDraft] = useState(pane.filter);
const [bookmarkPopoverOpen, setBookmarkPopoverOpen] = useState(false);
const [pendingBookmarkRemoval, setPendingBookmarkRemoval] = useState<SftpBookmark | null>(null);
const bookmarkButtonRef = useRef<HTMLButtonElement>(null);
const overflowButtonRef = useRef<HTMLButtonElement>(null);
const bookmarkRemovalFocusOriginRef = useRef<"inline" | "overflow" | null>(null);
const filterComposingRef = useRef(false);
const filterAtComposeStartRef = useRef(pane.filter);
// Directory at compositionstart. Navigation that leaves the filter already ""
@@ -459,6 +667,8 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
const isRemote = !pane.connection?.isLocal;
const viewModeToggleTarget = getSftpViewModeToggleTarget(viewMode);
const viewModeToggleLabel = t(viewModeToggleTarget.labelKey);
const listDensityToggleTarget = getNextSftpListDensity(listDensity);
const listDensityToggleLabel = t(getSftpListDensityToggleLabelKey(listDensity));
const shouldToggleBookmarkFromButton = shouldToggleSftpBookmarkFromButton({
bookmarkCount: bookmarks.length,
isCurrentPathBookmarked,
@@ -475,6 +685,7 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
"bookmark",
"copyPath",
"viewMode",
"listDensity",
"filter",
"newFolder",
"newFile",
@@ -496,6 +707,7 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
followTerminalCwd: t("sftp.followTerminalCwd"),
copyPath: t("sftp.copyCurrentPath"),
viewMode: viewModeToggleLabel,
listDensity: listDensityToggleLabel,
filter: t("sftp.filter"),
encoding: t("sftp.encoding.label"),
newFolder: t("sftp.newFolder"),
@@ -503,7 +715,7 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
showHidden: t("settings.sftp.showHiddenFiles"),
refresh: t("common.refresh"),
}),
[bookmarkButtonLabel, t, viewModeToggleLabel],
[bookmarkButtonLabel, listDensityToggleLabel, t, viewModeToggleLabel],
);
const itemIcons = useMemo(
@@ -514,6 +726,7 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
followTerminalCwd: <FolderSync size={14} />,
copyPath: <ClipboardCopy size={14} />,
viewMode: viewMode === "list" ? <List size={14} /> : <ListTree size={14} />,
listDensity: <Rows3 size={14} />,
filter: <Search size={14} />,
encoding: <Languages size={14} />,
newFolder: <FolderPlus size={14} />,
@@ -573,7 +786,29 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
[shown, collapsed, narrow],
);
const renderBookmarkPopoverBody = (onAfterLeafAction?: () => void) => (
const requestBookmarkRemoval = (
bookmark: SftpBookmark,
focusOrigin: "inline" | "overflow",
) => {
bookmarkRemovalFocusOriginRef.current = focusOrigin;
setPendingBookmarkRemoval(bookmark);
};
const restoreBookmarkRemovalFocus = () => {
const origin = bookmarkRemovalFocusOriginRef.current;
bookmarkRemovalFocusOriginRef.current = null;
window.setTimeout(() => {
const preferred = origin === "overflow" ? overflowButtonRef.current : bookmarkButtonRef.current;
const fallback = origin === "overflow" ? bookmarkButtonRef.current : overflowButtonRef.current;
const target = preferred?.isConnected ? preferred : fallback?.isConnected ? fallback : null;
target?.focus();
}, 0);
};
const renderBookmarkPopoverBody = (
onAfterLeafAction?: () => void,
focusOrigin: "inline" | "overflow" = "inline",
) => (
<SftpBookmarkPopoverBody
t={t}
bookmarks={bookmarks}
@@ -583,17 +818,22 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
onToggleBookmark={onToggleBookmark}
onAddGlobalBookmark={onAddGlobalBookmark}
onNavigateToBookmark={onNavigateToBookmark}
onDeleteBookmark={onDeleteBookmark}
onRequestDeleteBookmark={(bookmark) => (
requestBookmarkRemoval(bookmark, focusOrigin)
)}
onReorderBookmark={onReorderBookmark}
onRenameBookmark={onRenameBookmark}
onAfterLeafAction={onAfterLeafAction}
/>
);
const renderBookmarkButton = (size: "sm" | "md" = "sm") => (
<Popover>
<Popover open={bookmarkPopoverOpen} onOpenChange={setBookmarkPopoverOpen}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
ref={bookmarkButtonRef}
variant="ghost"
size="icon"
className={cn(
@@ -615,8 +855,8 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
</TooltipTrigger>
<TooltipContent>{bookmarkButtonLabel}</TooltipContent>
</Tooltip>
<PopoverContent className="w-64 p-0" align="start">
{renderBookmarkPopoverBody()}
<PopoverContent className="w-80 p-0" align="start">
{renderBookmarkPopoverBody(() => setBookmarkPopoverOpen(false))}
</PopoverContent>
</Popover>
);
@@ -630,7 +870,9 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
bookmarksCount={bookmarks.length}
shouldToggleBookmarkFromButton={shouldToggleBookmarkFromButton}
onToggleBookmark={onToggleBookmark}
renderBody={(closeOverflow) => renderBookmarkPopoverBody(closeOverflow)}
renderBody={(closeOverflow) => (
renderBookmarkPopoverBody(closeOverflow, "overflow")
)}
/>
);
@@ -773,6 +1015,23 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
<TooltipContent>{viewModeToggleLabel}</TooltipContent>
</Tooltip>
);
case "listDensity":
return (
<Tooltip key={id}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn("h-6 w-6", listDensity === "compact" && "bg-secondary text-foreground")}
aria-label={listDensityToggleLabel}
onClick={() => onSetListDensity?.(listDensityToggleTarget)}
>
<Rows3 size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{listDensityToggleLabel}</TooltipContent>
</Tooltip>
);
case "filter":
return (
<Tooltip key={id}>
@@ -916,6 +1175,18 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
{viewModeToggleLabel}
</button>
);
case "listDensity":
return (
<button
key={id}
type="button"
className={cn(menuItemClass, listDensity === "compact" && "text-primary")}
onClick={() => onSetListDensity?.(listDensityToggleTarget)}
>
<Rows3 size={14} className="shrink-0" />
{listDensityToggleLabel}
</button>
);
case "filter":
return (
<button key={id} type="button" className={menuItemClass} onClick={handleToggleFilter}>
@@ -1086,6 +1357,7 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
orientation="horizontal"
buttonClassName="h-6 w-6"
contentClassName="min-w-[140px]"
triggerRef={overflowButtonRef}
>
<div className="flex flex-col min-w-[140px]">{overflowNodes}</div>
</ToolbarOverflowMenu>
@@ -1215,6 +1487,28 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
</Tooltip>
</div>
)}
<ConfirmDialog
open={pendingBookmarkRemoval !== null}
title={t("sftp.bookmark.remove")}
message={pendingBookmarkRemoval
? t("sftp.bookmark.removeConfirm", { path: pendingBookmarkRemoval.path })
: undefined}
confirmLabel={t("sftp.bookmark.remove")}
destructive
onOpenChange={(open) => {
if (!open) {
setPendingBookmarkRemoval(null);
restoreBookmarkRemovalFocus();
}
}}
onConfirm={() => {
if (!pendingBookmarkRemoval) return;
const bookmark = pendingBookmarkRemoval;
setPendingBookmarkRemoval(null);
onDeleteBookmark(bookmark);
restoreBookmarkRemovalFocus();
}}
/>
</TooltipProvider>
);
});
@@ -1228,7 +1522,9 @@ function SftpBookmarkPopoverBody({
onToggleBookmark,
onAddGlobalBookmark,
onNavigateToBookmark,
onDeleteBookmark,
onRequestDeleteBookmark,
onReorderBookmark,
onRenameBookmark,
onAfterLeafAction,
}: {
t: (key: string, params?: Record<string, unknown>) => string;
@@ -1239,9 +1535,12 @@ function SftpBookmarkPopoverBody({
onToggleBookmark: () => void;
onAddGlobalBookmark: (path: string) => void;
onNavigateToBookmark: (path: string) => void;
onDeleteBookmark: (id: string) => void;
onRequestDeleteBookmark: (bookmark: SftpBookmark) => void;
onReorderBookmark?: (from: SftpBookmark, to: SftpBookmark) => void;
onRenameBookmark?: (bookmark: SftpBookmark, label: string) => void;
onAfterLeafAction?: () => void;
}) {
const [managing, setManaging] = useState(false);
const runThenClose = (action: () => void) => {
action();
onAfterLeafAction?.();
@@ -1249,15 +1548,34 @@ function SftpBookmarkPopoverBody({
return (
<>
<div className="px-3 py-2 border-b border-border/40">
<div className="px-3 py-2 border-b border-border/40 flex items-center justify-between gap-2">
<div className="text-xs font-medium">{t("sftp.bookmark.list")}</div>
{bookmarks.length > 0 && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-[11px]"
onClick={() => setManaging((value) => !value)}
>
{managing ? t("sftp.bookmark.done") : t("sftp.bookmark.manage")}
</Button>
)}
</div>
<div className="p-2 border-b border-border/40 flex gap-1">
<Button
variant={isCurrentPathBookmarked ? "secondary" : "ghost"}
size="sm"
className="flex-1 justify-start text-xs h-7"
onClick={() => runThenClose(onToggleBookmark)}
onClick={() => {
if (isCurrentPathBookmarked && currentPath) {
const bookmark = bookmarks.find((candidate) => (
candidate.path === currentPath && !candidate.global
)) ?? bookmarks.find((candidate) => candidate.path === currentPath);
if (bookmark) runThenClose(() => onRequestDeleteBookmark(bookmark));
return;
}
runThenClose(onToggleBookmark);
}}
>
<Bookmark
size={12}
@@ -1284,8 +1602,13 @@ function SftpBookmarkPopoverBody({
</div>
<SftpBookmarkList
bookmarks={bookmarks}
managing={managing}
onNavigateToBookmark={(path) => runThenClose(() => onNavigateToBookmark(path))}
onDeleteBookmark={(id) => runThenClose(() => onDeleteBookmark(id))}
onRequestDeleteBookmark={(bookmark) => (
runThenClose(() => onRequestDeleteBookmark(bookmark))
)}
onReorderBookmark={onReorderBookmark}
onRenameBookmark={onRenameBookmark}
t={t}
/>
</>
@@ -1341,7 +1664,7 @@ function SftpOverflowNestedBookmark({
</button>
</PopoverTrigger>
<PopoverContent
className="w-64 p-0"
className="w-80 p-0"
align="start"
side="left"
sideOffset={6}
@@ -0,0 +1,24 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import { getSftpTreeEntryOpenAction } from "./SftpPaneTreeView.tsx";
import type { SftpFileEntry } from "../../types";
const entry = (name: string, type: SftpFileEntry["type"]): SftpFileEntry => ({
name,
type,
size: 0,
lastModified: 0,
});
test("tree activation enters directories and keeps file opening separate", () => {
assert.equal(getSftpTreeEntryOpenAction(entry("..", "directory")), "up");
assert.equal(getSftpTreeEntryOpenAction(entry("docs", "directory")), "navigate");
assert.equal(getSftpTreeEntryOpenAction(entry("notes.txt", "file")), "open");
});
test("tree row double click routes through the open action", () => {
const source = fs.readFileSync(new URL("./SftpPaneTreeNode.tsx", import.meta.url), "utf8");
assert.match(source, /onDoubleClick=\{\(\) => onOpenEntry\(entry, entryPath\)\}/);
});
+1 -5
View File
@@ -67,11 +67,7 @@ export const TreeNode = React.memo<TreeNodeProps>(({
)}
style={{ gridTemplateColumns: columnTemplate, height: TREE_ROW_HEIGHT }}
onClick={e => onNodeClick(entry, entryPath, e)}
onDoubleClick={() => {
if (isParentEntry) { onOpenEntry(entry, entryPath); return; }
if (isDir) void onToggleExpand(entry, entryPath);
else onOpenEntry(entry, entryPath);
}}
onDoubleClick={() => onOpenEntry(entry, entryPath)}
onContextMenu={e => {
if (!isParentEntry) {
onContextMenu(entry, entryPath, e);
+15 -1
View File
@@ -24,6 +24,15 @@ interface ContextTarget {
entry: SftpFileEntry;
entryPath: string;
}
export const getSftpTreeEntryOpenAction = (
entry: SftpFileEntry,
): 'up' | 'navigate' | 'open' => {
if (entry.name === '..') return 'up';
if (isNavigableDirectory(entry)) return 'navigate';
return 'open';
};
export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
pane,
side,
@@ -400,10 +409,15 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
lastClickedPathRef.current = entryPath;
}, [focusTreeContainer, pane.id]);
const openTreeEntry = useCallback((entry: SftpFileEntry, entryPath: string) => {
if (entry.name === '..') {
const action = getSftpTreeEntryOpenAction(entry);
if (action === 'up') {
onNavigateUpRef.current();
return;
}
if (action === 'navigate') {
onNavigateToRef.current(entryPath);
return;
}
onOpenEntryRef.current(entry, entryPath);
}, []);
const stableOnRefresh = useCallback(() => onRefreshRef.current(), []);
+37 -6
View File
@@ -20,7 +20,7 @@ import {
} from "./SftpContext";
import type { SftpPane } from "../../application/state/sftp/types";
import { joinPath, getParentPath } from "../../application/state/sftp/utils";
import type { Host } from "../../domain/models";
import type { Host, SftpBookmark } from "../../domain/models";
import { useSftpPaneDialogs } from "./hooks/useSftpPaneDialogs";
import { useSftpPaneDragAndSelect } from "./hooks/useSftpPaneDragAndSelect";
import { useSftpPaneFiles } from "./hooks/useSftpPaneFiles";
@@ -33,6 +33,7 @@ import { useSftpBookmarks } from "./hooks/useSftpBookmarks";
import { useLocalSftpBookmarks } from "../../application/state/sftp/localSftpBookmarks";
import { useGlobalSftpBookmarks } from "./hooks/useGlobalSftpBookmarks";
import { useSftpHostViewMode } from "../../application/state/sftp/sftpHostViewModeStore";
import { useSftpListDensity } from "../../application/state/sftp/sftpListDensityStore";
import { sftpListOrderStore } from "./hooks/useSftpListOrderStore";
import { sftpTreeSelectionStore } from "../../application/state/sftp/sftpTreeSelectionStore";
import { sftpClipboardUploadStore } from "./clipboardUpload";
@@ -117,6 +118,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
const [showFilterBar, setShowFilterBar] = useState(false);
const initialViewMode = hostViewMode ?? sftpDefaultViewMode ?? 'list';
const [viewMode, setViewMode] = useState<'list' | 'tree'>(initialViewMode);
const { density: listDensity, setDensity: setListDensity } = useSftpListDensity();
const [treeReloadRequest, setTreeReloadRequest] = useState<TreeReloadRequest>({ token: 0, full: true });
// Lazy-mount: only render the tree component once tree mode has been activated
const [treeEverMounted, setTreeEverMounted] = useState(initialViewMode === 'tree');
@@ -195,7 +197,10 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
});
const hostBookmarks = pane.connection?.isLocal ? localBookmarks : remoteBookmarks;
const mergedBookmarks = useMemo(
() => [...globalBookmarks.bookmarks.map((b) => ({ ...b, global: true as const })), ...hostBookmarks.bookmarks],
() => [
...globalBookmarks.bookmarks.map((b) => ({ ...b, global: true as const })),
...hostBookmarks.bookmarks.map((b) => ({ ...b, global: false as const })),
],
[hostBookmarks.bookmarks, globalBookmarks.bookmarks],
);
const isCurrentPathBookmarked = hostBookmarks.isCurrentPathBookmarked || globalBookmarks.isCurrentPathBookmarked;
@@ -211,11 +216,31 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
}
}, [hostBookmarks, globalBookmarks, pane.connection?.currentPath]);
const deleteBookmark = useCallback(
(id: string) => {
if (id.startsWith("gbm-")) {
globalBookmarks.deleteBookmark(id);
(bookmark: SftpBookmark) => {
if (bookmark.global) {
globalBookmarks.deleteBookmark(bookmark.id);
} else {
hostBookmarks.deleteBookmark(id);
hostBookmarks.deleteBookmark(bookmark.id);
}
},
[hostBookmarks, globalBookmarks],
);
const reorderBookmark = useCallback(
(from: SftpBookmark, to: SftpBookmark) => {
if (from.global && to.global) {
globalBookmarks.reorderBookmark(from.id, to.id);
} else if (!from.global && !to.global) {
hostBookmarks.reorderBookmark(from.id, to.id);
}
},
[hostBookmarks, globalBookmarks],
);
const renameBookmark = useCallback(
(bookmark: SftpBookmark, label: string) => {
if (bookmark.global) {
globalBookmarks.renameBookmark(bookmark.id, label);
} else {
hostBookmarks.renameBookmark(bookmark.id, label);
}
},
[hostBookmarks, globalBookmarks],
@@ -376,6 +401,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
isActive,
enabled: viewMode === 'list',
sortedDisplayFiles,
layoutKey: listDensity,
});
const toFullPath = useCallback(
@@ -574,6 +600,8 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
isCurrentPathGlobalBookmarked={globalBookmarks.isCurrentPathBookmarked}
onNavigateToBookmark={callbacks.onNavigateTo}
onDeleteBookmark={deleteBookmark}
onReorderBookmark={reorderBookmark}
onRenameBookmark={renameBookmark}
showHiddenFiles={pane.showHiddenFiles}
onToggleShowHiddenFiles={onToggleShowHiddenFiles}
onGoToTerminalCwd={onGoToTerminalCwd}
@@ -582,6 +610,8 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
onToggleFollowTerminalCwd={onToggleFollowTerminalCwd}
viewMode={viewMode}
onSetViewMode={handleSetViewMode}
listDensity={listDensity}
onSetListDensity={setListDensity}
onListDrives={callbacks.onListDrives}
/>
@@ -671,6 +701,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
openDeleteConfirm={openDeleteConfirm}
rowHeight={rowHeight}
visibleRows={visibleRows}
listDensity={listDensity}
/>
</div>
@@ -4,7 +4,7 @@ import {
setGlobalSftpBookmarks,
subscribeGlobalSftpBookmarks,
} from "../../../application/state/sftp/globalSftpBookmarks";
import { createSftpBookmark } from "../../../application/state/sftp/bookmarkHelpers";
import { createSftpBookmark, moveSftpBookmark, renameSftpBookmark } from "../../../application/state/sftp/bookmarkHelpers";
interface UseGlobalSftpBookmarksParams {
currentPath: string | undefined;
@@ -34,10 +34,20 @@ export const useGlobalSftpBookmarks = ({
setGlobalSftpBookmarks((prev) => prev.filter((b) => b.id !== id));
}, []);
const reorderBookmark = useCallback((fromId: string, toId: string) => {
setGlobalSftpBookmarks((prev) => moveSftpBookmark(prev, fromId, toId));
}, []);
const renameBookmark = useCallback((id: string, label: string) => {
setGlobalSftpBookmarks((prev) => renameSftpBookmark(prev, id, label));
}, []);
return {
bookmarks,
isCurrentPathBookmarked,
addBookmark,
deleteBookmark,
reorderBookmark,
renameBookmark,
};
};
+19 -1
View File
@@ -1,6 +1,6 @@
import { useCallback, useMemo } from "react";
import type { Host, SftpBookmark } from "../../../domain/models";
import { createSftpBookmark } from "../../../application/state/sftp/bookmarkHelpers";
import { createSftpBookmark, moveSftpBookmark, renameSftpBookmark } from "../../../application/state/sftp/bookmarkHelpers";
interface UseSftpBookmarksParams {
host: Host | undefined;
@@ -13,6 +13,8 @@ interface UseSftpBookmarksResult {
isCurrentPathBookmarked: boolean;
toggleBookmark: () => void;
deleteBookmark: (id: string) => void;
reorderBookmark: (fromId: string, toId: string) => void;
renameBookmark: (id: string, label: string) => void;
}
export const useSftpBookmarks = ({
@@ -52,10 +54,26 @@ export const useSftpBookmarks = ({
[bookmarks, updateHostBookmarks],
);
const reorderBookmark = useCallback(
(fromId: string, toId: string) => {
updateHostBookmarks(moveSftpBookmark(bookmarks, fromId, toId));
},
[bookmarks, updateHostBookmarks],
);
const renameBookmark = useCallback(
(id: string, label: string) => {
updateHostBookmarks(renameSftpBookmark(bookmarks, id, label));
},
[bookmarks, updateHostBookmarks],
);
return {
bookmarks,
isCurrentPathBookmarked,
toggleBookmark,
deleteBookmark,
reorderBookmark,
renameBookmark,
};
};
@@ -5,6 +5,7 @@ interface UseSftpPaneVirtualListParams {
isActive: boolean;
enabled?: boolean;
sortedDisplayFiles: SftpFileEntry[];
layoutKey?: string;
}
interface UseSftpPaneVirtualListResult {
@@ -20,6 +21,7 @@ export const useSftpPaneVirtualList = ({
isActive,
enabled = true,
sortedDisplayFiles,
layoutKey,
}: UseSftpPaneVirtualListParams): UseSftpPaneVirtualListResult => {
const fileListRef = useRef<HTMLDivElement>(null);
const [rowHeight, setRowHeight] = useState(0);
@@ -39,7 +41,7 @@ export const useSftpPaneVirtualList = ({
resizeObserver.disconnect();
window.cancelAnimationFrame(raf);
};
}, [enabled, isActive, sortedDisplayFiles.length]);
}, [enabled, isActive, layoutKey, sortedDisplayFiles.length]);
useLayoutEffect(() => {
const container = fileListRef.current;
@@ -55,7 +57,7 @@ export const useSftpPaneVirtualList = ({
}
});
return () => window.cancelAnimationFrame(raf);
}, [enabled, isActive, rowHeight, sortedDisplayFiles.length]);
}, [enabled, isActive, layoutKey, rowHeight, sortedDisplayFiles.length]);
useEffect(() => {
return () => {
+1 -1
View File
@@ -832,7 +832,7 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
)}
{showHostInfoBar && <div className="flex-1 min-w-0" />}
<div className="flex items-center gap-0.5 flex-shrink-0">
{inWorkspace && onToggleBroadcast && (
{onToggleBroadcast && (
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -50,7 +50,7 @@ test("app resume handlers flush backlog and recover the terminal renderer before
assertRecoverTerminalOnAppResumeOrder(source);
assert.match(
resumeEffectSource,
/const handleVisibilityChange = \(\) => \{\s*if \(document\.visibilityState !== 'visible'\) return;\s*recoverTerminalOnAppResume\(\);\s*\};/,
/const handleVisibilityChange = \(\) => \{\s*if \(document\.visibilityState !== 'visible'\) \{\s*syncOutputPressureVisibility\(\);\s*return;\s*\}\s*recoverTerminalOnAppResume\(\);\s*\};/,
);
assert.match(
resumeEffectSource,
+128 -4
View File
@@ -6,7 +6,11 @@ import type { SerializeAddon as SerializeAddonType } from "@xterm/addon-serializ
import type { Terminal as XTermType } from "@xterm/xterm";
import { writeTerminalDataWithLineTimestamps } from "./runtime/terminalLineTimestamps.ts";
import { noteTerminalOutputPressureData } from "./runtime/terminalOutputPressure.ts";
import {
noteTerminalOutputPressureData,
setTerminalOutputPressureVisibility,
} from "./runtime/terminalOutputPressure.ts";
import { writeTerminalPayloadChunked } from "./terminalReplay.ts";
import { KeywordHighlighter } from "./keywordHighlight.ts";
const require = createRequire(import.meta.url);
@@ -243,6 +247,27 @@ test("bulk output skipped on the hot path is highlighted after output becomes qu
term.dispose();
});
test("streaming bulk output colors the viewport in the same write", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 50 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
// More than BULK_WRITE_LINE_BREAKS newlines per chunk, but far below the
// large-output rate, like a coalesced `tail -f` tick (#3271).
const chunk = Array.from({ length: 12 }, (_, index) => `line-${index} ERROR`).join("\r\n");
await write(term, chunk);
assert.equal(highlighter.rebuildCount, 0);
const viewportY = term.buffer.active.viewportY;
assert.equal(cellRgb(term, viewportY + 4, "ERROR"), RED, "visible rows must color in-frame");
assert.notEqual(cellRgb(term, 0, "ERROR"), RED, "history above the viewport stays with catch-up");
await new Promise((resolve) => setTimeout(resolve, 650));
await highlighter.whenSettled();
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), []);
assert.equal(highlighter.rebuildCount, 1);
highlighter.dispose();
term.dispose();
});
test("ordinary Enter does not rebuild a saturated scrollback", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 40, rows: 3, scrollback: 10 });
const highlighter = new KeywordHighlighter(term);
@@ -1039,7 +1064,9 @@ test("quiet catch-up refreshes a tall viewport once", async () => {
await write(term, flood);
await new Promise((resolve) => setTimeout(resolve, 650));
await highlighter.whenSettled();
assert.equal(refreshCount, 1);
// One refresh paints the viewport in-frame inside the bulk write callback;
// the second is the single catch-up viewport pass after output quiets.
assert.equal(refreshCount, 2);
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), []);
highlighter.dispose();
term.dispose();
@@ -1064,7 +1091,7 @@ test("identical flood lines are all colored after quiet catch-up", async () => {
term.dispose();
});
test("saturated flood does not rematch the pinned viewport on each write", async () => {
test("saturated streaming keeps the pinned viewport colored in-frame", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 6, scrollback: 8 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
@@ -1076,8 +1103,56 @@ test("saturated flood does not rematch the pinned viewport on each write", async
}
assert.equal(highlighter.rebuildCount, 0);
const viewportY = term.buffer.active.viewportY;
// Bulk bypass still colors the visible rows inside the write callback, so
// streaming logs never show an uncolored frame (#3271).
assert.equal(cellRgb(term, viewportY, "ERROR"), RED);
assert.equal(cellRgb(term, viewportY + 2, "ERROR"), RED);
highlighter.dispose();
term.dispose();
});
test("hidden panes keep bulk streaming on the deferred catch-up path", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 6, scrollback: 40 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
const line = "2026-08-13 INFO worker=1 WARN ERROR failed from 10.2.0.1";
const chunk = Array.from({ length: 16 }, () => line).join("\r\n");
// Below the 16KB/100ms flood gate, so without the background check the bulk
// bypass would recolor and refresh the viewport for every hidden batch.
setTerminalOutputPressureVisibility(term, false);
for (let index = 0; index < 4; index += 1) {
noteTerminalOutputPressureData(term, chunk);
await write(term, chunk);
}
const viewportY = term.buffer.active.viewportY;
assert.notEqual(cellRgb(term, viewportY, "ERROR"), RED);
assert.notEqual(cellRgb(term, viewportY + 2, "ERROR"), RED);
// Reveal the pane and let catch-up paint the deferred range.
setTerminalOutputPressureVisibility(term, true);
await new Promise((resolve) => setTimeout(resolve, 650));
await highlighter.whenSettled();
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), []);
highlighter.dispose();
term.dispose();
});
test("high-rate flood keeps the viewport on the deferred catch-up path", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 6, scrollback: 8 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
const line = "2026-08-13 INFO worker=1 WARN ERROR failed from 10.2.0.1";
// ~20KB inside the 100ms rate window trips the large-output degradation, so
// the in-frame viewport repaint stays off and only catch-up colors.
const chunk = Array.from({ length: 300 }, () => line).join("\r\n");
for (let index = 0; index < 3; index += 1) {
noteTerminalOutputPressureData(term, chunk);
await write(term, chunk);
}
assert.equal(highlighter.rebuildCount, 0);
const viewportY = term.buffer.active.viewportY;
assert.notEqual(cellRgb(term, viewportY, "ERROR"), RED);
await new Promise((resolve) => setTimeout(resolve, 900));
await highlighter.whenSettled();
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), []);
highlighter.dispose();
term.dispose();
});
@@ -1098,3 +1173,52 @@ test("recycled identical flood rows are recolored on catch-up", async () => {
highlighter.dispose();
term.dispose();
});
test("hibernate replay defers bulk viewport coloring but subsequent live output colors immediately", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 6, scrollback: 100 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
const chunk = Array.from({ length: 20 }, () => "history ERROR").join("\r\n");
try {
await writeTerminalPayloadChunked(term, chunk);
assert.notEqual(cellRgb(term, term.buffer.active.viewportY, "ERROR"), RED);
await highlighter.whenSettled();
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), []);
await write(term, "\r\n" + chunk);
assert.equal(cellRgb(term, term.buffer.active.viewportY, "ERROR"), RED);
} finally {
highlighter.dispose();
term.dispose();
}
});
test("bulk streaming does not recolor a scrolled-back history viewport", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 6, scrollback: 100 });
let bypass = false;
const highlighter = new KeywordHighlighter(term, { shouldBypassHighlight: () => bypass });
highlighter.setRules(rule(), true);
const chunk = Array.from({ length: 20 }, () => "log ERROR").join("\r\n") + "\r\n";
try {
await write(term, chunk);
await highlighter.whenSettled();
term.scrollToTop();
let refreshes = 0;
const refresh = term.refresh.bind(term);
term.refresh = (start, end) => { refreshes += 1; refresh(start, end); };
bypass = true;
await write(term, chunk);
const scrollRefreshes = refreshes;
refreshes = 0;
bypass = false;
await write(term, chunk);
assert.equal(refreshes, scrollRefreshes, "bulk callback must not add another history repaint");
term.scrollToBottom();
await write(term, chunk);
assert.equal(cellRgb(term, term.buffer.active.viewportY, "ERROR"), RED);
} finally {
highlighter.dispose();
term.dispose();
}
});
+53 -1
View File
@@ -5,9 +5,13 @@ import { isSafePluginDecorationPattern } from "../../domain/pluginTerminalProvid
import { checkRegexSafetyPattern } from "../../lib/regexSafety";
import type { KeywordHighlightRule } from "../../types";
import { XTERM_PERFORMANCE_CONFIG } from "../../infrastructure/config/xtermPerformance";
import { isTerminalReplayWrite } from "./terminalReplay";
import { readPluginTerminalBufferText } from "./pluginTerminalBufferText";
import { compileRe2RangeMatcher, forEachNonEmptyRegexMatch } from "./keywordHighlightRegex";
import { shouldDegradeTerminalKeywordHighlight } from "./runtime/terminalOutputPressure";
import {
isTerminalOutputInBackground,
shouldDegradeTerminalKeywordHighlight,
} from "./runtime/terminalOutputPressure";
type RuntimeKeywordHighlightRule = KeywordHighlightRule & { readonly providerId?: string };
@@ -396,12 +400,43 @@ export class KeywordHighlighter implements IDisposable {
? this.resolveAbsoluteRepaintRange(absoluteControls, originModeNeedsSafety)
: null;
const bypass = !startedOnNormal || this.shouldBypassWrite(data);
// Freeze in-frame eligibility at entry: the pressure window is a rolling
// sample, and by the time this write's callback runs (xterm parses in a
// macrotask while backlogged) later chunks may have aged it below the
// threshold. Re-checking inside the callback would let a write that took
// the flood path repaint the viewport anyway, defeating the deferred path.
// Pane visibility is the exception: it can flip while this write is still
// in flight (pane/page shown between entry and callback), so it is
// re-evaluated live in the callback below instead of frozen here.
const colorDuringBypass = bypass && this.mayColorViewportDuringBypass(data);
if (bypass) {
if (startedOnNormal && (this.enabled || this.compiledPatterns.length > 0 || this.hasPendingCatchUp())) {
this.markCatchUp(absoluteRepaintRange === null ? startY : Math.min(startY, startBaseY));
this.scheduleCatchUp();
}
return this.originalWrite(data, () => {
// Bulk writes skip per-write coloring so xterm can keep painting the
// flood, but the viewport itself must not sit uncolored until the
// catch-up timer fires (#3271). Recolor the visible rows here, inside
// the write callback: xterm parses in a macrotask and renders in the
// next rAF, so these colors land in the same frame as the text. The
// cost is O(viewport rows × rules), independent of chunk size, and
// true rate/long-line floods keep the fully deferred path. Rows that
// scrolled past the viewport stay with the deferred catch-up.
if (
this.term.buffer.active.type === "normal"
&& this.compiledPatterns.length > 0
&& colorDuringBypass
// Visibility may have changed since this write started (hidden pane
// shown before the async xterm parse finished): the entry snapshot
// only froze the flood/rate decision, so check the live state here.
&& !isTerminalOutputInBackground(this.term)
// Appended logs should not add another repaint while reading history.
// Scroll/reveal handlers retain their existing recoloring behavior.
&& this.term.buffer.active.viewportY === this.term.buffer.active.baseY
) {
this.recolorVisible();
}
if (this.term.buffer.active.type === "normal" && !startedOnNormal) {
if (this.enabled || this.compiledPatterns.length > 0) {
this.markCatchUp(0);
@@ -692,6 +727,23 @@ export class KeywordHighlighter implements IDisposable {
return this.countNewlines(data) >= BULK_WRITE_LINE_BREAKS;
}
/**
* Streaming logs usually trip only the bulk line-break heuristic (coalesced
* PTY ticks), which is cheap to color in-frame: rematching the visible
* viewport costs O(viewport rows × rules) per write, independent of chunk
* size. Rate/long-line floods and explicit full-bypass requests keep the
* deferred catch-up so xterm keeps painting the dump smoothly.
*
* This is the frozen flood/rate decision only: pane visibility is evaluated
* live in the write callback (`isTerminalOutputInBackground`) because it can
* flip between write entry and the asynchronous callback.
*/
private mayColorViewportDuringBypass(data: string | Uint8Array): boolean {
if (this.options.shouldBypassHighlight?.()) return false;
if (typeof data !== "string" || isTerminalReplayWrite(this.term)) return false;
return !shouldDegradeTerminalKeywordHighlight(this.term, data);
}
private countNewlines(data: string): number {
let count = 0;
for (let index = 0; index < data.length; index += 1) {
@@ -3708,7 +3708,7 @@ test("ssh session restores cwd before startup command after attaching", async ()
assert.deepEqual(progressLogs, ["Restoring working directory: /srv/app dir"]);
});
test("local session resets terminal timestamp state when reusing a terminal", async () => {
test("local session keeps timestamp anchors for preserved scrollback when reusing a terminal", async () => {
const writes: string[] = [];
const markerLines: number[] = [];
const disposedMarkerLines: number[] = [];
@@ -3814,8 +3814,10 @@ test("local session resets terminal timestamp state when reusing a terminal", as
assert.equal(writes.length, 2);
assert.equal(writes[0], "unfinished");
assert.equal(writes[1], "fresh");
// Reconnect preserves the buffer, so the pre-restart stamp stays anchored
// (not disposed) and the fresh output records its own stamp.
assert.deepEqual(markerLines, [0, 0]);
assert.deepEqual(disposedMarkerLines, [0]);
assert.deepEqual(disposedMarkerLines, []);
});
test("session data waits for prior terminal writes before evaluating prompt line breaks", async () => {
@@ -1,3 +1,4 @@
import { stringCellWidth } from "../autocomplete/terminalStringCellWidth";
import { FitAddon } from "@xterm/addon-fit";
import { ImageAddon } from "@xterm/addon-image";
import { SearchAddon } from "@xterm/addon-search";
@@ -1065,7 +1066,43 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
overlay.style.fontSize = `${currentTerminalFontSize()}px`;
overlay.style.fontFamily = String(term.options.fontFamily ?? fontFamily);
overlay.style.lineHeight = String(term.options.lineHeight ?? lineHeight);
overlay.textContent = previewRows.map((row) => row.text).join("\n");
// Native font advances differ from xterm's device-pixel-rounded cells.
// Fit each row to its terminal cell width, including double-width glyphs,
// rather than applying a single-cell correction once per Unicode glyph.
overlay.style.fontWeight = String(term.options.fontWeight ?? "normal");
overlay.style.fontKerning = "none";
overlay.style.fontVariantLigatures = "none";
const screenRect = term.element?.querySelector(".xterm-screen")?.getBoundingClientRect();
if (screenRect && screenRect.width > 0 && screenRect.height > 0
&& term.cols > 0 && term.rows > 0) {
overlay.style.lineHeight = `${screenRect.height / term.rows}px`;
const rowElements = previewRows.map((row) => {
const span = document.createElement("span");
span.style.display = "inline-block";
span.style.verticalAlign = "top";
span.style.transformOrigin = "left top";
span.textContent = row.text;
return span;
});
const fragment = document.createDocumentFragment();
rowElements.forEach((span, index) => {
if (index > 0) fragment.appendChild(document.createTextNode("\n"));
fragment.appendChild(span);
});
overlay.replaceChildren(fragment);
// Batch all measurements before writing transforms to avoid one forced
// layout per row. Text nodes and explicit newlines stay unchanged for copy.
const nativeWidths = rowElements.map((span) => span.getBoundingClientRect().width);
rowElements.forEach((span, index) => {
const nativeWidth = nativeWidths[index];
const cells = stringCellWidth(previewRows[index].text, term);
if (nativeWidth > 0 && cells > 0) {
span.style.transform = `scaleX(${cells * screenRect.width / term.cols / nativeWidth})`;
}
});
} else {
overlay.textContent = previewRows.map((row) => row.text).join("\n");
}
overlay.setAttribute(HISTORY_PREVIEW_WRAP_ATTR, encodeHistoryPreviewWrapFlags(previewRows));
return true;
};
@@ -17,10 +17,30 @@ import {
isHistoryPreviewDismissClick,
joinHistoryPreviewSelectionText,
nextHistoryPreviewTop,
selectHistoryPreviewAll,
shouldHideHistoryPreviewOnMouseDown,
shouldKeepHistoryPreviewOnKey,
} from "./terminalHistoryScrollOverride.ts";
test("select all includes every preview row and preserves soft-wrap copying", async () => {
const { JSDOM } = await import("jsdom");
const dom = new JSDOM("<pre><span>中文abc</span>\n<span>def</span>\n<span>last</span></pre>");
try {
const overlay = dom.window.document.querySelector("pre")!;
overlay.setAttribute(HISTORY_PREVIEW_WRAP_ATTR, "010");
assert.equal(selectHistoryPreviewAll(overlay), true);
assert.equal(dom.window.getSelection()!.toString(), "中文abc\ndef\nlast");
assert.equal(getHistoryPreviewSelectionText(overlay, dom.window.getSelection()), "中文abcdef\nlast");
overlay.textContent = "plain\ntext";
assert.equal(selectHistoryPreviewAll(overlay), true);
assert.equal(dom.window.getSelection()!.toString(), "plain\ntext");
overlay.textContent = "";
assert.equal(selectHistoryPreviewAll(overlay), false);
} finally {
dom.window.close();
}
});
const wheel = (
over: Partial<Parameters<typeof forcedHistoryScrollLinesForWheel>[0]> = {},
) => ({
@@ -352,3 +372,23 @@ test("history preview right-click is recognized as an app-menu target", () => {
false,
);
});
test("nested preview rows retain soft-wrap copy and partial selections", async () => {
const { JSDOM } = await import("jsdom");
const dom = new JSDOM("<pre><span>中文abc</span>\n<span>def</span></pre>");
const overlay = dom.window.document.querySelector("pre")!;
overlay.setAttribute(HISTORY_PREVIEW_WRAP_ATTR, "01");
const first = overlay.firstChild!.firstChild!;
const last = overlay.lastChild!.firstChild!;
const selection = {
rangeCount: 1, anchorNode: first, focusNode: last,
anchorOffset: 1, focusOffset: 2, toString: () => "文abc\ndef",
};
assert.equal(getHistoryPreviewSelectionText(overlay, selection), "文abcde");
assert.equal(getHistoryPreviewSelectionText(overlay, {
...selection, anchorNode: overlay, focusNode: overlay,
anchorOffset: 0, focusOffset: overlay.childNodes.length,
}), "中文abcdef");
dom.window.close();
});
@@ -147,6 +147,7 @@ export type HistoryPreviewSelectionLike = {
};
export type HistoryPreviewNodeLike = {
ownerDocument?: Pick<Document, "createRange">;
contains(node: { nodeType?: number } | null): boolean;
firstChild?: { nodeType?: number } | null;
getAttribute?(name: string): string | null;
@@ -271,6 +272,16 @@ const resolveOverlayTextOffset = (
node: { nodeType?: number } | null,
offset: number,
): number | null => {
if (overlay.ownerDocument && node) {
try {
const prefix = overlay.ownerDocument.createRange();
prefix.selectNodeContents(overlay as unknown as Node);
prefix.setEnd(node as Node, offset);
return prefix.toString().length;
} catch {
return null;
}
}
if (node === textNode) return offset;
if (node === overlay) return offset <= 0 ? 0 : overlay.textContent?.length ?? 0;
return null;
@@ -347,9 +358,7 @@ export const selectHistoryPreviewAll = (overlay: HTMLElement | null | undefined)
const selection = overlay.ownerDocument.getSelection();
if (!selection) return false;
const range = overlay.ownerDocument.createRange();
const textNode = overlay.firstChild;
if (textNode) range.selectNodeContents(textNode);
else range.selectNodeContents(overlay);
range.selectNodeContents(overlay);
selection.removeAllRanges();
selection.addRange(range);
return !selection.isCollapsed;
@@ -1501,6 +1501,23 @@ export const resetTerminalLineTimestamps = (term: XTerm) => {
resetTimestampStore(getTimestampStore(term));
};
/**
* Reconnect-style reattach on a reused terminal: the xterm buffer (including
* scrollback preserved by the reconnect flow) survives the previous session,
* so keep ledger stamps anchored to that history and only reset per-session
* recording state (segmenter line tracking, same-second gate, pending prefix).
* A full reset here would leave preserved scrollback rows without timestamps.
*/
export const resetTerminalLineTimestampRecordingState = (term: XTerm): void => {
const store = getTimestampStore(term);
store.segmenter.reset();
store.timestampOnlyPrefix = "";
store.lastStampSecondKey = null;
store.recordsSincePrune = 0;
store.disposedPendingCompact = 0;
notifyTimestampStore(store);
};
export const onTerminalLineTimestampsChange = (
term: XTerm,
listener: () => void,
@@ -246,6 +246,11 @@ export const setTerminalOutputPressureVisibility = (
getOrCreateState(term).background = !visible;
};
/** True while the pane is hidden (recorded via {@link setTerminalOutputPressureVisibility}). */
export const isTerminalOutputInBackground = (term: XTerm): boolean => (
pressureStates.get(term)?.background ?? false
);
export const setTerminalOutputPressureLargeOutput = (
term: XTerm,
largeOutput: boolean,
@@ -20,7 +20,10 @@ import {
writeSessionData,
} from "./terminalSessionAttachment.ts";
import { getVisibleTerminalLineTimestampRows } from "./terminalLineTimestamps.ts";
import { noteTerminalOutputPressureData } from "./terminalOutputPressure.ts";
import {
getTerminalOutputPressure,
noteTerminalOutputPressureData,
} from "./terminalOutputPressure.ts";
import {
clearTerminalSessionFlowAck,
@@ -1539,6 +1542,50 @@ test("writeSessionData keeps the hidden flush gate after coalescer reset and flu
clearTerminalSessionFlowAck("session-1");
});
test("writeSessionData refreshes pressure visibility when hidden output flushes after reveal", () => {
clearTerminalSessionFlowAck("session-1");
const payload = "x".repeat(XTERM_WRITE_CALLBACK_FAST_PATH_MAX_BYTES + 1);
const writes: string[] = [];
const term = {
buffer: { active: { type: "normal" } },
write(data: string, callback?: () => void) {
writes.push(data);
callback?.();
},
scrollToBottom() {},
} as unknown as XTerm;
const ctx = {
...createContext(false),
isVisibleRef: { current: false },
sessionRef: { current: "session-1" },
terminalBackend: {
ackSessionFlow: () => {},
},
};
getFlowController(ctx as never, term);
resetTerminalWriteCoalescer(term);
withAnimationFrameQueue(() => {
withDocumentVisibility("visible", () => {
writeSessionData(ctx as never, term, payload);
});
// The ingress snapshot records the pane as hidden.
assert.equal(getTerminalOutputPressure(term).background, true);
assert.deepEqual(writes, []);
ctx.isVisibleRef.current = true;
withDocumentVisibility("visible", () => {
flushPendingTerminalWritesOnResume(term);
});
// The batch is actually parsed after reveal, so the flush must clear the
// stale hidden flag: bulk write callbacks (e.g. keyword highlight) gate
// their in-frame viewport recolor on this state (#3272).
assert.deepEqual(writes, [payload]);
assert.equal(getTerminalOutputPressure(term).background, false);
});
clearTerminalSessionFlowAck("session-1");
});
test("hidden tab output marks pending scroll without scrolling immediately", async () => {
const writes: string[] = [];
let scrollCalls = 0;
@@ -2051,6 +2098,49 @@ test("attachSessionToTerminal resets timestamp state for a reused terminal", ()
assert.equal(writes[1], "fresh");
});
test("attachSessionToTerminal keeps timestamps for scrollback preserved by reconnect", () => {
const { term } = createFakeTerm();
const ctx = {
...createContext(false, { showLineTimestamps: true }),
sessionId: "session-1",
sessionRef: { current: null },
hasConnectedRef: { current: true },
hasRunStartupCommandRef: { current: false },
disposeDataRef: { current: null },
disposeExitRef: { current: null },
fitAddonRef: { current: null },
serializeAddonRef: { current: null },
pendingAuthRef: { current: null },
terminalBackend: {
onSessionData: () => () => {},
onSessionExit: () => () => {},
},
updateStatus: () => {},
setError: () => {},
onSessionExit: () => {},
};
const originalDateNow = Date.now;
try {
Date.now = () => new Date(2026, 0, 1, 12, 0, 59, 800).getTime();
writeSessionData(ctx as never, term, "before reconnect\r\n");
// Reconnect reuses the same xterm (scrollback preserved by the caller).
attachSessionToTerminal(ctx as never, term, "session-2");
Date.now = () => new Date(2026, 0, 1, 12, 1, 30, 0).getTime();
writeSessionData(ctx as never, term, "after reconnect\r\n");
assert.deepEqual(getVisibleTerminalLineTimestampRows(term), [
{ row: 0, label: "12:00:59" },
{ row: 1, label: "12:01:30" },
]);
} finally {
Date.now = originalDateNow;
resetTerminalWriteCoalescer(term);
}
});
test("attachSessionToTerminal clears the backend id before reporting exit", () => {
const { term } = createFakeTerm();
let onExit: ((evt: { reason?: string }) => void) | null = null;
@@ -23,7 +23,7 @@ import type {
} from "./createTerminalSessionStarters.types";
import { clearConnectionToken } from "./terminalDistroDetection";
import {
resetTerminalLineTimestamps,
resetTerminalLineTimestampRecordingState,
type TerminalLineTimestampPerfStep,
writeTerminalDataWithLineTimestamps,
} from "./terminalLineTimestamps";
@@ -89,6 +89,7 @@ import {
} from "./terminalOutputPipeline";
import {
hasPendingTerminalWrites,
isTerminalPageHidden,
maybeFlushTerminalWriteCoalescerWhenUnfocused,
scheduleTerminalRepaintWhenUnfocused,
shouldFlushTerminalWritesForBackgroundOutput,
@@ -114,6 +115,16 @@ const isTerminalPaneVisible = (ctx: TerminalSessionStartersContext): boolean =>
(ctx.isPaneVisibleRef?.current ?? ctx.isVisibleRef?.current) !== false
);
/**
* Visibility recorded for the output-pressure gates. A pane whose ref says
* "visible" still cannot paint while the document is page-hidden, so record it
* as background there too; otherwise bulk batches take the background flush
* path but keyword-highlight still repainted the viewport per batch.
*/
const isTerminalOutputVisibleForPressure = (ctx: TerminalSessionStartersContext): boolean => (
isTerminalPaneVisible(ctx) && !isTerminalPageHidden()
);
const handleTerminalOutputAutoScroll = (
ctx: TerminalSessionStartersContext,
term: XTerm,
@@ -409,7 +420,7 @@ export const getFlowController = (
return controller;
};
export const resetTerminalLineTimestampState = resetTerminalLineTimestamps;
export const resetTerminalLineTimestampState = resetTerminalLineTimestampRecordingState;
export const acknowledgeDroppedTerminalDisplayBytes = (
ctx: TerminalSessionStartersContext,
@@ -471,7 +482,7 @@ export const writeSessionData = (
visible: isPaneVisible,
});
flow.received(ingressBytes);
setTerminalOutputPressureVisibility(term, isPaneVisible);
setTerminalOutputPressureVisibility(term, isTerminalOutputVisibleForPressure(ctx));
noteTerminalOutputPressureData(term, data);
const settings = ctx.terminalSettingsRef?.current ?? ctx.terminalSettings;
const preservePromptSourceChunks = Boolean(
@@ -485,6 +496,12 @@ export const writeSessionData = (
batchIngress: number,
writeOptions?: CoalescedTerminalWriteOptions,
): void => {
// Output queued while hidden can be flushed after the pane is revealed.
// Refresh the pressure visibility at flush time so the write callback
// sees the live pane state instead of the ingress snapshot; otherwise a
// bulk batch written after reveal stays on the fully deferred path and
// the viewport sits uncolored until the quiet catch-up (#3272).
setTerminalOutputPressureVisibility(term, isTerminalOutputVisibleForPressure(ctx));
writeSessionDataImmediate(ctx, term, batch, batchIngress, {
...writeOptions,
deferStart: writeOptions?.deferStart ?? !isPaneCurrentlyVisible(),
@@ -856,7 +873,10 @@ export const attachSessionToTerminal = (
teardownTerminalOutputPipeline(ctx, term, id, flow);
flushTerminalWriteCoalescer(term);
resetTerminalSyncBlockFilter(term);
resetTerminalLineTimestamps(term);
// Reattach keeps the xterm buffer (reconnect preserves scrollback), so only
// reset per-session recording state — a full reset would strip timestamps
// from preserved history.
resetTerminalLineTimestampRecordingState(term);
resetTerminalOutputPressure(term);
ctx.onSessionAttached?.(id);
const assistMode =
@@ -24,7 +24,7 @@ test("renderer activity follows the hibernate setting instead of active-tab visi
);
});
test("inactive terminal surfaces remain painted and non-interactive without hibernate", () => {
test("inactive terminal surfaces remain mounted and non-interactive without hibernate", () => {
assert.match(supportSource, /resolveTerminalHibernateEnabledForProtocol\(terminalSettings, host\.protocol\)/);
assert.match(
supportSource,
+11 -1
View File
@@ -12,10 +12,20 @@ const scheduleFrame = (): Promise<void> => new Promise((resolve) => {
setTimeout(resolve, 0);
});
// The highlighter snapshots this flag synchronously when term.write enters.
// Replay has no PTY pressure samples, so it must not look like live log output.
const replayWrites = new WeakSet<XTerm>();
export const isTerminalReplayWrite = (term: XTerm): boolean => replayWrites.has(term);
const writeChunk = (term: XTerm, data: string): Promise<void> => {
if (!data) return Promise.resolve();
return new Promise((resolve) => {
term.write(data, () => resolve());
replayWrites.add(term);
try {
term.write(data, () => resolve());
} finally {
replayWrites.delete(term);
}
});
};
@@ -25,7 +25,7 @@ test('tab-switch suppression does not consume the visible recovery pass', () =>
assert.match(source, /const becameVisible = isVisible && !wasVisibleRef\.current/);
assert.match(
source,
/if \(!isVisible\) \{\s*wasVisibleRef\.current = false;\s*return;\s*\}[\s\S]*if \(splitResizeActive\) return;[\s\S]*wasVisibleRef\.current = true;[\s\S]*recoverTerminalAfterBecomeVisible\(\)/,
/if \(!isVisible\) \{\s*wasVisibleRef\.current = false;\s*syncOutputPressureVisibility\(\);\s*return;\s*\}[\s\S]*if \(splitResizeActive\) return;[\s\S]*wasVisibleRef\.current = true;[\s\S]*recoverTerminalAfterBecomeVisible\(\)/,
);
assert.doesNotMatch(source, /wasVisibleRef\.current = isVisible;\s*if \(!isVisible \|\| isResizing\) return/);
});
+25 -1
View File
@@ -41,8 +41,10 @@ import {
flushPendingTerminalWritesBeforeHibernate,
flushPendingTerminalWritesOnResume,
forceTerminalRepaintBypassingAnimationFrame,
isTerminalPageHidden,
repaintTerminalAfterReveal,
} from './runtime/terminalUnfocusedRepaint';
import { setTerminalOutputPressureVisibility } from './runtime/terminalOutputPressure';
import {
forceXTermFontRemeasure,
type XTermFontRemeasureTarget,
@@ -1274,10 +1276,27 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
pendingOutputScrollRef.current = false;
};
// The output-pressure visibility flag is read live by the keyword
// highlighter's write callback, but the write path only refreshes it at
// ingress and coalescer flush. A batch dispatched while hidden can still be
// parsing when the pane/page is revealed, and a stale `background: true`
// would then skip the in-frame viewport recolor (#3272). Re-sync the flag
// on every visibility transition so in-flight writes always see the
// current pane/document state.
const syncOutputPressureVisibility = () => {
const term = termRef.current;
if (!term) return;
setTerminalOutputPressureVisibility(
term,
isVisibleRef.current && !isTerminalPageHidden(),
);
};
const flushTerminalWritesAfterBecomeVisible = () => {
lastCommittedVisibleLayoutKeyRef.current = null;
const term = termRef.current;
if (term) {
syncOutputPressureVisibility();
cancelScheduledUnfocusedRepaint(term);
flushPendingTerminalWritesOnResume(term);
repaintTerminalAfterReveal(term, () => isVisibleRef.current);
@@ -1327,6 +1346,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
if (!isVisible) {
wasVisibleRef.current = false;
syncOutputPressureVisibility();
return;
}
@@ -1844,6 +1864,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
const recoverTerminalOnAppResume = () => {
const term = termRef.current;
if (term) {
syncOutputPressureVisibility();
cancelScheduledUnfocusedRepaint(term);
flushPendingTerminalWritesOnResume(term);
forceTerminalRepaintBypassingAnimationFrame(term);
@@ -1854,7 +1875,10 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
};
const handleVisibilityChange = () => {
if (document.visibilityState !== 'visible') return;
if (document.visibilityState !== 'visible') {
syncOutputPressureVisibility();
return;
}
recoverTerminalOnAppResume();
};
@@ -710,7 +710,10 @@ export interface TerminalLayerProps {
onCreateLocalTerminal?: () => void;
// Broadcast mode
isBroadcastEnabled?: (workspaceId: string) => boolean;
isGlobalBroadcastEnabled?: boolean;
canUseGlobalBroadcast?: boolean;
onToggleBroadcast?: (workspaceId: string) => void;
onToggleGlobalBroadcast?: () => void;
// SFTP side panel
updateHosts: (hosts: Host[]) => void;
updateSnippets?: (snippets: Snippet[]) => void;
@@ -810,6 +813,9 @@ interface TerminalPaneProps {
onSetWorkspaceFocusedSession?: (workspaceId: string, sessionId: string) => void;
onSplitSession?: (sessionId: string, direction: SplitDirection) => void;
isBroadcastEnabled?: (workspaceId: string) => boolean;
isGlobalBroadcastEnabled?: boolean;
canUseGlobalBroadcast?: boolean;
onToggleGlobalBroadcast?: () => void;
onBroadcastInput: (
data: string,
sourceSessionId: string,
@@ -935,6 +941,9 @@ const terminalPanePropsAreEqual = (
prev.onSetWorkspaceFocusedSession === next.onSetWorkspaceFocusedSession &&
prev.onSplitSession === next.onSplitSession &&
prev.isBroadcastEnabled === next.isBroadcastEnabled &&
prev.isGlobalBroadcastEnabled === next.isGlobalBroadcastEnabled &&
prev.canUseGlobalBroadcast === next.canUseGlobalBroadcast &&
prev.onToggleGlobalBroadcast === next.onToggleGlobalBroadcast &&
prev.onBroadcastInput === next.onBroadcastInput &&
prev.onBroadcastInterruptPriorityChange === next.onBroadcastInterruptPriorityChange &&
prev.onToggleWorkspaceComposeBar === next.onToggleWorkspaceComposeBar &&
@@ -1233,6 +1242,9 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
onSetWorkspaceFocusedSession,
onSplitSession,
isBroadcastEnabled,
isGlobalBroadcastEnabled,
canUseGlobalBroadcast,
onToggleGlobalBroadcast,
onBroadcastInput,
onBroadcastInterruptPriorityChange,
onToggleWorkspaceComposeBar,
@@ -1459,7 +1471,9 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
: undefined;
const splitHorizontalHandler = splitHorizontalHandlersRef.current.get(session.id);
const splitVerticalHandler = splitVerticalHandlersRef.current.get(session.id);
const broadcastEnabled = activeWorkspaceId ? !!isBroadcastEnabled?.(activeWorkspaceId) : false;
const broadcastEnabled = activeWorkspaceId
? !!isBroadcastEnabled?.(activeWorkspaceId)
: (isGlobalBroadcastEnabled ?? false);
const isHostEphemeral = !isSavedVaultHost(hostMap.get(host.id));
const sessionAppearance = useMemo(
() => resolveSessionAppearance({ host, isEphemeral: isHostEphemeral }),
@@ -1605,7 +1619,11 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
onSplitHorizontal={onSplitSession ? splitHorizontalHandler : undefined}
onSplitVertical={onSplitSession ? splitVerticalHandler : undefined}
isBroadcastEnabled={broadcastEnabled}
onToggleBroadcast={inActiveWorkspace ? workspaceBroadcastHandler : undefined}
onToggleBroadcast={
inActiveWorkspace
? workspaceBroadcastHandler
: (canUseGlobalBroadcast ? onToggleGlobalBroadcast : undefined)
}
onToggleComposeBar={inActiveWorkspace ? onToggleWorkspaceComposeBar : undefined}
isWorkspaceComposeBarOpen={inActiveWorkspace ? isComposeBarOpen : undefined}
onBroadcastInput={broadcastEnabled ? onBroadcastInput : undefined}
@@ -1697,6 +1715,9 @@ interface TerminalPanesHostProps {
onSetWorkspaceFocusedSession?: (workspaceId: string, sessionId: string) => void;
onSplitSession?: (sessionId: string, direction: SplitDirection) => void;
isBroadcastEnabled?: (workspaceId: string) => boolean;
isGlobalBroadcastEnabled?: boolean;
canUseGlobalBroadcast?: boolean;
onToggleGlobalBroadcast?: () => void;
onBroadcastInput: (
data: string,
sourceSessionId: string,
@@ -1784,6 +1805,9 @@ const terminalPanesHostPropsAreEqual = (
if (prev.onSetWorkspaceFocusedSession !== next.onSetWorkspaceFocusedSession) return false;
if (prev.onSplitSession !== next.onSplitSession) return false;
if (prev.isBroadcastEnabled !== next.isBroadcastEnabled) return false;
if (prev.isGlobalBroadcastEnabled !== next.isGlobalBroadcastEnabled) return false;
if (prev.canUseGlobalBroadcast !== next.canUseGlobalBroadcast) return false;
if (prev.onToggleGlobalBroadcast !== next.onToggleGlobalBroadcast) return false;
if (prev.onBroadcastInput !== next.onBroadcastInput) return false;
if (prev.onBroadcastInterruptPriorityChange !== next.onBroadcastInterruptPriorityChange) return false;
if (prev.onToggleWorkspaceComposeBar !== next.onToggleWorkspaceComposeBar) return false;
@@ -635,6 +635,9 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
validAIScopeTargetIds: s.validAIScopeTargetIds,
workspaceBroadcastHandlersRef: s.workspaceBroadcastHandlersRef,
workspaceById,
isGlobalBroadcastEnabled: s.isGlobalBroadcastEnabled,
canUseGlobalBroadcast: s.canUseGlobalBroadcast,
onToggleGlobalBroadcast: s.onToggleGlobalBroadcastRef.current,
// AI scope maintenance (merge/dissolve handoff) needs the full list; do not
// rely on workspaceById alone — SidePanelStateRoot reads ctx.workspaces.
workspaces: s.workspaces,
@@ -691,6 +694,9 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
s.terminalTheme,
s.resolveSessionAppearance,
s.hostMap,
s.isGlobalBroadcastEnabled,
s.canUseGlobalBroadcast,
s.onToggleGlobalBroadcastRef,
]);
return <TerminalLayerView ctx={ctx} />;
@@ -123,6 +123,9 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
onReorderTabs,
onStartSessionDrag,
onEndSessionDrag,
isGlobalBroadcastEnabled,
canUseGlobalBroadcast,
onToggleGlobalBroadcast,
t,
} = ctx;
@@ -255,6 +258,9 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
onReorderTabs={onReorderTabs}
onStartSessionDrag={onStartSessionDrag}
onEndSessionDrag={onEndSessionDrag}
isGlobalBroadcastEnabled={isGlobalBroadcastEnabled}
canUseGlobalBroadcast={canUseGlobalBroadcast}
onToggleGlobalBroadcast={onToggleGlobalBroadcast}
/>
{!isFocusMode && activeResizers.map((handle: any) => {
const isVertical = handle.direction === 'vertical';
Loaded 100 of 201 files, more files were not shown because too many files have changed in this diff. Show more