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
netcatty-bot ce41379796 fix: address Codex review on PR #2867 2026-08-10 10:04:02 +00:00
bincxz cc8ba20839 fix(update): enable Linux package auto-updates 2026-08-10 17:18:46 +08:00
180 changed files with 10110 additions and 1688 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;
}
+4
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,
@@ -132,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
+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;
}
+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...',
+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': 'Нажмите клавиши...',
@@ -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': '请按键...',
@@ -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,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();
};
}
+12 -5
View File
@@ -982,17 +982,24 @@ export function useAIState() {
latestAIActiveSessionMapSnapshot
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
?? {};
const nextMap = seedWorkspaceAIActiveSessionFromMembers({
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? {};
const seeded = seedWorkspaceAIActiveSessionFromMembers({
activeSessionIdMap: currentMap,
panelViewByScope: currentPanelViewByScope,
workspaceId: input.workspaceId,
memberTerminalIds: input.memberTerminalIds,
preferredTerminalId: input.preferredTerminalId,
});
if (!nextMap) return;
setLatestAIActiveSessionMapSnapshot(nextMap);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextMap);
if (!seeded) return;
setLatestAIActiveSessionMapSnapshot(seeded.activeSessionIdMap);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, seeded.activeSessionIdMap);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
setActiveSessionIdMapRaw(nextMap);
setActiveSessionIdMapRaw(seeded.activeSessionIdMap);
if (seeded.panelViewChanged) {
setLatestAIPanelViewByScopeSnapshot(seeded.panelViewByScope);
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
setPanelViewByScopeRaw(seeded.panelViewByScope);
}
}, []);
const handoffDissolvedWorkspaceScope = useCallback((input: {
+1
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
+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;
}
+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]);
}
+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.
+5 -1
View File
@@ -759,7 +759,11 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
: -1}
onFocus={() => onActiveTreeItemChange(`host:${host.id}`)}
draggable={!isMultiSelectMode}
onDragStart={(e) => e.dataTransfer.setData("host-id", host.id)}
onDragStart={(e) => {
// copyMove: vault reorder uses move; focus-sidebar append uses copy.
e.dataTransfer.effectAllowed = "copyMove";
e.dataTransfer.setData("host-id", host.id);
}}
onClick={() => {
const action = resolveHostActivateAction({
behavior: normalizedHostClickBehavior,
+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}
/>
)}
+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,
+140 -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";
@@ -270,6 +278,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
sessionId,
workspaceId,
restoreState,
vaultInitializedOverride,
pendingInitialCwd,
shellType,
lastCwd,
@@ -359,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);
@@ -775,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,
@@ -2506,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
? []
@@ -2537,6 +2566,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
})) {
connectScriptsConsumedRef.current = true;
}
if (connectScriptsBatchRef.current === batch) {
connectScriptsBatchRef.current = null;
connectScriptsInFlightRef.current = false;
}
return;
}
@@ -2545,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,
@@ -2557,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);
}
@@ -2570,6 +2606,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
},
})
.then(() => {
if (!batchStillActive()) return;
const resolvedAfterRun = resolveConnectScriptsForHost(host, snippets);
const doneAfterRun = resolvedAfterRun.length === 0
|| resolvedAfterRun.every(
@@ -2580,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;
@@ -2595,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,
@@ -2602,6 +2644,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
username: host.username,
},
onScriptComplete: (snippet) => {
if (!batchStillActive()) return;
if (snippet.id) {
pendingScriptRunIdRef.current = snippet.id;
} else {
@@ -2610,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(() => {
@@ -3347,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;
@@ -3371,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) {
@@ -3560,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;
@@ -3579,6 +3697,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
toast.error("Failed to toggle session log");
}
}, [
chooseManualSessionLogPath,
getManualSessionLogStatus,
host.hostname,
host.label,
+24 -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,
@@ -411,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
? {
@@ -578,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());
@@ -613,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;
@@ -830,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);
@@ -1329,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
@@ -1410,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;
@@ -2089,6 +2108,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onCopySession,
onCopySessionToNewWindow,
onRequestAddToWorkspace,
onAppendHostToWorkspace,
onSessionData,
onSetDraggingSessionId,
onSetWorkspaceFocusedSession,
@@ -2155,6 +2175,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sftpHostForTab,
sftpInitialLocationForTab,
sftpPendingUploadsForTab,
sftpPaneClosedTabIdsRef,
sftpRetainedAfterCloseTabIdsRef,
sftpShowHiddenFiles,
SftpSidePanel,
sftpUseCompressedUpload,
+4
View File
@@ -141,6 +141,10 @@ test('popup terminals resolve complete host config and pass jump hosts into Term
assert.match(source, /onDeleteSnippets=\{deleteSelectedSnippets\}/);
});
test('popup terminals use their window-local vault readiness', () => {
assert.match(source, /vaultInitializedOverride=\{vaultInitialized\}/);
});
test('popup provider tree mounts the plugin authentication host', () => {
assert.match(source, /import \{ PluginAuthenticationHost \} from '\.\/plugins\/PluginAuthenticationHost';/);
assert.match(
+1
View File
@@ -467,6 +467,7 @@ function TerminalPopupPageInner() {
accentMode={settings.accentMode}
customAccent={settings.customAccent}
terminalSettings={settings.terminalSettings}
vaultInitializedOverride={vaultInitialized}
disableTerminalFontZoom={settings.disableTerminalFontZoom}
sessionId={sessionId}
startupCommand={isAttachMode ? undefined : config.startupCommand}
+8
View File
@@ -361,6 +361,14 @@ test("workspace session drag data is recognized with a dedicated drag type", ()
assert.equal(getWorkspaceSessionDragId(transfer), "session-1");
});
test("TopTabs wires host drops to workspace tabs without replacing tab reorder", () => {
assert.match(topTabsSource, /appendHostFromWorkspaceDrop/);
assert.match(topTabsSource, /workspaceMap\.has\(targetTabId\)/);
assert.match(topTabsSource, /dropEffect = 'copy'/);
assert.match(topTabsSource, /isHostDropTarget=\{hostDropWorkspaceId === workspace\.id\}/);
assert.match(appViewSource, /<TopTabs[\s\S]*?onAppendHostToWorkspace=\{handleAppendHostToWorkspace\}/);
});
test("workspace session drag id falls back to the legacy session id", () => {
const transfer = {
types: ["session-id"],
+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>
)}
+29 -3
View File
@@ -169,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" },
);
});
+12 -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 };
}
+42 -7
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);
@@ -132,7 +153,11 @@ test("note editor exposes preview and edit modes with a markdown toolbar in edit
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 /);
@@ -164,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,
@@ -194,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 && \(/);
});
@@ -328,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/);
+553 -139
View File
@@ -32,13 +32,26 @@ import {
$createRangeSelection,
$getNearestNodeFromDOMNode,
$getSelection,
$isRangeSelection,
$isTextNode,
$setSelection,
CLEAR_HISTORY_COMMAND,
getNearestEditorFromDOMNode,
} from "lexical";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import React, {
startTransition,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useI18n } from "../../application/i18n/I18nProvider";
import { resolveRenderedMarkdownLinkHref } from "../../domain/notes";
import {
isPointerOnTaskCheckbox,
toggleTaskListItemAtIndex,
} from "../../domain/notes/taskList";
import { buildSshNoteLinkOpenHost } from "../../domain/sshDeepLink";
import { copyToClipboard } from "../keychain/utils";
import { toast } from "../ui/toast";
@@ -46,10 +59,18 @@ import { cn } from "../../lib/utils";
import { FixedSizeVirtualList, type FixedSizeVirtualListHandle } from "../ui/FixedSizeVirtualList";
import type { Host } from "../../types";
import {
normalizeNotePublicAssetPaths,
resolveNoteClipboardPaste,
shouldInsertClipboardTextAsMarkdown,
shouldInterceptResolvedNotePaste,
} from "./noteClipboardPaste";
import { annotateNoteImageSizes } from "./noteImageLayout";
export {
annotateNoteImageSizes,
isNoteSmallImageWidth,
NOTE_SMALL_IMAGE_MAX_WIDTH,
} from "./noteImageLayout";
export {
shouldInsertClipboardTextAsMarkdown,
@@ -62,6 +83,8 @@ export interface InlineMarkdownEditorProps {
value: string;
placeholder: string;
onChange: (value: string) => void;
/** When set, note switches reuse the MDX instance via setMarkdown (no full remount). */
noteId?: string;
hosts?: Host[];
editorMode?: NoteEditorMode;
onOpenHost?: (host: Host) => void;
@@ -239,6 +262,34 @@ export const hasActiveLexicalTextSelection = (target: EventTarget | null): boole
return hasSelection;
};
/**
* Last-resort caret paste after insertMarkdown no-ops (preventDefault already applied).
* Inserts clipboard text at the active Lexical range so non-empty notes are not discarded.
*/
export const insertClipboardTextAtActiveLexicalSelection = (
target: EventTarget | null,
text: string,
): boolean => {
if (!text) return false;
if (typeof Element === "undefined" || typeof Node === "undefined") return false;
const element = target instanceof Element
? target
: target instanceof Node
? target.parentElement
: null;
if (!element) return false;
const lexicalEditor = getNearestEditorFromDOMNode(element);
if (!lexicalEditor) return false;
let didInsert = false;
lexicalEditor.update(() => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) return;
selection.insertText(text);
didInsert = true;
});
return didInsert;
};
/**
* Merge a recovered markdown paste into the current document body.
* Used when Lexical has no caret (or insertMarkdown no-ops after preventDefault).
@@ -278,16 +329,16 @@ export const shouldInterceptNoteMarkdownPaste = (input: {
};
/**
* Lexical `insertMarkdown` often no-ops or stalls on large structured markdown
* after our capture-phase preventDefault. Above this size, prefer a document-
* level setMarkdown merge so long paste is never silently swallowed.
* Long pastes still use insertMarkdown when a caret/selection exists so we do
* not jump content to the document end (document-merge appends).
* Settle attempts scale up for large payloads.
*/
export const NOTE_MARKDOWN_PASTE_INSERT_MAX_CHARS = 4_000;
/** Poll window for deferred insertMarkdown settlement before document recovery. */
export const NOTE_MARKDOWN_PASTE_SETTLE_POLL_MS = 50;
export const NOTE_MARKDOWN_PASTE_SETTLE_MIN_ATTEMPTS = 6;
export const NOTE_MARKDOWN_PASTE_SETTLE_MAX_ATTEMPTS = 24;
export const NOTE_MARKDOWN_PASTE_SETTLE_MAX_ATTEMPTS = 40;
export type NoteMarkdownPasteStrategy = "document-merge" | "insert-at-selection";
@@ -295,17 +346,15 @@ export const resolveNoteMarkdownPasteStrategy = (input: {
canInsertMarkdownAtSelection: boolean;
clipboardText: string;
}): NoteMarkdownPasteStrategy => {
// Only fall back to whole-document merge when Lexical has no caret/selection.
// Forcing document-merge on long pastes used to append at EOF and lose caret.
if (!input.canInsertMarkdownAtSelection) return "document-merge";
// Long structured paste: skip Lexical insert (frequently no-ops / freezes UI).
if (input.clipboardText.length >= NOTE_MARKDOWN_PASTE_INSERT_MAX_CHARS) {
return "document-merge";
}
return "insert-at-selection";
};
/** Scale settle polls with paste size so slow inserts are not treated as no-ops. */
export const resolveNoteMarkdownPasteSettleAttempts = (clipboardLength: number): number => {
const scaled = Math.ceil(clipboardLength / 1_500);
const scaled = Math.ceil(clipboardLength / 1_200);
return Math.min(
NOTE_MARKDOWN_PASTE_SETTLE_MAX_ATTEMPTS,
Math.max(NOTE_MARKDOWN_PASTE_SETTLE_MIN_ATTEMPTS, scaled),
@@ -401,6 +450,19 @@ export const isPointerInsideLinkActionHoverZone = (
&& y <= action.top + LINK_ACTION_SIZE + LINK_ACTION_HOVER_PADDING;
};
/** Avoid React re-renders when the open-link chip does not move. */
export const linkActionStatesEqual = (
a: LinkActionState | null,
b: LinkActionState | null,
): boolean => {
if (a === b) return true;
if (!a || !b) return false;
return a.href === b.href
&& a.label === b.label
&& a.left === b.left
&& a.top === b.top;
};
export const getHostPickerTriggerRange = (textBeforeCursor: string): {
query: string;
startOffset: number;
@@ -530,6 +592,7 @@ export const InlineMarkdownEditor = React.memo(function InlineMarkdownEditor({
value,
placeholder,
onChange,
noteId,
hosts = [],
editorMode: controlledEditorMode,
onOpenHost,
@@ -538,11 +601,26 @@ export const InlineMarkdownEditor = React.memo(function InlineMarkdownEditor({
}: InlineMarkdownEditorProps) {
const { t } = useI18n();
const editorRef = useRef<MDXEditorMethods>(null);
const latestMarkdownRef = useRef(value);
const syncedPropValueRef = useRef(value);
// Display-normalized space (same as setMarkdown / public-asset rewrite).
const latestMarkdownRef = useRef(normalizeNotePublicAssetPaths(value));
const syncedPropValueRef = useRef(normalizeNotePublicAssetPaths(value));
const noteIdRef = useRef(noteId);
// Bumped on unmount / external value sync so deferred paste recovery cannot
// commit into a switched or unmounted note.
const pasteRecoveryGenerationRef = useRef(0);
// Invalidates in-flight deferred setMarkdown when the user switches again.
const contentSwapTokenRef = useRef(0);
const contentSwapPendingRef = useRef(false);
/** Latest markdown the in-flight note-switch import should apply (refreshed on external value). */
const contentSwapScheduledRef = useRef<{
token: number;
noteId: string;
markdown: string;
} | null>(null);
const contentSwapFramesRef = useRef<{ outer: number; inner: number }>({
outer: 0,
inner: 0,
});
const containerRef = useRef<HTMLDivElement>(null);
const lastLinkActivationRef = useRef<{ href: string; at: number } | null>(null);
@@ -555,9 +633,29 @@ export const InlineMarkdownEditor = React.memo(function InlineMarkdownEditor({
top: 32,
});
const [linkAction, setLinkAction] = useState<LinkActionState | null>(null);
const [isContentSwapping, setIsContentSwapping] = useState(false);
const linkActionRef = useRef<LinkActionState | null>(null);
linkActionRef.current = linkAction;
const mouseMoveFrameRef = useRef(0);
const editorMode = controlledEditorMode ?? "edit";
const hostPickerRangeRef = useRef<Range | null>(null);
const hostPickerListRef = useRef<FixedSizeVirtualListHandle>(null);
const hostsRef = useRef(hosts);
hostsRef.current = hosts;
const setLinkActionIfChanged = useCallback((next: LinkActionState | null) => {
if (linkActionStatesEqual(linkActionRef.current, next)) return;
linkActionRef.current = next;
setLinkAction(next);
}, []);
// Rewrite /public/* → /* for Vite static root (edit + preview). Display only;
// onChange still receives editor output (paste path also normalizes).
const displayMarkdown = useMemo(
() => normalizeNotePublicAssetPaths(value),
[value],
);
const plugins = useMemo(() => [
headingsPlugin(),
listsPlugin(),
@@ -592,23 +690,152 @@ export const InlineMarkdownEditor = React.memo(function InlineMarkdownEditor({
return filterHostPickerHosts(hostCandidates, hostPicker.query);
}, [hostCandidates, hostPicker.query]);
const cancelDeferredContentSwap = useCallback(() => {
const frames = contentSwapFramesRef.current;
if (frames.outer) window.cancelAnimationFrame(frames.outer);
if (frames.inner) window.cancelAnimationFrame(frames.inner);
contentSwapFramesRef.current = { outer: 0, inner: 0 };
}, []);
/** Clear shared Lexical undo/redo so Undo after a note switch cannot restore another note. */
const clearLexicalHistory = useCallback(() => {
const container = containerRef.current;
if (!container) return;
const editable = container.querySelector("[contenteditable]");
if (!(editable instanceof Element)) return;
const lexicalEditor = getNearestEditorFromDOMNode(editable);
if (!lexicalEditor) return;
try {
lexicalEditor.dispatchCommand(CLEAR_HISTORY_COMMAND, undefined);
} catch {
// History plugin may not be mounted yet.
}
}, []);
// Swap note content without remounting MDX/Lexical (key=noteId was the main
// switch lag). Parent flushes drafts before changing noteId/value.
// Yield a paint (double rAF) before setMarkdown so the notes tree selection
// can commit first; always show a blocking overlay while Lexical imports so
// toolbar/IME cannot mutate the previous note's tree.
// Cancel pending frames via token/ref only on the next note switch or unmount —
// not on effect re-runs for same-note value churn (that would drop the import).
useEffect(() => {
if (latestMarkdownRef.current === value) {
syncedPropValueRef.current = value;
const markdown = displayMarkdown;
const noteChanged = noteId !== undefined && noteId !== noteIdRef.current;
if (noteChanged) {
const scheduledNoteId = noteId;
noteIdRef.current = noteId;
pasteRecoveryGenerationRef.current += 1;
// Keep both refs in displayMarkdown space so public-path normalization
// does not look like a divergent local draft.
latestMarkdownRef.current = markdown;
syncedPropValueRef.current = markdown;
setHostPicker((current) => (
current.open
? { ...current, open: false, query: "", selectedIndex: 0 }
: current
));
setLinkActionIfChanged(null);
cancelDeferredContentSwap();
const token = contentSwapTokenRef.current + 1;
contentSwapTokenRef.current = token;
contentSwapPendingRef.current = true;
contentSwapScheduledRef.current = { token, noteId: scheduledNoteId, markdown };
setIsContentSwapping(true);
contentSwapFramesRef.current.outer = window.requestAnimationFrame(() => {
contentSwapFramesRef.current.outer = 0;
contentSwapFramesRef.current.inner = window.requestAnimationFrame(() => {
contentSwapFramesRef.current.inner = 0;
if (token !== contentSwapTokenRef.current) return;
const scheduled = contentSwapScheduledRef.current;
if (!scheduled || scheduled.token !== token) {
contentSwapPendingRef.current = false;
startTransition(() => setIsContentSwapping(false));
return;
}
// Drop the import if the user already edited away from the scheduled body.
if (
noteIdRef.current !== scheduled.noteId
|| latestMarkdownRef.current !== scheduled.markdown
) {
contentSwapPendingRef.current = false;
contentSwapScheduledRef.current = null;
startTransition(() => setIsContentSwapping(false));
return;
}
try {
// Use scheduled.markdown (may have been refreshed during the yield).
editorRef.current?.setMarkdown(scheduled.markdown);
clearLexicalHistory();
} catch {
// MDX may not be mounted yet (mode empty-preview); next mount gets markdown prop.
}
contentSwapPendingRef.current = false;
contentSwapScheduledRef.current = null;
startTransition(() => setIsContentSwapping(false));
});
});
return;
}
// Deferred switch still in flight: refresh the payload if the same note's
// external value changed (sync/publish), otherwise skip competing imports.
if (contentSwapPendingRef.current) {
if (
noteId !== undefined
&& noteId === noteIdRef.current
&& contentSwapScheduledRef.current?.noteId === noteId
) {
contentSwapScheduledRef.current = {
...contentSwapScheduledRef.current,
markdown,
};
latestMarkdownRef.current = markdown;
syncedPropValueRef.current = markdown;
}
return;
}
if (latestMarkdownRef.current === value || latestMarkdownRef.current === markdown) {
latestMarkdownRef.current = markdown;
syncedPropValueRef.current = markdown;
return;
}
// Local draft diverged from last external value — do not clobber.
if (latestMarkdownRef.current !== syncedPropValueRef.current) {
return;
}
pasteRecoveryGenerationRef.current += 1;
syncedPropValueRef.current = value;
latestMarkdownRef.current = value;
editorRef.current?.setMarkdown(value);
}, [value]);
syncedPropValueRef.current = markdown;
latestMarkdownRef.current = markdown;
try {
editorRef.current?.setMarkdown(markdown);
clearLexicalHistory();
} catch {
// ignore
}
}, [
noteId,
value,
displayMarkdown,
setLinkActionIfChanged,
cancelDeferredContentSwap,
clearLexicalHistory,
]);
useEffect(() => () => {
pasteRecoveryGenerationRef.current += 1;
}, []);
contentSwapTokenRef.current += 1;
contentSwapPendingRef.current = false;
cancelDeferredContentSwap();
if (mouseMoveFrameRef.current) {
window.cancelAnimationFrame(mouseMoveFrameRef.current);
mouseMoveFrameRef.current = 0;
}
}, [cancelDeferredContentSwap]);
useEffect(() => {
if (!hostPicker.open) return;
@@ -703,13 +930,14 @@ export const InlineMarkdownEditor = React.memo(function InlineMarkdownEditor({
const annotateHostLinks = useCallback(() => {
const container = containerRef.current;
if (!container) return;
const hostsSnapshot = hostsRef.current;
container.querySelectorAll<HTMLAnchorElement>(".netcatty-mdx-content a[href]").forEach((link) => {
const renderedHref = link.getAttribute("href") || link.href;
const label = link.textContent?.trim() || renderedHref;
if (!renderedHref) return;
const href = resolveRenderedMarkdownLinkHref(latestMarkdownRef.current, label, renderedHref);
const host = buildSshNoteLinkOpenHost(hosts, href, label, {
const host = buildSshNoteLinkOpenHost(hostsSnapshot, href, label, {
id: "note-link-preview",
now: 0,
});
@@ -722,40 +950,11 @@ export const InlineMarkdownEditor = React.memo(function InlineMarkdownEditor({
link.removeAttribute("title");
}
});
}, [hosts]);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
let frame = 0;
const schedule = () => {
if (frame) return;
frame = window.requestAnimationFrame(() => {
frame = 0;
annotateHostLinks();
});
};
schedule();
if (editorMode !== "preview") {
return () => {
if (frame) window.cancelAnimationFrame(frame);
};
}
const observer = new MutationObserver(schedule);
observer.observe(container, { childList: true, subtree: true });
return () => {
observer.disconnect();
if (frame) window.cancelAnimationFrame(frame);
};
}, [annotateHostLinks, editorMode]);
}, []);
const annotateCodeBlockCopyButtons = useCallback(() => {
const container = containerRef.current;
if (!container) return;
annotateNoteCodeBlockCopyButtons(container, {
copyLabel: t("action.copy"),
copiedLabel: t("notes.codeBlock.copied"),
@@ -764,28 +963,70 @@ export const InlineMarkdownEditor = React.memo(function InlineMarkdownEditor({
});
}, [t]);
// DOM decoration: host links, image sizes, preview code-copy buttons.
// Preview observes mutations (MDX mounts); edit debounces to avoid per-keystroke walks.
useEffect(() => {
const container = containerRef.current;
if (!container) return;
if (editorMode !== "preview") {
removeNoteCodeBlockCopyButtons(container);
return;
}
let frame = 0;
let debounceTimer = 0;
const EDIT_DECORATION_DEBOUNCE_MS = 180;
annotateCodeBlockCopyButtons();
const observer = new MutationObserver(() => {
annotateCodeBlockCopyButtons();
const runDecorations = (includeHostLinks: boolean) => {
annotateNoteImageSizes(container);
if (includeHostLinks) annotateHostLinks();
if (editorMode === "preview") {
annotateCodeBlockCopyButtons();
} else {
removeNoteCodeBlockCopyButtons(container);
}
};
const scheduleFromMutation = () => {
if (editorMode === "preview") {
if (frame) return;
frame = window.requestAnimationFrame(() => {
frame = 0;
runDecorations(true);
});
return;
}
// Debounced: still re-annotate host links after setMarkdown swaps in edit
// mode (childList) and after image width/height edits (attributes).
if (debounceTimer) window.clearTimeout(debounceTimer);
debounceTimer = window.setTimeout(() => {
debounceTimer = 0;
runDecorations(true);
}, EDIT_DECORATION_DEBOUNCE_MS);
};
runDecorations(true);
const observer = new MutationObserver(scheduleFromMutation);
observer.observe(container, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["width", "height", "src", "href"],
});
observer.observe(container, { childList: true, subtree: true });
return () => {
observer.disconnect();
if (frame) window.cancelAnimationFrame(frame);
if (debounceTimer) window.clearTimeout(debounceTimer);
removeNoteCodeBlockCopyButtons(container);
};
}, [annotateCodeBlockCopyButtons, editorMode, value]);
}, [annotateCodeBlockCopyButtons, annotateHostLinks, editorMode]);
// Host list identity can change while a note stays open (vault refresh).
useEffect(() => {
annotateHostLinks();
}, [annotateHostLinks, hosts]);
const commitMarkdown = useCallback((markdown: string) => {
// Deferred note-switch still shows the previous Lexical tree; ignore muted
// or stale onChange so we never write note A's body into note B's draft.
if (contentSwapPendingRef.current) return;
if (markdown === latestMarkdownRef.current) return;
latestMarkdownRef.current = markdown;
onChange(markdown);
@@ -884,30 +1125,63 @@ export const InlineMarkdownEditor = React.memo(function InlineMarkdownEditor({
}, [hosts, onOpenExternalLink, onOpenHost]);
const handleClickCapture = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
if (editorMode !== "preview") {
scheduleHostPickerUpdate();
return;
}
if (contentSwapPendingRef.current) return;
const target = event.target;
if (!(target instanceof Element)) return;
const link = target.closest<HTMLAnchorElement>("a[href]");
const renderedHref = link?.getAttribute("href") || link?.href;
if (!link || !renderedHref || !containerRef.current?.contains(link)) return;
const container = containerRef.current;
if (!container?.contains(target)) return;
const label = link.textContent?.trim() || renderedHref;
const href = resolveRenderedMarkdownLinkHref(
latestMarkdownRef.current,
label,
renderedHref,
);
const handled = openLink(href, label);
if (!handled) return;
// Preview is readOnly — Lexical CheckListPlugin refuses toggles. Map the
// checkbox click back onto GFM markdown so todos stay interactive.
if (editorMode === "preview") {
const taskItem = target.closest<HTMLElement>("li[role='checkbox'], li[aria-checked]");
if (taskItem && container.contains(taskItem)) {
const itemRect = taskItem.getBoundingClientRect();
if (isPointerOnTaskCheckbox(itemRect, event.clientX)) {
const items = container.querySelectorAll<HTMLElement>(
"li[role='checkbox'], li[aria-checked]",
);
const index = Array.prototype.indexOf.call(items, taskItem);
if (index >= 0) {
const next = toggleTaskListItemAtIndex(latestMarkdownRef.current, index);
if (next !== latestMarkdownRef.current) {
event.preventDefault();
event.stopPropagation();
event.nativeEvent.stopImmediatePropagation?.();
commitMarkdown(next);
try {
editorRef.current?.setMarkdown(next);
} catch {
// ignore — next mount/sync will pick up value
}
return;
}
}
}
}
event.preventDefault();
event.stopPropagation();
event.nativeEvent.stopImmediatePropagation?.();
}, [editorMode, openLink, scheduleHostPickerUpdate]);
const link = target.closest<HTMLAnchorElement>("a[href]");
const renderedHref = link?.getAttribute("href") || link?.href;
if (!link || !renderedHref || !container.contains(link)) return;
const label = link.textContent?.trim() || renderedHref;
const href = resolveRenderedMarkdownLinkHref(
latestMarkdownRef.current,
label,
renderedHref,
);
const handled = openLink(href, label);
if (!handled) return;
event.preventDefault();
event.stopPropagation();
event.nativeEvent.stopImmediatePropagation?.();
return;
}
scheduleHostPickerUpdate();
}, [commitMarkdown, editorMode, openLink, scheduleHostPickerUpdate]);
const activateLinkAction = useCallback((
event: React.SyntheticEvent<HTMLElement>,
@@ -922,56 +1196,72 @@ export const InlineMarkdownEditor = React.memo(function InlineMarkdownEditor({
}
lastLinkActivationRef.current = { href: action.href, at: now };
openLink(action.href, action.label);
setLinkAction(null);
}, [openLink]);
setLinkActionIfChanged(null);
}, [openLink, setLinkActionIfChanged]);
const handleMouseMoveCapture = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
if (editorMode !== "edit") {
setLinkAction(null);
setLinkActionIfChanged(null);
return;
}
// Coalesce to one evaluation per frame while scrubbing links in large docs.
const clientX = event.clientX;
const clientY = event.clientY;
const target = event.target;
if (!(target instanceof Element)) return;
if (target.closest("[data-note-link-action]")) return;
if (mouseMoveFrameRef.current) {
window.cancelAnimationFrame(mouseMoveFrameRef.current);
}
mouseMoveFrameRef.current = window.requestAnimationFrame(() => {
mouseMoveFrameRef.current = 0;
if (!(target instanceof Element)) return;
if (target.closest("[data-note-link-action]")) return;
const link = target.closest<HTMLAnchorElement>("a[href]");
const renderedHref = link?.getAttribute("href") || link?.href;
const container = containerRef.current;
if (!container) return;
const containerRect = container.getBoundingClientRect();
const pointerX = event.clientX - containerRect.left;
const pointerY = event.clientY - containerRect.top;
const link = target.closest<HTMLAnchorElement>("a[href]");
const renderedHref = link?.getAttribute("href") || link?.href;
const container = containerRef.current;
if (!container) return;
const containerRect = container.getBoundingClientRect();
const pointerX = clientX - containerRect.left;
const pointerY = clientY - containerRect.top;
if (!link || !renderedHref) {
if (!isPointerInsideLinkActionHoverZone(linkAction, pointerX, pointerY)) {
setLinkAction(null);
if (!link || !renderedHref) {
if (!isPointerInsideLinkActionHoverZone(linkActionRef.current, pointerX, pointerY)) {
setLinkActionIfChanged(null);
}
return;
}
return;
}
const label = link.textContent?.trim() || renderedHref;
const href = resolveRenderedMarkdownLinkHref(
latestMarkdownRef.current,
label,
renderedHref,
);
const canOpenLink = Boolean(buildSshNoteLinkOpenHost(hosts, href, label, {
id: "note-link-hover",
now: 0,
})) || isSupportedNoteExternalHref(href);
if (!canOpenLink) {
setLinkAction(null);
return;
}
const linkRect = link.getBoundingClientRect();
setLinkAction({
href,
label,
left: Math.max(0, Math.min(containerRect.width - LINK_ACTION_SIZE - 6, linkRect.right - containerRect.left + 2)),
top: Math.max(0, linkRect.top - containerRect.top - 2),
const label = link.textContent?.trim() || renderedHref;
// Fast path: skip full-markdown scan when the rendered href is already openable.
let href = renderedHref;
if (
!isSupportedNoteExternalHref(renderedHref)
&& !/^ssh:/i.test(renderedHref)
) {
href = resolveRenderedMarkdownLinkHref(
latestMarkdownRef.current,
label,
renderedHref,
);
}
const canOpenLink = Boolean(buildSshNoteLinkOpenHost(hostsRef.current, href, label, {
id: "note-link-hover",
now: 0,
})) || isSupportedNoteExternalHref(href);
if (!canOpenLink) {
setLinkActionIfChanged(null);
return;
}
const linkRect = link.getBoundingClientRect();
setLinkActionIfChanged({
href,
label,
left: Math.max(0, Math.min(containerRect.width - LINK_ACTION_SIZE - 6, linkRect.right - containerRect.left + 2)),
top: Math.max(0, linkRect.top - containerRect.top - 2),
});
});
}, [editorMode, hosts, linkAction]);
}, [editorMode, setLinkActionIfChanged]);
const handleBlurCapture = useCallback((event: React.FocusEvent<HTMLDivElement>) => {
const nextTarget = event.relatedTarget;
@@ -1021,44 +1311,126 @@ export const InlineMarkdownEditor = React.memo(function InlineMarkdownEditor({
clipboardText: markdown,
});
// No caret, or long structured paste: document merge (setMarkdown + commit).
// insertMarkdown frequently no-ops / stalls on large markdown after preventDefault.
// Document merge (append) only when there is no caret. With a selection,
// always insertMarkdown and never fall back to EOF append.
if (strategy === "document-merge") {
applyDocumentPaste();
} else {
const before = latestMarkdownRef.current;
// Baseline the live Lexical export — after muted setMarkdown (note switch),
// latestMarkdownRef can still be the input string while getMarkdown() is the
// round-tripped export. Comparing against the ref alone can false-positive
// "paste applied" and drop the clipboard body after preventDefault.
const beforeLatest = latestMarkdownRef.current;
let beforeEditor = beforeLatest;
try {
beforeEditor = editor.getMarkdown();
} catch {
beforeEditor = beforeLatest;
}
const recoveryGeneration = ++pasteRecoveryGenerationRef.current;
const maxAttempts = resolveNoteMarkdownPasteSettleAttempts(markdown.length);
const emptyDoc = !beforeEditor.replace(/\s+/g, "");
const pasteTarget = event.target;
// After the first insert is definitively unchanged, retry once at the
// selection — never mid-window (that can double-apply a merely-slow update).
const postFailureSettleAttempts = Math.max(
3,
Math.floor(NOTE_MARKDOWN_PASTE_SETTLE_MIN_ATTEMPTS / 2),
);
const draftStillOurs = () => (
latestMarkdownRef.current === beforeLatest
|| latestMarkdownRef.current === beforeEditor
);
const recoverInterceptedPasteAtSelection = () => {
if (pasteRecoveryGenerationRef.current !== recoveryGeneration) return;
if (!draftStillOurs()) return;
// First insert may still be in flight past the poll window — re-check
// before a second insertMarkdown to avoid double-applying a slow update.
let live = beforeEditor;
try {
live = editor.getMarkdown();
} catch {
live = beforeEditor;
}
if (live !== beforeEditor) {
commitMarkdown(live);
return;
}
try {
editor.focus();
editor.insertMarkdown(markdown);
} catch {
// Fall through to Lexical text insert below after settle.
}
const tryCommitRecoveredPaste = (attempt: number) => {
if (pasteRecoveryGenerationRef.current !== recoveryGeneration) return;
if (!draftStillOurs()) return;
let current = beforeEditor;
try {
current = editor.getMarkdown();
} catch {
current = beforeEditor;
}
if (current !== beforeEditor) {
commitMarkdown(current);
return;
}
if (attempt < postFailureSettleAttempts) {
window.setTimeout(
() => tryCommitRecoveredPaste(attempt + 1),
NOTE_MARKDOWN_PASTE_SETTLE_POLL_MS,
);
return;
}
// insertMarkdown still no-op'd: keep caret locus via Lexical insertText.
if (!insertClipboardTextAtActiveLexicalSelection(pasteTarget, markdown)) return;
try {
const next = editor.getMarkdown();
if (next !== beforeEditor) commitMarkdown(next);
} catch {
// Selection insert may not serialize; avoid discarding silently only
// when Lexical accepted the text (didInsert). Nothing more to commit.
}
};
window.setTimeout(
() => tryCommitRecoveredPaste(0),
NOTE_MARKDOWN_PASTE_SETTLE_POLL_MS,
);
};
try {
editor.focus();
editor.insertMarkdown(markdown);
} catch {
// Lexical/MDX can throw on pathological markdown; never swallow the paste.
applyDocumentPaste();
// Only append when the document is empty — never jump mid-doc paste to EOF.
if (emptyDoc) applyDocumentPaste();
else recoverInterceptedPasteAtSelection();
setHostPicker((current) => ({ ...current, open: false, query: "", selectedIndex: 0 }));
setLinkAction(null);
return;
}
// insertMarkdown's Lexical update is deferred. If onChange is muted/missed but
// the document changed, commit; if it still no-ops after the settle window,
// recover via document merge (gated on generation + unchanged body so a late
// insert cannot double-apply).
// insertMarkdown's Lexical update is deferred. Commit when settled; if it
// still no-ops after the settle window, recover at the selection (or append
// only for an empty document).
const tryCommitSettledPaste = (attempt: number) => {
if (pasteRecoveryGenerationRef.current !== recoveryGeneration) return;
if (latestMarkdownRef.current !== before) return;
let current = before;
if (!draftStillOurs()) return;
let current = beforeEditor;
try {
current = editor.getMarkdown();
} catch {
applyDocumentPaste();
if (emptyDoc) applyDocumentPaste();
else recoverInterceptedPasteAtSelection();
return;
}
if (current !== before) {
if (current !== beforeEditor) {
commitMarkdown(current);
return;
}
if (attempt >= maxAttempts) {
applyDocumentPaste();
if (emptyDoc) applyDocumentPaste();
else recoverInterceptedPasteAtSelection();
return;
}
window.setTimeout(
@@ -1076,19 +1448,58 @@ export const InlineMarkdownEditor = React.memo(function InlineMarkdownEditor({
setLinkAction(null);
}, [commitMarkdown, editorMode]);
const blockWhileContentSwapping = useCallback((event: React.SyntheticEvent) => {
if (!contentSwapPendingRef.current) return false;
event.preventDefault();
event.stopPropagation();
event.nativeEvent.stopImmediatePropagation?.();
return true;
}, []);
return (
<div
ref={containerRef}
className="relative flex h-full flex-col"
aria-busy={isContentSwapping || undefined}
onBlurCapture={handleBlurCapture}
onClickCapture={handleClickCapture}
onInputCapture={scheduleHostPickerUpdate}
onKeyDownCapture={handleKeyDownCapture}
onClickCapture={(event) => {
if (blockWhileContentSwapping(event)) return;
handleClickCapture(event);
}}
onPointerDownCapture={blockWhileContentSwapping}
onDragStartCapture={blockWhileContentSwapping}
onDropCapture={blockWhileContentSwapping}
onInputCapture={(event) => {
if (blockWhileContentSwapping(event)) return;
scheduleHostPickerUpdate();
}}
onKeyDownCapture={(event) => {
// Block edits while Lexical import is deferred/running (toolbar/IME too).
if (blockWhileContentSwapping(event)) return;
handleKeyDownCapture(event);
}}
onKeyUpCapture={handleKeyUpCapture}
onMouseLeave={() => setLinkAction(null)}
onMouseLeave={() => {
if (mouseMoveFrameRef.current) {
window.cancelAnimationFrame(mouseMoveFrameRef.current);
mouseMoveFrameRef.current = 0;
}
setLinkActionIfChanged(null);
}}
onMouseMoveCapture={handleMouseMoveCapture}
onPasteCapture={handlePasteCapture}
onPasteCapture={(event) => {
if (blockWhileContentSwapping(event)) return;
handlePasteCapture(event);
}}
>
{isContentSwapping && (
// Instant solid cover — no fade/opacity animation (composites poorly over Lexical).
<div
className="absolute inset-0 z-20 bg-background"
data-notes-content-swapping="true"
aria-hidden="true"
/>
)}
{editorMode === "edit" && linkAction && (
<button
type="button"
@@ -1153,7 +1564,7 @@ export const InlineMarkdownEditor = React.memo(function InlineMarkdownEditor({
</div>
</div>
)}
{editorMode === "preview" && !value.trim() ? (
{editorMode === "preview" && !displayMarkdown.trim() ? (
<div className="netcatty-note-preview-empty">
{previewEmptyLabel ?? placeholder}
</div>
@@ -1161,11 +1572,14 @@ export const InlineMarkdownEditor = React.memo(function InlineMarkdownEditor({
<MDXEditor
key={editorMode}
ref={editorRef}
markdown={value}
markdown={displayMarkdown}
placeholder={placeholder}
plugins={plugins}
readOnly={editorMode === "preview"}
className={cn("netcatty-mdx-editor", editorMode === "preview" && "netcatty-mdx-editor--preview")}
className={cn(
"netcatty-mdx-editor",
editorMode === "preview" && "netcatty-mdx-editor--preview",
)}
contentEditableClassName="netcatty-mdx-content"
onChange={commitMarkdown}
/>
+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}
+12 -296
View File
@@ -1,313 +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 { readFileSync } from "node:fs";
import test from "node:test";
import {
convertClipboardHtmlToMarkdown,
convertHtmlIslandsInMarkdown,
normalizeLinkedBadgeImages,
normalizePastedNoteMarkdown,
resolveNoteClipboardPaste,
serializeSafeHtmlImage,
shouldInterceptResolvedNotePaste,
shouldInsertClipboardTextAsMarkdown,
} from "./noteClipboardPaste.ts";
const CATTY_PASTE = `---
test("noteClipboardPaste re-exports domain resolve + convert helpers", () => {
assert.equal(shouldInsertClipboardTextAsMarkdown("# Title\n\n- item"), true);
assert.equal(shouldInsertClipboardTextAsMarkdown("plain only"), false);
<img width="3142" height="1764" alt="Screenshot 2026-07-02 at 22 51 24" src="https://github.com/user-attachments/assets/3116165d-623a-4d3a-a28a-914befb9b72d" />
---
<a name="catty-agent"></a>
# 🔥 Catty Agent — Your IT Ops AI Partner
> 🚀 **Boost your IT ops daily work with AI power.** Catty Agent is the built-in AI assistant that understands your servers, executes commands, and handles complex multi-host operations — all through natural conversation.
### 🔥 What can Catty Agent do?
- 🚀 **Natural language server management** — just tell it what you need, no more memorizing commands
- 🔥 **Real-time server diagnostics** — check status, inspect logs, monitor resources through conversation
`;
test("centered README hero blocks wrap in div align=center", () => {
const html = `
<p align="center">
<img src="https://example.com/icon.png" alt="Netcatty" width="128" height="128">
</p>
<h1 align="center">Netcatty</h1>
<p align="center">
<strong>🔥 AI-Powered SSH Client</strong><br/>
<a href="https://netcatty.app">netcatty.app</a>
</p>
`;
const md = convertClipboardHtmlToMarkdown(html);
assert.match(md, /<div align="center">/);
assert.match(md, /<\/div>/);
assert.match(md, /width="128"/);
assert.match(md, /height="128"/);
assert.match(md, /# Netcatty/);
assert.match(md, /netcatty\.app/);
const centerIdx = md.indexOf('<div align="center">');
const logoIdx = md.search(/icon\.png|# Netcatty/);
assert.ok(centerIdx >= 0 && logoIdx >= 0 && centerIdx < logoIdx);
});
test("island conversion keeps center on p align=center with image", () => {
const plain = `
<p align="center">
<img src="https://example.com/icon.png" alt="Netcatty" width="128" height="128">
</p>
<h1 align="center">Netcatty</h1>
`;
const md = convertHtmlIslandsInMarkdown(plain);
assert.match(md, /<div align="center">/);
assert.match(md, /width="128"/);
assert.match(md, /Netcatty/);
});
test("relative public/ image paths are kept (not silently dropped)", () => {
const md = convertHtmlIslandsInMarkdown(
'<p align="center"><img src="public/icon.png" alt="Netcatty" width="128" height="128"></p>',
);
assert.match(md, /src="public\/icon\.png"/);
assert.match(md, /width="128"/);
});
test("turndown converts pure html clipboard", () => {
const html = `
<html><body>
<!--StartFragment-->
<h1>Runbook</h1>
<p>Restart <strong>sshd</strong> on <em>prod</em>.</p>
<ul><li>check logs</li><li>open <a href="https://example.com">docs</a></li></ul>
<img alt="shot" src="https://example.com/a.png" />
<!--EndFragment-->
</body></html>
`;
const md = convertClipboardHtmlToMarkdown(html);
assert.match(md, /^# Runbook/m);
assert.match(md, /\*\*sshd\*\*/);
assert.match(md, /\[docs\]\(https:\/\/example\.com\)/);
assert.match(md, /!\[shot\]\(https:\/\/example\.com\/a\.png\)/);
});
test("screenshot images keep width and height attributes", () => {
const md = convertHtmlIslandsInMarkdown(CATTY_PASTE);
assert.match(md, /^# 🔥 Catty Agent/m);
assert.match(
md,
/<img\b[^>]*src="https:\/\/github\.com\/user-attachments\/assets\/3116165d-623a-4d3a-a28a-914befb9b72d"/,
);
assert.match(md, /width="3142"/);
assert.match(md, /height="1764"/);
assert.match(md, /alt="Screenshot 2026-07-02 at 22 51 24"/);
assert.doesNotMatch(md, /\\#/);
assert.doesNotMatch(md, /<a\s+name=/i);
});
test("serializeSafeHtmlImage preserves dimensions when present", () => {
assert.equal(
serializeSafeHtmlImage({
src: "https://example.com/a.png",
alt: "shot",
}),
"![shot](https://example.com/a.png)",
);
assert.match(
serializeSafeHtmlImage({
src: "https://example.com/a.png",
alt: "shot",
width: 3142,
height: 1764,
}),
/<img\b[^>]*width="3142"[^>]*height="1764"[^>]*\/>/,
);
assert.match(
serializeSafeHtmlImage({
src: "https://cdn.ko-fi.com/cdn/kofi3.png?v=2",
alt: "Support on Ko-fi",
width: 150,
}),
/width="150"/,
);
assert.match(
serializeSafeHtmlImage({
src: "https://example.com/icon.png",
alt: "icon",
height: 24,
}),
/<img\b[^>]*height="24"[^>]*\/>/,
);
});
test("serializeSafeHtmlImage keeps relative paths; rejects data/javascript", () => {
// Relative README paths are kept (may 404 in-app, but must not vanish on paste).
assert.equal(
serializeSafeHtmlImage({ src: "./docs/screenshot.png", alt: "shot" }),
"![shot](./docs/screenshot.png)",
);
assert.equal(
serializeSafeHtmlImage({ src: "public/icon.png", alt: "logo" }),
"![logo](public/icon.png)",
);
assert.equal(
serializeSafeHtmlImage({
src: "//cdn.example.com/a.png",
alt: "cdn",
}),
"![cdn](//cdn.example.com/a.png)",
);
assert.equal(
serializeSafeHtmlImage({ src: "data:image/png;base64,aaa", alt: "x" }),
"",
);
assert.equal(
serializeSafeHtmlImage({ src: "javascript:alert(1)", alt: "x" }),
"",
);
});
test("linked badge images stay as images (tight single-line / a>img), not text-only", () => {
const source = [
"[![GitHub Release](https://img.shields.io/github/v/release/binaricat/Netcatty)](https://github.com/binaricat/Netcatty/releases/latest)",
"",
"[ ",
"![Platform](https://img.shields.io/badge/Platform-macOS-blue)",
" ](#)",
"",
"[",
'<img alt="Support on Ko-fi" width="150" src="https://cdn.ko-fi.com/cdn/kofi3.png?v=2" />',
"](https://ko-fi.com/binaricat)",
"",
'<a href="https://example.com/dl"><img alt="Download" src="https://img.shields.io/badge/Download-latest-success" /></a>',
].join("\n");
const md = normalizeLinkedBadgeImages(source);
// Markdown linked image kept (with image src), not reduced to text-only [GitHub Release](url).
assert.match(
md,
/\[!\[GitHub Release\]\(https:\/\/img\.shields\.io\/github\/v\/release\/binaricat\/Netcatty\)\]\(https:\/\/github\.com\/binaricat\/Netcatty\/releases\/latest\)/,
);
assert.match(md, /\[!\[Platform\]\(https:\/\/img\.shields\.io\/badge\/Platform-macOS-blue\)\]\(#\)/);
// HTML img with width inside link → <a><img width></a>
assert.match(md, /<a href="https:\/\/ko-fi\.com\/binaricat"><img\b[^>]*src="https:\/\/cdn\.ko-fi\.com\/cdn\/kofi3\.png\?v=2"/);
// Dimension-less shield inside <a> → linked markdown image
assert.match(
md,
/\[!\[Download\]\(https:\/\/img\.shields\.io\/badge\/Download-latest-success\)\]\(https:\/\/example\.com\/dl\)/,
);
assert.doesNotMatch(md, /^\s*\]\(/m);
// Not text-only badge (must keep image syntax).
assert.doesNotMatch(
md,
/(?<!!)\[GitHub Release\]\(https:\/\/github\.com\/binaricat\/Netcatty\/releases\/latest\)/,
);
});
test("normalize removes orphan link closers but keeps image dimensions", () => {
const messy = [
"Intro",
"](https://example.com/orphan)",
'<img width="2000" height="1000" alt="wide" src="https://example.com/w.png" />',
"Done",
].join("\n");
const md = normalizePastedNoteMarkdown(messy);
assert.doesNotMatch(md, /\]\(https:\/\/example\.com\/orphan\)/);
assert.match(md, /src="https:\/\/example\.com\/w\.png"/);
assert.match(md, /width="2000"/);
assert.match(md, /height="1000"/);
});
test("normalize keeps link-closer lines inside fenced and indented code", () => {
const source = [
"Before",
"](https://example.com/orphan)",
"```md",
"](https://example.com)",
"```",
"",
" ](https://example.com/indented)",
"After",
].join("\n");
const md = normalizePastedNoteMarkdown(source);
assert.doesNotMatch(md, /^\]\(https:\/\/example\.com\/orphan\)$/m);
assert.match(md, /```md\n\]\(https:\/\/example\.com\)\n```/);
assert.match(md, /^ {4}\]\(https:\/\/example\.com\/indented\)$/m);
});
test("resolve pastes Catty-style mixed markdown+html with image sizes", () => {
const payload = resolveNoteClipboardPaste({
plainText: CATTY_PASTE,
htmlText: "",
});
assert.ok(payload.kind === "html-converted" || payload.kind === "markdown");
assert.equal(
shouldInterceptResolvedNotePaste({
editorMode: "edit",
pasteInsideCodeBlock: false,
payload,
}),
true,
);
assert.match(payload.text, /^# 🔥 Catty Agent/m);
assert.match(payload.text, /width="3142"/);
assert.match(payload.text, /height="1764"/);
});
test("repo README paste collapses shields badges without debris", () => {
const readmeHead = readFileSync(new URL("../../README.md", import.meta.url), "utf8").slice(0, 2200);
const payload = resolveNoteClipboardPaste({ plainText: readmeHead, htmlText: "" });
assert.ok(payload.text.length > 50);
assert.doesNotMatch(payload.text, /^\s*\]\([^)\n]+\)\s*$/m);
// Large screenshot keeps dimensions in source.
assert.match(payload.text, /width="3142"/);
assert.match(payload.text, /height="1764"/);
});
test("resolve uses full turndown for browser StartFragment html", () => {
const payload = resolveNoteClipboardPaste({
plainText: "flat text without structure",
htmlText: `
<html><body>
<!--StartFragment-->
<h1>From browser</h1>
<p>Hello <b>world</b></p>
<img alt="x" src="https://cdn.example.com/x.png" width="2000" height="1000" />
<!--EndFragment-->
</body></html>
`,
});
assert.equal(payload.kind, "html-converted");
assert.match(payload.text, /^# From browser/m);
assert.match(payload.text, /\*\*world\*\*/);
assert.match(payload.text, /src="https:\/\/cdn\.example\.com\/x\.png"/);
assert.match(payload.text, /width="2000"/);
assert.match(payload.text, /height="1000"/);
});
test("resolve uses structured plain markdown when html is absent", () => {
const payload = resolveNoteClipboardPaste({
plainText: "# From .md file\n\n- item",
plainText: "# From re-export\n\n- a",
htmlText: "",
});
assert.equal(payload.kind, "markdown");
assert.match(payload.text, /# From \.md file/);
});
assert.match(payload.text, /# From re-export/);
test("plain unstructured text is not intercepted", () => {
assert.equal(shouldInsertClipboardTextAsMarkdown("hello world"), false);
const payload = resolveNoteClipboardPaste({
plainText: "hello world",
htmlText: "",
});
assert.equal(payload.kind, "plain");
assert.equal(
shouldInterceptResolvedNotePaste({
editorMode: "edit",
pasteInsideCodeBlock: false,
payload,
}),
false,
const md = convertClipboardHtmlToMarkdown(
"<html><body><!--StartFragment--><h1>Hi</h1><!--EndFragment--></body></html>",
);
assert.match(md, /^# Hi/m);
});
+29 -594
View File
@@ -1,596 +1,31 @@
/**
* Clipboard resolution for vault markdown notes.
*
* Product policy (lossy but clean — not GitHub README clone):
* - HTML → Markdown via Turndown (+ island conversion for mixed sources)
* - Linked badge images become plain text links (no broken ](url) debris)
* - Image width/height attrs are preserved in source; CSS max-width:100%
* scales large screenshots in the side panel without cropping
* - Centered blocks (align=center / text-align:center) wrap as
* <div align="center"> so MDX GenericHTML + CSS keep hero title/logo centered
* UI re-export of note clipboard paste policy.
* Implementation lives in domain/notes/clipboardPaste.ts (pure, no React).
*/
import TurndownService from "turndown";
import { gfm } from "turndown-plugin-gfm";
export type NoteClipboardPasteKind =
| "markdown"
| "html-converted"
| "plain"
| "empty";
export type NoteClipboardPastePayload = {
text: string;
kind: NoteClipboardPasteKind;
};
const PASTED_MARKDOWN_PATTERNS = [
/^ {0,3}#{1,6}\s+\S/m,
/^ {0,3}(?:[-+*]|\d+[.)])\s+\S/m,
/^ {0,3}>\s+\S/m,
/^ {0,3}(?:```|~~~)/m,
/^ {0,3}[-*_](?:\s*[-*_]){2,}\s*$/m,
/^ {0,3}\|?.+\|.+\n {0,3}\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$/m,
/(^|[^!])\[[^\]\n]+\]\([^) \n]+(?:\s+"[^"\n]*")?\)/,
/(^|[\s([{])(?:\*\*|__)\S[\s\S]*?\S(?:\*\*|__)(?=$|[\s\])}.,;:!?])/,
/(^|[\s([{])`[^`\n]+`(?=$|[\s\])}.,;:!?])/,
/!\[[^\]]*\]\([^)\s]+\)/,
/<img\b/i,
];
/** True when plain clipboard text already looks like structured markdown source. */
export const shouldInsertClipboardTextAsMarkdown = (text: string): boolean => {
const markdown = text.replace(/\r\n?/g, "\n").trim();
if (!markdown) return false;
return PASTED_MARKDOWN_PATTERNS.some((pattern) => pattern.test(markdown));
};
/** True when clipboard HTML is worth converting (not empty / not a lone meta tag). */
export const looksLikeClipboardHtml = (html: string): boolean => {
const trimmed = html.trim();
if (!trimmed) return false;
if (!/<[a-zA-Z!/?]/.test(trimmed)) return false;
const withoutMeta = trimmed
.replace(/<!--[\s\S]*?-->/g, "")
.replace(/<meta\b[^>]*>/gi, "")
.replace(/<\/?(?:html|head|body)\b[^>]*>/gi, "")
.trim();
return withoutMeta.length > 0;
};
/** Plain markdown that still embeds raw HTML (GitHub-style). */
export const plainMarkdownContainsHtml = (text: string): boolean => (
/<\/?[a-zA-Z][^>]*>/.test(text)
);
/**
* True when the payload is primarily an HTML document (browser / Word / GitHub
* rich clipboard), not markdown-with-a-few-tags.
*/
export const isPrimarilyHtmlDocument = (html: string): boolean => {
const trimmed = html.trim();
if (!trimmed) return false;
if (/<!--StartFragment-->/i.test(trimmed)) return true;
if (/<\s*html[\s>]/i.test(trimmed)) return true;
if (/<\s*body[\s>]/i.test(trimmed)) return true;
const withoutTags = trimmed.replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
const tagChars = (trimmed.match(/<[^>]+>/g) ?? []).join("").length;
if (tagChars === 0) return false;
if (withoutTags.length === 0) return true;
return tagChars >= withoutTags.length * 0.35;
};
let turndownSingleton: TurndownService | null = null;
const getTurndown = (): TurndownService => {
if (turndownSingleton) return turndownSingleton;
const service = new TurndownService({
headingStyle: "atx",
hr: "---",
bulletListMarker: "-",
codeBlockStyle: "fenced",
emDelimiter: "*",
strongDelimiter: "**",
linkStyle: "inlined",
preformattedCode: true,
});
service.use(gfm);
service.addRule("stripEmptyAnchors", {
filter: (node) => (
node.nodeName === "A"
&& !(node as HTMLElement).getAttribute("href")
&& !(node.textContent ?? "").trim()
),
replacement: () => "",
});
service.addRule("skipDataImages", {
filter: (node) => (
node.nodeName === "IMG"
&& ((node as HTMLImageElement).getAttribute("src") ?? "").startsWith("data:")
),
replacement: () => "",
});
// README hero blocks: <p align="center"> / <h1 align="center"> → keep center.
service.addRule("keepCenteredBlocks", {
filter: (node) => isCenteredBlockElement(node as HTMLElement),
replacement: (content, node) => {
let inner = content.trim();
const tag = (node as HTMLElement).nodeName.toLowerCase();
const heading = /^h([1-6])$/.exec(tag);
// Restore ATX heading markers (turndown children are plain text for h*).
if (heading && inner && !/^#{1,6}\s/m.test(inner)) {
inner = `${"#".repeat(Number(heading[1]))} ${inner}`;
}
return wrapCenteredMarkdown(inner);
},
});
// Keep width/height when present (HTML <img>); CSS scales them in the panel.
service.addRule("imagesForNotes", {
filter: "img",
replacement: (_content, node) => {
const el = node as HTMLImageElement;
const src = (el.getAttribute("src") ?? "").trim();
if (!src || src.startsWith("data:")) return "";
const html = serializeSafeHtmlImage({
src,
alt: el.getAttribute("alt") ?? "",
title: el.getAttribute("title") ?? undefined,
width: el.getAttribute("width") ?? undefined,
height: el.getAttribute("height") ?? undefined,
});
return html ? `\n\n${html}\n\n` : "";
},
});
turndownSingleton = service;
return service;
};
const CENTERED_BLOCK_TAGS = new Set([
"P",
"DIV",
"H1",
"H2",
"H3",
"H4",
"H5",
"H6",
"SECTION",
"HEADER",
]);
/** True when a DOM/HTML block is explicitly center-aligned (GitHub README style). */
export const isCenteredBlockElement = (node: HTMLElement | Element | null | undefined): boolean => {
if (!node || !("nodeName" in node)) return false;
if (!CENTERED_BLOCK_TAGS.has(node.nodeName)) return false;
const el = node as HTMLElement;
const align = (el.getAttribute?.("align") ?? "").trim().toLowerCase();
if (align === "center") return true;
const style = el.getAttribute?.("style") ?? "";
if (/text-align\s*:\s*center/i.test(style)) return true;
return false;
};
/** Detect center alignment on a raw HTML open-tag blob. */
export const htmlOpenTagIsCentered = (openTagOrFull: string): boolean => {
if (/\balign\s*=\s*(?:"|')?center(?:"|')?/i.test(openTagOrFull)) return true;
if (/text-align\s*:\s*center/i.test(openTagOrFull)) return true;
return false;
};
/**
* Wrap turndown content so MDX GenericHTML keeps align=center.
* Blank lines inside let nested markdown / img HTML still parse.
*/
export const wrapCenteredMarkdown = (inner: string): string => {
const body = inner.replace(/\r\n?/g, "\n").trim();
if (!body) return "";
// Avoid double-wrapping.
if (/^<div\s+align="center">/i.test(body) && /<\/div>\s*$/i.test(body)) {
return `\n\n${body}\n\n`;
}
return `\n\n<div align="center">\n\n${body}\n\n</div>\n\n`;
};
const isSafeImageSrc = (src: string): boolean => {
const trimmed = src.trim();
if (!trimmed) return false;
// Never keep huge clipboard bitmaps / scripted URLs.
if (trimmed.startsWith("data:")) return false;
if (/^javascript:/i.test(trimmed)) return false;
if (/^https?:\/\//i.test(trimmed)) return true;
// Protocol-relative CDN (//img.shields.io/…).
if (trimmed.startsWith("//") && /^\/\/[^/\s]/.test(trimmed)) return true;
// Relative paths from README paste (public/icon.png). They may 404 in the
// note renderer, but dropping them silently loses logos; keep the tag so
// users still see a broken-image affordance / can fix the URL.
if (trimmed.startsWith("/") || trimmed.startsWith("./") || trimmed.startsWith("../")) {
return true;
}
// Bare relative: public/foo.svg — reject only obvious scheme-like tokens.
if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed)) return true;
return false;
};
const escapeHtmlAttr = (value: string): string => (
value
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
);
/**
* Build image markdown for notes.
* - With width/height → sanitized HTML <img> (MDX keeps dimensions; CSS scales)
* - Without → standard ![alt](src)
*
* Display safety: side-panel CSS uses max-width:100% + height:auto (when width
* is present or height is absent) so large intrinsic sizes shrink without
* cropping, while height-only icons keep their HTML height.
*/
export const serializeSafeHtmlImage = (input: {
src: string;
alt?: string;
title?: string;
width?: string | number | null;
height?: string | number | null;
}): string => {
const src = (input.src ?? "").trim();
if (!isSafeImageSrc(src)) return "";
const alt = (input.alt ?? "").replace(/[[\]]/g, "");
const title = input.title?.trim() || "";
const widthRaw = input.width != null ? String(input.width).trim() : "";
const heightRaw = input.height != null ? String(input.height).trim() : "";
// Accept plain px numbers or percent strings.
const safeWidth = /^(?:\d+(?:\.\d+)?%?)$/.test(widthRaw) ? widthRaw : "";
const safeHeight = /^(?:\d+(?:\.\d+)?%?)$/.test(heightRaw) ? heightRaw : "";
if (!safeWidth && !safeHeight) {
const titlePart = title ? ` "${title.replace(/"/g, '\\"')}"` : "";
return `![${alt}](${src}${titlePart})`;
}
const parts = [
`src="${escapeHtmlAttr(src)}"`,
`alt="${escapeHtmlAttr(alt)}"`,
];
if (title) parts.push(`title="${escapeHtmlAttr(title)}"`);
if (safeWidth) parts.push(`width="${escapeHtmlAttr(safeWidth)}"`);
if (safeHeight) parts.push(`height="${escapeHtmlAttr(safeHeight)}"`);
return `<img ${parts.join(" ")} />`;
};
const trimBlankLines = (value: string): string => (
value
.replace(/\r\n?/g, "\n")
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.replace(/^\n+/, "")
.replace(/\n+$/, "")
);
const turndownFragment = (html: string): string => {
try {
return getTurndown().turndown(html);
} catch {
return "";
}
};
/** Parse a single <img …> tag into safe markdown or modest HTML. */
export const convertHtmlImgTagToMarkdownOrHtml = (imgTag: string): string => {
const match = imgTag.match(/^<img\b([^>]*)\/?\s*>$/i);
if (!match) return turndownFragment(imgTag).trim();
const attrBlob = match[1] ?? "";
const getAttr = (name: string): string => {
const re = new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i");
const m = re.exec(attrBlob);
return (m?.[1] ?? m?.[2] ?? m?.[3] ?? "").trim();
};
return serializeSafeHtmlImage({
src: getAttr("src"),
alt: getAttr("alt"),
title: getAttr("title") || undefined,
width: getAttr("width") || undefined,
height: getAttr("height") || undefined,
});
};
const extractMarkdownImageAlt = (imageChunk: string): string => {
const md = /!\[([^\]]*)\]/.exec(imageChunk);
if (md) return (md[1] || "link").trim() || "link";
const htmlAlt = /alt\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(imageChunk);
if (htmlAlt) return (htmlAlt[1] ?? htmlAlt[2] ?? "link").trim() || "link";
return "link";
};
/**
* Keep linked badge *images* (not text-only), but force a single line so MDX
* does not tear `[![alt](img)](href)` into image + orphan `](href)`.
*
* Prefers HTML `<a href="…"><img … /></a>` when the image still has dimensions
* (shields / ko-fi) — MDX GenericHTML + img visitor render reliably.
*/
export const normalizeLinkedBadgeImages = (markdown: string): string => {
let body = markdown.replace(/\r\n?/g, "\n");
// [![alt](img)](href) with optional internal whitespace/newlines → one line
body = body.replace(
/\[\s*!\[[^\]]*\]\(([^)]+)\)\s*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g,
(full, imgSrc: string, href: string) => {
const alt = extractMarkdownImageAlt(full);
const src = (imgSrc || "").trim().split(/\s+/)[0] ?? "";
if (!src || !isSafeImageSrc(src)) {
return `[${alt}](${href})`;
}
// Shields / badge URLs: keep as linked markdown image (no pixel size).
return `[![${alt}](${src})](${href})`;
},
);
// [<img …>](href) → <a href><img></a> (keeps width/height + click target)
body = body.replace(
/\[\s*(<img\b[^>]*>)\s*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/gi,
(_full, imgTag: string, href: string) => {
const safeImg = convertHtmlImgTagToMarkdownOrHtml(String(imgTag).trim());
if (!safeImg) return "";
if (safeImg.startsWith("<img")) {
return `<a href="${escapeHtmlAttr(href)}">${safeImg}</a>`;
}
const m = /!\[([^\]]*)\]\(([^)\s]+)\)/.exec(safeImg);
if (m) return `[![${m[1]}](${m[2]})](${href})`;
return `[${extractMarkdownImageAlt(safeImg)}](${href})`;
},
);
// HTML <a href="…"> … <img> … </a> (README badge row) → compact linked image
body = body.replace(
/<a\b([^>]*)>\s*(<img\b[^>]*>)\s*<\/a>/gi,
(full, aAttrs: string, imgTag: string) => {
const hrefMatch = /\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(aAttrs);
const href = (hrefMatch?.[1] ?? hrefMatch?.[2] ?? hrefMatch?.[3] ?? "").trim();
if (!href || /^javascript:/i.test(href)) return convertHtmlImgTagToMarkdownOrHtml(imgTag) || "";
const safeImg = convertHtmlImgTagToMarkdownOrHtml(imgTag.trim());
if (!safeImg) return "";
if (safeImg.startsWith("<img")) {
return `<a href="${escapeHtmlAttr(href)}">${safeImg}</a>`;
}
const m = /!\[([^\]]*)\]\(([^)\s]+)\)/.exec(safeImg);
if (m) return `[![${m[1]}](${m[2]})](${href})`;
return `[${extractMarkdownImageAlt(full)}](${href})`;
},
);
return body;
};
/** @deprecated Use normalizeLinkedBadgeImages — kept as alias for older tests. */
export const collapseLinkedImagesToTextLinks = normalizeLinkedBadgeImages;
/**
* Drop orphan `](url)` lines left by broken badge conversion, but leave
* fenced / indented code alone (docs and parser fixtures often show that form).
*/
const stripOrphanLinkClosersOutsideCode = (markdown: string): string => {
const placeholders: string[] = [];
const stash = (chunk: string): string => {
const token = `@@NETCATTY_MD_CODE_${placeholders.length}@@`;
placeholders.push(chunk);
return token;
};
let body = markdown;
// Fenced code (``` / ~~~). Lookbehind keeps the prior newline so orphan
// closer lines above a fence stay alone for the strip below.
body = body.replace(
/(?<=^|\n)(```|~~~)[^\n]*\n[\s\S]*?\n\1[ \t]*(?=\n|$)/g,
(match) => stash(match),
);
// Indented code blocks (4 spaces or a tab).
body = body.replace(
/(?<=^|\n)(?:(?: {4}|\t).*(?:\n(?: {4}|\t).*)*)/g,
(match) => stash(match),
);
body = body.replace(/^\s*\]\([^)\n]+\)\s*$/gm, "");
return body.replace(/@@NETCATTY_MD_CODE_(\d+)@@/g, (_, idx: string) => (
placeholders[Number(idx)] ?? ""
));
};
/**
* Final cleanup for note paste: tighten linked badges (keep images),
* re-sanitize img tags (keeps width/height), tidy blank lines.
*/
export const normalizePastedNoteMarkdown = (markdown: string): string => {
let body = normalizeLinkedBadgeImages(markdown);
// Re-serialize standalone HTML images (keep width/height). Leave whole <a>…</a>
// anchors alone so linked badges stay <a><img></a>.
body = body.replace(/<a\b[^>]*>[\s\S]*?<\/a>|<img\b[^>]*>/gi, (chunk) => {
if (/^<a\b/i.test(chunk)) return chunk;
return convertHtmlImgTagToMarkdownOrHtml(chunk.trim()) || "";
});
// Second pass after img sanitization may recreate spaced wrappers.
body = normalizeLinkedBadgeImages(body);
body = stripOrphanLinkClosersOutsideCode(body);
return trimBlankLines(body);
};
/**
* Convert a full HTML clipboard document with Turndown (+ GFM).
*/
export const convertClipboardHtmlToMarkdown = (html: string): string => {
if (!looksLikeClipboardHtml(html)) return "";
return normalizePastedNoteMarkdown(turndownFragment(html));
};
/**
* Convert only HTML islands inside markdown source.
* Preserves # headings, lists, blockquotes, fences — never escapes them.
*/
export const convertHtmlIslandsInMarkdown = (markdown: string): string => {
// Do NOT normalize linked badges before turndown — converting <a><img> to
// markdown first leaves [![…]](…) as text inside <p align=center>, and
// turndown then escapes it to \[!\[…\]\](…).
let body = markdown.replace(/\r\n?/g, "\n");
if (!plainMarkdownContainsHtml(body)) {
return normalizePastedNoteMarkdown(body);
}
const fenceToken = (index: number) => `@@NETCATTY_MD_FENCE_${index}@@`;
const fences: string[] = [];
body = body.replace(
/(?:^|\n)(```|~~~)[^\n]*\n[\s\S]*?\n\1[ \t]*(?=\n|$)/g,
(match) => {
const token = fenceToken(fences.length);
fences.push(match);
return token;
},
);
body = body.replace(/<!--[\s\S]*?-->/g, "");
// Self-closing / void tags — emit image markdown without extra blank lines
// when still inside a link (should be rare after collapse).
body = body.replace(
/<([a-zA-Z][\w:-]*)(\s[^>]*)?\s*\/>/g,
(full, tag: string) => {
if (String(tag).toLowerCase() === "img") {
const md = convertHtmlImgTagToMarkdownOrHtml(full);
return md ? `\n\n${md}\n\n` : "";
}
const md = turndownFragment(full);
if (/^!\[/.test(md.trim()) || /^<img\b/i.test(md.trim())) {
return `\n\n${md.trim()}\n\n`;
}
return md;
},
);
for (let pass = 0; pass < 8; pass += 1) {
const next = body.replace(
/<([a-zA-Z][\w:-]*)(\s[^>]*)?>([\s\S]*?)<\/\1\s*>/g,
(full, tag: string) => {
const lower = String(tag).toLowerCase();
if (lower === "script" || lower === "style") return "";
// Already-emitted center wrappers — never re-turndown (escapes ** / #).
if (lower === "div" && htmlOpenTagIsCentered(full)) {
return full;
}
// Centered p/h* (README hero): turndown keepCenteredBlocks → div align=center.
if (
(lower === "p" || /^h[1-6]$/.test(lower))
&& htmlOpenTagIsCentered(full)
) {
const md = turndownFragment(full);
return md.trim() ? `\n\n${md.trim()}\n\n` : "";
}
const md = turndownFragment(full);
if (!md.trim()) return "";
if (
/^(p|div|section|article|table|ul|ol|blockquote|h[1-6]|pre|figure)$/i.test(lower)
) {
return `\n\n${md.trim()}\n\n`;
}
return md;
},
);
if (next === body) break;
body = next;
}
body = body.replace(
/<(img|br|hr)\b([^>]*)>/gi,
(full, tag: string) => {
if (String(tag).toLowerCase() === "img") {
const md = convertHtmlImgTagToMarkdownOrHtml(full);
return md ? `\n\n${md}\n\n` : "";
}
const md = turndownFragment(full.endsWith("/>") ? full : full.replace(/>$/, " />"));
if (/^!\[/.test(md.trim()) || /^<img\b/i.test(md.trim())) {
return `\n\n${md.trim()}\n\n`;
}
return md;
},
);
body = body.replace(/@@NETCATTY_MD_FENCE_(\d+)@@/g, (_, idx: string) => (
fences[Number(idx)] ?? ""
));
return normalizePastedNoteMarkdown(body);
};
/**
* Resolve what text should enter the note editor from a clipboard event.
*/
export const resolveNoteClipboardPaste = (input: {
plainText: string;
htmlText: string;
}): NoteClipboardPastePayload => {
const plain = (input.plainText ?? "").replace(/\r\n?/g, "\n");
const html = input.htmlText ?? "";
if (looksLikeClipboardHtml(html) && isPrimarilyHtmlDocument(html)) {
const converted = convertClipboardHtmlToMarkdown(html);
if (converted.trim()) {
return { text: converted, kind: "html-converted" };
}
}
if (looksLikeClipboardHtml(html) && !shouldInsertClipboardTextAsMarkdown(plain)) {
const converted = convertClipboardHtmlToMarkdown(html);
if (converted.trim()) {
return { text: converted, kind: "html-converted" };
}
}
if (shouldInsertClipboardTextAsMarkdown(plain) || plainMarkdownContainsHtml(plain)) {
if (plainMarkdownContainsHtml(plain)) {
const converted = convertHtmlIslandsInMarkdown(plain);
if (converted.trim()) {
return {
text: converted,
kind: plainMarkdownContainsHtml(converted) ? "markdown" : "html-converted",
};
}
}
if (shouldInsertClipboardTextAsMarkdown(plain)) {
// Still normalize badges / oversized HTML that may already be in the source.
return { text: normalizePastedNoteMarkdown(plain), kind: "markdown" };
}
}
if (looksLikeClipboardHtml(plain)) {
const converted = convertClipboardHtmlToMarkdown(plain);
if (converted.trim()) {
return { text: converted, kind: "html-converted" };
}
}
if (plain.trim()) {
return { text: plain, kind: "plain" };
}
return { text: "", kind: "empty" };
};
export const shouldInterceptResolvedNotePaste = (input: {
editorMode: "edit" | "preview";
pasteInsideCodeBlock: boolean;
payload: NoteClipboardPastePayload;
}): boolean => {
if (input.editorMode !== "edit") return false;
if (input.pasteInsideCodeBlock) return false;
if (input.payload.kind === "empty") return false;
if (input.payload.kind === "html-converted") return true;
if (input.payload.kind === "markdown") return true;
return false;
};
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;
}
});
};
+25 -10
View File
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
import test from "node:test";
import {
insertClipboardTextAtActiveLexicalSelection,
mergeNoteMarkdownDocumentPaste,
NOTE_MARKDOWN_PASTE_INSERT_MAX_CHARS,
resolveNoteMarkdownPasteSettleAttempts,
@@ -20,8 +21,6 @@ test("markdown paste intercepts structured clipboard text in edit mode even with
}),
true,
);
// After a prior insertMarkdown clears the caret, continuous paste must still
// be recoverable via document setMarkdown rather than a swallowed preventDefault.
assert.equal(
shouldInterceptNoteMarkdownPaste({
editorMode: "edit",
@@ -61,7 +60,7 @@ test("document paste merge preserves first-line indentation and appends after cu
);
});
test("paste strategy uses document merge when caret is missing or paste is long", () => {
test("paste strategy preserves caret: long paste with selection still inserts at selection", () => {
assert.equal(
resolveNoteMarkdownPasteStrategy({
canInsertMarkdownAtSelection: false,
@@ -78,20 +77,27 @@ test("paste strategy uses document merge when caret is missing or paste is long"
);
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,
}),
"document-merge",
"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), 8);
assert.equal(resolveNoteMarkdownPasteSettleAttempts(100_000), 24);
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", () => {
@@ -112,17 +118,26 @@ test("InlineMarkdownEditor only preventDefaults markdown paste after a successfu
source,
/if \(\s*!shouldInterceptResolvedNotePaste\([\s\S]*?\)\s*\{\s*return;\s*\}/,
);
// Long / no-caret path goes document-merge; short selection keeps insertMarkdown.
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\(\)/);
// Selection-path no-op recovery must fall back to document merge (long paste).
assert.match(source, /if \(attempt >= maxAttempts\) \{\s*applyDocumentPaste\(\);/);
// Document merge must read live editor markdown, not only the possibly-stale ref.
// 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>
@@ -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" && (
+73 -1
View File
@@ -1,7 +1,10 @@
import test from "node:test";
import assert from "node:assert/strict";
import { getAlignedPrompt } from "./autocomplete/promptDetector.ts";
import { detectPrompt, getAlignedPrompt } from "./autocomplete/promptDetector.ts";
import { resolveAutocompleteQueryInput } from "./autocomplete/terminalAutocompletePrompt.ts";
import { getSnippetSuggestions } from "./autocomplete/snippetCompleter.ts";
import { stringCellWidth } from "./autocomplete/terminalStringCellWidth.ts";
import { getCommandToRecordOnEnter } from "./autocomplete/useTerminalAutocomplete.ts";
function createFakeTerm(lineText: string, cursorX: number) {
@@ -25,6 +28,13 @@ function createFakeTerm(lineText: string, cursorX: number) {
};
}
/** Simulates xterm padding empty cells as trailing spaces after wide glyphs. */
function createPaddedFakeTerm(content: string, cursorX: number, cols = 80) {
const pad = Math.max(0, cols - stringCellWidth(content));
const lineText = content + " ".repeat(pad);
return createFakeTerm(lineText, cursorX);
}
function createWrappedFakeTerm(rows: string[], cursorY: number, cursorX: number, cols: number) {
return {
cols,
@@ -745,3 +755,65 @@ test("does not record partial standard prompt input while reliable typed input i
null,
);
});
test("CMD path prompts keep pre-echo Chinese input usable for snippet matching (#2813)", () => {
const prompts = [
String.raw`C:\Users\foo>`,
String.raw`C:\Users\用户>`,
String.raw`PS C:\Users\foo> `,
String.raw`PS C:\Users\用户> `,
];
const typedInput = "部署";
const snippet = { id: "zh", label: "部署服务", command: "echo deploy" };
for (const prompt of prompts) {
const term = createPaddedFakeTerm(prompt, stringCellWidth(prompt));
const raw = detectPrompt(term as never);
assert.equal(raw.isAtPrompt, true, prompt);
assert.equal(raw.userInput.trim(), "", prompt);
const aligned = getAlignedPrompt(term as never, typedInput, true);
assert.equal(aligned.prompt.isAtPrompt, true, prompt);
assert.equal(aligned.prompt.userInput, typedInput, prompt);
assert.equal(aligned.alignedTyped, null, prompt);
assert.equal(aligned.allowExternalProviders, false, prompt);
const query = resolveAutocompleteQueryInput(
aligned.prompt,
typedInput,
true,
);
assert.equal(query, typedInput, prompt);
assert.equal(
getSnippetSuggestions(query ?? "", [snippet as never], {})[0]?.snippet?.id,
"zh",
prompt,
);
}
});
test("CMD path prompts with CJK directories do not absorb padding into echoed Chinese input", () => {
const prompt = String.raw`C:\Users\用户>`;
const typedInput = "部署";
const content = `${prompt}${typedInput}`;
const term = createPaddedFakeTerm(content, stringCellWidth(content));
const raw = detectPrompt(term as never);
assert.equal(raw.isAtPrompt, true);
assert.equal(raw.promptText, prompt);
assert.equal(raw.userInput, typedInput);
assert.equal(raw.cursorOffset, typedInput.length);
// Unreliable typed buffer must still match snippets from the live line
// (fresh local CMD sessions often clear keystroke reliability on startup).
const query = resolveAutocompleteQueryInput(raw, "", false);
assert.equal(query, typedInput);
assert.equal(
getSnippetSuggestions(query ?? "", [{
id: "zh",
label: "部署服务",
command: "echo deploy",
} as never], {})[0]?.snippet?.id,
"zh",
);
});
@@ -20,6 +20,7 @@ const shouldSuppressMouseTrackingContextMenu = (
terminalContextMenu as {
shouldSuppressMouseTrackingContextMenu?: (options: {
isAlternateScreen?: boolean;
terminalMouseTrackingMode?: string;
showReconnectAction?: boolean;
forceMenuInAlternateScreen?: boolean;
}) => boolean;
@@ -41,6 +42,7 @@ const shouldOpenTerminalContextMenu = (
event: { shiftKey?: boolean; nativeEvent: MouseEvent };
rightClickBehavior?: "context-menu" | "paste" | "select-word";
isAlternateScreen?: boolean;
terminalMouseTrackingMode?: string;
showReconnectAction?: boolean;
forceMenuInAlternateScreen?: boolean;
}) => boolean;
@@ -50,6 +52,7 @@ const shouldRenderTerminalContextMenuContent = (
terminalContextMenu as {
shouldRenderTerminalContextMenuContent?: (options: {
isAlternateScreen?: boolean;
terminalMouseTrackingMode?: string;
showReconnectAction?: boolean;
allowSuppressedMenuContent?: boolean;
}) => boolean;
@@ -60,6 +63,7 @@ const shouldAllowSuppressedTerminalContextMenuContent = (
shouldAllowSuppressedTerminalContextMenuContent?: (options: {
event: { shiftKey?: boolean; nativeEvent: MouseEvent };
isAlternateScreen?: boolean;
terminalMouseTrackingMode?: string;
showReconnectAction?: boolean;
}) => boolean;
}
@@ -338,6 +342,65 @@ test("opens and renders middle-click menu while alternate-screen mouse tracking
);
});
test("uses the current mouse tracking mode when the cached state is stale", () => {
assert.equal(typeof shouldOpenTerminalContextMenu, "function");
assert.equal(typeof shouldRenderTerminalContextMenuContent, "function");
if (
typeof shouldOpenTerminalContextMenu !== "function" ||
typeof shouldRenderTerminalContextMenuContent !== "function"
) {
return;
}
const event = {
nativeEvent: {} as MouseEvent,
};
// xterm has already stopped reporting mouse events, but React still has
// the previous tracking state: paste/select-word must not be dropped.
assert.equal(
shouldOpenTerminalContextMenu({
event,
rightClickBehavior: "paste",
isAlternateScreen: true,
terminalMouseTrackingMode: "none",
showReconnectAction: false,
}),
false,
);
assert.equal(
shouldRenderTerminalContextMenuContent({
isAlternateScreen: true,
terminalMouseTrackingMode: "none",
showReconnectAction: false,
allowSuppressedMenuContent: false,
}),
true,
);
// Conversely, a newly active xterm mode must still suppress the app menu
// while the cached React state has not caught up.
assert.equal(
shouldOpenTerminalContextMenu({
event,
rightClickBehavior: "context-menu",
isAlternateScreen: false,
terminalMouseTrackingMode: "vt200",
showReconnectAction: false,
}),
false,
);
assert.equal(
shouldRenderTerminalContextMenuContent({
isAlternateScreen: false,
terminalMouseTrackingMode: "vt200",
showReconnectAction: false,
allowSuppressedMenuContent: false,
}),
false,
);
});
test("opens Shift right-click menu content for all right-click modes while mouse tracking suppresses unmodified menus", () => {
assert.equal(typeof shouldOpenTerminalContextMenu, "function");
assert.equal(typeof shouldRenderTerminalContextMenuContent, "function");
+31 -7
View File
@@ -27,7 +27,7 @@ import {
ContextMenuShortcut,
ContextMenuTrigger,
} from '../ui/context-menu';
import { isMiddleClickContextMenuEvent } from './runtime/middleClickBehavior';
import { isMiddleClickContextMenuEvent, isMouseTrackingActive } from './runtime/middleClickBehavior';
import { collectOwnedPluginMenus, comparePluginMenus, usePluginContributions } from '../../application/state/usePluginContributions';
import { buildTerminalPluginContributionContext } from '../../application/state/pluginContributionContexts';
import { PluginContributionIcon } from '../plugins/PluginContributionIcon';
@@ -44,6 +44,8 @@ export interface TerminalContextMenuProps {
keyBindings?: KeyBinding[];
rightClickBehavior?: RightClickBehavior;
isAlternateScreen?: boolean;
/** Read the current xterm mouse-tracking mode when handling a right-click. */
getMouseTrackingMode?: () => string | undefined;
/** When true, show the app context menu even while a fullscreen app (tmux/vim) holds mouse tracking. */
showContextMenuOverFullscreenApps?: boolean;
onCopy?: () => void;
@@ -75,13 +77,22 @@ export const shouldShowReconnectAction = ({
export const shouldSuppressMouseTrackingContextMenu = ({
isAlternateScreen,
terminalMouseTrackingMode,
showReconnectAction,
forceMenuInAlternateScreen,
}: {
isAlternateScreen?: boolean;
terminalMouseTrackingMode?: string;
showReconnectAction?: boolean;
forceMenuInAlternateScreen?: boolean;
}): boolean => Boolean(isAlternateScreen && !showReconnectAction && !forceMenuInAlternateScreen);
}): boolean => Boolean(
isMouseTrackingActive({
mouseTracking: Boolean(isAlternateScreen),
terminalMouseTrackingMode,
})
&& !showReconnectAction
&& !forceMenuInAlternateScreen,
);
export const shouldShowAddSelectionToAIContextMenuAction = (
onAddSelectionToAI?: () => void,
@@ -93,42 +104,48 @@ export const shouldShowUploadClipboardImageContextMenuAction = (
export const shouldRenderTerminalContextMenuContent = ({
isAlternateScreen,
terminalMouseTrackingMode,
showReconnectAction,
allowSuppressedMenuContent,
forceMenuInAlternateScreen,
}: {
isAlternateScreen?: boolean;
terminalMouseTrackingMode?: string;
showReconnectAction?: boolean;
allowSuppressedMenuContent?: boolean;
forceMenuInAlternateScreen?: boolean;
}): boolean =>
allowSuppressedMenuContent ||
!shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, showReconnectAction, forceMenuInAlternateScreen });
!shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, terminalMouseTrackingMode, showReconnectAction, forceMenuInAlternateScreen });
export const shouldAllowSuppressedTerminalContextMenuContent = ({
event,
isAlternateScreen,
terminalMouseTrackingMode,
showReconnectAction,
forceMenuInAlternateScreen,
}: {
event: { shiftKey?: boolean; nativeEvent: MouseEvent };
isAlternateScreen?: boolean;
terminalMouseTrackingMode?: string;
showReconnectAction?: boolean;
forceMenuInAlternateScreen?: boolean;
}): boolean =>
isMiddleClickContextMenuEvent(event.nativeEvent)
|| Boolean(event.shiftKey && shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, showReconnectAction, forceMenuInAlternateScreen }));
|| Boolean(event.shiftKey && shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, terminalMouseTrackingMode, showReconnectAction, forceMenuInAlternateScreen }));
export const shouldOpenTerminalContextMenu = ({
event,
rightClickBehavior = 'context-menu',
isAlternateScreen,
terminalMouseTrackingMode,
showReconnectAction,
forceMenuInAlternateScreen,
}: {
event: { shiftKey?: boolean; nativeEvent: MouseEvent };
rightClickBehavior?: RightClickBehavior;
isAlternateScreen?: boolean;
terminalMouseTrackingMode?: string;
showReconnectAction?: boolean;
forceMenuInAlternateScreen?: boolean;
}): boolean => {
@@ -140,7 +157,7 @@ export const shouldOpenTerminalContextMenu = ({
return true;
}
if (shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, showReconnectAction, forceMenuInAlternateScreen })) {
if (shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, terminalMouseTrackingMode, showReconnectAction, forceMenuInAlternateScreen })) {
return false;
}
@@ -159,6 +176,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
keyBindings,
rightClickBehavior = 'context-menu',
isAlternateScreen = false,
getMouseTrackingMode,
showContextMenuOverFullscreenApps = false,
onCopy,
onPaste,
@@ -233,6 +251,8 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
const clearShortcut = getShortcut('clear-buffer');
const showReconnectAction = shouldShowReconnectAction({ isReconnectable, onReconnect });
const terminalMouseTrackingMode = getMouseTrackingMode?.();
// Handle right-click: intercept for paste/select-word unless Shift is held
// or rightClickBehavior is 'context-menu'. The ContextMenuTrigger stays always
// enabled so Shift+Right-Click opens the menu on the first click.
@@ -241,15 +261,17 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
// In alternate screen (tmux, vim, etc.), let the terminal application
// handle right-click natively to avoid conflicting menus. Reconnect is
// still available after disconnect, even if mouse tracking was left on.
const currentMouseTrackingMode = getMouseTrackingMode?.();
const shouldOpenMenu = shouldOpenTerminalContextMenu({
event: e,
rightClickBehavior,
isAlternateScreen,
terminalMouseTrackingMode: currentMouseTrackingMode,
showReconnectAction,
forceMenuInAlternateScreen: showContextMenuOverFullscreenApps,
});
if (!shouldOpenMenu && shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, showReconnectAction, forceMenuInAlternateScreen: showContextMenuOverFullscreenApps })) {
if (!shouldOpenMenu && shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, terminalMouseTrackingMode: currentMouseTrackingMode, showReconnectAction, forceMenuInAlternateScreen: showContextMenuOverFullscreenApps })) {
e.preventDefault();
return;
}
@@ -265,6 +287,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
setAllowSuppressedMenuContent(shouldAllowSuppressedTerminalContextMenuContent({
event: e,
isAlternateScreen,
terminalMouseTrackingMode: currentMouseTrackingMode,
showReconnectAction,
forceMenuInAlternateScreen: showContextMenuOverFullscreenApps,
}));
@@ -279,7 +302,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
onSelectWord?.();
}
},
[rightClickBehavior, onPaste, onSelectWord, isAlternateScreen, showReconnectAction, showContextMenuOverFullscreenApps],
[rightClickBehavior, onPaste, onSelectWord, isAlternateScreen, getMouseTrackingMode, showReconnectAction, showContextMenuOverFullscreenApps],
);
// Always use ContextMenu wrapper to maintain consistent React tree structure
@@ -294,6 +317,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
</ContextMenuTrigger>
{shouldRenderTerminalContextMenuContent({
isAlternateScreen,
terminalMouseTrackingMode,
showReconnectAction,
allowSuppressedMenuContent,
forceMenuInAlternateScreen: showContextMenuOverFullscreenApps,
+1
View File
@@ -492,6 +492,7 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
keyBindings={keyBindings}
rightClickBehavior={terminalSettings?.rightClickBehavior}
isAlternateScreen={hasMouseTracking}
getMouseTrackingMode={() => termRef.current?.modes.mouseTrackingMode}
showContextMenuOverFullscreenApps={terminalSettings?.showContextMenuOverFullscreenApps}
onCopy={terminalContextActions.onCopy}
onPaste={terminalContextActions.onPaste}
@@ -13,3 +13,10 @@ test("hidden selected theme uses the normal theme item row", () => {
assert.match(source, /hiddenSelectedTheme && \(\s*<ThemeItem/);
assert.doesNotMatch(source, /terminal\.hiddenTheme\.title[\s\S]*terminal\.hiddenTheme\.desc/);
});
test("theme side panel tabs are vertically centered in the header", () => {
assert.match(
source,
/TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS, 'flex items-center px-1\.5 gap-0\.5 border-b'/,
);
});
+1 -1
View File
@@ -311,7 +311,7 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
>
{/* Tab Bar */}
<div
className={cn(TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS, 'flex px-1.5 gap-0.5 border-b')}
className={cn(TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS, 'flex items-center px-1.5 gap-0.5 border-b')}
style={{ borderColor: 'var(--terminal-panel-border)' }}
>
<button
@@ -9,6 +9,7 @@
import type { Terminal as XTerm } from "@xterm/xterm";
import { isSensitiveTerminalChallenge } from "../../../domain/terminalPromptSecurity";
import { sliceStringByCellColumns } from "./terminalStringCellWidth";
import { COMMON_SHELL_COMMANDS, NON_PROMPT_PATTERNS, PROMPT_CHARS } from "./promptDetectorPatterns";
export interface PromptDetectionResult {
@@ -83,7 +84,8 @@ function getCursorLinePrefix(term: XTerm): string | null {
if (!line) return null;
return line.translateToString(false).substring(0, Math.max(0, buffer.cursorX));
const lineText = line.translateToString(false);
return sliceStringByCellColumns(lineText, 0, Math.max(0, buffer.cursorX), term);
}
function getWrappedCursorPrefix(term: XTerm): string | null {
@@ -112,7 +114,8 @@ function getWrappedCursorPrefix(term: XTerm): string | null {
prefix += rowLine.translateToString(false);
}
return prefix + line.translateToString(false).substring(0, Math.max(0, cursorX));
const cursorRowText = line.translateToString(false);
return prefix + sliceStringByCellColumns(cursorRowText, 0, Math.max(0, cursorX), term);
}
function inferPromptTextBeforeTypedInput(
@@ -424,13 +427,27 @@ function endsWithHostStyleGreaterThanPrompt(promptText: string): boolean {
return /^[\w.-]+$/.test(promptName) && !isLikelyBareMongoPromptName(promptName);
}
function endsWithWindowsPathGreaterThanPrompt(promptText: string): boolean {
const trimmed = promptText.trimEnd();
if (!trimmed.endsWith(">")) return false;
const before = trimmed.slice(0, -1).trimEnd();
// cmd.exe: `C:\path>` / `C:\>`; PowerShell: `PS C:\path>`
if (/^[A-Za-z]:[\\/]/.test(before)) return true;
if (/^PS\s+[A-Za-z]:[\\/]/i.test(before)) return true;
return false;
}
function endsWithStandardShellPrompt(promptText: string): boolean {
const finalChar = promptText.trimEnd().at(-1);
return finalChar === "$" || finalChar === "#" || finalChar === "%";
}
function allowsShortPromptEcho(promptText: string): boolean {
return endsWithStandardShellPrompt(promptText) || endsWithHostStyleGreaterThanPrompt(promptText);
return (
endsWithStandardShellPrompt(promptText) ||
endsWithHostStyleGreaterThanPrompt(promptText) ||
endsWithWindowsPathGreaterThanPrompt(promptText)
);
}
function isReliableTypedPrefix(
@@ -539,25 +556,29 @@ export function detectPrompt(term: XTerm): PromptDetectionResult {
// Empty line
if (lineText.trim().length === 0) return NO_PROMPT;
const cursorLinePrefix = lineText.substring(0, Math.max(0, cursorX));
// cursorX is a cell column; lineText is characters. Wide glyphs (CJK in a
// Windows `C:\Users\用户>` prompt) make substring(cursorX) overshoot into
// xterm's empty-cell padding and poison userInput with spaces (#2813 CMD).
const cursorLinePrefix = sliceStringByCellColumns(lineText, 0, Math.max(0, cursorX), term);
const afterCursor = sliceStringByCellColumns(lineText, Math.max(0, cursorX), undefined, term);
// Try to find the prompt boundary on the current line. xterm buffer rows are
// padded with blank cells; when the cursor is at the visible row end, scan
// only up to the cursor so prompts like "root@host:~#" do not inherit a fake
// trailing space. If there is command text to the right of the cursor, keep
// the full line so "$" / ">" inside mid-line edits are validated against
// their real following character.
const promptScanText = lineText.slice(Math.max(0, cursorX)).trim().length > 0
const promptScanText = afterCursor.trim().length > 0
? lineText
: cursorLinePrefix;
const promptEnd = findPromptBoundary(promptScanText);
if (promptEnd >= 0) {
const promptText = lineText.substring(0, promptEnd);
// Use cursor position to determine actual input length — don't trim trailing
// spaces since they're significant for autocomplete (e.g., "git commit " should
// produce an empty trailing token to trigger option suggestions).
const rawInput = lineText.substring(promptEnd);
const userInput = rawInput.substring(0, Math.max(0, cursorX - promptEnd));
const cursorOffset = Math.max(0, cursorX - promptEnd);
// Input is whatever sits between the prompt and the cursor on the cell-
// accurate prefix — don't use cursorX as a character index.
const userInput = cursorLinePrefix.length >= promptEnd
? cursorLinePrefix.substring(promptEnd)
: "";
const cursorOffset = userInput.length;
return { isAtPrompt: true, promptText, userInput, cursorOffset };
}
@@ -102,3 +102,62 @@ export function stringCellWidth(
}
return fallbackStringCellWidth(s);
}
/**
* Slice a terminal line string by cell columns (xterm `cursorX` units).
*
* `translateToString()` returns characters, but `buffer.cursorX` is a cell
* column. Mixing them with `String#substring(cursorX)` pulls padding spaces
* into user input whenever the prompt contains wide glyphs (CJK paths in
* Windows CMD / PowerShell), which breaks autocomplete matching (#2813).
*/
export function sliceStringByCellColumns(
text: string,
startCell: number,
endCell?: number,
term?: XTerm | TermWithUnicodeService | null,
): string {
if (!text) return "";
const start = Math.max(0, startCell);
const end = endCell === undefined ? Number.POSITIVE_INFINITY : Math.max(start, endCell);
if (end === 0) return "";
let cell = 0;
let startIndex = 0;
let endIndex = text.length;
let sawStart = false;
const advance = (segment: string, index: number, segmentLength: number): boolean => {
const width = stringCellWidth(segment, term);
const nextCell = cell + width;
if (!sawStart && nextCell > start) {
startIndex = index;
sawStart = true;
}
if (nextCell >= end) {
endIndex = nextCell === end ? index + segmentLength : index;
if (!sawStart) {
startIndex = index;
sawStart = true;
}
return true;
}
cell = nextCell;
return false;
};
if (graphemeSegmenter) {
for (const { segment, index } of graphemeSegmenter.segment(text)) {
if (advance(segment, index, segment.length)) break;
}
} else {
let index = 0;
for (const ch of text) {
if (advance(ch, index, ch.length)) break;
index += ch.length;
}
}
if (!sawStart) return "";
return text.slice(startIndex, endIndex);
}
@@ -43,6 +43,7 @@ import {
resolveAutocompleteQueryInput,
shouldBlockAutocompleteForSensitivePrompt,
} from "./terminalAutocompletePrompt";
import { sliceStringByCellColumns } from "./terminalStringCellWidth";
import { isTerminalAlternateScreenActive } from "../terminalHibernateRuntime";
@@ -827,8 +828,15 @@ export function useTerminalAutocomplete(
// Suppress autocomplete when cursor is not at end of input —
// inserting text mid-line would corrupt the command (e.g., "git st|tus" → "git statustus")
const buffer = term.buffer.active;
const lineAfterCursor = buffer.getLine(buffer.cursorY + buffer.baseY)
?.translateToString(false).substring(buffer.cursorX).trimEnd();
const cursorLine = buffer.getLine(buffer.cursorY + buffer.baseY);
const lineAfterCursor = cursorLine
? sliceStringByCellColumns(
cursorLine.translateToString(false),
Math.max(0, buffer.cursorX),
undefined,
term,
).trimEnd()
: "";
if (lineAfterCursor && lineAfterCursor.length > 0) {
clearState();
return;
@@ -0,0 +1,57 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
cancelConnectAutomationBatch,
createConnectAutomationBatch,
trackConnectAutomationStop,
} from "./connectAutomationBatch.ts";
test("cancel waits for the stop operation registered by the abort listener", async () => {
const batch = createConnectAutomationBatch();
let releaseStop: (() => void) | undefined;
let stopFinished = false;
batch.controller.signal.addEventListener("abort", () => {
trackConnectAutomationStop(batch, () => new Promise<void>((resolve) => {
releaseStop = () => {
stopFinished = true;
resolve();
};
}));
}, { once: true });
const cancelling = cancelConnectAutomationBatch(batch);
await Promise.resolve();
assert.equal(stopFinished, false);
releaseStop?.();
await cancelling;
assert.equal(stopFinished, true);
});
test("cancel surfaces a backend stop failure", async () => {
const batch = createConnectAutomationBatch();
batch.controller.signal.addEventListener("abort", () => {
trackConnectAutomationStop(batch, async () => {
throw new Error("stop failed");
});
}, { once: true });
await assert.rejects(() => cancelConnectAutomationBatch(batch), /stop failed/);
});
test("cancel can retry a stop operation after a transient failure", async () => {
const batch = createConnectAutomationBatch();
let attempts = 0;
trackConnectAutomationStop(batch, async () => {
attempts += 1;
if (attempts === 1) throw new Error("temporary stop failure");
});
await assert.rejects(
() => cancelConnectAutomationBatch(batch),
/temporary stop failure/,
);
await cancelConnectAutomationBatch(batch);
assert.equal(attempts, 2);
});
@@ -0,0 +1,25 @@
export interface ConnectAutomationBatch {
controller: AbortController;
stopCurrentRun: (() => Promise<void>) | null;
}
export const createConnectAutomationBatch = (): ConnectAutomationBatch => ({
controller: new AbortController(),
stopCurrentRun: null,
});
export const trackConnectAutomationStop = (
batch: ConnectAutomationBatch,
stopCurrentRun: (() => Promise<void>) | null,
): void => {
batch.stopCurrentRun = stopCurrentRun;
};
export const cancelConnectAutomationBatch = async (
batch: ConnectAutomationBatch,
): Promise<void> => {
batch.controller.abort();
if (batch.stopCurrentRun) {
await batch.stopCurrentRun();
}
};
+220 -1
View File
@@ -1121,7 +1121,13 @@ test("Enter-driven scroll does not dispose nearby keyword decorations", async ()
test("user scroll during Enter keeps prior highlights mounted", async () => {
const raf = installAnimationFrameQueue();
try {
const { term, decorationStates, handlers } = createFakeTerminal("hello DEPLOY world", {
const {
term,
decorationStates,
handlers,
getTranslatedLineIndexes,
resetTranslateCount,
} = createFakeTerminal("hello DEPLOY world", {
lineCount: 80,
});
term.rows = 3;
@@ -1143,8 +1149,15 @@ test("user scroll during Enter keeps prior highlights mounted", async () => {
handlers.data?.("\r");
handlers.writeParsed?.();
term.buffer.active.viewportY = 10;
resetTranslateCount();
handlers.scroll?.();
assert.ok(
getTranslatedLineIndexes().some((lineY) => lineY >= 10 && lineY < 20),
"scrollback browsing during Enter should synchronously scan newly revealed lines",
);
raf.flush();
assert.equal(
existingDecorations.filter(({ isDisposed }) => isDisposed).length,
0,
@@ -1156,6 +1169,89 @@ test("user scroll during Enter keeps prior highlights mounted", async () => {
}
});
test("Enter does not defer a user scroll back to the bottom", () => {
const raf = installAnimationFrameQueue();
try {
const {
term,
handlers,
getTranslatedLineIndexes,
resetTranslateCount,
} = createFakeTerminal("hello DEPLOY world", { lineCount: 80 });
term.rows = 3;
term.buffer.active.viewportY = 20;
term.buffer.active.baseY = 20;
term.buffer.active.cursorY = 2;
const highlighter = new KeywordHighlighter(term as never);
highlighter.setRules([{
id: "deploy",
label: "Deploy",
patterns: ["DEPLOY"],
color: "#F87171",
enabled: true,
}], true);
raf.flush();
handlers.data?.("\r");
term.buffer.active.viewportY = 10;
handlers.scroll?.();
resetTranslateCount();
// Pressing End while the Enter guard is active returns to the bottom
// without changing the output position or waiting for a remote write.
term.buffer.active.viewportY = 20;
handlers.scroll?.();
assert.ok(
getTranslatedLineIndexes().some((lineY) => lineY >= 20),
"scrolling back to the bottom during Enter should scan synchronously",
);
highlighter.dispose();
} finally {
raf.restore();
}
});
test("output during Enter does not cancel an active scrollback browse", () => {
const raf = installAnimationFrameQueue();
try {
const { term, handlers } = createFakeTerminal("hello DEPLOY world", { lineCount: 80 });
term.buffer.active.viewportY = 20;
term.buffer.active.baseY = 20;
term.buffer.active.cursorY = 2;
const highlighter = new KeywordHighlighter(term as never);
highlighter.setRules([{
id: "deploy",
label: "Deploy",
patterns: ["DEPLOY"],
color: "#F87171",
enabled: true,
}], true);
raf.flush();
handlers.data?.("\r");
term.buffer.active.viewportY = 10;
const internals = highlighter as unknown as {
pendingRefreshReason: "scroll" | "write" | "full";
};
// Model a scroll refresh that is pending while the user is browsing
// scrollback, then let remote output move the bottom of the buffer.
internals.pendingRefreshReason = "scroll";
term.buffer.active.baseY += 1;
term.buffer.active.length += 1;
handlers.writeParsed?.();
assert.equal(
internals.pendingRefreshReason,
"scroll",
"remote output must not reclassify an active scrollback browse as Enter output",
);
highlighter.dispose();
} finally {
raf.restore();
}
});
test("large output delays keyword highlight scans until output quiets", async () => {
const raf = installAnimationFrameQueue();
try {
@@ -2083,6 +2179,129 @@ test("pressing Enter does not repaint after keyword markers move", async () => {
}
});
test("idle Enter scroll before writeParsed does not rescan visible keywords", () => {
const raf = installAnimationFrameQueue();
try {
const {
term,
decorationStates,
handlers,
getTranslateCount,
resetTranslateCount,
refreshCalls,
resetRefreshCalls,
} = createFakeTerminal("hello DEPLOY world", { lineCount: 40 });
term.buffer.active.viewportY = 20;
term.buffer.active.baseY = 20;
term.buffer.active.cursorY = 2;
const highlighter = new KeywordHighlighter(term as never);
highlighter.setRules([{
id: "deploy",
label: "Deploy",
patterns: ["DEPLOY"],
color: "#F87171",
enabled: true,
}], true);
raf.flush();
const existingDecorations = [...decorationStates];
assert.ok(existingDecorations.length > 0);
// Ordinary write refreshes clear lastRenderRange. An idle prompt then has no
// scroll coverage hint, so Enter echo onScroll (before writeParsed, Ubuntu RTT)
// would otherwise take the immediate user-scroll path and rescan the viewport.
const internals = highlighter as unknown as {
lastWriteAt: number;
lastRenderRange: { start: number; end: number } | null;
};
internals.lastWriteAt = performance.now() - 10_000;
internals.lastRenderRange = null;
resetTranslateCount();
resetRefreshCalls();
handlers.data?.("\r");
term.buffer.active.viewportY += 1;
term.buffer.active.baseY += 1;
term.buffer.active.length += 1;
handlers.scroll?.();
assert.equal(
getTranslateCount(),
0,
"Enter-pending scroll before writeParsed must not rescan visible keywords",
);
assert.deepEqual(
refreshCalls,
[],
"Enter-pending scroll before writeParsed must not force a keyword repaint",
);
assert.equal(
existingDecorations.filter(({ isDisposed }) => isDisposed).length,
0,
"Enter-pending scroll must keep existing keyword decorations mounted",
);
highlighter.dispose();
} finally {
raf.restore();
}
});
test("Enter without write clears pending so later user scroll can highlight", async () => {
const raf = installAnimationFrameQueue();
try {
const {
term,
handlers,
getTranslateCount,
resetTranslateCount,
} = createFakeTerminal("hello DEPLOY world", { lineCount: 40 });
term.buffer.active.viewportY = 20;
term.buffer.active.baseY = 20;
term.buffer.active.cursorY = 2;
const highlighter = new KeywordHighlighter(term as never);
highlighter.setRules([{
id: "deploy",
label: "Deploy",
patterns: ["DEPLOY"],
color: "#F87171",
enabled: true,
}], true);
raf.flush();
const internals = highlighter as unknown as {
enterInputPending: boolean;
lastWriteAt: number;
lastRenderRange: { start: number; end: number } | null;
};
internals.lastWriteAt = performance.now() - 10_000;
internals.lastRenderRange = null;
// Enter with no echo/writeParsed (echo off / stalled PTY).
handlers.data?.("\r");
assert.equal(internals.enterInputPending, true);
await new Promise((resolve) => { setTimeout(resolve, 700); });
assert.equal(
internals.enterInputPending,
false,
"Enter protection must time out when no write arrives",
);
// User browsing scrollback after the timed-out Enter guard.
resetTranslateCount();
term.buffer.active.viewportY = 10;
handlers.scroll?.();
raf.flush();
assert.ok(
getTranslateCount() > 0,
"user scroll after timed-out Enter must scan newly revealed scrollback",
);
highlighter.dispose();
} finally {
raf.restore();
}
});
test("long-line pressure avoids scanning across a whole soft-wrapped logical line", () => {
const raf = installAnimationFrameQueue();
try {
+49 -8
View File
@@ -134,6 +134,7 @@ export class KeywordHighlighter implements IDisposable {
private static readonly WRITE_BURST_DEBOUNCE_MS = 180;
private static readonly WRITE_BURST_IMMEDIATE_MIN_INTERVAL_MS = 48;
private static readonly WRITE_BURST_HIGHLIGHT_PAUSE_MS = 260;
private static readonly ENTER_INPUT_GUARD_MS = 600;
private static readonly WRITE_PRUNE_IDLE_MS = 600;
constructor(term: XTerm) {
@@ -153,9 +154,15 @@ export class KeywordHighlighter implements IDisposable {
if (data.includes("\r") || data.includes("\n")) {
this.enterInputPending = true;
this.enterQueuedWriteCancellationPending = true;
if (this.enterInputIdleTimer) {
clearTimeout(this.enterInputIdleTimer);
this.enterInputIdleTimer = null;
// Time-bound Enter protection even when no echo/write arrives (echo
// off, stalled PTY). onWriteParsed re-arms this on each write.
this.scheduleEnterInputIdleClear();
// Drop any pending user-scroll refresh so Enter echo cannot finish a
// scroll pass that rescans/repaints still-visible keyword decorations
// before onWriteParsed owns the write path (Ubuntu RTT).
if (this.pendingRefreshReason === "scroll" && !this.isBrowsingScrollback()) {
this.cancelQueuedRefreshSchedule();
this.pendingRefreshReason = "write";
}
}
}),
@@ -165,11 +172,12 @@ export class KeywordHighlighter implements IDisposable {
if (this.enterInputPending) {
this.scheduleEnterInputIdleClear();
}
const isBrowsingScrollback = this.isBrowsingScrollback();
const outputDrivenPendingScroll =
this.pendingRefreshReason === "scroll"
&& !isBrowsingScrollback
&& (
this.hasOutputPositionChangedSinceLastSnapshot()
|| this.hasDecorationMarkerShiftSinceLastRefresh()
this.hasOutputDrivenViewportChange()
);
const cancelQueuedWriteForEnter =
this.enterQueuedWriteCancellationPending
@@ -706,9 +714,26 @@ export class KeywordHighlighter implements IDisposable {
}
private triggerViewportChangeRefresh() {
const isBrowsingScrollback = this.isBrowsingScrollback();
// Enter echo often emits onScroll before onWriteParsed. After an idle gap
// lastWriteAt looks stale and lastRenderRange is usually null (cleared by
// the previous write refresh), so the output-driven scroll path would
// synchronously rescan the viewport and flash keywords still on screen.
// Keep real scrollback browsing synchronous; only defer the bottom-pinned
// viewport movement that can be caused by the pending Enter echo.
if (
this.enterInputPending
&& !isBrowsingScrollback
&& this.hasOutputDrivenViewportChange()
) {
if (this.pendingRefreshReason === "scroll") {
this.cancelQueuedRefreshSchedule();
this.pendingRefreshReason = "write";
}
this.markVisibleRangeDirty();
return;
}
const now = performance.now();
const buffer = this.term.buffer.active;
const isBrowsingScrollback = buffer.viewportY < buffer.baseY;
const isOutputDrivenViewportChange =
!isBrowsingScrollback &&
this.lastWriteAt > 0 &&
@@ -1061,7 +1086,23 @@ export class KeywordHighlighter implements IDisposable {
this.enterInputIdleTimer = setTimeout(() => {
this.enterInputIdleTimer = null;
this.enterInputPending = false;
}, KeywordHighlighter.WRITE_PRUNE_IDLE_MS);
// Catch up any viewport motion deferred while Enter protection blocked
// scroll refresh (e.g. user scrolled during the post-Enter window).
this.markVisibleRangeDirty();
this.triggerRefresh("debounced", "write");
}, KeywordHighlighter.ENTER_INPUT_GUARD_MS);
}
private isBrowsingScrollback(): boolean {
const buffer = this.term.buffer.active;
return buffer.viewportY < buffer.baseY;
}
private hasOutputDrivenViewportChange(): boolean {
return (
this.hasOutputPositionChangedSinceLastSnapshot()
|| this.hasDecorationMarkerShiftSinceLastRefresh()
);
}
private mergeRefreshReason(current: RefreshReason, next: RefreshReason): RefreshReason {
@@ -185,14 +185,18 @@ test("pending cursor-home lookahead controls are bounded before clear", () => {
assert.equal(log.includes("\x1b[31m"), false);
});
test("alternate-screen entry protects preserved replay history", () => {
test("alternate-screen body is omitted from replay-safe connection logs", () => {
const log = createReplaySafeTerminalLog(
"before\n\x1b[?1049h\x1b[Hvim screen\n\x1b[?1049lafter\n"
"before\n\x1b[?1049h\x1b[Hvim screen\n~\n\"file\" 0L, 0B\x1b[?1049lafter\n"
+ "\x1b[?47h\x1b[10;5Htop screen\n\x1b[?47l"
+ "\x1b[?25lcursor hidden\n",
);
assert.equal(log, "before\n\r\nvim screen\nafter\n\r\ntop screen\n\x1b[?25lcursor hidden\n");
assert.equal(log, "before\nafter\n\x1b[?25lcursor hidden\n");
assert.equal(log.includes("vim screen"), false);
assert.equal(log.includes("~"), false);
assert.equal(log.includes("0L, 0B"), false);
assert.equal(log.includes("top screen"), false);
assert.equal(log.includes("\x1b[?1049h"), false);
assert.equal(log.includes("\x1b[?1049l"), false);
assert.equal(log.includes("\x1b[?47h"), false);
@@ -202,6 +206,49 @@ test("alternate-screen entry protects preserved replay history", () => {
assert.equal(log.includes("\x1b[?25l"), true);
});
test("alternate-screen omit tracks enter/leave split across chunks", () => {
const sanitizer = createReplaySafeTerminalLogSanitizer();
const log = sanitizer.append("$ vim\n\x1b[?1049")
+ sanitizer.append("h~\nstatus\x1b[?1049")
+ sanitizer.append("l$ ")
+ sanitizer.finish();
assert.equal(log, "$ vim\n$ ");
assert.equal(log.includes("~"), false);
assert.equal(log.includes("status"), false);
});
test("alternate-screen omit does not use plain-text fast path between chunks", () => {
const sanitizer = createReplaySafeTerminalLogSanitizer();
const log = sanitizer.append("\x1b[?1049h")
+ sanitizer.append("~\nstatus")
+ sanitizer.append("\x1b[?1049l")
+ sanitizer.finish();
assert.equal(log, "");
assert.equal(log.includes("~"), false);
assert.equal(log.includes("status"), false);
});
test("seeded alternate-screen sanitizer omits TUI paint until leave", () => {
const sanitizer = createReplaySafeTerminalLogSanitizer({ alternateScreenActive: true });
const log = sanitizer.append("~\nstatus")
+ sanitizer.append("\x1b[?1049l$ done")
+ sanitizer.finish();
assert.equal(log, "$ done");
assert.equal(log.includes("~"), false);
assert.equal(log.includes("status"), false);
});
test("pending cursor-home is discarded when entering alternate screen", () => {
const log = createReplaySafeTerminalLog("before\n\x1b[H\x1b[?1049hTUI\x1b[?1049lafter");
assert.equal(log, "before\nafter");
assert.equal(log.includes("\x1b[H"), false);
assert.equal(log.includes("TUI"), false);
});
test("dec cursor save mode is not treated as alternate screen", () => {
const log = createReplaySafeTerminalLog("before\n\x1b[?1048h\x1b[?1048l\x1b[10;5Hpositioned\n");
@@ -225,6 +272,26 @@ test("terminal reset controls are dropped from replay data", () => {
assert.equal(log.includes("\x1bc"), false);
});
test("RIS during alternate screen restores logging of later shell output", () => {
const log = createReplaySafeTerminalLog("before\n\x1b[?1049hTUI\x1bcshell-after\n");
assert.equal(log, "before\n\r\nshell-after\n");
assert.equal(log.includes("TUI"), false);
assert.equal(log.includes("\x1bc"), false);
});
test("split RIS during alternate screen restores logging of later shell output", () => {
const sanitizer = createReplaySafeTerminalLogSanitizer();
const log = sanitizer.append("before\n\x1b[?1049hTUI\x1b")
+ sanitizer.append("cshell-after\n")
+ sanitizer.finish();
assert.equal(log, "before\n\r\nshell-after\n");
assert.equal(log.includes("TUI"), false);
assert.equal(log.includes("\x1bc"), false);
});
test("split terminal reset controls are dropped from replay data", () => {
const sanitizer = createReplaySafeTerminalLogSanitizer();
+46 -4
View File
@@ -13,6 +13,11 @@ const MAX_PENDING_ESCAPE_CHARS = 4096;
type ControlStringMode = "osc" | "string";
export type ReplaySafeTerminalLogSanitizerOptions = {
/** Seed when attaching mid-TUI so live redraws stay omitted until leave. */
alternateScreenActive?: boolean;
};
export interface ReplaySafeTerminalLogSanitizer {
append(input: string): string;
finish(): string;
@@ -175,9 +180,14 @@ class ReplaySafeTerminalLogSanitizerImpl implements ReplaySafeTerminalLogSanitiz
private discardingCsi = false;
private inClearCluster = false;
private protectingClearedHistory = false;
private alternateScreenActive: boolean;
private hasOutput = false;
private lastOutputChar = "";
constructor(options: ReplaySafeTerminalLogSanitizerOptions = {}) {
this.alternateScreenActive = options.alternateScreenActive === true;
}
append(input: string): string {
let output = "";
const data = this.pendingInput + input;
@@ -191,7 +201,8 @@ class ReplaySafeTerminalLogSanitizerImpl implements ReplaySafeTerminalLogSanitiz
};
if (
!this.pendingCursorHome
!this.alternateScreenActive
&& !this.pendingCursorHome
&& !this.discardingCsi
&& !this.controlStringMode
&& !hasReplayControlCandidate(data)
@@ -260,13 +271,22 @@ class ReplaySafeTerminalLogSanitizerImpl implements ReplaySafeTerminalLogSanitiz
if (sequence) {
const alternateScreenMode = getAlternateScreenMode(sequence);
if (alternateScreenMode) {
this.alternateScreenActive = alternateScreenMode === "enter";
if (alternateScreenMode === "enter") {
emitClearSeparator(false);
this.pendingCursorHome = "";
this.pendingAfterCursorHome = "";
this.replaySafePendingAfterCursorHome = "";
this.pendingAfterCursorHomeOverflowed = false;
}
i = sequence.end;
continue;
}
if (this.alternateScreenActive) {
i = sequence.end;
continue;
}
if (isCursorHomeSequence(sequence)) {
if (!this.inClearCluster) {
if (!this.pendingCursorHome) {
@@ -319,6 +339,25 @@ class ReplaySafeTerminalLogSanitizerImpl implements ReplaySafeTerminalLogSanitiz
continue;
}
if (this.alternateScreenActive) {
if (data[i] === ESC && i + 1 >= data.length) {
this.setPendingEscapeInput(data.slice(i));
break;
}
// RIS (ESC c) fully resets the terminal, including leaving the
// alternate screen. Process it before the omit-scan skip so later
// shell output is not dropped from the connection log.
if (data[i] === ESC && data[i + 1] === "c") {
this.alternateScreenActive = false;
emitClearSeparator(false);
i += 2;
continue;
}
const nextControl = nextReplayControlCandidate(data, i + 1);
i = nextControl === -1 ? data.length : nextControl;
continue;
}
if (data[i] === ESC) {
if (i + 1 >= data.length) {
this.setPendingEscapeInput(data.slice(i));
@@ -326,6 +365,7 @@ class ReplaySafeTerminalLogSanitizerImpl implements ReplaySafeTerminalLogSanitiz
}
if (data[i + 1] === "c") {
this.alternateScreenActive = false;
emitClearSeparator(false);
i += 2;
continue;
@@ -448,8 +488,10 @@ class ReplaySafeTerminalLogSanitizerImpl implements ReplaySafeTerminalLogSanitiz
}
}
export const createReplaySafeTerminalLogSanitizer = (): ReplaySafeTerminalLogSanitizer =>
new ReplaySafeTerminalLogSanitizerImpl();
export const createReplaySafeTerminalLogSanitizer = (
options: ReplaySafeTerminalLogSanitizerOptions = {},
): ReplaySafeTerminalLogSanitizer =>
new ReplaySafeTerminalLogSanitizerImpl(options);
/**
* Convert terminal output into a form that can be replayed in LogView without
@@ -4,6 +4,8 @@ import { readFileSync } from "node:fs";
import {
getInitialTerminalStatus,
resolveTerminalVaultInitialized,
shouldResetConnectAutomationOnReconnect,
shouldSuppressHostStartupCommandOnReconnect,
shouldStartTerminalBackend,
} from "./restoredSessionGate.ts";
@@ -41,12 +43,108 @@ test("terminal boot waits for vaultInitialized before creating a backend session
);
});
test("terminal popup can supply its own completed vault hydration state", () => {
const terminalSource = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const popupSource = readFileSync(new URL("../TerminalPopupPage.tsx", import.meta.url), "utf8");
assert.match(
terminalSource,
/const vaultInitialized = resolveTerminalVaultInitialized\(\s*sharedVaultInitialized,\s*vaultInitializedOverride,\s*\);/,
);
assert.match(popupSource, /vaultInitializedOverride=\{vaultInitialized\}/);
assert.equal(resolveTerminalVaultInitialized(false, true), true);
assert.equal(resolveTerminalVaultInitialized(false), false);
assert.equal(resolveTerminalVaultInitialized(true, false), false);
});
test("host startup command policy distinguishes restored and automatic reconnects", () => {
assert.equal(shouldSuppressHostStartupCommandOnReconnect("restored"), false);
assert.equal(shouldSuppressHostStartupCommandOnReconnect("manual"), false);
assert.equal(shouldSuppressHostStartupCommandOnReconnect("automatic"), true);
});
test("connect automation reset policy distinguishes manual and automatic reconnects", () => {
assert.equal(shouldResetConnectAutomationOnReconnect("manual"), true);
assert.equal(shouldResetConnectAutomationOnReconnect("restored"), true);
assert.equal(shouldResetConnectAutomationOnReconnect("automatic"), false);
});
test("manual reconnect resets connect automation before opening a new session", () => {
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const reconnectIndex = source.indexOf("const startReconnect = ");
const cancelBatchIndex = source.indexOf(
"await cancelConnectAutomationBatch(connectAutomationBatch)",
reconnectIndex,
);
const manualBranchIndex = source.indexOf('if (mode === "manual")', reconnectIndex);
const stopFailureRetryIndex = source.indexOf(
'if (mode === "auto" && retryTokenStillCurrent())',
cancelBatchIndex,
);
const stopFailureDisconnectedIndex = source.indexOf(
'updateStatus("disconnected")',
stopFailureRetryIndex,
);
const resetConsumedIndex = source.indexOf(
"connectScriptsConsumedRef.current = false",
manualBranchIndex,
);
const resetCompletedIndex = source.indexOf(
"connectScriptsCompletedIdsRef.current = new Set()",
manualBranchIndex,
);
const resetInFlightIndex = source.indexOf(
"connectScriptsInFlightRef.current = false",
manualBranchIndex,
);
const connectingIndex = source.indexOf('updateStatus("connecting")', manualBranchIndex);
const autoElseIndex = source.indexOf("} else {", manualBranchIndex);
const autoSuppressIndex = source.indexOf(
'shouldSuppressHostStartupCommandOnReconnect("automatic")',
autoElseIndex,
);
assert.notEqual(reconnectIndex, -1);
assert.notEqual(cancelBatchIndex, -1);
assert.notEqual(manualBranchIndex, -1);
assert.notEqual(stopFailureRetryIndex, -1);
assert.notEqual(stopFailureDisconnectedIndex, -1);
assert.notEqual(resetConsumedIndex, -1);
assert.notEqual(resetCompletedIndex, -1);
assert.notEqual(resetInFlightIndex, -1);
assert.notEqual(connectingIndex, -1);
assert.notEqual(autoElseIndex, -1);
assert.notEqual(autoSuppressIndex, -1);
assert.ok(
cancelBatchIndex < manualBranchIndex
&& cancelBatchIndex < resetConsumedIndex
&& cancelBatchIndex < resetCompletedIndex
&& cancelBatchIndex < resetInFlightIndex,
"every reconnect must await onConnect cancellation before manual guards are cleared",
);
assert.ok(
stopFailureRetryIndex < stopFailureDisconnectedIndex
&& stopFailureDisconnectedIndex < manualBranchIndex,
"automatic reconnect must return to disconnected when stopping the old batch fails",
);
assert.ok(
resetConsumedIndex < connectingIndex && resetCompletedIndex < connectingIndex,
"manual reconnect must clear connect-automation consumption before status becomes connecting",
);
assert.ok(
autoElseIndex < autoSuppressIndex && autoSuppressIndex < connectingIndex,
"automatic reconnect must keep the existing connect-automation consumption decision",
);
assert.ok(
!source.slice(autoElseIndex, connectingIndex).includes("connectScriptsConsumedRef.current = false"),
"automatic reconnect must not reset connect-automation refs",
);
assert.match(
source.slice(autoElseIndex, connectingIndex),
/connectScriptsConsumedRef\.current = true/,
"automatic reconnect must stop the old batch without queuing it again",
);
});
test("restored disconnected sessions still create a terminal runtime before backend startup", () => {
const source = readFileSync(new URL("./useTerminalEffects.ts", import.meta.url), "utf8");
const runtimeIndex = source.indexOf("const runtime = createXTermRuntime");
@@ -7,6 +7,11 @@ export const getInitialTerminalStatus = (): TerminalSession["status"] => (
"connecting"
);
export const resolveTerminalVaultInitialized = (
sharedVaultInitialized: boolean,
windowVaultInitialized?: boolean,
): boolean => windowVaultInitialized ?? sharedVaultInitialized;
/**
* Backend start waits for vault hydration so restored sessions cannot dial SSH
* with empty keys while hosts have already been published mid-init.
@@ -16,3 +21,11 @@ export const shouldStartTerminalBackend = (): boolean => isVaultInitialized();
export const shouldSuppressHostStartupCommandOnReconnect = (
mode: TerminalReconnectMode,
): boolean => mode === "automatic";
/**
* Manual and restored reconnects are a fresh "connect" for automation scripts.
* Automatic reconnect after a drop must not re-run onConnect scripts mid-session.
*/
export const shouldResetConnectAutomationOnReconnect = (
mode: TerminalReconnectMode,
): boolean => mode !== "automatic";
@@ -70,6 +70,27 @@ test("mouse-tracking context menu capture lets Shift-modified mouse events pass
);
});
test("mouse-tracking context menu capture prefers the terminal's current mode over stale cached state", () => {
assert.equal(
shouldInterceptMouseTrackingContextMenu({
event: { shiftKey: false } as MouseEvent,
mouseTracking: false,
terminalMouseTrackingMode: "vt200",
status: "connected",
}),
true,
);
assert.equal(
shouldInterceptMouseTrackingContextMenu({
event: { shiftKey: false } as MouseEvent,
mouseTracking: true,
terminalMouseTrackingMode: "none",
status: "connected",
}),
false,
);
});
test("mouse-tracking context menu capture yields to the fullscreen-apps menu setting for context-menu clicks", () => {
// Setting on + context-menu behavior: do NOT intercept, so Radix opens the menu.
assert.equal(
@@ -255,6 +276,31 @@ test("right-click mousedown is stopped when the fullscreen-apps menu setting for
);
});
test("right-click mousedown also uses the terminal's current mode", () => {
assert.equal(
shouldStopShiftRightClickMouseTrackingMouseDown({
event: { button: 2, shiftKey: false } as MouseEvent,
mouseTracking: false,
terminalMouseTrackingMode: "vt200",
status: "connected",
rightClickBehavior: "context-menu",
forceMenuInAlternateScreen: true,
}),
true,
);
assert.equal(
shouldStopShiftRightClickMouseTrackingMouseDown({
event: { button: 2, shiftKey: false } as MouseEvent,
mouseTracking: true,
terminalMouseTrackingMode: "none",
status: "connected",
rightClickBehavior: "context-menu",
forceMenuInAlternateScreen: true,
}),
false,
);
});
test("Shift right-click mousedown capture is limited to connected mouse tracking", () => {
const baseEvent = {
button: 2,
@@ -16,6 +16,8 @@ type ShiftSelectionReplayMouseEvent = MouseEvent & {
export interface MouseTrackingContextMenuCaptureState {
event: MouseEvent;
mouseTracking: boolean;
/** Current xterm mouse tracking mode, when available at event time. */
terminalMouseTrackingMode?: string;
status?: string | null;
/** The user's configured right-click action. */
rightClickBehavior?: RightClickBehavior;
@@ -33,6 +35,8 @@ export interface ShiftMouseSelectionReplayState {
export interface ShiftRightClickMouseDownCaptureState {
event: MouseEvent;
mouseTracking: boolean;
/** Current xterm mouse tracking mode, when available at event time. */
terminalMouseTrackingMode?: string;
status?: string | null;
/** The user's configured right-click action. */
rightClickBehavior?: RightClickBehavior;
@@ -90,14 +94,23 @@ const forcesMenuOverMouseTracking = ({
forceMenuInAlternateScreen?: boolean;
}): boolean => Boolean(forceMenuInAlternateScreen && rightClickBehavior === "context-menu");
export const isMouseTrackingActive = ({
mouseTracking,
terminalMouseTrackingMode,
}: Pick<MouseTrackingContextMenuCaptureState, "mouseTracking" | "terminalMouseTrackingMode">): boolean =>
terminalMouseTrackingMode === undefined
? mouseTracking
: terminalMouseTrackingMode !== "none";
export const shouldInterceptMouseTrackingContextMenu = ({
event,
mouseTracking,
terminalMouseTrackingMode,
status,
rightClickBehavior,
forceMenuInAlternateScreen,
}: MouseTrackingContextMenuCaptureState): boolean =>
mouseTracking
isMouseTrackingActive({ mouseTracking, terminalMouseTrackingMode })
&& status === "connected"
&& !event.shiftKey
&& !isMiddleClickContextMenuEvent(event)
@@ -122,11 +135,12 @@ export const shouldReplayShiftMouseSelectionAsMacOption = ({
export const shouldStopShiftRightClickMouseTrackingMouseDown = ({
event,
mouseTracking,
terminalMouseTrackingMode,
status,
rightClickBehavior,
forceMenuInAlternateScreen,
}: ShiftRightClickMouseDownCaptureState): boolean =>
mouseTracking
isMouseTrackingActive({ mouseTracking, terminalMouseTrackingMode })
&& status === "connected"
&& event.button === 2
&& (event.shiftKey || forcesMenuOverMouseTracking({ rightClickBehavior, forceMenuInAlternateScreen }));
@@ -3,9 +3,15 @@ import test from "node:test";
import { getSessionLogInitialLine } from "./sessionLogInitialLine.ts";
const makeTerm = (rows: Array<{ text: string; isWrapped?: boolean }>, cursorY: number, cursorX: number) => ({
const makeTerm = (
rows: Array<{ text: string; isWrapped?: boolean }>,
cursorY: number,
cursorX: number,
options: { type?: "normal" | "alternate" } = {},
) => ({
buffer: {
active: {
type: options.type ?? "normal",
baseY: 0,
cursorY,
cursorX,
@@ -37,3 +43,17 @@ test("getSessionLogInitialLine falls back to nearest trusted prompt line", () =>
assert.equal(getSessionLogInitialLine(term), "root@MyNAS:~# ");
});
test("getSessionLogInitialLine skips alternate-screen vim tilde rows", () => {
const term = makeTerm(
[
{ text: "~" },
{ text: "\"file.txt\" 0L, 0B" },
],
0,
1,
{ type: "alternate" },
);
assert.equal(getSessionLogInitialLine(term), "");
});
+4 -1
View File
@@ -12,7 +12,7 @@ type ActiveBufferLike = {
export type TerminalSessionLogInitialLineSource = {
buffer: {
active: ActiveBufferLike;
active: ActiveBufferLike & { type?: string };
};
};
@@ -26,6 +26,9 @@ const CMD_PROMPT_RE = /^[A-Za-z]:\\.*>$/;
export function getSessionLogInitialLine(term: TerminalSessionLogInitialLineSource): string {
const buffer = term.buffer.active;
if (buffer.type === "alternate") {
return "";
}
const cursorY = buffer.cursorY + buffer.baseY;
const currentPrefix = getLogicalLinePrefix(buffer, cursorY, buffer.cursorX);
+8 -2
View File
@@ -16,7 +16,10 @@ test("resolvePreferredTerminalCwd prefers fresh backend pwd when requested", asy
preferFreshBackend: true,
getSessionPwd: async (_sessionId, options) => {
backendCalls += 1;
assert.deepEqual(options, { allowHomeFallback: false });
assert.deepEqual(options, {
allowHomeFallback: false,
allowLoginShellFallback: true,
});
return { success: true, cwd: "/lost+found" };
},
});
@@ -47,7 +50,10 @@ test("resolvePreferredTerminalCwd falls back to renderer cwd when fresh backend
sessionId: "session-1",
preferFreshBackend: true,
getSessionPwd: async (_sessionId, options) => {
assert.deepEqual(options, { allowHomeFallback: false });
assert.deepEqual(options, {
allowHomeFallback: false,
allowLoginShellFallback: true,
});
return { success: false, error: "Could not determine cwd" };
},
});
+11 -1
View File
@@ -5,6 +5,11 @@ type SessionPwdResult = {
type SessionPwdOptions = {
allowHomeFallback?: boolean;
/**
* When true, fall back to the same-uid login shell cwd after su/sudo.
* When omitted, follows allowHomeFallback (backend default).
*/
allowLoginShellFallback?: boolean;
};
type ResolvePreferredTerminalCwdOptions = {
@@ -57,7 +62,12 @@ export const resolvePreferredTerminalCwd = async ({
try {
const result = await getSessionPwd(
sessionId,
preferFreshBackend ? { allowHomeFallback: false } : undefined,
// Disable ~ guessing so we do not open SFTP on a fabricated home path,
// but keep login-shell fallback after su/sudo so drag-drop still
// resolves a writable cwd (#2886).
preferFreshBackend
? { allowHomeFallback: false, allowLoginShellFallback: true }
: undefined,
);
const backendCwd = result.success ? normalizeCwd(result.cwd) : null;
return backendCwd ?? (allowRendererFallback ? knownCwd : null);
@@ -380,7 +380,10 @@ test("fresh cwd resolution falls back to the renderer cwd when backend probe has
sessionId: "session-1",
preferFreshBackend: true,
getSessionPwd: async (_sessionId, options) => {
assert.deepEqual(options, { allowHomeFallback: false });
assert.deepEqual(options, {
allowHomeFallback: false,
allowLoginShellFallback: true,
});
return { success: false, error: "Could not determine cwd" };
},
});
+2
View File
@@ -136,6 +136,8 @@ export interface TerminalProps {
sessionId: string;
workspaceId?: string;
restoreState?: TerminalSession["restoreState"];
/** Secondary windows hydrate their own vault state outside the main snapshot store. */
vaultInitializedOverride?: boolean;
pendingInitialCwd?: string;
shellType?: TerminalSession["shellType"];
lastCwd?: string;
+7
View File
@@ -40,6 +40,13 @@ test("terminal memo refreshes when restored local shell type changes", () => {
);
});
test("terminal memo refreshes when a popup vault finishes hydrating", () => {
assert.equal(
terminalPropsAreEqual(baseProps, { ...baseProps, vaultInitializedOverride: true }),
false,
);
});
test("terminal memo refreshes when terminal context reader callback changes", () => {
assert.equal(
terminalPropsAreEqual(baseProps, {
+1
View File
@@ -36,6 +36,7 @@ export const terminalPropsAreEqual = (
&& prev.terminalSettings === next.terminalSettings
&& prev.sessionId === next.sessionId
&& prev.restoreState === next.restoreState
&& prev.vaultInitializedOverride === next.vaultInitializedOverride
&& prev.shellType === next.shellType
&& prev.lastCwd === next.lastCwd
&& prev.restoreTerminalCwd === next.restoreTerminalCwd
@@ -1,8 +1,8 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import { stringCellWidth } from "./autocomplete/terminalStringCellWidth.ts";
import { stringCellWidth, sliceStringByCellColumns } from "./autocomplete/terminalStringCellWidth.ts";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
@@ -14,6 +14,20 @@ test("stringCellWidth counts CJK ideographs as two cells each", () => {
assert.equal(stringCellWidth("部署"), 4);
});
test("sliceStringByCellColumns respects wide CJK cells", () => {
const prompt = String.raw`C:\Users\用户>`;
const line = `${prompt}部署`;
const promptCells = stringCellWidth(prompt);
assert.equal(sliceStringByCellColumns(line, 0, promptCells), prompt);
assert.equal(sliceStringByCellColumns(line, promptCells), "部署");
// Padding spaces after the content must not leak into the cursor prefix.
const padded = line + " ".repeat(20);
assert.equal(
sliceStringByCellColumns(padded, 0, stringCellWidth(line)),
line,
);
});
test("stringCellWidth collapses ZWJ emoji to one wide grapheme", () => {
assert.equal(stringCellWidth("👨‍💻"), 2);
});
@@ -657,6 +657,12 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
logger.warn("Failed to write attach snapshot to popup terminal", writeErr);
}
}
// Snapshot was applied with term.write(), not the connection-log
// sanitizer. Re-seed from the restored buffer so live TUI redraws
// after attach (vim already open) stay omitted until leave.
terminalLogSanitizerRef.current = createReplaySafeTerminalLogSanitizer({
alternateScreenActive: isTerminalAlternateScreenActive(term),
});
const rebind = await terminalBackend.rebindSessionOutput?.(
sessionId,
@@ -1686,6 +1692,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
if (!shouldInterceptMouseTrackingContextMenu({
event: e,
mouseTracking: mouseTrackingRef.current,
terminalMouseTrackingMode: termRef.current?.modes.mouseTrackingMode,
status: statusRef.current,
rightClickBehavior: terminalSettingsRef.current?.rightClickBehavior,
forceMenuInAlternateScreen: terminalSettingsRef.current?.showContextMenuOverFullscreenApps,
@@ -1718,6 +1725,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
if (rightClickPressClaim.noteMouseDown({
event: e,
mouseTracking: mouseTrackingRef.current,
terminalMouseTrackingMode: termRef.current?.modes.mouseTrackingMode,
status: statusRef.current,
rightClickBehavior: terminalSettingsRef.current?.rightClickBehavior,
forceMenuInAlternateScreen: terminalSettingsRef.current?.showContextMenuOverFullscreenApps,
@@ -7,3 +7,31 @@ const source = readFileSync(new URL('./TerminalFocusSidebar.tsx', import.meta.ur
test('focus sidebar row memo refreshes when dynamic title mode changes', () => {
assert.match(source, /prev\.dynamicTabTitleMode === next\.dynamicTabTitleMode/);
});
test('focus sidebar accepts host-id drops for append-to-workspace', () => {
assert.match(source, /onAppendHostToWorkspace/);
assert.match(source, /resolveFocusSidebarDragKind/);
assert.match(source, /appendHostFromWorkspaceDrop/);
assert.match(source, /dropEffect = 'copy'/);
assert.match(source, /data-host-drop-active/);
});
test('the entire focus sidebar is the host drop target', () => {
const sidebarStart = source.indexOf('data-section="terminal-workspace-sidebar"');
const scrollAreaStart = source.indexOf('<ScrollArea className="flex-1">', sidebarStart);
assert.ok(sidebarStart >= 0 && scrollAreaStart > sidebarStart);
const sidebarOpeningTag = source.slice(sidebarStart, scrollAreaStart);
assert.match(sidebarOpeningTag, /data-focus-sidebar-drop-zone/);
assert.match(sidebarOpeningTag, /onDragOver=\{handleFocusSidebarHostDragOver\}/);
assert.match(sidebarOpeningTag, /onDrop=\{handleFocusSidebarHostDrop\}/);
assert.doesNotMatch(source.slice(scrollAreaStart), /\sdata-focus-sidebar-drop-zone(?:\s|>)/);
});
test('focus sidebar clears host-drop feedback when the drag leaves from a session row', () => {
assert.match(source, /data-focus-sidebar-drop-zone/);
assert.match(source, /onDragLeave=\{handleFocusSidebarHostDragLeave\}/);
assert.match(source, /onHostDragLeave=\{handleFocusSidebarHostDragLeave\}/);
assert.match(source, /dropZone\?\.contains\(next\)/);
assert.match(source, /clearFocusSidebarHostDrop\(\)/);
});
+111 -14
View File
@@ -7,6 +7,11 @@ import {
} from '../../application/state/sessionPresentationStore';
import { useStoredNumber } from '../../application/state/useStoredNumber';
import { terminalReconnectRegistry } from '../../application/state/terminalReconnectRegistry';
import {
appendHostFromWorkspaceDrop,
FOCUS_SIDEBAR_SESSION_DRAG_TYPE,
resolveFocusSidebarDragKind,
} from '../../domain/focusSidebarHostDrop';
import { resolveWorkspaceFocusSessionOrder } from '../../domain/workspace';
import { resolveSessionTabTitle } from '../../domain/sessionTabTitle';
import type { DynamicTabTitleMode } from '../../domain/models';
@@ -27,6 +32,7 @@ interface TerminalFocusSidebarProps {
focusedSessionId: string | undefined;
onReorderWorkspaceSessions?: (workspaceId: string, draggedSessionId: string, targetSessionId: string, position: 'before' | 'after') => void;
onRequestAddToWorkspace?: (workspaceId: string) => void;
onAppendHostToWorkspace?: (workspaceId: string, hostId: string) => void;
onCloseSession: (sessionId: string) => void;
onCopySession?: (sessionId: string) => void;
onCopySessionToNewWindow?: (sessionId: string) => void;
@@ -71,6 +77,7 @@ type WorkspaceFocusSessionRowProps = {
onSelect: (sessionId: string) => void;
onDragStart: (event: DragEvent, sessionId: string) => void;
onDragOver: (event: DragEvent, sessionId: string) => void;
onHostDragLeave: (event: DragEvent<HTMLDivElement>) => void;
onDrop: (event: DragEvent, sessionId: string) => void;
onDragEnd: () => void;
dynamicTabTitleMode?: DynamicTabTitleMode;
@@ -96,6 +103,7 @@ const WorkspaceFocusSessionRow = memo<WorkspaceFocusSessionRowProps>(({
onSelect,
onDragStart,
onDragOver,
onHostDragLeave,
onDrop,
onDragEnd,
dynamicTabTitleMode,
@@ -142,9 +150,7 @@ const WorkspaceFocusSessionRow = memo<WorkspaceFocusSessionRowProps>(({
onContextMenu={() => onSelect(session.id)}
onDragStart={(event) => onDragStart(event, session.id)}
onDragOver={(event) => onDragOver(event, session.id)}
onDragLeave={(event) => {
event.stopPropagation();
}}
onDragLeave={onHostDragLeave}
onDrop={(event) => onDrop(event, session.id)}
onDragEnd={onDragEnd}
onMouseEnter={(event) => {
@@ -247,6 +253,7 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
focusedSessionId,
onReorderWorkspaceSessions,
onRequestAddToWorkspace,
onAppendHostToWorkspace,
onCloseSession,
onCopySession,
onCopySessionToNewWindow,
@@ -262,6 +269,7 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
}) => {
const [focusSidebarSearch, setFocusSidebarSearch] = useState('');
const [focusSidebarDragSessionId, setFocusSidebarDragSessionId] = useState<string | null>(null);
const [focusSidebarHostDropActive, setFocusSidebarHostDropActive] = useState(false);
const [focusSidebarDropIndicator, setFocusSidebarDropIndicator] = useState<{
sessionId: string;
position: 'before' | 'after';
@@ -343,10 +351,23 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
const handleFocusSidebarDragStart = useCallback((event: DragEvent, sessionId: string) => {
event.stopPropagation();
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('workspace-focus-session-id', sessionId);
event.dataTransfer.setData(FOCUS_SIDEBAR_SESSION_DRAG_TYPE, sessionId);
setFocusSidebarDragSessionId(sessionId);
setFocusSidebarHostDropActive(false);
}, []);
const clearFocusSidebarHostDrop = useCallback(() => {
setFocusSidebarHostDropActive(false);
}, []);
const handleFocusSidebarHostDragLeave = useCallback((event: DragEvent<HTMLDivElement>) => {
event.stopPropagation();
const dropZone = event.currentTarget.closest<HTMLElement>('[data-focus-sidebar-drop-zone]');
const next = event.relatedTarget;
if (next instanceof Node && dropZone?.contains(next)) return;
clearFocusSidebarHostDrop();
}, [clearFocusSidebarHostDrop]);
const getFocusSidebarContainerDropTarget = useCallback((
container: HTMLElement,
clientY: number,
@@ -379,21 +400,45 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
return lastSessionId ? { sessionId: lastSessionId, position: 'after' } : null;
}, []);
const acceptFocusSidebarHostDrag = useCallback((event: DragEvent) => {
if (!onAppendHostToWorkspace) return false;
if (resolveFocusSidebarDragKind({
types: event.dataTransfer.types,
activeSessionDragId: focusSidebarDragSessionId,
}) !== 'host-append') {
return false;
}
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
setFocusSidebarDropIndicator(null);
setFocusSidebarHostDropActive(true);
return true;
}, [focusSidebarDragSessionId, onAppendHostToWorkspace]);
const handleFocusSidebarHostDragOver = useCallback((event: DragEvent<HTMLDivElement>) => {
acceptFocusSidebarHostDrag(event);
}, [acceptFocusSidebarHostDrag]);
const handleFocusSidebarDragOver = useCallback((event: DragEvent, targetSessionId: string) => {
const draggedSessionId = event.dataTransfer.getData('workspace-focus-session-id') || focusSidebarDragSessionId;
if (acceptFocusSidebarHostDrag(event)) return;
const draggedSessionId = event.dataTransfer.getData(FOCUS_SIDEBAR_SESSION_DRAG_TYPE) || focusSidebarDragSessionId;
if (!draggedSessionId || draggedSessionId === targetSessionId) return;
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = 'move';
setFocusSidebarHostDropActive(false);
const rect = event.currentTarget.getBoundingClientRect();
const position = event.clientY < rect.top + rect.height / 2 ? 'before' : 'after';
setFocusSidebarDropIndicator({ sessionId: targetSessionId, position });
}, [focusSidebarDragSessionId]);
}, [acceptFocusSidebarHostDrag, focusSidebarDragSessionId]);
const handleFocusSidebarContainerDragOver = useCallback((event: DragEvent<HTMLDivElement>) => {
const draggedSessionId = event.dataTransfer.getData('workspace-focus-session-id') || focusSidebarDragSessionId;
if (acceptFocusSidebarHostDrag(event)) return;
const draggedSessionId = event.dataTransfer.getData(FOCUS_SIDEBAR_SESSION_DRAG_TYPE) || focusSidebarDragSessionId;
if (!draggedSessionId) return;
const target = getFocusSidebarContainerDropTarget(event.currentTarget, event.clientY, draggedSessionId);
@@ -401,11 +446,36 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
setFocusSidebarHostDropActive(false);
setFocusSidebarDropIndicator(target);
}, [focusSidebarDragSessionId, getFocusSidebarContainerDropTarget]);
}, [acceptFocusSidebarHostDrag, focusSidebarDragSessionId, getFocusSidebarContainerDropTarget]);
const appendHostFromFocusSidebarDrop = useCallback((event: DragEvent) => {
if (!onAppendHostToWorkspace) return false;
const handled = appendHostFromWorkspaceDrop({
types: event.dataTransfer.types,
getData: (type) => event.dataTransfer.getData(type),
activeSessionDragId: focusSidebarDragSessionId,
workspaceId: activeWorkspace.id,
onAppendHostToWorkspace,
});
if (!handled) return false;
event.preventDefault();
event.stopPropagation();
setFocusSidebarHostDropActive(false);
setFocusSidebarDropIndicator(null);
return true;
}, [activeWorkspace.id, focusSidebarDragSessionId, onAppendHostToWorkspace]);
const handleFocusSidebarHostDrop = useCallback((event: DragEvent<HTMLDivElement>) => {
appendHostFromFocusSidebarDrop(event);
}, [appendHostFromFocusSidebarDrop]);
const handleFocusSidebarContainerDrop = useCallback((event: DragEvent<HTMLDivElement>) => {
const draggedSessionId = event.dataTransfer.getData('workspace-focus-session-id') || focusSidebarDragSessionId;
if (appendHostFromFocusSidebarDrop(event)) return;
const draggedSessionId = event.dataTransfer.getData(FOCUS_SIDEBAR_SESSION_DRAG_TYPE) || focusSidebarDragSessionId;
if (!draggedSessionId) return;
const target = focusSidebarDropIndicator
@@ -416,8 +486,10 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
onReorderWorkspaceSessions?.(activeWorkspace.id, draggedSessionId, target.sessionId, target.position);
setFocusSidebarDragSessionId(null);
setFocusSidebarDropIndicator(null);
setFocusSidebarHostDropActive(false);
}, [
activeWorkspace.id,
appendHostFromFocusSidebarDrop,
focusSidebarDragSessionId,
focusSidebarDropIndicator,
getFocusSidebarContainerDropTarget,
@@ -425,7 +497,9 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
]);
const handleFocusSidebarDrop = useCallback((event: DragEvent, targetSessionId: string) => {
const draggedSessionId = event.dataTransfer.getData('workspace-focus-session-id') || focusSidebarDragSessionId;
if (appendHostFromFocusSidebarDrop(event)) return;
const draggedSessionId = event.dataTransfer.getData(FOCUS_SIDEBAR_SESSION_DRAG_TYPE) || focusSidebarDragSessionId;
if (!draggedSessionId || draggedSessionId === targetSessionId) return;
event.preventDefault();
@@ -437,11 +511,19 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
onReorderWorkspaceSessions?.(activeWorkspace.id, draggedSessionId, targetSessionId, position);
setFocusSidebarDragSessionId(null);
setFocusSidebarDropIndicator(null);
}, [activeWorkspace.id, focusSidebarDragSessionId, focusSidebarDropIndicator, onReorderWorkspaceSessions]);
setFocusSidebarHostDropActive(false);
}, [
activeWorkspace.id,
appendHostFromFocusSidebarDrop,
focusSidebarDragSessionId,
focusSidebarDropIndicator,
onReorderWorkspaceSessions,
]);
const handleFocusSidebarDragEnd = useCallback(() => {
setFocusSidebarDragSessionId(null);
setFocusSidebarDropIndicator(null);
setFocusSidebarHostDropActive(false);
}, []);
const handleSelectSession = useCallback((sessionId: string) => {
@@ -469,14 +551,27 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
return (
<div
className="flex-shrink-0 flex flex-col relative"
className={cn(
'flex-shrink-0 flex flex-col relative transition-[box-shadow,background-color]',
focusSidebarHostDropActive && 'ring-1 ring-inset',
)}
style={{
width: focusSidebarWidth,
backgroundColor: theme.termBg,
backgroundColor: focusSidebarHostDropActive
? `color-mix(in srgb, ${theme.termFg} 8%, ${theme.termBg})`
: theme.termBg,
color: theme.termFg,
boxShadow: focusSidebarHostDropActive
? `inset 0 0 0 1px color-mix(in srgb, ${theme.termFg} 28%, transparent)`
: undefined,
['--terminal-workspace-sidebar-border' as string]: `1px solid ${theme.separator}`,
}}
data-section="terminal-workspace-sidebar"
data-focus-sidebar-drop-zone
data-host-drop-active={focusSidebarHostDropActive ? 'true' : 'false'}
onDragOver={handleFocusSidebarHostDragOver}
onDragLeave={handleFocusSidebarHostDragLeave}
onDrop={handleFocusSidebarHostDrop}
>
<div
className="absolute top-0 right-[-3px] h-full w-2 cursor-ew-resize z-30"
@@ -534,7 +629,7 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
<ScrollArea className="flex-1">
<div
className="p-2 space-y-1"
className="p-2 space-y-1 min-h-full rounded-md"
onDragOver={handleFocusSidebarContainerDragOver}
onDrop={handleFocusSidebarContainerDrop}
>
@@ -563,6 +658,7 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
onSelect={handleSelectSession}
onDragStart={handleFocusSidebarDragStart}
onDragOver={handleFocusSidebarDragOver}
onHostDragLeave={handleFocusSidebarHostDragLeave}
onDrop={handleFocusSidebarDrop}
onDragEnd={handleFocusSidebarDragEnd}
dynamicTabTitleMode={dynamicTabTitleMode}
@@ -592,6 +688,7 @@ function terminalFocusSidebarPropsEqual(
if (prev.t !== next.t) return false;
if (prev.onReorderWorkspaceSessions !== next.onReorderWorkspaceSessions) return false;
if (prev.onRequestAddToWorkspace !== next.onRequestAddToWorkspace) return false;
if (prev.onAppendHostToWorkspace !== next.onAppendHostToWorkspace) return false;
if (prev.onSetWorkspaceFocusedSession !== next.onSetWorkspaceFocusedSession) return false;
if (prev.onToggleWorkspaceViewMode !== next.onToggleWorkspaceViewMode) return false;
const prevWs = prev.activeWorkspace;
@@ -22,6 +22,7 @@ const {
getTerminalHostTreeInitialLayoutWidth,
getTerminalHostTreeLayoutTargetWidth,
getTerminalHostTreeMeasuredLayoutWidth,
resolveTerminalHostTreeDragCapabilities,
getTerminalHostTreeSidebarPanelStyle,
getTerminalHostTreeSidebarShellStyle,
isTerminalHostTreeSidebarVisible,
@@ -186,6 +187,25 @@ test('host tree host inline rename rejects empty names without changing hosts',
assert.equal(result.hosts, hosts);
});
test('filtered host rows can drag outward without enabling filtered tree reorder', () => {
assert.deepEqual(resolveTerminalHostTreeDragCapabilities({
kind: 'host',
canReorder: false,
isInlineEditing: false,
}), {
draggable: true,
canAcceptDrop: false,
});
assert.deepEqual(resolveTerminalHostTreeDragCapabilities({
kind: 'group',
canReorder: false,
isInlineEditing: false,
}), {
draggable: false,
canAcceptDrop: false,
});
});
test('host tree hover card is hidden while the same host is inline editing', () => {
assert.equal(shouldShowTerminalHostHoverCard('host-1', null), true);
assert.equal(shouldShowTerminalHostHoverCard('host-1', 'host-2'), true);
@@ -217,3 +237,17 @@ test('host tree row icons, labels, and protocol badges share centered line boxes
assert.match(source, /flex h-5 shrink-0 items-center">\s*\{isExpanded/);
assert.match(source, /flex min-w-0 flex-1 items-center truncate leading-5">\{node\.name\}/);
});
test('filtered host rows can still start host-id drag for focus-sidebar append', () => {
const source = readFileSync(new URL('./TerminalHostTreeSidebar.tsx', import.meta.url), 'utf8');
const hostRowStart = source.indexOf('data-row-type="host"');
const groupRowStart = source.indexOf('data-row-type="group"');
assert.ok(hostRowStart >= 0 && groupRowStart > hostRowStart);
const hostRowBlock = source.slice(hostRowStart, groupRowStart);
assert.match(hostRowBlock, /draggable=\{dragCapabilities\.draggable\}/);
assert.match(hostRowBlock, /effectAllowed = canReorder \? 'copyMove' : 'copy'/);
assert.match(hostRowBlock, /if \(!dragCapabilities\.draggable\) return;/);
assert.match(source, /const canReorder = Boolean\(menuActions\) && !searchActive && !tagsActive;/);
assert.match(source, /canAcceptDrop: input\.canReorder/);
});
@@ -104,6 +104,17 @@ type HostTreeDropTarget =
| { kind: 'root' }
| { kind: 'group'; path: string };
export function resolveTerminalHostTreeDragCapabilities(input: {
kind: HostTreeFlatRow['kind'];
canReorder: boolean;
isInlineEditing: boolean;
}): { draggable: boolean; canAcceptDrop: boolean } {
return {
draggable: !input.isInlineEditing && (input.kind === 'host' || input.canReorder),
canAcceptDrop: input.canReorder,
};
}
interface TerminalHostTreeSidebarProps {
enabled?: boolean;
surfaceVisible?: boolean;
@@ -309,7 +320,7 @@ type HostTreeFlatRowProps = {
activeHostId?: string | null;
expandedPaths: Set<string>;
searchActive: boolean;
canDrag: boolean;
canReorder: boolean;
isDragOver: boolean;
isInlineEditing: boolean;
inlineEditInitialName?: string;
@@ -331,7 +342,7 @@ const HostTreeFlatRowItem = memo<HostTreeFlatRowProps>(({
activeHostId,
expandedPaths,
searchActive,
canDrag,
canReorder,
isDragOver,
isInlineEditing,
inlineEditInitialName,
@@ -348,6 +359,11 @@ const HostTreeFlatRowItem = memo<HostTreeFlatRowProps>(({
menuActions,
}) => {
const { t } = useI18n();
const dragCapabilities = resolveTerminalHostTreeDragCapabilities({
kind: row.kind,
canReorder,
isInlineEditing,
});
if (row.kind === 'host') {
const isActive = activeHostId === row.host.id;
@@ -373,14 +389,18 @@ const HostTreeFlatRowItem = memo<HostTreeFlatRowProps>(({
paddingLeft: row.depth * 16 + 8,
backgroundColor: isActive ? theme.rowActiveBg : (isDragOver ? theme.rowDropBg : undefined),
}}
draggable={canDrag && !isInlineEditing}
// Host rows stay draggable under search/tag filters so focus-sidebar
// append can still receive host-id; in-tree reorder/drop stays disabled.
draggable={dragCapabilities.draggable}
onDragStart={(event) => {
if (!canDrag || isInlineEditing) return;
if (!dragCapabilities.draggable) return;
event.dataTransfer.setData(HOST_TREE_DRAG_HOST_ID, row.host.id);
event.dataTransfer.effectAllowed = 'move';
// copyMove: tree reorder uses move; focus-sidebar append uses copy.
// Filtered trees disable reorder, so allow copy-only for append.
event.dataTransfer.effectAllowed = canReorder ? 'copyMove' : 'copy';
}}
onDragOver={(event) => {
if (!canDrag) return;
if (!dragCapabilities.canAcceptDrop) return;
event.preventDefault();
event.stopPropagation();
if (hasDragType(event.dataTransfer, HOST_TREE_DRAG_HOST_ID)) {
@@ -393,7 +413,7 @@ const HostTreeFlatRowItem = memo<HostTreeFlatRowProps>(({
}}
onDragLeave={onDragLeaveRow}
onDrop={(event) => {
if (!canDrag) return;
if (!dragCapabilities.canAcceptDrop) return;
event.preventDefault();
event.stopPropagation();
clearHostTreeDropIndicators(event.currentTarget.ownerDocument);
@@ -507,14 +527,14 @@ const HostTreeFlatRowItem = memo<HostTreeFlatRowProps>(({
color: theme.termFg,
backgroundColor: isDragOver ? theme.rowDropBg : undefined,
}}
draggable={canDrag && !isInlineEditing}
draggable={dragCapabilities.draggable}
onDragStart={(event) => {
if (!canDrag || isInlineEditing) return;
if (!dragCapabilities.draggable) return;
event.dataTransfer.setData(HOST_TREE_DRAG_GROUP_PATH, node.path);
event.dataTransfer.effectAllowed = 'move';
}}
onDragOver={(event) => {
if (!canDrag) return;
if (!dragCapabilities.canAcceptDrop) return;
event.preventDefault();
event.stopPropagation();
const isDraggingGroup = hasDragType(event.dataTransfer, HOST_TREE_DRAG_GROUP_PATH);
@@ -529,7 +549,7 @@ const HostTreeFlatRowItem = memo<HostTreeFlatRowProps>(({
}}
onDragLeave={onDragLeaveRow}
onDrop={(event) => {
if (!canDrag) return;
if (!dragCapabilities.canAcceptDrop) return;
event.preventDefault();
event.stopPropagation();
clearHostTreeDropIndicators(event.currentTarget.ownerDocument);
@@ -615,7 +635,7 @@ const HostTreeFlatRowItem = memo<HostTreeFlatRowProps>(({
if (prev.row !== next.row) return false;
if (prev.expandedPaths !== next.expandedPaths) return false;
if (prev.searchActive !== next.searchActive) return false;
if (prev.canDrag !== next.canDrag) return false;
if (prev.canReorder !== next.canReorder) return false;
if (prev.isDragOver !== next.isDragOver) return false;
if (prev.isInlineEditing !== next.isInlineEditing) return false;
if (prev.inlineEditInitialName !== next.inlineEditInitialName) return false;
@@ -739,7 +759,7 @@ const TerminalHostTreeSidebarInner: React.FC<TerminalHostTreeSidebarProps> = ({
});
}, [expandedPaths, filteredTree, filteredUngrouped, treeExpandAll]);
const canDrag = Boolean(menuActions) && !searchActive && !tagsActive;
const canReorder = Boolean(menuActions) && !searchActive && !tagsActive;
const handleNewRootGroup = useCallback(() => {
if (!menuActions) return;
@@ -826,10 +846,10 @@ const TerminalHostTreeSidebarInner: React.FC<TerminalHostTreeSidebarProps> = ({
}, [clearDragOver]);
const handleRootDragOver = useCallback((event: React.DragEvent<HTMLDivElement>) => {
if (!canDrag) return;
if (!canReorder) return;
event.preventDefault();
setDragOverTarget({ kind: 'root' });
}, [canDrag]);
}, [canReorder]);
const handleRootDragLeave = useCallback((event: React.DragEvent<HTMLDivElement>) => {
const nextTarget = event.relatedTarget;
@@ -841,11 +861,11 @@ const TerminalHostTreeSidebarInner: React.FC<TerminalHostTreeSidebarProps> = ({
}, [clearDragOver]);
const handleRootDrop = useCallback((event: React.DragEvent<HTMLDivElement>) => {
if (!canDrag) return;
if (!canReorder) return;
event.preventDefault();
clearHostTreeDropIndicators(event.currentTarget.ownerDocument);
handleDropToParent(null, event.dataTransfer);
}, [canDrag, handleDropToParent]);
}, [canReorder, handleDropToParent]);
const handleListPointerDownCapture = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (!menuActions) return;
@@ -907,7 +927,7 @@ const TerminalHostTreeSidebarInner: React.FC<TerminalHostTreeSidebarProps> = ({
activeHostId={activeHostId}
expandedPaths={expandedPaths}
searchActive={treeExpandAll}
canDrag={canDrag}
canReorder={canReorder}
isDragOver={isRowDragOver(row)}
isInlineEditing={
(row.kind === 'group' && inlineEdit?.groupPath === row.node.path)
@@ -934,7 +954,7 @@ const TerminalHostTreeSidebarInner: React.FC<TerminalHostTreeSidebarProps> = ({
/>
), [
activeHostId,
canDrag,
canReorder,
clearDragOver,
expandedPaths,
inlineEdit,
@@ -15,6 +15,7 @@ function TerminalLayerFocusSidebarSectionInner({ ctx }: { ctx: FocusSidebarConte
focusedSessionId={ctx.focusedSessionId}
onReorderWorkspaceSessions={ctx.onReorderWorkspaceSessions}
onRequestAddToWorkspace={ctx.onRequestAddToWorkspace}
onAppendHostToWorkspace={ctx.onAppendHostToWorkspace}
onCloseSession={ctx.handleCloseSession}
onCopySession={ctx.onCopySession}
onCopySessionToNewWindow={ctx.onCopySessionToNewWindow}
@@ -165,6 +165,17 @@ test('side panel content scopes app color utilities to the resolved terminal the
assert.match(sectionSource, /\.\.\.sidePanelCssVars/);
});
test('side panel sets color-scheme from the terminal theme so native inputs match light panels', () => {
const sectionSource = readFileSync(new URL('./TerminalLayerSidePanelSection.tsx', import.meta.url), 'utf8');
// When a light terminal theme remaps --background under a dark app chrome,
// form controls (Codex/AI composer textarea) still follow color-scheme.
assert.match(
sectionSource,
/colorScheme:\s*resolvedSidePanelTerminalTheme\.type/,
);
});
test('a visible tool without a ready pane host stays in the hidden parking host', () => {
const parkingHost = { id: 'parking' };
const paneHost = { id: 'pane' };
Loaded 100 of 180 files, more files were not shown because too many files have changed in this diff. Show more