mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
105
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 | ||
|
|
5dc87ee178 | ||
|
|
a363ac5812 | ||
|
|
02bfea153c | ||
|
|
43534c34f1 | ||
|
|
a19bf708ac | ||
|
|
bbfa2ac6bf | ||
|
|
16f0304a5b | ||
|
|
e78d78b428 | ||
|
|
f6d846fbda | ||
|
|
b9702b043a | ||
|
|
89b3e27329 | ||
|
|
7e600d54e0 | ||
|
|
87aa2ea931 | ||
|
|
ac3993b789 |
No files matched your search
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
+114
-19
@@ -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);
|
||||
@@ -2511,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
|
||||
? []
|
||||
@@ -2542,6 +2566,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
})) {
|
||||
connectScriptsConsumedRef.current = true;
|
||||
}
|
||||
if (connectScriptsBatchRef.current === batch) {
|
||||
connectScriptsBatchRef.current = null;
|
||||
connectScriptsInFlightRef.current = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2550,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,
|
||||
@@ -2562,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);
|
||||
}
|
||||
@@ -2575,6 +2606,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
if (!batchStillActive()) return;
|
||||
const resolvedAfterRun = resolveConnectScriptsForHost(host, snippets);
|
||||
const doneAfterRun = resolvedAfterRun.length === 0
|
||||
|| resolvedAfterRun.every(
|
||||
@@ -2585,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;
|
||||
@@ -2600,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,
|
||||
@@ -2607,6 +2644,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
username: host.username,
|
||||
},
|
||||
onScriptComplete: (snippet) => {
|
||||
if (!batchStillActive()) return;
|
||||
if (snippet.id) {
|
||||
pendingScriptRunIdRef.current = snippet.id;
|
||||
} else {
|
||||
@@ -2615,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(() => {
|
||||
@@ -3352,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;
|
||||
@@ -3376,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) {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
@@ -83,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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -1692,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,
|
||||
@@ -1724,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' };
|
||||
|
||||
@@ -987,6 +987,9 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
data-side-panel-tab={isSidePanelOpenForCurrentTab ? (activeSidePanelTab ?? undefined) : undefined}
|
||||
style={{
|
||||
...sidePanelCssVars,
|
||||
// Keep native form controls (AI/Codex composer textarea) in sync when a
|
||||
// light terminal theme remaps --background under dark app chrome.
|
||||
colorScheme: resolvedSidePanelTerminalTheme.type,
|
||||
backgroundColor: sidePanelTheme.termBg,
|
||||
color: sidePanelTheme.termFg,
|
||||
...(isSidePanelOpenForCurrentTab && sidePanelPosition === 'left'
|
||||
|
||||
@@ -671,6 +671,7 @@ export interface TerminalLayerProps {
|
||||
onCreateWorkspaceFromSessions: (baseSessionId: string, joiningSessionId: string, hint: Exclude<SplitHint, null>) => void;
|
||||
onAddSessionToWorkspace: (workspaceId: string, sessionId: string, hint: Exclude<SplitHint, null>) => void;
|
||||
onRequestAddToWorkspace?: (workspaceId: string) => void;
|
||||
onAppendHostToWorkspace?: (workspaceId: string, hostId: string) => void;
|
||||
onUpdateSplitSizes: (workspaceId: string, splitId: string, sizes: number[]) => void;
|
||||
onSetDraggingSessionId: (id: string | null) => void;
|
||||
onToggleWorkspaceViewMode?: (workspaceId: string) => void;
|
||||
|
||||
@@ -377,6 +377,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
sidePanelWidth: s.sidePanelWidth,
|
||||
sftpActiveHost,
|
||||
sftpHostForTab,
|
||||
sftpPaneClosedTabIdsRef: s.sftpPaneClosedTabIdsRef,
|
||||
shouldMarkSessionActivity: s.shouldMarkSessionActivity,
|
||||
sidePanelOpenTabs,
|
||||
splitHorizontalHandlersRef: s.splitHorizontalHandlersRef,
|
||||
@@ -536,6 +537,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
onUpdateSessionDynamicTitle: s.onUpdateSessionDynamicTitle,
|
||||
onUpdateSessionCodingCliProvider: s.onUpdateSessionCodingCliProvider,
|
||||
onRequestAddToWorkspace: s.onRequestAddToWorkspace,
|
||||
onAppendHostToWorkspace: s.onAppendHostToWorkspace,
|
||||
onSetWorkspaceFocusedSession: s.onSetWorkspaceFocusedSession,
|
||||
onStartSessionRename: s.onStartSessionRename,
|
||||
onSubmitSessionRename: s.onSubmitSessionRename,
|
||||
@@ -581,6 +583,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
sftpFollowTerminalCwd: s.sftpFollowTerminalCwd,
|
||||
sftpInitialLocationForTab: s.sftpInitialLocationForTab,
|
||||
sftpPendingUploadsForTab: s.sftpPendingUploadsForTab,
|
||||
sftpPaneClosedTabIdsRef: s.sftpPaneClosedTabIdsRef,
|
||||
sftpShowHiddenFiles: s.sftpShowHiddenFiles,
|
||||
SftpSidePanel: s.SftpSidePanel,
|
||||
sftpUseCompressedUpload: s.sftpUseCompressedUpload,
|
||||
|
||||
@@ -3,6 +3,12 @@ import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import type { TransferTask } from "../../domain/models";
|
||||
import {
|
||||
closeSidePanelPane,
|
||||
collectSidePanelPanes,
|
||||
createSidePanelLayout,
|
||||
splitSidePanelPane,
|
||||
} from "../../domain/sidePanelLayout.ts";
|
||||
import {
|
||||
SFTP_TRANSFER_HISTORY_RETENTION_MS,
|
||||
countTransfersRetainingSftpOwner,
|
||||
@@ -13,6 +19,8 @@ import {
|
||||
shouldCloseSftpSidePanel,
|
||||
shouldClearSftpPanelAfterTransferChange,
|
||||
shouldKeepSftpMountedAfterClose,
|
||||
shouldKeepSftpBrowseSessionInteractive,
|
||||
shouldMarkSftpPaneClosed,
|
||||
shouldScheduleSftpRetainedPanelCleanup,
|
||||
terminalSftpTransferOwnerId,
|
||||
} from "./sftpPanelLifecycle.ts";
|
||||
@@ -43,6 +51,15 @@ test("single-pane SFTP close uses the shared full-panel cleanup and stops openin
|
||||
assert.match(layerSource, /closeTerminalSidePanelTab[\s\S]*setAiMountedTabIds[\s\S]*setNotesOpenNoteByTab/);
|
||||
});
|
||||
|
||||
test("all SFTP reopen paths clear the split-close marker", () => {
|
||||
const layerSource = readFileSync(new URL("../TerminalLayer.tsx", import.meta.url), "utf8");
|
||||
const effectsSource = readFileSync(new URL("./useTerminalLayerEffects.ts", import.meta.url), "utf8");
|
||||
|
||||
assert.match(layerSource, /if \(targetPanel === 'sftp'\) \{\s*sftpPaneClosedTabIdsRef\.current\.delete\(tabId\)/);
|
||||
assert.match(effectsSource, /const applySftpTargetOnTab[\s\S]*sftpPaneClosedTabIdsRef\.current\.delete\(tabId\)/);
|
||||
assert.match(effectsSource, /navigation\.kind === 'local-copy-panel'[\s\S]*sftpPaneClosedTabIdsRef\.current\.delete\(currentTabId!\)/);
|
||||
});
|
||||
|
||||
function task(
|
||||
partial: Partial<TransferTask> & Pick<TransferTask, "id" | "status">,
|
||||
): Pick<TransferTask, "id" | "status" | "parentTaskId" | "ownerId"> {
|
||||
@@ -74,6 +91,62 @@ test("closing an idle panel still releases its SFTP state", () => {
|
||||
assert.equal(shouldKeepSftpMountedAfterClose({ activeTransfersCount: 0 }), false);
|
||||
});
|
||||
|
||||
test("closing SFTP keeps another tool from reviving its browse session", () => {
|
||||
assert.equal(shouldKeepSftpBrowseSessionInteractive({
|
||||
sidePanelOpen: true,
|
||||
retainedAfterClose: false,
|
||||
sftpPaneClosed: false,
|
||||
}), true);
|
||||
assert.equal(shouldKeepSftpBrowseSessionInteractive({
|
||||
sidePanelOpen: true,
|
||||
retainedAfterClose: true,
|
||||
sftpPaneClosed: false,
|
||||
}), false);
|
||||
assert.equal(shouldKeepSftpBrowseSessionInteractive({
|
||||
sidePanelOpen: true,
|
||||
retainedAfterClose: false,
|
||||
sftpPaneClosed: true,
|
||||
}), false);
|
||||
assert.equal(shouldKeepSftpBrowseSessionInteractive({
|
||||
sidePanelOpen: false,
|
||||
retainedAfterClose: true,
|
||||
sftpPaneClosed: false,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("closing only the SFTP split pane parks its browse session while the other pane stays open", () => {
|
||||
let layout = createSidePanelLayout("sftp", "pane-sftp");
|
||||
layout = splitSidePanelPane(layout, "pane-sftp", "history", "vertical", {
|
||||
paneId: "pane-history",
|
||||
splitId: "split-root",
|
||||
}, 400);
|
||||
|
||||
const sftpPane = collectSidePanelPanes(layout.root).find((pane) => pane.tool === "sftp");
|
||||
assert.ok(sftpPane);
|
||||
const remaining = closeSidePanelPane(layout, sftpPane.id);
|
||||
assert.ok(remaining);
|
||||
assert.deepEqual(collectSidePanelPanes(remaining.root).map((pane) => pane.tool), ["history"]);
|
||||
let sftpPaneClosed = shouldMarkSftpPaneClosed({
|
||||
closingPaneTool: sftpPane.tool,
|
||||
closesWholePanel: false,
|
||||
});
|
||||
assert.equal(shouldKeepSftpBrowseSessionInteractive({
|
||||
sidePanelOpen: true,
|
||||
retainedAfterClose: false,
|
||||
sftpPaneClosed,
|
||||
}), false);
|
||||
sftpPaneClosed = false;
|
||||
assert.equal(shouldKeepSftpBrowseSessionInteractive({
|
||||
sidePanelOpen: true,
|
||||
retainedAfterClose: false,
|
||||
sftpPaneClosed,
|
||||
}), true);
|
||||
assert.equal(shouldMarkSftpPaneClosed({
|
||||
closingPaneTool: sftpPane.tool,
|
||||
closesWholePanel: true,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("a transfer retained by close keeps its history after completion", () => {
|
||||
assert.equal(shouldClearSftpPanelAfterTransferChange({
|
||||
activeTransfersCount: 0,
|
||||
@@ -214,6 +287,7 @@ test("terminal side panel reports transfer activity and uses store-backed retain
|
||||
assert.doesNotMatch(panelSource, /useEffect\(\(\) => \(\) => \{\s*onActiveTransfersChange\?\.\(0\);\s*\}, \[onActiveTransfersChange\]\)/);
|
||||
assert.match(panelSource, /interactive:\s*isBrowseSessionInteractive\(\{/);
|
||||
assert.match(panelSource, /surfaceVisible:\s*isVisible/);
|
||||
assert.match(panelSource, /ownerPanelOpen/);
|
||||
assert.match(panelSource, /useEditorTabPresenceRevision\(\)/);
|
||||
assert.match(panelSource, /hasOwnedEditorTab/);
|
||||
assert.match(panelSource, /hasActiveExternalEdit/);
|
||||
@@ -228,10 +302,15 @@ test("terminal side panel reports transfer activity and uses store-backed retain
|
||||
assert.match(layerSource, /sftpActiveExternalEditsByTabRef/);
|
||||
assert.match(layerSource, /sftpRetainedAfterCloseTabIdsRef/);
|
||||
assert.match(layerSource, /sftpRetainedCleanupTimersRef/);
|
||||
// Hidden UI parks browse channels; transfers keep pool / leased sessions.
|
||||
// External editor temps must also block park (closeSftp deletes those files).
|
||||
// Hidden UI parks browse channels only after the side panel closes;
|
||||
// tool switches keep browse warm via ownerPanelOpen. Transfers keep pool /
|
||||
// leased sessions. External editor temps must also block park (closeSftp
|
||||
// deletes those files).
|
||||
assert.match(stateSource, /shouldParkBrowseSessions/);
|
||||
assert.match(stateSource, /activeExternalEditCount/);
|
||||
assert.match(stateSource, /takeBrowseSessionsForClose/);
|
||||
assert.match(stateSource, /shouldRestoreBrowseSessions/);
|
||||
assert.match(slotsSource, /sftpRetainedAfterCloseTabIdsRef/);
|
||||
assert.match(slotsSource, /sftpPaneClosedTabIdsRef/);
|
||||
assert.match(slotsSource, /shouldKeepSftpBrowseSessionInteractive\(/);
|
||||
});
|
||||
@@ -65,6 +65,28 @@ export function shouldKeepSftpMountedAfterClose(params: {
|
||||
|| (params.activeExternalEditCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* A different side-panel tool keeps SFTP warm only when the SFTP owner was
|
||||
* never closed. A retained-after-close mount is kept for transfers/editor
|
||||
* cleanup, but its browse session must still be allowed to park.
|
||||
*/
|
||||
export function shouldKeepSftpBrowseSessionInteractive(params: {
|
||||
sidePanelOpen: boolean;
|
||||
retainedAfterClose: boolean;
|
||||
sftpPaneClosed: boolean;
|
||||
}): boolean {
|
||||
return params.sidePanelOpen
|
||||
&& !params.retainedAfterClose
|
||||
&& !params.sftpPaneClosed;
|
||||
}
|
||||
|
||||
export function shouldMarkSftpPaneClosed(params: {
|
||||
closingPaneTool: string | null | undefined;
|
||||
closesWholePanel: boolean;
|
||||
}): boolean {
|
||||
return !params.closesWholePanel && params.closingPaneTool === 'sftp';
|
||||
}
|
||||
|
||||
export function shouldCloseSftpSidePanel(params: {
|
||||
shouldKeepOpen: boolean;
|
||||
isOpen: boolean;
|
||||
|
||||
@@ -35,8 +35,11 @@ import {
|
||||
type SidePanelLayout,
|
||||
} from '../../domain/sidePanelLayout';
|
||||
import { sidePanelHiddenNotesPanelClassName, sidePanelHiddenPanelClassName } from './terminalLayerSidePanelHiddenWrapper';
|
||||
import { shouldKeepSftpBrowseSessionInteractive } from './sftpPanelLifecycle';
|
||||
|
||||
type SidePanelStableContext = Record<string, any>;
|
||||
type SidePanelStableContext = Record<string, any> & {
|
||||
sftpPaneClosedTabIdsRef: React.MutableRefObject<Set<string>>;
|
||||
};
|
||||
const navigatorPlatform = typeof navigator !== 'undefined' ? navigator.platform : '';
|
||||
const EMPTY_VAULT_NOTES: never[] = [];
|
||||
const EMPTY_VAULT_HOSTS: never[] = [];
|
||||
@@ -61,10 +64,13 @@ function SidePanelSftpSlotInner({
|
||||
tabId,
|
||||
ctx,
|
||||
isVisible,
|
||||
ownerPanelOpen,
|
||||
}: {
|
||||
tabId: string;
|
||||
ctx: SidePanelStableContext;
|
||||
isVisible: boolean;
|
||||
/** Side panel still open for this tab (may be showing another tool). */
|
||||
ownerPanelOpen: boolean;
|
||||
}) {
|
||||
const live = useSidePanelLiveSnapshotForTab(tabId, isVisible);
|
||||
|
||||
@@ -195,6 +201,7 @@ function SidePanelSftpSlotInner({
|
||||
onActiveExternalEditsChange={handleActiveExternalEditsChange}
|
||||
showWorkspaceHostHeader={isVisible && !!live.activeWorkspace}
|
||||
isVisible={isVisible}
|
||||
ownerPanelOpen={ownerPanelOpen}
|
||||
renderOverlays={isVisible}
|
||||
pendingUpload={sftpPendingUploadsForTab.get(tabId) ?? null}
|
||||
onPendingUploadHandled={handlePendingUploadHandledForTab}
|
||||
@@ -648,6 +655,16 @@ export function SidePanelMountedContent({
|
||||
activeTabStore.getActiveTabId,
|
||||
);
|
||||
const layouts = ctx.sidePanelLayouts as Map<string, SidePanelLayout>;
|
||||
const openTabs = ctx.sidePanelOpenTabs as Map<string, SidePanelTab>;
|
||||
const retainedAfterCloseTabIdsRef = ctx.sftpRetainedAfterCloseTabIdsRef as
|
||||
| React.MutableRefObject<ReadonlySet<string>>
|
||||
| undefined;
|
||||
const sftpPaneClosedTabIdsRef = ctx.sftpPaneClosedTabIdsRef;
|
||||
const isSftpOwnerPanelOpen = (tabId: string) => shouldKeepSftpBrowseSessionInteractive({
|
||||
sidePanelOpen: openTabs.has(tabId),
|
||||
retainedAfterClose: retainedAfterCloseTabIdsRef?.current.has(tabId) ?? false,
|
||||
sftpPaneClosed: sftpPaneClosedTabIdsRef.current.has(tabId),
|
||||
});
|
||||
const isToolVisible = (tabId: string, tool: SidePanelTab) => (
|
||||
activeTabId === tabId && sidePanelLayoutHasTool(layouts.get(tabId), tool)
|
||||
);
|
||||
@@ -666,7 +683,12 @@ export function SidePanelMountedContent({
|
||||
portalKey={`sftp-${tabId}`}
|
||||
target={portalTarget(tabId, 'sftp')}
|
||||
>
|
||||
<SidePanelSftpSlot tabId={tabId} ctx={ctx} isVisible={isToolVisible(tabId, 'sftp')} />
|
||||
<SidePanelSftpSlot
|
||||
tabId={tabId}
|
||||
ctx={ctx}
|
||||
isVisible={isToolVisible(tabId, 'sftp')}
|
||||
ownerPanelOpen={isSftpOwnerPanelOpen(tabId)}
|
||||
/>
|
||||
</PersistentSidePanelPortal>
|
||||
))}
|
||||
{systemMountedTabIds.map((tabId: string) => {
|
||||
|
||||
@@ -379,3 +379,26 @@ test("terminal layer focus sidebar re-renders when dynamic tab title mode change
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("terminal layer focus sidebar re-renders when host-append handler changes", () => {
|
||||
const baseCtx = {
|
||||
isFocusMode: true,
|
||||
activeWorkspace: workspace(),
|
||||
focusedSessionId: "session-1",
|
||||
resolvedPreviewTheme: {},
|
||||
sessionHostsMap: new Map(),
|
||||
sessions: [],
|
||||
terminalSettings: { dynamicTabTitleMode: "agent" },
|
||||
onAppendHostToWorkspace: undefined as
|
||||
| ((workspaceId: string, hostId: string) => void)
|
||||
| undefined,
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
terminalLayerFocusSidebarPropsEqual(
|
||||
baseCtx,
|
||||
{ ...baseCtx, onAppendHostToWorkspace: () => {} },
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -242,6 +242,8 @@ const SIDE_PANEL_STABLE_CTX_KEYS = [
|
||||
'sidePanelOpenTabs',
|
||||
'sidePanelLayouts',
|
||||
'sftpHostForTab',
|
||||
'sftpPaneClosedTabIdsRef',
|
||||
'sftpRetainedAfterCloseTabIdsRef',
|
||||
'effectiveHosts',
|
||||
'hosts',
|
||||
// SFTP Connected picker reads live terminal sessions from stable ctx.
|
||||
@@ -484,6 +486,7 @@ export function terminalLayerFocusSidebarPropsEqual(prev: Ctx, next: Ctx): boole
|
||||
&& eq(prev, next, 't')
|
||||
&& eq(prev, next, 'onReorderWorkspaceSessions')
|
||||
&& eq(prev, next, 'onRequestAddToWorkspace')
|
||||
&& eq(prev, next, 'onAppendHostToWorkspace')
|
||||
&& eq(prev, next, 'handleCloseSession')
|
||||
&& eq(prev, next, 'onCopySession')
|
||||
&& eq(prev, next, 'onCopySessionToNewWindow')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps */
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
|
||||
import type { MutableRefObject } from 'react';
|
||||
|
||||
import { terminalLayoutSuppressStore } from '../../application/state/terminalLayoutSuppressStore';
|
||||
import { terminalCwdStore } from '../../application/state/terminalCwdStore';
|
||||
@@ -15,6 +16,7 @@ import { collectSidePanelPanes, sidePanelLayoutHasTool } from '../../domain/side
|
||||
import { collectSessionIds } from '../../domain/workspace';
|
||||
import {
|
||||
moveSidePanelTabMap,
|
||||
moveSidePanelTabSet,
|
||||
remapMountedSidePanelTabIds,
|
||||
remapSidePanelTabMap,
|
||||
type SidePanelTabRemap,
|
||||
@@ -23,7 +25,9 @@ import { AI_PANEL_FORCE_HIDE_SHELL } from '../ai/aiPanelDiagnostics';
|
||||
import { toast } from '../ui/toast';
|
||||
import { getTerminalSidePanelShellWidth } from './TerminalLayerSidePanelSection';
|
||||
|
||||
type TerminalLayerEffectsContext = Record<string, any>;
|
||||
type TerminalLayerEffectsContext = Record<string, any> & {
|
||||
sftpPaneClosedTabIdsRef: MutableRefObject<Set<string>>;
|
||||
};
|
||||
|
||||
type RuntimeStateRef<T> = { current: Map<string, T> };
|
||||
|
||||
@@ -100,7 +104,7 @@ export function pruneTerminalTabMemoryState(
|
||||
|
||||
export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
|
||||
const { openPath } = useSftpBackend();
|
||||
const { activeSidePanelTab, activeSidePanelLayout, activeTabId, activeTabIdRef, activeWorkspace, activityTrackedSessions, cancelAnimationFrame, ChunkedEscapeFilter, clearTopTabsPreviewVars, document, dropHint, effectiveHosts, filterTabsMap, focusedSessionId, getSessionActivityIdsToClear, handleToggleAiFromTopBar, handleToggleScriptsSidePanel, handleToggleSidePanel, hasNotifiableTerminalOutput, isComposeBarOpen, isFocusMode, isTerminalLayerVisible, lastSidePanelTabRef, Map, onConnectToHost, onSessionData, onSplitSessionRef, onToggleBroadcastRef, onToggleWorkspaceViewModeRef, prevFocusedSessionIdRef, refocusActiveTerminalSession, requestAnimationFrame, ResizeObserver, sessionActivityStore, sessions, Set, setAiMountedTabIds, setDropHint, setNotesMountedTabIds, setScriptsMountedTabIds, setSystemMountedTabIds, setSftpHostForTab, setSftpInitialLocationForTab, setSftpPendingUploadsForTab, setSidePanelOpenTabs, setSidePanelLayouts, setThemeMountedTabIds, setWorkspaceArea, shouldMeasureTerminalLayerLayout, sidePanelPosition, sidePanelWidth, sftpActiveHost, sftpHostForTab, shouldMarkSessionActivity, sidePanelOpenTabs, splitHorizontalHandlersRef, splitVerticalHandlersRef, toggleScriptsSidePanelRef, toggleSidePanelRef, validAIScopeTargetIds, validSessionActivityIds, window, workspaceBroadcastHandlersRef, workspaceFocusHandlersRef, workspaceInnerRef, workspaces } = ctx;
|
||||
const { activeSidePanelTab, activeSidePanelLayout, activeTabId, activeTabIdRef, activeWorkspace, activityTrackedSessions, cancelAnimationFrame, ChunkedEscapeFilter, clearTopTabsPreviewVars, document, dropHint, effectiveHosts, filterTabsMap, focusedSessionId, getSessionActivityIdsToClear, handleToggleAiFromTopBar, handleToggleScriptsSidePanel, handleToggleSidePanel, hasNotifiableTerminalOutput, isComposeBarOpen, isFocusMode, isTerminalLayerVisible, lastSidePanelTabRef, Map, onConnectToHost, onSessionData, onSplitSessionRef, onToggleBroadcastRef, onToggleWorkspaceViewModeRef, prevFocusedSessionIdRef, refocusActiveTerminalSession, requestAnimationFrame, ResizeObserver, sessionActivityStore, sessions, Set, setAiMountedTabIds, setDropHint, setNotesMountedTabIds, setScriptsMountedTabIds, setSystemMountedTabIds, setSftpHostForTab, setSftpInitialLocationForTab, setSftpPendingUploadsForTab, setSidePanelOpenTabs, setSidePanelLayouts, setThemeMountedTabIds, setWorkspaceArea, shouldMeasureTerminalLayerLayout, sidePanelPosition, sidePanelWidth, sftpActiveHost, sftpHostForTab, sftpPaneClosedTabIdsRef, shouldMarkSessionActivity, sidePanelOpenTabs, splitHorizontalHandlersRef, splitVerticalHandlersRef, toggleScriptsSidePanelRef, toggleSidePanelRef, validAIScopeTargetIds, validSessionActivityIds, window, workspaceBroadcastHandlersRef, workspaceFocusHandlersRef, workspaceInnerRef, workspaces } = ctx;
|
||||
|
||||
const activeWorkspaceId = activeWorkspace?.id;
|
||||
const activeWorkspaceViewMode = activeWorkspace?.viewMode;
|
||||
@@ -205,6 +209,15 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
|
||||
}
|
||||
return next;
|
||||
});
|
||||
let sftpOwners = sftpHostForTab as ReadonlyMap<string, any>;
|
||||
for (const remap of remaps) {
|
||||
sftpPaneClosedTabIdsRef.current = moveSidePanelTabSet(
|
||||
sftpPaneClosedTabIdsRef.current,
|
||||
remap,
|
||||
{ ownerTabIds: new Set(sftpOwners.keys()) },
|
||||
);
|
||||
sftpOwners = moveSidePanelTabMap(sftpOwners, remap);
|
||||
}
|
||||
setAiMountedTabIds((prev: string[]) => {
|
||||
let next = prev;
|
||||
for (const remap of remaps) {
|
||||
@@ -495,6 +508,7 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
|
||||
|
||||
useEffect(() => {
|
||||
const applySftpTargetOnTab = (tabId: string, host: any, targetDirectory: string) => {
|
||||
sftpPaneClosedTabIdsRef.current.delete(tabId);
|
||||
// Bump initialLocation even when the host is already selected so the
|
||||
// path-navigation effect re-runs after reopen.
|
||||
setSftpHostForTab((prev: Map<string, any>) => new Map(prev).set(tabId, host));
|
||||
@@ -577,6 +591,7 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
|
||||
toast.error('Open a terminal tab first to browse this transfer', 'SFTP');
|
||||
return;
|
||||
}
|
||||
sftpPaneClosedTabIdsRef.current.delete(currentTabId!);
|
||||
setSidePanelOpenTabs((prev: Map<string, any>) => new Map(prev).set(currentTabId!, 'sftp'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -311,6 +311,21 @@ WindowControls.displayName = 'WindowControls';
|
||||
type TranslateFn = ReturnType<typeof useI18n>['t'];
|
||||
type RenderBulkCloseItems = (anchorId: string) => React.ReactNode;
|
||||
|
||||
/** 1-9 badge aligned with Cmd/Ctrl+[1...9] tab switching. */
|
||||
export const TabShortcutNumberBadge: React.FC<{ number: number }> = memo(({ number }) => (
|
||||
<span
|
||||
className="inline-flex h-3.5 min-w-3.5 shrink-0 items-center justify-center rounded px-0.5 font-mono text-[10px] font-semibold leading-none tabular-nums"
|
||||
style={{
|
||||
color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
||||
backgroundColor: 'color-mix(in srgb, var(--top-tabs-fg, hsl(var(--foreground))) 12%, transparent)',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{number}
|
||||
</span>
|
||||
));
|
||||
TabShortcutNumberBadge.displayName = 'TabShortcutNumberBadge';
|
||||
|
||||
const TOP_TAB_COMFORT_EDGE_RATIO = 0.22;
|
||||
const TOP_TAB_COMFORT_EDGE_MIN = 72;
|
||||
const TOP_TAB_COMFORT_EDGE_MAX = 160;
|
||||
@@ -379,9 +394,10 @@ interface RootTopTabProps {
|
||||
icon: React.ReactNode;
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
shortcutNumber?: number;
|
||||
}
|
||||
|
||||
export const RootTopTab: React.FC<RootTopTabProps> = memo(({ tabId, label, icon, className, compact = false }) => {
|
||||
export const RootTopTab: React.FC<RootTopTabProps> = memo(({ tabId, label, icon, className, compact = false, shortcutNumber }) => {
|
||||
const isActive = useIsTabActive(tabId);
|
||||
// The Vaults tab is the app's persistent "home", so keep its selected state
|
||||
// visually flat — no active background fill (the label/icon still brighten to
|
||||
@@ -432,7 +448,7 @@ export const RootTopTab: React.FC<RootTopTabProps> = memo(({ tabId, label, icon,
|
||||
}
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{shortcutNumber != null ? <TabShortcutNumberBadge number={shortcutNumber} /> : icon}
|
||||
<span className={cn('top-tab-root-label', compact && 'top-tab-root-label-compact')}>
|
||||
{label}
|
||||
</span>
|
||||
@@ -457,6 +473,7 @@ interface PluginViewTopTabProps {
|
||||
onTabDragLeave(e: React.DragEvent): void;
|
||||
onTabDrop(e: React.DragEvent, tabId: string): void;
|
||||
tabAnimationClass?: string;
|
||||
shortcutNumber?: number;
|
||||
}
|
||||
|
||||
export const PluginViewTopTab: React.FC<PluginViewTopTabProps> = memo(({
|
||||
@@ -475,6 +492,7 @@ export const PluginViewTopTab: React.FC<PluginViewTopTabProps> = memo(({
|
||||
onTabDragLeave,
|
||||
onTabDrop,
|
||||
tabAnimationClass,
|
||||
shortcutNumber,
|
||||
}) => {
|
||||
const isActive = useIsTabActive(tab.id);
|
||||
const close = useCallback((event?: React.MouseEvent) => {
|
||||
@@ -510,7 +528,9 @@ export const PluginViewTopTab: React.FC<PluginViewTopTabProps> = memo(({
|
||||
{showDropIndicatorBefore && isDraggingForReorder && <div className="absolute -left-0.5 bottom-1 top-1 w-0.5 rounded-full bg-primary" />}
|
||||
{showDropIndicatorAfter && isDraggingForReorder && <div className="absolute -right-0.5 bottom-1 top-1 w-0.5 rounded-full bg-primary" />}
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<PluginContributionIcon pluginId={tab.pluginId} icon={tab.icon} className="shrink-0" />
|
||||
{shortcutNumber != null
|
||||
? <TabShortcutNumberBadge number={shortcutNumber} />
|
||||
: <PluginContributionIcon pluginId={tab.pluginId} icon={tab.icon} className="shrink-0" />}
|
||||
<span className="truncate leading-5">{tab.title}</span>
|
||||
</div>
|
||||
<button onClick={close} className="flex h-5 w-5 shrink-0 items-center justify-center rounded hover:bg-muted" aria-label={t('tabs.closePluginViewAria', { title: tab.title })}><X size={12} /></button>
|
||||
@@ -550,6 +570,7 @@ interface EditorTopTabProps {
|
||||
onTabDragLeave: (e: React.DragEvent) => void;
|
||||
onTabDrop: (e: React.DragEvent, targetTabId: string) => void;
|
||||
tabAnimationClass?: string;
|
||||
shortcutNumber?: number;
|
||||
}
|
||||
|
||||
export const EditorTopTab: React.FC<EditorTopTabProps> = memo(({
|
||||
@@ -569,6 +590,7 @@ export const EditorTopTab: React.FC<EditorTopTabProps> = memo(({
|
||||
onTabDragLeave,
|
||||
onTabDrop,
|
||||
tabAnimationClass,
|
||||
shortcutNumber,
|
||||
}) => {
|
||||
const isActive = useIsTabActive(tabId);
|
||||
// Dirty is store-driven so App/TopTabs structure can stay presence-only.
|
||||
@@ -640,11 +662,15 @@ export const EditorTopTab: React.FC<EditorTopTabProps> = memo(({
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<FileIcon
|
||||
size={14}
|
||||
className="shrink-0"
|
||||
style={{ color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
/>
|
||||
{shortcutNumber != null ? (
|
||||
<TabShortcutNumberBadge number={shortcutNumber} />
|
||||
) : (
|
||||
<FileIcon
|
||||
size={14}
|
||||
className="shrink-0"
|
||||
style={{ color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
/>
|
||||
)}
|
||||
<span className="flex items-center gap-0.5 truncate leading-5">
|
||||
{dirty && <span className="mr-0.5 text-primary">●</span>}
|
||||
{editorTab.fileName}
|
||||
@@ -688,6 +714,7 @@ interface SessionTopTabProps {
|
||||
dynamicTabTitleMode?: DynamicTabTitleMode;
|
||||
t: TranslateFn;
|
||||
tabAnimationClass?: string;
|
||||
shortcutNumber?: number;
|
||||
}
|
||||
|
||||
export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
|
||||
@@ -712,6 +739,7 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
|
||||
dynamicTabTitleMode,
|
||||
t,
|
||||
tabAnimationClass,
|
||||
shortcutNumber,
|
||||
}) => {
|
||||
// Per-session presentation: sibling title/provider updates do not re-render this tab.
|
||||
const session = usePresentedSession(sessionProp);
|
||||
@@ -784,14 +812,18 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<SessionTabIcon
|
||||
host={host}
|
||||
session={session}
|
||||
isActive={isActive}
|
||||
protocol={session.protocol}
|
||||
shellIcon={session.localShellIcon}
|
||||
dynamicTabTitleMode={dynamicTabTitleMode}
|
||||
/>
|
||||
{shortcutNumber != null ? (
|
||||
<TabShortcutNumberBadge number={shortcutNumber} />
|
||||
) : (
|
||||
<SessionTabIcon
|
||||
host={host}
|
||||
session={session}
|
||||
isActive={isActive}
|
||||
protocol={session.protocol}
|
||||
shellIcon={session.localShellIcon}
|
||||
dynamicTabTitleMode={dynamicTabTitleMode}
|
||||
/>
|
||||
)}
|
||||
<span className="truncate leading-5">{tabTitle}</span>
|
||||
<div className="flex-shrink-0">{sessionStatusDot(session.status, hasActivity)}</div>
|
||||
</div>
|
||||
@@ -854,6 +886,7 @@ interface WorkspaceTopTabProps {
|
||||
shiftStyle: React.CSSProperties;
|
||||
showDropIndicatorBefore: boolean;
|
||||
showDropIndicatorAfter: boolean;
|
||||
isHostDropTarget: boolean;
|
||||
onTabDragStart: (e: React.DragEvent, tabId: string) => void;
|
||||
onTabDragEnd: () => void;
|
||||
onTabDragOver: (e: React.DragEvent, tabId: string) => void;
|
||||
@@ -869,6 +902,7 @@ interface WorkspaceTopTabProps {
|
||||
renderBulkCloseItems: RenderBulkCloseItems;
|
||||
t: TranslateFn;
|
||||
tabAnimationClass?: string;
|
||||
shortcutNumber?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -903,6 +937,7 @@ export const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
|
||||
shiftStyle,
|
||||
showDropIndicatorBefore,
|
||||
showDropIndicatorAfter,
|
||||
isHostDropTarget,
|
||||
onTabDragStart,
|
||||
onTabDragEnd,
|
||||
onTabDragOver,
|
||||
@@ -915,6 +950,7 @@ export const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
|
||||
renderBulkCloseItems,
|
||||
t,
|
||||
tabAnimationClass,
|
||||
shortcutNumber,
|
||||
}) => {
|
||||
const isActive = useIsTabActive(workspace.id);
|
||||
const hasActivity = useAnySessionActivity(workspaceSessionIds);
|
||||
@@ -935,6 +971,7 @@ export const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
|
||||
data-tab-id={workspace.id}
|
||||
data-tab-type="workspace"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
data-host-drop-active={isHostDropTarget ? 'true' : 'false'}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
@@ -949,6 +986,7 @@ export const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
|
||||
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[150px] max-w-[260px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"transition-transform duration-150",
|
||||
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : "",
|
||||
isHostDropTarget && "ring-1 ring-inset",
|
||||
tabAnimationClass,
|
||||
)}
|
||||
style={{
|
||||
@@ -986,11 +1024,15 @@ export const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<LayoutGrid
|
||||
size={14}
|
||||
className="shrink-0"
|
||||
style={{ color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
/>
|
||||
{shortcutNumber != null ? (
|
||||
<TabShortcutNumberBadge number={shortcutNumber} />
|
||||
) : (
|
||||
<LayoutGrid
|
||||
size={14}
|
||||
className="shrink-0"
|
||||
style={{ color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
/>
|
||||
)}
|
||||
<span className="truncate leading-5" title={tabLabel}>{tabLabel}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
@@ -1052,6 +1094,7 @@ interface LogViewTopTabProps {
|
||||
onTabDrop: (e: React.DragEvent, targetTabId: string) => void;
|
||||
t: TranslateFn;
|
||||
tabAnimationClass?: string;
|
||||
shortcutNumber?: number;
|
||||
}
|
||||
|
||||
export const LogViewTopTab: React.FC<LogViewTopTabProps> = memo(({
|
||||
@@ -1069,6 +1112,7 @@ export const LogViewTopTab: React.FC<LogViewTopTabProps> = memo(({
|
||||
onTabDrop,
|
||||
t,
|
||||
tabAnimationClass,
|
||||
shortcutNumber,
|
||||
}) => {
|
||||
const isActive = useIsTabActive(logView.id);
|
||||
const isLocal = logView.log.protocol === 'local' || logView.log.hostname === 'localhost';
|
||||
@@ -1135,11 +1179,15 @@ export const LogViewTopTab: React.FC<LogViewTopTabProps> = memo(({
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<FileText
|
||||
size={14}
|
||||
className="shrink-0"
|
||||
style={{ color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
/>
|
||||
{shortcutNumber != null ? (
|
||||
<TabShortcutNumberBadge number={shortcutNumber} />
|
||||
) : (
|
||||
<FileText
|
||||
size={14}
|
||||
className="shrink-0"
|
||||
style={{ color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
/>
|
||||
)}
|
||||
<span className="truncate leading-5">
|
||||
{t('tabs.logPrefix')} {isLocal ? t('tabs.logLocal') : logView.log.hostname}
|
||||
</span>
|
||||
|
||||
@@ -63,6 +63,21 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
const prepareGridLayoutAnimation = useVaultGridLayoutAnimation(hostListScrollRef);
|
||||
const [focusedHostId, setFocusedHostId] = React.useState<string | null>(null);
|
||||
const [focusedGroupPath, setFocusedGroupPath] = React.useState<string | null>(null);
|
||||
const hostListFilterFocusKey = React.useMemo(
|
||||
() => getVaultTreeAutoExpandKey(search, selectedTags) ?? "",
|
||||
[search, selectedTags],
|
||||
);
|
||||
const [prevHostListFilterFocusKey, setPrevHostListFilterFocusKey] = React.useState(
|
||||
hostListFilterFocusKey,
|
||||
);
|
||||
// Clear keyboard/selection focus as soon as search or tags change so the
|
||||
// virtual list cannot steal DOM focus back from the search input on the
|
||||
// same commit (useEffect would run too late).
|
||||
if (hostListFilterFocusKey !== prevHostListFilterFocusKey) {
|
||||
setPrevHostListFilterFocusKey(hostListFilterFocusKey);
|
||||
setFocusedHostId(null);
|
||||
setFocusedGroupPath(null);
|
||||
}
|
||||
const hostCollectionLayoutKey = [
|
||||
displayedGroups.length,
|
||||
hasHostsSidePanel ? "panel" : "full",
|
||||
@@ -266,7 +281,8 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
|
||||
|
||||
const handleHostDragStart = React.useCallback((e: React.DragEvent, hostId: string) => {
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
// copyMove: vault reorder uses move; focus-sidebar append uses copy.
|
||||
e.dataTransfer.effectAllowed = "copyMove";
|
||||
e.dataTransfer.setData("host-id", hostId);
|
||||
draggingHostIdRef.current = hostId;
|
||||
setDraggingHostId(hostId);
|
||||
|
||||
@@ -82,6 +82,30 @@ test("VaultTree labels use CJK-safe line-height under truncate", () => {
|
||||
assert.doesNotMatch(itemMarkup, /leading-none/);
|
||||
});
|
||||
|
||||
test("VaultTree labels expose full title and keep actions from shrinking", () => {
|
||||
const groupMarkup = renderToStaticMarkup(
|
||||
<VaultTreeGroupRow
|
||||
name="Dokploy服务信息"
|
||||
depth={0}
|
||||
actions={<button type="button" data-row-action="menu">…</button>}
|
||||
/>,
|
||||
);
|
||||
const itemMarkup = renderToStaticMarkup(
|
||||
<VaultTreeItemRow
|
||||
label="Security Audit Report - 完整版"
|
||||
depth={1}
|
||||
actions={<button type="button" data-row-action="menu">…</button>}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(groupMarkup, /title="Dokploy服务信息"/);
|
||||
assert.match(groupMarkup, /min-w-0 truncate/);
|
||||
assert.match(groupMarkup, /shrink-0[^>]*>[\s\S]*data-row-action="menu"/);
|
||||
assert.match(itemMarkup, /title="Security Audit Report - 完整版"/);
|
||||
assert.match(itemMarkup, /min-w-0 truncate leading-5/);
|
||||
assert.match(itemMarkup, /shrink-0[^>]*>[\s\S]*data-row-action="menu"/);
|
||||
});
|
||||
|
||||
test("VaultTreeInlineRenameInput uses shared inline edit marker", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<VaultTreeInlineRenameInput
|
||||
|
||||
@@ -165,17 +165,17 @@ export const VaultTreeGroupRow: React.FC<VaultTreeGroupRowProps> = ({
|
||||
) : (
|
||||
// leading-5 (not leading-none): CJK fallbacks like PingFang paint outside a 1.0 em box and get clipped by truncate.
|
||||
<span className="flex min-h-5 min-w-0 flex-1 items-center gap-1.5 leading-5">
|
||||
<span className="min-w-0 truncate">{name}</span>
|
||||
{labelActions}
|
||||
<span className="min-w-0 truncate" title={name}>{name}</span>
|
||||
{labelActions ? <span className="shrink-0">{labelActions}</span> : null}
|
||||
</span>
|
||||
)}
|
||||
{meta}
|
||||
{meta ? <div className="shrink-0">{meta}</div> : null}
|
||||
{typeof count === "number" && count > 0 && (
|
||||
<span className="shrink-0 rounded-full border border-border bg-background/50 px-1.5 py-0 text-[10px] opacity-70">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
{actions}
|
||||
{actions ? <div className="shrink-0">{actions}</div> : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -228,7 +228,7 @@ export const VaultTreeItemRow: React.FC<VaultTreeItemRowProps> = ({
|
||||
{leading ?? <div className="mr-1 h-5 w-4 flex-shrink-0" />}
|
||||
{icon ? <div className="mr-2 flex shrink-0 items-center self-center">{icon}</div> : <FileText size={14} className="mr-2 shrink-0 text-muted-foreground" />}
|
||||
{content ?? (
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
{editing && onRenameCommit && onRenameCancel ? (
|
||||
<VaultTreeInlineRenameInput
|
||||
initialName={editingInitialName ?? label}
|
||||
@@ -237,11 +237,11 @@ export const VaultTreeItemRow: React.FC<VaultTreeItemRowProps> = ({
|
||||
/>
|
||||
) : (
|
||||
// leading-5 keeps CJK glyphs inside the line box under truncate overflow.
|
||||
<div className="min-w-0 truncate leading-5">{label}</div>
|
||||
<div className="min-w-0 truncate leading-5" title={label}>{label}</div>
|
||||
)}
|
||||
{detail && <div className="truncate text-xs leading-4 text-muted-foreground">{detail}</div>}
|
||||
</div>
|
||||
)}
|
||||
{actions}
|
||||
{actions ? <div className="shrink-0">{actions}</div> : null}
|
||||
</div>
|
||||
);
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getNextRaggedRowPosition,
|
||||
getNextVirtualHostIndex,
|
||||
resolveVirtualFocusRequest,
|
||||
shouldApplyVirtualHostDomFocus,
|
||||
VirtualizedGroupedHostCollection,
|
||||
VirtualizedHostCollection,
|
||||
} from "./VirtualizedHostCollection.tsx";
|
||||
@@ -26,6 +27,34 @@ test("virtual focus retries when an active item enters a collection", () => {
|
||||
}), { status: "request", key: "host-1", index: 3 });
|
||||
});
|
||||
|
||||
test("virtual host DOM focus does not steal from outside controls", () => {
|
||||
const inside = { tagName: "BUTTON" } as unknown as Element;
|
||||
const searchInput = { tagName: "INPUT" } as unknown as Element;
|
||||
const body = { tagName: "BODY" } as unknown as Element;
|
||||
const collectionRoot = {
|
||||
contains(node: Node) {
|
||||
return node === inside;
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(shouldApplyVirtualHostDomFocus({
|
||||
collectionRoot,
|
||||
activeElement: inside,
|
||||
}), true);
|
||||
assert.equal(shouldApplyVirtualHostDomFocus({
|
||||
collectionRoot,
|
||||
activeElement: searchInput,
|
||||
}), false);
|
||||
assert.equal(shouldApplyVirtualHostDomFocus({
|
||||
collectionRoot,
|
||||
activeElement: body,
|
||||
}), true);
|
||||
assert.equal(shouldApplyVirtualHostDomFocus({
|
||||
collectionRoot: null,
|
||||
activeElement: inside,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("host grids use the same fixed card-width column calculation", () => {
|
||||
assert.equal(getVaultHostGridColumnCount(219), 1);
|
||||
assert.equal(getVaultHostGridColumnCount(452), 2);
|
||||
|
||||
@@ -82,6 +82,23 @@ export function resolveVirtualFocusRequest({
|
||||
return { status: "request", key, index };
|
||||
}
|
||||
|
||||
/**
|
||||
* Virtual collections may retry DOM focus when a remembered active item
|
||||
* re-enters the filtered set. Never steal focus from controls outside the
|
||||
* collection (e.g. the vault host search input).
|
||||
*/
|
||||
export function shouldApplyVirtualHostDomFocus(input: {
|
||||
collectionRoot: { contains: (node: Node) => boolean } | null | undefined;
|
||||
activeElement: Element | null | undefined;
|
||||
}): boolean {
|
||||
const { collectionRoot, activeElement } = input;
|
||||
if (!collectionRoot) return false;
|
||||
if (!activeElement) return true;
|
||||
const tagName = activeElement.tagName;
|
||||
if (tagName === "BODY" || tagName === "HTML") return true;
|
||||
return collectionRoot.contains(activeElement);
|
||||
}
|
||||
|
||||
export function VirtualizedHostCollection<T>({
|
||||
items,
|
||||
itemKey,
|
||||
@@ -170,6 +187,13 @@ export function VirtualizedHostCollection<T>({
|
||||
const focusRenderedItem = React.useCallback((key: string) => {
|
||||
const root = rootRef.current;
|
||||
if (!root) return false;
|
||||
if (!shouldApplyVirtualHostDomFocus({
|
||||
collectionRoot: root,
|
||||
activeElement: typeof document === "undefined" ? null : document.activeElement,
|
||||
})) {
|
||||
pendingFocusKeyRef.current = null;
|
||||
return true;
|
||||
}
|
||||
const wrapper = [...root.querySelectorAll<HTMLElement>("[data-vault-item-key]")]
|
||||
.find((element) => element.dataset.vaultItemKey === key);
|
||||
const focusTarget = wrapper?.querySelector<HTMLElement>(
|
||||
@@ -450,6 +474,13 @@ export function VirtualizedGroupedHostCollection<T>({
|
||||
const focusRenderedItem = React.useCallback((key: string) => {
|
||||
const root = rootRef.current;
|
||||
if (!root) return false;
|
||||
if (!shouldApplyVirtualHostDomFocus({
|
||||
collectionRoot: root,
|
||||
activeElement: typeof document === "undefined" ? null : document.activeElement,
|
||||
})) {
|
||||
pendingFocusKeyRef.current = null;
|
||||
return true;
|
||||
}
|
||||
const wrapper = [...root.querySelectorAll<HTMLElement>("[data-vault-item-key]")]
|
||||
.find((element) => element.dataset.vaultItemKey === key);
|
||||
const focusTarget = wrapper?.querySelector<HTMLElement>("[data-host-id]");
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
appendHostFromWorkspaceDrop,
|
||||
FOCUS_SIDEBAR_HOST_DRAG_TYPE,
|
||||
FOCUS_SIDEBAR_SESSION_DRAG_TYPE,
|
||||
readHostIdFromDataTransfer,
|
||||
resolveFocusSidebarDragKind,
|
||||
} from './focusSidebarHostDrop.ts';
|
||||
|
||||
test('resolveFocusSidebarDragKind prefers in-flight session reorder', () => {
|
||||
assert.equal(
|
||||
resolveFocusSidebarDragKind({
|
||||
types: [FOCUS_SIDEBAR_HOST_DRAG_TYPE],
|
||||
activeSessionDragId: 'session-1',
|
||||
}),
|
||||
'session-reorder',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveFocusSidebarDragKind detects session mime even without local state', () => {
|
||||
assert.equal(
|
||||
resolveFocusSidebarDragKind({
|
||||
types: [FOCUS_SIDEBAR_SESSION_DRAG_TYPE],
|
||||
activeSessionDragId: null,
|
||||
}),
|
||||
'session-reorder',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveFocusSidebarDragKind accepts vault or host-tree host drags', () => {
|
||||
assert.equal(
|
||||
resolveFocusSidebarDragKind({
|
||||
types: [FOCUS_SIDEBAR_HOST_DRAG_TYPE],
|
||||
activeSessionDragId: null,
|
||||
}),
|
||||
'host-append',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveFocusSidebarDragKind ignores unrelated drag payloads', () => {
|
||||
assert.equal(
|
||||
resolveFocusSidebarDragKind({
|
||||
types: ['text/plain', 'tab-reorder-id'],
|
||||
activeSessionDragId: null,
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('readHostIdFromDataTransfer returns trimmed host id', () => {
|
||||
assert.equal(
|
||||
readHostIdFromDataTransfer((type) => (type === FOCUS_SIDEBAR_HOST_DRAG_TYPE ? ' host-42 ' : '')),
|
||||
'host-42',
|
||||
);
|
||||
});
|
||||
|
||||
test('readHostIdFromDataTransfer returns null when host id is missing', () => {
|
||||
assert.equal(readHostIdFromDataTransfer(() => ''), null);
|
||||
assert.equal(readHostIdFromDataTransfer(() => ' '), null);
|
||||
});
|
||||
|
||||
test('appendHostFromWorkspaceDrop appends the dropped host to the target workspace once', () => {
|
||||
const calls: Array<[string, string]> = [];
|
||||
|
||||
assert.equal(appendHostFromWorkspaceDrop({
|
||||
types: [FOCUS_SIDEBAR_HOST_DRAG_TYPE],
|
||||
getData: (type) => (type === FOCUS_SIDEBAR_HOST_DRAG_TYPE ? 'host-42' : ''),
|
||||
workspaceId: 'workspace-7',
|
||||
onAppendHostToWorkspace: (workspaceId, hostId) => calls.push([workspaceId, hostId]),
|
||||
}), true);
|
||||
|
||||
assert.deepEqual(calls, [['workspace-7', 'host-42']]);
|
||||
});
|
||||
|
||||
test('appendHostFromWorkspaceDrop leaves session and invalid drags alone', () => {
|
||||
const calls: Array<[string, string]> = [];
|
||||
const onAppendHostToWorkspace = (workspaceId: string, hostId: string) => {
|
||||
calls.push([workspaceId, hostId]);
|
||||
};
|
||||
|
||||
assert.equal(appendHostFromWorkspaceDrop({
|
||||
types: [FOCUS_SIDEBAR_SESSION_DRAG_TYPE, FOCUS_SIDEBAR_HOST_DRAG_TYPE],
|
||||
getData: (type) => (type === FOCUS_SIDEBAR_HOST_DRAG_TYPE ? 'host-42' : 'session-1'),
|
||||
workspaceId: 'workspace-7',
|
||||
onAppendHostToWorkspace,
|
||||
}), false);
|
||||
assert.equal(appendHostFromWorkspaceDrop({
|
||||
types: [FOCUS_SIDEBAR_HOST_DRAG_TYPE],
|
||||
getData: () => ' ',
|
||||
workspaceId: 'workspace-7',
|
||||
onAppendHostToWorkspace,
|
||||
}), false);
|
||||
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
export const FOCUS_SIDEBAR_HOST_DRAG_TYPE = 'host-id';
|
||||
export const FOCUS_SIDEBAR_SESSION_DRAG_TYPE = 'workspace-focus-session-id';
|
||||
|
||||
export type FocusSidebarDragKind = 'session-reorder' | 'host-append' | null;
|
||||
|
||||
export function dataTransferHasType(
|
||||
types: ArrayLike<string> | readonly string[],
|
||||
type: string,
|
||||
): boolean {
|
||||
return Array.from(types as ArrayLike<string>).includes(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide how the focus-mode workspace sidebar should treat an in-progress drag.
|
||||
* Session reorder wins when this sidebar started the drag, so a host mime that
|
||||
* happens to be present cannot steal reorder gestures.
|
||||
*/
|
||||
export function resolveFocusSidebarDragKind(input: {
|
||||
types: ArrayLike<string> | readonly string[];
|
||||
activeSessionDragId?: string | null;
|
||||
}): FocusSidebarDragKind {
|
||||
if (
|
||||
input.activeSessionDragId
|
||||
|| dataTransferHasType(input.types, FOCUS_SIDEBAR_SESSION_DRAG_TYPE)
|
||||
) {
|
||||
return 'session-reorder';
|
||||
}
|
||||
if (dataTransferHasType(input.types, FOCUS_SIDEBAR_HOST_DRAG_TYPE)) {
|
||||
return 'host-append';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function readHostIdFromDataTransfer(
|
||||
getData: (type: string) => string,
|
||||
): string | null {
|
||||
const hostId = getData(FOCUS_SIDEBAR_HOST_DRAG_TYPE)?.trim();
|
||||
return hostId || null;
|
||||
}
|
||||
|
||||
export function appendHostFromWorkspaceDrop(input: {
|
||||
types: ArrayLike<string> | readonly string[];
|
||||
getData: (type: string) => string;
|
||||
workspaceId: string;
|
||||
activeSessionDragId?: string | null;
|
||||
onAppendHostToWorkspace: (workspaceId: string, hostId: string) => void;
|
||||
}): boolean {
|
||||
if (resolveFocusSidebarDragKind(input) !== 'host-append') return false;
|
||||
|
||||
const hostId = readHostIdFromDataTransfer(input.getData);
|
||||
if (!hostId) return false;
|
||||
|
||||
input.onAppendHostToWorkspace(input.workspaceId, hostId);
|
||||
return true;
|
||||
}
|
||||
+13
-1
@@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { keyEventToString, matchesKeyBinding } from './models.ts';
|
||||
import { keyEventToString, matchesKeyBinding, tabShortcutDigitFromEvent } from './models.ts';
|
||||
|
||||
const keyboardEvent = (
|
||||
key: string,
|
||||
@@ -62,3 +62,15 @@ test('shortcut matching preserves shifted number-row symbols', () => {
|
||||
assert.equal(matchesKeyBinding(event, 'Ctrl + Shift + 1', false), false);
|
||||
assert.equal(keyEventToString(event, false), 'Ctrl + Shift + !');
|
||||
});
|
||||
|
||||
test('shifted digit ranges still match via physical Digit code', () => {
|
||||
const event = keyboardEvent('!', 'Digit1', { ctrlKey: true, shiftKey: true });
|
||||
|
||||
assert.equal(matchesKeyBinding(event, 'Ctrl + Shift + [1...9]', false), true);
|
||||
assert.equal(tabShortcutDigitFromEvent(event), 1);
|
||||
});
|
||||
|
||||
test('tab shortcut digit falls back to e.key when code is missing', () => {
|
||||
assert.equal(tabShortcutDigitFromEvent({ key: '3', code: '' }), 3);
|
||||
assert.equal(tabShortcutDigitFromEvent({ key: '!', code: '' }), null);
|
||||
});
|
||||
Loaded 100 of 143 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user