mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 14:30:13 +00:00
Compare commits
189
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
072377777f | ||
|
|
27b62b2372 | ||
|
|
627e652f56 | ||
|
|
17c2eb6346 | ||
|
|
01102e2d66 | ||
|
|
81dcf849fb | ||
|
|
1dfe1379cd | ||
|
|
27ec58fa40 | ||
|
|
7ac878d00e | ||
|
|
f90f40e624 | ||
|
|
dfc9ed7f61 | ||
|
|
2c2d7e0b55 | ||
|
|
ef364b69fd | ||
|
|
700d603e64 | ||
|
|
f2b3facd9f | ||
|
|
7f08bec0b9 | ||
|
|
bbd4c698a5 | ||
|
|
4beccc3d61 | ||
|
|
60e057318f | ||
|
|
33b3beaea0 | ||
|
|
e65af2fd7b | ||
|
|
5d63a9476c | ||
|
|
58c72ace32 | ||
|
|
75cbda7edc | ||
|
|
6f6d8dc36d | ||
|
|
d0f819efb0 | ||
|
|
9671468412 | ||
|
|
380e96eae5 | ||
|
|
e4f9303de5 | ||
|
|
02ce011934 | ||
|
|
8e45763e4c | ||
|
|
0d92c5d738 | ||
|
|
c8a393f12b | ||
|
|
6bf4268e8e | ||
|
|
7114288d2f | ||
|
|
f02b645af7 | ||
|
|
eb3cd06278 | ||
|
|
7ed0b32489 | ||
|
|
2fe46cbf6f | ||
|
|
53ebbec58c | ||
|
|
0382c089e1 | ||
|
|
253ce57d84 | ||
|
|
9beb7d3108 | ||
|
|
525a78e83e | ||
|
|
0e94950a5c | ||
|
|
a2ea0f6464 | ||
|
|
42b174abb3 | ||
|
|
e2ad0f526e | ||
|
|
f8c7dd44db | ||
|
|
cfeee2ec89 | ||
|
|
be3e971064 | ||
|
|
35bb83f57c | ||
|
|
5279ac2836 | ||
|
|
793ba54bda | ||
|
|
a1ca1a3443 | ||
|
|
3c528c6ab5 | ||
|
|
6dd703afce | ||
|
|
10c399f47b | ||
|
|
a4ec7bab61 | ||
|
|
79215d8896 | ||
|
|
2012fc53c7 | ||
|
|
2be738b8c2 | ||
|
|
93d7f8e1ec | ||
|
|
924c9a0efa | ||
|
|
0d9da150ec | ||
|
|
d307a2ebc3 | ||
|
|
d97439f878 | ||
|
|
8285d7010b | ||
|
|
1f0ddd2bdb | ||
|
|
98db9e6fea | ||
|
|
8e53ec829f | ||
|
|
549c5534d5 | ||
|
|
8fae84148c | ||
|
|
6d14f1e3d6 | ||
|
|
aebe25ae70 | ||
|
|
df045bf144 | ||
|
|
32447885d9 | ||
|
|
195a23e380 | ||
|
|
459ac09ebb | ||
|
|
a1ea4cc546 | ||
|
|
25224cef7b | ||
|
|
452058bd42 | ||
|
|
50c25d8789 | ||
|
|
96a38593bf | ||
|
|
e3b9e71d8f | ||
|
|
01b9f745b6 | ||
|
|
e96384cc7f | ||
|
|
a5bf1765d4 | ||
|
|
c921e7d9a4 | ||
|
|
a1b7f3708a | ||
|
|
6a4a1ffd92 | ||
|
|
105107755b | ||
|
|
028e86b111 | ||
|
|
151c15b38f | ||
|
|
544613c007 | ||
|
|
b2886ed16b | ||
|
|
57e11822f2 | ||
|
|
ff05c33fc9 | ||
|
|
a2ff82187b | ||
|
|
997bba6e2c | ||
|
|
13979790e8 | ||
|
|
19cce8c82b | ||
|
|
02746754f3 | ||
|
|
f940bbc280 | ||
|
|
7e60b64766 | ||
|
|
18330fd1d1 | ||
|
|
ebfdb645a7 | ||
|
|
b89607dbb0 | ||
|
|
2471013aba | ||
|
|
2343f80ed2 | ||
|
|
8d94252990 | ||
|
|
87b4089812 | ||
|
|
d7377fe301 | ||
|
|
518e4a12ac | ||
|
|
fbaa038b0d | ||
|
|
6c4e27cb38 | ||
|
|
5dc87ee178 | ||
|
|
a363ac5812 | ||
|
|
02bfea153c | ||
|
|
43534c34f1 | ||
|
|
a19bf708ac | ||
|
|
bbfa2ac6bf | ||
|
|
16f0304a5b | ||
|
|
e78d78b428 | ||
|
|
f6d846fbda | ||
|
|
b9702b043a | ||
|
|
89b3e27329 | ||
|
|
7e600d54e0 | ||
|
|
87aa2ea931 | ||
|
|
ac3993b789 | ||
|
|
4935066e53 | ||
|
|
315e596e08 | ||
|
|
5f874a3156 | ||
|
|
ae58d60f8f | ||
|
|
8856c7e5ab | ||
|
|
c47fcce17d | ||
|
|
53da304e39 | ||
|
|
2df408a571 | ||
|
|
a965a94f0d | ||
|
|
f9803f3642 | ||
|
|
ddfeb0a1a8 | ||
|
|
0d6fc5f7f7 | ||
|
|
2b381c87be | ||
|
|
193a14febb | ||
|
|
8ffdc671b9 | ||
|
|
837f4ddfaa | ||
|
|
f8bdd950bc | ||
|
|
4fb0f0c470 | ||
|
|
afc27e759b | ||
|
|
ce41379796 | ||
|
|
27b2c1954f | ||
|
|
b36642994c | ||
|
|
701ef1449b | ||
|
|
25291591fa | ||
|
|
0ad1713f25 | ||
|
|
d8efadc9fd | ||
|
|
c0f1504b4a | ||
|
|
b41728968e | ||
|
|
589abaaa90 | ||
|
|
cc8ba20839 | ||
|
|
589001cfb7 | ||
|
|
fcd122d4a1 | ||
|
|
9589f10ffb | ||
|
|
8d14f0f3c2 | ||
|
|
3f67ccf3b2 | ||
|
|
daa3490654 | ||
|
|
eebd9acfa2 | ||
|
|
8b84a13d9f | ||
|
|
249468b095 | ||
|
|
f4bb1775b3 | ||
|
|
f2154a853a | ||
|
|
82c339246c | ||
|
|
2169c2f95c | ||
|
|
d189692a93 | ||
|
|
fee778a775 | ||
|
|
d03f8873c5 | ||
|
|
5f464b1b8e | ||
|
|
68385b42b9 | ||
|
|
71c64e3c6c | ||
|
|
d27fc24cae | ||
|
|
0527df7268 | ||
|
|
9ffb0d8642 | ||
|
|
d45b4dd65b | ||
|
|
b6321eea6f | ||
|
|
39138096c0 | ||
|
|
5c355da8c1 | ||
|
|
96628e8bd0 | ||
|
|
352de0131e | ||
|
|
df9284b9ab |
No files matched your search
+3
-2
@@ -3,6 +3,7 @@
|
||||
## [Unreleased] - 2026-03-11
|
||||
|
||||
### 功能
|
||||
- Linux deb/rpm/pacman 安装包启用应用内自动更新;未标记的开发包和 Snap 继续提供手动下载入口
|
||||
- 终端新增"粘贴时自动上传剪贴板图片"选项:剪贴板含图片时,在远程会话中粘贴(快捷键/右键/中键)自动通过 SFTP 上传图片到远端 `.netcatty-paste-images/` 目录并输入远端路径,否则保持原有文本粘贴行为(设置 > 终端 > 行为,默认关闭)
|
||||
- 修复自动更新 IPC 事件仅发送到单个窗口的问题,改为广播所有窗口(主窗口 + 设置窗口均可收到)
|
||||
- 统一手动检查更新与自动更新的状态机,消除三套并行状态
|
||||
@@ -13,7 +14,7 @@
|
||||
- 下载完成后弹出持久 toast 通知,用户点击"立即重启"即可安装
|
||||
- 下载失败时弹出错误 toast,提供"打开 Releases"降级入口
|
||||
- Settings > System 进度条实时展示自动下载进度,由 `useUpdateCheck` 统一驱动
|
||||
- Linux deb/rpm/snap 等不支持 electron-updater 的平台自动跳过,保持原有 GitHub API 通知行为
|
||||
- Linux Snap、未标记的开发包等不支持 electron-updater 的平台自动跳过,保持原有 GitHub API 通知行为
|
||||
|
||||
### 设计原理
|
||||
- `broadcastToAllWindows` 替换 `getSenderWindow` 单点发送,保证所有窗口都能收到 IPC 事件
|
||||
@@ -29,5 +30,5 @@
|
||||
|
||||
### 注意事项
|
||||
- `checkNow` 语义:使用 GitHub API(`performCheck`)检测是否有新版本,若发现更新且 electron-updater 尚未开始下载,则异步触发 `bridge.checkForUpdate()` 启动自动下载流程
|
||||
- 此功能仅对打包后的应用(Windows NSIS、macOS dmg/zip、Linux AppImage)生效,dev 模式需配合 `forceDevUpdateConfig=true` + `dev-app-update.yml` 测试(见 `.gitignore`)
|
||||
- 此功能仅对打包后的应用(Windows NSIS、macOS dmg/zip、Linux AppImage/deb/rpm/pacman)生效,dev 模式需配合 `forceDevUpdateConfig=true` + `dev-app-update.yml` 测试(见 `.gitignore`)
|
||||
- `hasUpdate` 旧 toast 在 `autoDownloadStatus !== 'idle'` 时自动抑制,避免与新 toast 重复
|
||||
@@ -272,6 +272,55 @@ test('next, previous, and number shortcuts include native plugin view tabs', ()
|
||||
assert.deepEqual(selected, [pluginTabId, 'session-1', pluginTabId]);
|
||||
});
|
||||
|
||||
test('switchToTab uses physical Digit code when Shift remaps e.key', () => {
|
||||
let activeTabId = 'session-1';
|
||||
const noop = () => {};
|
||||
const context = {
|
||||
IS_DEV: false,
|
||||
MOVE_FOCUS_DEBOUNCE_MS: 0,
|
||||
activeTabStore: { getActiveTabId: () => activeTabId },
|
||||
addConnectionLogRef: { current: noop },
|
||||
closeSession: noop,
|
||||
closeTabInFlightRef: { current: false },
|
||||
closeWorkspace: noop,
|
||||
collectSessionIds: () => [],
|
||||
confirmIfBusyLocalTerminal: async () => true,
|
||||
createLocalTerminalWithCurrentShell: noop,
|
||||
editorTabs: [],
|
||||
fromEditorTabId: () => null,
|
||||
handleOpenSettingsRef: { current: noop },
|
||||
handleRequestCloseEditorTabRef: { current: noop },
|
||||
isEditorTabId: () => false,
|
||||
isQuickSwitcherOpen: false,
|
||||
lastMoveFocusTimeRef: { current: 0 },
|
||||
moveFocusInWorkspace: noop,
|
||||
orderedTabs: ['session-1', 'session-2', 'session-3'],
|
||||
resolveCloseIntent: () => ({ kind: 'noop' }),
|
||||
resolveSnippetsShortcutIntent: () => ({ kind: 'noop' }),
|
||||
sessions: [],
|
||||
setActiveTabId: (id: string) => { activeTabId = id; },
|
||||
setAddToWorkspaceDialog: noop,
|
||||
setIsQuickSwitcherOpen: noop,
|
||||
setNavigateToSection: noop,
|
||||
settings: { showSftpTab: false, shellOnlyTabNumberShortcuts: false },
|
||||
splitSessionWithCurrentShell: noop,
|
||||
systemInfoRef: { current: { username: 'user', hostname: 'host' } },
|
||||
toEditorTabId: (id: string) => `editor:${id}`,
|
||||
toggleBroadcast: noop,
|
||||
toggleScriptsSidePanelRef: { current: noop },
|
||||
toggleSidePanelRef: { current: noop },
|
||||
toggleWorkspaceViewMode: noop,
|
||||
workspaces: [],
|
||||
};
|
||||
|
||||
executeHotkeyActionImpl(
|
||||
() => context,
|
||||
'switchToTab',
|
||||
{ key: '@', code: 'Digit3', ctrlKey: true, shiftKey: true } as KeyboardEvent,
|
||||
);
|
||||
assert.equal(activeTabId, 'session-2');
|
||||
});
|
||||
|
||||
test('next tab includes pinned tabs when shell-only shortcut mode is disabled', () => {
|
||||
let activeTabId = '';
|
||||
const noop = () => {};
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getEffectiveHostDistro, classifyDistroId, shouldProbeSessionCwd } from
|
||||
import { sanitizeHostIconFields } from '../../domain/hostIcon';
|
||||
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
|
||||
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
|
||||
import { tabShortcutDigitFromEvent } from '../../domain/models/keyBindings';
|
||||
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
|
||||
import { captureInheritedCwd } from '../state/inheritedCwd';
|
||||
|
||||
@@ -496,7 +497,14 @@ async function captureCtxInheritedCwd(getCtx: AppContextGetter, sessionId: strin
|
||||
}
|
||||
}
|
||||
|
||||
const probe = async (id: string, options?: { allowHomeFallback?: boolean; timeoutMs?: number }) =>
|
||||
const probe = async (
|
||||
id: string,
|
||||
options?: {
|
||||
allowHomeFallback?: boolean;
|
||||
allowLoginShellFallback?: boolean;
|
||||
timeoutMs?: number;
|
||||
},
|
||||
) =>
|
||||
(await bridge?.getSessionPwd?.(id, options)) ?? { success: false };
|
||||
return captureInheritedCwd(source, probe, { liveCwd, allowSshProbe });
|
||||
}
|
||||
@@ -673,12 +681,10 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
});
|
||||
switch (action) {
|
||||
case 'switchToTab': {
|
||||
// Get the number key pressed (1-9)
|
||||
const num = parseInt(e.key, 10);
|
||||
if (num >= 1 && num <= 9) {
|
||||
if (num <= shortcutTabs.length) {
|
||||
setActiveTabId(shortcutTabs[num - 1]);
|
||||
}
|
||||
// Prefer physical Digit code so Shift+[1...9] works when e.key is "!" etc.
|
||||
const num = tabShortcutDigitFromEvent(e);
|
||||
if (num !== null && num <= shortcutTabs.length) {
|
||||
setActiveTabId(shortcutTabs[num - 1]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { getNotesSnapshot } from '../state/notesStore';
|
||||
import { useVaultAgentBridge } from '../state/useVaultAgentBridge';
|
||||
import { useWindowControls } from '../state/useWindowControls';
|
||||
import { useTerminalKeyboardFocus } from '../state/useTerminalKeyboardFocus';
|
||||
import { useEditorTabChromeList } from '../state/editorTabStore';
|
||||
import {
|
||||
isPluginViewTabId,
|
||||
@@ -132,12 +133,15 @@ export function AppSideEffects() {
|
||||
terminalSettings,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
disableTerminalFontZoom,
|
||||
isHotkeyRecording,
|
||||
showSftpTab,
|
||||
shellOnlyTabNumberShortcuts,
|
||||
workspaceFocusStyle,
|
||||
} = settings;
|
||||
|
||||
useTerminalKeyboardFocus(hotkeyScheme !== 'disabled' && !disableTerminalFontZoom);
|
||||
|
||||
const discoveredShells = useDiscoveredShells();
|
||||
|
||||
// Sync workspace focus indicator style to DOM for CSS targeting
|
||||
|
||||
@@ -34,6 +34,7 @@ import { useMainWindowInputFocusRecovery } from '../state/useMainWindowInputFocu
|
||||
import { useExternalMcpToggleState } from '../state/useExternalMcpToggleState';
|
||||
import { selectPluginThemeTokens } from '../state/pluginContributionEnvironment';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { resolveEffectiveTerminalHost } from '../../domain/terminalHostResolution';
|
||||
import { pluginViewTabStore, usePluginViewTabs } from '../state/pluginViewTabStore';
|
||||
import { buildPluginSettingScopeCatalog } from '../state/usePluginSettingScopeCatalog';
|
||||
import { useWorkSurfaceHostEditor } from '../state/useWorkSurfaceHostEditor';
|
||||
@@ -317,6 +318,29 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
setAddToWorkspaceDialog({ mode: 'append', workspaceId });
|
||||
}, [setAddToWorkspaceDialog]);
|
||||
|
||||
const validProxyProfileIds = useMemo(
|
||||
() => new Set(proxyProfiles.map((profile) => profile.id)),
|
||||
[proxyProfiles],
|
||||
);
|
||||
|
||||
const resolveWorkspaceAppendHost = useCallback((host: typeof hosts[number]) => (
|
||||
resolveEffectiveTerminalHost({
|
||||
host,
|
||||
groupConfigs,
|
||||
proxyProfiles,
|
||||
validProxyProfileIds,
|
||||
})
|
||||
), [groupConfigs, proxyProfiles, validProxyProfileIds]);
|
||||
|
||||
const handleAppendHostToWorkspace = useCallback((workspaceId: string, hostId: string) => {
|
||||
const host = hosts.find((entry) => entry.id === hostId);
|
||||
if (!host) return;
|
||||
const ws = workspaces.find((entry) => entry.id === workspaceId);
|
||||
if (!ws) return;
|
||||
const rootDir = ws.root.type === 'split' ? ws.root.direction : 'vertical';
|
||||
appendHostToWorkspace(workspaceId, resolveWorkspaceAppendHost(host), rootDir);
|
||||
}, [appendHostToWorkspace, hosts, resolveWorkspaceAppendHost, workspaces]);
|
||||
|
||||
const isPeerSessionWindow = typeof window !== 'undefined'
|
||||
&& window.location.hash.startsWith('#/session-window');
|
||||
const externalMcpToggle = useExternalMcpToggleState();
|
||||
@@ -471,8 +495,10 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onEndSessionDrag={handleEndSessionDrag}
|
||||
onReorderTabs={reorderWorkTabs}
|
||||
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
|
||||
onAppendHostToWorkspace={handleAppendHostToWorkspace}
|
||||
showSftpTab={showSftpTab}
|
||||
showHostTreeSidebar={showHostTreeSidebar}
|
||||
switchTabKeyBinding={keyBindings.find((binding) => binding.action === 'switchToTab') ?? null}
|
||||
dynamicTabTitleMode={dynamicTabTitleMode}
|
||||
editorTabs={editorTabs}
|
||||
pluginViewTabs={pluginViewTabs}
|
||||
@@ -658,6 +684,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
|
||||
onAddSessionToWorkspace={addSessionToWorkspace}
|
||||
onRequestAddToWorkspace={handleRequestAddToWorkspace}
|
||||
onAppendHostToWorkspace={handleAppendHostToWorkspace}
|
||||
onUpdateSplitSizes={updateSplitSizes}
|
||||
onSetDraggingSessionId={setDraggingSessionId}
|
||||
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
|
||||
@@ -779,13 +806,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
<AddToWorkspaceDialog
|
||||
open
|
||||
onOpenChange={(open) => { if (!open) setAddToWorkspaceDialog(null); }}
|
||||
// Filter serial hosts only in append mode — appendHostToWorkspace
|
||||
// has no serial code path. Create mode goes through
|
||||
// createWorkspaceFromTargets, which builds a SerialConfig-backed
|
||||
// session for serial hosts, so those should remain pickable.
|
||||
hosts={addToWorkspaceDialog.mode === 'append'
|
||||
? hosts.filter((h) => h.protocol !== 'serial')
|
||||
: hosts}
|
||||
hosts={hosts}
|
||||
workspaceTitle={
|
||||
addToWorkspaceDialog.mode === 'append'
|
||||
? workspaces.find((w) => w.id === addToWorkspaceDialog.workspaceId)?.title
|
||||
@@ -804,7 +825,11 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
if (target.kind === 'local') {
|
||||
appendLocalTerminalToWorkspace(addToWorkspaceDialog.workspaceId, undefined, rootDir);
|
||||
} else {
|
||||
appendHostToWorkspace(addToWorkspaceDialog.workspaceId, target.host, rootDir);
|
||||
appendHostToWorkspace(
|
||||
addToWorkspaceDialog.workspaceId,
|
||||
resolveWorkspaceAppendHost(target.host),
|
||||
rootDir,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const source = readFileSync(new URL('./AppView.tsx', import.meta.url), 'utf8');
|
||||
|
||||
test('workspace append resolves group defaults before creating host sessions', () => {
|
||||
assert.match(source, /resolveEffectiveTerminalHost\(\{/);
|
||||
assert.match(source, /groupConfigs,/);
|
||||
assert.match(source, /proxyProfiles,/);
|
||||
assert.match(
|
||||
source,
|
||||
/appendHostToWorkspace\(workspaceId, resolveWorkspaceAppendHost\(host\), rootDir\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/appendHostToWorkspace\([\s\S]*?resolveWorkspaceAppendHost\(target\.host\),[\s\S]*?rootDir/,
|
||||
);
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { buildNumberShortcutTabTargets } from './tabShortcutTargets.ts';
|
||||
import { buildNumberShortcutTabTargets, buildTabShortcutNumberById } from './tabShortcutTargets.ts';
|
||||
|
||||
test('number shortcut tabs include vault and sftp by default', () => {
|
||||
assert.deepEqual(
|
||||
@@ -62,3 +62,43 @@ test('pinned tabs cannot be duplicated by a malformed persisted work ordering',
|
||||
['vault', 'sftp', 'session-1'],
|
||||
);
|
||||
});
|
||||
|
||||
test('shortcut number map uses 1-based indices matching Ctrl/Cmd+[1...9]', () => {
|
||||
const map = buildTabShortcutNumberById({
|
||||
showSftpTab: true,
|
||||
shellOnlyTabNumberShortcuts: false,
|
||||
orderedTabs: ['session-1', 'workspace-1'],
|
||||
editorTabIds: [],
|
||||
});
|
||||
assert.equal(map.get('vault'), 1);
|
||||
assert.equal(map.get('sftp'), 2);
|
||||
assert.equal(map.get('session-1'), 3);
|
||||
assert.equal(map.get('workspace-1'), 4);
|
||||
});
|
||||
|
||||
test('shortcut number map skips pinned tabs in shell-only mode', () => {
|
||||
const map = buildTabShortcutNumberById({
|
||||
showSftpTab: true,
|
||||
shellOnlyTabNumberShortcuts: true,
|
||||
orderedTabs: ['session-1', 'workspace-1'],
|
||||
editorTabIds: [],
|
||||
});
|
||||
assert.equal(map.has('vault'), false);
|
||||
assert.equal(map.has('sftp'), false);
|
||||
assert.equal(map.get('session-1'), 1);
|
||||
assert.equal(map.get('workspace-1'), 2);
|
||||
});
|
||||
|
||||
test('shortcut number map caps at nine entries', () => {
|
||||
const orderedTabs = Array.from({ length: 12 }, (_, index) => `session-${index + 1}`);
|
||||
const map = buildTabShortcutNumberById({
|
||||
showSftpTab: false,
|
||||
shellOnlyTabNumberShortcuts: true,
|
||||
orderedTabs,
|
||||
editorTabIds: [],
|
||||
});
|
||||
assert.equal(map.size, 9);
|
||||
assert.equal(map.get('session-1'), 1);
|
||||
assert.equal(map.get('session-9'), 9);
|
||||
assert.equal(map.has('session-10'), false);
|
||||
});
|
||||
@@ -12,3 +12,22 @@ export function buildNumberShortcutTabTargets(params: {
|
||||
const pinnedTabs = params.showSftpTab ? ['vault', 'sftp'] : ['vault'];
|
||||
return [...new Set([...pinnedTabs, ...workTabs])];
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps tab ids to Cmd/Ctrl+[1...9] shortcut indices (1-based).
|
||||
* Only the first nine shortcut targets receive a number.
|
||||
*/
|
||||
export function buildTabShortcutNumberById(params: {
|
||||
showSftpTab: boolean;
|
||||
shellOnlyTabNumberShortcuts: boolean;
|
||||
orderedTabs: readonly string[];
|
||||
editorTabIds: readonly string[];
|
||||
}): ReadonlyMap<string, number> {
|
||||
const targets = buildNumberShortcutTabTargets(params);
|
||||
const map = new Map<string, number>();
|
||||
const limit = Math.min(9, targets.length);
|
||||
for (let index = 0; index < limit; index += 1) {
|
||||
map.set(targets[index], index + 1);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
@@ -789,6 +789,8 @@ Highlight the focused split pane:
|
||||
'settings.shortcuts.disableTerminalFontZoom.desc': 'Turn off terminal font zoom shortcuts, including Cmd/Ctrl + mouse wheel.',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': 'Tab shortcuts skip pinned tabs',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': 'When enabled, Cmd/Ctrl+[1...9] and Ctrl+Tab switch only work tabs (terminals, workspaces, editors), not the pinned Vault or SFTP tabs.',
|
||||
'settings.shortcuts.showTabNumberBadges.label': 'Show tab number badges',
|
||||
'settings.shortcuts.showTabNumberBadges.desc': 'While holding Cmd/Ctrl, show 1-9 on tabs that match Cmd/Ctrl+[1...9], so you can jump accurately.',
|
||||
'settings.shortcuts.section.custom': 'Custom Shortcuts',
|
||||
'settings.shortcuts.resetAll': 'Reset All',
|
||||
'settings.shortcuts.recording': 'Press keys...',
|
||||
|
||||
@@ -13,6 +13,7 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
'systemManager.tabs.gpu': 'GPU',
|
||||
'systemManager.tabs.ariaLabel': 'System manager sections',
|
||||
'systemManager.popup.loading': 'Opening terminal…',
|
||||
'systemManager.popup.startupFailed': 'The startup command did not complete successfully. Check that the target is still available and try again.',
|
||||
|
||||
|
||||
@@ -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': 'Нажмите клавиши...',
|
||||
|
||||
@@ -13,6 +13,7 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
'systemManager.tabs.gpu': 'GPU',
|
||||
'systemManager.tabs.ariaLabel': 'Разделы системного менеджера',
|
||||
'systemManager.popup.loading': 'Открытие терминала…',
|
||||
'systemManager.popup.startupFailed': 'Команда запуска не была выполнена успешно. Проверьте, что цель доступна, и повторите попытку.',
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
'systemManager.tabs.gpu': 'GPU',
|
||||
'systemManager.tabs.ariaLabel': '系统管理分区',
|
||||
'systemManager.popup.loading': '正在打开终端…',
|
||||
'systemManager.popup.startupFailed': '启动命令未成功。请确认目标仍然可用后重试。',
|
||||
|
||||
|
||||
@@ -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': '请按键...',
|
||||
|
||||
@@ -13,6 +13,7 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
'systemManager.tabs.gpu': 'GPU',
|
||||
'systemManager.tabs.ariaLabel': '系統管理分區',
|
||||
'systemManager.popup.loading': '正在開啟終端…',
|
||||
'systemManager.popup.startupFailed': '啟動指令未順利完成。請確認目標仍可使用後再試一次。',
|
||||
|
||||
|
||||
@@ -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) {
|
||||
@@ -559,6 +568,7 @@ export function useSettingsStorageSync({
|
||||
setShowRecentHostsState,
|
||||
setShowSftpTabState,
|
||||
setShellOnlyTabNumberShortcutsState,
|
||||
setShowTabNumberBadgesState,
|
||||
setDisableTerminalFontZoomState,
|
||||
setRestorePreviousSessionState,
|
||||
setRestoreTerminalCwdState,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -162,7 +162,6 @@ export async function openTransferSftpSession(
|
||||
if (
|
||||
!wantDedicated
|
||||
&& options?.sourceSessionId
|
||||
&& !host.sftpSudo
|
||||
&& bridge.openSftpForSession
|
||||
) {
|
||||
try {
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { Host } from "../../../domain/models";
|
||||
import { ensureRemoteSftpSession } from "./ensureRemoteSftpSession";
|
||||
import { ensureRemoteSftpSession, probeSftpSession } from "./ensureRemoteSftpSession";
|
||||
import type { SftpPane } from "./types";
|
||||
|
||||
const host = {
|
||||
@@ -37,6 +37,24 @@ const remotePane = (connectionId: string): SftpPane => ({
|
||||
|
||||
const noopReleaseConnection = async () => {};
|
||||
|
||||
test("SFTP session probe accepts a virtual root directory", async () => {
|
||||
const calls: Array<[string, string]> = [];
|
||||
const ok = await probeSftpSession({
|
||||
realpathSftp: async (sftpId, remotePath) => {
|
||||
calls.push([sftpId, remotePath]);
|
||||
return "/";
|
||||
},
|
||||
}, "sftp-jumpserver");
|
||||
|
||||
assert.equal(ok, true);
|
||||
assert.deepEqual(calls, [["sftp-jumpserver", "."]]);
|
||||
});
|
||||
|
||||
test("SFTP session probe does not require home discovery", async () => {
|
||||
const ok = await probeSftpSession(undefined, "sftp-live");
|
||||
assert.equal(ok, true);
|
||||
});
|
||||
|
||||
test("returns an existing mapped SFTP session without reconnecting", async () => {
|
||||
let connectCalls = 0;
|
||||
const sftpId = await ensureRemoteSftpSession({
|
||||
|
||||
@@ -3,6 +3,24 @@ import type { MutableRefObject } from "react";
|
||||
import { isSessionError } from "./errors";
|
||||
import type { SftpPane } from "./types";
|
||||
|
||||
export interface SftpSessionProbeBridge {
|
||||
realpathSftp?: (sftpId: string, path: string) => Promise<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe the SFTP protocol itself instead of using remote home discovery.
|
||||
* Restricted/chroot servers can deny SSH exec or expose '/' as their virtual
|
||||
* root while the SFTP session remains fully usable.
|
||||
*/
|
||||
export async function probeSftpSession(
|
||||
bridge: SftpSessionProbeBridge | null | undefined,
|
||||
sftpId: string,
|
||||
): Promise<boolean> {
|
||||
if (!bridge?.realpathSftp) return true;
|
||||
await bridge.realpathSftp(sftpId, ".");
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface EnsureRemoteSftpSessionParams {
|
||||
side: "left" | "right";
|
||||
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||||
|
||||
@@ -3,8 +3,12 @@
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import {
|
||||
buildSftpConnectInFlightKey,
|
||||
buildSftpHomeDirCandidates,
|
||||
runSftpConnectOnceByKey,
|
||||
createSftpConnectionId,
|
||||
createPinnedReconnectSideResolver,
|
||||
openSftpConnectionOnce,
|
||||
@@ -15,12 +19,62 @@ import {
|
||||
resolvePinnedReconnectSide,
|
||||
} from "./useSftpConnections.ts";
|
||||
|
||||
test("home dir candidates prefer user home then root", () => {
|
||||
assert.deepEqual(buildSftpHomeDirCandidates("deploy"), ["/home/deploy", "/root"]);
|
||||
assert.deepEqual(buildSftpHomeDirCandidates("root"), ["/root"]);
|
||||
assert.deepEqual(buildSftpHomeDirCandidates(undefined), ["/root"]);
|
||||
assert.deepEqual(buildSftpHomeDirCandidates(null), ["/root"]);
|
||||
});
|
||||
|
||||
test("connection ids stay unique even when connects start in the same millisecond", () => {
|
||||
const ids = ["uuid-a", "uuid-b"];
|
||||
assert.equal(createSftpConnectionId("left", () => ids.shift()!), "left-uuid-a");
|
||||
assert.equal(createSftpConnectionId("left", () => ids.shift()!), "left-uuid-b");
|
||||
});
|
||||
|
||||
test("runSftpConnectOnceByKey shares an in-flight connect for the same tab and endpoint", async () => {
|
||||
const inFlight = new Map<string, Promise<void>>();
|
||||
let runs = 0;
|
||||
let releaseFirst: (() => void) | undefined;
|
||||
|
||||
const first = runSftpConnectOnceByKey(inFlight, "left:tab-1:host-key:ssh-session-1:/home", async () => {
|
||||
runs += 1;
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
});
|
||||
const second = runSftpConnectOnceByKey(inFlight, "left:tab-1:host-key:ssh-session-1:/home", async () => {
|
||||
runs += 1;
|
||||
});
|
||||
|
||||
assert.equal(runs, 1);
|
||||
releaseFirst?.();
|
||||
await Promise.all([first, second]);
|
||||
assert.equal(runs, 1);
|
||||
assert.equal(inFlight.size, 0);
|
||||
});
|
||||
|
||||
test("buildSftpConnectInFlightKey uses the allocated tab id for forced new tabs", () => {
|
||||
const first = buildSftpConnectInFlightKey({
|
||||
side: "left",
|
||||
tabId: "new-tab-a",
|
||||
targetConnectionKey: "host-key",
|
||||
sourceSessionId: "ssh-session-1",
|
||||
initialPath: "/home",
|
||||
forceNewTab: true,
|
||||
});
|
||||
const second = buildSftpConnectInFlightKey({
|
||||
side: "left",
|
||||
tabId: "new-tab-b",
|
||||
targetConnectionKey: "host-key",
|
||||
sourceSessionId: "ssh-session-1",
|
||||
initialPath: "/home",
|
||||
forceNewTab: true,
|
||||
});
|
||||
|
||||
assert.notEqual(first, second);
|
||||
});
|
||||
|
||||
const openOptions = {
|
||||
sessionId: "sftp-request-1",
|
||||
hostname: "192.168.9.138",
|
||||
@@ -73,6 +127,43 @@ test("openSftpWithSessionPreference falls back to normal SFTP when session reuse
|
||||
assert.deepEqual(calls, ["openForSession:ssh-session-1", "openSftp:sftp-request-1"]);
|
||||
});
|
||||
|
||||
test("openSftpWithSessionPreference tries session reuse for sudo SFTP before fresh auth", async () => {
|
||||
const calls: string[] = [];
|
||||
let passedOptions: NetcattySSHOptions | undefined;
|
||||
const sftpId = await openSftpWithSessionPreference({
|
||||
bridge: {
|
||||
openSftpForSession: async (sessionId: string, options?: NetcattySSHOptions) => {
|
||||
calls.push(`openForSession:${sessionId}`);
|
||||
passedOptions = options;
|
||||
return "sudo-session-backed-sftp";
|
||||
},
|
||||
openSftp: async () => {
|
||||
calls.push("openSftp");
|
||||
return "fresh-sftp";
|
||||
},
|
||||
},
|
||||
sourceSessionId: "ssh-session-1",
|
||||
openOptions: {
|
||||
...openOptions,
|
||||
sudo: true,
|
||||
password: "sudo-pass",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(sftpId, "sudo-session-backed-sftp");
|
||||
assert.deepEqual(calls, ["openForSession:ssh-session-1"]);
|
||||
assert.equal(passedOptions?.sudo, true);
|
||||
assert.equal(passedOptions?.password, "sudo-pass");
|
||||
});
|
||||
|
||||
test("remote browse connect does not discard sourceSessionId for sudo hosts", () => {
|
||||
const source = readFileSync(new URL("./useSftpConnections.ts", import.meta.url), "utf8");
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/host\.sftpSudo\s*\?\s*undefined\s*:\s*options\?\.sourceSessionId/,
|
||||
);
|
||||
});
|
||||
|
||||
test("openSftpWithSessionPreference opens normal SFTP without a source session", async () => {
|
||||
const calls: string[] = [];
|
||||
const sftpId = await openSftpWithSessionPreference({
|
||||
|
||||
@@ -97,6 +97,13 @@ export async function releaseSftpConnectionMetadata(params: {
|
||||
params.onRemoteSessionClosed?.(sftpId);
|
||||
}
|
||||
|
||||
/** Hardcoded home-path candidates when SSH exec / listable realpath fail. */
|
||||
export function buildSftpHomeDirCandidates(username?: string | null): string[] {
|
||||
if (username === "root") return ["/root"];
|
||||
if (username) return [`/home/${username}`, "/root"];
|
||||
return ["/root"];
|
||||
}
|
||||
|
||||
export function createSftpConnectionId(
|
||||
side: "left" | "right",
|
||||
randomUUID: () => string = () => crypto.randomUUID(),
|
||||
@@ -176,6 +183,41 @@ export function createPinnedReconnectSideResolver(
|
||||
};
|
||||
}
|
||||
|
||||
export function runSftpConnectOnceByKey(
|
||||
inFlight: Map<string, Promise<void>>,
|
||||
key: string,
|
||||
run: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
const existing = inFlight.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const promise = run().finally(() => {
|
||||
if (inFlight.get(key) === promise) {
|
||||
inFlight.delete(key);
|
||||
}
|
||||
});
|
||||
inFlight.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
export function buildSftpConnectInFlightKey(params: {
|
||||
side: "left" | "right";
|
||||
tabId: string;
|
||||
targetConnectionKey: string;
|
||||
sourceSessionId?: string;
|
||||
initialPath?: string;
|
||||
forceNewTab?: boolean;
|
||||
}): string {
|
||||
return [
|
||||
params.side,
|
||||
params.tabId,
|
||||
params.targetConnectionKey,
|
||||
params.sourceSessionId ?? "",
|
||||
params.initialPath ?? "",
|
||||
params.forceNewTab ? "force-new-tab" : "",
|
||||
].join("\u0000");
|
||||
}
|
||||
|
||||
interface UseSftpConnectionsResult {
|
||||
connect: (side: "left" | "right", host: Host | "local", options?: SftpConnectOptions) => Promise<void>;
|
||||
disconnect: (side: "left" | "right") => Promise<void>;
|
||||
@@ -249,6 +291,7 @@ export const useSftpConnections = ({
|
||||
const [hostKeyVerification, setHostKeyVerification] = useState<SftpHostKeyVerificationState | null>(null);
|
||||
const hostKeyVerificationRef = useRef<(SftpHostKeyVerificationState & { requestId: string; sessionId: string }) | null>(null);
|
||||
const activeHostKeySessionsRef = useRef<Map<string, { side: "left" | "right"; tabId: string }>>(new Map());
|
||||
const connectInFlightRef = useRef<Map<string, Promise<void>>>(new Map());
|
||||
|
||||
const setPendingHostKeyVerification = useCallback((
|
||||
next: (SftpHostKeyVerificationState & { requestId: string; sessionId: string }) | null,
|
||||
@@ -426,21 +469,31 @@ export const useSftpConnections = ({
|
||||
// immediately, avoiding race conditions with deferred effects.
|
||||
options?.onTabCreated?.(activeTabId);
|
||||
|
||||
const connectionId = createSftpConnectionId(side);
|
||||
const connectInFlightKey = buildSftpConnectInFlightKey({
|
||||
side,
|
||||
tabId: activeTabId,
|
||||
targetConnectionKey,
|
||||
sourceSessionId: host === "local" ? undefined : options?.sourceSessionId,
|
||||
initialPath: effectiveInitialPath,
|
||||
forceNewTab: options?.forceNewTab,
|
||||
});
|
||||
|
||||
navSeqRef.current[side] += 1;
|
||||
const connectRequestId = navSeqRef.current[side];
|
||||
const getTargetPane = () => {
|
||||
const targetSide = resolveTargetSide();
|
||||
const tabs = targetSide === "left" ? leftTabsRef.current.tabs : rightTabsRef.current.tabs;
|
||||
return tabs.find((tab) => tab.id === activeTabId) ?? null;
|
||||
};
|
||||
const isTargetConnectionCurrent = () => {
|
||||
const pane = getTargetPane();
|
||||
if (!pane) return false;
|
||||
if (pane.connection?.id === connectionId) return true;
|
||||
return !pane.connection && navSeqRef.current[side] === connectRequestId;
|
||||
};
|
||||
return runSftpConnectOnceByKey(connectInFlightRef.current, connectInFlightKey, async () => {
|
||||
const connectionId = createSftpConnectionId(side);
|
||||
|
||||
navSeqRef.current[side] += 1;
|
||||
const connectRequestId = navSeqRef.current[side];
|
||||
const getTargetPane = () => {
|
||||
const targetSide = resolveTargetSide();
|
||||
const tabs = targetSide === "left" ? leftTabsRef.current.tabs : rightTabsRef.current.tabs;
|
||||
return tabs.find((tab) => tab.id === activeTabId) ?? null;
|
||||
};
|
||||
const isTargetConnectionCurrent = () => {
|
||||
const pane = getTargetPane();
|
||||
if (!pane) return false;
|
||||
if (pane.connection?.id === connectionId) return true;
|
||||
return !pane.connection && navSeqRef.current[side] === connectRequestId;
|
||||
};
|
||||
const isTargetConnectionAtPath = (path: string) => {
|
||||
const connection = getTargetPane()?.connection;
|
||||
if (!connection) return navSeqRef.current[side] === connectRequestId;
|
||||
@@ -573,9 +626,7 @@ export const useSftpConnections = ({
|
||||
sharedHostCache?.path,
|
||||
);
|
||||
|
||||
// Sudo never reuses a terminal shell; normalize before UI state so
|
||||
// reusedConnection (spinner/overlay) matches the actual open path.
|
||||
const sourceSessionId = host.sftpSudo ? undefined : options?.sourceSessionId;
|
||||
const sourceSessionId = options?.sourceSessionId;
|
||||
|
||||
const connection: SftpConnection = {
|
||||
id: connectionId,
|
||||
@@ -698,15 +749,7 @@ export const useSftpConnections = ({
|
||||
}
|
||||
|
||||
if (!detected) {
|
||||
const candidates: string[] = [];
|
||||
if (credentials.username === "root") {
|
||||
candidates.push("/root");
|
||||
} else if (credentials.username) {
|
||||
candidates.push(`/home/${credentials.username}`);
|
||||
candidates.push("/root");
|
||||
} else {
|
||||
candidates.push("/root");
|
||||
}
|
||||
const candidates = buildSftpHomeDirCandidates(credentials.username);
|
||||
const statSftp = bridge?.statSftp;
|
||||
if (statSftp) {
|
||||
for (const candidate of candidates) {
|
||||
@@ -784,6 +827,23 @@ export const useSftpConnections = ({
|
||||
// root also failed
|
||||
}
|
||||
}
|
||||
// Last resort: home candidates. Covers provisional "/" from realpath
|
||||
// when discovery treated root as home and listing it failed, so
|
||||
// /home/<user> or /root can still recover the session.
|
||||
if (!fallbackSucceeded) {
|
||||
for (const candidate of buildSftpHomeDirCandidates(credentials.username)) {
|
||||
if (candidate === startPath) continue;
|
||||
try {
|
||||
files = await listRemoteFiles(sftpId, candidate, filenameEncoding);
|
||||
startPath = candidate;
|
||||
homeDir = candidate;
|
||||
fallbackSucceeded = true;
|
||||
break;
|
||||
} catch {
|
||||
// Ignore missing/permission errors
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!fallbackSucceeded) {
|
||||
throw new Error("Cannot list any remote directory");
|
||||
}
|
||||
@@ -855,6 +915,7 @@ export const useSftpConnections = ({
|
||||
unsubSftpProgress?.();
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
normalizeToolbarItemLayout,
|
||||
partitionToolbarItems,
|
||||
setToolbarItemPlacement,
|
||||
} from '../../domain/toolbarItemLayout.ts';
|
||||
import {
|
||||
SYSTEM_MANAGER_TAB_DEFAULT_ORDER,
|
||||
SYSTEM_MANAGER_TAB_LAYOUT_DEFAULTS,
|
||||
} from './systemManagerTabLayout.ts';
|
||||
|
||||
test('system manager tab layout defaults include every known section', () => {
|
||||
assert.deepEqual(
|
||||
[...SYSTEM_MANAGER_TAB_DEFAULT_ORDER],
|
||||
['overview', 'processes', 'ports', 'services', 'tmux', 'docker', 'gpu'],
|
||||
);
|
||||
assert.deepEqual(SYSTEM_MANAGER_TAB_LAYOUT_DEFAULTS.lockedIds, ['overview']);
|
||||
});
|
||||
|
||||
test('system manager overview cannot be hidden', () => {
|
||||
const layout = normalizeToolbarItemLayout(null, SYSTEM_MANAGER_TAB_LAYOUT_DEFAULTS);
|
||||
const next = setToolbarItemPlacement(
|
||||
layout,
|
||||
'overview',
|
||||
'hide',
|
||||
SYSTEM_MANAGER_TAB_LAYOUT_DEFAULTS,
|
||||
);
|
||||
assert.equal(next.placement.overview, 'show');
|
||||
});
|
||||
|
||||
test('system manager partition filters to available host tabs', () => {
|
||||
const layout = normalizeToolbarItemLayout(
|
||||
{
|
||||
order: [...SYSTEM_MANAGER_TAB_DEFAULT_ORDER],
|
||||
placement: {
|
||||
overview: 'show',
|
||||
processes: 'show',
|
||||
ports: 'collapse',
|
||||
services: 'hide',
|
||||
tmux: 'show',
|
||||
docker: 'show',
|
||||
gpu: 'show',
|
||||
},
|
||||
},
|
||||
SYSTEM_MANAGER_TAB_LAYOUT_DEFAULTS,
|
||||
);
|
||||
|
||||
const part = partitionToolbarItems(layout, ['overview', 'processes', 'ports', 'services']);
|
||||
assert.deepEqual(part.shown, ['overview', 'processes']);
|
||||
assert.deepEqual(part.collapsed, ['ports']);
|
||||
assert.deepEqual(part.hidden, ['services']);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { SystemManagerSubTab } from '../../domain/systemManager/types';
|
||||
import type { ToolbarItemLayoutDefaults } from '../../domain/toolbarItemLayout';
|
||||
|
||||
/** Canonical System Manager sub-tab order (all known sections). */
|
||||
export const SYSTEM_MANAGER_TAB_DEFAULT_ORDER: readonly SystemManagerSubTab[] = [
|
||||
'overview',
|
||||
'processes',
|
||||
'ports',
|
||||
'services',
|
||||
'tmux',
|
||||
'docker',
|
||||
'gpu',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Persistable layout defaults for System Manager sub-tabs.
|
||||
* Overview is locked so the panel never loses every reachable section.
|
||||
*/
|
||||
export const SYSTEM_MANAGER_TAB_LAYOUT_DEFAULTS: ToolbarItemLayoutDefaults = {
|
||||
order: [...SYSTEM_MANAGER_TAB_DEFAULT_ORDER],
|
||||
placement: Object.fromEntries(
|
||||
SYSTEM_MANAGER_TAB_DEFAULT_ORDER.map((id) => [id, 'show' as const]),
|
||||
),
|
||||
lockedIds: ['overview'],
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -73,6 +73,11 @@ import {
|
||||
type PanelViewByScope,
|
||||
} from './aiStateSnapshots';
|
||||
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
|
||||
import {
|
||||
handoffDissolvedWorkspaceAIScope,
|
||||
retargetWorkspaceActiveChatAfterMemberLoss,
|
||||
seedWorkspaceAIActiveSessionFromMembers,
|
||||
} from '../../domain/workspaceAiScopeHandoff';
|
||||
export function useAIState() {
|
||||
// ── Provider Config ──
|
||||
const [providers, setProvidersRaw] = useState<ProviderConfig[]>(() =>
|
||||
@@ -968,6 +973,119 @@ export function useAIState() {
|
||||
setPanelViewByScopeRaw(latestAIPanelViewByScopeSnapshot ?? {});
|
||||
}, []);
|
||||
|
||||
const seedWorkspaceActiveSessionFromMembers = useCallback((input: {
|
||||
workspaceId: string;
|
||||
memberTerminalIds: readonly string[];
|
||||
preferredTerminalId?: string | null;
|
||||
}) => {
|
||||
const currentMap =
|
||||
latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {};
|
||||
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? {};
|
||||
const seeded = seedWorkspaceAIActiveSessionFromMembers({
|
||||
activeSessionIdMap: currentMap,
|
||||
panelViewByScope: currentPanelViewByScope,
|
||||
workspaceId: input.workspaceId,
|
||||
memberTerminalIds: input.memberTerminalIds,
|
||||
preferredTerminalId: input.preferredTerminalId,
|
||||
});
|
||||
if (!seeded) return;
|
||||
setLatestAIActiveSessionMapSnapshot(seeded.activeSessionIdMap);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, seeded.activeSessionIdMap);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
setActiveSessionIdMapRaw(seeded.activeSessionIdMap);
|
||||
if (seeded.panelViewChanged) {
|
||||
setLatestAIPanelViewByScopeSnapshot(seeded.panelViewByScope);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
|
||||
setPanelViewByScopeRaw(seeded.panelViewByScope);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handoffDissolvedWorkspaceScope = useCallback((input: {
|
||||
workspaceId: string;
|
||||
terminalIds: readonly string[];
|
||||
preferredTerminalId?: string | null;
|
||||
}) => {
|
||||
const currentMap =
|
||||
latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {};
|
||||
const currentSessions =
|
||||
latestAISessionsSnapshot
|
||||
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
||||
?? [];
|
||||
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? {};
|
||||
const result = handoffDissolvedWorkspaceAIScope({
|
||||
activeSessionIdMap: currentMap,
|
||||
sessions: currentSessions,
|
||||
workspaceId: input.workspaceId,
|
||||
terminalIds: input.terminalIds,
|
||||
preferredTerminalId: input.preferredTerminalId,
|
||||
panelViewByScope: currentPanelViewByScope,
|
||||
});
|
||||
if (!result.changed) return;
|
||||
|
||||
if (result.activeSessionIdMap !== currentMap) {
|
||||
setLatestAIActiveSessionMapSnapshot(result.activeSessionIdMap);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, result.activeSessionIdMap);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
setActiveSessionIdMapRaw(result.activeSessionIdMap);
|
||||
}
|
||||
|
||||
if (result.sessions !== currentSessions) {
|
||||
sessionsRef.current = result.sessions;
|
||||
setLatestAISessionsSnapshot(result.sessions);
|
||||
persistSessions(result.sessions);
|
||||
setSessionsRaw(result.sessions);
|
||||
}
|
||||
|
||||
if (result.panelViewByScope !== currentPanelViewByScope) {
|
||||
setLatestAIPanelViewByScopeSnapshot(result.panelViewByScope);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
|
||||
setPanelViewByScopeRaw(result.panelViewByScope);
|
||||
}
|
||||
}, [persistSessions]);
|
||||
|
||||
const retargetWorkspaceActiveChatForMemberLoss = useCallback((input: {
|
||||
workspaceId: string;
|
||||
previousMemberTerminalIds: readonly string[];
|
||||
currentMemberTerminalIds: readonly string[];
|
||||
preferredTerminalId?: string | null;
|
||||
}) => {
|
||||
const currentMap =
|
||||
latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {};
|
||||
const currentSessions =
|
||||
latestAISessionsSnapshot
|
||||
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
||||
?? [];
|
||||
const result = retargetWorkspaceActiveChatAfterMemberLoss({
|
||||
activeSessionIdMap: currentMap,
|
||||
sessions: currentSessions,
|
||||
workspaceId: input.workspaceId,
|
||||
previousMemberTerminalIds: input.previousMemberTerminalIds,
|
||||
currentMemberTerminalIds: input.currentMemberTerminalIds,
|
||||
preferredTerminalId: input.preferredTerminalId,
|
||||
});
|
||||
if (!result.changed) return;
|
||||
|
||||
if (result.activeSessionIdMap !== currentMap) {
|
||||
setLatestAIActiveSessionMapSnapshot(result.activeSessionIdMap);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, result.activeSessionIdMap);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
setActiveSessionIdMapRaw(result.activeSessionIdMap);
|
||||
}
|
||||
|
||||
if (result.sessions !== currentSessions) {
|
||||
sessionsRef.current = result.sessions;
|
||||
setLatestAISessionsSnapshot(result.sessions);
|
||||
persistSessions(result.sessions);
|
||||
setSessionsRaw(result.sessions);
|
||||
}
|
||||
}, [persistSessions]);
|
||||
|
||||
// ── Provider CRUD helpers ──
|
||||
const addProvider = useCallback((provider: ProviderConfig) => {
|
||||
setProviders(prev => [...prev, provider]);
|
||||
@@ -1071,6 +1189,9 @@ export function useAIState() {
|
||||
updateMessageById,
|
||||
persistContextCompaction,
|
||||
cleanupOrphanedSessions,
|
||||
seedWorkspaceActiveSessionFromMembers,
|
||||
handoffDissolvedWorkspaceScope,
|
||||
retargetWorkspaceActiveChatForMemberLoss,
|
||||
}), [
|
||||
providers,
|
||||
setProviders,
|
||||
@@ -1124,5 +1245,8 @@ export function useAIState() {
|
||||
updateMessageById,
|
||||
persistContextCompaction,
|
||||
cleanupOrphanedSessions,
|
||||
seedWorkspaceActiveSessionFromMembers,
|
||||
handoffDissolvedWorkspaceScope,
|
||||
retargetWorkspaceActiveChatForMemberLoss,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createLatestPayloadSync } from './useExternalMcpSessionSync.ts';
|
||||
|
||||
const hookSource = readFileSync(new URL('./useExternalMcpSessionSync.ts', import.meta.url), 'utf8');
|
||||
const appSource = readFileSync(new URL('../app/AppSideEffects.tsx', import.meta.url), 'utf8');
|
||||
const preloadSource = readFileSync(new URL('../../electron/preload/api.cjs', import.meta.url), 'utf8');
|
||||
const handlerSource = readFileSync(
|
||||
new URL('../../electron/bridges/aiBridge/agentProcessHandlers.cjs', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
test('app live session sync stays mounted and is independent of External MCP enablement', () => {
|
||||
assert.match(appSource, /useExternalMcpSessionSync\(\{/);
|
||||
assert.match(hookSource, /bridge\.aiMcpUpdateLiveSessions\?\.\(nextPayload\)/);
|
||||
assert.match(hookSource, /liveSyncRef\.current\.push\(payload, serialized\)/);
|
||||
assert.match(hookSource, /bridge\.aiMcpUpdateSessions\?\.\(/);
|
||||
assert.match(hookSource, /externalSyncRef\.current\.push\(payload, serialized\)/);
|
||||
assert.doesNotMatch(
|
||||
hookSource,
|
||||
/if \(!enabled\)[\s\S]{0,300}aiMcpUpdateLiveSessions/,
|
||||
);
|
||||
});
|
||||
|
||||
test('live session sync has a complete renderer to main-process IPC path', () => {
|
||||
assert.match(preloadSource, /netcatty:ai:mcp:update-live-sessions/);
|
||||
assert.match(handlerSource, /netcatty:ai:mcp:update-live-sessions/);
|
||||
assert.match(handlerSource, /mcpServerBridge\.updateLiveSessionMetadata/);
|
||||
});
|
||||
|
||||
test('latest payload sync converges to A-B-A while B is in flight', async () => {
|
||||
let releaseB: (() => void) | undefined;
|
||||
const sent: string[] = [];
|
||||
const sync = createLatestPayloadSync(async (payload: string) => {
|
||||
sent.push(payload);
|
||||
if (payload === 'B') {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseB = resolve;
|
||||
});
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
await sync.push('A');
|
||||
const sendingB = sync.push('B');
|
||||
void sync.push('A');
|
||||
releaseB?.();
|
||||
await sendingB;
|
||||
|
||||
assert.deepEqual(sent, ['A', 'B', 'A']);
|
||||
});
|
||||
|
||||
test('cancelling a payload sync drops a queued update', async () => {
|
||||
let releaseA: (() => void) | undefined;
|
||||
const sent: string[] = [];
|
||||
const sync = createLatestPayloadSync(async (payload: string) => {
|
||||
sent.push(payload);
|
||||
if (payload === 'A') {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseA = resolve;
|
||||
});
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const sendingA = sync.push('A');
|
||||
void sync.push('B');
|
||||
sync.cancel();
|
||||
releaseA?.();
|
||||
await sendingA;
|
||||
|
||||
assert.deepEqual(sent, ['A']);
|
||||
});
|
||||
@@ -17,6 +17,49 @@ type UseExternalMcpSessionSyncOptions = {
|
||||
portForwardingRules: PortForwardingRule[];
|
||||
};
|
||||
|
||||
export function createLatestPayloadSync<T>(
|
||||
send: (payload: T) => Promise<unknown>,
|
||||
) {
|
||||
let desired: { serialized: string; payload: T } | null = null;
|
||||
let acknowledged = '';
|
||||
let inFlight: Promise<void> | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
const pump = async () => {
|
||||
while (!cancelled && desired && desired.serialized !== acknowledged) {
|
||||
const target = desired;
|
||||
try {
|
||||
const result = await send(target.payload);
|
||||
if (cancelled) break;
|
||||
if (result && typeof result === 'object' && 'ok' in result && (result as { ok?: boolean }).ok === false) {
|
||||
if (desired === target) break;
|
||||
continue;
|
||||
}
|
||||
acknowledged = target.serialized;
|
||||
} catch {
|
||||
if (desired === target) break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
push(payload: T, serialized = JSON.stringify(payload)): Promise<void> {
|
||||
if (cancelled) return Promise.resolve();
|
||||
desired = { payload, serialized };
|
||||
if (!inFlight) {
|
||||
inFlight = pump().finally(() => {
|
||||
inFlight = null;
|
||||
});
|
||||
}
|
||||
return inFlight;
|
||||
},
|
||||
cancel(): void {
|
||||
cancelled = true;
|
||||
desired = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isMainAppWindow(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const hash = window.location.hash || '';
|
||||
@@ -43,7 +86,6 @@ export function useExternalMcpSessionSync({
|
||||
void enabledTick;
|
||||
return readExternalMcpStoredEnabled();
|
||||
}, [enabledTick]);
|
||||
const syncGenerationRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const bump = () => setEnabledTick((value) => value + 1);
|
||||
@@ -77,34 +119,41 @@ export function useExternalMcpSessionSync({
|
||||
});
|
||||
}, [sessions, sessionHostsMap, hosts, portForwardingRules]);
|
||||
|
||||
const lastSentSerializedRef = useRef('');
|
||||
const liveSyncRef = useRef<ReturnType<typeof createLatestPayloadSync<typeof payload>> | null>(null);
|
||||
const externalSyncRef = useRef<ReturnType<typeof createLatestPayloadSync<typeof payload>> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMainAppWindow()) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.aiMcpUpdateLiveSessions) return;
|
||||
const serialized = JSON.stringify(payload);
|
||||
liveSyncRef.current ??= createLatestPayloadSync((nextPayload) => (
|
||||
Promise.resolve(bridge.aiMcpUpdateLiveSessions?.(nextPayload))
|
||||
));
|
||||
void liveSyncRef.current.push(payload, serialized);
|
||||
}, [payload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMainAppWindow()) return;
|
||||
if (!enabled) {
|
||||
syncGenerationRef.current += 1;
|
||||
lastSentSerializedRef.current = '';
|
||||
// Recreate the synchronizer after re-enabling because disabling can
|
||||
// clear the main-process scope even when the renderer payload is equal.
|
||||
externalSyncRef.current?.cancel();
|
||||
externalSyncRef.current = null;
|
||||
return;
|
||||
}
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.aiMcpUpdateSessions) return;
|
||||
|
||||
const serialized = JSON.stringify(payload);
|
||||
if (serialized === lastSentSerializedRef.current) return;
|
||||
|
||||
const generation = ++syncGenerationRef.current;
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void Promise.resolve(bridge.aiMcpUpdateSessions?.(payload, EXTERNAL_MCP_CHAT_SESSION_ID))
|
||||
.then((result) => {
|
||||
if (generation !== syncGenerationRef.current) return;
|
||||
if (result && typeof result === 'object' && 'ok' in result && (result as { ok?: boolean }).ok === false) {
|
||||
return;
|
||||
}
|
||||
lastSentSerializedRef.current = serialized;
|
||||
})
|
||||
.catch(() => {
|
||||
// Leave the ref unset so the next effect can retry.
|
||||
});
|
||||
externalSyncRef.current ??= createLatestPayloadSync((nextPayload) => (
|
||||
Promise.resolve(bridge.aiMcpUpdateSessions?.(
|
||||
nextPayload,
|
||||
EXTERNAL_MCP_CHAT_SESSION_ID,
|
||||
))
|
||||
));
|
||||
void externalSyncRef.current.push(payload, serialized);
|
||||
}, 250);
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -199,6 +199,36 @@ describe('useExternalMcpToggleState startup ready gate', () => {
|
||||
assert.match(hookSource, /if \(isPeerSessionWindow \|\| !enabled\) return;/);
|
||||
});
|
||||
|
||||
it('restores saved permission before enabling persistent External MCP', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
const storageKeys = await import('../../infrastructure/config/storageKeys.ts');
|
||||
globalThis.localStorage.setItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, 'true');
|
||||
globalThis.localStorage.setItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
|
||||
globalThis.localStorage.setItem(storageKeys.STORAGE_KEY_AI_PERMISSION_MODE, 'auto');
|
||||
|
||||
const calls: string[] = [];
|
||||
await syncExternalMcpStartupState({
|
||||
aiMcpSetPermissionMode: async (mode) => {
|
||||
calls.push(`permission:${mode}`);
|
||||
return { ok: true };
|
||||
},
|
||||
externalMcpSetConfig: async () => {
|
||||
calls.push('config');
|
||||
return { ok: true };
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled) => {
|
||||
calls.push(`enabled:${enabled}`);
|
||||
return { ok: true, enabled };
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['permission:auto', 'config', 'enabled:true']);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('re-reads storage after config await so concurrent top-bar toggles win', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
|
||||
@@ -5,8 +5,10 @@ import {
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_MODE,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_SILENT_SESSIONS,
|
||||
STORAGE_KEY_AI_PERMISSION_MODE,
|
||||
STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import type { AIPermissionMode } from '../../infrastructure/ai/types';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
|
||||
@@ -105,6 +107,7 @@ type ExternalMcpConfig = {
|
||||
};
|
||||
|
||||
type ExternalMcpBridge = {
|
||||
aiMcpSetPermissionMode?: (mode: AIPermissionMode) => Promise<unknown> | unknown;
|
||||
externalMcpSetConfig?: (config: ExternalMcpConfig) => Promise<unknown> | unknown;
|
||||
externalMcpSetEnabled?: (enabled: boolean) => Promise<unknown> | unknown;
|
||||
externalMcpGetStatus?: () => Promise<{
|
||||
@@ -126,6 +129,11 @@ export function normalizeExternalMcpMode(value: string | null): ExternalMcpMode
|
||||
return value === 'persistent' ? 'persistent' : 'temporary';
|
||||
}
|
||||
|
||||
function readAIPermissionMode(): AIPermissionMode {
|
||||
const stored = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
|
||||
return stored === 'observer' || stored === 'auto' ? stored : 'confirm';
|
||||
}
|
||||
|
||||
export function normalizeExternalMcpIdleTimeoutMinutes(value: number | null): number {
|
||||
if (!Number.isFinite(value)) return DEFAULT_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES;
|
||||
return Math.min(
|
||||
@@ -280,6 +288,13 @@ export async function syncExternalMcpStartupState(
|
||||
// toggle during boot wins over a stale enable/disable decision.
|
||||
const startupGeneration = externalMcpEnableGeneration;
|
||||
const initialPlan = readExternalMcpStartupSyncPlan();
|
||||
try {
|
||||
// External MCP discovery is written while enabling, so restore the saved
|
||||
// permission first instead of exposing the main-process default (confirm).
|
||||
await Promise.resolve(bridge?.aiMcpSetPermissionMode?.(readAIPermissionMode()));
|
||||
} catch {
|
||||
// Permission sync is best-effort; the main process keeps its safe default.
|
||||
}
|
||||
try {
|
||||
await Promise.resolve(bridge?.externalMcpSetConfig?.(initialPlan.config));
|
||||
} catch {
|
||||
|
||||
@@ -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
|
||||
@@ -183,16 +184,6 @@ function clearServerStatsTimers(session: SharedServerStatsSession): void {
|
||||
session.pollIntervalMs = null;
|
||||
}
|
||||
|
||||
function resetServerStatsSession(session: SharedServerStatsSession): void {
|
||||
clearServerStatsTimers(session);
|
||||
session.connectedAt = 0;
|
||||
session.hasFetched = false;
|
||||
session.fetchGeneration += 1;
|
||||
session.consecutiveFailures = 0;
|
||||
session.givenUp = false;
|
||||
updateServerStatsState(session, createInitialState());
|
||||
}
|
||||
|
||||
function getActiveServerStatsClients(session: SharedServerStatsSession): ServerStatsClient[] {
|
||||
return Array.from(session.clients.values()).filter((client) => (
|
||||
client.enabled && client.isSupportedOs && client.isConnected
|
||||
@@ -312,7 +303,16 @@ function reconcileSharedServerStatsSession(session: SharedServerStatsSession): v
|
||||
const activeClients = getActiveServerStatsClients(session);
|
||||
|
||||
if (activeClients.length === 0) {
|
||||
resetServerStatsSession(session);
|
||||
// Pause polling only. Keep the last successful stats so remounting Overview
|
||||
// (or re-enabling a terminal stats strip) can paint immediately instead of
|
||||
// flashing "No system overview data yet." Still clear give-up / failure so a
|
||||
// later resume can auto-retry (old reset wiped stats + flags together).
|
||||
clearServerStatsTimers(session);
|
||||
session.givenUp = false;
|
||||
session.consecutiveFailures = 0;
|
||||
if (session.state.isLoading) {
|
||||
updateServerStatsState(session, { isLoading: false });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
import { useCallback } from "react";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
|
||||
type ManualChoosePathPayload = {
|
||||
sessionId: string;
|
||||
sessionName?: string;
|
||||
preferredDirectory?: string;
|
||||
format?: "txt" | "raw" | "html";
|
||||
};
|
||||
|
||||
type ManualStartPayload = {
|
||||
sessionId: string;
|
||||
sessionName?: string;
|
||||
preferredDirectory?: string;
|
||||
/**
|
||||
* Opaque token from chooseManualSessionLogPath. Required for the post-dialog
|
||||
* second phase; raw file paths from the renderer are rejected.
|
||||
*/
|
||||
selectionToken?: string;
|
||||
format?: "txt" | "raw" | "html";
|
||||
timestampsEnabled?: boolean;
|
||||
initialLine?: string;
|
||||
alternateScreenActive?: boolean;
|
||||
};
|
||||
|
||||
type ManualStopPayload = {
|
||||
@@ -19,6 +32,12 @@ type ManualStatusPayload = {
|
||||
};
|
||||
|
||||
export const useSessionLogBackend = () => {
|
||||
const chooseManualSessionLogPath = useCallback(async (payload: ManualChoosePathPayload) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.chooseManualSessionLogPath?.(payload)
|
||||
?? { success: false, canceled: false, error: "Session log bridge unavailable" };
|
||||
}, []);
|
||||
|
||||
const startManualSessionLog = useCallback(async (payload: ManualStartPayload) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.startManualSessionLog?.(payload) ?? { success: false, started: false, error: "Session log bridge unavailable" };
|
||||
@@ -34,5 +53,10 @@ export const useSessionLogBackend = () => {
|
||||
return bridge?.getManualSessionLogStatus?.(payload) ?? { success: false, isLogging: false, error: "Session log bridge unavailable" };
|
||||
}, []);
|
||||
|
||||
return { startManualSessionLog, stopManualSessionLog, getManualSessionLogStatus };
|
||||
return {
|
||||
chooseManualSessionLogPath,
|
||||
startManualSessionLog,
|
||||
stopManualSessionLog,
|
||||
getManualSessionLogStatus,
|
||||
};
|
||||
};
|
||||
@@ -797,11 +797,6 @@ export const useSessionState = ({
|
||||
host: Host,
|
||||
direction: SplitDirection = 'vertical',
|
||||
): string | null => {
|
||||
// Serial hosts use a different session constructor; they currently
|
||||
// only enter workspaces via createSerialSession + drag, so reject
|
||||
// them here to avoid a partially-constructed session.
|
||||
if (host.protocol === 'serial') return null;
|
||||
|
||||
// Cheap early-exit using the ref when the workspace is clearly
|
||||
// absent. The authoritative check lives inside the setWorkspaces
|
||||
// updater below so we also cover the concurrent-close race.
|
||||
|
||||
@@ -105,4 +105,8 @@ test('workspace host creation paths use the host session snapshot factory', () =
|
||||
source,
|
||||
/createWorkspaceHostTerminalSession\(newSessionId,\s*host,\s*workspaceId\)/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/if \(host\.protocol === 'serial'\) return null/,
|
||||
);
|
||||
});
|
||||
@@ -75,6 +75,7 @@ import {
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
|
||||
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||
@@ -121,6 +122,7 @@ import {
|
||||
DEFAULT_SHOW_SFTP_TAB,
|
||||
DEFAULT_SHOW_HOST_TREE_SIDEBAR,
|
||||
DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
DEFAULT_SHOW_TAB_NUMBER_BADGES,
|
||||
DEFAULT_DISABLE_TERMINAL_FONT_ZOOM,
|
||||
DEFAULT_SSH_DEBUG_LOGS_ENABLED,
|
||||
DEFAULT_SSH_DEEP_LINK_ENABLED,
|
||||
@@ -413,6 +415,10 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
|
||||
return stored ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS;
|
||||
});
|
||||
const [showTabNumberBadges, setShowTabNumberBadgesState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_TAB_NUMBER_BADGES);
|
||||
return stored ?? DEFAULT_SHOW_TAB_NUMBER_BADGES;
|
||||
});
|
||||
const [disableTerminalFontZoom, setDisableTerminalFontZoomState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM);
|
||||
return stored ?? DEFAULT_DISABLE_TERMINAL_FONT_ZOOM;
|
||||
@@ -941,6 +947,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
);
|
||||
const storedShellOnlyTabNumberShortcuts = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
|
||||
setShellOnlyTabNumberShortcutsState(storedShellOnlyTabNumberShortcuts ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
|
||||
const storedShowTabNumberBadges = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_TAB_NUMBER_BADGES);
|
||||
setShowTabNumberBadgesState(storedShowTabNumberBadges ?? DEFAULT_SHOW_TAB_NUMBER_BADGES);
|
||||
const storedDisableTerminalFontZoom = localStorageAdapter.readBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM);
|
||||
setDisableTerminalFontZoomState(storedDisableTerminalFontZoom ?? DEFAULT_DISABLE_TERMINAL_FONT_ZOOM);
|
||||
const storedRestorePreviousSession = localStorageAdapter.readBoolean(STORAGE_KEY_RESTORE_PREVIOUS_SESSION);
|
||||
@@ -1145,7 +1153,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode,
|
||||
@@ -1155,7 +1163,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize: applyIncomingTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled, setJmsDeepLinkEnabledState: applyIncomingJmsDeepLinkEnabled, setExplorerContextMenuEnabledState: applyIncomingExplorerContextMenuEnabled,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity: applyIncomingWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
|
||||
@@ -1321,6 +1329,13 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
notifySettingsChanged(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setShowTabNumberBadges = useCallback((enabled: boolean) => {
|
||||
setShowTabNumberBadgesState(enabled);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_TAB_NUMBER_BADGES, enabled);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SHOW_TAB_NUMBER_BADGES, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setDisableTerminalFontZoom = useCallback((enabled: boolean) => {
|
||||
setDisableTerminalFontZoomState(enabled);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM, enabled);
|
||||
@@ -1746,6 +1761,9 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
showOnlyUngroupedHostsInRoot,
|
||||
dynamicTabTitleMode: terminalSettings.dynamicTabTitleMode,
|
||||
disableTerminalFontZoom,
|
||||
hotkeyScheme,
|
||||
shellOnlyTabNumberShortcuts,
|
||||
showTabNumberBadges,
|
||||
restoreTerminalCwd,
|
||||
terminalSidePanelAutoOpen,
|
||||
terminalSidePanelAutoOpenTab,
|
||||
@@ -1754,13 +1772,16 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
darkUiThemeId,
|
||||
disableTerminalFontZoom,
|
||||
hostClickBehavior,
|
||||
hotkeyScheme,
|
||||
lightUiThemeId,
|
||||
resolvedTheme,
|
||||
restoreTerminalCwd,
|
||||
shellOnlyTabNumberShortcuts,
|
||||
showHostTreeSidebar,
|
||||
showOnlyUngroupedHostsInRoot,
|
||||
showRecentHosts,
|
||||
showSftpTab,
|
||||
showTabNumberBadges,
|
||||
terminalSettings.dynamicTabTitleMode,
|
||||
terminalSidePanelAutoOpen,
|
||||
terminalSidePanelAutoOpenTab,
|
||||
@@ -1935,6 +1956,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setTerminalSidePanelAutoOpenTab,
|
||||
shellOnlyTabNumberShortcuts,
|
||||
setShellOnlyTabNumberShortcuts,
|
||||
showTabNumberBadges,
|
||||
setShowTabNumberBadges,
|
||||
disableTerminalFontZoom,
|
||||
setDisableTerminalFontZoom,
|
||||
restorePreviousSession,
|
||||
@@ -1994,7 +2017,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
||||
customKeyBindings, editorWordWrap,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom,
|
||||
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
]),
|
||||
};
|
||||
|
||||
@@ -30,7 +30,7 @@ import { buildSftpHostCredentials } from "./sftp/useSftpHostCredentials";
|
||||
import { useSftpFileWatch } from "./sftp/useSftpFileWatch";
|
||||
import { useSftpSessionCleanup } from "./sftp/useSftpSessionCleanup";
|
||||
import { useSftpSessionErrors } from "./sftp/useSftpSessionErrors";
|
||||
import { ensureRemoteSftpSession } from "./sftp/ensureRemoteSftpSession";
|
||||
import { ensureRemoteSftpSession, probeSftpSession } from "./sftp/ensureRemoteSftpSession";
|
||||
import { openTransferSftpSession } from "./sftp/dedicatedTransferResume";
|
||||
import {
|
||||
createTransferPoolKeyCache,
|
||||
@@ -316,9 +316,7 @@ export const useSftpState = (
|
||||
// Prefer borrowing the live (or parked) terminal SSH transport so MFA is
|
||||
// not repeated. Transport leases keep the shared conn alive after the
|
||||
// terminal tab closes until the transfer SFTP lease is returned.
|
||||
const sourceSessionId = !host.sftpSudo
|
||||
? resolveTransferSourceSessionId?.(host.id, host)
|
||||
: undefined;
|
||||
const sourceSessionId = resolveTransferSourceSessionId?.(host.id, host);
|
||||
if (sourceSessionId) {
|
||||
try {
|
||||
logger.info(
|
||||
@@ -608,16 +606,7 @@ export const useSftpState = (
|
||||
forceReconnect: ensureOptions?.forceReconnect,
|
||||
releaseConnection,
|
||||
tabId,
|
||||
probeSession: async (sftpId) => {
|
||||
// Lightweight liveness check; any session-error from the bridge
|
||||
// triggers a reconnect in ensureRemoteSftpSession.
|
||||
if (!bridge?.getSftpHomeDir) return true;
|
||||
const result = await bridge.getSftpHomeDir(sftpId);
|
||||
if (result && result.success === false) {
|
||||
throw new Error(result.error || "SFTP session not found");
|
||||
}
|
||||
return true;
|
||||
},
|
||||
probeSession: (sftpId) => probeSftpSession(bridge, sftpId),
|
||||
});
|
||||
},
|
||||
resolveConnectedHost: (tabId) => connectedHostByTabIdRef.current.get(tabId) ?? null,
|
||||
@@ -643,10 +632,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;
|
||||
@@ -703,6 +693,12 @@ export const useSftpState = (
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection || pane.connection.isLocal) continue;
|
||||
if (sftpSessionsRef.current.has(pane.connection.id)) continue;
|
||||
const connectedHost = connectedHostByTabIdRef.current.get(pane.id) ?? null;
|
||||
const vaultHost = hosts.find((host) => host.id === pane.connection?.hostId) ?? null;
|
||||
const targetHost = connectedHost && connectedHost !== "local" ? connectedHost : vaultHost;
|
||||
const resolvedSourceSessionId = targetHost
|
||||
? resolveBrowseSourceSessionId?.(targetHost.id, targetHost)
|
||||
: undefined;
|
||||
try {
|
||||
await ensureRemoteSftpSession({
|
||||
side,
|
||||
@@ -712,16 +708,10 @@ export const useSftpState = (
|
||||
connect,
|
||||
resolveConnectedHost: (id) => connectedHostByTabIdRef.current.get(id) ?? null,
|
||||
resolveHostById: (hostId) => hosts.find((host) => host.id === hostId) ?? null,
|
||||
resolveSourceSessionId: resolveBrowseSourceSessionId,
|
||||
probeSession: async (sftpId) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getSftpHomeDir) return true;
|
||||
const result = await bridge.getSftpHomeDir(sftpId);
|
||||
if (result && result.success === false) {
|
||||
throw new Error(result.error || "SFTP session not found");
|
||||
}
|
||||
return true;
|
||||
},
|
||||
resolveSourceSessionId: resolvedSourceSessionId
|
||||
? () => resolvedSourceSessionId
|
||||
: resolveBrowseSourceSessionId,
|
||||
probeSession: (sftpId) => probeSftpSession(netcattyBridge.get(), sftpId),
|
||||
releaseConnection,
|
||||
});
|
||||
logger.info(`[SFTP] Restored browse session on ${side}`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -59,6 +59,7 @@ export function useTerminalAiContexts({
|
||||
.map((sessionId) => sessionById.get(sessionId)?.hostId)
|
||||
.filter((hostId): hostId is string => !!hostId),
|
||||
scopeLabel: workspace.title,
|
||||
focusedSessionId: workspace.focusedSessionId,
|
||||
terminalSessions: sessionIds.map((sessionId) =>
|
||||
buildAITerminalSessionInfo(
|
||||
sessionById.get(sessionId),
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -123,6 +123,7 @@ export function useTerminalSidePanelLayoutState() {
|
||||
setSidePanelOpenTabs,
|
||||
sidePanelOpenTabsRef,
|
||||
sidePanelLayouts,
|
||||
setSidePanelLayouts,
|
||||
sidePanelLayoutsRef,
|
||||
focusPane,
|
||||
splitPane,
|
||||
|
||||
@@ -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.
@@ -252,3 +252,51 @@ test('AI side panel skips re-render when only a sibling scope session object cha
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('workspace AI panel memo follows visible inherited session, not hidden terminal maps', () => {
|
||||
const visibleMemberChat = session({
|
||||
id: 'chat-visible',
|
||||
scope: { type: 'terminal', targetId: 'terminal-b', hostIds: ['host-b'] },
|
||||
});
|
||||
const hiddenFocusedChat = session({
|
||||
id: 'chat-hidden',
|
||||
scope: { type: 'terminal', targetId: 'terminal-other', hostIds: ['host-other'] },
|
||||
});
|
||||
const prev = baseProps({
|
||||
isVisible: true,
|
||||
scopeType: 'workspace',
|
||||
scopeTargetId: 'ws-1',
|
||||
focusedSessionId: 'terminal-a',
|
||||
terminalSessions: [
|
||||
{
|
||||
sessionId: 'terminal-a',
|
||||
hostId: 'host-a',
|
||||
hostname: 'a.example',
|
||||
label: 'A',
|
||||
connected: true,
|
||||
},
|
||||
{
|
||||
sessionId: 'terminal-b',
|
||||
hostId: 'host-b',
|
||||
hostname: 'b.example',
|
||||
label: 'B',
|
||||
connected: true,
|
||||
},
|
||||
],
|
||||
activeSessionIdMap: {
|
||||
'terminal:terminal-a': 'chat-hidden',
|
||||
'terminal:terminal-b': 'chat-visible',
|
||||
},
|
||||
sessions: [visibleMemberChat, hiddenFocusedChat],
|
||||
});
|
||||
const streamedVisible = session({
|
||||
id: 'chat-visible',
|
||||
scope: { type: 'terminal', targetId: 'terminal-b', hostIds: ['host-b'] },
|
||||
messages: [{ id: 'm1', role: 'assistant', content: 'stream', timestamp: 2 }],
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
aiChatSidePanelPropsAreEqual(prev, { ...prev, sessions: [streamedVisible, hiddenFocusedChat] }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
|
||||
|
||||
import React, { useCallback, useEffect, useDeferredValue, useMemo, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useWindowControls } from '../application/state/useWindowControls';
|
||||
@@ -30,9 +30,9 @@ import { subscribeUserSkillsStatusChanged } from './ai/userSkillsStatusEvents';
|
||||
import {
|
||||
applyDraftEntrySelection,
|
||||
applyHistorySessionSelection,
|
||||
panelViewsEqual,
|
||||
resolveDisplayedPanelView,
|
||||
resolveDisplayedSession,
|
||||
shouldForceDraftViewSync,
|
||||
} from './ai/aiPanelViewState';
|
||||
import {
|
||||
endSendForKey,
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
type DefaultTargetSessionHint,
|
||||
} from '../application/state/useAIChatStreaming';
|
||||
import { getScopedHistorySessions } from './ai/scopedHistorySessions';
|
||||
import { resolveInheritedAIActiveSessionId } from '../domain/aiWorkspaceScopeInherit';
|
||||
import { aiSessionIdSetEqual, exactScopeAISessionsEqual } from '../domain/aiSessionsForScope';
|
||||
import { buildExternalAgentHistoryMessagesForBridge } from './ai/externalAgentHistory';
|
||||
import { canSendWithAgent, findEnabledExternalAgent } from './ai/agentSendEligibility';
|
||||
@@ -286,6 +287,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
scopeTargetId,
|
||||
scopeHostIds,
|
||||
scopeLabel,
|
||||
focusedSessionId,
|
||||
terminalSessions = [],
|
||||
resolveExecutorContext,
|
||||
isVisible = true,
|
||||
@@ -345,24 +347,48 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
return sessionIds;
|
||||
}, [activeSessionIdMap, scopeKey]);
|
||||
|
||||
const deferredSessions = useDeferredValue(sessions);
|
||||
const workspaceMemberTerminalIds = useMemo(() => {
|
||||
if (scopeType !== 'workspace') return undefined;
|
||||
return new Set(
|
||||
terminalSessions
|
||||
.map((session) => session.sessionId)
|
||||
.filter((sessionId): sessionId is string => Boolean(sessionId)),
|
||||
);
|
||||
}, [scopeType, terminalSessions]);
|
||||
|
||||
// Use live sessions for history + view resolution. Deferring the list used to
|
||||
// lag one paint behind createSession, so normalizePanelView treated the new
|
||||
// chat as missing and the draft-sync effect forced showDraftView — which
|
||||
// parked the just-sent session into history under StrictMode.
|
||||
const historySessions = useMemo(
|
||||
() => profileAIPanelCalculation(
|
||||
'AIChatSidePanel.historySessions',
|
||||
() => getScopedHistorySessions(
|
||||
deferredSessions,
|
||||
sessions,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeHostIds,
|
||||
activeTerminalSessionIds,
|
||||
workspaceMemberTerminalIds,
|
||||
),
|
||||
),
|
||||
[deferredSessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalSessionIds],
|
||||
[sessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalSessionIds, workspaceMemberTerminalIds],
|
||||
);
|
||||
|
||||
const explicitPanelView = panelViewByScope[scopeKey];
|
||||
const currentDraft = draftsByScope[scopeKey] ?? null;
|
||||
const persistedSessionId = activeSessionIdMap[scopeKey] ?? null;
|
||||
const visibleHistorySessionIds = useMemo(
|
||||
() => new Set(historySessions.map((session) => session.id)),
|
||||
[historySessions],
|
||||
);
|
||||
const persistedSessionId = resolveInheritedAIActiveSessionId({
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
activeSessionIdMap,
|
||||
memberTerminalIds: terminalSessions.map((session) => session.sessionId).filter(Boolean),
|
||||
preferredTerminalId: focusedSessionId,
|
||||
visibleSessionIds: visibleHistorySessionIds,
|
||||
});
|
||||
const normalizedPanelView = useMemo<AIPanelView>(
|
||||
() => resolveDisplayedPanelView(explicitPanelView, currentDraft != null, historySessions, persistedSessionId, scopeType),
|
||||
[explicitPanelView, currentDraft, historySessions, persistedSessionId, scopeType],
|
||||
@@ -424,9 +450,17 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
if (!explicitPanelView || panelViewsEqual(normalizedPanelView, explicitPanelView)) return;
|
||||
// Predicate must match normalizePanelView's list (scoped history), not the
|
||||
// global store — out-of-scope sessions must still demote to draft.
|
||||
if (!shouldForceDraftViewSync(
|
||||
explicitPanelView,
|
||||
normalizedPanelView,
|
||||
(sessionId) => historySessions.some((session) => session.id === sessionId),
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
showDraftView(scopeKey);
|
||||
}, [isVisible, normalizedPanelView, explicitPanelView, scopeKey, showDraftView]);
|
||||
}, [isVisible, normalizedPanelView, explicitPanelView, scopeKey, historySessions, showDraftView]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSession) return;
|
||||
@@ -445,9 +479,24 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
if (normalizedPanelView.mode !== 'draft') return;
|
||||
// Keep ownership while an explicit session is still displayable in this
|
||||
// scope (normalize has not demoted it). Out-of-scope / missing ids clear.
|
||||
if (
|
||||
explicitPanelView?.mode === 'session'
|
||||
&& historySessions.some((session) => session.id === explicitPanelView.sessionId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (persistedSessionId == null) return;
|
||||
setActiveSessionId(null);
|
||||
}, [isVisible, normalizedPanelView.mode, persistedSessionId, setActiveSessionId]);
|
||||
}, [
|
||||
isVisible,
|
||||
normalizedPanelView.mode,
|
||||
explicitPanelView,
|
||||
historySessions,
|
||||
persistedSessionId,
|
||||
setActiveSessionId,
|
||||
]);
|
||||
|
||||
const ensureScopeDraft = useCallback((agentId: string) => {
|
||||
ensureDraftForScope(scopeKey, agentId);
|
||||
@@ -1579,6 +1628,7 @@ export function aiChatSidePanelPropsAreEqual(
|
||||
if (prev.scopeType !== next.scopeType) return false;
|
||||
if (prev.scopeTargetId !== next.scopeTargetId) return false;
|
||||
if (prev.scopeLabel !== next.scopeLabel) return false;
|
||||
if ((prev.focusedSessionId ?? '') !== (next.focusedSessionId ?? '')) return false;
|
||||
if ((prev.isVisible ?? true) !== (next.isVisible ?? true)) return false;
|
||||
if (prev.scopeHostIds !== next.scopeHostIds) return false;
|
||||
if (prev.terminalSessions !== next.terminalSessions) return false;
|
||||
@@ -1597,10 +1647,45 @@ export function aiChatSidePanelPropsAreEqual(
|
||||
// resume whose stored scope.targetId is an older terminal.
|
||||
// Fuzzy history still receives the full list; drawer open forces re-render
|
||||
// via isVisible / other prop paths when the user actually needs it.
|
||||
const scopeKey = `${prev.scopeType}:${prev.scopeTargetId ?? ''}`;
|
||||
const selectedSessionId = prev.activeSessionIdMap[scopeKey]
|
||||
?? next.activeSessionIdMap[scopeKey]
|
||||
?? null;
|
||||
// Keep visibleSessionIds in sync with the live panel so inheritance that
|
||||
// skips non-history chats does not leave memo pinned to a different id.
|
||||
const resolveSelectedSessionId = (
|
||||
props: AIChatSidePanelProps,
|
||||
): string | null => {
|
||||
const scopeKey = `${props.scopeType}:${props.scopeTargetId ?? ''}`;
|
||||
const memberTerminalIds = (props.terminalSessions ?? [])
|
||||
.map((session) => session.sessionId)
|
||||
.filter((sessionId): sessionId is string => Boolean(sessionId));
|
||||
const workspaceMemberTerminalIds = props.scopeType === 'workspace'
|
||||
? new Set(memberTerminalIds)
|
||||
: undefined;
|
||||
const activeTerminalSessionIds = new Set<string>();
|
||||
for (const [sessionScopeKey, sessionId] of Object.entries(props.activeSessionIdMap) as Array<[string, string | null]>) {
|
||||
if (!sessionScopeKey.startsWith('terminal:') || !sessionId) continue;
|
||||
if (sessionScopeKey === scopeKey) continue;
|
||||
activeTerminalSessionIds.add(sessionId);
|
||||
}
|
||||
const visibleSessionIds = new Set(
|
||||
getScopedHistorySessions(
|
||||
props.sessions,
|
||||
props.scopeType,
|
||||
props.scopeTargetId,
|
||||
props.scopeHostIds,
|
||||
activeTerminalSessionIds,
|
||||
workspaceMemberTerminalIds,
|
||||
).map((session) => session.id),
|
||||
);
|
||||
return resolveInheritedAIActiveSessionId({
|
||||
scopeType: props.scopeType,
|
||||
scopeTargetId: props.scopeTargetId,
|
||||
activeSessionIdMap: props.activeSessionIdMap,
|
||||
memberTerminalIds,
|
||||
preferredTerminalId: props.focusedSessionId,
|
||||
visibleSessionIds,
|
||||
});
|
||||
};
|
||||
const selectedSessionId = resolveSelectedSessionId(prev)
|
||||
?? resolveSelectedSessionId(next);
|
||||
if (!exactScopeAISessionsEqual(
|
||||
prev.sessions,
|
||||
next.sessions,
|
||||
|
||||
@@ -87,6 +87,8 @@ export interface AIChatSidePanelProps {
|
||||
scopeTargetId?: string;
|
||||
scopeHostIds?: string[];
|
||||
scopeLabel?: string;
|
||||
/** Workspace focused pane; preferred when inheriting AI chat after merge. */
|
||||
focusedSessionId?: string;
|
||||
|
||||
// Terminal session context (from parent)
|
||||
terminalSessions?: Array<{
|
||||
|
||||
@@ -759,7 +759,11 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
: -1}
|
||||
onFocus={() => onActiveTreeItemChange(`host:${host.id}`)}
|
||||
draggable={!isMultiSelectMode}
|
||||
onDragStart={(e) => e.dataTransfer.setData("host-id", host.id)}
|
||||
onDragStart={(e) => {
|
||||
// copyMove: vault reorder uses move; focus-sidebar append uses copy.
|
||||
e.dataTransfer.effectAllowed = "copyMove";
|
||||
e.dataTransfer.setData("host-id", host.id);
|
||||
}}
|
||||
onClick={() => {
|
||||
const action = resolveHostActivateAction({
|
||||
behavior: normalizedHostClickBehavior,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { usePortForwardingState } from "../application/state/usePortForwardingState";
|
||||
import { STORAGE_KEY_PORT_FORWARDING_PANEL_WIDTH } from "../infrastructure/config/storageKeys";
|
||||
import {
|
||||
GroupConfig,
|
||||
Host,
|
||||
@@ -94,6 +95,11 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
terminalSettings,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const portForwardingPanelResizeProps = {
|
||||
resizable: true as const,
|
||||
persistWidthStorageKey: STORAGE_KEY_PORT_FORWARDING_PANEL_WIDTH,
|
||||
resizeAriaLabel: t("vault.panel.resizeWidth"),
|
||||
};
|
||||
const {
|
||||
rules: _rules,
|
||||
selectedRuleId,
|
||||
@@ -312,6 +318,12 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
// Start new rule - wizard or form based on user preference
|
||||
const startNewRule = (type: PortForwardingType) => {
|
||||
setShowNewMenu(false);
|
||||
// The new-rule flow replaces an open editor; never leave both side panels mounted.
|
||||
setShowEditPanel(false);
|
||||
setEditingRule(null);
|
||||
setEditDraft({});
|
||||
setSelectedRuleId(null);
|
||||
setShowHostSelector(false);
|
||||
|
||||
if (preferFormMode) {
|
||||
// Form mode: show all-in-one form
|
||||
@@ -604,8 +616,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
{/* Main Content */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 flex flex-col min-h-0",
|
||||
showWizard || showEditPanel || showNewForm ? "mr-[360px]" : "",
|
||||
"flex-1 min-w-0 flex flex-col min-h-0",
|
||||
)}
|
||||
>
|
||||
<VaultPageHeader className="z-20">
|
||||
@@ -773,7 +784,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Edit Panel - shown when a rule is selected */}
|
||||
{showEditPanel && editingRule && (
|
||||
{showEditPanel && editingRule && !showHostSelector && (
|
||||
<EditPanel
|
||||
rule={editingRule}
|
||||
draft={editDraft}
|
||||
@@ -789,11 +800,12 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
}}
|
||||
onDelete={() => handleDeleteRule(editingRule)}
|
||||
onOpenHostSelector={() => setShowHostSelector(true)}
|
||||
{...portForwardingPanelResizeProps}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Wizard Panel */}
|
||||
{showWizard && (
|
||||
{showWizard && !showHostSelector && (
|
||||
<AsidePanel
|
||||
open={true}
|
||||
onClose={() => {
|
||||
@@ -802,6 +814,8 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
}}
|
||||
title={isEditing ? t("pf.wizard.editTitle") : t("pf.wizard.newTitle")}
|
||||
width="w-[360px]"
|
||||
layout="inline"
|
||||
{...portForwardingPanelResizeProps}
|
||||
showBackButton={!!getPrevStep()}
|
||||
onBack={
|
||||
getPrevStep()
|
||||
@@ -902,11 +916,14 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
managedSources={managedSources}
|
||||
onSaveHost={onSaveHost}
|
||||
onCreateGroup={_onCreateGroup}
|
||||
width="w-[360px]"
|
||||
layout="inline"
|
||||
{...portForwardingPanelResizeProps}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* New Form Panel (skip wizard mode) */}
|
||||
{showNewForm && (
|
||||
{showNewForm && !showHostSelector && (
|
||||
<NewFormPanel
|
||||
draft={newFormDraft}
|
||||
hosts={hosts}
|
||||
@@ -918,6 +935,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
onOpenHostSelector={() => setShowHostSelector(true)}
|
||||
onOpenWizard={openWizardFromForm}
|
||||
isValid={isNewFormValid()}
|
||||
{...portForwardingPanelResizeProps}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ interface SelectHostPanelProps {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
className?: string;
|
||||
width?: string;
|
||||
layout?: AsidePanelLayout;
|
||||
resizable?: boolean;
|
||||
persistWidthStorageKey?: string;
|
||||
@@ -50,6 +51,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
title,
|
||||
subtitle,
|
||||
className,
|
||||
width,
|
||||
layout = "overlay",
|
||||
resizable = false,
|
||||
persistWidthStorageKey,
|
||||
@@ -79,6 +81,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
showBackButton={true}
|
||||
onBack={onBack}
|
||||
className={cn(layout === "overlay" && "z-40", newHostPanelOpen && "overflow-visible", className)}
|
||||
width={width}
|
||||
layout={layout}
|
||||
resizable={resizable}
|
||||
persistWidthStorageKey={persistWidthStorageKey}
|
||||
|
||||
@@ -417,7 +417,7 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
|
||||
aria-selected={multiSelect ? groupState === 'all' : false}
|
||||
data-active={isActive ? 'true' : undefined}
|
||||
className={cn(
|
||||
'flex h-full min-h-0 items-center gap-2.5 overflow-hidden px-2.5 rounded-lg transition-colors',
|
||||
'flex h-full min-h-0 items-center gap-2.5 overflow-hidden rounded-lg px-2.5 transition-colors',
|
||||
isActive ? 'bg-primary/10 ring-1 ring-primary/40' : 'hover:bg-muted/70',
|
||||
)}
|
||||
onClick={() => {
|
||||
|
||||
@@ -51,7 +51,7 @@ export const buildIssueUrl = (appInfo: AppInfo) => {
|
||||
const installSource =
|
||||
appInfo.version === "0.0.0"
|
||||
? "Built from source (npm run dev / pack)"
|
||||
: "GitHub Release (.dmg / .exe / .AppImage / .deb)";
|
||||
: "GitHub Release (.dmg / .exe / .AppImage / .deb / .rpm / .pacman)";
|
||||
params.set("install_source", installSource);
|
||||
|
||||
const ua = typeof navigator !== "undefined" ? navigator.userAgent : "unknown";
|
||||
|
||||
@@ -572,6 +572,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
setHotkeyScheme={settings.setHotkeyScheme}
|
||||
shellOnlyTabNumberShortcuts={settings.shellOnlyTabNumberShortcuts}
|
||||
setShellOnlyTabNumberShortcuts={settings.setShellOnlyTabNumberShortcuts}
|
||||
showTabNumberBadges={settings.showTabNumberBadges}
|
||||
setShowTabNumberBadges={settings.setShowTabNumberBadges}
|
||||
disableTerminalFontZoom={settings.disableTerminalFontZoom}
|
||||
setDisableTerminalFontZoom={settings.setDisableTerminalFontZoom}
|
||||
keyBindings={settings.keyBindings}
|
||||
|
||||
@@ -86,6 +86,7 @@ import { scheduleDeferredTerminalFocus } from "./systemManager/tmuxActionFocus";
|
||||
import {
|
||||
connectionKeyMatchesHost,
|
||||
findReusableSftpSidePanelTab,
|
||||
isPendingSameEndpointSshSession,
|
||||
shouldAcceptPendingSftpUpload,
|
||||
shouldResetSftpSidePanelSourceSession,
|
||||
shouldSkipSftpSidePanelAutoConnect,
|
||||
@@ -128,6 +129,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 +183,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
onActiveExternalEditsChange,
|
||||
showWorkspaceHostHeader = false,
|
||||
isVisible = true,
|
||||
ownerPanelOpen = false,
|
||||
renderOverlays = true,
|
||||
pendingUpload = null,
|
||||
onPendingUploadHandled,
|
||||
@@ -258,8 +265,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 +285,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
fileWatchHandlers,
|
||||
hasOwnedEditorTab,
|
||||
isVisible,
|
||||
ownerPanelOpen,
|
||||
transferOwnerId,
|
||||
sftpUseCompressedUpload,
|
||||
sftpShowHiddenFiles,
|
||||
@@ -550,6 +561,12 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
activeHost.username,
|
||||
activeHost.sftpFileProtocol,
|
||||
);
|
||||
const pendingSameEndpointSession = sessions.find((session) => (
|
||||
isPendingSameEndpointSshSession(session, activeHost)
|
||||
));
|
||||
if (!activeSessionId && pendingSameEndpointSession) {
|
||||
return;
|
||||
}
|
||||
const sessionChanged = shouldResetSftpSidePanelSourceSession(
|
||||
lastSourceSessionIdRef.current,
|
||||
activeSessionId,
|
||||
@@ -727,7 +744,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
tabConnectionKeyMapRef.current.set(tabId, connectionKey);
|
||||
},
|
||||
});
|
||||
}, [activeHost, activeSessionId, initialLocation, interactiveWorkActive]);
|
||||
}, [activeHost, activeSessionId, initialLocation, interactiveWorkActive, sessions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeHost || !isVisible) return;
|
||||
@@ -1861,6 +1878,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,
|
||||
|
||||
+151
-26
@@ -225,8 +225,16 @@ import {
|
||||
} from "./terminal/terminalRuntimeMount";
|
||||
import type { CreateXTermRuntimeContext } from "./terminal/runtime/createXTermRuntime";
|
||||
import { TerminalView } from "./terminal/TerminalView";
|
||||
import {
|
||||
cancelConnectAutomationBatch,
|
||||
createConnectAutomationBatch,
|
||||
trackConnectAutomationStop,
|
||||
type ConnectAutomationBatch,
|
||||
} from "./terminal/connectAutomationBatch";
|
||||
import {
|
||||
getInitialTerminalStatus,
|
||||
resolveTerminalVaultInitialized,
|
||||
shouldResetConnectAutomationOnReconnect,
|
||||
shouldSuppressHostStartupCommandOnReconnect,
|
||||
shouldStartTerminalBackend,
|
||||
} from "./terminal/restoredSessionGate";
|
||||
@@ -270,6 +278,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
sessionId,
|
||||
workspaceId,
|
||||
restoreState,
|
||||
vaultInitializedOverride,
|
||||
pendingInitialCwd,
|
||||
shellType,
|
||||
lastCwd,
|
||||
@@ -359,10 +368,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}, []);
|
||||
// Reactive vault-ready flag so restored panes re-arm boot after hydration
|
||||
// (module isVaultInitialized() alone is not a React dependency).
|
||||
const vaultInitialized = useVaultSnapshotField("isVaultInitialized");
|
||||
const sharedVaultInitialized = useVaultSnapshotField("isVaultInitialized");
|
||||
const vaultInitialized = resolveTerminalVaultInitialized(
|
||||
sharedVaultInitialized,
|
||||
vaultInitializedOverride,
|
||||
);
|
||||
const connectScriptsConsumedRef = useRef(false);
|
||||
const connectScriptsCompletedIdsRef = useRef(new Set<string>());
|
||||
const connectScriptsInFlightRef = useRef(false);
|
||||
const connectScriptsBatchRef = useRef<ConnectAutomationBatch | null>(null);
|
||||
const pendingScriptRunIdRef = useRef<string | null>(null);
|
||||
const pendingScriptHandledRef = useRef<Snippet | null>(null);
|
||||
const pendingScriptRef = useRef(pendingScript);
|
||||
@@ -775,7 +789,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const sudoHintRef = useRef<((active: boolean) => boolean) | undefined>(undefined);
|
||||
|
||||
const terminalBackend = useTerminalBackend();
|
||||
const { startManualSessionLog, stopManualSessionLog, getManualSessionLogStatus } = useSessionLogBackend();
|
||||
const {
|
||||
chooseManualSessionLogPath,
|
||||
startManualSessionLog,
|
||||
stopManualSessionLog,
|
||||
getManualSessionLogStatus,
|
||||
} = useSessionLogBackend();
|
||||
const {
|
||||
resizeSession,
|
||||
receiveSerialYmodem,
|
||||
@@ -1598,7 +1617,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
terminalCwdTracker,
|
||||
]);
|
||||
|
||||
const cleanupSession = async () => {
|
||||
const cleanupSession = async (options?: { retainOwnership?: boolean }) => {
|
||||
const closingSessionId = sessionRef.current;
|
||||
xtermRuntimeRef.current?.flushKittyKeyboardReleases();
|
||||
sessionRef.current = null;
|
||||
@@ -1626,7 +1645,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// Still notify main so in-flight SSH passphrase prompts for this UI
|
||||
// sessionId are aborted even before a backend session was attached.
|
||||
try {
|
||||
await terminalBackend.closeSession(sessionId, { bootEpoch: resolveCloseBootEpoch() });
|
||||
await terminalBackend.closeSession(sessionId, {
|
||||
bootEpoch: resolveCloseBootEpoch(),
|
||||
...(options?.retainOwnership === true ? { retainOwnership: true } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn("Failed to cancel pending session boot on disconnect", err);
|
||||
}
|
||||
@@ -1735,7 +1757,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
clearTerminalSessionFlowAck(closingSessionId);
|
||||
}
|
||||
try {
|
||||
await terminalBackend.closeSession(closingSessionId, { bootEpoch: resolveCloseBootEpoch() });
|
||||
await terminalBackend.closeSession(closingSessionId, {
|
||||
bootEpoch: resolveCloseBootEpoch(),
|
||||
...(options?.retainOwnership === true ? { retainOwnership: true } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn("Failed to close SSH session", err);
|
||||
}
|
||||
@@ -2506,9 +2531,19 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
if (!shouldEvaluateConnect && !hasPendingWork) return;
|
||||
if (connectScriptsInFlightRef.current) return;
|
||||
|
||||
const batch = createConnectAutomationBatch();
|
||||
connectScriptsBatchRef.current = batch;
|
||||
connectScriptsInFlightRef.current = true;
|
||||
const batchStillActive = () => (
|
||||
!batch.controller.signal.aborted && connectScriptsBatchRef.current === batch
|
||||
);
|
||||
|
||||
// Defer until xterm has rendered login output and the main-process output tap
|
||||
// has populated SessionOutputBuffer (avoids waitForPrompt racing an empty buffer).
|
||||
let timerFired = false;
|
||||
const timer = window.setTimeout(() => {
|
||||
timerFired = true;
|
||||
if (!batchStillActive()) return;
|
||||
const runPending = Boolean(pendingOne);
|
||||
const connectQueueNow = connectScriptsConsumedRef.current
|
||||
? []
|
||||
@@ -2537,6 +2572,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
})) {
|
||||
connectScriptsConsumedRef.current = true;
|
||||
}
|
||||
if (connectScriptsBatchRef.current === batch) {
|
||||
connectScriptsBatchRef.current = null;
|
||||
connectScriptsInFlightRef.current = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2545,11 +2584,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
connectQueueNow.map((item) => item.id).filter((id): id is string => Boolean(id)),
|
||||
);
|
||||
|
||||
connectScriptsInFlightRef.current = true;
|
||||
|
||||
void runConnectScriptsSequential({
|
||||
scripts: scriptsToRun,
|
||||
sessionId,
|
||||
signal: batch.controller.signal,
|
||||
onCancelableRunChange: (stopCurrentRun) => (
|
||||
trackConnectAutomationStop(batch, stopCurrentRun)
|
||||
),
|
||||
sessionMeta: {
|
||||
connected: true,
|
||||
name: scriptSessionName,
|
||||
@@ -2557,6 +2598,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
username: host.username,
|
||||
},
|
||||
onScriptComplete: (snippet) => {
|
||||
if (!batchStillActive()) return;
|
||||
if (snippet.id && connectIdsInBatch.has(snippet.id)) {
|
||||
connectScriptsCompletedIdsRef.current.add(snippet.id);
|
||||
}
|
||||
@@ -2570,6 +2612,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
if (!batchStillActive()) return;
|
||||
const resolvedAfterRun = resolveConnectScriptsForHost(host, snippets);
|
||||
const doneAfterRun = resolvedAfterRun.length === 0
|
||||
|| resolvedAfterRun.every(
|
||||
@@ -2580,6 +2623,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
})
|
||||
.catch(async (err) => {
|
||||
if (!batchStillActive()) return;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
toast.error(message.includes('Observer mode') ? t('scripts.observer.blocked') : message);
|
||||
connectScriptsConsumedRef.current = true;
|
||||
@@ -2595,6 +2639,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
await runConnectScriptsSequential({
|
||||
scripts: [pendingScriptToMark],
|
||||
sessionId,
|
||||
signal: batch.controller.signal,
|
||||
onCancelableRunChange: (stopCurrentRun) => (
|
||||
trackConnectAutomationStop(batch, stopCurrentRun)
|
||||
),
|
||||
sessionMeta: {
|
||||
connected: true,
|
||||
name: scriptSessionName,
|
||||
@@ -2602,6 +2650,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
username: host.username,
|
||||
},
|
||||
onScriptComplete: (snippet) => {
|
||||
if (!batchStillActive()) return;
|
||||
if (snippet.id) {
|
||||
pendingScriptRunIdRef.current = snippet.id;
|
||||
} else {
|
||||
@@ -2610,16 +2659,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(() => {
|
||||
@@ -3244,7 +3309,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
setPendingHostKeyRequestId(null);
|
||||
setError(null);
|
||||
setProgressLogs((prev) => [...prev, "Disconnected by user."]);
|
||||
void cleanupSession();
|
||||
void cleanupSession({ retainOwnership: true });
|
||||
updateStatus("disconnected");
|
||||
setChainProgress(null);
|
||||
setIsDisconnectedDialogDismissed(false);
|
||||
@@ -3347,20 +3412,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return;
|
||||
}
|
||||
if (!termRef.current) return;
|
||||
if (mode === "manual") {
|
||||
clearAutoReconnect();
|
||||
prepareRestoredReconnect();
|
||||
// A clone's first connection can fail (auth/host-key/transport) before the
|
||||
// inherited `cd` is consumed. prepareRestoredReconnect() just cleared the
|
||||
// intent for non-restored sessions, so re-arm it here; the callback no-ops
|
||||
// once the cwd was consumed or when there is no pending inherited cwd.
|
||||
prepareInitialCwdIntent();
|
||||
} else {
|
||||
restoreCwdIntentRef.current = null;
|
||||
suppressHostStartupCommandRef.current = shouldSuppressHostStartupCommandOnReconnect("automatic");
|
||||
}
|
||||
// Claim the retry before awaiting close. A close/cancel/unmount during the
|
||||
// awaited backend cleanup invalidates this token and stops the continuation.
|
||||
// Claim the retry before awaiting either script cancellation or backend
|
||||
// cleanup. A second reconnect/cancel invalidates this continuation.
|
||||
const retryToken = Symbol("retry");
|
||||
retryTokenRef.current = retryToken;
|
||||
reconnectPreparationTokenRef.current = retryToken;
|
||||
@@ -3371,8 +3424,61 @@ 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();
|
||||
await cleanupSession({ retainOwnership: true });
|
||||
} catch (error) {
|
||||
finishReconnectPreparation();
|
||||
throw error;
|
||||
@@ -3560,13 +3666,31 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const startResult = await startManualSessionLog({
|
||||
// Choose the destination first, then re-sample terminal state. The save
|
||||
// dialog can stay open long enough for the user to enter/leave vim, so
|
||||
// alternate-screen / initial-line must be captured at stream activation.
|
||||
const chooseResult = await chooseManualSessionLogPath({
|
||||
sessionId: currentSessionId,
|
||||
sessionName: host.label || host.hostname || currentSessionId,
|
||||
preferredDirectory: sessionLog?.directory,
|
||||
format: sessionLog?.format,
|
||||
});
|
||||
if (!chooseResult?.success) {
|
||||
toast.error(chooseResult?.error || "Failed to start session log");
|
||||
return;
|
||||
}
|
||||
if (chooseResult.canceled || !chooseResult.selectionToken) return;
|
||||
|
||||
const startResult = await startManualSessionLog({
|
||||
sessionId: currentSessionId,
|
||||
sessionName: host.label || host.hostname || currentSessionId,
|
||||
selectionToken: chooseResult.selectionToken,
|
||||
format: sessionLog?.format,
|
||||
timestampsEnabled: sessionLog?.timestampsEnabled,
|
||||
initialLine: termRef.current ? getSessionLogInitialLine(termRef.current) : "",
|
||||
alternateScreenActive: termRef.current
|
||||
? isTerminalAlternateScreenActive(termRef.current)
|
||||
: false,
|
||||
});
|
||||
if (startResult?.success) {
|
||||
if (!startResult?.started && startResult?.canceled) return;
|
||||
@@ -3579,6 +3703,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
toast.error("Failed to toggle session log");
|
||||
}
|
||||
}, [
|
||||
chooseManualSessionLogPath,
|
||||
getManualSessionLogStatus,
|
||||
host.hostname,
|
||||
host.label,
|
||||
|
||||
@@ -93,6 +93,7 @@ import {
|
||||
shouldCloseSftpSidePanel,
|
||||
shouldClearSftpPanelAfterTransferChange,
|
||||
shouldKeepSftpMountedAfterClose,
|
||||
shouldMarkSftpPaneClosed,
|
||||
shouldScheduleSftpRetainedPanelCleanup,
|
||||
terminalSftpTransferOwnerId,
|
||||
} from './terminalLayer/sftpPanelLifecycle';
|
||||
@@ -219,6 +220,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onCreateWorkspaceFromSessions,
|
||||
onAddSessionToWorkspace,
|
||||
onRequestAddToWorkspace,
|
||||
onAppendHostToWorkspace,
|
||||
onUpdateSplitSizes,
|
||||
onSetDraggingSessionId,
|
||||
onToggleWorkspaceViewMode,
|
||||
@@ -268,6 +270,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
setSidePanelOpenTabs,
|
||||
sidePanelOpenTabsRef,
|
||||
sidePanelLayouts,
|
||||
setSidePanelLayouts,
|
||||
sidePanelLayoutsRef,
|
||||
focusPane: focusSidePanelPaneForTab,
|
||||
splitPane: splitSidePanelPaneForTab,
|
||||
@@ -410,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
|
||||
? {
|
||||
@@ -577,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());
|
||||
@@ -612,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;
|
||||
@@ -829,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);
|
||||
@@ -1328,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
|
||||
@@ -1409,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;
|
||||
@@ -2088,6 +2108,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onCopySession,
|
||||
onCopySessionToNewWindow,
|
||||
onRequestAddToWorkspace,
|
||||
onAppendHostToWorkspace,
|
||||
onSessionData,
|
||||
onSetDraggingSessionId,
|
||||
onSetWorkspaceFocusedSession,
|
||||
@@ -2136,6 +2157,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
setSystemMountedTabIds,
|
||||
setThemeMountedTabIds,
|
||||
setSidePanelOpenTabs,
|
||||
setSidePanelLayouts,
|
||||
setSidePanelWidth,
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpHostForTab,
|
||||
@@ -2153,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>
|
||||
)}
|
||||
|
||||
@@ -38,24 +38,16 @@ test("resolved approval state retains only tool calls still present in messages"
|
||||
assert.deepEqual([...next], [["live-call", false]]);
|
||||
});
|
||||
|
||||
test("assistant content stays plain when markdown is hidden or streaming", () => {
|
||||
test("assistant content stays plain only when markdown is hidden", () => {
|
||||
assert.equal(shouldRenderAssistantAsPlainText({
|
||||
hideMarkdown: false,
|
||||
}), false);
|
||||
assert.equal(shouldRenderAssistantAsPlainText({
|
||||
hideMarkdown: true,
|
||||
}), true);
|
||||
assert.equal(shouldRenderAssistantAsPlainText({
|
||||
hideMarkdown: false,
|
||||
isStreaming: true,
|
||||
}), true);
|
||||
assert.equal(shouldRenderAssistantAsPlainText({
|
||||
hideMarkdown: false,
|
||||
isStreaming: false,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("ChatMessageList renders plain text for the streaming assistant message", () => {
|
||||
test("ChatMessageList renders Streamdown for the streaming assistant message", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{
|
||||
id: "user-1",
|
||||
@@ -83,16 +75,16 @@ test("ChatMessageList renders plain text for the streaming assistant message", (
|
||||
),
|
||||
);
|
||||
|
||||
assert.match(markup, /data-ai-content="plain"/);
|
||||
assert.match(markup, /data-ai-content="markdown"/);
|
||||
assert.match(markup, /streaming-body/);
|
||||
assert.doesNotMatch(markup, /data-ai-content="markdown"/);
|
||||
assert.doesNotMatch(markup, /data-ai-content="plain"/);
|
||||
});
|
||||
|
||||
test("streaming assistant content skips Streamdown while the turn is live", () => {
|
||||
test("streaming assistant content keeps Streamdown live with isAnimating", () => {
|
||||
const source = readFileSync(new URL("./ChatMessageList.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /isStreaming: !!isThisStreaming/);
|
||||
assert.match(source, /shouldRenderAssistantAsPlainText\(\{[\s\S]*?isStreaming/);
|
||||
assert.match(source, /isAnimating=\{!!isThisStreaming\}/);
|
||||
assert.doesNotMatch(source, /isStreaming: !!isThisStreaming/);
|
||||
});
|
||||
|
||||
test("ChatMessageList hydrates markdown after streaming settles", () => {
|
||||
|
||||
@@ -113,9 +113,10 @@ export function shouldProvideVaultArtifactNavigation({
|
||||
|
||||
export function shouldRenderAssistantAsPlainText(options: {
|
||||
hideMarkdown: boolean;
|
||||
isStreaming?: boolean;
|
||||
}): boolean {
|
||||
return options.hideMarkdown || !!options.isStreaming;
|
||||
// Streaming stays on Streamdown with isAnimating so incomplete markdown
|
||||
// updates live. Only diagnostic hideMarkdown forces plain text.
|
||||
return options.hideMarkdown;
|
||||
}
|
||||
|
||||
const ASSISTANT_PLAIN_TEXT_CLASS = 'whitespace-pre-wrap break-words text-[13px] leading-[1.45]';
|
||||
@@ -701,10 +702,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
{message.content && (
|
||||
isUser
|
||||
? <div className={ASSISTANT_PLAIN_TEXT_CLASS}>{message.content}</div>
|
||||
: shouldRenderAssistantAsPlainText({
|
||||
hideMarkdown,
|
||||
isStreaming: !!isThisStreaming,
|
||||
})
|
||||
: shouldRenderAssistantAsPlainText({ hideMarkdown })
|
||||
? (
|
||||
<div
|
||||
className={ASSISTANT_PLAIN_TEXT_CLASS}
|
||||
@@ -716,7 +714,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
: (
|
||||
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Markdown')}>
|
||||
<div data-ai-content="markdown">
|
||||
<LazyMessageResponse isAnimating={false}>
|
||||
<LazyMessageResponse isAnimating={!!isThisStreaming}>
|
||||
{message.content}
|
||||
</LazyMessageResponse>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
panelViewsEqual,
|
||||
resolveDisplayedPanelView,
|
||||
resolveDisplayedSession,
|
||||
shouldForceDraftViewSync,
|
||||
} from "./aiPanelViewState.ts";
|
||||
|
||||
function createSession(id: string): AISession {
|
||||
@@ -75,6 +76,54 @@ test("missing session target normalizes back to draft view", () => {
|
||||
assert.deepEqual(normalizePanelView(panelView, sessions), { mode: "draft" });
|
||||
});
|
||||
|
||||
test("shouldForceDraftViewSync keeps explicit session while it is still in scoped history", () => {
|
||||
const explicit: AIPanelView = { mode: "session", sessionId: "just-created" };
|
||||
const normalized: AIPanelView = { mode: "draft" };
|
||||
// Predicate mirrors scoped historySessions (not the global store).
|
||||
const scopedHistory = new Set(["just-created"]);
|
||||
|
||||
assert.equal(
|
||||
shouldForceDraftViewSync(explicit, normalized, (id) => scopedHistory.has(id)),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldForceDraftViewSync forces draft when explicit session is gone", () => {
|
||||
const explicit: AIPanelView = { mode: "session", sessionId: "deleted" };
|
||||
const normalized: AIPanelView = { mode: "draft" };
|
||||
|
||||
assert.equal(
|
||||
shouldForceDraftViewSync(explicit, normalized, () => false),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldForceDraftViewSync demotes when session is only in global store, not scoped history", () => {
|
||||
const explicit: AIPanelView = { mode: "session", sessionId: "other-scope" };
|
||||
const normalized: AIPanelView = { mode: "draft" };
|
||||
// Global store still has it; scoped history (what normalize uses) does not.
|
||||
const globalStore = new Set(["other-scope"]);
|
||||
const scopedHistory = new Set<string>();
|
||||
|
||||
assert.equal(
|
||||
shouldForceDraftViewSync(explicit, normalized, (id) => scopedHistory.has(id)),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldForceDraftViewSync(explicit, normalized, (id) => globalStore.has(id)),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldForceDraftViewSync is a no-op when views already match", () => {
|
||||
const view: AIPanelView = { mode: "session", sessionId: "session-1" };
|
||||
|
||||
assert.equal(
|
||||
shouldForceDraftViewSync(view, view, () => true),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("missing explicit panel view resumes the most recent matching history when no draft exists", () => {
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
@@ -120,18 +169,44 @@ test("terminal scope without explicit view always starts from draft even when hi
|
||||
);
|
||||
});
|
||||
|
||||
test("missing explicit panel view prefers the draft when unsent input exists", () => {
|
||||
test("missing explicit panel view prefers the draft when unsent input exists without an active chat", () => {
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.deepEqual(
|
||||
resolveDisplayedPanelView(undefined, true, sessions),
|
||||
resolveDisplayedPanelView(undefined, true, sessions, null, "workspace"),
|
||||
{ mode: "draft" },
|
||||
);
|
||||
});
|
||||
|
||||
test("workspace unsent draft keeps the persisted active chat after merge seed", () => {
|
||||
// Merge often seeds activeSessionIdMap without writing panelView. Typing a
|
||||
// follow-up must not demote to draft or send will createSession().
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.deepEqual(
|
||||
resolveDisplayedPanelView(undefined, true, sessions, "session-1", "workspace"),
|
||||
{ mode: "session", sessionId: "session-1" },
|
||||
);
|
||||
});
|
||||
|
||||
test("explicit new-chat draft still wins over a stale persisted id", () => {
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.deepEqual(
|
||||
resolveDisplayedPanelView(
|
||||
{ mode: "draft" },
|
||||
true,
|
||||
sessions,
|
||||
"session-1",
|
||||
"workspace",
|
||||
),
|
||||
{ mode: "draft" },
|
||||
);
|
||||
});
|
||||
|
||||
test("draft state is used when there is no implicit history to resume", () => {
|
||||
assert.deepEqual(
|
||||
resolveDisplayedPanelView(undefined, true, []),
|
||||
resolveDisplayedPanelView(undefined, true, [], null, "workspace"),
|
||||
{ mode: "draft" },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -44,22 +44,28 @@ export function resolveDisplayedPanelView(
|
||||
return normalizePanelView(panelView, sessions);
|
||||
}
|
||||
|
||||
if (hasDraft) {
|
||||
return DEFAULT_PANEL_VIEW;
|
||||
}
|
||||
|
||||
// New terminal sessions should always start from a blank draft. History is
|
||||
// still available in the drawer, but never auto-resumed into a fresh SSH tab.
|
||||
// Explicit panelView above is the only way a terminal scope shows a session
|
||||
// (e.g. after dissolve handoff writes mode:session).
|
||||
if (scopeType === "terminal") {
|
||||
return DEFAULT_PANEL_VIEW;
|
||||
}
|
||||
|
||||
// Honour the persisted active-session selection (survives cold mount)
|
||||
// before falling back to the newest history entry.
|
||||
// Workspace: keep the inherited/persisted active chat when the user starts
|
||||
// typing a follow-up. Merge seed often writes activeSessionIdMap only, with
|
||||
// no explicit panelView — if unsent draft outranked that selection, send
|
||||
// would createSession() while the main area still looked like the old chat.
|
||||
// Explicit "New Chat" clears the active map and writes mode:draft, so it
|
||||
// still wins via the panelView branch above.
|
||||
if (persistedSessionId && sessions.some((s) => s.id === persistedSessionId)) {
|
||||
return { mode: "session", sessionId: persistedSessionId };
|
||||
}
|
||||
|
||||
if (hasDraft) {
|
||||
return DEFAULT_PANEL_VIEW;
|
||||
}
|
||||
|
||||
if (sessions[0]) {
|
||||
return { mode: "session", sessionId: sessions[0].id };
|
||||
}
|
||||
@@ -80,6 +86,33 @@ export function normalizePanelView(
|
||||
: DEFAULT_PANEL_VIEW;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the panel should force `showDraftView` when the normalized view
|
||||
* differs from the explicit store view.
|
||||
*
|
||||
* Explicit session views must stay put while the session is still present in
|
||||
* the same scoped history list that `normalizePanelView` uses — otherwise a
|
||||
* one-frame history lag after draft send demotes the new chat into history
|
||||
* and reopens a blank draft (especially under StrictMode).
|
||||
*
|
||||
* `sessionExists` must NOT consult the global store alone: a session that
|
||||
* exists but is out of this scope's history must demote so the panel does not
|
||||
* stick on a blank draft with a ghost active-map entry.
|
||||
*/
|
||||
export function shouldForceDraftViewSync(
|
||||
explicitPanelView: AIPanelView | undefined,
|
||||
normalizedPanelView: AIPanelView,
|
||||
sessionExists: (sessionId: string) => boolean,
|
||||
): boolean {
|
||||
if (!explicitPanelView || panelViewsEqual(normalizedPanelView, explicitPanelView)) {
|
||||
return false;
|
||||
}
|
||||
if (explicitPanelView.mode === "session" && sessionExists(explicitPanelView.sessionId)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function resolveDisplayedSession(
|
||||
panelView: AIPanelView,
|
||||
sessions: AISession[],
|
||||
|
||||
@@ -47,6 +47,31 @@ test("workspace history remains visible after the original workspace target is g
|
||||
);
|
||||
});
|
||||
|
||||
test("workspace history includes member-terminal chats and ranks them above stale workspaces", () => {
|
||||
const memberTerminalSession = createSession(
|
||||
"terminal-a-chat",
|
||||
{ type: "terminal", targetId: "terminal-a" },
|
||||
1,
|
||||
);
|
||||
const staleWorkspaceSession = createSession(
|
||||
"workspace-stale",
|
||||
{ type: "workspace", targetId: "workspace-before-restart" },
|
||||
99,
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
getScopedHistorySessions(
|
||||
[staleWorkspaceSession, memberTerminalSession],
|
||||
"workspace",
|
||||
"workspace-merged",
|
||||
["host-a"],
|
||||
new Set(),
|
||||
new Set(["terminal-a", "terminal-b"]),
|
||||
).map((session) => session.id),
|
||||
["terminal-a-chat", "workspace-stale"],
|
||||
);
|
||||
});
|
||||
|
||||
test("terminal history without host ids remains visible after the original terminal target is gone", () => {
|
||||
const staleLocalSession = createSession(
|
||||
"terminal-local-stale",
|
||||
|
||||
@@ -10,12 +10,16 @@ function buildHistoryCacheKey(
|
||||
scopeTargetId: string | undefined,
|
||||
scopeHostIds: string[] | undefined,
|
||||
activeTerminalSessionIds: Set<string>,
|
||||
workspaceMemberTerminalIds: Set<string> | undefined,
|
||||
): HistoryCacheKey {
|
||||
const hostKey = scopeHostIds ? [...scopeHostIds].sort().join(',') : '';
|
||||
const terminalKey = scopeType === 'terminal'
|
||||
? [...activeTerminalSessionIds].sort().join(',')
|
||||
: '';
|
||||
return `${scopeType}:${scopeTargetId ?? ''}:${hostKey}:${terminalKey}`;
|
||||
const memberKey = scopeType === 'workspace' && workspaceMemberTerminalIds
|
||||
? [...workspaceMemberTerminalIds].sort().join(',')
|
||||
: '';
|
||||
return `${scopeType}:${scopeTargetId ?? ''}:${hostKey}:${terminalKey}:${memberKey}`;
|
||||
}
|
||||
|
||||
export function getScopedHistorySessions(
|
||||
@@ -24,6 +28,7 @@ export function getScopedHistorySessions(
|
||||
scopeTargetId: string | undefined,
|
||||
scopeHostIds: string[] | undefined,
|
||||
activeTerminalSessionIds: Set<string>,
|
||||
workspaceMemberTerminalIds?: Set<string>,
|
||||
): AISession[] {
|
||||
let scopeCache = historyCache.get(sessions);
|
||||
if (!scopeCache) {
|
||||
@@ -36,6 +41,7 @@ export function getScopedHistorySessions(
|
||||
scopeTargetId,
|
||||
scopeHostIds,
|
||||
activeTerminalSessionIds,
|
||||
workspaceMemberTerminalIds,
|
||||
);
|
||||
const cached = scopeCache.get(cacheKey);
|
||||
if (cached) {
|
||||
@@ -53,6 +59,7 @@ export function getScopedHistorySessions(
|
||||
scopeTargetId,
|
||||
scopeHostIds,
|
||||
activeTerminalSessionIds,
|
||||
workspaceMemberTerminalIds,
|
||||
),
|
||||
}))
|
||||
.filter(({ matchRank }) => matchRank > 0)
|
||||
|
||||
@@ -114,3 +114,35 @@ test("scope type mismatch returns 0 regardless of target or hosts", () => {
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test("workspace scope treats member-terminal chats as exact matches after merge", () => {
|
||||
const session = createSession("session-1", "terminal-a", ["host-a"]);
|
||||
|
||||
assert.equal(
|
||||
getSessionScopeMatchRank(
|
||||
session,
|
||||
"workspace",
|
||||
"ws-1",
|
||||
["host-a"],
|
||||
undefined,
|
||||
new Set(["terminal-a", "terminal-b"]),
|
||||
),
|
||||
3,
|
||||
);
|
||||
});
|
||||
|
||||
test("workspace scope ignores terminal chats that are not workspace members", () => {
|
||||
const session = createSession("session-1", "terminal-other", ["host-a"]);
|
||||
|
||||
assert.equal(
|
||||
getSessionScopeMatchRank(
|
||||
session,
|
||||
"workspace",
|
||||
"ws-1",
|
||||
["host-a"],
|
||||
undefined,
|
||||
new Set(["terminal-a", "terminal-b"]),
|
||||
),
|
||||
0,
|
||||
);
|
||||
});
|
||||
@@ -12,7 +12,25 @@ export function getSessionScopeMatchRank(
|
||||
* not offered (or cleaned) as if it were orphaned.
|
||||
*/
|
||||
activeTerminalSessionIds?: Set<string>,
|
||||
/**
|
||||
* Terminal session ids currently living in this workspace. When terminals
|
||||
* merge, their AI chats stay `scope.type === "terminal"`; treating those
|
||||
* member chats as exact workspace matches keeps history/resume working.
|
||||
*/
|
||||
workspaceMemberTerminalIds?: Set<string>,
|
||||
): number {
|
||||
// After a terminal merge the AI panel flips to workspace scope, but chats
|
||||
// created on the member terminals remain terminal-scoped. Rank those as
|
||||
// exact matches so they stay visible and preferred over stale workspaces.
|
||||
if (
|
||||
scopeType === "workspace"
|
||||
&& session.scope.type === "terminal"
|
||||
&& session.scope.targetId
|
||||
&& workspaceMemberTerminalIds?.has(session.scope.targetId)
|
||||
) {
|
||||
return 3;
|
||||
}
|
||||
|
||||
if (session.scope.type !== scopeType) return 0;
|
||||
if (session.scope.targetId === scopeTargetId) return 3;
|
||||
|
||||
|
||||
@@ -4,8 +4,11 @@ import { readFileSync } from "node:fs";
|
||||
|
||||
import {
|
||||
getHostPickerTriggerRange,
|
||||
isNoteSmallImageWidth,
|
||||
isSupportedNoteExternalHref,
|
||||
isPointerInsideLinkActionHoverZone,
|
||||
linkActionStatesEqual,
|
||||
NOTE_SMALL_IMAGE_MAX_WIDTH,
|
||||
resolveHostPickerPopupPosition,
|
||||
shouldInsertClipboardTextAsMarkdown,
|
||||
shouldHandleHostPickerNavigationKey,
|
||||
@@ -53,6 +56,24 @@ test("link action hover zone keeps the open button reachable but not sticky", ()
|
||||
assert.equal(isPointerInsideLinkActionHoverZone(null, 105, 55), false);
|
||||
});
|
||||
|
||||
test("link action state equality skips identical hover chips", () => {
|
||||
const a = { href: "https://example.com", label: "example", left: 100, top: 50 };
|
||||
assert.equal(linkActionStatesEqual(a, { ...a }), true);
|
||||
assert.equal(linkActionStatesEqual(a, { ...a, left: 101 }), false);
|
||||
assert.equal(linkActionStatesEqual(a, null), false);
|
||||
assert.equal(linkActionStatesEqual(null, null), true);
|
||||
});
|
||||
|
||||
test("small note image width threshold matches README badge sizes", () => {
|
||||
assert.equal(isNoteSmallImageWidth(32), true);
|
||||
assert.equal(isNoteSmallImageWidth("96"), true);
|
||||
assert.equal(isNoteSmallImageWidth(NOTE_SMALL_IMAGE_MAX_WIDTH), true);
|
||||
assert.equal(isNoteSmallImageWidth(128), false);
|
||||
assert.equal(isNoteSmallImageWidth(2000), false);
|
||||
assert.equal(isNoteSmallImageWidth(""), false);
|
||||
assert.equal(isNoteSmallImageWidth(null), false);
|
||||
});
|
||||
|
||||
test("host picker trigger range only covers the typed trigger and query", () => {
|
||||
const text = "before\n\n@10.2.0.32";
|
||||
const range = getHostPickerTriggerRange(text);
|
||||
@@ -104,7 +125,9 @@ test("pasted markdown is detected only when it has renderable structure", () =>
|
||||
assert.equal(shouldInsertClipboardTextAsMarkdown("```sh\nuptime\n```"), true);
|
||||
assert.equal(shouldInsertClipboardTextAsMarkdown("plain text from clipboard"), false);
|
||||
assert.equal(shouldInsertClipboardTextAsMarkdown("https://example.com/path_(x)"), false);
|
||||
assert.equal(shouldInsertClipboardTextAsMarkdown(""), false);
|
||||
// Image markdown / raw HTML img must intercept so notes can render remote images.
|
||||
assert.equal(shouldInsertClipboardTextAsMarkdown(""), true);
|
||||
assert.equal(shouldInsertClipboardTextAsMarkdown('<img alt="logo" src="https://example.com/logo.png" />'), true);
|
||||
});
|
||||
|
||||
test("note editor registers a code block editor for pasted fenced code", () => {
|
||||
@@ -118,13 +141,23 @@ test("note editor registers a code block editor for pasted fenced code", () => {
|
||||
assert.match(source, /syntaxHighlighting\(noteCodeHighlightStyle\)/);
|
||||
});
|
||||
|
||||
test("note editor enables image plugin for remote markdown images", () => {
|
||||
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
|
||||
assert.match(source, /imagePlugin\(\{\s*allowSetImageDimensions:\s*true/);
|
||||
assert.match(source, /InsertImage/);
|
||||
});
|
||||
|
||||
test("note editor exposes preview and edit modes with a markdown toolbar in edit mode", () => {
|
||||
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
|
||||
const managerSource = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /type NoteEditorMode = "edit" \| "preview"/);
|
||||
assert.match(source, /toolbarPlugin\(\{\s*toolbarContents:/s);
|
||||
// Preview and edit both use MDXEditor (readOnly in preview).
|
||||
assert.match(source, /readOnly=\{editorMode === "preview"\}/);
|
||||
assert.match(source, /key=\{editorMode\}/);
|
||||
assert.match(source, /netcatty-mdx-editor--preview/);
|
||||
assert.doesNotMatch(source, /NoteMarkdownPreview|react-markdown|github-markdown/);
|
||||
assert.match(source, /<BlockTypeSelect \/>/);
|
||||
assert.match(source, /<BoldItalicUnderlineToggles /);
|
||||
assert.match(source, /<ListsToggle /);
|
||||
@@ -156,6 +189,12 @@ test("note markdown toolbar remains usable in narrow panes", () => {
|
||||
styles,
|
||||
/\.netcatty-mdx-editor\s*\{[^}]*container-type:\s*inline-size;/s,
|
||||
);
|
||||
// Keep fixed-containing-block in sync with MDX linkDialog coordinate math
|
||||
// (container-type alone is not a fixed CB in browsers).
|
||||
assert.match(
|
||||
styles,
|
||||
/\.netcatty-mdx-editor\s*\{[^}]*transform:\s*translateZ\(0\);/s,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.netcatty-note-markdown-toolbar\s*\{[^}]*max-width:\s*100%;[^}]*height:\s*auto\s*!important;[^}]*overflow:\s*visible\s*!important;/s,
|
||||
@@ -186,10 +225,15 @@ test("preview mode opens links directly without showing the edit hover action",
|
||||
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /const handleClickCapture = useCallback/);
|
||||
assert.match(source, /if \(editorMode !== "preview"\) \{[\s\S]*scheduleHostPickerUpdate\(\);[\s\S]*return;/);
|
||||
assert.match(
|
||||
source,
|
||||
/if \(editorMode === "preview"\) \{[\s\S]*toggleTaskListItemAtIndex[\s\S]*const handled = openLink\(href, label\);/,
|
||||
"preview click path handles task toggles then openable links",
|
||||
);
|
||||
assert.match(source, /const handled = openLink\(href, label\);[\s\S]*if \(!handled\) return;[\s\S]*event\.preventDefault\(\);/);
|
||||
assert.match(source, /onClickCapture=\{handleClickCapture\}/);
|
||||
assert.match(source, /if \(editorMode !== "edit"\) \{[\s\S]*setLinkAction\(null\);[\s\S]*return;/);
|
||||
assert.match(source, /scheduleHostPickerUpdate\(\);\s*\n\s*\}, \[commitMarkdown, editorMode, openLink, scheduleHostPickerUpdate\]/);
|
||||
assert.match(source, /onClickCapture=\{\(event\) => \{[\s\S]*blockWhileContentSwapping[\s\S]*handleClickCapture/);
|
||||
assert.match(source, /if \(editorMode !== "edit"\) \{[\s\S]*setLinkActionIfChanged\(null\);[\s\S]*return;/);
|
||||
assert.match(source, /\{editorMode === "edit" && linkAction && \(/);
|
||||
});
|
||||
|
||||
@@ -320,15 +364,14 @@ test("annotateNoteCodeBlockCopyButtons adds a copy action to code blocks", () =>
|
||||
assert.match(source, /onCopy\(text\)/);
|
||||
});
|
||||
|
||||
test("note code blocks expose a hover copy action only in preview mode", () => {
|
||||
test("note preview uses MDX readOnly with code-copy chrome", () => {
|
||||
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
|
||||
const styles = readFileSync(new URL("../../index.css", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /removeNoteCodeBlockCopyButtons/);
|
||||
assert.match(source, /if \(editorMode !== "preview"\) \{[\s\S]*removeNoteCodeBlockCopyButtons/);
|
||||
assert.match(source, /readOnly=\{editorMode === "preview"\}/);
|
||||
assert.match(source, /annotateCodeBlockCopyButtons/);
|
||||
assert.match(source, /annotateNoteCodeBlockCopyButtons/);
|
||||
assert.match(source, /notes\.codeBlock\.copied/);
|
||||
assert.match(source, /copyFailedLabel/);
|
||||
assert.match(source, /MutationObserver/);
|
||||
assert.match(source, /setAttribute\("aria-label", copiedLabel\)/);
|
||||
assert.match(styles, /\.netcatty-note-code-copy/);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -8,6 +8,25 @@ const layoutSource = readFileSync(
|
||||
"utf8",
|
||||
);
|
||||
|
||||
test("notes tree width resize avoids React setState on every pointermove", () => {
|
||||
// Dragging the sidebar used to call setTreeWidth per pixel and re-render the
|
||||
// whole NotesManager (including MDXEditor). Live width must stay on the DOM
|
||||
// until pointerup commits state + localStorage.
|
||||
assert.match(managerSource, /treeAsideRef/);
|
||||
assert.match(managerSource, /requestAnimationFrame/);
|
||||
assert.match(managerSource, /aside\.style\.width = `\$\{width\}px`/);
|
||||
assert.match(
|
||||
managerSource,
|
||||
/const handlePointerMove = \(moveEvent: PointerEvent\) => \{[\s\S]*?requestAnimationFrame/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
managerSource,
|
||||
/const handlePointerMove = \(moveEvent: PointerEvent\) => \{\s*\n\s*setTreeWidth\(/,
|
||||
);
|
||||
assert.match(managerSource, /persistTreeWidth\(nextWidth\)/);
|
||||
assert.match(managerSource, /isTreeResizing && "pointer-events-none"/);
|
||||
});
|
||||
|
||||
test("note content drafts stay in refs so MDX keystrokes do not rebuild the shell", () => {
|
||||
assert.doesNotMatch(
|
||||
managerSource,
|
||||
@@ -72,6 +91,63 @@ test("mode toggle flushes ref-only content drafts before remounting the editor",
|
||||
);
|
||||
});
|
||||
|
||||
test("note switches reuse MDX instance instead of key=noteId remount", () => {
|
||||
assert.match(managerSource, /noteId=\{selectedNoteView\.id\}/);
|
||||
assert.doesNotMatch(
|
||||
managerSource,
|
||||
/<InlineMarkdownEditor[\s\S]{0,200}key=\{selectedNoteView\.id\}/,
|
||||
"key=noteId forces full Lexical teardown on every note switch",
|
||||
);
|
||||
const editorSource = readFileSync(
|
||||
new URL("./InlineMarkdownEditor.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(editorSource, /noteId !== noteIdRef\.current/);
|
||||
assert.match(editorSource, /setMarkdown\(scheduled\.markdown\)/);
|
||||
assert.match(
|
||||
editorSource,
|
||||
/contentSwapFramesRef\.current\.outer = window\.requestAnimationFrame/,
|
||||
"outer rAF yields a paint so the tree selection updates before Lexical import",
|
||||
);
|
||||
assert.match(
|
||||
editorSource,
|
||||
/contentSwapFramesRef\.current\.inner = window\.requestAnimationFrame/,
|
||||
"inner rAF completes the double-yield before setMarkdown",
|
||||
);
|
||||
assert.match(editorSource, /data-notes-content-swapping="true"/);
|
||||
assert.match(editorSource, /setIsContentSwapping\(true\)/);
|
||||
assert.match(editorSource, /CLEAR_HISTORY_COMMAND/);
|
||||
assert.match(editorSource, /clearLexicalHistory/);
|
||||
assert.match(editorSource, /attributeFilter:\s*\[\s*"width",\s*"height"/);
|
||||
assert.match(editorSource, /onPointerDownCapture=\{blockWhileContentSwapping\}/);
|
||||
assert.match(editorSource, /startTransition\(\(\) => setIsContentSwapping\(false\)\)/);
|
||||
assert.match(
|
||||
editorSource,
|
||||
/latestMarkdownRef\.current !== scheduled\.markdown/,
|
||||
"deferred setMarkdown must not clobber edits typed after the switch",
|
||||
);
|
||||
assert.match(
|
||||
editorSource,
|
||||
/if \(contentSwapPendingRef\.current\) return;/,
|
||||
"stale onChange during the swap yield must not write the previous note into the new draft",
|
||||
);
|
||||
assert.match(
|
||||
editorSource,
|
||||
/contentSwapScheduledRef/,
|
||||
"deferred import must refresh when the same note's value changes during the yield",
|
||||
);
|
||||
assert.match(
|
||||
editorSource,
|
||||
/syncedPropValueRef\.current = markdown;/,
|
||||
"draft-clobber guard compares display-normalized values after note switch",
|
||||
);
|
||||
assert.match(
|
||||
editorSource,
|
||||
/runDecorations\(true\)/,
|
||||
"edit-mode note swaps must re-annotate host links after setMarkdown",
|
||||
);
|
||||
});
|
||||
|
||||
test("host-link annotation does not re-run on every markdown value keystroke", () => {
|
||||
const editorSource = readFileSync(
|
||||
new URL("./InlineMarkdownEditor.tsx", import.meta.url),
|
||||
@@ -82,7 +158,21 @@ test("host-link annotation does not re-run on every markdown value keystroke", (
|
||||
/annotateHostLinks,\s*value\s*\]/,
|
||||
"value in annotateHostLinks effect deps walks the DOM on every keystroke",
|
||||
);
|
||||
assert.match(editorSource, /\[annotateHostLinks, editorMode\]/);
|
||||
assert.doesNotMatch(
|
||||
editorSource,
|
||||
/annotateCodeBlockCopyButtons,\s*editorMode,\s*value\s*\]/,
|
||||
"value in code-copy effect deps re-walks every code block on each draft identity change",
|
||||
);
|
||||
assert.match(
|
||||
editorSource,
|
||||
/\[annotateCodeBlockCopyButtons, annotateHostLinks, editorMode\]/,
|
||||
"DOM decoration is independent of markdown value identity",
|
||||
);
|
||||
assert.match(
|
||||
editorSource,
|
||||
/readOnly=\{editorMode === "preview"\}/,
|
||||
"preview reuses MDXEditor in read-only mode",
|
||||
);
|
||||
assert.match(
|
||||
editorSource,
|
||||
/syncedPropValueRef/,
|
||||
@@ -93,3 +183,26 @@ test("host-link annotation does not re-run on every markdown value keystroke", (
|
||||
/latestMarkdownRef\.current !== syncedPropValueRef\.current/,
|
||||
);
|
||||
});
|
||||
|
||||
test("link hover and small-image CSS avoid render thrash", () => {
|
||||
const editorSource = readFileSync(
|
||||
new URL("./InlineMarkdownEditor.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const cssSource = readFileSync(new URL("../../index.css", import.meta.url), "utf8");
|
||||
|
||||
const imageLayoutSource = readFileSync(
|
||||
new URL("./noteImageLayout.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(editorSource, /linkActionStatesEqual/);
|
||||
assert.match(editorSource, /setLinkActionIfChanged/);
|
||||
assert.match(editorSource, /annotateNoteImageSizes/);
|
||||
assert.match(imageLayoutSource, /data-note-img-size/);
|
||||
// No combinatorial :has(img[width="N"]) matrix for small icons.
|
||||
assert.doesNotMatch(
|
||||
cssSource,
|
||||
/:has\(img\[width="16"\]\).*?:has\(img\[width="20"\]/s,
|
||||
);
|
||||
assert.match(cssSource, /img\[data-note-img-size="sm"\]/);
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
|
||||
import type { VaultNote } from "../../types.ts";
|
||||
import { TooltipProvider } from "../ui/tooltip.tsx";
|
||||
import {
|
||||
clampNotesTreeWidth,
|
||||
getFallbackNoteSelectionState,
|
||||
getNoteActionTargetGroup,
|
||||
getNoteGroupSelectionState,
|
||||
@@ -56,7 +57,14 @@ test("NotesManager renders notes tree and selected markdown editor", () => {
|
||||
|
||||
assert.match(markup, /Ops/);
|
||||
assert.match(markup, /Postgres failover checklist/);
|
||||
assert.match(markup, /data-notes-editor-loading="true"/);
|
||||
// React.lazy may show Suspense fallback or the resolved editor depending on
|
||||
// whether a prior test already settled the MDX chunk.
|
||||
assert.ok(
|
||||
markup.includes('data-notes-editor-loading="true"')
|
||||
|| markup.includes("editable markdown")
|
||||
|| markup.includes("Promote replica"),
|
||||
"full mode should mount the note editor (loading fallback or resolved MDX)",
|
||||
);
|
||||
});
|
||||
|
||||
test("NotesManager marks selected notebook rows with shared tree state", () => {
|
||||
@@ -76,6 +84,36 @@ test("NotesManager balances folder and note tree icon sizes", () => {
|
||||
assert.match(markup, /width="16" height="16"[^>]*class="lucide lucide-file-text/);
|
||||
});
|
||||
|
||||
test("NotesManager tree scroll area constrains width so titles can truncate", () => {
|
||||
const markup = renderNotes();
|
||||
|
||||
// Radix ScrollArea viewport child is display:table by default; force block +
|
||||
// min-w-0 so narrowing the notes sidebar ellipsizes long titles instead of
|
||||
// clipping them. React encodes `&` / `>` in class attributes.
|
||||
assert.match(
|
||||
markup,
|
||||
/data-radix-scroll-area-viewport\](?:>|>)div\]:!block/,
|
||||
);
|
||||
assert.match(
|
||||
markup,
|
||||
/data-radix-scroll-area-viewport\](?:>|>)div\]:!min-w-0/,
|
||||
);
|
||||
assert.match(markup, /data-notes-drop-zone="root"/);
|
||||
assert.match(markup, /min-h-full min-w-0 space-y-1 overflow-hidden/);
|
||||
// Aside stays unclipped so the resize handle's outer half remains hit-testable;
|
||||
// truncation is enforced by the inner tree shell + ScrollArea constraints.
|
||||
assert.match(markup, /aside class="[^"]*min-w-0[^"]*flex-col bg-background/);
|
||||
assert.match(markup, /flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden/);
|
||||
assert.match(markup, /role="separator"/);
|
||||
assert.match(markup, /translate-x-1\/2 cursor-col-resize/);
|
||||
});
|
||||
|
||||
test("clampNotesTreeWidth keeps the sidebar within the design range", () => {
|
||||
assert.equal(clampNotesTreeWidth(100), 160);
|
||||
assert.equal(clampNotesTreeWidth(300), 300);
|
||||
assert.equal(clampNotesTreeWidth(900), 520);
|
||||
});
|
||||
|
||||
test("NotesManager selection helpers keep note and folder selection exclusive", () => {
|
||||
const notes = [note(), note({ id: "note-2", title: "Deploy", group: "Deploy" })];
|
||||
const noteSelection = getNoteSelectionState(notes[0], false);
|
||||
@@ -297,7 +335,12 @@ test("NotesManager sidebar mode opens the requested note without selecting its f
|
||||
);
|
||||
|
||||
assert.match(markup, /Deploy overlay/);
|
||||
assert.match(markup, /data-notes-editor-loading="true"/);
|
||||
assert.ok(
|
||||
markup.includes('data-notes-editor-loading="true"')
|
||||
|| markup.includes("Deploy overlay content")
|
||||
|| markup.includes("editable markdown"),
|
||||
"openNoteId should mount the sidebar note overlay editor",
|
||||
);
|
||||
assert.equal(markup.match(/data-selected="true"/g)?.length, 1);
|
||||
assert.match(markup, /data-vault-tree-row="group"[^>]*data-selected="false"/);
|
||||
assert.match(markup, /data-vault-tree-row="item"[^>]*data-selected="true"[^>]*data-note-id="note-2"/);
|
||||
|
||||
@@ -100,11 +100,16 @@ type NotesToolbarPanel = "search" | null;
|
||||
const toolbarIconButtonClass = "netcatty-tab h-6 w-6 shrink-0 rounded-md p-0 hover:bg-transparent";
|
||||
const menuItemClass = "flex h-8 w-full items-center rounded-md px-3 text-left text-sm hover:bg-secondary";
|
||||
const NOTES_TREE_DEFAULT_WIDTH = 300;
|
||||
const NOTES_TREE_MIN_WIDTH = 220;
|
||||
/** Narrow enough for nested folders + ellipsis; toolbar scrolls if needed. */
|
||||
const NOTES_TREE_MIN_WIDTH = 160;
|
||||
const NOTES_TREE_MAX_WIDTH = 520;
|
||||
const NOTE_DRAG_TYPE = "application/x-netcatty-note-id";
|
||||
const NOTE_GROUP_DRAG_TYPE = "application/x-netcatty-note-group-path";
|
||||
|
||||
export function clampNotesTreeWidth(value: number): number {
|
||||
return Math.max(NOTES_TREE_MIN_WIDTH, Math.min(NOTES_TREE_MAX_WIDTH, value));
|
||||
}
|
||||
|
||||
export const normalizeNoteEditorMode = (value: string | null): NoteEditorMode | null =>
|
||||
value === "edit" || value === "preview" ? value : null;
|
||||
|
||||
@@ -113,7 +118,7 @@ const isNoteEditorMode = (value: string | null): value is NoteEditorMode =>
|
||||
|
||||
const InlineMarkdownEditorFallback = () => (
|
||||
<div
|
||||
className="netcatty-lazy-fade-in min-h-[420px]"
|
||||
className="min-h-[420px] bg-background"
|
||||
data-notes-editor-loading="true"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
@@ -388,6 +393,9 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
NOTES_TREE_DEFAULT_WIDTH,
|
||||
{ min: NOTES_TREE_MIN_WIDTH, max: NOTES_TREE_MAX_WIDTH },
|
||||
);
|
||||
const treeAsideRef = useRef<HTMLElement | null>(null);
|
||||
const treeWidthRef = useRef(treeWidth);
|
||||
treeWidthRef.current = treeWidth;
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const importFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const isImportingMarkdownRef = useRef(false);
|
||||
@@ -1232,7 +1240,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
return (
|
||||
<div
|
||||
key={`new-folder-${parent || "root"}`}
|
||||
className="flex h-7 items-center px-2 text-sm"
|
||||
className="flex h-7 min-w-0 items-center px-2 text-sm"
|
||||
style={{ paddingLeft: depth * 16 + 4 }}
|
||||
>
|
||||
<div className="mr-1 h-5 w-4 shrink-0" />
|
||||
@@ -1444,23 +1452,53 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
event.stopPropagation();
|
||||
|
||||
const startX = event.clientX;
|
||||
const startWidth = treeWidth;
|
||||
const startWidth = treeWidthRef.current;
|
||||
const previousCursor = document.body.style.cursor;
|
||||
const previousUserSelect = document.body.style.userSelect;
|
||||
const aside = treeAsideRef.current;
|
||||
let frame = 0;
|
||||
let latestWidth = startWidth;
|
||||
|
||||
// Avoid setState on every pointermove — NotesManager owns the MDX editor and
|
||||
// re-rendering it per pixel makes sidebar resize feel stuck.
|
||||
setIsTreeResizing(true);
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
if (aside) {
|
||||
aside.style.willChange = "width";
|
||||
}
|
||||
|
||||
const clampWidth = (value: number) =>
|
||||
Math.max(NOTES_TREE_MIN_WIDTH, Math.min(NOTES_TREE_MAX_WIDTH, value));
|
||||
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
setTreeWidth(clampWidth(startWidth + moveEvent.clientX - startX));
|
||||
const applyWidth = (width: number) => {
|
||||
latestWidth = width;
|
||||
if (aside) {
|
||||
aside.style.width = `${width}px`;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerUp = (upEvent: PointerEvent) => {
|
||||
const nextWidth = clampWidth(startWidth + upEvent.clientX - startX);
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
const nextWidth = clampNotesTreeWidth(startWidth + moveEvent.clientX - startX);
|
||||
if (frame) {
|
||||
latestWidth = nextWidth;
|
||||
return;
|
||||
}
|
||||
latestWidth = nextWidth;
|
||||
frame = window.requestAnimationFrame(() => {
|
||||
frame = 0;
|
||||
applyWidth(latestWidth);
|
||||
});
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
if (frame) {
|
||||
window.cancelAnimationFrame(frame);
|
||||
frame = 0;
|
||||
}
|
||||
const nextWidth = clampNotesTreeWidth(latestWidth);
|
||||
applyWidth(nextWidth);
|
||||
if (aside) {
|
||||
aside.style.willChange = "";
|
||||
}
|
||||
treeWidthRef.current = nextWidth;
|
||||
setTreeWidth(nextWidth);
|
||||
persistTreeWidth(nextWidth);
|
||||
setIsTreeResizing(false);
|
||||
@@ -1474,7 +1512,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", handlePointerUp);
|
||||
window.addEventListener("pointercancel", handlePointerUp);
|
||||
}, [persistTreeWidth, setTreeWidth, treeWidth]);
|
||||
}, [persistTreeWidth, setTreeWidth]);
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full min-h-0 flex-col overflow-hidden">
|
||||
@@ -1491,16 +1529,20 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{shouldShowNotesTree && (
|
||||
<aside
|
||||
ref={treeAsideRef}
|
||||
className={cn(
|
||||
"relative flex flex-col bg-background",
|
||||
isSidebarMode ? "min-w-0 flex-1" : "shrink-0 border-r border-border/60",
|
||||
// min-w-0 on the aside; overflow-hidden stays on the inner tree shell
|
||||
// so the resize separator's translate-x-1/2 hit area is not clipped.
|
||||
"relative flex min-w-0 flex-col bg-background",
|
||||
isSidebarMode ? "flex-1" : "shrink-0 border-r border-border/60",
|
||||
)}
|
||||
style={isSidebarMode ? undefined : { width: treeWidth }}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="min-w-0 flex-shrink-0 overflow-hidden">
|
||||
<div className={cn(
|
||||
TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS,
|
||||
"flex items-center gap-1 border-b border-border/60 px-1.5",
|
||||
"flex min-w-0 items-center gap-1 overflow-x-auto border-b border-border/60 px-1.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
||||
)}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -1645,9 +1687,13 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
{/* Radix ScrollArea viewport child defaults to display:table, which
|
||||
expands to content width and blocks title ellipsis on narrow trees.
|
||||
Force block + min-w-0 so rows shrink with the sidebar (same pattern
|
||||
as AsidePanelContent). */}
|
||||
<ScrollArea className="min-w-0 flex-1 [&>[data-radix-scroll-area-viewport]>div]:!block [&>[data-radix-scroll-area-viewport]>div]:!min-w-0">
|
||||
<div
|
||||
className="min-h-full space-y-1 px-1.5 pt-1.5 pb-4"
|
||||
className="min-h-full min-w-0 space-y-1 overflow-hidden px-1.5 pt-1.5 pb-4"
|
||||
data-notes-drop-zone="root"
|
||||
onDragOver={(event) => {
|
||||
if (!hasNotesTreeDrag(event.dataTransfer)) return;
|
||||
@@ -1667,6 +1713,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
{!isSidebarMode && (
|
||||
<div
|
||||
role="separator"
|
||||
@@ -1685,7 +1732,14 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
)}
|
||||
|
||||
{!isSidebarMode && (
|
||||
<main className="flex min-w-0 flex-1 flex-col bg-background">
|
||||
<main
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 flex-col bg-background",
|
||||
// Keep the editor out of hit-testing / paint thrash while the tree
|
||||
// width is driven from the DOM during resize.
|
||||
isTreeResizing && "pointer-events-none",
|
||||
)}
|
||||
>
|
||||
{selectedNoteView ? (
|
||||
<>
|
||||
<div className="flex min-h-[54px] shrink-0 items-center gap-3 px-8 pt-6 pb-1" data-note-title-row>
|
||||
@@ -1712,10 +1766,10 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LazyLoadBoundary name="Notes editor" resetKey={selectedNoteView.id}>
|
||||
<LazyLoadBoundary name="Notes editor" resetKey="notes-editor">
|
||||
<Suspense fallback={<InlineMarkdownEditorFallback />}>
|
||||
<InlineMarkdownEditor
|
||||
key={selectedNoteView.id}
|
||||
noteId={selectedNoteView.id}
|
||||
value={selectedNoteView.content}
|
||||
placeholder={t("notes.editor.placeholder")}
|
||||
editorMode={noteEditorMode}
|
||||
@@ -1803,10 +1857,10 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LazyLoadBoundary name="Notes editor" resetKey={overlayNoteView.id}>
|
||||
<LazyLoadBoundary name="Notes editor" resetKey="notes-overlay-editor">
|
||||
<Suspense fallback={<InlineMarkdownEditorFallback />}>
|
||||
<InlineMarkdownEditor
|
||||
key={overlayNoteView.id}
|
||||
noteId={overlayNoteView.id}
|
||||
value={overlayNoteView.content}
|
||||
placeholder={t("notes.editor.placeholder")}
|
||||
editorMode={noteEditorMode}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Smoke: UI re-export surface stays wired to domain paste policy.
|
||||
* Full policy coverage lives in domain/notes/clipboardPaste.test.ts.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
convertClipboardHtmlToMarkdown,
|
||||
resolveNoteClipboardPaste,
|
||||
shouldInsertClipboardTextAsMarkdown,
|
||||
} from "./noteClipboardPaste.ts";
|
||||
|
||||
test("noteClipboardPaste re-exports domain resolve + convert helpers", () => {
|
||||
assert.equal(shouldInsertClipboardTextAsMarkdown("# Title\n\n- item"), true);
|
||||
assert.equal(shouldInsertClipboardTextAsMarkdown("plain only"), false);
|
||||
|
||||
const payload = resolveNoteClipboardPaste({
|
||||
plainText: "# From re-export\n\n- a",
|
||||
htmlText: "",
|
||||
});
|
||||
assert.equal(payload.kind, "markdown");
|
||||
assert.match(payload.text, /# From re-export/);
|
||||
|
||||
const md = convertClipboardHtmlToMarkdown(
|
||||
"<html><body><!--StartFragment--><h1>Hi</h1><!--EndFragment--></body></html>",
|
||||
);
|
||||
assert.match(md, /^# Hi/m);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* UI re-export of note clipboard paste policy.
|
||||
* Implementation lives in domain/notes/clipboardPaste.ts (pure, no React).
|
||||
*/
|
||||
export {
|
||||
type NoteClipboardPasteKind,
|
||||
type NoteClipboardPastePayload,
|
||||
shouldInsertClipboardTextAsMarkdown,
|
||||
looksLikeClipboardHtml,
|
||||
plainMarkdownContainsHtml,
|
||||
isPrimarilyHtmlDocument,
|
||||
isCenteredBlockElement,
|
||||
htmlOpenTagIsCentered,
|
||||
wrapCenteredMarkdown,
|
||||
decodeHtmlEntities,
|
||||
normalizeImageSrc,
|
||||
normalizeNotePublicAssetPaths,
|
||||
serializeSafeHtmlImage,
|
||||
trimBlankLinesOutsideCode,
|
||||
findHtmlTagEnd,
|
||||
convertHtmlImgTagToMarkdownOrHtml,
|
||||
normalizeLinkedBadgeImages,
|
||||
maskCodeRegions,
|
||||
unmaskCodeRegions,
|
||||
normalizePastedNoteMarkdown,
|
||||
convertClipboardHtmlToMarkdown,
|
||||
extractBalancedHtmlElement,
|
||||
convertHtmlIslandsInMarkdown,
|
||||
resolveNoteClipboardPaste,
|
||||
shouldInterceptResolvedNotePaste,
|
||||
} from "../../domain/notes/clipboardPaste";
|
||||
@@ -0,0 +1,47 @@
|
||||
/** Compact README-style icons (width ≤ 96). CSS uses data-note-img-size instead of :has(). */
|
||||
export const NOTE_SMALL_IMAGE_MAX_WIDTH = 96;
|
||||
|
||||
export const isNoteSmallImageWidth = (widthRaw: string | number | null | undefined): boolean => {
|
||||
const width = typeof widthRaw === "number" ? widthRaw : Number(String(widthRaw ?? "").trim());
|
||||
return Number.isFinite(width) && width > 0 && width <= NOTE_SMALL_IMAGE_MAX_WIDTH;
|
||||
};
|
||||
|
||||
/**
|
||||
* Mark compact images so CSS can lay out badge rows without hundreds of :has()
|
||||
* width selectors. Also enable lazy loading for remote images.
|
||||
*/
|
||||
export const annotateNoteImageSizes = (container: HTMLElement): void => {
|
||||
container.querySelectorAll("img").forEach((node) => {
|
||||
if (!(node instanceof HTMLImageElement)) return;
|
||||
const isSmall = isNoteSmallImageWidth(node.getAttribute("width"));
|
||||
|
||||
if (isSmall) {
|
||||
node.dataset.noteImgSize = "sm";
|
||||
} else {
|
||||
delete node.dataset.noteImgSize;
|
||||
}
|
||||
|
||||
// Prefer browser-native lazy decode for remote screenshots / badges.
|
||||
if (node.getAttribute("src") && !node.getAttribute("loading")) {
|
||||
node.loading = "lazy";
|
||||
}
|
||||
if (!node.getAttribute("decoding")) {
|
||||
node.decoding = "async";
|
||||
}
|
||||
|
||||
const wrappers: HTMLElement[] = [];
|
||||
const block = node.closest<HTMLElement>("[data-editor-block-type=\"image\"]");
|
||||
if (block) wrappers.push(block);
|
||||
const imageWrapper = node.closest<HTMLElement>("[class*=\"_imageWrapper_\"]");
|
||||
if (imageWrapper && imageWrapper !== block) wrappers.push(imageWrapper);
|
||||
const parent = node.parentElement;
|
||||
if (parent?.tagName === "P" && parent.childElementCount === 1) {
|
||||
wrappers.push(parent);
|
||||
}
|
||||
|
||||
for (const wrapper of wrappers) {
|
||||
if (isSmall) wrapper.dataset.noteImgSize = "sm";
|
||||
else delete wrapper.dataset.noteImgSize;
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -3,7 +3,11 @@ import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
insertClipboardTextAtActiveLexicalSelection,
|
||||
mergeNoteMarkdownDocumentPaste,
|
||||
NOTE_MARKDOWN_PASTE_INSERT_MAX_CHARS,
|
||||
resolveNoteMarkdownPasteSettleAttempts,
|
||||
resolveNoteMarkdownPasteStrategy,
|
||||
shouldInterceptNoteMarkdownPaste,
|
||||
} from "./InlineMarkdownEditor.tsx";
|
||||
|
||||
@@ -17,8 +21,6 @@ test("markdown paste intercepts structured clipboard text in edit mode even with
|
||||
}),
|
||||
true,
|
||||
);
|
||||
// After a prior insertMarkdown clears the caret, continuous paste must still
|
||||
// be recoverable via document setMarkdown rather than a swallowed preventDefault.
|
||||
assert.equal(
|
||||
shouldInterceptNoteMarkdownPaste({
|
||||
editorMode: "edit",
|
||||
@@ -58,36 +60,84 @@ test("document paste merge preserves first-line indentation and appends after cu
|
||||
);
|
||||
});
|
||||
|
||||
test("paste strategy preserves caret: long paste with selection still inserts at selection", () => {
|
||||
assert.equal(
|
||||
resolveNoteMarkdownPasteStrategy({
|
||||
canInsertMarkdownAtSelection: false,
|
||||
clipboardText: "# short",
|
||||
}),
|
||||
"document-merge",
|
||||
);
|
||||
assert.equal(
|
||||
resolveNoteMarkdownPasteStrategy({
|
||||
canInsertMarkdownAtSelection: true,
|
||||
clipboardText: "# short body",
|
||||
}),
|
||||
"insert-at-selection",
|
||||
);
|
||||
const longMarkdown = `# Title\n\n${"paragraph text ".repeat(400)}`;
|
||||
assert.ok(longMarkdown.length >= NOTE_MARKDOWN_PASTE_INSERT_MAX_CHARS);
|
||||
// Long paste must NOT force document-merge when a caret exists (would append at EOF).
|
||||
assert.equal(
|
||||
resolveNoteMarkdownPasteStrategy({
|
||||
canInsertMarkdownAtSelection: true,
|
||||
clipboardText: longMarkdown,
|
||||
}),
|
||||
"insert-at-selection",
|
||||
);
|
||||
});
|
||||
|
||||
test("paste settle attempts scale with clipboard size", () => {
|
||||
assert.equal(resolveNoteMarkdownPasteSettleAttempts(100), 6);
|
||||
assert.equal(resolveNoteMarkdownPasteSettleAttempts(3_000), 6);
|
||||
assert.equal(resolveNoteMarkdownPasteSettleAttempts(12_000), 10);
|
||||
assert.ok(resolveNoteMarkdownPasteSettleAttempts(100_000) <= 40);
|
||||
assert.ok(resolveNoteMarkdownPasteSettleAttempts(100_000) >= 6);
|
||||
});
|
||||
|
||||
test("selection paste recovery helper rejects empty text or missing target", () => {
|
||||
assert.equal(insertClipboardTextAtActiveLexicalSelection(null, "# Heading"), false);
|
||||
assert.equal(insertClipboardTextAtActiveLexicalSelection(null, ""), false);
|
||||
});
|
||||
|
||||
test("InlineMarkdownEditor only preventDefaults markdown paste after a successful intercept guard", () => {
|
||||
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /shouldInterceptNoteMarkdownPaste/);
|
||||
assert.match(source, /resolveNoteClipboardPaste/);
|
||||
assert.match(source, /shouldInterceptResolvedNotePaste/);
|
||||
assert.match(source, /hasActiveLexicalTextSelection/);
|
||||
assert.match(source, /mergeNoteMarkdownDocumentPaste/);
|
||||
assert.match(source, /setMarkdown\(/);
|
||||
assert.match(source, /canInsertAtSelection/);
|
||||
assert.match(source, /resolveNoteMarkdownPasteStrategy/);
|
||||
assert.match(source, /text\/html/);
|
||||
assert.match(
|
||||
source,
|
||||
/shouldInterceptNoteMarkdownPaste\([\s\S]*?\)[\s\S]*?event\.preventDefault\(\)/,
|
||||
/shouldInterceptResolvedNotePaste\([\s\S]*?\)[\s\S]*?event\.preventDefault\(\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/if \(\s*!shouldInterceptNoteMarkdownPaste\([\s\S]*?\)\s*\{\s*return;\s*\}/,
|
||||
/if \(\s*!shouldInterceptResolvedNotePaste\([\s\S]*?\)\s*\{\s*return;\s*\}/,
|
||||
);
|
||||
// Live selection must keep insertMarkdown; document merge is the no-selection path only.
|
||||
assert.match(source, /if\s*\(\s*!canInsertAtSelection\s*\)\s*\{\s*applyDocumentPaste\(\);/);
|
||||
assert.match(source, /strategy === "document-merge"/);
|
||||
assert.match(source, /editor\.insertMarkdown\(markdown\)/);
|
||||
assert.match(source, /pasteRecoveryGenerationRef/);
|
||||
assert.match(source, /tryCommitSettledPaste/);
|
||||
assert.match(source, /editor\.getMarkdown\(\)/);
|
||||
// Selection-path recovery must not re-enter applyDocumentPaste (duplicate / stale-note risk).
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/insertMarkdown\(markdown\);[\s\S]*applyDocumentPaste\(\)/,
|
||||
);
|
||||
// Document merge must read live editor markdown, not only the possibly-stale ref.
|
||||
// With caret: settle failure must not blindly append (emptyDoc gate).
|
||||
assert.match(source, /if \(emptyDoc\) applyDocumentPaste\(\)/);
|
||||
assert.match(
|
||||
source,
|
||||
/const currentMarkdown = editor\.getMarkdown\(\);[\s\S]*mergeNoteMarkdownDocumentPaste\(currentMarkdown, markdown\)/,
|
||||
);
|
||||
// Do not re-queue insertMarkdown at the settle midpoint (double-insert risk).
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/attempt === Math\.floor\(maxAttempts \/ 2\)/,
|
||||
);
|
||||
// Non-empty settle failure must recover at the selection, not discard after preventDefault.
|
||||
assert.match(source, /recoverInterceptedPasteAtSelection/);
|
||||
assert.match(
|
||||
source,
|
||||
/if \(attempt >= maxAttempts\)[\s\S]*emptyDoc[\s\S]*applyDocumentPaste[\s\S]*recoverInterceptedPasteAtSelection/,
|
||||
);
|
||||
});
|
||||
@@ -8,13 +8,21 @@ import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Host,PortForwardingRule } from '../../domain/models';
|
||||
import { DistroAvatar } from '../DistroAvatar';
|
||||
import { TrafficDiagram } from '../TrafficDiagram';
|
||||
import { AsideActionMenu,AsideActionMenuItem,AsidePanel,AsidePanelContent,AsidePanelFooter } from '../ui/aside-panel';
|
||||
import {
|
||||
AsideActionMenu,
|
||||
AsideActionMenuItem,
|
||||
AsidePanel,
|
||||
AsidePanelContent,
|
||||
AsidePanelFooter,
|
||||
type AsidePanelLayout,
|
||||
type AsidePanelResizeProps,
|
||||
} from '../ui/aside-panel';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Switch } from '../ui/switch';
|
||||
|
||||
export interface EditPanelProps {
|
||||
export interface EditPanelProps extends AsidePanelResizeProps {
|
||||
rule: PortForwardingRule;
|
||||
draft: Partial<PortForwardingRule>;
|
||||
hosts: Host[];
|
||||
@@ -24,6 +32,7 @@ export interface EditPanelProps {
|
||||
onDuplicate: () => void;
|
||||
onDelete: () => void;
|
||||
onOpenHostSelector: () => void;
|
||||
layout?: AsidePanelLayout;
|
||||
}
|
||||
|
||||
export const EditPanel: React.FC<EditPanelProps> = ({
|
||||
@@ -36,6 +45,10 @@ export const EditPanel: React.FC<EditPanelProps> = ({
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
onOpenHostSelector,
|
||||
layout = 'inline',
|
||||
resizable = false,
|
||||
persistWidthStorageKey,
|
||||
resizeAriaLabel,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const selectedHost = hosts.find(h => h.id === draft.hostId);
|
||||
@@ -46,6 +59,10 @@ export const EditPanel: React.FC<EditPanelProps> = ({
|
||||
onClose={onClose}
|
||||
title={t('pf.wizard.editTitle')}
|
||||
width="w-[360px]"
|
||||
layout={layout}
|
||||
resizable={resizable}
|
||||
persistWidthStorageKey={persistWidthStorageKey}
|
||||
resizeAriaLabel={resizeAriaLabel}
|
||||
actions={
|
||||
<AsideActionMenu>
|
||||
<AsideActionMenuItem
|
||||
@@ -117,7 +134,7 @@ export const EditPanel: React.FC<EditPanelProps> = ({
|
||||
<DistroAvatar
|
||||
host={selectedHost}
|
||||
fallback={selectedHost.os[0].toUpperCase()}
|
||||
className="h-6 w-6"
|
||||
size="tree"
|
||||
/>
|
||||
<span>{selectedHost.label}</span>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,13 @@ import { Host,PortForwardingRule,PortForwardingType } from '../../domain/models'
|
||||
import { cn } from '../../lib/utils';
|
||||
import { DistroAvatar } from '../DistroAvatar';
|
||||
import { TrafficDiagram } from '../TrafficDiagram';
|
||||
import { AsidePanel,AsidePanelContent,AsidePanelFooter } from '../ui/aside-panel';
|
||||
import {
|
||||
AsidePanel,
|
||||
AsidePanelContent,
|
||||
AsidePanelFooter,
|
||||
type AsidePanelLayout,
|
||||
type AsidePanelResizeProps,
|
||||
} from '../ui/aside-panel';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
@@ -17,7 +23,7 @@ import { Label } from '../ui/label';
|
||||
import { Switch } from '../ui/switch';
|
||||
import { getTypeLabel } from './utils';
|
||||
|
||||
export interface NewFormPanelProps {
|
||||
export interface NewFormPanelProps extends AsidePanelResizeProps {
|
||||
draft: Partial<PortForwardingRule>;
|
||||
hosts: Host[];
|
||||
onDraftChange: (updates: Partial<PortForwardingRule>) => void;
|
||||
@@ -26,6 +32,7 @@ export interface NewFormPanelProps {
|
||||
onOpenHostSelector: () => void;
|
||||
onOpenWizard: () => void;
|
||||
isValid: boolean;
|
||||
layout?: AsidePanelLayout;
|
||||
}
|
||||
|
||||
export const NewFormPanel: React.FC<NewFormPanelProps> = ({
|
||||
@@ -37,6 +44,10 @@ export const NewFormPanel: React.FC<NewFormPanelProps> = ({
|
||||
onOpenHostSelector,
|
||||
onOpenWizard,
|
||||
isValid,
|
||||
layout = 'inline',
|
||||
resizable = false,
|
||||
persistWidthStorageKey,
|
||||
resizeAriaLabel,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const selectedHost = hosts.find(h => h.id === draft.hostId);
|
||||
@@ -47,6 +58,10 @@ export const NewFormPanel: React.FC<NewFormPanelProps> = ({
|
||||
onClose={onClose}
|
||||
title={t('pf.wizard.newTitle')}
|
||||
width="w-[360px]"
|
||||
layout={layout}
|
||||
resizable={resizable}
|
||||
persistWidthStorageKey={persistWidthStorageKey}
|
||||
resizeAriaLabel={resizeAriaLabel}
|
||||
>
|
||||
<AsidePanelContent>
|
||||
{/* Type Selector */}
|
||||
@@ -119,7 +134,7 @@ export const NewFormPanel: React.FC<NewFormPanelProps> = ({
|
||||
<DistroAvatar
|
||||
host={selectedHost}
|
||||
fallback={selectedHost.os[0].toUpperCase()}
|
||||
className="h-6 w-6"
|
||||
size="tree"
|
||||
/>
|
||||
<span>{selectedHost.label}</span>
|
||||
</div>
|
||||
|
||||
@@ -210,7 +210,8 @@ export const SettingRow: React.FC<SettingRowProps> = ({
|
||||
id={anchorId ? settingsAnchorDomId(anchorId) : undefined}
|
||||
data-settings-anchor={anchorId}
|
||||
className={cn(
|
||||
"flex justify-between py-3 gap-4 rounded-md",
|
||||
// Keep square corners: rounded rows bend divide-y separators at both ends.
|
||||
"flex justify-between py-3 gap-4",
|
||||
align === "start" ? "items-start" : "items-center",
|
||||
)}
|
||||
>
|
||||
@@ -233,7 +234,8 @@ export const SettingsAnchor: React.FC<{
|
||||
<div
|
||||
id={settingsAnchorDomId(anchorId)}
|
||||
data-settings-anchor={anchorId}
|
||||
className={cn("rounded-md", className)}
|
||||
// No default rounded-md: inside divide-y cards, child border-radius bends separators.
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
@@ -244,7 +246,11 @@ export const SettingsTabContent: React.FC<{
|
||||
children: React.ReactNode;
|
||||
}> = ({ value, children }) => (
|
||||
<TabsContent value={value} className="flex-1 m-0 h-full overflow-hidden">
|
||||
<div className="h-full overflow-y-auto overflow-x-hidden">
|
||||
{/* data-settings-scroll-pane: search jump scrolls only this pane, not the window */}
|
||||
<div
|
||||
data-settings-scroll-pane
|
||||
className="h-full overflow-y-auto overflow-x-hidden"
|
||||
>
|
||||
<div className="p-6 space-y-6">{children}</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
test("settings search focus scrolls the content pane, not the window viewport", () => {
|
||||
const source = readFileSync(new URL("./settingsFocus.ts", import.meta.url), "utf8");
|
||||
const uiSource = readFileSync(new URL("./settings-ui.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /scrollSettingsAnchorIntoView/);
|
||||
assert.match(source, /findSettingsScrollContainer/);
|
||||
assert.match(source, /data-settings-scroll-pane/);
|
||||
assert.match(source, /preventScroll:\s*true/);
|
||||
// Viewport centering lifts the whole settings window under macOS traffic lights.
|
||||
assert.doesNotMatch(source, /block:\s*["']center["']/);
|
||||
assert.match(uiSource, /data-settings-scroll-pane/);
|
||||
});
|
||||
|
||||
test("scrollSettingsAnchorIntoView prefers marked pane over document scroll", async () => {
|
||||
const { scrollSettingsAnchorIntoView } = await import("./settingsFocus.ts");
|
||||
|
||||
const calls: Array<{ top: number; behavior?: ScrollBehavior }> = [];
|
||||
const scroller = {
|
||||
scrollHeight: 2000,
|
||||
clientHeight: 400,
|
||||
scrollTop: 0,
|
||||
getBoundingClientRect: () => ({
|
||||
top: 100,
|
||||
left: 0,
|
||||
bottom: 500,
|
||||
right: 400,
|
||||
width: 400,
|
||||
height: 400,
|
||||
x: 0,
|
||||
y: 100,
|
||||
toJSON() {
|
||||
return this;
|
||||
},
|
||||
}),
|
||||
scrollTo(opts: { top: number; behavior?: ScrollBehavior }) {
|
||||
calls.push(opts);
|
||||
this.scrollTop = opts.top;
|
||||
},
|
||||
} as unknown as HTMLElement;
|
||||
|
||||
const anchor = {
|
||||
getBoundingClientRect: () => ({
|
||||
top: 700,
|
||||
left: 0,
|
||||
bottom: 760,
|
||||
right: 200,
|
||||
width: 200,
|
||||
height: 60,
|
||||
x: 0,
|
||||
y: 700,
|
||||
toJSON() {
|
||||
return this;
|
||||
},
|
||||
}),
|
||||
closest(selector: string) {
|
||||
if (selector === "[data-settings-scroll-pane]") return scroller;
|
||||
return null;
|
||||
},
|
||||
parentElement: scroller,
|
||||
} as unknown as HTMLElement;
|
||||
|
||||
scrollSettingsAnchorIntoView(anchor, "auto");
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
// elTopInScroller = 700 - 100 + 0 = 600; minus 24 padding → 576
|
||||
assert.equal(calls[0].top, 576);
|
||||
assert.equal(calls[0].behavior, "auto");
|
||||
});
|
||||
@@ -2,6 +2,8 @@ import { settingsAnchorDomId } from "../../domain/settingsSearchCatalog";
|
||||
|
||||
const HIGHLIGHT_CLASS = "settings-search-highlight";
|
||||
const HIGHLIGHT_MS = 1600;
|
||||
/** Keep focused anchors slightly below the top of the settings content pane. */
|
||||
const SCROLL_TOP_PADDING_PX = 24;
|
||||
|
||||
export type SettingsFocusTarget = {
|
||||
tab: string;
|
||||
@@ -39,12 +41,65 @@ function focusAnchorElement(el: HTMLElement): void {
|
||||
target.tabIndex = -1;
|
||||
}
|
||||
try {
|
||||
// preventScroll: we own scrolling so the settings titlebar / traffic-light
|
||||
// chrome never rides along with a viewport-level scrollIntoView.
|
||||
target.focus({ preventScroll: true });
|
||||
} catch {
|
||||
// ignore focus failures in non-interactive hosts
|
||||
}
|
||||
}
|
||||
|
||||
function isVerticallyScrollable(el: HTMLElement): boolean {
|
||||
const { overflowY } = window.getComputedStyle(el);
|
||||
if (overflowY !== "auto" && overflowY !== "scroll" && overflowY !== "overlay") {
|
||||
return false;
|
||||
}
|
||||
return el.scrollHeight > el.clientHeight + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer the dedicated settings content scroller; never fall back to
|
||||
* document/body scrolling (that lifts the whole window under macOS traffic lights).
|
||||
*/
|
||||
export function findSettingsScrollContainer(el: HTMLElement): HTMLElement | null {
|
||||
// Trust the marked pane even when content is shorter than the viewport —
|
||||
// search jumps must still scroll this host, not the window.
|
||||
const marked = el.closest<HTMLElement>("[data-settings-scroll-pane]");
|
||||
if (marked) return marked;
|
||||
|
||||
let node: HTMLElement | null = el.parentElement;
|
||||
while (node && node !== document.documentElement && node !== document.body) {
|
||||
if (isVerticallyScrollable(node)) return node;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll only within the settings content pane so the window chrome stays put.
|
||||
*/
|
||||
export function scrollSettingsAnchorIntoView(
|
||||
el: HTMLElement,
|
||||
behavior: ScrollBehavior = "smooth",
|
||||
): void {
|
||||
const scroller = findSettingsScrollContainer(el);
|
||||
if (!scroller) {
|
||||
// Nearest-only, never "center" — avoids yanking the whole settings window.
|
||||
el.scrollIntoView({ behavior, block: "nearest", inline: "nearest" });
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollerRect = scroller.getBoundingClientRect();
|
||||
const elRect = el.getBoundingClientRect();
|
||||
const elTopInScroller = elRect.top - scrollerRect.top + scroller.scrollTop;
|
||||
const targetTop = Math.max(0, elTopInScroller - SCROLL_TOP_PADDING_PX);
|
||||
const maxScroll = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
|
||||
scroller.scrollTo({
|
||||
top: Math.min(targetTop, maxScroll),
|
||||
behavior,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll the settings content pane to a catalog anchor and briefly highlight it.
|
||||
* Retries to cover lazy tab mounts / nested sub-tab switches.
|
||||
@@ -91,7 +146,7 @@ export function focusSettingsAnchor(
|
||||
return;
|
||||
}
|
||||
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
scrollSettingsAnchorIntoView(el, "smooth");
|
||||
el.classList.remove(HIGHLIGHT_CLASS);
|
||||
void el.offsetWidth;
|
||||
el.classList.add(HIGHLIGHT_CLASS);
|
||||
|
||||
@@ -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" && (
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { getSftpBreadcrumbSegments } from "../../application/state/sftp/utils.ts";
|
||||
import {
|
||||
normalizeSftpBreadcrumbMaxVisibleParts,
|
||||
resolveSftpBreadcrumbVisibleParts,
|
||||
scrollSftpBreadcrumbViewportToTail,
|
||||
shouldShowSftpBreadcrumbEllipsis,
|
||||
splitSftpBreadcrumbPinnedParts,
|
||||
} from "./SftpBreadcrumb.tsx";
|
||||
|
||||
const breadcrumbSource = fs.readFileSync(
|
||||
path.join(path.dirname(fileURLToPath(import.meta.url)), "SftpBreadcrumb.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
test("deep unix paths keep the first segment and trailing segments", () => {
|
||||
const { segments } = getSftpBreadcrumbSegments(
|
||||
"/var/www/apps/netcatty/releases/current/public",
|
||||
);
|
||||
const resolved = resolveSftpBreadcrumbVisibleParts({
|
||||
segments,
|
||||
maxVisibleParts: 4,
|
||||
});
|
||||
|
||||
assert.equal(resolved.needsTruncation, true);
|
||||
assert.deepEqual(
|
||||
resolved.visibleParts.map((part) => part.segment.label),
|
||||
["var", "releases", "current", "public"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolved.hiddenParts.map((part) => part.segment.label),
|
||||
["www", "apps", "netcatty"],
|
||||
);
|
||||
|
||||
const split = splitSftpBreadcrumbPinnedParts(resolved.visibleParts);
|
||||
assert.equal(split.leadingPart?.segment.label, "var");
|
||||
assert.deepEqual(
|
||||
split.trailingParts.map((part) => part.segment.label),
|
||||
["releases", "current", "public"],
|
||||
);
|
||||
});
|
||||
|
||||
test("windows drive paths keep the drive letter while preferring the tail", () => {
|
||||
const { segments } = getSftpBreadcrumbSegments(
|
||||
"C:\\Users\\alice\\projects\\netcatty\\src\\components",
|
||||
);
|
||||
const resolved = resolveSftpBreadcrumbVisibleParts({
|
||||
segments,
|
||||
maxVisibleParts: 4,
|
||||
});
|
||||
|
||||
assert.equal(resolved.needsTruncation, true);
|
||||
assert.equal(resolved.visibleParts[0]?.segment.label, "C:");
|
||||
assert.deepEqual(
|
||||
resolved.visibleParts.slice(1).map((part) => part.segment.label),
|
||||
["netcatty", "src", "components"],
|
||||
);
|
||||
});
|
||||
|
||||
test("windows UNC paths keep the share root while preferring the tail", () => {
|
||||
const { segments } = getSftpBreadcrumbSegments(
|
||||
"\\\\wsl.localhost\\Ubuntu-22.04\\home\\alice\\projects\\netcatty\\src",
|
||||
);
|
||||
const resolved = resolveSftpBreadcrumbVisibleParts({
|
||||
segments,
|
||||
maxVisibleParts: 4,
|
||||
});
|
||||
|
||||
assert.equal(resolved.needsTruncation, true);
|
||||
assert.equal(
|
||||
resolved.visibleParts[0]?.segment.label,
|
||||
"\\\\wsl.localhost\\Ubuntu-22.04",
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolved.visibleParts.slice(1).map((part) => part.segment.label),
|
||||
["projects", "netcatty", "src"],
|
||||
);
|
||||
});
|
||||
|
||||
test("budget of one keeps the leading root and still exposes hidden segments via ellipsis", () => {
|
||||
assert.equal(normalizeSftpBreadcrumbMaxVisibleParts(0), 1);
|
||||
assert.equal(normalizeSftpBreadcrumbMaxVisibleParts(1.9), 1);
|
||||
|
||||
const { segments } = getSftpBreadcrumbSegments(
|
||||
"C:\\Users\\alice\\projects\\netcatty\\src",
|
||||
);
|
||||
const resolved = resolveSftpBreadcrumbVisibleParts({
|
||||
segments,
|
||||
maxVisibleParts: 1,
|
||||
});
|
||||
|
||||
assert.equal(resolved.needsTruncation, true);
|
||||
assert.deepEqual(
|
||||
resolved.visibleParts.map((part) => part.segment.label),
|
||||
["C:"],
|
||||
);
|
||||
assert.ok(resolved.hiddenParts.length >= 3);
|
||||
assert.equal(
|
||||
shouldShowSftpBreadcrumbEllipsis({
|
||||
needsTruncation: resolved.needsTruncation,
|
||||
hiddenPartsCount: resolved.hiddenParts.length,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(splitSftpBreadcrumbPinnedParts(resolved.visibleParts).trailingParts, []);
|
||||
});
|
||||
|
||||
test("breadcrumb pins leading chrome and only scrolls trailing chips", () => {
|
||||
assert.doesNotMatch(breadcrumbSource, /dir="rtl"/);
|
||||
assert.match(breadcrumbSource, /splitSftpBreadcrumbPinnedParts/);
|
||||
assert.match(breadcrumbSource, /shouldShowSftpBreadcrumbEllipsis/);
|
||||
assert.match(breadcrumbSource, /scrollSftpBreadcrumbViewportToTail/);
|
||||
assert.match(breadcrumbSource, /shrink-0/);
|
||||
assert.match(breadcrumbSource, /flex-1 overflow-hidden/);
|
||||
|
||||
const calls: Array<{ left: number }> = [];
|
||||
const viewport = {
|
||||
scrollWidth: 400,
|
||||
clientWidth: 120,
|
||||
set scrollLeft(value: number) {
|
||||
calls.push({ left: value });
|
||||
},
|
||||
get scrollLeft() {
|
||||
return calls.at(-1)?.left ?? 0;
|
||||
},
|
||||
} as HTMLElement;
|
||||
|
||||
scrollSftpBreadcrumbViewportToTail(viewport);
|
||||
assert.deepEqual(calls, [{ left: 280 }]);
|
||||
|
||||
const shortCalls: number[] = [];
|
||||
scrollSftpBreadcrumbViewportToTail({
|
||||
scrollWidth: 100,
|
||||
clientWidth: 120,
|
||||
set scrollLeft(value: number) {
|
||||
shortCalls.push(value);
|
||||
},
|
||||
get scrollLeft() {
|
||||
return shortCalls.at(-1) ?? 0;
|
||||
},
|
||||
} as HTMLElement);
|
||||
assert.deepEqual(shortCalls, [0]);
|
||||
});
|
||||
+237
-103
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { ChevronDown, ChevronRight, Home, MoreHorizontal } from 'lucide-react';
|
||||
import React, { memo, useCallback, useMemo, useState } from 'react';
|
||||
import React, { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { getSftpBreadcrumbSegments } from '../../application/state/sftp/utils';
|
||||
import type { SftpWindowsPathOptions } from '../../application/state/sftp/utils';
|
||||
@@ -23,6 +23,102 @@ interface SftpBreadcrumbProps {
|
||||
acceptForwardSlashUnc?: boolean;
|
||||
}
|
||||
|
||||
type BreadcrumbSegment = ReturnType<typeof getSftpBreadcrumbSegments>['segments'][number];
|
||||
|
||||
export type SftpBreadcrumbVisiblePart = {
|
||||
segment: BreadcrumbSegment;
|
||||
originalIndex: number;
|
||||
};
|
||||
|
||||
/** Clamp the visible-segment budget to a positive integer. */
|
||||
export function normalizeSftpBreadcrumbMaxVisibleParts(maxVisibleParts: number): number {
|
||||
if (!Number.isFinite(maxVisibleParts)) return 1;
|
||||
return Math.max(1, Math.floor(maxVisibleParts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer the path tail when truncating, but always keep the first segment so the
|
||||
* root / drive / UNC share stays clickable. Budget of 1 shows only the first segment.
|
||||
*/
|
||||
export function resolveSftpBreadcrumbVisibleParts({
|
||||
segments,
|
||||
maxVisibleParts,
|
||||
}: {
|
||||
segments: BreadcrumbSegment[];
|
||||
maxVisibleParts: number;
|
||||
}): {
|
||||
visibleParts: SftpBreadcrumbVisiblePart[];
|
||||
hiddenParts: SftpBreadcrumbVisiblePart[];
|
||||
needsTruncation: boolean;
|
||||
} {
|
||||
const budget = normalizeSftpBreadcrumbMaxVisibleParts(maxVisibleParts);
|
||||
if (segments.length <= budget) {
|
||||
return {
|
||||
visibleParts: segments.map((segment, idx) => ({ segment, originalIndex: idx })),
|
||||
hiddenParts: [],
|
||||
needsTruncation: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (budget === 1) {
|
||||
return {
|
||||
visibleParts: [{ segment: segments[0], originalIndex: 0 }],
|
||||
hiddenParts: segments.slice(1).map((segment, idx) => ({
|
||||
segment,
|
||||
originalIndex: idx + 1,
|
||||
})),
|
||||
needsTruncation: true,
|
||||
};
|
||||
}
|
||||
|
||||
const lastPartsCount = budget - 1;
|
||||
const lastParts = segments.slice(-lastPartsCount).map((segment, idx) => ({
|
||||
segment,
|
||||
originalIndex: segments.length - lastPartsCount + idx,
|
||||
}));
|
||||
const hiddenParts = segments.slice(1, -lastPartsCount).map((segment, idx) => ({
|
||||
segment,
|
||||
originalIndex: idx + 1,
|
||||
}));
|
||||
|
||||
return {
|
||||
visibleParts: [{ segment: segments[0], originalIndex: 0 }, ...lastParts],
|
||||
hiddenParts,
|
||||
needsTruncation: true,
|
||||
};
|
||||
}
|
||||
|
||||
/** Split pinned leading chrome from the scrollable trailing chips. */
|
||||
export function splitSftpBreadcrumbPinnedParts(visibleParts: SftpBreadcrumbVisiblePart[]): {
|
||||
leadingPart: SftpBreadcrumbVisiblePart | null;
|
||||
trailingParts: SftpBreadcrumbVisiblePart[];
|
||||
} {
|
||||
if (visibleParts.length === 0) {
|
||||
return { leadingPart: null, trailingParts: [] };
|
||||
}
|
||||
return {
|
||||
leadingPart: visibleParts[0],
|
||||
trailingParts: visibleParts.slice(1),
|
||||
};
|
||||
}
|
||||
|
||||
/** True when truncated middle segments need an ellipsis affordance. */
|
||||
export function shouldShowSftpBreadcrumbEllipsis({
|
||||
needsTruncation,
|
||||
hiddenPartsCount,
|
||||
}: {
|
||||
needsTruncation: boolean;
|
||||
hiddenPartsCount: number;
|
||||
}): boolean {
|
||||
return needsTruncation && hiddenPartsCount > 0;
|
||||
}
|
||||
|
||||
/** Scroll a breadcrumb viewport so overflow keeps the trailing path visible. */
|
||||
export function scrollSftpBreadcrumbViewportToTail(viewport: HTMLElement | null): void {
|
||||
if (!viewport) return;
|
||||
viewport.scrollLeft = Math.max(0, viewport.scrollWidth - viewport.clientWidth);
|
||||
}
|
||||
|
||||
const SftpBreadcrumbInner: React.FC<SftpBreadcrumbProps> = ({
|
||||
path,
|
||||
onNavigate,
|
||||
@@ -36,6 +132,8 @@ const SftpBreadcrumbInner: React.FC<SftpBreadcrumbProps> = ({
|
||||
|
||||
const [drives, setDrives] = useState<string[]>([]);
|
||||
const [driveDropdownOpen, setDriveDropdownOpen] = useState(false);
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleDriveDropdownOpen = useCallback(async (open: boolean) => {
|
||||
setDriveDropdownOpen(open);
|
||||
@@ -55,118 +153,154 @@ const SftpBreadcrumbInner: React.FC<SftpBreadcrumbProps> = ({
|
||||
[path, pathOptions],
|
||||
);
|
||||
|
||||
// Determine which parts to show (always truncate, no expansion)
|
||||
const { visibleParts, hiddenParts, needsTruncation } = useMemo(() => {
|
||||
if (segments.length <= maxVisibleParts) {
|
||||
return {
|
||||
visibleParts: segments.map((segment, idx) => ({ segment, originalIndex: idx })),
|
||||
hiddenParts: [] as { segment: (typeof segments)[number]; originalIndex: number }[],
|
||||
needsTruncation: false,
|
||||
};
|
||||
}
|
||||
const { visibleParts, hiddenParts, needsTruncation } = useMemo(
|
||||
() =>
|
||||
resolveSftpBreadcrumbVisibleParts({
|
||||
segments,
|
||||
maxVisibleParts,
|
||||
}),
|
||||
[segments, maxVisibleParts],
|
||||
);
|
||||
|
||||
// Show first part + ellipsis + last (maxVisibleParts - 1) parts
|
||||
const firstPart = [{ segment: segments[0], originalIndex: 0 }];
|
||||
const lastPartsCount = maxVisibleParts - 1;
|
||||
const lastParts = segments.slice(-lastPartsCount).map((segment, idx) => ({
|
||||
segment,
|
||||
originalIndex: segments.length - lastPartsCount + idx,
|
||||
}));
|
||||
const hidden = segments.slice(1, -lastPartsCount).map((segment, idx) => ({
|
||||
segment,
|
||||
originalIndex: idx + 1,
|
||||
}));
|
||||
const { leadingPart, trailingParts } = useMemo(
|
||||
() => splitSftpBreadcrumbPinnedParts(visibleParts),
|
||||
[visibleParts],
|
||||
);
|
||||
|
||||
return {
|
||||
visibleParts: [...firstPart, ...lastParts],
|
||||
hiddenParts: hidden,
|
||||
needsTruncation: true,
|
||||
};
|
||||
}, [segments, maxVisibleParts]);
|
||||
const showEllipsis = shouldShowSftpBreadcrumbEllipsis({
|
||||
needsTruncation,
|
||||
hiddenPartsCount: hiddenParts.length,
|
||||
});
|
||||
|
||||
const syncTailScroll = useCallback(() => {
|
||||
scrollSftpBreadcrumbViewportToTail(viewportRef.current);
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
syncTailScroll();
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport || typeof ResizeObserver === 'undefined') return;
|
||||
const ro = new ResizeObserver(() => syncTailScroll());
|
||||
ro.observe(viewport);
|
||||
const track = trackRef.current;
|
||||
if (track) ro.observe(track);
|
||||
return () => ro.disconnect();
|
||||
}, [syncTailScroll, path, trailingParts, showEllipsis]);
|
||||
|
||||
const showDriveDropdown = isWindowsDrive && isLocal && !!onListDrives;
|
||||
|
||||
const renderSegmentButton = (
|
||||
part: SftpBreadcrumbVisiblePart,
|
||||
{ showTrailingChevron }: { showTrailingChevron: boolean },
|
||||
) => {
|
||||
const { segment, originalIndex } = part;
|
||||
const isLast = originalIndex === segments.length - 1;
|
||||
const node = originalIndex === 0 && showDriveDropdown ? (
|
||||
<Dropdown open={driveDropdownOpen} onOpenChange={handleDriveDropdownOpen}>
|
||||
<DropdownTrigger asChild>
|
||||
<button className="hover:text-foreground px-1 py-0.5 rounded hover:bg-secondary/60 shrink-0 flex items-center gap-0.5">
|
||||
{segment.label}
|
||||
<ChevronDown size={10} className="opacity-60" />
|
||||
</button>
|
||||
</DropdownTrigger>
|
||||
<DropdownContent align="start" className="w-16 p-1">
|
||||
{drives.map(drive => (
|
||||
<button
|
||||
key={drive}
|
||||
onClick={() => { onNavigate(drive + '\\'); setDriveDropdownOpen(false); }}
|
||||
className={cn(
|
||||
"w-full text-left px-2 py-1 text-xs rounded hover:bg-secondary/60",
|
||||
drive === segment.label && "bg-secondary font-medium"
|
||||
)}
|
||||
>
|
||||
{drive}
|
||||
</button>
|
||||
))}
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => onNavigate(segment.path)}
|
||||
className={cn(
|
||||
"hover:text-foreground px-1 py-0.5 rounded hover:bg-secondary/60 truncate max-w-[160px] shrink-0",
|
||||
isLast && "text-foreground font-medium"
|
||||
)}
|
||||
>
|
||||
{segment.label}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{segment.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
return (
|
||||
<React.Fragment key={segment.path}>
|
||||
{node}
|
||||
{showTrailingChevron && <ChevronRight size={12} className="opacity-40 shrink-0" />}
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
// Pin Home + leading root + ellipsis outside the scrollport so narrow panes
|
||||
// can still navigate prefixes while the trailing chips scroll toward the end.
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground overflow-hidden cursor-default">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onHome}
|
||||
className="hover:text-foreground p-1 rounded hover:bg-secondary/60 shrink-0"
|
||||
>
|
||||
<Home size={12} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.goHome")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<ChevronRight size={12} className="opacity-40 shrink-0" />
|
||||
{visibleParts.map(({ segment, originalIndex }, displayIdx) => {
|
||||
const partPath = segment.path;
|
||||
const isLast = originalIndex === segments.length - 1;
|
||||
const showEllipsisBefore = needsTruncation && displayIdx === 1;
|
||||
<div className="flex w-full min-w-0 items-center gap-1 text-xs text-muted-foreground cursor-default">
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onHome}
|
||||
className="hover:text-foreground p-1 rounded hover:bg-secondary/60 shrink-0"
|
||||
>
|
||||
<Home size={12} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.goHome")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<ChevronRight size={12} className="opacity-40 shrink-0" />
|
||||
{leadingPart && renderSegmentButton(leadingPart, {
|
||||
showTrailingChevron: showEllipsis || trailingParts.length > 0,
|
||||
})}
|
||||
{showEllipsis && (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="px-1 py-0.5 shrink-0 flex items-center text-muted-foreground cursor-default">
|
||||
<MoreHorizontal size={14} />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{`${t("sftp.showHiddenPaths")}: ${hiddenParts.map(h => h.segment.label).join(' > ')}`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{trailingParts.length > 0 && (
|
||||
<ChevronRight size={12} className="opacity-40 shrink-0" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
return (
|
||||
<React.Fragment key={partPath}>
|
||||
{showEllipsisBefore && (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="px-1 py-0.5 shrink-0 flex items-center text-muted-foreground cursor-default">
|
||||
<MoreHorizontal size={14} />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{`${t("sftp.showHiddenPaths")}: ${hiddenParts.map(h => h.segment.label).join(' > ')}`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<ChevronRight size={12} className="opacity-40 shrink-0" />
|
||||
</>
|
||||
{trailingParts.length > 0 && (
|
||||
<div
|
||||
ref={viewportRef}
|
||||
className="min-w-0 flex-1 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
ref={trackRef}
|
||||
className="flex w-max max-w-none items-center gap-1"
|
||||
>
|
||||
{trailingParts.map((part, idx) =>
|
||||
renderSegmentButton(part, {
|
||||
showTrailingChevron: idx < trailingParts.length - 1,
|
||||
}),
|
||||
)}
|
||||
{originalIndex === 0 && showDriveDropdown ? (
|
||||
<Dropdown open={driveDropdownOpen} onOpenChange={handleDriveDropdownOpen}>
|
||||
<DropdownTrigger asChild>
|
||||
<button className="hover:text-foreground px-1 py-0.5 rounded hover:bg-secondary/60 shrink-0 flex items-center gap-0.5">
|
||||
{segment.label}
|
||||
<ChevronDown size={10} className="opacity-60" />
|
||||
</button>
|
||||
</DropdownTrigger>
|
||||
<DropdownContent align="start" className="w-16 p-1">
|
||||
{drives.map(drive => (
|
||||
<button
|
||||
key={drive}
|
||||
onClick={() => { onNavigate(drive + '\\'); setDriveDropdownOpen(false); }}
|
||||
className={cn(
|
||||
"w-full text-left px-2 py-1 text-xs rounded hover:bg-secondary/60",
|
||||
drive === segment.label && "bg-secondary font-medium"
|
||||
)}
|
||||
>
|
||||
{drive}
|
||||
</button>
|
||||
))}
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => onNavigate(partPath)}
|
||||
className={cn(
|
||||
"hover:text-foreground px-1 py-0.5 rounded hover:bg-secondary/60 truncate max-w-[120px] shrink-0",
|
||||
isLast && "text-foreground font-medium"
|
||||
)}
|
||||
>
|
||||
{segment.label}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{segment.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!isLast && <ChevronRight size={12} className="opacity-40 shrink-0" />}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{path}</TooltipContent>
|
||||
|
||||
@@ -50,7 +50,7 @@ test("sftp host picker uses single-line quick switcher row layout", () => {
|
||||
assert.doesNotMatch(source, /text-xs text-muted-foreground truncate/);
|
||||
});
|
||||
|
||||
test("sftp host picker omits sourceSessionId for sudo connected hosts", () => {
|
||||
test("sftp host picker passes sourceSessionId for sudo connected hosts", () => {
|
||||
assert.match(source, /sftpSourceSessionIdForHost/);
|
||||
assert.match(
|
||||
source,
|
||||
@@ -60,11 +60,6 @@ test("sftp host picker omits sourceSessionId for sudo connected hosts", () => {
|
||||
source,
|
||||
/onSelectHost\(\s*item\.entry\.host,\s*sourceSessionId \? \{ sourceSessionId \} : undefined,\s*\)/,
|
||||
);
|
||||
// Must not always pass sessionId as a reuse hint (sudo would hide the spinner).
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/onSelectHost\(item\.entry\.host, \{ sourceSessionId: item\.entry\.sessionId \}\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("sftp host picker virtualizes the host list", () => {
|
||||
|
||||
@@ -460,6 +460,23 @@ test("SFTP filter clears the IME composing guard and resyncs the draft when the
|
||||
);
|
||||
});
|
||||
|
||||
test("toolbar keeps path chrome on its own row above the action controls", () => {
|
||||
assert.match(toolbarSource, /data-section="terminal-sftp-actions"/);
|
||||
assert.match(toolbarSource, /data-section="terminal-sftp-path-row"/);
|
||||
assert.match(
|
||||
toolbarSource,
|
||||
/data-section="terminal-sftp-path-row"[\s\S]*data-section="terminal-sftp-actions"/,
|
||||
);
|
||||
assert.match(
|
||||
toolbarSource,
|
||||
/className="ml-auto shrink-0" data-section="terminal-sftp-overflow"/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
toolbarSource,
|
||||
/data-section="terminal-sftp-toolbar"[\s\S]*className="h-7 px-2 flex items-center gap-1 border-b/,
|
||||
);
|
||||
});
|
||||
|
||||
test("toolbar display path keeps the previous confirmed path while loading the same connection", () => {
|
||||
assert.equal(
|
||||
getNextSftpToolbarDisplayPath({
|
||||
|
||||
@@ -62,8 +62,8 @@ export const SFTP_TOOLBAR_LAYOUT_DEFAULTS: ToolbarItemLayoutDefaults = {
|
||||
};
|
||||
|
||||
/**
|
||||
* When the toolbar is narrow, keep these user-shown actions inline so the path
|
||||
* still has room. Other user-shown actions temporarily join the ⋮ list.
|
||||
* When the action row is narrow, keep these user-shown actions inline.
|
||||
* Other user-shown actions temporarily join the ⋮ list.
|
||||
* User "hide" / permanent "collapse" are always respected (never re-shown).
|
||||
*/
|
||||
export const SFTP_TOOLBAR_NARROW_INLINE_IDS = new Set<SftpToolbarItemId>([
|
||||
@@ -76,7 +76,7 @@ export const SFTP_TOOLBAR_NARROW_INLINE_IDS = new Set<SftpToolbarItemId>([
|
||||
"filter",
|
||||
]);
|
||||
|
||||
/** Prioritize path space; same threshold as the pre-customize toolbar. */
|
||||
/** Spill non-pinned actions into ⋮ when the action row is this narrow. */
|
||||
export const SFTP_TOOLBAR_NARROW_WIDTH = 400;
|
||||
|
||||
/** Apply user placement, then optional narrow-width temporary spill of show → overflow. */
|
||||
@@ -970,87 +970,97 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
|
||||
|
||||
const overflowNodes = overflowIds.map(renderCollapsed).filter(Boolean);
|
||||
|
||||
const pathEditor = isEditingPath ? (
|
||||
<div className="relative w-full min-w-0" data-section="terminal-sftp-path">
|
||||
<Input
|
||||
ref={pathInputRef}
|
||||
value={editingPathValue}
|
||||
onChange={(e) => {
|
||||
setEditingPathValue(e.target.value);
|
||||
setShowPathSuggestions(true);
|
||||
setPathSuggestionIndex(-1);
|
||||
}}
|
||||
onBlur={handlePathBlur}
|
||||
onKeyDown={handlePathKeyDown}
|
||||
onFocus={() => setShowPathSuggestions(true)}
|
||||
className="h-5 w-full text-[10px] bg-background"
|
||||
autoFocus
|
||||
/>
|
||||
{showPathSuggestions && pathSuggestions.length > 0 && (
|
||||
<div
|
||||
ref={pathDropdownRef}
|
||||
className="absolute top-full left-0 right-0 mt-1 bg-popover border border-border rounded-md shadow-lg z-50 max-h-48 overflow-auto"
|
||||
>
|
||||
{pathSuggestions.map((suggestion, idx) => (
|
||||
<button
|
||||
key={suggestion.path}
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full px-3 py-2 text-left text-xs flex items-center gap-2 hover:bg-secondary/60 transition-colors",
|
||||
idx === pathSuggestionIndex && "bg-secondary/80",
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
handlePathSubmit(suggestion.path);
|
||||
}}
|
||||
>
|
||||
{suggestion.type === "folder" ? (
|
||||
<Folder size={12} className="text-primary shrink-0" />
|
||||
) : (
|
||||
<Home size={12} className="text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<span className="truncate font-mono">{suggestion.path}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="w-full min-w-0 cursor-text hover:bg-secondary/50 rounded px-1 transition-colors"
|
||||
data-section="terminal-sftp-path"
|
||||
onDoubleClick={handlePathDoubleClick}
|
||||
>
|
||||
<SftpBreadcrumb
|
||||
path={displayPath}
|
||||
onNavigate={onNavigateTo}
|
||||
onHome={() =>
|
||||
pane.connection?.homeDir && onNavigateTo(pane.connection.homeDir)
|
||||
}
|
||||
isLocal={!isRemote}
|
||||
onListDrives={onListDrives}
|
||||
acceptForwardSlashUnc={
|
||||
isWindowsPath(displayPath)
|
||||
|| isWindowsPath(pane.connection?.homeDir ?? "")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.path.doubleClickToEdit")}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={500} skipDelayDuration={100} disableHoverableContent>
|
||||
{/* Path chrome stays outside customize so path right-click keeps native/browser menus. */}
|
||||
<div
|
||||
ref={outerRef}
|
||||
className="h-7 px-2 flex items-center gap-1 border-b border-border/40 bg-secondary/20"
|
||||
className="flex flex-col border-b border-border/40 bg-secondary/20"
|
||||
data-section="terminal-sftp-toolbar"
|
||||
>
|
||||
{/* Editable Breadcrumb with autocomplete */}
|
||||
{isEditingPath ? (
|
||||
<div className="relative flex-1 min-w-0" data-section="terminal-sftp-path">
|
||||
<Input
|
||||
ref={pathInputRef}
|
||||
value={editingPathValue}
|
||||
onChange={(e) => {
|
||||
setEditingPathValue(e.target.value);
|
||||
setShowPathSuggestions(true);
|
||||
setPathSuggestionIndex(-1);
|
||||
}}
|
||||
onBlur={handlePathBlur}
|
||||
onKeyDown={handlePathKeyDown}
|
||||
onFocus={() => setShowPathSuggestions(true)}
|
||||
className="h-5 w-full text-[10px] bg-background"
|
||||
autoFocus
|
||||
/>
|
||||
{showPathSuggestions && pathSuggestions.length > 0 && (
|
||||
<div
|
||||
ref={pathDropdownRef}
|
||||
className="absolute top-full left-0 right-0 mt-1 bg-popover border border-border rounded-md shadow-lg z-50 max-h-48 overflow-auto"
|
||||
>
|
||||
{pathSuggestions.map((suggestion, idx) => (
|
||||
<button
|
||||
key={suggestion.path}
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full px-3 py-2 text-left text-xs flex items-center gap-2 hover:bg-secondary/60 transition-colors",
|
||||
idx === pathSuggestionIndex && "bg-secondary/80",
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
handlePathSubmit(suggestion.path);
|
||||
}}
|
||||
>
|
||||
{suggestion.type === "folder" ? (
|
||||
<Folder size={12} className="text-primary shrink-0" />
|
||||
) : (
|
||||
<Home size={12} className="text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<span className="truncate font-mono">{suggestion.path}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="flex-1 min-w-0 cursor-text hover:bg-secondary/50 rounded px-1 transition-colors"
|
||||
data-section="terminal-sftp-path"
|
||||
onDoubleClick={handlePathDoubleClick}
|
||||
>
|
||||
<SftpBreadcrumb
|
||||
path={displayPath}
|
||||
onNavigate={onNavigateTo}
|
||||
onHome={() =>
|
||||
pane.connection?.homeDir && onNavigateTo(pane.connection.homeDir)
|
||||
}
|
||||
isLocal={!isRemote}
|
||||
onListDrives={onListDrives}
|
||||
acceptForwardSlashUnc={
|
||||
isWindowsPath(displayPath)
|
||||
|| isWindowsPath(pane.connection?.homeDir ?? "")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.path.doubleClickToEdit")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<div
|
||||
className="h-7 px-2 flex items-center min-w-0"
|
||||
data-section="terminal-sftp-path-row"
|
||||
>
|
||||
{pathEditor}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="h-7 px-2 flex items-center gap-1 border-t border-border/40"
|
||||
data-section="terminal-sftp-actions"
|
||||
>
|
||||
<ToolbarCustomizeContextMenu
|
||||
items={customizeItems}
|
||||
placementOf={(id) => toolbarLayout.layout.placement[id] ?? "show"}
|
||||
@@ -1058,19 +1068,22 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
|
||||
onMove={moveSftpItem}
|
||||
onReset={toolbarLayout.reset}
|
||||
t={t}
|
||||
className="ml-auto flex items-center gap-0.5 shrink-0"
|
||||
className="flex items-center gap-0.5 w-full min-w-0"
|
||||
>
|
||||
{inlineIds.map(renderInline)}
|
||||
<ToolbarOverflowMenu
|
||||
hasItems={overflowNodes.length > 0}
|
||||
label={t("common.more")}
|
||||
orientation="horizontal"
|
||||
buttonClassName="h-6 w-6"
|
||||
contentClassName="min-w-[140px]"
|
||||
>
|
||||
<div className="flex flex-col min-w-[140px]">{overflowNodes}</div>
|
||||
</ToolbarOverflowMenu>
|
||||
<div className="ml-auto shrink-0" data-section="terminal-sftp-overflow">
|
||||
<ToolbarOverflowMenu
|
||||
hasItems={overflowNodes.length > 0}
|
||||
label={t("common.more")}
|
||||
orientation="horizontal"
|
||||
buttonClassName="h-6 w-6"
|
||||
contentClassName="min-w-[140px]"
|
||||
>
|
||||
<div className="flex flex-col min-w-[140px]">{overflowNodes}</div>
|
||||
</ToolbarOverflowMenu>
|
||||
</div>
|
||||
</ToolbarCustomizeContextMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showFilterBar && (
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { SftpPane } from "../../application/state/sftp/types";
|
||||
import {
|
||||
connectionKeyMatchesHost,
|
||||
findReusableSftpSidePanelTab,
|
||||
isPendingSameEndpointSshSession,
|
||||
isRemoteSftpTabHealthy,
|
||||
shouldAcceptPendingSftpUpload,
|
||||
shouldResetSftpSidePanelSourceSession,
|
||||
@@ -77,6 +78,35 @@ test("shouldSkipSftpSidePanelAutoConnect rejects when the active tab has no endp
|
||||
);
|
||||
});
|
||||
|
||||
test("isPendingSameEndpointSshSession only waits for actively connecting SSH sessions", () => {
|
||||
const host = {
|
||||
id: "host-1",
|
||||
hostname: "server.example",
|
||||
port: 22,
|
||||
username: "root",
|
||||
};
|
||||
const baseSession = {
|
||||
hostId: "host-1",
|
||||
hostname: "server.example",
|
||||
port: 22,
|
||||
username: "root",
|
||||
protocol: "ssh",
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
isPendingSameEndpointSshSession({ ...baseSession, status: "connecting" }, host),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isPendingSameEndpointSshSession({ ...baseSession, status: "disconnected" }, host),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isPendingSameEndpointSshSession({ ...baseSession, status: "connected" }, host),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("connectionKeyMatchesHost accepts host-id prefix keys", () => {
|
||||
assert.equal(connectionKeyMatchesHost("host-1:server:22:ssh::root", "host-1"), true);
|
||||
assert.equal(connectionKeyMatchesHost("host-2:server:22:ssh::root", "host-1"), false);
|
||||
|
||||
Loaded 100 of 273 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user