Compare commits

...
Author SHA1 Message Date
bincxz ef364b69fd Fix theme header button alignment 2026-08-12 17:50:10 +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
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
陈大猫 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
陈大猫 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
陈大猫 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 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
bincxz 6bf4268e8e fix(ssh): scope parked reconnect cwd guard 2026-08-12 13:17:50 +08: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 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
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
netcatty-bot 5279ac2836 fix(#2919): automated Cursor CLI fix 2026-08-12 03:16:44 +00: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
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
陈大猫 d307a2ebc3 Merge pull request #2910 from binaricat/automation/nix-release-v1.1.78
Update Nix release metadata for v1.1.78
2026-08-12 01:14:30 +08:00
github-actions[bot] d97439f878 Update Nix release metadata for v1.1.78 2026-08-11 17:10:46 +00:00
陈大猫 8285d7010b Merge pull request #2909 from binaricat/perf/notes-tree-resize-dom
perf(notes): drive tree width resize from the DOM
2026-08-12 00:50:36 +08:00
bincxz 1f0ddd2bdb perf(notes): drive tree width resize from the DOM
Pointermove used to setTreeWidth every pixel and re-render NotesManager
including MDXEditor. Apply live width via rAF + aside.style, commit React
state and storage only on pointerup.
2026-08-12 00:48:29 +08:00
陈大猫 98db9e6fea Merge pull request #2908 from binaricat/fix/notes-tree-sidebar-ellipsis
fix(notes): truncate tree titles when sidebar is narrowed
2026-08-12 00:35:03 +08:00
陈大猫 8e53ec829f Merge pull request #2906 from binaricat/codex/fix-issue-2865-terminal-font-shortcuts
[Bug] Keep Ctrl+=/-/0 scoped to terminal font size
2026-08-12 00:32:02 +08:00
netcatty-bot 549c5534d5 fix: address Codex review on PR #2908 2026-08-11 16:30:13 +00:00
bincxz 8fae84148c fix: keep popup shortcuts outside issue scope 2026-08-12 00:17:20 +08:00
bincxz 6d14f1e3d6 fix: keep terminal focus routing across window focus changes 2026-08-12 00:08:29 +08:00
bincxz aebe25ae70 fix(notes): truncate tree titles when sidebar is narrowed
Radix ScrollArea's display:table viewport child blocked flex shrink, so
narrowing the notes tree clipped long titles mid-glyph. Constrain the
tree width chain and ellipsize labels so content adapts with the panel.
2026-08-12 00:08:22 +08:00
bincxz df045bf144 Revert "fix: address Codex review on PR #2906"
This reverts commit a1b7f3708a.
2026-08-11 23:57:57 +08:00
bincxz 32447885d9 Revert "fix: complete terminal shortcut routing"
This reverts commit c921e7d9a4.
2026-08-11 23:57:57 +08:00
bincxz 195a23e380 Revert "fix: honor configured terminal shortcuts"
This reverts commit a5bf1765d4.
2026-08-11 23:57:57 +08:00
bincxz 459ac09ebb Revert "fix: keep popup zoom setting visible"
This reverts commit e96384cc7f.
2026-08-11 23:57:57 +08:00
bincxz a1ea4cc546 Revert "fix: preserve terminal focus after toolbar clicks"
This reverts commit 01b9f745b6.
2026-08-11 23:57:57 +08:00
bincxz 25224cef7b Revert "fix: match physical shortcut keys"
This reverts commit e3b9e71d8f.
2026-08-11 23:57:57 +08:00
bincxz 452058bd42 Revert "fix: match logical shortcut keys by layout"
This reverts commit 50c25d8789.
2026-08-11 23:57:57 +08:00
bincxz 50c25d8789 fix: match logical shortcut keys by layout 2026-08-11 23:51:38 +08:00
陈大猫 96a38593bf Merge pull request #2907 from jingyaogong/main
[fix] preserve snippet execution results
2026-08-11 23:48:44 +08:00
bincxz e3b9e71d8f fix: match physical shortcut keys 2026-08-11 23:44:04 +08:00
bincxz 01b9f745b6 fix: preserve terminal focus after toolbar clicks 2026-08-11 23:30:24 +08:00
bincxz e96384cc7f fix: keep popup zoom setting visible 2026-08-11 23:08:27 +08:00
bincxz a5bf1765d4 fix: honor configured terminal shortcuts 2026-08-11 23:03:14 +08:00
bincxz c921e7d9a4 fix: complete terminal shortcut routing 2026-08-11 23:01:32 +08:00
netcatty-bot a1b7f3708a fix: address Codex review on PR #2906 2026-08-11 14:47:01 +00:00
jingyaogong 6a4a1ffd92 [fix] preserve snippet execution results 2026-08-11 22:41:47 +08:00
bincxz 105107755b fix: route terminal font shortcuts by focus 2026-08-11 22:14:06 +08:00
陈大猫 028e86b111 Merge pull request #2905 from binaricat/codex/fix-fullscreen-context-menu
fix: honor fullscreen app context menu setting
2026-08-11 21:48:00 +08:00
bincxz 151c15b38f fix: use current mouse mode in context menu 2026-08-11 21:43:52 +08:00
bincxz 544613c007 fix: honor fullscreen app context menu setting 2026-08-11 21:35:31 +08:00
陈大猫 b2886ed16b Merge pull request #2904 from binaricat/codex/follow-up-pr-2900-custom-shortcut-preview 2026-08-11 21:19:12 +08:00
netcatty-bot 57e11822f2 fix: address Codex review on PR #2904 2026-08-11 12:39:55 +00:00
netcatty-bot ff05c33fc9 fix: address Codex review on PR #2904 2026-08-11 12:15:44 +00:00
bincxz a2ff82187b fix(tabs): follow custom shortcut bindings for previews 2026-08-11 19:42:10 +08:00
陈大猫 997bba6e2c Merge pull request #2900 from binaricat/cursor/issue-2898-31478402911
feat(tabs): show optional 1-9 shortcut badges on top tabs
2026-08-11 19:31:55 +08:00
bincxz 13979790e8 feat(tabs): reveal shortcut numbers while holding modifier 2026-08-11 19:31:15 +08:00
陈大猫 19cce8c82b Merge pull request #2901 from binaricat/codex/fix-2893-local-forward-client-close
[Bug] Keep local forwarding listener alive after client disconnect
2026-08-11 19:06:17 +08:00
陈大猫 02746754f3 Merge pull request #2903 from binaricat/cursor/issue-2902-31483214309
fix(vault): keep host search focus after selecting a host
2026-08-11 18:59:51 +08:00
netcatty-bot f940bbc280 fix(#2902): automated Cursor CLI fix 2026-08-11 10:55:50 +00:00
bincxz 7e60b64766 fix(port-forward): keep listener alive after client disconnect 2026-08-11 18:52:24 +08:00
netcatty-bot 18330fd1d1 fix: address Codex review on PR #2900 2026-08-11 10:20:09 +00:00
陈大猫 ebfdb645a7 Merge pull request #2896 from LJAYi/codex/fix-macos-small-app-icon
fix(mac): prevent corrupted small app icons
2026-08-11 18:02:29 +08:00
netcatty-bot b89607dbb0 fix(#2898): automated Cursor CLI fix 2026-08-11 09:55:39 +00:00
LJAYi 2471013aba fix(mac): ship valid small app icon representations 2026-08-11 17:23:06 +08:00
陈大猫 2343f80ed2 Merge pull request #2894 from binaricat/cursor/issue-2891-31471727414
fix(system): exclude network FUSE mounts from disk overview totals
2026-08-11 17:17:34 +08:00
bincxz 8d94252990 fix(system): refine remote FUSE disk filtering 2026-08-11 17:04:08 +08:00
bincxz 87b4089812 fix(system): detect FUSE filesystem types in disk stats 2026-08-11 16:58:22 +08:00
netcatty-bot d7377fe301 fix(#2891): automated Cursor CLI fix 2026-08-11 08:24:07 +00:00
陈大猫 518e4a12ac Merge pull request #2880 from binaricat/cursor/issue-2879-31446551895
fix(terminal): defer keyword scroll refresh while Enter is pending
2026-08-11 16:21:20 +08:00
bincxz fbaa038b0d fix(terminal): preserve End scroll during Enter guard 2026-08-11 16:16:28 +08:00
bincxz 6c4e27cb38 fix(terminal): keep scrollback responsive during Enter guard 2026-08-11 16:03:56 +08:00
陈大猫 5dc87ee178 Merge pull request #2888 from binaricat/cursor/issue-2886-31463885953
fix(terminal): keep login-shell cwd after sudo for drag-drop SFTP
2026-08-11 15:23:28 +08:00
陈大猫 a363ac5812 Merge pull request #2884 from binaricat/cursor/issue-2882-31452615583
fix(sftp): keep browse sessions when switching side-panel tools
2026-08-11 15:23:18 +08:00
陈大猫 02bfea153c Merge pull request #2889 from binaricat/codex/port-forwarding-panel-polish
Polish port forwarding side panels
2026-08-11 15:14:32 +08:00
bincxz 43534c34f1 fix(sftp): preserve absent close markers on remap 2026-08-11 15:12:44 +08:00
bincxz a19bf708ac Prevent inline panel content clipping 2026-08-11 15:06:32 +08:00
bincxz bbfa2ac6bf fix(sftp): preserve split-pane close lifecycle 2026-08-11 15:04:04 +08:00
netcatty-bot 16f0304a5b fix: address Codex review on PR #2888 2026-08-11 07:04:02 +00:00
bincxz e78d78b428 Address port forwarding review feedback 2026-08-11 14:57:08 +08:00
bincxz f6d846fbda Polish port forwarding side panels 2026-08-11 14:44:30 +08:00
netcatty-bot b9702b043a fix(#2886): automated Cursor CLI fix 2026-08-11 06:27:49 +00:00
bincxz 89b3e27329 fix(sftp): preserve close lifecycle when switching tools 2026-08-11 14:24:51 +08:00
陈大猫 7e600d54e0 Merge pull request #2885 from binaricat/cursor/issue-2813-31455487268
fix(autocomplete): keep Chinese snippets on Windows CMD path prompts
2026-08-11 12:04:42 +08:00
netcatty-bot 87aa2ea931 fix(#2813): automated Cursor CLI fix 2026-08-11 03:48:30 +00:00
netcatty-bot ac3993b789 fix(#2882): automated Cursor CLI fix 2026-08-11 02:47:51 +00:00
netcatty-bot 4935066e53 fix: address Codex review on PR #2880 2026-08-11 01:12:51 +00:00
netcatty-bot 315e596e08 fix(#2879): automated Cursor CLI fix 2026-08-11 00:52:24 +00:00
陈大猫 5f874a3156 Merge pull request #2878 from binaricat/automation/nix-release-v1.1.77
Update Nix release metadata for v1.1.77
2026-08-11 01:11:52 +08:00
github-actions[bot] ae58d60f8f Update Nix release metadata for v1.1.77 2026-08-10 17:04:46 +00:00
陈大猫 8856c7e5ab Merge pull request #2877 from binaricat/fix/notes-mdx-link-dialog-position
fix(notes): keep MDX link preview toolbar over the link
2026-08-11 00:17:16 +08:00
bincxz c47fcce17d fix(notes): keep MDX link preview toolbar over the link
container-type on .netcatty-mdx-editor makes MDX adjust fixed-anchor coords
as if a containing block exists, but browsers do not. Add a zero transform so
the fixed CB is real and the preview no longer shifts into the notes tree.
2026-08-11 00:16:10 +08:00
陈大猫 53da304e39 Merge pull request #2876 from binaricat/fix/ai-workspace-merge-continue-chat
fix(ai): keep workspace chat when follow-up draft is typed after merge
2026-08-10 23:58:05 +08:00
bincxz 2df408a571 fix(ai): keep workspace chat when follow-up draft is typed after merge
Merge seed only wrote activeSessionIdMap, so unsent draft demoted the panel
to draft mode and send created a new session while the old chat still showed.
Prefer persisted workspace active over hasDraft, and materialize panelView.
2026-08-10 23:57:37 +08:00
a965a94f0d fix(session-logs): omit alternate-screen TUI paint from logs (#2875)
* fix(#2872): automated Cursor CLI fix

* fix: address Codex review on PR #2875

* fix: address Codex review on PR #2875

* fix(session-logs): sample alternate-screen state after save dialog

Manual session log start previously snapshotted alternate-screen mode
and the initial line before showSaveDialog resolved. Split path selection
from stream start so the renderer re-samples buffer state at activation,
avoiding stale omission/leak if the user enters or leaves a TUI while the
dialog is open.

* fix(session-logs): honor C1 CSI alternate-screen modes in text renderer

8-bit CSI (U+009B) is a valid alternate form of ESC[. Recognize it in
TerminalTextRenderer so DECSET 47/1047/1049 enter/leave still omits TUI
paint from txt/HTML session logs when remotes use the C1 encoding.

* fix(session-logs): apply log-safe RIS reset in text renderer

RIS now leaves alternate-screen mode, resets SGR to defaults, and starts
post-reset shell text on a new log line when needed so mid-line TUI
exits do not glue onto prior output or keep pre-entry colors. History is
preserved because session logs are not live screen wipes.

* fix(session-logs): token-bind dialog path and rebase after RIS

- chooseManualSessionLogPath stores the selected path in main and returns
  an opaque selectionToken; startManualSessionLog redeems that token and
  rejects renderer-supplied filePath so the save dialog cannot be bypassed.
- RIS rebases screenBaseRow onto a fresh log screen (history preserved)
  so post-reset CSI H cannot overwrite pre-RIS text.

* fix(session-logs): seed replay sanitizer on observe attach mid-TUI

When an observe popup restores a snapshot while vim/less is already on
the alternate buffer, re-seed the connection-log sanitizer from the
restored xterm active-buffer type so subsequent TUI redraws are omitted
until leave.

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-08-10 23:35:56 +08:00
陈大猫andnetcatty-bot f9803f3642 fix(notes): domain paste policy + Codex review follow-ups (#2869)
* fix(notes): domain paste policy + Codex review follow-ups

Move clipboard paste resolution to domain/notes (pure, no React) and
address post-#2868 review items: structured text/plain wins over
wrapper HTML, caret-safe insert for long pastes (no EOF append),
https/protocol-relative image src, entity decode, quote-aware img
attrs, code-region protection, nested HTML islands, hard-break
preservation, height-only image CSS, and complete small-icon widths.

* fix: address Codex review on PR #2869

* perf(notes): cut hover re-renders and heavy image :has() CSS

Reduce perceived lag in large notes by (1) coalescing link-hover
setState with equality checks and rAF, (2) replacing the combinatorial
:has(img[width]) matrix with data-note-img-size markers plus lazy
images, and (3) merging DOM decoration into one observer that debounces
in edit mode so Lexical keystrokes do not re-walk anchors every frame.

* perf(notes): render preview with Streamdown instead of MDXEditor

Vault note preview no longer mounts Lexical/MDXEditor/CodeMirror for
read-only viewing. Preview uses static Streamdown (same stack as chat)
with HTML islands for centered heroes and sized images; edit mode keeps
MDXEditor. This cuts large-note scroll and mode-switch cost while
preserving host-link click handling and compact badge image layout.

* fix(notes): render markdown inside center HTML for Streamdown preview

Streamdown/remark does not parse Markdown nested in HTML blocks, so
README-style <div align="center"> heroes showed raw # ** and badge
syntax. Preprocess preview markdown: expand centered islands via marked
GFM to HTML, and rewrite relative images so Streamdown no longer shows
"Image blocked" chips under app CSP.

* fix(notes): GitHub-style preview with react-markdown

Replace Streamdown note preview (chat-oriented Image blocked chrome and
poor README layout) with react-markdown + remark-gfm + rehype-raw/sanitize
and github-markdown-css. Center HTML islands still expand via marked so
pasted heroes render; relative images and empty links are dropped quietly
instead of alt-as-title or blocked chips.

* fix(notes): keep relative images; map Vite public/ to site root

Stop dropping non-https note images. Preserve absolute and relative src
as-is, except rewrite repo public/ assets to site-root paths
(public/icon.png → /icon.png) so Vite/Electron serve them without
/public/* warnings. Unwrap oversized center blocks so Features-style
body stays left-aligned in preview like edit mode.

* fix(notes): left-align preview body; center only README hero

Force markdown-body text-align left and direct block headings/lists left.
After the first hero thematic break, strip align=center / text-align:center
so Catty/Features sections no longer inherit full-width centering. Tighten
oversized-center heuristics (Catty Agent, lists, blockquotes) without
treating the Netcatty logo title as a body section.

* fix(notes): force preview body left-align (nuclear CSS + unwrap)

Catty/Features still rendered centered because small align=center shells
and inheritance beat partial CSS. Force every node left via * selector,
re-enable center only under [align=center], unwrap all center shells after
the hero --- band, and set inline textAlign left on the preview root.

* fix(notes): stop /public asset requests; fix preview center inheritance

Root cause of body still centering: CSS re-applied text-align:center to
all descendants of [align=center], so one large shell centered Catty/lists.
Keep * left; only the shell itself may center (inline badges).

Root cause of Vite /public/* warnings: stored notes still had /public/...
paths and MDX loaded them raw. normalizeNotePublicAssetPaths rewrites
/public/ and public/ to site-root paths for both edit and preview display.

* revert(notes): restore MDXEditor for preview (drop GitHub markdown path)

GitHub-style react-markdown preview caused layout and asset-path issues
that diverged from edit mode. Preview again uses MDXEditor with
readOnly + mode key remount, matching the pre-Streamdown/GitHub path.
Keep display-time public/ → / rewrite so Vite does not request /public/*.

* perf(notes): reuse MDX editor when switching notes

Remove key={noteId} remount that tore down Lexical/CodeMirror on every
note change. Pass noteId and call setMarkdown on switch instead, after
the existing draft flush, so selection stays snappy and only document
content is replaced.

* perf(notes): defer setMarkdown on switch so selection paints first

Yield a double rAF before Lexical import on note change, show a
blocking overlay for large notes, and ignore external sync while a
swap is in flight. Hardens flaky Suspense loading assertions in SSR tests.

* fix(notes): align task-list checkboxes and make them clickable in preview

Center GFM checkboxes on the first text line, give them a solid hit target,
and toggle markdown from preview clicks since Lexical readOnly blocks
CheckListPlugin. Pure toggle helper lives in domain/notes/taskList.

* fix(notes): close multi-agent review findings on switch/paste/tasks

Gate stale onChange during deferred note-switch import, keep draft refs
in display-markdown space, baseline paste settle on live getMarkdown to
avoid false success after muted setMarkdown, mask code regions in task
toggles, and drop abandoned GitHub/Streamdown preview module and deps.

* fix(notes): address remaining Codex review findings

Clear Lexical undo history on note switch, always inert-block during
deferred setMarkdown, re-annotate host links and image sizes on attribute
mutations, harden entity/code-region paste paths, and angle-bracket image
destinations that contain spaces.

* fix(notes): tighten Codex follow-ups on src, code masks, tasks

Prefer real img src over data-src, normalize public/ paths only outside
code, mask indented/blockquote fences, and accept 1) ordered task markers.

* fix(notes): keep swap payloads fresh; keep nested list tasks

Refresh deferred setMarkdown when the same note's value changes mid-yield,
and stop treating nested list lines as indented code so task toggles stay
aligned with the preview DOM.

* test(notes): update swap guard source assertions

* fix(notes): collision-free code masks, real href, cancel hover rAF

Use a document-unique mask sentinel, match anchor href with attribute
boundaries (not data-href), and cancel the pending link-hover frame on
mouse leave so chips cannot reappear after exit.

* fix(notes): accept longer closing fences in code masks

CommonMark allows closing fences with more backticks/tildes than the
opener; line-scan masking now matches length >= open so public/ paths and
task samples inside those blocks stay protected.

* fix(notes): harden CommonMark fence, span, and indented-code masks

Use homogeneous fence openers, multi-backtick inline spans, and list-context
aware indented-code detection so samples stay masked without breaking nested
task indexing.

* fix(notes): task spacing, angled image dests, deep blockquote fences

Require whitespace after task brackets, parse <angled> image destinations
with spaces, and mask fenced code at any blockquote depth.

* fix(notes): raw-text HTML elements and HTML comment task masks

Locate script/style closers without nested-tag walks so raw bodies with
'<' are removed, and mask HTML comments before task indexing so preview
toggles hit visible checkboxes only.

* perf(notes): drop fade animation on note switch and editor load

Use an instant solid cover instead of opacity/transform fade-in so Lexical
swaps do not pay extra composite cost that can feel more janky than a hard cut.

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-08-10 22:33:26 +08:00
陈大猫 ddfeb0a1a8 Merge pull request #2867 from binaricat/worktree/silver-river-f187
fix(update): enable Linux package auto-updates
2026-08-10 20:41:40 +08:00
bincxz 0d6fc5f7f7 fix(update): recover failed Linux installs 2026-08-10 19:34:34 +08:00
陈大猫 2b381c87be Merge pull request #2868 from binaricat/fix/notes-long-markdown-paste
fix(notes): paste long markdown and HTML via Turndown
2026-08-10 19:21:54 +08:00
bincxz 193a14febb style(notes): polish MDX image edit dialog to match app chrome
Restyle the ImageDialog overlay, panel, labels, inputs, dimension row,
and Save/Cancel buttons with Netcatty tokens so the form no longer
uses stock MDX light/flat chrome in the dark notes editor.
2026-08-10 19:17:15 +08:00
bincxz 8ffdc671b9 fix(notes): keep badge images on paste instead of text-only links
Linked shields/ko-fi badges were reduced to [Label](url) so icons vanished.
Normalize to single-line [![alt](img)](href) or <a><img></a>, allow relative
README image paths again, and style linked images as inline badge rows.
2026-08-10 19:14:45 +08:00
bincxz 837f4ddfaa fix(notes): preserve centered title and image blocks on paste
Wrap README-style align=center / text-align:center blocks in
<div align="center"> so MDX GenericHTML keeps layout. Restore ATX
heading markers inside centered h1–h6, skip re-turndown of center
wrappers, and add text-align CSS for align=center in the note editor.
2026-08-10 19:09:54 +08:00
netcatty-bot f8bdd950bc fix: address Codex review on PR #2868 2026-08-10 11:03:43 +00:00
netcatty-bot 4fb0f0c470 fix: address Codex review on PR #2868 2026-08-10 10:37:36 +00:00
bincxz afc27e759b fix(notes): flow small width icons horizontally in the editor
README distro walls use width=48 SVGs that MDX stacks as blocks. Treat
common small width attrs (≤96) as inline-block on both the img and MDX
image wrapper, hide per-icon chrome until hover, and leave large
screenshots as full-width block images.
2026-08-10 18:04:35 +08:00
netcatty-bot ce41379796 fix: address Codex review on PR #2867 2026-08-10 10:04:02 +00:00
bincxz 27b2c1954f fix(notes): keep image width/height on paste, scale via CSS
Preserve HTML img dimensions in note source (including large screenshots).
Side-panel display still uses max-width:100% and height:auto so content
fits without cropping; small logos can honor their smaller width attrs.
2026-08-10 18:00:47 +08:00
bincxz b36642994c fix(notes): lossy-but-clean paste for README-style content
Product policy: notes are not a GitHub renderer. Collapse linked badge
images to text links (no ](url) debris), drop oversized screenshot width
attrs so the side panel scales with CSS instead of cropping, and keep
modest widths only for small assets. Center/align HTML remains out of scope.
2026-08-10 17:57:41 +08:00
bincxz 701ef1449b fix(notes): preserve image dimensions and scale like GitHub
Keep width/height by emitting sanitized HTML <img> (MDXEditor's HTML
image visitor understands them); plain ![alt](src) alone drops size.
Cap note images at max-width 100% with auto height so large README
screenshots fit the pane instead of overflowing at native pixel size.
2026-08-10 17:51:30 +08:00
bincxz 25291591fa fix(notes): satisfy eslint for paste fence tokens and tests
Use a printable fence sentinel instead of NUL (no-control-regex) and drop
a useless regex escape in the paste unit test so npm run dev lint passes.
2026-08-10 17:46:43 +08:00
bincxz 0ad1713f25 fix(notes): preserve mixed markdown paste and render remote images
GitHub-style pastes mix markdown with <img>/<a name> HTML. Running Turndown
on the whole string escaped headings and collapsed structure. Convert only
HTML islands (Turndown per fragment), keep markdown intact, and enable
MDXEditor imagePlugin so ![alt](https://…) from paste actually shows.
2026-08-10 17:42:59 +08:00
bincxz d8efadc9fd fix(notes): use Turndown for HTML clipboard paste
Replace the hand-rolled HTML→Markdown converter with Turndown + GFM.
Prefer text/html when present (GitHub/browser pastes) so raw <img>/<a name>
islands in text/plain no longer make MDX insertMarkdown silently no-op.
2026-08-10 17:34:12 +08:00
bincxz c0f1504b4a fix(notes): convert clipboard HTML to markdown on paste
Browser/Word copies put structure in text/html while text/plain is flat.
Resolve HTML to markdown (headings, lists, links, emphasis, tables, code)
and intercept those pastes so Lexical no longer swallows rich HTML.
Structured plain markdown still wins when the user copies .md source.
2026-08-10 17:31:58 +08:00
bincxz b41728968e fix(notes): recover long markdown paste when Lexical insert stalls
Structured long paste was preventDefault'd then handed to insertMarkdown;
when Lexical no-op'd or took longer than the short settle poll, the
clipboard was swallowed with no UI change. Prefer document setMarkdown
merge for large pastes and fall back to the same path if a short
selection insert still no-ops after a size-scaled settle window.
2026-08-10 17:26:11 +08:00
陈大猫 589abaaa90 Merge pull request #2866 from binaricat/fix/ui-stability-system-manager-ai
fix: stabilize AI session handoff, system manager tabs, and settings scroll
2026-08-10 17:22:29 +08:00
bincxz cc8ba20839 fix(update): enable Linux package auto-updates 2026-08-10 17:18:46 +08:00
bincxz 589001cfb7 fix: address multi-agent review findings on PR 2866
- Use scoped historySessions for draft-force and ownership-clear guards
  so out-of-scope sessions demote instead of sticking blank.
- On server-stats pause, keep the last snapshot but clear givenUp so
  resume can auto-retry after hard failure.
- Fix ProcessRow undeclared vendorLabel via vendorDisplayLabel; make
  GPU marks decorative (aria-hidden); attribute Simple Icons as CC0.
- Sticky icon-only when tab bar width is zero; include workspaces in
  TerminalLayer ctx deps.
2026-08-10 17:16:21 +08:00
bincxz fcd122d4a1 fix: stabilize AI session handoff, system manager tabs, and settings scroll
Prevent StrictMode draft demotion after send, keep workspace AI maintenance
mounted with a workspaces list, and keep live Streamdown markdown while
streaming. System Manager gains overflow-aware icon-only tabs, customize
layout, GPU vendor badges, and cache-preserving overview remounts. Settings
search scrolls only the content pane so traffic lights stay put.
2026-08-10 16:59:39 +08:00
陈大猫 9589f10ffb Merge pull request #2864 from binaricat/cursor/issue-2863-31366092278
fix(zmodem): overwrite existing files on terminal drag-drop upload
2026-08-10 15:50:13 +08:00
netcatty-bot 8d14f0f3c2 fix(#2863): automated Cursor CLI fix 2026-08-10 07:46:35 +00:00
陈大猫 3f67ccf3b2 Merge pull request #2855 from binaricat/cursor/issue-2851-31353442217
fix(ai): keep chat session after terminal merge
2026-08-10 15:24:59 +08:00
bincxzandCursor daa3490654 fix(ai): remint dissolved chat onto preferred survivor pane
When dissolve prefers a focused pane that did not own the inherited chat,
retarget the session and clear the old terminal selection so ownership
filters do not hide it as another scope's active thread.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 15:14:11 +08:00
bincxzandCursor eebd9acfa2 fix(ai): keep scope maintenance mounted and clear detached duplicates
Mount AI maintenance even when no side-panel tools are open, and clear a departed pane's active-map entry when a live workspace retargets its chat.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 15:05:41 +08:00
bincxzandCursor 8b84a13d9f fix(ai): retarget workspace chat when a member pane leaves
Keep inherited terminal-scoped chats visible in a still-live multi-pane workspace by reminting the active chat onto a surviving pane.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 14:57:33 +08:00
陈大猫 249468b095 Merge pull request #2861 from jingyaogong/main
[fix] restore MCP permission on startup
2026-08-10 14:48:28 +08:00
bincxzandCursor f4bb1775b3 fix(ai): avoid rewriting panel views when dissolve only remints sessions
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 14:47:55 +08:00
bincxzandCursor f2154a853a fix(ai): move SFTP tab mounts on workspace merge instead of cloning
Keep a single SFTP portal/transfer owner when promoting or dissolving workspace tabs so the visible panel retains live browser state.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 14:47:34 +08:00
bincxzandCursor 82c339246c fix(ai): hand off panel view and retarget orphaned chats on dissolve
Keep the surviving terminal on the dissolved workspace's active session view, and remint chats whose original pane is gone so history matching still resolves them.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 14:35:48 +08:00
jingyaogong 2169c2f95c [fix] restore MCP permission on startup 2026-08-10 14:29:24 +08:00
d189692a93 fix(ai): keep host_open off sidebar Catty (#2859)
Sidebar chats are scoped to already-open terminals/workspaces; exposing
host_open let Catty expand scope mid-turn and then fail to drive the new
session. Restrict vault.host.open to global/MCP/CLI and stop prompting
Catty to open hosts itself.

Fixes #2856

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 14:21:18 +08:00
bincxzandCursor fee778a775 fix(ai): preserve chat and side panel across workspace merge
Inherit the focused terminal's AI session when tabs merge, hand it back on dissolve, and remap side-panel open/layout/SFTP state so the panel does not look cleared.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 14:21:07 +08:00
netcatty-bot d03f8873c5 fix(#2851): automated Cursor CLI fix 2026-08-10 13:32:48 +08:00
陈大猫 5f464b1b8e Merge pull request #2857 from binaricat/fix/sftp-path-own-row-2846
fix(sftp): give path its own toolbar row and prefer the tail
2026-08-10 13:14:17 +08:00
陈大猫 68385b42b9 Merge pull request #2854 from binaricat/cursor/issue-2850-31353310617
fix(terminal): record full command after mid-line history edit
2026-08-10 13:13:55 +08:00
netcatty-bot e60db5e08a fix: address Codex review on PR #2854 2026-08-10 05:03:56 +00:00
bincxzandCursor 71c64e3c6c fix(sftp): pin breadcrumb roots outside the scrollport
Keep Home, the leading root, and the ellipsis fixed while only trailing chips scroll toward the current directory, and always show the ellipsis when hidden segments exist under a budget of one.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 12:24:16 +08:00
bincxzandCursor d27fc24cae fix(sftp): keep clickable roots and scroll breadcrumbs toward the tail
Always retain the first breadcrumb segment when truncating, clamp tiny budgets, and scroll overflow viewports to the trailing edge so narrow panes still show the current directory without RTL right-align.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 12:21:23 +08:00
bincxzandCursor 593da6f52d fix(terminal): strip style-divergent autosuggest tails
Drop zsh ghost text past the cursor when cell fg/dim differs from the
accepted input, so mid-line history edits still record full commands
without absorbing unaccepted cross-token suggestions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 12:21:09 +08:00
bincxzandCursor 0527df7268 fix(sftp): keep UNC share roots when truncating breadcrumbs
Drive-letter truncation kept C: visible, but WSL/UNC roots were dropped because isWindowsDrive is false for those paths. Keep the leading UNC share root the same way.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 12:17:59 +08:00
bincxzandCursor 9ffb0d8642 fix(sftp): pin the overflow menu to the trailing edge
Keep inline SFTP actions left-aligned and push only the collapsed ⋮ control to the right of the actions row.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 12:14:32 +08:00
bincxzandCursor d45b4dd65b fix(sftp): left-align path chrome and put actions under the path
The first two-row layout right-aligned short paths via RTL clip and pushed toolbar actions with ml-auto. Keep the address on top, left-aligned, with controls underneath.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 12:13:01 +08:00
bincxzandCursor 63278d5d40 fix(terminal): ignore autosuggest tails after history recall
Only prefer the full painted line for mid-line edits when the keystroke
buffer is already a whole token there, and keep the cursor-visible
command when an unaccepted zsh ghost continues past the cursor.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 12:08:27 +08:00
bincxzandCursor b6321eea6f fix(sftp): give path its own toolbar row and prefer the tail
Deep side-panel paths were squeezed beside action buttons and clipped at the end. Split path chrome onto a dedicated row and truncate/overflow toward the current directory.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 12:05:57 +08:00
陈大猫 39138096c0 Merge pull request #2852 from binaricat/fix/autocomplete-popup-pre-echo-local
fix(terminal): restore autocomplete popup after HMR dispose and pre-echo gate
2026-08-10 11:58:19 +08:00
陈大猫 5c355da8c1 Merge pull request #2853 from binaricat/cursor/issue-2849-31353101241
fix(terminal): clear leftover search highlights after reset
2026-08-10 11:57:34 +08:00
netcatty-bot e96a51bfe4 fix(#2850): automated Cursor CLI fix 2026-08-10 03:56:35 +00:00
netcatty-bot 96628e8bd0 fix(#2849): automated Cursor CLI fix 2026-08-10 03:54:54 +00:00
bincxzandCursor 352de0131e fix(terminal): keep silent shell-shaped prompts fail-closed
Restore the pre-echo allowExternalProviders wait so read -s -p '$ '-style
prompts cannot surface local history/fig suggestions before echo validates.
Keep the HMR disposedRef re-arm and explicit sensitive-prompt latch.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 11:53:05 +08:00
bincxzandCursor df9284b9ab fix(terminal): restore autocomplete popup after HMR dispose and pre-echo gate
Re-arm disposedRef when the autocomplete mount effect re-runs so Fast Refresh
cleanup no longer permanently kills fetchSuggestions. Allow local history/fig
matches during empty-echo lag while still blocking sensitive prompts, and fall
back pane visibility for standalone terminal mounts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 11:45:33 +08:00
陈大猫 4f46a3728b Merge pull request #2820 from binaricat/cursor/issue-2818-31232543614
feat(vault): add copy hostname action in host context menu
2026-08-10 11:14:57 +08:00
bincxzandCursor 5294f63544 ui(vault): move copy hostname into host context menu
Keep the one-click hostname copy from #2818 without crowding host cards;
expose it next to copy-credentials in list/grid and tree menus instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 11:14:42 +08:00
陈大猫 7e843f6f2e Merge pull request #2847 from binaricat/ui/scripts-sidepanel-compact-toolbar
ui(scripts): compact side panel toolbar like host tree
2026-08-10 11:02:46 +08:00
bincxzandCursor 2004f26c3e ui(scripts): compact side panel toolbar like host tree
Hide search behind an icon toggle, surface create actions as inline icons,
and move bulk delete into a selection bar so the Library toolbar stays compact.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 11:02:28 +08:00
7c478215c8 fix(notes): recover long markdown paste when Lexical insert no-ops (#2838)
* fix(#2783): automated Cursor CLI fix

* fix: address Codex review on PR #2838

* fix: address Codex review on PR #2838

* fix: address Codex review on PR #2838

* fix: address Codex review on PR #2838

* fix: address Codex review on PR #2838

* fix: address Codex review on PR #2838

* fix: address Codex review on PR #2838

* fix: address Codex review on PR #2838

* fix: address Codex review on PR #2838

* fix: address Codex review on PR #2838

* fix: address Codex review on PR #2838

* fix: address Codex review on PR #2838

* fix: address Codex review on PR #2838

* fix: address Codex review on PR #2838

* fix: address Codex review on PR #2838

* fix: address Codex review on PR #2838

* fix: address Codex review on PR #2838

* fix: address Codex review on PR #2838

* fix: address Codex review on PR #2838

* fix: address Codex review on PR #2838

* fix: address Codex review on PR #2838

* fix: address Codex review on PR #2838

* fix: address Codex review on PR #2838

* fix: address Codex review on PR #2838

* fix: address Codex review on PR #2838

* fix: address Codex review on PR #2838

* fix: address Codex review on PR #2838

* fix: address Codex review on PR #2838

* fix: address Codex review on PR #2838

* fix: address Codex review on PR #2838

* fix: address Codex review on PR #2838

* fix: address Codex review on PR #2838

* fix: address Codex review on PR #2838

* fix: address Codex review on PR #2838

* fix: address Codex review on PR #2838

* fix: address Codex review on PR #2838

* fix: address Codex review on PR #2838

* fix: address Codex review on PR #2838

* fix: address Codex review on PR #2838

* fix: address Codex review on PR #2838

* fix(notes): slim paste recovery after Codex review bloat

Collapse the 40-round Lexical serialization/domain rewrite back to the
original caret-loss recovery: intercept without selection, setMarkdown
merge when there is no caret, and keep insertMarkdown when there is one.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(notes): harden paste recovery against stale commits

Invalidate deferred paste commits on note switch/unmount, merge from live
editor markdown, and poll briefly for muted onChange instead of appending.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 10:52:59 +08:00
Netcatty-Bot 01b818d598 fix(terminal): keep autocomplete local when SSH echo/path lags (#2831)
Prefer the reliable keystroke buffer under SSH echo lag so local history/fig/snippet matches appear without waiting for remote echo or path listings. Soft-budget remote path completion and keep Enter recording on the stricter alignment path.
2026-08-10 10:51:03 +08:00
Netcatty-Bot 0d453a42c4 fix(system): show PVE CT ZFS/bind mounts in disk overview (#2837)
Minimal fix for PVE CT disk overview: broaden df filtering, derive dash Capacity, fall back to df /.
2026-08-10 10:46:17 +08:00
5dcba28621 feat(scripts): add expand/collapse all and delete-selected toolbar actions (#2828)
* fix(#2826): automated Cursor CLI fix

* fix(scripts): confirm bulk delete and clear host bindings

Scripts side-panel mass-delete skipped confirmation and, when
onSnippetsChange was wired, filtered snippets without clearing host
login/connect script bindings. Confirm first, always dispatch the shared
delete event, and apply deletes via deleteSelectedSnippetsFromVault.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(scripts): harden delete handler against stale vault state

Read snippets/hosts from refs in the shared delete listener so rapid
confirms cannot resurrect already-removed items, and clear pending bulk
delete when the scripts panel hides so the confirm dialog does not
resurface after a mid-prompt hide.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(scripts): keep delete refs current within the same tick

Update snippetsRef/hostsRef immediately after applying a delete so a
same-tick second event cannot rebuild vault state from the pre-delete
snapshot and resurrect removed snippets or host bindings.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix: address Codex review on PR #2828

* fix(scripts): hoist bulk-delete confirm outside toolbar popover

Portalled Dialog focus dismissed the compact Scripts popover and cleared
pendingDeleteIds before confirm. Own the confirm in TerminalToolbar and
defer via onBulkDeleteRequest when nested in that popover.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix: address Codex review on PR #2828

* fix: address Codex review on PR #2828

* fix: address Codex review on PR #2828

* fix: address Codex review on PR #2828

* fix: address Codex review on PR #2828

* fix: serialize snippet saves with bulk-delete vault lock

updateSnippets wrote STORAGE_KEY_SNIPPETS without withVaultImportLock, so a
concurrent edit could land between bulk-delete's snapshot read and journal
commit and be discarded. Queue ordinary snippet writes on the shared lock and
include them in waitForPendingVaultWrites.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix: rebase queued snippet saves onto latest vault snapshot

Locking updateSnippets alone still wrote a pre-delete full array, so a
popup bulk-delete could be resurrected (or a storage-event version bump
could cancel the local edit). Rebase under the vault lock and supersede
only newer local writers via snippetsWriteOwnerRef.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix: address Codex review on PR #2828

* fix: address Codex review on PR #2828

* fix: address Codex review on PR #2828

* fix: address Codex review on PR #2828

* fix: address Codex review on PR #2828

* fix: address Codex review on PR #2828

* fix: address Codex review on PR #2828

* fix: address Codex review on PR #2828

* fix: address Codex review on PR #2828

* fix: address Codex review on PR #2828

* fix: address Codex review on PR #2828

* fix: address Codex review on PR #2828

* fix: address Codex review on PR #2828

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-10 10:20:55 +08:00
Netcatty-Botandnetcatty-bot 3513addb9c feat(ai): add one-click provider connection test in settings (#2845)
* fix(#2844): automated Cursor CLI fix

* fix: address Codex review on PR #2845

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-08-10 10:20:32 +08:00
netcatty-bot b67ff061c2 fix: address Codex review on PR #2820 2026-08-08 21:50:02 +00:00
netcatty-bot 8a482dc928 fix: address Codex review on PR #2820 2026-08-08 21:31:43 +00:00
netcatty-bot 3d433c8727 fix: address Codex review on PR #2820 2026-08-08 21:16:34 +00:00
netcatty-bot c718d042ac fix(#2818): automated Cursor CLI fix 2026-08-08 01:40:23 +00:00
272 changed files with 18074 additions and 1379 deletions

No files matched your search

+3 -2
View File
@@ -3,6 +3,7 @@
## [Unreleased] - 2026-03-11
### 功能
- Linux deb/rpm/pacman 安装包启用应用内自动更新;未标记的开发包和 Snap 继续提供手动下载入口
- 终端新增"粘贴时自动上传剪贴板图片"选项:剪贴板含图片时,在远程会话中粘贴(快捷键/右键/中键)自动通过 SFTP 上传图片到远端 `.netcatty-paste-images/` 目录并输入远端路径,否则保持原有文本粘贴行为(设置 > 终端 > 行为,默认关闭)
- 修复自动更新 IPC 事件仅发送到单个窗口的问题,改为广播所有窗口(主窗口 + 设置窗口均可收到)
- 统一手动检查更新与自动更新的状态机,消除三套并行状态
@@ -13,7 +14,7 @@
- 下载完成后弹出持久 toast 通知,用户点击"立即重启"即可安装
- 下载失败时弹出错误 toast,提供"打开 Releases"降级入口
- Settings > System 进度条实时展示自动下载进度,由 `useUpdateCheck` 统一驱动
- Linux deb/rpm/snap 等不支持 electron-updater 的平台自动跳过,保持原有 GitHub API 通知行为
- Linux Snap、未标记的开发包等不支持 electron-updater 的平台自动跳过,保持原有 GitHub API 通知行为
### 设计原理
- `broadcastToAllWindows` 替换 `getSenderWindow` 单点发送,保证所有窗口都能收到 IPC 事件
@@ -29,5 +30,5 @@
### 注意事项
- `checkNow` 语义:使用 GitHub API(`performCheck`)检测是否有新版本,若发现更新且 electron-updater 尚未开始下载,则异步触发 `bridge.checkForUpdate()` 启动自动下载流程
- 此功能仅对打包后的应用(Windows NSIS、macOS dmg/zip、Linux AppImage)生效,dev 模式需配合 `forceDevUpdateConfig=true` + `dev-app-update.yml` 测试(见 `.gitignore`)
- 此功能仅对打包后的应用(Windows NSIS、macOS dmg/zip、Linux AppImage/deb/rpm/pacman)生效,dev 模式需配合 `forceDevUpdateConfig=true` + `dev-app-update.yml` 测试(见 `.gitignore`)
- `hasUpdate` 旧 toast 在 `autoDownloadStatus !== 'idle'` 时自动抑制,避免与新 toast 重复
@@ -272,6 +272,55 @@ test('next, previous, and number shortcuts include native plugin view tabs', ()
assert.deepEqual(selected, [pluginTabId, 'session-1', pluginTabId]);
});
test('switchToTab uses physical Digit code when Shift remaps e.key', () => {
let activeTabId = 'session-1';
const noop = () => {};
const context = {
IS_DEV: false,
MOVE_FOCUS_DEBOUNCE_MS: 0,
activeTabStore: { getActiveTabId: () => activeTabId },
addConnectionLogRef: { current: noop },
closeSession: noop,
closeTabInFlightRef: { current: false },
closeWorkspace: noop,
collectSessionIds: () => [],
confirmIfBusyLocalTerminal: async () => true,
createLocalTerminalWithCurrentShell: noop,
editorTabs: [],
fromEditorTabId: () => null,
handleOpenSettingsRef: { current: noop },
handleRequestCloseEditorTabRef: { current: noop },
isEditorTabId: () => false,
isQuickSwitcherOpen: false,
lastMoveFocusTimeRef: { current: 0 },
moveFocusInWorkspace: noop,
orderedTabs: ['session-1', 'session-2', 'session-3'],
resolveCloseIntent: () => ({ kind: 'noop' }),
resolveSnippetsShortcutIntent: () => ({ kind: 'noop' }),
sessions: [],
setActiveTabId: (id: string) => { activeTabId = id; },
setAddToWorkspaceDialog: noop,
setIsQuickSwitcherOpen: noop,
setNavigateToSection: noop,
settings: { showSftpTab: false, shellOnlyTabNumberShortcuts: false },
splitSessionWithCurrentShell: noop,
systemInfoRef: { current: { username: 'user', hostname: 'host' } },
toEditorTabId: (id: string) => `editor:${id}`,
toggleBroadcast: noop,
toggleScriptsSidePanelRef: { current: noop },
toggleSidePanelRef: { current: noop },
toggleWorkspaceViewMode: noop,
workspaces: [],
};
executeHotkeyActionImpl(
() => context,
'switchToTab',
{ key: '@', code: 'Digit3', ctrlKey: true, shiftKey: true } as KeyboardEvent,
);
assert.equal(activeTabId, 'session-2');
});
test('next tab includes pinned tabs when shell-only shortcut mode is disabled', () => {
let activeTabId = '';
const noop = () => {};
+13 -7
View File
@@ -7,6 +7,7 @@ import { getEffectiveHostDistro, classifyDistroId, shouldProbeSessionCwd } from
import { sanitizeHostIconFields } from '../../domain/hostIcon';
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
import { tabShortcutDigitFromEvent } from '../../domain/models/keyBindings';
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
import { captureInheritedCwd } from '../state/inheritedCwd';
@@ -496,7 +497,14 @@ async function captureCtxInheritedCwd(getCtx: AppContextGetter, sessionId: strin
}
}
const probe = async (id: string, options?: { allowHomeFallback?: boolean; timeoutMs?: number }) =>
const probe = async (
id: string,
options?: {
allowHomeFallback?: boolean;
allowLoginShellFallback?: boolean;
timeoutMs?: number;
},
) =>
(await bridge?.getSessionPwd?.(id, options)) ?? { success: false };
return captureInheritedCwd(source, probe, { liveCwd, allowSshProbe });
}
@@ -673,12 +681,10 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
});
switch (action) {
case 'switchToTab': {
// Get the number key pressed (1-9)
const num = parseInt(e.key, 10);
if (num >= 1 && num <= 9) {
if (num <= shortcutTabs.length) {
setActiveTabId(shortcutTabs[num - 1]);
}
// Prefer physical Digit code so Shift+[1...9] works when e.key is "!" etc.
const num = tabShortcutDigitFromEvent(e);
if (num !== null && num <= shortcutTabs.length) {
setActiveTabId(shortcutTabs[num - 1]);
}
break;
}
@@ -0,0 +1,30 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
const source = readFileSync(new URL("./AppSideEffects.tsx", import.meta.url), "utf8");
test("snippets delete handler cleans host bindings via deleteSelectedSnippets", () => {
assert.match(source, /collectSnippetDeleteIds/);
assert.match(source, /deleteSelectedSnippets/);
assert.match(
source,
/netcatty:snippets:delete[\s\S]*void deleteSelectedSnippets\(ids\)/,
);
assert.doesNotMatch(
source,
/updateSnippets\(snippets\.filter\(\(s\) => !ids\.has\(s\.id\)\)\)/,
);
});
test("snippets delete handler uses vault live snapshot instead of component refs", () => {
// Component-level snippetsRef/hostsRef lag concurrent vault mutations that
// already advanced useVaultState refs before React re-renders AppSideEffects.
// Deletion must go through the vault hook's atomic live-snapshot path.
assert.match(source, /deleteSelectedSnippets,/);
assert.doesNotMatch(source, /snippetsRef\.current\s*=\s*snippets/);
assert.doesNotMatch(
source,
/deleteSelectedSnippetsFromVault\(\s*snippetsRef\.current/,
);
});
+17 -7
View File
@@ -18,6 +18,7 @@ import {
import { getNotesSnapshot } from '../state/notesStore';
import { useVaultAgentBridge } from '../state/useVaultAgentBridge';
import { useWindowControls } from '../state/useWindowControls';
import { useTerminalKeyboardFocus } from '../state/useTerminalKeyboardFocus';
import { useEditorTabChromeList } from '../state/editorTabStore';
import {
isPluginViewTabId,
@@ -82,6 +83,7 @@ import { useDiscoveredShells, resolveShellSetting } from '../../lib/useDiscovere
import { Host, HostProtocol, KnownHost, SerialConfig, Snippet, SSHKey, TerminalSession } from '../../types';
import { resolveSnippetCommand } from '../../components/SnippetExecutionProvider';
import { isScriptSnippet } from '../../domain/snippetScript.ts';
import { collectSnippetDeleteIds } from '../../domain/snippetSelection.ts';
import { useAppStartupEffects } from './useAppStartupEffects';
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl } from './AppHandlers';
@@ -131,12 +133,15 @@ export function AppSideEffects() {
terminalSettings,
hotkeyScheme,
keyBindings,
disableTerminalFontZoom,
isHotkeyRecording,
showSftpTab,
shellOnlyTabNumberShortcuts,
workspaceFocusStyle,
} = settings;
useTerminalKeyboardFocus(hotkeyScheme !== 'disabled' && !disableTerminalFontZoom);
const discoveredShells = useDiscoveredShells();
// Sync workspace focus indicator style to DOM for CSS targeting
@@ -184,6 +189,7 @@ export function AppSideEffects() {
updateHosts,
updateKeys,
updateSnippets,
deleteSelectedSnippets,
updateCustomGroups,
updateKnownHosts,
updateManagedSources,
@@ -1600,18 +1606,22 @@ export function AppSideEffects() {
};
}, [handleOpenSettings, t]);
// Delete-from-sidepanel plumbing: ScriptsSidePanel's right-click menu
// dispatches `netcatty:snippets:delete` with the snippet id. Handled here
// (rather than in QuickAddSnippetDialog) because delete needs no UI.
// Delete-from-sidepanel plumbing: ScriptsSidePanel dispatches
// `netcatty:snippets:delete` with `id` (single) or `ids` (bulk). Handled
// here (rather than in QuickAddSnippetDialog) because delete needs no UI.
// Goes through useVaultState.deleteSelectedSnippets so login/connect script
// bindings clear with the snippets (SnippetsManager parity) against the
// latest persisted vault snapshot under the shared cross-window lock.
useEffect(() => {
const handler = (e: Event) => {
const id = (e as CustomEvent<{ id?: string }>).detail?.id;
if (!id) return;
updateSnippets(snippets.filter((s) => s.id !== id));
const detail = (e as CustomEvent<{ id?: string; ids?: string[] }>).detail;
const ids = collectSnippetDeleteIds(detail);
if (ids.size === 0) return;
void deleteSelectedSnippets(ids);
};
window.addEventListener('netcatty:snippets:delete', handler);
return () => window.removeEventListener('netcatty:snippets:delete', handler);
}, [snippets, updateSnippets]);
}, [deleteSelectedSnippets]);
const handleEndSessionDrag = useCallback(() => {
setDraggingSessionId(null);
+33 -8
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';
@@ -317,6 +318,29 @@ function AppViewInner({ domains }: AppViewProps) {
setAddToWorkspaceDialog({ mode: 'append', workspaceId });
}, [setAddToWorkspaceDialog]);
const validProxyProfileIds = useMemo(
() => new Set(proxyProfiles.map((profile) => profile.id)),
[proxyProfiles],
);
const resolveWorkspaceAppendHost = useCallback((host: typeof hosts[number]) => (
resolveEffectiveTerminalHost({
host,
groupConfigs,
proxyProfiles,
validProxyProfileIds,
})
), [groupConfigs, proxyProfiles, validProxyProfileIds]);
const handleAppendHostToWorkspace = useCallback((workspaceId: string, hostId: string) => {
const host = hosts.find((entry) => entry.id === hostId);
if (!host) return;
const ws = workspaces.find((entry) => entry.id === workspaceId);
if (!ws) return;
const rootDir = ws.root.type === 'split' ? ws.root.direction : 'vertical';
appendHostToWorkspace(workspaceId, resolveWorkspaceAppendHost(host), rootDir);
}, [appendHostToWorkspace, hosts, resolveWorkspaceAppendHost, workspaces]);
const isPeerSessionWindow = typeof window !== 'undefined'
&& window.location.hash.startsWith('#/session-window');
const externalMcpToggle = useExternalMcpToggleState();
@@ -471,8 +495,10 @@ function AppViewInner({ domains }: AppViewProps) {
onEndSessionDrag={handleEndSessionDrag}
onReorderTabs={reorderWorkTabs}
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
onAppendHostToWorkspace={handleAppendHostToWorkspace}
showSftpTab={showSftpTab}
showHostTreeSidebar={showHostTreeSidebar}
switchTabKeyBinding={keyBindings.find((binding) => binding.action === 'switchToTab') ?? null}
dynamicTabTitleMode={dynamicTabTitleMode}
editorTabs={editorTabs}
pluginViewTabs={pluginViewTabs}
@@ -658,6 +684,7 @@ function AppViewInner({ domains }: AppViewProps) {
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
onAddSessionToWorkspace={addSessionToWorkspace}
onRequestAddToWorkspace={handleRequestAddToWorkspace}
onAppendHostToWorkspace={handleAppendHostToWorkspace}
onUpdateSplitSizes={updateSplitSizes}
onSetDraggingSessionId={setDraggingSessionId}
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
@@ -779,13 +806,7 @@ function AppViewInner({ domains }: AppViewProps) {
<AddToWorkspaceDialog
open
onOpenChange={(open) => { if (!open) setAddToWorkspaceDialog(null); }}
// Filter serial hosts only in append mode — appendHostToWorkspace
// has no serial code path. Create mode goes through
// createWorkspaceFromTargets, which builds a SerialConfig-backed
// session for serial hosts, so those should remain pickable.
hosts={addToWorkspaceDialog.mode === 'append'
? hosts.filter((h) => h.protocol !== 'serial')
: hosts}
hosts={hosts}
workspaceTitle={
addToWorkspaceDialog.mode === 'append'
? workspaces.find((w) => w.id === addToWorkspaceDialog.workspaceId)?.title
@@ -804,7 +825,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/,
);
});
+41 -1
View File
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildNumberShortcutTabTargets } from './tabShortcutTargets.ts';
import { buildNumberShortcutTabTargets, buildTabShortcutNumberById } from './tabShortcutTargets.ts';
test('number shortcut tabs include vault and sftp by default', () => {
assert.deepEqual(
@@ -62,3 +62,43 @@ test('pinned tabs cannot be duplicated by a malformed persisted work ordering',
['vault', 'sftp', 'session-1'],
);
});
test('shortcut number map uses 1-based indices matching Ctrl/Cmd+[1...9]', () => {
const map = buildTabShortcutNumberById({
showSftpTab: true,
shellOnlyTabNumberShortcuts: false,
orderedTabs: ['session-1', 'workspace-1'],
editorTabIds: [],
});
assert.equal(map.get('vault'), 1);
assert.equal(map.get('sftp'), 2);
assert.equal(map.get('session-1'), 3);
assert.equal(map.get('workspace-1'), 4);
});
test('shortcut number map skips pinned tabs in shell-only mode', () => {
const map = buildTabShortcutNumberById({
showSftpTab: true,
shellOnlyTabNumberShortcuts: true,
orderedTabs: ['session-1', 'workspace-1'],
editorTabIds: [],
});
assert.equal(map.has('vault'), false);
assert.equal(map.has('sftp'), false);
assert.equal(map.get('session-1'), 1);
assert.equal(map.get('workspace-1'), 2);
});
test('shortcut number map caps at nine entries', () => {
const orderedTabs = Array.from({ length: 12 }, (_, index) => `session-${index + 1}`);
const map = buildTabShortcutNumberById({
showSftpTab: false,
shellOnlyTabNumberShortcuts: true,
orderedTabs,
editorTabIds: [],
});
assert.equal(map.size, 9);
assert.equal(map.get('session-1'), 1);
assert.equal(map.get('session-9'), 9);
assert.equal(map.has('session-10'), false);
});
+19
View File
@@ -12,3 +12,22 @@ export function buildNumberShortcutTabTargets(params: {
const pinnedTabs = params.showSftpTab ? ['vault', 'sftp'] : ['vault'];
return [...new Set([...pinnedTabs, ...workTabs])];
}
/**
* Maps tab ids to Cmd/Ctrl+[1...9] shortcut indices (1-based).
* Only the first nine shortcut targets receive a number.
*/
export function buildTabShortcutNumberById(params: {
showSftpTab: boolean;
shellOnlyTabNumberShortcuts: boolean;
orderedTabs: readonly string[];
editorTabIds: readonly string[];
}): ReadonlyMap<string, number> {
const targets = buildNumberShortcutTabTargets(params);
const map = new Map<string, number>();
const limit = Math.min(9, targets.length);
for (let index = 0; index < limit; index += 1) {
map.set(targets[index], index + 1);
}
return map;
}
+9
View File
@@ -61,6 +61,15 @@ export const enAiMessages: Messages = {
'ai.providers.contextWindow.help': 'Leave blank to use the model list value when available, otherwise Netcatty uses a safe default.',
'ai.providers.contextWindow.error': 'Enter a positive whole number, or leave it blank.',
'ai.providers.refreshModels': 'Refresh models',
'ai.providers.test': 'Test',
'ai.providers.test.testing': 'Testing…',
'ai.providers.test.ok': 'Connected ({latency} ms)',
'ai.providers.test.warn': 'Reached endpoint, but response looks incomplete ({latency} ms)',
'ai.providers.test.warnSlow': 'Connected, but slow ({latency} ms)',
'ai.providers.test.error': 'Failed ({detail})',
'ai.providers.test.missingBaseUrl': 'Enter a Base URL first',
'ai.providers.test.missingApiKey': 'Enter an API key first',
'ai.providers.test.unavailable': 'Connection test is unavailable in this environment',
'ai.providers.searchModel': 'Search or type model ID...',
'ai.providers.filterModels': 'Filter models...',
'ai.providers.loadingModels': 'Loading models...',
+2
View File
@@ -789,6 +789,8 @@ Highlight the focused split pane:
'settings.shortcuts.disableTerminalFontZoom.desc': 'Turn off terminal font zoom shortcuts, including Cmd/Ctrl + mouse wheel.',
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': 'Tab shortcuts skip pinned tabs',
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': 'When enabled, Cmd/Ctrl+[1...9] and Ctrl+Tab switch only work tabs (terminals, workspaces, editors), not the pinned Vault or SFTP tabs.',
'settings.shortcuts.showTabNumberBadges.label': 'Show tab number badges',
'settings.shortcuts.showTabNumberBadges.desc': 'While holding Cmd/Ctrl, show 1-9 on tabs that match Cmd/Ctrl+[1...9], so you can jump accurately.',
'settings.shortcuts.section.custom': 'Custom Shortcuts',
'settings.shortcuts.resetAll': 'Reset All',
'settings.shortcuts.recording': 'Press keys...',
@@ -13,6 +13,7 @@ export const enSystemManagerMessages: Messages = {
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
'systemManager.tabs.gpu': 'GPU',
'systemManager.tabs.ariaLabel': 'System manager sections',
'systemManager.popup.loading': 'Opening terminal…',
'systemManager.popup.startupFailed': 'The startup command did not complete successfully. Check that the target is still available and try again.',
+9
View File
@@ -42,6 +42,15 @@ export const ruAiMessages: Messages = {
'ai.providers.contextWindow.help': 'Оставьте пустым, чтобы использовать значение из списка моделей, если оно доступно; иначе Netcatty применит безопасное значение по умолчанию.',
'ai.providers.contextWindow.error': 'Введите положительное целое число или оставьте поле пустым.',
'ai.providers.refreshModels': 'Обновить модели',
'ai.providers.test': 'Проверить',
'ai.providers.test.testing': 'Проверка…',
'ai.providers.test.ok': 'Подключено ({latency} ms)',
'ai.providers.test.warn': 'Эндпоинт доступен, но ответ выглядит неполным ({latency} ms)',
'ai.providers.test.warnSlow': 'Подключено, но медленно ({latency} ms)',
'ai.providers.test.error': 'Ошибка ({detail})',
'ai.providers.test.missingBaseUrl': 'Сначала укажите Base URL',
'ai.providers.test.missingApiKey': 'Сначала укажите API-ключ',
'ai.providers.test.unavailable': 'Проверка соединения недоступна в этой среде',
'ai.providers.searchModel': 'Искать или ввести ID модели...',
'ai.providers.filterModels': 'Фильтровать модели...',
'ai.providers.loadingModels': 'Загрузка моделей...',
+2
View File
@@ -734,6 +734,8 @@ export const ruCoreMessages: Messages = {
'settings.shortcuts.disableTerminalFontZoom.desc': 'Отключает быстрый масштаб текста в терминале, включая Cmd/Ctrl + колесо мыши.',
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': 'Сочетания без закреплённых вкладок',
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': 'Если включено, Cmd/Ctrl+[1...9] и Ctrl+Tab переключают только рабочие вкладки (терминалы, рабочие области, редакторы), а не закреплённые Vault и SFTP.',
'settings.shortcuts.showTabNumberBadges.label': 'Номера на вкладках',
'settings.shortcuts.showTabNumberBadges.desc': 'Показывать 1-9 на вкладках при удержании Cmd/Ctrl, чтобы точнее переключаться через Cmd/Ctrl+[1...9].',
'settings.shortcuts.section.custom': 'Пользовательские сочетания',
'settings.shortcuts.resetAll': 'Сбросить все',
'settings.shortcuts.recording': 'Нажмите клавиши...',
@@ -13,6 +13,7 @@ export const ruSystemManagerMessages: Messages = {
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
'systemManager.tabs.gpu': 'GPU',
'systemManager.tabs.ariaLabel': 'Разделы системного менеджера',
'systemManager.popup.loading': 'Открытие терминала…',
'systemManager.popup.startupFailed': 'Команда запуска не была выполнена успешно. Проверьте, что цель доступна, и повторите попытку.',
+9
View File
@@ -61,6 +61,15 @@ export const zhCNAiMessages: Messages = {
'ai.providers.contextWindow.help': '留空时优先使用模型列表返回的值;如果没有,Netcatty 会使用安全默认值。',
'ai.providers.contextWindow.error': '请输入正整数,或留空。',
'ai.providers.refreshModels': '刷新模型列表',
'ai.providers.test': '检测',
'ai.providers.test.testing': '检测中…',
'ai.providers.test.ok': '连接正常({latency} ms)',
'ai.providers.test.warn': '已连通,但响应不完整({latency} ms)',
'ai.providers.test.warnSlow': '已连通,但较慢({latency} ms)',
'ai.providers.test.error': '检测失败({detail})',
'ai.providers.test.missingBaseUrl': '请先填写 Base URL',
'ai.providers.test.missingApiKey': '请先填写 API Key',
'ai.providers.test.unavailable': '当前环境无法进行连接检测',
'ai.providers.searchModel': '搜索或输入模型 ID...',
'ai.providers.filterModels': '筛选模型...',
'ai.providers.loadingModels': '加载模型中...',
@@ -13,6 +13,7 @@ export const zhCnSystemManagerMessages: Messages = {
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
'systemManager.tabs.gpu': 'GPU',
'systemManager.tabs.ariaLabel': '系统管理分区',
'systemManager.popup.loading': '正在打开终端…',
'systemManager.popup.startupFailed': '启动命令未成功。请确认目标仍然可用后重试。',
@@ -513,6 +513,8 @@ export const zhCNTerminalMessages: Messages = {
'settings.shortcuts.disableTerminalFontZoom.desc': '关闭终端文字缩放快捷操作,包括 Cmd/Ctrl 加滚轮。',
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': '标签快捷键跳过固定标签',
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': '开启后,Cmd/Ctrl+[1...9] 和 Ctrl+Tab 仅在终端、工作区、编辑器等可关闭标签页之间切换,不包括固定的 Vault 和 SFTP 标签页。',
'settings.shortcuts.showTabNumberBadges.label': '显示标签页序号角标',
'settings.shortcuts.showTabNumberBadges.desc': '按住 Cmd/Ctrl 时,在对应 Cmd/Ctrl+[1...9] 的标签页上显示 1-9,方便准确切换。',
'settings.shortcuts.section.custom': '自定义快捷键',
'settings.shortcuts.resetAll': '全部重置',
'settings.shortcuts.recording': '请按键...',
+9
View File
@@ -61,6 +61,15 @@ export const zhTWAiMessages: Messages = {
'ai.providers.contextWindow.help': '留空時優先使用模型列表傳回的值;如果沒有,Netcatty 會使用安全預設值。',
'ai.providers.contextWindow.error': '請輸入正整數,或留空。',
'ai.providers.refreshModels': '重新整理模型列表',
'ai.providers.test': '檢測',
'ai.providers.test.testing': '檢測中…',
'ai.providers.test.ok': '連線正常({latency} ms)',
'ai.providers.test.warn': '已連通,但回應不完整({latency} ms)',
'ai.providers.test.warnSlow': '已連通,但較慢({latency} ms)',
'ai.providers.test.error': '檢測失敗({detail})',
'ai.providers.test.missingBaseUrl': '請先填寫 Base URL',
'ai.providers.test.missingApiKey': '請先填寫 API Key',
'ai.providers.test.unavailable': '目前環境無法進行連線檢測',
'ai.providers.searchModel': '搜尋或輸入模型 ID...',
'ai.providers.filterModels': '篩選模型...',
'ai.providers.loadingModels': '載入模型中...',
@@ -13,6 +13,7 @@ export const zhTwSystemManagerMessages: Messages = {
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
'systemManager.tabs.gpu': 'GPU',
'systemManager.tabs.ariaLabel': '系統管理分區',
'systemManager.popup.loading': '正在開啟終端…',
'systemManager.popup.startupFailed': '啟動指令未順利完成。請確認目標仍可使用後再試一次。',
@@ -513,6 +513,8 @@ export const zhTWTerminalMessages: Messages = {
'settings.shortcuts.disableTerminalFontZoom.desc': '關閉終端文字縮放快捷操作,包括 Cmd/Ctrl 加滾輪。',
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': '標籤快捷鍵跳過固定標籤',
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': '開啟後,Cmd/Ctrl+[1...9] 和 Ctrl+Tab 僅在終端、工作區、編輯器等可關閉標籤頁之間切換,不包括固定的 Vault 和 SFTP 標籤頁。',
'settings.shortcuts.showTabNumberBadges.label': '顯示標籤頁序號角標',
'settings.shortcuts.showTabNumberBadges.desc': '按住 Cmd/Ctrl 時,在對應 Cmd/Ctrl+[1...9] 的標籤頁上顯示 1-9,方便準確切換。',
'settings.shortcuts.section.custom': '自訂快捷鍵',
'settings.shortcuts.resetAll': '全部重置',
'settings.shortcuts.recording': '請按鍵...',
+5 -1
View File
@@ -2,7 +2,11 @@ import type { TerminalSession } from "../../domain/models";
export type SessionPwdProbe = (
sessionId: string,
options?: { allowHomeFallback?: boolean; timeoutMs?: number },
options?: {
allowHomeFallback?: boolean;
allowLoginShellFallback?: boolean;
timeoutMs?: number;
},
) => Promise<{ success: boolean; cwd?: string }>;
type CaptureSession = Pick<TerminalSession, "id" | "protocol" | "status" | "lastCwd" | "localStartDir">;
@@ -1,11 +1,14 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
runConnectScriptsSequential,
selectScriptOverlayRun,
setScriptRuns,
waitForScriptRun,
} from './scriptAutomationCoordinator.ts';
import type { ScriptRun } from '@/types/global/netcatty-bridge-script.d.ts';
import type { Snippet } from '@/domain/models';
import { netcattyBridge } from '@/infrastructure/services/netcattyBridge.ts';
test('waitForScriptRun resolves when run is already completed on subscribe', async () => {
const runId = 'run-already-done';
@@ -64,3 +67,182 @@ test('selectScriptOverlayRun does not resurface older completed runs after dismi
dismissedRunIds.add(latestRun.runId);
assert.equal(selectScriptOverlayRun([olderRun, latestRun], 'sess1', dismissedRunIds), undefined);
});
test('runConnectScriptsSequential cancels only its own queued run and waits for stop', async () => {
const originalGet = netcattyBridge.get;
const sessionId = 'sess-connect-abort';
let queuedRunId: string | undefined;
let releaseStop: (() => void) | undefined;
const scriptStopCalls: string[] = [];
const storage = new Map<string, string>();
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => { storage.set(key, String(value)); },
removeItem: (key: string) => { storage.delete(key); },
clear: () => { storage.clear(); },
},
});
setScriptRuns([{
runId: 'unrelated-active-run',
scriptId: 'manual-script',
sessionId,
status: 'running',
startedAt: Date.now(),
logs: [],
}]);
netcattyBridge.get = () => ({
scriptRun: async (params) => {
queuedRunId = params.runId;
assert.equal(params.returnWhenQueued, true);
return { runId: params.runId!, runIds: [params.runId!] };
},
scriptStop: (id: string) => new Promise((resolve) => {
scriptStopCalls.push(id);
releaseStop = () => resolve({ ok: true });
}),
}) as ReturnType<typeof netcattyBridge.get>;
const controller = new AbortController();
let stopCurrentRun: (() => Promise<void>) | null = null;
const snippet: Snippet = {
id: 'connect-script',
label: 'Connect',
command: 'nct.session.sleep(60)',
kind: 'script',
};
try {
const running = runConnectScriptsSequential({
scripts: [snippet],
sessionId,
signal: controller.signal,
onCancelableRunChange: (stop) => { stopCurrentRun = stop; },
});
void running.catch(() => {});
await Promise.resolve();
controller.abort();
for (let attempt = 0; attempt < 10 && scriptStopCalls.length === 0; attempt += 1) {
await new Promise<void>((resolve) => setImmediate(resolve));
}
assert.deepEqual(scriptStopCalls, [queuedRunId]);
assert.notEqual(queuedRunId, 'unrelated-active-run');
assert.equal(typeof stopCurrentRun, 'function');
let settled = false;
void running.finally(() => { settled = true; }).catch(() => {});
await Promise.resolve();
assert.equal(settled, false);
releaseStop?.();
await assert.rejects(
() => running,
(error: unknown) => error instanceof DOMException && error.name === 'AbortError',
);
} finally {
netcattyBridge.get = originalGet;
setScriptRuns([]);
Reflect.deleteProperty(globalThis, 'localStorage');
}
});
test('runConnectScriptsSequential retries the exact stop after a transient failure', async () => {
const originalGet = netcattyBridge.get;
const storage = new Map<string, string>();
const stopCalls: string[] = [];
let stopCurrentRun: (() => Promise<void>) | null = null;
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => { storage.set(key, String(value)); },
removeItem: (key: string) => { storage.delete(key); },
clear: () => { storage.clear(); },
},
});
netcattyBridge.get = () => ({
scriptRun: async (params) => ({ runId: params.runId!, runIds: [params.runId!] }),
scriptStop: async (id: string) => {
stopCalls.push(id);
return { ok: stopCalls.length > 1 };
},
}) as ReturnType<typeof netcattyBridge.get>;
const controller = new AbortController();
try {
const running = runConnectScriptsSequential({
scripts: [{ id: 'retry-stop', label: 'Retry stop', command: 'await nct.sleep(60)', kind: 'script' }],
sessionId: 'sess-retry-stop',
signal: controller.signal,
onCancelableRunChange: (stop) => { stopCurrentRun = stop; },
});
void running.catch(() => {});
for (let attempt = 0; attempt < 10 && !stopCurrentRun; attempt += 1) {
await new Promise<void>((resolve) => setImmediate(resolve));
}
controller.abort();
await assert.rejects(() => running, /could not be stopped/);
assert.equal(typeof stopCurrentRun, 'function');
await stopCurrentRun!();
assert.equal(stopCalls.length, 2);
} finally {
netcattyBridge.get = originalGet;
setScriptRuns([]);
Reflect.deleteProperty(globalThis, 'localStorage');
}
});
test('runConnectScriptsSequential does not treat a script error named Aborted as cancellation', async () => {
const originalGet = netcattyBridge.get;
const storage = new Map<string, string>();
const scriptStopCalls: string[] = [];
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => { storage.set(key, String(value)); },
removeItem: (key: string) => { storage.delete(key); },
clear: () => { storage.clear(); },
},
});
netcattyBridge.get = () => ({
scriptRun: async (params) => {
setScriptRuns([{
runId: params.runId!,
scriptId: params.scriptId,
sessionId: params.sessionId!,
status: 'failed',
startedAt: Date.now() - 10,
endedAt: Date.now(),
error: 'Aborted',
logs: [],
}]);
return { runId: params.runId!, runIds: [params.runId!] };
},
scriptStop: async (id: string) => {
scriptStopCalls.push(id);
return { ok: true };
},
}) as ReturnType<typeof netcattyBridge.get>;
try {
await assert.rejects(
() => runConnectScriptsSequential({
scripts: [{ id: 'fails', label: 'Fails', command: "throw new Error('Aborted')", kind: 'script' }],
sessionId: 'sess-real-error',
signal: new AbortController().signal,
}),
/Aborted/,
);
assert.deepEqual(scriptStopCalls, []);
} finally {
netcattyBridge.get = originalGet;
setScriptRuns([]);
Reflect.deleteProperty(globalThis, 'localStorage');
}
});
@@ -83,6 +83,8 @@ export function getActiveScriptRunForSession(sessionId: string): ScriptRun | und
}
export async function runAutomationScript(params: {
runId?: string;
returnWhenQueued?: boolean;
snippet: Snippet;
sessionId: string;
sessionIds?: string[];
@@ -104,6 +106,8 @@ export async function runAutomationScript(params: {
throw new Error('Script bridge unavailable');
}
return bridge.scriptRun({
runId: params.runId,
returnWhenQueued: params.returnWhenQueued,
scriptId: params.snippet.id,
scriptLabel: params.snippet.label,
content: params.snippet.command,
@@ -176,6 +180,7 @@ export async function runConnectScriptsSequential(params: {
scripts: Snippet[];
sessionId: string;
signal?: AbortSignal;
onCancelableRunChange?: (stopCurrentRun: (() => Promise<void>) | null) => void;
onScriptStart?: (snippet: Snippet) => void;
onScriptComplete?: (snippet: Snippet) => void;
sessionMeta?: {
@@ -185,18 +190,63 @@ export async function runConnectScriptsSequential(params: {
username?: string;
};
}): Promise<void> {
for (const snippet of params.scripts) {
const throwIfAborted = () => {
if (params.signal?.aborted) {
throw new Error('Aborted');
throw new DOMException('Connect script run cancelled', 'AbortError');
}
};
for (const snippet of params.scripts) {
throwIfAborted();
const runId = crypto.randomUUID();
let stopPromise: Promise<void> | undefined;
let stopped = false;
params.onScriptStart?.(snippet);
const { runId } = await runAutomationScript({
const queueAccepted = runAutomationScript({
runId,
returnWhenQueued: true,
snippet,
sessionId: params.sessionId,
sessionMeta: params.sessionMeta,
});
await waitForScriptRun(runId, { signal: params.signal });
params.onScriptComplete?.(snippet);
const stopThisRun = () => {
if (stopPromise) return stopPromise;
const attempt = queueAccepted.then(async () => {
const result = await stopScriptRun(runId);
if (!result.ok) {
throw new Error(`Connect script run could not be stopped: ${runId}`);
}
stopped = true;
});
stopPromise = attempt;
void attempt.catch(() => {
if (stopPromise === attempt) stopPromise = undefined;
});
return attempt;
};
params.onCancelableRunChange?.(stopThisRun);
const onAbort = () => {
void stopThisRun().catch(() => {});
};
params.signal?.addEventListener('abort', onAbort, { once: true });
try {
await queueAccepted;
throwIfAborted();
await waitForScriptRun(runId, { signal: params.signal });
params.onScriptComplete?.(snippet);
} catch (err) {
if (params.signal?.aborted) {
await stopThisRun();
throw new DOMException('Connect script run cancelled', 'AbortError');
}
throw err;
} finally {
params.signal?.removeEventListener('abort', onAbort);
if (!params.signal?.aborted || stopped) {
params.onCancelableRunChange?.(null);
}
}
}
}
+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");
});
+10
View File
@@ -1,6 +1,7 @@
import { useSyncExternalStore } from 'react';
import type { UILanguage } from '../../domain/models/connection';
import type { HotkeyScheme } from '../../domain/models/keyBindings';
import type { DynamicTabTitleMode } from '../../domain/models/terminal';
import type { HostClickBehavior } from '../../domain/hostClickBehavior';
import type { TerminalSidePanelAutoOpenTab } from '../../domain/terminalSidePanelAutoOpen';
@@ -29,6 +30,9 @@ export type SettingsChromeSnapshot = {
showOnlyUngroupedHostsInRoot: boolean;
dynamicTabTitleMode: DynamicTabTitleMode;
disableTerminalFontZoom: boolean;
hotkeyScheme: HotkeyScheme;
shellOnlyTabNumberShortcuts: boolean;
showTabNumberBadges: boolean;
restoreTerminalCwd: boolean;
terminalSidePanelAutoOpen: boolean;
terminalSidePanelAutoOpenTab: TerminalSidePanelAutoOpenTab;
@@ -58,6 +62,9 @@ export const DEFAULT_SETTINGS_CHROME_SNAPSHOT: SettingsChromeSnapshot = Object.f
showOnlyUngroupedHostsInRoot: false,
dynamicTabTitleMode: 'off',
disableTerminalFontZoom: false,
hotkeyScheme: 'pc',
shellOnlyTabNumberShortcuts: false,
showTabNumberBadges: true,
restoreTerminalCwd: true,
terminalSidePanelAutoOpen: false,
terminalSidePanelAutoOpenTab: 'ai',
@@ -80,6 +87,9 @@ export function settingsChromeSnapshotsEqual(
&& a.showOnlyUngroupedHostsInRoot === b.showOnlyUngroupedHostsInRoot
&& a.dynamicTabTitleMode === b.dynamicTabTitleMode
&& a.disableTerminalFontZoom === b.disableTerminalFontZoom
&& a.hotkeyScheme === b.hotkeyScheme
&& a.shellOnlyTabNumberShortcuts === b.shellOnlyTabNumberShortcuts
&& a.showTabNumberBadges === b.showTabNumberBadges
&& a.restoreTerminalCwd === b.restoreTerminalCwd
&& a.terminalSidePanelAutoOpen === b.terminalSidePanelAutoOpen
&& a.terminalSidePanelAutoOpenTab === b.terminalSidePanelAutoOpenTab;
@@ -82,6 +82,7 @@ export const DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT = false;
export const DEFAULT_SHOW_SFTP_TAB = true;
export const DEFAULT_SHOW_HOST_TREE_SIDEBAR = true;
export const DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS = false;
export const DEFAULT_SHOW_TAB_NUMBER_BADGES = true;
export const DEFAULT_DISABLE_TERMINAL_FONT_ZOOM = false;
export { DEFAULT_RESTORE_PREVIOUS_SESSION } from './sessionRestoreSettings';
+13 -4
View File
@@ -37,6 +37,7 @@ import {
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
STORAGE_KEY_TERM_FONT_FAMILY,
@@ -92,6 +93,7 @@ interface UseSettingsStorageSyncParams {
terminalSidePanelAutoOpen: boolean;
terminalSidePanelAutoOpenTab: TerminalSidePanelAutoOpenTab;
shellOnlyTabNumberShortcuts: boolean;
showTabNumberBadges: boolean;
disableTerminalFontZoom: boolean;
restorePreviousSession: boolean;
restoreTerminalCwd: boolean;
@@ -139,6 +141,7 @@ interface UseSettingsStorageSyncParams {
setTerminalSidePanelAutoOpenState: Dispatch<SetStateAction<boolean>>;
setTerminalSidePanelAutoOpenTabState: Dispatch<SetStateAction<TerminalSidePanelAutoOpenTab>>;
setShellOnlyTabNumberShortcutsState: Dispatch<SetStateAction<boolean>>;
setShowTabNumberBadgesState: Dispatch<SetStateAction<boolean>>;
setDisableTerminalFontZoomState: Dispatch<SetStateAction<boolean>>;
setRestorePreviousSessionState: Dispatch<SetStateAction<boolean>>;
setRestoreTerminalCwdState: Dispatch<SetStateAction<boolean>>;
@@ -169,7 +172,7 @@ export function useSettingsStorageSync({
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, applyIncomingCustomAccent,
@@ -178,7 +181,7 @@ export function useSettingsStorageSync({
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState, setExplorerContextMenuEnabledState,
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
@@ -193,7 +196,7 @@ export function useSettingsStorageSync({
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
});
@@ -203,7 +206,7 @@ export function useSettingsStorageSync({
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
};
@@ -453,6 +456,12 @@ export function useSettingsStorageSync({
setShellOnlyTabNumberShortcutsState(newValue);
}
}
if (e.key === STORAGE_KEY_SHOW_TAB_NUMBER_BADGES && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.showTabNumberBadges) {
setShowTabNumberBadgesState(newValue);
}
}
if (e.key === STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.disableTerminalFontZoom) {
@@ -39,6 +39,32 @@ test("keeps a hidden SFTP owner interactive while its promoted editor tab is ope
assert.equal(shouldParkBrowseSessions({ interactive, browseParked: false }), false);
});
test("keeps browse warm while the terminal side panel stays open on another tool", () => {
// SFTP→History/System replaces the focused pane tool, so surfaceVisible is
// false, but the owner stays mounted for instant switch-back. Parking here
// forces reconnect + directory reload (and intermittent blank lists).
const interactive = isBrowseSessionInteractive({
surfaceVisible: false,
ownerPanelOpen: true,
hasOwnedEditorTab: false,
});
assert.equal(interactive, true);
assert.equal(shouldParkBrowseSessions({ interactive, browseParked: false }), false);
});
test("parks browse when the side panel is closed and nothing else retains it", () => {
const interactive = isBrowseSessionInteractive({
surfaceVisible: false,
ownerPanelOpen: false,
hasOwnedEditorTab: false,
hasActiveExternalEdit: false,
});
assert.equal(interactive, false);
assert.equal(shouldParkBrowseSessions({ interactive, browseParked: false }), true);
});
test("parks browse only when the interactive surface hides and not already parked", () => {
assert.equal(shouldParkBrowseSessions({ interactive: false, browseParked: false }), true);
assert.equal(shouldParkBrowseSessions({ interactive: false, browseParked: true }), false);
@@ -1,18 +1,27 @@
/**
* Browse vs transfer session lifecycle helpers.
*
* FileZilla model: the interactive browser can soft-close its SFTP channels
* while bulk transfers keep dedicated pool connections (and any leased browse
* sessions held by in-flight streams).
* FileZilla model: a *closed* terminal SFTP side panel can soft-close its browse
* SFTP channels while bulk transfers keep dedicated pool connections (and any
* leased browse sessions held by in-flight streams). Switching to another side
* panel tool (History / System / …) must keep browse warm — the owner stays
* mounted for instant switch-back, and parking would force reconnect + reload.
*/
export function isBrowseSessionInteractive(params: {
surfaceVisible: boolean;
/**
* Terminal side panel still open for this owner (another tool may be focused).
* Keeps browse sessions alive across tool switches without treating that as a
* full panel dismiss.
*/
ownerPanelOpen?: boolean;
hasOwnedEditorTab: boolean;
/** External editor temp files (Notepad++ etc.) still need the browse session. */
hasActiveExternalEdit?: boolean;
}): boolean {
return params.surfaceVisible
|| !!params.ownerPanelOpen
|| params.hasOwnedEditorTab
|| !!params.hasActiveExternalEdit;
}
@@ -0,0 +1,54 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
normalizeToolbarItemLayout,
partitionToolbarItems,
setToolbarItemPlacement,
} from '../../domain/toolbarItemLayout.ts';
import {
SYSTEM_MANAGER_TAB_DEFAULT_ORDER,
SYSTEM_MANAGER_TAB_LAYOUT_DEFAULTS,
} from './systemManagerTabLayout.ts';
test('system manager tab layout defaults include every known section', () => {
assert.deepEqual(
[...SYSTEM_MANAGER_TAB_DEFAULT_ORDER],
['overview', 'processes', 'ports', 'services', 'tmux', 'docker', 'gpu'],
);
assert.deepEqual(SYSTEM_MANAGER_TAB_LAYOUT_DEFAULTS.lockedIds, ['overview']);
});
test('system manager overview cannot be hidden', () => {
const layout = normalizeToolbarItemLayout(null, SYSTEM_MANAGER_TAB_LAYOUT_DEFAULTS);
const next = setToolbarItemPlacement(
layout,
'overview',
'hide',
SYSTEM_MANAGER_TAB_LAYOUT_DEFAULTS,
);
assert.equal(next.placement.overview, 'show');
});
test('system manager partition filters to available host tabs', () => {
const layout = normalizeToolbarItemLayout(
{
order: [...SYSTEM_MANAGER_TAB_DEFAULT_ORDER],
placement: {
overview: 'show',
processes: 'show',
ports: 'collapse',
services: 'hide',
tmux: 'show',
docker: 'show',
gpu: 'show',
},
},
SYSTEM_MANAGER_TAB_LAYOUT_DEFAULTS,
);
const part = partitionToolbarItems(layout, ['overview', 'processes', 'ports', 'services']);
assert.deepEqual(part.shown, ['overview', 'processes']);
assert.deepEqual(part.collapsed, ['ports']);
assert.deepEqual(part.hidden, ['services']);
});
@@ -0,0 +1,25 @@
import type { SystemManagerSubTab } from '../../domain/systemManager/types';
import type { ToolbarItemLayoutDefaults } from '../../domain/toolbarItemLayout';
/** Canonical System Manager sub-tab order (all known sections). */
export const SYSTEM_MANAGER_TAB_DEFAULT_ORDER: readonly SystemManagerSubTab[] = [
'overview',
'processes',
'ports',
'services',
'tmux',
'docker',
'gpu',
] as const;
/**
* Persistable layout defaults for System Manager sub-tabs.
* Overview is locked so the panel never loses every reachable section.
*/
export const SYSTEM_MANAGER_TAB_LAYOUT_DEFAULTS: ToolbarItemLayoutDefaults = {
order: [...SYSTEM_MANAGER_TAB_DEFAULT_ORDER],
placement: Object.fromEntries(
SYSTEM_MANAGER_TAB_DEFAULT_ORDER.map((id) => [id, 'show' as const]),
),
lockedIds: ['overview'],
};
@@ -0,0 +1,98 @@
const TERMINAL_KEYBOARD_TARGET_SELECTOR =
".xterm, .xterm-helper-textarea, .xterm-screen, .xterm-viewport";
type FocusDocument = Pick<Document, "activeElement" | "addEventListener" | "removeEventListener">;
type FocusWindow = Pick<Window, "addEventListener" | "removeEventListener">;
export const isTerminalKeyboardTarget = (target: EventTarget | null): boolean => {
const element = target as (Element & {
closest?: (selector: string) => Element | null;
}) | null;
return Boolean(element?.closest?.(TERMINAL_KEYBOARD_TARGET_SELECTOR));
};
export function installTerminalKeyboardFocusTracking(
documentRef: FocusDocument,
windowRef: FocusWindow,
onFocusChange: (focused: boolean) => void,
): () => void {
let pendingPointerTarget: EventTarget | null | undefined;
let pendingPointerClearTimer: ReturnType<typeof setTimeout> | undefined;
let isTracking = true;
let windowFocused = true;
const clearPendingPointerTarget = () => {
if (pendingPointerClearTimer !== undefined) {
clearTimeout(pendingPointerClearTimer);
pendingPointerClearTimer = undefined;
}
pendingPointerTarget = undefined;
};
const publishForTarget = (target: EventTarget | null) => {
if (!isTracking) return;
onFocusChange(windowFocused && isTerminalKeyboardTarget(target));
};
const handlePointerDown = (event: Event) => {
clearPendingPointerTarget();
pendingPointerTarget = event.target;
pendingPointerClearTimer = setTimeout(() => {
const pointerTarget = pendingPointerTarget;
clearPendingPointerTarget();
if (pointerTarget !== undefined) {
publishForTarget(documentRef.activeElement);
}
}, 0);
};
const handleFocusIn = (event: Event) => {
clearPendingPointerTarget();
publishForTarget(event.target);
};
const handleFocusOut = () => {
queueMicrotask(() => {
if (!isTracking) return;
const pointerTarget = pendingPointerTarget;
clearPendingPointerTarget();
if (!windowFocused) {
onFocusChange(false);
return;
}
if (pointerTarget !== undefined) {
publishForTarget(documentRef.activeElement);
return;
}
publishForTarget(documentRef.activeElement);
});
};
const handleWindowBlur = () => {
windowFocused = false;
clearPendingPointerTarget();
onFocusChange(false);
};
const handleWindowFocus = () => {
windowFocused = true;
publishForTarget(documentRef.activeElement);
};
publishForTarget(documentRef.activeElement);
documentRef.addEventListener("pointerdown", handlePointerDown, true);
documentRef.addEventListener("focusin", handleFocusIn, true);
documentRef.addEventListener("focusout", handleFocusOut, true);
windowRef.addEventListener("blur", handleWindowBlur);
windowRef.addEventListener("focus", handleWindowFocus);
return () => {
isTracking = false;
documentRef.removeEventListener("pointerdown", handlePointerDown, true);
documentRef.removeEventListener("focusin", handleFocusIn, true);
documentRef.removeEventListener("focusout", handleFocusOut, true);
windowRef.removeEventListener("blur", handleWindowBlur);
windowRef.removeEventListener("focus", handleWindowFocus);
clearPendingPointerTarget();
};
}
+124
View File
@@ -73,6 +73,11 @@ import {
type PanelViewByScope,
} from './aiStateSnapshots';
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
import {
handoffDissolvedWorkspaceAIScope,
retargetWorkspaceActiveChatAfterMemberLoss,
seedWorkspaceAIActiveSessionFromMembers,
} from '../../domain/workspaceAiScopeHandoff';
export function useAIState() {
// ── Provider Config ──
const [providers, setProvidersRaw] = useState<ProviderConfig[]>(() =>
@@ -968,6 +973,119 @@ export function useAIState() {
setPanelViewByScopeRaw(latestAIPanelViewByScopeSnapshot ?? {});
}, []);
const seedWorkspaceActiveSessionFromMembers = useCallback((input: {
workspaceId: string;
memberTerminalIds: readonly string[];
preferredTerminalId?: string | null;
}) => {
const currentMap =
latestAIActiveSessionMapSnapshot
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
?? {};
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? {};
const seeded = seedWorkspaceAIActiveSessionFromMembers({
activeSessionIdMap: currentMap,
panelViewByScope: currentPanelViewByScope,
workspaceId: input.workspaceId,
memberTerminalIds: input.memberTerminalIds,
preferredTerminalId: input.preferredTerminalId,
});
if (!seeded) return;
setLatestAIActiveSessionMapSnapshot(seeded.activeSessionIdMap);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, seeded.activeSessionIdMap);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
setActiveSessionIdMapRaw(seeded.activeSessionIdMap);
if (seeded.panelViewChanged) {
setLatestAIPanelViewByScopeSnapshot(seeded.panelViewByScope);
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
setPanelViewByScopeRaw(seeded.panelViewByScope);
}
}, []);
const handoffDissolvedWorkspaceScope = useCallback((input: {
workspaceId: string;
terminalIds: readonly string[];
preferredTerminalId?: string | null;
}) => {
const currentMap =
latestAIActiveSessionMapSnapshot
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
?? {};
const currentSessions =
latestAISessionsSnapshot
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
?? [];
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? {};
const result = handoffDissolvedWorkspaceAIScope({
activeSessionIdMap: currentMap,
sessions: currentSessions,
workspaceId: input.workspaceId,
terminalIds: input.terminalIds,
preferredTerminalId: input.preferredTerminalId,
panelViewByScope: currentPanelViewByScope,
});
if (!result.changed) return;
if (result.activeSessionIdMap !== currentMap) {
setLatestAIActiveSessionMapSnapshot(result.activeSessionIdMap);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, result.activeSessionIdMap);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
setActiveSessionIdMapRaw(result.activeSessionIdMap);
}
if (result.sessions !== currentSessions) {
sessionsRef.current = result.sessions;
setLatestAISessionsSnapshot(result.sessions);
persistSessions(result.sessions);
setSessionsRaw(result.sessions);
}
if (result.panelViewByScope !== currentPanelViewByScope) {
setLatestAIPanelViewByScopeSnapshot(result.panelViewByScope);
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
setPanelViewByScopeRaw(result.panelViewByScope);
}
}, [persistSessions]);
const retargetWorkspaceActiveChatForMemberLoss = useCallback((input: {
workspaceId: string;
previousMemberTerminalIds: readonly string[];
currentMemberTerminalIds: readonly string[];
preferredTerminalId?: string | null;
}) => {
const currentMap =
latestAIActiveSessionMapSnapshot
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
?? {};
const currentSessions =
latestAISessionsSnapshot
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
?? [];
const result = retargetWorkspaceActiveChatAfterMemberLoss({
activeSessionIdMap: currentMap,
sessions: currentSessions,
workspaceId: input.workspaceId,
previousMemberTerminalIds: input.previousMemberTerminalIds,
currentMemberTerminalIds: input.currentMemberTerminalIds,
preferredTerminalId: input.preferredTerminalId,
});
if (!result.changed) return;
if (result.activeSessionIdMap !== currentMap) {
setLatestAIActiveSessionMapSnapshot(result.activeSessionIdMap);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, result.activeSessionIdMap);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
setActiveSessionIdMapRaw(result.activeSessionIdMap);
}
if (result.sessions !== currentSessions) {
sessionsRef.current = result.sessions;
setLatestAISessionsSnapshot(result.sessions);
persistSessions(result.sessions);
setSessionsRaw(result.sessions);
}
}, [persistSessions]);
// ── Provider CRUD helpers ──
const addProvider = useCallback((provider: ProviderConfig) => {
setProviders(prev => [...prev, provider]);
@@ -1071,6 +1189,9 @@ export function useAIState() {
updateMessageById,
persistContextCompaction,
cleanupOrphanedSessions,
seedWorkspaceActiveSessionFromMembers,
handoffDissolvedWorkspaceScope,
retargetWorkspaceActiveChatForMemberLoss,
}), [
providers,
setProviders,
@@ -1124,5 +1245,8 @@ export function useAIState() {
updateMessageById,
persistContextCompaction,
cleanupOrphanedSessions,
seedWorkspaceActiveSessionFromMembers,
handoffDissolvedWorkspaceScope,
retargetWorkspaceActiveChatForMemberLoss,
]);
}
@@ -199,6 +199,36 @@ describe('useExternalMcpToggleState startup ready gate', () => {
assert.match(hookSource, /if \(isPeerSessionWindow \|\| !enabled\) return;/);
});
it('restores saved permission before enabling persistent External MCP', async () => {
const restore = installMemoryLocalStorage();
try {
const storageKeys = await import('../../infrastructure/config/storageKeys.ts');
globalThis.localStorage.setItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, 'true');
globalThis.localStorage.setItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
globalThis.localStorage.setItem(storageKeys.STORAGE_KEY_AI_PERMISSION_MODE, 'auto');
const calls: string[] = [];
await syncExternalMcpStartupState({
aiMcpSetPermissionMode: async (mode) => {
calls.push(`permission:${mode}`);
return { ok: true };
},
externalMcpSetConfig: async () => {
calls.push('config');
return { ok: true };
},
externalMcpSetEnabled: async (enabled) => {
calls.push(`enabled:${enabled}`);
return { ok: true, enabled };
},
});
assert.deepEqual(calls, ['permission:auto', 'config', 'enabled:true']);
} finally {
restore();
}
});
it('re-reads storage after config await so concurrent top-bar toggles win', async () => {
const restore = installMemoryLocalStorage();
try {
@@ -5,8 +5,10 @@ import {
STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES,
STORAGE_KEY_AI_EXTERNAL_MCP_MODE,
STORAGE_KEY_AI_EXTERNAL_MCP_SILENT_SESSIONS,
STORAGE_KEY_AI_PERMISSION_MODE,
STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES,
} from '../../infrastructure/config/storageKeys';
import type { AIPermissionMode } from '../../infrastructure/ai/types';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
@@ -105,6 +107,7 @@ type ExternalMcpConfig = {
};
type ExternalMcpBridge = {
aiMcpSetPermissionMode?: (mode: AIPermissionMode) => Promise<unknown> | unknown;
externalMcpSetConfig?: (config: ExternalMcpConfig) => Promise<unknown> | unknown;
externalMcpSetEnabled?: (enabled: boolean) => Promise<unknown> | unknown;
externalMcpGetStatus?: () => Promise<{
@@ -126,6 +129,11 @@ export function normalizeExternalMcpMode(value: string | null): ExternalMcpMode
return value === 'persistent' ? 'persistent' : 'temporary';
}
function readAIPermissionMode(): AIPermissionMode {
const stored = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
return stored === 'observer' || stored === 'auto' ? stored : 'confirm';
}
export function normalizeExternalMcpIdleTimeoutMinutes(value: number | null): number {
if (!Number.isFinite(value)) return DEFAULT_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES;
return Math.min(
@@ -280,6 +288,13 @@ export async function syncExternalMcpStartupState(
// toggle during boot wins over a stale enable/disable decision.
const startupGeneration = externalMcpEnableGeneration;
const initialPlan = readExternalMcpStartupSyncPlan();
try {
// External MCP discovery is written while enabling, so restore the saved
// permission first instead of exposing the main-process default (confirm).
await Promise.resolve(bridge?.aiMcpSetPermissionMode?.(readAIPermissionMode()));
} catch {
// Permission sync is best-effort; the main process keeps its safe default.
}
try {
await Promise.resolve(bridge?.externalMcpSetConfig?.(initialPlan.config));
} catch {
+11 -11
View File
@@ -3,6 +3,7 @@ import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export interface DiskInfo {
capacityKey?: string; // Filesystem or shared-pool identity for deduplication
filesystemType?: string; // Filesystem type reported by df (for network/FUSE filtering)
mountPoint: string;
used: number; // Used in GB
total: number; // Total in GB
@@ -183,16 +184,6 @@ function clearServerStatsTimers(session: SharedServerStatsSession): void {
session.pollIntervalMs = null;
}
function resetServerStatsSession(session: SharedServerStatsSession): void {
clearServerStatsTimers(session);
session.connectedAt = 0;
session.hasFetched = false;
session.fetchGeneration += 1;
session.consecutiveFailures = 0;
session.givenUp = false;
updateServerStatsState(session, createInitialState());
}
function getActiveServerStatsClients(session: SharedServerStatsSession): ServerStatsClient[] {
return Array.from(session.clients.values()).filter((client) => (
client.enabled && client.isSupportedOs && client.isConnected
@@ -312,7 +303,16 @@ function reconcileSharedServerStatsSession(session: SharedServerStatsSession): v
const activeClients = getActiveServerStatsClients(session);
if (activeClients.length === 0) {
resetServerStatsSession(session);
// Pause polling only. Keep the last successful stats so remounting Overview
// (or re-enabling a terminal stats strip) can paint immediately instead of
// flashing "No system overview data yet." Still clear give-up / failure so a
// later resume can auto-retry (old reset wiped stats + flags together).
clearServerStatsTimers(session);
session.givenUp = false;
session.consecutiveFailures = 0;
if (session.state.isLoading) {
updateServerStatsState(session, { isLoading: false });
}
return;
}
+25 -1
View File
@@ -1,13 +1,26 @@
import { useCallback } from "react";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
type ManualChoosePathPayload = {
sessionId: string;
sessionName?: string;
preferredDirectory?: string;
format?: "txt" | "raw" | "html";
};
type ManualStartPayload = {
sessionId: string;
sessionName?: string;
preferredDirectory?: string;
/**
* Opaque token from chooseManualSessionLogPath. Required for the post-dialog
* second phase; raw file paths from the renderer are rejected.
*/
selectionToken?: string;
format?: "txt" | "raw" | "html";
timestampsEnabled?: boolean;
initialLine?: string;
alternateScreenActive?: boolean;
};
type ManualStopPayload = {
@@ -19,6 +32,12 @@ type ManualStatusPayload = {
};
export const useSessionLogBackend = () => {
const chooseManualSessionLogPath = useCallback(async (payload: ManualChoosePathPayload) => {
const bridge = netcattyBridge.get();
return bridge?.chooseManualSessionLogPath?.(payload)
?? { success: false, canceled: false, error: "Session log bridge unavailable" };
}, []);
const startManualSessionLog = useCallback(async (payload: ManualStartPayload) => {
const bridge = netcattyBridge.get();
return bridge?.startManualSessionLog?.(payload) ?? { success: false, started: false, error: "Session log bridge unavailable" };
@@ -34,5 +53,10 @@ export const useSessionLogBackend = () => {
return bridge?.getManualSessionLogStatus?.(payload) ?? { success: false, isLogging: false, error: "Session log bridge unavailable" };
}, []);
return { startManualSessionLog, stopManualSessionLog, getManualSessionLogStatus };
return {
chooseManualSessionLogPath,
startManualSessionLog,
stopManualSessionLog,
getManualSessionLogStatus,
};
};
-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/,
);
});
+26 -3
View File
@@ -75,6 +75,7 @@ import {
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
} from '../../infrastructure/config/storageKeys';
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
@@ -121,6 +122,7 @@ import {
DEFAULT_SHOW_SFTP_TAB,
DEFAULT_SHOW_HOST_TREE_SIDEBAR,
DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
DEFAULT_SHOW_TAB_NUMBER_BADGES,
DEFAULT_DISABLE_TERMINAL_FONT_ZOOM,
DEFAULT_SSH_DEBUG_LOGS_ENABLED,
DEFAULT_SSH_DEEP_LINK_ENABLED,
@@ -413,6 +415,10 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
return stored ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS;
});
const [showTabNumberBadges, setShowTabNumberBadgesState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_TAB_NUMBER_BADGES);
return stored ?? DEFAULT_SHOW_TAB_NUMBER_BADGES;
});
const [disableTerminalFontZoom, setDisableTerminalFontZoomState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM);
return stored ?? DEFAULT_DISABLE_TERMINAL_FONT_ZOOM;
@@ -941,6 +947,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
);
const storedShellOnlyTabNumberShortcuts = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
setShellOnlyTabNumberShortcutsState(storedShellOnlyTabNumberShortcuts ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
const storedShowTabNumberBadges = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_TAB_NUMBER_BADGES);
setShowTabNumberBadgesState(storedShowTabNumberBadges ?? DEFAULT_SHOW_TAB_NUMBER_BADGES);
const storedDisableTerminalFontZoom = localStorageAdapter.readBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM);
setDisableTerminalFontZoomState(storedDisableTerminalFontZoom ?? DEFAULT_DISABLE_TERMINAL_FONT_ZOOM);
const storedRestorePreviousSession = localStorageAdapter.readBoolean(STORAGE_KEY_RESTORE_PREVIOUS_SESSION);
@@ -1145,7 +1153,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode,
@@ -1155,7 +1163,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize: applyIncomingTerminalFontSize,
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled, setJmsDeepLinkEnabledState: applyIncomingJmsDeepLinkEnabled, setExplorerContextMenuEnabledState: applyIncomingExplorerContextMenuEnabled,
setGlobalHotkeyEnabled, setWindowOpacity: applyIncomingWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
@@ -1321,6 +1329,13 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
notifySettingsChanged(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, enabled);
}, [notifySettingsChanged]);
const setShowTabNumberBadges = useCallback((enabled: boolean) => {
setShowTabNumberBadgesState(enabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_TAB_NUMBER_BADGES, enabled);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SHOW_TAB_NUMBER_BADGES, enabled);
}, [notifySettingsChanged]);
const setDisableTerminalFontZoom = useCallback((enabled: boolean) => {
setDisableTerminalFontZoomState(enabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM, enabled);
@@ -1746,6 +1761,9 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
showOnlyUngroupedHostsInRoot,
dynamicTabTitleMode: terminalSettings.dynamicTabTitleMode,
disableTerminalFontZoom,
hotkeyScheme,
shellOnlyTabNumberShortcuts,
showTabNumberBadges,
restoreTerminalCwd,
terminalSidePanelAutoOpen,
terminalSidePanelAutoOpenTab,
@@ -1754,13 +1772,16 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
darkUiThemeId,
disableTerminalFontZoom,
hostClickBehavior,
hotkeyScheme,
lightUiThemeId,
resolvedTheme,
restoreTerminalCwd,
shellOnlyTabNumberShortcuts,
showHostTreeSidebar,
showOnlyUngroupedHostsInRoot,
showRecentHosts,
showSftpTab,
showTabNumberBadges,
terminalSettings.dynamicTabTitleMode,
terminalSidePanelAutoOpen,
terminalSidePanelAutoOpenTab,
@@ -1935,6 +1956,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setTerminalSidePanelAutoOpenTab,
shellOnlyTabNumberShortcuts,
setShellOnlyTabNumberShortcuts,
showTabNumberBadges,
setShowTabNumberBadges,
disableTerminalFontZoom,
setDisableTerminalFontZoom,
restorePreviousSession,
@@ -1994,7 +2017,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
customKeyBindings, editorWordWrap,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom,
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
]),
};
+5 -4
View File
@@ -643,10 +643,11 @@ export const useSftpState = (
[resolveTransferConflict, resolveUploadConflict, uploadConflicts],
);
// FileZilla-style: when the browser UI is hidden, soft-close browse SFTP
// channels. Defer park while this owner still has unfinished transfers so
// pre-lease prep (conflict/stat) cannot race a hard-close of the browse id.
// In-flight streams also soft-close via leases; pool handles bulk I/O.
// FileZilla-style: when the side panel is closed (not merely showing another
// tool), soft-close browse SFTP channels. Defer park while this owner still
// has unfinished transfers so pre-lease prep (conflict/stat) cannot race a
// hard-close of the browse id. In-flight streams also soft-close via leases;
// pool handles bulk I/O.
const interactive = options?.interactive !== false;
useEffect(() => {
const gen = ++browseLifecycleGenRef.current;
@@ -0,0 +1,59 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
getShortcutModifierRequirements,
isShortcutModifierHeld,
shouldReleaseShortcutModifier,
type ShortcutModifierEvent,
} from './useShortcutModifierHeld.ts';
const event = (overrides: Partial<ShortcutModifierEvent> = {}): ShortcutModifierEvent => ({
metaKey: false,
ctrlKey: false,
altKey: false,
shiftKey: false,
...overrides,
});
test('shortcut modifier requirements follow the effective Mac or PC binding', () => {
const mac = getShortcutModifierRequirements('⌘ + [1...9]', 'mac');
assert.deepEqual(mac, { metaKey: true, ctrlKey: false, altKey: false, shiftKey: false });
assert.equal(isShortcutModifierHeld(event({ metaKey: true }), mac), true);
assert.equal(isShortcutModifierHeld(event({ ctrlKey: true }), mac), false);
const pc = getShortcutModifierRequirements('Alt + Shift + [1...9]', 'pc');
assert.deepEqual(pc, { metaKey: false, ctrlKey: false, altKey: true, shiftKey: true });
assert.equal(isShortcutModifierHeld(event({ altKey: true, shiftKey: true }), pc), true);
assert.equal(isShortcutModifierHeld(event({ altKey: true }), pc), false);
assert.equal(isShortcutModifierHeld(event({ altKey: true, metaKey: true, shiftKey: true }), pc), false);
});
test('shortcut number preview is unavailable for disabled, non-range, or modifier-free bindings', () => {
assert.equal(getShortcutModifierRequirements('Disabled', 'mac'), null);
assert.equal(getShortcutModifierRequirements('⌘ + K', 'mac'), null);
assert.equal(getShortcutModifierRequirements('[1...9]', 'mac'), null);
assert.equal(getShortcutModifierRequirements('⌘ + [1...9]', 'disabled'), null);
});
test('shortcut modifier state clears when the required combination stops matching', () => {
const requirements = getShortcutModifierRequirements('⌘ + [1...9]', 'mac');
assert.equal(shouldReleaseShortcutModifier(event({ metaKey: true }), requirements), false);
assert.equal(shouldReleaseShortcutModifier(event(), requirements), true);
});
test('shortcut modifier held state tracks exact combo when extras are pressed or released', () => {
const requirements = getShortcutModifierRequirements('⌘ + [1...9]', 'mac');
// Already matching, then an extra modifier: preview must clear.
assert.equal(isShortcutModifierHeld(event({ metaKey: true }), requirements), true);
assert.equal(
isShortcutModifierHeld(event({ metaKey: true, shiftKey: true }), requirements),
false,
);
// Extra pressed first, then released while required modifiers remain: preview re-arms.
assert.equal(
isShortcutModifierHeld(event({ metaKey: true, altKey: true }), requirements),
false,
);
assert.equal(isShortcutModifierHeld(event({ metaKey: true }), requirements), true);
});
@@ -0,0 +1,113 @@
import { useEffect, useMemo, useState } from 'react';
import { parseKeyCombo, type HotkeyScheme } from '../../domain/models/keyBindings';
export type ShortcutModifierEvent = Pick<KeyboardEvent, 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey'>;
export type ShortcutModifierRequirements = Readonly<ShortcutModifierEvent>;
const MODIFIER_TOKENS = {
mac: {
'⌘': 'metaKey',
'⌃': 'ctrlKey',
'⌥': 'altKey',
Shift: 'shiftKey',
},
pc: {
Ctrl: 'ctrlKey',
Alt: 'altKey',
Shift: 'shiftKey',
Win: 'metaKey',
},
} as const;
/**
* Resolve the modifier combination for the effective [1...9] binding.
* A non-range or disabled binding cannot reveal tab numbers because the
* dispatcher does not use it for the number-switch action.
*/
export function getShortcutModifierRequirements(
keyBinding: string | null,
scheme: HotkeyScheme,
): ShortcutModifierRequirements | null {
if (scheme === 'disabled' || !keyBinding || !keyBinding.includes('[1...9]')) return null;
const parsed = parseKeyCombo(keyBinding);
if (!parsed || parsed.modifiers.length === 0) return null;
const requirements: Record<keyof ShortcutModifierEvent, boolean> = {
metaKey: false,
ctrlKey: false,
altKey: false,
shiftKey: false,
};
const tokenMap = MODIFIER_TOKENS[scheme];
for (const modifier of parsed.modifiers) {
const flag = tokenMap[modifier as keyof typeof tokenMap];
if (!flag) return null;
requirements[flag] = true;
}
return requirements;
}
/** Whether a keyboard event has exactly the modifiers required by the binding. */
export function isShortcutModifierHeld(
event: ShortcutModifierEvent,
requirements: ShortcutModifierRequirements | null,
): boolean {
if (!requirements) return false;
return event.metaKey === requirements.metaKey
&& event.ctrlKey === requirements.ctrlKey
&& event.altKey === requirements.altKey
&& event.shiftKey === requirements.shiftKey;
}
/** Whether a keyup event means the required modifier combination is no longer held. */
export function shouldReleaseShortcutModifier(
event: ShortcutModifierEvent,
requirements: ShortcutModifierRequirements | null,
): boolean {
return !isShortcutModifierHeld(event, requirements);
}
export function useShortcutModifierHeld(
keyBinding: string | null,
scheme: HotkeyScheme,
): boolean {
const [isHeld, setIsHeld] = useState(false);
const requirements = useMemo(
() => getShortcutModifierRequirements(keyBinding, scheme),
[keyBinding, scheme],
);
useEffect(() => {
setIsHeld(false);
if (!requirements) return;
// Sync on every modifier transition so extras clear the preview and
// releasing an extra modifier can re-arm when the exact combo remains.
const syncHeldState = (event: KeyboardEvent) => {
setIsHeld(isShortcutModifierHeld(event, requirements));
};
const clearHeldState = () => setIsHeld(false);
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
clearHeldState();
}
};
window.addEventListener('keydown', syncHeldState, true);
window.addEventListener('keyup', syncHeldState, true);
window.addEventListener('blur', clearHeldState);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
window.removeEventListener('keydown', syncHeldState, true);
window.removeEventListener('keyup', syncHeldState, true);
window.removeEventListener('blur', clearHeldState);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [requirements]);
return isHeld;
}
@@ -59,6 +59,7 @@ export function useTerminalAiContexts({
.map((sessionId) => sessionById.get(sessionId)?.hostId)
.filter((hostId): hostId is string => !!hostId),
scopeLabel: workspace.title,
focusedSessionId: workspace.focusedSessionId,
terminalSessions: sessionIds.map((sessionId) =>
buildAITerminalSessionInfo(
sessionById.get(sessionId),
+4 -1
View File
@@ -513,7 +513,10 @@ export const useTerminalBackend = () => {
return bridge?.onZmodemEvent?.(sessionId, cb) ?? (() => {});
}, []);
const getSessionPwd = useCallback(async (sessionId: string, options?: { allowHomeFallback?: boolean }) => {
const getSessionPwd = useCallback(async (
sessionId: string,
options?: { allowHomeFallback?: boolean; allowLoginShellFallback?: boolean },
) => {
const bridge = netcattyBridge.get();
if (!bridge?.getSessionPwd) return { success: false, error: 'getSessionPwd unavailable' };
return bridge.getSessionPwd(sessionId, options);
@@ -0,0 +1,74 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
installTerminalKeyboardFocusTracking,
isTerminalKeyboardTarget,
} from "./terminalKeyboardFocus";
const targetWithClosest = (matches: boolean) => ({
closest: (selector: string) => {
assert.match(selector, /\.xterm/);
return matches ? {} : null;
},
}) as unknown as EventTarget;
test("terminal keyboard target detection recognizes xterm descendants", () => {
assert.equal(isTerminalKeyboardTarget(targetWithClosest(true)), true);
assert.equal(isTerminalKeyboardTarget(targetWithClosest(false)), false);
assert.equal(isTerminalKeyboardTarget(null), false);
});
test("terminal keyboard focus tracking publishes deduplicated focus transitions and cleans up", async () => {
const listeners = new Map<string, (event?: Event) => void>();
const removed: string[] = [];
const body = targetWithClosest(false);
const terminal = targetWithClosest(true);
const documentRef = {
activeElement: body as unknown as Element,
addEventListener: (type: string, listener: (event?: Event) => void) => {
listeners.set(type, listener);
},
removeEventListener: (type: string) => {
removed.push(type);
},
} as unknown as Document;
const windowRef = {
addEventListener: (type: string, listener: (event?: Event) => void) => {
listeners.set(`window:${type}`, listener);
},
removeEventListener: (type: string) => {
removed.push(`window:${type}`);
},
} as unknown as Window;
const changes: boolean[] = [];
const cleanup = installTerminalKeyboardFocusTracking(documentRef, windowRef, (focused) => {
if (changes.at(-1) !== focused) changes.push(focused);
});
assert.deepEqual(changes, [false]);
listeners.get("pointerdown")?.({ target: terminal } as unknown as Event);
listeners.get("focusin")?.({ target: terminal } as unknown as Event);
assert.deepEqual(changes, [false, true]);
(documentRef as unknown as { activeElement: Element }).activeElement = terminal as unknown as Element;
listeners.get("pointerdown")?.({ target: body } as unknown as Event);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(changes, [false, true]);
(documentRef as unknown as { activeElement: Element }).activeElement = body as unknown as Element;
listeners.get("focusout")?.();
await Promise.resolve();
assert.deepEqual(changes, [false, true, false]);
listeners.get("window:blur")?.();
assert.deepEqual(changes, [false, true, false]);
(documentRef as unknown as { activeElement: Element }).activeElement = terminal as unknown as Element;
listeners.get("window:focus")?.();
assert.deepEqual(changes, [false, true, false, true]);
cleanup();
assert.deepEqual(removed.sort(), ["focusin", "focusout", "pointerdown", "window:blur", "window:focus"]);
});
@@ -0,0 +1,33 @@
import { useEffect } from "react";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
import { installTerminalKeyboardFocusTracking } from "./terminalKeyboardFocus";
export function useTerminalKeyboardFocus(enabled = true): void {
useEffect(() => {
if (!enabled) return undefined;
const bridge = netcattyBridge.get();
if (!bridge?.setTerminalKeyboardFocus) return undefined;
let lastPublished: boolean | undefined;
const publish = (focused: boolean) => {
if (focused === lastPublished) return;
lastPublished = focused;
try {
bridge.setTerminalKeyboardFocus?.(focused);
} catch {
// Browser preview or a disposed Electron bridge.
}
};
const cleanupTracking = installTerminalKeyboardFocusTracking(
document,
window,
publish,
);
return () => {
cleanupTracking();
publish(false);
};
}, [enabled]);
}
@@ -123,6 +123,7 @@ export function useTerminalSidePanelLayoutState() {
setSidePanelOpenTabs,
sidePanelOpenTabsRef,
sidePanelLayouts,
setSidePanelLayouts,
sidePanelLayoutsRef,
focusPane,
splitPane,
@@ -0,0 +1,237 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
const source = readFileSync(new URL("./useVaultState.ts", import.meta.url), "utf8");
test("deleteSelectedSnippets merges into persisted vault under the shared lock", () => {
assert.match(source, /const deleteSelectedSnippets = useCallback\(async/);
assert.match(
source,
/deleteSelectedSnippets[\s\S]*withVaultImportLock\("vault"/,
);
assert.match(
source,
/deleteSelectedSnippetsFromVault\(\s*latestSnippets\s*,\s*latestHosts\s*,\s*selectedSnippetIds,?\s*\)/,
);
assert.match(
source,
/deleteSelectedSnippets[\s\S]*commitPluginImporterTransaction\(localStorageAdapter, \[[\s\S]*STORAGE_KEY_HOSTS[\s\S]*STORAGE_KEY_SNIPPETS/,
);
// Persistence rejection must be caught: callers void the promise after the
// confirm dialog closes, so an uncaught throw would leave no retry feedback.
assert.match(
source,
/deleteSelectedSnippets[\s\S]*try \{\s*commitPluginImporterTransaction[\s\S]*catch \{[\s\S]*notify\.error\([\s\S]*Snippets could not be deleted/,
);
// Must not rebuild hosts from a per-window in-memory snapshot (popup race).
assert.doesNotMatch(
source,
/deleteSelectedSnippetsFromVault\(\s*snippetsRef\.current\s*,\s*hostsRef\.current/,
);
// Paired writes must not publish in-memory state if only one key lands.
assert.doesNotMatch(
source,
/deleteSelectedSnippets[\s\S]*localStorageAdapter\.write\(STORAGE_KEY_HOSTS, encryptedHosts\)[\s\S]*localStorageAdapter\.write\(STORAGE_KEY_SNIPPETS/,
);
});
test("updateHosts prunes stale snippet bindings under the vault lock", () => {
// Cross-window bulk-delete can land while a host writer is queued with a
// pre-delete encrypted blob. Re-check the live snippet catalog under the lock
// before persisting so login/connect bindings are not restored.
assert.match(
source,
/commitEncryptedHostsUnderVaultLock[\s\S]*pruneHostsStaleSnippetBindings\(hostsToPersist, latestSnippets\)/,
);
assert.match(
source,
/const writePromise = encryptPromise\.then\(async \(enc\) => \{[\s\S]*return commitEncryptedHostsUnderVaultLock\(ver, cleaned, enc\)/,
);
});
test("importData replaces snippets instead of additive-rebasing", () => {
// Sync restore / backup import must match the selected snapshot. Additive
// rebase would keep concurrent disk-only ids and report a successful restore
// that still contains snippets absent from the imported payload.
assert.match(
source,
/updateSnippets\(payload\.snippets,\s*\{\s*replace:\s*true\s*\}\)/,
);
});
test("updateSnippets disk writes take the shared vault lock", () => {
// Unlocked snippet saves can interleave with bulk-delete's journaled commit
// and discard concurrent edits. Ordinary writers must queue on the same lock
// and be visible to waitForPendingVaultWrites.
assert.match(
source,
/const updateSnippets = useCallback\(\(\s*data: Snippet\[\],\s*options\?: \{ replace\?: boolean \},\s*\) => \{[\s\S]*withVaultImportLock\("vault", async \(\) => \{[\s\S]*localStorageAdapter\.write\(STORAGE_KEY_SNIPPETS, rebased\)/,
);
assert.match(
source,
/snippetsWritePendingRef\.current = writePromise/,
);
assert.match(
source,
/waitForPendingVaultWrites[\s\S]*snippetsWritePendingRef\.current/,
);
assert.doesNotMatch(
source,
/const updateSnippets = useCallback\(\([\s\S]*?\) => \{[\s\S]*setSnippets\(cleaned\);\s*localStorageAdapter\.write\(STORAGE_KEY_SNIPPETS, cleaned\);\s*\}, \[\]\);/,
);
});
test("updateSnippets rebases onto the latest persisted snapshot under the lock", () => {
// Web Locks serialize writers but do not merge snapshots. A popup bulk-delete
// that lands while a main-window save is queued must not be resurrected.
assert.match(
source,
/rebaseSnippetVaultWrite\(\{\s*base,\s*ours: cleaned,\s*theirs:/,
);
assert.match(
source,
/snippetsWriteOwnerRef\.current !== ver/,
);
});
test("clearVaultData replaces snippets without additive rebase", () => {
// Clear All Local Data must not preserve concurrent disk-only snippet adds
// that rebaseSnippetVaultWrite would otherwise keep.
assert.match(
source,
/updateSnippets\(\[\],\s*\{\s*replace:\s*true\s*\}\)/,
);
assert.match(
source,
/const replace =\s*options\?\.replace === true \|\| snippetsWriteReplaceRef\.current/,
);
assert.match(
source,
/replace\s*\?\s*cleaned\s*:\s*rebaseSnippetVaultWrite/,
);
assert.match(
source,
/if \(replace\) \{\s*\/\/ Restore\/import\/clear[\s\S]*snippetsWriteBaseRef\.current = null/,
);
});
test("updateSnippets preserves replace mode across superseded local saves", () => {
// A clear/restore queued behind the vault lock must not lose replacement
// intent when a later create/edit becomes the write owner — otherwise that
// save additively rebases against the stale disk catalog and resurrects
// every pre-replacement snippet.
assert.match(
source,
/const snippetsWriteReplaceRef = useRef\(false\)/,
);
assert.match(
source,
/if \(options\?\.replace === true\) \{\s*snippetsWriteReplaceRef\.current = true;\s*\}/,
);
assert.match(
source,
/snippetsWriteBaseRef\.current = null;\s*snippetsWriteReplaceRef\.current = false;/,
);
});
test("updateSnippets keeps the persisted rebase ancestor across superseded saves", () => {
// Two queued local saves must not let the second treat the first save's
// optimistic in-memory array as base — that array never hit disk, so an add
// would look like a concurrent delete and be dropped.
assert.match(
source,
/const snippetsWriteBaseRef = useRef<Snippet\[\] \| null>\(null\)/,
);
assert.match(
source,
/const base = snippetsWriteBaseRef\.current \?\? snippetsRef\.current/,
);
assert.match(
source,
/if \(snippetsWriteBaseRef\.current === null\) \{\s*snippetsWriteBaseRef\.current = base;\s*\}/,
);
assert.match(
source,
/const persisted = localStorageAdapter\.write\(STORAGE_KEY_SNIPPETS, rebased\)/,
);
// Quota failure must keep the ancestor; clearing it would drop a local add on
// the next save after space is freed.
assert.match(
source,
/if \(!persisted\) \{[\s\S]*return "failed" as const;[\s\S]*\}\s*\/\/ Disk caught up[\s\S]*snippetsWriteBaseRef\.current = null/,
);
assert.match(
source,
/if \(!persisted\) \{[\s\S]*Snippets could not be saved/,
);
});
test("snippet storage events keep queued local edits and their rebase ancestor", () => {
// A cross-window write must not clear snippetsWriteBaseRef / replace optimistic
// state while a local owner is still queued — the next edit would supersede
// that owner from a remote-only snapshot and drop the first local mutation.
assert.match(
source,
/if \(key === STORAGE_KEY_SNIPPETS\) \{[\s\S]*const pendingBase = snippetsWriteBaseRef\.current;\s*if \(pendingBase !== null\) \{[\s\S]*rebaseSnippetVaultWrite\(\{\s*base: pendingBase,\s*ours: snippetsRef\.current,\s*theirs: next,\s*\}\)/,
);
assert.doesNotMatch(
source,
/if \(key === STORAGE_KEY_SNIPPETS\) \{[\s\S]*snippetsWriteBaseRef\.current = null;\s*snippetsRef\.current = next/,
);
});
test("snippet storage events retain outstanding replace snapshots", () => {
// clear/restore sets replace with base null. Adopting a remote old catalog
// before the lock is acquired would pollute memory; a later local edit would
// still replace disk with that polluted snapshot.
assert.match(
source,
/if \(key === STORAGE_KEY_SNIPPETS\) \{[\s\S]*if \(snippetsWriteReplaceRef\.current\) \{[\s\S]*return;\s*\}[\s\S]*snippetsRef\.current = next/,
);
});
test("snippet storage events reject payloads older than current disk", () => {
// After a queued replace commits, snippetsWriteReplaceRef is false again. A
// delayed peer StorageEvent still carries the pre-replacement newValue; if
// adopted, the next edit would persist that resurrected catalog.
assert.match(
source,
/if \(key === STORAGE_KEY_SNIPPETS\) \{[\s\S]*event\.newValue !== localStorageAdapter\.readString\(STORAGE_KEY_SNIPPETS\)[\s\S]*return;/,
);
});
test("startup host re-encryption uses the locked prune writer", () => {
// A second renderer migrating saved hosts must not write the pre-delete
// encryption blob unlocked after bulk-delete cleared login/connect bindings.
assert.match(
source,
/migrateHostsFromLegacyLineTimestamps[\s\S]*commitEncryptedHostsUnderVaultLock\(ver, sanitized, enc\)/,
);
assert.match(
source,
/migrateHostsFromLegacyLineTimestamps[\s\S]*hostsWritePendingRef\.current = writePromise/,
);
assert.doesNotMatch(
source,
/migrateHostsFromLegacyLineTimestamps[\s\S]*localStorageAdapter\.write\(STORAGE_KEY_HOSTS, enc\)/,
);
});
test("startup snippet order backfill uses the locked vault writer", () => {
// An initializing renderer must not rewrite a pre-delete snippet snapshot
// unlocked after peer bulk-delete cleared host bindings — that resurrects
// every selected snippet while binding cleanup remains committed.
assert.match(
source,
/needsOrderPersist[\s\S]*withVaultImportLock\("vault", async \(\) => \{[\s\S]*readStoredArray<Snippet>\([\s\S]*STORAGE_KEY_SNIPPETS[\s\S]*localStorageAdapter\.write\(STORAGE_KEY_SNIPPETS, latest\)/,
);
assert.match(
source,
/needsOrderPersist[\s\S]*snippetsWritePendingRef\.current = writePromise/,
);
assert.doesNotMatch(
source,
/const orderedSnippets = normalizeVaultOrder\(savedSnippets\);\s*setSnippets\(orderedSnippets\);\s*localStorageAdapter\.write\(STORAGE_KEY_SNIPPETS, orderedSnippets\);/,
);
});
+313 -33
View File
@@ -64,6 +64,11 @@ import {
type ConnectionLogTerminalDataMap,
} from "../../domain/connectionLogTerminalData";
import { getNextVaultOrder, normalizeVaultOrder } from "../../domain/vaultOrder";
import {
deleteSelectedSnippetsFromVault,
pruneHostsStaleSnippetBindings,
rebaseSnippetVaultWrite,
} from "../../domain/snippetSelection";
import { loadSanitizedShellHistory } from "./shellHistoryPersistence";
import {
publishConnectionLogsSnapshot,
@@ -290,12 +295,14 @@ export const useVaultState = () => {
const customGroupsRef = useRef<string[]>([]);
const managedSourcesRef = useRef<ManagedSource[]>([]);
const hostsRef = useRef<Host[]>([]);
const snippetsRef = useRef<Snippet[]>([]);
const notesRef = useRef<VaultNote[]>([]);
const noteGroupsRef = useRef<string[]>([]);
const notesPersistFailureNotifiedAtRef = useRef(0);
customGroupsRef.current = customGroups;
managedSourcesRef.current = managedSources;
hostsRef.current = hosts;
snippetsRef.current = snippets;
notesRef.current = notes;
noteGroupsRef.current = noteGroups;
@@ -307,6 +314,18 @@ export const useVaultState = () => {
const identitiesWriteVersion = useRef(0);
const proxyProfilesWriteVersion = useRef(0);
const snippetsWriteVersion = 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.
const snippetsWriteOwnerRef = useRef(0);
// Last persisted ancestor for in-flight updateSnippets rebases. Superseded
// saves must not advance this to an optimistic in-memory array that never
// landed on disk — that would make a local add look like a concurrent delete.
const snippetsWriteBaseRef = useRef<Snippet[] | null>(null);
// Outstanding clear/restore/import replace must survive a superseding local
// save. Otherwise the later owner rebases additively against the old disk
// catalog and resurrects every pre-replacement snippet.
const snippetsWriteReplaceRef = useRef(false);
const customGroupsWriteVersion = useRef(0);
const groupConfigsWriteVersion = useRef(0);
// Encrypt-phase promises can always be awaited, even under the vault lock.
@@ -320,6 +339,7 @@ export const useVaultState = () => {
const keysWritePendingRef = useRef<Promise<unknown>>(Promise.resolve());
const identitiesWritePendingRef = useRef<Promise<unknown>>(Promise.resolve());
const groupConfigsWritePendingRef = useRef<Promise<unknown>>(Promise.resolve());
const snippetsWritePendingRef = useRef<Promise<unknown>>(Promise.resolve());
const waitForPendingVaultWrites = useCallback(async () => {
while (true) {
@@ -344,6 +364,7 @@ export const useVaultState = () => {
keysWritePendingRef.current,
identitiesWritePendingRef.current,
groupConfigsWritePendingRef.current,
snippetsWritePendingRef.current,
];
await Promise.all(writePending);
if (
@@ -351,6 +372,7 @@ export const useVaultState = () => {
&& writePending[1] === keysWritePendingRef.current
&& writePending[2] === identitiesWritePendingRef.current
&& writePending[3] === groupConfigsWritePendingRef.current
&& writePending[4] === snippetsWritePendingRef.current
&& encryptPending[0] === hostsEncryptPendingRef.current
&& encryptPending[1] === keysEncryptPendingRef.current
&& encryptPending[2] === identitiesEncryptPendingRef.current
@@ -451,6 +473,37 @@ export const useVaultState = () => {
return mergeConnectionLogsFromStorage(prev, storedLogs, terminalDataMap);
}, []);
// Encrypt outside the lock, then under the lock prune script bindings against
// the latest snippet catalog so a queued full-array write cannot restore
// login/connect ids cleared by a concurrent bulk-delete in another window.
const commitEncryptedHostsUnderVaultLock = useCallback(async (
ver: number,
hostsToPersist: Host[],
encrypted: Awaited<ReturnType<typeof encryptHosts>>,
) => {
return withVaultImportLock("vault", async () => {
if (ver !== hostsWriteVersion.current) return "superseded" as const;
const latestSnippets = normalizeVaultOrder(
readStoredArray<Snippet>(
STORAGE_KEY_SNIPPETS,
localStorageAdapter.readString(STORAGE_KEY_SNIPPETS),
),
);
const pruned = pruneHostsStaleSnippetBindings(hostsToPersist, latestSnippets);
let payload = encrypted;
if (pruned !== hostsToPersist) {
const normalized = normalizeVaultOrder(pruned);
payload = await encryptHosts(normalized);
if (ver !== hostsWriteVersion.current) return "superseded" as const;
hostsRef.current = normalized;
setHosts(normalized);
}
return localStorageAdapter.write(STORAGE_KEY_HOSTS, payload)
? "written" as const
: "failed" as const;
});
}, []);
const updateHosts = useCallback((data: Host[] | ((prev: Host[]) => Host[])) => {
// Keep object identity for hosts that did not actually change. Callers that
// do `hosts.map(h => h.id === id ? patch(h) : h)` already pass through
@@ -483,14 +536,11 @@ export const useVaultState = () => {
hostsEncryptPendingRef.current = encryptPromise.then(() => undefined);
const writePromise = encryptPromise.then(async (enc) => {
if (ver !== hostsWriteVersion.current) return "superseded" as const;
return withVaultImportLock("vault", async () => {
if (ver !== hostsWriteVersion.current) return "superseded" as const;
return localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
});
return commitEncryptedHostsUnderVaultLock(ver, cleaned, enc);
});
hostsWritePendingRef.current = writePromise;
return writePromise;
}, []);
}, [commitEncryptedHostsUnderVaultLock]);
const readPersistedHosts = useCallback(async (): Promise<Host[]> => {
// Always drain what is safe to wait for. Under the vault lock this is only
@@ -592,13 +642,166 @@ export const useVaultState = () => {
});
}, []);
const updateSnippets = useCallback((data: Snippet[]) => {
const updateSnippets = useCallback((
data: Snippet[],
options?: { replace?: boolean },
) => {
// Capture the pre-update snapshot for a 3-way rebase once we hold the lock.
// Callers pass a full array derived from this window's view; a popup delete
// (or another window's import) may land on disk before our write runs.
// Keep the first outstanding ancestor across superseded local saves so a
// 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;
if (options?.replace === true) {
snippetsWriteReplaceRef.current = true;
}
if (replace) {
// Restore/import/clear must not keep an additive ancestor — storage events
// would otherwise merge disk-only ids back into memory while replace waits.
// Keep this cleared when a later create/edit supersedes the replacement
// owner so that save stays in replace mode instead of capturing []/restored
// memory as an additive rebase base against the stale disk catalog.
snippetsWriteBaseRef.current = null;
} else if (snippetsWriteBaseRef.current === null) {
snippetsWriteBaseRef.current = base;
}
const cleaned = normalizeVaultOrder(data);
++snippetsWriteVersion.current;
const ver = ++snippetsWriteVersion.current;
snippetsWriteOwnerRef.current = ver;
// Keep live snapshot ahead of React commit so same-tick readers (delete,
// agent bridge) do not observe a stale pre-write array.
snippetsRef.current = cleaned;
setSnippets(cleaned);
localStorageAdapter.write(STORAGE_KEY_SNIPPETS, cleaned);
// Serialize with deleteSelectedSnippets / plugin importer under the shared
// vault lock, then rebase onto the latest persisted snapshot so a queued
// full-array write cannot resurrect concurrently deleted snippets (or drop
// concurrent additions). Explicit replace (clear / sync restore / import)
// 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
: rebaseSnippetVaultWrite({
base,
ours: cleaned,
theirs: normalizeVaultOrder(
readStoredArray<Snippet>(
STORAGE_KEY_SNIPPETS,
localStorageAdapter.readString(STORAGE_KEY_SNIPPETS),
),
),
}),
);
const persisted = localStorageAdapter.write(STORAGE_KEY_SNIPPETS, rebased);
if (!persisted) {
// Keep the pre-update ancestor. Clearing it here would make the next
// save rebase against the optimistic array while disk still lacks the
// add, so rebase would treat the add as a concurrent delete.
notify.error(
"Snippets could not be saved. Free some local storage space and try again.",
"Scripts",
);
return "failed" as const;
}
// Disk caught up; next save should treat this write as its ancestor.
snippetsWriteBaseRef.current = null;
snippetsWriteReplaceRef.current = false;
if (snippetsWriteOwnerRef.current === ver) {
snippetsRef.current = rebased;
setSnippets(rebased);
}
return "written" as const;
});
snippetsWritePendingRef.current = writePromise;
return writePromise;
}, []);
// Cross-window safe: merge binding cleanup into the latest persisted
// hosts/snippets under the shared vault lock. Popup terminals own a separate
// useVaultState instance — writing hostsRef from that window can discard a
// main-window host edit, or a storage-event version bump can cancel the host
// write after snippets were already removed.
const deleteSelectedSnippets = useCallback(async (selectedSnippetIds: ReadonlySet<string>) => {
if (selectedSnippetIds.size === 0) {
return {
snippets: snippetsRef.current,
hosts: hostsRef.current,
deletedCount: 0,
};
}
while (true) {
await waitForPendingVaultWrites();
const attempt = await withVaultImportLock("vault", async () => {
const writeVersions = {
hosts: hostsWriteVersion.current,
snippets: snippetsWriteVersion.current,
};
const rawHosts = localStorageAdapter.readString(STORAGE_KEY_HOSTS);
const rawSnippets = localStorageAdapter.readString(STORAGE_KEY_SNIPPETS);
const storedHosts = readStoredArray<Host>(STORAGE_KEY_HOSTS, rawHosts);
const storedSnippets = readStoredArray<Snippet>(STORAGE_KEY_SNIPPETS, rawSnippets);
const latestHosts = normalizeVaultOrder(
(await decryptHosts(storedHosts)).map((host) => sanitizeHost(host)),
);
const latestSnippets = normalizeVaultOrder(storedSnippets);
const result = deleteSelectedSnippetsFromVault(
latestSnippets,
latestHosts,
selectedSnippetIds,
);
if (result.deletedCount === 0) return result;
const encryptedHosts = await encryptHosts(result.hosts);
const changedWhilePreparing = (
writeVersions.hosts !== hostsWriteVersion.current
|| writeVersions.snippets !== snippetsWriteVersion.current
|| localStorageAdapter.readString(STORAGE_KEY_HOSTS) !== rawHosts
|| localStorageAdapter.readString(STORAGE_KEY_SNIPPETS) !== rawSnippets
);
if (changedWhilePreparing) return null;
++hostsWriteVersion.current;
++snippetsWriteVersion.current;
// Journaled pair write: a partial hosts/snippets persist would drop
// login/connect bindings on restart while leaving the snippets behind.
// Callers fire-and-forget this promise after closing the confirm dialog,
// so storage rejection must not become an unhandled rejection.
try {
commitPluginImporterTransaction(localStorageAdapter, [
[STORAGE_KEY_HOSTS, encryptedHosts],
[STORAGE_KEY_SNIPPETS, result.snippets],
]);
} catch {
notify.error(
"Snippets could not be deleted. Free some local storage space and try again.",
"Scripts",
);
return {
snippets: latestSnippets,
hosts: latestHosts,
deletedCount: 0,
};
}
hostsRef.current = result.hosts;
snippetsRef.current = result.snippets;
// Persisted delete is the new ancestor; drop any stale rebase base from
// a superseded/queued updateSnippets that never landed.
snippetsWriteBaseRef.current = null;
snippetsWriteReplaceRef.current = false;
setHosts(result.hosts);
setSnippets(result.snippets);
hostsWritePendingRef.current = Promise.resolve("unchanged" as const);
snippetsWritePendingRef.current = Promise.resolve("unchanged" as const);
return result;
});
if (attempt !== null) return attempt;
}
}, [waitForPendingVaultWrites]);
const updateSnippetPackages = useCallback((data: string[]) => {
setSnippetPackages(data);
localStorageAdapter.write(STORAGE_KEY_SNIPPET_PACKAGES, data);
@@ -835,7 +1038,7 @@ export const useVaultState = () => {
updateKeys([]);
updateIdentities([]);
updateProxyProfiles([]);
updateSnippets([]);
updateSnippets([], { replace: true });
updateSnippetPackages([]);
updateNotes([]);
updateNoteGroups([]);
@@ -1026,17 +1229,14 @@ export const useVaultState = () => {
hostsEncryptPendingRef.current = encryptPromise.then(() => undefined);
const writePromise = encryptPromise.then(async (enc) => {
if (ver !== hostsWriteVersion.current) return;
return withVaultImportLock("vault", async () => {
if (ver === hostsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
});
return commitEncryptedHostsUnderVaultLock(ver, updated, enc);
});
hostsWritePendingRef.current = writePromise;
return updated;
});
return newHost;
}, [hosts]);
}, [commitEncryptedHostsUnderVaultLock, hosts]);
useEffect(() => {
let cancelled = false;
@@ -1062,15 +1262,21 @@ export const useVaultState = () => {
readLegacyLineTimestampsEnabled(),
),
);
hostsRef.current = sanitized;
setHosts(sanitized);
// Always re-encrypt the batch. Stale enc:v1 placeholders are left
// unchanged by encryptCredentialValue (no double-wrap); plaintext
// siblings still need encryption when safeStorage was previously
// unavailable for some records.
encryptHosts(sanitized).then((enc) => {
if (ver === hostsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
// unavailable for some records. Route through the locked writer so
// a concurrent snippet delete can prune login/connect bindings
// before this migration blob lands.
const encryptPromise = encryptHosts(sanitized);
hostsEncryptPendingRef.current = encryptPromise.then(() => undefined);
const writePromise = encryptPromise.then(async (enc) => {
if (ver !== hostsWriteVersion.current) return;
return commitEncryptedHostsUnderVaultLock(ver, sanitized, enc);
});
hostsWritePendingRef.current = writePromise;
}
} else {
updateHosts(INITIAL_HOSTS);
@@ -1163,8 +1369,43 @@ export const useVaultState = () => {
if (savedSnippets) {
const orderedSnippets = normalizeVaultOrder(savedSnippets);
snippetsRef.current = orderedSnippets;
setSnippets(orderedSnippets);
localStorageAdapter.write(STORAGE_KEY_SNIPPETS, orderedSnippets);
// Persist order backfill only when fields changed. Never rewrite the
// pre-lock snapshot unlocked: another renderer can delete under the
// vault lock after this read and before this write, resurrecting
// snippets while host-binding cleanup stays committed. Re-normalize
// the live disk catalog under the same lock as deleteSelectedSnippets.
const needsOrderPersist = orderedSnippets.some(
(snippet, index) => snippet !== savedSnippets[index],
);
if (needsOrderPersist) {
const ver = ++snippetsWriteVersion.current;
snippetsWriteOwnerRef.current = ver;
const writePromise = withVaultImportLock("vault", async () => {
if (snippetsWriteOwnerRef.current !== ver) return "superseded" as const;
const latest = normalizeVaultOrder(
readStoredArray<Snippet>(
STORAGE_KEY_SNIPPETS,
localStorageAdapter.readString(STORAGE_KEY_SNIPPETS),
),
);
const persisted = localStorageAdapter.write(STORAGE_KEY_SNIPPETS, latest);
if (!persisted) {
notify.error(
"Snippets could not be saved. Free some local storage space and try again.",
"Scripts",
);
return "failed" as const;
}
if (snippetsWriteOwnerRef.current === ver) {
snippetsRef.current = latest;
setSnippets(latest);
}
return "written" as const;
});
snippetsWritePendingRef.current = writePromise;
}
}
else updateSnippets(INITIAL_SNIPPETS);
@@ -1251,7 +1492,7 @@ export const useVaultState = () => {
return () => {
cancelled = true;
};
}, [updateHosts, updateSnippets]);
}, [commitEncryptedHostsUnderVaultLock, updateHosts, updateSnippets]);
useEffect(() => {
if (!isInitialized) return;
@@ -1356,9 +1597,44 @@ export const useVaultState = () => {
}
if (key === STORAGE_KEY_SNIPPETS) {
const next = safeParse<Snippet[]>(event.newValue) ?? [];
// StorageEvent.newValue is the value at fire time. A peer write can be
// delivered only after this window's replace already committed and
// cleared snippetsWriteReplaceRef — adopting that older payload would
// resurrect the pre-replacement catalog in memory (and on the next edit).
if (event.newValue !== localStorageAdapter.readString(STORAGE_KEY_SNIPPETS)) {
return;
}
const next = normalizeVaultOrder(safeParse<Snippet[]>(event.newValue) ?? []);
// Invalidate write-version readers, but do not clear snippetsWriteOwnerRef:
// an in-flight updateSnippets rebases onto this disk snapshot under the
// vault lock instead of being cancelled (which would drop local edits).
++snippetsWriteVersion.current;
setSnippets(normalizeVaultOrder(next));
const pendingBase = snippetsWriteBaseRef.current;
if (pendingBase !== null) {
// A queued local save still owns an outstanding ancestor. Replacing
// optimistic state with the remote snapshot (and clearing that base)
// would make the next local edit derive from remote-only and drop the
// first mutation when it supersedes the queued owner.
const merged = normalizeVaultOrder(
rebaseSnippetVaultWrite({
base: pendingBase,
ours: snippetsRef.current,
theirs: next,
}),
);
snippetsRef.current = merged;
setSnippets(merged);
return;
}
if (snippetsWriteReplaceRef.current) {
// Clear/restore/import left base null while replace is outstanding.
// Adopting the remote catalog here would pollute memory; a later local
// edit would still run in replace mode and overwrite disk with the old
// catalog plus that edit.
return;
}
snippetsRef.current = next;
setSnippets(next);
return;
}
@@ -1473,15 +1749,12 @@ export const useVaultState = () => {
hostsEncryptPendingRef.current = encryptPromise.then(() => undefined);
const writePromise = encryptPromise.then(async (enc) => {
if (ver !== hostsWriteVersion.current) return;
return withVaultImportLock("vault", async () => {
if (ver === hostsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
});
return commitEncryptedHostsUnderVaultLock(ver, next, enc);
});
hostsWritePendingRef.current = writePromise;
return next;
});
}, []);
}, [commitEncryptedHostsUnderVaultLock]);
const updateHostDistro = useCallback((hostId: string, distro: string) => {
const normalized = normalizeDistroId(distro);
@@ -1494,15 +1767,12 @@ export const useVaultState = () => {
hostsEncryptPendingRef.current = encryptPromise.then(() => undefined);
const writePromise = encryptPromise.then(async (enc) => {
if (ver !== hostsWriteVersion.current) return;
return withVaultImportLock("vault", async () => {
if (ver === hostsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
});
return commitEncryptedHostsUnderVaultLock(ver, next, enc);
});
hostsWritePendingRef.current = writePromise;
return next;
});
}, []);
}, [commitEncryptedHostsUnderVaultLock]);
const exportData = useCallback(
(): ExportableVaultData => ({
@@ -1528,7 +1798,13 @@ export const useVaultState = () => {
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));
if (payload.snippets) updateSnippets(payload.snippets);
if (payload.snippets) {
// Full-snapshot restore/import must replace, not additive-rebase; otherwise
// a concurrent disk-only snippet survives and the restore looks incomplete.
encryptedWrites.push(
updateSnippets(payload.snippets, { replace: true }).then(() => undefined),
);
}
if (payload.customGroups) updateCustomGroups(payload.customGroups);
if (payload.snippetPackages) updateSnippetPackages(payload.snippetPackages);
if (payload.notes) updateNotes(payload.notes);
@@ -1675,6 +1951,9 @@ export const useVaultState = () => {
[STORAGE_KEY_GROUP_CONFIGS, encryptedGroupConfigs],
]);
customGroupsRef.current = nextGroups;
snippetsRef.current = nextSnippets;
snippetsWriteBaseRef.current = null;
snippetsWriteReplaceRef.current = false;
setHosts(nextHosts);
setKeys(nextKeys);
setIdentities(nextIdentities);
@@ -1720,6 +1999,7 @@ export const useVaultState = () => {
updateIdentities,
updateProxyProfiles,
updateSnippets,
deleteSelectedSnippets,
updateSnippetPackages,
updateNotes,
updateNoteGroups,
@@ -6,6 +6,7 @@ import type { VaultOrderPosition } from '../../domain/vaultOrder';
export interface VaultHostTreeActions {
onDeleteHost: (host: Host) => void;
onDuplicateHost: (host: Host) => void;
onCopyHostname?: (host: Host) => void;
onCopyCredentials: (host: Host) => void;
onRenameHost: (host: Host) => void;
onNewGroup: (parentPath?: string) => void;
+7
View File
@@ -87,6 +87,7 @@ import {
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_AI_PROVIDERS,
@@ -298,6 +299,7 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_AI_PROVIDERS,
STORAGE_KEY_AI_ACTIVE_PROVIDER,
@@ -519,6 +521,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
if (showSftpTab != null) settings.showSftpTab = showSftpTab;
const shellOnlyTabNumberShortcuts = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
if (shellOnlyTabNumberShortcuts != null) settings.shellOnlyTabNumberShortcuts = shellOnlyTabNumberShortcuts;
const showTabNumberBadges = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_TAB_NUMBER_BADGES);
if (showTabNumberBadges != null) settings.showTabNumberBadges = showTabNumberBadges;
const disableTerminalFontZoom = localStorageAdapter.readBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM);
if (disableTerminalFontZoom != null) settings.disableTerminalFontZoom = disableTerminalFontZoom;
const showHostTreeSidebar = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
@@ -757,6 +761,9 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
if (settings.shellOnlyTabNumberShortcuts != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, settings.shellOnlyTabNumberShortcuts);
}
if (settings.showTabNumberBadges != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_TAB_NUMBER_BADGES, settings.showTabNumberBadges);
}
if (settings.disableTerminalFontZoom != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM, settings.disableTerminalFontZoom);
}
BIN
View File
Binary file not shown.
@@ -252,3 +252,51 @@ test('AI side panel skips re-render when only a sibling scope session object cha
false,
);
});
test('workspace AI panel memo follows visible inherited session, not hidden terminal maps', () => {
const visibleMemberChat = session({
id: 'chat-visible',
scope: { type: 'terminal', targetId: 'terminal-b', hostIds: ['host-b'] },
});
const hiddenFocusedChat = session({
id: 'chat-hidden',
scope: { type: 'terminal', targetId: 'terminal-other', hostIds: ['host-other'] },
});
const prev = baseProps({
isVisible: true,
scopeType: 'workspace',
scopeTargetId: 'ws-1',
focusedSessionId: 'terminal-a',
terminalSessions: [
{
sessionId: 'terminal-a',
hostId: 'host-a',
hostname: 'a.example',
label: 'A',
connected: true,
},
{
sessionId: 'terminal-b',
hostId: 'host-b',
hostname: 'b.example',
label: 'B',
connected: true,
},
],
activeSessionIdMap: {
'terminal:terminal-a': 'chat-hidden',
'terminal:terminal-b': 'chat-visible',
},
sessions: [visibleMemberChat, hiddenFocusedChat],
});
const streamedVisible = session({
id: 'chat-visible',
scope: { type: 'terminal', targetId: 'terminal-b', hostIds: ['host-b'] },
messages: [{ id: 'm1', role: 'assistant', content: 'stream', timestamp: 2 }],
});
assert.equal(
aiChatSidePanelPropsAreEqual(prev, { ...prev, sessions: [streamedVisible, hiddenFocusedChat] }),
false,
);
});
+98 -13
View File
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useDeferredValue, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Loader2 } from 'lucide-react';
import { useI18n } from '../application/i18n/I18nProvider';
import { useWindowControls } from '../application/state/useWindowControls';
@@ -30,9 +30,9 @@ import { subscribeUserSkillsStatusChanged } from './ai/userSkillsStatusEvents';
import {
applyDraftEntrySelection,
applyHistorySessionSelection,
panelViewsEqual,
resolveDisplayedPanelView,
resolveDisplayedSession,
shouldForceDraftViewSync,
} from './ai/aiPanelViewState';
import {
endSendForKey,
@@ -51,6 +51,7 @@ import {
type DefaultTargetSessionHint,
} from '../application/state/useAIChatStreaming';
import { getScopedHistorySessions } from './ai/scopedHistorySessions';
import { resolveInheritedAIActiveSessionId } from '../domain/aiWorkspaceScopeInherit';
import { aiSessionIdSetEqual, exactScopeAISessionsEqual } from '../domain/aiSessionsForScope';
import { buildExternalAgentHistoryMessagesForBridge } from './ai/externalAgentHistory';
import { canSendWithAgent, findEnabledExternalAgent } from './ai/agentSendEligibility';
@@ -286,6 +287,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
scopeTargetId,
scopeHostIds,
scopeLabel,
focusedSessionId,
terminalSessions = [],
resolveExecutorContext,
isVisible = true,
@@ -345,24 +347,48 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
return sessionIds;
}, [activeSessionIdMap, scopeKey]);
const deferredSessions = useDeferredValue(sessions);
const workspaceMemberTerminalIds = useMemo(() => {
if (scopeType !== 'workspace') return undefined;
return new Set(
terminalSessions
.map((session) => session.sessionId)
.filter((sessionId): sessionId is string => Boolean(sessionId)),
);
}, [scopeType, terminalSessions]);
// Use live sessions for history + view resolution. Deferring the list used to
// lag one paint behind createSession, so normalizePanelView treated the new
// chat as missing and the draft-sync effect forced showDraftView — which
// parked the just-sent session into history under StrictMode.
const historySessions = useMemo(
() => profileAIPanelCalculation(
'AIChatSidePanel.historySessions',
() => getScopedHistorySessions(
deferredSessions,
sessions,
scopeType,
scopeTargetId,
scopeHostIds,
activeTerminalSessionIds,
workspaceMemberTerminalIds,
),
),
[deferredSessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalSessionIds],
[sessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalSessionIds, workspaceMemberTerminalIds],
);
const explicitPanelView = panelViewByScope[scopeKey];
const currentDraft = draftsByScope[scopeKey] ?? null;
const persistedSessionId = activeSessionIdMap[scopeKey] ?? null;
const visibleHistorySessionIds = useMemo(
() => new Set(historySessions.map((session) => session.id)),
[historySessions],
);
const persistedSessionId = resolveInheritedAIActiveSessionId({
scopeType,
scopeTargetId,
activeSessionIdMap,
memberTerminalIds: terminalSessions.map((session) => session.sessionId).filter(Boolean),
preferredTerminalId: focusedSessionId,
visibleSessionIds: visibleHistorySessionIds,
});
const normalizedPanelView = useMemo<AIPanelView>(
() => resolveDisplayedPanelView(explicitPanelView, currentDraft != null, historySessions, persistedSessionId, scopeType),
[explicitPanelView, currentDraft, historySessions, persistedSessionId, scopeType],
@@ -424,9 +450,17 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
useEffect(() => {
if (!isVisible) return;
if (!explicitPanelView || panelViewsEqual(normalizedPanelView, explicitPanelView)) return;
// Predicate must match normalizePanelView's list (scoped history), not the
// global store — out-of-scope sessions must still demote to draft.
if (!shouldForceDraftViewSync(
explicitPanelView,
normalizedPanelView,
(sessionId) => historySessions.some((session) => session.id === sessionId),
)) {
return;
}
showDraftView(scopeKey);
}, [isVisible, normalizedPanelView, explicitPanelView, scopeKey, showDraftView]);
}, [isVisible, normalizedPanelView, explicitPanelView, scopeKey, historySessions, showDraftView]);
useEffect(() => {
if (!activeSession) return;
@@ -445,9 +479,24 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
useEffect(() => {
if (!isVisible) return;
if (normalizedPanelView.mode !== 'draft') return;
// Keep ownership while an explicit session is still displayable in this
// scope (normalize has not demoted it). Out-of-scope / missing ids clear.
if (
explicitPanelView?.mode === 'session'
&& historySessions.some((session) => session.id === explicitPanelView.sessionId)
) {
return;
}
if (persistedSessionId == null) return;
setActiveSessionId(null);
}, [isVisible, normalizedPanelView.mode, persistedSessionId, setActiveSessionId]);
}, [
isVisible,
normalizedPanelView.mode,
explicitPanelView,
historySessions,
persistedSessionId,
setActiveSessionId,
]);
const ensureScopeDraft = useCallback((agentId: string) => {
ensureDraftForScope(scopeKey, agentId);
@@ -1579,6 +1628,7 @@ export function aiChatSidePanelPropsAreEqual(
if (prev.scopeType !== next.scopeType) return false;
if (prev.scopeTargetId !== next.scopeTargetId) return false;
if (prev.scopeLabel !== next.scopeLabel) return false;
if ((prev.focusedSessionId ?? '') !== (next.focusedSessionId ?? '')) return false;
if ((prev.isVisible ?? true) !== (next.isVisible ?? true)) return false;
if (prev.scopeHostIds !== next.scopeHostIds) return false;
if (prev.terminalSessions !== next.terminalSessions) return false;
@@ -1597,10 +1647,45 @@ export function aiChatSidePanelPropsAreEqual(
// resume whose stored scope.targetId is an older terminal.
// Fuzzy history still receives the full list; drawer open forces re-render
// via isVisible / other prop paths when the user actually needs it.
const scopeKey = `${prev.scopeType}:${prev.scopeTargetId ?? ''}`;
const selectedSessionId = prev.activeSessionIdMap[scopeKey]
?? next.activeSessionIdMap[scopeKey]
?? null;
// Keep visibleSessionIds in sync with the live panel so inheritance that
// skips non-history chats does not leave memo pinned to a different id.
const resolveSelectedSessionId = (
props: AIChatSidePanelProps,
): string | null => {
const scopeKey = `${props.scopeType}:${props.scopeTargetId ?? ''}`;
const memberTerminalIds = (props.terminalSessions ?? [])
.map((session) => session.sessionId)
.filter((sessionId): sessionId is string => Boolean(sessionId));
const workspaceMemberTerminalIds = props.scopeType === 'workspace'
? new Set(memberTerminalIds)
: undefined;
const activeTerminalSessionIds = new Set<string>();
for (const [sessionScopeKey, sessionId] of Object.entries(props.activeSessionIdMap) as Array<[string, string | null]>) {
if (!sessionScopeKey.startsWith('terminal:') || !sessionId) continue;
if (sessionScopeKey === scopeKey) continue;
activeTerminalSessionIds.add(sessionId);
}
const visibleSessionIds = new Set(
getScopedHistorySessions(
props.sessions,
props.scopeType,
props.scopeTargetId,
props.scopeHostIds,
activeTerminalSessionIds,
workspaceMemberTerminalIds,
).map((session) => session.id),
);
return resolveInheritedAIActiveSessionId({
scopeType: props.scopeType,
scopeTargetId: props.scopeTargetId,
activeSessionIdMap: props.activeSessionIdMap,
memberTerminalIds,
preferredTerminalId: props.focusedSessionId,
visibleSessionIds,
});
};
const selectedSessionId = resolveSelectedSessionId(prev)
?? resolveSelectedSessionId(next);
if (!exactScopeAISessionsEqual(
prev.sessions,
next.sessions,
+2
View File
@@ -87,6 +87,8 @@ export interface AIChatSidePanelProps {
scopeTargetId?: string;
scopeHostIds?: string[];
scopeLabel?: string;
/** Workspace focused pane; preferred when inheriting AI chat after merge. */
focusedSessionId?: string;
// Terminal session context (from parent)
terminalSessions?: Array<{
+18 -1
View File
@@ -283,6 +283,7 @@ interface HostTreeViewProps {
onDuplicateHost: (host: Host) => void;
onDeleteHost: (host: Host) => void;
onCopyCredentials: (host: Host) => void;
onCopyHostname?: (host: Host) => void;
onNewGroup: (parentPath?: string) => void;
onRenameGroup: (groupPath: string) => void;
onEditGroup: (groupPath: string) => void;
@@ -322,6 +323,7 @@ interface TreeNodeProps {
onDuplicateHost: (host: Host) => void;
onDeleteHost: (host: Host) => void;
onCopyCredentials: (host: Host) => void;
onCopyHostname?: (host: Host) => void;
onNewGroup: (parentPath?: string) => void;
onRenameGroup: (groupPath: string) => void;
onEditGroup: (groupPath: string) => void;
@@ -367,6 +369,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
onDuplicateHost,
onDeleteHost,
onCopyCredentials,
onCopyHostname,
onNewGroup,
onRenameGroup,
onEditGroup,
@@ -577,6 +580,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
onDuplicateHost={onDuplicateHost}
onDeleteHost={onDeleteHost}
onCopyCredentials={onCopyCredentials}
onCopyHostname={onCopyHostname}
onNewGroup={onNewGroup}
onRenameGroup={onRenameGroup}
onEditGroup={onEditGroup}
@@ -619,6 +623,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
onDuplicateHost={onDuplicateHost}
onDeleteHost={onDeleteHost}
onCopyCredentials={onCopyCredentials}
onCopyHostname={onCopyHostname}
moveHostToGroup={moveHostToGroup}
isMultiSelectMode={isMultiSelectMode}
@@ -651,6 +656,7 @@ interface HostTreeItemProps {
onDuplicateHost: (host: Host) => void;
onDeleteHost: (host: Host) => void;
onCopyCredentials: (host: Host) => void;
onCopyHostname?: (host: Host) => void;
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
isMultiSelectMode?: boolean;
@@ -696,6 +702,7 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
onDuplicateHost,
onDeleteHost,
onCopyCredentials,
onCopyHostname,
moveHostToGroup: _moveHostToGroup,
isMultiSelectMode,
@@ -752,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,
@@ -849,7 +860,9 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
<HostTreeHostContextMenuContent
host={host}
onConnect={onConnect}
onEditHost={onEditHost}
onDuplicateHost={onDuplicateHost}
onCopyHostname={onCopyHostname}
onCopyCredentials={onCopyCredentials}
onDeleteHost={onDeleteHost}
/>
@@ -870,6 +883,7 @@ const HostTreeViewInner: React.FC<HostTreeViewProps> = ({
onDuplicateHost,
onDeleteHost,
onCopyCredentials,
onCopyHostname,
onNewGroup,
onRenameGroup,
onEditGroup,
@@ -1178,6 +1192,7 @@ const HostTreeViewInner: React.FC<HostTreeViewProps> = ({
onDuplicateHost={onDuplicateHost}
onDeleteHost={onDeleteHost}
onCopyCredentials={onCopyCredentials}
onCopyHostname={onCopyHostname}
onNewGroup={onNewGroup}
onRenameGroup={onRenameGroup}
onEditGroup={onEditGroup}
@@ -1218,6 +1233,7 @@ const HostTreeViewInner: React.FC<HostTreeViewProps> = ({
onDuplicateHost={onDuplicateHost}
onDeleteHost={onDeleteHost}
onCopyCredentials={onCopyCredentials}
onCopyHostname={onCopyHostname}
moveHostToGroup={moveHostToGroup}
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
@@ -1266,6 +1282,7 @@ function hostTreeViewAreEqual(prev: HostTreeViewProps, next: HostTreeViewProps):
&& prev.onDuplicateHost === next.onDuplicateHost
&& prev.onDeleteHost === next.onDeleteHost
&& prev.onCopyCredentials === next.onCopyCredentials
&& prev.onCopyHostname === next.onCopyHostname
&& prev.onNewGroup === next.onNewGroup
&& prev.onRenameGroup === next.onRenameGroup
&& prev.onEditGroup === next.onEditGroup
+23 -5
View File
@@ -11,6 +11,7 @@ import {
import React, { useCallback, useMemo, useRef, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { usePortForwardingState } from "../application/state/usePortForwardingState";
import { STORAGE_KEY_PORT_FORWARDING_PANEL_WIDTH } from "../infrastructure/config/storageKeys";
import {
GroupConfig,
Host,
@@ -94,6 +95,11 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
terminalSettings,
}) => {
const { t } = useI18n();
const portForwardingPanelResizeProps = {
resizable: true as const,
persistWidthStorageKey: STORAGE_KEY_PORT_FORWARDING_PANEL_WIDTH,
resizeAriaLabel: t("vault.panel.resizeWidth"),
};
const {
rules: _rules,
selectedRuleId,
@@ -312,6 +318,12 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
// Start new rule - wizard or form based on user preference
const startNewRule = (type: PortForwardingType) => {
setShowNewMenu(false);
// The new-rule flow replaces an open editor; never leave both side panels mounted.
setShowEditPanel(false);
setEditingRule(null);
setEditDraft({});
setSelectedRuleId(null);
setShowHostSelector(false);
if (preferFormMode) {
// Form mode: show all-in-one form
@@ -604,8 +616,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
{/* Main Content */}
<div
className={cn(
"flex-1 flex flex-col min-h-0",
showWizard || showEditPanel || showNewForm ? "mr-[360px]" : "",
"flex-1 min-w-0 flex flex-col min-h-0",
)}
>
<VaultPageHeader className="z-20">
@@ -773,7 +784,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
</div>
{/* Edit Panel - shown when a rule is selected */}
{showEditPanel && editingRule && (
{showEditPanel && editingRule && !showHostSelector && (
<EditPanel
rule={editingRule}
draft={editDraft}
@@ -789,11 +800,12 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
}}
onDelete={() => handleDeleteRule(editingRule)}
onOpenHostSelector={() => setShowHostSelector(true)}
{...portForwardingPanelResizeProps}
/>
)}
{/* Wizard Panel */}
{showWizard && (
{showWizard && !showHostSelector && (
<AsidePanel
open={true}
onClose={() => {
@@ -802,6 +814,8 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
}}
title={isEditing ? t("pf.wizard.editTitle") : t("pf.wizard.newTitle")}
width="w-[360px]"
layout="inline"
{...portForwardingPanelResizeProps}
showBackButton={!!getPrevStep()}
onBack={
getPrevStep()
@@ -902,11 +916,14 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
managedSources={managedSources}
onSaveHost={onSaveHost}
onCreateGroup={_onCreateGroup}
width="w-[360px]"
layout="inline"
{...portForwardingPanelResizeProps}
/>
)}
{/* New Form Panel (skip wizard mode) */}
{showNewForm && (
{showNewForm && !showHostSelector && (
<NewFormPanel
draft={newFormDraft}
hosts={hosts}
@@ -918,6 +935,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
onOpenHostSelector={() => setShowHostSelector(true)}
onOpenWizard={openWizardFromForm}
isValid={isNewFormValid()}
{...portForwardingPanelResizeProps}
/>
)}
+78 -4
View File
@@ -2,7 +2,10 @@ import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { buildScriptsSidePanelRows } from "./ScriptsSidePanel.tsx";
import {
buildScriptsSidePanelRows,
collectScriptsSidePanelPackagePaths,
} from "./ScriptsSidePanel.tsx";
import type { Snippet } from "../types";
const snippet = (overrides: Partial<Snippet>): Snippet => ({
@@ -50,10 +53,81 @@ test("scripts side panel offers run-on-all-tabs for every snippet, not only auto
assert.match(source, /openPackageDialog/);
});
test("scripts side panel add control is a split button with secondary create actions", () => {
assert.match(source, /ChevronDown/);
assert.match(source, /snippets\.action\.newScript/);
test("scripts side panel exposes create actions as inline toolbar icons", () => {
assert.match(source, /snippets\.action\.newSnippet/);
assert.match(source, /snippets\.action\.newPackage/);
assert.match(source, /snippets\.action\.newScript/);
assert.match(source, /openPackageDialog/);
assert.match(source, /handleAddScript/);
assert.doesNotMatch(source, /ChevronDown/);
assert.doesNotMatch(source, /DropdownTrigger/);
});
test("collectScriptsSidePanelPackagePaths includes implied ancestors", () => {
assert.deepEqual(
collectScriptsSidePanelPackagePaths(["ops/linux"], [
snippet({ id: "a", package: "ops/linux/disk" }),
snippet({ id: "b", package: "" }),
]).sort(),
["ops", "ops/linux", "ops/linux/disk"],
);
});
test("scripts side panel search is icon-toggled and expands below the toolbar", () => {
assert.match(source, /searchExpanded/);
assert.match(source, /setSearchExpanded/);
assert.match(source, /max-h-0 opacity-0/);
assert.match(source, /snippets\.searchPlaceholder/);
});
test("scripts side panel shows delete in a selection bar only when snippets are selected", () => {
assert.match(source, /selectedSnippetIds\.size > 0/);
assert.match(source, /snippets\.selection\.selected[\s\S]*?deleteSelectedSnippets/);
assert.match(source, /snippets\.selection\.deleteSelected/);
// Trash must not sit between multi-select and add on the permanent icon row.
assert.doesNotMatch(
source,
/snippets\.action\.selectSnippets[\s\S]*?Trash2[\s\S]*?snippets\.action\.newSnippet/,
);
});
test("scripts side panel toolbar exposes expand and collapse actions", () => {
assert.match(source, /vault\.tree\.expandAll/);
assert.match(source, /vault\.tree\.collapseAll/);
assert.match(source, /expandAllGroups/);
assert.match(source, /collapseAllGroups/);
assert.match(source, /isMultiSelectMode/);
});
test("scripts side panel confirms bulk delete and routes it through the shared delete event", () => {
// Mass-delete must confirm first (SnippetsManager parity) and must not bypass
// AppSideEffects via onSnippetsChange filtering — that path skips host binding cleanup.
assert.match(source, /VaultDeleteConfirmDialog/);
assert.match(source, /snippets\.selection\.deleteConfirmTitle/);
assert.match(source, /snippets\.selection\.deleteConfirmDesc/);
assert.match(source, /detail:\s*\{\s*ids\s*\}/);
assert.doesNotMatch(
source,
/onSnippetsChange\(snippets\.filter\(\(snippet\) => !selectedSnippetIds\.has/,
);
});
test("scripts side panel defers bulk-delete confirm when a parent owns the dialog", () => {
// Compact TerminalToolbar nests this panel in a Popover; the portalled confirm
// must be owned outside that tree so focus cannot unmount the prompt.
assert.match(source, /onBulkDeleteRequest\?:/);
assert.match(source, /if\s*\(\s*onBulkDeleteRequest\s*\)\s*\{/);
assert.match(source, /onBulkDeleteRequest\(ids\)/);
assert.match(source, /!onBulkDeleteRequest/);
});
test("scripts side panel clears pending bulk delete when the panel hides", () => {
// Returning null while isVisible is false unmounts the confirm dialog; drop
// pending deletes so a later re-show does not resurrect a half-dismissed prompt.
assert.match(
source,
/if\s*\(\s*!isVisible\s*\)\s*setPendingDeleteIds\(\s*null\s*\)/,
);
});
test("scripts side panel package dialog traps focus and exposes dialog close contract", () => {
+363 -93
View File
@@ -3,14 +3,30 @@
*
* Shows snippets organized by package hierarchy as a single tree view.
* Packages expand / collapse via a chevron; clicking a snippet executes it
* in the focused terminal session. Typing in the search box flattens to a
* list of matching snippets regardless of package nesting.
* in the focused terminal session. Search is icon-toggled (expands below the
* toolbar); typing flattens matches regardless of package nesting.
*/
import { ChevronDown, ChevronRight, Edit2, FolderPlus, Layers, Package, Play, Plus, Search, Trash2, Zap } from 'lucide-react';
import {
CheckSquare,
ChevronRight,
Edit2,
Expand,
FolderPlus,
Layers,
Minimize2,
Package,
Play,
Plus,
Search,
Trash2,
X,
Zap,
} from 'lucide-react';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { getScriptRecordingSnapshot, subscribeScriptRecording } from '../application/state/scriptRecordingStore.ts';
import { VaultDeleteConfirmDialog } from './vault/VaultDeleteConfirmDialog';
import { reorderVaultItems, reorderVaultStrings, sortByVaultOrder } from '../domain/vaultOrder';
import { isScriptSnippet } from '../domain/snippetScript.ts';
import { cn } from '../lib/utils';
@@ -24,7 +40,6 @@ import {
ContextMenuItem,
ContextMenuTrigger,
} from './ui/context-menu';
import { Dropdown, DropdownContent, DropdownTrigger } from './ui/dropdown';
import { FixedSizeVirtualList } from './ui/FixedSizeVirtualList';
import { Button } from './ui/button';
import { Input } from './ui/input';
@@ -33,6 +48,9 @@ import { SnippetCommandTooltipContent } from './snippets/SnippetCommandTooltipCo
import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from './terminalLayer/terminalSidePanelChrome';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
const toolbarIconButtonClass =
'h-7 w-7 shrink-0 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors disabled:opacity-40 disabled:pointer-events-none';
const SCRIPT_ROW_HEIGHT = 34;
const isRootPackagePath = (path: string): boolean => {
@@ -55,6 +73,12 @@ interface ScriptsSidePanelProps {
onResumeRun?: (runId: string) => void;
onStartRecording?: () => void;
focusedSessionId?: string;
/**
* When set, bulk-delete confirmation is owned by the parent. Required when
* this panel is nested in a Popover — the portalled confirm would otherwise
* steal focus, dismiss the popover, and unmount the prompt.
*/
onBulkDeleteRequest?: (ids: string[]) => void;
}
type TreeRow =
@@ -126,15 +150,11 @@ const getVerticalDropIntent = (
const hasDragType = (dataTransfer: DataTransfer, type: string) =>
Array.from(dataTransfer.types).includes(type);
export function buildScriptsSidePanelRows({
snippets,
packages,
expandedPaths,
}: {
snippets: Snippet[];
packages: string[];
expandedPaths: Set<string>;
}): TreeRow[] {
/** Collect every package path (including implied ancestors) shown in the tree. */
export function collectScriptsSidePanelPackagePaths(
packages: string[],
snippets: Snippet[],
): string[] {
const normalizedPackages = new Set<string>();
const addWithAncestors = (raw: string) => {
const path = raw.trim();
@@ -152,6 +172,19 @@ export function buildScriptsSidePanelRows({
snippets.forEach((snippet) => {
if (snippet.package) addWithAncestors(snippet.package);
});
return Array.from(normalizedPackages);
}
export function buildScriptsSidePanelRows({
snippets,
packages,
expandedPaths,
}: {
snippets: Snippet[];
packages: string[];
expandedPaths: Set<string>;
}): TreeRow[] {
const normalizedPackages = new Set(collectScriptsSidePanelPackagePaths(packages, snippets));
const snippetsByPackage = new Map<string, Snippet[]>();
const descendantCountByPackage = new Map<string, number>();
@@ -250,17 +283,30 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
onResumeRun,
onStartRecording,
focusedSessionId,
onBulkDeleteRequest,
}) => {
const { t } = useI18n();
const [search, setSearch] = useState('');
const [searchExpanded, setSearchExpanded] = useState(false);
const [subView, setSubView] = useState<'library' | 'running'>('library');
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);
const [selectedSnippetIds, setSelectedSnippetIds] = useState<Set<string>>(new Set());
const [pendingDeleteIds, setPendingDeleteIds] = useState<string[] | null>(null);
const [isPackageDialogOpen, setIsPackageDialogOpen] = useState(false);
const [newPackageName, setNewPackageName] = useState('');
const [packageError, setPackageError] = useState('');
const [addMenuOpen, setAddMenuOpen] = useState(false);
const packageDialogRef = useRef<HTMLDivElement>(null);
const packageNameInputRef = useRef<HTMLInputElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!searchExpanded) return;
const frame = requestAnimationFrame(() => {
searchInputRef.current?.focus();
});
return () => cancelAnimationFrame(frame);
}, [searchExpanded]);
useEffect(() => {
const handler = (event: Event) => {
@@ -290,24 +336,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
// (e.g. package "a/b/c" implies roots "a" and "a/b" even when not listed).
const normalizedPackages = useMemo(() => {
if (!isVisible) return new Set<string>();
const set = new Set<string>();
const addWithAncestors = (raw: string) => {
const path = raw.trim();
if (!path) return;
const isAbs = path.startsWith('/');
const body = isAbs ? path.slice(1) : path;
const parts = body.split('/').filter(Boolean);
for (let i = 1; i <= parts.length; i++) {
const sub = parts.slice(0, i).join('/');
set.add(isAbs ? `/${sub}` : sub);
}
};
packages.forEach(addWithAncestors);
// A snippet may reference a package path that's not in `packages` yet.
snippets.forEach((s) => {
if (s.package) addWithAncestors(s.package);
});
return set;
return new Set(collectScriptsSidePanelPackagePaths(packages, snippets));
}, [packages, snippets, isVisible]);
// Track every package we've ever observed so we can tell "new" from
@@ -348,6 +377,92 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
});
}, []);
const expandAllGroups = useCallback(() => {
setExpandedPaths(new Set(normalizedPackages));
}, [normalizedPackages]);
const collapseAllGroups = useCallback(() => {
setExpandedPaths(new Set());
}, []);
const clearSnippetSelection = useCallback(() => {
setSelectedSnippetIds(new Set());
setIsMultiSelectMode(false);
}, []);
useEffect(() => {
setSelectedSnippetIds((prev) => {
if (prev.size === 0) return prev;
const alive = new Set(snippets.map((snippet) => snippet.id));
let changed = false;
const next = new Set<string>();
prev.forEach((id) => {
if (alive.has(id)) next.add(id);
else changed = true;
});
return changed ? next : prev;
});
}, [snippets]);
useEffect(() => {
if (!isVisible) setPendingDeleteIds(null);
}, [isVisible]);
// Parent-owned confirm (compact toolbar popover) dispatches the shared delete
// event; clear local multi-select when that bulk delete lands.
useEffect(() => {
if (!onBulkDeleteRequest) return;
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ ids?: string[] }>).detail;
if (!detail?.ids?.length) return;
clearSnippetSelection();
};
window.addEventListener('netcatty:snippets:delete', handler);
return () => window.removeEventListener('netcatty:snippets:delete', handler);
}, [clearSnippetSelection, onBulkDeleteRequest]);
const toggleSnippetSelection = useCallback((id: string) => {
setSelectedSnippetIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const deleteSelectedSnippets = useCallback(() => {
const ids = snippets
.filter((snippet) => selectedSnippetIds.has(snippet.id))
.map((snippet) => snippet.id);
if (ids.length === 0) return;
if (onBulkDeleteRequest) {
onBulkDeleteRequest(ids);
return;
}
setPendingDeleteIds(ids);
}, [onBulkDeleteRequest, selectedSnippetIds, snippets]);
const existingPendingDeleteIds = pendingDeleteIds
? pendingDeleteIds.filter((id) => snippets.some((snippet) => snippet.id === id))
: [];
const confirmDeleteSelectedSnippets = useCallback(() => {
const ids = (pendingDeleteIds ?? []).filter((id) =>
snippets.some((snippet) => snippet.id === id),
);
setPendingDeleteIds(null);
if (ids.length === 0) {
clearSnippetSelection();
return;
}
// Always route through the shared event so AppSideEffects can clear host
// login/connect bindings (onSnippetsChange alone would leave them stale).
window.dispatchEvent(
new CustomEvent('netcatty:snippets:delete', { detail: { ids } }),
);
clearSnippetSelection();
}, [clearSnippetSelection, pendingDeleteIds, snippets]);
// When search is active, flatten everything (no tree, no packages).
const searchMatches = useMemo(() => {
if (!isVisible) return null;
@@ -400,6 +515,10 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
const handleSnippetClick = useCallback(
(snippet: Snippet) => {
if (isMultiSelectMode) {
toggleSnippetSelection(snippet.id);
return;
}
if (isScriptSnippet(snippet)) {
onRunScript?.(snippet);
setSubView('running');
@@ -407,9 +526,12 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
}
onSnippetClick(snippet);
},
[onRunScript, onSnippetClick],
[isMultiSelectMode, onRunScript, onSnippetClick, toggleSnippetSelection],
);
const hasSearch = search.trim().length > 0;
const canExpandCollapse = normalizedPackages.size > 0 && !hasSearch;
const sessionRuns = useMemo(() => {
if (!focusedSessionId) return runs;
return runs.filter((run) => run.sessionId === focusedSessionId);
@@ -757,82 +879,159 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
</div>
) : (
<>
{/* Search + Add */}
<div className="shrink-0 px-2 py-1.5 border-b border-border/50 flex items-center gap-1.5">
<div className="relative flex-1 min-w-0">
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('snippets.searchPlaceholder')}
className="h-7 pl-7 text-xs bg-muted/30 border-none"
/>
</div>
{/* Split add control: primary = new snippet; menu = package / automation script */}
<div className="shrink-0 flex items-center">
{/* Icon toolbar + expandable search */}
<div className="shrink-0 border-b border-border/50">
<div className="px-2 py-1.5 flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
toolbarIconButtonClass,
(searchExpanded || hasSearch) && 'bg-muted/70 text-foreground',
)}
aria-label={t('snippets.searchPlaceholder')}
aria-pressed={searchExpanded}
onClick={() => setSearchExpanded((open) => !open)}
>
<Search size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{t('snippets.searchPlaceholder')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={toolbarIconButtonClass}
disabled={!canExpandCollapse}
aria-label={t('vault.tree.expandAll')}
onClick={expandAllGroups}
>
<Expand size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{t('vault.tree.expandAll')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={toolbarIconButtonClass}
disabled={!canExpandCollapse}
aria-label={t('vault.tree.collapseAll')}
onClick={collapseAllGroups}
>
<Minimize2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{t('vault.tree.collapseAll')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
toolbarIconButtonClass,
isMultiSelectMode && 'bg-muted/70 text-foreground',
)}
aria-label={t('snippets.action.selectSnippets')}
aria-pressed={isMultiSelectMode}
onClick={() => {
if (isMultiSelectMode) {
clearSnippetSelection();
} else {
setIsMultiSelectMode(true);
}
}}
>
<CheckSquare size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{t('snippets.action.selectSnippets')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleAddSnippet}
aria-label={t('snippets.action.newSnippet')}
className="h-7 w-7 flex items-center justify-center rounded-l-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
className={cn(toolbarIconButtonClass, 'ml-auto')}
>
<Plus size={14} />
</button>
</TooltipTrigger>
<TooltipContent>{t('snippets.action.newSnippet')}</TooltipContent>
<TooltipContent side="bottom">{t('snippets.action.newSnippet')}</TooltipContent>
</Tooltip>
<Dropdown open={addMenuOpen} onOpenChange={setAddMenuOpen}>
<DropdownTrigger asChild>
<button
type="button"
aria-label={t('common.more')}
aria-haspopup="menu"
aria-expanded={addMenuOpen}
className="h-7 w-5 flex items-center justify-center rounded-r-md border-l border-border/40 text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
>
<ChevronDown size={12} />
</button>
</DropdownTrigger>
<DropdownContent className="w-48 p-1" align="end">
<button
type="button"
className="w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-md hover:bg-muted transition-colors"
onClick={() => {
setAddMenuOpen(false);
handleAddSnippet();
}}
>
<Zap size={12} className="text-muted-foreground" />
{t('snippets.action.newSnippet')}
</button>
{onPackagesChange ? (
{onPackagesChange ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-md hover:bg-muted transition-colors"
onClick={() => {
setAddMenuOpen(false);
openPackageDialog();
}}
onClick={openPackageDialog}
aria-label={t('snippets.action.newPackage')}
className={toolbarIconButtonClass}
>
<FolderPlus size={12} className="text-muted-foreground" />
{t('snippets.action.newPackage')}
<FolderPlus size={14} />
</button>
) : null}
</TooltipTrigger>
<TooltipContent side="bottom">{t('snippets.action.newPackage')}</TooltipContent>
</Tooltip>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-md hover:bg-muted transition-colors"
onClick={() => {
setAddMenuOpen(false);
handleAddScript();
}}
onClick={handleAddScript}
aria-label={t('snippets.action.newScript')}
className={toolbarIconButtonClass}
>
<Play size={12} className="text-primary" />
{t('snippets.action.newScript')}
<Play size={14} />
</button>
</DropdownContent>
</Dropdown>
</TooltipTrigger>
<TooltipContent side="bottom">{t('snippets.action.newScript')}</TooltipContent>
</Tooltip>
</div>
<div
className={cn(
'overflow-hidden transition-[max-height,opacity] duration-200 ease-out',
searchExpanded ? 'max-h-9 opacity-100' : 'max-h-0 opacity-0',
)}
>
<div className="h-9 flex items-center gap-0.5 px-2 border-t border-border/50">
<div className="relative flex-1 min-w-0">
<Search
size={12}
className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none"
/>
<Input
ref={searchInputRef}
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('snippets.searchPlaceholder')}
className="h-7 pl-7 text-xs bg-muted/30 border-none"
/>
</div>
{hasSearch ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={toolbarIconButtonClass}
aria-label={t('common.clear')}
onClick={() => {
setSearch('');
searchInputRef.current?.focus();
}}
>
<X size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{t('common.clear')}</TooltipContent>
</Tooltip>
) : null}
</div>
</div>
</div>
@@ -862,6 +1061,8 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
snippet={item.snippet}
depth={0}
subtitle={item.snippet.package || t('terminal.toolbar.library')}
selected={selectedSnippetIds.has(item.snippet.id)}
multiSelect={isMultiSelectMode}
draggable={false}
sortableTarget={false}
onDragOver={handleRowDragOver}
@@ -904,7 +1105,9 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
<SnippetRow
snippet={item.row.snippet}
depth={item.row.depth}
draggable={Boolean(onSnippetsChange)}
selected={selectedSnippetIds.has(item.row.snippet.id)}
multiSelect={isMultiSelectMode}
draggable={Boolean(onSnippetsChange) && !isMultiSelectMode}
sortableTarget={true}
onDragOver={handleRowDragOver}
onDrop={handleRowDrop}
@@ -931,6 +1134,43 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
/>
)}
</div>
{selectedSnippetIds.size > 0 ? (
<div className="shrink-0 px-2 py-1.5 border-t border-border/50 flex items-center gap-2">
<span className="flex-1 min-w-0 text-[11px] text-muted-foreground truncate">
{t('snippets.selection.selected', { count: selectedSnippetIds.size })}
</span>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(toolbarIconButtonClass, 'text-destructive hover:text-destructive')}
aria-label={t('snippets.selection.deleteSelected', {
count: selectedSnippetIds.size,
})}
onClick={deleteSelectedSnippets}
>
<Trash2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="top">
{t('snippets.selection.deleteSelected', { count: selectedSnippetIds.size })}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={toolbarIconButtonClass}
aria-label={t('snippets.selection.deselectAll')}
onClick={clearSnippetSelection}
>
<X size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="top">{t('snippets.selection.deselectAll')}</TooltipContent>
</Tooltip>
</div>
) : null}
<div className="shrink-0 px-2 py-2 border-t border-border/50 flex items-center gap-2">
<button
type="button"
@@ -1051,6 +1291,20 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
</div>
</div>
) : null}
{!onBulkDeleteRequest ? (
<VaultDeleteConfirmDialog
open={Boolean(pendingDeleteIds)}
title={t('snippets.selection.deleteConfirmTitle', {
count: existingPendingDeleteIds.length,
})}
description={t('snippets.selection.deleteConfirmDesc')}
onOpenChange={(open) => {
if (!open) setPendingDeleteIds(null);
}}
onConfirm={confirmDeleteSelectedSnippets}
/>
) : null}
</div>
</TooltipProvider>
);
@@ -1102,6 +1356,8 @@ interface SnippetRowProps {
snippet: Snippet;
depth: number;
subtitle?: string;
selected?: boolean;
multiSelect?: boolean;
draggable: boolean;
sortableTarget: boolean;
onDragOver: (event: React.DragEvent<HTMLElement>) => void;
@@ -1122,6 +1378,8 @@ const SnippetRow = memo<SnippetRowProps>(({
snippet,
depth,
subtitle,
selected = false,
multiSelect = false,
draggable,
sortableTarget,
onDragOver,
@@ -1157,13 +1415,25 @@ const SnippetRow = memo<SnippetRowProps>(({
<button
type="button"
onClick={onClick}
className="w-full flex items-center gap-1.5 pr-3 py-1.5 text-left hover:bg-accent/50 transition-colors overflow-hidden"
aria-pressed={multiSelect ? selected : undefined}
className={cn(
'w-full flex items-center gap-1.5 pr-3 py-1.5 text-left hover:bg-accent/50 transition-colors overflow-hidden',
selected && 'bg-primary/10 hover:bg-primary/15',
)}
style={{ paddingLeft: 8 + depth * 14 }}
>
{/* Hidden chevron column mirrors PackageRow's layout so the
snippet icon lines up exactly with the package icon above. */}
<ChevronRight size={12} className="shrink-0 opacity-0" aria-hidden />
{isScriptSnippet(snippet) ? (
{multiSelect ? (
<CheckSquare
size={12}
className={cn(
'shrink-0',
selected ? 'text-primary' : 'text-muted-foreground/70',
)}
/>
) : isScriptSnippet(snippet) ? (
<Play size={12} className="shrink-0 text-primary" />
) : (
<Zap size={12} className="shrink-0 text-muted-foreground" />
+3
View File
@@ -25,6 +25,7 @@ interface SelectHostPanelProps {
title?: string;
subtitle?: string;
className?: string;
width?: string;
layout?: AsidePanelLayout;
resizable?: boolean;
persistWidthStorageKey?: string;
@@ -50,6 +51,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
title,
subtitle,
className,
width,
layout = "overlay",
resizable = false,
persistWidthStorageKey,
@@ -79,6 +81,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
showBackButton={true}
onBack={onBack}
className={cn(layout === "overlay" && "z-40", newHostPanelOpen && "overflow-visible", className)}
width={width}
layout={layout}
resizable={resizable}
persistWidthStorageKey={persistWidthStorageKey}
+1 -1
View File
@@ -417,7 +417,7 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
aria-selected={multiSelect ? groupState === 'all' : false}
data-active={isActive ? 'true' : undefined}
className={cn(
'flex h-full min-h-0 items-center gap-2.5 overflow-hidden px-2.5 rounded-lg transition-colors',
'flex h-full min-h-0 items-center gap-2.5 overflow-hidden rounded-lg px-2.5 transition-colors',
isActive ? 'bg-primary/10 ring-1 ring-primary/40' : 'hover:bg-muted/70',
)}
onClick={() => {
+1 -1
View File
@@ -51,7 +51,7 @@ export const buildIssueUrl = (appInfo: AppInfo) => {
const installSource =
appInfo.version === "0.0.0"
? "Built from source (npm run dev / pack)"
: "GitHub Release (.dmg / .exe / .AppImage / .deb)";
: "GitHub Release (.dmg / .exe / .AppImage / .deb / .rpm / .pacman)";
params.set("install_source", installSource);
const ua = typeof navigator !== "undefined" ? navigator.userAgent : "unknown";
+2
View File
@@ -572,6 +572,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
setHotkeyScheme={settings.setHotkeyScheme}
shellOnlyTabNumberShortcuts={settings.shellOnlyTabNumberShortcuts}
setShellOnlyTabNumberShortcuts={settings.setShellOnlyTabNumberShortcuts}
showTabNumberBadges={settings.showTabNumberBadges}
setShowTabNumberBadges={settings.setShowTabNumberBadges}
disableTerminalFontZoom={settings.disableTerminalFontZoom}
setDisableTerminalFontZoom={settings.setDisableTerminalFontZoom}
keyBindings={settings.keyBindings}
+11
View File
@@ -128,6 +128,11 @@ interface SftpSidePanelProps {
onActiveExternalEditsChange?: (count: number) => void;
showWorkspaceHostHeader?: boolean;
isVisible?: boolean;
/**
* Side panel chrome still open for this terminal tab (another tool may be
* focused). Keeps browse SFTP sessions warm across History/System switches.
*/
ownerPanelOpen?: boolean;
renderOverlays?: boolean;
pendingUpload?: {
requestId: string;
@@ -177,6 +182,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
onActiveExternalEditsChange,
showWorkspaceHostHeader = false,
isVisible = true,
ownerPanelOpen = false,
renderOverlays = true,
pendingUpload = null,
onPendingUploadHandled,
@@ -258,8 +264,11 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
// becomes hidden, so its browse channel must stay alive until the editor closes.
// External editor temps (Notepad++ etc.) likewise need the session: parking
// calls closeSftp which deletes those local files.
// Keep browse warm while the side panel stays open on another tool
// (History / System / …) so switch-back does not reconnect + reload.
interactive: isBrowseSessionInteractive({
surfaceVisible: isVisible,
ownerPanelOpen,
hasOwnedEditorTab,
hasActiveExternalEdit: activeExternalEditCountRef.current > 0,
}),
@@ -275,6 +284,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
fileWatchHandlers,
hasOwnedEditorTab,
isVisible,
ownerPanelOpen,
transferOwnerId,
sftpUseCompressedUpload,
sftpShowHiddenFiles,
@@ -1861,6 +1871,7 @@ const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps):
prev.focusedSessionId === next.focusedSessionId &&
prev.showWorkspaceHostHeader === next.showWorkspaceHostHeader &&
prev.isVisible === next.isVisible &&
prev.ownerPanelOpen === next.ownerPanelOpen &&
prev.renderOverlays === next.renderOverlays &&
prev.pendingUpload?.requestId === next.pendingUpload?.requestId &&
prev.onPendingUploadHandled === next.onPendingUploadHandled &&
+3 -2
View File
@@ -126,8 +126,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
...fileWatchHandlers,
transferOwnerId: "main-sftp-view",
// Main SFTP page stays interactive while mounted so top-tab switches
// (e.g. Terminal ↔ SFTP) must not soft-close every tab's session;
// the terminal side panel still parks when its panel is hidden.
// (e.g. Terminal ↔ SFTP) must not soft-close every tab's session.
// The terminal side panel parks only after the panel is closed (not when
// switching History/System while the chrome stays open).
// Bulk transfers use dedicated pool sessions regardless.
interactive: true,
useCompressedUpload: sftpUseCompressedUpload,
+143 -21
View File
@@ -225,8 +225,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";
@@ -250,6 +258,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
snippets,
snippetPackages = [],
compactToolbar = false,
onDeleteSnippets,
lineTimestampsAvailable = true,
chainHosts = EMPTY_CHAIN_HOSTS,
appearanceTheme,
@@ -269,6 +278,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
sessionId,
workspaceId,
restoreState,
vaultInitializedOverride,
pendingInitialCwd,
shellType,
lastCwd,
@@ -358,10 +368,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);
@@ -774,7 +789,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const sudoHintRef = useRef<((active: boolean) => boolean) | undefined>(undefined);
const terminalBackend = useTerminalBackend();
const { startManualSessionLog, stopManualSessionLog, getManualSessionLogStatus } = useSessionLogBackend();
const {
chooseManualSessionLogPath,
startManualSessionLog,
stopManualSessionLog,
getManualSessionLogStatus,
} = useSessionLogBackend();
const {
resizeSession,
receiveSerialYmodem,
@@ -2505,9 +2525,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
? []
@@ -2536,6 +2566,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
})) {
connectScriptsConsumedRef.current = true;
}
if (connectScriptsBatchRef.current === batch) {
connectScriptsBatchRef.current = null;
connectScriptsInFlightRef.current = false;
}
return;
}
@@ -2544,11 +2578,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,
@@ -2556,6 +2592,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);
}
@@ -2569,6 +2606,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
},
})
.then(() => {
if (!batchStillActive()) return;
const resolvedAfterRun = resolveConnectScriptsForHost(host, snippets);
const doneAfterRun = resolvedAfterRun.length === 0
|| resolvedAfterRun.every(
@@ -2579,6 +2617,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;
@@ -2594,6 +2633,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,
@@ -2601,6 +2644,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
username: host.username,
},
onScriptComplete: (snippet) => {
if (!batchStillActive()) return;
if (snippet.id) {
pendingScriptRunIdRef.current = snippet.id;
} else {
@@ -2609,16 +2653,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(() => {
@@ -3346,20 +3406,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;
@@ -3370,6 +3418,59 @@ 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();
} catch (error) {
@@ -3559,13 +3660,31 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return;
}
const startResult = await startManualSessionLog({
// Choose the destination first, then re-sample terminal state. The save
// dialog can stay open long enough for the user to enter/leave vim, so
// alternate-screen / initial-line must be captured at stream activation.
const chooseResult = await chooseManualSessionLogPath({
sessionId: currentSessionId,
sessionName: host.label || host.hostname || currentSessionId,
preferredDirectory: sessionLog?.directory,
format: sessionLog?.format,
});
if (!chooseResult?.success) {
toast.error(chooseResult?.error || "Failed to start session log");
return;
}
if (chooseResult.canceled || !chooseResult.selectionToken) return;
const startResult = await startManualSessionLog({
sessionId: currentSessionId,
sessionName: host.label || host.hostname || currentSessionId,
selectionToken: chooseResult.selectionToken,
format: sessionLog?.format,
timestampsEnabled: sessionLog?.timestampsEnabled,
initialLine: termRef.current ? getSessionLogInitialLine(termRef.current) : "",
alternateScreenActive: termRef.current
? isTerminalAlternateScreenActive(termRef.current)
: false,
});
if (startResult?.success) {
if (!startResult?.started && startResult?.canceled) return;
@@ -3578,6 +3697,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
toast.error("Failed to toggle session log");
}
}, [
chooseManualSessionLogPath,
getManualSessionLogStatus,
host.hostname,
host.label,
@@ -3631,6 +3751,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
snippets={snippets}
snippetPackages={snippetPackages}
onSnippetClick={(snippet) => { void executeSnippet(snippet); }}
onDeleteSnippets={onDeleteSnippets}
onOpenSFTP={handleOpenSFTP}
onSendYmodem={isSerialConnection ? handleSendYmodem : undefined}
onReceiveYmodem={isSerialConnection ? handleReceiveYmodem : undefined}
@@ -3694,6 +3815,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
isSessionLogging,
isWorkspaceComposeBarOpen,
onCloseSession,
onDeleteSnippets,
onOpenScripts,
onOpenHistory,
onOpenTheme,
+26 -2
View File
@@ -93,6 +93,7 @@ import {
shouldCloseSftpSidePanel,
shouldClearSftpPanelAfterTransferChange,
shouldKeepSftpMountedAfterClose,
shouldMarkSftpPaneClosed,
shouldScheduleSftpRetainedPanelCleanup,
terminalSftpTransferOwnerId,
} from './terminalLayer/sftpPanelLifecycle';
@@ -219,6 +220,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onCreateWorkspaceFromSessions,
onAddSessionToWorkspace,
onRequestAddToWorkspace,
onAppendHostToWorkspace,
onUpdateSplitSizes,
onSetDraggingSessionId,
onToggleWorkspaceViewMode,
@@ -268,6 +270,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
setSidePanelOpenTabs,
sidePanelOpenTabsRef,
sidePanelLayouts,
setSidePanelLayouts,
sidePanelLayoutsRef,
focusPane: focusSidePanelPaneForTab,
splitPane: splitSidePanelPaneForTab,
@@ -410,6 +413,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
lastSidePanelTabRef.current.set(tabId, targetPanel);
if (targetPanel === 'sftp') {
sftpPaneClosedTabIdsRef.current.delete(tabId);
const host = hostsRef.current.find(h => h.id === session.hostId);
const hostWithOverrides: Host = host
? {
@@ -577,6 +581,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const sftpActiveTransfersByTabRef = useRef<Map<string, number>>(new Map());
const sftpActiveExternalEditsByTabRef = useRef<Map<string, number>>(new Map());
const sftpRetainedAfterCloseTabIdsRef = useRef<Set<string>>(new Set());
const sftpPaneClosedTabIdsRef = useRef<Set<string>>(new Set());
const sftpOpeningTabIdsRef = useRef<Set<string>>(new Set());
const sftpRetainedCleanupTimersRef = useRef<Map<string, number>>(new Map());
const sftpLastPathForSourceRef = useRef<Map<string, SftpRememberedLocation>>(new Map());
@@ -612,6 +617,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sftpActiveTransfersByTabRef.current.delete(tabId);
sftpActiveExternalEditsByTabRef.current.delete(tabId);
sftpRetainedAfterCloseTabIdsRef.current.delete(tabId);
sftpPaneClosedTabIdsRef.current.delete(tabId);
sftpOpeningTabIdsRef.current.delete(tabId);
setSftpHostForTab(prev => {
if (!prev.has(tabId)) return prev;
@@ -829,6 +835,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sftpRetainedCleanupTimersRef.current.delete(tabId);
}
sftpRetainedAfterCloseTabIdsRef.current.delete(tabId);
sftpPaneClosedTabIdsRef.current.delete(tabId);
setSidePanelOpenTabs(prev => {
const next = new Map(prev);
@@ -1328,6 +1335,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sftpRetainedCleanupTimersRef.current.delete(tabId);
}
sftpRetainedAfterCloseTabIdsRef.current.delete(tabId);
sftpPaneClosedTabIdsRef.current.delete(tabId);
}
// If switching to SFTP and no host is stored yet, resolve it
@@ -1409,10 +1417,22 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const handleCloseSidePanelPane = useCallback((paneId: string) => {
const tabId = activeTabIdRef.current;
if (!tabId) return;
if (closeSidePanelPaneForTab(tabId, paneId)) {
const layout = sidePanelLayoutsRef.current.get(tabId);
const closingPane = layout
? collectSidePanelPanes(layout.root).find((pane) => pane.id === paneId)
: undefined;
const closesWholePanel = closeSidePanelPaneForTab(tabId, paneId);
if (closesWholePanel) {
handleCloseSidePanel();
return;
}
}, [closeSidePanelPaneForTab, handleCloseSidePanel]);
if (shouldMarkSftpPaneClosed({
closingPaneTool: closingPane?.tool,
closesWholePanel: closesWholePanel,
})) {
sftpPaneClosedTabIdsRef.current.add(tabId);
}
}, [closeSidePanelPaneForTab, handleCloseSidePanel, sidePanelLayoutsRef]);
const handleResizeSidePanelSplit = useCallback((splitId: string, sizes: number[]) => {
const tabId = activeTabIdRef.current;
@@ -2088,6 +2108,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onCopySession,
onCopySessionToNewWindow,
onRequestAddToWorkspace,
onAppendHostToWorkspace,
onSessionData,
onSetDraggingSessionId,
onSetWorkspaceFocusedSession,
@@ -2136,6 +2157,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
setSystemMountedTabIds,
setThemeMountedTabIds,
setSidePanelOpenTabs,
setSidePanelLayouts,
setSidePanelWidth,
setSftpFollowTerminalCwd,
setSftpHostForTab,
@@ -2153,6 +2175,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sftpHostForTab,
sftpInitialLocationForTab,
sftpPendingUploadsForTab,
sftpPaneClosedTabIdsRef,
sftpRetainedAfterCloseTabIdsRef,
sftpShowHiddenFiles,
SftpSidePanel,
sftpUseCompressedUpload,
+7
View File
@@ -133,9 +133,16 @@ test('resolveTerminalPopupReuseId uses the explicit reuse id from the prepared s
test('popup terminals resolve complete host config and pass jump hosts into Terminal', () => {
assert.match(source, /proxyProfiles,\s+knownHosts,\s+snippets,\s+snippetPackages,\s+groupConfigs,/);
assert.match(source, /deleteSelectedSnippets,/);
assert.match(source, /resolveTerminalPopupHost\(config,\s*hosts,\s*\{\s+groupConfigs,\s+proxyProfiles,/);
assert.match(source, /resolveTerminalChainHosts\(\{\s+host,\s+hosts,\s+groupConfigs,\s+proxyProfiles,/);
assert.match(source, /chainHosts=\{chainHosts\}/);
// Popup has no AppSideEffects listener; bulk delete must hit this vault instance.
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', () => {
+3
View File
@@ -272,6 +272,7 @@ function TerminalPopupPageInner() {
snippetPackages,
groupConfigs,
updateKnownHosts,
deleteSelectedSnippets,
} = useVaultState();
const [config, setConfig] = useState<TerminalPopupPayload | null>(null);
const [terminalReady, setTerminalReady] = useState(false);
@@ -453,6 +454,7 @@ function TerminalPopupPageInner() {
snippetPackages={snippetPackages}
chainHosts={chainHosts}
compactToolbar
onDeleteSnippets={deleteSelectedSnippets}
lineTimestampsAvailable={false}
knownHosts={effectiveKnownHosts}
onAddKnownHost={handleAddKnownHost}
@@ -465,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"],
+89 -5
View File
@@ -1,11 +1,17 @@
import { Folder, FolderLock, Menu, MoreHorizontal, Plus, Settings, Sparkles } from 'lucide-react';
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { fromEditorTabId, isEditorTabId, useActiveTabId } from '../application/state/activeTabStore';
import { fromEditorTabId, isEditorTabId, toEditorTabId, useActiveTabId } from '../application/state/activeTabStore';
import { topTabsSessionsEqual } from '../domain/topTabsSessionsEqual';
import { isHostTreeWorkTabSurface } from '../application/app/workTabSurface';
import { buildTabShortcutNumberById } from '../application/app/tabShortcutTargets';
import { useShortcutModifierHeld } from '../application/state/useShortcutModifierHeld';
import type { EditorTabChrome } from '../application/state/editorTabStore';
import { collectSessionIds } from '../domain/workspace';
import type { DynamicTabTitleMode } from '../domain/models';
import {
appendHostFromWorkspaceDrop,
resolveFocusSidebarDragKind,
} from '../domain/focusSidebarHostDrop';
import type { DynamicTabTitleMode, KeyBinding } from '../domain/models';
import { getTopTabInsertionTarget, getWorkspaceSessionDragId, hasWorkspaceSessionDrag } from '../application/state/terminalDragData';
import {
@@ -15,6 +21,7 @@ import {
} from '../application/state/terminalHostTreeStore';
import type { LogView } from '../application/state/logViewState';
import { useWindowControls } from '../application/state/useWindowControls';
import { useSettingsChromeStore } from '../application/state/settingsChromeStore';
import { useI18n } from '../application/i18n/I18nProvider';
import { Host, TerminalSession, Workspace } from '../types';
import { cn } from '../lib/utils';
@@ -153,8 +160,10 @@ 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;
dynamicTabTitleMode?: DynamicTabTitleMode;
editorTabs: readonly EditorTabChrome[];
pluginViewTabs: readonly PluginViewTab[];
@@ -197,8 +206,10 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onEndSessionDrag,
onReorderTabs,
onRemoveSessionFromWorkspace,
onAppendHostToWorkspace,
showSftpTab,
showHostTreeSidebar,
switchTabKeyBinding,
dynamicTabTitleMode,
editorTabs,
pluginViewTabs,
@@ -208,6 +219,28 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
}) => {
const { t } = useI18n();
const { maximize, isFullscreen, onFullscreenChanged } = useWindowControls();
const {
hotkeyScheme,
showTabNumberBadges,
shellOnlyTabNumberShortcuts,
} = useSettingsChromeStore();
const switchTabKey = hotkeyScheme === 'mac'
? switchTabKeyBinding?.mac ?? null
: hotkeyScheme === 'pc'
? switchTabKeyBinding?.pc ?? null
: null;
const shortcutModifierHeld = useShortcutModifierHeld(switchTabKey, hotkeyScheme);
const tabShortcutNumbers = useMemo(() => {
// Keep the tab bar quiet until the modifier for the active shortcut scheme
// is held, while retaining the existing setting as the master toggle.
if (!showTabNumberBadges || hotkeyScheme === 'disabled' || !shortcutModifierHeld) return null;
return buildTabShortcutNumberById({
showSftpTab,
shellOnlyTabNumberShortcuts,
orderedTabs,
editorTabIds: editorTabs.map((tab) => toEditorTabId(tab.id)),
});
}, [hotkeyScheme, showTabNumberBadges, showSftpTab, shellOnlyTabNumberShortcuts, shortcutModifierHeld, orderedTabs, editorTabs]);
const isHostTreeOpen = useTerminalHostTreeOpen();
const hostTreeLayoutWidth = useTerminalHostTreeLayoutWidth();
const toggleHostTree = useToggleTerminalHostTree();
@@ -233,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);
@@ -494,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);
@@ -517,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);
@@ -555,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;
@@ -752,6 +826,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onTabDragLeave={handleTabDragLeave}
onTabDrop={handleTabDrop}
tabAnimationClass={getTabAnimationClass(tabId)}
shortcutNumber={tabShortcutNumbers?.get(tabId)}
/>
);
}
@@ -776,6 +851,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onTabDragLeave={handleTabDragLeave}
onTabDrop={handleTabDrop}
tabAnimationClass={getTabAnimationClass(tabId)}
shortcutNumber={tabShortcutNumbers?.get(tabId)}
/>
);
}
@@ -811,6 +887,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
dynamicTabTitleMode={dynamicTabTitleMode}
t={t}
tabAnimationClass={getTabAnimationClass(session.id)}
shortcutNumber={tabShortcutNumbers?.get(session.id)}
/>
);
}
@@ -842,6 +919,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
shiftStyle={shiftStyle}
showDropIndicatorBefore={showDropIndicatorBefore}
showDropIndicatorAfter={showDropIndicatorAfter}
isHostDropTarget={hostDropWorkspaceId === workspace.id}
onTabDragStart={handleTabDragStart}
onTabDragEnd={handleTabDragEnd}
onTabDragOver={handleTabDragOver}
@@ -854,6 +932,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
renderBulkCloseItems={renderBulkCloseItems}
t={t}
tabAnimationClass={getTabAnimationClass(workspace.id)}
shortcutNumber={tabShortcutNumbers?.get(workspace.id)}
/>
);
}
@@ -882,6 +961,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onTabDrop={handleTabDrop}
t={t}
tabAnimationClass={getTabAnimationClass(logView.id)}
shortcutNumber={tabShortcutNumbers?.get(logView.id)}
/>
);
}
@@ -933,6 +1013,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
icon={<FolderLock size={14} />}
className="rounded"
compact={rootTabsCompact}
shortcutNumber={tabShortcutNumbers?.get('vault')}
/>
{showSftpTab && (
<RootTopTab
@@ -941,6 +1022,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
icon={<Folder size={14} />}
className="rounded-t-md"
compact={rootTabsCompact}
shortcutNumber={tabShortcutNumbers?.get('sftp')}
/>
)}
</div>
@@ -1174,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 &&
@@ -1186,6 +1269,7 @@ export const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean
prev.onThemeChange === next.onThemeChange &&
prev.showSftpTab === next.showSftpTab &&
prev.showHostTreeSidebar === next.showHostTreeSidebar &&
prev.switchTabKeyBinding === next.switchTabKeyBinding &&
prev.dynamicTabTitleMode === next.dynamicTabTitleMode &&
prev.hostById === next.hostById
);
+19 -18
View File
@@ -70,7 +70,7 @@ export const TrafficDiagram: React.FC<TrafficDiagramProps> = ({ type, isAnimatin
<div className="relative w-full h-48 flex items-center justify-center overflow-hidden rounded-xl bg-secondary/60 border border-border/50">
{/* ========== LOCAL FORWARDING ========== */}
{type === 'local' && (
<div className="relative w-full h-full">
<div className="relative w-[300px] max-w-full aspect-[300/170]">
{/* App Logo - left (same line as firewall) */}
<div className={`absolute left-6 top-5 z-10 transition-opacity duration-300 ${getOpacity('app')}`}>
<AppLogo className="h-12 w-12" />
@@ -95,21 +95,21 @@ export const TrafficDiagram: React.FC<TrafficDiagramProps> = ({ type, isAnimatin
</div>
{/* SVG Lines */}
<svg className="absolute inset-0 w-full h-full" style={{ zIndex: 1 }}>
<svg className="absolute inset-0 w-full h-full" viewBox="0 0 300 170" preserveAspectRatio="none" style={{ zIndex: 1 }}>
{/* App to Firewall - blocked red line (with gap) */}
<AnimatedLine x1={78} y1={40} x2={127} y2={40} isAnimating={false} isBlocked />
<AnimatedLine x1={78} y1={44} x2={122} y2={44} isAnimating={false} isBlocked />
{/* App to SSH Server - blue animated (with gap) */}
<AnimatedLine x1={78} y1={58} x2={145} y2={138} isAnimating={isAnimating} />
<AnimatedLine x1={76} y1={64} x2={126} y2={110} isAnimating={isAnimating} />
{/* SSH Server to targets - blue animated (with gap) */}
<AnimatedLine x1={178} y1={148} x2={238} y2={58} isAnimating={isAnimating} />
<AnimatedLine x1={178} y1={152} x2={238} y2={88} isAnimating={isAnimating} />
<AnimatedLine x1={174} y1={112} x2={242} y2={28} isAnimating={isAnimating} />
<AnimatedLine x1={178} y1={120} x2={242} y2={68} isAnimating={isAnimating} />
</svg>
</div>
)}
{/* ========== REMOTE FORWARDING ========== */}
{type === 'remote' && (
<div className="relative w-full h-full">
<div className="relative w-[300px] max-w-full aspect-[300/170]">
{/* Left Server - the remote SSH server where port will be opened */}
<div className={`absolute left-6 top-5 z-10 transition-opacity duration-300 ${getOpacity('ssh-server')}`}>
<ServerIcon className="h-10 w-10" />
@@ -131,20 +131,20 @@ export const TrafficDiagram: React.FC<TrafficDiagramProps> = ({ type, isAnimatin
</div>
{/* SVG Lines */}
<svg className="absolute inset-0 w-full h-full" style={{ zIndex: 1 }}>
<svg className="absolute inset-0 w-full h-full" viewBox="0 0 300 170" preserveAspectRatio="none" style={{ zIndex: 1 }}>
{/* Left server to Firewall - blocked (with gap) */}
<AnimatedLine x1={68} y1={38} x2={128} y2={38} isAnimating={false} isBlocked />
<AnimatedLine x1={70} y1={40} x2={122} y2={40} isAnimating={false} isBlocked />
{/* Left server to App - blue animated (with gap) */}
<AnimatedLine x1={58} y1={58} x2={145} y2={135} isAnimating={isAnimating} />
<AnimatedLine x1={68} y1={58} x2={124} y2={128} isAnimating={isAnimating} />
{/* Right server to App - blue animated (with gap) */}
<AnimatedLine x1={262} y1={58} x2={175} y2={135} isAnimating={isAnimating} reverse />
<AnimatedLine x1={250} y1={58} x2={176} y2={128} isAnimating={isAnimating} reverse />
</svg>
</div>
)}
{/* ========== DYNAMIC FORWARDING ========== */}
{type === 'dynamic' && (
<div className="relative w-full h-full">
<div className="relative w-[300px] max-w-full aspect-[300/170]">
{/* App Logo - left (same line as firewall) */}
<div className={`absolute left-6 top-5 z-10 transition-opacity duration-300 ${getOpacity('app')}`}>
<AppLogo className="h-12 w-12" />
@@ -175,15 +175,16 @@ export const TrafficDiagram: React.FC<TrafficDiagramProps> = ({ type, isAnimatin
</div>
{/* SVG Lines */}
<svg className="absolute inset-0 w-full h-full" style={{ zIndex: 1 }}>
<svg className="absolute inset-0 w-full h-full" viewBox="0 0 300 170" preserveAspectRatio="none" style={{ zIndex: 1 }}>
{/* App to Firewall - blocked (with gap) */}
<AnimatedLine x1={78} y1={42} x2={128} y2={42} isAnimating={false} isBlocked />
<AnimatedLine x1={78} y1={44} x2={122} y2={44} isAnimating={false} isBlocked />
{/* App to SSH Server - blue animated (with gap) */}
<AnimatedLine x1={78} y1={58} x2={145} y2={138} isAnimating={isAnimating} />
<AnimatedLine x1={76} y1={64} x2={126} y2={110} isAnimating={isAnimating} />
{/* SSH Server to clouds - blue animated (with gap) */}
<AnimatedLine x1={178} y1={142} x2={238} y2={42} isAnimating={isAnimating} />
<AnimatedLine x1={178} y1={148} x2={238} y2={72} isAnimating={isAnimating} />
<AnimatedLine x1={178} y1={155} x2={238} y2={148} isAnimating={isAnimating} />
<AnimatedLine x1={174} y1={112} x2={246} y2={26} isAnimating={isAnimating} />
<AnimatedLine x1={178} y1={120} x2={246} y2={58} isAnimating={isAnimating} />
<AnimatedLine x1={178} y1={128} x2={246} y2={90} isAnimating={isAnimating} />
<AnimatedLine x1={174} y1={128} x2={234} y2={130} isAnimating={isAnimating} />
</svg>
</div>
)}
+25
View File
@@ -61,6 +61,7 @@ import {
} from "../domain/groupConfig";
import {
getEffectiveHostDistro,
getHostAddressForClipboard,
resolveTelnetPassword,
resolveTelnetPort,
resolveTelnetUsername,
@@ -728,6 +729,28 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
}
}, [hosts, keys, t]);
// Copy hostname/IP for cross-host paste without opening the editor
const handleCopyHostname = useCallback(
(host: Host) => {
const hostname = getHostAddressForClipboard(host);
if (!hostname) {
toast.error(t("terminal.statusbar.copyHostname.error"));
return;
}
void navigator.clipboard.writeText(hostname).then(
() => {
toast.success(
t("terminal.statusbar.copyHostname.toast", { hostname }),
);
},
() => {
toast.error(t("terminal.statusbar.copyHostname.error"));
},
);
},
[t],
);
// Copy host credentials to clipboard
const handleCopyCredentials = useCallback(
(host: Host) => {
@@ -1319,6 +1342,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
useRegisterVaultHostTreeActions({
handleCopyCredentials,
handleCopyHostname,
handleDuplicateHost,
startInlineRenameHost,
onDeleteHost,
@@ -1443,6 +1467,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
groupedDisplayHosts,
handleConnectClick,
handleCopyCredentials,
handleCopyHostname,
handleDeleteTag,
handleDuplicateHost,
handleEditGroupConfig,
+7 -15
View File
@@ -38,24 +38,16 @@ test("resolved approval state retains only tool calls still present in messages"
assert.deepEqual([...next], [["live-call", false]]);
});
test("assistant content stays plain when markdown is hidden or streaming", () => {
test("assistant content stays plain only when markdown is hidden", () => {
assert.equal(shouldRenderAssistantAsPlainText({
hideMarkdown: false,
}), false);
assert.equal(shouldRenderAssistantAsPlainText({
hideMarkdown: true,
}), true);
assert.equal(shouldRenderAssistantAsPlainText({
hideMarkdown: false,
isStreaming: true,
}), true);
assert.equal(shouldRenderAssistantAsPlainText({
hideMarkdown: false,
isStreaming: false,
}), false);
});
test("ChatMessageList renders plain text for the streaming assistant message", () => {
test("ChatMessageList renders Streamdown for the streaming assistant message", () => {
const messages: ChatMessage[] = [
{
id: "user-1",
@@ -83,16 +75,16 @@ test("ChatMessageList renders plain text for the streaming assistant message", (
),
);
assert.match(markup, /data-ai-content="plain"/);
assert.match(markup, /data-ai-content="markdown"/);
assert.match(markup, /streaming-body/);
assert.doesNotMatch(markup, /data-ai-content="markdown"/);
assert.doesNotMatch(markup, /data-ai-content="plain"/);
});
test("streaming assistant content skips Streamdown while the turn is live", () => {
test("streaming assistant content keeps Streamdown live with isAnimating", () => {
const source = readFileSync(new URL("./ChatMessageList.tsx", import.meta.url), "utf8");
assert.match(source, /isStreaming: !!isThisStreaming/);
assert.match(source, /shouldRenderAssistantAsPlainText\(\{[\s\S]*?isStreaming/);
assert.match(source, /isAnimating=\{!!isThisStreaming\}/);
assert.doesNotMatch(source, /isStreaming: !!isThisStreaming/);
});
test("ChatMessageList hydrates markdown after streaming settles", () => {
+5 -7
View File
@@ -113,9 +113,10 @@ export function shouldProvideVaultArtifactNavigation({
export function shouldRenderAssistantAsPlainText(options: {
hideMarkdown: boolean;
isStreaming?: boolean;
}): boolean {
return options.hideMarkdown || !!options.isStreaming;
// Streaming stays on Streamdown with isAnimating so incomplete markdown
// updates live. Only diagnostic hideMarkdown forces plain text.
return options.hideMarkdown;
}
const ASSISTANT_PLAIN_TEXT_CLASS = 'whitespace-pre-wrap break-words text-[13px] leading-[1.45]';
@@ -701,10 +702,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
{message.content && (
isUser
? <div className={ASSISTANT_PLAIN_TEXT_CLASS}>{message.content}</div>
: shouldRenderAssistantAsPlainText({
hideMarkdown,
isStreaming: !!isThisStreaming,
})
: shouldRenderAssistantAsPlainText({ hideMarkdown })
? (
<div
className={ASSISTANT_PLAIN_TEXT_CLASS}
@@ -716,7 +714,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
: (
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Markdown')}>
<div data-ai-content="markdown">
<LazyMessageResponse isAnimating={false}>
<LazyMessageResponse isAnimating={!!isThisStreaming}>
{message.content}
</LazyMessageResponse>
</div>
+78 -3
View File
@@ -12,6 +12,7 @@ import {
panelViewsEqual,
resolveDisplayedPanelView,
resolveDisplayedSession,
shouldForceDraftViewSync,
} from "./aiPanelViewState.ts";
function createSession(id: string): AISession {
@@ -75,6 +76,54 @@ test("missing session target normalizes back to draft view", () => {
assert.deepEqual(normalizePanelView(panelView, sessions), { mode: "draft" });
});
test("shouldForceDraftViewSync keeps explicit session while it is still in scoped history", () => {
const explicit: AIPanelView = { mode: "session", sessionId: "just-created" };
const normalized: AIPanelView = { mode: "draft" };
// Predicate mirrors scoped historySessions (not the global store).
const scopedHistory = new Set(["just-created"]);
assert.equal(
shouldForceDraftViewSync(explicit, normalized, (id) => scopedHistory.has(id)),
false,
);
});
test("shouldForceDraftViewSync forces draft when explicit session is gone", () => {
const explicit: AIPanelView = { mode: "session", sessionId: "deleted" };
const normalized: AIPanelView = { mode: "draft" };
assert.equal(
shouldForceDraftViewSync(explicit, normalized, () => false),
true,
);
});
test("shouldForceDraftViewSync demotes when session is only in global store, not scoped history", () => {
const explicit: AIPanelView = { mode: "session", sessionId: "other-scope" };
const normalized: AIPanelView = { mode: "draft" };
// Global store still has it; scoped history (what normalize uses) does not.
const globalStore = new Set(["other-scope"]);
const scopedHistory = new Set<string>();
assert.equal(
shouldForceDraftViewSync(explicit, normalized, (id) => scopedHistory.has(id)),
true,
);
assert.equal(
shouldForceDraftViewSync(explicit, normalized, (id) => globalStore.has(id)),
false,
);
});
test("shouldForceDraftViewSync is a no-op when views already match", () => {
const view: AIPanelView = { mode: "session", sessionId: "session-1" };
assert.equal(
shouldForceDraftViewSync(view, view, () => true),
false,
);
});
test("missing explicit panel view resumes the most recent matching history when no draft exists", () => {
const sessions = [createSession("session-2"), createSession("session-1")];
@@ -120,18 +169,44 @@ test("terminal scope without explicit view always starts from draft even when hi
);
});
test("missing explicit panel view prefers the draft when unsent input exists", () => {
test("missing explicit panel view prefers the draft when unsent input exists without an active chat", () => {
const sessions = [createSession("session-2"), createSession("session-1")];
assert.deepEqual(
resolveDisplayedPanelView(undefined, true, sessions),
resolveDisplayedPanelView(undefined, true, sessions, null, "workspace"),
{ mode: "draft" },
);
});
test("workspace unsent draft keeps the persisted active chat after merge seed", () => {
// Merge often seeds activeSessionIdMap without writing panelView. Typing a
// follow-up must not demote to draft or send will createSession().
const sessions = [createSession("session-2"), createSession("session-1")];
assert.deepEqual(
resolveDisplayedPanelView(undefined, true, sessions, "session-1", "workspace"),
{ mode: "session", sessionId: "session-1" },
);
});
test("explicit new-chat draft still wins over a stale persisted id", () => {
const sessions = [createSession("session-2"), createSession("session-1")];
assert.deepEqual(
resolveDisplayedPanelView(
{ mode: "draft" },
true,
sessions,
"session-1",
"workspace",
),
{ mode: "draft" },
);
});
test("draft state is used when there is no implicit history to resume", () => {
assert.deepEqual(
resolveDisplayedPanelView(undefined, true, []),
resolveDisplayedPanelView(undefined, true, [], null, "workspace"),
{ mode: "draft" },
);
});
+39 -6
View File
@@ -44,22 +44,28 @@ export function resolveDisplayedPanelView(
return normalizePanelView(panelView, sessions);
}
if (hasDraft) {
return DEFAULT_PANEL_VIEW;
}
// New terminal sessions should always start from a blank draft. History is
// still available in the drawer, but never auto-resumed into a fresh SSH tab.
// Explicit panelView above is the only way a terminal scope shows a session
// (e.g. after dissolve handoff writes mode:session).
if (scopeType === "terminal") {
return DEFAULT_PANEL_VIEW;
}
// Honour the persisted active-session selection (survives cold mount)
// before falling back to the newest history entry.
// Workspace: keep the inherited/persisted active chat when the user starts
// typing a follow-up. Merge seed often writes activeSessionIdMap only, with
// no explicit panelView — if unsent draft outranked that selection, send
// would createSession() while the main area still looked like the old chat.
// Explicit "New Chat" clears the active map and writes mode:draft, so it
// still wins via the panelView branch above.
if (persistedSessionId && sessions.some((s) => s.id === persistedSessionId)) {
return { mode: "session", sessionId: persistedSessionId };
}
if (hasDraft) {
return DEFAULT_PANEL_VIEW;
}
if (sessions[0]) {
return { mode: "session", sessionId: sessions[0].id };
}
@@ -80,6 +86,33 @@ export function normalizePanelView(
: DEFAULT_PANEL_VIEW;
}
/**
* Whether the panel should force `showDraftView` when the normalized view
* differs from the explicit store view.
*
* Explicit session views must stay put while the session is still present in
* the same scoped history list that `normalizePanelView` uses — otherwise a
* one-frame history lag after draft send demotes the new chat into history
* and reopens a blank draft (especially under StrictMode).
*
* `sessionExists` must NOT consult the global store alone: a session that
* exists but is out of this scope's history must demote so the panel does not
* stick on a blank draft with a ghost active-map entry.
*/
export function shouldForceDraftViewSync(
explicitPanelView: AIPanelView | undefined,
normalizedPanelView: AIPanelView,
sessionExists: (sessionId: string) => boolean,
): boolean {
if (!explicitPanelView || panelViewsEqual(normalizedPanelView, explicitPanelView)) {
return false;
}
if (explicitPanelView.mode === "session" && sessionExists(explicitPanelView.sessionId)) {
return false;
}
return true;
}
export function resolveDisplayedSession(
panelView: AIPanelView,
sessions: AISession[],
@@ -47,6 +47,31 @@ test("workspace history remains visible after the original workspace target is g
);
});
test("workspace history includes member-terminal chats and ranks them above stale workspaces", () => {
const memberTerminalSession = createSession(
"terminal-a-chat",
{ type: "terminal", targetId: "terminal-a" },
1,
);
const staleWorkspaceSession = createSession(
"workspace-stale",
{ type: "workspace", targetId: "workspace-before-restart" },
99,
);
assert.deepEqual(
getScopedHistorySessions(
[staleWorkspaceSession, memberTerminalSession],
"workspace",
"workspace-merged",
["host-a"],
new Set(),
new Set(["terminal-a", "terminal-b"]),
).map((session) => session.id),
["terminal-a-chat", "workspace-stale"],
);
});
test("terminal history without host ids remains visible after the original terminal target is gone", () => {
const staleLocalSession = createSession(
"terminal-local-stale",
+8 -1
View File
@@ -10,12 +10,16 @@ function buildHistoryCacheKey(
scopeTargetId: string | undefined,
scopeHostIds: string[] | undefined,
activeTerminalSessionIds: Set<string>,
workspaceMemberTerminalIds: Set<string> | undefined,
): HistoryCacheKey {
const hostKey = scopeHostIds ? [...scopeHostIds].sort().join(',') : '';
const terminalKey = scopeType === 'terminal'
? [...activeTerminalSessionIds].sort().join(',')
: '';
return `${scopeType}:${scopeTargetId ?? ''}:${hostKey}:${terminalKey}`;
const memberKey = scopeType === 'workspace' && workspaceMemberTerminalIds
? [...workspaceMemberTerminalIds].sort().join(',')
: '';
return `${scopeType}:${scopeTargetId ?? ''}:${hostKey}:${terminalKey}:${memberKey}`;
}
export function getScopedHistorySessions(
@@ -24,6 +28,7 @@ export function getScopedHistorySessions(
scopeTargetId: string | undefined,
scopeHostIds: string[] | undefined,
activeTerminalSessionIds: Set<string>,
workspaceMemberTerminalIds?: Set<string>,
): AISession[] {
let scopeCache = historyCache.get(sessions);
if (!scopeCache) {
@@ -36,6 +41,7 @@ export function getScopedHistorySessions(
scopeTargetId,
scopeHostIds,
activeTerminalSessionIds,
workspaceMemberTerminalIds,
);
const cached = scopeCache.get(cacheKey);
if (cached) {
@@ -53,6 +59,7 @@ export function getScopedHistorySessions(
scopeTargetId,
scopeHostIds,
activeTerminalSessionIds,
workspaceMemberTerminalIds,
),
}))
.filter(({ matchRank }) => matchRank > 0)
+32
View File
@@ -114,3 +114,35 @@ test("scope type mismatch returns 0 regardless of target or hosts", () => {
0,
);
});
test("workspace scope treats member-terminal chats as exact matches after merge", () => {
const session = createSession("session-1", "terminal-a", ["host-a"]);
assert.equal(
getSessionScopeMatchRank(
session,
"workspace",
"ws-1",
["host-a"],
undefined,
new Set(["terminal-a", "terminal-b"]),
),
3,
);
});
test("workspace scope ignores terminal chats that are not workspace members", () => {
const session = createSession("session-1", "terminal-other", ["host-a"]);
assert.equal(
getSessionScopeMatchRank(
session,
"workspace",
"ws-1",
["host-a"],
undefined,
new Set(["terminal-a", "terminal-b"]),
),
0,
);
});
+18
View File
@@ -12,7 +12,25 @@ export function getSessionScopeMatchRank(
* not offered (or cleaned) as if it were orphaned.
*/
activeTerminalSessionIds?: Set<string>,
/**
* Terminal session ids currently living in this workspace. When terminals
* merge, their AI chats stay `scope.type === "terminal"`; treating those
* member chats as exact workspace matches keeps history/resume working.
*/
workspaceMemberTerminalIds?: Set<string>,
): number {
// After a terminal merge the AI panel flips to workspace scope, but chats
// created on the member terminals remain terminal-scoped. Rank those as
// exact matches so they stay visible and preferred over stale workspaces.
if (
scopeType === "workspace"
&& session.scope.type === "terminal"
&& session.scope.targetId
&& workspaceMemberTerminalIds?.has(session.scope.targetId)
) {
return 3;
}
if (session.scope.type !== scopeType) return 0;
if (session.scope.targetId === scopeTargetId) return 3;
+9
View File
@@ -3,6 +3,7 @@ import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { sanitizeHost } from '../../domain/host';
import { isPluginHostProtocol } from '../../domain/pluginConnection';
import type { Host } from '../../types';
import { ContextMenuContent, ContextMenuItem, ContextMenuShortcut } from '../ui/context-menu';
import { collectOwnedPluginMenus, comparePluginMenus, usePluginContributions } from '../../application/state/usePluginContributions';
@@ -13,6 +14,7 @@ export interface HostTreeHostContextMenuHandlers {
onEditHost?: (host: Host) => void;
onRenameHost?: (host: Host) => void;
onDuplicateHost: (host: Host) => void;
onCopyHostname?: (host: Host) => void;
onCopyCredentials: (host: Host) => void;
onDeleteHost: (host: Host) => void;
}
@@ -25,6 +27,7 @@ export const HostTreeHostContextMenuContent: React.FC<
onEditHost,
onRenameHost,
onDuplicateHost,
onCopyHostname,
onCopyCredentials,
onDeleteHost,
}) => {
@@ -40,6 +43,7 @@ export const HostTreeHostContextMenuContent: React.FC<
const pluginMenus = collectOwnedPluginMenus(pluginContributions.snapshot.plugins)
.filter((menu) => menu.location === 'host/context' && menu.visible)
.sort(comparePluginMenus);
const canCopyHostname = Boolean(onCopyHostname) && !isPluginHostProtocol(safeHost.protocol);
return (
<ContextMenuContent>
@@ -59,6 +63,11 @@ export const HostTreeHostContextMenuContent: React.FC<
<ContextMenuItem onClick={() => onDuplicateHost(host)}>
<Copy className="mr-2 h-4 w-4" /> {t('action.duplicate')}
</ContextMenuItem>
{canCopyHostname ? (
<ContextMenuItem onClick={() => onCopyHostname?.(host)}>
<Copy className="mr-2 h-4 w-4" /> {t('terminal.statusbar.copyHostname.label')}
</ContextMenuItem>
) : null}
<ContextMenuItem onClick={() => onCopyCredentials(host)}>
<Server className="mr-2 h-4 w-4" /> {t('vault.hosts.copyCredentials')}
</ContextMenuItem>
+51 -8
View File
@@ -4,8 +4,11 @@ import { readFileSync } from "node:fs";
import {
getHostPickerTriggerRange,
isNoteSmallImageWidth,
isSupportedNoteExternalHref,
isPointerInsideLinkActionHoverZone,
linkActionStatesEqual,
NOTE_SMALL_IMAGE_MAX_WIDTH,
resolveHostPickerPopupPosition,
shouldInsertClipboardTextAsMarkdown,
shouldHandleHostPickerNavigationKey,
@@ -53,6 +56,24 @@ test("link action hover zone keeps the open button reachable but not sticky", ()
assert.equal(isPointerInsideLinkActionHoverZone(null, 105, 55), false);
});
test("link action state equality skips identical hover chips", () => {
const a = { href: "https://example.com", label: "example", left: 100, top: 50 };
assert.equal(linkActionStatesEqual(a, { ...a }), true);
assert.equal(linkActionStatesEqual(a, { ...a, left: 101 }), false);
assert.equal(linkActionStatesEqual(a, null), false);
assert.equal(linkActionStatesEqual(null, null), true);
});
test("small note image width threshold matches README badge sizes", () => {
assert.equal(isNoteSmallImageWidth(32), true);
assert.equal(isNoteSmallImageWidth("96"), true);
assert.equal(isNoteSmallImageWidth(NOTE_SMALL_IMAGE_MAX_WIDTH), true);
assert.equal(isNoteSmallImageWidth(128), false);
assert.equal(isNoteSmallImageWidth(2000), false);
assert.equal(isNoteSmallImageWidth(""), false);
assert.equal(isNoteSmallImageWidth(null), false);
});
test("host picker trigger range only covers the typed trigger and query", () => {
const text = "before\n\n@10.2.0.32";
const range = getHostPickerTriggerRange(text);
@@ -104,7 +125,9 @@ test("pasted markdown is detected only when it has renderable structure", () =>
assert.equal(shouldInsertClipboardTextAsMarkdown("```sh\nuptime\n```"), true);
assert.equal(shouldInsertClipboardTextAsMarkdown("plain text from clipboard"), false);
assert.equal(shouldInsertClipboardTextAsMarkdown("https://example.com/path_(x)"), false);
assert.equal(shouldInsertClipboardTextAsMarkdown("![logo](https://example.com/logo.png)"), false);
// Image markdown / raw HTML img must intercept so notes can render remote images.
assert.equal(shouldInsertClipboardTextAsMarkdown("![logo](https://example.com/logo.png)"), true);
assert.equal(shouldInsertClipboardTextAsMarkdown('<img alt="logo" src="https://example.com/logo.png" />'), true);
});
test("note editor registers a code block editor for pasted fenced code", () => {
@@ -118,13 +141,23 @@ test("note editor registers a code block editor for pasted fenced code", () => {
assert.match(source, /syntaxHighlighting\(noteCodeHighlightStyle\)/);
});
test("note editor enables image plugin for remote markdown images", () => {
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
assert.match(source, /imagePlugin\(\{\s*allowSetImageDimensions:\s*true/);
assert.match(source, /InsertImage/);
});
test("note editor exposes preview and edit modes with a markdown toolbar in edit mode", () => {
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
const managerSource = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
assert.match(source, /type NoteEditorMode = "edit" \| "preview"/);
assert.match(source, /toolbarPlugin\(\{\s*toolbarContents:/s);
// Preview and edit both use MDXEditor (readOnly in preview).
assert.match(source, /readOnly=\{editorMode === "preview"\}/);
assert.match(source, /key=\{editorMode\}/);
assert.match(source, /netcatty-mdx-editor--preview/);
assert.doesNotMatch(source, /NoteMarkdownPreview|react-markdown|github-markdown/);
assert.match(source, /<BlockTypeSelect \/>/);
assert.match(source, /<BoldItalicUnderlineToggles /);
assert.match(source, /<ListsToggle /);
@@ -156,6 +189,12 @@ test("note markdown toolbar remains usable in narrow panes", () => {
styles,
/\.netcatty-mdx-editor\s*\{[^}]*container-type:\s*inline-size;/s,
);
// Keep fixed-containing-block in sync with MDX linkDialog coordinate math
// (container-type alone is not a fixed CB in browsers).
assert.match(
styles,
/\.netcatty-mdx-editor\s*\{[^}]*transform:\s*translateZ\(0\);/s,
);
assert.match(
styles,
/\.netcatty-note-markdown-toolbar\s*\{[^}]*max-width:\s*100%;[^}]*height:\s*auto\s*!important;[^}]*overflow:\s*visible\s*!important;/s,
@@ -186,10 +225,15 @@ test("preview mode opens links directly without showing the edit hover action",
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
assert.match(source, /const handleClickCapture = useCallback/);
assert.match(source, /if \(editorMode !== "preview"\) \{[\s\S]*scheduleHostPickerUpdate\(\);[\s\S]*return;/);
assert.match(
source,
/if \(editorMode === "preview"\) \{[\s\S]*toggleTaskListItemAtIndex[\s\S]*const handled = openLink\(href, label\);/,
"preview click path handles task toggles then openable links",
);
assert.match(source, /const handled = openLink\(href, label\);[\s\S]*if \(!handled\) return;[\s\S]*event\.preventDefault\(\);/);
assert.match(source, /onClickCapture=\{handleClickCapture\}/);
assert.match(source, /if \(editorMode !== "edit"\) \{[\s\S]*setLinkAction\(null\);[\s\S]*return;/);
assert.match(source, /scheduleHostPickerUpdate\(\);\s*\n\s*\}, \[commitMarkdown, editorMode, openLink, scheduleHostPickerUpdate\]/);
assert.match(source, /onClickCapture=\{\(event\) => \{[\s\S]*blockWhileContentSwapping[\s\S]*handleClickCapture/);
assert.match(source, /if \(editorMode !== "edit"\) \{[\s\S]*setLinkActionIfChanged\(null\);[\s\S]*return;/);
assert.match(source, /\{editorMode === "edit" && linkAction && \(/);
});
@@ -320,15 +364,14 @@ test("annotateNoteCodeBlockCopyButtons adds a copy action to code blocks", () =>
assert.match(source, /onCopy\(text\)/);
});
test("note code blocks expose a hover copy action only in preview mode", () => {
test("note preview uses MDX readOnly with code-copy chrome", () => {
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
const styles = readFileSync(new URL("../../index.css", import.meta.url), "utf8");
assert.match(source, /removeNoteCodeBlockCopyButtons/);
assert.match(source, /if \(editorMode !== "preview"\) \{[\s\S]*removeNoteCodeBlockCopyButtons/);
assert.match(source, /readOnly=\{editorMode === "preview"\}/);
assert.match(source, /annotateCodeBlockCopyButtons/);
assert.match(source, /annotateNoteCodeBlockCopyButtons/);
assert.match(source, /notes\.codeBlock\.copied/);
assert.match(source, /copyFailedLabel/);
assert.match(source, /MutationObserver/);
assert.match(source, /setAttribute\("aria-label", copiedLabel\)/);
assert.match(styles, /\.netcatty-note-code-copy/);
File diff suppressed because it is too large. Load diff
+114 -1
View File
@@ -8,6 +8,25 @@ const layoutSource = readFileSync(
"utf8",
);
test("notes tree width resize avoids React setState on every pointermove", () => {
// Dragging the sidebar used to call setTreeWidth per pixel and re-render the
// whole NotesManager (including MDXEditor). Live width must stay on the DOM
// until pointerup commits state + localStorage.
assert.match(managerSource, /treeAsideRef/);
assert.match(managerSource, /requestAnimationFrame/);
assert.match(managerSource, /aside\.style\.width = `\$\{width\}px`/);
assert.match(
managerSource,
/const handlePointerMove = \(moveEvent: PointerEvent\) => \{[\s\S]*?requestAnimationFrame/,
);
assert.doesNotMatch(
managerSource,
/const handlePointerMove = \(moveEvent: PointerEvent\) => \{\s*\n\s*setTreeWidth\(/,
);
assert.match(managerSource, /persistTreeWidth\(nextWidth\)/);
assert.match(managerSource, /isTreeResizing && "pointer-events-none"/);
});
test("note content drafts stay in refs so MDX keystrokes do not rebuild the shell", () => {
assert.doesNotMatch(
managerSource,
@@ -72,6 +91,63 @@ test("mode toggle flushes ref-only content drafts before remounting the editor",
);
});
test("note switches reuse MDX instance instead of key=noteId remount", () => {
assert.match(managerSource, /noteId=\{selectedNoteView\.id\}/);
assert.doesNotMatch(
managerSource,
/<InlineMarkdownEditor[\s\S]{0,200}key=\{selectedNoteView\.id\}/,
"key=noteId forces full Lexical teardown on every note switch",
);
const editorSource = readFileSync(
new URL("./InlineMarkdownEditor.tsx", import.meta.url),
"utf8",
);
assert.match(editorSource, /noteId !== noteIdRef\.current/);
assert.match(editorSource, /setMarkdown\(scheduled\.markdown\)/);
assert.match(
editorSource,
/contentSwapFramesRef\.current\.outer = window\.requestAnimationFrame/,
"outer rAF yields a paint so the tree selection updates before Lexical import",
);
assert.match(
editorSource,
/contentSwapFramesRef\.current\.inner = window\.requestAnimationFrame/,
"inner rAF completes the double-yield before setMarkdown",
);
assert.match(editorSource, /data-notes-content-swapping="true"/);
assert.match(editorSource, /setIsContentSwapping\(true\)/);
assert.match(editorSource, /CLEAR_HISTORY_COMMAND/);
assert.match(editorSource, /clearLexicalHistory/);
assert.match(editorSource, /attributeFilter:\s*\[\s*"width",\s*"height"/);
assert.match(editorSource, /onPointerDownCapture=\{blockWhileContentSwapping\}/);
assert.match(editorSource, /startTransition\(\(\) => setIsContentSwapping\(false\)\)/);
assert.match(
editorSource,
/latestMarkdownRef\.current !== scheduled\.markdown/,
"deferred setMarkdown must not clobber edits typed after the switch",
);
assert.match(
editorSource,
/if \(contentSwapPendingRef\.current\) return;/,
"stale onChange during the swap yield must not write the previous note into the new draft",
);
assert.match(
editorSource,
/contentSwapScheduledRef/,
"deferred import must refresh when the same note's value changes during the yield",
);
assert.match(
editorSource,
/syncedPropValueRef\.current = markdown;/,
"draft-clobber guard compares display-normalized values after note switch",
);
assert.match(
editorSource,
/runDecorations\(true\)/,
"edit-mode note swaps must re-annotate host links after setMarkdown",
);
});
test("host-link annotation does not re-run on every markdown value keystroke", () => {
const editorSource = readFileSync(
new URL("./InlineMarkdownEditor.tsx", import.meta.url),
@@ -82,7 +158,21 @@ test("host-link annotation does not re-run on every markdown value keystroke", (
/annotateHostLinks,\s*value\s*\]/,
"value in annotateHostLinks effect deps walks the DOM on every keystroke",
);
assert.match(editorSource, /\[annotateHostLinks, editorMode\]/);
assert.doesNotMatch(
editorSource,
/annotateCodeBlockCopyButtons,\s*editorMode,\s*value\s*\]/,
"value in code-copy effect deps re-walks every code block on each draft identity change",
);
assert.match(
editorSource,
/\[annotateCodeBlockCopyButtons, annotateHostLinks, editorMode\]/,
"DOM decoration is independent of markdown value identity",
);
assert.match(
editorSource,
/readOnly=\{editorMode === "preview"\}/,
"preview reuses MDXEditor in read-only mode",
);
assert.match(
editorSource,
/syncedPropValueRef/,
@@ -93,3 +183,26 @@ test("host-link annotation does not re-run on every markdown value keystroke", (
/latestMarkdownRef\.current !== syncedPropValueRef\.current/,
);
});
test("link hover and small-image CSS avoid render thrash", () => {
const editorSource = readFileSync(
new URL("./InlineMarkdownEditor.tsx", import.meta.url),
"utf8",
);
const cssSource = readFileSync(new URL("../../index.css", import.meta.url), "utf8");
const imageLayoutSource = readFileSync(
new URL("./noteImageLayout.ts", import.meta.url),
"utf8",
);
assert.match(editorSource, /linkActionStatesEqual/);
assert.match(editorSource, /setLinkActionIfChanged/);
assert.match(editorSource, /annotateNoteImageSizes/);
assert.match(imageLayoutSource, /data-note-img-size/);
// No combinatorial :has(img[width="N"]) matrix for small icons.
assert.doesNotMatch(
cssSource,
/:has\(img\[width="16"\]\).*?:has\(img\[width="20"\]/s,
);
assert.match(cssSource, /img\[data-note-img-size="sm"\]/);
});
+45 -2
View File
@@ -8,6 +8,7 @@ import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
import type { VaultNote } from "../../types.ts";
import { TooltipProvider } from "../ui/tooltip.tsx";
import {
clampNotesTreeWidth,
getFallbackNoteSelectionState,
getNoteActionTargetGroup,
getNoteGroupSelectionState,
@@ -56,7 +57,14 @@ test("NotesManager renders notes tree and selected markdown editor", () => {
assert.match(markup, /Ops/);
assert.match(markup, /Postgres failover checklist/);
assert.match(markup, /data-notes-editor-loading="true"/);
// React.lazy may show Suspense fallback or the resolved editor depending on
// whether a prior test already settled the MDX chunk.
assert.ok(
markup.includes('data-notes-editor-loading="true"')
|| markup.includes("editable markdown")
|| markup.includes("Promote replica"),
"full mode should mount the note editor (loading fallback or resolved MDX)",
);
});
test("NotesManager marks selected notebook rows with shared tree state", () => {
@@ -76,6 +84,36 @@ test("NotesManager balances folder and note tree icon sizes", () => {
assert.match(markup, /width="16" height="16"[^>]*class="lucide lucide-file-text/);
});
test("NotesManager tree scroll area constrains width so titles can truncate", () => {
const markup = renderNotes();
// Radix ScrollArea viewport child is display:table by default; force block +
// min-w-0 so narrowing the notes sidebar ellipsizes long titles instead of
// clipping them. React encodes `&` / `>` in class attributes.
assert.match(
markup,
/data-radix-scroll-area-viewport\](?:&gt;|>)div\]:!block/,
);
assert.match(
markup,
/data-radix-scroll-area-viewport\](?:&gt;|>)div\]:!min-w-0/,
);
assert.match(markup, /data-notes-drop-zone="root"/);
assert.match(markup, /min-h-full min-w-0 space-y-1 overflow-hidden/);
// Aside stays unclipped so the resize handle's outer half remains hit-testable;
// truncation is enforced by the inner tree shell + ScrollArea constraints.
assert.match(markup, /aside class="[^"]*min-w-0[^"]*flex-col bg-background/);
assert.match(markup, /flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden/);
assert.match(markup, /role="separator"/);
assert.match(markup, /translate-x-1\/2 cursor-col-resize/);
});
test("clampNotesTreeWidth keeps the sidebar within the design range", () => {
assert.equal(clampNotesTreeWidth(100), 160);
assert.equal(clampNotesTreeWidth(300), 300);
assert.equal(clampNotesTreeWidth(900), 520);
});
test("NotesManager selection helpers keep note and folder selection exclusive", () => {
const notes = [note(), note({ id: "note-2", title: "Deploy", group: "Deploy" })];
const noteSelection = getNoteSelectionState(notes[0], false);
@@ -297,7 +335,12 @@ test("NotesManager sidebar mode opens the requested note without selecting its f
);
assert.match(markup, /Deploy overlay/);
assert.match(markup, /data-notes-editor-loading="true"/);
assert.ok(
markup.includes('data-notes-editor-loading="true"')
|| markup.includes("Deploy overlay content")
|| markup.includes("editable markdown"),
"openNoteId should mount the sidebar note overlay editor",
);
assert.equal(markup.match(/data-selected="true"/g)?.length, 1);
assert.match(markup, /data-vault-tree-row="group"[^>]*data-selected="false"/);
assert.match(markup, /data-vault-tree-row="item"[^>]*data-selected="true"[^>]*data-note-id="note-2"/);
+77 -23
View File
@@ -100,11 +100,16 @@ type NotesToolbarPanel = "search" | null;
const toolbarIconButtonClass = "netcatty-tab h-6 w-6 shrink-0 rounded-md p-0 hover:bg-transparent";
const menuItemClass = "flex h-8 w-full items-center rounded-md px-3 text-left text-sm hover:bg-secondary";
const NOTES_TREE_DEFAULT_WIDTH = 300;
const NOTES_TREE_MIN_WIDTH = 220;
/** Narrow enough for nested folders + ellipsis; toolbar scrolls if needed. */
const NOTES_TREE_MIN_WIDTH = 160;
const NOTES_TREE_MAX_WIDTH = 520;
const NOTE_DRAG_TYPE = "application/x-netcatty-note-id";
const NOTE_GROUP_DRAG_TYPE = "application/x-netcatty-note-group-path";
export function clampNotesTreeWidth(value: number): number {
return Math.max(NOTES_TREE_MIN_WIDTH, Math.min(NOTES_TREE_MAX_WIDTH, value));
}
export const normalizeNoteEditorMode = (value: string | null): NoteEditorMode | null =>
value === "edit" || value === "preview" ? value : null;
@@ -113,7 +118,7 @@ const isNoteEditorMode = (value: string | null): value is NoteEditorMode =>
const InlineMarkdownEditorFallback = () => (
<div
className="netcatty-lazy-fade-in min-h-[420px]"
className="min-h-[420px] bg-background"
data-notes-editor-loading="true"
aria-hidden="true"
/>
@@ -388,6 +393,9 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
NOTES_TREE_DEFAULT_WIDTH,
{ min: NOTES_TREE_MIN_WIDTH, max: NOTES_TREE_MAX_WIDTH },
);
const treeAsideRef = useRef<HTMLElement | null>(null);
const treeWidthRef = useRef(treeWidth);
treeWidthRef.current = treeWidth;
const searchInputRef = useRef<HTMLInputElement>(null);
const importFileInputRef = useRef<HTMLInputElement>(null);
const isImportingMarkdownRef = useRef(false);
@@ -1232,7 +1240,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
return (
<div
key={`new-folder-${parent || "root"}`}
className="flex h-7 items-center px-2 text-sm"
className="flex h-7 min-w-0 items-center px-2 text-sm"
style={{ paddingLeft: depth * 16 + 4 }}
>
<div className="mr-1 h-5 w-4 shrink-0" />
@@ -1444,23 +1452,53 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
event.stopPropagation();
const startX = event.clientX;
const startWidth = treeWidth;
const startWidth = treeWidthRef.current;
const previousCursor = document.body.style.cursor;
const previousUserSelect = document.body.style.userSelect;
const aside = treeAsideRef.current;
let frame = 0;
let latestWidth = startWidth;
// Avoid setState on every pointermove — NotesManager owns the MDX editor and
// re-rendering it per pixel makes sidebar resize feel stuck.
setIsTreeResizing(true);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
if (aside) {
aside.style.willChange = "width";
}
const clampWidth = (value: number) =>
Math.max(NOTES_TREE_MIN_WIDTH, Math.min(NOTES_TREE_MAX_WIDTH, value));
const handlePointerMove = (moveEvent: PointerEvent) => {
setTreeWidth(clampWidth(startWidth + moveEvent.clientX - startX));
const applyWidth = (width: number) => {
latestWidth = width;
if (aside) {
aside.style.width = `${width}px`;
}
};
const handlePointerUp = (upEvent: PointerEvent) => {
const nextWidth = clampWidth(startWidth + upEvent.clientX - startX);
const handlePointerMove = (moveEvent: PointerEvent) => {
const nextWidth = clampNotesTreeWidth(startWidth + moveEvent.clientX - startX);
if (frame) {
latestWidth = nextWidth;
return;
}
latestWidth = nextWidth;
frame = window.requestAnimationFrame(() => {
frame = 0;
applyWidth(latestWidth);
});
};
const handlePointerUp = () => {
if (frame) {
window.cancelAnimationFrame(frame);
frame = 0;
}
const nextWidth = clampNotesTreeWidth(latestWidth);
applyWidth(nextWidth);
if (aside) {
aside.style.willChange = "";
}
treeWidthRef.current = nextWidth;
setTreeWidth(nextWidth);
persistTreeWidth(nextWidth);
setIsTreeResizing(false);
@@ -1474,7 +1512,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", handlePointerUp);
}, [persistTreeWidth, setTreeWidth, treeWidth]);
}, [persistTreeWidth, setTreeWidth]);
return (
<div className="relative flex h-full min-h-0 flex-col overflow-hidden">
@@ -1491,16 +1529,20 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
<div className="flex min-h-0 flex-1">
{shouldShowNotesTree && (
<aside
ref={treeAsideRef}
className={cn(
"relative flex flex-col bg-background",
isSidebarMode ? "min-w-0 flex-1" : "shrink-0 border-r border-border/60",
// min-w-0 on the aside; overflow-hidden stays on the inner tree shell
// so the resize separator's translate-x-1/2 hit area is not clipped.
"relative flex min-w-0 flex-col bg-background",
isSidebarMode ? "flex-1" : "shrink-0 border-r border-border/60",
)}
style={isSidebarMode ? undefined : { width: treeWidth }}
>
<div className="flex-shrink-0">
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<div className="min-w-0 flex-shrink-0 overflow-hidden">
<div className={cn(
TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS,
"flex items-center gap-1 border-b border-border/60 px-1.5",
"flex min-w-0 items-center gap-1 overflow-x-auto border-b border-border/60 px-1.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
)}>
<Tooltip>
<TooltipTrigger asChild>
@@ -1645,9 +1687,13 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
</div>
</div>
</div>
<ScrollArea className="flex-1">
{/* Radix ScrollArea viewport child defaults to display:table, which
expands to content width and blocks title ellipsis on narrow trees.
Force block + min-w-0 so rows shrink with the sidebar (same pattern
as AsidePanelContent). */}
<ScrollArea className="min-w-0 flex-1 [&>[data-radix-scroll-area-viewport]>div]:!block [&>[data-radix-scroll-area-viewport]>div]:!min-w-0">
<div
className="min-h-full space-y-1 px-1.5 pt-1.5 pb-4"
className="min-h-full min-w-0 space-y-1 overflow-hidden px-1.5 pt-1.5 pb-4"
data-notes-drop-zone="root"
onDragOver={(event) => {
if (!hasNotesTreeDrag(event.dataTransfer)) return;
@@ -1667,6 +1713,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
)}
</div>
</ScrollArea>
</div>
{!isSidebarMode && (
<div
role="separator"
@@ -1685,7 +1732,14 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
)}
{!isSidebarMode && (
<main className="flex min-w-0 flex-1 flex-col bg-background">
<main
className={cn(
"flex min-w-0 flex-1 flex-col bg-background",
// Keep the editor out of hit-testing / paint thrash while the tree
// width is driven from the DOM during resize.
isTreeResizing && "pointer-events-none",
)}
>
{selectedNoteView ? (
<>
<div className="flex min-h-[54px] shrink-0 items-center gap-3 px-8 pt-6 pb-1" data-note-title-row>
@@ -1712,10 +1766,10 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
}
}}
>
<LazyLoadBoundary name="Notes editor" resetKey={selectedNoteView.id}>
<LazyLoadBoundary name="Notes editor" resetKey="notes-editor">
<Suspense fallback={<InlineMarkdownEditorFallback />}>
<InlineMarkdownEditor
key={selectedNoteView.id}
noteId={selectedNoteView.id}
value={selectedNoteView.content}
placeholder={t("notes.editor.placeholder")}
editorMode={noteEditorMode}
@@ -1803,10 +1857,10 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
}
}}
>
<LazyLoadBoundary name="Notes editor" resetKey={overlayNoteView.id}>
<LazyLoadBoundary name="Notes editor" resetKey="notes-overlay-editor">
<Suspense fallback={<InlineMarkdownEditorFallback />}>
<InlineMarkdownEditor
key={overlayNoteView.id}
noteId={overlayNoteView.id}
value={overlayNoteView.content}
placeholder={t("notes.editor.placeholder")}
editorMode={noteEditorMode}
@@ -0,0 +1,29 @@
/**
* Smoke: UI re-export surface stays wired to domain paste policy.
* Full policy coverage lives in domain/notes/clipboardPaste.test.ts.
*/
import assert from "node:assert/strict";
import test from "node:test";
import {
convertClipboardHtmlToMarkdown,
resolveNoteClipboardPaste,
shouldInsertClipboardTextAsMarkdown,
} from "./noteClipboardPaste.ts";
test("noteClipboardPaste re-exports domain resolve + convert helpers", () => {
assert.equal(shouldInsertClipboardTextAsMarkdown("# Title\n\n- item"), true);
assert.equal(shouldInsertClipboardTextAsMarkdown("plain only"), false);
const payload = resolveNoteClipboardPaste({
plainText: "# From re-export\n\n- a",
htmlText: "",
});
assert.equal(payload.kind, "markdown");
assert.match(payload.text, /# From re-export/);
const md = convertClipboardHtmlToMarkdown(
"<html><body><!--StartFragment--><h1>Hi</h1><!--EndFragment--></body></html>",
);
assert.match(md, /^# Hi/m);
});
+31
View File
@@ -0,0 +1,31 @@
/**
* UI re-export of note clipboard paste policy.
* Implementation lives in domain/notes/clipboardPaste.ts (pure, no React).
*/
export {
type NoteClipboardPasteKind,
type NoteClipboardPastePayload,
shouldInsertClipboardTextAsMarkdown,
looksLikeClipboardHtml,
plainMarkdownContainsHtml,
isPrimarilyHtmlDocument,
isCenteredBlockElement,
htmlOpenTagIsCentered,
wrapCenteredMarkdown,
decodeHtmlEntities,
normalizeImageSrc,
normalizeNotePublicAssetPaths,
serializeSafeHtmlImage,
trimBlankLinesOutsideCode,
findHtmlTagEnd,
convertHtmlImgTagToMarkdownOrHtml,
normalizeLinkedBadgeImages,
maskCodeRegions,
unmaskCodeRegions,
normalizePastedNoteMarkdown,
convertClipboardHtmlToMarkdown,
extractBalancedHtmlElement,
convertHtmlIslandsInMarkdown,
resolveNoteClipboardPaste,
shouldInterceptResolvedNotePaste,
} from "../../domain/notes/clipboardPaste";
+47
View File
@@ -0,0 +1,47 @@
/** Compact README-style icons (width ≤ 96). CSS uses data-note-img-size instead of :has(). */
export const NOTE_SMALL_IMAGE_MAX_WIDTH = 96;
export const isNoteSmallImageWidth = (widthRaw: string | number | null | undefined): boolean => {
const width = typeof widthRaw === "number" ? widthRaw : Number(String(widthRaw ?? "").trim());
return Number.isFinite(width) && width > 0 && width <= NOTE_SMALL_IMAGE_MAX_WIDTH;
};
/**
* Mark compact images so CSS can lay out badge rows without hundreds of :has()
* width selectors. Also enable lazy loading for remote images.
*/
export const annotateNoteImageSizes = (container: HTMLElement): void => {
container.querySelectorAll("img").forEach((node) => {
if (!(node instanceof HTMLImageElement)) return;
const isSmall = isNoteSmallImageWidth(node.getAttribute("width"));
if (isSmall) {
node.dataset.noteImgSize = "sm";
} else {
delete node.dataset.noteImgSize;
}
// Prefer browser-native lazy decode for remote screenshots / badges.
if (node.getAttribute("src") && !node.getAttribute("loading")) {
node.loading = "lazy";
}
if (!node.getAttribute("decoding")) {
node.decoding = "async";
}
const wrappers: HTMLElement[] = [];
const block = node.closest<HTMLElement>("[data-editor-block-type=\"image\"]");
if (block) wrappers.push(block);
const imageWrapper = node.closest<HTMLElement>("[class*=\"_imageWrapper_\"]");
if (imageWrapper && imageWrapper !== block) wrappers.push(imageWrapper);
const parent = node.parentElement;
if (parent?.tagName === "P" && parent.childElementCount === 1) {
wrappers.push(parent);
}
for (const wrapper of wrappers) {
if (isSmall) wrapper.dataset.noteImgSize = "sm";
else delete wrapper.dataset.noteImgSize;
}
});
};
+99 -6
View File
@@ -2,9 +2,16 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { shouldInterceptNoteMarkdownPaste } from "./InlineMarkdownEditor.tsx";
import {
insertClipboardTextAtActiveLexicalSelection,
mergeNoteMarkdownDocumentPaste,
NOTE_MARKDOWN_PASTE_INSERT_MAX_CHARS,
resolveNoteMarkdownPasteSettleAttempts,
resolveNoteMarkdownPasteStrategy,
shouldInterceptNoteMarkdownPaste,
} from "./InlineMarkdownEditor.tsx";
test("markdown paste intercept requires a live Lexical selection", () => {
test("markdown paste intercepts structured clipboard text in edit mode even without a Lexical selection", () => {
assert.equal(
shouldInterceptNoteMarkdownPaste({
editorMode: "edit",
@@ -21,7 +28,7 @@ test("markdown paste intercept requires a live Lexical selection", () => {
clipboardText: "# Heading\n\n- item",
canInsertMarkdownAtSelection: false,
}),
false,
true,
);
assert.equal(
shouldInterceptNoteMarkdownPaste({
@@ -34,17 +41,103 @@ test("markdown paste intercept requires a live Lexical selection", () => {
);
});
test("document paste merge preserves first-line indentation and appends after current body", () => {
assert.equal(
mergeNoteMarkdownDocumentPaste("Existing note", "# Pasted\n\n- item"),
"Existing note\n\n# Pasted\n\n- item",
);
assert.equal(
mergeNoteMarkdownDocumentPaste(" ", "# Only paste"),
"# Only paste",
);
assert.equal(
mergeNoteMarkdownDocumentPaste("- parent", " - child"),
"- parent\n\n - child",
);
assert.equal(
mergeNoteMarkdownDocumentPaste("Existing", "\n\n# Pasted\n"),
"Existing\n\n# Pasted",
);
});
test("paste strategy preserves caret: long paste with selection still inserts at selection", () => {
assert.equal(
resolveNoteMarkdownPasteStrategy({
canInsertMarkdownAtSelection: false,
clipboardText: "# short",
}),
"document-merge",
);
assert.equal(
resolveNoteMarkdownPasteStrategy({
canInsertMarkdownAtSelection: true,
clipboardText: "# short body",
}),
"insert-at-selection",
);
const longMarkdown = `# Title\n\n${"paragraph text ".repeat(400)}`;
assert.ok(longMarkdown.length >= NOTE_MARKDOWN_PASTE_INSERT_MAX_CHARS);
// Long paste must NOT force document-merge when a caret exists (would append at EOF).
assert.equal(
resolveNoteMarkdownPasteStrategy({
canInsertMarkdownAtSelection: true,
clipboardText: longMarkdown,
}),
"insert-at-selection",
);
});
test("paste settle attempts scale with clipboard size", () => {
assert.equal(resolveNoteMarkdownPasteSettleAttempts(100), 6);
assert.equal(resolveNoteMarkdownPasteSettleAttempts(3_000), 6);
assert.equal(resolveNoteMarkdownPasteSettleAttempts(12_000), 10);
assert.ok(resolveNoteMarkdownPasteSettleAttempts(100_000) <= 40);
assert.ok(resolveNoteMarkdownPasteSettleAttempts(100_000) >= 6);
});
test("selection paste recovery helper rejects empty text or missing target", () => {
assert.equal(insertClipboardTextAtActiveLexicalSelection(null, "# Heading"), false);
assert.equal(insertClipboardTextAtActiveLexicalSelection(null, ""), false);
});
test("InlineMarkdownEditor only preventDefaults markdown paste after a successful intercept guard", () => {
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
assert.match(source, /shouldInterceptNoteMarkdownPaste/);
assert.match(source, /resolveNoteClipboardPaste/);
assert.match(source, /shouldInterceptResolvedNotePaste/);
assert.match(source, /hasActiveLexicalTextSelection/);
assert.match(source, /mergeNoteMarkdownDocumentPaste/);
assert.match(source, /setMarkdown\(/);
assert.match(source, /resolveNoteMarkdownPasteStrategy/);
assert.match(source, /text\/html/);
assert.match(
source,
/shouldInterceptNoteMarkdownPaste\([\s\S]*?\)[\s\S]*?event\.preventDefault\(\)/,
/shouldInterceptResolvedNotePaste\([\s\S]*?\)[\s\S]*?event\.preventDefault\(\)/,
);
assert.match(
source,
/if \(\s*!shouldInterceptNoteMarkdownPaste\([\s\S]*?\)\s*\{\s*return;\s*\}/,
/if \(\s*!shouldInterceptResolvedNotePaste\([\s\S]*?\)\s*\{\s*return;\s*\}/,
);
assert.match(source, /strategy === "document-merge"/);
assert.match(source, /editor\.insertMarkdown\(markdown\)/);
assert.match(source, /pasteRecoveryGenerationRef/);
assert.match(source, /tryCommitSettledPaste/);
assert.match(source, /editor\.getMarkdown\(\)/);
// With caret: settle failure must not blindly append (emptyDoc gate).
assert.match(source, /if \(emptyDoc\) applyDocumentPaste\(\)/);
assert.match(
source,
/const currentMarkdown = editor\.getMarkdown\(\);[\s\S]*mergeNoteMarkdownDocumentPaste\(currentMarkdown, markdown\)/,
);
// Do not re-queue insertMarkdown at the settle midpoint (double-insert risk).
assert.doesNotMatch(
source,
/attempt === Math\.floor\(maxAttempts \/ 2\)/,
);
// Non-empty settle failure must recover at the selection, not discard after preventDefault.
assert.match(source, /recoverInterceptedPasteAtSelection/);
assert.match(
source,
/if \(attempt >= maxAttempts\)[\s\S]*emptyDoc[\s\S]*applyDocumentPaste[\s\S]*recoverInterceptedPasteAtSelection/,
);
});
+20 -3
View File
@@ -8,13 +8,21 @@ import { useI18n } from '../../application/i18n/I18nProvider';
import { Host,PortForwardingRule } from '../../domain/models';
import { DistroAvatar } from '../DistroAvatar';
import { TrafficDiagram } from '../TrafficDiagram';
import { AsideActionMenu,AsideActionMenuItem,AsidePanel,AsidePanelContent,AsidePanelFooter } from '../ui/aside-panel';
import {
AsideActionMenu,
AsideActionMenuItem,
AsidePanel,
AsidePanelContent,
AsidePanelFooter,
type AsidePanelLayout,
type AsidePanelResizeProps,
} from '../ui/aside-panel';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Switch } from '../ui/switch';
export interface EditPanelProps {
export interface EditPanelProps extends AsidePanelResizeProps {
rule: PortForwardingRule;
draft: Partial<PortForwardingRule>;
hosts: Host[];
@@ -24,6 +32,7 @@ export interface EditPanelProps {
onDuplicate: () => void;
onDelete: () => void;
onOpenHostSelector: () => void;
layout?: AsidePanelLayout;
}
export const EditPanel: React.FC<EditPanelProps> = ({
@@ -36,6 +45,10 @@ export const EditPanel: React.FC<EditPanelProps> = ({
onDuplicate,
onDelete,
onOpenHostSelector,
layout = 'inline',
resizable = false,
persistWidthStorageKey,
resizeAriaLabel,
}) => {
const { t } = useI18n();
const selectedHost = hosts.find(h => h.id === draft.hostId);
@@ -46,6 +59,10 @@ export const EditPanel: React.FC<EditPanelProps> = ({
onClose={onClose}
title={t('pf.wizard.editTitle')}
width="w-[360px]"
layout={layout}
resizable={resizable}
persistWidthStorageKey={persistWidthStorageKey}
resizeAriaLabel={resizeAriaLabel}
actions={
<AsideActionMenu>
<AsideActionMenuItem
@@ -117,7 +134,7 @@ export const EditPanel: React.FC<EditPanelProps> = ({
<DistroAvatar
host={selectedHost}
fallback={selectedHost.os[0].toUpperCase()}
className="h-6 w-6"
size="tree"
/>
<span>{selectedHost.label}</span>
</div>
+18 -3
View File
@@ -9,7 +9,13 @@ import { Host,PortForwardingRule,PortForwardingType } from '../../domain/models'
import { cn } from '../../lib/utils';
import { DistroAvatar } from '../DistroAvatar';
import { TrafficDiagram } from '../TrafficDiagram';
import { AsidePanel,AsidePanelContent,AsidePanelFooter } from '../ui/aside-panel';
import {
AsidePanel,
AsidePanelContent,
AsidePanelFooter,
type AsidePanelLayout,
type AsidePanelResizeProps,
} from '../ui/aside-panel';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
@@ -17,7 +23,7 @@ import { Label } from '../ui/label';
import { Switch } from '../ui/switch';
import { getTypeLabel } from './utils';
export interface NewFormPanelProps {
export interface NewFormPanelProps extends AsidePanelResizeProps {
draft: Partial<PortForwardingRule>;
hosts: Host[];
onDraftChange: (updates: Partial<PortForwardingRule>) => void;
@@ -26,6 +32,7 @@ export interface NewFormPanelProps {
onOpenHostSelector: () => void;
onOpenWizard: () => void;
isValid: boolean;
layout?: AsidePanelLayout;
}
export const NewFormPanel: React.FC<NewFormPanelProps> = ({
@@ -37,6 +44,10 @@ export const NewFormPanel: React.FC<NewFormPanelProps> = ({
onOpenHostSelector,
onOpenWizard,
isValid,
layout = 'inline',
resizable = false,
persistWidthStorageKey,
resizeAriaLabel,
}) => {
const { t } = useI18n();
const selectedHost = hosts.find(h => h.id === draft.hostId);
@@ -47,6 +58,10 @@ export const NewFormPanel: React.FC<NewFormPanelProps> = ({
onClose={onClose}
title={t('pf.wizard.newTitle')}
width="w-[360px]"
layout={layout}
resizable={resizable}
persistWidthStorageKey={persistWidthStorageKey}
resizeAriaLabel={resizeAriaLabel}
>
<AsidePanelContent>
{/* Type Selector */}
@@ -119,7 +134,7 @@ export const NewFormPanel: React.FC<NewFormPanelProps> = ({
<DistroAvatar
host={selectedHost}
fallback={selectedHost.os[0].toUpperCase()}
className="h-6 w-6"
size="tree"
/>
<span>{selectedHost.label}</span>
</div>
+9 -3
View File
@@ -210,7 +210,8 @@ export const SettingRow: React.FC<SettingRowProps> = ({
id={anchorId ? settingsAnchorDomId(anchorId) : undefined}
data-settings-anchor={anchorId}
className={cn(
"flex justify-between py-3 gap-4 rounded-md",
// Keep square corners: rounded rows bend divide-y separators at both ends.
"flex justify-between py-3 gap-4",
align === "start" ? "items-start" : "items-center",
)}
>
@@ -233,7 +234,8 @@ export const SettingsAnchor: React.FC<{
<div
id={settingsAnchorDomId(anchorId)}
data-settings-anchor={anchorId}
className={cn("rounded-md", className)}
// No default rounded-md: inside divide-y cards, child border-radius bends separators.
className={className}
>
{children}
</div>
@@ -244,7 +246,11 @@ export const SettingsTabContent: React.FC<{
children: React.ReactNode;
}> = ({ value, children }) => (
<TabsContent value={value} className="flex-1 m-0 h-full overflow-hidden">
<div className="h-full overflow-y-auto overflow-x-hidden">
{/* data-settings-scroll-pane: search jump scrolls only this pane, not the window */}
<div
data-settings-scroll-pane
className="h-full overflow-y-auto overflow-x-hidden"
>
<div className="p-6 space-y-6">{children}</div>
</div>
</TabsContent>
+72
View File
@@ -0,0 +1,72 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
test("settings search focus scrolls the content pane, not the window viewport", () => {
const source = readFileSync(new URL("./settingsFocus.ts", import.meta.url), "utf8");
const uiSource = readFileSync(new URL("./settings-ui.tsx", import.meta.url), "utf8");
assert.match(source, /scrollSettingsAnchorIntoView/);
assert.match(source, /findSettingsScrollContainer/);
assert.match(source, /data-settings-scroll-pane/);
assert.match(source, /preventScroll:\s*true/);
// Viewport centering lifts the whole settings window under macOS traffic lights.
assert.doesNotMatch(source, /block:\s*["']center["']/);
assert.match(uiSource, /data-settings-scroll-pane/);
});
test("scrollSettingsAnchorIntoView prefers marked pane over document scroll", async () => {
const { scrollSettingsAnchorIntoView } = await import("./settingsFocus.ts");
const calls: Array<{ top: number; behavior?: ScrollBehavior }> = [];
const scroller = {
scrollHeight: 2000,
clientHeight: 400,
scrollTop: 0,
getBoundingClientRect: () => ({
top: 100,
left: 0,
bottom: 500,
right: 400,
width: 400,
height: 400,
x: 0,
y: 100,
toJSON() {
return this;
},
}),
scrollTo(opts: { top: number; behavior?: ScrollBehavior }) {
calls.push(opts);
this.scrollTop = opts.top;
},
} as unknown as HTMLElement;
const anchor = {
getBoundingClientRect: () => ({
top: 700,
left: 0,
bottom: 760,
right: 200,
width: 200,
height: 60,
x: 0,
y: 700,
toJSON() {
return this;
},
}),
closest(selector: string) {
if (selector === "[data-settings-scroll-pane]") return scroller;
return null;
},
parentElement: scroller,
} as unknown as HTMLElement;
scrollSettingsAnchorIntoView(anchor, "auto");
assert.equal(calls.length, 1);
// elTopInScroller = 700 - 100 + 0 = 600; minus 24 padding → 576
assert.equal(calls[0].top, 576);
assert.equal(calls[0].behavior, "auto");
});
+56 -1
View File
@@ -2,6 +2,8 @@ import { settingsAnchorDomId } from "../../domain/settingsSearchCatalog";
const HIGHLIGHT_CLASS = "settings-search-highlight";
const HIGHLIGHT_MS = 1600;
/** Keep focused anchors slightly below the top of the settings content pane. */
const SCROLL_TOP_PADDING_PX = 24;
export type SettingsFocusTarget = {
tab: string;
@@ -39,12 +41,65 @@ function focusAnchorElement(el: HTMLElement): void {
target.tabIndex = -1;
}
try {
// preventScroll: we own scrolling so the settings titlebar / traffic-light
// chrome never rides along with a viewport-level scrollIntoView.
target.focus({ preventScroll: true });
} catch {
// ignore focus failures in non-interactive hosts
}
}
function isVerticallyScrollable(el: HTMLElement): boolean {
const { overflowY } = window.getComputedStyle(el);
if (overflowY !== "auto" && overflowY !== "scroll" && overflowY !== "overlay") {
return false;
}
return el.scrollHeight > el.clientHeight + 1;
}
/**
* Prefer the dedicated settings content scroller; never fall back to
* document/body scrolling (that lifts the whole window under macOS traffic lights).
*/
export function findSettingsScrollContainer(el: HTMLElement): HTMLElement | null {
// Trust the marked pane even when content is shorter than the viewport —
// search jumps must still scroll this host, not the window.
const marked = el.closest<HTMLElement>("[data-settings-scroll-pane]");
if (marked) return marked;
let node: HTMLElement | null = el.parentElement;
while (node && node !== document.documentElement && node !== document.body) {
if (isVerticallyScrollable(node)) return node;
node = node.parentElement;
}
return null;
}
/**
* Scroll only within the settings content pane so the window chrome stays put.
*/
export function scrollSettingsAnchorIntoView(
el: HTMLElement,
behavior: ScrollBehavior = "smooth",
): void {
const scroller = findSettingsScrollContainer(el);
if (!scroller) {
// Nearest-only, never "center" — avoids yanking the whole settings window.
el.scrollIntoView({ behavior, block: "nearest", inline: "nearest" });
return;
}
const scrollerRect = scroller.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
const elTopInScroller = elRect.top - scrollerRect.top + scroller.scrollTop;
const targetTop = Math.max(0, elTopInScroller - SCROLL_TOP_PADDING_PX);
const maxScroll = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
scroller.scrollTo({
top: Math.min(targetTop, maxScroll),
behavior,
});
}
/**
* Scroll the settings content pane to a catalog anchor and briefly highlight it.
* Retries to cover lazy tab mounts / nested sub-tab switches.
@@ -91,7 +146,7 @@ export function focusSettingsAnchor(
return;
}
el.scrollIntoView({ behavior: "smooth", block: "center" });
scrollSettingsAnchorIntoView(el, "smooth");
el.classList.remove(HIGHLIGHT_CLASS);
void el.offsetWidth;
el.classList.add(HIGHLIGHT_CLASS);
@@ -12,6 +12,8 @@ export default function SettingsShortcutsTab(props: {
setHotkeyScheme: (scheme: HotkeyScheme) => void;
shellOnlyTabNumberShortcuts: boolean;
setShellOnlyTabNumberShortcuts: (enabled: boolean) => void;
showTabNumberBadges: boolean;
setShowTabNumberBadges: (enabled: boolean) => void;
disableTerminalFontZoom: boolean;
setDisableTerminalFontZoom: (enabled: boolean) => void;
keyBindings: KeyBinding[];
@@ -25,6 +27,8 @@ export default function SettingsShortcutsTab(props: {
setHotkeyScheme,
shellOnlyTabNumberShortcuts,
setShellOnlyTabNumberShortcuts,
showTabNumberBadges,
setShowTabNumberBadges,
disableTerminalFontZoom,
setDisableTerminalFontZoom,
keyBindings,
@@ -165,6 +169,16 @@ export default function SettingsShortcutsTab(props: {
onChange={setShellOnlyTabNumberShortcuts}
/>
</SettingRow>
<SettingRow
anchorId="shortcuts-show-tab-number-badges"
label={t("settings.shortcuts.showTabNumberBadges.label")}
description={t("settings.shortcuts.showTabNumberBadges.desc")}
>
<Toggle
checked={showTabNumberBadges}
onChange={setShowTabNumberBadges}
/>
</SettingRow>
</div>
{hotkeyScheme !== "disabled" && (
@@ -1,14 +1,19 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Pencil, Upload, RotateCcw, X } from "lucide-react";
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Pencil, Upload, RotateCcw, X, RefreshCw } from "lucide-react";
import type { ProviderConfig, ProviderAdvancedParams, ProviderStyle } from "../../../../infrastructure/ai/types";
import { PROVIDER_PRESETS, resolveProviderStyle } from "../../../../infrastructure/ai/types";
import { sanitizeContextWindow } from "../../../../infrastructure/ai/contextCompaction";
import {
probeProviderConnection,
validateProviderProbeInputs,
type ProviderProbeHealth,
} from "../../../../infrastructure/ai/providerConnectionProbe";
import { encryptField, decryptField } from "../../../../infrastructure/persistence/secureFieldAdapter";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
import { cn } from "../../../../lib/utils";
import type { BuiltinProviderIcon } from "./types";
import { BUILTIN_PROVIDER_ICONS } from "./types";
import { BUILTIN_PROVIDER_ICONS, getFetchBridge } from "./types";
import type { ProviderFormState } from "./types";
import { ModelSelector } from "./ModelSelector";
import { mergeModelContextWindow } from "./modelMetadata";
@@ -77,6 +82,12 @@ export const ProviderConfigForm: React.FC<{
const [iconError, setIconError] = useState<string | null>(null);
const [contextWindowError, setContextWindowError] = useState<string | null>(null);
const [apiKeySourceVersion, setApiKeySourceVersion] = useState(0);
const [isTesting, setIsTesting] = useState(false);
const [probeResult, setProbeResult] = useState<{
health: ProviderProbeHealth;
message: string;
} | null>(null);
const probeRequestIdRef = useRef(0);
const preset = PROVIDER_PRESETS[provider.providerId];
const resolvedStyle: ProviderStyle = form.style || resolveProviderStyle({ providerId: provider.providerId });
@@ -96,7 +107,22 @@ export const ProviderConfigForm: React.FC<{
preset?.modelsEndpoint,
resolvedStyle,
]);
const probeFingerprint = useMemo(() => JSON.stringify({
baseURL: form.baseURL || preset?.defaultBaseURL || "",
apiKey: form.apiKey,
style: resolvedStyle,
skipTLSVerify: form.skipTLSVerify,
modelsEndpoint: preset?.modelsEndpoint ?? "",
}), [
form.apiKey,
form.baseURL,
form.skipTLSVerify,
preset?.defaultBaseURL,
preset?.modelsEndpoint,
resolvedStyle,
]);
const modelMetadataSourceKeyRef = useRef<string | null>(null);
const probeFingerprintRef = useRef<string | null>(null);
const previewProvider: Pick<ProviderConfig, "providerId" | "name" | "iconId" | "iconDataUrl"> = {
providerId: provider.providerId,
name: form.name,
@@ -133,6 +159,19 @@ export const ProviderConfigForm: React.FC<{
: prev);
}, [modelMetadataSourceKey]);
useEffect(() => {
if (probeFingerprintRef.current == null) {
probeFingerprintRef.current = probeFingerprint;
return;
}
if (probeFingerprintRef.current === probeFingerprint) return;
probeFingerprintRef.current = probeFingerprint;
probeRequestIdRef.current += 1;
setProbeResult(null);
setIsTesting(false);
}, [probeFingerprint]);
const [advancedParamRaw, setAdvancedParamRaw] = useState<Record<string, string>>({});
const handleAdvancedParam = useCallback((key: keyof ProviderAdvancedParams, raw: string) => {
setAdvancedParamRaw((prev) => ({ ...prev, [key]: raw }));
@@ -180,6 +219,90 @@ export const ProviderConfigForm: React.FC<{
setForm((prev) => ({ ...prev, apiKey: value }));
}, []);
const handleTestConnection = useCallback(async () => {
const baseURL = form.baseURL || preset?.defaultBaseURL || "";
const inputCheck = validateProviderProbeInputs({
baseURL,
apiKey: form.apiKey,
providerId: provider.providerId,
});
if (!inputCheck.ok) {
probeRequestIdRef.current += 1;
setIsTesting(false);
setProbeResult({
health: "error",
message: t(
inputCheck.reason === "missing_base_url"
? "ai.providers.test.missingBaseUrl"
: "ai.providers.test.missingApiKey",
),
});
return;
}
const requestId = ++probeRequestIdRef.current;
setIsTesting(true);
setProbeResult(null);
try {
const run = await probeProviderConnection({
bridge: getFetchBridge(),
baseURL,
apiKey: form.apiKey,
providerId: provider.providerId,
style: resolvedStyle,
presetModelsEndpoint: preset?.modelsEndpoint,
skipTLSVerify: form.skipTLSVerify,
});
if (probeRequestIdRef.current !== requestId) return;
if (!run.ok) {
setProbeResult({
health: "error",
message: t(
run.reason === "missing_base_url"
? "ai.providers.test.missingBaseUrl"
: run.reason === "missing_api_key"
? "ai.providers.test.missingApiKey"
: "ai.providers.test.unavailable",
),
});
return;
}
const classified = run.classification;
const latency = String(classified.latencyMs);
if (classified.health === "ok") {
setProbeResult({
health: "ok",
message: t("ai.providers.test.ok", { latency }),
});
} else if (classified.health === "warn") {
const warnKey = classified.modelCount === 0 || classified.error
? "ai.providers.test.warn"
: "ai.providers.test.warnSlow";
setProbeResult({
health: "warn",
message: t(warnKey, { latency }),
});
} else {
const detail = classified.error
|| (classified.statusCode ? `HTTP ${classified.statusCode}` : "error");
setProbeResult({
health: "error",
message: t("ai.providers.test.error", { detail }),
});
}
} catch (err) {
if (probeRequestIdRef.current !== requestId) return;
setProbeResult({
health: "error",
message: t("ai.providers.test.error", {
detail: err instanceof Error ? err.message : String(err),
}),
});
} finally {
if (probeRequestIdRef.current === requestId) setIsTesting(false);
}
}, [form.apiKey, form.baseURL, form.skipTLSVerify, preset?.defaultBaseURL, preset?.modelsEndpoint, provider.providerId, resolvedStyle, t]);
const handleSave = useCallback(async () => {
const cleanedParams: ProviderAdvancedParams = {};
const ap = form.advancedParams;
@@ -534,14 +657,40 @@ export const ProviderConfigForm: React.FC<{
</div>
{/* Actions */}
<div className="flex items-center gap-2 pt-1">
<Button variant="default" size="sm" onClick={() => void handleSave()}>
<Check size={14} className="mr-1.5" />
{t('common.save')}
</Button>
<Button variant="ghost" size="sm" onClick={onCancel}>
{t('common.cancel')}
</Button>
<div className="flex flex-col gap-2 pt-1">
<div className="flex items-center gap-2">
<Button variant="default" size="sm" onClick={() => void handleSave()}>
<Check size={14} className="mr-1.5" />
{t('common.save')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => void handleTestConnection()}
disabled={isTesting || isDecrypting}
>
<RefreshCw size={14} className={cn("mr-1.5", isTesting && "animate-spin")} />
{isTesting ? t('ai.providers.test.testing') : t('ai.providers.test')}
</Button>
<Button variant="ghost" size="sm" onClick={onCancel}>
{t('common.cancel')}
</Button>
</div>
{(isTesting || probeResult) && (
<p
className={cn(
"text-[11px]",
isTesting && "text-muted-foreground",
probeResult?.health === "ok" && "text-emerald-500",
probeResult?.health === "warn" && "text-amber-500",
probeResult?.health === "error" && "text-destructive",
)}
role="status"
aria-live="polite"
>
{isTesting ? t('ai.providers.test.testing') : probeResult?.message}
</p>
)}
</div>
</div>
);
Loaded 100 of 272 files, more files were not shown because too many files have changed in this diff. Show more