mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-30 18:32:39 +00:00
Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
151c15b38f | ||
|
|
544613c007 | ||
|
|
997bba6e2c | ||
|
|
13979790e8 | ||
|
|
19cce8c82b | ||
|
|
02746754f3 | ||
|
|
f940bbc280 | ||
|
|
7e60b64766 | ||
|
|
18330fd1d1 | ||
|
|
ebfdb645a7 | ||
|
|
b89607dbb0 | ||
|
|
2471013aba | ||
|
|
2343f80ed2 | ||
|
|
8d94252990 | ||
|
|
87b4089812 | ||
|
|
d7377fe301 | ||
|
|
518e4a12ac | ||
|
|
fbaa038b0d | ||
|
|
6c4e27cb38 | ||
|
|
5dc87ee178 | ||
|
|
a363ac5812 | ||
|
|
02bfea153c | ||
|
|
43534c34f1 | ||
|
|
a19bf708ac | ||
|
|
bbfa2ac6bf | ||
|
|
16f0304a5b | ||
|
|
e78d78b428 | ||
|
|
f6d846fbda | ||
|
|
b9702b043a | ||
|
|
89b3e27329 | ||
|
|
7e600d54e0 | ||
|
|
87aa2ea931 | ||
|
|
ac3993b789 | ||
|
|
4935066e53 | ||
|
|
315e596e08 | ||
|
|
5f874a3156 | ||
|
|
ae58d60f8f | ||
|
|
8856c7e5ab | ||
|
|
c47fcce17d | ||
|
|
53da304e39 | ||
|
|
2df408a571 | ||
|
|
a965a94f0d | ||
|
|
f9803f3642 | ||
|
|
ddfeb0a1a8 | ||
|
|
0d6fc5f7f7 | ||
|
|
2b381c87be | ||
|
|
ce41379796 | ||
|
|
cc8ba20839 |
No files matched your search
+3
-2
@@ -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 重复
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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...',
|
||||
|
||||
@@ -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': '請按鍵...',
|
||||
|
||||
@@ -2,7 +2,11 @@ import type { TerminalSession } from "../../domain/models";
|
||||
|
||||
export type SessionPwdProbe = (
|
||||
sessionId: string,
|
||||
options?: { allowHomeFallback?: boolean; timeoutMs?: number },
|
||||
options?: {
|
||||
allowHomeFallback?: boolean;
|
||||
allowLoginShellFallback?: boolean;
|
||||
timeoutMs?: number;
|
||||
},
|
||||
) => Promise<{ success: boolean; cwd?: string }>;
|
||||
|
||||
type CaptureSession = Pick<TerminalSession, "id" | "protocol" | "status" | "lastCwd" | "localStartDir">;
|
||||
|
||||
@@ -1,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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
]),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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" },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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"\]/);
|
||||
});
|
||||
@@ -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"/);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
"",
|
||||
);
|
||||
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" }),
|
||||
"",
|
||||
);
|
||||
assert.equal(
|
||||
serializeSafeHtmlImage({ src: "public/icon.png", alt: "logo" }),
|
||||
"",
|
||||
);
|
||||
assert.equal(
|
||||
serializeSafeHtmlImage({
|
||||
src: "//cdn.example.com/a.png",
|
||||
alt: "cdn",
|
||||
}),
|
||||
"",
|
||||
);
|
||||
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 = [
|
||||
"[](https://github.com/binaricat/Netcatty/releases/latest)",
|
||||
"",
|
||||
"[ ",
|
||||
"",
|
||||
" ](#)",
|
||||
"",
|
||||
"[",
|
||||
'<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);
|
||||
});
|
||||
@@ -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, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
);
|
||||
|
||||
/**
|
||||
* Build image markdown for notes.
|
||||
* - With width/height → sanitized HTML <img> (MDX keeps dimensions; CSS scales)
|
||||
* - Without → standard 
|
||||
*
|
||||
* 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 ``;
|
||||
}
|
||||
|
||||
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 `[](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");
|
||||
|
||||
// [](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 `[](${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";
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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" && (
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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), "");
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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" };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)。
|
||||
@@ -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",
|
||||
}),
|
||||
"",
|
||||
);
|
||||
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" }),
|
||||
"",
|
||||
);
|
||||
assert.equal(
|
||||
serializeSafeHtmlImage({ src: "public/icon.png", alt: "logo" }),
|
||||
"",
|
||||
);
|
||||
// Protocol-relative → https (covered more fully below; keep here as non-drop).
|
||||
assert.equal(
|
||||
serializeSafeHtmlImage({
|
||||
src: "//cdn.example.com/a.png",
|
||||
alt: "cdn",
|
||||
}),
|
||||
"",
|
||||
);
|
||||
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 = [
|
||||
"[](https://github.com/binaricat/Netcatty/releases/latest)",
|
||||
"",
|
||||
"[ ",
|
||||
"",
|
||||
" ](#)",
|
||||
"",
|
||||
"[",
|
||||
'<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" }),
|
||||
"",
|
||||
);
|
||||
assert.equal(
|
||||
serializeSafeHtmlImage({ src: "//cdn.example.com/a.png", alt: "cdn" }),
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
test("image attributes decode entities before re-serialize", () => {
|
||||
const md = convertHtmlImgTagToMarkdownOrHtml(
|
||||
'<img width="100" alt="A & B" src="https://example.com/a.png?x=1&y=2" />',
|
||||
);
|
||||
assert.match(md, /alt="A & B"/);
|
||||
assert.match(md, /src="https:\/\/example\.com\/a\.png\?x=1&y=2"/);
|
||||
assert.doesNotMatch(md, /&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 > 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 A end"), "ok A end");
|
||||
assert.equal(decodeHtmlEntities("bad � keep"), "bad � keep");
|
||||
assert.equal(decodeHtmlEntities("bad � keep"), "bad � keep");
|
||||
assert.doesNotThrow(() => decodeHtmlEntities("��"));
|
||||
});
|
||||
|
||||
test("serializeSafeHtmlImage angles destinations that contain spaces", () => {
|
||||
assert.equal(
|
||||
serializeSafeHtmlImage({ src: "images/company logo.png", alt: "logo" }),
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
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",
|
||||
"[](http://example.com)",
|
||||
"```",
|
||||
"",
|
||||
"[](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",
|
||||
"",
|
||||
"```",
|
||||
"",
|
||||
"",
|
||||
].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",
|
||||
"",
|
||||
"- [ ] 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", "", "```", "", "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 `  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(
|
||||
"[](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",
|
||||
"> > ",
|
||||
"> > ~~~",
|
||||
"",
|
||||
"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");
|
||||
});
|
||||
@@ -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 [](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(/ /gi, " ")
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/gi, "'")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/gi, ">")
|
||||
.replace(/&#(\d+);/g, (_, n: string) => codePointFromEntity(Number(n), _))
|
||||
.replace(/&#x([0-9a-f]+);/gi, (_, hex: string) => (
|
||||
codePointFromEntity(Number.parseInt(hex, 16), _)
|
||||
))
|
||||
// & last so we don't re-expand decoded entities
|
||||
.replace(/&/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, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
);
|
||||
|
||||
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 ``;
|
||||
}
|
||||
|
||||
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 `[](${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;
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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'], {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
@@ -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
Reference in new issue
Block a user