Compare commits

...
Author SHA1 Message Date
bincxz d17e0f4e5d fix(terminal): clear revived search selection after reset
SearchAddon can re-select the prior active match ~200ms after a write
or resize. The revival guard now clears that selection unless the user
started a new one during the window.
2026-08-14 15:36:14 +08:00
netcatty-bot b37ca5ef76 fix: address Codex review on PR #2981 2026-08-14 07:10:40 +00:00
netcatty-bot e1ac0bb6c3 fix(#2980): automated Cursor CLI fix 2026-08-14 06:29:07 +00:00
陈大猫 894d85e1ea Merge pull request #2958 from binaricat/cursor/issue-2956-31671177509
fix(sftp): rebind side panel after SSH start-over reconnect
2026-08-14 11:44:01 +08:00
陈大猫 c6fcef7226 Merge pull request #2955 from binaricat/cursor/issue-2954-31661803086
fix(sftp): preserve remote permissions on upload replace
2026-08-14 11:43:08 +08:00
陈大猫 c763b2dd7d Merge pull request #2968 from binaricat/cursor/issue-2967-31721002385
fix(zmodem): wait for transport drain on large rz uploads
2026-08-14 11:41:14 +08:00
bincxz 4ea1d7ae28 chore(sftp): keep reconnect comments ASCII 2026-08-14 11:38:16 +08:00
bincxz 0c433b5ff2 fix: track partial SSH drain progress 2026-08-14 11:07:17 +08:00
bincxz e7f702d5fb fix(upload): replace local file entries safely 2026-08-14 11:06:34 +08:00
bincxz a6043f0eac fix(sftp): track reconnects across workspace focus 2026-08-14 11:01:41 +08:00
bincxz 6ec872bb64 fix(sftp): make symlink replacement unlink-only 2026-08-14 11:00:36 +08:00
bincxz 5384362e58 fix: cancel drain wait on zmodem errors 2026-08-14 10:58:50 +08:00
bincxz ef6fbdb692 fix(sftp): unlink SCP symlinks safely 2026-08-14 10:58:17 +08:00
bincxz 54e3601426 fix(sftp): complete safe permission-preserving replace 2026-08-14 10:46:11 +08:00
bincxz 851efb0927 fix(sftp): preserve standalone fallback during reconnect 2026-08-14 10:44:45 +08:00
bincxz b308fd378b fix: bound idle SSH drain waits 2026-08-14 10:43:20 +08:00
bincxz 96ee77236e fix: keep slow SSH uploads draining 2026-08-14 10:36:09 +08:00
bincxz 59563fc468 fix: narrow zmodem drain handling to SSH 2026-08-14 10:32:18 +08:00
bincxz 3b82004dd2 fix(sftp): observe reconnects while panel is hidden 2026-08-14 10:31:34 +08:00
netcatty-bot 8e2363b9bc fix: address Codex review on PR #2968 2026-08-13 19:11:09 +00:00
netcatty-bot 4c909fa698 fix: address Codex review on PR #2968 2026-08-13 18:42:36 +00:00
netcatty-bot af678ae652 fix: address Codex review on PR #2968 2026-08-13 18:15:12 +00:00
netcatty-bot 7db89bd063 fix: address Codex review on PR #2968 2026-08-13 17:50:52 +00:00
netcatty-bot c161d0a828 fix: address Codex review on PR #2968 2026-08-13 17:24:07 +00:00
netcatty-bot 1a609182a4 fix(#2967): automated Cursor CLI fix 2026-08-13 16:46:21 +00:00
陈大猫 0afb81a5b5 Merge pull request #2960 from binaricat/cursor/issue-2959-31674705640
fix(ai): detect Windows OpenSSH shell for PTY exec wrappers
2026-08-13 21:57:18 +08:00
陈大猫andnetcatty-bot e8e696971f fix(terminal): avoid keyword highlight flicker (#2951)
* fix(terminal): avoid keyword highlight flicker

* fix: address Codex review on PR #2951

* fix: address Codex review on PR #2951

* perf(terminal): defer highlight catch-up under load

* perf(terminal): bound deferred highlight work

* fix(terminal): bound highlight catch-up backlog

* fix(terminal): mirror history wipes in highlighter

* fix(ci): load throughput baseline from PR base

* fix(terminal): preserve highlight rebuild ordering

* fix(terminal): drain highlights before exit capture

* fix(terminal): preserve line state across clear

* perf(terminal): slice history serialization

* fix(terminal): preserve parser state on recolor

* fix(terminal): preserve protocol state on recolor

* fix(terminal): retain live state during recolor

* fix(terminal): close recolor state gaps

* fix(terminal): retain saved colors across recolor

* fix(terminal): serialize recolor side effects

* fix(terminal): color keywords in place without history rebuild

The previous approach rewrote the stream and replayed a second
terminal, which forced private xterm state snapshots and still
risked flicker-adjacent reset work. Writes now stay pristine and
only dirty cells get a new foreground. Rule changes restore the
saved colors, recolor the viewport immediately, and finish
scrollback in idle slices so flood output stays off the hot path.

Related to #2784
Related to #2879

* fix(terminal): keep in-place keyword coloring correct under load

Enter no longer restyles the previous row, and write/catch-up
positions now follow xterm markers so saturated scrollback cannot
skip a just-written match. Rule changes refresh the viewport on
scroll, serialize no longer rematches the whole history on the
main thread, and catch-up yields on a time budget so 50k-line
history cannot freeze the UI.

Related to #2784
Related to #2879

* perf(terminal): keep flood and scroll off the highlight hot path

Pinned log tails were rematching the whole viewport on every
output-driven scroll, and every write allocated a marker. Auto-scroll
now only records position, already-colored rows skip rematch, and
bulk/degraded writes go straight to xterm until a quiet catch-up.

Related to #2784
Related to #2879

* perf(terminal): merge flood writes on a 16ms burst window

Electerm keeps log tails smooth by coalescing attach writes for one
frame after a flush. Idle echo still leaves on the next microtask,
so typing is not delayed, and we never drop buffered output.

Related to #2784
Related to #2879

* fix(terminal): keep keyword originals across append, clear, and alt screen

Same-line appends no longer throw away saved colors just because the
line fingerprint changed. Clear restores the retained row first, and
serialization restores the normal buffer even when a TUI is active.
Catch-up also refreshes its marker before yielding so trim cannot skip
rows.

Related to #2784
Related to #2879

* fix(ci): stop resetting highlight catch-up on every flood write

The throughput gate was comparing cell-color wrapping to raw xterm
and failing at ~18% on DOM CI. Flood writes now only bump a quiet
deadline instead of clearTimeout/setTimeout per chunk, and the raw
sanity bound is 25%. The product check remains 10% versus main.

Related to #2784
Related to #2879

* fix(terminal): rematch recycled rows and cap wrapped catch-up

Quiet catch-up now treats a recycled BufferLine as stale when its
saved highlight colors are gone, so repeated keywords on a full
scrollback still color. Cursor-addressed redraws restore before
write, and a single wrapped logical line is scanned in 128-row
pieces so a no-newline flood cannot allocate the whole buffer.

Related to #2784
Related to #2879

* fix(terminal): accept plugin #RRGGBBAA highlight colors

Plugin decorations allow 8-digit hex. Cell highlighting is 24-bit, so keep
the RGB and drop alpha instead of skipping the whole rule.

* fix(terminal): color cells for partial grapheme matches

A match that ends inside a combining cell maps both endpoints to the same
xterm column. Color that cell instead of building an empty range.

* fix(terminal): rematch flood rows after empty-line stamps

A hot-path \r\n write stamped the following empty cursor row as current.
The next flood chunk reused that BufferLine with ERROR text, and quiet
catch-up skipped it. Fingerprint the stamped content so filled or
recycled identical rows are recolored.

* perf(terminal): avoid flood-path registerMarker after trim

Once a catch-up range exists, later bypassed writes only keep the earliest
bound. A disposed marker collapses to 0 without allocating a new one on
every saturated-scrollback chunk.

* fix(terminal): recolor the row retained by clear

xterm clear keeps the cursor row after we restore originals. Recolor that
surviving line so enabled rules still apply until the next write.

* perf(terminal): stamp no-match rows without cell snapshots

Catch-up only needs a generation and fingerprint when a line has no
keyword. Allocate typed-array originals when a cell is actually colored.

* perf(terminal): treat pinned-bottom recycle as output scroll

Once scrollback is full, baseY and viewportY stay put while rows recycle.
That was treated as a user scroll, so every flood write rematched the
visible area. Keep pinned-to-bottom updates off the rematch path.

* perf(terminal): keep quiet catch-up to a single viewport paint

Catch-up already refreshes visible slices. A trailing recolorVisible()
caused a second settle paint and tripped the atomic-repaint gate.

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-08-13 21:26:16 +08:00
netcatty-bot 6a179cf0c1 fix: address Codex review on PR #2960 2026-08-13 11:24:57 +00:00
netcatty-bot e5658ff4db fix: address Codex review on PR #2960 2026-08-13 10:58:35 +00:00
netcatty-bot a9faf92494 fix: address Codex review on PR #2960 2026-08-13 10:33:28 +00:00
netcatty-bot 208e6b6789 fix: address Codex review on PR #2960 2026-08-13 10:04:02 +00:00
netcatty-bot 9c4ecda4f1 fix: address Codex review on PR #2960 2026-08-13 09:36:21 +00:00
netcatty-bot 05cdaab74e fix: address Codex review on PR #2960 2026-08-13 09:06:24 +00:00
netcatty-bot ec6392d013 fix: address Codex review on PR #2955 2026-08-13 09:05:15 +00:00
netcatty-bot b34d23deea fix: address Codex review on PR #2955 2026-08-13 08:31:26 +00:00
netcatty-bot e50ae0fd94 fix: address Codex review on PR #2960 2026-08-13 08:30:51 +00:00
netcatty-bot 7324d1fc4f fix: address Codex review on PR #2955 2026-08-13 07:57:38 +00:00
netcatty-bot c1ad45ca51 fix: address Codex review on PR #2960 2026-08-13 07:56:39 +00:00
陈大猫 92fd917760 Merge pull request #2957 from LJAYi/codex/fix-macos-small-icon-frame
fix(mac): remove bright frame from small icons
2026-08-13 15:32:59 +08:00
netcatty-bot 0e8fb6b7cb fix: address Codex review on PR #2955 2026-08-13 07:17:14 +00:00
netcatty-bot 730203c84a fix(#2959): automated Cursor CLI fix 2026-08-13 07:00:36 +00:00
netcatty-bot 14b613d84a fix: address Codex review on PR #2955 2026-08-13 06:45:18 +00:00
netcatty-bot 1ab9434e4f fix: address Codex review on PR #2958 2026-08-13 06:45:17 +00:00
netcatty-bot 3064a9d39c fix: address Codex review on PR #2955 2026-08-13 06:07:13 +00:00
netcatty-bot aef7aeadb4 fix(#2956): automated Cursor CLI fix 2026-08-13 05:58:21 +00:00
LJAYi 1146a94bbf fix(mac): remove bright frame from small icons 2026-08-13 13:45:16 +08:00
netcatty-bot dbfd5b5758 fix: address Codex review on PR #2955 2026-08-13 05:29:48 +00:00
netcatty-bot e24a08b909 fix: address Codex review on PR #2955 2026-08-13 04:45:49 +00:00
netcatty-bot 4f2db51e55 fix: address Codex review on PR #2955 2026-08-13 04:06:20 +00:00
netcatty-bot a7d5c69e91 fix(#2954): automated Cursor CLI fix 2026-08-13 03:02:16 +00:00
陈大猫 e38b372d1f Merge pull request #2897 from yintian710/main
feat(vault): 添加动态组路径支持用于自动化脚本
2026-08-12 22:24:12 +08:00
bincxz 189db57625 fix(vault): finish dynamic group targeting 2026-08-12 22:00:18 +08:00
陈大猫 6736f95532 Merge pull request #2949 from atoz03/feat/disk-capacity-t-p-units
feat(terminal): scale mounted-disk capacity into T and P units
2026-08-12 21:21:42 +08:00
atoz03andClaude Opus 5 fc641cb304 feat(terminal): scale mounted-disk capacity into T and P units
Multi-terabyte pools rendered as raw gigabytes (118736.4G), which is hard
to read at a glance. Scale each side of the used/total pair independently
so a mostly-empty pool keeps its used figure (136.37G/6.93T instead of
0.13/6.93T), and emit a single shared suffix when both land in the same
unit so ordinary disks keep the familiar 71.12/878.15G shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 06:06:39 -07:00
yintian 27dc1c62af feat(vault): 添加组变更提交功能以支持原子操作
- 在多个组件中引入 commitVaultGroupMutation 函数
- 更新 useHostTreeInlineGroupActions 钩子以使用新的提交机制
- 修改 vault 组件以传递新的组变更提交函数
- 实现组重命名和删除操作的统一提交逻辑
- 添加相关测试用例验证组变更提交功能
- 更新类型定义以包含新的组变更状态管理接口
2026-08-12 20:37:10 +08:00
陈大猫 ef0a07e560 Merge pull request #2948 from binaricat/automation/nix-release-v1.1.79
Update Nix release metadata for v1.1.79
2026-08-12 19:47:46 +08:00
github-actions[bot] fb3396e389 Update Nix release metadata for v1.1.79 2026-08-12 11:43:42 +00:00
陈大猫 aced2a99f8 Merge pull request #2946 from binaricat/cursor/issue-2944-31588973204
fix(ai): align Anthropic-compat Base URL for detect and chat
2026-08-12 19:23:48 +08:00
陈大猫 cfb534615c Merge pull request #2931 from binaricat/cursor/issue-2879-31571007873
fix(terminal): stop idle-Enter keyword highlight flash after #2880 hole
2026-08-12 19:23:30 +08:00
bincxz 409fd30969 fix(ai): only append /v1 for bare Anthropic Base URLs
Codex P2: unconditional normalize broke custom proxy prefixes such as
https://proxy.example/anthropic that already complete the SDK base.
Restrict /v1 inference to scheme+host origins (Claude Code style) and
leave other path prefixes unchanged.
2026-08-12 19:19:31 +08:00
netcatty-bot cd284ae4f3 fix(#2944): automated Cursor CLI fix 2026-08-12 11:03:00 +00:00
陈大猫 be3658b24f Merge pull request #2945 from binaricat/codex/fix-sftp-sudo-session-fallback
fix(sftp): session-backed sudo 对齐 openSftp 回退
2026-08-12 19:01:39 +08:00
bincxz 072377777f fix(sftp): align session-backed sudo open with openSftp fallbacks
Reject sudo+forced-SCP before channel open, and fall back to the standard
SFTP subsystem on exit 127 so reused SSH transports do not re-prompt MFA
on hosts without a standalone sftp-server (e.g. ESXi).
2026-08-12 18:57:00 +08:00
陈大猫 27b62b2372 Merge pull request #2938 from myderr/codex/fix-sftp-sudo-mfa-reuse
fix(sftp): 复用 SSH 会话打开 sudo SFTP
2026-08-12 18:25:49 +08:00
bincxz 627e652f56 merge: sync main and resolve SFTP connection test conflict
Keep sudo MFA session reuse plus home-dir candidate recovery from #2941.
2026-08-12 18:25:28 +08:00
陈大猫 17c2eb6346 Merge pull request #2941 from binaricat/codex/fix-sftp-root-provisional
fix(sftp): keep home candidates when realpath root is not listable
2026-08-12 18:22:52 +08:00
ydm 01102e2d66 merge: 同步 main 并解决 SFTP 复用冲突 2026-08-12 18:19:45 +08:00
bincxz 81dcf849fb fix(sftp): keep home candidates when realpath root is not listable
Accept realpath('/') as home only after a successful readdir so virtual/chroot
roots from #2934 still work, while non-listable root no longer skips
/home/<user> and /root probing. Also retry those candidates if listing fails.
2026-08-12 18:18:14 +08:00
陈大猫 1dfe1379cd Merge pull request #2934 from pplulee/codex/fix-sftp-virtual-root
修复 SFTP 虚拟根目录会话的传输失败
2026-08-12 18:10:02 +08:00
ydm 27ec58fa40 fix(sftp): 修复侧栏自动连接去重边界 2026-08-12 18:09:59 +08:00
陈大猫 7ac878d00e Merge pull request #2939 from binaricat/fix/show-tab-number-badges-exhaustive-deps
fix: include setShowTabNumberBadgesState in storage sync deps
2026-08-12 18:02:00 +08:00
bincxz f90f40e624 fix: include setShowTabNumberBadgesState in storage sync deps
The storage event listener uses setShowTabNumberBadgesState but
omitted it from the useEffect dependency array, which triggered
react-hooks/exhaustive-deps.
2026-08-12 18:01:07 +08:00
ydm dfc9ed7f61 fix(sftp): 复用 SSH 会话打开 sudo SFTP 2026-08-12 17:58:45 +08:00
陈大猫 2c2d7e0b55 Merge pull request #2937 from binaricat/codex/fix-theme-header-button-alignment
Fix theme header button alignment
2026-08-12 17:54:23 +08:00
bincxz ef364b69fd Fix theme header button alignment 2026-08-12 17:50:10 +08:00
陈大猫 700d603e64 Merge pull request #2925 from binaricat/cursor/issue-2922-31559806317
fix(ai): keep host_open sessions after sidebar scope sync
2026-08-12 17:40:46 +08:00
netcatty-bot f2b3facd9f fix: address Codex review on PR #2925 2026-08-12 09:32:41 +00:00
netcatty-bot d1cd072502 fix: address Codex review on PR #2931 2026-08-12 09:26:44 +00:00
netcatty-bot 60820acf9a fix: address Codex review on PR #2931 2026-08-12 08:58:01 +00:00
bincxz 7f08bec0b9 Merge remote-tracking branch 'origin/main' into codex/pr-2925-review
# Conflicts:
#	electron/bridges/terminalBridge.sessionActivity.test.cjs
2026-08-12 16:50:25 +08:00
bincxz bbd4c698a5 fix(ai): harden host_open session lifecycle 2026-08-12 16:42:43 +08:00
陈大猫 4beccc3d61 Merge pull request #2928 from binaricat/cursor/issue-2926-31561097764
fix(terminal): re-run onConnect scripts on manual reconnect
2026-08-12 16:42:39 +08:00
pplulee 60e057318f fix: handle SFTP virtual root sessions 2026-08-12 16:29:35 +08:00
netcatty-bot 7408e960cc fix: address Codex review on PR #2931 2026-08-12 08:20:21 +00:00
bincxz 33b3beaea0 Merge remote-tracking branch 'origin/main' into codex/pr-2928 2026-08-12 16:14:56 +08:00
bincxz e65af2fd7b fix(terminal): safely rerun connect scripts on manual reconnect 2026-08-12 16:14:43 +08:00
netcatty-bot 9cd756f44b fix: address Codex review on PR #2931 2026-08-12 07:49:44 +00:00
陈大猫 5d63a9476c Merge pull request #2932 from binaricat/codex/fix-2924-dropbear-stats-limit
fix(ssh): keep server stats safe for Dropbear
2026-08-12 15:35:20 +08:00
bincxz 58c72ace32 fix(ssh): keep server stats safe for Dropbear 2026-08-12 15:29:14 +08:00
netcatty-bot cd12b04647 fix(#2879): automated Cursor CLI fix 2026-08-12 07:07:55 +00:00
陈大猫 75cbda7edc Merge pull request #2917 from binaricat/cursor/issue-2899-31554398579
fix(ssh): stop parked reconnect PID probes
2026-08-12 14:55:52 +08:00
bincxz 6f6d8dc36d fix(ssh): recover cwd safely after parked reconnect 2026-08-12 14:42:25 +08:00
netcatty-bot d0f819efb0 fix: address Codex review on PR #2917 2026-08-12 06:36:25 +00:00
netcatty-bot 9671468412 fix: address Codex review on PR #2925 2026-08-12 06:36:20 +00:00
陈大猫 380e96eae5 Merge pull request #2929 from binaricat/codex/fix-docker-tmux-popup-timeout
fix(terminal): start command popups after vault hydration
2026-08-12 14:12:01 +08:00
netcatty-bot e4f9303de5 fix: address Codex review on PR #2925 2026-08-12 06:04:52 +00:00
netcatty-bot 02ce011934 fix: address Codex review on PR #2917 2026-08-12 06:03:20 +00:00
bincxz 8e45763e4c fix(terminal): start command popups after vault hydration 2026-08-12 13:53:54 +08:00
netcatty-bot 0d92c5d738 fix: address Codex review on PR #2928 2026-08-12 05:26:14 +00:00
netcatty-bot c8a393f12b fix: address Codex review on PR #2925 2026-08-12 05:25:45 +00:00
bincxz 6bf4268e8e fix(ssh): scope parked reconnect cwd guard 2026-08-12 13:17:50 +08:00
netcatty-bot 7114288d2f fix: address Codex review on PR #2925 2026-08-12 04:46:21 +00:00
bincxz f02b645af7 fix(ssh): avoid stale cwd after parked reconnect 2026-08-12 12:44:11 +08:00
netcatty-bot eb3cd06278 fix: address Codex review on PR #2928 2026-08-12 04:42:43 +00:00
bincxz 7ed0b32489 Merge remote-tracking branch 'origin/main' into codex/pr-2917-final
# Conflicts:
#	electron/bridges/sshBridge.connectionReuse.test.cjs
2026-08-12 12:36:46 +08:00
陈大猫 2fe46cbf6f Merge pull request #2918 from binaricat/cursor/issue-2916-31555268705
fix(workspace): allow dragging hosts into focus-mode sidebar
2026-08-12 12:24:21 +08:00
bincxz 53ebbec58c fix(workspace): expand focus sidebar host drop target 2026-08-12 12:16:52 +08:00
netcatty-bot 0382c089e1 fix: address Codex review on PR #2925 2026-08-12 04:05:21 +00:00
bincxz e9f5bcc6dc fix(vault): harden dynamic group script scopes 2026-08-12 12:04:42 +08:00
netcatty-bot 253ce57d84 fix: address Codex review on PR #2917 2026-08-12 04:02:40 +00:00
netcatty-bot 9beb7d3108 fix(#2926): automated Cursor CLI fix 2026-08-12 04:00:36 +00:00
陈大猫 525a78e83e Merge pull request #2921 from binaricat/cursor/issue-2919-31558580686
fix(ai): sync side panel color-scheme for light Codex composer
2026-08-12 11:57:04 +08:00
陈大猫 0e94950a5c Merge pull request #2927 from binaricat/cursor/issue-2923-31560157438
fix(ssh): skip idle-park reconnect PID discovery that breaks bastions
2026-08-12 11:56:18 +08:00
陈大猫 a2ea0f6464 Merge pull request #2913 from binaricat/cursor/issue-2912-31541806461
fix(system): exclude NFS/CIFS mounts from overview disk totals
2026-08-12 11:52:44 +08:00
netcatty-bot 42b174abb3 fix(#2923): automated Cursor CLI fix 2026-08-12 03:49:12 +00:00
bincxz e2ad0f526e fix(workspace): complete host drop paths 2026-08-12 11:44:35 +08:00
netcatty-bot f8c7dd44db fix(#2922): automated Cursor CLI fix 2026-08-12 03:43:57 +00:00
bincxz cfeee2ec89 fix(ssh): skip redundant PID discovery on reconnect 2026-08-12 11:39:58 +08:00
bincxz be3e971064 fix(system): filter legacy distributed filesystem sources 2026-08-12 11:35:37 +08:00
陈大猫 35bb83f57c Merge pull request #2914 from binaricat/cursor/issue-2863-31551484962
fix(zmodem): fail skipped uploads and auto-overwrite on terminal drag-drop
2026-08-12 11:34:37 +08:00
bincxz 793ba54bda fix(system): cover generic FUSE and scoped IPv6 mounts 2026-08-12 11:14:40 +08:00
bincxz a1ca1a3443 fix(zmodem): preserve drag-drop overwrite targets 2026-08-12 11:14:03 +08:00
netcatty-bot 3c528c6ab5 fix: address Codex review on PR #2917 2026-08-12 03:13:17 +00:00
netcatty-bot 6dd703afce fix: address Codex review on PR #2918 2026-08-12 03:12:52 +00:00
netcatty-bot 10c399f47b fix(#2916): automated Cursor CLI fix 2026-08-12 02:13:50 +00:00
netcatty-bot a4ec7bab61 fix: address Codex review on PR #2914 2026-08-12 02:11:24 +00:00
netcatty-bot 79215d8896 fix(#2899): automated Cursor CLI fix 2026-08-12 02:02:41 +00:00
netcatty-bot 2012fc53c7 fix: address Codex review on PR #2914 2026-08-12 01:24:14 +00:00
yintian a47de3cb52 feat(terminal): 添加动态组目标支持到代码片段功能
- 在工具规范中添加 targetGroups 字段定义
- 扩展终端自动补全引擎以支持 hostGroup 参数传递
- 实现基于嵌套主机组成员资格的动态组目标过滤
- 更新 CLI 工具以支持 --target-groups 参数解析
- 添加代码片段导入导出中的动态组范围保留功能
- 增加相关测试用例验证组目标匹配逻辑
2026-08-12 09:23:27 +08:00
netcatty-bot 2be738b8c2 fix(#2863): automated Cursor CLI fix 2026-08-12 01:04:52 +00:00
netcatty-bot 93d7f8e1ec fix: address Codex review on PR #2913 2026-08-11 23:16:26 +00:00
netcatty-bot 924c9a0efa fix: address Codex review on PR #2913 2026-08-11 22:56:23 +00:00
netcatty-bot 0d9da150ec fix(#2912): automated Cursor CLI fix 2026-08-11 22:39:50 +00:00
yintian 0ccea42785 feat(vault): 添加动态组路径支持用于自动化脚本
- 在脚本配置中添加 targetGroups 字段用于指定动态组路径
- 更新工具规范定义以支持动态组路径参数
- 实现组范围的连接脚本动态继承功能
- 更新 CLI 适配器以支持 --target-groups 参数
- 添加测试用例验证动态组路径映射和解析
- 更新国际化资源文件添加相关提示文本
- 重构脚本目标匹配逻辑分离显式主机和组路径匹配
- 实现组路径变更时的重映射和删除处理逻辑
- 更新 UI 组件支持组路径选择和显示功能
- 修改文档描述更新动态组路径使用说明
2026-08-11 17:31:23 +08:00
253 changed files with 14640 additions and 5659 deletions

No files matched your search

+11 -2
View File
@@ -21,6 +21,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@v7
@@ -50,11 +52,18 @@ jobs:
- name: Test
run: npm test
- name: Test terminal decoration performance
- name: Test terminal keyword highlight performance
env:
NETCATTY_TERMINAL_PERF_SHOW_WINDOW: "1"
# GitHub-hosted runners do not configure Electron's SUID sandbox helper.
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox scripts/xterm-decoration-performance.live.test.cjs
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox scripts/xterm-keyword-highlight-performance.live.test.cjs
- name: Test terminal keyword highlight throughput
env:
NETCATTY_TERMINAL_PERF_MAIN_REF: ${{ github.event.pull_request.base.sha || 'HEAD^' }}
NETCATTY_TERMINAL_PERF_ROUNDS: "3"
NETCATTY_TERMINAL_PERF_SHOW_WINDOW: "1"
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox scripts/xterm-keyword-highlight-throughput.live.test.cjs
- name: Build
run: npm run build
+2
View File
@@ -200,6 +200,7 @@ export function AppSideEffects() {
readPersistedHosts,
groupConfigs,
updateGroupConfigs,
commitVaultGroupMutation,
} = vaultState;
const hostsRef = useRef(hosts);
@@ -1283,6 +1284,7 @@ export function AppSideEffects() {
groupConfigs,
updateGroupConfigs,
updateManagedSources,
commitVaultGroupMutation,
updatePortForwardingRules: importPortForwardingRules,
startTunnel,
stopTunnel,
+34 -9
View File
@@ -34,6 +34,7 @@ import { useMainWindowInputFocusRecovery } from '../state/useMainWindowInputFocu
import { useExternalMcpToggleState } from '../state/useExternalMcpToggleState';
import { selectPluginThemeTokens } from '../state/pluginContributionEnvironment';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { resolveEffectiveTerminalHost } from '../../domain/terminalHostResolution';
import { pluginViewTabStore, usePluginViewTabs } from '../state/pluginViewTabStore';
import { buildPluginSettingScopeCatalog } from '../state/usePluginSettingScopeCatalog';
import { useWorkSurfaceHostEditor } from '../state/useWorkSurfaceHostEditor';
@@ -257,7 +258,7 @@ function AppViewInner({ domains }: AppViewProps) {
const {
addShellHistoryEntry, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
clearAndRemoveSource, clearAndRemoveSources, closeLogView, closeSession, closeTabsBatch, closeWorkspace, commitPluginImporterData, commitVaultImportTransaction, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell, copyWorkspaceWithCurrentShell,
clearAndRemoveSource, clearAndRemoveSources, closeLogView, closeSession, closeTabsBatch, closeWorkspace, commitPluginImporterData, commitVaultImportTransaction, commitVaultGroupMutation, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell, copyWorkspaceWithCurrentShell,
convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts,
customGroups, currentTerminalTheme, deepLinkHostDraft, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
followAppTerminalTheme,
@@ -317,6 +318,29 @@ function AppViewInner({ domains }: AppViewProps) {
setAddToWorkspaceDialog({ mode: 'append', workspaceId });
}, [setAddToWorkspaceDialog]);
const validProxyProfileIds = useMemo(
() => new Set(proxyProfiles.map((profile) => profile.id)),
[proxyProfiles],
);
const resolveWorkspaceAppendHost = useCallback((host: typeof hosts[number]) => (
resolveEffectiveTerminalHost({
host,
groupConfigs,
proxyProfiles,
validProxyProfileIds,
})
), [groupConfigs, proxyProfiles, validProxyProfileIds]);
const handleAppendHostToWorkspace = useCallback((workspaceId: string, hostId: string) => {
const host = hosts.find((entry) => entry.id === hostId);
if (!host) return;
const ws = workspaces.find((entry) => entry.id === workspaceId);
if (!ws) return;
const rootDir = ws.root.type === 'split' ? ws.root.direction : 'vertical';
appendHostToWorkspace(workspaceId, resolveWorkspaceAppendHost(host), rootDir);
}, [appendHostToWorkspace, hosts, resolveWorkspaceAppendHost, workspaces]);
const isPeerSessionWindow = typeof window !== 'undefined'
&& window.location.hash.startsWith('#/session-window');
const externalMcpToggle = useExternalMcpToggleState();
@@ -471,6 +495,7 @@ function AppViewInner({ domains }: AppViewProps) {
onEndSessionDrag={handleEndSessionDrag}
onReorderTabs={reorderWorkTabs}
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
onAppendHostToWorkspace={handleAppendHostToWorkspace}
showSftpTab={showSftpTab}
showHostTreeSidebar={showHostTreeSidebar}
switchTabKeyBinding={keyBindings.find((binding) => binding.action === 'switchToTab') ?? null}
@@ -567,6 +592,7 @@ function AppViewInner({ domains }: AppViewProps) {
onUpdateManagedSources={updateManagedSources}
onReadPersistedManagedSources={readPersistedManagedSources}
onCommitVaultImportTransaction={commitVaultImportTransaction}
onCommitVaultGroupMutation={commitVaultGroupMutation}
onClearAndRemoveManagedSource={clearAndRemoveSource}
onClearAndRemoveManagedSources={clearAndRemoveSources}
onUnmanageSource={unmanageSource}
@@ -659,6 +685,7 @@ function AppViewInner({ domains }: AppViewProps) {
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
onAddSessionToWorkspace={addSessionToWorkspace}
onRequestAddToWorkspace={handleRequestAddToWorkspace}
onAppendHostToWorkspace={handleAppendHostToWorkspace}
onUpdateSplitSizes={updateSplitSizes}
onSetDraggingSessionId={setDraggingSessionId}
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
@@ -780,13 +807,7 @@ function AppViewInner({ domains }: AppViewProps) {
<AddToWorkspaceDialog
open
onOpenChange={(open) => { if (!open) setAddToWorkspaceDialog(null); }}
// Filter serial hosts only in append mode — appendHostToWorkspace
// has no serial code path. Create mode goes through
// createWorkspaceFromTargets, which builds a SerialConfig-backed
// session for serial hosts, so those should remain pickable.
hosts={addToWorkspaceDialog.mode === 'append'
? hosts.filter((h) => h.protocol !== 'serial')
: hosts}
hosts={hosts}
workspaceTitle={
addToWorkspaceDialog.mode === 'append'
? workspaces.find((w) => w.id === addToWorkspaceDialog.workspaceId)?.title
@@ -805,7 +826,11 @@ function AppViewInner({ domains }: AppViewProps) {
if (target.kind === 'local') {
appendLocalTerminalToWorkspace(addToWorkspaceDialog.workspaceId, undefined, rootDir);
} else {
appendHostToWorkspace(addToWorkspaceDialog.workspaceId, target.host, rootDir);
appendHostToWorkspace(
addToWorkspaceDialog.workspaceId,
resolveWorkspaceAppendHost(target.host),
rootDir,
);
}
}
} else {
@@ -0,0 +1,19 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const source = readFileSync(new URL('./AppView.tsx', import.meta.url), 'utf8');
test('workspace append resolves group defaults before creating host sessions', () => {
assert.match(source, /resolveEffectiveTerminalHost\(\{/);
assert.match(source, /groupConfigs,/);
assert.match(source, /proxyProfiles,/);
assert.match(
source,
/appendHostToWorkspace\(workspaceId, resolveWorkspaceAppendHost\(host\), rootDir\)/,
);
assert.match(
source,
/appendHostToWorkspace\([\s\S]*?resolveWorkspaceAppendHost\(target\.host\),[\s\S]*?rootDir/,
);
});
+1
View File
@@ -38,6 +38,7 @@ export function VaultHost() {
removeShellHistoryEntry: actions?.removeShellHistoryEntry,
commitPluginImporterData: actions?.commitPluginImporterData,
commitVaultImportTransaction: actions?.commitVaultImportTransaction,
commitVaultGroupMutation: actions?.commitVaultGroupMutation,
convertKnownHostToHost: actions?.convertKnownHostToHost,
customGroups: vault.customGroups,
deepLinkHostDraft: local.deepLinkHostDraft,
@@ -67,6 +67,7 @@ export function VaultPublisher({ children }: VaultPublisherProps) {
readPersistedManagedSources,
commitPluginImporterData,
commitVaultImportTransaction,
commitVaultGroupMutation,
updateHostDistro,
updateHostLastConnected,
addShellHistoryEntry,
@@ -138,6 +139,7 @@ export function VaultPublisher({ children }: VaultPublisherProps) {
readPersistedManagedSources,
commitPluginImporterData,
commitVaultImportTransaction,
commitVaultGroupMutation,
updateHostDistro,
updateHostLastConnected,
addShellHistoryEntry,
@@ -150,6 +152,7 @@ export function VaultPublisher({ children }: VaultPublisherProps) {
addShellHistoryEntry,
commitPluginImporterData,
commitVaultImportTransaction,
commitVaultGroupMutation,
convertKnownHostToHost,
importOrReuseKey,
readPersistedHosts,
+1
View File
@@ -53,6 +53,7 @@ export const enAiMessages: Messages = {
'ai.providers.apiKey.placeholder': 'Enter API key',
'ai.providers.apiKey.decrypting': 'Decrypting...',
'ai.providers.baseUrl': 'Base URL',
'ai.providers.baseUrl.anthropicHelp': 'Anthropic-compatible: host with or without /v1 (for example https://gateway.example or https://gateway.example/v1). Detection and chat both use /v1/models and /v1/messages.',
'ai.providers.skipTLSVerify': 'Skip TLS certificate verification (for self-signed certs)',
'ai.providers.defaultModel': 'Default Model',
'ai.providers.defaultModel.placeholder': 'e.g. gpt-4o, claude-sonnet-4-20250514',
+4 -1
View File
@@ -18,8 +18,11 @@ export const enScriptsMessages = {
'scripts.editor.modalSubtitle': 'Edit metadata and script code in a larger workspace.',
'scripts.editor.lineCount': '{count} lines',
'scripts.editor.resize': 'Resize editor',
'scripts.targets.hint': 'Automatic triggers apply to selected hosts, or to all hosts when that option is enabled.',
'scripts.targets.hint': 'Selected groups are resolved dynamically, so hosts added later are included automatically.',
'scripts.targets.connectOrderHint': 'Run order for connect scripts is configured per host under Host details → Automation.',
'scripts.targets.currentHostMismatch': 'This script is not assigned to the current host.',
'hostDetails.automation.groupScripts': 'Inherited group scripts',
'hostDetails.automation.groupScriptsHint': 'These scripts follow the host group dynamically and are ordered before the host-specific queue.',
'scripts.actions.runNowHint': 'Run on selected targets, or on all connectable hosts when that option is enabled.',
'scripts.actions.runParallel': 'Run on all tabs (parallel)',
'scripts.actions.runSequential': 'Run on all tabs (sequential)',
+3
View File
@@ -659,6 +659,9 @@ export const enTerminalMessages: Messages = {
'snippets.scriptEditor.modalTitle': 'Edit script',
'snippets.targets.title': 'Targets',
'snippets.targets.add': 'Add targets',
'snippets.targets.selectHosts': 'Hosts',
'snippets.targets.selectGroups': 'Groups',
'snippets.targets.noGroups': 'No groups found',
'snippets.targets.allHosts': 'Apply to all hosts',
'snippets.targets.allHostsShort': 'All hosts',
'snippets.targets.allHostsActive': 'Applies to every connectable host.',
+1
View File
@@ -34,6 +34,7 @@ export const ruAiMessages: Messages = {
'ai.providers.apiKey.placeholder': 'Введите API-ключ',
'ai.providers.apiKey.decrypting': 'Расшифровка...',
'ai.providers.baseUrl': 'Базовый URL',
'ai.providers.baseUrl.anthropicHelp': 'Anthropic-совместимый: хост с /v1 или без (например https://gateway.example или https://gateway.example/v1). Проверка и чат используют /v1/models и /v1/messages.',
'ai.providers.skipTLSVerify': 'Пропустить проверку TLS-сертификата (для самоподписанных сертификатов)',
'ai.providers.defaultModel': 'Модель по умолчанию',
'ai.providers.defaultModel.placeholder': 'например, gpt-4o, claude-sonnet-4-20250514',
+4 -1
View File
@@ -18,8 +18,11 @@ export const ruScriptsMessages = {
'scripts.editor.modalSubtitle': 'Редактируйте метаданные и код скрипта в большом рабочем пространстве.',
'scripts.editor.lineCount': '{count} строк',
'scripts.editor.resize': 'Изменить размер редактора',
'scripts.targets.hint': 'Автоматические триггеры применяются к выбранным хостам или ко всем хостам, если включена эта настройка.',
'scripts.targets.hint': 'Выбранные группы разрешаются динамически, поэтому новые хосты включаются автоматически.',
'scripts.targets.connectOrderHint': 'Порядок запуска скриптов подключения настраивается для каждого хоста в разделе «Сведения о хосте → Автоматизация».',
'scripts.targets.currentHostMismatch': 'Этот скрипт не назначен текущему хосту.',
'hostDetails.automation.groupScripts': 'Унаследованные скрипты группы',
'hostDetails.automation.groupScriptsHint': 'Эти скрипты динамически следуют за группой хоста и выполняются перед очередью хоста.',
'scripts.actions.runNowHint': 'Запускается для выбранных целей либо для всех доступных для подключения хостов, если включена эта настройка.',
'scripts.actions.runParallel': 'Запустить на всех вкладках (параллельно)',
'scripts.actions.runSequential': 'Запустить на всех вкладках (последовательно)',
+3
View File
@@ -681,6 +681,9 @@ export const ruTerminalMessages: Messages = {
'snippets.field.variableDefault': 'по умолчанию {value}',
'snippets.targets.title': 'Цели',
'snippets.targets.add': 'Добавить цели',
'snippets.targets.selectHosts': 'Хосты',
'snippets.targets.selectGroups': 'Группы',
'snippets.targets.noGroups': 'Группы не найдены',
'snippets.history.title': 'История оболочки',
'snippets.history.subtitle': '{count} команд',
'snippets.history.emptyTitle': 'История оболочки пока пуста',
+1
View File
@@ -53,6 +53,7 @@ export const zhCNAiMessages: Messages = {
'ai.providers.apiKey.placeholder': '输入 API Key',
'ai.providers.apiKey.decrypting': '解密中...',
'ai.providers.baseUrl': 'Base URL',
'ai.providers.baseUrl.anthropicHelp': 'Anthropic 兼容:可填不带或带 /v1 的主机(例如 https://gateway.example 或 https://gateway.example/v1)。检测与聊天都会请求 /v1/models、/v1/messages。',
'ai.providers.skipTLSVerify': '跳过 TLS 证书验证(用于自签名证书)',
'ai.providers.defaultModel': '默认模型',
'ai.providers.defaultModel.placeholder': '例如 gpt-4o, claude-sonnet-4-20250514',
+4 -1
View File
@@ -18,8 +18,11 @@ export const zhCNScriptsMessages = {
'scripts.editor.modalSubtitle': '在更大的工作区中编辑脚本元数据和代码。',
'scripts.editor.lineCount': '{count} 行',
'scripts.editor.resize': '调整编辑器高度',
'scripts.targets.hint': '自动触发作用于所选主机;勾选「对所有主机生效」则作用于全部可连接主机。',
'scripts.targets.hint': '所选分组会动态匹配,之后加入分组的主机也会自动生效。',
'scripts.targets.connectOrderHint': '连接脚本的执行顺序请在主机详情 → 自动化 中调整。',
'scripts.targets.currentHostMismatch': '此脚本未分配给当前主机。',
'hostDetails.automation.groupScripts': '分组继承脚本',
'hostDetails.automation.groupScriptsHint': '这些脚本动态跟随主机所在分组,并在主机专属队列之前执行。',
'scripts.actions.runNowHint': '在所选目标主机上运行;勾选「对所有主机生效」时在所有可连接主机上运行。',
'scripts.actions.runParallel': '在所有标签页并行运行',
'scripts.actions.runSequential': '在所有标签页顺序运行',
+5 -2
View File
@@ -742,8 +742,11 @@ export const zhCNTerminalMessages: Messages = {
'snippets.scriptEditor.expand': '弹窗编辑',
'snippets.scriptEditor.resize': '调整编辑器高度',
'snippets.scriptEditor.modalTitle': '编辑脚本',
'snippets.targets.title': '目标主机',
'snippets.targets.add': '添加目标主机',
'snippets.targets.title': '执行目标',
'snippets.targets.add': '添加目标',
'snippets.targets.selectHosts': '选择主机',
'snippets.targets.selectGroups': '选择分组',
'snippets.targets.noGroups': '未找到分组',
'snippets.targets.allHosts': '对所有主机生效',
'snippets.targets.allHostsShort': '所有主机',
'snippets.targets.allHostsActive': '将作用于所有可连接的主机。',
+1
View File
@@ -53,6 +53,7 @@ export const zhTWAiMessages: Messages = {
'ai.providers.apiKey.placeholder': '輸入 API Key',
'ai.providers.apiKey.decrypting': '解密中...',
'ai.providers.baseUrl': 'Base URL',
'ai.providers.baseUrl.anthropicHelp': 'Anthropic 相容:可填不帶或帶 /v1 的主機(例如 https://gateway.example 或 https://gateway.example/v1)。偵測與聊天都會請求 /v1/models、/v1/messages。',
'ai.providers.skipTLSVerify': '跳過 TLS 憑證驗證(用於自我簽署憑證)',
'ai.providers.defaultModel': '預設模型',
'ai.providers.defaultModel.placeholder': '例如 gpt-4o, claude-sonnet-4-20250514',
+4 -1
View File
@@ -18,8 +18,11 @@ export const zhTWScriptsMessages = {
'scripts.editor.modalSubtitle': '在更大的工作區中編輯指令碼的中繼資料與程式碼。',
'scripts.editor.lineCount': '{count} 行',
'scripts.editor.resize': '調整編輯器高度',
'scripts.targets.hint': '自動觸發會作用於所選主機;勾選「對所有主機生效」則作用於全部可連線的主機。',
'scripts.targets.hint': '所選分組會動態比對,之後加入分組的主機也會自動生效。',
'scripts.targets.connectOrderHint': '連線指令碼的執行順序請在主機詳情 → 自動化 中調整。',
'scripts.targets.currentHostMismatch': '此指令碼未分配給目前主機。',
'hostDetails.automation.groupScripts': '分組繼承指令碼',
'hostDetails.automation.groupScriptsHint': '這些指令碼會動態跟隨主機所在分組,並在主機專屬佇列之前執行。',
'scripts.actions.runNowHint': '在所選目標主機上執行;勾選「對所有主機生效」時在所有可連線的主機上執行。',
'scripts.actions.runParallel': '在所有標籤頁並行執行',
'scripts.actions.runSequential': '在所有標籤頁依序執行',
+5 -2
View File
@@ -739,8 +739,11 @@ export const zhTWTerminalMessages: Messages = {
'snippets.scriptEditor.expand': '彈出視窗編輯',
'snippets.scriptEditor.resize': '調整編輯器高度',
'snippets.scriptEditor.modalTitle': '編輯指令碼',
'snippets.targets.title': '目標主機',
'snippets.targets.add': '新增目標主機',
'snippets.targets.title': '執行目標',
'snippets.targets.add': '新增目標',
'snippets.targets.selectHosts': '選擇主機',
'snippets.targets.selectGroups': '選擇分組',
'snippets.targets.noGroups': '未找到分組',
'snippets.history.title': 'Shell 歷史',
'snippets.history.subtitle': '{count} 條指令',
'snippets.history.emptyTitle': '暫無 Shell 歷史',
@@ -1,11 +1,14 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
runConnectScriptsSequential,
selectScriptOverlayRun,
setScriptRuns,
waitForScriptRun,
} from './scriptAutomationCoordinator.ts';
import type { ScriptRun } from '@/types/global/netcatty-bridge-script.d.ts';
import type { Snippet } from '@/domain/models';
import { netcattyBridge } from '@/infrastructure/services/netcattyBridge.ts';
test('waitForScriptRun resolves when run is already completed on subscribe', async () => {
const runId = 'run-already-done';
@@ -64,3 +67,182 @@ test('selectScriptOverlayRun does not resurface older completed runs after dismi
dismissedRunIds.add(latestRun.runId);
assert.equal(selectScriptOverlayRun([olderRun, latestRun], 'sess1', dismissedRunIds), undefined);
});
test('runConnectScriptsSequential cancels only its own queued run and waits for stop', async () => {
const originalGet = netcattyBridge.get;
const sessionId = 'sess-connect-abort';
let queuedRunId: string | undefined;
let releaseStop: (() => void) | undefined;
const scriptStopCalls: string[] = [];
const storage = new Map<string, string>();
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => { storage.set(key, String(value)); },
removeItem: (key: string) => { storage.delete(key); },
clear: () => { storage.clear(); },
},
});
setScriptRuns([{
runId: 'unrelated-active-run',
scriptId: 'manual-script',
sessionId,
status: 'running',
startedAt: Date.now(),
logs: [],
}]);
netcattyBridge.get = () => ({
scriptRun: async (params) => {
queuedRunId = params.runId;
assert.equal(params.returnWhenQueued, true);
return { runId: params.runId!, runIds: [params.runId!] };
},
scriptStop: (id: string) => new Promise((resolve) => {
scriptStopCalls.push(id);
releaseStop = () => resolve({ ok: true });
}),
}) as ReturnType<typeof netcattyBridge.get>;
const controller = new AbortController();
let stopCurrentRun: (() => Promise<void>) | null = null;
const snippet: Snippet = {
id: 'connect-script',
label: 'Connect',
command: 'nct.session.sleep(60)',
kind: 'script',
};
try {
const running = runConnectScriptsSequential({
scripts: [snippet],
sessionId,
signal: controller.signal,
onCancelableRunChange: (stop) => { stopCurrentRun = stop; },
});
void running.catch(() => {});
await Promise.resolve();
controller.abort();
for (let attempt = 0; attempt < 10 && scriptStopCalls.length === 0; attempt += 1) {
await new Promise<void>((resolve) => setImmediate(resolve));
}
assert.deepEqual(scriptStopCalls, [queuedRunId]);
assert.notEqual(queuedRunId, 'unrelated-active-run');
assert.equal(typeof stopCurrentRun, 'function');
let settled = false;
void running.finally(() => { settled = true; }).catch(() => {});
await Promise.resolve();
assert.equal(settled, false);
releaseStop?.();
await assert.rejects(
() => running,
(error: unknown) => error instanceof DOMException && error.name === 'AbortError',
);
} finally {
netcattyBridge.get = originalGet;
setScriptRuns([]);
Reflect.deleteProperty(globalThis, 'localStorage');
}
});
test('runConnectScriptsSequential retries the exact stop after a transient failure', async () => {
const originalGet = netcattyBridge.get;
const storage = new Map<string, string>();
const stopCalls: string[] = [];
let stopCurrentRun: (() => Promise<void>) | null = null;
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => { storage.set(key, String(value)); },
removeItem: (key: string) => { storage.delete(key); },
clear: () => { storage.clear(); },
},
});
netcattyBridge.get = () => ({
scriptRun: async (params) => ({ runId: params.runId!, runIds: [params.runId!] }),
scriptStop: async (id: string) => {
stopCalls.push(id);
return { ok: stopCalls.length > 1 };
},
}) as ReturnType<typeof netcattyBridge.get>;
const controller = new AbortController();
try {
const running = runConnectScriptsSequential({
scripts: [{ id: 'retry-stop', label: 'Retry stop', command: 'await nct.sleep(60)', kind: 'script' }],
sessionId: 'sess-retry-stop',
signal: controller.signal,
onCancelableRunChange: (stop) => { stopCurrentRun = stop; },
});
void running.catch(() => {});
for (let attempt = 0; attempt < 10 && !stopCurrentRun; attempt += 1) {
await new Promise<void>((resolve) => setImmediate(resolve));
}
controller.abort();
await assert.rejects(() => running, /could not be stopped/);
assert.equal(typeof stopCurrentRun, 'function');
await stopCurrentRun!();
assert.equal(stopCalls.length, 2);
} finally {
netcattyBridge.get = originalGet;
setScriptRuns([]);
Reflect.deleteProperty(globalThis, 'localStorage');
}
});
test('runConnectScriptsSequential does not treat a script error named Aborted as cancellation', async () => {
const originalGet = netcattyBridge.get;
const storage = new Map<string, string>();
const scriptStopCalls: string[] = [];
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => { storage.set(key, String(value)); },
removeItem: (key: string) => { storage.delete(key); },
clear: () => { storage.clear(); },
},
});
netcattyBridge.get = () => ({
scriptRun: async (params) => {
setScriptRuns([{
runId: params.runId!,
scriptId: params.scriptId,
sessionId: params.sessionId!,
status: 'failed',
startedAt: Date.now() - 10,
endedAt: Date.now(),
error: 'Aborted',
logs: [],
}]);
return { runId: params.runId!, runIds: [params.runId!] };
},
scriptStop: async (id: string) => {
scriptStopCalls.push(id);
return { ok: true };
},
}) as ReturnType<typeof netcattyBridge.get>;
try {
await assert.rejects(
() => runConnectScriptsSequential({
scripts: [{ id: 'fails', label: 'Fails', command: "throw new Error('Aborted')", kind: 'script' }],
sessionId: 'sess-real-error',
signal: new AbortController().signal,
}),
/Aborted/,
);
assert.deepEqual(scriptStopCalls, []);
} finally {
netcattyBridge.get = originalGet;
setScriptRuns([]);
Reflect.deleteProperty(globalThis, 'localStorage');
}
});
@@ -83,6 +83,8 @@ export function getActiveScriptRunForSession(sessionId: string): ScriptRun | und
}
export async function runAutomationScript(params: {
runId?: string;
returnWhenQueued?: boolean;
snippet: Snippet;
sessionId: string;
sessionIds?: string[];
@@ -104,6 +106,8 @@ export async function runAutomationScript(params: {
throw new Error('Script bridge unavailable');
}
return bridge.scriptRun({
runId: params.runId,
returnWhenQueued: params.returnWhenQueued,
scriptId: params.snippet.id,
scriptLabel: params.snippet.label,
content: params.snippet.command,
@@ -176,6 +180,7 @@ export async function runConnectScriptsSequential(params: {
scripts: Snippet[];
sessionId: string;
signal?: AbortSignal;
onCancelableRunChange?: (stopCurrentRun: (() => Promise<void>) | null) => void;
onScriptStart?: (snippet: Snippet) => void;
onScriptComplete?: (snippet: Snippet) => void;
sessionMeta?: {
@@ -185,18 +190,63 @@ export async function runConnectScriptsSequential(params: {
username?: string;
};
}): Promise<void> {
for (const snippet of params.scripts) {
const throwIfAborted = () => {
if (params.signal?.aborted) {
throw new Error('Aborted');
throw new DOMException('Connect script run cancelled', 'AbortError');
}
};
for (const snippet of params.scripts) {
throwIfAborted();
const runId = crypto.randomUUID();
let stopPromise: Promise<void> | undefined;
let stopped = false;
params.onScriptStart?.(snippet);
const { runId } = await runAutomationScript({
const queueAccepted = runAutomationScript({
runId,
returnWhenQueued: true,
snippet,
sessionId: params.sessionId,
sessionMeta: params.sessionMeta,
});
await waitForScriptRun(runId, { signal: params.signal });
params.onScriptComplete?.(snippet);
const stopThisRun = () => {
if (stopPromise) return stopPromise;
const attempt = queueAccepted.then(async () => {
const result = await stopScriptRun(runId);
if (!result.ok) {
throw new Error(`Connect script run could not be stopped: ${runId}`);
}
stopped = true;
});
stopPromise = attempt;
void attempt.catch(() => {
if (stopPromise === attempt) stopPromise = undefined;
});
return attempt;
};
params.onCancelableRunChange?.(stopThisRun);
const onAbort = () => {
void stopThisRun().catch(() => {});
};
params.signal?.addEventListener('abort', onAbort, { once: true });
try {
await queueAccepted;
throwIfAborted();
await waitForScriptRun(runId, { signal: params.signal });
params.onScriptComplete?.(snippet);
} catch (err) {
if (params.signal?.aborted) {
await stopThisRun();
throw new DOMException('Connect script run cancelled', 'AbortError');
}
throw err;
} finally {
params.signal?.removeEventListener('abort', onAbort);
if (!params.signal?.aborted || stopped) {
params.onCancelableRunChange?.(null);
}
}
}
}
+35 -5
View File
@@ -2,9 +2,9 @@ import assert from "node:assert/strict";
import test from "node:test";
import type { Host } from "../../domain/models";
import { applyGroupDefaults } from "../../domain/groupConfig";
import { prepareSerialConfigForSavedHost } from "../../domain/serialBackspace";
import { buildTelnetDeepLinkConnectionHost } from "../../domain/telnetDeepLink";
import { resolveEffectiveTerminalHost } from "../../domain/terminalHostResolution";
import {
createHostTerminalSession,
createSerialTerminalSession,
@@ -74,7 +74,34 @@ test("serial session factories snapshot effective legacy Backspace behavior", ()
assert.equal(explicitDefaultSession.serialConfig?.backspaceBehavior, "default");
});
test("saved quick-connect hosts can inherit Ctrl-H after moving into a group", () => {
test("workspace host factory creates a complete serial session", () => {
const session = createWorkspaceHostTerminalSession("session-serial", host({
protocol: "serial",
hostname: "COM7",
port: 57600,
username: "",
serialConfig: {
path: "COM7",
baudRate: 57600,
dataBits: 7,
stopBits: 2,
parity: "even",
},
}), "workspace-1");
assert.equal(session.workspaceId, "workspace-1");
assert.equal(session.protocol, "serial");
assert.deepEqual(session.serialConfig, {
path: "COM7",
baudRate: 57600,
dataBits: 7,
stopBits: 2,
parity: "even",
backspaceBehavior: "default",
});
});
test("workspace append snapshots serial Backspace behavior inherited from a group", () => {
const savedHost = host({
protocol: "serial",
hostname: "COM3",
@@ -87,12 +114,15 @@ test("saved quick-connect hosts can inherit Ctrl-H after moving into a group", (
backspaceBehavior: "default",
}),
});
const effectiveHost = applyGroupDefaults(savedHost, {
backspaceBehavior: "ctrl-h",
const effectiveHost = resolveEffectiveTerminalHost({
host: savedHost,
groupConfigs: [{ path: "network", backspaceBehavior: "ctrl-h" }],
proxyProfiles: [],
});
const session = createHostTerminalSession("session-1", effectiveHost);
const session = createWorkspaceHostTerminalSession("session-1", effectiveHost, "workspace-1");
assert.equal(session.workspaceId, "workspace-1");
assert.equal(session.serialConfig?.backspaceBehavior, "ctrl-h");
});
+1
View File
@@ -568,6 +568,7 @@ export function useSettingsStorageSync({
setShowRecentHostsState,
setShowSftpTabState,
setShellOnlyTabNumberShortcutsState,
setShowTabNumberBadgesState,
setDisableTerminalFontZoomState,
setRestorePreviousSessionState,
setRestoreTerminalCwdState,
@@ -162,7 +162,6 @@ export async function openTransferSftpSession(
if (
!wantDedicated
&& options?.sourceSessionId
&& !host.sftpSudo
&& bridge.openSftpForSession
) {
try {
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";
import type { Host } from "../../../domain/models";
import { ensureRemoteSftpSession } from "./ensureRemoteSftpSession";
import { ensureRemoteSftpSession, probeSftpSession } from "./ensureRemoteSftpSession";
import type { SftpPane } from "./types";
const host = {
@@ -37,6 +37,24 @@ const remotePane = (connectionId: string): SftpPane => ({
const noopReleaseConnection = async () => {};
test("SFTP session probe accepts a virtual root directory", async () => {
const calls: Array<[string, string]> = [];
const ok = await probeSftpSession({
realpathSftp: async (sftpId, remotePath) => {
calls.push([sftpId, remotePath]);
return "/";
},
}, "sftp-jumpserver");
assert.equal(ok, true);
assert.deepEqual(calls, [["sftp-jumpserver", "."]]);
});
test("SFTP session probe does not require home discovery", async () => {
const ok = await probeSftpSession(undefined, "sftp-live");
assert.equal(ok, true);
});
test("returns an existing mapped SFTP session without reconnecting", async () => {
let connectCalls = 0;
const sftpId = await ensureRemoteSftpSession({
@@ -3,6 +3,24 @@ import type { MutableRefObject } from "react";
import { isSessionError } from "./errors";
import type { SftpPane } from "./types";
export interface SftpSessionProbeBridge {
realpathSftp?: (sftpId: string, path: string) => Promise<string>;
}
/**
* Probe the SFTP protocol itself instead of using remote home discovery.
* Restricted/chroot servers can deny SSH exec or expose '/' as their virtual
* root while the SFTP session remains fully usable.
*/
export async function probeSftpSession(
bridge: SftpSessionProbeBridge | null | undefined,
sftpId: string,
): Promise<boolean> {
if (!bridge?.realpathSftp) return true;
await bridge.realpathSftp(sftpId, ".");
return true;
}
export interface EnsureRemoteSftpSessionParams {
side: "left" | "right";
getActivePane: (side: "left" | "right") => SftpPane | null;
+27
View File
@@ -0,0 +1,27 @@
import assert from "node:assert/strict";
import test from "node:test";
import { isMissingStatError } from "./errors";
test("isMissingStatError accepts only true path absence codes", () => {
for (const code of [2, "ENOENT", "NO_SUCH_FILE", "SSH_FX_NO_SUCH_FILE"] as const) {
const error = new Error("missing") as Error & { code: string | number };
error.code = code;
assert.equal(isMissingStatError(error), true, String(code));
}
assert.equal(isMissingStatError(new Error("ENOENT")), true);
});
test("isMissingStatError rejects unsupported LSTAT and other failures", () => {
const enotsup = new Error("Remote server does not support LSTAT") as Error & {
code: string;
lstatUnavailable: boolean;
};
enotsup.code = "ENOTSUP";
enotsup.lstatUnavailable = true;
assert.equal(isMissingStatError(enotsup), false);
const eperm = new Error("denied") as Error & { code: string };
eperm.code = "EPERM";
assert.equal(isMissingStatError(eperm), false);
assert.equal(isMissingStatError(new Error("channel closed")), false);
});
+10
View File
@@ -17,3 +17,13 @@ export const isSessionError = (err: unknown): boolean => {
msg.includes("timed out")
);
};
/** True absence only — ENOTSUP / unknown inspection must not map to "no conflict". */
export const isMissingStatError = (error: unknown): boolean => {
const code = (error as { code?: string | number } | null)?.code;
return code === 2
|| code === "ENOENT"
|| code === "NO_SUCH_FILE"
|| code === "SSH_FX_NO_SUCH_FILE"
|| String((error as { message?: string } | null)?.message || "").trim() === "ENOENT";
};
+30 -23
View File
@@ -1,6 +1,7 @@
import { useCallback } from "react";
import type { SftpFilenameEncoding, TransferTask } from "../../../domain/models";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { isMissingStatError } from "./errors";
import type { SftpPane } from "./types";
import { getParentPath, joinPath } from "./utils";
@@ -24,28 +25,36 @@ export function useSftpTransferConflictOps() {
): Promise<{ type?: "file" | "directory" | "symlink"; size: number; mtime: number } | null> => {
if (!targetPane.connection) return null;
if (targetPane.connection.isLocal) {
const stat = await netcattyBridge.get()?.statLocal?.(targetPath);
try {
if (targetPane.connection.isLocal) {
const bridge = netcattyBridge.get();
const stat = await (bridge?.lstatLocal ?? bridge?.statLocal)?.(targetPath);
if (!stat) return null;
return {
type: stat.type as "file" | "directory" | "symlink" | undefined,
size: stat.size,
mtime: stat.lastModified || Date.now(),
};
}
if (!targetSftpId) return null;
const bridge = netcattyBridge.get();
const stat = await (bridge?.lstatSftp ?? bridge?.statSftp)?.(
targetSftpId,
targetPath,
targetEncoding,
);
if (!stat) return null;
return {
type: stat.type as "file" | "directory" | "symlink" | undefined,
size: stat.size,
mtime: stat.lastModified || Date.now(),
};
} catch (error) {
// Missing path = no conflict. ENOTSUP / unknown type must fail closed.
if (isMissingStatError(error)) return null;
throw error;
}
if (!targetSftpId) return null;
const stat = await netcattyBridge.get()?.statSftp?.(
targetSftpId,
targetPath,
targetEncoding,
);
if (!stat) return null;
return {
type: stat.type as "file" | "directory" | "symlink" | undefined,
size: stat.size,
mtime: stat.lastModified || Date.now(),
};
},
[],
);
@@ -64,12 +73,9 @@ export function useSftpTransferConflictOps() {
const suffix = index === 1 ? " (copy)" : ` (copy ${index})`;
const fileName = `${baseName}${suffix}${ext}`;
const targetPath = joinPath(parentPath, fileName);
try {
const existing = await statTargetPath(targetPane, targetSftpId, targetPath, targetEncoding);
if (!existing) return { fileName, targetPath };
} catch {
return { fileName, targetPath };
}
// Unsupported LSTAT must propagate — do not treat as a free name.
const existing = await statTargetPath(targetPane, targetSftpId, targetPath, targetEncoding);
if (!existing) return { fileName, targetPath };
}
const fallbackName = `${baseName} (copy ${Date.now()})${ext}`;
@@ -84,18 +90,19 @@ export function useSftpTransferConflictOps() {
targetPane: SftpPane,
targetSftpId: string | null,
targetEncoding: SftpFilenameEncoding,
expectedType?: "file" | "directory" | "symlink",
) => {
if (!targetPane.connection) return;
if (targetPane.connection.isLocal) {
const deleteLocalFile = netcattyBridge.get()?.deleteLocalFile;
if (!deleteLocalFile) throw new Error("Local delete unavailable");
await deleteLocalFile(task.targetPath);
await deleteLocalFile(task.targetPath, expectedType);
return;
}
if (!targetSftpId) throw new Error("Target SFTP session not found");
const deleteSftp = netcattyBridge.get()?.deleteSftp;
if (!deleteSftp) throw new Error("SFTP delete unavailable");
await deleteSftp(targetSftpId, task.targetPath, targetEncoding);
await deleteSftp(targetSftpId, task.targetPath, targetEncoding, expectedType);
},
[],
);
@@ -3,8 +3,12 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import {
buildSftpConnectInFlightKey,
buildSftpHomeDirCandidates,
runSftpConnectOnceByKey,
createSftpConnectionId,
createPinnedReconnectSideResolver,
openSftpConnectionOnce,
@@ -15,12 +19,62 @@ import {
resolvePinnedReconnectSide,
} from "./useSftpConnections.ts";
test("home dir candidates prefer user home then root", () => {
assert.deepEqual(buildSftpHomeDirCandidates("deploy"), ["/home/deploy", "/root"]);
assert.deepEqual(buildSftpHomeDirCandidates("root"), ["/root"]);
assert.deepEqual(buildSftpHomeDirCandidates(undefined), ["/root"]);
assert.deepEqual(buildSftpHomeDirCandidates(null), ["/root"]);
});
test("connection ids stay unique even when connects start in the same millisecond", () => {
const ids = ["uuid-a", "uuid-b"];
assert.equal(createSftpConnectionId("left", () => ids.shift()!), "left-uuid-a");
assert.equal(createSftpConnectionId("left", () => ids.shift()!), "left-uuid-b");
});
test("runSftpConnectOnceByKey shares an in-flight connect for the same tab and endpoint", async () => {
const inFlight = new Map<string, Promise<void>>();
let runs = 0;
let releaseFirst: (() => void) | undefined;
const first = runSftpConnectOnceByKey(inFlight, "left:tab-1:host-key:ssh-session-1:/home", async () => {
runs += 1;
await new Promise<void>((resolve) => {
releaseFirst = resolve;
});
});
const second = runSftpConnectOnceByKey(inFlight, "left:tab-1:host-key:ssh-session-1:/home", async () => {
runs += 1;
});
assert.equal(runs, 1);
releaseFirst?.();
await Promise.all([first, second]);
assert.equal(runs, 1);
assert.equal(inFlight.size, 0);
});
test("buildSftpConnectInFlightKey uses the allocated tab id for forced new tabs", () => {
const first = buildSftpConnectInFlightKey({
side: "left",
tabId: "new-tab-a",
targetConnectionKey: "host-key",
sourceSessionId: "ssh-session-1",
initialPath: "/home",
forceNewTab: true,
});
const second = buildSftpConnectInFlightKey({
side: "left",
tabId: "new-tab-b",
targetConnectionKey: "host-key",
sourceSessionId: "ssh-session-1",
initialPath: "/home",
forceNewTab: true,
});
assert.notEqual(first, second);
});
const openOptions = {
sessionId: "sftp-request-1",
hostname: "192.168.9.138",
@@ -73,6 +127,43 @@ test("openSftpWithSessionPreference falls back to normal SFTP when session reuse
assert.deepEqual(calls, ["openForSession:ssh-session-1", "openSftp:sftp-request-1"]);
});
test("openSftpWithSessionPreference tries session reuse for sudo SFTP before fresh auth", async () => {
const calls: string[] = [];
let passedOptions: NetcattySSHOptions | undefined;
const sftpId = await openSftpWithSessionPreference({
bridge: {
openSftpForSession: async (sessionId: string, options?: NetcattySSHOptions) => {
calls.push(`openForSession:${sessionId}`);
passedOptions = options;
return "sudo-session-backed-sftp";
},
openSftp: async () => {
calls.push("openSftp");
return "fresh-sftp";
},
},
sourceSessionId: "ssh-session-1",
openOptions: {
...openOptions,
sudo: true,
password: "sudo-pass",
},
});
assert.equal(sftpId, "sudo-session-backed-sftp");
assert.deepEqual(calls, ["openForSession:ssh-session-1"]);
assert.equal(passedOptions?.sudo, true);
assert.equal(passedOptions?.password, "sudo-pass");
});
test("remote browse connect does not discard sourceSessionId for sudo hosts", () => {
const source = readFileSync(new URL("./useSftpConnections.ts", import.meta.url), "utf8");
assert.doesNotMatch(
source,
/host\.sftpSudo\s*\?\s*undefined\s*:\s*options\?\.sourceSessionId/,
);
});
test("openSftpWithSessionPreference opens normal SFTP without a source session", async () => {
const calls: string[] = [];
const sftpId = await openSftpWithSessionPreference({
+87 -26
View File
@@ -97,6 +97,13 @@ export async function releaseSftpConnectionMetadata(params: {
params.onRemoteSessionClosed?.(sftpId);
}
/** Hardcoded home-path candidates when SSH exec / listable realpath fail. */
export function buildSftpHomeDirCandidates(username?: string | null): string[] {
if (username === "root") return ["/root"];
if (username) return [`/home/${username}`, "/root"];
return ["/root"];
}
export function createSftpConnectionId(
side: "left" | "right",
randomUUID: () => string = () => crypto.randomUUID(),
@@ -176,6 +183,41 @@ export function createPinnedReconnectSideResolver(
};
}
export function runSftpConnectOnceByKey(
inFlight: Map<string, Promise<void>>,
key: string,
run: () => Promise<void>,
): Promise<void> {
const existing = inFlight.get(key);
if (existing) return existing;
const promise = run().finally(() => {
if (inFlight.get(key) === promise) {
inFlight.delete(key);
}
});
inFlight.set(key, promise);
return promise;
}
export function buildSftpConnectInFlightKey(params: {
side: "left" | "right";
tabId: string;
targetConnectionKey: string;
sourceSessionId?: string;
initialPath?: string;
forceNewTab?: boolean;
}): string {
return [
params.side,
params.tabId,
params.targetConnectionKey,
params.sourceSessionId ?? "",
params.initialPath ?? "",
params.forceNewTab ? "force-new-tab" : "",
].join("\u0000");
}
interface UseSftpConnectionsResult {
connect: (side: "left" | "right", host: Host | "local", options?: SftpConnectOptions) => Promise<void>;
disconnect: (side: "left" | "right") => Promise<void>;
@@ -249,6 +291,7 @@ export const useSftpConnections = ({
const [hostKeyVerification, setHostKeyVerification] = useState<SftpHostKeyVerificationState | null>(null);
const hostKeyVerificationRef = useRef<(SftpHostKeyVerificationState & { requestId: string; sessionId: string }) | null>(null);
const activeHostKeySessionsRef = useRef<Map<string, { side: "left" | "right"; tabId: string }>>(new Map());
const connectInFlightRef = useRef<Map<string, Promise<void>>>(new Map());
const setPendingHostKeyVerification = useCallback((
next: (SftpHostKeyVerificationState & { requestId: string; sessionId: string }) | null,
@@ -426,21 +469,31 @@ export const useSftpConnections = ({
// immediately, avoiding race conditions with deferred effects.
options?.onTabCreated?.(activeTabId);
const connectionId = createSftpConnectionId(side);
const connectInFlightKey = buildSftpConnectInFlightKey({
side,
tabId: activeTabId,
targetConnectionKey,
sourceSessionId: host === "local" ? undefined : options?.sourceSessionId,
initialPath: effectiveInitialPath,
forceNewTab: options?.forceNewTab,
});
navSeqRef.current[side] += 1;
const connectRequestId = navSeqRef.current[side];
const getTargetPane = () => {
const targetSide = resolveTargetSide();
const tabs = targetSide === "left" ? leftTabsRef.current.tabs : rightTabsRef.current.tabs;
return tabs.find((tab) => tab.id === activeTabId) ?? null;
};
const isTargetConnectionCurrent = () => {
const pane = getTargetPane();
if (!pane) return false;
if (pane.connection?.id === connectionId) return true;
return !pane.connection && navSeqRef.current[side] === connectRequestId;
};
return runSftpConnectOnceByKey(connectInFlightRef.current, connectInFlightKey, async () => {
const connectionId = createSftpConnectionId(side);
navSeqRef.current[side] += 1;
const connectRequestId = navSeqRef.current[side];
const getTargetPane = () => {
const targetSide = resolveTargetSide();
const tabs = targetSide === "left" ? leftTabsRef.current.tabs : rightTabsRef.current.tabs;
return tabs.find((tab) => tab.id === activeTabId) ?? null;
};
const isTargetConnectionCurrent = () => {
const pane = getTargetPane();
if (!pane) return false;
if (pane.connection?.id === connectionId) return true;
return !pane.connection && navSeqRef.current[side] === connectRequestId;
};
const isTargetConnectionAtPath = (path: string) => {
const connection = getTargetPane()?.connection;
if (!connection) return navSeqRef.current[side] === connectRequestId;
@@ -573,9 +626,7 @@ export const useSftpConnections = ({
sharedHostCache?.path,
);
// Sudo never reuses a terminal shell; normalize before UI state so
// reusedConnection (spinner/overlay) matches the actual open path.
const sourceSessionId = host.sftpSudo ? undefined : options?.sourceSessionId;
const sourceSessionId = options?.sourceSessionId;
const connection: SftpConnection = {
id: connectionId,
@@ -698,15 +749,7 @@ export const useSftpConnections = ({
}
if (!detected) {
const candidates: string[] = [];
if (credentials.username === "root") {
candidates.push("/root");
} else if (credentials.username) {
candidates.push(`/home/${credentials.username}`);
candidates.push("/root");
} else {
candidates.push("/root");
}
const candidates = buildSftpHomeDirCandidates(credentials.username);
const statSftp = bridge?.statSftp;
if (statSftp) {
for (const candidate of candidates) {
@@ -784,6 +827,23 @@ export const useSftpConnections = ({
// root also failed
}
}
// Last resort: home candidates. Covers provisional "/" from realpath
// when discovery treated root as home and listing it failed, so
// /home/<user> or /root can still recover the session.
if (!fallbackSucceeded) {
for (const candidate of buildSftpHomeDirCandidates(credentials.username)) {
if (candidate === startPath) continue;
try {
files = await listRemoteFiles(sftpId, candidate, filenameEncoding);
startPath = candidate;
homeDir = candidate;
fallbackSucceeded = true;
break;
} catch {
// Ignore missing/permission errors
}
}
}
if (!fallbackSucceeded) {
throw new Error("Cannot list any remote directory");
}
@@ -855,6 +915,7 @@ export const useSftpConnections = ({
unsubSftpProgress?.();
}
}
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
@@ -842,6 +842,9 @@ export const useSftpExternalOperations = (
writeLocalFile: bridge?.writeLocalFile,
mkdirLocal: bridge?.mkdirLocal,
statLocal: bridge?.statLocal,
// No-follow stats so Replace unlinks a same-named symlink instead of
// writing through it (uploadService prefers lstat* over followed stat*).
lstatLocal: bridge?.lstatLocal,
deleteLocalFile: bridge?.deleteLocalFile,
stageUploadFile: bridge?.stageUploadFile,
cancelStagedUploadFile: bridge?.cancelStagedUploadFile,
@@ -857,10 +860,18 @@ export const useSftpExternalOperations = (
if (!b?.statSftp) return null;
return b.statSftp(sftpId, path);
},
deleteSftp: async (sftpId: string, path: string) => {
// Only wire when present so uploadService can fall back via `lstat ?? stat`.
lstatSftp: bridge?.lstatSftp
? async (sftpId: string, path: string) => {
const b = netcattyBridge.get();
if (!b?.lstatSftp) return null;
return b.lstatSftp(sftpId, path);
}
: undefined,
deleteSftp: async (sftpId: string, path: string, expectedType) => {
const b = netcattyBridge.get();
if (b?.deleteSftp) {
await b.deleteSftp(sftpId, path);
await b.deleteSftp(sftpId, path, undefined, expectedType);
}
},
// Stream transfer for large files (avoids loading into memory).
+61 -3
View File
@@ -12,6 +12,7 @@ import {
describeSftpExistingKind,
describeSftpIncomingKind,
getSftpConflictTypeKey,
shouldUnlinkSftpConflictBeforeReplace,
} from "../../../domain/sftpConflict";
import {
findActivePathConflict,
@@ -23,6 +24,7 @@ import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge"
import { logger } from "../../../lib/logger";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
import { SftpPane } from "./types";
import { isMissingStatError } from "./errors";
import { useSftpDirectoryTransferOps } from "./transferDirectoryOps";
import { useSftpTransferConflictOps } from "./transferConflictOps";
import { useSftpTransferTaskOps } from "./transferTaskOps";
@@ -444,7 +446,7 @@ export const useSftpTransfers = ({
cleanupTaskArtifacts,
});
const { statTargetPath, getDuplicateTarget } = useSftpTransferConflictOps();
const { statTargetPath, getDuplicateTarget, deleteTargetPath } = useSftpTransferConflictOps();
const { transferFile, transferDirectory } = useSftpDirectoryTransferOps({
ownerId,
@@ -740,8 +742,10 @@ export const useSftpTransfers = ({
newModified: sourceStat?.mtime || Date.now(),
};
}
} catch {
// ignore
} catch (error) {
// Missing path = no conflict. ENOTSUP / unknown type fail closed.
if (isMissingStatError(error)) return null;
throw error;
}
return null;
})();
@@ -783,6 +787,13 @@ export const useSftpTransfers = ({
return "failed";
}
if (
defaultAction === "replace"
&& shouldUnlinkSftpConflictBeforeReplace(conflict.existingType)
) {
await deleteTargetPath(task, targetPane, targetSftpId, targetEncoding, "symlink");
}
const duplicateTarget = defaultAction === "duplicate"
? await getDuplicateTarget(task, targetPane, targetSftpId, targetEncoding)
: null;
@@ -1537,6 +1548,7 @@ export const useSftpTransfers = ({
const updatedTasks: TransferTask[] = [];
const blockedReplaceTasks: Array<{ task: TransferTask; conflict: FileConflict }> = [];
const failedReplaceTasks: Array<{ task: TransferTask; error: string }> = [];
for (const affectedTask of affectedTasks) {
if (cancelledTasksRef.current.has(affectedTask.id)) continue;
@@ -1568,6 +1580,34 @@ export const useSftpTransfers = ({
blockedReplaceTasks.push({ task: affectedTask, conflict: affectedConflict });
continue;
}
if (
affectedConflict
&& shouldUnlinkSftpConflictBeforeReplace(affectedConflict.existingType)
) {
const endpoints = resolveTaskEndpoints(affectedTask);
if (!endpoints?.targetPane.connection) continue;
const targetSftpId = endpoints.targetPane.connection.isLocal
? null
: sftpSessionsRef.current.get(endpoints.targetPane.connection.id) ?? null;
const targetEncoding = endpoints.targetPane.connection.isLocal
? "auto"
: endpoints.targetPane.filenameEncoding || "auto";
try {
await deleteTargetPath(
affectedTask,
endpoints.targetPane,
targetSftpId,
targetEncoding,
"symlink",
);
} catch (error) {
failedReplaceTasks.push({
task: affectedTask,
error: error instanceof Error ? error.message : String(error),
});
continue;
}
}
updatedTask = {
...affectedTask,
skipConflictCheck: true,
@@ -1607,6 +1647,24 @@ export const useSftpTransfers = ({
);
}
if (failedReplaceTasks.length > 0) {
const failedErrors = new Map(
failedReplaceTasks.map(({ task: failedTask, error }) => [failedTask.id, error]),
);
setTransfers((prev) =>
prev.map((candidate) => failedErrors.has(candidate.id)
? {
...candidate,
status: "failed" as TransferStatus,
endTime: Date.now(),
error: failedErrors.get(candidate.id),
conflict: undefined,
}
: candidate,
),
);
}
const liveUpdatedTasks = updatedTasks.filter((candidate) => !cancelledTasksRef.current.has(candidate.id));
const updatedTaskMap = new Map(liveUpdatedTasks.map((updatedTask) => [updatedTask.id, updatedTask]));
setTransfers((prev) =>
@@ -6,6 +6,7 @@ import {
canReuseTerminalConnection,
createCopiedTerminalSessionClone,
createSplitTerminalSessionClone,
isTerminalSessionEligibleForSftpReuse,
} from "./terminalConnectionReuse";
const session = (overrides: Partial<TerminalSession> = {}): TerminalSession => ({
@@ -24,6 +25,14 @@ test("connected SSH sessions can reuse their authenticated connection", () => {
assert.equal(canReuseTerminalConnection(session({ protocol: undefined })), true);
});
test("SSH sessions stay SFTP-linkable while reconnecting", () => {
assert.equal(isTerminalSessionEligibleForSftpReuse(session({ status: "connecting" })), true);
assert.equal(isTerminalSessionEligibleForSftpReuse(session({ status: "disconnected" })), true);
assert.equal(isTerminalSessionEligibleForSftpReuse(session({ protocol: "local" })), false);
assert.equal(isTerminalSessionEligibleForSftpReuse(session({ moshEnabled: true })), false);
assert.equal(isTerminalSessionEligibleForSftpReuse(session({ etEnabled: true })), false);
});
test("non-SSH or unavailable sessions do not reuse a connection", () => {
assert.equal(canReuseTerminalConnection(session({ status: "connecting" })), false);
assert.equal(canReuseTerminalConnection(session({ status: "disconnected" })), false);
+9 -5
View File
@@ -1,14 +1,18 @@
import type { TerminalSession } from "../../domain/models";
export function canReuseTerminalConnection(session: TerminalSession): boolean {
/** SSH (non-mosh/et) sessions that can back SFTP once the transport is up. */
export function isTerminalSessionEligibleForSftpReuse(session: TerminalSession): boolean {
return (
(session.protocol === "ssh" || session.protocol === undefined) &&
!session.moshEnabled &&
!session.etEnabled &&
session.status === "connected"
(session.protocol === "ssh" || session.protocol === undefined)
&& !session.moshEnabled
&& !session.etEnabled
);
}
export function canReuseTerminalConnection(session: TerminalSession): boolean {
return isTerminalSessionEligibleForSftpReuse(session) && session.status === "connected";
}
type CloneSessionOptions = {
id: string;
localShellType?: TerminalSession["shellType"];
+335
View File
@@ -490,6 +490,341 @@ test("does not replace an existing directory when uploading a same-named file",
assert.match(results[0].error ?? "", /directory/i);
});
test("file replace leaves the remote destination so upload can restore mode bits", async () => {
// Deleting before overwrite recreates the inode with umask defaults and drops
// bits like +x (#2954). Keep the target so stage+rename can restore mode.
const file = new File(["new-bytes"], "tool.sh", { lastModified: 1234 });
Object.defineProperty(file, "path", { value: "/local/tool.sh" });
const deletedPaths: string[] = [];
const deletedTypes: Array<string | undefined> = [];
const uploadedPaths: string[] = [];
const results = await uploadFromFileList(
[file],
{
targetPath: "/usr/local/bin",
sftpId: "sftp-1",
isLocal: false,
bridge: {
mkdirSftp: async () => {},
statSftp: async (_sftpId, path) =>
path === "/usr/local/bin/tool.sh"
? { type: "file", size: 9, lastModified: 1000 }
: null,
deleteSftp: async (_sftpId, path, expectedType) => {
deletedPaths.push(path);
deletedTypes.push(expectedType);
},
startStreamTransfer: async ({ targetPath: path }) => {
uploadedPaths.push(path);
return { transferId: "upload-1" };
},
},
joinPath: (base, name) => `${base}/${name}`,
resolveConflict: async () => "replace",
},
);
assert.deepEqual(deletedPaths, [], "file replace must not delete before upload");
assert.deepEqual(uploadedPaths, ["/usr/local/bin/tool.sh"]);
assert.equal(results.length, 1);
assert.equal(results[0].success, true);
});
test("file replace unlinks an existing symlink before upload", async () => {
// Leaving the symlink would let in-place upload follow it and overwrite the
// link target outside the displayed directory.
// Conflict checks use lstatSftp so Replace unlinks the link first.
const file = new File(["new-bytes"], "tool.sh", { lastModified: 1234 });
Object.defineProperty(file, "path", { value: "/local/tool.sh" });
const deletedPaths: string[] = [];
const deletedTypes: Array<string | undefined> = [];
const uploadedPaths: string[] = [];
const results = await uploadFromFileList(
[file],
{
targetPath: "/usr/local/bin",
sftpId: "sftp-1",
isLocal: false,
bridge: {
mkdirSftp: async () => {},
// Followed stat would report type "file" and skip pre-delete.
statSftp: async () => ({ type: "file", size: 4096, lastModified: 1000 }),
lstatSftp: async (_sftpId, path) =>
path === "/usr/local/bin/tool.sh"
? { type: "symlink", size: 12, lastModified: 1000 }
: null,
deleteSftp: async (_sftpId, path, expectedType) => {
deletedPaths.push(path);
deletedTypes.push(expectedType);
},
startStreamTransfer: async ({ targetPath: path }) => {
uploadedPaths.push(path);
return { transferId: "upload-1" };
},
},
joinPath: (base, name) => `${base}/${name}`,
resolveConflict: async () => "replace",
},
);
assert.deepEqual(deletedPaths, ["/usr/local/bin/tool.sh"]);
assert.deepEqual(deletedTypes, ["symlink"]);
assert.deepEqual(uploadedPaths, ["/usr/local/bin/tool.sh"]);
assert.equal(results.length, 1);
assert.equal(results[0].success, true);
});
test("LSTAT ENOTSUP is not treated as an absent destination during conflict check", async () => {
// If unsupported LSTAT is swallowed as "missing", upload skips Replace and may
// write through an existing symlink to a target outside the displayed dir.
const file = new File(["new-bytes"], "tool.sh", { lastModified: 1234 });
Object.defineProperty(file, "path", { value: "/local/tool.sh" });
let uploaded = 0;
let conflictPrompts = 0;
await assert.rejects(
() => uploadFromFileList(
[file],
{
targetPath: "/usr/local/bin",
sftpId: "sftp-1",
isLocal: false,
bridge: {
mkdirSftp: async () => {},
lstatSftp: async () => {
const error = new Error(
"Remote server does not support LSTAT; cannot classify path without following symlinks",
) as Error & { code: string; lstatUnavailable: boolean };
error.code = "ENOTSUP";
error.lstatUnavailable = true;
throw error;
},
startStreamTransfer: async () => {
uploaded += 1;
return { transferId: "upload-1" };
},
},
joinPath: (base, name) => `${base}/${name}`,
resolveConflict: async () => {
conflictPrompts += 1;
return "replace";
},
},
),
(error: Error & { code?: string }) => {
assert.equal(error.code, "ENOTSUP");
assert.match(String(error.message), /does not support LSTAT/i);
return true;
},
);
assert.equal(uploaded, 0, "must not upload when destination type is unknown");
assert.equal(conflictPrompts, 0, "must not pretend destination is missing");
});
test("ENOENT from lstat still means absent destination for conflict check", async () => {
const file = new File(["new-bytes"], "tool.sh", { lastModified: 1234 });
Object.defineProperty(file, "path", { value: "/local/tool.sh" });
const uploadedPaths: string[] = [];
const results = await uploadFromFileList(
[file],
{
targetPath: "/usr/local/bin",
sftpId: "sftp-1",
isLocal: false,
bridge: {
mkdirSftp: async () => {},
lstatSftp: async () => {
const error = new Error("No such file") as Error & { code: string };
error.code = "ENOENT";
throw error;
},
startStreamTransfer: async ({ targetPath: path }) => {
uploadedPaths.push(path);
return { transferId: "upload-1" };
},
},
joinPath: (base, name) => `${base}/${name}`,
resolveConflict: async () => {
throw new Error("absent destination must not prompt for conflict");
},
},
);
assert.deepEqual(uploadedPaths, ["/usr/local/bin/tool.sh"]);
assert.equal(results.length, 1);
assert.equal(results[0].success, true);
});
test("compressed-upload LSTAT ENOTSUP is not treated as an absent destination", async (t) => {
let compressedStarts = 0;
installCompressedUploadBridge(t, {
onStart: () => { compressedStarts += 1; },
});
let streamCalls = 0;
await assert.rejects(
() => uploadFromFileList(
[createPickedFolderFile()],
{
targetPath: "/remote",
sftpId: "sftp-1",
isLocal: false,
bridge: {
mkdirSftp: async () => {},
lstatSftp: async () => {
const error = new Error(
"Remote server does not support LSTAT; cannot classify path without following symlinks",
) as Error & { code: string; lstatUnavailable: boolean };
error.code = "ENOTSUP";
error.lstatUnavailable = true;
throw error;
},
startStreamTransfer: async (payload) => {
streamCalls += 1;
return { transferId: payload.transferId };
},
},
joinPath: (base, name) => `${base}/${name}`,
useCompressedUpload: true,
resolveConflict: async () => "merge",
},
),
(error: Error & { code?: string }) => {
assert.equal(error.code, "ENOTSUP");
return true;
},
);
assert.equal(compressedStarts, 0);
assert.equal(streamCalls, 0);
});
test("directory replace unlinks an existing symlink before mkdir", async () => {
// No-follow lstat reports symlink; Replace must unlink then create the dir
// (followed stat used to classify symlink-to-dir as directory and succeed).
const file = new File(["nested"], "file.txt", { lastModified: 1234 });
Object.defineProperty(file, "path", { value: "/local/docs/file.txt" });
Object.defineProperty(file, "webkitRelativePath", { value: "docs/file.txt" });
const deletedPaths: string[] = [];
const deletedTypes: Array<string | undefined> = [];
const madeDirs: string[] = [];
const uploadedPaths: string[] = [];
const results = await uploadFromFileList(
[file],
{
targetPath: "/remote",
sftpId: "sftp-1",
isLocal: false,
bridge: {
mkdirSftp: async (_sftpId, path) => {
madeDirs.push(path);
},
lstatSftp: async (_sftpId, path) =>
path === "/remote/docs"
? { type: "symlink", size: 8, lastModified: 1000 }
: null,
deleteSftp: async (_sftpId, path, expectedType) => {
deletedPaths.push(path);
deletedTypes.push(expectedType);
},
startStreamTransfer: async ({ targetPath: path }) => {
uploadedPaths.push(path);
return { transferId: "upload-dir-1" };
},
},
joinPath: (base, name) => `${base}/${name}`,
resolveConflict: async () => "replace",
},
);
assert.deepEqual(deletedPaths, ["/remote/docs"]);
assert.deepEqual(deletedTypes, ["symlink"]);
assert.ok(madeDirs.includes("/remote/docs"), `expected mkdir after unlink, got ${JSON.stringify(madeDirs)}`);
assert.deepEqual(uploadedPaths, ["/remote/docs/file.txt"]);
assert.equal(results.some((r) => r.success), true);
});
test("local file replace unlinks an existing symlink before writeLocalFile", async () => {
// Pathless File uploads fall back to writeLocalFile, which follows symlinks.
// Conflict checks use lstatLocal so Replace unlinks the link first.
const file = new File(["new-bytes"], "tool.sh", { lastModified: 1234 });
const deletedPaths: string[] = [];
const deletedTypes: Array<string | undefined> = [];
const writtenPaths: string[] = [];
const results = await uploadFromFileList(
[file],
{
targetPath: "/Users/me/bin",
sftpId: null,
isLocal: true,
bridge: {
mkdirSftp: async () => {},
lstatLocal: async (path) =>
path === "/Users/me/bin/tool.sh"
? { type: "symlink", size: 12, lastModified: 1000 }
: null,
deleteLocalFile: async (path, expectedType) => {
deletedPaths.push(path);
deletedTypes.push(expectedType);
},
writeLocalFile: async (path) => {
writtenPaths.push(path);
},
},
joinPath: (base, name) => `${base}/${name}`,
resolveConflict: async () => "replace",
},
);
assert.deepEqual(deletedPaths, ["/Users/me/bin/tool.sh"]);
assert.deepEqual(deletedTypes, ["symlink"]);
assert.deepEqual(writtenPaths, ["/Users/me/bin/tool.sh"]);
assert.equal(results.length, 1);
assert.equal(results[0].success, true);
});
test("local file replace unlinks a regular file before writeLocalFile", async () => {
const file = new File(["new-bytes"], "tool.sh", { lastModified: 1234 });
const operations: string[] = [];
const results = await uploadFromFileList(
[file],
{
targetPath: "/Users/me/bin",
sftpId: null,
isLocal: true,
bridge: {
mkdirSftp: async () => {},
lstatLocal: async (path) =>
path === "/Users/me/bin/tool.sh"
? { type: "file", size: 12, lastModified: 1000 }
: null,
deleteLocalFile: async (path, expectedType) => {
operations.push(`delete:${path}:${expectedType}`);
},
writeLocalFile: async (path) => {
operations.push(`write:${path}`);
},
},
joinPath: (base, name) => `${base}/${name}`,
resolveConflict: async () => "replace",
},
);
assert.deepEqual(operations, [
"delete:/Users/me/bin/tool.sh:file",
"write:/Users/me/bin/tool.sh",
]);
assert.equal(results.length, 1);
assert.equal(results[0].success, true);
});
test("counts apply-to-all upload conflicts by incoming and existing type", async () => {
const files = [
new File(["local"], "existing-file", { lastModified: 1234 }),
@@ -0,0 +1,75 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { createLatestPayloadSync } from './useExternalMcpSessionSync.ts';
const hookSource = readFileSync(new URL('./useExternalMcpSessionSync.ts', import.meta.url), 'utf8');
const appSource = readFileSync(new URL('../app/AppSideEffects.tsx', import.meta.url), 'utf8');
const preloadSource = readFileSync(new URL('../../electron/preload/api.cjs', import.meta.url), 'utf8');
const handlerSource = readFileSync(
new URL('../../electron/bridges/aiBridge/agentProcessHandlers.cjs', import.meta.url),
'utf8',
);
test('app live session sync stays mounted and is independent of External MCP enablement', () => {
assert.match(appSource, /useExternalMcpSessionSync\(\{/);
assert.match(hookSource, /bridge\.aiMcpUpdateLiveSessions\?\.\(nextPayload\)/);
assert.match(hookSource, /liveSyncRef\.current\.push\(payload, serialized\)/);
assert.match(hookSource, /bridge\.aiMcpUpdateSessions\?\.\(/);
assert.match(hookSource, /externalSyncRef\.current\.push\(payload, serialized\)/);
assert.doesNotMatch(
hookSource,
/if \(!enabled\)[\s\S]{0,300}aiMcpUpdateLiveSessions/,
);
});
test('live session sync has a complete renderer to main-process IPC path', () => {
assert.match(preloadSource, /netcatty:ai:mcp:update-live-sessions/);
assert.match(handlerSource, /netcatty:ai:mcp:update-live-sessions/);
assert.match(handlerSource, /mcpServerBridge\.updateLiveSessionMetadata/);
});
test('latest payload sync converges to A-B-A while B is in flight', async () => {
let releaseB: (() => void) | undefined;
const sent: string[] = [];
const sync = createLatestPayloadSync(async (payload: string) => {
sent.push(payload);
if (payload === 'B') {
await new Promise<void>((resolve) => {
releaseB = resolve;
});
}
return { ok: true };
});
await sync.push('A');
const sendingB = sync.push('B');
void sync.push('A');
releaseB?.();
await sendingB;
assert.deepEqual(sent, ['A', 'B', 'A']);
});
test('cancelling a payload sync drops a queued update', async () => {
let releaseA: (() => void) | undefined;
const sent: string[] = [];
const sync = createLatestPayloadSync(async (payload: string) => {
sent.push(payload);
if (payload === 'A') {
await new Promise<void>((resolve) => {
releaseA = resolve;
});
}
return { ok: true };
});
const sendingA = sync.push('A');
void sync.push('B');
sync.cancel();
releaseA?.();
await sendingA;
assert.deepEqual(sent, ['A']);
});
+67 -18
View File
@@ -17,6 +17,49 @@ type UseExternalMcpSessionSyncOptions = {
portForwardingRules: PortForwardingRule[];
};
export function createLatestPayloadSync<T>(
send: (payload: T) => Promise<unknown>,
) {
let desired: { serialized: string; payload: T } | null = null;
let acknowledged = '';
let inFlight: Promise<void> | null = null;
let cancelled = false;
const pump = async () => {
while (!cancelled && desired && desired.serialized !== acknowledged) {
const target = desired;
try {
const result = await send(target.payload);
if (cancelled) break;
if (result && typeof result === 'object' && 'ok' in result && (result as { ok?: boolean }).ok === false) {
if (desired === target) break;
continue;
}
acknowledged = target.serialized;
} catch {
if (desired === target) break;
}
}
};
return {
push(payload: T, serialized = JSON.stringify(payload)): Promise<void> {
if (cancelled) return Promise.resolve();
desired = { payload, serialized };
if (!inFlight) {
inFlight = pump().finally(() => {
inFlight = null;
});
}
return inFlight;
},
cancel(): void {
cancelled = true;
desired = null;
},
};
}
function isMainAppWindow(): boolean {
if (typeof window === 'undefined') return false;
const hash = window.location.hash || '';
@@ -43,7 +86,6 @@ export function useExternalMcpSessionSync({
void enabledTick;
return readExternalMcpStoredEnabled();
}, [enabledTick]);
const syncGenerationRef = useRef(0);
useEffect(() => {
const bump = () => setEnabledTick((value) => value + 1);
@@ -77,34 +119,41 @@ export function useExternalMcpSessionSync({
});
}, [sessions, sessionHostsMap, hosts, portForwardingRules]);
const lastSentSerializedRef = useRef('');
const liveSyncRef = useRef<ReturnType<typeof createLatestPayloadSync<typeof payload>> | null>(null);
const externalSyncRef = useRef<ReturnType<typeof createLatestPayloadSync<typeof payload>> | null>(null);
useEffect(() => {
if (!isMainAppWindow()) return;
const bridge = netcattyBridge.get();
if (!bridge?.aiMcpUpdateLiveSessions) return;
const serialized = JSON.stringify(payload);
liveSyncRef.current ??= createLatestPayloadSync((nextPayload) => (
Promise.resolve(bridge.aiMcpUpdateLiveSessions?.(nextPayload))
));
void liveSyncRef.current.push(payload, serialized);
}, [payload]);
useEffect(() => {
if (!isMainAppWindow()) return;
if (!enabled) {
syncGenerationRef.current += 1;
lastSentSerializedRef.current = '';
// Recreate the synchronizer after re-enabling because disabling can
// clear the main-process scope even when the renderer payload is equal.
externalSyncRef.current?.cancel();
externalSyncRef.current = null;
return;
}
const bridge = netcattyBridge.get();
if (!bridge?.aiMcpUpdateSessions) return;
const serialized = JSON.stringify(payload);
if (serialized === lastSentSerializedRef.current) return;
const generation = ++syncGenerationRef.current;
const timeoutId = window.setTimeout(() => {
void Promise.resolve(bridge.aiMcpUpdateSessions?.(payload, EXTERNAL_MCP_CHAT_SESSION_ID))
.then((result) => {
if (generation !== syncGenerationRef.current) return;
if (result && typeof result === 'object' && 'ok' in result && (result as { ok?: boolean }).ok === false) {
return;
}
lastSentSerializedRef.current = serialized;
})
.catch(() => {
// Leave the ref unset so the next effect can retry.
});
externalSyncRef.current ??= createLatestPayloadSync((nextPayload) => (
Promise.resolve(bridge.aiMcpUpdateSessions?.(
nextPayload,
EXTERNAL_MCP_CHAT_SESSION_ID,
))
));
void externalSyncRef.current.push(payload, serialized);
}, 250);
return () => {
@@ -1,5 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import {
createDeferredOutputTriggerEventProcessor,
createOutputTriggerScanBuffer,
@@ -7,6 +9,7 @@ import {
findMatchEndingAfter,
hasApplicableOutputTriggerSnippet,
inspectDroppedOutputOverflowAlternateScreenState,
useOutputTriggers,
} from './useOutputTriggers.ts';
import { createTerminalOutputTriggerFilter } from '@/domain/terminalOutputTriggerFilter.ts';
import type { Snippet } from '@/domain/models';
@@ -93,6 +96,80 @@ test('hasApplicableOutputTriggerSnippet requires a runnable output trigger for t
assert.equal(hasApplicableOutputTriggerSnippet(snippets, 'host-a'), true);
});
test('hasApplicableOutputTriggerSnippet follows the host current group', () => {
const snippets: Snippet[] = [{
id: 'group-trigger',
label: 'Group trigger',
command: '',
kind: 'script',
trigger: 'onOutput',
triggerPattern: 'READY',
targetGroups: ['Production'],
}];
assert.equal(
hasApplicableOutputTriggerSnippet(snippets, { id: 'host-a', group: 'Production/Web' }),
true,
);
assert.equal(
hasApplicableOutputTriggerSnippet(snippets, { id: 'host-a', group: 'Staging' }),
false,
);
});
test('equivalent host objects do not reset a pending output trigger match', async () => {
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
const snippets: Snippet[] = [{
id: 'stable-host-trigger',
label: 'Stable host trigger',
command: '',
kind: 'script',
trigger: 'onOutput',
triggerPattern: 'READY',
targetGroups: ['Production'],
}];
let appendOutput: ((chunk: string) => void) | undefined;
let runCount = 0;
let renderer: ReactTestRenderer | null = null;
const onRunScript = () => {
runCount += 1;
};
const Probe = ({ host }: { host: { id: string; group: string } }) => {
appendOutput = useOutputTriggers({
sessionId: 'session-1',
host,
snippets,
onRunScript,
}).appendOutput;
return null;
};
await act(async () => {
renderer = create(React.createElement(Probe, {
host: { id: 'host-a', group: 'Production/Web' },
}));
});
try {
await act(async () => {
appendOutput?.('REA');
renderer?.update(React.createElement(Probe, {
host: { id: 'host-a', group: 'Production/Web' },
}));
appendOutput?.('DY');
await new Promise((resolve) => setTimeout(resolve, 40));
});
} finally {
await act(async () => renderer?.unmount());
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
assert.equal(runCount, 1);
});
test('output trigger scan buffer consumes scanned content and only keeps overlap', () => {
const buffer = createOutputTriggerScanBuffer(4);
+19 -10
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import type { Snippet } from '@/domain/models';
import type { Host, Snippet } from '@/domain/models';
import { snippetAppliesToOutputTrigger } from '@/domain/snippetTargets.ts';
import { isScriptSnippet } from '@/domain/snippetScript.ts';
import {
@@ -19,7 +19,7 @@ const OUTPUT_TRIGGER_DROPPED_OVERFLOW_SCAN_WINDOW_CHARS = 2048;
type OutputTriggerContext = {
sessionId: string;
hostId?: string;
host?: Pick<Host, 'id' | 'group'>;
snippets: Snippet[];
onRunScript: (snippet: Snippet, sessionId: string) => void | Promise<void>;
};
@@ -575,22 +575,31 @@ export function findMatchEndingAfter(text: string, pattern: string, minEndOffset
return null;
}
export function hasApplicableOutputTriggerSnippet(snippets: Snippet[], hostId?: string): boolean {
export function hasApplicableOutputTriggerSnippet(
snippets: Snippet[],
host?: Pick<Host, 'id' | 'group'> | string,
): boolean {
return snippets.some((snippet) => (
isScriptSnippet(snippet)
&& snippet.trigger === 'onOutput'
&& Boolean(snippet.triggerPattern)
&& Boolean(snippet.id)
&& snippetAppliesToOutputTrigger(snippet, hostId)
&& snippetAppliesToOutputTrigger(snippet, host)
));
}
export function useOutputTriggers({
sessionId,
hostId,
host,
snippets,
onRunScript,
}: OutputTriggerContext) {
const targetHostId = host?.id;
const targetHostGroup = host?.group;
const targetHost = useMemo(
() => targetHostId ? { id: targetHostId, group: targetHostGroup } : undefined,
[targetHostGroup, targetHostId],
);
const launchingRef = useRef(false);
const lastTriggerMatchEndRef = useRef(new Map<string, number>());
const serverOutputFilterRef = useRef(createTerminalOutputTriggerFilter());
@@ -600,8 +609,8 @@ export function useOutputTriggers({
const pendingDroppedOverflowFinalActionRef = useRef<'leave' | null>(null);
const pendingDroppedOverflowScanStateResetRef = useRef(false);
const hasOutputTriggers = useMemo(
() => hasApplicableOutputTriggerSnippet(snippets, hostId),
[hostId, snippets],
() => hasApplicableOutputTriggerSnippet(snippets, targetHost),
[snippets, targetHost],
);
const scanOutput = useCallback((scannableText: string) => {
@@ -621,7 +630,7 @@ export function useOutputTriggers({
if (!isScriptSnippet(snippet) || snippet.trigger !== 'onOutput' || !snippet.triggerPattern || !snippet.id) {
continue;
}
if (!snippetAppliesToOutputTrigger(snippet, hostId)) continue;
if (!snippetAppliesToOutputTrigger(snippet, targetHost)) continue;
try {
const matched = findMatchEndingAfter(scanWindow.text, snippet.triggerPattern, scanWindow.minEndOffset);
if (!matched) {
@@ -647,7 +656,7 @@ export function useOutputTriggers({
// ignore invalid regex
}
}
}, [hasOutputTriggers, hostId, onRunScript, sessionId, snippets]);
}, [hasOutputTriggers, onRunScript, sessionId, snippets, targetHost]);
const scanOutputRef = useRef(scanOutput);
scanOutputRef.current = scanOutput;
@@ -813,7 +822,7 @@ export function useOutputTriggers({
outputTriggerScanSuppressedRef.current = false;
pendingDroppedOverflowFinalActionRef.current = null;
pendingDroppedOverflowScanStateResetRef.current = false;
}, [sessionId, hostId, hasOutputTriggers, outputTriggerEventProcessor]);
}, [sessionId, targetHost, hasOutputTriggers, outputTriggerEventProcessor]);
useEffect(() => () => {
outputTriggerEventProcessor.reset();
-5
View File
@@ -797,11 +797,6 @@ export const useSessionState = ({
host: Host,
direction: SplitDirection = 'vertical',
): string | null => {
// Serial hosts use a different session constructor; they currently
// only enter workspaces via createSerialSession + drag, so reject
// them here to avoid a partially-constructed session.
if (host.protocol === 'serial') return null;
// Cheap early-exit using the ref when the workspace is clearly
// absent. The authoritative check lives inside the setWorkspaces
// updater below so we also cover the concurrent-close race.
@@ -105,4 +105,8 @@ test('workspace host creation paths use the host session snapshot factory', () =
source,
/createWorkspaceHostTerminalSession\(newSessionId,\s*host,\s*workspaceId\)/,
);
assert.doesNotMatch(
source,
/if \(host\.protocol === 'serial'\) return null/,
);
});
+13 -24
View File
@@ -30,7 +30,7 @@ import { buildSftpHostCredentials } from "./sftp/useSftpHostCredentials";
import { useSftpFileWatch } from "./sftp/useSftpFileWatch";
import { useSftpSessionCleanup } from "./sftp/useSftpSessionCleanup";
import { useSftpSessionErrors } from "./sftp/useSftpSessionErrors";
import { ensureRemoteSftpSession } from "./sftp/ensureRemoteSftpSession";
import { ensureRemoteSftpSession, probeSftpSession } from "./sftp/ensureRemoteSftpSession";
import { openTransferSftpSession } from "./sftp/dedicatedTransferResume";
import {
createTransferPoolKeyCache,
@@ -316,9 +316,7 @@ export const useSftpState = (
// Prefer borrowing the live (or parked) terminal SSH transport so MFA is
// not repeated. Transport leases keep the shared conn alive after the
// terminal tab closes until the transfer SFTP lease is returned.
const sourceSessionId = !host.sftpSudo
? resolveTransferSourceSessionId?.(host.id, host)
: undefined;
const sourceSessionId = resolveTransferSourceSessionId?.(host.id, host);
if (sourceSessionId) {
try {
logger.info(
@@ -608,16 +606,7 @@ export const useSftpState = (
forceReconnect: ensureOptions?.forceReconnect,
releaseConnection,
tabId,
probeSession: async (sftpId) => {
// Lightweight liveness check; any session-error from the bridge
// triggers a reconnect in ensureRemoteSftpSession.
if (!bridge?.getSftpHomeDir) return true;
const result = await bridge.getSftpHomeDir(sftpId);
if (result && result.success === false) {
throw new Error(result.error || "SFTP session not found");
}
return true;
},
probeSession: (sftpId) => probeSftpSession(bridge, sftpId),
});
},
resolveConnectedHost: (tabId) => connectedHostByTabIdRef.current.get(tabId) ?? null,
@@ -704,6 +693,12 @@ export const useSftpState = (
const pane = getActivePane(side);
if (!pane?.connection || pane.connection.isLocal) continue;
if (sftpSessionsRef.current.has(pane.connection.id)) continue;
const connectedHost = connectedHostByTabIdRef.current.get(pane.id) ?? null;
const vaultHost = hosts.find((host) => host.id === pane.connection?.hostId) ?? null;
const targetHost = connectedHost && connectedHost !== "local" ? connectedHost : vaultHost;
const resolvedSourceSessionId = targetHost
? resolveBrowseSourceSessionId?.(targetHost.id, targetHost)
: undefined;
try {
await ensureRemoteSftpSession({
side,
@@ -713,16 +708,10 @@ export const useSftpState = (
connect,
resolveConnectedHost: (id) => connectedHostByTabIdRef.current.get(id) ?? null,
resolveHostById: (hostId) => hosts.find((host) => host.id === hostId) ?? null,
resolveSourceSessionId: resolveBrowseSourceSessionId,
probeSession: async (sftpId) => {
const bridge = netcattyBridge.get();
if (!bridge?.getSftpHomeDir) return true;
const result = await bridge.getSftpHomeDir(sftpId);
if (result && result.success === false) {
throw new Error(result.error || "SFTP session not found");
}
return true;
},
resolveSourceSessionId: resolvedSourceSessionId
? () => resolvedSourceSessionId
: resolveBrowseSourceSessionId,
probeSession: (sftpId) => probeSftpSession(netcattyBridge.get(), sftpId),
releaseConnection,
});
logger.info(`[SFTP] Restored browse session on ${side}`);
@@ -5,6 +5,7 @@ import {
resolveVaultAgentEffectiveHost,
resolveVaultAgentNotes,
} from './useVaultAgentBridge';
import type { Host } from '../../domain/models';
import { publishNotesSnapshot } from './notesStore';
type Snapshot = Parameters<typeof haveSameVaultAgentSnapshot>[0];
@@ -28,10 +29,10 @@ describe('haveSameVaultAgentSnapshot', () => {
describe('resolveVaultAgentEffectiveHost', () => {
it('uses the latest snapshotted group defaults', () => {
const host = {
const host: Host = {
id: 'host-1', label: 'Host', hostname: 'host.test', username: 'root',
group: 'production', tags: [], os: 'linux',
} as const;
};
assert.equal(
resolveVaultAgentEffectiveHost(host, [{ path: 'production', protocol: 'telnet' }], []).protocol,
+26 -4
View File
@@ -16,6 +16,10 @@ import {
resolveDefaultKeyPassphraseAliases,
} from '../defaultKeyPassphrases';
import { getNotesActions, getNotesSnapshot, subscribeNotes } from './notesStore';
import type {
VaultGroupMutationResult,
VaultGroupMutationState,
} from '../../domain/vaultGroupMutation';
export interface UseVaultAgentBridgeInput {
hosts: Host[];
@@ -29,12 +33,17 @@ export interface UseVaultAgentBridgeInput {
terminalSettings?: Pick<TerminalSettings, 'keepaliveInterval' | 'keepaliveCountMax'>;
updateHosts: (hosts: Host[]) => void;
updateKeys: (keys: SSHKey[]) => Promise<unknown> | unknown;
updateSnippets: (snippets: Snippet[]) => void;
updateSnippets: (
snippets: Snippet[] | ((current: Snippet[]) => Snippet[]),
) => void;
customGroups: string[];
updateCustomGroups: (groups: string[]) => void;
groupConfigs: GroupConfig[];
updateGroupConfigs: (configs: GroupConfig[]) => void;
updateManagedSources: (sources: ManagedSource[]) => void;
commitVaultGroupMutation: (
mutate: (current: VaultGroupMutationState) => VaultGroupMutationResult,
) => Promise<VaultGroupMutationResult | { ok: false; superseded: true }>;
updatePortForwardingRules: (rules: PortForwardingRule[]) => void;
/** Optional override; defaults to notesStore so App need not subscribe to notes. */
notes?: VaultNote[];
@@ -185,6 +194,17 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
vaultSnapshotRef.current.hosts = hosts;
current.updateHosts(hosts);
},
commitVaultGroupMutation: async (mutate) => {
const result = await current.commitVaultGroupMutation(mutate);
if (result.ok) {
vaultSnapshotRef.current.customGroups = result.state.groups;
vaultSnapshotRef.current.groupConfigs = result.state.configs;
vaultSnapshotRef.current.hosts = result.state.hosts;
vaultSnapshotRef.current.managedSources = result.state.managedSources;
vaultSnapshotRef.current.snippets = result.state.snippets;
}
return result;
},
saveKeyPassphrase: (keyPath, passphrase) => rememberKeyPassphrase({
keyPath,
passphrase,
@@ -222,9 +242,11 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
vaultSnapshotRef.current.notes = notes;
applyUpdateNotes(notes);
},
updateSnippets: (nextSnippets) => {
vaultSnapshotRef.current.snippets = nextSnippets;
current.updateSnippets(nextSnippets);
updateSnippets: (snippetUpdate) => {
vaultSnapshotRef.current.snippets = typeof snippetUpdate === 'function'
? snippetUpdate(vaultSnapshotRef.current.snippets)
: snippetUpdate;
current.updateSnippets(snippetUpdate);
},
startTunnel: current.startTunnel,
stopTunnel: current.stopTunnel,
@@ -31,19 +31,16 @@ test("group deletion removes saved settings in the same Vault transaction", asyn
managedSources: storedSources,
onReadPersistedHosts: async () => [],
onReadPersistedManagedSources: () => storedSources,
onCommitVaultImportTransaction: async (
_hosts,
updateGroups,
updateSources,
updateGroupConfigs,
) => {
committedGroupConfigs = updateGroupConfigs?.(storedGroupConfigs) ?? storedGroupConfigs;
return {
status: "persisted",
groups: updateGroups(storedGroups),
sources: updateSources(storedSources),
groupConfigs: committedGroupConfigs,
};
onCommitVaultGroupMutation: async (mutate) => {
const result = mutate({
groups: storedGroups,
configs: storedGroupConfigs,
hosts: [],
managedSources: storedSources,
snippets: [],
});
if (result.ok) committedGroupConfigs = result.state.configs;
return result;
},
});
return null;
@@ -102,14 +99,9 @@ test("group deletion restores managed files when the persisted Vault cannot be r
onClearAndRemoveManagedSources: async () => async () => {
restoreCount += 1;
},
onCommitVaultImportTransaction: async () => {
onCommitVaultGroupMutation: async () => {
commitCount += 1;
return {
status: "persisted",
groups: [],
sources: [],
groupConfigs: [],
};
throw new Error("unexpected commit");
},
});
return null;
@@ -187,21 +179,15 @@ test("group deletion retries file clearing when another managed source enters th
restoreCount += 1;
};
},
onCommitVaultImportTransaction: async (
_hosts,
updateGroups,
updateSources,
updateGroupConfigs,
expectedHosts,
) => {
onCommitVaultGroupMutation: async (mutate) => {
commitCount += 1;
assert.deepEqual(expectedHosts, []);
return {
status: "persisted",
groups: updateGroups(["Production"]),
sources: updateSources([firstSource, concurrentSource]),
groupConfigs: updateGroupConfigs?.([]) ?? [],
};
return mutate({
groups: ["Production"],
configs: [],
hosts: [],
managedSources: [firstSource, concurrentSource],
snippets: [],
});
},
});
return null;
@@ -228,3 +214,67 @@ test("group deletion retries file clearing when another managed source enters th
assert.equal(restoreCount, 1);
assert.equal(commitCount, 1);
});
test("group deletion restores managed files and does not report success when commit fails", async () => {
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
const source: ManagedSource = {
id: "managed-production",
type: "ssh_config",
filePath: "/tmp/managed-production.conf",
groupName: "Production",
lastSyncedAt: 1,
};
let deleteGroups: ((paths: Iterable<string>) => Promise<void>) | undefined;
let renderer: ReactTestRenderer | null = null;
let restoreCount = 0;
let reportedCount = 0;
const Probe = () => {
deleteGroups = useVaultGroupDeletion({
customGroups: ["Production"],
hosts: [],
groupConfigs: [],
managedSources: [source],
onReadPersistedHosts: async () => [],
onReadPersistedManagedSources: () => [source],
onClearAndRemoveManagedSources: async () => async () => {
restoreCount += 1;
},
onCommitVaultGroupMutation: async () => {
throw new Error("Vault quota exhausted");
},
onDeletedPaths: () => {
reportedCount += 1;
},
});
return null;
};
let deletionError: unknown;
try {
await act(async () => {
renderer = create(React.createElement(Probe));
});
try {
await act(async () => {
await deleteGroups?.(["Production"]);
});
} catch (error) {
deletionError = error;
}
} finally {
await act(async () => {
renderer?.unmount();
});
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
assert.match(String(deletionError), /quota exhausted/i);
assert.equal(restoreCount, 1);
assert.equal(reportedCount, 0);
});
+44 -53
View File
@@ -1,7 +1,12 @@
import { useCallback, useRef } from "react";
import type { GroupConfig, Host, ManagedSource } from "../../domain/models";
import { removeSnippetTargetGroupPaths } from "../../domain/hostGroupPathMutations";
import { buildVaultGroupDeletion } from "../../domain/vaultGroupDeletion";
import type {
VaultGroupMutationResult,
VaultGroupMutationState,
} from "../../domain/vaultGroupMutation";
import {
type VaultLockHandle,
withVaultImportLock,
@@ -26,7 +31,7 @@ export function useVaultGroupDeletion({
managedSources,
onReadPersistedHosts,
onReadPersistedManagedSources,
onCommitVaultImportTransaction,
onCommitVaultGroupMutation,
onClearAndRemoveManagedSource,
onClearAndRemoveManagedSources,
onDeletedPaths,
@@ -37,22 +42,10 @@ export function useVaultGroupDeletion({
managedSources: ManagedSource[];
onReadPersistedHosts: () => Promise<Host[]>;
onReadPersistedManagedSources: () => ManagedSource[];
onCommitVaultImportTransaction: (
hosts: Host[],
updateGroups: (current: string[]) => string[],
updateSources: (current: ManagedSource[]) => ManagedSource[],
updateGroupConfigs?: (current: GroupConfig[]) => GroupConfig[],
expectedHosts?: Host[],
onCommitVaultGroupMutation: (
mutate: (current: VaultGroupMutationState) => VaultGroupMutationResult,
lock?: VaultLockHandle | null,
) => Promise<
| {
status: "persisted";
groups: string[];
sources: ManagedSource[];
groupConfigs: GroupConfig[];
}
| { status: "superseded" }
>;
) => Promise<VaultGroupMutationResult | { ok: false; superseded: true }>;
onClearAndRemoveManagedSource?: (source: ManagedSource) => Promise<() => Promise<void>>;
onClearAndRemoveManagedSources?: (sources: ManagedSource[]) => Promise<() => Promise<void>>;
onDeletedPaths?: (selectedRoots: string[]) => void;
@@ -120,29 +113,16 @@ export function useVaultGroupDeletion({
}
deletion = refreshedDeletion;
const expectedHosts = latestRef.current.hosts;
const expectedSources = deletion.sourcesToRemove;
const nextHosts = deletion.hosts.filter(
(host) => !additionallyDeletedHostIds.has(host.id),
);
const transaction = await onCommitVaultImportTransaction(
nextHosts,
(currentGroups) => buildVaultGroupDeletion({
selectedPaths,
deleteHosts,
customGroups: currentGroups,
hosts: [],
groupConfigs: [],
managedSources: [],
}).customGroups,
(currentSources) => {
const transaction = await onCommitVaultGroupMutation(
(current) => {
const currentDeletion = buildVaultGroupDeletion({
selectedPaths,
deleteHosts,
customGroups: [],
hosts: [],
groupConfigs: [],
managedSources: currentSources,
customGroups: current.groups,
hosts: current.hosts,
groupConfigs: current.configs,
managedSources: current.managedSources,
});
if (!managedSourceSnapshotsMatch(
expectedSources,
@@ -150,29 +130,40 @@ export function useVaultGroupDeletion({
)) {
throw RETRY_VAULT_GROUP_DELETION;
}
const removedIds = new Set(expectedSources.map((source) => source.id));
return currentSources.filter((source) => !removedIds.has(source.id));
return {
ok: true,
state: {
groups: currentDeletion.customGroups,
hosts: currentDeletion.hosts.filter(
(host) => !additionallyDeletedHostIds.has(host.id),
),
configs: currentDeletion.groupConfigs,
managedSources: current.managedSources.filter(
(source) => !currentDeletion.sourcesToRemove.some(
(removed) => removed.id === source.id,
),
),
snippets: removeSnippetTargetGroupPaths(
current.snippets,
currentDeletion.selectedRoots,
),
},
};
},
(currentGroupConfigs) => buildVaultGroupDeletion({
selectedPaths,
deleteHosts,
customGroups: [],
hosts: [],
groupConfigs: currentGroupConfigs,
managedSources: [],
}).groupConfigs,
expectedHosts,
lock,
);
if (transaction.status === "superseded") {
// Release the lock so concurrent host saves can finish before retry.
if (!transaction.ok && "superseded" in transaction) {
// Release the lock so concurrent saves can finish before retry.
return { status: "retry" as const, restoreManagedFiles };
}
if ("error" in transaction) {
throw new Error(transaction.error);
}
latestRef.current = {
customGroups: transaction.groups,
hosts: nextHosts,
groupConfigs: transaction.groupConfigs,
managedSources: transaction.sources,
customGroups: transaction.state.groups,
hosts: transaction.state.hosts,
groupConfigs: transaction.state.configs,
managedSources: transaction.state.managedSources,
};
return {
status: "done" as const,
@@ -205,7 +196,7 @@ export function useVaultGroupDeletion({
}, [
onClearAndRemoveManagedSource,
onClearAndRemoveManagedSources,
onCommitVaultImportTransaction,
onCommitVaultGroupMutation,
onDeletedPaths,
onReadPersistedHosts,
onReadPersistedManagedSources,
@@ -66,7 +66,7 @@ test("updateSnippets disk writes take the shared vault lock", () => {
// and be visible to waitForPendingVaultWrites.
assert.match(
source,
/const updateSnippets = useCallback\(\(\s*data: Snippet\[\],\s*options\?: \{ replace\?: boolean \},\s*\) => \{[\s\S]*withVaultImportLock\("vault", async \(\) => \{[\s\S]*localStorageAdapter\.write\(STORAGE_KEY_SNIPPETS, rebased\)/,
/const updateSnippets = useCallback\(\([\s\S]*withVaultImportLock\("vault", async \(\) => \{[\s\S]*localStorageAdapter\.write\(STORAGE_KEY_SNIPPETS, rebased\)/,
);
assert.match(
source,
@@ -95,6 +95,24 @@ test("updateSnippets rebases onto the latest persisted snapshot under the lock",
);
});
test("updateSnippets functional updates derive from the latest in-memory snapshot", () => {
// Group deletion can wait for managed-source cleanup. A script created while
// it waits must still be present when the deletion callback removes the old
// group path, so updater callbacks must not derive from the initiating render.
assert.match(
source,
/data: Snippet\[\] \| \(\(current: Snippet\[\]\) => Snippet\[\]\)/,
);
assert.match(
source,
/const updater = typeof data === "function" \? data : null;[\s\S]*const current = snippetsRef\.current;[\s\S]*typeof data === "function" \? data\(current\) : data/,
);
assert.match(
source,
/updater\(rebaseSnippetVaultWrite\(\{\s*base,\s*ours: current,\s*theirs: latestPersisted,\s*\}\)\)/,
);
});
test("clearVaultData replaces snippets without additive rebase", () => {
// Clear All Local Data must not preserve concurrent disk-only snippet adds
// that rebaseSnippetVaultWrite would otherwise keep.
@@ -108,7 +126,7 @@ test("clearVaultData replaces snippets without additive rebase", () => {
);
assert.match(
source,
/replace\s*\?\s*cleaned\s*:\s*rebaseSnippetVaultWrite/,
/replace\s*\?\s*cleaned\s*:\s*updater\s*\?\s*updater\(rebaseSnippetVaultWrite/,
);
assert.match(
source,
@@ -145,7 +163,7 @@ test("updateSnippets keeps the persisted rebase ancestor across superseded saves
);
assert.match(
source,
/const base = snippetsWriteBaseRef\.current \?\? snippetsRef\.current/,
/const current = snippetsRef\.current;\s*const base = snippetsWriteBaseRef\.current \?\? current/,
);
assert.match(
source,
+150 -21
View File
@@ -106,10 +106,15 @@ import {
persistVaultImportMetadata,
readStoredArray,
} from "./vaultImportPersistence";
import type {
VaultGroupMutationResult,
VaultGroupMutationState,
} from "../../domain/vaultGroupMutation";
import {
commitPluginImporterTransaction,
recoverPluginImporterTransaction,
} from "./pluginImporterTransaction";
import { commitVaultGroupMutationPersistence } from "./vaultGroupMutationPersistence";
type ExportableVaultData = {
hosts: Host[];
@@ -296,6 +301,7 @@ export const useVaultState = () => {
const managedSourcesRef = useRef<ManagedSource[]>([]);
const hostsRef = useRef<Host[]>([]);
const snippetsRef = useRef<Snippet[]>([]);
const groupConfigsRef = useRef<GroupConfig[]>([]);
const notesRef = useRef<VaultNote[]>([]);
const noteGroupsRef = useRef<string[]>([]);
const notesPersistFailureNotifiedAtRef = useRef(0);
@@ -303,6 +309,7 @@ export const useVaultState = () => {
managedSourcesRef.current = managedSources;
hostsRef.current = hosts;
snippetsRef.current = snippets;
groupConfigsRef.current = groupConfigs;
notesRef.current = notes;
noteGroupsRef.current = noteGroups;
@@ -314,6 +321,7 @@ export const useVaultState = () => {
const identitiesWriteVersion = useRef(0);
const proxyProfilesWriteVersion = useRef(0);
const snippetsWriteVersion = useRef(0);
const managedSourcesWriteVersion = useRef(0);
// Tracks the latest local updateSnippets schedule. Storage events also bump
// snippetsWriteVersion (to invalidate naive writers), so queued saves must
// key supersede checks off this owner instead of that shared counter.
@@ -340,6 +348,7 @@ export const useVaultState = () => {
const identitiesWritePendingRef = useRef<Promise<unknown>>(Promise.resolve());
const groupConfigsWritePendingRef = useRef<Promise<unknown>>(Promise.resolve());
const snippetsWritePendingRef = useRef<Promise<unknown>>(Promise.resolve());
const managedSourcesWritePendingRef = useRef<Promise<unknown>>(Promise.resolve());
const waitForPendingVaultWrites = useCallback(async () => {
while (true) {
@@ -365,6 +374,7 @@ export const useVaultState = () => {
identitiesWritePendingRef.current,
groupConfigsWritePendingRef.current,
snippetsWritePendingRef.current,
managedSourcesWritePendingRef.current,
];
await Promise.all(writePending);
if (
@@ -373,6 +383,7 @@ export const useVaultState = () => {
&& writePending[2] === identitiesWritePendingRef.current
&& writePending[3] === groupConfigsWritePendingRef.current
&& writePending[4] === snippetsWritePendingRef.current
&& writePending[5] === managedSourcesWritePendingRef.current
&& encryptPending[0] === hostsEncryptPendingRef.current
&& encryptPending[1] === keysEncryptPendingRef.current
&& encryptPending[2] === identitiesEncryptPendingRef.current
@@ -643,7 +654,7 @@ export const useVaultState = () => {
}, []);
const updateSnippets = useCallback((
data: Snippet[],
data: Snippet[] | ((current: Snippet[]) => Snippet[]),
options?: { replace?: boolean },
) => {
// Capture the pre-update snapshot for a 3-way rebase once we hold the lock.
@@ -653,7 +664,9 @@ export const useVaultState = () => {
// second edit does not rebase against the first save's optimistic memory.
const replace =
options?.replace === true || snippetsWriteReplaceRef.current;
const base = snippetsWriteBaseRef.current ?? snippetsRef.current;
const updater = typeof data === "function" ? data : null;
const current = snippetsRef.current;
const base = snippetsWriteBaseRef.current ?? current;
if (options?.replace === true) {
snippetsWriteReplaceRef.current = true;
}
@@ -667,7 +680,9 @@ export const useVaultState = () => {
} else if (snippetsWriteBaseRef.current === null) {
snippetsWriteBaseRef.current = base;
}
const cleaned = normalizeVaultOrder(data);
const cleaned = normalizeVaultOrder(
typeof data === "function" ? data(current) : data,
);
const ver = ++snippetsWriteVersion.current;
snippetsWriteOwnerRef.current = ver;
// Keep live snapshot ahead of React commit so same-tick readers (delete,
@@ -681,20 +696,25 @@ export const useVaultState = () => {
// skips the additive rebase so a concurrent disk-only add cannot survive.
const writePromise = withVaultImportLock("vault", async () => {
if (snippetsWriteOwnerRef.current !== ver) return "superseded" as const;
const rebased = normalizeVaultOrder(
replace
? cleaned
const latestPersisted = normalizeVaultOrder(
readStoredArray<Snippet>(
STORAGE_KEY_SNIPPETS,
localStorageAdapter.readString(STORAGE_KEY_SNIPPETS),
),
);
const rebased = normalizeVaultOrder(replace
? cleaned
: updater
? updater(rebaseSnippetVaultWrite({
base,
ours: current,
theirs: latestPersisted,
}))
: rebaseSnippetVaultWrite({
base,
ours: cleaned,
theirs: normalizeVaultOrder(
readStoredArray<Snippet>(
STORAGE_KEY_SNIPPETS,
localStorageAdapter.readString(STORAGE_KEY_SNIPPETS),
),
),
}),
);
theirs: latestPersisted,
}));
const persisted = localStorageAdapter.write(STORAGE_KEY_SNIPPETS, rebased);
if (!persisted) {
// Keep the pre-update ancestor. Clearing it here would make the next
@@ -852,6 +872,7 @@ export const useVaultState = () => {
setCustomGroups(next);
const cleanedGroupConfigs = buildGroupConfigsForGroups(next, groupConfigs);
groupConfigsRef.current = cleanedGroupConfigs;
setGroupConfigs(cleanedGroupConfigs);
const configsVer = ++groupConfigsWriteVersion.current;
const encryptPromise = encryptGroupConfigs(cleanedGroupConfigs);
@@ -880,13 +901,105 @@ export const useVaultState = () => {
const next = typeof data === "function" ? data(managedSourcesRef.current) : data;
managedSourcesRef.current = next;
setManagedSources(next);
void withVaultImportLock("vault", async () => {
const ver = ++managedSourcesWriteVersion.current;
const writePromise = withVaultImportLock("vault", async () => {
// Latest ref wins if another update ran while waiting for the lock.
if (managedSourcesRef.current !== next) return;
localStorageAdapter.write(STORAGE_KEY_MANAGED_SOURCES, next);
if (ver !== managedSourcesWriteVersion.current) return "superseded" as const;
return localStorageAdapter.write(STORAGE_KEY_MANAGED_SOURCES, next)
? "written" as const
: "failed" as const;
});
managedSourcesWritePendingRef.current = writePromise;
return writePromise;
}, []);
const commitVaultGroupMutation = useCallback(async (
mutate: (current: VaultGroupMutationState) => VaultGroupMutationResult,
lock?: VaultLockHandle | null,
): Promise<VaultGroupMutationResult | { ok: false; superseded: true }> => {
const captureVersions = () => ({
hosts: hostsWriteVersion.current,
snippets: snippetsWriteVersion.current,
groups: customGroupsWriteVersion.current,
configs: groupConfigsWriteVersion.current,
sources: managedSourcesWriteVersion.current,
});
const versionsAreCurrent = (versions: ReturnType<typeof captureVersions>) => (
versions.hosts === hostsWriteVersion.current
&& versions.snippets === snippetsWriteVersion.current
&& versions.groups === customGroupsWriteVersion.current
&& versions.configs === groupConfigsWriteVersion.current
&& versions.sources === managedSourcesWriteVersion.current
);
const stateMatchesLiveSnapshot = (current: VaultGroupMutationState) => (
JSON.stringify(current.hosts) === JSON.stringify(hostsRef.current)
&& JSON.stringify(current.snippets) === JSON.stringify(snippetsRef.current)
&& JSON.stringify(current.groups) === JSON.stringify(customGroupsRef.current)
&& JSON.stringify(current.configs) === JSON.stringify(groupConfigsRef.current)
&& JSON.stringify(current.managedSources) === JSON.stringify(managedSourcesRef.current)
);
const runCommit = async (
versions: ReturnType<typeof captureVersions>,
): Promise<VaultGroupMutationResult | { ok: false; superseded: true }> => {
const result = await commitVaultGroupMutationPersistence({
storage: localStorageAdapter,
mutate,
prepareState: (state) => {
const groups = Array.from(new Set(state.groups));
return {
groups,
configs: buildGroupConfigsForGroups(groups, state.configs),
hosts: normalizeVaultOrder(state.hosts.map((host) => sanitizeHost(host))),
managedSources: state.managedSources,
snippets: normalizeVaultOrder(state.snippets),
};
},
decryptHosts: async (items) => normalizeVaultOrder(
(await decryptHosts(items)).map((host) => sanitizeHost(host)),
),
decryptConfigs: async (items) => normalizeVaultOrder(
(await decryptGroupConfigs(items)).map(sanitizeGroupConfig),
),
encryptHosts,
encryptConfigs: encryptGroupConfigs,
isCurrent: () => versionsAreCurrent(versions),
validateCurrent: lock ? stateMatchesLiveSnapshot : undefined,
});
if (!result.ok) return result;
const nextState = result.state;
++hostsWriteVersion.current;
++snippetsWriteVersion.current;
snippetsWriteOwnerRef.current = snippetsWriteVersion.current;
++customGroupsWriteVersion.current;
++groupConfigsWriteVersion.current;
++managedSourcesWriteVersion.current;
hostsRef.current = nextState.hosts;
snippetsRef.current = nextState.snippets;
customGroupsRef.current = nextState.groups;
managedSourcesRef.current = nextState.managedSources;
snippetsWriteBaseRef.current = null;
snippetsWriteReplaceRef.current = false;
setHosts(nextState.hosts);
setSnippets(nextState.snippets);
setCustomGroups(nextState.groups);
setManagedSources(nextState.managedSources);
setGroupConfigs(nextState.configs);
groupConfigsRef.current = nextState.configs;
return { ok: true, state: nextState };
};
if (lock) {
const versions = captureVersions();
return withVaultImportLockIfNeeded("vault", () => runCommit(versions), lock);
}
while (true) {
await waitForPendingVaultWrites();
const versions = captureVersions();
const result = await withVaultImportLock("vault", () => runCommit(versions));
if (!("superseded" in result)) return result;
}
}, [waitForPendingVaultWrites]);
const readPersistedManagedSources = useCallback((): ManagedSource[] => (
readStoredArray<ManagedSource>(
STORAGE_KEY_MANAGED_SOURCES,
@@ -996,6 +1109,8 @@ export const useVaultState = () => {
++groupConfigsWriteVersion.current;
customGroupsRef.current = result.groups;
managedSourcesRef.current = result.sources;
hostsRef.current = cleanedHosts;
groupConfigsRef.current = nextGroupConfigs;
setHosts(cleanedHosts);
setCustomGroups(result.groups);
setManagedSources(result.sources);
@@ -1018,6 +1133,7 @@ export const useVaultState = () => {
// sit in memory and re-persist with `fontFamilyOverride: true` until
// the next reload. Mirrors updateHosts → sanitizeHost.
const cleaned = normalizeVaultOrder(data.map(sanitizeGroupConfig));
groupConfigsRef.current = cleaned;
setGroupConfigs(cleaned);
const ver = ++groupConfigsWriteVersion.current;
const encryptPromise = encryptGroupConfigs(cleaned);
@@ -1461,7 +1577,10 @@ export const useVaultState = () => {
const savedManagedSources = localStorageAdapter.read<ManagedSource[]>(
STORAGE_KEY_MANAGED_SOURCES,
);
if (savedManagedSources) setManagedSources(savedManagedSources);
if (savedManagedSources) {
managedSourcesRef.current = savedManagedSources;
setManagedSources(savedManagedSources);
}
// Load group configs
const savedGroupConfigs = localStorageAdapter.read<GroupConfig[]>(STORAGE_KEY_GROUP_CONFIGS);
@@ -1471,6 +1590,7 @@ export const useVaultState = () => {
if (cancelled) return;
if (gcVer === groupConfigsWriteVersion.current) {
const sanitizedGC = normalizeVaultOrder(decryptedGC.map(sanitizeGroupConfig));
groupConfigsRef.current = sanitizedGC;
setGroupConfigs(sanitizedGC);
encryptGroupConfigs(sanitizedGC).then((enc) => {
if (gcVer === groupConfigsWriteVersion.current)
@@ -1641,6 +1761,7 @@ export const useVaultState = () => {
if (key === STORAGE_KEY_GROUPS) {
const next = safeParse<string[]>(event.newValue) ?? [];
++customGroupsWriteVersion.current;
customGroupsRef.current = next;
setCustomGroups(next);
return;
}
@@ -1695,6 +1816,8 @@ export const useVaultState = () => {
if (key === STORAGE_KEY_MANAGED_SOURCES) {
const next = safeParse<ManagedSource[]>(event.newValue) ?? [];
++managedSourcesWriteVersion.current;
managedSourcesRef.current = next;
setManagedSources(next);
return;
}
@@ -1705,8 +1828,11 @@ export const useVaultState = () => {
const seq = ++groupConfigsReadSeq.current;
const writeAtStart = groupConfigsWriteVersion.current;
decryptGroupConfigs(next).then((dec) => {
if (seq === groupConfigsReadSeq.current && writeAtStart === groupConfigsWriteVersion.current)
setGroupConfigs(normalizeVaultOrder(dec.map(sanitizeGroupConfig)));
if (seq === groupConfigsReadSeq.current && writeAtStart === groupConfigsWriteVersion.current) {
const normalized = normalizeVaultOrder(dec.map(sanitizeGroupConfig));
groupConfigsRef.current = normalized;
setGroupConfigs(normalized);
}
});
return;
}
@@ -1794,7 +1920,7 @@ export const useVaultState = () => {
const importData = useCallback(
(payload: Partial<ExportableVaultData>): Promise<void> => {
const encryptedWrites: Promise<void>[] = [];
if (payload.hosts) encryptedWrites.push(updateHosts(payload.hosts));
if (payload.hosts) encryptedWrites.push(updateHosts(payload.hosts).then(() => undefined));
if (payload.keys) encryptedWrites.push(updateKeys(payload.keys));
if (payload.identities) encryptedWrites.push(updateIdentities(payload.identities));
if (Array.isArray(payload.proxyProfiles)) encryptedWrites.push(updateProxyProfiles(payload.proxyProfiles));
@@ -1952,6 +2078,8 @@ export const useVaultState = () => {
]);
customGroupsRef.current = nextGroups;
snippetsRef.current = nextSnippets;
hostsRef.current = nextHosts;
groupConfigsRef.current = nextGroupConfigs;
snippetsWriteBaseRef.current = null;
snippetsWriteReplaceRef.current = false;
setHosts(nextHosts);
@@ -2008,6 +2136,7 @@ export const useVaultState = () => {
updateManagedSources,
readPersistedManagedSources,
commitVaultImportTransaction,
commitVaultGroupMutation,
updateGroupConfigs,
addShellHistoryEntry,
clearShellHistory,
@@ -0,0 +1,220 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { Host, Snippet } from '../../domain/models';
import {
remapSnippetTargetGroupPaths,
removeSnippetTargetGroupPaths,
} from '../../domain/hostGroupPathMutations';
import {
STORAGE_KEY_GROUP_CONFIGS,
STORAGE_KEY_GROUPS,
STORAGE_KEY_HOSTS,
STORAGE_KEY_MANAGED_SOURCES,
STORAGE_KEY_SNIPPETS,
} from '../../infrastructure/config/storageKeys';
import { commitVaultGroupMutationPersistence } from './vaultGroupMutationPersistence';
const createStorage = (initial: Record<string, unknown>, failKey?: string) => {
const values = new Map(Object.entries(initial).map(([key, value]) => [key, JSON.stringify(value)]));
return {
values,
read<T>(key: string): T | null {
const value = values.get(key);
return value === undefined ? null : JSON.parse(value) as T;
},
readString: (key: string) => values.get(key) ?? null,
write<T>(key: string, value: T) {
if (key === failKey) return false;
values.set(key, JSON.stringify(value));
return true;
},
writeString(key: string, value: string) {
values.set(key, value);
return true;
},
remove(key: string) {
values.delete(key);
},
};
};
const identity = async <T>(value: T): Promise<T> => value;
const prepareState = <T>(value: T): T => value;
const initialHost: Host = {
id: 'host-1', label: 'Host', hostname: 'host.test', username: 'root',
group: 'prod', tags: [], os: 'linux',
};
const initialSnippet: Snippet = {
id: 'script-1', label: 'Deploy', command: 'nct.log(1)', kind: 'script',
targetGroups: ['prod'],
};
function initialVault() {
return {
[STORAGE_KEY_HOSTS]: [initialHost],
[STORAGE_KEY_GROUPS]: ['prod'],
[STORAGE_KEY_MANAGED_SOURCES]: [],
[STORAGE_KEY_GROUP_CONFIGS]: [{ path: 'prod', username: 'root' }],
[STORAGE_KEY_SNIPPETS]: [initialSnippet],
};
}
test('group rename rolls back every Vault key when the snippet write fails', async () => {
const initial = initialVault();
const storage = createStorage(initial, STORAGE_KEY_SNIPPETS);
await assert.rejects(commitVaultGroupMutationPersistence({
storage,
mutate: (current) => ({
ok: true,
state: {
...current,
groups: ['production'],
configs: current.configs.map((config) => ({ ...config, path: 'production' })),
hosts: current.hosts.map((host) => ({ ...host, group: 'production' })),
snippets: remapSnippetTargetGroupPaths(current.snippets, 'prod', 'production'),
},
}),
prepareState,
decryptHosts: identity,
decryptConfigs: identity,
encryptHosts: identity,
encryptConfigs: identity,
isCurrent: () => true,
}), /rejected importer transaction/);
for (const [key, value] of Object.entries(initial)) {
assert.deepEqual(storage.read(key), value);
}
});
test('group delete rolls back every Vault key when the snippet write fails', async () => {
const initial = initialVault();
const storage = createStorage(initial, STORAGE_KEY_SNIPPETS);
await assert.rejects(commitVaultGroupMutationPersistence({
storage,
mutate: (current) => ({
ok: true,
state: {
...current,
groups: [],
configs: [],
hosts: current.hosts.map((host) => ({ ...host, group: undefined })),
snippets: removeSnippetTargetGroupPaths(current.snippets, ['prod']),
},
}),
prepareState,
decryptHosts: identity,
decryptConfigs: identity,
encryptHosts: identity,
encryptConfigs: identity,
isCurrent: () => true,
}), /rejected importer transaction/);
for (const [key, value] of Object.entries(initial)) {
assert.deepEqual(storage.read(key), value);
}
});
test('group rename preserves a concurrent snippet update from the locked snapshot', async () => {
const concurrentSnippet = { ...initialSnippet, command: 'nct.log(2)' };
const addedSnippet: Snippet = {
id: 'script-2', label: 'Check', command: 'nct.log(3)', kind: 'script',
targetGroups: ['prod/child'],
};
const storage = createStorage({
...initialVault(),
[STORAGE_KEY_SNIPPETS]: [concurrentSnippet, addedSnippet],
});
const result = await commitVaultGroupMutationPersistence({
storage,
mutate: (current) => ({
ok: true,
state: {
...current,
groups: ['production'],
snippets: remapSnippetTargetGroupPaths(current.snippets, 'prod', 'production'),
},
}),
prepareState,
decryptHosts: identity,
decryptConfigs: identity,
encryptHosts: identity,
encryptConfigs: identity,
isCurrent: () => true,
});
assert.equal(result.ok, true);
const saved = storage.read<Snippet[]>(STORAGE_KEY_SNIPPETS) ?? [];
assert.equal(saved[0]?.command, 'nct.log(2)');
assert.deepEqual(saved.map((snippet) => snippet.targetGroups), [
['production'],
['production/child'],
]);
});
test('group delete cleans targets from the latest persisted snippet snapshot', async () => {
const concurrentSnippet = { ...initialSnippet, command: 'nct.log(2)' };
const addedSnippet: Snippet = {
id: 'script-2', label: 'Check', command: 'nct.log(3)', kind: 'script',
targetGroups: ['prod/child', 'staging'],
};
const storage = createStorage({
...initialVault(),
[STORAGE_KEY_SNIPPETS]: [concurrentSnippet, addedSnippet],
});
const result = await commitVaultGroupMutationPersistence({
storage,
mutate: (current) => ({
ok: true,
state: {
...current,
groups: [],
snippets: removeSnippetTargetGroupPaths(current.snippets, ['prod']),
},
}),
prepareState,
decryptHosts: identity,
decryptConfigs: identity,
encryptHosts: identity,
encryptConfigs: identity,
isCurrent: () => true,
});
assert.equal(result.ok, true);
const saved = storage.read<Snippet[]>(STORAGE_KEY_SNIPPETS) ?? [];
assert.equal(saved[0]?.command, 'nct.log(2)');
assert.deepEqual(saved.map((snippet) => snippet.targetGroups), [
[],
['staging'],
]);
});
test('group mutation stops before writing when memory is ahead of persisted Vault state', async () => {
const storage = createStorage(initialVault());
let mutateCalled = false;
const result = await commitVaultGroupMutationPersistence({
storage,
mutate: (current) => {
mutateCalled = true;
return { ok: true, state: current };
},
prepareState,
decryptHosts: identity,
decryptConfigs: identity,
encryptHosts: identity,
encryptConfigs: identity,
isCurrent: () => true,
validateCurrent: (current) => current.snippets[0]?.command === 'nct.log(2)',
});
assert.deepEqual(result, { ok: false, superseded: true });
assert.equal(mutateCalled, false);
assert.deepEqual(storage.read<Snippet[]>(STORAGE_KEY_SNIPPETS), [initialSnippet]);
});
@@ -0,0 +1,97 @@
import type { GroupConfig, Host, ManagedSource, Snippet } from '../../domain/models';
import type {
VaultGroupMutationResult,
VaultGroupMutationState,
} from '../../domain/vaultGroupMutation';
import {
STORAGE_KEY_GROUP_CONFIGS,
STORAGE_KEY_GROUPS,
STORAGE_KEY_HOSTS,
STORAGE_KEY_MANAGED_SOURCES,
STORAGE_KEY_SNIPPETS,
} from '../../infrastructure/config/storageKeys';
import { commitPluginImporterTransaction } from './pluginImporterTransaction';
import { readStoredArray } from './vaultImportPersistence';
type TransactionStorage = {
read<T>(key: string): T | null;
readString(key: string): string | null;
write<T>(key: string, value: T): boolean;
writeString(key: string, value: string): boolean;
remove(key: string): void;
};
const GROUP_MUTATION_KEYS = [
STORAGE_KEY_HOSTS,
STORAGE_KEY_GROUPS,
STORAGE_KEY_MANAGED_SOURCES,
STORAGE_KEY_GROUP_CONFIGS,
STORAGE_KEY_SNIPPETS,
] as const;
export async function commitVaultGroupMutationPersistence({
storage,
mutate,
prepareState,
decryptHosts,
decryptConfigs,
encryptHosts,
encryptConfigs,
isCurrent,
validateCurrent,
}: {
storage: TransactionStorage;
mutate: (current: VaultGroupMutationState) => VaultGroupMutationResult;
prepareState: (state: VaultGroupMutationState) => VaultGroupMutationState;
decryptHosts: (hosts: Host[]) => Promise<Host[]>;
decryptConfigs: (configs: GroupConfig[]) => Promise<GroupConfig[]>;
encryptHosts: (hosts: Host[]) => Promise<unknown>;
encryptConfigs: (configs: GroupConfig[]) => Promise<unknown>;
isCurrent: () => boolean;
validateCurrent?: (current: VaultGroupMutationState) => boolean;
}): Promise<VaultGroupMutationResult | { ok: false; superseded: true }> {
const raw = new Map(GROUP_MUTATION_KEYS.map((key) => [key, storage.readString(key)]));
const [hosts, configs] = await Promise.all([
decryptHosts(readStoredArray<Host>(STORAGE_KEY_HOSTS, raw.get(STORAGE_KEY_HOSTS) ?? null)),
decryptConfigs(readStoredArray<GroupConfig>(
STORAGE_KEY_GROUP_CONFIGS,
raw.get(STORAGE_KEY_GROUP_CONFIGS) ?? null,
)),
]);
const current: VaultGroupMutationState = {
groups: readStoredArray<string>(STORAGE_KEY_GROUPS, raw.get(STORAGE_KEY_GROUPS) ?? null),
configs,
hosts,
managedSources: readStoredArray<ManagedSource>(
STORAGE_KEY_MANAGED_SOURCES,
raw.get(STORAGE_KEY_MANAGED_SOURCES) ?? null,
),
snippets: readStoredArray<Snippet>(STORAGE_KEY_SNIPPETS, raw.get(STORAGE_KEY_SNIPPETS) ?? null),
};
if (!isCurrent() || (validateCurrent && !validateCurrent(current))) {
return { ok: false, superseded: true };
}
const mutation = mutate(current);
if (!mutation.ok) return mutation;
const nextState = prepareState(mutation.state);
const [encryptedHosts, encryptedConfigs] = await Promise.all([
encryptHosts(nextState.hosts),
encryptConfigs(nextState.configs),
]);
if (
!isCurrent()
|| GROUP_MUTATION_KEYS.some((key) => storage.readString(key) !== raw.get(key))
) {
return { ok: false, superseded: true };
}
commitPluginImporterTransaction(storage, [
[STORAGE_KEY_HOSTS, encryptedHosts],
[STORAGE_KEY_GROUPS, nextState.groups],
[STORAGE_KEY_MANAGED_SOURCES, nextState.managedSources],
[STORAGE_KEY_GROUP_CONFIGS, encryptedConfigs],
[STORAGE_KEY_SNIPPETS, nextState.snippets],
]);
return { ok: true, state: nextState };
}
@@ -12,7 +12,7 @@ export interface VaultHostTreeActions {
onNewGroup: (parentPath?: string) => void;
onRenameGroup: (groupPath: string) => void;
onDeleteGroup: (groupPath: string) => void;
commitInlineGroupRename: (name: string) => void;
commitInlineGroupRename: (name: string) => boolean | void | Promise<boolean | void>;
cancelInlineGroupEdit: () => void;
commitInlineHostRename: (name: string) => void;
cancelInlineHostEdit: () => void;
@@ -81,6 +81,7 @@ test('vault actions register and unregister', () => {
readPersistedManagedSources: noop,
commitPluginImporterData: noop,
commitVaultImportTransaction: noop,
commitVaultGroupMutation: noop,
updateHostDistro: noop,
updateHostLastConnected: noop,
addShellHistoryEntry: noop,
+1
View File
@@ -58,6 +58,7 @@ export type VaultSnapshotActions = Pick<
| 'readPersistedManagedSources'
| 'commitPluginImporterData'
| 'commitVaultImportTransaction'
| 'commitVaultGroupMutation'
| 'updateHostDistro'
| 'updateHostLastConnected'
| 'addShellHistoryEntry'
BIN
View File
Binary file not shown.
+7 -3
View File
@@ -290,7 +290,7 @@ interface HostTreeViewProps {
onDeleteGroup: (groupPath: string) => void;
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
moveGroup: (sourcePath: string, targetParent: string | null) => void;
commitInlineGroupRename?: (name: string) => void;
commitInlineGroupRename?: (name: string) => boolean | void | Promise<boolean | void>;
cancelInlineGroupEdit?: () => void;
managedGroupPaths?: Set<string>;
onUnmanageGroup?: (groupPath: string) => void;
@@ -330,7 +330,7 @@ interface TreeNodeProps {
onDeleteGroup: (groupPath: string) => void;
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
moveGroup: (sourcePath: string, targetParent: string | null) => void;
commitInlineGroupRename?: (name: string) => void;
commitInlineGroupRename?: (name: string) => boolean | void | Promise<boolean | void>;
cancelInlineGroupEdit?: () => void;
managedGroupPaths?: Set<string>;
onUnmanageGroup?: (groupPath: string) => void;
@@ -759,7 +759,11 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
: -1}
onFocus={() => onActiveTreeItemChange(`host:${host.id}`)}
draggable={!isMultiSelectMode}
onDragStart={(e) => e.dataTransfer.setData("host-id", host.id)}
onDragStart={(e) => {
// copyMove: vault reorder uses move; focus-sidebar append uses copy.
e.dataTransfer.effectAllowed = "copyMove";
e.dataTransfer.setData("host-id", host.id);
}}
onClick={() => {
const action = resolveHostActivateAction({
behavior: normalizedHostClickBehavior,
+168
View File
@@ -0,0 +1,168 @@
import { CheckSquare, FolderTree, Search, Square } from 'lucide-react';
import React, { useMemo, useState } from 'react';
import { useI18n } from '@/application/i18n/I18nProvider';
import type { Host } from '@/domain/models';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { FixedSizeVirtualList } from '@/components/ui/FixedSizeVirtualList';
import { cn } from '@/lib/utils';
export interface SelectGroupDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
hosts: Host[];
customGroups?: string[];
selectedGroupPaths: string[];
onSelectionChange: (selectedGroupPaths: string[]) => void;
}
const GROUP_ROW_HEIGHT = 48;
function collectGroupPaths(
hosts: Host[],
customGroups: string[],
selectedGroupPaths: string[],
): string[] {
const paths = new Set<string>();
for (const groupPath of [...customGroups, ...selectedGroupPaths]) {
const parts = groupPath.split('/').filter(Boolean);
for (let index = 1; index <= parts.length; index += 1) {
paths.add(parts.slice(0, index).join('/'));
}
}
for (const host of hosts) {
const parts = host.group?.split('/').filter(Boolean) ?? [];
for (let index = 1; index <= parts.length; index += 1) {
paths.add(parts.slice(0, index).join('/'));
}
}
return [...paths].sort((left, right) => left.localeCompare(right));
}
function countHostsByGroupPath(hosts: Host[]): Map<string, number> {
const counts = new Map<string, number>();
for (const host of hosts) {
if (host.protocol === 'serial') continue;
const parts = host.group?.split('/').filter(Boolean) ?? [];
for (let index = 1; index <= parts.length; index += 1) {
const path = parts.slice(0, index).join('/');
counts.set(path, (counts.get(path) ?? 0) + 1);
}
}
return counts;
}
export const SelectGroupDialog: React.FC<SelectGroupDialogProps> = ({
open,
onOpenChange,
hosts,
customGroups = [],
selectedGroupPaths,
onSelectionChange,
}) => {
const { t } = useI18n();
const [searchQuery, setSearchQuery] = useState('');
const selected = useMemo(() => new Set(selectedGroupPaths), [selectedGroupPaths]);
const groupPaths = useMemo(
() => collectGroupPaths(hosts, customGroups, selectedGroupPaths),
[customGroups, hosts, selectedGroupPaths],
);
const displayedPaths = useMemo(() => {
const query = searchQuery.trim().toLocaleLowerCase();
if (!query) return groupPaths;
return groupPaths.filter((path) => path.toLocaleLowerCase().includes(query));
}, [groupPaths, searchQuery]);
const hostCountByPath = useMemo(() => countHostsByGroupPath(hosts), [hosts]);
const togglePath = (path: string) => {
if (selected.has(path)) {
onSelectionChange(selectedGroupPaths.filter((candidate) => candidate !== path));
return;
}
onSelectionChange([...selectedGroupPaths, path]);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md h-[min(78vh,600px)] flex flex-col p-0 gap-0 overflow-hidden">
<DialogHeader className="px-4 pt-4 pb-3 shrink-0 border-b border-border/60">
<DialogTitle className="text-base">{t('snippets.targets.selectGroups')}</DialogTitle>
</DialogHeader>
<div className="px-4 py-3 border-b border-border/60 shrink-0">
<div className="relative">
<Search
size={14}
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder={t('common.searchPlaceholder')}
className="h-8 pl-8"
/>
</div>
</div>
<div className="flex-1 min-h-0 p-3">
{displayedPaths.length === 0 ? (
<p className="py-10 text-center text-sm text-muted-foreground">
{t('snippets.targets.noGroups')}
</p>
) : (
<FixedSizeVirtualList
className="h-full"
items={displayedPaths}
itemHeight={GROUP_ROW_HEIGHT}
getItemKey={(path) => path}
renderItem={(path) => {
const isSelected = selected.has(path);
const depth = Math.max(0, path.split('/').filter(Boolean).length - 1);
const label = path.split('/').filter(Boolean).pop() ?? path;
const hostCount = hostCountByPath.get(path) ?? 0;
return (
<button
type="button"
className={cn(
'w-full h-11 flex items-center gap-2.5 rounded-lg px-2.5 text-left transition-colors',
isSelected ? 'bg-primary/10 text-foreground' : 'hover:bg-muted/70',
)}
style={{ paddingLeft: `${10 + depth * 16}px` }}
onClick={() => togglePath(path)}
>
{isSelected ? (
<CheckSquare size={16} className="text-primary shrink-0" />
) : (
<Square size={16} className="text-muted-foreground shrink-0" />
)}
<FolderTree size={16} className="text-primary shrink-0" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{label}</span>
<span className="block truncate text-[11px] text-muted-foreground">{path}</span>
</span>
<span className="text-[11px] text-muted-foreground shrink-0">
{t('vault.groups.hostsCount', { count: hostCount })}
</span>
</button>
);
}}
/>
)}
</div>
<DialogFooter className="px-4 py-3 border-t border-border/60 shrink-0">
<Button className="w-full" onClick={() => onOpenChange(false)}>
{t('selectHost.continueWithCount', { count: selectedGroupPaths.length })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
+72 -11
View File
@@ -86,8 +86,12 @@ import { scheduleDeferredTerminalFocus } from "./systemManager/tmuxActionFocus";
import {
connectionKeyMatchesHost,
findReusableSftpSidePanelTab,
isPendingSameEndpointSshSession,
rememberSftpSidePanelSourceStatus,
resolveSftpSidePanelTrackedSourceStatusUpdate,
shouldAcceptPendingSftpUpload,
shouldResetSftpSidePanelSourceSession,
shouldDeferSftpSidePanelAutoConnectForSession,
shouldRebindSftpSidePanelSourceSession,
shouldSkipSftpSidePanelAutoConnect,
} from "./sftp/sftpSidePanelAutoConnect";
import {
@@ -116,7 +120,7 @@ interface SftpSidePanelProps {
sftpDefaultViewMode: "list" | "tree";
/** The host to connect to (follows focused terminal) */
activeHost: Host | null;
/** The terminal session id whose SSH connection can be reused for SFTP */
/** Linked same-endpoint SSH session id (may be reconnecting; reuse only when connected) */
activeSessionId?: string | null;
/** Focused terminal session (includes mosh/et/local) for locate-path writes */
focusedSessionId?: string | null;
@@ -219,7 +223,8 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
}, [hosts, sessions]);
// Browse restore can run before the session list reflects the focused tab;
// prefer the active source when its visible endpoint matches the pane host.
// prefer the active source when its visible endpoint matches the pane host
// and the SSH transport is already up (linked id survives reconnect phases).
const resolveBrowseSourceSessionId = useCallback((hostId: string, host?: Host) => {
if (
activeSessionId
@@ -227,10 +232,13 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
&& activeHost.id === hostId
&& (!host || sftpHostEndpointsEqual(activeHost, host))
) {
return activeSessionId;
const linkedSession = sessions.find((session) => session.id === activeSessionId);
if (linkedSession?.status === "connected") {
return activeSessionId;
}
}
return resolveTransferSourceSessionId(hostId, host);
}, [activeHost, activeSessionId, resolveTransferSourceSessionId]);
}, [activeHost, activeSessionId, resolveTransferSourceSessionId, sessions]);
const fileWatchHandlers = useMemo(() => ({
onFileWatchSynced: (payload: { remotePath: string }) => {
@@ -493,6 +501,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
const connectedKeyRef = useRef<string | null>(null);
const connectedHostObjRef = useRef<Host | null>(null);
const lastSourceSessionIdRef = useRef<string | null>(null);
const lastSourceSessionStatusRef = useRef<string | null>(null);
const lastAppliedInitialLocationKeyRef = useRef<string | null>(null);
const handledPendingUploadIdRef = useRef<string | null>(null);
const tabConnectionKeyMapRef = useRef<Map<string, string>>(new Map());
@@ -560,10 +569,36 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
activeHost.username,
activeHost.sftpFileProtocol,
);
const sessionChanged = shouldResetSftpSidePanelSourceSession(
lastSourceSessionIdRef.current,
activeSessionId,
);
const pendingSameEndpointSession = sessions.find((session) => (
isPendingSameEndpointSshSession(session, activeHost)
));
if (!activeSessionId && pendingSameEndpointSession) {
return;
}
const activeSession = activeSessionId
? sessions.find((session) => session.id === activeSessionId) ?? null
: null;
const activeSessionStatus = activeSession?.status ?? null;
if (
shouldDeferSftpSidePanelAutoConnectForSession({
activeSessionId,
sessionStatus: activeSessionStatus,
})
) {
// Remember the non-connected status so the later connected transition
// is treated as a transport rebind (same session id after Start over).
lastSourceSessionStatusRef.current = activeSessionStatus;
if (activeSessionId) {
lastSourceSessionIdRef.current = activeSessionId;
}
return;
}
const sessionChanged = shouldRebindSftpSidePanelSourceSession({
previousSessionId: lastSourceSessionIdRef.current,
nextSessionId: activeSessionId,
previousStatus: lastSourceSessionStatusRef.current,
nextStatus: activeSessionStatus ?? (activeSessionId ? "connected" : null),
});
const hasBackendSession = (connectionId: string) => !!s.getSftpIdForConnection(connectionId);
const activeTab = s.leftTabs.tabs.find((tab) => tab.id === s.leftTabs.activeTabId) ?? null;
@@ -592,6 +627,11 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
if (activeSessionId) {
lastSourceSessionIdRef.current = activeSessionId;
}
lastSourceSessionStatusRef.current = rememberSftpSidePanelSourceStatus({
previousStatus: lastSourceSessionStatusRef.current,
activeSessionId,
activeSessionStatus,
});
return;
}
// Defer advancing the session cursor while interactive work blocks rebind,
@@ -600,6 +640,11 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
if (activeSessionId) {
lastSourceSessionIdRef.current = activeSessionId;
}
lastSourceSessionStatusRef.current = rememberSftpSidePanelSourceStatus({
previousStatus: lastSourceSessionStatusRef.current,
activeSessionId,
activeSessionStatus,
});
logger.info("[SftpSidePanel] Auto-connect triggered", {
hostId: activeHost.id,
@@ -730,14 +775,16 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
connectedKeyRef.current = connectionKey;
connectedHostObjRef.current = activeHost;
s.connect("left", activeHost, {
sourceSessionId: activeSessionId ?? undefined,
sourceSessionId: activeSessionStatus === "connected"
? (activeSessionId ?? undefined)
: undefined,
...(initialPath ? { initialPath } : undefined),
...(needsNewTab ? { forceNewTab: true } : undefined),
onTabCreated: (tabId) => {
tabConnectionKeyMapRef.current.set(tabId, connectionKey);
},
});
}, [activeHost, activeSessionId, initialLocation, interactiveWorkActive]);
}, [activeHost, activeSessionId, initialLocation, interactiveWorkActive, sessions]);
useEffect(() => {
if (!activeHost || !isVisible) return;
@@ -752,6 +799,20 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
};
}, [activeHost, activeSessionId, interactiveWorkActive, isVisible, runAutoConnect]);
useEffect(() => {
if (activeSessionId) return;
const trackedSessionId = lastSourceSessionIdRef.current;
const trackedSession = trackedSessionId
? sessions.find((candidate) => candidate.id === trackedSessionId) ?? null
: null;
const update = resolveSftpSidePanelTrackedSourceStatusUpdate({
trackedSessionId,
sessionStatus: trackedSession?.status ?? null,
});
if (!update) return;
lastSourceSessionStatusRef.current = update.status;
}, [activeSessionId, sessions]);
useEffect(() => {
const connection = sftp.leftPane.connection;
if (!connection || connection.status === "error" || connection.status === "disconnected") {
+16 -2
View File
@@ -18,7 +18,11 @@ import {
type SnippetExportPayload,
type SnippetImportConflictAction,
} from '../domain/snippetTransfer';
import { getRunnableHostsForSnippet, snippetHasRunTargets } from '../domain/snippetTargets.ts';
import {
getRunnableHostsForSnippet,
resolveSnippetTargetGroupsForSave,
snippetHasRunTargets,
} from '../domain/snippetTargets.ts';
import { removeHostConnectScript, syncHostsForSnippetTargetChange } from '../domain/hostConnectScripts.ts';
import { flattenSnippetCommandPreview } from '../domain/snippetPreview.ts';
import { deleteSelectedSnippetsFromVault } from '../domain/snippetSelection.ts';
@@ -486,6 +490,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
targets: [],
});
const [targetSelection, setTargetSelection] = useState<string[]>([]);
const [targetGroupSelection, setTargetGroupSelection] = useState<string[]>([]);
const [copiedId, setCopiedId] = useState<string | null>(null);
const [selectedPackage, setSelectedPackage] = useState<string | null>(null);
const [newPackageName, setNewPackageName] = useState('');
@@ -735,6 +740,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
if (snippet) {
setEditingSnippet(snippet);
setTargetSelection(snippet.targetsAllHosts ? [] : (snippet.targets || []));
setTargetGroupSelection(snippet.targetsAllHosts ? [] : (snippet.targetGroups || []));
} else {
setEditingSnippet(asScript ? {
label: '',
@@ -751,6 +757,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
targets: [],
});
setTargetSelection([]);
setTargetGroupSelection([]);
}
setRightPanelMode('edit-snippet');
}, [selectedPackage]);
@@ -773,6 +780,10 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
tags: editingSnippet.tags || [],
package: editingSnippet.package || '',
targets: editingSnippet.targetsAllHosts ? [] : targetSelection,
targetGroups: resolveSnippetTargetGroupsForSave(
editingSnippet,
targetGroupSelection,
),
targetsAllHosts: editingSnippet.targetsAllHosts || undefined,
shortkey: editingSnippet.shortkey,
noAutoRun: editingSnippet.noAutoRun,
@@ -784,7 +795,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
trigger: editingSnippet.trigger,
triggerPattern: editingSnippet.triggerPattern,
};
}, [editingSnippet, targetSelection]);
}, [editingSnippet, targetGroupSelection, targetSelection]);
const syncHostsAfterSnippetSave = useCallback((
savedSnippet: Snippet,
@@ -855,6 +866,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
setRightPanelMode('none');
setEditingSnippet({ label: '', command: '', package: '', targets: [] });
setTargetSelection([]);
setTargetGroupSelection([]);
};
const hostById = useMemo(() => (
@@ -1663,6 +1675,8 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
customGroups={customGroups}
targetSelection={targetSelection}
setTargetSelection={setTargetSelection}
targetGroupSelection={targetGroupSelection}
setTargetGroupSelection={setTargetGroupSelection}
handleTargetSelect={handleTargetSelect}
handleTargetSelectionChange={handleTargetSelectionChange}
handleTargetPickerBack={handleTargetPickerBack}
+36 -2
View File
@@ -3,6 +3,7 @@ import { parseSnippetVariables } from '../domain/snippetVariables';
import { Check, Clock, Keyboard, Loader2, Package, RotateCcw, Trash2 } from 'lucide-react';
import { cn } from '../lib/utils';
import SelectHostPanel from './SelectHostPanel';
import { SelectGroupDialog } from './SelectGroupDialog';
import { AsidePanel, AsidePanelContent, AsidePanelFooter } from './ui/aside-panel';
import { Button } from './ui/button';
import { Card } from './ui/card';
@@ -17,7 +18,10 @@ import { Combobox } from './ui/combobox';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { HistoryItem } from './SnippetsHistoryItem';
import type { Snippet } from '@/domain/models';
import { getRunnableHostsForSnippet } from '@/domain/snippetTargets.ts';
import {
getRunnableHostsForSnippet,
resolveSnippetTargetGroupsForSave,
} from '@/domain/snippetTargets.ts';
import { STORAGE_KEY_SNIPPETS_PANEL_WIDTH } from '@/infrastructure/config/storageKeys.ts';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -29,6 +33,8 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
customGroups,
targetSelection,
setTargetSelection,
targetGroupSelection,
setTargetGroupSelection,
handleTargetSelect,
handleTargetSelectionChange,
handleTargetPickerBack,
@@ -71,6 +77,7 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
[editingSnippet?.command],
);
const [scriptEditorModalOpen, setScriptEditorModalOpen] = useState(false);
const [groupPickerOpen, setGroupPickerOpen] = useState(false);
const isEditingScript = isScriptSnippet(editingSnippet as import('../types').Snippet);
const snippetsPanelResizeProps = {
resizable: true as const,
@@ -81,8 +88,12 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
const runnableEditingSnippet = useMemo(() => ({
...(editingSnippet as Snippet),
targets: editingSnippet.targetsAllHosts ? [] : targetSelection,
targetGroups: resolveSnippetTargetGroupsForSave(
editingSnippet,
targetGroupSelection,
),
targetsAllHosts: editingSnippet.targetsAllHosts || undefined,
}), [editingSnippet, targetSelection]);
}), [editingSnippet, targetGroupSelection, targetSelection]);
const runTargets = useMemo(
() => getRunnableHostsForSnippet(runnableEditingSnippet, hosts),
@@ -99,10 +110,12 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
const handleTargetsAllHostsChange = (checked: boolean) => {
if (checked) {
setTargetSelection([]);
setTargetGroupSelection([]);
setEditingSnippet({
...editingSnippet,
targetsAllHosts: true,
targets: [],
targetGroups: undefined,
});
return;
}
@@ -137,6 +150,7 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
if (rightPanelMode === 'edit-snippet') {
return (
<>
<AsidePanel
open={true}
onClose={handleClosePanel}
@@ -266,7 +280,9 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
<SnippetTargetsSection
t={t}
targetHosts={targetHosts}
targetGroups={isEditingScript ? targetGroupSelection : []}
onEditTargets={openTargetPicker}
onEditGroups={isEditingScript ? () => setGroupPickerOpen(true) : undefined}
hint={isEditingScript
? (editingSnippet.trigger === 'onConnect'
? t('scripts.targets.connectOrderHint')
@@ -386,11 +402,29 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
selectedHostIds={targetSelection}
onSelectHost={handleTargetSelect}
onSelectionChange={handleTargetSelectionChange}
selectedGroupPaths={targetGroupSelection}
onGroupSelectionChange={setTargetGroupSelection}
targetsAllHosts={Boolean(editingSnippet.targetsAllHosts)}
onTargetsAllHostsChange={handleTargetsAllHostsChange}
/>
) : null}
</AsidePanel>
<SelectGroupDialog
open={groupPickerOpen}
onOpenChange={setGroupPickerOpen}
hosts={hosts}
customGroups={customGroups}
selectedGroupPaths={targetGroupSelection}
onSelectionChange={(nextGroups) => {
setTargetGroupSelection(nextGroups);
setEditingSnippet({
...editingSnippet,
targetGroups: nextGroups,
targetsAllHosts: undefined,
});
}}
/>
</>
);
}
+142 -28
View File
@@ -121,6 +121,7 @@ import { useVaultSnapshotField } from "@/application/state/vaultSnapshotStore.ts
import { netcattyBridge } from "@/infrastructure/services/netcattyBridge.ts";
import { ScriptExecutionOverlay } from "./terminal/ScriptExecutionOverlay";
import { isScriptSnippet } from "@/domain/snippetScript.ts";
import { snippetCanRunInTerminal } from "@/domain/snippetTargets.ts";
import { useOutputTriggers } from "@/application/state/useOutputTriggers.ts";
import { TerminalComposeBar } from "./terminal/TerminalComposeBar";
import { TerminalContextMenu } from "./terminal/TerminalContextMenu";
@@ -225,8 +226,16 @@ import {
} from "./terminal/terminalRuntimeMount";
import type { CreateXTermRuntimeContext } from "./terminal/runtime/createXTermRuntime";
import { TerminalView } from "./terminal/TerminalView";
import {
cancelConnectAutomationBatch,
createConnectAutomationBatch,
trackConnectAutomationStop,
type ConnectAutomationBatch,
} from "./terminal/connectAutomationBatch";
import {
getInitialTerminalStatus,
resolveTerminalVaultInitialized,
shouldResetConnectAutomationOnReconnect,
shouldSuppressHostStartupCommandOnReconnect,
shouldStartTerminalBackend,
} from "./terminal/restoredSessionGate";
@@ -270,6 +279,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
sessionId,
workspaceId,
restoreState,
vaultInitializedOverride,
pendingInitialCwd,
shellType,
lastCwd,
@@ -359,10 +369,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}, []);
// Reactive vault-ready flag so restored panes re-arm boot after hydration
// (module isVaultInitialized() alone is not a React dependency).
const vaultInitialized = useVaultSnapshotField("isVaultInitialized");
const sharedVaultInitialized = useVaultSnapshotField("isVaultInitialized");
const vaultInitialized = resolveTerminalVaultInitialized(
sharedVaultInitialized,
vaultInitializedOverride,
);
const connectScriptsConsumedRef = useRef(false);
const connectScriptsCompletedIdsRef = useRef(new Set<string>());
const connectScriptsInFlightRef = useRef(false);
const connectScriptsBatchRef = useRef<ConnectAutomationBatch | null>(null);
const pendingScriptRunIdRef = useRef<string | null>(null);
const pendingScriptHandledRef = useRef<Snippet | null>(null);
const pendingScriptRef = useRef(pendingScript);
@@ -413,7 +428,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const scriptSessionName = sessionDisplayName || host.label;
const outputTriggers = useOutputTriggers({
sessionId,
hostId: host.id,
host,
snippets,
onRunScript: (snippet, sid) => runAutomationScript({
snippet,
@@ -424,7 +439,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
hostname: host.hostname,
username: host.username,
},
}).catch((err) => {
}).then(() => undefined).catch((err) => {
const message = err instanceof Error ? err.message : String(err);
toast.error(message.includes('Observer mode') ? t('scripts.observer.blocked') : message);
throw err;
@@ -1498,6 +1513,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return;
}
}
await xtermRuntimeRef.current?.keywordHighlighter.prepareForSerialization();
snapshot = serializeAddonRef.current.serialize() || "";
} else if (hibernatedRef.current || softHiddenRef.current) {
// Hibernate path: live xterm is torn down; use retained snapshot.
@@ -1603,7 +1619,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
terminalCwdTracker,
]);
const cleanupSession = async () => {
const cleanupSession = async (options?: { retainOwnership?: boolean }) => {
const closingSessionId = sessionRef.current;
xtermRuntimeRef.current?.flushKittyKeyboardReleases();
sessionRef.current = null;
@@ -1631,7 +1647,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// Still notify main so in-flight SSH passphrase prompts for this UI
// sessionId are aborted even before a backend session was attached.
try {
await terminalBackend.closeSession(sessionId, { bootEpoch: resolveCloseBootEpoch() });
await terminalBackend.closeSession(sessionId, {
bootEpoch: resolveCloseBootEpoch(),
...(options?.retainOwnership === true ? { retainOwnership: true } : {}),
});
} catch (err) {
logger.warn("Failed to cancel pending session boot on disconnect", err);
}
@@ -1678,6 +1697,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
let serializedSnapshot: unknown;
try {
await xtermRuntimeRef.current?.keywordHighlighter.prepareForSerialization();
serializedSnapshot = serializeAddonRef.current?.serialize?.();
} catch (err) {
logger.warn("Failed to serialize terminal snapshot for attach popup", err);
@@ -1740,7 +1760,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
clearTerminalSessionFlowAck(closingSessionId);
}
try {
await terminalBackend.closeSession(closingSessionId, { bootEpoch: resolveCloseBootEpoch() });
await terminalBackend.closeSession(closingSessionId, {
bootEpoch: resolveCloseBootEpoch(),
...(options?.retainOwnership === true ? { retainOwnership: true } : {}),
});
} catch (err) {
logger.warn("Failed to close SSH session", err);
}
@@ -2032,7 +2055,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const snapshot = await serializeTerminalForHibernate(
term,
serializeAddon,
{ preferWasm: resolveHibernatePreferWasmSerialize(terminalSettingsRef.current) },
{
preferWasm: resolveHibernatePreferWasmSerialize(terminalSettingsRef.current),
prepare: () => xtermRuntimeRef.current?.keywordHighlighter.prepareForSerialization()
?? Promise.resolve(),
},
);
if (!canFinishHibernate()) return false;
@@ -2154,6 +2181,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
termRef,
fitAddonRef,
serializeAddonRef,
prepareKeywordHighlightSerialization: () => xtermRuntimeRef.current
?.keywordHighlighter.prepareForSerialization() ?? Promise.resolve(),
searchAddonRef,
hasRuntimeRef,
}), []);
@@ -2511,9 +2540,19 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (!shouldEvaluateConnect && !hasPendingWork) return;
if (connectScriptsInFlightRef.current) return;
const batch = createConnectAutomationBatch();
connectScriptsBatchRef.current = batch;
connectScriptsInFlightRef.current = true;
const batchStillActive = () => (
!batch.controller.signal.aborted && connectScriptsBatchRef.current === batch
);
// Defer until xterm has rendered login output and the main-process output tap
// has populated SessionOutputBuffer (avoids waitForPrompt racing an empty buffer).
let timerFired = false;
const timer = window.setTimeout(() => {
timerFired = true;
if (!batchStillActive()) return;
const runPending = Boolean(pendingOne);
const connectQueueNow = connectScriptsConsumedRef.current
? []
@@ -2542,6 +2581,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
})) {
connectScriptsConsumedRef.current = true;
}
if (connectScriptsBatchRef.current === batch) {
connectScriptsBatchRef.current = null;
connectScriptsInFlightRef.current = false;
}
return;
}
@@ -2550,11 +2593,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
connectQueueNow.map((item) => item.id).filter((id): id is string => Boolean(id)),
);
connectScriptsInFlightRef.current = true;
void runConnectScriptsSequential({
scripts: scriptsToRun,
sessionId,
signal: batch.controller.signal,
onCancelableRunChange: (stopCurrentRun) => (
trackConnectAutomationStop(batch, stopCurrentRun)
),
sessionMeta: {
connected: true,
name: scriptSessionName,
@@ -2562,6 +2607,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
username: host.username,
},
onScriptComplete: (snippet) => {
if (!batchStillActive()) return;
if (snippet.id && connectIdsInBatch.has(snippet.id)) {
connectScriptsCompletedIdsRef.current.add(snippet.id);
}
@@ -2575,6 +2621,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
},
})
.then(() => {
if (!batchStillActive()) return;
const resolvedAfterRun = resolveConnectScriptsForHost(host, snippets);
const doneAfterRun = resolvedAfterRun.length === 0
|| resolvedAfterRun.every(
@@ -2585,6 +2632,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
})
.catch(async (err) => {
if (!batchStillActive()) return;
const message = err instanceof Error ? err.message : String(err);
toast.error(message.includes('Observer mode') ? t('scripts.observer.blocked') : message);
connectScriptsConsumedRef.current = true;
@@ -2600,6 +2648,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
await runConnectScriptsSequential({
scripts: [pendingScriptToMark],
sessionId,
signal: batch.controller.signal,
onCancelableRunChange: (stopCurrentRun) => (
trackConnectAutomationStop(batch, stopCurrentRun)
),
sessionMeta: {
connected: true,
name: scriptSessionName,
@@ -2607,6 +2659,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
username: host.username,
},
onScriptComplete: (snippet) => {
if (!batchStillActive()) return;
if (snippet.id) {
pendingScriptRunIdRef.current = snippet.id;
} else {
@@ -2615,16 +2668,32 @@ const TerminalComponent: React.FC<TerminalProps> = ({
},
});
} catch (pendingErr) {
if (!batchStillActive()) return;
const pendingMessage = pendingErr instanceof Error ? pendingErr.message : String(pendingErr);
toast.error(pendingMessage.includes('Observer mode') ? t('scripts.observer.blocked') : pendingMessage);
}
})
.finally(() => {
connectScriptsInFlightRef.current = false;
if (
!batch.controller.signal.aborted
&& connectScriptsBatchRef.current === batch
) {
connectScriptsBatchRef.current = null;
connectScriptsInFlightRef.current = false;
}
});
}, 400);
return () => window.clearTimeout(timer);
return () => {
window.clearTimeout(timer);
if (!timerFired && connectScriptsBatchRef.current === batch) {
batch.controller.abort();
connectScriptsBatchRef.current = null;
connectScriptsInFlightRef.current = false;
} else if (statusRef.current !== "connected") {
void cancelConnectAutomationBatch(batch).catch(() => {});
}
};
}, [effectiveTerminalProtocol, host, isPendingScriptAlreadyHandled, moshShellReady, pendingScript, pendingScriptId, scriptSessionName, sessionId, snippets, status, t]);
useEffect(() => {
@@ -3013,6 +3082,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const executeSnippet = useCallback(async (snippet: Snippet) => {
if (isScriptSnippet(snippet)) {
if (!snippetCanRunInTerminal(snippet, host)) {
toast.error(t('scripts.targets.currentHostMismatch'));
return;
}
try {
await runAutomationScript({
snippet,
@@ -3035,7 +3108,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
executeSnippetCommand(command, snippet.noAutoRun, {
multiLineRunMode: snippet.multiLineRunMode,
});
}, [executeSnippetCommand, host.hostname, host.username, scriptSessionName, sessionId, t]);
}, [executeSnippetCommand, host, scriptSessionName, sessionId, t]);
const onSnippetShortkeyRef = useRef(executeSnippet);
onSnippetShortkeyRef.current = executeSnippet;
@@ -3249,7 +3322,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
setPendingHostKeyRequestId(null);
setError(null);
setProgressLogs((prev) => [...prev, "Disconnected by user."]);
void cleanupSession();
void cleanupSession({ retainOwnership: true });
updateStatus("disconnected");
setChainProgress(null);
setIsDisconnectedDialogDismissed(false);
@@ -3352,20 +3425,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return;
}
if (!termRef.current) return;
if (mode === "manual") {
clearAutoReconnect();
prepareRestoredReconnect();
// A clone's first connection can fail (auth/host-key/transport) before the
// inherited `cd` is consumed. prepareRestoredReconnect() just cleared the
// intent for non-restored sessions, so re-arm it here; the callback no-ops
// once the cwd was consumed or when there is no pending inherited cwd.
prepareInitialCwdIntent();
} else {
restoreCwdIntentRef.current = null;
suppressHostStartupCommandRef.current = shouldSuppressHostStartupCommandOnReconnect("automatic");
}
// Claim the retry before awaiting close. A close/cancel/unmount during the
// awaited backend cleanup invalidates this token and stops the continuation.
// Claim the retry before awaiting either script cancellation or backend
// cleanup. A second reconnect/cancel invalidates this continuation.
const retryToken = Symbol("retry");
retryTokenRef.current = retryToken;
reconnectPreparationTokenRef.current = retryToken;
@@ -3376,8 +3437,61 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
};
const connectAutomationBatch = connectScriptsBatchRef.current;
if (connectAutomationBatch) {
try {
await cancelConnectAutomationBatch(connectAutomationBatch);
} catch (error) {
finishReconnectPreparation();
const message = error instanceof Error ? error.message : String(error);
toast.error(message);
if (mode === "auto" && retryTokenStillCurrent()) {
// Return to disconnected so the existing auto-reconnect loop can
// schedule another attempt, including another exact stop request.
updateStatus("disconnected");
}
return;
}
if (!retryTokenStillCurrent()) {
finishReconnectPreparation();
return;
}
if (connectScriptsBatchRef.current === connectAutomationBatch) {
connectScriptsBatchRef.current = null;
connectScriptsInFlightRef.current = false;
}
}
if (mode === "manual") {
clearAutoReconnect();
// Manual reconnect skips the disconnected status transition that normally
// clears these refs, so onConnect scripts would otherwise stay consumed.
if (shouldResetConnectAutomationOnReconnect(
restoreState === "restored-disconnected" ? "restored" : "manual",
)) {
// The prior batch is now stopped, so its callbacks cannot mutate these
// newly allocated guards for the fresh manual connection.
connectScriptsConsumedRef.current = false;
connectScriptsCompletedIdsRef.current = new Set();
connectScriptsInFlightRef.current = false;
}
prepareRestoredReconnect();
// A clone's first connection can fail (auth/host-key/transport) before the
// inherited `cd` is consumed. prepareRestoredReconnect() just cleared the
// intent for non-restored sessions, so re-arm it here; the callback no-ops
// once the cwd was consumed or when there is no pending inherited cwd.
prepareInitialCwdIntent();
} else {
// An automatic reconnect replaces the transport but remains the same user
// session. Stop old automation above, then preserve the existing policy of
// not starting onConnect scripts again on the replacement transport.
connectScriptsConsumedRef.current = true;
restoreCwdIntentRef.current = null;
suppressHostStartupCommandRef.current = shouldSuppressHostStartupCommandOnReconnect("automatic");
}
try {
await cleanupSession();
await cleanupSession({ retainOwnership: true });
} catch (error) {
finishReconnectPreparation();
throw error;
+2
View File
@@ -220,6 +220,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onCreateWorkspaceFromSessions,
onAddSessionToWorkspace,
onRequestAddToWorkspace,
onAppendHostToWorkspace,
onUpdateSplitSizes,
onSetDraggingSessionId,
onToggleWorkspaceViewMode,
@@ -2107,6 +2108,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onCopySession,
onCopySessionToNewWindow,
onRequestAddToWorkspace,
onAppendHostToWorkspace,
onSessionData,
onSetDraggingSessionId,
onSetWorkspaceFocusedSession,
+4
View File
@@ -141,6 +141,10 @@ test('popup terminals resolve complete host config and pass jump hosts into Term
assert.match(source, /onDeleteSnippets=\{deleteSelectedSnippets\}/);
});
test('popup terminals use their window-local vault readiness', () => {
assert.match(source, /vaultInitializedOverride=\{vaultInitialized\}/);
});
test('popup provider tree mounts the plugin authentication host', () => {
assert.match(source, /import \{ PluginAuthenticationHost \} from '\.\/plugins\/PluginAuthenticationHost';/);
assert.match(
+1
View File
@@ -467,6 +467,7 @@ function TerminalPopupPageInner() {
accentMode={settings.accentMode}
customAccent={settings.customAccent}
terminalSettings={settings.terminalSettings}
vaultInitializedOverride={vaultInitialized}
disableTerminalFontZoom={settings.disableTerminalFontZoom}
sessionId={sessionId}
startupCommand={isAttachMode ? undefined : config.startupCommand}
+8
View File
@@ -361,6 +361,14 @@ test("workspace session drag data is recognized with a dedicated drag type", ()
assert.equal(getWorkspaceSessionDragId(transfer), "session-1");
});
test("TopTabs wires host drops to workspace tabs without replacing tab reorder", () => {
assert.match(topTabsSource, /appendHostFromWorkspaceDrop/);
assert.match(topTabsSource, /workspaceMap\.has\(targetTabId\)/);
assert.match(topTabsSource, /dropEffect = 'copy'/);
assert.match(topTabsSource, /isHostDropTarget=\{hostDropWorkspaceId === workspace\.id\}/);
assert.match(appViewSource, /<TopTabs[\s\S]*?onAppendHostToWorkspace=\{handleAppendHostToWorkspace\}/);
});
test("workspace session drag id falls back to the legacy session id", () => {
const transfer = {
types: ["session-id"],
+52 -3
View File
@@ -7,6 +7,10 @@ import { buildTabShortcutNumberById } from '../application/app/tabShortcutTarget
import { useShortcutModifierHeld } from '../application/state/useShortcutModifierHeld';
import type { EditorTabChrome } from '../application/state/editorTabStore';
import { collectSessionIds } from '../domain/workspace';
import {
appendHostFromWorkspaceDrop,
resolveFocusSidebarDragKind,
} from '../domain/focusSidebarHostDrop';
import type { DynamicTabTitleMode, KeyBinding } from '../domain/models';
import { getTopTabInsertionTarget, getWorkspaceSessionDragId, hasWorkspaceSessionDrag } from '../application/state/terminalDragData';
@@ -156,6 +160,7 @@ interface TopTabsProps {
sessionId: string,
tabInsertionTarget?: { tabId: string; position: 'before' | 'after'; additionalTabIds?: readonly string[] },
) => void;
onAppendHostToWorkspace?: (workspaceId: string, hostId: string) => void;
showSftpTab: boolean;
showHostTreeSidebar: boolean;
switchTabKeyBinding: Pick<KeyBinding, 'mac' | 'pc'> | null;
@@ -201,6 +206,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onEndSessionDrag,
onReorderTabs,
onRemoveSessionFromWorkspace,
onAppendHostToWorkspace,
showSftpTab,
showHostTreeSidebar,
switchTabKeyBinding,
@@ -260,6 +266,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
// Tab reorder drag state
const [dropIndicator, setDropIndicator] = useState<{ tabId: string; position: 'before' | 'after' } | null>(null);
const [isDraggingForReorder, setIsDraggingForReorder] = useState(false);
const [hostDropWorkspaceId, setHostDropWorkspaceId] = useState<string | null>(null);
const draggedTabIdRef = useRef<string | null>(null);
const [isWindowFullscreen, setIsWindowFullscreen] = useState(false);
@@ -521,12 +528,28 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
draggedTabIdRef.current = null;
setDropIndicator(null);
setIsDraggingForReorder(false);
setHostDropWorkspaceId(null);
onEndSessionDrag();
}, [onEndSessionDrag]);
const handleTabDragOver = useCallback((e: React.DragEvent, tabId: string) => {
const dragKind = resolveFocusSidebarDragKind({ types: e.dataTransfer.types });
if (dragKind === 'host-append') {
if (!onAppendHostToWorkspace || !workspaceMap.has(tabId)) {
setHostDropWorkspaceId(null);
return;
}
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'copy';
setDropIndicator(null);
setHostDropWorkspaceId(tabId);
return;
}
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setHostDropWorkspaceId(null);
if (hasWorkspaceSessionDrag(e.dataTransfer)) {
setDropIndicator(null);
@@ -544,16 +567,40 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
// Always update drop indicator on drag over to ensure it doesn't get stuck
setDropIndicator({ tabId, position });
}, []);
}, [onAppendHostToWorkspace, workspaceMap]);
const handleTabDragLeave = useCallback((_e: React.DragEvent) => {
const handleTabDragLeave = useCallback((e: React.DragEvent) => {
const next = e.relatedTarget;
if (next instanceof Node && e.currentTarget.contains(next)) return;
setHostDropWorkspaceId(null);
// Don't clear drop indicator on drag leave - let onDragOver manage it
// This prevents the indicator from flickering/disappearing during fast drags
// The indicator will be cleared when drag ends or on drop
}, []);
const handleTabDrop = useCallback((e: React.DragEvent, targetTabId: string) => {
const dragKind = resolveFocusSidebarDragKind({ types: e.dataTransfer.types });
if (dragKind === 'host-append') {
const handled = Boolean(
onAppendHostToWorkspace
&& workspaceMap.has(targetTabId)
&& appendHostFromWorkspaceDrop({
types: e.dataTransfer.types,
getData: (type) => e.dataTransfer.getData(type),
workspaceId: targetTabId,
onAppendHostToWorkspace,
}),
);
setHostDropWorkspaceId(null);
setDropIndicator(null);
if (!handled) return;
e.preventDefault();
e.stopPropagation();
return;
}
e.preventDefault();
setHostDropWorkspaceId(null);
if (hasWorkspaceSessionDrag(e.dataTransfer)) {
const draggedSessionId = getWorkspaceSessionDragId(e.dataTransfer);
const draggedSession = sessions.find((s) => s.id === draggedSessionId);
@@ -582,7 +629,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
setDropIndicator(null);
setIsDraggingForReorder(false);
}, [dropIndicator, onEndSessionDrag, onRemoveSessionFromWorkspace, onReorderTabs, sessions, workspaces]);
}, [dropIndicator, onAppendHostToWorkspace, onEndSessionDrag, onRemoveSessionFromWorkspace, onReorderTabs, sessions, workspaceMap, workspaces]);
const handleTabBarDrop = useCallback((e: React.DragEvent) => {
if (!hasWorkspaceSessionDrag(e.dataTransfer)) return;
@@ -872,6 +919,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
shiftStyle={shiftStyle}
showDropIndicatorBefore={showDropIndicatorBefore}
showDropIndicatorAfter={showDropIndicatorAfter}
isHostDropTarget={hostDropWorkspaceId === workspace.id}
onTabDragStart={handleTabDragStart}
onTabDragEnd={handleTabDragEnd}
onTabDragOver={handleTabDragOver}
@@ -1208,6 +1256,7 @@ export const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean
prev.onCopySession === next.onCopySession &&
prev.onCopySessionToNewWindow === next.onCopySessionToNewWindow &&
prev.onEditHost === next.onEditHost &&
prev.onAppendHostToWorkspace === next.onAppendHostToWorkspace &&
prev.onCopyWorkspace === next.onCopyWorkspace &&
prev.onOpenSettings === next.onOpenSettings &&
prev.externalMcpEnabled === next.externalMcpEnabled &&
+60 -42
View File
@@ -60,48 +60,66 @@ const renderVault = (sortMode: string | null, hosts: Host[]) => {
return renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(
TooltipProvider,
null,
React.createElement(VaultView, {
hosts,
keys: [],
identities: [],
proxyProfiles: [],
snippets: [],
snippetPackages: [],
customGroups: [],
knownHosts: [],
managedSources: [],
sessionCount: 0,
hotkeyScheme: "mac",
keyBindings: [],
terminalThemeId: "default",
terminalFontSize: 14,
onOpenSettings: noop,
onOpenQuickSwitcher: noop,
onCreateLocalTerminal: noop,
onDeleteHost: noop,
onConnect: noop,
onUpdateHosts: noop,
onUpdateKeys: noop,
onImportOrReuseKey: () => fallbackKey,
onUpdateIdentities: noop,
onUpdateProxyProfiles: noop,
onUpdateSnippets: noop,
onUpdateSnippetPackages: noop,
onUpdateCustomGroups: noop,
onUpdateKnownHosts: noop,
onUpdateManagedSources: noop,
onConvertKnownHost: noop,
onOpenLogView: noop,
groupConfigs: [],
onUpdateGroupConfigs: noop,
showRecentHosts: false,
showOnlyUngroupedHostsInRoot: false,
}),
),
{
locale: "en",
children: React.createElement(
TooltipProvider,
null,
React.createElement(VaultView, {
hosts,
keys: [],
identities: [],
proxyProfiles: [],
snippets: [],
snippetPackages: [],
customGroups: [],
knownHosts: [],
managedSources: [],
sessionCount: 0,
hotkeyScheme: "mac",
keyBindings: [],
terminalThemeId: "default",
terminalFontSize: 14,
onOpenSettings: noop,
onOpenQuickSwitcher: noop,
onCreateLocalTerminal: noop,
onDeleteHost: noop,
onConnect: noop,
onUpdateHosts: noop,
onUpdateKeys: noop,
onImportOrReuseKey: () => fallbackKey,
onUpdateIdentities: noop,
onUpdateProxyProfiles: noop,
onUpdateSnippets: noop,
onUpdateSnippetPackages: noop,
onUpdateCustomGroups: noop,
onUpdateKnownHosts: noop,
onUpdateManagedSources: noop,
onReadPersistedHosts: async () => hosts,
onReadPersistedManagedSources: () => [],
onCommitPluginImporterData: async () => 0,
onCommitVaultImportTransaction: async () => ({
status: "persisted" as const,
groups: [],
sources: [],
groupConfigs: [],
}),
onCommitVaultGroupMutation: async (mutate) => mutate({
groups: [],
configs: [],
hosts,
managedSources: [],
snippets: [],
}),
onConvertKnownHost: noop,
onOpenLogView: noop,
groupConfigs: [],
onUpdateGroupConfigs: noop,
showRecentHosts: false,
showOnlyUngroupedHostsInRoot: false,
}),
),
},
),
);
};
+91 -113
View File
@@ -52,6 +52,10 @@ import type { VaultLockHandle } from "../application/state/vaultManagedImportLoc
import { useTreeExpandedState } from "../application/state/useTreeExpandedState";
import { useVaultGroupDeletion } from "../application/state/useVaultGroupDeletion";
import type { VaultHostPersistenceResult } from "../application/state/vaultImportProgress";
import type {
VaultGroupMutationResult,
VaultGroupMutationState,
} from "../domain/vaultGroupMutation";
import type { PluginImporterCommitRequest } from "../application/state/usePluginImporterCommit";
import { buildVaultCsvCredentialOptions } from "../application/vaultCsvExportCredentials";
import { sanitizeCredentialValue } from "../domain/credentials";
@@ -69,6 +73,9 @@ import {
upsertHostById,
} from "../domain/host";
import { exportHostsToCsvWithStats } from "../domain/vaultImport";
import {
remapSnippetTargetGroupPaths,
} from "../domain/hostGroupPathMutations";
import {
reorderVaultItems,
reorderVaultStrings,
@@ -261,6 +268,10 @@ interface VaultViewProps {
}
| { status: "superseded" }
>;
onCommitVaultGroupMutation: (
mutate: (current: VaultGroupMutationState) => VaultGroupMutationResult,
lock?: VaultLockHandle | null,
) => Promise<VaultGroupMutationResult | { ok: false; superseded: true }>;
onClearAndRemoveManagedSource?: (source: ManagedSource) => Promise<() => Promise<void>>;
onClearAndRemoveManagedSources?: (sources: ManagedSource[]) => Promise<() => Promise<void>>;
onUnmanageSource?: (sourceId: string) => void;
@@ -325,6 +336,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
onUpdateManagedSources,
onReadPersistedManagedSources,
onCommitVaultImportTransaction,
onCommitVaultGroupMutation,
onClearAndRemoveManagedSource,
onClearAndRemoveManagedSources,
onUnmanageSource,
@@ -951,7 +963,66 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
setIsNewFolderOpen(false);
};
const submitRenameGroup = () => {
const commitGroupPathChange = useCallback(async (
sourcePath: string,
nextPath: string,
replacementConfig?: GroupConfig,
) => {
try {
return await onCommitVaultGroupMutation((current) => {
if (!current.groups.includes(sourcePath)) {
return { ok: false, error: `Group "${sourcePath}" was not found.` };
}
const belongsToTree = (path: string) => (
path === sourcePath || path.startsWith(`${sourcePath}/`)
);
const rename = (path: string) => (
path === sourcePath
? nextPath
: path.startsWith(`${sourcePath}/`)
? nextPath + path.slice(sourcePath.length)
: path
);
const occupied = new Set([
...current.groups.filter((path) => !belongsToTree(path)),
...current.configs.map((config) => config.path).filter((path) => !belongsToTree(path)),
]);
const collision = [
...current.groups,
...current.configs.map((config) => config.path),
].filter(belongsToTree).map(rename).find((path) => occupied.has(path));
if (collision) return { ok: false, error: `Group "${collision}" already exists.` };
const configs = current.configs.map((config) => {
if (replacementConfig && config.path === sourcePath) {
return { ...replacementConfig, path: nextPath };
}
return belongsToTree(config.path) ? { ...config, path: rename(config.path) } : config;
});
if (replacementConfig && !current.configs.some((config) => config.path === sourcePath)) {
configs.push({ ...replacementConfig, path: nextPath });
}
return {
ok: true,
state: {
groups: Array.from(new Set(current.groups.map(rename))),
configs,
hosts: current.hosts.map((host) => (
host.group && belongsToTree(host.group) ? { ...host, group: rename(host.group) } : host
)),
managedSources: current.managedSources.map((source) => (
belongsToTree(source.groupName) ? { ...source, groupName: rename(source.groupName) } : source
)),
snippets: remapSnippetTargetGroupPaths(current.snippets, sourcePath, nextPath),
},
};
});
} catch (error) {
return { ok: false as const, error: error instanceof Error ? error.message : String(error) };
}
}, [onCommitVaultGroupMutation]);
const submitRenameGroup = async () => {
if (!renameTargetPath) return;
const nextName = renameGroupName.trim();
@@ -972,37 +1043,11 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
return;
}
const updatedGroups = customGroups.map((g) => {
if (g === renameTargetPath) return nextPath;
if (g.startsWith(renameTargetPath + "/"))
return nextPath + g.slice(renameTargetPath.length);
return g;
});
const updatedHosts = hosts.map((h) => {
const g = h.group || "";
if (g === renameTargetPath) return { ...h, group: nextPath };
if (g.startsWith(renameTargetPath + "/"))
return { ...h, group: nextPath + g.slice(renameTargetPath.length) };
return h;
});
// Update managed sources if any match the renamed group path
const updatedManagedSources = managedSources.map((s) => {
if (s.groupName === renameTargetPath)
return { ...s, groupName: nextPath };
if (s.groupName.startsWith(renameTargetPath + "/"))
return {
...s,
groupName: nextPath + s.groupName.slice(renameTargetPath.length),
};
return s;
});
if (updatedManagedSources.some((s, i) => s !== managedSources[i])) {
onUpdateManagedSources(updatedManagedSources);
const result = await commitGroupPathChange(renameTargetPath, nextPath);
if ("superseded" in result || "error" in result) {
setRenameGroupError("superseded" in result ? t("common.error") : result.error);
return;
}
onUpdateCustomGroups(Array.from(new Set(updatedGroups)));
onUpdateHosts(updatedHosts);
if (
selectedGroupPath &&
(selectedGroupPath === renameTargetPath ||
@@ -1026,7 +1071,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
}, []);
const handleSaveGroupConfig = useCallback(
(config: GroupConfig, _newName?: string, _newParent?: string | null) => {
async (config: GroupConfig, _newName?: string, _newParent?: string | null) => {
const oldPath = editingGroupPath!;
const newPath = config.path; // Panel already computed the correct path
@@ -1044,41 +1089,11 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
// Handle path change (rename or parent change)
if (newPath !== oldPath) {
// Update groups, hosts, managed sources, and configs for path change
const updatedGroups = customGroups.map((g) => {
if (g === oldPath) return newPath;
if (g.startsWith(oldPath + "/"))
return newPath + g.slice(oldPath.length);
return g;
});
const updatedHosts = hosts.map((h) => {
const g = h.group || "";
if (g === oldPath) return { ...h, group: newPath };
if (g.startsWith(oldPath + "/"))
return { ...h, group: newPath + g.slice(oldPath.length) };
return h;
});
const updatedManagedSources = managedSources.map((s) => {
if (s.groupName === oldPath) return { ...s, groupName: newPath };
if (s.groupName.startsWith(oldPath + "/"))
return {
...s,
groupName: newPath + s.groupName.slice(oldPath.length),
};
return s;
});
if (updatedManagedSources.some((s, i) => s !== managedSources[i])) {
onUpdateManagedSources(updatedManagedSources);
const result = await commitGroupPathChange(oldPath, newPath, config);
if ("superseded" in result || "error" in result) {
toast.error("superseded" in result ? t("common.error") : result.error);
return;
}
onUpdateCustomGroups(Array.from(new Set(updatedGroups)));
onUpdateHosts(updatedHosts);
// Update child config paths too
const finalConfigs = updatedConfigs.map((c) => {
if (c.path.startsWith(oldPath + "/"))
return { ...c, path: newPath + c.path.slice(oldPath.length) };
return c;
});
onUpdateGroupConfigs(finalConfigs);
if (selectedGroupPath === oldPath) setSelectedGroupPath(newPath);
if (selectedGroupPath?.startsWith(oldPath + "/")) {
setSelectedGroupPath(
@@ -1096,13 +1111,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
groupConfigs,
editingGroupPath,
customGroups,
hosts,
managedSources,
selectedGroupPath,
commitGroupPathChange,
onUpdateGroupConfigs,
onUpdateCustomGroups,
onUpdateHosts,
onUpdateManagedSources,
t,
],
);
@@ -1129,7 +1140,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
managedSources,
onReadPersistedHosts,
onReadPersistedManagedSources,
onCommitVaultImportTransaction,
onCommitVaultGroupMutation,
onClearAndRemoveManagedSource,
onClearAndRemoveManagedSources,
onDeletedPaths: handleDeletedGroupPaths,
@@ -1138,7 +1149,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
const deleteGroupPath = (path: string, deleteHosts: boolean = false) =>
deleteGroupPaths([path], deleteHosts);
const moveGroup = (sourcePath: string, targetParent: string | null) => {
const moveGroup = async (sourcePath: string, targetParent: string | null) => {
const name = sourcePath.split("/").filter(Boolean).pop() || "";
const newPath = targetParent ? `${targetParent}/${name}` : name;
if (newPath === sourcePath || newPath.startsWith(sourcePath + "/")) return;
@@ -1146,50 +1157,17 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
toast.error(t("vault.groups.errors.duplicatePath"));
return;
}
const updatedGroups = customGroups.map((g) => {
if (g === sourcePath) return newPath;
if (g.startsWith(sourcePath + "/"))
return newPath + g.slice(sourcePath.length);
return g;
});
const updatedHosts = hosts.map((h) => {
const g = h.group || "";
if (g === sourcePath) return { ...h, group: newPath };
if (g.startsWith(sourcePath + "/"))
return { ...h, group: newPath + g.slice(sourcePath.length) };
return h;
});
// Update managed sources if any match the moved group path
const updatedManagedSources = managedSources.map((s) => {
if (s.groupName === sourcePath) return { ...s, groupName: newPath };
if (s.groupName.startsWith(sourcePath + "/"))
return {
...s,
groupName: newPath + s.groupName.slice(sourcePath.length),
};
return s;
});
if (updatedManagedSources.some((s, i) => s !== managedSources[i])) {
onUpdateManagedSources(updatedManagedSources);
}
onUpdateCustomGroups(Array.from(new Set(updatedGroups)));
onUpdateHosts(updatedHosts);
// Update group configs for moved paths
const updatedGroupConfigs = groupConfigs.map((c) => {
if (c.path === sourcePath) return { ...c, path: newPath };
if (c.path.startsWith(sourcePath + "/"))
return { ...c, path: newPath + c.path.slice(sourcePath.length) };
return c;
});
if (updatedGroupConfigs.some((c, i) => c !== groupConfigs[i])) {
onUpdateGroupConfigs(updatedGroupConfigs);
const result = await commitGroupPathChange(sourcePath, newPath);
if ("superseded" in result || "error" in result) {
toast.error("superseded" in result ? t("common.error") : result.error);
return;
}
if (
selectedGroupPath &&
(selectedGroupPath === sourcePath ||
selectedGroupPath.startsWith(sourcePath + "/"))
) {
setSelectedGroupPath(newPath);
setSelectedGroupPath(newPath + selectedGroupPath.slice(sourcePath.length));
}
};
@@ -1321,8 +1299,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
hosts,
managedSources,
onUpdateCustomGroups,
onUpdateHosts,
onUpdateManagedSources,
onCommitGroupPathChange: commitGroupPathChange,
selectedGroupPath,
setSelectedGroupPath,
ensurePathExpanded: treeExpandedState.ensurePathExpanded,
@@ -1688,6 +1665,7 @@ export const vaultViewAreEqual = (
prev.onReadPersistedHosts === next.onReadPersistedHosts &&
prev.onReadPersistedManagedSources === next.onReadPersistedManagedSources &&
prev.onCommitVaultImportTransaction === next.onCommitVaultImportTransaction &&
prev.onCommitVaultGroupMutation === next.onCommitVaultGroupMutation &&
prev.onCommitPluginImporterData === next.onCommitPluginImporterData &&
prev.showRecentHosts === next.showRecentHosts &&
prev.hostClickBehavior === next.hostClickBehavior &&
+49 -2
View File
@@ -1,10 +1,11 @@
import { Globe, Link2, Play, Trash2 } from 'lucide-react';
import { FolderTree, Globe, Link2, Play, Trash2 } from 'lucide-react';
import React, { useCallback, useMemo, useState, type DragEvent } from 'react';
import type { Host, Snippet } from '@/domain/models';
import {
appendHostConnectScript,
getEditableHostConnectScriptIds,
getGlobalConnectScripts,
getGroupConnectScriptsForHost,
removeHostConnectScript,
reorderHostConnectScript,
} from '@/domain/hostConnectScripts.ts';
@@ -63,6 +64,16 @@ export const HostDetailsScriptsSection: React.FC<HostDetailsScriptsSectionProps>
() => getEditableHostConnectScriptIds(host, snippets),
[host, snippets],
);
const groupScripts = useMemo(
() => getGroupConnectScriptsForHost(host, snippets).filter(
(script) => !script.id || !queueIds.includes(script.id),
),
[host, queueIds, snippets],
);
const groupScriptIds = useMemo(
() => new Set(groupScripts.map((script) => script.id)),
[groupScripts],
);
const queuedScripts = useMemo(
() => queueIds
.map((id) => scriptById(snippets, id))
@@ -73,10 +84,11 @@ export const HostDetailsScriptsSection: React.FC<HostDetailsScriptsSectionProps>
() => scripts.filter((script) => {
if (!script.id) return false;
if (script.targetsAllHosts) return false;
if (groupScriptIds.has(script.id)) return false;
if (queueIds.includes(script.id)) return false;
return true;
}),
[queueIds, scripts],
[groupScriptIds, queueIds, scripts],
);
const linkOptions = useMemo(
() => linkableScripts.map((script) => ({
@@ -170,6 +182,41 @@ export const HostDetailsScriptsSection: React.FC<HostDetailsScriptsSectionProps>
</div>
) : null}
{groupScripts.length > 0 ? (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<FolderTree size={12} className="text-muted-foreground shrink-0" />
<label className="text-xs text-muted-foreground">
{t('hostDetails.automation.groupScripts')}
</label>
</div>
<p className="text-[10px] text-muted-foreground leading-relaxed">
{t('hostDetails.automation.groupScriptsHint')}
</p>
<div className="space-y-1.5">
{groupScripts.map((script) => (
<div
key={script.id}
className="flex items-center gap-2.5 px-2.5 py-2 rounded-md border border-border/50 bg-primary/5"
>
<VaultEntityIcon
className={cn(vaultEntityIconSmClass, vaultAutomationScriptIconClass)}
icon={<Play size={14} />}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">
{script.label || t('scripts.running.unnamed')}
</div>
<div className="text-[10px] text-muted-foreground truncate">
{script.targetGroups?.join(', ')}
</div>
</div>
</div>
))}
</div>
</div>
) : null}
<div className="space-y-2">
{queuedScripts.length === 0 ? (
<p className="text-[11px] text-muted-foreground">{t('hostDetails.automation.connectQueueEmpty')}</p>
@@ -0,0 +1,55 @@
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 { HostTreeGroupInlineRenameInput } from './HostTreeGroupInlineRenameInput';
test('inline group rename can retry after an asynchronous commit failure', async () => {
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
let renderer: ReactTestRenderer | null = null;
let attempts = 0;
try {
await act(async () => {
renderer = create(React.createElement(HostTreeGroupInlineRenameInput, {
initialName: 'prod',
onCommit: async () => {
attempts += 1;
return attempts > 1;
},
onCancel: () => undefined,
}));
});
const input = renderer!.root.findByType('input');
await act(async () => {
input.props.onKeyDown({
key: 'Enter',
preventDefault: () => undefined,
stopPropagation: () => undefined,
});
await Promise.resolve();
});
await act(async () => {
input.props.onKeyDown({
key: 'Enter',
preventDefault: () => undefined,
stopPropagation: () => undefined,
});
await Promise.resolve();
});
assert.equal(attempts, 2);
} finally {
await act(async () => {
renderer?.unmount();
});
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
@@ -4,7 +4,7 @@ import { cn } from '../../lib/utils';
type HostTreeGroupInlineRenameInputProps = {
initialName: string;
onCommit: (name: string) => void;
onCommit: (name: string) => boolean | void | Promise<boolean | void>;
onCancel: () => void;
className?: string;
style?: React.CSSProperties;
@@ -19,7 +19,7 @@ export const HostTreeGroupInlineRenameInput: React.FC<HostTreeGroupInlineRenameI
}) => {
const inputRef = useRef<HTMLInputElement>(null);
const [value, setValue] = useState(initialName);
const committedRef = useRef(false);
const submittingRef = useRef(false);
useEffect(() => {
const input = inputRef.current;
@@ -28,15 +28,20 @@ export const HostTreeGroupInlineRenameInput: React.FC<HostTreeGroupInlineRenameI
input.select();
}, []);
const commit = () => {
if (committedRef.current) return;
committedRef.current = true;
onCommit(value);
const commit = async () => {
if (submittingRef.current) return;
submittingRef.current = true;
try {
const committed = await onCommit(value);
if (committed === false) submittingRef.current = false;
} catch {
submittingRef.current = false;
}
};
const cancel = () => {
if (committedRef.current) return;
committedRef.current = true;
if (submittingRef.current) return;
submittingRef.current = true;
onCancel();
};
@@ -49,7 +54,7 @@ export const HostTreeGroupInlineRenameInput: React.FC<HostTreeGroupInlineRenameI
onChange={(event) => setValue(event.target.value)}
onBlur={() => {
queueMicrotask(() => {
commit();
void commit();
});
}}
onClick={(event) => event.stopPropagation()}
@@ -64,7 +69,7 @@ export const HostTreeGroupInlineRenameInput: React.FC<HostTreeGroupInlineRenameI
event.stopPropagation();
if (event.key === 'Enter') {
event.preventDefault();
commit();
void commit();
}
if (event.key === 'Escape') {
event.preventDefault();
+19 -2
View File
@@ -6,6 +6,7 @@ import {
} from '@/domain/snippetScript.ts';
import {
getRunnableHostsForSnippet,
resolveSnippetTargetGroupsForSave,
} from '@/domain/snippetTargets.ts';
import {
removeHostConnectScript,
@@ -54,11 +55,13 @@ export const QuickScriptEditorDialog: React.FC<QuickScriptEditorDialogProps> = (
const [open, setOpen] = useState(false);
const [editingSnippet, setEditingSnippet] = useState<Partial<Snippet>>(createBlankScript);
const [targetSelection, setTargetSelection] = useState<string[]>([]);
const [targetGroupSelection, setTargetGroupSelection] = useState<string[]>([]);
useEffect(() => {
const handler = () => {
setEditingSnippet(createBlankScript());
setTargetSelection([]);
setTargetGroupSelection([]);
setOpen(true);
};
window.addEventListener('netcatty:scripts:add', handler);
@@ -71,6 +74,7 @@ export const QuickScriptEditorDialog: React.FC<QuickScriptEditorDialogProps> = (
if (!snippet || !isScriptSnippet(snippet)) return;
setEditingSnippet(snippet);
setTargetSelection(snippet.targetsAllHosts ? [] : (snippet.targets ?? []));
setTargetGroupSelection(snippet.targetsAllHosts ? [] : (snippet.targetGroups ?? []));
setOpen(true);
};
window.addEventListener('netcatty:snippets:edit', handler);
@@ -112,6 +116,7 @@ export const QuickScriptEditorDialog: React.FC<QuickScriptEditorDialogProps> = (
if (detail.editAfterSave) {
setEditingSnippet(snippet);
setTargetSelection([]);
setTargetGroupSelection([]);
setOpen(true);
}
};
@@ -132,8 +137,12 @@ export const QuickScriptEditorDialog: React.FC<QuickScriptEditorDialogProps> = (
const runnableSnippet = useMemo(() => ({
...(editingSnippet as Snippet),
targets: editingSnippet.targetsAllHosts ? [] : targetSelection,
targetGroups: resolveSnippetTargetGroupsForSave(
editingSnippet,
targetGroupSelection,
),
targetsAllHosts: editingSnippet.targetsAllHosts || undefined,
}), [editingSnippet, targetSelection]);
}), [editingSnippet, targetGroupSelection, targetSelection]);
const runTargets = useMemo(
() => getRunnableHostsForSnippet(runnableSnippet, hosts),
@@ -174,6 +183,10 @@ export const QuickScriptEditorDialog: React.FC<QuickScriptEditorDialogProps> = (
tags: editingSnippet.tags ?? [],
package: packagePath,
targets: editingSnippet.targetsAllHosts ? [] : targetSelection,
targetGroups: resolveSnippetTargetGroupsForSave(
editingSnippet,
targetGroupSelection,
),
targetsAllHosts: editingSnippet.targetsAllHosts || undefined,
kind: 'script',
language: editingSnippet.language ?? 'javascript',
@@ -182,7 +195,7 @@ export const QuickScriptEditorDialog: React.FC<QuickScriptEditorDialogProps> = (
triggerPattern: editingSnippet.triggerPattern,
order: editingSnippet.order,
};
}, [editingSnippet, onCreatePackage, packages, targetSelection]);
}, [editingSnippet, onCreatePackage, packages, targetGroupSelection, targetSelection]);
const persistSnippet = useCallback((): Snippet | null => {
const savedSnippet = buildSavedSnippet();
@@ -238,10 +251,12 @@ export const QuickScriptEditorDialog: React.FC<QuickScriptEditorDialogProps> = (
const handleTargetsAllHostsChange = useCallback((checked: boolean) => {
if (checked) {
setTargetSelection([]);
setTargetGroupSelection([]);
setEditingSnippet((prev) => ({
...prev,
targetsAllHosts: true,
targets: [],
targetGroups: undefined,
}));
return;
}
@@ -266,6 +281,8 @@ export const QuickScriptEditorDialog: React.FC<QuickScriptEditorDialogProps> = (
selectedHostIds={targetSelection}
onSelectHost={handleSelectHost}
onSelectionChange={handleSelectionChange}
selectedGroupPaths={targetGroupSelection}
onGroupSelectionChange={setTargetGroupSelection}
targetsAllHosts={Boolean(editingSnippet.targetsAllHosts)}
onTargetsAllHostsChange={handleTargetsAllHostsChange}
/>
+28 -1
View File
@@ -5,9 +5,11 @@ import type { Host, Snippet } from '@/domain/models';
import { DEFAULT_SCRIPT_TEMPLATE } from '@/domain/snippetScript.ts';
import { scheduleWindowInputFocus } from '@/application/state/windowInputFocus';
import { SelectHostDialog } from '@/components/SelectHostDialog';
import { SelectGroupDialog } from '@/components/SelectGroupDialog';
import { ScriptCodeEditor } from './ScriptCodeEditor';
import { ScriptMetaFields } from './ScriptMetaFields';
import { SnippetTargetsSection } from '@/components/snippets/SnippetTargetsSection';
import { resolveSnippetTargetGroupsForSave } from '@/domain/snippetTargets.ts';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
@@ -26,6 +28,8 @@ export interface ScriptEditorModalProps {
selectedHostIds: string[];
onSelectHost: (host: Host) => void;
onSelectionChange?: (selectedHostIds: string[]) => void;
selectedGroupPaths?: string[];
onGroupSelectionChange?: (selectedGroupPaths: string[]) => void;
targetsAllHosts?: boolean;
onTargetsAllHostsChange?: (checked: boolean) => void;
}
@@ -53,11 +57,14 @@ export const ScriptEditorModal: React.FC<ScriptEditorModalProps> = ({
selectedHostIds,
onSelectHost,
onSelectionChange,
selectedGroupPaths = [],
onGroupSelectionChange,
targetsAllHosts = false,
onTargetsAllHostsChange,
}) => {
const { t } = useI18n();
const [targetPickerOpen, setTargetPickerOpen] = useState(false);
const [groupPickerOpen, setGroupPickerOpen] = useState(false);
const handleOpenChange = useCallback((isOpen: boolean) => {
if (!isOpen) {
@@ -81,9 +88,10 @@ export const ScriptEditorModal: React.FC<ScriptEditorModalProps> = ({
onChange({
...snippet,
targets: selectedHostIds,
targetGroups: resolveSnippetTargetGroupsForSave(snippet, selectedGroupPaths),
targetsAllHosts: undefined,
});
}, [onChange, selectedHostIds, snippet]);
}, [onChange, selectedGroupPaths, selectedHostIds, snippet]);
const language = 'javascript';
const editorValue = snippet.command || DEFAULT_SCRIPT_TEMPLATE;
@@ -146,9 +154,13 @@ export const ScriptEditorModal: React.FC<ScriptEditorModalProps> = ({
variant="embedded"
t={t}
targetHosts={targetHosts}
targetGroups={selectedGroupPaths}
onEditTargets={() => {
if (!targetsAllHosts) setTargetPickerOpen(true);
}}
onEditGroups={onGroupSelectionChange ? () => {
if (!targetsAllHosts) setGroupPickerOpen(true);
} : undefined}
hint={t('scripts.targets.hint')}
targetsAllHosts={targetsAllHosts}
onTargetsAllHostsChange={onTargetsAllHostsChange}
@@ -192,6 +204,21 @@ export const ScriptEditorModal: React.FC<ScriptEditorModalProps> = ({
onSelectionChange={onSelectionChange}
onConfirm={handleTargetsConfirm}
/>
<SelectGroupDialog
open={groupPickerOpen}
onOpenChange={setGroupPickerOpen}
hosts={hosts}
customGroups={customGroups}
selectedGroupPaths={selectedGroupPaths}
onSelectionChange={(nextGroups) => {
onGroupSelectionChange?.(nextGroups);
onChange({
...snippet,
targetGroups: nextGroups,
targetsAllHosts: undefined,
});
}}
/>
</>
);
};
@@ -3,6 +3,7 @@ import { Check, ChevronDown, RefreshCw } from "lucide-react";
import type { AIProviderId, ProviderStyle } from "../../../../infrastructure/ai/types";
import { resolveProviderStyle } from "../../../../infrastructure/ai/types";
import { buildModelDiscoveryHeaders, resolveModelsDiscoveryEndpoint } from "../../../../infrastructure/ai/modelDiscoveryHeaders";
import { buildProviderProbeUrl } from "../../../../infrastructure/ai/providerConnectionProbe";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip";
@@ -150,7 +151,7 @@ export const ModelSelector: React.FC<{
if (bridge.aiAllowlistAddHost && baseURL) {
await bridge.aiAllowlistAddHost(baseURL);
}
const url = `${baseURL.replace(/\/+$/, "")}${effectiveModelsEndpoint}`;
const url = buildProviderProbeUrl(baseURL, effectiveModelsEndpoint);
const headers = buildModelDiscoveryHeaders(resolvedStyle, apiKey);
const result = await bridge.aiFetch(url, "GET", headers, undefined, undefined, undefined, undefined, skipTLSVerify);
if (!result.ok) {
@@ -509,6 +509,9 @@ export const ProviderConfigForm: React.FC<{
placeholder={preset?.defaultBaseURL || "https://"}
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
{resolvedStyle === "anthropic" ? (
<p className="text-[11px] text-muted-foreground/70">{t('ai.providers.baseUrl.anthropicHelp')}</p>
) : null}
</div>
{/* Default Model */}
@@ -23,3 +23,17 @@ test("offers replace when a file upload conflicts with an existing file", () =>
existingType: "file",
}), true);
});
test("offers replace when a directory upload conflicts with an existing symlink", () => {
assert.equal(canReplaceConflict({
isDirectory: true,
existingType: "symlink",
}), true);
});
test("offers replace when a file upload conflicts with an existing symlink", () => {
assert.equal(canReplaceConflict({
isDirectory: false,
existingType: "symlink",
}), true);
});
+1 -6
View File
@@ -50,7 +50,7 @@ test("sftp host picker uses single-line quick switcher row layout", () => {
assert.doesNotMatch(source, /text-xs text-muted-foreground truncate/);
});
test("sftp host picker omits sourceSessionId for sudo connected hosts", () => {
test("sftp host picker passes sourceSessionId for sudo connected hosts", () => {
assert.match(source, /sftpSourceSessionIdForHost/);
assert.match(
source,
@@ -60,11 +60,6 @@ test("sftp host picker omits sourceSessionId for sudo connected hosts", () => {
source,
/onSelectHost\(\s*item\.entry\.host,\s*sourceSessionId \? \{ sourceSessionId \} : undefined,\s*\)/,
);
// Must not always pass sessionId as a reuse hint (sudo would hide the spinner).
assert.doesNotMatch(
source,
/onSelectHost\(item\.entry\.host, \{ sourceSessionId: item\.entry\.sessionId \}\)/,
);
});
test("sftp host picker virtualizes the host list", () => {
@@ -4,8 +4,13 @@ import type { SftpPane } from "../../application/state/sftp/types";
import {
connectionKeyMatchesHost,
findReusableSftpSidePanelTab,
isPendingSameEndpointSshSession,
isRemoteSftpTabHealthy,
rememberSftpSidePanelSourceStatus,
resolveSftpSidePanelTrackedSourceStatusUpdate,
shouldAcceptPendingSftpUpload,
shouldDeferSftpSidePanelAutoConnectForSession,
shouldRebindSftpSidePanelSourceSession,
shouldResetSftpSidePanelSourceSession,
shouldSkipSftpSidePanelAutoConnect,
} from "./sftpSidePanelAutoConnect";
@@ -77,6 +82,35 @@ test("shouldSkipSftpSidePanelAutoConnect rejects when the active tab has no endp
);
});
test("isPendingSameEndpointSshSession only waits for actively connecting SSH sessions", () => {
const host = {
id: "host-1",
hostname: "server.example",
port: 22,
username: "root",
};
const baseSession = {
hostId: "host-1",
hostname: "server.example",
port: 22,
username: "root",
protocol: "ssh",
};
assert.equal(
isPendingSameEndpointSshSession({ ...baseSession, status: "connecting" }, host),
true,
);
assert.equal(
isPendingSameEndpointSshSession({ ...baseSession, status: "disconnected" }, host),
false,
);
assert.equal(
isPendingSameEndpointSshSession({ ...baseSession, status: "connected" }, host),
false,
);
});
test("connectionKeyMatchesHost accepts host-id prefix keys", () => {
assert.equal(connectionKeyMatchesHost("host-1:server:22:ssh::root", "host-1"), true);
assert.equal(connectionKeyMatchesHost("host-2:server:22:ssh::root", "host-1"), false);
@@ -146,6 +180,175 @@ test("shouldResetSftpSidePanelSourceSession detects terminal session changes", (
assert.equal(shouldResetSftpSidePanelSourceSession("sess-a", null), false);
});
test("shouldRebindSftpSidePanelSourceSession treats SSH start-over as a transport change", () => {
// Same terminal tab id after Start over - transport was replaced even though
// the session id did not change.
assert.equal(
shouldRebindSftpSidePanelSourceSession({
previousSessionId: "sess-a",
nextSessionId: "sess-a",
previousStatus: "disconnected",
nextStatus: "connected",
}),
true,
);
assert.equal(
shouldRebindSftpSidePanelSourceSession({
previousSessionId: "sess-a",
nextSessionId: "sess-a",
previousStatus: "connecting",
nextStatus: "connected",
}),
true,
);
assert.equal(
shouldRebindSftpSidePanelSourceSession({
previousSessionId: "sess-a",
nextSessionId: "sess-a",
previousStatus: "connected",
nextStatus: "connected",
}),
false,
);
assert.equal(
shouldRebindSftpSidePanelSourceSession({
previousSessionId: "sess-a",
nextSessionId: "sess-b",
previousStatus: "connected",
nextStatus: "connected",
}),
true,
);
assert.equal(
shouldRebindSftpSidePanelSourceSession({
previousSessionId: "sess-a",
nextSessionId: "sess-a",
previousStatus: "disconnected",
nextStatus: "connecting",
}),
false,
);
});
test("shouldDeferSftpSidePanelAutoConnectForSession only waits during an active reconnect", () => {
assert.equal(
shouldDeferSftpSidePanelAutoConnectForSession({
activeSessionId: "sess-a",
sessionStatus: "disconnected",
}),
false,
);
assert.equal(
shouldDeferSftpSidePanelAutoConnectForSession({
activeSessionId: "sess-a",
sessionStatus: "connecting",
}),
true,
);
assert.equal(
shouldDeferSftpSidePanelAutoConnectForSession({
activeSessionId: "sess-a",
sessionStatus: "connected",
}),
false,
);
assert.equal(
shouldDeferSftpSidePanelAutoConnectForSession({
activeSessionId: null,
sessionStatus: "connecting",
}),
false,
);
});
test("resolveSftpSidePanelTrackedSourceStatusUpdate remembers background disconnects", () => {
assert.deepEqual(
resolveSftpSidePanelTrackedSourceStatusUpdate({
trackedSessionId: "sess-a",
sessionStatus: "disconnected",
}),
{ sessionId: "sess-a", status: "disconnected" },
);
assert.deepEqual(
resolveSftpSidePanelTrackedSourceStatusUpdate({
trackedSessionId: "sess-a",
sessionStatus: "connecting",
}),
{ sessionId: "sess-a", status: "connecting" },
);
assert.equal(
resolveSftpSidePanelTrackedSourceStatusUpdate({
trackedSessionId: "sess-a",
sessionStatus: "connected",
}),
null,
);
assert.equal(
resolveSftpSidePanelTrackedSourceStatusUpdate({
trackedSessionId: null,
sessionStatus: "disconnected",
}),
null,
);
});
test("rememberSftpSidePanelSourceStatus keeps the linked SSH status across non-SSH focus", () => {
assert.equal(
rememberSftpSidePanelSourceStatus({
previousStatus: "connecting",
activeSessionId: null,
activeSessionStatus: null,
}),
"connecting",
);
assert.equal(
rememberSftpSidePanelSourceStatus({
previousStatus: "disconnected",
activeSessionId: null,
activeSessionStatus: null,
}),
"disconnected",
);
assert.equal(
rememberSftpSidePanelSourceStatus({
previousStatus: "disconnected",
activeSessionId: "sess-a",
activeSessionStatus: "connected",
}),
"connected",
);
});
test("failed terminal reconnect keeps a healthy standalone SFTP tab", () => {
const tab = remoteConnectedTab();
const sessionChanged = shouldRebindSftpSidePanelSourceSession({
previousSessionId: "sess-a",
nextSessionId: "sess-a",
previousStatus: "connecting",
nextStatus: "disconnected",
});
assert.equal(sessionChanged, false);
assert.equal(
!sessionChanged && shouldSkipSftpSidePanelAutoConnect(
"host-1:key",
"host-1:key",
tab,
true,
"host-1:key",
),
true,
);
assert.equal(
shouldRebindSftpSidePanelSourceSession({
previousSessionId: "sess-a",
nextSessionId: "sess-a",
previousStatus: "connecting",
nextStatus: "connected",
}),
true,
);
});
test("session change still requires rebind even when the endpoint key matches", () => {
const tab = remoteConnectedTab();
// Callers must not skip auto-connect solely because the tab is healthy —
@@ -33,6 +33,36 @@ export function shouldSkipSftpSidePanelAutoConnect(
return isRemoteSftpTabHealthy(activeTab, hasBackendSession);
}
export function isPendingSameEndpointSshSession(
session: {
hostId?: string | null;
status?: string;
hostname?: string | null;
port?: number | null;
username?: string | null;
protocol?: string | null;
moshEnabled?: boolean;
etEnabled?: boolean;
},
host: {
id: string;
hostname: string;
port?: number | null;
username: string;
},
): boolean {
return Boolean(
session.hostId === host.id
&& session.status === "connecting"
&& session.hostname === host.hostname
&& (session.port ?? 22) === (host.port ?? 22)
&& session.username === host.username
&& (session.protocol === "ssh" || session.protocol === undefined)
&& !session.moshEnabled
&& !session.etEnabled,
);
}
/** Whether a stored endpoint key still belongs to the live connection's host. */
export function connectionKeyMatchesHost(
connectionKey: string | null | undefined,
@@ -105,3 +135,66 @@ export function shouldResetSftpSidePanelSourceSession(
if (!previousSessionId) return false;
return nextSessionId !== previousSessionId;
}
/**
* True when the SFTP side panel must rebind onto a fresh SSH transport.
* Covers focus switches (session id change) and same-tab reconnect /
* Start over, where the id is stable but the underlying channel was replaced.
*/
export function shouldRebindSftpSidePanelSourceSession(params: {
previousSessionId: string | null | undefined;
nextSessionId: string | null | undefined;
previousStatus?: string | null;
nextStatus?: string | null;
}): boolean {
if (shouldResetSftpSidePanelSourceSession(params.previousSessionId, params.nextSessionId)) {
return true;
}
if (!params.nextSessionId) return false;
if (params.previousSessionId !== params.nextSessionId) return false;
if (params.nextStatus !== "connected") return false;
if (params.previousStatus == null) return false;
return params.previousStatus !== "connected";
}
/**
* While the linked terminal is actively connecting, wait for its transport so
* SFTP can reuse it. A terminal left disconnected must not block standalone
* SFTP fallback.
*/
export function shouldDeferSftpSidePanelAutoConnectForSession(params: {
activeSessionId?: string | null;
sessionStatus?: string | null;
}): boolean {
if (!params.activeSessionId) return false;
return params.sessionStatus === "connecting";
}
/**
* While the last linked SSH session is not the active reusable source,
* remember its non-connected status so a background reconnect still rebinds
* when that session becomes active again.
*/
export function resolveSftpSidePanelTrackedSourceStatusUpdate(params: {
trackedSessionId?: string | null;
sessionStatus?: string | null;
}): { sessionId: string; status: string } | null {
if (!params.trackedSessionId) return null;
if (!params.sessionStatus || params.sessionStatus === "connected") return null;
return {
sessionId: params.trackedSessionId,
status: params.sessionStatus,
};
}
/** Keep the tracked source status when focus temporarily has no reusable SSH. */
export function rememberSftpSidePanelSourceStatus(params: {
previousStatus?: string | null;
activeSessionId?: string | null;
activeSessionStatus?: string | null;
}): string | null {
if (params.activeSessionId && params.activeSessionStatus) {
return params.activeSessionStatus;
}
return params.previousStatus ?? null;
}
@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const sidePanelSource = readFileSync(new URL("../SftpSidePanel.tsx", import.meta.url), "utf8");
const tabBridgeSource = readFileSync(
new URL("../terminalLayer/TerminalLayerTabBridge.tsx", import.meta.url),
"utf8",
);
test("SFTP side panel rebinds after same-tab SSH start-over", () => {
assert.match(sidePanelSource, /shouldRebindSftpSidePanelSourceSession\(/);
assert.match(sidePanelSource, /shouldDeferSftpSidePanelAutoConnectForSession\(/);
assert.match(sidePanelSource, /lastSourceSessionStatusRef/);
assert.match(
sidePanelSource,
/previousStatus:\s*lastSourceSessionStatusRef\.current/,
);
// Reuse only after SSH is connected; linked id may arrive while reconnecting.
assert.match(
sidePanelSource,
/sourceSessionId:\s*activeSessionStatus === "connected"/,
);
assert.match(sidePanelSource, /resolveSftpSidePanelTrackedSourceStatusUpdate\(/);
assert.match(
sidePanelSource,
/trackedSessionId = lastSourceSessionIdRef\.current/,
);
assert.match(sidePanelSource, /if \(activeSessionId\) return;/);
});
test("terminal layer keeps the linked SFTP session id while SSH reconnects", () => {
assert.match(tabBridgeSource, /isTerminalSessionEligibleForSftpReuse\(session\)/);
assert.doesNotMatch(
tabBridgeSource,
/activeTerminalSessionIdForSftp[\s\S]*canReuseTerminalConnection\(session\)/,
);
});
@@ -0,0 +1,47 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { SnippetTargetsSection } from './SnippetTargetsSection';
const t = (key: string) => ({
'snippets.targets.title': 'Targets',
'snippets.targets.selectHosts': 'Select hosts',
'snippets.targets.selectGroups': 'Select groups',
'snippets.targets.add': 'Add targets',
'snippets.targets.allHosts': 'All hosts',
'snippets.targets.allHostsShort': 'All hosts',
'snippets.targets.allHostsActive': 'Every host',
}[key] ?? key);
test('renders dynamic group targets separately from explicit hosts', () => {
const markup = renderToStaticMarkup(
<SnippetTargetsSection
t={t}
targetHosts={[]}
targetGroups={['Production/Web']}
onEditTargets={() => undefined}
onEditGroups={() => undefined}
/>,
);
assert.match(markup, /Production\/Web/);
assert.match(markup, /Select hosts/);
assert.match(markup, /Select groups/);
});
test('all-host mode hides host and group pickers', () => {
const markup = renderToStaticMarkup(
<SnippetTargetsSection
t={t}
targetHosts={[]}
targetGroups={['Production']}
onEditTargets={() => undefined}
onEditGroups={() => undefined}
targetsAllHosts
onTargetsAllHostsChange={() => undefined}
/>,
);
assert.doesNotMatch(markup, /Select hosts/);
assert.doesNotMatch(markup, /Select groups/);
assert.match(markup, /Every host/);
});
+46 -9
View File
@@ -1,3 +1,4 @@
import { FolderTree, Server } from 'lucide-react';
import React from 'react';
import { DistroAvatar } from '../DistroAvatar';
import { Button } from '@/components/ui/button';
@@ -10,6 +11,8 @@ export interface SnippetTargetsSectionProps {
t: (key: string, params?: Record<string, unknown>) => string;
targetHosts: Host[];
onEditTargets: () => void;
targetGroups?: string[];
onEditGroups?: () => void;
hint?: string;
variant?: 'card' | 'embedded';
targetsAllHosts?: boolean;
@@ -25,6 +28,8 @@ const TargetsBody: React.FC<{
t: SnippetTargetsSectionProps['t'];
targetHosts: Host[];
onEditTargets: () => void;
targetGroups?: string[];
onEditGroups?: () => void;
hint?: string;
embedded?: boolean;
targetsAllHosts?: boolean;
@@ -33,6 +38,8 @@ const TargetsBody: React.FC<{
t,
targetHosts,
onEditTargets,
targetGroups = [],
onEditGroups,
hint,
embedded = false,
targetsAllHosts = false,
@@ -50,14 +57,28 @@ const TargetsBody: React.FC<{
<div className="flex items-center gap-1 min-w-0">
{!targetsAllHosts ? (
<Button
variant="ghost"
size="sm"
className={cn(actionButtonClass(embedded), 'text-primary')}
onClick={onEditTargets}
>
{targetHosts.length > 0 ? t('action.edit') : t('snippets.targets.add')}
</Button>
<>
<Button
variant="ghost"
size="sm"
className={cn(actionButtonClass(embedded), 'text-primary gap-1')}
onClick={onEditTargets}
>
<Server size={12} />
{t('snippets.targets.selectHosts')}
</Button>
{onEditGroups ? (
<Button
variant="ghost"
size="sm"
className={cn(actionButtonClass(embedded), 'text-primary gap-1')}
onClick={onEditGroups}
>
<FolderTree size={12} />
{t('snippets.targets.selectGroups')}
</Button>
) : null}
</>
) : null}
{onTargetsAllHostsChange ? (
@@ -91,7 +112,7 @@ const TargetsBody: React.FC<{
>
{t('snippets.targets.allHostsActive')}
</p>
) : targetHosts.length === 0 ? (
) : targetHosts.length === 0 && targetGroups.length === 0 ? (
embedded ? null : (
<Button
variant="secondary"
@@ -103,6 +124,18 @@ const TargetsBody: React.FC<{
)
) : (
<div className={cn('gap-2', embedded ? 'flex flex-wrap' : 'space-y-2')}>
{targetGroups.map((groupPath) => (
<div
key={`group:${groupPath}`}
className={cn(
'flex items-center gap-2 rounded-lg border border-border/60 bg-primary/5 max-w-full',
embedded ? 'px-2 py-1.5 text-xs' : 'px-3 py-2 text-sm',
)}
>
<FolderTree size={14} className="text-primary shrink-0" />
<span className="truncate font-medium">{groupPath}</span>
</div>
))}
{targetHosts.map((host) => (
embedded ? (
<div
@@ -133,6 +166,8 @@ export const SnippetTargetsSection: React.FC<SnippetTargetsSectionProps> = ({
t,
targetHosts,
onEditTargets,
targetGroups,
onEditGroups,
hint,
variant = 'card',
targetsAllHosts,
@@ -143,6 +178,8 @@ export const SnippetTargetsSection: React.FC<SnippetTargetsSectionProps> = ({
t={t}
targetHosts={targetHosts}
onEditTargets={onEditTargets}
targetGroups={targetGroups}
onEditGroups={onEditGroups}
hint={hint}
embedded={variant === 'embedded'}
targetsAllHosts={targetsAllHosts}
+5 -1
View File
@@ -21,6 +21,7 @@ interface TerminalAutocompleteProps {
termRef: RefObject<XTerm | null>;
sessionId: string;
hostId: string;
hostGroup?: string;
hostOs: "linux" | "windows" | "macos";
settings?: Partial<AutocompleteSettings>;
protocol?: string;
@@ -63,6 +64,7 @@ export function TerminalAutocomplete({
termRef,
sessionId,
hostId,
hostGroup,
hostOs,
settings,
protocol,
@@ -119,6 +121,7 @@ export function TerminalAutocomplete({
...(options.cwd ? { cwd: options.cwd } : {}),
},
hostOs,
hostGroup,
cwdSource: options.cwdSource,
snippets: options.snippets,
maximum: options.maxResults ?? 15,
@@ -126,12 +129,13 @@ export function TerminalAutocomplete({
signal: options.signal,
onLatePathSuggestions: options.onLatePathSuggestions,
});
}, [allowHostStyleGreaterThanPrompt, hostId, hostOs, isPluginCompletionProviderAvailable, protocol, sensitiveInputActiveRef, sessionId, settings?.historyScope, status, workspaceId]);
}, [allowHostStyleGreaterThanPrompt, hostGroup, hostId, hostOs, isPluginCompletionProviderAvailable, protocol, sensitiveInputActiveRef, sessionId, settings?.historyScope, status, workspaceId]);
const autocomplete = useTerminalAutocomplete({
termRef,
containerRef,
sessionId,
hostId,
hostGroup,
hostOs,
settings,
onAcceptText,
+3 -3
View File
@@ -7,7 +7,7 @@ import { HoverCard, HoverCardContent, HoverCardTrigger } from '../ui/hover-card'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { formatNetSpeed } from './terminalHelpers';
import { useServerStats } from '../../application/state/useServerStats';
import { formatDiskCapacityGb, resolveTerminalDiskSummary } from './serverStatsFormat';
import { formatDiskCapacityRange, resolveTerminalDiskSummary } from './serverStatsFormat';
interface TerminalServerStatsProps {
sessionId: string;
@@ -263,7 +263,7 @@ export const TerminalServerStats: React.FC<TerminalServerStatsProps> = ({
diskSummary.percent !== null && diskSummary.percent >= 80 && diskSummary.percent < 90 && "text-amber-400"
)}>
{diskSummary.used !== null && diskSummary.total !== null && diskSummary.percent !== null
? `${formatDiskCapacityGb(diskSummary.used)}/${formatDiskCapacityGb(diskSummary.total)}G (${diskSummary.percent}%)`
? `${formatDiskCapacityRange(diskSummary.used, diskSummary.total)} (${diskSummary.percent}%)`
: diskSummary.percent !== null
? `${diskSummary.percent}%`
: '--'}
@@ -295,7 +295,7 @@ export const TerminalServerStats: React.FC<TerminalServerStatsProps> = ({
"text-[11px] font-medium whitespace-nowrap",
disk.percent >= 90 ? "text-red-400" : disk.percent >= 80 ? "text-amber-400" : "text-emerald-400"
)}>
{formatDiskCapacityGb(disk.used)}/{formatDiskCapacityGb(disk.total)}G ({disk.percent}%)
{formatDiskCapacityRange(disk.used, disk.total)} ({disk.percent}%)
</span>
</div>
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
+1 -1
View File
@@ -394,7 +394,7 @@ test("manual disconnect keeps the session pane for reconnect", () => {
assert.match(body, /isBootActiveRef\.current = false/);
assert.match(body, /setIsCancelling\(true\)/);
assert.match(body, /updateStatus\("disconnected"\)/);
assert.match(body, /void cleanupSession\(\)/);
assert.match(body, /void cleanupSession\(\{ retainOwnership: true \}\)/);
assert.match(source, /trackSessionCleanup/);
assert.doesNotMatch(body, /onCloseSession/);
assert.match(source, /handleDisconnect: \(attachExistingSession \|\| compactToolbar\) \? undefined : handleDisconnect/);
+1
View File
@@ -993,6 +993,7 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
termRef={termRef}
sessionId={sessionId}
hostId={host.id}
hostGroup={host.group}
hostOs={autocompleteHostOs}
settings={autocompleteSettings}
protocol={effectiveTerminalProtocol ?? resolveEffectiveTerminalProtocol(host)}
@@ -13,3 +13,10 @@ test("hidden selected theme uses the normal theme item row", () => {
assert.match(source, /hiddenSelectedTheme && \(\s*<ThemeItem/);
assert.doesNotMatch(source, /terminal\.hiddenTheme\.title[\s\S]*terminal\.hiddenTheme\.desc/);
});
test("theme side panel tabs are vertically centered in the header", () => {
assert.match(
source,
/TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS, 'flex items-center px-1\.5 gap-0\.5 border-b'/,
);
});
+1 -1
View File
@@ -311,7 +311,7 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
>
{/* Tab Bar */}
<div
className={cn(TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS, 'flex px-1.5 gap-0.5 border-b')}
className={cn(TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS, 'flex items-center px-1.5 gap-0.5 border-b')}
style={{ borderColor: 'var(--terminal-panel-border)' }}
>
<button
@@ -242,6 +242,7 @@ export async function getCompletions(
input: string,
options: {
hostId?: string;
hostGroup?: string;
os?: "linux" | "windows" | "macos";
maxResults?: number;
/** Session ID for remote path completion */
@@ -397,7 +398,10 @@ export async function getCompletions(
// a snippet's label collides with an existing history entry's text, the
// score-sort + final dedup below keeps the snippet (the higher-scored one).
if (options.snippets && options.snippets.length > 0 && ctx.wordIndex === 0) {
for (const snippetSuggestion of getSnippetSuggestions(input, options.snippets, { hostId })) {
for (const snippetSuggestion of getSnippetSuggestions(input, options.snippets, {
hostId,
hostGroup: options.hostGroup,
})) {
suggestions.push(snippetSuggestion);
}
}
@@ -7,31 +7,37 @@
* Snippet so the accept path can run it through the canonical executeSnippetCommand.
*/
import type { Snippet } from "../../../domain/models";
import { snippetAppliesToHost } from "../../../domain/snippetTargets";
import { matchesSearchQuery } from "../../../lib/searchMatcher";
import type { CompletionSuggestion } from "./completionEngine";
const SNIPPET_BASE_SCORE = 2000; // Above history (1000+freq) per "snippet > history".
const SNIPPET_PREFIX_BONUS = 100;
function snippetAvailableForAutocomplete(snippet: Snippet, hostId?: string): boolean {
function snippetAvailableForAutocomplete(
snippet: Snippet,
host: { hostId?: string; hostGroup?: string },
): boolean {
if (snippet.targetsAllHosts) return true;
if (snippet.targets && snippet.targets.length > 0) {
return hostId !== undefined && snippet.targets.includes(hostId);
}
return true;
const hasScopedTargets = Boolean(
snippet.targets?.length || snippet.targetGroups !== undefined,
);
if (!hasScopedTargets) return true;
if (!host.hostId) return false;
return snippetAppliesToHost(snippet, { id: host.hostId, group: host.hostGroup });
}
export function getSnippetSuggestions(
input: string,
snippets: Snippet[],
options: { hostId?: string } = {},
options: { hostId?: string; hostGroup?: string } = {},
): CompletionSuggestion[] {
const needle = input.trim().toLowerCase();
if (!needle || !Array.isArray(snippets)) return [];
const out: CompletionSuggestion[] = [];
for (const snippet of snippets) {
if (!snippetAvailableForAutocomplete(snippet, options.hostId)) continue;
if (!snippetAvailableForAutocomplete(snippet, options)) continue;
const label = (snippet.label || "").toLowerCase();
const firstLine = (snippet.command || "").split("\n")[0].trim().toLowerCase();
@@ -13,6 +13,7 @@ import type { AutocompleteHistoryScope, Snippet } from '../../../domain/models';
export interface TerminalCompletionProviderRequest {
input: string;
session: NetcattyTerminalSessionSnapshot;
hostGroup?: string;
hostOs: 'linux' | 'windows' | 'macos';
cwdSource?: AutocompleteCwdSource;
snippets?: Snippet[];
@@ -63,6 +64,7 @@ export async function provideTerminalCompletions(
): Promise<CompletionSuggestion[]> {
const builtInPromise = getCompletions(request.input, {
hostId: request.session.hostId,
hostGroup: request.hostGroup,
os: request.hostOs,
maxResults: request.maximum,
sessionId: request.session.sessionId,
@@ -149,6 +149,7 @@ interface UseTerminalAutocompleteOptions {
containerRef: RefObject<HTMLElement | null>;
sessionId: string;
hostId: string;
hostGroup?: string;
hostOs: "linux" | "windows" | "macos";
settings?: Partial<AutocompleteSettings>;
/** Callback to write text to the terminal session — replaces CustomEvent */
@@ -200,6 +201,7 @@ export function useTerminalAutocomplete(
containerRef,
sessionId,
hostId,
hostGroup,
hostOs,
settings: userSettings,
onAcceptText,
@@ -237,6 +239,8 @@ export function useTerminalAutocomplete(
onAcceptSnippetRef.current = onAcceptSnippet;
const hostIdRef = useRef(hostId);
hostIdRef.current = hostId;
const hostGroupRef = useRef(hostGroup);
hostGroupRef.current = hostGroup;
const hostOsRef = useRef(hostOs);
hostOsRef.current = hostOs;
const sessionIdRef = useRef(sessionId);
@@ -1048,6 +1052,7 @@ export function useTerminalAutocomplete(
try {
completions = await provideCompletionsRef.current(input, {
hostId: hostIdRef.current,
hostGroup: hostGroupRef.current,
os: hostOsRef.current,
maxResults: settingsRef.current.maxSuggestions,
historyScope: settingsRef.current.historyScope,
@@ -19,6 +19,27 @@ test("getCompletions does not surface snippets past the command position", async
assert.equal(out.find((s) => s.source === "snippet"), undefined);
});
test("getCompletions applies dynamic group targets to the current host", async () => {
const groupedSnippet: Snippet = {
...deploySnippet,
targetGroups: ["Production"],
};
const matching = await getCompletions("dep", {
hostId: "host-prod",
hostGroup: "Production/Web",
snippets: [groupedSnippet],
});
const outside = await getCompletions("dep", {
hostId: "host-dev",
hostGroup: "Development",
snippets: [groupedSnippet],
});
assert.ok(matching.some((suggestion) => suggestion.source === "snippet"));
assert.equal(outside.some((suggestion) => suggestion.source === "snippet"), false);
});
test("getCompletions returns more than 8 snippet matches when default maxSuggestions allows it", async () => {
const snippets: Snippet[] = Array.from({ length: 20 }, (_, i) => ({
id: `s${i}`,
@@ -0,0 +1,57 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
cancelConnectAutomationBatch,
createConnectAutomationBatch,
trackConnectAutomationStop,
} from "./connectAutomationBatch.ts";
test("cancel waits for the stop operation registered by the abort listener", async () => {
const batch = createConnectAutomationBatch();
let releaseStop: (() => void) | undefined;
let stopFinished = false;
batch.controller.signal.addEventListener("abort", () => {
trackConnectAutomationStop(batch, () => new Promise<void>((resolve) => {
releaseStop = () => {
stopFinished = true;
resolve();
};
}));
}, { once: true });
const cancelling = cancelConnectAutomationBatch(batch);
await Promise.resolve();
assert.equal(stopFinished, false);
releaseStop?.();
await cancelling;
assert.equal(stopFinished, true);
});
test("cancel surfaces a backend stop failure", async () => {
const batch = createConnectAutomationBatch();
batch.controller.signal.addEventListener("abort", () => {
trackConnectAutomationStop(batch, async () => {
throw new Error("stop failed");
});
}, { once: true });
await assert.rejects(() => cancelConnectAutomationBatch(batch), /stop failed/);
});
test("cancel can retry a stop operation after a transient failure", async () => {
const batch = createConnectAutomationBatch();
let attempts = 0;
trackConnectAutomationStop(batch, async () => {
attempts += 1;
if (attempts === 1) throw new Error("temporary stop failure");
});
await assert.rejects(
() => cancelConnectAutomationBatch(batch),
/temporary stop failure/,
);
await cancelConnectAutomationBatch(batch);
assert.equal(attempts, 2);
});
@@ -0,0 +1,25 @@
export interface ConnectAutomationBatch {
controller: AbortController;
stopCurrentRun: (() => Promise<void>) | null;
}
export const createConnectAutomationBatch = (): ConnectAutomationBatch => ({
controller: new AbortController(),
stopCurrentRun: null,
});
export const trackConnectAutomationStop = (
batch: ConnectAutomationBatch,
stopCurrentRun: (() => Promise<void>) | null,
): void => {
batch.stopCurrentRun = stopCurrentRun;
};
export const cancelConnectAutomationBatch = async (
batch: ConnectAutomationBatch,
): Promise<void> => {
batch.controller.abort();
if (batch.stopCurrentRun) {
await batch.stopCurrentRun();
}
};
+162 -17
View File
@@ -1,12 +1,16 @@
import type { SearchAddon } from "@xterm/addon-search";
import type { Terminal as XTerm } from "@xterm/xterm";
import { useCallback, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { RefObject } from "react";
import { useStoredBoolean } from "../../../application/state/useStoredBoolean";
import { STORAGE_KEY_TERMINAL_SEARCH_OPEN } from "../../../infrastructure/config/storageKeys";
type SearchMatchCount = { current: number; total: number } | null;
type SearchAddonResetTarget = Pick<SearchAddon, "findNext" | "clearDecorations"> | null;
type TerminalSearchResetTarget = Pick<XTerm, "refresh" | "rows" | "clearSelection"> | null;
type TerminalSearchGuardTarget = Pick<XTerm, "refresh" | "rows" | "clearSelection"> | null;
const SEARCH_DECORATIONS = {
matchBackground: "#FFFF0044",
matchBorder: "#FFFF00",
@@ -23,19 +27,50 @@ const SEARCH_OPTIONS = {
decorations: SEARCH_DECORATIONS,
} as const;
export const resetTerminalSearch = (
searchAddon: Pick<SearchAddon, "findNext" | "clearDecorations"> | null,
searchTermRef: { current: string },
/**
* SearchAddon schedules `_updateMatches` 200ms after writes/resizes and does
* not cancel that timer from `clearDecorations()`. A timeout that already
* captured the prior term can revive yellow match decorations after reset —
* re-clear once past that window (issue #2980).
*/
export const SEARCH_HIGHLIGHT_REVIVAL_GUARD_MS = 250;
/**
* Delayed re-clear for addon decoration revival only. Do not clearSelection
* here: reset already cleared the search selection, and a user may have made
* a new manual selection during the guard window.
*/
export const clearTerminalSearchHighlights = (
searchAddon: Pick<SearchAddon, "clearDecorations"> | null,
term?: Pick<XTerm, "refresh" | "rows"> | null,
): void => {
searchAddon?.clearDecorations();
if (term && term.rows > 0) {
term.refresh(0, term.rows - 1);
}
};
export const resetTerminalSearch = (
searchAddon: SearchAddonResetTarget,
searchTermRef: { current: string },
term?: TerminalSearchResetTarget,
): void => {
searchTermRef.current = "";
// The addon's empty-search path clears both its decorations and the terminal
// selection used for the active match. clearDecorations() alone leaves that
// selection visible.
searchAddon?.findNext("", SEARCH_OPTIONS);
// findNext("") assigns cachedSearchTerm back to "". Clear decorations again
// so the addon drops that cache to undefined and cannot revive match
// highlights via its delayed onResize/onWriteParsed update path.
// Drop decorations and cachedSearchTerm first so any not-yet-running addon
// `_updateMatches` timeout observes an empty cache and does not revive.
searchAddon?.clearDecorations();
// clearDecorations() leaves the active-match selection; clear it explicitly.
term?.clearSelection();
// Empty find clears selection via the addon path. Do NOT pass SEARCH_OPTIONS:
// findNext always assigns lastSearchOptions, and decoration options would
// keep that latch armed for later write/resize updates.
try {
searchAddon?.findNext("");
} catch {
// Addon not activated yet.
}
// findNext("") assigns cachedSearchTerm back to "". Clear again so the cache
// is undefined rather than an empty string.
searchAddon?.clearDecorations();
// Disposing search decorations does not always repaint cells (observed on
// Windows after clearing or closing search). Keyword highlighting already
@@ -46,6 +81,85 @@ export const resetTerminalSearch = (
}
};
/**
* Pointer listeners used to tell a user-created selection apart from the
* addon's delayed findPrevious re-select. Keyboard selections in this 250ms
* window are rare enough that treating them as addon revival is acceptable.
*/
export const subscribeTerminalUserSelection = (
term: Pick<XTerm, "element"> | null | undefined,
mark: () => void,
): (() => void) => {
const el = term?.element;
if (!el) return () => {};
const onPointer = () => mark();
el.addEventListener("mousedown", onPointer);
el.addEventListener("touchstart", onPointer);
return () => {
el.removeEventListener("mousedown", onPointer);
el.removeEventListener("touchstart", onPointer);
};
};
export const armSearchHighlightRevivalGuard = ({
getSearchAddon,
getTerm,
subscribeUserSelection,
delayMs = SEARCH_HIGHLIGHT_REVIVAL_GUARD_MS,
setTimeoutFn = setTimeout,
clearTimeoutFn = clearTimeout,
}: {
getSearchAddon: () => Pick<SearchAddon, "clearDecorations"> | null;
getTerm: () => TerminalSearchGuardTarget;
subscribeUserSelection?: (mark: () => void) => () => void;
delayMs?: number;
setTimeoutFn?: typeof setTimeout;
clearTimeoutFn?: typeof clearTimeout;
}): { arm: () => void; dispose: () => void; markUserSelection: () => void } => {
let timer: ReturnType<typeof setTimeout> | null = null;
let userTouchedSelection = false;
let unsubscribeUserSelection: (() => void) | null = null;
const markUserSelection = () => {
userTouchedSelection = true;
};
const dispose = () => {
if (timer !== null) {
clearTimeoutFn(timer);
timer = null;
}
unsubscribeUserSelection?.();
unsubscribeUserSelection = null;
};
const arm = () => {
dispose();
userTouchedSelection = false;
if (subscribeUserSelection) {
unsubscribeUserSelection = subscribeUserSelection(markUserSelection);
}
timer = setTimeoutFn(() => {
timer = null;
unsubscribeUserSelection?.();
unsubscribeUserSelection = null;
const term = getTerm();
clearTerminalSearchHighlights(getSearchAddon(), term);
// Addon findPrevious re-selects the prior active match. Clear that
// revived selection unless the user started a new one in this window.
if (!userTouchedSelection) {
term?.clearSelection();
}
}, delayMs);
};
return { arm, dispose, markUserSelection };
};
/** True when this terminal has a local query that shared search-close must clear. */
export const shouldResetOnSharedSearchClose = (localSearchTerm: string): boolean =>
localSearchTerm !== "";
export const useTerminalSearch = ({
searchAddonRef,
termRef,
@@ -64,6 +178,36 @@ export const useTerminalSearch = ({
// never returns to the input. See issue #1789.
const [searchFocusToken, setSearchFocusToken] = useState(0);
const searchTermRef = useRef<string>("");
const revivalGuardRef = useRef<ReturnType<typeof armSearchHighlightRevivalGuard> | null>(null);
if (revivalGuardRef.current === null) {
revivalGuardRef.current = armSearchHighlightRevivalGuard({
getSearchAddon: () => searchAddonRef.current,
getTerm: () => termRef.current,
subscribeUserSelection: (mark) => subscribeTerminalUserSelection(termRef.current, mark),
});
}
useEffect(() => () => {
revivalGuardRef.current?.dispose();
}, []);
const runReset = useCallback(() => {
resetTerminalSearch(searchAddonRef.current, searchTermRef, termRef.current);
revivalGuardRef.current?.arm();
}, [searchAddonRef, termRef]);
// Search open state is shared via localStorage across terminal sessions. When
// another session closes search, this session's bar unmounts without going
// through handleCloseSearch — clear leftover decorations only when this
// terminal actually searched (otherwise shared false would wipe unrelated
// manual selections in other splits).
useEffect(() => {
if (isSearchOpen) return;
setSearchMatchCount(null);
if (!shouldResetOnSharedSearchClose(searchTermRef.current)) return;
runReset();
}, [isSearchOpen, runReset]);
// Invoked by the searchTerminal hotkey (Cmd/Ctrl+F). Always opens the bar
// and bumps the focus token: when closed, setIsSearchOpen(true) mounts the
@@ -83,20 +227,21 @@ export const useTerminalSearch = ({
setIsSearchOpen(next);
if (!next) {
setSearchMatchCount(null);
resetTerminalSearch(searchAddonRef.current, searchTermRef, termRef.current);
runReset();
}
}, [isSearchOpen, searchAddonRef, setIsSearchOpen, termRef]);
}, [isSearchOpen, runReset, setIsSearchOpen]);
const handleSearch = useCallback(
(term: string): boolean => {
const searchAddon = searchAddonRef.current;
if (!searchAddon || !term) {
resetTerminalSearch(searchAddon, searchTermRef, termRef.current);
runReset();
setSearchMatchCount(null);
return false;
}
searchTermRef.current = term;
revivalGuardRef.current?.dispose();
searchAddon.clearDecorations();
const found = searchAddon.findNext(term, SEARCH_OPTIONS);
@@ -109,7 +254,7 @@ export const useTerminalSearch = ({
return found;
},
[searchAddonRef, termRef],
[runReset, searchAddonRef],
);
const handleFindNext = useCallback((): boolean => {
@@ -129,9 +274,9 @@ export const useTerminalSearch = ({
const handleCloseSearch = useCallback(() => {
setIsSearchOpen(false);
setSearchMatchCount(null);
resetTerminalSearch(searchAddonRef.current, searchTermRef, termRef.current);
runReset();
termRef.current?.focus();
}, [searchAddonRef, setIsSearchOpen, termRef]);
}, [runReset, setIsSearchOpen, termRef]);
return {
isSearchOpen,
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -211,6 +211,68 @@ test('visual Provider host preserves semantics when command completion wins the
host.dispose();
});
test('visual Provider host restores annotations after terminal history is rebuilt', async () => {
const markers: Array<{ line: number; isDisposed: boolean; dispose(): void }> = [];
const renderedText: string[] = [];
const term = {
element: null,
buffer: { active: { type: 'normal', baseY: 4, cursorY: 2, cursorX: 0, getLine() { return undefined; } } },
onWriteParsed() { return { dispose() {} }; },
registerMarker(offset: number) {
const marker = {
line: 6 + offset,
isDisposed: false,
dispose() { this.isDisposed = true; this.line = -1; },
};
markers.push(marker);
return marker;
},
registerDecoration() {
return {
dispose() {},
onRender(listener: (element: Record<string, unknown>) => void) {
const element = {
className: '',
textContent: '',
title: '',
style: {},
setAttribute() {},
};
listener(element);
renderedText.push(String(element.textContent));
return { dispose() {} };
},
};
},
};
const host = new PluginTerminalVisualProviderHost({
term: term as never,
isProviderAvailable: (kind) => kind === 'terminal.semantic',
async request() {
return {
stale: false,
results: [{ providerId: 'semantic', status: 'ok', result: {
classification: 'success',
annotations: [{ text: 'retained annotation' }],
} }],
};
},
});
await host.commandSubmitted('true');
await host.commandCompleted();
assert.deepEqual(renderedText, ['success | retained annotation']);
markers[0].dispose();
host.terminalRebuilt();
assert.deepEqual(renderedText, [
'success | retained annotation',
'success | retained annotation',
]);
assert.equal(markers[1].line, 6);
host.dispose();
});
test('visual Provider host preserves semantic queue order at its bounded capacity', async () => {
let requests = 0;
const renderedText: string[] = [];
@@ -134,6 +134,9 @@ export class PluginTerminalVisualProviderHost implements IDisposable {
readonly #disposables: IDisposable[] = [];
readonly #matcherDecorations: Array<IDecoration | IMarker | undefined> = [];
readonly #annotationDecorations: Array<IDecoration | IMarker | undefined> = [];
#visibleAnnotations: readonly PluginTerminalAnnotation[] = Object.freeze([]);
#annotationAnchorLine: number | null = null;
#annotationMarker: IMarker | null = null;
readonly #requestControllers = new Set<AbortController>();
readonly #matcherRequestControllers = new Set<AbortController>();
#matcherTimer: ReturnType<typeof setTimeout> | undefined;
@@ -196,6 +199,9 @@ export class PluginTerminalVisualProviderHost implements IDisposable {
}
this.#isProviderAvailable = options.isProviderAvailable ?? (() => true);
this.#disposables.push(this.#term.onWriteParsed(() => {
if (this.#annotationMarker && !this.#annotationMarker.isDisposed) {
this.#annotationAnchorLine = this.#annotationMarker.line;
}
this.#matcherGeneration += 1;
for (const controller of this.#matcherRequestControllers) controller.abort();
this.#matcherRequestControllers.clear();
@@ -432,11 +438,23 @@ export class PluginTerminalVisualProviderHost implements IDisposable {
}
}
#renderAnnotations(annotations: readonly PluginTerminalAnnotation[]): void {
#renderAnnotations(
annotations: readonly PluginTerminalAnnotation[],
anchorLine?: number | null,
): void {
disposeAll(this.#annotationDecorations);
if (annotations.length === 0 || this.#disposed || !this.#active || !this.#visible) return;
const marker = this.#term.registerMarker(0);
this.#annotationMarker = null;
this.#visibleAnnotations = Object.freeze([...annotations]);
if (annotations.length === 0 || this.#disposed || !this.#active || !this.#visible) {
this.#annotationAnchorLine = null;
return;
}
const buffer = this.#term.buffer.active;
const cursorLine = buffer.baseY + buffer.cursorY;
const marker = this.#term.registerMarker((anchorLine ?? cursorLine) - cursorLine);
if (!marker) return;
this.#annotationMarker = marker;
this.#annotationAnchorLine = marker.line;
const decoration = this.#term.registerDecoration({ marker, anchor: 'right', x: 0, width: 1, layer: 'top' });
const text = annotations.map((annotation) => annotation.text).join(' | ').slice(0, 512);
const description = annotations.map((annotation) => annotation.description).filter(Boolean).join('\n');
@@ -454,6 +472,19 @@ export class PluginTerminalVisualProviderHost implements IDisposable {
this.#annotationDecorations.push(decoration, marker);
}
/** Recreate markers disposed by a terminal history replay without re-querying plugins. */
terminalRebuilt(): void {
if (this.#visibleAnnotations.length === 0) return;
this.#renderAnnotations(this.#visibleAnnotations, this.#annotationAnchorLine);
}
#clearAnnotations(): void {
disposeAll(this.#annotationDecorations);
this.#annotationMarker = null;
this.#visibleAnnotations = Object.freeze([]);
this.#annotationAnchorLine = null;
}
setActive(active: boolean, terminalBackground?: string): void {
if (this.#disposed || this.#active === active) {
if (active && terminalBackground) void this.refreshBackground('theme-changed', terminalBackground);
@@ -475,7 +506,7 @@ export class PluginTerminalVisualProviderHost implements IDisposable {
this.#backgroundGeneration += 1;
this.#pendingSemantics.splice(0);
disposeAll(this.#matcherDecorations);
disposeAll(this.#annotationDecorations);
this.#clearAnnotations();
this.#renderBackground([]);
}
@@ -493,7 +524,7 @@ export class PluginTerminalVisualProviderHost implements IDisposable {
this.#pendingSemantics.splice(0);
if (!visible) {
disposeAll(this.#matcherDecorations);
disposeAll(this.#annotationDecorations);
this.#clearAnnotations();
this.#renderBackground([]);
}
if (visible && this.#active) {
@@ -510,7 +541,7 @@ export class PluginTerminalVisualProviderHost implements IDisposable {
this.#backgroundGeneration += 1;
this.#abortProviderRequests();
this.#pendingSemantics.splice(0);
disposeAll(this.#annotationDecorations);
this.#clearAnnotations();
if (!this.#isProviderAvailable('terminal.background')) this.#renderBackground([]);
if (!this.#isProviderAvailable('terminal.matcher')) disposeAll(this.#matcherDecorations);
if (this.#active && this.#visible) {
@@ -557,7 +588,7 @@ export class PluginTerminalVisualProviderHost implements IDisposable {
this.#abortProviderRequests();
this.#pendingSemantics.splice(0);
disposeAll(this.#matcherDecorations);
disposeAll(this.#annotationDecorations);
this.#clearAnnotations();
this.#backgroundOverlay?.remove();
this.#backgroundOverlay = null;
disposeAll(this.#disposables);
@@ -4,6 +4,8 @@ import { readFileSync } from "node:fs";
import {
getInitialTerminalStatus,
resolveTerminalVaultInitialized,
shouldResetConnectAutomationOnReconnect,
shouldSuppressHostStartupCommandOnReconnect,
shouldStartTerminalBackend,
} from "./restoredSessionGate.ts";
@@ -41,12 +43,108 @@ test("terminal boot waits for vaultInitialized before creating a backend session
);
});
test("terminal popup can supply its own completed vault hydration state", () => {
const terminalSource = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const popupSource = readFileSync(new URL("../TerminalPopupPage.tsx", import.meta.url), "utf8");
assert.match(
terminalSource,
/const vaultInitialized = resolveTerminalVaultInitialized\(\s*sharedVaultInitialized,\s*vaultInitializedOverride,\s*\);/,
);
assert.match(popupSource, /vaultInitializedOverride=\{vaultInitialized\}/);
assert.equal(resolveTerminalVaultInitialized(false, true), true);
assert.equal(resolveTerminalVaultInitialized(false), false);
assert.equal(resolveTerminalVaultInitialized(true, false), false);
});
test("host startup command policy distinguishes restored and automatic reconnects", () => {
assert.equal(shouldSuppressHostStartupCommandOnReconnect("restored"), false);
assert.equal(shouldSuppressHostStartupCommandOnReconnect("manual"), false);
assert.equal(shouldSuppressHostStartupCommandOnReconnect("automatic"), true);
});
test("connect automation reset policy distinguishes manual and automatic reconnects", () => {
assert.equal(shouldResetConnectAutomationOnReconnect("manual"), true);
assert.equal(shouldResetConnectAutomationOnReconnect("restored"), true);
assert.equal(shouldResetConnectAutomationOnReconnect("automatic"), false);
});
test("manual reconnect resets connect automation before opening a new session", () => {
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const reconnectIndex = source.indexOf("const startReconnect = ");
const cancelBatchIndex = source.indexOf(
"await cancelConnectAutomationBatch(connectAutomationBatch)",
reconnectIndex,
);
const manualBranchIndex = source.indexOf('if (mode === "manual")', reconnectIndex);
const stopFailureRetryIndex = source.indexOf(
'if (mode === "auto" && retryTokenStillCurrent())',
cancelBatchIndex,
);
const stopFailureDisconnectedIndex = source.indexOf(
'updateStatus("disconnected")',
stopFailureRetryIndex,
);
const resetConsumedIndex = source.indexOf(
"connectScriptsConsumedRef.current = false",
manualBranchIndex,
);
const resetCompletedIndex = source.indexOf(
"connectScriptsCompletedIdsRef.current = new Set()",
manualBranchIndex,
);
const resetInFlightIndex = source.indexOf(
"connectScriptsInFlightRef.current = false",
manualBranchIndex,
);
const connectingIndex = source.indexOf('updateStatus("connecting")', manualBranchIndex);
const autoElseIndex = source.indexOf("} else {", manualBranchIndex);
const autoSuppressIndex = source.indexOf(
'shouldSuppressHostStartupCommandOnReconnect("automatic")',
autoElseIndex,
);
assert.notEqual(reconnectIndex, -1);
assert.notEqual(cancelBatchIndex, -1);
assert.notEqual(manualBranchIndex, -1);
assert.notEqual(stopFailureRetryIndex, -1);
assert.notEqual(stopFailureDisconnectedIndex, -1);
assert.notEqual(resetConsumedIndex, -1);
assert.notEqual(resetCompletedIndex, -1);
assert.notEqual(resetInFlightIndex, -1);
assert.notEqual(connectingIndex, -1);
assert.notEqual(autoElseIndex, -1);
assert.notEqual(autoSuppressIndex, -1);
assert.ok(
cancelBatchIndex < manualBranchIndex
&& cancelBatchIndex < resetConsumedIndex
&& cancelBatchIndex < resetCompletedIndex
&& cancelBatchIndex < resetInFlightIndex,
"every reconnect must await onConnect cancellation before manual guards are cleared",
);
assert.ok(
stopFailureRetryIndex < stopFailureDisconnectedIndex
&& stopFailureDisconnectedIndex < manualBranchIndex,
"automatic reconnect must return to disconnected when stopping the old batch fails",
);
assert.ok(
resetConsumedIndex < connectingIndex && resetCompletedIndex < connectingIndex,
"manual reconnect must clear connect-automation consumption before status becomes connecting",
);
assert.ok(
autoElseIndex < autoSuppressIndex && autoSuppressIndex < connectingIndex,
"automatic reconnect must keep the existing connect-automation consumption decision",
);
assert.ok(
!source.slice(autoElseIndex, connectingIndex).includes("connectScriptsConsumedRef.current = false"),
"automatic reconnect must not reset connect-automation refs",
);
assert.match(
source.slice(autoElseIndex, connectingIndex),
/connectScriptsConsumedRef\.current = true/,
"automatic reconnect must stop the old batch without queuing it again",
);
});
test("restored disconnected sessions still create a terminal runtime before backend startup", () => {
const source = readFileSync(new URL("./useTerminalEffects.ts", import.meta.url), "utf8");
const runtimeIndex = source.indexOf("const runtime = createXTermRuntime");
Loaded 100 of 253 files, more files were not shown because too many files have changed in this diff. Show more