mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-31 19:02:31 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef364b69fd | ||
|
|
4beccc3d61 | ||
|
|
33b3beaea0 | ||
|
|
e65af2fd7b | ||
|
|
5d63a9476c | ||
|
|
58c72ace32 | ||
|
|
75cbda7edc | ||
|
|
6f6d8dc36d | ||
|
|
d0f819efb0 | ||
|
|
380e96eae5 | ||
|
|
02ce011934 | ||
|
|
8e45763e4c | ||
|
|
0d92c5d738 | ||
|
|
6bf4268e8e | ||
|
|
f02b645af7 | ||
|
|
eb3cd06278 | ||
|
|
7ed0b32489 | ||
|
|
2fe46cbf6f | ||
|
|
53ebbec58c | ||
|
|
253ce57d84 | ||
|
|
9beb7d3108 | ||
|
|
525a78e83e | ||
|
|
0e94950a5c | ||
|
|
a2ea0f6464 | ||
|
|
42b174abb3 | ||
|
|
e2ad0f526e | ||
|
|
cfeee2ec89 | ||
|
|
be3e971064 | ||
|
|
35bb83f57c | ||
|
|
5279ac2836 | ||
|
|
793ba54bda | ||
|
|
a1ca1a3443 | ||
|
|
3c528c6ab5 | ||
|
|
6dd703afce | ||
|
|
10c399f47b | ||
|
|
a4ec7bab61 | ||
|
|
79215d8896 | ||
|
|
2012fc53c7 | ||
|
|
2be738b8c2 | ||
|
|
93d7f8e1ec | ||
|
|
924c9a0efa | ||
|
|
0d9da150ec | ||
|
|
d307a2ebc3 | ||
|
|
d97439f878 | ||
|
|
8285d7010b | ||
|
|
1f0ddd2bdb | ||
|
|
98db9e6fea | ||
|
|
8e53ec829f | ||
|
|
549c5534d5 | ||
|
|
8fae84148c | ||
|
|
6d14f1e3d6 | ||
|
|
aebe25ae70 | ||
|
|
df045bf144 | ||
|
|
32447885d9 | ||
|
|
195a23e380 | ||
|
|
459ac09ebb | ||
|
|
a1ea4cc546 | ||
|
|
25224cef7b | ||
|
|
452058bd42 | ||
|
|
50c25d8789 | ||
|
|
96a38593bf | ||
|
|
e3b9e71d8f | ||
|
|
01b9f745b6 | ||
|
|
e96384cc7f | ||
|
|
a5bf1765d4 | ||
|
|
c921e7d9a4 | ||
|
|
a1b7f3708a | ||
|
|
6a4a1ffd92 | ||
|
|
105107755b | ||
|
|
028e86b111 | ||
|
|
151c15b38f | ||
|
|
544613c007 | ||
|
|
b2886ed16b | ||
|
|
57e11822f2 | ||
|
|
ff05c33fc9 | ||
|
|
a2ff82187b | ||
|
|
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 重复
|
||||
@@ -272,6 +272,55 @@ test('next, previous, and number shortcuts include native plugin view tabs', ()
|
||||
assert.deepEqual(selected, [pluginTabId, 'session-1', pluginTabId]);
|
||||
});
|
||||
|
||||
test('switchToTab uses physical Digit code when Shift remaps e.key', () => {
|
||||
let activeTabId = 'session-1';
|
||||
const noop = () => {};
|
||||
const context = {
|
||||
IS_DEV: false,
|
||||
MOVE_FOCUS_DEBOUNCE_MS: 0,
|
||||
activeTabStore: { getActiveTabId: () => activeTabId },
|
||||
addConnectionLogRef: { current: noop },
|
||||
closeSession: noop,
|
||||
closeTabInFlightRef: { current: false },
|
||||
closeWorkspace: noop,
|
||||
collectSessionIds: () => [],
|
||||
confirmIfBusyLocalTerminal: async () => true,
|
||||
createLocalTerminalWithCurrentShell: noop,
|
||||
editorTabs: [],
|
||||
fromEditorTabId: () => null,
|
||||
handleOpenSettingsRef: { current: noop },
|
||||
handleRequestCloseEditorTabRef: { current: noop },
|
||||
isEditorTabId: () => false,
|
||||
isQuickSwitcherOpen: false,
|
||||
lastMoveFocusTimeRef: { current: 0 },
|
||||
moveFocusInWorkspace: noop,
|
||||
orderedTabs: ['session-1', 'session-2', 'session-3'],
|
||||
resolveCloseIntent: () => ({ kind: 'noop' }),
|
||||
resolveSnippetsShortcutIntent: () => ({ kind: 'noop' }),
|
||||
sessions: [],
|
||||
setActiveTabId: (id: string) => { activeTabId = id; },
|
||||
setAddToWorkspaceDialog: noop,
|
||||
setIsQuickSwitcherOpen: noop,
|
||||
setNavigateToSection: noop,
|
||||
settings: { showSftpTab: false, shellOnlyTabNumberShortcuts: false },
|
||||
splitSessionWithCurrentShell: noop,
|
||||
systemInfoRef: { current: { username: 'user', hostname: 'host' } },
|
||||
toEditorTabId: (id: string) => `editor:${id}`,
|
||||
toggleBroadcast: noop,
|
||||
toggleScriptsSidePanelRef: { current: noop },
|
||||
toggleSidePanelRef: { current: noop },
|
||||
toggleWorkspaceViewMode: noop,
|
||||
workspaces: [],
|
||||
};
|
||||
|
||||
executeHotkeyActionImpl(
|
||||
() => context,
|
||||
'switchToTab',
|
||||
{ key: '@', code: 'Digit3', ctrlKey: true, shiftKey: true } as KeyboardEvent,
|
||||
);
|
||||
assert.equal(activeTabId, 'session-2');
|
||||
});
|
||||
|
||||
test('next tab includes pinned tabs when shell-only shortcut mode is disabled', () => {
|
||||
let activeTabId = '';
|
||||
const noop = () => {};
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getEffectiveHostDistro, classifyDistroId, shouldProbeSessionCwd } from
|
||||
import { sanitizeHostIconFields } from '../../domain/hostIcon';
|
||||
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
|
||||
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
|
||||
import { tabShortcutDigitFromEvent } from '../../domain/models/keyBindings';
|
||||
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
|
||||
import { captureInheritedCwd } from '../state/inheritedCwd';
|
||||
|
||||
@@ -496,7 +497,14 @@ async function captureCtxInheritedCwd(getCtx: AppContextGetter, sessionId: strin
|
||||
}
|
||||
}
|
||||
|
||||
const probe = async (id: string, options?: { allowHomeFallback?: boolean; timeoutMs?: number }) =>
|
||||
const probe = async (
|
||||
id: string,
|
||||
options?: {
|
||||
allowHomeFallback?: boolean;
|
||||
allowLoginShellFallback?: boolean;
|
||||
timeoutMs?: number;
|
||||
},
|
||||
) =>
|
||||
(await bridge?.getSessionPwd?.(id, options)) ?? { success: false };
|
||||
return captureInheritedCwd(source, probe, { liveCwd, allowSshProbe });
|
||||
}
|
||||
@@ -673,12 +681,10 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
});
|
||||
switch (action) {
|
||||
case 'switchToTab': {
|
||||
// Get the number key pressed (1-9)
|
||||
const num = parseInt(e.key, 10);
|
||||
if (num >= 1 && num <= 9) {
|
||||
if (num <= shortcutTabs.length) {
|
||||
setActiveTabId(shortcutTabs[num - 1]);
|
||||
}
|
||||
// Prefer physical Digit code so Shift+[1...9] works when e.key is "!" etc.
|
||||
const num = tabShortcutDigitFromEvent(e);
|
||||
if (num !== null && num <= shortcutTabs.length) {
|
||||
setActiveTabId(shortcutTabs[num - 1]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { getNotesSnapshot } from '../state/notesStore';
|
||||
import { useVaultAgentBridge } from '../state/useVaultAgentBridge';
|
||||
import { useWindowControls } from '../state/useWindowControls';
|
||||
import { useTerminalKeyboardFocus } from '../state/useTerminalKeyboardFocus';
|
||||
import { useEditorTabChromeList } from '../state/editorTabStore';
|
||||
import {
|
||||
isPluginViewTabId,
|
||||
@@ -132,12 +133,15 @@ export function AppSideEffects() {
|
||||
terminalSettings,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
disableTerminalFontZoom,
|
||||
isHotkeyRecording,
|
||||
showSftpTab,
|
||||
shellOnlyTabNumberShortcuts,
|
||||
workspaceFocusStyle,
|
||||
} = settings;
|
||||
|
||||
useTerminalKeyboardFocus(hotkeyScheme !== 'disabled' && !disableTerminalFontZoom);
|
||||
|
||||
const discoveredShells = useDiscoveredShells();
|
||||
|
||||
// Sync workspace focus indicator style to DOM for CSS targeting
|
||||
|
||||
@@ -34,6 +34,7 @@ import { useMainWindowInputFocusRecovery } from '../state/useMainWindowInputFocu
|
||||
import { useExternalMcpToggleState } from '../state/useExternalMcpToggleState';
|
||||
import { selectPluginThemeTokens } from '../state/pluginContributionEnvironment';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { resolveEffectiveTerminalHost } from '../../domain/terminalHostResolution';
|
||||
import { pluginViewTabStore, usePluginViewTabs } from '../state/pluginViewTabStore';
|
||||
import { buildPluginSettingScopeCatalog } from '../state/usePluginSettingScopeCatalog';
|
||||
import { useWorkSurfaceHostEditor } from '../state/useWorkSurfaceHostEditor';
|
||||
@@ -317,6 +318,29 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
setAddToWorkspaceDialog({ mode: 'append', workspaceId });
|
||||
}, [setAddToWorkspaceDialog]);
|
||||
|
||||
const validProxyProfileIds = useMemo(
|
||||
() => new Set(proxyProfiles.map((profile) => profile.id)),
|
||||
[proxyProfiles],
|
||||
);
|
||||
|
||||
const resolveWorkspaceAppendHost = useCallback((host: typeof hosts[number]) => (
|
||||
resolveEffectiveTerminalHost({
|
||||
host,
|
||||
groupConfigs,
|
||||
proxyProfiles,
|
||||
validProxyProfileIds,
|
||||
})
|
||||
), [groupConfigs, proxyProfiles, validProxyProfileIds]);
|
||||
|
||||
const handleAppendHostToWorkspace = useCallback((workspaceId: string, hostId: string) => {
|
||||
const host = hosts.find((entry) => entry.id === hostId);
|
||||
if (!host) return;
|
||||
const ws = workspaces.find((entry) => entry.id === workspaceId);
|
||||
if (!ws) return;
|
||||
const rootDir = ws.root.type === 'split' ? ws.root.direction : 'vertical';
|
||||
appendHostToWorkspace(workspaceId, resolveWorkspaceAppendHost(host), rootDir);
|
||||
}, [appendHostToWorkspace, hosts, resolveWorkspaceAppendHost, workspaces]);
|
||||
|
||||
const isPeerSessionWindow = typeof window !== 'undefined'
|
||||
&& window.location.hash.startsWith('#/session-window');
|
||||
const externalMcpToggle = useExternalMcpToggleState();
|
||||
@@ -471,8 +495,10 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onEndSessionDrag={handleEndSessionDrag}
|
||||
onReorderTabs={reorderWorkTabs}
|
||||
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
|
||||
onAppendHostToWorkspace={handleAppendHostToWorkspace}
|
||||
showSftpTab={showSftpTab}
|
||||
showHostTreeSidebar={showHostTreeSidebar}
|
||||
switchTabKeyBinding={keyBindings.find((binding) => binding.action === 'switchToTab') ?? null}
|
||||
dynamicTabTitleMode={dynamicTabTitleMode}
|
||||
editorTabs={editorTabs}
|
||||
pluginViewTabs={pluginViewTabs}
|
||||
@@ -658,6 +684,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
|
||||
onAddSessionToWorkspace={addSessionToWorkspace}
|
||||
onRequestAddToWorkspace={handleRequestAddToWorkspace}
|
||||
onAppendHostToWorkspace={handleAppendHostToWorkspace}
|
||||
onUpdateSplitSizes={updateSplitSizes}
|
||||
onSetDraggingSessionId={setDraggingSessionId}
|
||||
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
|
||||
@@ -779,13 +806,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
<AddToWorkspaceDialog
|
||||
open
|
||||
onOpenChange={(open) => { if (!open) setAddToWorkspaceDialog(null); }}
|
||||
// Filter serial hosts only in append mode — appendHostToWorkspace
|
||||
// has no serial code path. Create mode goes through
|
||||
// createWorkspaceFromTargets, which builds a SerialConfig-backed
|
||||
// session for serial hosts, so those should remain pickable.
|
||||
hosts={addToWorkspaceDialog.mode === 'append'
|
||||
? hosts.filter((h) => h.protocol !== 'serial')
|
||||
: hosts}
|
||||
hosts={hosts}
|
||||
workspaceTitle={
|
||||
addToWorkspaceDialog.mode === 'append'
|
||||
? workspaces.find((w) => w.id === addToWorkspaceDialog.workspaceId)?.title
|
||||
@@ -804,7 +825,11 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
if (target.kind === 'local') {
|
||||
appendLocalTerminalToWorkspace(addToWorkspaceDialog.workspaceId, undefined, rootDir);
|
||||
} else {
|
||||
appendHostToWorkspace(addToWorkspaceDialog.workspaceId, target.host, rootDir);
|
||||
appendHostToWorkspace(
|
||||
addToWorkspaceDialog.workspaceId,
|
||||
resolveWorkspaceAppendHost(target.host),
|
||||
rootDir,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const source = readFileSync(new URL('./AppView.tsx', import.meta.url), 'utf8');
|
||||
|
||||
test('workspace append resolves group defaults before creating host sessions', () => {
|
||||
assert.match(source, /resolveEffectiveTerminalHost\(\{/);
|
||||
assert.match(source, /groupConfigs,/);
|
||||
assert.match(source, /proxyProfiles,/);
|
||||
assert.match(
|
||||
source,
|
||||
/appendHostToWorkspace\(workspaceId, resolveWorkspaceAppendHost\(host\), rootDir\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/appendHostToWorkspace\([\s\S]*?resolveWorkspaceAppendHost\(target\.host\),[\s\S]*?rootDir/,
|
||||
);
|
||||
});
|
||||
@@ -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,11 +1,14 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
runConnectScriptsSequential,
|
||||
selectScriptOverlayRun,
|
||||
setScriptRuns,
|
||||
waitForScriptRun,
|
||||
} from './scriptAutomationCoordinator.ts';
|
||||
import type { ScriptRun } from '@/types/global/netcatty-bridge-script.d.ts';
|
||||
import type { Snippet } from '@/domain/models';
|
||||
import { netcattyBridge } from '@/infrastructure/services/netcattyBridge.ts';
|
||||
|
||||
test('waitForScriptRun resolves when run is already completed on subscribe', async () => {
|
||||
const runId = 'run-already-done';
|
||||
@@ -64,3 +67,182 @@ test('selectScriptOverlayRun does not resurface older completed runs after dismi
|
||||
dismissedRunIds.add(latestRun.runId);
|
||||
assert.equal(selectScriptOverlayRun([olderRun, latestRun], 'sess1', dismissedRunIds), undefined);
|
||||
});
|
||||
|
||||
test('runConnectScriptsSequential cancels only its own queued run and waits for stop', async () => {
|
||||
const originalGet = netcattyBridge.get;
|
||||
const sessionId = 'sess-connect-abort';
|
||||
let queuedRunId: string | undefined;
|
||||
let releaseStop: (() => void) | undefined;
|
||||
const scriptStopCalls: string[] = [];
|
||||
const storage = new Map<string, string>();
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { storage.set(key, String(value)); },
|
||||
removeItem: (key: string) => { storage.delete(key); },
|
||||
clear: () => { storage.clear(); },
|
||||
},
|
||||
});
|
||||
|
||||
setScriptRuns([{
|
||||
runId: 'unrelated-active-run',
|
||||
scriptId: 'manual-script',
|
||||
sessionId,
|
||||
status: 'running',
|
||||
startedAt: Date.now(),
|
||||
logs: [],
|
||||
}]);
|
||||
|
||||
netcattyBridge.get = () => ({
|
||||
scriptRun: async (params) => {
|
||||
queuedRunId = params.runId;
|
||||
assert.equal(params.returnWhenQueued, true);
|
||||
return { runId: params.runId!, runIds: [params.runId!] };
|
||||
},
|
||||
scriptStop: (id: string) => new Promise((resolve) => {
|
||||
scriptStopCalls.push(id);
|
||||
releaseStop = () => resolve({ ok: true });
|
||||
}),
|
||||
}) as ReturnType<typeof netcattyBridge.get>;
|
||||
|
||||
const controller = new AbortController();
|
||||
let stopCurrentRun: (() => Promise<void>) | null = null;
|
||||
const snippet: Snippet = {
|
||||
id: 'connect-script',
|
||||
label: 'Connect',
|
||||
command: 'nct.session.sleep(60)',
|
||||
kind: 'script',
|
||||
};
|
||||
|
||||
try {
|
||||
const running = runConnectScriptsSequential({
|
||||
scripts: [snippet],
|
||||
sessionId,
|
||||
signal: controller.signal,
|
||||
onCancelableRunChange: (stop) => { stopCurrentRun = stop; },
|
||||
});
|
||||
void running.catch(() => {});
|
||||
await Promise.resolve();
|
||||
controller.abort();
|
||||
for (let attempt = 0; attempt < 10 && scriptStopCalls.length === 0; attempt += 1) {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
assert.deepEqual(scriptStopCalls, [queuedRunId]);
|
||||
assert.notEqual(queuedRunId, 'unrelated-active-run');
|
||||
assert.equal(typeof stopCurrentRun, 'function');
|
||||
|
||||
let settled = false;
|
||||
void running.finally(() => { settled = true; }).catch(() => {});
|
||||
await Promise.resolve();
|
||||
assert.equal(settled, false);
|
||||
|
||||
releaseStop?.();
|
||||
await assert.rejects(
|
||||
() => running,
|
||||
(error: unknown) => error instanceof DOMException && error.name === 'AbortError',
|
||||
);
|
||||
} finally {
|
||||
netcattyBridge.get = originalGet;
|
||||
setScriptRuns([]);
|
||||
Reflect.deleteProperty(globalThis, 'localStorage');
|
||||
}
|
||||
});
|
||||
|
||||
test('runConnectScriptsSequential retries the exact stop after a transient failure', async () => {
|
||||
const originalGet = netcattyBridge.get;
|
||||
const storage = new Map<string, string>();
|
||||
const stopCalls: string[] = [];
|
||||
let stopCurrentRun: (() => Promise<void>) | null = null;
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { storage.set(key, String(value)); },
|
||||
removeItem: (key: string) => { storage.delete(key); },
|
||||
clear: () => { storage.clear(); },
|
||||
},
|
||||
});
|
||||
|
||||
netcattyBridge.get = () => ({
|
||||
scriptRun: async (params) => ({ runId: params.runId!, runIds: [params.runId!] }),
|
||||
scriptStop: async (id: string) => {
|
||||
stopCalls.push(id);
|
||||
return { ok: stopCalls.length > 1 };
|
||||
},
|
||||
}) as ReturnType<typeof netcattyBridge.get>;
|
||||
|
||||
const controller = new AbortController();
|
||||
try {
|
||||
const running = runConnectScriptsSequential({
|
||||
scripts: [{ id: 'retry-stop', label: 'Retry stop', command: 'await nct.sleep(60)', kind: 'script' }],
|
||||
sessionId: 'sess-retry-stop',
|
||||
signal: controller.signal,
|
||||
onCancelableRunChange: (stop) => { stopCurrentRun = stop; },
|
||||
});
|
||||
void running.catch(() => {});
|
||||
for (let attempt = 0; attempt < 10 && !stopCurrentRun; attempt += 1) {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
controller.abort();
|
||||
await assert.rejects(() => running, /could not be stopped/);
|
||||
assert.equal(typeof stopCurrentRun, 'function');
|
||||
await stopCurrentRun!();
|
||||
assert.equal(stopCalls.length, 2);
|
||||
} finally {
|
||||
netcattyBridge.get = originalGet;
|
||||
setScriptRuns([]);
|
||||
Reflect.deleteProperty(globalThis, 'localStorage');
|
||||
}
|
||||
});
|
||||
|
||||
test('runConnectScriptsSequential does not treat a script error named Aborted as cancellation', async () => {
|
||||
const originalGet = netcattyBridge.get;
|
||||
const storage = new Map<string, string>();
|
||||
const scriptStopCalls: string[] = [];
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { storage.set(key, String(value)); },
|
||||
removeItem: (key: string) => { storage.delete(key); },
|
||||
clear: () => { storage.clear(); },
|
||||
},
|
||||
});
|
||||
|
||||
netcattyBridge.get = () => ({
|
||||
scriptRun: async (params) => {
|
||||
setScriptRuns([{
|
||||
runId: params.runId!,
|
||||
scriptId: params.scriptId,
|
||||
sessionId: params.sessionId!,
|
||||
status: 'failed',
|
||||
startedAt: Date.now() - 10,
|
||||
endedAt: Date.now(),
|
||||
error: 'Aborted',
|
||||
logs: [],
|
||||
}]);
|
||||
return { runId: params.runId!, runIds: [params.runId!] };
|
||||
},
|
||||
scriptStop: async (id: string) => {
|
||||
scriptStopCalls.push(id);
|
||||
return { ok: true };
|
||||
},
|
||||
}) as ReturnType<typeof netcattyBridge.get>;
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => runConnectScriptsSequential({
|
||||
scripts: [{ id: 'fails', label: 'Fails', command: "throw new Error('Aborted')", kind: 'script' }],
|
||||
sessionId: 'sess-real-error',
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
/Aborted/,
|
||||
);
|
||||
assert.deepEqual(scriptStopCalls, []);
|
||||
} finally {
|
||||
netcattyBridge.get = originalGet;
|
||||
setScriptRuns([]);
|
||||
Reflect.deleteProperty(globalThis, 'localStorage');
|
||||
}
|
||||
});
|
||||
@@ -83,6 +83,8 @@ export function getActiveScriptRunForSession(sessionId: string): ScriptRun | und
|
||||
}
|
||||
|
||||
export async function runAutomationScript(params: {
|
||||
runId?: string;
|
||||
returnWhenQueued?: boolean;
|
||||
snippet: Snippet;
|
||||
sessionId: string;
|
||||
sessionIds?: string[];
|
||||
@@ -104,6 +106,8 @@ export async function runAutomationScript(params: {
|
||||
throw new Error('Script bridge unavailable');
|
||||
}
|
||||
return bridge.scriptRun({
|
||||
runId: params.runId,
|
||||
returnWhenQueued: params.returnWhenQueued,
|
||||
scriptId: params.snippet.id,
|
||||
scriptLabel: params.snippet.label,
|
||||
content: params.snippet.command,
|
||||
@@ -176,6 +180,7 @@ export async function runConnectScriptsSequential(params: {
|
||||
scripts: Snippet[];
|
||||
sessionId: string;
|
||||
signal?: AbortSignal;
|
||||
onCancelableRunChange?: (stopCurrentRun: (() => Promise<void>) | null) => void;
|
||||
onScriptStart?: (snippet: Snippet) => void;
|
||||
onScriptComplete?: (snippet: Snippet) => void;
|
||||
sessionMeta?: {
|
||||
@@ -185,18 +190,63 @@ export async function runConnectScriptsSequential(params: {
|
||||
username?: string;
|
||||
};
|
||||
}): Promise<void> {
|
||||
for (const snippet of params.scripts) {
|
||||
const throwIfAborted = () => {
|
||||
if (params.signal?.aborted) {
|
||||
throw new Error('Aborted');
|
||||
throw new DOMException('Connect script run cancelled', 'AbortError');
|
||||
}
|
||||
};
|
||||
|
||||
for (const snippet of params.scripts) {
|
||||
throwIfAborted();
|
||||
const runId = crypto.randomUUID();
|
||||
let stopPromise: Promise<void> | undefined;
|
||||
let stopped = false;
|
||||
params.onScriptStart?.(snippet);
|
||||
const { runId } = await runAutomationScript({
|
||||
const queueAccepted = runAutomationScript({
|
||||
runId,
|
||||
returnWhenQueued: true,
|
||||
snippet,
|
||||
sessionId: params.sessionId,
|
||||
sessionMeta: params.sessionMeta,
|
||||
});
|
||||
await waitForScriptRun(runId, { signal: params.signal });
|
||||
params.onScriptComplete?.(snippet);
|
||||
const stopThisRun = () => {
|
||||
if (stopPromise) return stopPromise;
|
||||
const attempt = queueAccepted.then(async () => {
|
||||
const result = await stopScriptRun(runId);
|
||||
if (!result.ok) {
|
||||
throw new Error(`Connect script run could not be stopped: ${runId}`);
|
||||
}
|
||||
stopped = true;
|
||||
});
|
||||
stopPromise = attempt;
|
||||
void attempt.catch(() => {
|
||||
if (stopPromise === attempt) stopPromise = undefined;
|
||||
});
|
||||
return attempt;
|
||||
};
|
||||
params.onCancelableRunChange?.(stopThisRun);
|
||||
const onAbort = () => {
|
||||
void stopThisRun().catch(() => {});
|
||||
};
|
||||
params.signal?.addEventListener('abort', onAbort, { once: true });
|
||||
|
||||
try {
|
||||
await queueAccepted;
|
||||
throwIfAborted();
|
||||
await waitForScriptRun(runId, { signal: params.signal });
|
||||
params.onScriptComplete?.(snippet);
|
||||
} catch (err) {
|
||||
if (params.signal?.aborted) {
|
||||
await stopThisRun();
|
||||
throw new DOMException('Connect script run cancelled', 'AbortError');
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
params.signal?.removeEventListener('abort', onAbort);
|
||||
if (!params.signal?.aborted || stopped) {
|
||||
params.onCancelableRunChange?.(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { Host } from "../../domain/models";
|
||||
import { applyGroupDefaults } from "../../domain/groupConfig";
|
||||
import { prepareSerialConfigForSavedHost } from "../../domain/serialBackspace";
|
||||
import { buildTelnetDeepLinkConnectionHost } from "../../domain/telnetDeepLink";
|
||||
import { resolveEffectiveTerminalHost } from "../../domain/terminalHostResolution";
|
||||
import {
|
||||
createHostTerminalSession,
|
||||
createSerialTerminalSession,
|
||||
@@ -74,7 +74,34 @@ test("serial session factories snapshot effective legacy Backspace behavior", ()
|
||||
assert.equal(explicitDefaultSession.serialConfig?.backspaceBehavior, "default");
|
||||
});
|
||||
|
||||
test("saved quick-connect hosts can inherit Ctrl-H after moving into a group", () => {
|
||||
test("workspace host factory creates a complete serial session", () => {
|
||||
const session = createWorkspaceHostTerminalSession("session-serial", host({
|
||||
protocol: "serial",
|
||||
hostname: "COM7",
|
||||
port: 57600,
|
||||
username: "",
|
||||
serialConfig: {
|
||||
path: "COM7",
|
||||
baudRate: 57600,
|
||||
dataBits: 7,
|
||||
stopBits: 2,
|
||||
parity: "even",
|
||||
},
|
||||
}), "workspace-1");
|
||||
|
||||
assert.equal(session.workspaceId, "workspace-1");
|
||||
assert.equal(session.protocol, "serial");
|
||||
assert.deepEqual(session.serialConfig, {
|
||||
path: "COM7",
|
||||
baudRate: 57600,
|
||||
dataBits: 7,
|
||||
stopBits: 2,
|
||||
parity: "even",
|
||||
backspaceBehavior: "default",
|
||||
});
|
||||
});
|
||||
|
||||
test("workspace append snapshots serial Backspace behavior inherited from a group", () => {
|
||||
const savedHost = host({
|
||||
protocol: "serial",
|
||||
hostname: "COM3",
|
||||
@@ -87,12 +114,15 @@ test("saved quick-connect hosts can inherit Ctrl-H after moving into a group", (
|
||||
backspaceBehavior: "default",
|
||||
}),
|
||||
});
|
||||
const effectiveHost = applyGroupDefaults(savedHost, {
|
||||
backspaceBehavior: "ctrl-h",
|
||||
const effectiveHost = resolveEffectiveTerminalHost({
|
||||
host: savedHost,
|
||||
groupConfigs: [{ path: "network", backspaceBehavior: "ctrl-h" }],
|
||||
proxyProfiles: [],
|
||||
});
|
||||
|
||||
const session = createHostTerminalSession("session-1", effectiveHost);
|
||||
const session = createWorkspaceHostTerminalSession("session-1", effectiveHost, "workspace-1");
|
||||
|
||||
assert.equal(session.workspaceId, "workspace-1");
|
||||
assert.equal(session.serialConfig?.backspaceBehavior, "ctrl-h");
|
||||
});
|
||||
|
||||
|
||||
@@ -1,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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
const TERMINAL_KEYBOARD_TARGET_SELECTOR =
|
||||
".xterm, .xterm-helper-textarea, .xterm-screen, .xterm-viewport";
|
||||
|
||||
type FocusDocument = Pick<Document, "activeElement" | "addEventListener" | "removeEventListener">;
|
||||
type FocusWindow = Pick<Window, "addEventListener" | "removeEventListener">;
|
||||
|
||||
export const isTerminalKeyboardTarget = (target: EventTarget | null): boolean => {
|
||||
const element = target as (Element & {
|
||||
closest?: (selector: string) => Element | null;
|
||||
}) | null;
|
||||
return Boolean(element?.closest?.(TERMINAL_KEYBOARD_TARGET_SELECTOR));
|
||||
};
|
||||
|
||||
export function installTerminalKeyboardFocusTracking(
|
||||
documentRef: FocusDocument,
|
||||
windowRef: FocusWindow,
|
||||
onFocusChange: (focused: boolean) => void,
|
||||
): () => void {
|
||||
let pendingPointerTarget: EventTarget | null | undefined;
|
||||
let pendingPointerClearTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let isTracking = true;
|
||||
let windowFocused = true;
|
||||
|
||||
const clearPendingPointerTarget = () => {
|
||||
if (pendingPointerClearTimer !== undefined) {
|
||||
clearTimeout(pendingPointerClearTimer);
|
||||
pendingPointerClearTimer = undefined;
|
||||
}
|
||||
pendingPointerTarget = undefined;
|
||||
};
|
||||
|
||||
const publishForTarget = (target: EventTarget | null) => {
|
||||
if (!isTracking) return;
|
||||
onFocusChange(windowFocused && isTerminalKeyboardTarget(target));
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: Event) => {
|
||||
clearPendingPointerTarget();
|
||||
pendingPointerTarget = event.target;
|
||||
pendingPointerClearTimer = setTimeout(() => {
|
||||
const pointerTarget = pendingPointerTarget;
|
||||
clearPendingPointerTarget();
|
||||
if (pointerTarget !== undefined) {
|
||||
publishForTarget(documentRef.activeElement);
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleFocusIn = (event: Event) => {
|
||||
clearPendingPointerTarget();
|
||||
publishForTarget(event.target);
|
||||
};
|
||||
|
||||
const handleFocusOut = () => {
|
||||
queueMicrotask(() => {
|
||||
if (!isTracking) return;
|
||||
const pointerTarget = pendingPointerTarget;
|
||||
clearPendingPointerTarget();
|
||||
if (!windowFocused) {
|
||||
onFocusChange(false);
|
||||
return;
|
||||
}
|
||||
if (pointerTarget !== undefined) {
|
||||
publishForTarget(documentRef.activeElement);
|
||||
return;
|
||||
}
|
||||
publishForTarget(documentRef.activeElement);
|
||||
});
|
||||
};
|
||||
|
||||
const handleWindowBlur = () => {
|
||||
windowFocused = false;
|
||||
clearPendingPointerTarget();
|
||||
onFocusChange(false);
|
||||
};
|
||||
|
||||
const handleWindowFocus = () => {
|
||||
windowFocused = true;
|
||||
publishForTarget(documentRef.activeElement);
|
||||
};
|
||||
|
||||
publishForTarget(documentRef.activeElement);
|
||||
documentRef.addEventListener("pointerdown", handlePointerDown, true);
|
||||
documentRef.addEventListener("focusin", handleFocusIn, true);
|
||||
documentRef.addEventListener("focusout", handleFocusOut, true);
|
||||
windowRef.addEventListener("blur", handleWindowBlur);
|
||||
windowRef.addEventListener("focus", handleWindowFocus);
|
||||
|
||||
return () => {
|
||||
isTracking = false;
|
||||
documentRef.removeEventListener("pointerdown", handlePointerDown, true);
|
||||
documentRef.removeEventListener("focusin", handleFocusIn, true);
|
||||
documentRef.removeEventListener("focusout", handleFocusOut, true);
|
||||
windowRef.removeEventListener("blur", handleWindowBlur);
|
||||
windowRef.removeEventListener("focus", handleWindowFocus);
|
||||
clearPendingPointerTarget();
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -797,11 +797,6 @@ export const useSessionState = ({
|
||||
host: Host,
|
||||
direction: SplitDirection = 'vertical',
|
||||
): string | null => {
|
||||
// Serial hosts use a different session constructor; they currently
|
||||
// only enter workspaces via createSerialSession + drag, so reject
|
||||
// them here to avoid a partially-constructed session.
|
||||
if (host.protocol === 'serial') return null;
|
||||
|
||||
// Cheap early-exit using the ref when the workspace is clearly
|
||||
// absent. The authoritative check lives inside the setWorkspaces
|
||||
// updater below so we also cover the concurrent-close race.
|
||||
|
||||
@@ -105,4 +105,8 @@ test('workspace host creation paths use the host session snapshot factory', () =
|
||||
source,
|
||||
/createWorkspaceHostTerminalSession\(newSessionId,\s*host,\s*workspaceId\)/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/if \(host\.protocol === 'serial'\) return null/,
|
||||
);
|
||||
});
|
||||
@@ -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,59 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
getShortcutModifierRequirements,
|
||||
isShortcutModifierHeld,
|
||||
shouldReleaseShortcutModifier,
|
||||
type ShortcutModifierEvent,
|
||||
} from './useShortcutModifierHeld.ts';
|
||||
|
||||
const event = (overrides: Partial<ShortcutModifierEvent> = {}): ShortcutModifierEvent => ({
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('shortcut modifier requirements follow the effective Mac or PC binding', () => {
|
||||
const mac = getShortcutModifierRequirements('⌘ + [1...9]', 'mac');
|
||||
assert.deepEqual(mac, { metaKey: true, ctrlKey: false, altKey: false, shiftKey: false });
|
||||
assert.equal(isShortcutModifierHeld(event({ metaKey: true }), mac), true);
|
||||
assert.equal(isShortcutModifierHeld(event({ ctrlKey: true }), mac), false);
|
||||
|
||||
const pc = getShortcutModifierRequirements('Alt + Shift + [1...9]', 'pc');
|
||||
assert.deepEqual(pc, { metaKey: false, ctrlKey: false, altKey: true, shiftKey: true });
|
||||
assert.equal(isShortcutModifierHeld(event({ altKey: true, shiftKey: true }), pc), true);
|
||||
assert.equal(isShortcutModifierHeld(event({ altKey: true }), pc), false);
|
||||
assert.equal(isShortcutModifierHeld(event({ altKey: true, metaKey: true, shiftKey: true }), pc), false);
|
||||
});
|
||||
|
||||
test('shortcut number preview is unavailable for disabled, non-range, or modifier-free bindings', () => {
|
||||
assert.equal(getShortcutModifierRequirements('Disabled', 'mac'), null);
|
||||
assert.equal(getShortcutModifierRequirements('⌘ + K', 'mac'), null);
|
||||
assert.equal(getShortcutModifierRequirements('[1...9]', 'mac'), null);
|
||||
assert.equal(getShortcutModifierRequirements('⌘ + [1...9]', 'disabled'), null);
|
||||
});
|
||||
|
||||
test('shortcut modifier state clears when the required combination stops matching', () => {
|
||||
const requirements = getShortcutModifierRequirements('⌘ + [1...9]', 'mac');
|
||||
assert.equal(shouldReleaseShortcutModifier(event({ metaKey: true }), requirements), false);
|
||||
assert.equal(shouldReleaseShortcutModifier(event(), requirements), true);
|
||||
});
|
||||
|
||||
test('shortcut modifier held state tracks exact combo when extras are pressed or released', () => {
|
||||
const requirements = getShortcutModifierRequirements('⌘ + [1...9]', 'mac');
|
||||
// Already matching, then an extra modifier: preview must clear.
|
||||
assert.equal(isShortcutModifierHeld(event({ metaKey: true }), requirements), true);
|
||||
assert.equal(
|
||||
isShortcutModifierHeld(event({ metaKey: true, shiftKey: true }), requirements),
|
||||
false,
|
||||
);
|
||||
// Extra pressed first, then released while required modifiers remain: preview re-arms.
|
||||
assert.equal(
|
||||
isShortcutModifierHeld(event({ metaKey: true, altKey: true }), requirements),
|
||||
false,
|
||||
);
|
||||
assert.equal(isShortcutModifierHeld(event({ metaKey: true }), requirements), true);
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { parseKeyCombo, type HotkeyScheme } from '../../domain/models/keyBindings';
|
||||
|
||||
export type ShortcutModifierEvent = Pick<KeyboardEvent, 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey'>;
|
||||
|
||||
export type ShortcutModifierRequirements = Readonly<ShortcutModifierEvent>;
|
||||
|
||||
const MODIFIER_TOKENS = {
|
||||
mac: {
|
||||
'⌘': 'metaKey',
|
||||
'⌃': 'ctrlKey',
|
||||
'⌥': 'altKey',
|
||||
Shift: 'shiftKey',
|
||||
},
|
||||
pc: {
|
||||
Ctrl: 'ctrlKey',
|
||||
Alt: 'altKey',
|
||||
Shift: 'shiftKey',
|
||||
Win: 'metaKey',
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Resolve the modifier combination for the effective [1...9] binding.
|
||||
* A non-range or disabled binding cannot reveal tab numbers because the
|
||||
* dispatcher does not use it for the number-switch action.
|
||||
*/
|
||||
export function getShortcutModifierRequirements(
|
||||
keyBinding: string | null,
|
||||
scheme: HotkeyScheme,
|
||||
): ShortcutModifierRequirements | null {
|
||||
if (scheme === 'disabled' || !keyBinding || !keyBinding.includes('[1...9]')) return null;
|
||||
const parsed = parseKeyCombo(keyBinding);
|
||||
if (!parsed || parsed.modifiers.length === 0) return null;
|
||||
|
||||
const requirements: Record<keyof ShortcutModifierEvent, boolean> = {
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
};
|
||||
const tokenMap = MODIFIER_TOKENS[scheme];
|
||||
for (const modifier of parsed.modifiers) {
|
||||
const flag = tokenMap[modifier as keyof typeof tokenMap];
|
||||
if (!flag) return null;
|
||||
requirements[flag] = true;
|
||||
}
|
||||
|
||||
return requirements;
|
||||
}
|
||||
|
||||
/** Whether a keyboard event has exactly the modifiers required by the binding. */
|
||||
export function isShortcutModifierHeld(
|
||||
event: ShortcutModifierEvent,
|
||||
requirements: ShortcutModifierRequirements | null,
|
||||
): boolean {
|
||||
if (!requirements) return false;
|
||||
return event.metaKey === requirements.metaKey
|
||||
&& event.ctrlKey === requirements.ctrlKey
|
||||
&& event.altKey === requirements.altKey
|
||||
&& event.shiftKey === requirements.shiftKey;
|
||||
}
|
||||
|
||||
/** Whether a keyup event means the required modifier combination is no longer held. */
|
||||
export function shouldReleaseShortcutModifier(
|
||||
event: ShortcutModifierEvent,
|
||||
requirements: ShortcutModifierRequirements | null,
|
||||
): boolean {
|
||||
return !isShortcutModifierHeld(event, requirements);
|
||||
}
|
||||
|
||||
export function useShortcutModifierHeld(
|
||||
keyBinding: string | null,
|
||||
scheme: HotkeyScheme,
|
||||
): boolean {
|
||||
const [isHeld, setIsHeld] = useState(false);
|
||||
const requirements = useMemo(
|
||||
() => getShortcutModifierRequirements(keyBinding, scheme),
|
||||
[keyBinding, scheme],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setIsHeld(false);
|
||||
if (!requirements) return;
|
||||
|
||||
// Sync on every modifier transition so extras clear the preview and
|
||||
// releasing an extra modifier can re-arm when the exact combo remains.
|
||||
const syncHeldState = (event: KeyboardEvent) => {
|
||||
setIsHeld(isShortcutModifierHeld(event, requirements));
|
||||
};
|
||||
const clearHeldState = () => setIsHeld(false);
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
clearHeldState();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', syncHeldState, true);
|
||||
window.addEventListener('keyup', syncHeldState, true);
|
||||
window.addEventListener('blur', clearHeldState);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', syncHeldState, true);
|
||||
window.removeEventListener('keyup', syncHeldState, true);
|
||||
window.removeEventListener('blur', clearHeldState);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [requirements]);
|
||||
|
||||
return isHeld;
|
||||
}
|
||||
@@ -513,7 +513,10 @@ export const useTerminalBackend = () => {
|
||||
return bridge?.onZmodemEvent?.(sessionId, cb) ?? (() => {});
|
||||
}, []);
|
||||
|
||||
const getSessionPwd = useCallback(async (sessionId: string, options?: { allowHomeFallback?: boolean }) => {
|
||||
const getSessionPwd = useCallback(async (
|
||||
sessionId: string,
|
||||
options?: { allowHomeFallback?: boolean; allowLoginShellFallback?: boolean },
|
||||
) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getSessionPwd) return { success: false, error: 'getSessionPwd unavailable' };
|
||||
return bridge.getSessionPwd(sessionId, options);
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
installTerminalKeyboardFocusTracking,
|
||||
isTerminalKeyboardTarget,
|
||||
} from "./terminalKeyboardFocus";
|
||||
|
||||
const targetWithClosest = (matches: boolean) => ({
|
||||
closest: (selector: string) => {
|
||||
assert.match(selector, /\.xterm/);
|
||||
return matches ? {} : null;
|
||||
},
|
||||
}) as unknown as EventTarget;
|
||||
|
||||
test("terminal keyboard target detection recognizes xterm descendants", () => {
|
||||
assert.equal(isTerminalKeyboardTarget(targetWithClosest(true)), true);
|
||||
assert.equal(isTerminalKeyboardTarget(targetWithClosest(false)), false);
|
||||
assert.equal(isTerminalKeyboardTarget(null), false);
|
||||
});
|
||||
|
||||
test("terminal keyboard focus tracking publishes deduplicated focus transitions and cleans up", async () => {
|
||||
const listeners = new Map<string, (event?: Event) => void>();
|
||||
const removed: string[] = [];
|
||||
const body = targetWithClosest(false);
|
||||
const terminal = targetWithClosest(true);
|
||||
const documentRef = {
|
||||
activeElement: body as unknown as Element,
|
||||
addEventListener: (type: string, listener: (event?: Event) => void) => {
|
||||
listeners.set(type, listener);
|
||||
},
|
||||
removeEventListener: (type: string) => {
|
||||
removed.push(type);
|
||||
},
|
||||
} as unknown as Document;
|
||||
const windowRef = {
|
||||
addEventListener: (type: string, listener: (event?: Event) => void) => {
|
||||
listeners.set(`window:${type}`, listener);
|
||||
},
|
||||
removeEventListener: (type: string) => {
|
||||
removed.push(`window:${type}`);
|
||||
},
|
||||
} as unknown as Window;
|
||||
const changes: boolean[] = [];
|
||||
const cleanup = installTerminalKeyboardFocusTracking(documentRef, windowRef, (focused) => {
|
||||
if (changes.at(-1) !== focused) changes.push(focused);
|
||||
});
|
||||
|
||||
assert.deepEqual(changes, [false]);
|
||||
|
||||
listeners.get("pointerdown")?.({ target: terminal } as unknown as Event);
|
||||
listeners.get("focusin")?.({ target: terminal } as unknown as Event);
|
||||
assert.deepEqual(changes, [false, true]);
|
||||
|
||||
(documentRef as unknown as { activeElement: Element }).activeElement = terminal as unknown as Element;
|
||||
listeners.get("pointerdown")?.({ target: body } as unknown as Event);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(changes, [false, true]);
|
||||
|
||||
(documentRef as unknown as { activeElement: Element }).activeElement = body as unknown as Element;
|
||||
listeners.get("focusout")?.();
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(changes, [false, true, false]);
|
||||
|
||||
listeners.get("window:blur")?.();
|
||||
assert.deepEqual(changes, [false, true, false]);
|
||||
|
||||
(documentRef as unknown as { activeElement: Element }).activeElement = terminal as unknown as Element;
|
||||
listeners.get("window:focus")?.();
|
||||
assert.deepEqual(changes, [false, true, false, true]);
|
||||
|
||||
cleanup();
|
||||
assert.deepEqual(removed.sort(), ["focusin", "focusout", "pointerdown", "window:blur", "window:focus"]);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useEffect } from "react";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
import { installTerminalKeyboardFocusTracking } from "./terminalKeyboardFocus";
|
||||
|
||||
export function useTerminalKeyboardFocus(enabled = true): void {
|
||||
useEffect(() => {
|
||||
if (!enabled) return undefined;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.setTerminalKeyboardFocus) return undefined;
|
||||
|
||||
let lastPublished: boolean | undefined;
|
||||
const publish = (focused: boolean) => {
|
||||
if (focused === lastPublished) return;
|
||||
lastPublished = focused;
|
||||
try {
|
||||
bridge.setTerminalKeyboardFocus?.(focused);
|
||||
} catch {
|
||||
// Browser preview or a disposed Electron bridge.
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupTracking = installTerminalKeyboardFocusTracking(
|
||||
document,
|
||||
window,
|
||||
publish,
|
||||
);
|
||||
|
||||
return () => {
|
||||
cleanupTracking();
|
||||
publish(false);
|
||||
};
|
||||
}, [enabled]);
|
||||
}
|
||||
@@ -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.
@@ -759,7 +759,11 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
: -1}
|
||||
onFocus={() => onActiveTreeItemChange(`host:${host.id}`)}
|
||||
draggable={!isMultiSelectMode}
|
||||
onDragStart={(e) => e.dataTransfer.setData("host-id", host.id)}
|
||||
onDragStart={(e) => {
|
||||
// copyMove: vault reorder uses move; focus-sidebar append uses copy.
|
||||
e.dataTransfer.effectAllowed = "copyMove";
|
||||
e.dataTransfer.setData("host-id", host.id);
|
||||
}}
|
||||
onClick={() => {
|
||||
const action = resolveHostActivateAction({
|
||||
behavior: normalizedHostClickBehavior,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+140
-21
@@ -225,8 +225,16 @@ import {
|
||||
} from "./terminal/terminalRuntimeMount";
|
||||
import type { CreateXTermRuntimeContext } from "./terminal/runtime/createXTermRuntime";
|
||||
import { TerminalView } from "./terminal/TerminalView";
|
||||
import {
|
||||
cancelConnectAutomationBatch,
|
||||
createConnectAutomationBatch,
|
||||
trackConnectAutomationStop,
|
||||
type ConnectAutomationBatch,
|
||||
} from "./terminal/connectAutomationBatch";
|
||||
import {
|
||||
getInitialTerminalStatus,
|
||||
resolveTerminalVaultInitialized,
|
||||
shouldResetConnectAutomationOnReconnect,
|
||||
shouldSuppressHostStartupCommandOnReconnect,
|
||||
shouldStartTerminalBackend,
|
||||
} from "./terminal/restoredSessionGate";
|
||||
@@ -270,6 +278,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
sessionId,
|
||||
workspaceId,
|
||||
restoreState,
|
||||
vaultInitializedOverride,
|
||||
pendingInitialCwd,
|
||||
shellType,
|
||||
lastCwd,
|
||||
@@ -359,10 +368,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}, []);
|
||||
// Reactive vault-ready flag so restored panes re-arm boot after hydration
|
||||
// (module isVaultInitialized() alone is not a React dependency).
|
||||
const vaultInitialized = useVaultSnapshotField("isVaultInitialized");
|
||||
const sharedVaultInitialized = useVaultSnapshotField("isVaultInitialized");
|
||||
const vaultInitialized = resolveTerminalVaultInitialized(
|
||||
sharedVaultInitialized,
|
||||
vaultInitializedOverride,
|
||||
);
|
||||
const connectScriptsConsumedRef = useRef(false);
|
||||
const connectScriptsCompletedIdsRef = useRef(new Set<string>());
|
||||
const connectScriptsInFlightRef = useRef(false);
|
||||
const connectScriptsBatchRef = useRef<ConnectAutomationBatch | null>(null);
|
||||
const pendingScriptRunIdRef = useRef<string | null>(null);
|
||||
const pendingScriptHandledRef = useRef<Snippet | null>(null);
|
||||
const pendingScriptRef = useRef(pendingScript);
|
||||
@@ -775,7 +789,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const sudoHintRef = useRef<((active: boolean) => boolean) | undefined>(undefined);
|
||||
|
||||
const terminalBackend = useTerminalBackend();
|
||||
const { startManualSessionLog, stopManualSessionLog, getManualSessionLogStatus } = useSessionLogBackend();
|
||||
const {
|
||||
chooseManualSessionLogPath,
|
||||
startManualSessionLog,
|
||||
stopManualSessionLog,
|
||||
getManualSessionLogStatus,
|
||||
} = useSessionLogBackend();
|
||||
const {
|
||||
resizeSession,
|
||||
receiveSerialYmodem,
|
||||
@@ -2506,9 +2525,19 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
if (!shouldEvaluateConnect && !hasPendingWork) return;
|
||||
if (connectScriptsInFlightRef.current) return;
|
||||
|
||||
const batch = createConnectAutomationBatch();
|
||||
connectScriptsBatchRef.current = batch;
|
||||
connectScriptsInFlightRef.current = true;
|
||||
const batchStillActive = () => (
|
||||
!batch.controller.signal.aborted && connectScriptsBatchRef.current === batch
|
||||
);
|
||||
|
||||
// Defer until xterm has rendered login output and the main-process output tap
|
||||
// has populated SessionOutputBuffer (avoids waitForPrompt racing an empty buffer).
|
||||
let timerFired = false;
|
||||
const timer = window.setTimeout(() => {
|
||||
timerFired = true;
|
||||
if (!batchStillActive()) return;
|
||||
const runPending = Boolean(pendingOne);
|
||||
const connectQueueNow = connectScriptsConsumedRef.current
|
||||
? []
|
||||
@@ -2537,6 +2566,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
})) {
|
||||
connectScriptsConsumedRef.current = true;
|
||||
}
|
||||
if (connectScriptsBatchRef.current === batch) {
|
||||
connectScriptsBatchRef.current = null;
|
||||
connectScriptsInFlightRef.current = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2545,11 +2578,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
connectQueueNow.map((item) => item.id).filter((id): id is string => Boolean(id)),
|
||||
);
|
||||
|
||||
connectScriptsInFlightRef.current = true;
|
||||
|
||||
void runConnectScriptsSequential({
|
||||
scripts: scriptsToRun,
|
||||
sessionId,
|
||||
signal: batch.controller.signal,
|
||||
onCancelableRunChange: (stopCurrentRun) => (
|
||||
trackConnectAutomationStop(batch, stopCurrentRun)
|
||||
),
|
||||
sessionMeta: {
|
||||
connected: true,
|
||||
name: scriptSessionName,
|
||||
@@ -2557,6 +2592,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
username: host.username,
|
||||
},
|
||||
onScriptComplete: (snippet) => {
|
||||
if (!batchStillActive()) return;
|
||||
if (snippet.id && connectIdsInBatch.has(snippet.id)) {
|
||||
connectScriptsCompletedIdsRef.current.add(snippet.id);
|
||||
}
|
||||
@@ -2570,6 +2606,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
if (!batchStillActive()) return;
|
||||
const resolvedAfterRun = resolveConnectScriptsForHost(host, snippets);
|
||||
const doneAfterRun = resolvedAfterRun.length === 0
|
||||
|| resolvedAfterRun.every(
|
||||
@@ -2580,6 +2617,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
})
|
||||
.catch(async (err) => {
|
||||
if (!batchStillActive()) return;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
toast.error(message.includes('Observer mode') ? t('scripts.observer.blocked') : message);
|
||||
connectScriptsConsumedRef.current = true;
|
||||
@@ -2595,6 +2633,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
await runConnectScriptsSequential({
|
||||
scripts: [pendingScriptToMark],
|
||||
sessionId,
|
||||
signal: batch.controller.signal,
|
||||
onCancelableRunChange: (stopCurrentRun) => (
|
||||
trackConnectAutomationStop(batch, stopCurrentRun)
|
||||
),
|
||||
sessionMeta: {
|
||||
connected: true,
|
||||
name: scriptSessionName,
|
||||
@@ -2602,6 +2644,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
username: host.username,
|
||||
},
|
||||
onScriptComplete: (snippet) => {
|
||||
if (!batchStillActive()) return;
|
||||
if (snippet.id) {
|
||||
pendingScriptRunIdRef.current = snippet.id;
|
||||
} else {
|
||||
@@ -2610,16 +2653,32 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
},
|
||||
});
|
||||
} catch (pendingErr) {
|
||||
if (!batchStillActive()) return;
|
||||
const pendingMessage = pendingErr instanceof Error ? pendingErr.message : String(pendingErr);
|
||||
toast.error(pendingMessage.includes('Observer mode') ? t('scripts.observer.blocked') : pendingMessage);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
connectScriptsInFlightRef.current = false;
|
||||
if (
|
||||
!batch.controller.signal.aborted
|
||||
&& connectScriptsBatchRef.current === batch
|
||||
) {
|
||||
connectScriptsBatchRef.current = null;
|
||||
connectScriptsInFlightRef.current = false;
|
||||
}
|
||||
});
|
||||
}, 400);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
if (!timerFired && connectScriptsBatchRef.current === batch) {
|
||||
batch.controller.abort();
|
||||
connectScriptsBatchRef.current = null;
|
||||
connectScriptsInFlightRef.current = false;
|
||||
} else if (statusRef.current !== "connected") {
|
||||
void cancelConnectAutomationBatch(batch).catch(() => {});
|
||||
}
|
||||
};
|
||||
}, [effectiveTerminalProtocol, host, isPendingScriptAlreadyHandled, moshShellReady, pendingScript, pendingScriptId, scriptSessionName, sessionId, snippets, status, t]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -3347,20 +3406,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return;
|
||||
}
|
||||
if (!termRef.current) return;
|
||||
if (mode === "manual") {
|
||||
clearAutoReconnect();
|
||||
prepareRestoredReconnect();
|
||||
// A clone's first connection can fail (auth/host-key/transport) before the
|
||||
// inherited `cd` is consumed. prepareRestoredReconnect() just cleared the
|
||||
// intent for non-restored sessions, so re-arm it here; the callback no-ops
|
||||
// once the cwd was consumed or when there is no pending inherited cwd.
|
||||
prepareInitialCwdIntent();
|
||||
} else {
|
||||
restoreCwdIntentRef.current = null;
|
||||
suppressHostStartupCommandRef.current = shouldSuppressHostStartupCommandOnReconnect("automatic");
|
||||
}
|
||||
// Claim the retry before awaiting close. A close/cancel/unmount during the
|
||||
// awaited backend cleanup invalidates this token and stops the continuation.
|
||||
// Claim the retry before awaiting either script cancellation or backend
|
||||
// cleanup. A second reconnect/cancel invalidates this continuation.
|
||||
const retryToken = Symbol("retry");
|
||||
retryTokenRef.current = retryToken;
|
||||
reconnectPreparationTokenRef.current = retryToken;
|
||||
@@ -3371,6 +3418,59 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const connectAutomationBatch = connectScriptsBatchRef.current;
|
||||
if (connectAutomationBatch) {
|
||||
try {
|
||||
await cancelConnectAutomationBatch(connectAutomationBatch);
|
||||
} catch (error) {
|
||||
finishReconnectPreparation();
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
toast.error(message);
|
||||
if (mode === "auto" && retryTokenStillCurrent()) {
|
||||
// Return to disconnected so the existing auto-reconnect loop can
|
||||
// schedule another attempt, including another exact stop request.
|
||||
updateStatus("disconnected");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!retryTokenStillCurrent()) {
|
||||
finishReconnectPreparation();
|
||||
return;
|
||||
}
|
||||
if (connectScriptsBatchRef.current === connectAutomationBatch) {
|
||||
connectScriptsBatchRef.current = null;
|
||||
connectScriptsInFlightRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === "manual") {
|
||||
clearAutoReconnect();
|
||||
// Manual reconnect skips the disconnected status transition that normally
|
||||
// clears these refs, so onConnect scripts would otherwise stay consumed.
|
||||
if (shouldResetConnectAutomationOnReconnect(
|
||||
restoreState === "restored-disconnected" ? "restored" : "manual",
|
||||
)) {
|
||||
// The prior batch is now stopped, so its callbacks cannot mutate these
|
||||
// newly allocated guards for the fresh manual connection.
|
||||
connectScriptsConsumedRef.current = false;
|
||||
connectScriptsCompletedIdsRef.current = new Set();
|
||||
connectScriptsInFlightRef.current = false;
|
||||
}
|
||||
prepareRestoredReconnect();
|
||||
// A clone's first connection can fail (auth/host-key/transport) before the
|
||||
// inherited `cd` is consumed. prepareRestoredReconnect() just cleared the
|
||||
// intent for non-restored sessions, so re-arm it here; the callback no-ops
|
||||
// once the cwd was consumed or when there is no pending inherited cwd.
|
||||
prepareInitialCwdIntent();
|
||||
} else {
|
||||
// An automatic reconnect replaces the transport but remains the same user
|
||||
// session. Stop old automation above, then preserve the existing policy of
|
||||
// not starting onConnect scripts again on the replacement transport.
|
||||
connectScriptsConsumedRef.current = true;
|
||||
restoreCwdIntentRef.current = null;
|
||||
suppressHostStartupCommandRef.current = shouldSuppressHostStartupCommandOnReconnect("automatic");
|
||||
}
|
||||
|
||||
try {
|
||||
await cleanupSession();
|
||||
} catch (error) {
|
||||
@@ -3560,13 +3660,31 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const startResult = await startManualSessionLog({
|
||||
// Choose the destination first, then re-sample terminal state. The save
|
||||
// dialog can stay open long enough for the user to enter/leave vim, so
|
||||
// alternate-screen / initial-line must be captured at stream activation.
|
||||
const chooseResult = await chooseManualSessionLogPath({
|
||||
sessionId: currentSessionId,
|
||||
sessionName: host.label || host.hostname || currentSessionId,
|
||||
preferredDirectory: sessionLog?.directory,
|
||||
format: sessionLog?.format,
|
||||
});
|
||||
if (!chooseResult?.success) {
|
||||
toast.error(chooseResult?.error || "Failed to start session log");
|
||||
return;
|
||||
}
|
||||
if (chooseResult.canceled || !chooseResult.selectionToken) return;
|
||||
|
||||
const startResult = await startManualSessionLog({
|
||||
sessionId: currentSessionId,
|
||||
sessionName: host.label || host.hostname || currentSessionId,
|
||||
selectionToken: chooseResult.selectionToken,
|
||||
format: sessionLog?.format,
|
||||
timestampsEnabled: sessionLog?.timestampsEnabled,
|
||||
initialLine: termRef.current ? getSessionLogInitialLine(termRef.current) : "",
|
||||
alternateScreenActive: termRef.current
|
||||
? isTerminalAlternateScreenActive(termRef.current)
|
||||
: false,
|
||||
});
|
||||
if (startResult?.success) {
|
||||
if (!startResult?.started && startResult?.canceled) return;
|
||||
@@ -3579,6 +3697,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
toast.error("Failed to toggle session log");
|
||||
}
|
||||
}, [
|
||||
chooseManualSessionLogPath,
|
||||
getManualSessionLogStatus,
|
||||
host.hostname,
|
||||
host.label,
|
||||
|
||||
@@ -93,6 +93,7 @@ import {
|
||||
shouldCloseSftpSidePanel,
|
||||
shouldClearSftpPanelAfterTransferChange,
|
||||
shouldKeepSftpMountedAfterClose,
|
||||
shouldMarkSftpPaneClosed,
|
||||
shouldScheduleSftpRetainedPanelCleanup,
|
||||
terminalSftpTransferOwnerId,
|
||||
} from './terminalLayer/sftpPanelLifecycle';
|
||||
@@ -219,6 +220,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onCreateWorkspaceFromSessions,
|
||||
onAddSessionToWorkspace,
|
||||
onRequestAddToWorkspace,
|
||||
onAppendHostToWorkspace,
|
||||
onUpdateSplitSizes,
|
||||
onSetDraggingSessionId,
|
||||
onToggleWorkspaceViewMode,
|
||||
@@ -411,6 +413,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
lastSidePanelTabRef.current.set(tabId, targetPanel);
|
||||
|
||||
if (targetPanel === 'sftp') {
|
||||
sftpPaneClosedTabIdsRef.current.delete(tabId);
|
||||
const host = hostsRef.current.find(h => h.id === session.hostId);
|
||||
const hostWithOverrides: Host = host
|
||||
? {
|
||||
@@ -578,6 +581,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const sftpActiveTransfersByTabRef = useRef<Map<string, number>>(new Map());
|
||||
const sftpActiveExternalEditsByTabRef = useRef<Map<string, number>>(new Map());
|
||||
const sftpRetainedAfterCloseTabIdsRef = useRef<Set<string>>(new Set());
|
||||
const sftpPaneClosedTabIdsRef = useRef<Set<string>>(new Set());
|
||||
const sftpOpeningTabIdsRef = useRef<Set<string>>(new Set());
|
||||
const sftpRetainedCleanupTimersRef = useRef<Map<string, number>>(new Map());
|
||||
const sftpLastPathForSourceRef = useRef<Map<string, SftpRememberedLocation>>(new Map());
|
||||
@@ -613,6 +617,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
sftpActiveTransfersByTabRef.current.delete(tabId);
|
||||
sftpActiveExternalEditsByTabRef.current.delete(tabId);
|
||||
sftpRetainedAfterCloseTabIdsRef.current.delete(tabId);
|
||||
sftpPaneClosedTabIdsRef.current.delete(tabId);
|
||||
sftpOpeningTabIdsRef.current.delete(tabId);
|
||||
setSftpHostForTab(prev => {
|
||||
if (!prev.has(tabId)) return prev;
|
||||
@@ -830,6 +835,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
sftpRetainedCleanupTimersRef.current.delete(tabId);
|
||||
}
|
||||
sftpRetainedAfterCloseTabIdsRef.current.delete(tabId);
|
||||
sftpPaneClosedTabIdsRef.current.delete(tabId);
|
||||
|
||||
setSidePanelOpenTabs(prev => {
|
||||
const next = new Map(prev);
|
||||
@@ -1329,6 +1335,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
sftpRetainedCleanupTimersRef.current.delete(tabId);
|
||||
}
|
||||
sftpRetainedAfterCloseTabIdsRef.current.delete(tabId);
|
||||
sftpPaneClosedTabIdsRef.current.delete(tabId);
|
||||
}
|
||||
|
||||
// If switching to SFTP and no host is stored yet, resolve it
|
||||
@@ -1410,10 +1417,22 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const handleCloseSidePanelPane = useCallback((paneId: string) => {
|
||||
const tabId = activeTabIdRef.current;
|
||||
if (!tabId) return;
|
||||
if (closeSidePanelPaneForTab(tabId, paneId)) {
|
||||
const layout = sidePanelLayoutsRef.current.get(tabId);
|
||||
const closingPane = layout
|
||||
? collectSidePanelPanes(layout.root).find((pane) => pane.id === paneId)
|
||||
: undefined;
|
||||
const closesWholePanel = closeSidePanelPaneForTab(tabId, paneId);
|
||||
if (closesWholePanel) {
|
||||
handleCloseSidePanel();
|
||||
return;
|
||||
}
|
||||
}, [closeSidePanelPaneForTab, handleCloseSidePanel]);
|
||||
if (shouldMarkSftpPaneClosed({
|
||||
closingPaneTool: closingPane?.tool,
|
||||
closesWholePanel: closesWholePanel,
|
||||
})) {
|
||||
sftpPaneClosedTabIdsRef.current.add(tabId);
|
||||
}
|
||||
}, [closeSidePanelPaneForTab, handleCloseSidePanel, sidePanelLayoutsRef]);
|
||||
|
||||
const handleResizeSidePanelSplit = useCallback((splitId: string, sizes: number[]) => {
|
||||
const tabId = activeTabIdRef.current;
|
||||
@@ -2089,6 +2108,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onCopySession,
|
||||
onCopySessionToNewWindow,
|
||||
onRequestAddToWorkspace,
|
||||
onAppendHostToWorkspace,
|
||||
onSessionData,
|
||||
onSetDraggingSessionId,
|
||||
onSetWorkspaceFocusedSession,
|
||||
@@ -2155,6 +2175,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
sftpHostForTab,
|
||||
sftpInitialLocationForTab,
|
||||
sftpPendingUploadsForTab,
|
||||
sftpPaneClosedTabIdsRef,
|
||||
sftpRetainedAfterCloseTabIdsRef,
|
||||
sftpShowHiddenFiles,
|
||||
SftpSidePanel,
|
||||
sftpUseCompressedUpload,
|
||||
|
||||
@@ -141,6 +141,10 @@ test('popup terminals resolve complete host config and pass jump hosts into Term
|
||||
assert.match(source, /onDeleteSnippets=\{deleteSelectedSnippets\}/);
|
||||
});
|
||||
|
||||
test('popup terminals use their window-local vault readiness', () => {
|
||||
assert.match(source, /vaultInitializedOverride=\{vaultInitialized\}/);
|
||||
});
|
||||
|
||||
test('popup provider tree mounts the plugin authentication host', () => {
|
||||
assert.match(source, /import \{ PluginAuthenticationHost \} from '\.\/plugins\/PluginAuthenticationHost';/);
|
||||
assert.match(
|
||||
|
||||
@@ -467,6 +467,7 @@ function TerminalPopupPageInner() {
|
||||
accentMode={settings.accentMode}
|
||||
customAccent={settings.customAccent}
|
||||
terminalSettings={settings.terminalSettings}
|
||||
vaultInitializedOverride={vaultInitialized}
|
||||
disableTerminalFontZoom={settings.disableTerminalFontZoom}
|
||||
sessionId={sessionId}
|
||||
startupCommand={isAttachMode ? undefined : config.startupCommand}
|
||||
|
||||
@@ -361,6 +361,14 @@ test("workspace session drag data is recognized with a dedicated drag type", ()
|
||||
assert.equal(getWorkspaceSessionDragId(transfer), "session-1");
|
||||
});
|
||||
|
||||
test("TopTabs wires host drops to workspace tabs without replacing tab reorder", () => {
|
||||
assert.match(topTabsSource, /appendHostFromWorkspaceDrop/);
|
||||
assert.match(topTabsSource, /workspaceMap\.has\(targetTabId\)/);
|
||||
assert.match(topTabsSource, /dropEffect = 'copy'/);
|
||||
assert.match(topTabsSource, /isHostDropTarget=\{hostDropWorkspaceId === workspace\.id\}/);
|
||||
assert.match(appViewSource, /<TopTabs[\s\S]*?onAppendHostToWorkspace=\{handleAppendHostToWorkspace\}/);
|
||||
});
|
||||
|
||||
test("workspace session drag id falls back to the legacy session id", () => {
|
||||
const transfer = {
|
||||
types: ["session-id"],
|
||||
|
||||
+89
-5
@@ -1,11 +1,17 @@
|
||||
import { Folder, FolderLock, Menu, MoreHorizontal, Plus, Settings, Sparkles } from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { fromEditorTabId, isEditorTabId, useActiveTabId } from '../application/state/activeTabStore';
|
||||
import { fromEditorTabId, isEditorTabId, toEditorTabId, useActiveTabId } from '../application/state/activeTabStore';
|
||||
import { topTabsSessionsEqual } from '../domain/topTabsSessionsEqual';
|
||||
import { isHostTreeWorkTabSurface } from '../application/app/workTabSurface';
|
||||
import { buildTabShortcutNumberById } from '../application/app/tabShortcutTargets';
|
||||
import { useShortcutModifierHeld } from '../application/state/useShortcutModifierHeld';
|
||||
import type { EditorTabChrome } from '../application/state/editorTabStore';
|
||||
import { collectSessionIds } from '../domain/workspace';
|
||||
import type { DynamicTabTitleMode } from '../domain/models';
|
||||
import {
|
||||
appendHostFromWorkspaceDrop,
|
||||
resolveFocusSidebarDragKind,
|
||||
} from '../domain/focusSidebarHostDrop';
|
||||
import type { DynamicTabTitleMode, KeyBinding } from '../domain/models';
|
||||
|
||||
import { getTopTabInsertionTarget, getWorkspaceSessionDragId, hasWorkspaceSessionDrag } from '../application/state/terminalDragData';
|
||||
import {
|
||||
@@ -15,6 +21,7 @@ import {
|
||||
} from '../application/state/terminalHostTreeStore';
|
||||
import type { LogView } from '../application/state/logViewState';
|
||||
import { useWindowControls } from '../application/state/useWindowControls';
|
||||
import { useSettingsChromeStore } from '../application/state/settingsChromeStore';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { Host, TerminalSession, Workspace } from '../types';
|
||||
import { cn } from '../lib/utils';
|
||||
@@ -153,8 +160,10 @@ interface TopTabsProps {
|
||||
sessionId: string,
|
||||
tabInsertionTarget?: { tabId: string; position: 'before' | 'after'; additionalTabIds?: readonly string[] },
|
||||
) => void;
|
||||
onAppendHostToWorkspace?: (workspaceId: string, hostId: string) => void;
|
||||
showSftpTab: boolean;
|
||||
showHostTreeSidebar: boolean;
|
||||
switchTabKeyBinding: Pick<KeyBinding, 'mac' | 'pc'> | null;
|
||||
dynamicTabTitleMode?: DynamicTabTitleMode;
|
||||
editorTabs: readonly EditorTabChrome[];
|
||||
pluginViewTabs: readonly PluginViewTab[];
|
||||
@@ -197,8 +206,10 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onEndSessionDrag,
|
||||
onReorderTabs,
|
||||
onRemoveSessionFromWorkspace,
|
||||
onAppendHostToWorkspace,
|
||||
showSftpTab,
|
||||
showHostTreeSidebar,
|
||||
switchTabKeyBinding,
|
||||
dynamicTabTitleMode,
|
||||
editorTabs,
|
||||
pluginViewTabs,
|
||||
@@ -208,6 +219,28 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { maximize, isFullscreen, onFullscreenChanged } = useWindowControls();
|
||||
const {
|
||||
hotkeyScheme,
|
||||
showTabNumberBadges,
|
||||
shellOnlyTabNumberShortcuts,
|
||||
} = useSettingsChromeStore();
|
||||
const switchTabKey = hotkeyScheme === 'mac'
|
||||
? switchTabKeyBinding?.mac ?? null
|
||||
: hotkeyScheme === 'pc'
|
||||
? switchTabKeyBinding?.pc ?? null
|
||||
: null;
|
||||
const shortcutModifierHeld = useShortcutModifierHeld(switchTabKey, hotkeyScheme);
|
||||
const tabShortcutNumbers = useMemo(() => {
|
||||
// Keep the tab bar quiet until the modifier for the active shortcut scheme
|
||||
// is held, while retaining the existing setting as the master toggle.
|
||||
if (!showTabNumberBadges || hotkeyScheme === 'disabled' || !shortcutModifierHeld) return null;
|
||||
return buildTabShortcutNumberById({
|
||||
showSftpTab,
|
||||
shellOnlyTabNumberShortcuts,
|
||||
orderedTabs,
|
||||
editorTabIds: editorTabs.map((tab) => toEditorTabId(tab.id)),
|
||||
});
|
||||
}, [hotkeyScheme, showTabNumberBadges, showSftpTab, shellOnlyTabNumberShortcuts, shortcutModifierHeld, orderedTabs, editorTabs]);
|
||||
const isHostTreeOpen = useTerminalHostTreeOpen();
|
||||
const hostTreeLayoutWidth = useTerminalHostTreeLayoutWidth();
|
||||
const toggleHostTree = useToggleTerminalHostTree();
|
||||
@@ -233,6 +266,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
// Tab reorder drag state
|
||||
const [dropIndicator, setDropIndicator] = useState<{ tabId: string; position: 'before' | 'after' } | null>(null);
|
||||
const [isDraggingForReorder, setIsDraggingForReorder] = useState(false);
|
||||
const [hostDropWorkspaceId, setHostDropWorkspaceId] = useState<string | null>(null);
|
||||
const draggedTabIdRef = useRef<string | null>(null);
|
||||
const [isWindowFullscreen, setIsWindowFullscreen] = useState(false);
|
||||
|
||||
@@ -494,12 +528,28 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
draggedTabIdRef.current = null;
|
||||
setDropIndicator(null);
|
||||
setIsDraggingForReorder(false);
|
||||
setHostDropWorkspaceId(null);
|
||||
onEndSessionDrag();
|
||||
}, [onEndSessionDrag]);
|
||||
|
||||
const handleTabDragOver = useCallback((e: React.DragEvent, tabId: string) => {
|
||||
const dragKind = resolveFocusSidebarDragKind({ types: e.dataTransfer.types });
|
||||
if (dragKind === 'host-append') {
|
||||
if (!onAppendHostToWorkspace || !workspaceMap.has(tabId)) {
|
||||
setHostDropWorkspaceId(null);
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
setDropIndicator(null);
|
||||
setHostDropWorkspaceId(tabId);
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
setHostDropWorkspaceId(null);
|
||||
|
||||
if (hasWorkspaceSessionDrag(e.dataTransfer)) {
|
||||
setDropIndicator(null);
|
||||
@@ -517,16 +567,40 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
|
||||
// Always update drop indicator on drag over to ensure it doesn't get stuck
|
||||
setDropIndicator({ tabId, position });
|
||||
}, []);
|
||||
}, [onAppendHostToWorkspace, workspaceMap]);
|
||||
|
||||
const handleTabDragLeave = useCallback((_e: React.DragEvent) => {
|
||||
const handleTabDragLeave = useCallback((e: React.DragEvent) => {
|
||||
const next = e.relatedTarget;
|
||||
if (next instanceof Node && e.currentTarget.contains(next)) return;
|
||||
setHostDropWorkspaceId(null);
|
||||
// Don't clear drop indicator on drag leave - let onDragOver manage it
|
||||
// This prevents the indicator from flickering/disappearing during fast drags
|
||||
// The indicator will be cleared when drag ends or on drop
|
||||
}, []);
|
||||
|
||||
const handleTabDrop = useCallback((e: React.DragEvent, targetTabId: string) => {
|
||||
const dragKind = resolveFocusSidebarDragKind({ types: e.dataTransfer.types });
|
||||
if (dragKind === 'host-append') {
|
||||
const handled = Boolean(
|
||||
onAppendHostToWorkspace
|
||||
&& workspaceMap.has(targetTabId)
|
||||
&& appendHostFromWorkspaceDrop({
|
||||
types: e.dataTransfer.types,
|
||||
getData: (type) => e.dataTransfer.getData(type),
|
||||
workspaceId: targetTabId,
|
||||
onAppendHostToWorkspace,
|
||||
}),
|
||||
);
|
||||
setHostDropWorkspaceId(null);
|
||||
setDropIndicator(null);
|
||||
if (!handled) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
setHostDropWorkspaceId(null);
|
||||
if (hasWorkspaceSessionDrag(e.dataTransfer)) {
|
||||
const draggedSessionId = getWorkspaceSessionDragId(e.dataTransfer);
|
||||
const draggedSession = sessions.find((s) => s.id === draggedSessionId);
|
||||
@@ -555,7 +629,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
|
||||
setDropIndicator(null);
|
||||
setIsDraggingForReorder(false);
|
||||
}, [dropIndicator, onEndSessionDrag, onRemoveSessionFromWorkspace, onReorderTabs, sessions, workspaces]);
|
||||
}, [dropIndicator, onAppendHostToWorkspace, onEndSessionDrag, onRemoveSessionFromWorkspace, onReorderTabs, sessions, workspaceMap, workspaces]);
|
||||
|
||||
const handleTabBarDrop = useCallback((e: React.DragEvent) => {
|
||||
if (!hasWorkspaceSessionDrag(e.dataTransfer)) return;
|
||||
@@ -752,6 +826,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onTabDragLeave={handleTabDragLeave}
|
||||
onTabDrop={handleTabDrop}
|
||||
tabAnimationClass={getTabAnimationClass(tabId)}
|
||||
shortcutNumber={tabShortcutNumbers?.get(tabId)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -776,6 +851,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onTabDragLeave={handleTabDragLeave}
|
||||
onTabDrop={handleTabDrop}
|
||||
tabAnimationClass={getTabAnimationClass(tabId)}
|
||||
shortcutNumber={tabShortcutNumbers?.get(tabId)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -811,6 +887,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
dynamicTabTitleMode={dynamicTabTitleMode}
|
||||
t={t}
|
||||
tabAnimationClass={getTabAnimationClass(session.id)}
|
||||
shortcutNumber={tabShortcutNumbers?.get(session.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -842,6 +919,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
shiftStyle={shiftStyle}
|
||||
showDropIndicatorBefore={showDropIndicatorBefore}
|
||||
showDropIndicatorAfter={showDropIndicatorAfter}
|
||||
isHostDropTarget={hostDropWorkspaceId === workspace.id}
|
||||
onTabDragStart={handleTabDragStart}
|
||||
onTabDragEnd={handleTabDragEnd}
|
||||
onTabDragOver={handleTabDragOver}
|
||||
@@ -854,6 +932,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
renderBulkCloseItems={renderBulkCloseItems}
|
||||
t={t}
|
||||
tabAnimationClass={getTabAnimationClass(workspace.id)}
|
||||
shortcutNumber={tabShortcutNumbers?.get(workspace.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -882,6 +961,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onTabDrop={handleTabDrop}
|
||||
t={t}
|
||||
tabAnimationClass={getTabAnimationClass(logView.id)}
|
||||
shortcutNumber={tabShortcutNumbers?.get(logView.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -933,6 +1013,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
icon={<FolderLock size={14} />}
|
||||
className="rounded"
|
||||
compact={rootTabsCompact}
|
||||
shortcutNumber={tabShortcutNumbers?.get('vault')}
|
||||
/>
|
||||
{showSftpTab && (
|
||||
<RootTopTab
|
||||
@@ -941,6 +1022,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
icon={<Folder size={14} />}
|
||||
className="rounded-t-md"
|
||||
compact={rootTabsCompact}
|
||||
shortcutNumber={tabShortcutNumbers?.get('sftp')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -1174,6 +1256,7 @@ export const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean
|
||||
prev.onCopySession === next.onCopySession &&
|
||||
prev.onCopySessionToNewWindow === next.onCopySessionToNewWindow &&
|
||||
prev.onEditHost === next.onEditHost &&
|
||||
prev.onAppendHostToWorkspace === next.onAppendHostToWorkspace &&
|
||||
prev.onCopyWorkspace === next.onCopyWorkspace &&
|
||||
prev.onOpenSettings === next.onOpenSettings &&
|
||||
prev.externalMcpEnabled === next.externalMcpEnabled &&
|
||||
@@ -1186,6 +1269,7 @@ export const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean
|
||||
prev.onThemeChange === next.onThemeChange &&
|
||||
prev.showSftpTab === next.showSftpTab &&
|
||||
prev.showHostTreeSidebar === next.showHostTreeSidebar &&
|
||||
prev.switchTabKeyBinding === next.switchTabKeyBinding &&
|
||||
prev.dynamicTabTitleMode === next.dynamicTabTitleMode &&
|
||||
prev.hostById === next.hostById
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -8,6 +8,25 @@ const layoutSource = readFileSync(
|
||||
"utf8",
|
||||
);
|
||||
|
||||
test("notes tree width resize avoids React setState on every pointermove", () => {
|
||||
// Dragging the sidebar used to call setTreeWidth per pixel and re-render the
|
||||
// whole NotesManager (including MDXEditor). Live width must stay on the DOM
|
||||
// until pointerup commits state + localStorage.
|
||||
assert.match(managerSource, /treeAsideRef/);
|
||||
assert.match(managerSource, /requestAnimationFrame/);
|
||||
assert.match(managerSource, /aside\.style\.width = `\$\{width\}px`/);
|
||||
assert.match(
|
||||
managerSource,
|
||||
/const handlePointerMove = \(moveEvent: PointerEvent\) => \{[\s\S]*?requestAnimationFrame/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
managerSource,
|
||||
/const handlePointerMove = \(moveEvent: PointerEvent\) => \{\s*\n\s*setTreeWidth\(/,
|
||||
);
|
||||
assert.match(managerSource, /persistTreeWidth\(nextWidth\)/);
|
||||
assert.match(managerSource, /isTreeResizing && "pointer-events-none"/);
|
||||
});
|
||||
|
||||
test("note content drafts stay in refs so MDX keystrokes do not rebuild the shell", () => {
|
||||
assert.doesNotMatch(
|
||||
managerSource,
|
||||
@@ -72,6 +91,63 @@ test("mode toggle flushes ref-only content drafts before remounting the editor",
|
||||
);
|
||||
});
|
||||
|
||||
test("note switches reuse MDX instance instead of key=noteId remount", () => {
|
||||
assert.match(managerSource, /noteId=\{selectedNoteView\.id\}/);
|
||||
assert.doesNotMatch(
|
||||
managerSource,
|
||||
/<InlineMarkdownEditor[\s\S]{0,200}key=\{selectedNoteView\.id\}/,
|
||||
"key=noteId forces full Lexical teardown on every note switch",
|
||||
);
|
||||
const editorSource = readFileSync(
|
||||
new URL("./InlineMarkdownEditor.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(editorSource, /noteId !== noteIdRef\.current/);
|
||||
assert.match(editorSource, /setMarkdown\(scheduled\.markdown\)/);
|
||||
assert.match(
|
||||
editorSource,
|
||||
/contentSwapFramesRef\.current\.outer = window\.requestAnimationFrame/,
|
||||
"outer rAF yields a paint so the tree selection updates before Lexical import",
|
||||
);
|
||||
assert.match(
|
||||
editorSource,
|
||||
/contentSwapFramesRef\.current\.inner = window\.requestAnimationFrame/,
|
||||
"inner rAF completes the double-yield before setMarkdown",
|
||||
);
|
||||
assert.match(editorSource, /data-notes-content-swapping="true"/);
|
||||
assert.match(editorSource, /setIsContentSwapping\(true\)/);
|
||||
assert.match(editorSource, /CLEAR_HISTORY_COMMAND/);
|
||||
assert.match(editorSource, /clearLexicalHistory/);
|
||||
assert.match(editorSource, /attributeFilter:\s*\[\s*"width",\s*"height"/);
|
||||
assert.match(editorSource, /onPointerDownCapture=\{blockWhileContentSwapping\}/);
|
||||
assert.match(editorSource, /startTransition\(\(\) => setIsContentSwapping\(false\)\)/);
|
||||
assert.match(
|
||||
editorSource,
|
||||
/latestMarkdownRef\.current !== scheduled\.markdown/,
|
||||
"deferred setMarkdown must not clobber edits typed after the switch",
|
||||
);
|
||||
assert.match(
|
||||
editorSource,
|
||||
/if \(contentSwapPendingRef\.current\) return;/,
|
||||
"stale onChange during the swap yield must not write the previous note into the new draft",
|
||||
);
|
||||
assert.match(
|
||||
editorSource,
|
||||
/contentSwapScheduledRef/,
|
||||
"deferred import must refresh when the same note's value changes during the yield",
|
||||
);
|
||||
assert.match(
|
||||
editorSource,
|
||||
/syncedPropValueRef\.current = markdown;/,
|
||||
"draft-clobber guard compares display-normalized values after note switch",
|
||||
);
|
||||
assert.match(
|
||||
editorSource,
|
||||
/runDecorations\(true\)/,
|
||||
"edit-mode note swaps must re-annotate host links after setMarkdown",
|
||||
);
|
||||
});
|
||||
|
||||
test("host-link annotation does not re-run on every markdown value keystroke", () => {
|
||||
const editorSource = readFileSync(
|
||||
new URL("./InlineMarkdownEditor.tsx", import.meta.url),
|
||||
@@ -82,7 +158,21 @@ test("host-link annotation does not re-run on every markdown value keystroke", (
|
||||
/annotateHostLinks,\s*value\s*\]/,
|
||||
"value in annotateHostLinks effect deps walks the DOM on every keystroke",
|
||||
);
|
||||
assert.match(editorSource, /\[annotateHostLinks, editorMode\]/);
|
||||
assert.doesNotMatch(
|
||||
editorSource,
|
||||
/annotateCodeBlockCopyButtons,\s*editorMode,\s*value\s*\]/,
|
||||
"value in code-copy effect deps re-walks every code block on each draft identity change",
|
||||
);
|
||||
assert.match(
|
||||
editorSource,
|
||||
/\[annotateCodeBlockCopyButtons, annotateHostLinks, editorMode\]/,
|
||||
"DOM decoration is independent of markdown value identity",
|
||||
);
|
||||
assert.match(
|
||||
editorSource,
|
||||
/readOnly=\{editorMode === "preview"\}/,
|
||||
"preview reuses MDXEditor in read-only mode",
|
||||
);
|
||||
assert.match(
|
||||
editorSource,
|
||||
/syncedPropValueRef/,
|
||||
@@ -93,3 +183,26 @@ test("host-link annotation does not re-run on every markdown value keystroke", (
|
||||
/latestMarkdownRef\.current !== syncedPropValueRef\.current/,
|
||||
);
|
||||
});
|
||||
|
||||
test("link hover and small-image CSS avoid render thrash", () => {
|
||||
const editorSource = readFileSync(
|
||||
new URL("./InlineMarkdownEditor.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const cssSource = readFileSync(new URL("../../index.css", import.meta.url), "utf8");
|
||||
|
||||
const imageLayoutSource = readFileSync(
|
||||
new URL("./noteImageLayout.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(editorSource, /linkActionStatesEqual/);
|
||||
assert.match(editorSource, /setLinkActionIfChanged/);
|
||||
assert.match(editorSource, /annotateNoteImageSizes/);
|
||||
assert.match(imageLayoutSource, /data-note-img-size/);
|
||||
// No combinatorial :has(img[width="N"]) matrix for small icons.
|
||||
assert.doesNotMatch(
|
||||
cssSource,
|
||||
/:has\(img\[width="16"\]\).*?:has\(img\[width="20"\]/s,
|
||||
);
|
||||
assert.match(cssSource, /img\[data-note-img-size="sm"\]/);
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
|
||||
import type { VaultNote } from "../../types.ts";
|
||||
import { TooltipProvider } from "../ui/tooltip.tsx";
|
||||
import {
|
||||
clampNotesTreeWidth,
|
||||
getFallbackNoteSelectionState,
|
||||
getNoteActionTargetGroup,
|
||||
getNoteGroupSelectionState,
|
||||
@@ -56,7 +57,14 @@ test("NotesManager renders notes tree and selected markdown editor", () => {
|
||||
|
||||
assert.match(markup, /Ops/);
|
||||
assert.match(markup, /Postgres failover checklist/);
|
||||
assert.match(markup, /data-notes-editor-loading="true"/);
|
||||
// React.lazy may show Suspense fallback or the resolved editor depending on
|
||||
// whether a prior test already settled the MDX chunk.
|
||||
assert.ok(
|
||||
markup.includes('data-notes-editor-loading="true"')
|
||||
|| markup.includes("editable markdown")
|
||||
|| markup.includes("Promote replica"),
|
||||
"full mode should mount the note editor (loading fallback or resolved MDX)",
|
||||
);
|
||||
});
|
||||
|
||||
test("NotesManager marks selected notebook rows with shared tree state", () => {
|
||||
@@ -76,6 +84,36 @@ test("NotesManager balances folder and note tree icon sizes", () => {
|
||||
assert.match(markup, /width="16" height="16"[^>]*class="lucide lucide-file-text/);
|
||||
});
|
||||
|
||||
test("NotesManager tree scroll area constrains width so titles can truncate", () => {
|
||||
const markup = renderNotes();
|
||||
|
||||
// Radix ScrollArea viewport child is display:table by default; force block +
|
||||
// min-w-0 so narrowing the notes sidebar ellipsizes long titles instead of
|
||||
// clipping them. React encodes `&` / `>` in class attributes.
|
||||
assert.match(
|
||||
markup,
|
||||
/data-radix-scroll-area-viewport\](?:>|>)div\]:!block/,
|
||||
);
|
||||
assert.match(
|
||||
markup,
|
||||
/data-radix-scroll-area-viewport\](?:>|>)div\]:!min-w-0/,
|
||||
);
|
||||
assert.match(markup, /data-notes-drop-zone="root"/);
|
||||
assert.match(markup, /min-h-full min-w-0 space-y-1 overflow-hidden/);
|
||||
// Aside stays unclipped so the resize handle's outer half remains hit-testable;
|
||||
// truncation is enforced by the inner tree shell + ScrollArea constraints.
|
||||
assert.match(markup, /aside class="[^"]*min-w-0[^"]*flex-col bg-background/);
|
||||
assert.match(markup, /flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden/);
|
||||
assert.match(markup, /role="separator"/);
|
||||
assert.match(markup, /translate-x-1\/2 cursor-col-resize/);
|
||||
});
|
||||
|
||||
test("clampNotesTreeWidth keeps the sidebar within the design range", () => {
|
||||
assert.equal(clampNotesTreeWidth(100), 160);
|
||||
assert.equal(clampNotesTreeWidth(300), 300);
|
||||
assert.equal(clampNotesTreeWidth(900), 520);
|
||||
});
|
||||
|
||||
test("NotesManager selection helpers keep note and folder selection exclusive", () => {
|
||||
const notes = [note(), note({ id: "note-2", title: "Deploy", group: "Deploy" })];
|
||||
const noteSelection = getNoteSelectionState(notes[0], false);
|
||||
@@ -297,7 +335,12 @@ test("NotesManager sidebar mode opens the requested note without selecting its f
|
||||
);
|
||||
|
||||
assert.match(markup, /Deploy overlay/);
|
||||
assert.match(markup, /data-notes-editor-loading="true"/);
|
||||
assert.ok(
|
||||
markup.includes('data-notes-editor-loading="true"')
|
||||
|| markup.includes("Deploy overlay content")
|
||||
|| markup.includes("editable markdown"),
|
||||
"openNoteId should mount the sidebar note overlay editor",
|
||||
);
|
||||
assert.equal(markup.match(/data-selected="true"/g)?.length, 1);
|
||||
assert.match(markup, /data-vault-tree-row="group"[^>]*data-selected="false"/);
|
||||
assert.match(markup, /data-vault-tree-row="item"[^>]*data-selected="true"[^>]*data-note-id="note-2"/);
|
||||
|
||||
@@ -100,11 +100,16 @@ type NotesToolbarPanel = "search" | null;
|
||||
const toolbarIconButtonClass = "netcatty-tab h-6 w-6 shrink-0 rounded-md p-0 hover:bg-transparent";
|
||||
const menuItemClass = "flex h-8 w-full items-center rounded-md px-3 text-left text-sm hover:bg-secondary";
|
||||
const NOTES_TREE_DEFAULT_WIDTH = 300;
|
||||
const NOTES_TREE_MIN_WIDTH = 220;
|
||||
/** Narrow enough for nested folders + ellipsis; toolbar scrolls if needed. */
|
||||
const NOTES_TREE_MIN_WIDTH = 160;
|
||||
const NOTES_TREE_MAX_WIDTH = 520;
|
||||
const NOTE_DRAG_TYPE = "application/x-netcatty-note-id";
|
||||
const NOTE_GROUP_DRAG_TYPE = "application/x-netcatty-note-group-path";
|
||||
|
||||
export function clampNotesTreeWidth(value: number): number {
|
||||
return Math.max(NOTES_TREE_MIN_WIDTH, Math.min(NOTES_TREE_MAX_WIDTH, value));
|
||||
}
|
||||
|
||||
export const normalizeNoteEditorMode = (value: string | null): NoteEditorMode | null =>
|
||||
value === "edit" || value === "preview" ? value : null;
|
||||
|
||||
@@ -113,7 +118,7 @@ const isNoteEditorMode = (value: string | null): value is NoteEditorMode =>
|
||||
|
||||
const InlineMarkdownEditorFallback = () => (
|
||||
<div
|
||||
className="netcatty-lazy-fade-in min-h-[420px]"
|
||||
className="min-h-[420px] bg-background"
|
||||
data-notes-editor-loading="true"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
@@ -388,6 +393,9 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
NOTES_TREE_DEFAULT_WIDTH,
|
||||
{ min: NOTES_TREE_MIN_WIDTH, max: NOTES_TREE_MAX_WIDTH },
|
||||
);
|
||||
const treeAsideRef = useRef<HTMLElement | null>(null);
|
||||
const treeWidthRef = useRef(treeWidth);
|
||||
treeWidthRef.current = treeWidth;
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const importFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const isImportingMarkdownRef = useRef(false);
|
||||
@@ -1232,7 +1240,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
return (
|
||||
<div
|
||||
key={`new-folder-${parent || "root"}`}
|
||||
className="flex h-7 items-center px-2 text-sm"
|
||||
className="flex h-7 min-w-0 items-center px-2 text-sm"
|
||||
style={{ paddingLeft: depth * 16 + 4 }}
|
||||
>
|
||||
<div className="mr-1 h-5 w-4 shrink-0" />
|
||||
@@ -1444,23 +1452,53 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
event.stopPropagation();
|
||||
|
||||
const startX = event.clientX;
|
||||
const startWidth = treeWidth;
|
||||
const startWidth = treeWidthRef.current;
|
||||
const previousCursor = document.body.style.cursor;
|
||||
const previousUserSelect = document.body.style.userSelect;
|
||||
const aside = treeAsideRef.current;
|
||||
let frame = 0;
|
||||
let latestWidth = startWidth;
|
||||
|
||||
// Avoid setState on every pointermove — NotesManager owns the MDX editor and
|
||||
// re-rendering it per pixel makes sidebar resize feel stuck.
|
||||
setIsTreeResizing(true);
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
if (aside) {
|
||||
aside.style.willChange = "width";
|
||||
}
|
||||
|
||||
const clampWidth = (value: number) =>
|
||||
Math.max(NOTES_TREE_MIN_WIDTH, Math.min(NOTES_TREE_MAX_WIDTH, value));
|
||||
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
setTreeWidth(clampWidth(startWidth + moveEvent.clientX - startX));
|
||||
const applyWidth = (width: number) => {
|
||||
latestWidth = width;
|
||||
if (aside) {
|
||||
aside.style.width = `${width}px`;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerUp = (upEvent: PointerEvent) => {
|
||||
const nextWidth = clampWidth(startWidth + upEvent.clientX - startX);
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
const nextWidth = clampNotesTreeWidth(startWidth + moveEvent.clientX - startX);
|
||||
if (frame) {
|
||||
latestWidth = nextWidth;
|
||||
return;
|
||||
}
|
||||
latestWidth = nextWidth;
|
||||
frame = window.requestAnimationFrame(() => {
|
||||
frame = 0;
|
||||
applyWidth(latestWidth);
|
||||
});
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
if (frame) {
|
||||
window.cancelAnimationFrame(frame);
|
||||
frame = 0;
|
||||
}
|
||||
const nextWidth = clampNotesTreeWidth(latestWidth);
|
||||
applyWidth(nextWidth);
|
||||
if (aside) {
|
||||
aside.style.willChange = "";
|
||||
}
|
||||
treeWidthRef.current = nextWidth;
|
||||
setTreeWidth(nextWidth);
|
||||
persistTreeWidth(nextWidth);
|
||||
setIsTreeResizing(false);
|
||||
@@ -1474,7 +1512,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", handlePointerUp);
|
||||
window.addEventListener("pointercancel", handlePointerUp);
|
||||
}, [persistTreeWidth, setTreeWidth, treeWidth]);
|
||||
}, [persistTreeWidth, setTreeWidth]);
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full min-h-0 flex-col overflow-hidden">
|
||||
@@ -1491,16 +1529,20 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{shouldShowNotesTree && (
|
||||
<aside
|
||||
ref={treeAsideRef}
|
||||
className={cn(
|
||||
"relative flex flex-col bg-background",
|
||||
isSidebarMode ? "min-w-0 flex-1" : "shrink-0 border-r border-border/60",
|
||||
// min-w-0 on the aside; overflow-hidden stays on the inner tree shell
|
||||
// so the resize separator's translate-x-1/2 hit area is not clipped.
|
||||
"relative flex min-w-0 flex-col bg-background",
|
||||
isSidebarMode ? "flex-1" : "shrink-0 border-r border-border/60",
|
||||
)}
|
||||
style={isSidebarMode ? undefined : { width: treeWidth }}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="min-w-0 flex-shrink-0 overflow-hidden">
|
||||
<div className={cn(
|
||||
TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS,
|
||||
"flex items-center gap-1 border-b border-border/60 px-1.5",
|
||||
"flex min-w-0 items-center gap-1 overflow-x-auto border-b border-border/60 px-1.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
||||
)}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -1645,9 +1687,13 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
{/* Radix ScrollArea viewport child defaults to display:table, which
|
||||
expands to content width and blocks title ellipsis on narrow trees.
|
||||
Force block + min-w-0 so rows shrink with the sidebar (same pattern
|
||||
as AsidePanelContent). */}
|
||||
<ScrollArea className="min-w-0 flex-1 [&>[data-radix-scroll-area-viewport]>div]:!block [&>[data-radix-scroll-area-viewport]>div]:!min-w-0">
|
||||
<div
|
||||
className="min-h-full space-y-1 px-1.5 pt-1.5 pb-4"
|
||||
className="min-h-full min-w-0 space-y-1 overflow-hidden px-1.5 pt-1.5 pb-4"
|
||||
data-notes-drop-zone="root"
|
||||
onDragOver={(event) => {
|
||||
if (!hasNotesTreeDrag(event.dataTransfer)) return;
|
||||
@@ -1667,6 +1713,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
{!isSidebarMode && (
|
||||
<div
|
||||
role="separator"
|
||||
@@ -1685,7 +1732,14 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
)}
|
||||
|
||||
{!isSidebarMode && (
|
||||
<main className="flex min-w-0 flex-1 flex-col bg-background">
|
||||
<main
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 flex-col bg-background",
|
||||
// Keep the editor out of hit-testing / paint thrash while the tree
|
||||
// width is driven from the DOM during resize.
|
||||
isTreeResizing && "pointer-events-none",
|
||||
)}
|
||||
>
|
||||
{selectedNoteView ? (
|
||||
<>
|
||||
<div className="flex min-h-[54px] shrink-0 items-center gap-3 px-8 pt-6 pb-1" data-note-title-row>
|
||||
@@ -1712,10 +1766,10 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LazyLoadBoundary name="Notes editor" resetKey={selectedNoteView.id}>
|
||||
<LazyLoadBoundary name="Notes editor" resetKey="notes-editor">
|
||||
<Suspense fallback={<InlineMarkdownEditorFallback />}>
|
||||
<InlineMarkdownEditor
|
||||
key={selectedNoteView.id}
|
||||
noteId={selectedNoteView.id}
|
||||
value={selectedNoteView.content}
|
||||
placeholder={t("notes.editor.placeholder")}
|
||||
editorMode={noteEditorMode}
|
||||
@@ -1803,10 +1857,10 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LazyLoadBoundary name="Notes editor" resetKey={overlayNoteView.id}>
|
||||
<LazyLoadBoundary name="Notes editor" resetKey="notes-overlay-editor">
|
||||
<Suspense fallback={<InlineMarkdownEditorFallback />}>
|
||||
<InlineMarkdownEditor
|
||||
key={overlayNoteView.id}
|
||||
noteId={overlayNoteView.id}
|
||||
value={overlayNoteView.content}
|
||||
placeholder={t("notes.editor.placeholder")}
|
||||
editorMode={noteEditorMode}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -13,3 +13,10 @@ test("hidden selected theme uses the normal theme item row", () => {
|
||||
assert.match(source, /hiddenSelectedTheme && \(\s*<ThemeItem/);
|
||||
assert.doesNotMatch(source, /terminal\.hiddenTheme\.title[\s\S]*terminal\.hiddenTheme\.desc/);
|
||||
});
|
||||
|
||||
test("theme side panel tabs are vertically centered in the header", () => {
|
||||
assert.match(
|
||||
source,
|
||||
/TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS, 'flex items-center px-1\.5 gap-0\.5 border-b'/,
|
||||
);
|
||||
});
|
||||
@@ -311,7 +311,7 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
|
||||
>
|
||||
{/* Tab Bar */}
|
||||
<div
|
||||
className={cn(TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS, 'flex px-1.5 gap-0.5 border-b')}
|
||||
className={cn(TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS, 'flex items-center px-1.5 gap-0.5 border-b')}
|
||||
style={{ borderColor: 'var(--terminal-panel-border)' }}
|
||||
>
|
||||
<button
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import { isSensitiveTerminalChallenge } from "../../../domain/terminalPromptSecurity";
|
||||
import { sliceStringByCellColumns } from "./terminalStringCellWidth";
|
||||
import { COMMON_SHELL_COMMANDS, NON_PROMPT_PATTERNS, PROMPT_CHARS } from "./promptDetectorPatterns";
|
||||
|
||||
export interface PromptDetectionResult {
|
||||
@@ -83,7 +84,8 @@ function getCursorLinePrefix(term: XTerm): string | null {
|
||||
|
||||
if (!line) return null;
|
||||
|
||||
return line.translateToString(false).substring(0, Math.max(0, buffer.cursorX));
|
||||
const lineText = line.translateToString(false);
|
||||
return sliceStringByCellColumns(lineText, 0, Math.max(0, buffer.cursorX), term);
|
||||
}
|
||||
|
||||
function getWrappedCursorPrefix(term: XTerm): string | null {
|
||||
@@ -112,7 +114,8 @@ function getWrappedCursorPrefix(term: XTerm): string | null {
|
||||
prefix += rowLine.translateToString(false);
|
||||
}
|
||||
|
||||
return prefix + line.translateToString(false).substring(0, Math.max(0, cursorX));
|
||||
const cursorRowText = line.translateToString(false);
|
||||
return prefix + sliceStringByCellColumns(cursorRowText, 0, Math.max(0, cursorX), term);
|
||||
}
|
||||
|
||||
function inferPromptTextBeforeTypedInput(
|
||||
@@ -424,13 +427,27 @@ function endsWithHostStyleGreaterThanPrompt(promptText: string): boolean {
|
||||
return /^[\w.-]+$/.test(promptName) && !isLikelyBareMongoPromptName(promptName);
|
||||
}
|
||||
|
||||
function endsWithWindowsPathGreaterThanPrompt(promptText: string): boolean {
|
||||
const trimmed = promptText.trimEnd();
|
||||
if (!trimmed.endsWith(">")) return false;
|
||||
const before = trimmed.slice(0, -1).trimEnd();
|
||||
// cmd.exe: `C:\path>` / `C:\>`; PowerShell: `PS C:\path>`
|
||||
if (/^[A-Za-z]:[\\/]/.test(before)) return true;
|
||||
if (/^PS\s+[A-Za-z]:[\\/]/i.test(before)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function endsWithStandardShellPrompt(promptText: string): boolean {
|
||||
const finalChar = promptText.trimEnd().at(-1);
|
||||
return finalChar === "$" || finalChar === "#" || finalChar === "%";
|
||||
}
|
||||
|
||||
function allowsShortPromptEcho(promptText: string): boolean {
|
||||
return endsWithStandardShellPrompt(promptText) || endsWithHostStyleGreaterThanPrompt(promptText);
|
||||
return (
|
||||
endsWithStandardShellPrompt(promptText) ||
|
||||
endsWithHostStyleGreaterThanPrompt(promptText) ||
|
||||
endsWithWindowsPathGreaterThanPrompt(promptText)
|
||||
);
|
||||
}
|
||||
|
||||
function isReliableTypedPrefix(
|
||||
@@ -539,25 +556,29 @@ export function detectPrompt(term: XTerm): PromptDetectionResult {
|
||||
// Empty line
|
||||
if (lineText.trim().length === 0) return NO_PROMPT;
|
||||
|
||||
const cursorLinePrefix = lineText.substring(0, Math.max(0, cursorX));
|
||||
// cursorX is a cell column; lineText is characters. Wide glyphs (CJK in a
|
||||
// Windows `C:\Users\用户>` prompt) make substring(cursorX) overshoot into
|
||||
// xterm's empty-cell padding and poison userInput with spaces (#2813 CMD).
|
||||
const cursorLinePrefix = sliceStringByCellColumns(lineText, 0, Math.max(0, cursorX), term);
|
||||
const afterCursor = sliceStringByCellColumns(lineText, Math.max(0, cursorX), undefined, term);
|
||||
// Try to find the prompt boundary on the current line. xterm buffer rows are
|
||||
// padded with blank cells; when the cursor is at the visible row end, scan
|
||||
// only up to the cursor so prompts like "root@host:~#" do not inherit a fake
|
||||
// trailing space. If there is command text to the right of the cursor, keep
|
||||
// the full line so "$" / ">" inside mid-line edits are validated against
|
||||
// their real following character.
|
||||
const promptScanText = lineText.slice(Math.max(0, cursorX)).trim().length > 0
|
||||
const promptScanText = afterCursor.trim().length > 0
|
||||
? lineText
|
||||
: cursorLinePrefix;
|
||||
const promptEnd = findPromptBoundary(promptScanText);
|
||||
if (promptEnd >= 0) {
|
||||
const promptText = lineText.substring(0, promptEnd);
|
||||
// Use cursor position to determine actual input length — don't trim trailing
|
||||
// spaces since they're significant for autocomplete (e.g., "git commit " should
|
||||
// produce an empty trailing token to trigger option suggestions).
|
||||
const rawInput = lineText.substring(promptEnd);
|
||||
const userInput = rawInput.substring(0, Math.max(0, cursorX - promptEnd));
|
||||
const cursorOffset = Math.max(0, cursorX - promptEnd);
|
||||
// Input is whatever sits between the prompt and the cursor on the cell-
|
||||
// accurate prefix — don't use cursorX as a character index.
|
||||
const userInput = cursorLinePrefix.length >= promptEnd
|
||||
? cursorLinePrefix.substring(promptEnd)
|
||||
: "";
|
||||
const cursorOffset = userInput.length;
|
||||
|
||||
return { isAtPrompt: true, promptText, userInput, cursorOffset };
|
||||
}
|
||||
|
||||
@@ -102,3 +102,62 @@ export function stringCellWidth(
|
||||
}
|
||||
return fallbackStringCellWidth(s);
|
||||
}
|
||||
|
||||
/**
|
||||
* Slice a terminal line string by cell columns (xterm `cursorX` units).
|
||||
*
|
||||
* `translateToString()` returns characters, but `buffer.cursorX` is a cell
|
||||
* column. Mixing them with `String#substring(cursorX)` pulls padding spaces
|
||||
* into user input whenever the prompt contains wide glyphs (CJK paths in
|
||||
* Windows CMD / PowerShell), which breaks autocomplete matching (#2813).
|
||||
*/
|
||||
export function sliceStringByCellColumns(
|
||||
text: string,
|
||||
startCell: number,
|
||||
endCell?: number,
|
||||
term?: XTerm | TermWithUnicodeService | null,
|
||||
): string {
|
||||
if (!text) return "";
|
||||
const start = Math.max(0, startCell);
|
||||
const end = endCell === undefined ? Number.POSITIVE_INFINITY : Math.max(start, endCell);
|
||||
if (end === 0) return "";
|
||||
|
||||
let cell = 0;
|
||||
let startIndex = 0;
|
||||
let endIndex = text.length;
|
||||
let sawStart = false;
|
||||
|
||||
const advance = (segment: string, index: number, segmentLength: number): boolean => {
|
||||
const width = stringCellWidth(segment, term);
|
||||
const nextCell = cell + width;
|
||||
if (!sawStart && nextCell > start) {
|
||||
startIndex = index;
|
||||
sawStart = true;
|
||||
}
|
||||
if (nextCell >= end) {
|
||||
endIndex = nextCell === end ? index + segmentLength : index;
|
||||
if (!sawStart) {
|
||||
startIndex = index;
|
||||
sawStart = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
cell = nextCell;
|
||||
return false;
|
||||
};
|
||||
|
||||
if (graphemeSegmenter) {
|
||||
for (const { segment, index } of graphemeSegmenter.segment(text)) {
|
||||
if (advance(segment, index, segment.length)) break;
|
||||
}
|
||||
} else {
|
||||
let index = 0;
|
||||
for (const ch of text) {
|
||||
if (advance(ch, index, ch.length)) break;
|
||||
index += ch.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (!sawStart) return "";
|
||||
return text.slice(startIndex, endIndex);
|
||||
}
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
resolveAutocompleteQueryInput,
|
||||
shouldBlockAutocompleteForSensitivePrompt,
|
||||
} from "./terminalAutocompletePrompt";
|
||||
import { sliceStringByCellColumns } from "./terminalStringCellWidth";
|
||||
import { isTerminalAlternateScreenActive } from "../terminalHibernateRuntime";
|
||||
|
||||
|
||||
@@ -827,8 +828,15 @@ export function useTerminalAutocomplete(
|
||||
// Suppress autocomplete when cursor is not at end of input —
|
||||
// inserting text mid-line would corrupt the command (e.g., "git st|tus" → "git statustus")
|
||||
const buffer = term.buffer.active;
|
||||
const lineAfterCursor = buffer.getLine(buffer.cursorY + buffer.baseY)
|
||||
?.translateToString(false).substring(buffer.cursorX).trimEnd();
|
||||
const cursorLine = buffer.getLine(buffer.cursorY + buffer.baseY);
|
||||
const lineAfterCursor = cursorLine
|
||||
? sliceStringByCellColumns(
|
||||
cursorLine.translateToString(false),
|
||||
Math.max(0, buffer.cursorX),
|
||||
undefined,
|
||||
term,
|
||||
).trimEnd()
|
||||
: "";
|
||||
if (lineAfterCursor && lineAfterCursor.length > 0) {
|
||||
clearState();
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
cancelConnectAutomationBatch,
|
||||
createConnectAutomationBatch,
|
||||
trackConnectAutomationStop,
|
||||
} from "./connectAutomationBatch.ts";
|
||||
|
||||
test("cancel waits for the stop operation registered by the abort listener", async () => {
|
||||
const batch = createConnectAutomationBatch();
|
||||
let releaseStop: (() => void) | undefined;
|
||||
let stopFinished = false;
|
||||
|
||||
batch.controller.signal.addEventListener("abort", () => {
|
||||
trackConnectAutomationStop(batch, () => new Promise<void>((resolve) => {
|
||||
releaseStop = () => {
|
||||
stopFinished = true;
|
||||
resolve();
|
||||
};
|
||||
}));
|
||||
}, { once: true });
|
||||
|
||||
const cancelling = cancelConnectAutomationBatch(batch);
|
||||
await Promise.resolve();
|
||||
assert.equal(stopFinished, false);
|
||||
releaseStop?.();
|
||||
await cancelling;
|
||||
assert.equal(stopFinished, true);
|
||||
});
|
||||
|
||||
test("cancel surfaces a backend stop failure", async () => {
|
||||
const batch = createConnectAutomationBatch();
|
||||
batch.controller.signal.addEventListener("abort", () => {
|
||||
trackConnectAutomationStop(batch, async () => {
|
||||
throw new Error("stop failed");
|
||||
});
|
||||
}, { once: true });
|
||||
|
||||
await assert.rejects(() => cancelConnectAutomationBatch(batch), /stop failed/);
|
||||
});
|
||||
|
||||
test("cancel can retry a stop operation after a transient failure", async () => {
|
||||
const batch = createConnectAutomationBatch();
|
||||
let attempts = 0;
|
||||
trackConnectAutomationStop(batch, async () => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) throw new Error("temporary stop failure");
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => cancelConnectAutomationBatch(batch),
|
||||
/temporary stop failure/,
|
||||
);
|
||||
await cancelConnectAutomationBatch(batch);
|
||||
assert.equal(attempts, 2);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
export interface ConnectAutomationBatch {
|
||||
controller: AbortController;
|
||||
stopCurrentRun: (() => Promise<void>) | null;
|
||||
}
|
||||
|
||||
export const createConnectAutomationBatch = (): ConnectAutomationBatch => ({
|
||||
controller: new AbortController(),
|
||||
stopCurrentRun: null,
|
||||
});
|
||||
|
||||
export const trackConnectAutomationStop = (
|
||||
batch: ConnectAutomationBatch,
|
||||
stopCurrentRun: (() => Promise<void>) | null,
|
||||
): void => {
|
||||
batch.stopCurrentRun = stopCurrentRun;
|
||||
};
|
||||
|
||||
export const cancelConnectAutomationBatch = async (
|
||||
batch: ConnectAutomationBatch,
|
||||
): Promise<void> => {
|
||||
batch.controller.abort();
|
||||
if (batch.stopCurrentRun) {
|
||||
await batch.stopCurrentRun();
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -4,6 +4,8 @@ import { readFileSync } from "node:fs";
|
||||
|
||||
import {
|
||||
getInitialTerminalStatus,
|
||||
resolveTerminalVaultInitialized,
|
||||
shouldResetConnectAutomationOnReconnect,
|
||||
shouldSuppressHostStartupCommandOnReconnect,
|
||||
shouldStartTerminalBackend,
|
||||
} from "./restoredSessionGate.ts";
|
||||
@@ -41,12 +43,108 @@ test("terminal boot waits for vaultInitialized before creating a backend session
|
||||
);
|
||||
});
|
||||
|
||||
test("terminal popup can supply its own completed vault hydration state", () => {
|
||||
const terminalSource = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
|
||||
const popupSource = readFileSync(new URL("../TerminalPopupPage.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(
|
||||
terminalSource,
|
||||
/const vaultInitialized = resolveTerminalVaultInitialized\(\s*sharedVaultInitialized,\s*vaultInitializedOverride,\s*\);/,
|
||||
);
|
||||
assert.match(popupSource, /vaultInitializedOverride=\{vaultInitialized\}/);
|
||||
assert.equal(resolveTerminalVaultInitialized(false, true), true);
|
||||
assert.equal(resolveTerminalVaultInitialized(false), false);
|
||||
assert.equal(resolveTerminalVaultInitialized(true, false), false);
|
||||
});
|
||||
|
||||
test("host startup command policy distinguishes restored and automatic reconnects", () => {
|
||||
assert.equal(shouldSuppressHostStartupCommandOnReconnect("restored"), false);
|
||||
assert.equal(shouldSuppressHostStartupCommandOnReconnect("manual"), false);
|
||||
assert.equal(shouldSuppressHostStartupCommandOnReconnect("automatic"), true);
|
||||
});
|
||||
|
||||
test("connect automation reset policy distinguishes manual and automatic reconnects", () => {
|
||||
assert.equal(shouldResetConnectAutomationOnReconnect("manual"), true);
|
||||
assert.equal(shouldResetConnectAutomationOnReconnect("restored"), true);
|
||||
assert.equal(shouldResetConnectAutomationOnReconnect("automatic"), false);
|
||||
});
|
||||
|
||||
test("manual reconnect resets connect automation before opening a new session", () => {
|
||||
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
|
||||
const reconnectIndex = source.indexOf("const startReconnect = ");
|
||||
const cancelBatchIndex = source.indexOf(
|
||||
"await cancelConnectAutomationBatch(connectAutomationBatch)",
|
||||
reconnectIndex,
|
||||
);
|
||||
const manualBranchIndex = source.indexOf('if (mode === "manual")', reconnectIndex);
|
||||
const stopFailureRetryIndex = source.indexOf(
|
||||
'if (mode === "auto" && retryTokenStillCurrent())',
|
||||
cancelBatchIndex,
|
||||
);
|
||||
const stopFailureDisconnectedIndex = source.indexOf(
|
||||
'updateStatus("disconnected")',
|
||||
stopFailureRetryIndex,
|
||||
);
|
||||
const resetConsumedIndex = source.indexOf(
|
||||
"connectScriptsConsumedRef.current = false",
|
||||
manualBranchIndex,
|
||||
);
|
||||
const resetCompletedIndex = source.indexOf(
|
||||
"connectScriptsCompletedIdsRef.current = new Set()",
|
||||
manualBranchIndex,
|
||||
);
|
||||
const resetInFlightIndex = source.indexOf(
|
||||
"connectScriptsInFlightRef.current = false",
|
||||
manualBranchIndex,
|
||||
);
|
||||
const connectingIndex = source.indexOf('updateStatus("connecting")', manualBranchIndex);
|
||||
const autoElseIndex = source.indexOf("} else {", manualBranchIndex);
|
||||
const autoSuppressIndex = source.indexOf(
|
||||
'shouldSuppressHostStartupCommandOnReconnect("automatic")',
|
||||
autoElseIndex,
|
||||
);
|
||||
|
||||
assert.notEqual(reconnectIndex, -1);
|
||||
assert.notEqual(cancelBatchIndex, -1);
|
||||
assert.notEqual(manualBranchIndex, -1);
|
||||
assert.notEqual(stopFailureRetryIndex, -1);
|
||||
assert.notEqual(stopFailureDisconnectedIndex, -1);
|
||||
assert.notEqual(resetConsumedIndex, -1);
|
||||
assert.notEqual(resetCompletedIndex, -1);
|
||||
assert.notEqual(resetInFlightIndex, -1);
|
||||
assert.notEqual(connectingIndex, -1);
|
||||
assert.notEqual(autoElseIndex, -1);
|
||||
assert.notEqual(autoSuppressIndex, -1);
|
||||
assert.ok(
|
||||
cancelBatchIndex < manualBranchIndex
|
||||
&& cancelBatchIndex < resetConsumedIndex
|
||||
&& cancelBatchIndex < resetCompletedIndex
|
||||
&& cancelBatchIndex < resetInFlightIndex,
|
||||
"every reconnect must await onConnect cancellation before manual guards are cleared",
|
||||
);
|
||||
assert.ok(
|
||||
stopFailureRetryIndex < stopFailureDisconnectedIndex
|
||||
&& stopFailureDisconnectedIndex < manualBranchIndex,
|
||||
"automatic reconnect must return to disconnected when stopping the old batch fails",
|
||||
);
|
||||
assert.ok(
|
||||
resetConsumedIndex < connectingIndex && resetCompletedIndex < connectingIndex,
|
||||
"manual reconnect must clear connect-automation consumption before status becomes connecting",
|
||||
);
|
||||
assert.ok(
|
||||
autoElseIndex < autoSuppressIndex && autoSuppressIndex < connectingIndex,
|
||||
"automatic reconnect must keep the existing connect-automation consumption decision",
|
||||
);
|
||||
assert.ok(
|
||||
!source.slice(autoElseIndex, connectingIndex).includes("connectScriptsConsumedRef.current = false"),
|
||||
"automatic reconnect must not reset connect-automation refs",
|
||||
);
|
||||
assert.match(
|
||||
source.slice(autoElseIndex, connectingIndex),
|
||||
/connectScriptsConsumedRef\.current = true/,
|
||||
"automatic reconnect must stop the old batch without queuing it again",
|
||||
);
|
||||
});
|
||||
test("restored disconnected sessions still create a terminal runtime before backend startup", () => {
|
||||
const source = readFileSync(new URL("./useTerminalEffects.ts", import.meta.url), "utf8");
|
||||
const runtimeIndex = source.indexOf("const runtime = createXTermRuntime");
|
||||
|
||||
@@ -7,6 +7,11 @@ export const getInitialTerminalStatus = (): TerminalSession["status"] => (
|
||||
"connecting"
|
||||
);
|
||||
|
||||
export const resolveTerminalVaultInitialized = (
|
||||
sharedVaultInitialized: boolean,
|
||||
windowVaultInitialized?: boolean,
|
||||
): boolean => windowVaultInitialized ?? sharedVaultInitialized;
|
||||
|
||||
/**
|
||||
* Backend start waits for vault hydration so restored sessions cannot dial SSH
|
||||
* with empty keys while hosts have already been published mid-init.
|
||||
@@ -16,3 +21,11 @@ export const shouldStartTerminalBackend = (): boolean => isVaultInitialized();
|
||||
export const shouldSuppressHostStartupCommandOnReconnect = (
|
||||
mode: TerminalReconnectMode,
|
||||
): boolean => mode === "automatic";
|
||||
|
||||
/**
|
||||
* Manual and restored reconnects are a fresh "connect" for automation scripts.
|
||||
* Automatic reconnect after a drop must not re-run onConnect scripts mid-session.
|
||||
*/
|
||||
export const shouldResetConnectAutomationOnReconnect = (
|
||||
mode: TerminalReconnectMode,
|
||||
): boolean => mode !== "automatic";
|
||||
@@ -70,6 +70,27 @@ test("mouse-tracking context menu capture lets Shift-modified mouse events pass
|
||||
);
|
||||
});
|
||||
|
||||
test("mouse-tracking context menu capture prefers the terminal's current mode over stale cached state", () => {
|
||||
assert.equal(
|
||||
shouldInterceptMouseTrackingContextMenu({
|
||||
event: { shiftKey: false } as MouseEvent,
|
||||
mouseTracking: false,
|
||||
terminalMouseTrackingMode: "vt200",
|
||||
status: "connected",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldInterceptMouseTrackingContextMenu({
|
||||
event: { shiftKey: false } as MouseEvent,
|
||||
mouseTracking: true,
|
||||
terminalMouseTrackingMode: "none",
|
||||
status: "connected",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("mouse-tracking context menu capture yields to the fullscreen-apps menu setting for context-menu clicks", () => {
|
||||
// Setting on + context-menu behavior: do NOT intercept, so Radix opens the menu.
|
||||
assert.equal(
|
||||
@@ -255,6 +276,31 @@ test("right-click mousedown is stopped when the fullscreen-apps menu setting for
|
||||
);
|
||||
});
|
||||
|
||||
test("right-click mousedown also uses the terminal's current mode", () => {
|
||||
assert.equal(
|
||||
shouldStopShiftRightClickMouseTrackingMouseDown({
|
||||
event: { button: 2, shiftKey: false } as MouseEvent,
|
||||
mouseTracking: false,
|
||||
terminalMouseTrackingMode: "vt200",
|
||||
status: "connected",
|
||||
rightClickBehavior: "context-menu",
|
||||
forceMenuInAlternateScreen: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldStopShiftRightClickMouseTrackingMouseDown({
|
||||
event: { button: 2, shiftKey: false } as MouseEvent,
|
||||
mouseTracking: true,
|
||||
terminalMouseTrackingMode: "none",
|
||||
status: "connected",
|
||||
rightClickBehavior: "context-menu",
|
||||
forceMenuInAlternateScreen: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("Shift right-click mousedown capture is limited to connected mouse tracking", () => {
|
||||
const baseEvent = {
|
||||
button: 2,
|
||||
|
||||
@@ -16,6 +16,8 @@ type ShiftSelectionReplayMouseEvent = MouseEvent & {
|
||||
export interface MouseTrackingContextMenuCaptureState {
|
||||
event: MouseEvent;
|
||||
mouseTracking: boolean;
|
||||
/** Current xterm mouse tracking mode, when available at event time. */
|
||||
terminalMouseTrackingMode?: string;
|
||||
status?: string | null;
|
||||
/** The user's configured right-click action. */
|
||||
rightClickBehavior?: RightClickBehavior;
|
||||
@@ -33,6 +35,8 @@ export interface ShiftMouseSelectionReplayState {
|
||||
export interface ShiftRightClickMouseDownCaptureState {
|
||||
event: MouseEvent;
|
||||
mouseTracking: boolean;
|
||||
/** Current xterm mouse tracking mode, when available at event time. */
|
||||
terminalMouseTrackingMode?: string;
|
||||
status?: string | null;
|
||||
/** The user's configured right-click action. */
|
||||
rightClickBehavior?: RightClickBehavior;
|
||||
@@ -90,14 +94,23 @@ const forcesMenuOverMouseTracking = ({
|
||||
forceMenuInAlternateScreen?: boolean;
|
||||
}): boolean => Boolean(forceMenuInAlternateScreen && rightClickBehavior === "context-menu");
|
||||
|
||||
export const isMouseTrackingActive = ({
|
||||
mouseTracking,
|
||||
terminalMouseTrackingMode,
|
||||
}: Pick<MouseTrackingContextMenuCaptureState, "mouseTracking" | "terminalMouseTrackingMode">): boolean =>
|
||||
terminalMouseTrackingMode === undefined
|
||||
? mouseTracking
|
||||
: terminalMouseTrackingMode !== "none";
|
||||
|
||||
export const shouldInterceptMouseTrackingContextMenu = ({
|
||||
event,
|
||||
mouseTracking,
|
||||
terminalMouseTrackingMode,
|
||||
status,
|
||||
rightClickBehavior,
|
||||
forceMenuInAlternateScreen,
|
||||
}: MouseTrackingContextMenuCaptureState): boolean =>
|
||||
mouseTracking
|
||||
isMouseTrackingActive({ mouseTracking, terminalMouseTrackingMode })
|
||||
&& status === "connected"
|
||||
&& !event.shiftKey
|
||||
&& !isMiddleClickContextMenuEvent(event)
|
||||
@@ -122,11 +135,12 @@ export const shouldReplayShiftMouseSelectionAsMacOption = ({
|
||||
export const shouldStopShiftRightClickMouseTrackingMouseDown = ({
|
||||
event,
|
||||
mouseTracking,
|
||||
terminalMouseTrackingMode,
|
||||
status,
|
||||
rightClickBehavior,
|
||||
forceMenuInAlternateScreen,
|
||||
}: ShiftRightClickMouseDownCaptureState): boolean =>
|
||||
mouseTracking
|
||||
isMouseTrackingActive({ mouseTracking, terminalMouseTrackingMode })
|
||||
&& status === "connected"
|
||||
&& event.button === 2
|
||||
&& (event.shiftKey || forcesMenuOverMouseTracking({ rightClickBehavior, forceMenuInAlternateScreen }));
|
||||
|
||||
@@ -3,9 +3,15 @@ import test from "node:test";
|
||||
|
||||
import { getSessionLogInitialLine } from "./sessionLogInitialLine.ts";
|
||||
|
||||
const makeTerm = (rows: Array<{ text: string; isWrapped?: boolean }>, cursorY: number, cursorX: number) => ({
|
||||
const makeTerm = (
|
||||
rows: Array<{ text: string; isWrapped?: boolean }>,
|
||||
cursorY: number,
|
||||
cursorX: number,
|
||||
options: { type?: "normal" | "alternate" } = {},
|
||||
) => ({
|
||||
buffer: {
|
||||
active: {
|
||||
type: options.type ?? "normal",
|
||||
baseY: 0,
|
||||
cursorY,
|
||||
cursorX,
|
||||
@@ -37,3 +43,17 @@ test("getSessionLogInitialLine falls back to nearest trusted prompt line", () =>
|
||||
|
||||
assert.equal(getSessionLogInitialLine(term), "root@MyNAS:~# ");
|
||||
});
|
||||
|
||||
test("getSessionLogInitialLine skips alternate-screen vim tilde rows", () => {
|
||||
const term = makeTerm(
|
||||
[
|
||||
{ text: "~" },
|
||||
{ text: "\"file.txt\" 0L, 0B" },
|
||||
],
|
||||
0,
|
||||
1,
|
||||
{ type: "alternate" },
|
||||
);
|
||||
|
||||
assert.equal(getSessionLogInitialLine(term), "");
|
||||
});
|
||||
@@ -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" };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -136,6 +136,8 @@ export interface TerminalProps {
|
||||
sessionId: string;
|
||||
workspaceId?: string;
|
||||
restoreState?: TerminalSession["restoreState"];
|
||||
/** Secondary windows hydrate their own vault state outside the main snapshot store. */
|
||||
vaultInitializedOverride?: boolean;
|
||||
pendingInitialCwd?: string;
|
||||
shellType?: TerminalSession["shellType"];
|
||||
lastCwd?: string;
|
||||
|
||||
@@ -40,6 +40,13 @@ test("terminal memo refreshes when restored local shell type changes", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("terminal memo refreshes when a popup vault finishes hydrating", () => {
|
||||
assert.equal(
|
||||
terminalPropsAreEqual(baseProps, { ...baseProps, vaultInitializedOverride: true }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("terminal memo refreshes when terminal context reader callback changes", () => {
|
||||
assert.equal(
|
||||
terminalPropsAreEqual(baseProps, {
|
||||
|
||||
@@ -36,6 +36,7 @@ export const terminalPropsAreEqual = (
|
||||
&& prev.terminalSettings === next.terminalSettings
|
||||
&& prev.sessionId === next.sessionId
|
||||
&& prev.restoreState === next.restoreState
|
||||
&& prev.vaultInitializedOverride === next.vaultInitializedOverride
|
||||
&& prev.shellType === next.shellType
|
||||
&& prev.lastCwd === next.lastCwd
|
||||
&& prev.restoreTerminalCwd === next.restoreTerminalCwd
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
import { stringCellWidth } from "./autocomplete/terminalStringCellWidth.ts";
|
||||
import { stringCellWidth, sliceStringByCellColumns } from "./autocomplete/terminalStringCellWidth.ts";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
@@ -14,6 +14,20 @@ test("stringCellWidth counts CJK ideographs as two cells each", () => {
|
||||
assert.equal(stringCellWidth("部署"), 4);
|
||||
});
|
||||
|
||||
test("sliceStringByCellColumns respects wide CJK cells", () => {
|
||||
const prompt = String.raw`C:\Users\用户>`;
|
||||
const line = `${prompt}部署`;
|
||||
const promptCells = stringCellWidth(prompt);
|
||||
assert.equal(sliceStringByCellColumns(line, 0, promptCells), prompt);
|
||||
assert.equal(sliceStringByCellColumns(line, promptCells), "部署");
|
||||
// Padding spaces after the content must not leak into the cursor prefix.
|
||||
const padded = line + " ".repeat(20);
|
||||
assert.equal(
|
||||
sliceStringByCellColumns(padded, 0, stringCellWidth(line)),
|
||||
line,
|
||||
);
|
||||
});
|
||||
|
||||
test("stringCellWidth collapses ZWJ emoji to one wide grapheme", () => {
|
||||
assert.equal(stringCellWidth("👨💻"), 2);
|
||||
});
|
||||
|
||||
@@ -657,6 +657,12 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
logger.warn("Failed to write attach snapshot to popup terminal", writeErr);
|
||||
}
|
||||
}
|
||||
// Snapshot was applied with term.write(), not the connection-log
|
||||
// sanitizer. Re-seed from the restored buffer so live TUI redraws
|
||||
// after attach (vim already open) stay omitted until leave.
|
||||
terminalLogSanitizerRef.current = createReplaySafeTerminalLogSanitizer({
|
||||
alternateScreenActive: isTerminalAlternateScreenActive(term),
|
||||
});
|
||||
|
||||
const rebind = await terminalBackend.rebindSessionOutput?.(
|
||||
sessionId,
|
||||
@@ -1686,6 +1692,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
if (!shouldInterceptMouseTrackingContextMenu({
|
||||
event: e,
|
||||
mouseTracking: mouseTrackingRef.current,
|
||||
terminalMouseTrackingMode: termRef.current?.modes.mouseTrackingMode,
|
||||
status: statusRef.current,
|
||||
rightClickBehavior: terminalSettingsRef.current?.rightClickBehavior,
|
||||
forceMenuInAlternateScreen: terminalSettingsRef.current?.showContextMenuOverFullscreenApps,
|
||||
@@ -1718,6 +1725,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
if (rightClickPressClaim.noteMouseDown({
|
||||
event: e,
|
||||
mouseTracking: mouseTrackingRef.current,
|
||||
terminalMouseTrackingMode: termRef.current?.modes.mouseTrackingMode,
|
||||
status: statusRef.current,
|
||||
rightClickBehavior: terminalSettingsRef.current?.rightClickBehavior,
|
||||
forceMenuInAlternateScreen: terminalSettingsRef.current?.showContextMenuOverFullscreenApps,
|
||||
|
||||
@@ -7,3 +7,31 @@ const source = readFileSync(new URL('./TerminalFocusSidebar.tsx', import.meta.ur
|
||||
test('focus sidebar row memo refreshes when dynamic title mode changes', () => {
|
||||
assert.match(source, /prev\.dynamicTabTitleMode === next\.dynamicTabTitleMode/);
|
||||
});
|
||||
|
||||
test('focus sidebar accepts host-id drops for append-to-workspace', () => {
|
||||
assert.match(source, /onAppendHostToWorkspace/);
|
||||
assert.match(source, /resolveFocusSidebarDragKind/);
|
||||
assert.match(source, /appendHostFromWorkspaceDrop/);
|
||||
assert.match(source, /dropEffect = 'copy'/);
|
||||
assert.match(source, /data-host-drop-active/);
|
||||
});
|
||||
|
||||
test('the entire focus sidebar is the host drop target', () => {
|
||||
const sidebarStart = source.indexOf('data-section="terminal-workspace-sidebar"');
|
||||
const scrollAreaStart = source.indexOf('<ScrollArea className="flex-1">', sidebarStart);
|
||||
assert.ok(sidebarStart >= 0 && scrollAreaStart > sidebarStart);
|
||||
|
||||
const sidebarOpeningTag = source.slice(sidebarStart, scrollAreaStart);
|
||||
assert.match(sidebarOpeningTag, /data-focus-sidebar-drop-zone/);
|
||||
assert.match(sidebarOpeningTag, /onDragOver=\{handleFocusSidebarHostDragOver\}/);
|
||||
assert.match(sidebarOpeningTag, /onDrop=\{handleFocusSidebarHostDrop\}/);
|
||||
assert.doesNotMatch(source.slice(scrollAreaStart), /\sdata-focus-sidebar-drop-zone(?:\s|>)/);
|
||||
});
|
||||
|
||||
test('focus sidebar clears host-drop feedback when the drag leaves from a session row', () => {
|
||||
assert.match(source, /data-focus-sidebar-drop-zone/);
|
||||
assert.match(source, /onDragLeave=\{handleFocusSidebarHostDragLeave\}/);
|
||||
assert.match(source, /onHostDragLeave=\{handleFocusSidebarHostDragLeave\}/);
|
||||
assert.match(source, /dropZone\?\.contains\(next\)/);
|
||||
assert.match(source, /clearFocusSidebarHostDrop\(\)/);
|
||||
});
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
} from '../../application/state/sessionPresentationStore';
|
||||
import { useStoredNumber } from '../../application/state/useStoredNumber';
|
||||
import { terminalReconnectRegistry } from '../../application/state/terminalReconnectRegistry';
|
||||
import {
|
||||
appendHostFromWorkspaceDrop,
|
||||
FOCUS_SIDEBAR_SESSION_DRAG_TYPE,
|
||||
resolveFocusSidebarDragKind,
|
||||
} from '../../domain/focusSidebarHostDrop';
|
||||
import { resolveWorkspaceFocusSessionOrder } from '../../domain/workspace';
|
||||
import { resolveSessionTabTitle } from '../../domain/sessionTabTitle';
|
||||
import type { DynamicTabTitleMode } from '../../domain/models';
|
||||
@@ -27,6 +32,7 @@ interface TerminalFocusSidebarProps {
|
||||
focusedSessionId: string | undefined;
|
||||
onReorderWorkspaceSessions?: (workspaceId: string, draggedSessionId: string, targetSessionId: string, position: 'before' | 'after') => void;
|
||||
onRequestAddToWorkspace?: (workspaceId: string) => void;
|
||||
onAppendHostToWorkspace?: (workspaceId: string, hostId: string) => void;
|
||||
onCloseSession: (sessionId: string) => void;
|
||||
onCopySession?: (sessionId: string) => void;
|
||||
onCopySessionToNewWindow?: (sessionId: string) => void;
|
||||
@@ -71,6 +77,7 @@ type WorkspaceFocusSessionRowProps = {
|
||||
onSelect: (sessionId: string) => void;
|
||||
onDragStart: (event: DragEvent, sessionId: string) => void;
|
||||
onDragOver: (event: DragEvent, sessionId: string) => void;
|
||||
onHostDragLeave: (event: DragEvent<HTMLDivElement>) => void;
|
||||
onDrop: (event: DragEvent, sessionId: string) => void;
|
||||
onDragEnd: () => void;
|
||||
dynamicTabTitleMode?: DynamicTabTitleMode;
|
||||
@@ -96,6 +103,7 @@ const WorkspaceFocusSessionRow = memo<WorkspaceFocusSessionRowProps>(({
|
||||
onSelect,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onHostDragLeave,
|
||||
onDrop,
|
||||
onDragEnd,
|
||||
dynamicTabTitleMode,
|
||||
@@ -142,9 +150,7 @@ const WorkspaceFocusSessionRow = memo<WorkspaceFocusSessionRowProps>(({
|
||||
onContextMenu={() => onSelect(session.id)}
|
||||
onDragStart={(event) => onDragStart(event, session.id)}
|
||||
onDragOver={(event) => onDragOver(event, session.id)}
|
||||
onDragLeave={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onDragLeave={onHostDragLeave}
|
||||
onDrop={(event) => onDrop(event, session.id)}
|
||||
onDragEnd={onDragEnd}
|
||||
onMouseEnter={(event) => {
|
||||
@@ -247,6 +253,7 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
|
||||
focusedSessionId,
|
||||
onReorderWorkspaceSessions,
|
||||
onRequestAddToWorkspace,
|
||||
onAppendHostToWorkspace,
|
||||
onCloseSession,
|
||||
onCopySession,
|
||||
onCopySessionToNewWindow,
|
||||
@@ -262,6 +269,7 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
|
||||
}) => {
|
||||
const [focusSidebarSearch, setFocusSidebarSearch] = useState('');
|
||||
const [focusSidebarDragSessionId, setFocusSidebarDragSessionId] = useState<string | null>(null);
|
||||
const [focusSidebarHostDropActive, setFocusSidebarHostDropActive] = useState(false);
|
||||
const [focusSidebarDropIndicator, setFocusSidebarDropIndicator] = useState<{
|
||||
sessionId: string;
|
||||
position: 'before' | 'after';
|
||||
@@ -343,10 +351,23 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
|
||||
const handleFocusSidebarDragStart = useCallback((event: DragEvent, sessionId: string) => {
|
||||
event.stopPropagation();
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData('workspace-focus-session-id', sessionId);
|
||||
event.dataTransfer.setData(FOCUS_SIDEBAR_SESSION_DRAG_TYPE, sessionId);
|
||||
setFocusSidebarDragSessionId(sessionId);
|
||||
setFocusSidebarHostDropActive(false);
|
||||
}, []);
|
||||
|
||||
const clearFocusSidebarHostDrop = useCallback(() => {
|
||||
setFocusSidebarHostDropActive(false);
|
||||
}, []);
|
||||
|
||||
const handleFocusSidebarHostDragLeave = useCallback((event: DragEvent<HTMLDivElement>) => {
|
||||
event.stopPropagation();
|
||||
const dropZone = event.currentTarget.closest<HTMLElement>('[data-focus-sidebar-drop-zone]');
|
||||
const next = event.relatedTarget;
|
||||
if (next instanceof Node && dropZone?.contains(next)) return;
|
||||
clearFocusSidebarHostDrop();
|
||||
}, [clearFocusSidebarHostDrop]);
|
||||
|
||||
const getFocusSidebarContainerDropTarget = useCallback((
|
||||
container: HTMLElement,
|
||||
clientY: number,
|
||||
@@ -379,21 +400,45 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
|
||||
return lastSessionId ? { sessionId: lastSessionId, position: 'after' } : null;
|
||||
}, []);
|
||||
|
||||
const acceptFocusSidebarHostDrag = useCallback((event: DragEvent) => {
|
||||
if (!onAppendHostToWorkspace) return false;
|
||||
if (resolveFocusSidebarDragKind({
|
||||
types: event.dataTransfer.types,
|
||||
activeSessionDragId: focusSidebarDragSessionId,
|
||||
}) !== 'host-append') {
|
||||
return false;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
setFocusSidebarDropIndicator(null);
|
||||
setFocusSidebarHostDropActive(true);
|
||||
return true;
|
||||
}, [focusSidebarDragSessionId, onAppendHostToWorkspace]);
|
||||
|
||||
const handleFocusSidebarHostDragOver = useCallback((event: DragEvent<HTMLDivElement>) => {
|
||||
acceptFocusSidebarHostDrag(event);
|
||||
}, [acceptFocusSidebarHostDrag]);
|
||||
|
||||
const handleFocusSidebarDragOver = useCallback((event: DragEvent, targetSessionId: string) => {
|
||||
const draggedSessionId = event.dataTransfer.getData('workspace-focus-session-id') || focusSidebarDragSessionId;
|
||||
if (acceptFocusSidebarHostDrag(event)) return;
|
||||
|
||||
const draggedSessionId = event.dataTransfer.getData(FOCUS_SIDEBAR_SESSION_DRAG_TYPE) || focusSidebarDragSessionId;
|
||||
if (!draggedSessionId || draggedSessionId === targetSessionId) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
setFocusSidebarHostDropActive(false);
|
||||
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const position = event.clientY < rect.top + rect.height / 2 ? 'before' : 'after';
|
||||
setFocusSidebarDropIndicator({ sessionId: targetSessionId, position });
|
||||
}, [focusSidebarDragSessionId]);
|
||||
}, [acceptFocusSidebarHostDrag, focusSidebarDragSessionId]);
|
||||
|
||||
const handleFocusSidebarContainerDragOver = useCallback((event: DragEvent<HTMLDivElement>) => {
|
||||
const draggedSessionId = event.dataTransfer.getData('workspace-focus-session-id') || focusSidebarDragSessionId;
|
||||
if (acceptFocusSidebarHostDrag(event)) return;
|
||||
|
||||
const draggedSessionId = event.dataTransfer.getData(FOCUS_SIDEBAR_SESSION_DRAG_TYPE) || focusSidebarDragSessionId;
|
||||
if (!draggedSessionId) return;
|
||||
|
||||
const target = getFocusSidebarContainerDropTarget(event.currentTarget, event.clientY, draggedSessionId);
|
||||
@@ -401,11 +446,36 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
|
||||
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
setFocusSidebarHostDropActive(false);
|
||||
setFocusSidebarDropIndicator(target);
|
||||
}, [focusSidebarDragSessionId, getFocusSidebarContainerDropTarget]);
|
||||
}, [acceptFocusSidebarHostDrag, focusSidebarDragSessionId, getFocusSidebarContainerDropTarget]);
|
||||
|
||||
const appendHostFromFocusSidebarDrop = useCallback((event: DragEvent) => {
|
||||
if (!onAppendHostToWorkspace) return false;
|
||||
const handled = appendHostFromWorkspaceDrop({
|
||||
types: event.dataTransfer.types,
|
||||
getData: (type) => event.dataTransfer.getData(type),
|
||||
activeSessionDragId: focusSidebarDragSessionId,
|
||||
workspaceId: activeWorkspace.id,
|
||||
onAppendHostToWorkspace,
|
||||
});
|
||||
if (!handled) return false;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setFocusSidebarHostDropActive(false);
|
||||
setFocusSidebarDropIndicator(null);
|
||||
return true;
|
||||
}, [activeWorkspace.id, focusSidebarDragSessionId, onAppendHostToWorkspace]);
|
||||
|
||||
const handleFocusSidebarHostDrop = useCallback((event: DragEvent<HTMLDivElement>) => {
|
||||
appendHostFromFocusSidebarDrop(event);
|
||||
}, [appendHostFromFocusSidebarDrop]);
|
||||
|
||||
const handleFocusSidebarContainerDrop = useCallback((event: DragEvent<HTMLDivElement>) => {
|
||||
const draggedSessionId = event.dataTransfer.getData('workspace-focus-session-id') || focusSidebarDragSessionId;
|
||||
if (appendHostFromFocusSidebarDrop(event)) return;
|
||||
|
||||
const draggedSessionId = event.dataTransfer.getData(FOCUS_SIDEBAR_SESSION_DRAG_TYPE) || focusSidebarDragSessionId;
|
||||
if (!draggedSessionId) return;
|
||||
|
||||
const target = focusSidebarDropIndicator
|
||||
@@ -416,8 +486,10 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
|
||||
onReorderWorkspaceSessions?.(activeWorkspace.id, draggedSessionId, target.sessionId, target.position);
|
||||
setFocusSidebarDragSessionId(null);
|
||||
setFocusSidebarDropIndicator(null);
|
||||
setFocusSidebarHostDropActive(false);
|
||||
}, [
|
||||
activeWorkspace.id,
|
||||
appendHostFromFocusSidebarDrop,
|
||||
focusSidebarDragSessionId,
|
||||
focusSidebarDropIndicator,
|
||||
getFocusSidebarContainerDropTarget,
|
||||
@@ -425,7 +497,9 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
|
||||
]);
|
||||
|
||||
const handleFocusSidebarDrop = useCallback((event: DragEvent, targetSessionId: string) => {
|
||||
const draggedSessionId = event.dataTransfer.getData('workspace-focus-session-id') || focusSidebarDragSessionId;
|
||||
if (appendHostFromFocusSidebarDrop(event)) return;
|
||||
|
||||
const draggedSessionId = event.dataTransfer.getData(FOCUS_SIDEBAR_SESSION_DRAG_TYPE) || focusSidebarDragSessionId;
|
||||
if (!draggedSessionId || draggedSessionId === targetSessionId) return;
|
||||
|
||||
event.preventDefault();
|
||||
@@ -437,11 +511,19 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
|
||||
onReorderWorkspaceSessions?.(activeWorkspace.id, draggedSessionId, targetSessionId, position);
|
||||
setFocusSidebarDragSessionId(null);
|
||||
setFocusSidebarDropIndicator(null);
|
||||
}, [activeWorkspace.id, focusSidebarDragSessionId, focusSidebarDropIndicator, onReorderWorkspaceSessions]);
|
||||
setFocusSidebarHostDropActive(false);
|
||||
}, [
|
||||
activeWorkspace.id,
|
||||
appendHostFromFocusSidebarDrop,
|
||||
focusSidebarDragSessionId,
|
||||
focusSidebarDropIndicator,
|
||||
onReorderWorkspaceSessions,
|
||||
]);
|
||||
|
||||
const handleFocusSidebarDragEnd = useCallback(() => {
|
||||
setFocusSidebarDragSessionId(null);
|
||||
setFocusSidebarDropIndicator(null);
|
||||
setFocusSidebarHostDropActive(false);
|
||||
}, []);
|
||||
|
||||
const handleSelectSession = useCallback((sessionId: string) => {
|
||||
@@ -469,14 +551,27 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-shrink-0 flex flex-col relative"
|
||||
className={cn(
|
||||
'flex-shrink-0 flex flex-col relative transition-[box-shadow,background-color]',
|
||||
focusSidebarHostDropActive && 'ring-1 ring-inset',
|
||||
)}
|
||||
style={{
|
||||
width: focusSidebarWidth,
|
||||
backgroundColor: theme.termBg,
|
||||
backgroundColor: focusSidebarHostDropActive
|
||||
? `color-mix(in srgb, ${theme.termFg} 8%, ${theme.termBg})`
|
||||
: theme.termBg,
|
||||
color: theme.termFg,
|
||||
boxShadow: focusSidebarHostDropActive
|
||||
? `inset 0 0 0 1px color-mix(in srgb, ${theme.termFg} 28%, transparent)`
|
||||
: undefined,
|
||||
['--terminal-workspace-sidebar-border' as string]: `1px solid ${theme.separator}`,
|
||||
}}
|
||||
data-section="terminal-workspace-sidebar"
|
||||
data-focus-sidebar-drop-zone
|
||||
data-host-drop-active={focusSidebarHostDropActive ? 'true' : 'false'}
|
||||
onDragOver={handleFocusSidebarHostDragOver}
|
||||
onDragLeave={handleFocusSidebarHostDragLeave}
|
||||
onDrop={handleFocusSidebarHostDrop}
|
||||
>
|
||||
<div
|
||||
className="absolute top-0 right-[-3px] h-full w-2 cursor-ew-resize z-30"
|
||||
@@ -534,7 +629,7 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div
|
||||
className="p-2 space-y-1"
|
||||
className="p-2 space-y-1 min-h-full rounded-md"
|
||||
onDragOver={handleFocusSidebarContainerDragOver}
|
||||
onDrop={handleFocusSidebarContainerDrop}
|
||||
>
|
||||
@@ -563,6 +658,7 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
|
||||
onSelect={handleSelectSession}
|
||||
onDragStart={handleFocusSidebarDragStart}
|
||||
onDragOver={handleFocusSidebarDragOver}
|
||||
onHostDragLeave={handleFocusSidebarHostDragLeave}
|
||||
onDrop={handleFocusSidebarDrop}
|
||||
onDragEnd={handleFocusSidebarDragEnd}
|
||||
dynamicTabTitleMode={dynamicTabTitleMode}
|
||||
@@ -592,6 +688,7 @@ function terminalFocusSidebarPropsEqual(
|
||||
if (prev.t !== next.t) return false;
|
||||
if (prev.onReorderWorkspaceSessions !== next.onReorderWorkspaceSessions) return false;
|
||||
if (prev.onRequestAddToWorkspace !== next.onRequestAddToWorkspace) return false;
|
||||
if (prev.onAppendHostToWorkspace !== next.onAppendHostToWorkspace) return false;
|
||||
if (prev.onSetWorkspaceFocusedSession !== next.onSetWorkspaceFocusedSession) return false;
|
||||
if (prev.onToggleWorkspaceViewMode !== next.onToggleWorkspaceViewMode) return false;
|
||||
const prevWs = prev.activeWorkspace;
|
||||
|
||||
@@ -22,6 +22,7 @@ const {
|
||||
getTerminalHostTreeInitialLayoutWidth,
|
||||
getTerminalHostTreeLayoutTargetWidth,
|
||||
getTerminalHostTreeMeasuredLayoutWidth,
|
||||
resolveTerminalHostTreeDragCapabilities,
|
||||
getTerminalHostTreeSidebarPanelStyle,
|
||||
getTerminalHostTreeSidebarShellStyle,
|
||||
isTerminalHostTreeSidebarVisible,
|
||||
@@ -186,6 +187,25 @@ test('host tree host inline rename rejects empty names without changing hosts',
|
||||
assert.equal(result.hosts, hosts);
|
||||
});
|
||||
|
||||
test('filtered host rows can drag outward without enabling filtered tree reorder', () => {
|
||||
assert.deepEqual(resolveTerminalHostTreeDragCapabilities({
|
||||
kind: 'host',
|
||||
canReorder: false,
|
||||
isInlineEditing: false,
|
||||
}), {
|
||||
draggable: true,
|
||||
canAcceptDrop: false,
|
||||
});
|
||||
assert.deepEqual(resolveTerminalHostTreeDragCapabilities({
|
||||
kind: 'group',
|
||||
canReorder: false,
|
||||
isInlineEditing: false,
|
||||
}), {
|
||||
draggable: false,
|
||||
canAcceptDrop: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('host tree hover card is hidden while the same host is inline editing', () => {
|
||||
assert.equal(shouldShowTerminalHostHoverCard('host-1', null), true);
|
||||
assert.equal(shouldShowTerminalHostHoverCard('host-1', 'host-2'), true);
|
||||
@@ -217,3 +237,17 @@ test('host tree row icons, labels, and protocol badges share centered line boxes
|
||||
assert.match(source, /flex h-5 shrink-0 items-center">\s*\{isExpanded/);
|
||||
assert.match(source, /flex min-w-0 flex-1 items-center truncate leading-5">\{node\.name\}/);
|
||||
});
|
||||
|
||||
test('filtered host rows can still start host-id drag for focus-sidebar append', () => {
|
||||
const source = readFileSync(new URL('./TerminalHostTreeSidebar.tsx', import.meta.url), 'utf8');
|
||||
const hostRowStart = source.indexOf('data-row-type="host"');
|
||||
const groupRowStart = source.indexOf('data-row-type="group"');
|
||||
assert.ok(hostRowStart >= 0 && groupRowStart > hostRowStart);
|
||||
const hostRowBlock = source.slice(hostRowStart, groupRowStart);
|
||||
|
||||
assert.match(hostRowBlock, /draggable=\{dragCapabilities\.draggable\}/);
|
||||
assert.match(hostRowBlock, /effectAllowed = canReorder \? 'copyMove' : 'copy'/);
|
||||
assert.match(hostRowBlock, /if \(!dragCapabilities\.draggable\) return;/);
|
||||
assert.match(source, /const canReorder = Boolean\(menuActions\) && !searchActive && !tagsActive;/);
|
||||
assert.match(source, /canAcceptDrop: input\.canReorder/);
|
||||
});
|
||||
@@ -104,6 +104,17 @@ type HostTreeDropTarget =
|
||||
| { kind: 'root' }
|
||||
| { kind: 'group'; path: string };
|
||||
|
||||
export function resolveTerminalHostTreeDragCapabilities(input: {
|
||||
kind: HostTreeFlatRow['kind'];
|
||||
canReorder: boolean;
|
||||
isInlineEditing: boolean;
|
||||
}): { draggable: boolean; canAcceptDrop: boolean } {
|
||||
return {
|
||||
draggable: !input.isInlineEditing && (input.kind === 'host' || input.canReorder),
|
||||
canAcceptDrop: input.canReorder,
|
||||
};
|
||||
}
|
||||
|
||||
interface TerminalHostTreeSidebarProps {
|
||||
enabled?: boolean;
|
||||
surfaceVisible?: boolean;
|
||||
@@ -309,7 +320,7 @@ type HostTreeFlatRowProps = {
|
||||
activeHostId?: string | null;
|
||||
expandedPaths: Set<string>;
|
||||
searchActive: boolean;
|
||||
canDrag: boolean;
|
||||
canReorder: boolean;
|
||||
isDragOver: boolean;
|
||||
isInlineEditing: boolean;
|
||||
inlineEditInitialName?: string;
|
||||
@@ -331,7 +342,7 @@ const HostTreeFlatRowItem = memo<HostTreeFlatRowProps>(({
|
||||
activeHostId,
|
||||
expandedPaths,
|
||||
searchActive,
|
||||
canDrag,
|
||||
canReorder,
|
||||
isDragOver,
|
||||
isInlineEditing,
|
||||
inlineEditInitialName,
|
||||
@@ -348,6 +359,11 @@ const HostTreeFlatRowItem = memo<HostTreeFlatRowProps>(({
|
||||
menuActions,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const dragCapabilities = resolveTerminalHostTreeDragCapabilities({
|
||||
kind: row.kind,
|
||||
canReorder,
|
||||
isInlineEditing,
|
||||
});
|
||||
|
||||
if (row.kind === 'host') {
|
||||
const isActive = activeHostId === row.host.id;
|
||||
@@ -373,14 +389,18 @@ const HostTreeFlatRowItem = memo<HostTreeFlatRowProps>(({
|
||||
paddingLeft: row.depth * 16 + 8,
|
||||
backgroundColor: isActive ? theme.rowActiveBg : (isDragOver ? theme.rowDropBg : undefined),
|
||||
}}
|
||||
draggable={canDrag && !isInlineEditing}
|
||||
// Host rows stay draggable under search/tag filters so focus-sidebar
|
||||
// append can still receive host-id; in-tree reorder/drop stays disabled.
|
||||
draggable={dragCapabilities.draggable}
|
||||
onDragStart={(event) => {
|
||||
if (!canDrag || isInlineEditing) return;
|
||||
if (!dragCapabilities.draggable) return;
|
||||
event.dataTransfer.setData(HOST_TREE_DRAG_HOST_ID, row.host.id);
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
// copyMove: tree reorder uses move; focus-sidebar append uses copy.
|
||||
// Filtered trees disable reorder, so allow copy-only for append.
|
||||
event.dataTransfer.effectAllowed = canReorder ? 'copyMove' : 'copy';
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
if (!canDrag) return;
|
||||
if (!dragCapabilities.canAcceptDrop) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (hasDragType(event.dataTransfer, HOST_TREE_DRAG_HOST_ID)) {
|
||||
@@ -393,7 +413,7 @@ const HostTreeFlatRowItem = memo<HostTreeFlatRowProps>(({
|
||||
}}
|
||||
onDragLeave={onDragLeaveRow}
|
||||
onDrop={(event) => {
|
||||
if (!canDrag) return;
|
||||
if (!dragCapabilities.canAcceptDrop) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
clearHostTreeDropIndicators(event.currentTarget.ownerDocument);
|
||||
@@ -507,14 +527,14 @@ const HostTreeFlatRowItem = memo<HostTreeFlatRowProps>(({
|
||||
color: theme.termFg,
|
||||
backgroundColor: isDragOver ? theme.rowDropBg : undefined,
|
||||
}}
|
||||
draggable={canDrag && !isInlineEditing}
|
||||
draggable={dragCapabilities.draggable}
|
||||
onDragStart={(event) => {
|
||||
if (!canDrag || isInlineEditing) return;
|
||||
if (!dragCapabilities.draggable) return;
|
||||
event.dataTransfer.setData(HOST_TREE_DRAG_GROUP_PATH, node.path);
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
if (!canDrag) return;
|
||||
if (!dragCapabilities.canAcceptDrop) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const isDraggingGroup = hasDragType(event.dataTransfer, HOST_TREE_DRAG_GROUP_PATH);
|
||||
@@ -529,7 +549,7 @@ const HostTreeFlatRowItem = memo<HostTreeFlatRowProps>(({
|
||||
}}
|
||||
onDragLeave={onDragLeaveRow}
|
||||
onDrop={(event) => {
|
||||
if (!canDrag) return;
|
||||
if (!dragCapabilities.canAcceptDrop) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
clearHostTreeDropIndicators(event.currentTarget.ownerDocument);
|
||||
@@ -615,7 +635,7 @@ const HostTreeFlatRowItem = memo<HostTreeFlatRowProps>(({
|
||||
if (prev.row !== next.row) return false;
|
||||
if (prev.expandedPaths !== next.expandedPaths) return false;
|
||||
if (prev.searchActive !== next.searchActive) return false;
|
||||
if (prev.canDrag !== next.canDrag) return false;
|
||||
if (prev.canReorder !== next.canReorder) return false;
|
||||
if (prev.isDragOver !== next.isDragOver) return false;
|
||||
if (prev.isInlineEditing !== next.isInlineEditing) return false;
|
||||
if (prev.inlineEditInitialName !== next.inlineEditInitialName) return false;
|
||||
@@ -739,7 +759,7 @@ const TerminalHostTreeSidebarInner: React.FC<TerminalHostTreeSidebarProps> = ({
|
||||
});
|
||||
}, [expandedPaths, filteredTree, filteredUngrouped, treeExpandAll]);
|
||||
|
||||
const canDrag = Boolean(menuActions) && !searchActive && !tagsActive;
|
||||
const canReorder = Boolean(menuActions) && !searchActive && !tagsActive;
|
||||
|
||||
const handleNewRootGroup = useCallback(() => {
|
||||
if (!menuActions) return;
|
||||
@@ -826,10 +846,10 @@ const TerminalHostTreeSidebarInner: React.FC<TerminalHostTreeSidebarProps> = ({
|
||||
}, [clearDragOver]);
|
||||
|
||||
const handleRootDragOver = useCallback((event: React.DragEvent<HTMLDivElement>) => {
|
||||
if (!canDrag) return;
|
||||
if (!canReorder) return;
|
||||
event.preventDefault();
|
||||
setDragOverTarget({ kind: 'root' });
|
||||
}, [canDrag]);
|
||||
}, [canReorder]);
|
||||
|
||||
const handleRootDragLeave = useCallback((event: React.DragEvent<HTMLDivElement>) => {
|
||||
const nextTarget = event.relatedTarget;
|
||||
@@ -841,11 +861,11 @@ const TerminalHostTreeSidebarInner: React.FC<TerminalHostTreeSidebarProps> = ({
|
||||
}, [clearDragOver]);
|
||||
|
||||
const handleRootDrop = useCallback((event: React.DragEvent<HTMLDivElement>) => {
|
||||
if (!canDrag) return;
|
||||
if (!canReorder) return;
|
||||
event.preventDefault();
|
||||
clearHostTreeDropIndicators(event.currentTarget.ownerDocument);
|
||||
handleDropToParent(null, event.dataTransfer);
|
||||
}, [canDrag, handleDropToParent]);
|
||||
}, [canReorder, handleDropToParent]);
|
||||
|
||||
const handleListPointerDownCapture = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!menuActions) return;
|
||||
@@ -907,7 +927,7 @@ const TerminalHostTreeSidebarInner: React.FC<TerminalHostTreeSidebarProps> = ({
|
||||
activeHostId={activeHostId}
|
||||
expandedPaths={expandedPaths}
|
||||
searchActive={treeExpandAll}
|
||||
canDrag={canDrag}
|
||||
canReorder={canReorder}
|
||||
isDragOver={isRowDragOver(row)}
|
||||
isInlineEditing={
|
||||
(row.kind === 'group' && inlineEdit?.groupPath === row.node.path)
|
||||
@@ -934,7 +954,7 @@ const TerminalHostTreeSidebarInner: React.FC<TerminalHostTreeSidebarProps> = ({
|
||||
/>
|
||||
), [
|
||||
activeHostId,
|
||||
canDrag,
|
||||
canReorder,
|
||||
clearDragOver,
|
||||
expandedPaths,
|
||||
inlineEdit,
|
||||
|
||||
@@ -15,6 +15,7 @@ function TerminalLayerFocusSidebarSectionInner({ ctx }: { ctx: FocusSidebarConte
|
||||
focusedSessionId={ctx.focusedSessionId}
|
||||
onReorderWorkspaceSessions={ctx.onReorderWorkspaceSessions}
|
||||
onRequestAddToWorkspace={ctx.onRequestAddToWorkspace}
|
||||
onAppendHostToWorkspace={ctx.onAppendHostToWorkspace}
|
||||
onCloseSession={ctx.handleCloseSession}
|
||||
onCopySession={ctx.onCopySession}
|
||||
onCopySessionToNewWindow={ctx.onCopySessionToNewWindow}
|
||||
|
||||
@@ -165,6 +165,17 @@ test('side panel content scopes app color utilities to the resolved terminal the
|
||||
assert.match(sectionSource, /\.\.\.sidePanelCssVars/);
|
||||
});
|
||||
|
||||
test('side panel sets color-scheme from the terminal theme so native inputs match light panels', () => {
|
||||
const sectionSource = readFileSync(new URL('./TerminalLayerSidePanelSection.tsx', import.meta.url), 'utf8');
|
||||
|
||||
// When a light terminal theme remaps --background under a dark app chrome,
|
||||
// form controls (Codex/AI composer textarea) still follow color-scheme.
|
||||
assert.match(
|
||||
sectionSource,
|
||||
/colorScheme:\s*resolvedSidePanelTerminalTheme\.type/,
|
||||
);
|
||||
});
|
||||
|
||||
test('a visible tool without a ready pane host stays in the hidden parking host', () => {
|
||||
const parkingHost = { id: 'parking' };
|
||||
const paneHost = { id: 'pane' };
|
||||
|
||||
Loaded 100 of 180 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user