Compare commits

..
Author SHA1 Message Date
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
陈大猫 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
116 changed files with 6112 additions and 1403 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 重复
+8 -1
View File
@@ -496,7 +496,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 });
}
+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">;
+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;
}
+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,
};
};
+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,36 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
isShortcutModifierHeld,
shouldReleaseShortcutModifier,
type ShortcutModifierEvent,
} from './useShortcutModifierHeld.ts';
const event = (overrides: Partial<ShortcutModifierEvent> = {}): ShortcutModifierEvent => ({
key: 'a',
metaKey: false,
ctrlKey: false,
...overrides,
});
test('shortcut modifier state follows the configured Mac or PC scheme', () => {
assert.equal(isShortcutModifierHeld(event({ key: 'Meta' }), 'mac'), true);
assert.equal(isShortcutModifierHeld(event({ metaKey: true }), 'mac'), true);
assert.equal(isShortcutModifierHeld(event({ ctrlKey: true }), 'mac'), false);
assert.equal(isShortcutModifierHeld(event({ key: 'Control' }), 'pc'), true);
assert.equal(isShortcutModifierHeld(event({ ctrlKey: true }), 'pc'), true);
assert.equal(isShortcutModifierHeld(event({ metaKey: true }), 'pc'), false);
assert.equal(isShortcutModifierHeld(event({ metaKey: true, ctrlKey: true }), 'disabled'), false);
});
test('shortcut modifier state clears on modifier release or a missed modifier keyup', () => {
assert.equal(shouldReleaseShortcutModifier(event({ key: 'Meta' }), 'mac'), true);
assert.equal(shouldReleaseShortcutModifier(event({ key: 'a', metaKey: true }), 'mac'), false);
assert.equal(shouldReleaseShortcutModifier(event({ key: 'a' }), 'mac'), true);
assert.equal(shouldReleaseShortcutModifier(event({ key: 'Control' }), 'pc'), true);
assert.equal(shouldReleaseShortcutModifier(event({ key: 'a', ctrlKey: true }), 'pc'), false);
assert.equal(shouldReleaseShortcutModifier(event({ key: 'a' }), 'pc'), true);
});
@@ -0,0 +1,71 @@
import { useEffect, useState } from 'react';
import type { HotkeyScheme } from '../../domain/models/keyBindings';
export type ShortcutModifierEvent = Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey'>;
function getModifierKey(scheme: Exclude<HotkeyScheme, 'disabled'>): 'Meta' | 'Control' {
return scheme === 'mac' ? 'Meta' : 'Control';
}
function hasModifierFlag(event: ShortcutModifierEvent, scheme: Exclude<HotkeyScheme, 'disabled'>): boolean {
return scheme === 'mac' ? event.metaKey : event.ctrlKey;
}
/** Whether a keyboard event indicates that the active shortcut modifier is held. */
export function isShortcutModifierHeld(
event: ShortcutModifierEvent,
scheme: HotkeyScheme,
): boolean {
if (scheme === 'disabled') return false;
return event.key === getModifierKey(scheme) || hasModifierFlag(event, scheme);
}
/** Whether a keyup event means the active shortcut modifier is no longer held. */
export function shouldReleaseShortcutModifier(
event: ShortcutModifierEvent,
scheme: HotkeyScheme,
): boolean {
if (scheme === 'disabled') return true;
return event.key === getModifierKey(scheme) || !hasModifierFlag(event, scheme);
}
export function useShortcutModifierHeld(scheme: HotkeyScheme): boolean {
const [isHeld, setIsHeld] = useState(false);
useEffect(() => {
setIsHeld(false);
if (scheme === 'disabled') return;
const handleKeyDown = (event: KeyboardEvent) => {
if (isShortcutModifierHeld(event, scheme)) {
setIsHeld(true);
}
};
const handleKeyUp = (event: KeyboardEvent) => {
if (shouldReleaseShortcutModifier(event, scheme)) {
setIsHeld(false);
}
};
const clearHeldState = () => setIsHeld(false);
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
clearHeldState();
}
};
window.addEventListener('keydown', handleKeyDown, true);
window.addEventListener('keyup', handleKeyUp, true);
window.addEventListener('blur', clearHeldState);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
window.removeEventListener('keydown', handleKeyDown, true);
window.removeEventListener('keyup', handleKeyUp, true);
window.removeEventListener('blur', clearHeldState);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [scheme]);
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);
+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.
+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,
+26 -2
View File
@@ -775,7 +775,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,
@@ -3560,13 +3565,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 +3602,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
toast.error("Failed to toggle session log");
}
}, [
chooseManualSessionLogPath,
getManualSessionLogStatus,
host.hostname,
host.label,
+22 -2
View File
@@ -93,6 +93,7 @@ import {
shouldCloseSftpSidePanel,
shouldClearSftpPanelAfterTransferChange,
shouldKeepSftpMountedAfterClose,
shouldMarkSftpPaneClosed,
shouldScheduleSftpRetainedPanelCleanup,
terminalSftpTransferOwnerId,
} from './terminalLayer/sftpPanelLifecycle';
@@ -411,6 +412,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 +580,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 +616,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 +834,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 +1334,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 +1416,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;
@@ -2155,6 +2173,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sftpHostForTab,
sftpInitialLocationForTab,
sftpPendingUploadsForTab,
sftpPaneClosedTabIdsRef,
sftpRetainedAfterCloseTabIdsRef,
sftpShowHiddenFiles,
SftpSidePanel,
sftpUseCompressedUpload,
+28 -1
View File
@@ -1,8 +1,10 @@
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';
@@ -15,6 +17,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';
@@ -208,6 +211,23 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
}) => {
const { t } = useI18n();
const { maximize, isFullscreen, onFullscreenChanged } = useWindowControls();
const {
hotkeyScheme,
showTabNumberBadges,
shellOnlyTabNumberShortcuts,
} = useSettingsChromeStore();
const shortcutModifierHeld = useShortcutModifierHeld(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();
@@ -752,6 +772,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onTabDragLeave={handleTabDragLeave}
onTabDrop={handleTabDrop}
tabAnimationClass={getTabAnimationClass(tabId)}
shortcutNumber={tabShortcutNumbers?.get(tabId)}
/>
);
}
@@ -776,6 +797,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onTabDragLeave={handleTabDragLeave}
onTabDrop={handleTabDrop}
tabAnimationClass={getTabAnimationClass(tabId)}
shortcutNumber={tabShortcutNumbers?.get(tabId)}
/>
);
}
@@ -811,6 +833,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
dynamicTabTitleMode={dynamicTabTitleMode}
t={t}
tabAnimationClass={getTabAnimationClass(session.id)}
shortcutNumber={tabShortcutNumbers?.get(session.id)}
/>
);
}
@@ -854,6 +877,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
renderBulkCloseItems={renderBulkCloseItems}
t={t}
tabAnimationClass={getTabAnimationClass(workspace.id)}
shortcutNumber={tabShortcutNumbers?.get(workspace.id)}
/>
);
}
@@ -882,6 +906,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onTabDrop={handleTabDrop}
t={t}
tabAnimationClass={getTabAnimationClass(logView.id)}
shortcutNumber={tabShortcutNumbers?.get(logView.id)}
/>
);
}
@@ -933,6 +958,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
icon={<FolderLock size={14} />}
className="rounded"
compact={rootTabsCompact}
shortcutNumber={tabShortcutNumbers?.get('vault')}
/>
{showSftpTab && (
<RootTopTab
@@ -941,6 +967,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
icon={<Folder size={14} />}
className="rounded-t-md"
compact={rootTabsCompact}
shortcutNumber={tabShortcutNumbers?.get('sftp')}
/>
)}
</div>
+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}
/>
+95 -1
View File
@@ -72,6 +72,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 +139,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 +164,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"\]/);
});
+14 -2
View File
@@ -56,7 +56,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", () => {
@@ -297,7 +304,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"/);
+5 -5
View File
@@ -113,7 +113,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"
/>
@@ -1712,10 +1712,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 +1803,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}
@@ -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;
+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
@@ -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" };
},
});
@@ -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,
@@ -377,6 +377,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
sidePanelWidth: s.sidePanelWidth,
sftpActiveHost,
sftpHostForTab,
sftpPaneClosedTabIdsRef: s.sftpPaneClosedTabIdsRef,
shouldMarkSessionActivity: s.shouldMarkSessionActivity,
sidePanelOpenTabs,
splitHorizontalHandlersRef: s.splitHorizontalHandlersRef,
@@ -581,6 +582,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
sftpFollowTerminalCwd: s.sftpFollowTerminalCwd,
sftpInitialLocationForTab: s.sftpInitialLocationForTab,
sftpPendingUploadsForTab: s.sftpPendingUploadsForTab,
sftpPaneClosedTabIdsRef: s.sftpPaneClosedTabIdsRef,
sftpShowHiddenFiles: s.sftpShowHiddenFiles,
SftpSidePanel: s.SftpSidePanel,
sftpUseCompressedUpload: s.sftpUseCompressedUpload,
@@ -3,6 +3,12 @@ import { readFileSync } from "node:fs";
import test from "node:test";
import type { TransferTask } from "../../domain/models";
import {
closeSidePanelPane,
collectSidePanelPanes,
createSidePanelLayout,
splitSidePanelPane,
} from "../../domain/sidePanelLayout.ts";
import {
SFTP_TRANSFER_HISTORY_RETENTION_MS,
countTransfersRetainingSftpOwner,
@@ -13,6 +19,8 @@ import {
shouldCloseSftpSidePanel,
shouldClearSftpPanelAfterTransferChange,
shouldKeepSftpMountedAfterClose,
shouldKeepSftpBrowseSessionInteractive,
shouldMarkSftpPaneClosed,
shouldScheduleSftpRetainedPanelCleanup,
terminalSftpTransferOwnerId,
} from "./sftpPanelLifecycle.ts";
@@ -43,6 +51,15 @@ test("single-pane SFTP close uses the shared full-panel cleanup and stops openin
assert.match(layerSource, /closeTerminalSidePanelTab[\s\S]*setAiMountedTabIds[\s\S]*setNotesOpenNoteByTab/);
});
test("all SFTP reopen paths clear the split-close marker", () => {
const layerSource = readFileSync(new URL("../TerminalLayer.tsx", import.meta.url), "utf8");
const effectsSource = readFileSync(new URL("./useTerminalLayerEffects.ts", import.meta.url), "utf8");
assert.match(layerSource, /if \(targetPanel === 'sftp'\) \{\s*sftpPaneClosedTabIdsRef\.current\.delete\(tabId\)/);
assert.match(effectsSource, /const applySftpTargetOnTab[\s\S]*sftpPaneClosedTabIdsRef\.current\.delete\(tabId\)/);
assert.match(effectsSource, /navigation\.kind === 'local-copy-panel'[\s\S]*sftpPaneClosedTabIdsRef\.current\.delete\(currentTabId!\)/);
});
function task(
partial: Partial<TransferTask> & Pick<TransferTask, "id" | "status">,
): Pick<TransferTask, "id" | "status" | "parentTaskId" | "ownerId"> {
@@ -74,6 +91,62 @@ test("closing an idle panel still releases its SFTP state", () => {
assert.equal(shouldKeepSftpMountedAfterClose({ activeTransfersCount: 0 }), false);
});
test("closing SFTP keeps another tool from reviving its browse session", () => {
assert.equal(shouldKeepSftpBrowseSessionInteractive({
sidePanelOpen: true,
retainedAfterClose: false,
sftpPaneClosed: false,
}), true);
assert.equal(shouldKeepSftpBrowseSessionInteractive({
sidePanelOpen: true,
retainedAfterClose: true,
sftpPaneClosed: false,
}), false);
assert.equal(shouldKeepSftpBrowseSessionInteractive({
sidePanelOpen: true,
retainedAfterClose: false,
sftpPaneClosed: true,
}), false);
assert.equal(shouldKeepSftpBrowseSessionInteractive({
sidePanelOpen: false,
retainedAfterClose: true,
sftpPaneClosed: false,
}), false);
});
test("closing only the SFTP split pane parks its browse session while the other pane stays open", () => {
let layout = createSidePanelLayout("sftp", "pane-sftp");
layout = splitSidePanelPane(layout, "pane-sftp", "history", "vertical", {
paneId: "pane-history",
splitId: "split-root",
}, 400);
const sftpPane = collectSidePanelPanes(layout.root).find((pane) => pane.tool === "sftp");
assert.ok(sftpPane);
const remaining = closeSidePanelPane(layout, sftpPane.id);
assert.ok(remaining);
assert.deepEqual(collectSidePanelPanes(remaining.root).map((pane) => pane.tool), ["history"]);
let sftpPaneClosed = shouldMarkSftpPaneClosed({
closingPaneTool: sftpPane.tool,
closesWholePanel: false,
});
assert.equal(shouldKeepSftpBrowseSessionInteractive({
sidePanelOpen: true,
retainedAfterClose: false,
sftpPaneClosed,
}), false);
sftpPaneClosed = false;
assert.equal(shouldKeepSftpBrowseSessionInteractive({
sidePanelOpen: true,
retainedAfterClose: false,
sftpPaneClosed,
}), true);
assert.equal(shouldMarkSftpPaneClosed({
closingPaneTool: sftpPane.tool,
closesWholePanel: true,
}), false);
});
test("a transfer retained by close keeps its history after completion", () => {
assert.equal(shouldClearSftpPanelAfterTransferChange({
activeTransfersCount: 0,
@@ -214,6 +287,7 @@ test("terminal side panel reports transfer activity and uses store-backed retain
assert.doesNotMatch(panelSource, /useEffect\(\(\) => \(\) => \{\s*onActiveTransfersChange\?\.\(0\);\s*\}, \[onActiveTransfersChange\]\)/);
assert.match(panelSource, /interactive:\s*isBrowseSessionInteractive\(\{/);
assert.match(panelSource, /surfaceVisible:\s*isVisible/);
assert.match(panelSource, /ownerPanelOpen/);
assert.match(panelSource, /useEditorTabPresenceRevision\(\)/);
assert.match(panelSource, /hasOwnedEditorTab/);
assert.match(panelSource, /hasActiveExternalEdit/);
@@ -228,10 +302,15 @@ test("terminal side panel reports transfer activity and uses store-backed retain
assert.match(layerSource, /sftpActiveExternalEditsByTabRef/);
assert.match(layerSource, /sftpRetainedAfterCloseTabIdsRef/);
assert.match(layerSource, /sftpRetainedCleanupTimersRef/);
// Hidden UI parks browse channels; transfers keep pool / leased sessions.
// External editor temps must also block park (closeSftp deletes those files).
// Hidden UI parks browse channels only after the side panel closes;
// tool switches keep browse warm via ownerPanelOpen. Transfers keep pool /
// leased sessions. External editor temps must also block park (closeSftp
// deletes those files).
assert.match(stateSource, /shouldParkBrowseSessions/);
assert.match(stateSource, /activeExternalEditCount/);
assert.match(stateSource, /takeBrowseSessionsForClose/);
assert.match(stateSource, /shouldRestoreBrowseSessions/);
assert.match(slotsSource, /sftpRetainedAfterCloseTabIdsRef/);
assert.match(slotsSource, /sftpPaneClosedTabIdsRef/);
assert.match(slotsSource, /shouldKeepSftpBrowseSessionInteractive\(/);
});
@@ -65,6 +65,28 @@ export function shouldKeepSftpMountedAfterClose(params: {
|| (params.activeExternalEditCount ?? 0) > 0;
}
/**
* A different side-panel tool keeps SFTP warm only when the SFTP owner was
* never closed. A retained-after-close mount is kept for transfers/editor
* cleanup, but its browse session must still be allowed to park.
*/
export function shouldKeepSftpBrowseSessionInteractive(params: {
sidePanelOpen: boolean;
retainedAfterClose: boolean;
sftpPaneClosed: boolean;
}): boolean {
return params.sidePanelOpen
&& !params.retainedAfterClose
&& !params.sftpPaneClosed;
}
export function shouldMarkSftpPaneClosed(params: {
closingPaneTool: string | null | undefined;
closesWholePanel: boolean;
}): boolean {
return !params.closesWholePanel && params.closingPaneTool === 'sftp';
}
export function shouldCloseSftpSidePanel(params: {
shouldKeepOpen: boolean;
isOpen: boolean;
@@ -35,8 +35,11 @@ import {
type SidePanelLayout,
} from '../../domain/sidePanelLayout';
import { sidePanelHiddenNotesPanelClassName, sidePanelHiddenPanelClassName } from './terminalLayerSidePanelHiddenWrapper';
import { shouldKeepSftpBrowseSessionInteractive } from './sftpPanelLifecycle';
type SidePanelStableContext = Record<string, any>;
type SidePanelStableContext = Record<string, any> & {
sftpPaneClosedTabIdsRef: React.MutableRefObject<Set<string>>;
};
const navigatorPlatform = typeof navigator !== 'undefined' ? navigator.platform : '';
const EMPTY_VAULT_NOTES: never[] = [];
const EMPTY_VAULT_HOSTS: never[] = [];
@@ -61,10 +64,13 @@ function SidePanelSftpSlotInner({
tabId,
ctx,
isVisible,
ownerPanelOpen,
}: {
tabId: string;
ctx: SidePanelStableContext;
isVisible: boolean;
/** Side panel still open for this tab (may be showing another tool). */
ownerPanelOpen: boolean;
}) {
const live = useSidePanelLiveSnapshotForTab(tabId, isVisible);
@@ -195,6 +201,7 @@ function SidePanelSftpSlotInner({
onActiveExternalEditsChange={handleActiveExternalEditsChange}
showWorkspaceHostHeader={isVisible && !!live.activeWorkspace}
isVisible={isVisible}
ownerPanelOpen={ownerPanelOpen}
renderOverlays={isVisible}
pendingUpload={sftpPendingUploadsForTab.get(tabId) ?? null}
onPendingUploadHandled={handlePendingUploadHandledForTab}
@@ -648,6 +655,16 @@ export function SidePanelMountedContent({
activeTabStore.getActiveTabId,
);
const layouts = ctx.sidePanelLayouts as Map<string, SidePanelLayout>;
const openTabs = ctx.sidePanelOpenTabs as Map<string, SidePanelTab>;
const retainedAfterCloseTabIdsRef = ctx.sftpRetainedAfterCloseTabIdsRef as
| React.MutableRefObject<ReadonlySet<string>>
| undefined;
const sftpPaneClosedTabIdsRef = ctx.sftpPaneClosedTabIdsRef;
const isSftpOwnerPanelOpen = (tabId: string) => shouldKeepSftpBrowseSessionInteractive({
sidePanelOpen: openTabs.has(tabId),
retainedAfterClose: retainedAfterCloseTabIdsRef?.current.has(tabId) ?? false,
sftpPaneClosed: sftpPaneClosedTabIdsRef.current.has(tabId),
});
const isToolVisible = (tabId: string, tool: SidePanelTab) => (
activeTabId === tabId && sidePanelLayoutHasTool(layouts.get(tabId), tool)
);
@@ -666,7 +683,12 @@ export function SidePanelMountedContent({
portalKey={`sftp-${tabId}`}
target={portalTarget(tabId, 'sftp')}
>
<SidePanelSftpSlot tabId={tabId} ctx={ctx} isVisible={isToolVisible(tabId, 'sftp')} />
<SidePanelSftpSlot
tabId={tabId}
ctx={ctx}
isVisible={isToolVisible(tabId, 'sftp')}
ownerPanelOpen={isSftpOwnerPanelOpen(tabId)}
/>
</PersistentSidePanelPortal>
))}
{systemMountedTabIds.map((tabId: string) => {
@@ -242,6 +242,8 @@ const SIDE_PANEL_STABLE_CTX_KEYS = [
'sidePanelOpenTabs',
'sidePanelLayouts',
'sftpHostForTab',
'sftpPaneClosedTabIdsRef',
'sftpRetainedAfterCloseTabIdsRef',
'effectiveHosts',
'hosts',
// SFTP Connected picker reads live terminal sessions from stable ctx.
@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps */
import { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
import type { MutableRefObject } from 'react';
import { terminalLayoutSuppressStore } from '../../application/state/terminalLayoutSuppressStore';
import { terminalCwdStore } from '../../application/state/terminalCwdStore';
@@ -15,6 +16,7 @@ import { collectSidePanelPanes, sidePanelLayoutHasTool } from '../../domain/side
import { collectSessionIds } from '../../domain/workspace';
import {
moveSidePanelTabMap,
moveSidePanelTabSet,
remapMountedSidePanelTabIds,
remapSidePanelTabMap,
type SidePanelTabRemap,
@@ -23,7 +25,9 @@ import { AI_PANEL_FORCE_HIDE_SHELL } from '../ai/aiPanelDiagnostics';
import { toast } from '../ui/toast';
import { getTerminalSidePanelShellWidth } from './TerminalLayerSidePanelSection';
type TerminalLayerEffectsContext = Record<string, any>;
type TerminalLayerEffectsContext = Record<string, any> & {
sftpPaneClosedTabIdsRef: MutableRefObject<Set<string>>;
};
type RuntimeStateRef<T> = { current: Map<string, T> };
@@ -100,7 +104,7 @@ export function pruneTerminalTabMemoryState(
export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
const { openPath } = useSftpBackend();
const { activeSidePanelTab, activeSidePanelLayout, activeTabId, activeTabIdRef, activeWorkspace, activityTrackedSessions, cancelAnimationFrame, ChunkedEscapeFilter, clearTopTabsPreviewVars, document, dropHint, effectiveHosts, filterTabsMap, focusedSessionId, getSessionActivityIdsToClear, handleToggleAiFromTopBar, handleToggleScriptsSidePanel, handleToggleSidePanel, hasNotifiableTerminalOutput, isComposeBarOpen, isFocusMode, isTerminalLayerVisible, lastSidePanelTabRef, Map, onConnectToHost, onSessionData, onSplitSessionRef, onToggleBroadcastRef, onToggleWorkspaceViewModeRef, prevFocusedSessionIdRef, refocusActiveTerminalSession, requestAnimationFrame, ResizeObserver, sessionActivityStore, sessions, Set, setAiMountedTabIds, setDropHint, setNotesMountedTabIds, setScriptsMountedTabIds, setSystemMountedTabIds, setSftpHostForTab, setSftpInitialLocationForTab, setSftpPendingUploadsForTab, setSidePanelOpenTabs, setSidePanelLayouts, setThemeMountedTabIds, setWorkspaceArea, shouldMeasureTerminalLayerLayout, sidePanelPosition, sidePanelWidth, sftpActiveHost, sftpHostForTab, shouldMarkSessionActivity, sidePanelOpenTabs, splitHorizontalHandlersRef, splitVerticalHandlersRef, toggleScriptsSidePanelRef, toggleSidePanelRef, validAIScopeTargetIds, validSessionActivityIds, window, workspaceBroadcastHandlersRef, workspaceFocusHandlersRef, workspaceInnerRef, workspaces } = ctx;
const { activeSidePanelTab, activeSidePanelLayout, activeTabId, activeTabIdRef, activeWorkspace, activityTrackedSessions, cancelAnimationFrame, ChunkedEscapeFilter, clearTopTabsPreviewVars, document, dropHint, effectiveHosts, filterTabsMap, focusedSessionId, getSessionActivityIdsToClear, handleToggleAiFromTopBar, handleToggleScriptsSidePanel, handleToggleSidePanel, hasNotifiableTerminalOutput, isComposeBarOpen, isFocusMode, isTerminalLayerVisible, lastSidePanelTabRef, Map, onConnectToHost, onSessionData, onSplitSessionRef, onToggleBroadcastRef, onToggleWorkspaceViewModeRef, prevFocusedSessionIdRef, refocusActiveTerminalSession, requestAnimationFrame, ResizeObserver, sessionActivityStore, sessions, Set, setAiMountedTabIds, setDropHint, setNotesMountedTabIds, setScriptsMountedTabIds, setSystemMountedTabIds, setSftpHostForTab, setSftpInitialLocationForTab, setSftpPendingUploadsForTab, setSidePanelOpenTabs, setSidePanelLayouts, setThemeMountedTabIds, setWorkspaceArea, shouldMeasureTerminalLayerLayout, sidePanelPosition, sidePanelWidth, sftpActiveHost, sftpHostForTab, sftpPaneClosedTabIdsRef, shouldMarkSessionActivity, sidePanelOpenTabs, splitHorizontalHandlersRef, splitVerticalHandlersRef, toggleScriptsSidePanelRef, toggleSidePanelRef, validAIScopeTargetIds, validSessionActivityIds, window, workspaceBroadcastHandlersRef, workspaceFocusHandlersRef, workspaceInnerRef, workspaces } = ctx;
const activeWorkspaceId = activeWorkspace?.id;
const activeWorkspaceViewMode = activeWorkspace?.viewMode;
@@ -205,6 +209,15 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
}
return next;
});
let sftpOwners = sftpHostForTab as ReadonlyMap<string, any>;
for (const remap of remaps) {
sftpPaneClosedTabIdsRef.current = moveSidePanelTabSet(
sftpPaneClosedTabIdsRef.current,
remap,
{ ownerTabIds: new Set(sftpOwners.keys()) },
);
sftpOwners = moveSidePanelTabMap(sftpOwners, remap);
}
setAiMountedTabIds((prev: string[]) => {
let next = prev;
for (const remap of remaps) {
@@ -495,6 +508,7 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
useEffect(() => {
const applySftpTargetOnTab = (tabId: string, host: any, targetDirectory: string) => {
sftpPaneClosedTabIdsRef.current.delete(tabId);
// Bump initialLocation even when the host is already selected so the
// path-navigation effect re-runs after reopen.
setSftpHostForTab((prev: Map<string, any>) => new Map(prev).set(tabId, host));
@@ -577,6 +591,7 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
toast.error('Open a terminal tab first to browse this transfer', 'SFTP');
return;
}
sftpPaneClosedTabIdsRef.current.delete(currentTabId!);
setSidePanelOpenTabs((prev: Map<string, any>) => new Map(prev).set(currentTabId!, 'sftp'));
return;
}
+70 -26
View File
@@ -311,6 +311,21 @@ WindowControls.displayName = 'WindowControls';
type TranslateFn = ReturnType<typeof useI18n>['t'];
type RenderBulkCloseItems = (anchorId: string) => React.ReactNode;
/** 1-9 badge aligned with Cmd/Ctrl+[1...9] tab switching. */
export const TabShortcutNumberBadge: React.FC<{ number: number }> = memo(({ number }) => (
<span
className="inline-flex h-3.5 min-w-3.5 shrink-0 items-center justify-center rounded px-0.5 font-mono text-[10px] font-semibold leading-none tabular-nums"
style={{
color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
backgroundColor: 'color-mix(in srgb, var(--top-tabs-fg, hsl(var(--foreground))) 12%, transparent)',
}}
aria-hidden="true"
>
{number}
</span>
));
TabShortcutNumberBadge.displayName = 'TabShortcutNumberBadge';
const TOP_TAB_COMFORT_EDGE_RATIO = 0.22;
const TOP_TAB_COMFORT_EDGE_MIN = 72;
const TOP_TAB_COMFORT_EDGE_MAX = 160;
@@ -379,9 +394,10 @@ interface RootTopTabProps {
icon: React.ReactNode;
className?: string;
compact?: boolean;
shortcutNumber?: number;
}
export const RootTopTab: React.FC<RootTopTabProps> = memo(({ tabId, label, icon, className, compact = false }) => {
export const RootTopTab: React.FC<RootTopTabProps> = memo(({ tabId, label, icon, className, compact = false, shortcutNumber }) => {
const isActive = useIsTabActive(tabId);
// The Vaults tab is the app's persistent "home", so keep its selected state
// visually flat — no active background fill (the label/icon still brighten to
@@ -432,7 +448,7 @@ export const RootTopTab: React.FC<RootTopTabProps> = memo(({ tabId, label, icon,
}
}}
>
{icon}
{shortcutNumber != null ? <TabShortcutNumberBadge number={shortcutNumber} /> : icon}
<span className={cn('top-tab-root-label', compact && 'top-tab-root-label-compact')}>
{label}
</span>
@@ -457,6 +473,7 @@ interface PluginViewTopTabProps {
onTabDragLeave(e: React.DragEvent): void;
onTabDrop(e: React.DragEvent, tabId: string): void;
tabAnimationClass?: string;
shortcutNumber?: number;
}
export const PluginViewTopTab: React.FC<PluginViewTopTabProps> = memo(({
@@ -475,6 +492,7 @@ export const PluginViewTopTab: React.FC<PluginViewTopTabProps> = memo(({
onTabDragLeave,
onTabDrop,
tabAnimationClass,
shortcutNumber,
}) => {
const isActive = useIsTabActive(tab.id);
const close = useCallback((event?: React.MouseEvent) => {
@@ -510,7 +528,9 @@ export const PluginViewTopTab: React.FC<PluginViewTopTabProps> = memo(({
{showDropIndicatorBefore && isDraggingForReorder && <div className="absolute -left-0.5 bottom-1 top-1 w-0.5 rounded-full bg-primary" />}
{showDropIndicatorAfter && isDraggingForReorder && <div className="absolute -right-0.5 bottom-1 top-1 w-0.5 rounded-full bg-primary" />}
<div className="flex min-w-0 flex-1 items-center gap-2">
<PluginContributionIcon pluginId={tab.pluginId} icon={tab.icon} className="shrink-0" />
{shortcutNumber != null
? <TabShortcutNumberBadge number={shortcutNumber} />
: <PluginContributionIcon pluginId={tab.pluginId} icon={tab.icon} className="shrink-0" />}
<span className="truncate leading-5">{tab.title}</span>
</div>
<button onClick={close} className="flex h-5 w-5 shrink-0 items-center justify-center rounded hover:bg-muted" aria-label={t('tabs.closePluginViewAria', { title: tab.title })}><X size={12} /></button>
@@ -550,6 +570,7 @@ interface EditorTopTabProps {
onTabDragLeave: (e: React.DragEvent) => void;
onTabDrop: (e: React.DragEvent, targetTabId: string) => void;
tabAnimationClass?: string;
shortcutNumber?: number;
}
export const EditorTopTab: React.FC<EditorTopTabProps> = memo(({
@@ -569,6 +590,7 @@ export const EditorTopTab: React.FC<EditorTopTabProps> = memo(({
onTabDragLeave,
onTabDrop,
tabAnimationClass,
shortcutNumber,
}) => {
const isActive = useIsTabActive(tabId);
// Dirty is store-driven so App/TopTabs structure can stay presence-only.
@@ -640,11 +662,15 @@ export const EditorTopTab: React.FC<EditorTopTabProps> = memo(({
/>
)}
<div className="flex items-center gap-2 min-w-0 flex-1">
<FileIcon
size={14}
className="shrink-0"
style={{ color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
/>
{shortcutNumber != null ? (
<TabShortcutNumberBadge number={shortcutNumber} />
) : (
<FileIcon
size={14}
className="shrink-0"
style={{ color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
/>
)}
<span className="flex items-center gap-0.5 truncate leading-5">
{dirty && <span className="mr-0.5 text-primary">●</span>}
{editorTab.fileName}
@@ -688,6 +714,7 @@ interface SessionTopTabProps {
dynamicTabTitleMode?: DynamicTabTitleMode;
t: TranslateFn;
tabAnimationClass?: string;
shortcutNumber?: number;
}
export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
@@ -712,6 +739,7 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
dynamicTabTitleMode,
t,
tabAnimationClass,
shortcutNumber,
}) => {
// Per-session presentation: sibling title/provider updates do not re-render this tab.
const session = usePresentedSession(sessionProp);
@@ -784,14 +812,18 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
/>
)}
<div className="flex items-center gap-2 min-w-0 flex-1">
<SessionTabIcon
host={host}
session={session}
isActive={isActive}
protocol={session.protocol}
shellIcon={session.localShellIcon}
dynamicTabTitleMode={dynamicTabTitleMode}
/>
{shortcutNumber != null ? (
<TabShortcutNumberBadge number={shortcutNumber} />
) : (
<SessionTabIcon
host={host}
session={session}
isActive={isActive}
protocol={session.protocol}
shellIcon={session.localShellIcon}
dynamicTabTitleMode={dynamicTabTitleMode}
/>
)}
<span className="truncate leading-5">{tabTitle}</span>
<div className="flex-shrink-0">{sessionStatusDot(session.status, hasActivity)}</div>
</div>
@@ -869,6 +901,7 @@ interface WorkspaceTopTabProps {
renderBulkCloseItems: RenderBulkCloseItems;
t: TranslateFn;
tabAnimationClass?: string;
shortcutNumber?: number;
}
/**
@@ -915,6 +948,7 @@ export const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
renderBulkCloseItems,
t,
tabAnimationClass,
shortcutNumber,
}) => {
const isActive = useIsTabActive(workspace.id);
const hasActivity = useAnySessionActivity(workspaceSessionIds);
@@ -986,11 +1020,15 @@ export const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
/>
)}
<div className="flex items-center gap-2 truncate">
<LayoutGrid
size={14}
className="shrink-0"
style={{ color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
/>
{shortcutNumber != null ? (
<TabShortcutNumberBadge number={shortcutNumber} />
) : (
<LayoutGrid
size={14}
className="shrink-0"
style={{ color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
/>
)}
<span className="truncate leading-5" title={tabLabel}>{tabLabel}</span>
</div>
<div className="flex items-center gap-1.5 shrink-0">
@@ -1052,6 +1090,7 @@ interface LogViewTopTabProps {
onTabDrop: (e: React.DragEvent, targetTabId: string) => void;
t: TranslateFn;
tabAnimationClass?: string;
shortcutNumber?: number;
}
export const LogViewTopTab: React.FC<LogViewTopTabProps> = memo(({
@@ -1069,6 +1108,7 @@ export const LogViewTopTab: React.FC<LogViewTopTabProps> = memo(({
onTabDrop,
t,
tabAnimationClass,
shortcutNumber,
}) => {
const isActive = useIsTabActive(logView.id);
const isLocal = logView.log.protocol === 'local' || logView.log.hostname === 'localhost';
@@ -1135,11 +1175,15 @@ export const LogViewTopTab: React.FC<LogViewTopTabProps> = memo(({
/>
)}
<div className="flex items-center gap-2 min-w-0 flex-1">
<FileText
size={14}
className="shrink-0"
style={{ color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
/>
{shortcutNumber != null ? (
<TabShortcutNumberBadge number={shortcutNumber} />
) : (
<FileText
size={14}
className="shrink-0"
style={{ color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
/>
)}
<span className="truncate leading-5">
{t('tabs.logPrefix')} {isLocal ? t('tabs.logLocal') : logView.log.hostname}
</span>
+15
View File
@@ -63,6 +63,21 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
const prepareGridLayoutAnimation = useVaultGridLayoutAnimation(hostListScrollRef);
const [focusedHostId, setFocusedHostId] = React.useState<string | null>(null);
const [focusedGroupPath, setFocusedGroupPath] = React.useState<string | null>(null);
const hostListFilterFocusKey = React.useMemo(
() => getVaultTreeAutoExpandKey(search, selectedTags) ?? "",
[search, selectedTags],
);
const [prevHostListFilterFocusKey, setPrevHostListFilterFocusKey] = React.useState(
hostListFilterFocusKey,
);
// Clear keyboard/selection focus as soon as search or tags change so the
// virtual list cannot steal DOM focus back from the search input on the
// same commit (useEffect would run too late).
if (hostListFilterFocusKey !== prevHostListFilterFocusKey) {
setPrevHostListFilterFocusKey(hostListFilterFocusKey);
setFocusedHostId(null);
setFocusedGroupPath(null);
}
const hostCollectionLayoutKey = [
displayedGroups.length,
hasHostsSidePanel ? "panel" : "full",
@@ -7,6 +7,7 @@ import {
getNextRaggedRowPosition,
getNextVirtualHostIndex,
resolveVirtualFocusRequest,
shouldApplyVirtualHostDomFocus,
VirtualizedGroupedHostCollection,
VirtualizedHostCollection,
} from "./VirtualizedHostCollection.tsx";
@@ -26,6 +27,34 @@ test("virtual focus retries when an active item enters a collection", () => {
}), { status: "request", key: "host-1", index: 3 });
});
test("virtual host DOM focus does not steal from outside controls", () => {
const inside = { tagName: "BUTTON" } as unknown as Element;
const searchInput = { tagName: "INPUT" } as unknown as Element;
const body = { tagName: "BODY" } as unknown as Element;
const collectionRoot = {
contains(node: Node) {
return node === inside;
},
};
assert.equal(shouldApplyVirtualHostDomFocus({
collectionRoot,
activeElement: inside,
}), true);
assert.equal(shouldApplyVirtualHostDomFocus({
collectionRoot,
activeElement: searchInput,
}), false);
assert.equal(shouldApplyVirtualHostDomFocus({
collectionRoot,
activeElement: body,
}), true);
assert.equal(shouldApplyVirtualHostDomFocus({
collectionRoot: null,
activeElement: inside,
}), false);
});
test("host grids use the same fixed card-width column calculation", () => {
assert.equal(getVaultHostGridColumnCount(219), 1);
assert.equal(getVaultHostGridColumnCount(452), 2);
@@ -82,6 +82,23 @@ export function resolveVirtualFocusRequest({
return { status: "request", key, index };
}
/**
* Virtual collections may retry DOM focus when a remembered active item
* re-enters the filtered set. Never steal focus from controls outside the
* collection (e.g. the vault host search input).
*/
export function shouldApplyVirtualHostDomFocus(input: {
collectionRoot: { contains: (node: Node) => boolean } | null | undefined;
activeElement: Element | null | undefined;
}): boolean {
const { collectionRoot, activeElement } = input;
if (!collectionRoot) return false;
if (!activeElement) return true;
const tagName = activeElement.tagName;
if (tagName === "BODY" || tagName === "HTML") return true;
return collectionRoot.contains(activeElement);
}
export function VirtualizedHostCollection<T>({
items,
itemKey,
@@ -170,6 +187,13 @@ export function VirtualizedHostCollection<T>({
const focusRenderedItem = React.useCallback((key: string) => {
const root = rootRef.current;
if (!root) return false;
if (!shouldApplyVirtualHostDomFocus({
collectionRoot: root,
activeElement: typeof document === "undefined" ? null : document.activeElement,
})) {
pendingFocusKeyRef.current = null;
return true;
}
const wrapper = [...root.querySelectorAll<HTMLElement>("[data-vault-item-key]")]
.find((element) => element.dataset.vaultItemKey === key);
const focusTarget = wrapper?.querySelector<HTMLElement>(
@@ -450,6 +474,13 @@ export function VirtualizedGroupedHostCollection<T>({
const focusRenderedItem = React.useCallback((key: string) => {
const root = rootRef.current;
if (!root) return false;
if (!shouldApplyVirtualHostDomFocus({
collectionRoot: root,
activeElement: typeof document === "undefined" ? null : document.activeElement,
})) {
pendingFocusKeyRef.current = null;
return true;
}
const wrapper = [...root.querySelectorAll<HTMLElement>("[data-vault-item-key]")]
.find((element) => element.dataset.vaultItemKey === key);
const focusTarget = wrapper?.querySelector<HTMLElement>("[data-host-id]");
@@ -0,0 +1,130 @@
# Issue #2848:Linux `.deb` 应用内自更新研究
研究日期:2026-08-10
研究范围:Issue #2848、`electron-builder` / `electron-updater` 官方文档与源码,以及 Element Desktop、Joplin、Beekeeper Studio 三个开源 Electron 项目的固定提交源码。本文只记录已经在一手来源中确认的行为,不把“发布了 `.deb`”等同于“支持应用内自更新”。
## 结论先行
1. `electron-updater@6.8.3` 已经有 Linux `.deb` 的完整更新路径。它不是只能更新 AppImage:`electron-builder` 为可发布的 FPM Linux 包写入 `app-update.yml` 和 `resources/package-type`,`electron-updater` 再根据 `package-type=deb` 实例化 `DebUpdater`。[FpmTarget.ts](https://github.com/electron-userland/electron-builder/blob/103863c143e09c5a4dd3cca24a78302cf1b782e4/packages/app-builder-lib/src/targets/FpmTarget.ts#L139-L150)、[main.ts](https://github.com/electron-userland/electron-builder/blob/3a3f4396e1c6a390f04afb2c6d6f667a9022f5a6/packages/electron-updater/src/main.ts#L24-L66)
2. 官方 `.deb` 路径的边界很清楚:检查更新时读取 Linux 更新清单,下载清单中匹配架构的 `.deb`,下载过程校验哈希并缓存;安装时调用 `dpkg` 或 `apt`,普通用户通过 `pkexec`、图形化 sudo 工具或 `sudo` 提权。它不是“把新文件复制到应用目录”,也不提供应用级的旧版本回滚。[DebUpdater.ts](https://github.com/electron-userland/electron-builder/blob/3a3f4396e1c6a390f04afb2c6d6f667a9022f5a6/packages/electron-updater/src/DebUpdater.ts#L15-L81)、[LinuxUpdater.ts](https://github.com/electron-userland/electron-builder/blob/3a3f4396e1c6a390f04afb2c6d6f667a9022f5a6/packages/electron-updater/src/LinuxUpdater.ts#L10-L97)
3. 官方库本身不会替应用设计确认界面。`autoDownload` 默认开启,下载完成后 `autoInstallOnAppQuit` 默认也开启;如果产品要求“用户确认下载”和“用户确认重启安装”,应用层应关闭自动下载/退出安装,收到事件后由界面触发下载和 `quitAndInstall`。[AppUpdater.ts](https://github.com/electron-userland/electron-builder/blob/3a3f4396e1c6a390f04afb2c6d6f667a9022f5a6/packages/electron-updater/src/AppUpdater.ts#L52-L69)、[BaseUpdater.ts](https://github.com/electron-userland/electron-builder/blob/3a3f4396e1c6a390f04afb2c6d6f667a9022f5a6/packages/electron-updater/src/BaseUpdater.ts#L14-L25)
4. 三个成熟项目都没有把 Linux `.deb` 做成应用内自更新:Element Desktop 在 Linux 直接关闭更新;Joplin 的桌面更新入口打开下载地址,Linux 安装脚本更新的是用户目录中的 AppImage;Beekeeper Studio 明确只对 Linux AppImage 启用 `electron-updater`,非 AppImage Linux 包直接跳过。[Element updater.ts](https://github.com/element-hq/element-desktop/blob/bcd84015638697695b50b3e9d3031ba4eecff831/src/updater.ts#L74-L128)、[Joplin checkForUpdates.ts](https://github.com/laurent22/joplin/blob/2654b33620775080d1d59c552259d41e33dad3d2/packages/app-desktop/checkForUpdates.ts#L85-L126)、[Beekeeper update_manager.ts](https://github.com/beekeeper-studio/beekeeper-studio/blob/55938d331df2b102aeda57f7a640a98489c7826f/apps/studio/src/background/update_manager.ts#L31-L69)
5. 对 Issue #2848 来说,关键问题不是 `electron-updater` 能不能安装 `.deb`,而是发布物和运行时是否成对满足条件:`.deb` 内要有正确的 `package-type` 和 `app-update.yml`,更新服务器要有对应的 `latest-linux[-架构].yml` 与 `.deb`,安装时要处理提权确认、取消、失败和手动降级入口。当前工作区的桥接层已经按这个方向读取 `package-type`,构建配置也包含 `.deb` 和 GitHub 发布配置;但本次是只读研究,没有把它表述为已在实际安装包中验证或已随正式版本发布。[Netcatty autoUpdateBridge.cjs](https://github.com/binaricat/Netcatty/blob/3f67ccf3b2766e7757e3d2ca63b1044808a8363c/electron/bridges/autoUpdateBridge.cjs#L50-L80)、[Netcatty electron-builder.config.cjs](https://github.com/binaricat/Netcatty/blob/3f67ccf3b2766e7757e3d2ca63b1044808a8363c/electron-builder.config.cjs#L257-L305)、[Netcatty package.json](https://github.com/binaricat/Netcatty/blob/3f67ccf3b2766e7757e3d2ca63b1044808a8363c/package.json#L107-L113)
## 1. 官方实现:从安装包识别到安装
### 1.1 如何识别 `.deb`
`electron-builder` 的 FPM 目标在有发布配置时,向 Linux 应用资源目录写入两份更新文件:
- `app-update.yml`:发布服务配置;
- `package-type`:目标名,例如 `deb`、`rpm` 或 `pacman`。
`electron-updater` 在 Linux 启动时先创建 `AppImageUpdater`,然后读取 `process.resourcesPath/package-type`。读取到 `deb` 时改用 `DebUpdater`;没有该标记时保持 AppImage 路径。AppImage 则通过 `APPIMAGE` 环境变量识别。[FpmTarget.ts](https://github.com/electron-userland/electron-builder/blob/103863c143e09c5a4dd3cca24a78302cf1b782e4/packages/app-builder-lib/src/targets/FpmTarget.ts#L139-L150)、[main.ts](https://github.com/electron-userland/electron-builder/blob/3a3f4396e1c6a390f04afb2c6d6f667a9022f5a6/packages/electron-updater/src/main.ts#L24-L66)、[AppImageUpdater.ts](https://github.com/electron-userland/electron-builder/blob/3a3f4396e1c6a390f04afb2c6d6f667a9022f5a6/packages/electron-updater/src/AppImageUpdater.ts#L462-L489)
这意味着不能只按 `process.platform === "linux"` 判断是否支持更新,也不能只看当前安装文件的扩展名。运行时真正依赖的是构建时写入的安装类型标记和发布配置。
### 1.2 如何选择清单和安装包
Linux 更新清单的默认名称带有平台和架构后缀:x64 使用 `latest-linux.yml`,其他架构使用 `latest-linux-架构.yml`。清单中的文件会经过扩展名筛选,并优先选择名称中包含当前 `process.arch` 的文件。[Provider.ts](https://github.com/electron-userland/electron-builder/blob/3a3f4396e1c6a390f04afb2c6d6f667a9022f5a6/packages/electron-updater/src/providers/Provider.ts#L40-L60)、[Provider.ts 的 findFile](https://github.com/electron-userland/electron-builder/blob/3a3f4396e1c6a390f04afb2c6d6f667a9022f5a6/packages/electron-updater/src/providers/Provider.ts#L97-L111)
`DebUpdater` 只选择 `.deb` 文件,下载任务把文件扩展名固定为 `deb`,并把下载进度向外发出。与 AppImage 路径不同,这里没有差分下载逻辑;它下载完整的 `.deb`。[DebUpdater.ts](https://github.com/electron-userland/electron-builder/blob/3a3f4396e1c6a390f04afb2c6d6f667a9022f5a6/packages/electron-updater/src/DebUpdater.ts#L15-L29)
### 1.3 下载、校验和缓存
通用下载流程会把清单中的 `sha2/sha512` 传给 HTTP 下载器,先写入缓存目录中的临时文件,下载成功后再改名为最终缓存文件。网络错误、取消或改名失败会清理临时下载;已缓存的更新会在再次使用前检查清单哈希和文件哈希,校验不一致时清空缓存并重新下载。[AppUpdater.ts](https://github.com/electron-userland/electron-builder/blob/3a3f4396e1c6a390f04afb2c6d6f667a9022f5a6/packages/electron-updater/src/AppUpdater.ts#L709-L799)、[DownloadedUpdateHelper.ts](https://github.com/electron-userland/electron-builder/blob/3a3f4396e1c6a390f04afb2c6d6f667a9022f5a6/packages/electron-updater/src/DownloadedUpdateHelper.ts#L38-L151)、[builder-util-runtime HTTP 校验](https://github.com/electron-userland/electron-builder/blob/3a3f4396e1c6a390f04afb2c6d6f667a9022f5a6/packages/builder-util-runtime/src/httpExecutor.ts#L451-L549)
所以,下载失败时的可靠回退是“删除坏的临时/缓存文件,等待下一次检查重新下载”,不是切换到另一个 Linux 安装格式。安装失败后也不会自动把旧 `.deb` 重新安装一遍。
### 1.4 root 权限和用户确认
安装逻辑先检查 `dpkg` 或 `apt` 是否存在,并优先选择 `dpkg`。如果当前进程 UID 是 0,直接运行包管理命令;普通用户则按顺序寻找 `gksudo`、`kdesudo`、`pkexec`、`beesu`,都没有时使用 `sudo`,并以应用名生成“希望更新”的提示文本。[LinuxUpdater.ts](https://github.com/electron-userland/electron-builder/blob/3a3f4396e1c6a390f04afb2c6d6f667a9022f5a6/packages/electron-updater/src/LinuxUpdater.ts#L10-L73)
源码没有弹出产品层“是否现在更新”的对话框。它的默认值是发现更新后自动下载,下载完成后在正常退出时自动安装;显式 `quitAndInstall` 则立即进入安装并退出应用。[AppUpdater.ts](https://github.com/electron-userland/electron-builder/blob/3a3f4396e1c6a390f04afb2c6d6f667a9022f5a6/packages/electron-updater/src/AppUpdater.ts#L52-L69)、[BaseUpdater.ts](https://github.com/electron-userland/electron-builder/blob/3a3f4396e1c6a390f04afb2c6d6f667a9022f5a6/packages/electron-updater/src/BaseUpdater.ts#L78-L103)
因此,若 Netcatty 要求用户确认,确认应由 Netcatty 自己的界面控制:确认下载后调用 `downloadUpdate()`,确认重启后调用 `quitAndInstall()`;同时关闭默认的退出即安装,避免用户只是退出应用时突然触发提权。
### 1.5 安装失败和回退
官方 `DebUpdater` 的失败处理是:
- 找不到下载文件,或找不到 `dpkg/apt`:发出错误并返回失败;
- 使用 `dpkg -i` 时失败:记录警告,再执行 `apt-get install -f -y` 尝试修复依赖;
- 没有 `dpkg`、只有 `apt`:执行本地 `.deb` 安装,并带上 `--allow-unauthenticated`、`--allow-downgrades`、`--allow-change-held-packages`;
- 安装命令仍失败:发出错误,不会在应用层恢复旧版本。[DebUpdater.ts](https://github.com/electron-userland/electron-builder/blob/3a3f4396e1c6a390f04afb2c6d6f667a9022f5a6/packages/electron-updater/src/DebUpdater.ts#L31-L81)
其中 `--allow-unauthenticated` 是当前 `6.8.3` 源码的实际行为,不能被描述成安全的签名验证方案。它说明官方实现把 `.deb` 安装交给系统包管理器,但没有替应用建立完整的包签名信任链。
## 2. 开源项目对照
| 项目 | 发布的 Linux 格式 | 实际更新路径 | 权限 / 用户确认 | 失败或回退行为 |
| --- | --- | --- | --- | --- |
| `electron-updater@6.8.3` 官方 | `.deb`、AppImage、RPM、Pacman | 用 `package-type` 选择 updater;`.deb` 下载完整包,调用 `dpkg/apt` | root 直接安装;普通用户走 `pkexec`/sudo 等;确认界面由应用层决定,默认下载和退出安装 | 下载临时文件和缓存有哈希校验与清理;`dpkg` 失败尝试 `apt-get -f`;没有应用级旧版本回滚 |
| Element Desktop | `tar.gz`、`.deb`;构建配置确认发布 `.deb` | Linux `available()` 直接返回 false;只对 macOS/Windows 配置 Electron 自带 updater | Linux 不进入更新流程,因此没有应用内提权或确认逻辑 | Linux 的回退就是不自更新,用户使用外部下载/安装方式 |
| Joplin | AppImage、`.deb` | 更新检查弹窗确认后打开下载 URL;Linux 安装脚本实际下载和替换 `$HOME/.joplin/Joplin.AppImage` | 用户目录安装不需要 root;脚本默认拒绝 root,必须显式 `--allow-root` 才能运行 | 版本比较会避免重复安装和降级;先下载到临时目录,之后删除旧 AppImage 并移动新文件;没有确认到的 `.deb` 应用内安装路径 |
| Beekeeper Studio | Snap、`.deb`、AppImage、RPM、Flatpak、Pacman | `manageUpdates()` 对 Linux 非 AppImage 直接返回;只有 AppImage 走 `electron-updater` | AppImage 使用“下载 / 稍后 / 立即重启”界面;`.deb` 不进入该流程 | Portable 有打开官网手动下载的分支;本次确认的 Linux `.deb` 路径没有独立的提权或回滚实现 |
### 2.1 Element Desktop:发布 `.deb`,但不做 Linux 应用内更新
Element 的构建配置把 Linux 目标设为 `tar.gz` 和 `deb`。[electron-builder.ts](https://github.com/element-hq/element-desktop/blob/bcd84015638697695b50b3e9d3031ba4eecff831/electron-builder.ts#L129-L152)
但其更新入口明确写着 Linux 不支持自动更新,并在 `available()` 中直接返回 `false`;`start()` 也只为 macOS 和 Windows 生成更新地址,Linux 不会设置更新源。[updater.ts](https://github.com/element-hq/element-desktop/blob/bcd84015638697695b50b3e9d3031ba4eecff831/src/updater.ts#L74-L128)
项目自己的更新文档也只说桌面应用能在 macOS 和 Windows 自更新。[docs/updates.md](https://github.com/element-hq/element-desktop/blob/bcd84015638697695b50b3e9d3031ba4eecff831/docs/updates.md#L1-L15)
已确认的结论:Element 的 `.deb` 是发布/安装格式,不是应用内更新格式。Linux 没有可供比较的 root 提权、安装确认或旧版本回退实现;不能从源码推断它对这些情况有额外处理。
### 2.2 Joplin:Linux 实际更新的是用户目录 AppImage
Joplin 的构建配置同时发布 AppImage 和 `.deb`。[package.json](https://github.com/laurent22/joplin/blob/2654b33620775080d1d59c552259d41e33dad3d2/packages/app-desktop/package.json#L132-L150)
桌面更新入口从 Joplin 自己的发布地址取版本列表,比较版本后弹窗;用户点击 Download 时打开 `release.downloadUrl` 或 `release.pageUrl`,而不是把 `.deb` 交给应用内安装器。[checkForUpdates.ts](https://github.com/laurent22/joplin/blob/2654b33620775080d1d59c552259d41e33dad3d2/packages/app-desktop/checkForUpdates.ts#L27-L42)、[checkForUpdates.ts](https://github.com/laurent22/joplin/blob/2654b33620775080d1d59c552259d41e33dad3d2/packages/app-desktop/checkForUpdates.ts#L85-L126)
其 Linux 安装/更新脚本把安装目录默认设为 `$HOME/.joplin`,root 默认被拒绝;脚本先把新版 AppImage 下载到临时目录,之后删除旧 AppImage,移动新版并补执行权限。脚本使用 `set -e` 和错误提示,但源码中没有确认到包管理器提权或应用内 `.deb` 安装流程。[Joplin_install_and_update.sh](https://github.com/laurent22/joplin/blob/2654b33620775080d1d59c552259d41e33dad3d2/Joplin_install_and_update.sh#L27-L31)、[Joplin_install_and_update.sh](https://github.com/laurent22/joplin/blob/2654b33620775080d1d59c552259d41e33dad3d2/Joplin_install_and_update.sh#L148-L152)、[Joplin_install_and_update.sh](https://github.com/laurent22/joplin/blob/2654b33620775080d1d59c552259d41e33dad3d2/Joplin_install_and_update.sh#L228-L245)
另外,Joplin 的构建后处理明确只为 AppImage 生成 SHA-512 文件。[afterAllArtifactBuild.js](https://github.com/laurent22/joplin/blob/2654b33620775080d1d59c552259d41e33dad3d2/packages/app-desktop/afterAllArtifactBuild.js#L8-L31)
已确认的结论:Joplin 采用“用户确认后打开下载地址 + 用户目录 AppImage 替换”的路线,避免了 `.deb` 的 root 安装问题;这不是可直接复用的 `.deb` 应用内自更新方案。
### 2.3 Beekeeper Studio:只给 AppImage 接上 `electron-updater`
Beekeeper 的 Linux 构建配置包含 Snap、`.deb`、AppImage、RPM、Flatpak 和 Pacman,并为 Linux 发布到 GitHub。[electron-builder-config.js](https://github.com/beekeeper-studio/beekeeper-studio/blob/55938d331df2b102aeda57f7a640a98489c7826f/apps/studio/electron-builder-config.js#L168-L200)
运行时代码在 Linux 且不是 AppImage 时直接跳过更新;Snap 也在入口处跳过。只有 AppImage 才会处理 `APPIMAGE` 路径、检查更新、下载和安装。它关闭了自动下载,界面提供 Download、Later 和 Restart Now 三步确认。[update_manager.ts](https://github.com/beekeeper-studio/beekeeper-studio/blob/55938d331df2b102aeda57f7a640a98489c7826f/apps/studio/src/background/update_manager.ts#L11-L99)、[AutoUpdater.vue](https://github.com/beekeeper-studio/beekeeper-studio/blob/55938d331df2b102aeda57f7a640a98489c7826f/apps/studio/src/components/AutoUpdater.vue#L13-L95)
已确认的结论:Beekeeper 的 UI 确认流程值得参考,但它只服务于 AppImage。对 Linux `.deb`,源码没有单独的提权确认、包管理器安装或回滚路径,不能把 AppImage 的行为直接外推到 `.deb`。
## 3. 对 Netcatty Issue #2848 的直接启示
Issue #2848 描述的是“`.deb` 应用检测到新版后仍需手动下载 `.deb` 安装”。[Issue #2848](https://github.com/binaricat/Netcatty/issues/2848)
本工作区固定提交 `3f67ccf3b2766e7757e3d2ca63b1044808a8363c` 中,已经能确认以下事实:
- `autoUpdateBridge.cjs` 把 `APPIMAGE` 识别为 AppImage;否则读取 `resources/package-type`,接受 `deb/rpm/pacman`;无标记时返回不支持。[autoUpdateBridge.cjs](https://github.com/binaricat/Netcatty/blob/3f67ccf3b2766e7757e3d2ca63b1044808a8363c/electron/bridges/autoUpdateBridge.cjs#L50-L80)
- `electron-builder.config.cjs` 的 Linux 目标包含 `AppImage`、`deb`、`rpm`、`pacman`,发布配置为 GitHub。[electron-builder.config.cjs](https://github.com/binaricat/Netcatty/blob/3f67ccf3b2766e7757e3d2ca63b1044808a8363c/electron-builder.config.cjs#L257-L305)
- 应用依赖范围是 `electron-updater` `^6.8.3`,锁文件解析到 `6.8.3`;这与上面确认的 `DebUpdater` 实现相符。[package.json](https://github.com/binaricat/Netcatty/blob/3f67ccf3b2766e7757e3d2ca63b1044808a8363c/package.json#L107-L113)
- 桥接层把 `autoInstallOnAppQuit` 设为 `false`,把检查、下载和安装暴露为显式操作;安装前还会检查未保存编辑。[autoUpdateBridge.cjs](https://github.com/binaricat/Netcatty/blob/3f67ccf3b2766e7757e3d2ca63b1044808a8363c/electron/bridges/autoUpdateBridge.cjs#L101-L105)、[autoUpdateBridge.cjs](https://github.com/binaricat/Netcatty/blob/3f67ccf3b2766e7757e3d2ca63b1044808a8363c/electron/bridges/autoUpdateBridge.cjs#L386-L565)
因此,后续若要验证 Issue 是否真正完成,最小证据链应是:
1. 从实际 `.deb` 安装包解出 `resources/package-type`,确认内容为 `deb`,同时确认存在 `app-update.yml`;
2. 用对应发布版本检查 `latest-linux.yml` 和 `.deb` 文件名/架构是否匹配;
3. 以普通用户执行一次“检查 → 用户确认下载 → 用户确认重启”,确认提权对话框出现且取消不会让应用退出;
4. 分别验证下载哈希错误、无 `dpkg/apt`、权限取消和依赖修复失败时,界面能保留可见错误并提供手动 Releases 入口;
5. 验证安装失败后不会把“已安装成功”写入 UI 状态,也不把“下载成功”误报成“安装成功”。
这份研究没有执行实际安装包测试,因此不把上述运行时证据当作已满足。
## 来源与版本边界
- Issue:[#2848](https://github.com/binaricat/Netcatty/issues/2848)
- 官方自动更新说明:[electron-builder Auto Update](https://www.electron.build/docs/features/auto-update/)
- 官方 Linux 目标说明:[electron-builder Linux](https://www.electron.build/docs/linux/)
- 官方 API 说明:[electron-updater API](https://www.electron.build/docs/api/electron-updater/)
- 官方源码快照:`electron-updater@6.8.3` 对应提交 [`3a3f4396e1c6a390f04afb2c6d6f667a9022f5a6`](https://github.com/electron-userland/electron-builder/commit/3a3f4396e1c6a390f04afb2c6d6f667a9022f5a6);`electron-builder@26.11.1` 对应提交 [`103863c143e09c5a4dd3cca24a78302cf1b782e4`](https://github.com/electron-userland/electron-builder/commit/103863c143e09c5a4dd3cca24a78302cf1b782e4)。
- 项目源码快照:Element Desktop [`bcd84015638697695b50b3e9d3031ba4eecff831`](https://github.com/element-hq/element-desktop/commit/bcd84015638697695b50b3e9d3031ba4eecff831);Joplin [`2654b33620775080d1d59c552259d41e33dad3d2`](https://github.com/laurent22/joplin/commit/2654b33620775080d1d59c552259d41e33dad3d2);Beekeeper Studio [`55938d331df2b102aeda57f7a640a98489c7826f`](https://github.com/beekeeper-studio/beekeeper-studio/commit/55938d331df2b102aeda57f7a640a98489c7826f)。
+593
View File
@@ -0,0 +1,593 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
convertClipboardHtmlToMarkdown,
convertHtmlImgTagToMarkdownOrHtml,
convertHtmlIslandsInMarkdown,
decodeHtmlEntities,
extractBalancedHtmlElement,
maskCodeRegions,
normalizeLinkedBadgeImages,
normalizeNotePublicAssetPaths,
normalizePastedNoteMarkdown,
plainMarkdownContainsHtml,
resolveNoteClipboardPaste,
serializeSafeHtmlImage,
shouldInterceptResolvedNotePaste,
shouldInsertClipboardTextAsMarkdown,
unmaskCodeRegions,
} from "./clipboardPaste.ts";
const CATTY_PASTE = `---
<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 map to Vite site root (not dropped)", () => {
const md = convertHtmlIslandsInMarkdown(
'<p align="center"><img src="public/icon.png" alt="Netcatty" width="128" height="128"></p>',
);
// Vite serves public/ at / — store /icon.png so the browser does not request /public/...
assert.match(md, /src="\/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](/icon.png)",
);
// Protocol-relative → https (covered more fully below; keep here as non-drop).
assert.equal(
serializeSafeHtmlImage({
src: "//cdn.example.com/a.png",
alt: "cdn",
}),
"![cdn](https://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",
htmlText: "",
});
assert.equal(payload.kind, "markdown");
assert.match(payload.text, /# From \.md file/);
});
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,
);
});
test("structured plain markdown wins over presentation HTML wrappers", () => {
const payload = resolveNoteClipboardPaste({
plainText: "# Title\n\n- item one",
htmlText: "<div><div># Title</div><div>- item one</div></div>",
});
assert.equal(payload.kind, "markdown");
assert.match(payload.text, /^# Title/m);
assert.doesNotMatch(payload.text, /\\# Title/);
});
test("TypeScript generics are not treated as HTML islands", () => {
assert.equal(plainMarkdownContainsHtml("const values: List<string> = []"), false);
assert.equal(plainMarkdownContainsHtml("type M = Map<string, number>"), false);
assert.equal(plainMarkdownContainsHtml("fn(): Promise<boolean>"), false);
assert.equal(plainMarkdownContainsHtml("Use <span>status</span> here"), true);
assert.equal(plainMarkdownContainsHtml('<img src="https://x.com/a.png" />'), true);
assert.equal(plainMarkdownContainsHtml("<div>block</div>"), true);
});
test("hard-break trailing spaces are kept outside code", () => {
const source = "line one \nline two\n\n\nline three";
const md = normalizePastedNoteMarkdown(source);
assert.match(md, /line one {2}\nline two/);
assert.doesNotMatch(md, /\n{3,}/);
});
test("http and protocol-relative image src normalize to https", () => {
assert.equal(
serializeSafeHtmlImage({ src: "http://example.com/a.png", alt: "a" }),
"![a](https://example.com/a.png)",
);
assert.equal(
serializeSafeHtmlImage({ src: "//cdn.example.com/a.png", alt: "cdn" }),
"![cdn](https://cdn.example.com/a.png)",
);
});
test("image attributes decode entities before re-serialize", () => {
const md = convertHtmlImgTagToMarkdownOrHtml(
'<img width="100" alt="A &amp; B" src="https://example.com/a.png?x=1&amp;y=2" />',
);
assert.match(md, /alt="A &amp; B"/);
assert.match(md, /src="https:\/\/example\.com\/a\.png\?x=1&amp;y=2"/);
assert.doesNotMatch(md, /&amp;amp;/);
});
test("img alt with > inside quotes is not truncated", () => {
const md = convertHtmlImgTagToMarkdownOrHtml(
'<img alt="A > B" src="https://example.com/a.png" width="20" />',
);
assert.match(md, /alt="A &gt; B"/);
assert.match(md, /src="https:\/\/example\.com\/a\.png"/);
assert.match(md, /width="20"/);
});
test("fenced code with ](url) and <img> examples is not rewritten", () => {
const source = [
"# Doc",
"",
"```md",
"](https://example.com)",
'<img src="https://example.com/x.png" />',
"```",
"",
"Use `<span>status</span>` inline.",
].join("\n");
const md = normalizePastedNoteMarkdown(source);
assert.match(md, /```md\n\]\(https:\/\/example\.com\)/);
assert.match(md, /<img src="https:\/\/example\.com\/x\.png" \/>/);
assert.match(md, /`<span>status<\/span>`/);
});
test("nested same-tag HTML islands convert without truncating outer close", () => {
const md = convertHtmlIslandsInMarkdown(
'<div><div>inner</div><p>after</p></div>\n\n# Done',
);
assert.match(md, /inner/);
assert.match(md, /after/);
assert.match(md, /# Done/);
});
test("decodeHtmlEntities ignores out-of-range numeric entities", () => {
assert.equal(decodeHtmlEntities("ok &#65; end"), "ok A end");
assert.equal(decodeHtmlEntities("bad &#1114112; keep"), "bad &#1114112; keep");
assert.equal(decodeHtmlEntities("bad &#x110000; keep"), "bad &#x110000; keep");
assert.doesNotThrow(() => decodeHtmlEntities("&#x110000;&#1114112;"));
});
test("serializeSafeHtmlImage angles destinations that contain spaces", () => {
assert.equal(
serializeSafeHtmlImage({ src: "images/company logo.png", alt: "logo" }),
"![logo](<images/company logo.png>)",
);
});
test("indented code HTML samples are not converted as islands", () => {
const source = [
"Intro",
"",
" <img src=\"https://example.com/code.png\" />",
"",
'<img src="https://example.com/real.png" />',
].join("\n");
const md = convertHtmlIslandsInMarkdown(source);
assert.match(md, / {4}<img src="https:\/\/example\.com\/code\.png" \/>/);
assert.match(md, /!\[\]\(https:\/\/example\.com\/real\.png\)|src="https:\/\/example\.com\/real\.png"/);
});
test("linked badge examples inside fenced code are not rewritten", () => {
const source = [
"```md",
"[![shield](http://img.shields.io/badge/x-1-blue)](http://example.com)",
"```",
"",
"[![live](http://img.shields.io/badge/y-2-green)](http://example.com)",
].join("\n");
const md = normalizePastedNoteMarkdown(source);
assert.match(md, /```md\n\[!\[shield\]\(http:\/\/img\.shields\.io/);
assert.match(md, /!\[live\]\(https:\/\/img\.shields\.io/);
});
test("img getAttr prefers real src over data-src", () => {
const md = convertHtmlImgTagToMarkdownOrHtml(
'<img data-src="https://lazy.example/x.png" src="https://real.example/y.png" alt="pic" />',
);
assert.match(md, /real\.example\/y\.png/);
assert.doesNotMatch(md, /lazy\.example/);
});
test("normalizeNotePublicAssetPaths leaves public/ samples inside code alone", () => {
const source = [
"See `public/icon.png` and:",
"",
"```md",
"![x](public/icon.png)",
"```",
"",
"![live](public/icon.png)",
].join("\n");
const md = normalizeNotePublicAssetPaths(source);
assert.match(md, /`public\/icon\.png`/);
assert.match(md, /```md\n!\[x\]\(public\/icon\.png\)/);
assert.match(md, /!\[live\]\(\/icon\.png\)/);
});
test("maskCodeRegions covers indented and blockquote fences", () => {
const source = [
" ```md",
" - [ ] fake",
" ```",
"",
"> ```",
"> - [ ] quoted-fake",
"> ```",
"",
"- [ ] real",
].join("\n");
const { text } = maskCodeRegions(source);
assert.doesNotMatch(text, /- \[ \] fake/);
assert.doesNotMatch(text, /- \[ \] quoted-fake/);
assert.match(text, /- \[ \] real/);
});
test("maskCodeRegions does not hide nested list tasks as indented code", () => {
const source = [
"- parent",
" - [ ] child",
"- [ ] later",
"",
" plain indented code",
].join("\n");
const { text } = maskCodeRegions(source);
assert.match(text, / {4}- \[ \] child/);
assert.match(text, /- \[ \] later/);
assert.doesNotMatch(text, /plain indented code/);
});
test("maskCodeRegions sentinels do not collide with user-authored tokens", () => {
const source = [
"keep @@NETCATTY_MD_CODE_0@@ literal",
"",
"```",
"code body",
"```",
].join("\n");
const mask = maskCodeRegions(source);
assert.match(mask.text, /keep @@NETCATTY_MD_CODE_0@@ literal/);
assert.notEqual(mask.sentinel, "@@NETCATTY_MD_CODE_");
const restored = unmaskCodeRegions(mask.text, mask.slots, mask.sentinel);
assert.equal(restored, source);
});
test("linked badge anchors prefer real href over data-href", () => {
const md = normalizeLinkedBadgeImages(
'<a data-href="https://wrong.example" href="https://right.example"><img src="https://img.example/a.png" alt="a" /></a>',
);
assert.match(md, /right\.example/);
assert.doesNotMatch(md, /wrong\.example/);
});
test("maskCodeRegions accepts longer closing fences", () => {
const source = [
"```md",
"![x](public/icon.png)",
"- [ ] fake",
"````",
"",
"- [ ] real",
].join("\n");
const mask = maskCodeRegions(source);
assert.doesNotMatch(mask.text, /public\/icon\.png/);
assert.doesNotMatch(mask.text, /- \[ \] fake/);
assert.match(mask.text, /- \[ \] real/);
assert.equal(unmaskCodeRegions(mask.text, mask.slots, mask.sentinel), source);
});
test("maskCodeRegions keeps info strings starting with the other fence char", () => {
// Opening is three backticks; info may start with ~ without lengthening the fence.
const source = ["```~tip", "![x](public/a.png)", "```", "", "after"].join("\n");
const mask = maskCodeRegions(source);
assert.doesNotMatch(mask.text, /public\/a\.png/);
assert.match(mask.text, /after/);
assert.equal(unmaskCodeRegions(mask.text, mask.slots, mask.sentinel), source);
});
test("maskCodeRegions masks multi-backtick spans with inner shorter runs", () => {
const source = "Use ``a ` ![x](public/a.png) b`` end";
const mask = maskCodeRegions(source);
assert.doesNotMatch(mask.text, /public\/a\.png/);
assert.match(mask.text, / end$/);
assert.equal(unmaskCodeRegions(mask.text, mask.slots, mask.sentinel), source);
});
test("maskCodeRegions masks standalone indented task samples as code", () => {
const source = [" - [ ] sample", "", "- [ ] real"].join("\n");
const mask = maskCodeRegions(source);
assert.doesNotMatch(mask.text, /- \[ \] sample/);
assert.match(mask.text, /- \[ \] real/);
});
test("normalizeLinkedBadgeImages preserves angled destinations with spaces", () => {
const md = normalizeLinkedBadgeImages(
"[![logo](<images/company logo.png>)](https://example.com)",
);
assert.match(md, /company logo\.png|company%20logo\.png|company logo/);
assert.match(md, /example\.com/);
assert.doesNotMatch(md, /<images\/company(?! logo)/);
});
test("maskCodeRegions masks multi-level blockquote fences", () => {
const source = [
"> > ~~~md",
"> > ![x](public/a.png)",
"> > ~~~",
"",
"after",
].join("\n");
const mask = maskCodeRegions(source);
assert.doesNotMatch(mask.text, /public\/a\.png/);
assert.match(mask.text, /after/);
});
test("extractBalancedHtmlElement handles raw-text script bodies with <", () => {
const source = '<script>if (a < b) alert(1)</script>\n# after';
const extracted = extractBalancedHtmlElement(source, 0);
assert.ok(extracted);
assert.equal(extracted?.tag, "script");
assert.match(extracted?.full ?? "", /if \(a < b\)/);
assert.equal(source.slice(extracted?.end ?? 0), "\n# after");
});
+909
View File
@@ -0,0 +1,909 @@
/**
* Note clipboard paste policy (domain — pure, no React).
*
* Product policy (lossy but clean — not a GitHub README clone):
* - Prefer structured text/plain Markdown when present
* - Otherwise HTML → Markdown via Turndown (+ island conversion)
* - Linked badges stay as images (tight [![alt](src)](href) or <a><img>)
* - Image dimensions preserved; CSS scales large screenshots in the panel
* - Centered blocks → <div align="center"> for MDX GenericHTML
*/
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,
];
/**
* Bare known HTML element names. Type tokens like `string` in `List<string>` are
* intentionally absent so TypeScript generics are not treated as markup islands.
*/
const BARE_HTML_TAG_RE =
/<\/?(?:a|abbr|address|area|article|aside|audio|b|base|bdi|bdo|blockquote|body|br|button|canvas|caption|cite|code|col|colgroup|data|datalist|dd|del|details|dfn|dialog|div|dl|dt|em|embed|fieldset|figcaption|figure|footer|form|h[1-6]|head|header|hgroup|hr|html|i|iframe|img|input|ins|kbd|label|legend|li|link|main|map|mark|menu|meta|meter|nav|noscript|object|ol|optgroup|option|output|p|param|picture|pre|progress|q|rp|rt|ruby|s|samp|script|section|select|slot|small|source|span|strong|style|sub|summary|sup|svg|table|tbody|td|template|textarea|tfoot|th|thead|time|title|tr|track|u|ul|var|video|wbr)(?:\s|\/|>)/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;
};
/**
* True when plain text embeds real HTML tags (not TS generics like List<string>).
*
* Heuristics:
* - comments / doctype → HTML
* - closing tags → HTML
* - open tags with attributes or void self-close → HTML
* - bare known HTML element names only (`<div>`, `<span>`) → HTML
*/
export const plainMarkdownContainsHtml = (text: string): boolean => {
if (/<!--/.test(text) || /<!doctype\b/i.test(text)) return true;
if (/<\/[a-z][a-z0-9:-]*\s*>/i.test(text)) return true;
if (/<[a-z][a-z0-9:-]*\s+[^>]*>/i.test(text)) return true;
if (/<[a-z][a-z0-9:-]*\s*\/>/i.test(text)) return true;
return BARE_HTML_TAG_RE.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: () => "",
});
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);
if (heading && inner && !/^#{1,6}\s/m.test(inner)) {
inner = `${"#".repeat(Number(heading[1]))} ${inner}`;
}
return wrapCenteredMarkdown(inner);
},
});
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",
]);
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;
};
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;
// MDX / Tailwind class-based centering
if (/\bclass(?:Name)?\s*=\s*["'][^"']*\btext-center\b/i.test(openTagOrFull)) return true;
return false;
};
export const wrapCenteredMarkdown = (inner: string): string => {
const body = inner.replace(/\r\n?/g, "\n").trim();
if (!body) return "";
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`;
};
/** Unicode scalar values accepted by String.fromCodePoint (rejects surrogates-only overflow). */
const isValidUnicodeCodePoint = (code: number): boolean => (
Number.isFinite(code)
&& Number.isInteger(code)
&& code >= 0
&& code <= 0x10FFFF
);
const codePointFromEntity = (code: number, fallback: string): string => (
isValidUnicodeCodePoint(code) ? String.fromCodePoint(code) : fallback
);
/** Decode common HTML entities (once) before re-escaping on serialize. */
export const decodeHtmlEntities = (value: string): string => (
value
.replace(/&nbsp;/gi, " ")
.replace(/&quot;/gi, '"')
.replace(/&#39;/g, "'")
.replace(/&#x27;/gi, "'")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&#(\d+);/g, (_, n: string) => codePointFromEntity(Number(n), _))
.replace(/&#x([0-9a-f]+);/gi, (_, hex: string) => (
codePointFromEntity(Number.parseInt(hex, 16), _)
))
// &amp; last so we don't re-expand decoded entities
.replace(/&amp;/gi, "&")
);
/**
* Normalize image src for note storage / in-app load.
* - https ok
* - http → https (CSP blocks http images)
* - //host → https://host (app:// base would otherwise break)
* - Vite/Electron: files under repo `public/` are served at site root, so
* `public/icon.png` / `/public/icon.png` → `/icon.png` (avoids Vite
* "use /icon.png instead of /public/icon.png" warnings)
* - other relative paths kept as-is (`./docs/...`, `/distro/foo.svg`)
* - data:/javascript: rejected
*/
export const normalizeImageSrc = (src: string): string | null => {
let trimmed = src.trim();
if (!trimmed) return null;
if (trimmed.startsWith("data:")) return null;
if (/^javascript:/i.test(trimmed)) return null;
if (trimmed.startsWith("//") && /^\/\/[^/\s]/.test(trimmed)) {
trimmed = `https:${trimmed}`;
}
if (/^http:\/\//i.test(trimmed)) {
trimmed = `https://${trimmed.slice("http://".length)}`;
}
if (/^https:\/\//i.test(trimmed)) return trimmed;
// public/ is the Vite static root — never request /public/...
if (/^\/?public\//i.test(trimmed)) {
return `/${trimmed.replace(/^\/?public\//i, "")}`;
}
if (trimmed.startsWith("/") || trimmed.startsWith("./") || trimmed.startsWith("../")) {
return trimmed;
}
// Bare relative like `public/icon.png` already handled; `docs/foo.png` keep.
if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed)) return trimmed;
return null;
};
/**
* Rewrite Vite public-dir URLs in markdown/HTML so the browser never requests /public/*.
* /public/foo.png → /foo.png
* public/foo.png → /foo.png
* Only outside fenced/indented/inline code so docs samples stay literal.
*/
export const normalizeNotePublicAssetPaths = (markdown: string): string => (
mapOutsideCode(markdown, (plain) => {
let body = plain;
body = body.replace(/((?:^|\s)src\s*=\s*["'])\/?public\//gi, "$1/");
body = body.replace(/\]\(\s*\/?public\//gi, "](/");
return body;
})
);
const escapeHtmlAttr = (value: string): string => (
value
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
);
export const serializeSafeHtmlImage = (input: {
src: string;
alt?: string;
title?: string;
width?: string | number | null;
height?: string | number | null;
}): string => {
const src = normalizeImageSrc(input.src ?? "");
if (!src) return "";
const alt = decodeHtmlEntities(input.alt ?? "").replace(/[[\]]/g, "");
const title = decodeHtmlEntities(input.title?.trim() || "");
const widthRaw = input.width != null ? String(input.width).trim() : "";
const heightRaw = input.height != null ? String(input.height).trim() : "";
const safeWidth = /^(?:\d+(?:\.\d+)?%?)$/.test(widthRaw) ? widthRaw : "";
const safeHeight = /^(?:\d+(?:\.\d+)?%?)$/.test(heightRaw) ? heightRaw : "";
if (!safeWidth && !safeHeight) {
const titlePart = title ? ` "${title.replace(/"/g, '\\"')}"` : "";
// CommonMark: destinations with spaces/parens must be <angled> or they break.
const destination = /[\s()]/.test(src) ? `<${src.replace(/[<>]/g, "")}>` : src;
return `![${alt}](${destination}${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(" ")} />`;
};
/**
* Collapse consecutive blank lines outside fenced/indented code, but keep
* Turndown hard-breaks (two trailing spaces before \n) and blank lines in code.
*/
export const trimBlankLinesOutsideCode = (value: string): string => {
const regions = maskCodeRegions(value.replace(/\r\n?/g, "\n"));
let body = regions.text;
// Collapse 3+ blank lines → 2, without eating hard-break spaces on content lines.
body = body.replace(/\n{3,}/g, "\n\n");
body = body.replace(/^\n+/, "").replace(/\n+$/, "");
// Strip trailing spaces on blank-only lines, but keep " \n" hard breaks on non-empty lines.
body = body.replace(/^[ \t]+$/gm, "");
return unmaskCodeRegions(body, regions.slots, regions.sentinel);
};
const trimBlankLines = trimBlankLinesOutsideCode;
const turndownFragment = (html: string): string => {
try {
return getTurndown().turndown(html);
} catch {
return "";
}
};
/** Scan an HTML tag end respecting quoted attribute values (allows `>` inside quotes). */
export const findHtmlTagEnd = (source: string, start: number): number => {
if (source[start] !== "<") return -1;
let i = start + 1;
let quote: '"' | "'" | null = null;
while (i < source.length) {
const ch = source[i];
if (quote) {
if (ch === quote) quote = null;
i += 1;
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
i += 1;
continue;
}
if (ch === ">") return i;
i += 1;
}
return -1;
};
/** Parse a single <img …> tag (quote-aware) into safe markdown/HTML. */
export const convertHtmlImgTagToMarkdownOrHtml = (imgTag: string): string => {
const trimmed = imgTag.trim();
if (!/^<img\b/i.test(trimmed)) return turndownFragment(imgTag).trim();
const end = findHtmlTagEnd(trimmed, 0);
if (end < 0) return "";
const open = trimmed.slice(0, end + 1);
// Only convert a pure img tag (optional trailing whitespace), not following debris.
if (trimmed.slice(end + 1).trim()) {
// Fall back: try only the tag portion
}
const attrBlob = open.replace(/^<img\b/i, "").replace(/\/?>$/, "");
const getAttr = (name: string): string => {
// Require a real attribute boundary so `data-src` does not match `src`.
const re = new RegExp(
`(?:^|[\\s"'/])${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`,
"i",
);
const m = re.exec(attrBlob);
const raw = (m?.[1] ?? m?.[2] ?? m?.[3] ?? "").trim();
return decodeHtmlEntities(raw);
};
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 decodeHtmlEntities((htmlAlt[1] ?? htmlAlt[2] ?? "link").trim()) || "link";
}
return "link";
};
export const normalizeLinkedBadgeImages = (markdown: string): string => {
let body = markdown.replace(/\r\n?/g, "\n");
body = body.replace(
/\[\s*!\[[^\]]*\]\(([^)]+)\)\s*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g,
(full, imgSrc: string, href: string) => {
const alt = extractMarkdownImageAlt(full);
// Support angled destinations with spaces: <images/company logo.png>
const rawDest = (imgSrc || "").trim();
let dest = rawDest;
if (rawDest.startsWith("<")) {
const end = rawDest.indexOf(">");
dest = end > 0 ? rawDest.slice(1, end) : rawDest.slice(1);
} else {
dest = rawDest.split(/\s+/)[0] ?? "";
}
const src = normalizeImageSrc(dest);
if (!src) return `[${alt}](${href})`;
return `[![${alt}](${src})](${href})`;
},
);
body = body.replace(
/\[\s*(<img\b[\s\S]*?>)\s*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/gi,
(_full, imgTag: string, href: string) => {
const end = findHtmlTagEnd(String(imgTag).trim(), 0);
const tag = end >= 0 ? String(imgTag).trim().slice(0, end + 1) : String(imgTag).trim();
const safeImg = convertHtmlImgTagToMarkdownOrHtml(tag);
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})`;
},
);
body = body.replace(
/<a\b([^>]*)>\s*(<img\b[\s\S]*?>)\s*<\/a>/gi,
(full, aAttrs: string, imgTag: string) => {
// Require a real attribute boundary so `data-href` does not match `href`.
const hrefMatch = /(?:^|[\s"'/])href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(aAttrs);
const href = decodeHtmlEntities(
(hrefMatch?.[1] ?? hrefMatch?.[2] ?? hrefMatch?.[3] ?? "").trim(),
);
if (!href || /^javascript:/i.test(href)) {
return convertHtmlImgTagToMarkdownOrHtml(imgTag) || "";
}
const end = findHtmlTagEnd(String(imgTag).trim(), 0);
const tag = end >= 0 ? String(imgTag).trim().slice(0, end + 1) : String(imgTag).trim();
const safeImg = convertHtmlImgTagToMarkdownOrHtml(tag);
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;
};
export type CodeMask = {
text: string;
slots: string[];
/** Unique prefix for this pass so user-authored sentinel text cannot collide. */
sentinel: string;
};
const escapeRegExp = (value: string): string => (
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
);
const chooseCodeMaskSentinel = (markdown: string): string => {
let n = 0;
let sentinel = "@@NETCATTY_MD_CODE_";
while (markdown.includes(sentinel)) {
n += 1;
sentinel = `@@NETCATTY_MD_CODE_S${n}_`;
}
return sentinel;
};
/**
* Mask GFM fenced blocks. Closing fence may be longer than the opener
* (CommonMark: close with same char and length >= open).
* Fence run must be homogeneous (` or ~), not a mixed character class.
*/
const maskFencedCodeBlocks = (
markdown: string,
stash: (chunk: string) => string,
): string => {
const lines = markdown.split("\n");
const out: string[] = [];
let i = 0;
// Any blockquote depth, then up to 3 spaces, then homogeneous ``` or ~~~ (3+).
const openRe = /^((?:[ \t]{0,3}>[ \t]?)*)[ \t]{0,3}(`{3,}|~{3,})(.*)$/;
const closeRe = /^((?:[ \t]{0,3}>[ \t]?)*)[ \t]{0,3}(`{3,}|~{3,})[ \t]*$/;
while (i < lines.length) {
const openMatch = openRe.exec(lines[i] ?? "");
if (!openMatch) {
out.push(lines[i] ?? "");
i += 1;
continue;
}
const fence = openMatch[2] ?? "";
const fenceChar = fence[0] ?? "`";
const fenceLen = fence.length;
// Info string may not contain the fence character (CommonMark).
const info = openMatch[3] ?? "";
if (info.includes(fenceChar)) {
out.push(lines[i] ?? "");
i += 1;
continue;
}
const block: string[] = [lines[i] ?? ""];
i += 1;
while (i < lines.length) {
const line = lines[i] ?? "";
const closeMatch = closeRe.exec(line);
if (
closeMatch
&& (closeMatch[2]?.[0] ?? "") === fenceChar
&& (closeMatch[2]?.length ?? 0) >= fenceLen
) {
block.push(line);
i += 1;
break;
}
block.push(line);
i += 1;
}
// Unclosed fence: still mask through EOF so trailing samples stay protected.
out.push(stash(block.join("\n")));
}
return out.join("\n");
};
const isMarkdownListLine = (line: string): boolean => (
/^(?:[ \t]{0,3}>[ \t]?)*[ \t]*(?:[-*+]|\d+[.)])[ \t]+/.test(line)
);
/**
* Mask 4-space / tab indented code. Nested list items under a preceding list
* line stay unmasked so task indices match the preview DOM; standalone
* ` - [ ] sample` at document root remains code (no checkbox in preview).
*/
const maskIndentedCodeBlocks = (
markdown: string,
stash: (chunk: string) => string,
): string => {
const lines = markdown.split("\n");
const out: string[] = [];
let i = 0;
const isBlank = (line: string) => /^[ \t]*$/.test(line);
const isIndented = (line: string) => /^(?: {4}|\t)/.test(line);
while (i < lines.length) {
const line = lines[i] ?? "";
if (!isIndented(line)) {
out.push(line);
i += 1;
continue;
}
let prev = i - 1;
while (prev >= 0 && isBlank(lines[prev] ?? "")) prev -= 1;
const inListContext = prev >= 0 && isMarkdownListLine(lines[prev] ?? "");
if (inListContext && isMarkdownListLine(line)) {
out.push(line);
i += 1;
continue;
}
const block: string[] = [];
while (i < lines.length && isIndented(lines[i] ?? "")) {
const cur = lines[i] ?? "";
// Nested list continuation inside an open list stays as list, not code.
if (inListContext && isMarkdownListLine(cur)) {
if (block.length > 0) out.push(stash(block.join("\n")));
block.length = 0;
out.push(cur);
i += 1;
continue;
}
block.push(cur);
i += 1;
}
if (block.length > 0) out.push(stash(block.join("\n")));
}
return out.join("\n");
};
/**
* Mask CommonMark inline code spans (`…`, `` … ` … ``, etc.).
* Content may include shorter backtick runs; close with the same length.
*/
const maskInlineCodeSpans = (
markdown: string,
stash: (chunk: string) => string,
): string => {
let out = "";
let i = 0;
while (i < markdown.length) {
if (markdown[i] !== "`") {
out += markdown[i];
i += 1;
continue;
}
let j = i;
while (j < markdown.length && markdown[j] === "`") j += 1;
const n = j - i;
// Scan for a closing run of exactly n backticks (not part of a longer run).
let k = j;
let found = -1;
while (k < markdown.length) {
// Blank line ends an inline code attempt (CommonMark).
if (markdown[k] === "\n" && markdown[k + 1] === "\n") break;
if (markdown[k] !== "`") {
k += 1;
continue;
}
let m = k;
while (m < markdown.length && markdown[m] === "`") m += 1;
const run = m - k;
if (run === n) {
found = m;
break;
}
k = m;
}
if (found < 0) {
out += markdown[i];
i += 1;
continue;
}
out += stash(markdown.slice(i, found));
i = found;
}
return out;
};
/** Mask fenced (3+ ticks), indented, and inline code so cleanup won't touch them. */
export const maskCodeRegions = (markdown: string): CodeMask => {
const slots: string[] = [];
const sentinel = chooseCodeMaskSentinel(markdown);
const stash = (chunk: string): string => {
const token = `${sentinel}${slots.length}@@`;
slots.push(chunk);
return token;
};
let body = maskFencedCodeBlocks(markdown, stash);
body = maskIndentedCodeBlocks(body, stash);
body = maskInlineCodeSpans(body, stash);
return { text: body, slots, sentinel };
};
export const unmaskCodeRegions = (
text: string,
slots: string[],
sentinel = "@@NETCATTY_MD_CODE_",
): string => {
const re = new RegExp(`${escapeRegExp(sentinel)}(\\d+)@@`, "g");
return text.replace(re, (_, idx: string) => slots[Number(idx)] ?? "");
};
const stripOrphanLinkClosersOutsideCode = (markdown: string): string => {
const { text, slots, sentinel } = maskCodeRegions(markdown);
const cleaned = text.replace(/^\s*\]\([^)\n]+\)\s*$/gm, "");
return unmaskCodeRegions(cleaned, slots, sentinel);
};
/** Apply a transform only outside code regions. */
const mapOutsideCode = (markdown: string, fn: (plain: string) => string): string => {
const { text, slots, sentinel } = maskCodeRegions(markdown);
return unmaskCodeRegions(fn(text), slots, sentinel);
};
export const normalizePastedNoteMarkdown = (markdown: string): string => {
// Badge + bare <img> cleanup only outside fenced/indented/inline code so
// samples like `[](https://…)` inside fences are not rewritten.
let body = mapOutsideCode(markdown, (plain) => {
let next = normalizeLinkedBadgeImages(plain);
next = next.replace(/<a\b[^>]*>[\s\S]*?<\/a>|<img\b[\s\S]*?>/gi, (chunk) => {
if (/^<a\b/i.test(chunk)) return chunk;
// Quote-aware img slice
const end = findHtmlTagEnd(chunk.trim(), 0);
const tag = end >= 0 ? chunk.trim().slice(0, end + 1) : chunk.trim();
return convertHtmlImgTagToMarkdownOrHtml(tag) || "";
});
return normalizeLinkedBadgeImages(next);
});
body = stripOrphanLinkClosersOutsideCode(body);
return trimBlankLines(body);
};
export const convertClipboardHtmlToMarkdown = (html: string): string => {
if (!looksLikeClipboardHtml(html)) return "";
return normalizePastedNoteMarkdown(turndownFragment(html));
};
/**
* Extract a balanced HTML element starting at `start` (must point at '<').
* Returns [fullMatch, endIndexExclusive] or null.
*/
export const extractBalancedHtmlElement = (
source: string,
start: number,
): { full: string; end: number; tag: string } | null => {
if (source[start] !== "<") return null;
const openEnd = findHtmlTagEnd(source, start);
if (openEnd < 0) return null;
const openTag = source.slice(start, openEnd + 1);
const tagMatch = /^<\/?([a-zA-Z][\w:-]*)/.exec(openTag);
if (!tagMatch) return null;
const tag = tagMatch[1].toLowerCase();
if (/\/\s*>$/.test(openTag) || /^<(?:br|hr|img|meta|link|input|source|track|wbr)\b/i.test(openTag)) {
return { full: openTag, end: openEnd + 1, tag };
}
if (openTag.startsWith("</")) return null;
// Raw-text elements: body is not HTML — do not treat `<` inside as nested tags.
if (/^(?:script|style|textarea|title|xmp)$/i.test(tag)) {
const closeRe = new RegExp(`</${tag}\\s*>`, "i");
const rest = source.slice(openEnd + 1);
const closeMatch = closeRe.exec(rest);
if (!closeMatch) return null;
const end = openEnd + 1 + closeMatch.index + closeMatch[0].length;
return { full: source.slice(start, end), end, tag };
}
let i = openEnd + 1;
let depth = 1;
while (i < source.length && depth > 0) {
const next = source.indexOf("<", i);
if (next < 0) return null;
const te = findHtmlTagEnd(source, next);
if (te < 0) return null;
const piece = source.slice(next, te + 1);
const tm = /^<\/?([a-zA-Z][\w:-]*)/.exec(piece);
if (tm && tm[1].toLowerCase() === tag) {
if (piece.startsWith("</")) depth -= 1;
else if (!/\/\s*>$/.test(piece)) depth += 1;
}
i = te + 1;
if (depth === 0) {
return { full: source.slice(start, i), end: i, tag };
}
}
return null;
};
export const convertHtmlIslandsInMarkdown = (markdown: string): string => {
let body = markdown.replace(/\r\n?/g, "\n");
if (!plainMarkdownContainsHtml(body)) {
return normalizePastedNoteMarkdown(body);
}
// Mask fenced + indented + inline code so HTML samples in code are not Turndown'd.
const { text: masked, slots, sentinel } = maskCodeRegions(body);
body = masked.replace(/<!--[\s\S]*?-->/g, "");
// Walk left-to-right converting HTML islands with balanced matching.
let out = "";
let i = 0;
while (i < body.length) {
if (body[i] !== "<") {
out += body[i];
i += 1;
continue;
}
const extracted = extractBalancedHtmlElement(body, i);
if (!extracted) {
out += body[i];
i += 1;
continue;
}
const { full, end, tag } = extracted;
const lower = tag.toLowerCase();
if (lower === "script" || lower === "style") {
i = end;
continue;
}
if (lower === "div" && htmlOpenTagIsCentered(full)) {
out += full;
i = end;
continue;
}
if (lower === "img" || lower === "br" || lower === "hr") {
if (lower === "img") {
const md = convertHtmlImgTagToMarkdownOrHtml(full);
out += md ? `\n\n${md}\n\n` : "";
} else {
const md = turndownFragment(full.endsWith("/>") ? full : full.replace(/>$/, " />"));
out += md || (lower === "br" ? " \n" : "");
}
i = end;
continue;
}
if (
(lower === "p" || /^h[1-6]$/.test(lower))
&& htmlOpenTagIsCentered(full)
) {
const md = turndownFragment(full);
out += md.trim() ? `\n\n${md.trim()}\n\n` : "";
i = end;
continue;
}
const md = turndownFragment(full);
if (md.trim()) {
if (
/^(p|div|section|article|table|ul|ol|blockquote|h[1-6]|pre|figure)$/i.test(lower)
) {
out += `\n\n${md.trim()}\n\n`;
} else {
out += md;
}
}
i = end;
}
return normalizePastedNoteMarkdown(unmaskCodeRegions(out, slots, sentinel));
};
/**
* Resolve clipboard plain + html into note markdown.
* Structured text/plain wins over presentation HTML wrappers.
*/
export const resolveNoteClipboardPaste = (input: {
plainText: string;
htmlText: string;
}): NoteClipboardPastePayload => {
const plain = (input.plainText ?? "").replace(/\r\n?/g, "\n");
const html = input.htmlText ?? "";
// 1) Structured plain Markdown is authoritative (browser often also puts
// wrapper HTML that would escape # / ** if Turndown runs first).
if (shouldInsertClipboardTextAsMarkdown(plain)) {
if (plainMarkdownContainsHtml(plain)) {
const converted = convertHtmlIslandsInMarkdown(plain);
if (converted.trim()) {
return {
text: converted,
kind: plainMarkdownContainsHtml(converted) ? "markdown" : "html-converted",
};
}
}
return { text: normalizePastedNoteMarkdown(plain), kind: "markdown" };
}
// 2) Rich HTML document (browser / Word / GitHub render clipboard)
if (looksLikeClipboardHtml(html) && isPrimarilyHtmlDocument(html)) {
const converted = convertClipboardHtmlToMarkdown(html);
if (converted.trim()) {
return { text: converted, kind: "html-converted" };
}
}
// 3) Non-primary HTML fragment when plain is unstructured
if (looksLikeClipboardHtml(html)) {
const converted = convertClipboardHtmlToMarkdown(html);
if (converted.trim()) {
return { text: converted, kind: "html-converted" };
}
}
// 4) Plain that is mostly HTML fragment
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;
};
+91
View File
@@ -0,0 +1,91 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
countTaskListItems,
isPointerOnTaskCheckbox,
toggleTaskListItemAtIndex,
} from "./taskList";
test("toggleTaskListItemAtIndex flips the Nth GFM checkbox", () => {
const src = [
"# List",
"",
"- [ ] one",
"- [x] two",
"* [ ] three",
"1. [ ] four",
].join("\n");
assert.equal(countTaskListItems(src), 4);
assert.match(toggleTaskListItemAtIndex(src, 0), /^- \[x\] one$/m);
assert.match(toggleTaskListItemAtIndex(src, 1), /^- \[ \] two$/m);
assert.match(toggleTaskListItemAtIndex(src, 2), /^\* \[x\] three$/m);
assert.match(toggleTaskListItemAtIndex(src, 3), /^1\. \[x\] four$/m);
assert.equal(toggleTaskListItemAtIndex(src, 9), src);
assert.equal(toggleTaskListItemAtIndex(src, -1), src);
});
test("toggleTaskListItemAtIndex preserves indentation and surrounding text", () => {
const src = " - [ ] nested code `apt`\n- [x] done";
const next = toggleTaskListItemAtIndex(src, 0);
assert.equal(next, " - [x] nested code `apt`\n- [x] done");
});
test("toggleTaskListItemAtIndex ignores checkboxes inside fenced code", () => {
const src = [
"- [ ] real",
"```",
"- [ ] fake",
"```",
"- [ ] second",
].join("\n");
assert.equal(countTaskListItems(src), 2);
const next = toggleTaskListItemAtIndex(src, 1);
assert.match(next, /^- \[ \] real$/m);
assert.match(next, /^- \[ \] fake$/m);
assert.match(next, /^- \[x\] second$/m);
});
test("toggleTaskListItemAtIndex handles blockquote task lines", () => {
const src = "> - [ ] quoted\n- [ ] plain";
assert.equal(countTaskListItems(src), 2);
assert.match(toggleTaskListItemAtIndex(src, 0), /^> - \[x\] quoted$/m);
});
test("toggleTaskListItemAtIndex recognizes parenthesized ordered markers", () => {
const src = "1) [ ] first\n2. [ ] second";
assert.equal(countTaskListItems(src), 2);
assert.match(toggleTaskListItemAtIndex(src, 0), /^1\) \[x\] first$/m);
});
test("toggleTaskListItemAtIndex counts nested list tasks", () => {
const src = "- parent\n - [ ] child\n- [ ] later";
assert.equal(countTaskListItems(src), 2);
assert.match(toggleTaskListItemAtIndex(src, 0), / {4}- \[x\] child/);
assert.match(toggleTaskListItemAtIndex(src, 1), /^- \[x\] later$/m);
});
test("toggleTaskListItemAtIndex ignores tasks without space after bracket", () => {
const src = "- [ ]foo\n- [ ] real";
assert.equal(countTaskListItems(src), 1);
assert.match(toggleTaskListItemAtIndex(src, 0), /^- \[x\] real$/m);
assert.match(toggleTaskListItemAtIndex(src, 0), /^- \[ \]foo$/m);
});
test("toggleTaskListItemAtIndex ignores tasks inside HTML comments", () => {
const src = "<!--\n- [ ] hidden\n-->\n- [ ] visible";
assert.equal(countTaskListItems(src), 1);
const next = toggleTaskListItemAtIndex(src, 0);
assert.match(next, /<!--\n- \[ \] hidden\n-->/);
assert.match(next, /^- \[x\] visible$/m);
});
test("isPointerOnTaskCheckbox only accepts the left hit box", () => {
const rect = { left: 100, right: 400 };
assert.equal(isPointerOnTaskCheckbox(rect, 100), true);
assert.equal(isPointerOnTaskCheckbox(rect, 120), true);
assert.equal(isPointerOnTaskCheckbox(rect, 128), true);
assert.equal(isPointerOnTaskCheckbox(rect, 129), false);
assert.equal(isPointerOnTaskCheckbox(rect, 99), false);
});
+107
View File
@@ -0,0 +1,107 @@
/**
* GFM task-list helpers for note markdown.
* Lexical CheckListPlugin only toggles when the editor is editable; preview mode
* reuses these pure transforms so checkboxes stay clickable.
*/
import { maskCodeRegions, unmaskCodeRegions } from "./clipboardPaste";
/**
* Matches "- [ ]", "* [x]", "1. [X]", "1) [ ]", and optional blockquote
* prefixes (`> - [ ]`) at line start. Code regions and HTML comments are
* masked before matching so they never steal a DOM checkbox index.
*/
// Require whitespace (or EOL) after `]` so `- [ ]foo` is not treated as a task.
const TASK_LIST_ITEM_PATTERN =
"^([ \\t]*(?:>[ \\t]*)*(?:[-*+]|\\d+[.)])[ \\t]+)\\[([ xX])\\](?=\\s|$)";
const createTaskListItemRe = (): RegExp => new RegExp(TASK_LIST_ITEM_PATTERN, "gm");
const escapeRegExp = (value: string): string => (
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
);
/** Mask HTML comments with a unique sentinel so task indices skip them. */
const maskHtmlComments = (markdown: string): {
text: string;
slots: string[];
sentinel: string;
} => {
let n = 0;
let sentinel = "@@NETCATTY_MD_COMMENT_";
while (markdown.includes(sentinel)) {
n += 1;
sentinel = `@@NETCATTY_MD_COMMENT_S${n}_`;
}
const slots: string[] = [];
const text = markdown.replace(/<!--[\s\S]*?-->/g, (chunk) => {
const token = `${sentinel}${slots.length}@@`;
slots.push(chunk);
return token;
});
return { text, slots, sentinel };
};
const unmaskHtmlComments = (
text: string,
slots: string[],
sentinel: string,
): string => {
const re = new RegExp(`${escapeRegExp(sentinel)}(\\d+)@@`, "g");
return text.replace(re, (_, idx: string) => slots[Number(idx)] ?? "");
};
/** Prepare markdown for task scanning: code regions then HTML comments. */
const prepareTaskScanText = (markdown: string): {
text: string;
restore: (body: string) => string;
} => {
const codeMask = maskCodeRegions(markdown);
const commentMask = maskHtmlComments(codeMask.text);
return {
text: commentMask.text,
restore: (body: string) => {
const withComments = unmaskHtmlComments(
body,
commentMask.slots,
commentMask.sentinel,
);
return unmaskCodeRegions(withComments, codeMask.slots, codeMask.sentinel);
},
};
};
export const countTaskListItems = (markdown: string): number => {
const { text } = prepareTaskScanText(markdown);
return text.match(createTaskListItemRe())?.length ?? 0;
};
/**
* Toggle the Nth GFM task checkbox (0-based order among rendered tasks:
* outside fenced/indented/inline code and HTML comments). Returns the original
* string when the index is out of range.
*/
export const toggleTaskListItemAtIndex = (markdown: string, index: number): string => {
if (index < 0 || !Number.isFinite(index)) return markdown;
const { text, restore } = prepareTaskScanText(markdown);
let seen = 0;
let changed = false;
const next = text.replace(createTaskListItemRe(), (full, prefix: string, mark: string) => {
if (seen++ !== index) return full;
changed = true;
const nextMark = mark === " " ? "x" : " ";
return `${prefix}[${nextMark}]`;
});
return changed ? restore(next) : markdown;
};
/** Left-edge hit box for checklist toggles (checkbox + padding), in CSS px. */
export const NOTE_TASK_CHECKBOX_HIT_PX = 28;
export const isPointerOnTaskCheckbox = (
listItemRect: Pick<DOMRect, "left" | "right">,
clientX: number,
hitPx: number = NOTE_TASK_CHECKBOX_HIT_PX,
): boolean => clientX >= listItemRect.left && clientX <= listItemRect.left + hitPx;
+7
View File
@@ -517,6 +517,13 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
descriptionKey: "settings.shortcuts.shellOnlyTabNumberShortcuts.desc",
sectionKey: "settings.shortcuts.section.scheme",
},
{
id: "shortcuts-show-tab-number-badges",
tab: "shortcuts",
labelKey: "settings.shortcuts.showTabNumberBadges.label",
descriptionKey: "settings.shortcuts.showTabNumberBadges.desc",
sectionKey: "settings.shortcuts.section.scheme",
},
{
id: "shortcuts-section-custom",
tab: "shortcuts",
+2
View File
@@ -337,6 +337,8 @@ export interface SyncPayload {
showSftpTab?: boolean;
// Shortcuts: Cmd/Ctrl+[1...9] and Ctrl+Tab skip pinned Vault/SFTP tabs
shellOnlyTabNumberShortcuts?: boolean;
// Shortcuts: show 1...9 badges on tabs matching number switch shortcuts
showTabNumberBadges?: boolean;
// Shortcuts: disable terminal font zoom shortcuts
disableTerminalFontZoom?: boolean;
// Terminal/editor tabs: show left host list sidebar
+53
View File
@@ -13,6 +13,59 @@ test("aggregateMountedDiskUsage totals every mounted disk", () => {
);
});
test("aggregateMountedDiskUsage skips rclone/CloudDrive/ufs network FUSE capacities", () => {
assert.deepEqual(
aggregateMountedDiskUsage([
{ capacityKey: "/dev/sda1", mountPoint: "/", used: 20, total: 100 },
{ capacityKey: "/dev/sdb1", mountPoint: "/data", used: 10, total: 50 },
{ capacityKey: "fuse.rclone", mountPoint: "/mnt/rclone", used: 500, total: 1000 },
{ capacityKey: "rclone:gdrive:media", mountPoint: "/mnt/gdrive", used: 1000, total: 2000 },
{ capacityKey: "CloudDrive", mountPoint: "/CloudNAS/CloudDrive", used: 2000, total: 4000 },
{ capacityKey: "ufs", mountPoint: "/mnt/ufs", used: 500, total: 1000 },
{ capacityKey: "gdrive:media", filesystemType: "fuse.rclone", mountPoint: "/mnt/remote", used: 800, total: 1600 },
]),
{ used: 30, total: 150, percent: 20 },
);
});
test("aggregateMountedDiskUsage trusts a reported local filesystem type over its source name", () => {
assert.deepEqual(
aggregateMountedDiskUsage([
{ capacityKey: "ufs", filesystemType: "ext4", mountPoint: "/data", used: 5, total: 10 },
{ capacityKey: "remote:gdrive", filesystemType: "fuse.rclone", mountPoint: "/mnt/gdrive", used: 50, total: 100 },
]),
{ used: 5, total: 10, percent: 50 },
);
});
test("aggregateMountedDiskUsage keeps a local UFS filesystem when its type is reported", () => {
assert.deepEqual(
aggregateMountedDiskUsage([
{ capacityKey: "ufs", filesystemType: "ufs", mountPoint: "/data", used: 5, total: 10 },
{ capacityKey: "remote:gdrive", filesystemType: "fuse.rclone", mountPoint: "/mnt/gdrive", used: 50, total: 100 },
]),
{ used: 5, total: 10, percent: 50 },
);
});
test("aggregateMountedDiskUsage keeps a local fuseblk filesystem", () => {
assert.deepEqual(
aggregateMountedDiskUsage([
{ capacityKey: "/dev/sdb1", filesystemType: "fuseblk", mountPoint: "/media/ntfs", used: 5, total: 10 },
]),
{ used: 5, total: 10, percent: 50 },
);
});
test("aggregateMountedDiskUsage uses source heuristics when filesystem type is unavailable", () => {
assert.equal(
aggregateMountedDiskUsage([
{ capacityKey: "rclone:gdrive", filesystemType: "-", mountPoint: "/mnt/gdrive", used: 50, total: 100 },
]),
null,
);
});
test("aggregateMountedDiskUsage ignores unusable disk rows", () => {
assert.deepEqual(
aggregateMountedDiskUsage([
+42
View File
@@ -1,5 +1,6 @@
export interface MountedDiskUsage {
capacityKey?: string;
filesystemType?: string;
mountPoint: string;
used: number;
total: number;
@@ -11,6 +12,42 @@ export interface AggregatedDiskUsage {
percent: number;
}
/** Network/FUSE sources that report cloud quotas, not local block capacity. */
export function isNetworkOrFuseCapacityKey(capacityKey: string | undefined): boolean {
const key = capacityKey?.trim();
if (!key) return false;
const lower = key.toLowerCase();
if (lower === "fuse" || lower.startsWith("fuse.")) return true;
if (lower === "/dev/fuse" || lower.endsWith("/fuse")) return true;
if (lower === "rclone" || lower.startsWith("rclone:")) return true;
if (lower.includes("clouddrive")) return true;
if (
lower === "sshfs"
|| lower === "s3fs"
|| lower === "gcsfuse"
|| lower === "mergerfs"
|| lower === "unionfs"
|| lower === "unionfs-fuse"
|| lower === "ufs"
) {
return true;
}
return false;
}
export function isNetworkOrFuseFilesystemType(filesystemType: string | undefined): boolean {
const type = filesystemType?.trim().toLowerCase();
if (!type) return false;
if (type.includes("clouddrive")) return true;
if (/^fuse\.(rclone|sshfs|s3fs|gcsfuse|ufs)$/.test(type)) return true;
return [
"rclone",
"sshfs",
"s3fs",
"gcsfuse",
].includes(type);
}
export function aggregateMountedDiskUsage(
disks: readonly MountedDiskUsage[],
): AggregatedDiskUsage | null {
@@ -19,6 +56,11 @@ export function aggregateMountedDiskUsage(
for (const disk of disks) {
if (!Number.isFinite(disk.used) || !Number.isFinite(disk.total)) continue;
if (disk.used < 0 || disk.total <= 0) continue;
const filesystemType = disk.filesystemType?.trim();
const hasFilesystemType = filesystemType && filesystemType !== "-";
if (hasFilesystemType
? isNetworkOrFuseFilesystemType(filesystemType)
: isNetworkOrFuseCapacityKey(disk.capacityKey)) continue;
const identity = disk.capacityKey?.trim() || `mount:${disk.mountPoint}`;
const existing = capacityGroups.get(identity);
capacityGroups.set(identity, {
+7 -1
View File
@@ -18,11 +18,17 @@ test('seedWorkspaceAIActiveSessionFromMembers writes focused terminal chat onto
preferredTerminalId: 'term-a',
});
assert.deepEqual(next, {
assert.ok(next);
assert.deepEqual(next.activeSessionIdMap, {
'terminal:term-a': 'chat-a',
'terminal:term-b': 'chat-b',
'workspace:ws-1': 'chat-a',
});
assert.equal(next.panelViewChanged, true);
assert.deepEqual(next.panelViewByScope['workspace:ws-1'], {
mode: 'session',
sessionId: 'chat-a',
});
});
test('seedWorkspaceAIActiveSessionFromMembers leaves an existing workspace active alone', () => {
+31 -3
View File
@@ -18,16 +18,26 @@ export type AIPanelViewHandoffLike = {
sessionId?: string;
};
export type SeedWorkspaceAIActiveSessionResult = {
activeSessionIdMap: Record<string, string | null>;
panelViewByScope: Record<string, AIPanelViewHandoffLike>;
panelViewChanged: boolean;
};
/**
* Seed a brand-new workspace scope from member terminal maps (focused first)
* so the first paint does not wait on a visible-panel write-back.
*
* Also materializes an explicit session panel view so follow-up typing does not
* fall through to draft-only resolution and create a new chat on send.
*/
export function seedWorkspaceAIActiveSessionFromMembers(input: {
activeSessionIdMap: AIActiveSessionIdMap;
panelViewByScope?: Readonly<Record<string, AIPanelViewHandoffLike | undefined>>;
workspaceId: string;
memberTerminalIds: readonly string[];
preferredTerminalId?: string | null;
}): Record<string, string | null> | null {
}): SeedWorkspaceAIActiveSessionResult | null {
const workspaceKey = buildAIScopeKey('workspace', input.workspaceId);
const existing = input.activeSessionIdMap[workspaceKey];
if (typeof existing === 'string' && existing.length > 0) {
@@ -43,9 +53,27 @@ export function seedWorkspaceAIActiveSessionFromMembers(input: {
});
if (!inherited) return null;
const previousPanelViewByScope = (
input.panelViewByScope as Record<string, AIPanelViewHandoffLike> | undefined
) ?? {};
const existingPanelView = previousPanelViewByScope[workspaceKey];
const needsPanelView = !(
existingPanelView?.mode === 'session'
&& existingPanelView.sessionId === inherited
);
return {
...(input.activeSessionIdMap as Record<string, string | null>),
[workspaceKey]: inherited,
activeSessionIdMap: {
...(input.activeSessionIdMap as Record<string, string | null>),
[workspaceKey]: inherited,
},
panelViewByScope: needsPanelView
? {
...previousPanelViewByScope,
[workspaceKey]: { mode: 'session', sessionId: inherited },
}
: previousPanelViewByScope,
panelViewChanged: needsPanelView,
};
}
+54
View File
@@ -3,6 +3,7 @@ import test from 'node:test';
import {
moveSidePanelTabMap,
moveSidePanelTabSet,
remapMountedSidePanelTabIds,
remapSidePanelTabMap,
} from './workspaceSidePanelTabRemap.ts';
@@ -145,6 +146,59 @@ test('moveSidePanelTabMap demote relocates the workspace mount onto the survivor
);
});
test('moveSidePanelTabSet promotes and demotes the closed-pane marker with ownership', () => {
const promoted = moveSidePanelTabSet(new Set(['term-b']), {
kind: 'promote',
fromTabIds: ['term-a', 'term-b'],
toTabId: 'ws-1',
preferredFromTabId: 'term-a',
});
assert.deepEqual([...promoted], ['ws-1']);
const demoted = moveSidePanelTabSet(new Set(['ws-1']), {
kind: 'demote',
fromTabId: 'ws-1',
toTabIds: ['term-a', 'term-b'],
preferredToTabId: 'term-a',
});
assert.deepEqual([...demoted], ['term-a']);
});
test('moveSidePanelTabSet does not bind a marker to a different promoted SFTP owner', () => {
const marker = moveSidePanelTabSet(new Set(['term-b']), {
kind: 'promote',
fromTabIds: ['term-a', 'term-b'],
toTabId: 'ws-1',
preferredFromTabId: 'term-a',
}, { ownerTabIds: new Set(['term-a', 'term-b']) });
assert.deepEqual([...marker], ['term-b']);
const preferredMarker = moveSidePanelTabSet(new Set(['term-a']), {
kind: 'promote',
fromTabIds: ['term-a', 'term-b'],
toTabId: 'ws-1',
preferredFromTabId: 'term-a',
}, { ownerTabIds: new Set(['term-a', 'term-b']) });
assert.deepEqual([...preferredMarker], ['ws-1']);
const nonOwnerDemote = moveSidePanelTabSet(new Set(['term-b']), {
kind: 'demote',
fromTabId: 'ws-1',
toTabIds: ['term-a', 'term-b'],
preferredToTabId: 'term-a',
}, { ownerTabIds: new Set(['term-a']) });
assert.deepEqual([...nonOwnerDemote], ['term-b']);
const absentMarkerDemote = moveSidePanelTabSet(new Set(), {
kind: 'demote',
fromTabId: 'ws-1',
toTabIds: ['term-a', 'term-b'],
preferredToTabId: 'term-a',
}, { ownerTabIds: new Set(['ws-1']) });
assert.deepEqual([...absentMarkerDemote], []);
});
test('mounted tab ids gain the destination tab when the source was mounted', () => {
assert.deepEqual(
remapMountedSidePanelTabIds(['term-a'], {
+38
View File
@@ -134,6 +134,44 @@ export function moveSidePanelTabMap<T>(
return next;
}
/**
* Move a tab-owned marker across the same workspace/session remaps as the
* ownership maps. Markers are not cloned: after a promote or demote, the old
* tab id must not keep an obsolete lifecycle decision alive.
*/
export function moveSidePanelTabSet(
source: ReadonlySet<string>,
remap: SidePanelTabRemap,
options?: { ownerTabIds?: ReadonlySet<string> },
): Set<string> {
const next = new Set(source);
const ownerTabIds = options?.ownerTabIds ?? source;
if (remap.kind === 'promote') {
if (ownerTabIds.has(remap.toTabId)) {
for (const tabId of remap.fromTabIds) next.delete(tabId);
return next;
}
const fromId = pickPreferredSourceId(
remap.fromTabIds,
remap.preferredFromTabId,
(tabId) => ownerTabIds.has(tabId),
);
if (fromId && source.has(fromId)) next.add(remap.toTabId);
if (fromId) next.delete(fromId);
return next;
}
if (!ownerTabIds.has(remap.fromTabId)) return next;
const preferredTo = remap.preferredToTabId
&& remap.toTabIds.includes(remap.preferredToTabId)
? remap.preferredToTabId
: remap.toTabIds.find(Boolean);
if (source.has(remap.fromTabId) && preferredTo) next.add(preferredTo);
next.delete(remap.fromTabId);
return next;
}
export function remapMountedSidePanelTabIds(
mountedTabIds: readonly string[],
remap: SidePanelTabRemap,
+5
View File
@@ -186,6 +186,11 @@ module.exports = {
'skills/**/*'
],
mac: {
// app-builder's PNG-to-ICNS conversion can corrupt the 16px/32px 1x
// representations even though the source PNG is valid RGBA. Use the
// iconutil-generated bundle instead so Finder and app switchers do not
// render those representations as colored noise.
icon: 'build/icon.icns',
target: [
{
target: 'dmg',
+88 -18
View File
@@ -5,9 +5,10 @@
* install-on-quit. Designed around a "prompt" model: the renderer asks to
* check, then explicitly triggers download and install.
*
* Platforms where auto-update is NOT supported (Linux deb/rpm/snap) get a
* graceful { available: false, error } response so the renderer can fall back
* to a manual "open GitHub releases" link.
* Linux packages use electron-updater's package-manager path when the
* electron-builder package-type marker is present. Unmarked Linux builds
* (including snap and development runs) get a graceful fallback so the
* renderer can offer a manual "open GitHub releases" link.
*/
let _deps = null;
@@ -46,20 +47,36 @@ function writeAutoUpdatePreference(enabled) {
}
}
const SUPPORTED_LINUX_PACKAGE_TYPES = new Set(["deb", "rpm", "pacman"]);
/**
* Identify the Linux package format written by electron-builder into the
* packaged resources directory. AppImage does not have this marker; its
* runtime exposes the APPIMAGE environment variable instead.
*/
function getLinuxPackageType() {
if (process.env.APPIMAGE) return "AppImage";
if (typeof process.resourcesPath !== "string") return null;
try {
const fs = require("fs");
const path = require("path");
const packageType = fs.readFileSync(path.join(process.resourcesPath, "package-type"), "utf8").trim();
return SUPPORTED_LINUX_PACKAGE_TYPES.has(packageType) ? packageType : null;
} catch {
return null;
}
}
/**
* Returns true when the current packaging format supports electron-updater
* (macOS zip/dmg, Windows NSIS, Linux AppImage).
* (macOS zip/dmg, Windows NSIS, Linux AppImage/deb/rpm/pacman).
*/
function isAutoUpdateSupported() {
if (process.platform === "darwin" || process.platform === "win32") {
return true;
}
// Linux: only AppImage supports in-place update.
// The APPIMAGE env variable is set by the AppImage runtime.
if (process.platform === "linux" && process.env.APPIMAGE) {
return true;
}
return false;
return process.platform === "linux" && getLinuxPackageType() !== null;
}
/** Lazily resolved autoUpdater — avoids importing electron-updater in
@@ -72,6 +89,9 @@ let _listenersRegistered = false;
/** Track whether a download is in progress to distinguish download errors from check errors */
let _isDownloading = false;
/** Track whether quitAndInstall has entered the package installation phase */
let _isInstalling = false;
/** Track whether a checkForUpdates call is in flight (set before call, cleared on result event) */
let _isChecking = false;
@@ -151,13 +171,21 @@ function setupGlobalListeners() {
_isChecking = false;
// Only broadcast download-phase errors; check-phase errors (e.g. network failures
// during checkForUpdates) are not download failures and must not set autoDownloadStatus.
if (!_isDownloading) {
// Install errors are also broadcast: Linux package managers report a cancelled
// elevation prompt or a failed command after update-downloaded has already
// cleared _isDownloading.
if (!_isDownloading && !_isInstalling) {
_lastStatus = { ..._lastStatus, isChecking: false };
console.warn("[AutoUpdate] Check-phase error (not broadcast to renderer):", err?.message || err);
return;
}
_isDownloading = false;
const errorMsg = err?.message || "Unknown update error";
if (_isInstalling) {
_isInstalling = false;
cancelQuittingForUpdateWatchdog();
setQuittingForUpdate(false);
}
_lastStatus = { ..._lastStatus, status: 'error', error: errorMsg };
broadcastToAllWindows("netcatty:update:error", {
error: errorMsg,
@@ -169,7 +197,8 @@ function setupGlobalListeners() {
/**
* Trigger an automatic update check after a delay.
* No-op on platforms that don't support auto-update (Linux deb/rpm/snap).
* No-op on platforms that don't support auto-update (for example Linux snap
* or an unmarked development build).
* Called from main process after the main window is created.
*
* @param {number} delayMs - Milliseconds to wait before checking (default: 5000)
@@ -317,12 +346,20 @@ function queryDirtyEditorsSafe(webContents, ipcMain) {
let _quittingForUpdateWatchdog = null;
const QUITTING_FOR_UPDATE_WATCHDOG_MS = 60000;
function cancelQuittingForUpdateWatchdog() {
if (_quittingForUpdateWatchdog) {
clearTimeout(_quittingForUpdateWatchdog);
_quittingForUpdateWatchdog = null;
}
}
function scheduleQuittingForUpdateWatchdog() {
if (_quittingForUpdateWatchdog) {
clearTimeout(_quittingForUpdateWatchdog);
}
_quittingForUpdateWatchdog = setTimeout(() => {
_quittingForUpdateWatchdog = null;
_isInstalling = false;
// Still alive after the grace period — the install did not quit the app.
console.warn("[AutoUpdate] App still running after quitAndInstall; clearing quitting-for-update state");
setQuittingForUpdate(false);
@@ -334,6 +371,18 @@ function scheduleQuittingForUpdateWatchdog() {
}
}
/**
* Cancel an install after the main quit guard finds unsaved editor changes.
* The main process owns the dirty-editor decision, but the install lifecycle
* state lives here and must be released together with the window-manager flag.
*/
function cancelPendingInstall() {
if (!_isInstalling) return;
_isInstalling = false;
cancelQuittingForUpdateWatchdog();
setQuittingForUpdate(false);
}
function init(deps) {
_deps = deps;
setupGlobalListeners();
@@ -478,6 +527,7 @@ function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:update:install", async () => {
const updater = getAutoUpdater();
if (!updater) return;
if (_isInstalling) return;
// Check for unsaved editors BEFORE committing to a quit (#1215 review).
//
@@ -507,6 +557,10 @@ function registerHandlers(ipcMain) {
}
}
// Another request may have completed its dirty-editor check while this
// request was waiting. Only one request may commit the app to a quit.
if (_isInstalling) return;
// Commit the app to a real quit BEFORE quitAndInstall fires app.quit().
// Without this the in-place install silently fails (#1215): the main-window
// close handler hides to tray when close-to-tray is on, so the process
@@ -514,17 +568,20 @@ function registerHandlers(ipcMain) {
// PID to die before swapping the bundle — ends up in launchd "pending
// spawn" limbo and never installs. setQuittingForUpdate(true) sets
// isQuitting so close-to-tray is bypassed and the window actually closes.
_isInstalling = true;
setQuittingForUpdate(true);
// On macOS, the system tray keeps the app process alive even after all
// windows are closed, which prevents quitAndInstall from completing.
// Destroy the tray (and its panel window) before quitting so the app
// can exit cleanly and the installer can proceed.
try {
const globalShortcutBridge = require("./globalShortcutBridge.cjs");
globalShortcutBridge.cleanup();
} catch {
// ignore — bridge may not be available
if (process.platform === "darwin") {
try {
const globalShortcutBridge = require("./globalShortcutBridge.cjs");
globalShortcutBridge.cleanup();
} catch {
// ignore — bridge may not be available
}
}
try {
@@ -535,10 +592,17 @@ function registerHandlers(ipcMain) {
// instead of permanently bypassing close-to-tray + the dirty-editor
// guard (#1215 review).
console.error("[AutoUpdate] quitAndInstall failed:", err?.message || err);
_isInstalling = false;
cancelQuittingForUpdateWatchdog();
setQuittingForUpdate(false);
return;
}
// Linux package-manager failures emit the updater error synchronously from
// quitAndInstall(). The error listener clears _isInstalling in that case,
// so do not install a watchdog after the failure has already been handled.
if (!_isInstalling) return;
// quitAndInstall can also fail to quit asynchronously (e.g. Squirrel.Mac's
// follow-up check errors, or a stale/missing downloaded file) — it returns
// without app.quit() ever firing. A watchdog clears the flags if we're
@@ -578,4 +642,10 @@ function registerHandlers(ipcMain) {
console.log("[AutoUpdate] Handlers registered");
}
module.exports = { init, registerHandlers, isAutoUpdateSupported, startAutoCheck };
module.exports = {
init,
registerHandlers,
isAutoUpdateSupported,
startAutoCheck,
cancelPendingInstall,
};
+257 -3
View File
@@ -1,7 +1,9 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const Module = require("node:module");
const tempDirBridge = require("./tempDirBridge.cjs");
const BRIDGE_PATH = require.resolve("./autoUpdateBridge.cjs");
const WINDOW_MANAGER_PATH = require.resolve("./windowManager.cjs");
@@ -213,6 +215,44 @@ function makeWindowManagerWithMainWindows(count, options = {}) {
};
}
async function withLinuxPackageEnvironment({ packageType, appImage }, fn) {
const packageDir = fs.mkdtempSync(path.join(tempDirBridge.getTempDir(), "auto-update-test-"));
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
const resourcesPathDescriptor = Object.getOwnPropertyDescriptor(process, "resourcesPath");
const previousAppImage = process.env.APPIMAGE;
try {
if (packageType) {
fs.writeFileSync(path.join(packageDir, "package-type"), `${packageType}\n`, "utf8");
}
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
Object.defineProperty(process, "resourcesPath", { value: packageDir, configurable: true });
if (appImage) {
process.env.APPIMAGE = path.join(packageDir, "Netcatty.AppImage");
} else {
delete process.env.APPIMAGE;
}
return await fn();
} finally {
if (platformDescriptor) {
Object.defineProperty(process, "platform", platformDescriptor);
} else {
delete process.platform;
}
if (resourcesPathDescriptor) {
Object.defineProperty(process, "resourcesPath", resourcesPathDescriptor);
} else {
delete process.resourcesPath;
}
if (previousAppImage === undefined) {
delete process.env.APPIMAGE;
} else {
process.env.APPIMAGE = previousAppImage;
}
fs.rmSync(packageDir, { recursive: true, force: true });
}
}
/**
* Minimal ipcMain stand-in that captures the handlers the bridge registers so a
* test can invoke a single channel directly.
@@ -235,6 +275,66 @@ function makeIpcMain() {
};
}
test("recognizes packaged Linux FPM formats as auto-update capable", async () => {
for (const packageType of ["deb", "rpm", "pacman"]) {
await withLinuxPackageEnvironment({ packageType }, async () => {
await withMocks({}, async ({ bridge }) => {
assert.equal(bridge.isAutoUpdateSupported(), true, packageType);
});
});
}
});
test("allows the update check to reach electron-updater for a packaged Linux deb", async () => {
let checkCalls = 0;
const autoUpdater = {
autoDownload: true,
autoInstallOnAppQuit: false,
logger: undefined,
on() {},
checkForUpdates() {
checkCalls += 1;
return Promise.resolve({
updateInfo: {
version: "1.1.18",
releaseNotes: "",
releaseDate: null,
},
});
},
};
await withLinuxPackageEnvironment({ packageType: "deb" }, async () => {
await withMocks({ autoUpdater }, async ({ bridge, fakeAutoUpdater }) => {
fakeAutoUpdater.autoDownload = false;
const ipcMain = makeIpcMain();
bridge.registerHandlers(ipcMain);
const result = await ipcMain.invoke("netcatty:update:check");
assert.equal(checkCalls, 1);
assert.equal(result.supported, true);
assert.equal(result.available, true);
assert.equal(result.version, "1.1.18");
});
});
});
test("keeps the manual-update fallback for an unmarked Linux package", async () => {
await withLinuxPackageEnvironment({}, async () => {
await withMocks({}, async ({ bridge }) => {
assert.equal(bridge.isAutoUpdateSupported(), false);
});
});
});
test("keeps AppImage auto-update support on Linux", async () => {
await withLinuxPackageEnvironment({ appImage: true }, async () => {
await withMocks({}, async ({ bridge }) => {
assert.equal(bridge.isAutoUpdateSupported(), true);
});
});
});
test("install handler marks quitting-for-update before quitAndInstall", async () => {
const order = [];
const autoUpdater = {
@@ -269,8 +369,9 @@ test("install handler marks quitting-for-update before quitAndInstall", async ()
// close-to-tray / before-quit guards would already be racing the quit (#1215).
assert.equal(order[0], "setQuittingForUpdate");
assert.ok(order.indexOf("setQuittingForUpdate") < order.indexOf("quitAndInstall"));
// Tray cleanup still runs so the tray doesn't keep the process alive.
assert.equal(fakeGlobalShortcut.cleanupCount, 1);
// Only macOS needs the tray destroyed before quitAndInstall; other
// platforms clean it up from the normal will-quit handler.
assert.equal(fakeGlobalShortcut.cleanupCount, process.platform === "darwin" ? 1 : 0);
assert.equal(order.includes("quitAndInstall"), true);
});
});
@@ -331,6 +432,159 @@ test("install handler rolls back quitting-for-update when quitAndInstall throws"
});
});
test("install handler reports package-manager failures and restores the app state", async () => {
const listeners = new Map();
const autoUpdater = {
autoDownload: true,
autoInstallOnAppQuit: false,
logger: undefined,
on(event, listener) {
listeners.set(event, listener);
},
quitAndInstall() {
listeners.get("error")(new Error("authorization cancelled"));
},
};
const fakeWindowManager = {
calls: [],
setQuittingForUpdate(value) {
this.calls.push(value);
},
isQuittingForUpdate() {
return this.calls[this.calls.length - 1] === true;
},
};
const broadcastWindow = makeBroadcastWindow();
await withLinuxPackageEnvironment({ packageType: "deb" }, async () => {
await withMocks({
autoUpdater,
windowManager: fakeWindowManager,
browserWindows: [broadcastWindow],
}, async ({ bridge, fakeGlobalShortcut }) => {
const ipcMain = makeIpcMain();
bridge.registerHandlers(ipcMain);
listeners.get("update-available")({ version: "1.1.18" });
listeners.get("update-downloaded")();
assert.equal((await ipcMain.invoke("netcatty:update:getStatus")).status, "ready");
await ipcMain.invoke("netcatty:update:install");
assert.deepEqual(fakeWindowManager.calls, [true, false]);
assert.equal(fakeWindowManager.isQuittingForUpdate(), false);
assert.equal(fakeGlobalShortcut.cleanupCount, 0);
assert.deepEqual(await ipcMain.invoke("netcatty:update:getStatus"), {
status: "error",
percent: 100,
error: "authorization cancelled",
version: "1.1.18",
isChecking: false,
});
assert.equal(broadcastWindow.sentChannels.includes("netcatty:update:error"), true);
});
});
});
test("install handler ignores concurrent install requests", async () => {
let installCalls = 0;
const autoUpdater = {
autoDownload: true,
autoInstallOnAppQuit: false,
logger: undefined,
on() {},
quitAndInstall() {
installCalls += 1;
},
};
const fakeWindowManager = makeWindowManagerWithMainWindow();
const dirtyResolvers = [];
const fakeDirtyEditorGuard = {
queryDirtyEditors() {
return new Promise((resolve) => dirtyResolvers.push(resolve));
},
};
const originalSetTimeout = global.setTimeout;
let watchdogFn = null;
global.setTimeout = (fn) => {
watchdogFn = fn;
return { unref() {} };
};
try {
await withMocks({
autoUpdater,
windowManager: fakeWindowManager,
dirtyEditorGuard: fakeDirtyEditorGuard,
}, async ({ bridge }) => {
const ipcMain = makeIpcMain();
bridge.registerHandlers(ipcMain);
const firstInstall = ipcMain.invoke("netcatty:update:install");
const secondInstall = ipcMain.invoke("netcatty:update:install");
assert.equal(dirtyResolvers.length, 2);
dirtyResolvers.forEach((resolve) => resolve(false));
await Promise.all([firstInstall, secondInstall]);
assert.equal(installCalls, 1);
assert.deepEqual(fakeWindowManager.calls, [true]);
watchdogFn?.();
assert.equal(fakeWindowManager.isQuittingForUpdate(), false);
});
} finally {
global.setTimeout = originalSetTimeout;
}
});
test("cancelPendingInstall releases the install guard for an immediate retry", async () => {
let installCalls = 0;
const autoUpdater = {
autoDownload: true,
autoInstallOnAppQuit: false,
logger: undefined,
on() {},
quitAndInstall() {
installCalls += 1;
},
};
const fakeWindowManager = {
calls: [],
setQuittingForUpdate(value) {
this.calls.push(value);
},
isQuittingForUpdate() {
return this.calls[this.calls.length - 1] === true;
},
};
const originalSetTimeout = global.setTimeout;
const originalClearTimeout = global.clearTimeout;
global.setTimeout = () => ({ unref() {} });
global.clearTimeout = () => {};
try {
await withMocks({ autoUpdater, windowManager: fakeWindowManager }, async ({ bridge }) => {
const ipcMain = makeIpcMain();
bridge.registerHandlers(ipcMain);
await ipcMain.invoke("netcatty:update:install");
assert.equal(installCalls, 1);
assert.deepEqual(fakeWindowManager.calls, [true]);
// Simulate main.cjs cancelling the quit after a dirty-editor result.
bridge.cancelPendingInstall();
assert.deepEqual(fakeWindowManager.calls, [true, false]);
await ipcMain.invoke("netcatty:update:install");
assert.equal(installCalls, 2);
assert.deepEqual(fakeWindowManager.calls, [true, false, true]);
});
} finally {
global.setTimeout = originalSetTimeout;
global.clearTimeout = originalClearTimeout;
}
});
test("install handler watchdog clears quitting-for-update if the app never quits", async () => {
const autoUpdater = {
autoDownload: true,
@@ -571,7 +825,7 @@ test("install handler proceeds to quitAndInstall when there are no dirty editors
assert.deepEqual(fakeWindowManager.calls, [true]);
assert.ok(order.indexOf("setQuittingForUpdate") < order.indexOf("quitAndInstall"));
assert.equal(order.includes("quitAndInstall"), true);
assert.equal(fakeGlobalShortcut.cleanupCount, 1);
assert.equal(fakeGlobalShortcut.cleanupCount, process.platform === "darwin" ? 1 : 0);
// No needs-save broadcast when nothing is dirty.
assert.equal(win.sentChannels.includes("netcatty:update:needs-save"), false);
},
+37 -11
View File
@@ -284,24 +284,29 @@ function destroyTunnelPipeEndpoint(endpoint) {
try { endpoint.end?.(); } catch { /* ignore */ }
}
function destroyTunnelPipeEntry(tunnelState, entry) {
if (!entry || entry.closed) return;
function destroyTunnelPipeEntry(tunnelState, entry, { abortOpen = true, remove = true } = {}) {
if (!entry) return;
entry.closed = true;
const openAbortController = entry.openAbortController;
entry.openAbortController = null;
if (openAbortController && !openAbortController.signal.aborted) {
const shouldDestroyEndpoints = !entry.endpointsDestroyed;
entry.endpointsDestroyed = true;
if (abortOpen && openAbortController && !openAbortController.signal.aborted) {
try { openAbortController.abort(new Error("Port forward client closed during SSH channel open")); } catch { /* ignore */ }
}
try { tunnelState?.activePipes?.delete(entry); } catch { /* ignore */ }
destroyTunnelPipeEndpoint(entry.socket);
destroyTunnelPipeEndpoint(entry.stream);
if (remove) {
entry.openAbortController = null;
try { tunnelState?.activePipes?.delete(entry); } catch { /* ignore */ }
}
if (shouldDestroyEndpoints) {
destroyTunnelPipeEndpoint(entry.socket);
destroyTunnelPipeEndpoint(entry.stream);
}
}
function attachTunnelPipeStream(tunnelState, entry, stream) {
if (!entry || entry.closed || isTunnelCancelled(tunnelState)) {
destroyTunnelPipeEndpoint(entry?.socket);
if (entry) destroyTunnelPipeEntry(tunnelState, entry, { abortOpen: false });
destroyTunnelPipeEndpoint(stream);
if (entry) destroyTunnelPipeEntry(tunnelState, entry);
return false;
}
entry.openAbortController = null;
@@ -319,10 +324,24 @@ function trackTunnelPipe(tunnelState, socket, stream = null) {
socket,
stream: null,
closed: false,
openStarted: false,
endpointsDestroyed: false,
openAbortController: stream ? null : new AbortController(),
};
tunnelState.activePipes.add(entry);
const drop = () => destroyTunnelPipeEntry(tunnelState, entry);
// A client disconnect only abandons this channel open. The bounded open
// helper invalidates the physical SSH connection when its signal aborts;
// doing that here would tear down a shared local-forward listener. Tunnel-
// wide cleanup still aborts pending opens through destroyTunnelPipes(). Keep
// a pending entry tracked until its late channel callback is closed, so a
// later tunnel stop can still cancel the underlying request.
const drop = () => {
const pendingOpen = Boolean(entry.openStarted && entry.openAbortController);
destroyTunnelPipeEntry(tunnelState, entry, {
abortOpen: false,
remove: !pendingOpen,
});
};
try { socket?.once?.("close", drop); } catch { /* ignore */ }
try { socket?.once?.("error", drop); } catch { /* ignore */ }
if (stream) attachTunnelPipeStream(tunnelState, entry, stream);
@@ -579,6 +598,7 @@ function bindPortForwardChannels({
if (type === "local") {
const server = net.createServer((socket) => {
const pipeEntry = trackTunnelPipe(tunnelState, socket);
pipeEntry.openStarted = true;
openBoundedForwardOutCallback(
conn,
bindAddress,
@@ -588,7 +608,7 @@ function bindPortForwardChannels({
(err, stream) => {
if (err) {
console.error(`[PortForward] Forward error:`, err.message);
destroyTunnelPipeEntry(tunnelState, pipeEntry);
destroyTunnelPipeEntry(tunnelState, pipeEntry, { abortOpen: false });
return;
}
if (!attachTunnelPipeStream(tunnelState, pipeEntry, stream)) return;
@@ -772,6 +792,7 @@ function bindPortForwardChannels({
const pipeEntry = trackTunnelPipe(tunnelState, socket);
socket.once("data", (data) => {
if (data[0] !== 0x05) {
destroyTunnelPipeEntry(tunnelState, pipeEntry, { abortOpen: false });
socket.end();
return;
}
@@ -780,6 +801,7 @@ function bindPortForwardChannels({
if (request[0] !== 0x05 || request[1] !== 0x01) {
socket.write(Buffer.from([0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
socket.end();
destroyTunnelPipeEntry(tunnelState, pipeEntry, { abortOpen: false });
return;
}
@@ -797,13 +819,16 @@ function bindPortForwardChannels({
} else if (addressType === 0x04) {
socket.write(Buffer.from([0x05, 0x08, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
socket.end();
destroyTunnelPipeEntry(tunnelState, pipeEntry, { abortOpen: false });
return;
} else {
socket.write(Buffer.from([0x05, 0x08, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
socket.end();
destroyTunnelPipeEntry(tunnelState, pipeEntry, { abortOpen: false });
return;
}
pipeEntry.openStarted = true;
openBoundedForwardOutCallback(
conn,
bindAddress,
@@ -814,6 +839,7 @@ function bindPortForwardChannels({
if (err) {
socket.write(Buffer.from([0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
socket.end();
destroyTunnelPipeEntry(tunnelState, pipeEntry, { abortOpen: false });
return;
}
if (!attachTunnelPipeStream(tunnelState, pipeEntry, stream)) return;
@@ -1,17 +1,25 @@
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const { EventEmitter, once } = require("node:events");
const net = require("node:net");
const test = require("node:test");
const {
_bindPortForwardChannelsForTests: bindPortForwardChannels,
_attachTunnelPipeStreamForTests: attachTunnelPipeStream,
_clearPortForwardTunnelsForTests: clearPortForwardTunnels,
_destroyTunnelPipesForTests: destroyTunnelPipes,
_trackTunnelPipeForTests: trackTunnelPipe,
cancelTunnel,
} = require("./portForwardingBridge.cjs");
function fakeEndpoint() {
const endpoint = new EventEmitter();
endpoint.destroyedByTest = false;
endpoint.destroy = () => { endpoint.destroyedByTest = true; };
endpoint.destroyCalls = 0;
endpoint.destroy = () => {
endpoint.destroyCalls += 1;
endpoint.destroyedByTest = true;
};
endpoint.end = () => { endpoint.destroyedByTest = true; };
endpoint.close = () => { endpoint.destroyedByTest = true; };
return endpoint;
@@ -20,14 +28,183 @@ function fakeEndpoint() {
test("accepted sockets are tracked before forwardOut finishes and stop destroys them", () => {
const tunnel = { cancelled: false, activePipes: new Set() };
const socket = fakeEndpoint();
trackTunnelPipe(tunnel, socket);
const entry = trackTunnelPipe(tunnel, socket);
assert.equal(tunnel.activePipes.size, 1);
destroyTunnelPipes(tunnel);
assert.equal(socket.destroyedByTest, true);
assert.equal(entry.openAbortController, null);
assert.equal(tunnel.activePipes.size, 0);
});
test("client disconnect drops only its pending channel open", () => {
const tunnel = { cancelled: false, activePipes: new Set() };
const socket = fakeEndpoint();
const entry = trackTunnelPipe(tunnel, socket);
entry.openStarted = true;
let aborts = 0;
entry.openAbortController.signal.addEventListener("abort", () => { aborts += 1; });
socket.emit("close");
assert.equal(aborts, 0);
assert.equal(entry.closed, true);
assert.equal(entry.openAbortController.signal.aborted, false);
assert.equal(tunnel.activePipes.size, 1);
const destroyCalls = socket.destroyCalls;
socket.emit("error", new Error("client closed"));
assert.equal(socket.destroyCalls, destroyCalls);
destroyTunnelPipes(tunnel);
assert.equal(aborts, 1);
assert.equal(tunnel.activePipes.size, 0);
});
test("a client disconnect does not close the local forwarding listener", async () => {
let finishForwardOut;
let transportEnds = 0;
let transportDestroys = 0;
const conn = new EventEmitter();
conn.forwardOut = (_sourceAddress, _sourcePort, _targetAddress, _targetPort, callback) => {
finishForwardOut = callback;
};
conn.end = () => { transportEnds += 1; };
conn.destroy = () => { transportDestroys += 1; };
const tunnel = {
tunnelId: "pf-client-disconnect",
cancelled: false,
activePipes: new Set(),
chainConnections: [],
sshTransportManaged: false,
};
const sender = { id: 1, isDestroyed: () => false };
const started = await bindPortForwardChannels({
type: "local",
conn,
tunnelId: tunnel.tunnelId,
tunnelState: tunnel,
sender,
bindAddress: "127.0.0.1",
localPort: 0,
remoteHost: "127.0.0.1",
remotePort: 22,
chainConnections: [],
sendStatus() {},
});
assert.equal(started.success, true);
const client = net.connect(tunnel.server.address().port, "127.0.0.1");
await once(client, "connect");
client.destroy();
await once(client, "close");
assert.equal(tunnel.server.listening, true);
finishForwardOut(new Error("client closed"));
await new Promise((resolve) => setImmediate(resolve));
assert.equal(transportEnds, 0);
assert.equal(transportDestroys, 0);
await new Promise((resolve, reject) => tunnel.server.close((error) => error ? reject(error) : resolve()));
});
test("a disconnected SOCKS client remains cancellable until its channel open settles", async () => {
let finishForwardOut;
let forwardOutStarted;
const conn = new EventEmitter();
conn.forwardOut = (_sourceAddress, _sourcePort, _targetAddress, _targetPort, callback) => {
finishForwardOut = callback;
forwardOutStarted?.();
};
conn.end = () => {};
conn.destroy = () => {};
const tunnel = {
tunnelId: "pf-socks-client-disconnect",
cancelled: false,
activePipes: new Set(),
chainConnections: [],
sshTransportManaged: false,
};
const sender = { id: 2, isDestroyed: () => false };
try {
const started = await bindPortForwardChannels({
type: "dynamic",
conn,
tunnelId: tunnel.tunnelId,
tunnelState: tunnel,
sender,
bindAddress: "127.0.0.1",
localPort: 0,
remoteHost: "127.0.0.1",
remotePort: 22,
chainConnections: [],
sendStatus() {},
});
assert.equal(started.success, true);
const client = net.connect(tunnel.server.address().port, "127.0.0.1");
await once(client, "connect");
client.write(Buffer.from([0x05, 0x01, 0x00]));
await once(client, "data");
const channelStarted = new Promise((resolve) => { forwardOutStarted = resolve; });
client.write(Buffer.from([0x05, 0x01, 0x00, 0x01, 127, 0, 0, 1, 0, 22]));
await channelStarted;
client.destroy();
await once(client, "close");
assert.equal(tunnel.server.listening, true);
assert.equal(tunnel.activePipes.size, 1);
await cancelTunnel(tunnel.tunnelId, tunnel, () => {});
assert.equal(tunnel.activePipes.size, 0);
} finally {
clearPortForwardTunnels();
}
});
test("an invalid SOCKS client is removed before opening an SSH channel", async () => {
const conn = new EventEmitter();
conn.end = () => {};
conn.destroy = () => {};
const tunnel = {
tunnelId: "pf-socks-invalid-client",
cancelled: false,
activePipes: new Set(),
chainConnections: [],
sshTransportManaged: false,
};
const sender = { id: 3, isDestroyed: () => false };
try {
const started = await bindPortForwardChannels({
type: "dynamic",
conn,
tunnelId: tunnel.tunnelId,
tunnelState: tunnel,
sender,
bindAddress: "127.0.0.1",
localPort: 0,
remoteHost: "127.0.0.1",
remotePort: 22,
chainConnections: [],
sendStatus() {},
});
assert.equal(started.success, true);
const client = net.connect(tunnel.server.address().port, "127.0.0.1");
await once(client, "connect");
client.write(Buffer.from([0x04, 0x01, 0x00]));
await once(client, "close");
assert.equal(tunnel.activePipes.size, 0);
assert.equal(tunnel.server.listening, true);
} finally {
await new Promise((resolve, reject) => tunnel.server.close((error) => error ? reject(error) : resolve()));
clearPortForwardTunnels();
}
});
test("a late forwardOut stream after stop is rejected and destroyed", () => {
const tunnel = { cancelled: false, activePipes: new Set() };
const socket = fakeEndpoint();
+4 -1
View File
@@ -214,7 +214,9 @@ function createStreamEntry(sessionId, opts) {
format,
isRaw,
isHtml,
renderer: isRaw ? null : createTerminalTextRenderer(),
renderer: isRaw ? null : createTerminalTextRenderer({
alternateScreenActive: opts.alternateScreenActive === true,
}),
renderedTimestampPrefixer: !isRaw && opts.timestampsEnabled
? createRenderedLineTimestampPrefixer({ timestampProvider: opts.timestampProvider })
: null,
@@ -266,6 +268,7 @@ function startStreamToFile(sessionId, opts = {}) {
timestampsEnabled: opts.timestampsEnabled,
timestampProvider: opts.timestampProvider,
stopRequiresToken: opts.stopRequiresToken,
alternateScreenActive: opts.alternateScreenActive === true,
});
if (typeof initialLine === "string" && initialLine.length > 0) {
appendData(sessionId, initialLine);
@@ -370,6 +370,31 @@ test("startStreamToFile can write human-readable text logs from ANSI terminal ou
}
});
test("startStreamToFile seeds alternate-screen omission for late-started txt logs", async () => {
const directory = path.join(TEMP_ROOT, `manual-alt-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const filePath = path.join(directory, "manual.txt");
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
try {
const result = startStreamToFile(sessionId, {
filePath,
format: "txt",
hostLabel: "manual",
alternateScreenActive: true,
});
assert.equal(result.ok, true);
appendData(sessionId, "~\nstatus line\n\x1b[?1049l$ done\n");
const finalPath = await stopStream(sessionId);
assert.equal(finalPath, filePath);
assert.equal(fs.readFileSync(filePath, "utf8"), "$ done");
} finally {
await stopStream(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("raw stream hides sudo autofill prompt markers and rewritten command echoes", async () => {
const directory = path.join(TEMP_ROOT, `stream-sudo-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
+118 -10
View File
@@ -3,6 +3,7 @@
* Provides functionality to export terminal logs to files and manage auto-save settings
*/
const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
const { dialog } = require("electron");
@@ -14,6 +15,8 @@ const {
const FILE_NAME_UNSAFE_CHARS = new Set(["<", ">", ":", "\"", "/", "\\", "|", "?", "*"]);
const WINDOWS_RESERVED_DEVICE_NAME = /^(con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\..*)?$/i;
const manualSessionLogTokens = new Map();
/** @type {Map<string, { sessionId: string, filePath: string, format: string, senderId: number | null }>} */
const pendingManualSessionLogChoices = new Map();
const SESSION_LOG_FORMATS = new Set(["txt", "raw", "html"]);
function isControlCharacter(char) {
@@ -473,15 +476,19 @@ async function clearSessionLogsDir(event, payload = {}, terminalWorkerManager =
}
}
async function startManualSessionLog(event, payload = {}) {
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
const { sessionId, sessionName, preferredDirectory, initialLine } = payload;
/**
* Resolve a manual session-log destination via the save dialog (and optional
* overwrite confirm). Separated from stream start so the renderer can re-sample
* alternate-screen / initial-line state after the dialog closes.
*
* The selected path is stored only in the main process. Callers receive an
* opaque selectionToken for startManualSessionLog — renderer-supplied paths are
* never trusted for open/truncate.
*/
async function chooseManualSessionLogPath(event, payload = {}) {
const { sessionId, sessionName, preferredDirectory } = payload;
if (!sessionId) {
return { success: false, started: false, error: "Missing sessionId" };
}
if (sessionLogStreamManager.hasStream(sessionId)) {
return { success: false, started: false, error: "Session log is already active" };
return { success: false, canceled: false, error: "Missing sessionId" };
}
const targetDirectory = typeof preferredDirectory === "string" && preferredDirectory.trim()
@@ -503,12 +510,108 @@ async function startManualSessionLog(event, payload = {}) {
});
if (result.canceled || !result.filePath) {
return { success: true, started: false, canceled: true };
return { success: true, canceled: true };
}
const filePath = normalizeManualSessionLogFilePath(result.filePath, extension);
if (filePath !== result.filePath && !(await confirmManualSessionLogOverwrite(filePath))) {
return { success: true, started: false, canceled: true };
return { success: true, canceled: true };
}
// Drop any prior unused selection for this session so only the latest dialog
// result can be redeemed.
for (const [token, pending] of pendingManualSessionLogChoices) {
if (pending.sessionId === sessionId) pendingManualSessionLogChoices.delete(token);
}
const selectionToken = crypto.randomBytes(16).toString("hex");
pendingManualSessionLogChoices.set(selectionToken, {
sessionId,
filePath,
format,
senderId: event?.sender?.id ?? null,
});
return {
success: true,
canceled: false,
selectionToken,
// Informational for UI/tests; start must redeem selectionToken, not this path.
filePath,
format,
};
} catch (err) {
return { success: false, canceled: false, error: err?.message || String(err) };
}
}
function redeemManualSessionLogSelection(event, sessionId, selectionToken) {
if (typeof selectionToken !== "string" || !selectionToken) return null;
const pending = pendingManualSessionLogChoices.get(selectionToken);
if (!pending || pending.sessionId !== sessionId) return null;
const senderId = event?.sender?.id;
if (pending.senderId != null && senderId != null && pending.senderId !== senderId) {
return null;
}
pendingManualSessionLogChoices.delete(selectionToken);
return pending;
}
async function startManualSessionLog(event, payload = {}) {
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
const { sessionId, sessionName, preferredDirectory, initialLine } = payload;
if (!sessionId) {
return { success: false, started: false, error: "Missing sessionId" };
}
if (sessionLogStreamManager.hasStream(sessionId)) {
return { success: false, started: false, error: "Session log is already active" };
}
const displaySessionName = sessionName || sessionId;
let filePath = "";
let format = SESSION_LOG_FORMATS.has(payload.format) ? payload.format : "raw";
try {
if (typeof payload.selectionToken === "string" && payload.selectionToken) {
const pending = redeemManualSessionLogSelection(event, sessionId, payload.selectionToken);
if (!pending) {
return {
success: false,
started: false,
error: "Invalid or expired session log selection",
};
}
filePath = pending.filePath;
format = pending.format;
} else if (typeof payload.filePath === "string" && payload.filePath.trim()) {
// Do not trust renderer-supplied paths: they bypass the save dialog and
// overwrite confirmation and could truncate arbitrary writable files.
return {
success: false,
started: false,
error: "Session log path must be chosen via the save dialog",
};
} else {
// One-shot callers / tests: show the dialog and start immediately.
const chosen = await chooseManualSessionLogPath(event, {
sessionId,
sessionName,
preferredDirectory,
format,
});
if (!chosen.success) {
return { success: false, started: false, error: chosen.error || "Failed to choose session log path" };
}
if (chosen.canceled || !chosen.selectionToken) {
return { success: true, started: false, canceled: true };
}
const pending = redeemManualSessionLogSelection(event, sessionId, chosen.selectionToken);
if (!pending) {
return { success: false, started: false, error: "Invalid or expired session log selection" };
}
filePath = pending.filePath;
format = pending.format;
}
const startResult = sessionLogStreamManager.startStreamToFile(sessionId, {
@@ -520,6 +623,9 @@ async function startManualSessionLog(event, payload = {}) {
initialLine: typeof initialLine === "string" ? initialLine : "",
separateInitialLineBeforeLeadingCarriageReturn: true,
stopRequiresToken: true,
// Caller should sample this after the save dialog resolves so enter/leave
// while the dialog is open does not seed a stale alternate-screen mode.
alternateScreenActive: payload.alternateScreenActive === true,
});
if (!startResult.ok) {
@@ -620,6 +726,7 @@ function registerHandlers(ipcMain, options = {}) {
);
// Main can also answer this (manual / script streams) for symmetry / tests.
ipcMain.handle("netcatty:sessionLogs:getActivePaths", async () => getLocalActiveLogPaths());
ipcMain.handle("netcatty:sessionLog:manualChoosePath", chooseManualSessionLogPath);
ipcMain.handle("netcatty:sessionLog:manualStart", startManualSessionLog);
ipcMain.handle("netcatty:sessionLog:manualStop", stopManualSessionLog);
ipcMain.handle("netcatty:sessionLog:manualStatus", getManualSessionLogStatus);
@@ -650,6 +757,7 @@ module.exports = {
collectActiveLogPaths,
getLocalActiveLogPaths,
isSessionLogArtifactName,
chooseManualSessionLogPath,
startManualSessionLog,
stopManualSessionLog,
getManualSessionLogStatus,
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff. Show more