mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-01 03:12:33 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa8412d70c | ||
|
|
7bb4e96c55 | ||
|
|
7a66161355 | ||
|
|
edfe5ba517 | ||
|
|
7bce9e55e3 | ||
|
|
0cb692b17e | ||
|
|
337191382a | ||
|
|
18b19b1ccc | ||
|
|
ce2611d868 | ||
|
|
a0dce456fa | ||
|
|
2c7c0f9fc4 | ||
|
|
db2152dcfa | ||
|
|
877df24f59 | ||
|
|
40d114eab6 |
No files matched your search
+2
-3
@@ -3,7 +3,6 @@
|
||||
## [Unreleased] - 2026-03-11
|
||||
|
||||
### 功能
|
||||
- Linux deb/rpm/pacman 安装包启用应用内自动更新;未标记的开发包和 Snap 继续提供手动下载入口
|
||||
- 终端新增"粘贴时自动上传剪贴板图片"选项:剪贴板含图片时,在远程会话中粘贴(快捷键/右键/中键)自动通过 SFTP 上传图片到远端 `.netcatty-paste-images/` 目录并输入远端路径,否则保持原有文本粘贴行为(设置 > 终端 > 行为,默认关闭)
|
||||
- 修复自动更新 IPC 事件仅发送到单个窗口的问题,改为广播所有窗口(主窗口 + 设置窗口均可收到)
|
||||
- 统一手动检查更新与自动更新的状态机,消除三套并行状态
|
||||
@@ -14,7 +13,7 @@
|
||||
- 下载完成后弹出持久 toast 通知,用户点击"立即重启"即可安装
|
||||
- 下载失败时弹出错误 toast,提供"打开 Releases"降级入口
|
||||
- Settings > System 进度条实时展示自动下载进度,由 `useUpdateCheck` 统一驱动
|
||||
- Linux Snap、未标记的开发包等不支持 electron-updater 的平台自动跳过,保持原有 GitHub API 通知行为
|
||||
- Linux deb/rpm/snap 等不支持 electron-updater 的平台自动跳过,保持原有 GitHub API 通知行为
|
||||
|
||||
### 设计原理
|
||||
- `broadcastToAllWindows` 替换 `getSenderWindow` 单点发送,保证所有窗口都能收到 IPC 事件
|
||||
@@ -30,5 +29,5 @@
|
||||
|
||||
### 注意事项
|
||||
- `checkNow` 语义:使用 GitHub API(`performCheck`)检测是否有新版本,若发现更新且 electron-updater 尚未开始下载,则异步触发 `bridge.checkForUpdate()` 启动自动下载流程
|
||||
- 此功能仅对打包后的应用(Windows NSIS、macOS dmg/zip、Linux AppImage/deb/rpm/pacman)生效,dev 模式需配合 `forceDevUpdateConfig=true` + `dev-app-update.yml` 测试(见 `.gitignore`)
|
||||
- 此功能仅对打包后的应用(Windows NSIS、macOS dmg/zip、Linux AppImage)生效,dev 模式需配合 `forceDevUpdateConfig=true` + `dev-app-update.yml` 测试(见 `.gitignore`)
|
||||
- `hasUpdate` 旧 toast 在 `autoDownloadStatus !== 'idle'` 时自动抑制,避免与新 toast 重复
|
||||
@@ -272,55 +272,6 @@ 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,7 +7,6 @@ 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';
|
||||
|
||||
@@ -497,14 +496,7 @@ async function captureCtxInheritedCwd(getCtx: AppContextGetter, sessionId: strin
|
||||
}
|
||||
}
|
||||
|
||||
const probe = async (
|
||||
id: string,
|
||||
options?: {
|
||||
allowHomeFallback?: boolean;
|
||||
allowLoginShellFallback?: boolean;
|
||||
timeoutMs?: number;
|
||||
},
|
||||
) =>
|
||||
const probe = async (id: string, options?: { allowHomeFallback?: boolean; timeoutMs?: number }) =>
|
||||
(await bridge?.getSessionPwd?.(id, options)) ?? { success: false };
|
||||
return captureInheritedCwd(source, probe, { liveCwd, allowSshProbe });
|
||||
}
|
||||
@@ -681,10 +673,12 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
});
|
||||
switch (action) {
|
||||
case 'switchToTab': {
|
||||
// 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]);
|
||||
// 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]);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ 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,
|
||||
@@ -133,15 +132,12 @@ 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,7 +34,6 @@ 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';
|
||||
@@ -318,29 +317,6 @@ 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();
|
||||
@@ -495,10 +471,8 @@ 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}
|
||||
@@ -684,7 +658,6 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
|
||||
onAddSessionToWorkspace={addSessionToWorkspace}
|
||||
onRequestAddToWorkspace={handleRequestAddToWorkspace}
|
||||
onAppendHostToWorkspace={handleAppendHostToWorkspace}
|
||||
onUpdateSplitSizes={updateSplitSizes}
|
||||
onSetDraggingSessionId={setDraggingSessionId}
|
||||
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
|
||||
@@ -806,7 +779,13 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
<AddToWorkspaceDialog
|
||||
open
|
||||
onOpenChange={(open) => { if (!open) setAddToWorkspaceDialog(null); }}
|
||||
hosts={hosts}
|
||||
// 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}
|
||||
workspaceTitle={
|
||||
addToWorkspaceDialog.mode === 'append'
|
||||
? workspaces.find((w) => w.id === addToWorkspaceDialog.workspaceId)?.title
|
||||
@@ -825,11 +804,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
if (target.kind === 'local') {
|
||||
appendLocalTerminalToWorkspace(addToWorkspaceDialog.workspaceId, undefined, rootDir);
|
||||
} else {
|
||||
appendHostToWorkspace(
|
||||
addToWorkspaceDialog.workspaceId,
|
||||
resolveWorkspaceAppendHost(target.host),
|
||||
rootDir,
|
||||
);
|
||||
appendHostToWorkspace(addToWorkspaceDialog.workspaceId, target.host, rootDir);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
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, buildTabShortcutNumberById } from './tabShortcutTargets.ts';
|
||||
import { buildNumberShortcutTabTargets } from './tabShortcutTargets.ts';
|
||||
|
||||
test('number shortcut tabs include vault and sftp by default', () => {
|
||||
assert.deepEqual(
|
||||
@@ -62,43 +62,3 @@ 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,22 +12,3 @@ 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;
|
||||
}
|
||||
@@ -61,15 +61,6 @@ export const enAiMessages: Messages = {
|
||||
'ai.providers.contextWindow.help': 'Leave blank to use the model list value when available, otherwise Netcatty uses a safe default.',
|
||||
'ai.providers.contextWindow.error': 'Enter a positive whole number, or leave it blank.',
|
||||
'ai.providers.refreshModels': 'Refresh models',
|
||||
'ai.providers.test': 'Test',
|
||||
'ai.providers.test.testing': 'Testing…',
|
||||
'ai.providers.test.ok': 'Connected ({latency} ms)',
|
||||
'ai.providers.test.warn': 'Reached endpoint, but response looks incomplete ({latency} ms)',
|
||||
'ai.providers.test.warnSlow': 'Connected, but slow ({latency} ms)',
|
||||
'ai.providers.test.error': 'Failed ({detail})',
|
||||
'ai.providers.test.missingBaseUrl': 'Enter a Base URL first',
|
||||
'ai.providers.test.missingApiKey': 'Enter an API key first',
|
||||
'ai.providers.test.unavailable': 'Connection test is unavailable in this environment',
|
||||
'ai.providers.searchModel': 'Search or type model ID...',
|
||||
'ai.providers.filterModels': 'Filter models...',
|
||||
'ai.providers.loadingModels': 'Loading models...',
|
||||
|
||||
@@ -789,8 +789,6 @@ 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,7 +13,6 @@ 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.',
|
||||
|
||||
|
||||
@@ -42,15 +42,6 @@ export const ruAiMessages: Messages = {
|
||||
'ai.providers.contextWindow.help': 'Оставьте пустым, чтобы использовать значение из списка моделей, если оно доступно; иначе Netcatty применит безопасное значение по умолчанию.',
|
||||
'ai.providers.contextWindow.error': 'Введите положительное целое число или оставьте поле пустым.',
|
||||
'ai.providers.refreshModels': 'Обновить модели',
|
||||
'ai.providers.test': 'Проверить',
|
||||
'ai.providers.test.testing': 'Проверка…',
|
||||
'ai.providers.test.ok': 'Подключено ({latency} ms)',
|
||||
'ai.providers.test.warn': 'Эндпоинт доступен, но ответ выглядит неполным ({latency} ms)',
|
||||
'ai.providers.test.warnSlow': 'Подключено, но медленно ({latency} ms)',
|
||||
'ai.providers.test.error': 'Ошибка ({detail})',
|
||||
'ai.providers.test.missingBaseUrl': 'Сначала укажите Base URL',
|
||||
'ai.providers.test.missingApiKey': 'Сначала укажите API-ключ',
|
||||
'ai.providers.test.unavailable': 'Проверка соединения недоступна в этой среде',
|
||||
'ai.providers.searchModel': 'Искать или ввести ID модели...',
|
||||
'ai.providers.filterModels': 'Фильтровать модели...',
|
||||
'ai.providers.loadingModels': 'Загрузка моделей...',
|
||||
|
||||
@@ -734,8 +734,6 @@ 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,7 +13,6 @@ 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': 'Команда запуска не была выполнена успешно. Проверьте, что цель доступна, и повторите попытку.',
|
||||
|
||||
|
||||
@@ -61,15 +61,6 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.providers.contextWindow.help': '留空时优先使用模型列表返回的值;如果没有,Netcatty 会使用安全默认值。',
|
||||
'ai.providers.contextWindow.error': '请输入正整数,或留空。',
|
||||
'ai.providers.refreshModels': '刷新模型列表',
|
||||
'ai.providers.test': '检测',
|
||||
'ai.providers.test.testing': '检测中…',
|
||||
'ai.providers.test.ok': '连接正常({latency} ms)',
|
||||
'ai.providers.test.warn': '已连通,但响应不完整({latency} ms)',
|
||||
'ai.providers.test.warnSlow': '已连通,但较慢({latency} ms)',
|
||||
'ai.providers.test.error': '检测失败({detail})',
|
||||
'ai.providers.test.missingBaseUrl': '请先填写 Base URL',
|
||||
'ai.providers.test.missingApiKey': '请先填写 API Key',
|
||||
'ai.providers.test.unavailable': '当前环境无法进行连接检测',
|
||||
'ai.providers.searchModel': '搜索或输入模型 ID...',
|
||||
'ai.providers.filterModels': '筛选模型...',
|
||||
'ai.providers.loadingModels': '加载模型中...',
|
||||
|
||||
@@ -13,7 +13,6 @@ 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,8 +513,6 @@ 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': '请按键...',
|
||||
|
||||
@@ -61,15 +61,6 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.providers.contextWindow.help': '留空時優先使用模型列表傳回的值;如果沒有,Netcatty 會使用安全預設值。',
|
||||
'ai.providers.contextWindow.error': '請輸入正整數,或留空。',
|
||||
'ai.providers.refreshModels': '重新整理模型列表',
|
||||
'ai.providers.test': '檢測',
|
||||
'ai.providers.test.testing': '檢測中…',
|
||||
'ai.providers.test.ok': '連線正常({latency} ms)',
|
||||
'ai.providers.test.warn': '已連通,但回應不完整({latency} ms)',
|
||||
'ai.providers.test.warnSlow': '已連通,但較慢({latency} ms)',
|
||||
'ai.providers.test.error': '檢測失敗({detail})',
|
||||
'ai.providers.test.missingBaseUrl': '請先填寫 Base URL',
|
||||
'ai.providers.test.missingApiKey': '請先填寫 API Key',
|
||||
'ai.providers.test.unavailable': '目前環境無法進行連線檢測',
|
||||
'ai.providers.searchModel': '搜尋或輸入模型 ID...',
|
||||
'ai.providers.filterModels': '篩選模型...',
|
||||
'ai.providers.loadingModels': '載入模型中...',
|
||||
|
||||
@@ -13,7 +13,6 @@ 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,8 +513,6 @@ 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,11 +2,7 @@ import type { TerminalSession } from "../../domain/models";
|
||||
|
||||
export type SessionPwdProbe = (
|
||||
sessionId: string,
|
||||
options?: {
|
||||
allowHomeFallback?: boolean;
|
||||
allowLoginShellFallback?: boolean;
|
||||
timeoutMs?: number;
|
||||
},
|
||||
options?: { allowHomeFallback?: boolean; timeoutMs?: number },
|
||||
) => Promise<{ success: boolean; cwd?: string }>;
|
||||
|
||||
type CaptureSession = Pick<TerminalSession, "id" | "protocol" | "status" | "lastCwd" | "localStartDir">;
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
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';
|
||||
@@ -67,182 +64,3 @@ 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,8 +83,6 @@ export function getActiveScriptRunForSession(sessionId: string): ScriptRun | und
|
||||
}
|
||||
|
||||
export async function runAutomationScript(params: {
|
||||
runId?: string;
|
||||
returnWhenQueued?: boolean;
|
||||
snippet: Snippet;
|
||||
sessionId: string;
|
||||
sessionIds?: string[];
|
||||
@@ -106,8 +104,6 @@ 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,
|
||||
@@ -180,7 +176,6 @@ 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?: {
|
||||
@@ -190,63 +185,18 @@ export async function runConnectScriptsSequential(params: {
|
||||
username?: string;
|
||||
};
|
||||
}): Promise<void> {
|
||||
const throwIfAborted = () => {
|
||||
if (params.signal?.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;
|
||||
if (params.signal?.aborted) {
|
||||
throw new Error('Aborted');
|
||||
}
|
||||
params.onScriptStart?.(snippet);
|
||||
const queueAccepted = runAutomationScript({
|
||||
runId,
|
||||
returnWhenQueued: true,
|
||||
const { runId } = await runAutomationScript({
|
||||
snippet,
|
||||
sessionId: params.sessionId,
|
||||
sessionMeta: params.sessionMeta,
|
||||
});
|
||||
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);
|
||||
}
|
||||
}
|
||||
await waitForScriptRun(runId, { signal: params.signal });
|
||||
params.onScriptComplete?.(snippet);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,34 +74,7 @@ test("serial session factories snapshot effective legacy Backspace behavior", ()
|
||||
assert.equal(explicitDefaultSession.serialConfig?.backspaceBehavior, "default");
|
||||
});
|
||||
|
||||
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", () => {
|
||||
test("saved quick-connect hosts can inherit Ctrl-H after moving into a group", () => {
|
||||
const savedHost = host({
|
||||
protocol: "serial",
|
||||
hostname: "COM3",
|
||||
@@ -114,15 +87,12 @@ test("workspace append snapshots serial Backspace behavior inherited from a grou
|
||||
backspaceBehavior: "default",
|
||||
}),
|
||||
});
|
||||
const effectiveHost = resolveEffectiveTerminalHost({
|
||||
host: savedHost,
|
||||
groupConfigs: [{ path: "network", backspaceBehavior: "ctrl-h" }],
|
||||
proxyProfiles: [],
|
||||
const effectiveHost = applyGroupDefaults(savedHost, {
|
||||
backspaceBehavior: "ctrl-h",
|
||||
});
|
||||
|
||||
const session = createWorkspaceHostTerminalSession("session-1", effectiveHost, "workspace-1");
|
||||
const session = createHostTerminalSession("session-1", effectiveHost);
|
||||
|
||||
assert.equal(session.workspaceId, "workspace-1");
|
||||
assert.equal(session.serialConfig?.backspaceBehavior, "ctrl-h");
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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';
|
||||
@@ -30,9 +29,6 @@ export type SettingsChromeSnapshot = {
|
||||
showOnlyUngroupedHostsInRoot: boolean;
|
||||
dynamicTabTitleMode: DynamicTabTitleMode;
|
||||
disableTerminalFontZoom: boolean;
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
shellOnlyTabNumberShortcuts: boolean;
|
||||
showTabNumberBadges: boolean;
|
||||
restoreTerminalCwd: boolean;
|
||||
terminalSidePanelAutoOpen: boolean;
|
||||
terminalSidePanelAutoOpenTab: TerminalSidePanelAutoOpenTab;
|
||||
@@ -62,9 +58,6 @@ 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',
|
||||
@@ -87,9 +80,6 @@ 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,7 +82,6 @@ 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,7 +37,6 @@ 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,
|
||||
@@ -93,7 +92,6 @@ interface UseSettingsStorageSyncParams {
|
||||
terminalSidePanelAutoOpen: boolean;
|
||||
terminalSidePanelAutoOpenTab: TerminalSidePanelAutoOpenTab;
|
||||
shellOnlyTabNumberShortcuts: boolean;
|
||||
showTabNumberBadges: boolean;
|
||||
disableTerminalFontZoom: boolean;
|
||||
restorePreviousSession: boolean;
|
||||
restoreTerminalCwd: boolean;
|
||||
@@ -141,7 +139,6 @@ 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>>;
|
||||
@@ -172,7 +169,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, applyIncomingCustomAccent,
|
||||
@@ -181,7 +178,7 @@ export function useSettingsStorageSync({
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState, setExplorerContextMenuEnabledState,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
|
||||
@@ -196,7 +193,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
});
|
||||
@@ -206,7 +203,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
};
|
||||
@@ -456,12 +453,6 @@ export function useSettingsStorageSync({
|
||||
setShellOnlyTabNumberShortcutsState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SHOW_TAB_NUMBER_BADGES && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.showTabNumberBadges) {
|
||||
setShowTabNumberBadgesState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.disableTerminalFontZoom) {
|
||||
|
||||
@@ -39,32 +39,6 @@ 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,27 +1,18 @@
|
||||
/**
|
||||
* Browse vs transfer session lifecycle helpers.
|
||||
*
|
||||
* 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.
|
||||
* 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).
|
||||
*/
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
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']);
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
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'],
|
||||
};
|
||||
@@ -1,98 +0,0 @@
|
||||
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,11 +73,6 @@ 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[]>(() =>
|
||||
@@ -973,119 +968,6 @@ 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]);
|
||||
@@ -1189,9 +1071,6 @@ export function useAIState() {
|
||||
updateMessageById,
|
||||
persistContextCompaction,
|
||||
cleanupOrphanedSessions,
|
||||
seedWorkspaceActiveSessionFromMembers,
|
||||
handoffDissolvedWorkspaceScope,
|
||||
retargetWorkspaceActiveChatForMemberLoss,
|
||||
}), [
|
||||
providers,
|
||||
setProviders,
|
||||
@@ -1245,8 +1124,5 @@ export function useAIState() {
|
||||
updateMessageById,
|
||||
persistContextCompaction,
|
||||
cleanupOrphanedSessions,
|
||||
seedWorkspaceActiveSessionFromMembers,
|
||||
handoffDissolvedWorkspaceScope,
|
||||
retargetWorkspaceActiveChatForMemberLoss,
|
||||
]);
|
||||
}
|
||||
@@ -199,36 +199,6 @@ 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,10 +5,8 @@ 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';
|
||||
@@ -107,7 +105,6 @@ type ExternalMcpConfig = {
|
||||
};
|
||||
|
||||
type ExternalMcpBridge = {
|
||||
aiMcpSetPermissionMode?: (mode: AIPermissionMode) => Promise<unknown> | unknown;
|
||||
externalMcpSetConfig?: (config: ExternalMcpConfig) => Promise<unknown> | unknown;
|
||||
externalMcpSetEnabled?: (enabled: boolean) => Promise<unknown> | unknown;
|
||||
externalMcpGetStatus?: () => Promise<{
|
||||
@@ -129,11 +126,6 @@ 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(
|
||||
@@ -288,13 +280,6 @@ 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,7 +3,6 @@ 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
|
||||
@@ -184,6 +183,16 @@ 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
|
||||
@@ -303,16 +312,7 @@ function reconcileSharedServerStatsSession(session: SharedServerStatsSession): v
|
||||
const activeClients = getActiveServerStatsClients(session);
|
||||
|
||||
if (activeClients.length === 0) {
|
||||
// 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 });
|
||||
}
|
||||
resetServerStatsSession(session);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,26 +1,13 @@
|
||||
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 = {
|
||||
@@ -32,12 +19,6 @@ 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" };
|
||||
@@ -53,10 +34,5 @@ export const useSessionLogBackend = () => {
|
||||
return bridge?.getManualSessionLogStatus?.(payload) ?? { success: false, isLogging: false, error: "Session log bridge unavailable" };
|
||||
}, []);
|
||||
|
||||
return {
|
||||
chooseManualSessionLogPath,
|
||||
startManualSessionLog,
|
||||
stopManualSessionLog,
|
||||
getManualSessionLogStatus,
|
||||
};
|
||||
return { startManualSessionLog, stopManualSessionLog, getManualSessionLogStatus };
|
||||
};
|
||||
@@ -797,6 +797,11 @@ 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,8 +105,4 @@ 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,7 +75,6 @@ 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';
|
||||
@@ -122,7 +121,6 @@ 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,
|
||||
@@ -415,10 +413,6 @@ 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;
|
||||
@@ -947,8 +941,6 @@ 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);
|
||||
@@ -1153,7 +1145,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, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode,
|
||||
@@ -1163,7 +1155,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, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled, setJmsDeepLinkEnabledState: applyIncomingJmsDeepLinkEnabled, setExplorerContextMenuEnabledState: applyIncomingExplorerContextMenuEnabled,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity: applyIncomingWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
|
||||
@@ -1329,13 +1321,6 @@ 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);
|
||||
@@ -1761,9 +1746,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
showOnlyUngroupedHostsInRoot,
|
||||
dynamicTabTitleMode: terminalSettings.dynamicTabTitleMode,
|
||||
disableTerminalFontZoom,
|
||||
hotkeyScheme,
|
||||
shellOnlyTabNumberShortcuts,
|
||||
showTabNumberBadges,
|
||||
restoreTerminalCwd,
|
||||
terminalSidePanelAutoOpen,
|
||||
terminalSidePanelAutoOpenTab,
|
||||
@@ -1772,16 +1754,13 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
darkUiThemeId,
|
||||
disableTerminalFontZoom,
|
||||
hostClickBehavior,
|
||||
hotkeyScheme,
|
||||
lightUiThemeId,
|
||||
resolvedTheme,
|
||||
restoreTerminalCwd,
|
||||
shellOnlyTabNumberShortcuts,
|
||||
showHostTreeSidebar,
|
||||
showOnlyUngroupedHostsInRoot,
|
||||
showRecentHosts,
|
||||
showSftpTab,
|
||||
showTabNumberBadges,
|
||||
terminalSettings.dynamicTabTitleMode,
|
||||
terminalSidePanelAutoOpen,
|
||||
terminalSidePanelAutoOpenTab,
|
||||
@@ -1956,8 +1935,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setTerminalSidePanelAutoOpenTab,
|
||||
shellOnlyTabNumberShortcuts,
|
||||
setShellOnlyTabNumberShortcuts,
|
||||
showTabNumberBadges,
|
||||
setShowTabNumberBadges,
|
||||
disableTerminalFontZoom,
|
||||
setDisableTerminalFontZoom,
|
||||
restorePreviousSession,
|
||||
@@ -2017,7 +1994,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, showTabNumberBadges, disableTerminalFontZoom,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
|
||||
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
]),
|
||||
};
|
||||
|
||||
@@ -643,11 +643,10 @@ export const useSftpState = (
|
||||
[resolveTransferConflict, resolveUploadConflict, uploadConflicts],
|
||||
);
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
const interactive = options?.interactive !== false;
|
||||
useEffect(() => {
|
||||
const gen = ++browseLifecycleGenRef.current;
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,113 +0,0 @@
|
||||
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,7 +59,6 @@ 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,10 +513,7 @@ export const useTerminalBackend = () => {
|
||||
return bridge?.onZmodemEvent?.(sessionId, cb) ?? (() => {});
|
||||
}, []);
|
||||
|
||||
const getSessionPwd = useCallback(async (
|
||||
sessionId: string,
|
||||
options?: { allowHomeFallback?: boolean; allowLoginShellFallback?: boolean },
|
||||
) => {
|
||||
const getSessionPwd = useCallback(async (sessionId: string, options?: { allowHomeFallback?: boolean }) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getSessionPwd) return { success: false, error: 'getSessionPwd unavailable' };
|
||||
return bridge.getSessionPwd(sessionId, options);
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
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"]);
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
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,7 +123,6 @@ export function useTerminalSidePanelLayoutState() {
|
||||
setSidePanelOpenTabs,
|
||||
sidePanelOpenTabsRef,
|
||||
sidePanelLayouts,
|
||||
setSidePanelLayouts,
|
||||
sidePanelLayoutsRef,
|
||||
focusPane,
|
||||
splitPane,
|
||||
|
||||
@@ -36,37 +36,13 @@ test("deleteSelectedSnippets merges into persisted vault under the shared lock",
|
||||
);
|
||||
});
|
||||
|
||||
test("updateHosts prunes stale snippet bindings under the vault lock", () => {
|
||||
// Cross-window bulk-delete can land while a host writer is queued with a
|
||||
// pre-delete encrypted blob. Re-check the live snippet catalog under the lock
|
||||
// before persisting so login/connect bindings are not restored.
|
||||
assert.match(
|
||||
source,
|
||||
/commitEncryptedHostsUnderVaultLock[\s\S]*pruneHostsStaleSnippetBindings\(hostsToPersist, latestSnippets\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const writePromise = encryptPromise\.then\(async \(enc\) => \{[\s\S]*return commitEncryptedHostsUnderVaultLock\(ver, cleaned, enc\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("importData replaces snippets instead of additive-rebasing", () => {
|
||||
// Sync restore / backup import must match the selected snapshot. Additive
|
||||
// rebase would keep concurrent disk-only ids and report a successful restore
|
||||
// that still contains snippets absent from the imported payload.
|
||||
assert.match(
|
||||
source,
|
||||
/updateSnippets\(payload\.snippets,\s*\{\s*replace:\s*true\s*\}\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("updateSnippets disk writes take the shared vault lock", () => {
|
||||
// Unlocked snippet saves can interleave with bulk-delete's journaled commit
|
||||
// and discard concurrent edits. Ordinary writers must queue on the same lock
|
||||
// and be visible to waitForPendingVaultWrites.
|
||||
assert.match(
|
||||
source,
|
||||
/const updateSnippets = useCallback\(\(\s*data: Snippet\[\],\s*options\?: \{ replace\?: boolean \},\s*\) => \{[\s\S]*withVaultImportLock\("vault", async \(\) => \{[\s\S]*localStorageAdapter\.write\(STORAGE_KEY_SNIPPETS, rebased\)/,
|
||||
/const updateSnippets = useCallback\(\(data: Snippet\[\]\) => \{[\s\S]*withVaultImportLock\("vault", async \(\) => \{[\s\S]*localStorageAdapter\.write\(STORAGE_KEY_SNIPPETS, rebased\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
@@ -78,7 +54,7 @@ test("updateSnippets disk writes take the shared vault lock", () => {
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/const updateSnippets = useCallback\(\([\s\S]*?\) => \{[\s\S]*setSnippets\(cleaned\);\s*localStorageAdapter\.write\(STORAGE_KEY_SNIPPETS, cleaned\);\s*\}, \[\]\);/,
|
||||
/const updateSnippets = useCallback\(\(data: Snippet\[\]\) => \{[\s\S]*setSnippets\(cleaned\);\s*localStorageAdapter\.write\(STORAGE_KEY_SNIPPETS, cleaned\);\s*\}, \[\]\);/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -87,151 +63,10 @@ test("updateSnippets rebases onto the latest persisted snapshot under the lock",
|
||||
// that lands while a main-window save is queued must not be resurrected.
|
||||
assert.match(
|
||||
source,
|
||||
/rebaseSnippetVaultWrite\(\{\s*base,\s*ours: cleaned,\s*theirs:/,
|
||||
/rebaseSnippetVaultWrite\(\{\s*base,\s*ours: cleaned,\s*theirs: latest\s*\}\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/snippetsWriteOwnerRef\.current !== ver/,
|
||||
);
|
||||
});
|
||||
|
||||
test("clearVaultData replaces snippets without additive rebase", () => {
|
||||
// Clear All Local Data must not preserve concurrent disk-only snippet adds
|
||||
// that rebaseSnippetVaultWrite would otherwise keep.
|
||||
assert.match(
|
||||
source,
|
||||
/updateSnippets\(\[\],\s*\{\s*replace:\s*true\s*\}\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const replace =\s*options\?\.replace === true \|\| snippetsWriteReplaceRef\.current/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/replace\s*\?\s*cleaned\s*:\s*rebaseSnippetVaultWrite/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/if \(replace\) \{\s*\/\/ Restore\/import\/clear[\s\S]*snippetsWriteBaseRef\.current = null/,
|
||||
);
|
||||
});
|
||||
|
||||
test("updateSnippets preserves replace mode across superseded local saves", () => {
|
||||
// A clear/restore queued behind the vault lock must not lose replacement
|
||||
// intent when a later create/edit becomes the write owner — otherwise that
|
||||
// save additively rebases against the stale disk catalog and resurrects
|
||||
// every pre-replacement snippet.
|
||||
assert.match(
|
||||
source,
|
||||
/const snippetsWriteReplaceRef = useRef\(false\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/if \(options\?\.replace === true\) \{\s*snippetsWriteReplaceRef\.current = true;\s*\}/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/snippetsWriteBaseRef\.current = null;\s*snippetsWriteReplaceRef\.current = false;/,
|
||||
);
|
||||
});
|
||||
|
||||
test("updateSnippets keeps the persisted rebase ancestor across superseded saves", () => {
|
||||
// Two queued local saves must not let the second treat the first save's
|
||||
// optimistic in-memory array as base — that array never hit disk, so an add
|
||||
// would look like a concurrent delete and be dropped.
|
||||
assert.match(
|
||||
source,
|
||||
/const snippetsWriteBaseRef = useRef<Snippet\[\] \| null>\(null\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const base = snippetsWriteBaseRef\.current \?\? snippetsRef\.current/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/if \(snippetsWriteBaseRef\.current === null\) \{\s*snippetsWriteBaseRef\.current = base;\s*\}/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const persisted = localStorageAdapter\.write\(STORAGE_KEY_SNIPPETS, rebased\)/,
|
||||
);
|
||||
// Quota failure must keep the ancestor; clearing it would drop a local add on
|
||||
// the next save after space is freed.
|
||||
assert.match(
|
||||
source,
|
||||
/if \(!persisted\) \{[\s\S]*return "failed" as const;[\s\S]*\}\s*\/\/ Disk caught up[\s\S]*snippetsWriteBaseRef\.current = null/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/if \(!persisted\) \{[\s\S]*Snippets could not be saved/,
|
||||
);
|
||||
});
|
||||
|
||||
test("snippet storage events keep queued local edits and their rebase ancestor", () => {
|
||||
// A cross-window write must not clear snippetsWriteBaseRef / replace optimistic
|
||||
// state while a local owner is still queued — the next edit would supersede
|
||||
// that owner from a remote-only snapshot and drop the first local mutation.
|
||||
assert.match(
|
||||
source,
|
||||
/if \(key === STORAGE_KEY_SNIPPETS\) \{[\s\S]*const pendingBase = snippetsWriteBaseRef\.current;\s*if \(pendingBase !== null\) \{[\s\S]*rebaseSnippetVaultWrite\(\{\s*base: pendingBase,\s*ours: snippetsRef\.current,\s*theirs: next,\s*\}\)/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/if \(key === STORAGE_KEY_SNIPPETS\) \{[\s\S]*snippetsWriteBaseRef\.current = null;\s*snippetsRef\.current = next/,
|
||||
);
|
||||
});
|
||||
|
||||
test("snippet storage events retain outstanding replace snapshots", () => {
|
||||
// clear/restore sets replace with base null. Adopting a remote old catalog
|
||||
// before the lock is acquired would pollute memory; a later local edit would
|
||||
// still replace disk with that polluted snapshot.
|
||||
assert.match(
|
||||
source,
|
||||
/if \(key === STORAGE_KEY_SNIPPETS\) \{[\s\S]*if \(snippetsWriteReplaceRef\.current\) \{[\s\S]*return;\s*\}[\s\S]*snippetsRef\.current = next/,
|
||||
);
|
||||
});
|
||||
|
||||
test("snippet storage events reject payloads older than current disk", () => {
|
||||
// After a queued replace commits, snippetsWriteReplaceRef is false again. A
|
||||
// delayed peer StorageEvent still carries the pre-replacement newValue; if
|
||||
// adopted, the next edit would persist that resurrected catalog.
|
||||
assert.match(
|
||||
source,
|
||||
/if \(key === STORAGE_KEY_SNIPPETS\) \{[\s\S]*event\.newValue !== localStorageAdapter\.readString\(STORAGE_KEY_SNIPPETS\)[\s\S]*return;/,
|
||||
);
|
||||
});
|
||||
|
||||
test("startup host re-encryption uses the locked prune writer", () => {
|
||||
// A second renderer migrating saved hosts must not write the pre-delete
|
||||
// encryption blob unlocked after bulk-delete cleared login/connect bindings.
|
||||
assert.match(
|
||||
source,
|
||||
/migrateHostsFromLegacyLineTimestamps[\s\S]*commitEncryptedHostsUnderVaultLock\(ver, sanitized, enc\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/migrateHostsFromLegacyLineTimestamps[\s\S]*hostsWritePendingRef\.current = writePromise/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/migrateHostsFromLegacyLineTimestamps[\s\S]*localStorageAdapter\.write\(STORAGE_KEY_HOSTS, enc\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("startup snippet order backfill uses the locked vault writer", () => {
|
||||
// An initializing renderer must not rewrite a pre-delete snippet snapshot
|
||||
// unlocked after peer bulk-delete cleared host bindings — that resurrects
|
||||
// every selected snippet while binding cleanup remains committed.
|
||||
assert.match(
|
||||
source,
|
||||
/needsOrderPersist[\s\S]*withVaultImportLock\("vault", async \(\) => \{[\s\S]*readStoredArray<Snippet>\([\s\S]*STORAGE_KEY_SNIPPETS[\s\S]*localStorageAdapter\.write\(STORAGE_KEY_SNIPPETS, latest\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/needsOrderPersist[\s\S]*snippetsWritePendingRef\.current = writePromise/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/const orderedSnippets = normalizeVaultOrder\(savedSnippets\);\s*setSnippets\(orderedSnippets\);\s*localStorageAdapter\.write\(STORAGE_KEY_SNIPPETS, orderedSnippets\);/,
|
||||
);
|
||||
});
|
||||
@@ -66,7 +66,6 @@ import {
|
||||
import { getNextVaultOrder, normalizeVaultOrder } from "../../domain/vaultOrder";
|
||||
import {
|
||||
deleteSelectedSnippetsFromVault,
|
||||
pruneHostsStaleSnippetBindings,
|
||||
rebaseSnippetVaultWrite,
|
||||
} from "../../domain/snippetSelection";
|
||||
import { loadSanitizedShellHistory } from "./shellHistoryPersistence";
|
||||
@@ -318,14 +317,6 @@ export const useVaultState = () => {
|
||||
// snippetsWriteVersion (to invalidate naive writers), so queued saves must
|
||||
// key supersede checks off this owner instead of that shared counter.
|
||||
const snippetsWriteOwnerRef = useRef(0);
|
||||
// Last persisted ancestor for in-flight updateSnippets rebases. Superseded
|
||||
// saves must not advance this to an optimistic in-memory array that never
|
||||
// landed on disk — that would make a local add look like a concurrent delete.
|
||||
const snippetsWriteBaseRef = useRef<Snippet[] | null>(null);
|
||||
// Outstanding clear/restore/import replace must survive a superseding local
|
||||
// save. Otherwise the later owner rebases additively against the old disk
|
||||
// catalog and resurrects every pre-replacement snippet.
|
||||
const snippetsWriteReplaceRef = useRef(false);
|
||||
const customGroupsWriteVersion = useRef(0);
|
||||
const groupConfigsWriteVersion = useRef(0);
|
||||
// Encrypt-phase promises can always be awaited, even under the vault lock.
|
||||
@@ -473,37 +464,6 @@ export const useVaultState = () => {
|
||||
return mergeConnectionLogsFromStorage(prev, storedLogs, terminalDataMap);
|
||||
}, []);
|
||||
|
||||
// Encrypt outside the lock, then under the lock prune script bindings against
|
||||
// the latest snippet catalog so a queued full-array write cannot restore
|
||||
// login/connect ids cleared by a concurrent bulk-delete in another window.
|
||||
const commitEncryptedHostsUnderVaultLock = useCallback(async (
|
||||
ver: number,
|
||||
hostsToPersist: Host[],
|
||||
encrypted: Awaited<ReturnType<typeof encryptHosts>>,
|
||||
) => {
|
||||
return withVaultImportLock("vault", async () => {
|
||||
if (ver !== hostsWriteVersion.current) return "superseded" as const;
|
||||
const latestSnippets = normalizeVaultOrder(
|
||||
readStoredArray<Snippet>(
|
||||
STORAGE_KEY_SNIPPETS,
|
||||
localStorageAdapter.readString(STORAGE_KEY_SNIPPETS),
|
||||
),
|
||||
);
|
||||
const pruned = pruneHostsStaleSnippetBindings(hostsToPersist, latestSnippets);
|
||||
let payload = encrypted;
|
||||
if (pruned !== hostsToPersist) {
|
||||
const normalized = normalizeVaultOrder(pruned);
|
||||
payload = await encryptHosts(normalized);
|
||||
if (ver !== hostsWriteVersion.current) return "superseded" as const;
|
||||
hostsRef.current = normalized;
|
||||
setHosts(normalized);
|
||||
}
|
||||
return localStorageAdapter.write(STORAGE_KEY_HOSTS, payload)
|
||||
? "written" as const
|
||||
: "failed" as const;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateHosts = useCallback((data: Host[] | ((prev: Host[]) => Host[])) => {
|
||||
// Keep object identity for hosts that did not actually change. Callers that
|
||||
// do `hosts.map(h => h.id === id ? patch(h) : h)` already pass through
|
||||
@@ -536,11 +496,14 @@ export const useVaultState = () => {
|
||||
hostsEncryptPendingRef.current = encryptPromise.then(() => undefined);
|
||||
const writePromise = encryptPromise.then(async (enc) => {
|
||||
if (ver !== hostsWriteVersion.current) return "superseded" as const;
|
||||
return commitEncryptedHostsUnderVaultLock(ver, cleaned, enc);
|
||||
return withVaultImportLock("vault", async () => {
|
||||
if (ver !== hostsWriteVersion.current) return "superseded" as const;
|
||||
return localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||||
});
|
||||
});
|
||||
hostsWritePendingRef.current = writePromise;
|
||||
return writePromise;
|
||||
}, [commitEncryptedHostsUnderVaultLock]);
|
||||
}, []);
|
||||
|
||||
const readPersistedHosts = useCallback(async (): Promise<Host[]> => {
|
||||
// Always drain what is safe to wait for. Under the vault lock this is only
|
||||
@@ -642,31 +605,11 @@ export const useVaultState = () => {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateSnippets = useCallback((
|
||||
data: Snippet[],
|
||||
options?: { replace?: boolean },
|
||||
) => {
|
||||
const updateSnippets = useCallback((data: Snippet[]) => {
|
||||
// Capture the pre-update snapshot for a 3-way rebase once we hold the lock.
|
||||
// Callers pass a full array derived from this window's view; a popup delete
|
||||
// (or another window's import) may land on disk before our write runs.
|
||||
// Keep the first outstanding ancestor across superseded local saves so a
|
||||
// second edit does not rebase against the first save's optimistic memory.
|
||||
const replace =
|
||||
options?.replace === true || snippetsWriteReplaceRef.current;
|
||||
const base = snippetsWriteBaseRef.current ?? snippetsRef.current;
|
||||
if (options?.replace === true) {
|
||||
snippetsWriteReplaceRef.current = true;
|
||||
}
|
||||
if (replace) {
|
||||
// Restore/import/clear must not keep an additive ancestor — storage events
|
||||
// would otherwise merge disk-only ids back into memory while replace waits.
|
||||
// Keep this cleared when a later create/edit supersedes the replacement
|
||||
// owner so that save stays in replace mode instead of capturing []/restored
|
||||
// memory as an additive rebase base against the stale disk catalog.
|
||||
snippetsWriteBaseRef.current = null;
|
||||
} else if (snippetsWriteBaseRef.current === null) {
|
||||
snippetsWriteBaseRef.current = base;
|
||||
}
|
||||
const base = snippetsRef.current;
|
||||
const cleaned = normalizeVaultOrder(data);
|
||||
const ver = ++snippetsWriteVersion.current;
|
||||
snippetsWriteOwnerRef.current = ver;
|
||||
@@ -677,38 +620,17 @@ export const useVaultState = () => {
|
||||
// Serialize with deleteSelectedSnippets / plugin importer under the shared
|
||||
// vault lock, then rebase onto the latest persisted snapshot so a queued
|
||||
// full-array write cannot resurrect concurrently deleted snippets (or drop
|
||||
// concurrent additions). Explicit replace (clear / sync restore / import)
|
||||
// skips the additive rebase so a concurrent disk-only add cannot survive.
|
||||
// concurrent additions).
|
||||
const writePromise = withVaultImportLock("vault", async () => {
|
||||
if (snippetsWriteOwnerRef.current !== ver) return "superseded" as const;
|
||||
const rebased = normalizeVaultOrder(
|
||||
replace
|
||||
? cleaned
|
||||
: rebaseSnippetVaultWrite({
|
||||
base,
|
||||
ours: cleaned,
|
||||
theirs: normalizeVaultOrder(
|
||||
readStoredArray<Snippet>(
|
||||
STORAGE_KEY_SNIPPETS,
|
||||
localStorageAdapter.readString(STORAGE_KEY_SNIPPETS),
|
||||
),
|
||||
),
|
||||
}),
|
||||
const rawSnippets = localStorageAdapter.readString(STORAGE_KEY_SNIPPETS);
|
||||
const latest = normalizeVaultOrder(
|
||||
readStoredArray<Snippet>(STORAGE_KEY_SNIPPETS, rawSnippets),
|
||||
);
|
||||
const persisted = localStorageAdapter.write(STORAGE_KEY_SNIPPETS, rebased);
|
||||
if (!persisted) {
|
||||
// Keep the pre-update ancestor. Clearing it here would make the next
|
||||
// save rebase against the optimistic array while disk still lacks the
|
||||
// add, so rebase would treat the add as a concurrent delete.
|
||||
notify.error(
|
||||
"Snippets could not be saved. Free some local storage space and try again.",
|
||||
"Scripts",
|
||||
);
|
||||
return "failed" as const;
|
||||
}
|
||||
// Disk caught up; next save should treat this write as its ancestor.
|
||||
snippetsWriteBaseRef.current = null;
|
||||
snippetsWriteReplaceRef.current = false;
|
||||
const rebased = normalizeVaultOrder(
|
||||
rebaseSnippetVaultWrite({ base, ours: cleaned, theirs: latest }),
|
||||
);
|
||||
localStorageAdapter.write(STORAGE_KEY_SNIPPETS, rebased);
|
||||
if (snippetsWriteOwnerRef.current === ver) {
|
||||
snippetsRef.current = rebased;
|
||||
setSnippets(rebased);
|
||||
@@ -788,10 +710,6 @@ export const useVaultState = () => {
|
||||
}
|
||||
hostsRef.current = result.hosts;
|
||||
snippetsRef.current = result.snippets;
|
||||
// Persisted delete is the new ancestor; drop any stale rebase base from
|
||||
// a superseded/queued updateSnippets that never landed.
|
||||
snippetsWriteBaseRef.current = null;
|
||||
snippetsWriteReplaceRef.current = false;
|
||||
setHosts(result.hosts);
|
||||
setSnippets(result.snippets);
|
||||
hostsWritePendingRef.current = Promise.resolve("unchanged" as const);
|
||||
@@ -1038,7 +956,7 @@ export const useVaultState = () => {
|
||||
updateKeys([]);
|
||||
updateIdentities([]);
|
||||
updateProxyProfiles([]);
|
||||
updateSnippets([], { replace: true });
|
||||
updateSnippets([]);
|
||||
updateSnippetPackages([]);
|
||||
updateNotes([]);
|
||||
updateNoteGroups([]);
|
||||
@@ -1229,14 +1147,17 @@ export const useVaultState = () => {
|
||||
hostsEncryptPendingRef.current = encryptPromise.then(() => undefined);
|
||||
const writePromise = encryptPromise.then(async (enc) => {
|
||||
if (ver !== hostsWriteVersion.current) return;
|
||||
return commitEncryptedHostsUnderVaultLock(ver, updated, enc);
|
||||
return withVaultImportLock("vault", async () => {
|
||||
if (ver === hostsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||||
});
|
||||
});
|
||||
hostsWritePendingRef.current = writePromise;
|
||||
return updated;
|
||||
});
|
||||
|
||||
return newHost;
|
||||
}, [commitEncryptedHostsUnderVaultLock, hosts]);
|
||||
}, [hosts]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -1262,21 +1183,15 @@ export const useVaultState = () => {
|
||||
readLegacyLineTimestampsEnabled(),
|
||||
),
|
||||
);
|
||||
hostsRef.current = sanitized;
|
||||
setHosts(sanitized);
|
||||
// Always re-encrypt the batch. Stale enc:v1 placeholders are left
|
||||
// unchanged by encryptCredentialValue (no double-wrap); plaintext
|
||||
// siblings still need encryption when safeStorage was previously
|
||||
// unavailable for some records. Route through the locked writer so
|
||||
// a concurrent snippet delete can prune login/connect bindings
|
||||
// before this migration blob lands.
|
||||
const encryptPromise = encryptHosts(sanitized);
|
||||
hostsEncryptPendingRef.current = encryptPromise.then(() => undefined);
|
||||
const writePromise = encryptPromise.then(async (enc) => {
|
||||
if (ver !== hostsWriteVersion.current) return;
|
||||
return commitEncryptedHostsUnderVaultLock(ver, sanitized, enc);
|
||||
// unavailable for some records.
|
||||
encryptHosts(sanitized).then((enc) => {
|
||||
if (ver === hostsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||||
});
|
||||
hostsWritePendingRef.current = writePromise;
|
||||
}
|
||||
} else {
|
||||
updateHosts(INITIAL_HOSTS);
|
||||
@@ -1369,43 +1284,8 @@ export const useVaultState = () => {
|
||||
|
||||
if (savedSnippets) {
|
||||
const orderedSnippets = normalizeVaultOrder(savedSnippets);
|
||||
snippetsRef.current = orderedSnippets;
|
||||
setSnippets(orderedSnippets);
|
||||
// Persist order backfill only when fields changed. Never rewrite the
|
||||
// pre-lock snapshot unlocked: another renderer can delete under the
|
||||
// vault lock after this read and before this write, resurrecting
|
||||
// snippets while host-binding cleanup stays committed. Re-normalize
|
||||
// the live disk catalog under the same lock as deleteSelectedSnippets.
|
||||
const needsOrderPersist = orderedSnippets.some(
|
||||
(snippet, index) => snippet !== savedSnippets[index],
|
||||
);
|
||||
if (needsOrderPersist) {
|
||||
const ver = ++snippetsWriteVersion.current;
|
||||
snippetsWriteOwnerRef.current = ver;
|
||||
const writePromise = withVaultImportLock("vault", async () => {
|
||||
if (snippetsWriteOwnerRef.current !== ver) return "superseded" as const;
|
||||
const latest = normalizeVaultOrder(
|
||||
readStoredArray<Snippet>(
|
||||
STORAGE_KEY_SNIPPETS,
|
||||
localStorageAdapter.readString(STORAGE_KEY_SNIPPETS),
|
||||
),
|
||||
);
|
||||
const persisted = localStorageAdapter.write(STORAGE_KEY_SNIPPETS, latest);
|
||||
if (!persisted) {
|
||||
notify.error(
|
||||
"Snippets could not be saved. Free some local storage space and try again.",
|
||||
"Scripts",
|
||||
);
|
||||
return "failed" as const;
|
||||
}
|
||||
if (snippetsWriteOwnerRef.current === ver) {
|
||||
snippetsRef.current = latest;
|
||||
setSnippets(latest);
|
||||
}
|
||||
return "written" as const;
|
||||
});
|
||||
snippetsWritePendingRef.current = writePromise;
|
||||
}
|
||||
localStorageAdapter.write(STORAGE_KEY_SNIPPETS, orderedSnippets);
|
||||
}
|
||||
else updateSnippets(INITIAL_SNIPPETS);
|
||||
|
||||
@@ -1492,7 +1372,7 @@ export const useVaultState = () => {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [commitEncryptedHostsUnderVaultLock, updateHosts, updateSnippets]);
|
||||
}, [updateHosts, updateSnippets]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInitialized) return;
|
||||
@@ -1597,42 +1477,11 @@ export const useVaultState = () => {
|
||||
}
|
||||
|
||||
if (key === STORAGE_KEY_SNIPPETS) {
|
||||
// StorageEvent.newValue is the value at fire time. A peer write can be
|
||||
// delivered only after this window's replace already committed and
|
||||
// cleared snippetsWriteReplaceRef — adopting that older payload would
|
||||
// resurrect the pre-replacement catalog in memory (and on the next edit).
|
||||
if (event.newValue !== localStorageAdapter.readString(STORAGE_KEY_SNIPPETS)) {
|
||||
return;
|
||||
}
|
||||
const next = normalizeVaultOrder(safeParse<Snippet[]>(event.newValue) ?? []);
|
||||
// Invalidate write-version readers, but do not clear snippetsWriteOwnerRef:
|
||||
// an in-flight updateSnippets rebases onto this disk snapshot under the
|
||||
// vault lock instead of being cancelled (which would drop local edits).
|
||||
++snippetsWriteVersion.current;
|
||||
const pendingBase = snippetsWriteBaseRef.current;
|
||||
if (pendingBase !== null) {
|
||||
// A queued local save still owns an outstanding ancestor. Replacing
|
||||
// optimistic state with the remote snapshot (and clearing that base)
|
||||
// would make the next local edit derive from remote-only and drop the
|
||||
// first mutation when it supersedes the queued owner.
|
||||
const merged = normalizeVaultOrder(
|
||||
rebaseSnippetVaultWrite({
|
||||
base: pendingBase,
|
||||
ours: snippetsRef.current,
|
||||
theirs: next,
|
||||
}),
|
||||
);
|
||||
snippetsRef.current = merged;
|
||||
setSnippets(merged);
|
||||
return;
|
||||
}
|
||||
if (snippetsWriteReplaceRef.current) {
|
||||
// Clear/restore/import left base null while replace is outstanding.
|
||||
// Adopting the remote catalog here would pollute memory; a later local
|
||||
// edit would still run in replace mode and overwrite disk with the old
|
||||
// catalog plus that edit.
|
||||
return;
|
||||
}
|
||||
snippetsRef.current = next;
|
||||
setSnippets(next);
|
||||
return;
|
||||
@@ -1749,12 +1598,15 @@ export const useVaultState = () => {
|
||||
hostsEncryptPendingRef.current = encryptPromise.then(() => undefined);
|
||||
const writePromise = encryptPromise.then(async (enc) => {
|
||||
if (ver !== hostsWriteVersion.current) return;
|
||||
return commitEncryptedHostsUnderVaultLock(ver, next, enc);
|
||||
return withVaultImportLock("vault", async () => {
|
||||
if (ver === hostsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||||
});
|
||||
});
|
||||
hostsWritePendingRef.current = writePromise;
|
||||
return next;
|
||||
});
|
||||
}, [commitEncryptedHostsUnderVaultLock]);
|
||||
}, []);
|
||||
|
||||
const updateHostDistro = useCallback((hostId: string, distro: string) => {
|
||||
const normalized = normalizeDistroId(distro);
|
||||
@@ -1767,12 +1619,15 @@ export const useVaultState = () => {
|
||||
hostsEncryptPendingRef.current = encryptPromise.then(() => undefined);
|
||||
const writePromise = encryptPromise.then(async (enc) => {
|
||||
if (ver !== hostsWriteVersion.current) return;
|
||||
return commitEncryptedHostsUnderVaultLock(ver, next, enc);
|
||||
return withVaultImportLock("vault", async () => {
|
||||
if (ver === hostsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||||
});
|
||||
});
|
||||
hostsWritePendingRef.current = writePromise;
|
||||
return next;
|
||||
});
|
||||
}, [commitEncryptedHostsUnderVaultLock]);
|
||||
}, []);
|
||||
|
||||
const exportData = useCallback(
|
||||
(): ExportableVaultData => ({
|
||||
@@ -1799,11 +1654,7 @@ export const useVaultState = () => {
|
||||
if (payload.identities) encryptedWrites.push(updateIdentities(payload.identities));
|
||||
if (Array.isArray(payload.proxyProfiles)) encryptedWrites.push(updateProxyProfiles(payload.proxyProfiles));
|
||||
if (payload.snippets) {
|
||||
// Full-snapshot restore/import must replace, not additive-rebase; otherwise
|
||||
// a concurrent disk-only snippet survives and the restore looks incomplete.
|
||||
encryptedWrites.push(
|
||||
updateSnippets(payload.snippets, { replace: true }).then(() => undefined),
|
||||
);
|
||||
encryptedWrites.push(updateSnippets(payload.snippets).then(() => undefined));
|
||||
}
|
||||
if (payload.customGroups) updateCustomGroups(payload.customGroups);
|
||||
if (payload.snippetPackages) updateSnippetPackages(payload.snippetPackages);
|
||||
@@ -1951,9 +1802,6 @@ export const useVaultState = () => {
|
||||
[STORAGE_KEY_GROUP_CONFIGS, encryptedGroupConfigs],
|
||||
]);
|
||||
customGroupsRef.current = nextGroups;
|
||||
snippetsRef.current = nextSnippets;
|
||||
snippetsWriteBaseRef.current = null;
|
||||
snippetsWriteReplaceRef.current = false;
|
||||
setHosts(nextHosts);
|
||||
setKeys(nextKeys);
|
||||
setIdentities(nextIdentities);
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { VaultOrderPosition } from '../../domain/vaultOrder';
|
||||
export interface VaultHostTreeActions {
|
||||
onDeleteHost: (host: Host) => void;
|
||||
onDuplicateHost: (host: Host) => void;
|
||||
onCopyHostname?: (host: Host) => void;
|
||||
onCopyCredentials: (host: Host) => void;
|
||||
onRenameHost: (host: Host) => void;
|
||||
onNewGroup: (parentPath?: string) => void;
|
||||
|
||||
@@ -87,7 +87,6 @@ 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,
|
||||
@@ -299,7 +298,6 @@ 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,
|
||||
@@ -521,8 +519,6 @@ 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);
|
||||
@@ -761,9 +757,6 @@ 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,51 +252,3 @@ 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, useMemo, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useDeferredValue, 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,7 +51,6 @@ 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';
|
||||
@@ -287,7 +286,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
scopeTargetId,
|
||||
scopeHostIds,
|
||||
scopeLabel,
|
||||
focusedSessionId,
|
||||
terminalSessions = [],
|
||||
resolveExecutorContext,
|
||||
isVisible = true,
|
||||
@@ -347,48 +345,24 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
return sessionIds;
|
||||
}, [activeSessionIdMap, scopeKey]);
|
||||
|
||||
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 deferredSessions = useDeferredValue(sessions);
|
||||
const historySessions = useMemo(
|
||||
() => profileAIPanelCalculation(
|
||||
'AIChatSidePanel.historySessions',
|
||||
() => getScopedHistorySessions(
|
||||
sessions,
|
||||
deferredSessions,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeHostIds,
|
||||
activeTerminalSessionIds,
|
||||
workspaceMemberTerminalIds,
|
||||
),
|
||||
),
|
||||
[sessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalSessionIds, workspaceMemberTerminalIds],
|
||||
[deferredSessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalSessionIds],
|
||||
);
|
||||
|
||||
const explicitPanelView = panelViewByScope[scopeKey];
|
||||
const currentDraft = draftsByScope[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 persistedSessionId = activeSessionIdMap[scopeKey] ?? null;
|
||||
const normalizedPanelView = useMemo<AIPanelView>(
|
||||
() => resolveDisplayedPanelView(explicitPanelView, currentDraft != null, historySessions, persistedSessionId, scopeType),
|
||||
[explicitPanelView, currentDraft, historySessions, persistedSessionId, scopeType],
|
||||
@@ -450,17 +424,9 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) 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;
|
||||
}
|
||||
if (!explicitPanelView || panelViewsEqual(normalizedPanelView, explicitPanelView)) return;
|
||||
showDraftView(scopeKey);
|
||||
}, [isVisible, normalizedPanelView, explicitPanelView, scopeKey, historySessions, showDraftView]);
|
||||
}, [isVisible, normalizedPanelView, explicitPanelView, scopeKey, showDraftView]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSession) return;
|
||||
@@ -479,24 +445,9 @@ 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,
|
||||
explicitPanelView,
|
||||
historySessions,
|
||||
persistedSessionId,
|
||||
setActiveSessionId,
|
||||
]);
|
||||
}, [isVisible, normalizedPanelView.mode, persistedSessionId, setActiveSessionId]);
|
||||
|
||||
const ensureScopeDraft = useCallback((agentId: string) => {
|
||||
ensureDraftForScope(scopeKey, agentId);
|
||||
@@ -1628,7 +1579,6 @@ 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;
|
||||
@@ -1647,45 +1597,10 @@ 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.
|
||||
// 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);
|
||||
const scopeKey = `${prev.scopeType}:${prev.scopeTargetId ?? ''}`;
|
||||
const selectedSessionId = prev.activeSessionIdMap[scopeKey]
|
||||
?? next.activeSessionIdMap[scopeKey]
|
||||
?? null;
|
||||
if (!exactScopeAISessionsEqual(
|
||||
prev.sessions,
|
||||
next.sessions,
|
||||
|
||||
@@ -87,8 +87,6 @@ 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<{
|
||||
|
||||
@@ -283,7 +283,6 @@ interface HostTreeViewProps {
|
||||
onDuplicateHost: (host: Host) => void;
|
||||
onDeleteHost: (host: Host) => void;
|
||||
onCopyCredentials: (host: Host) => void;
|
||||
onCopyHostname?: (host: Host) => void;
|
||||
onNewGroup: (parentPath?: string) => void;
|
||||
onRenameGroup: (groupPath: string) => void;
|
||||
onEditGroup: (groupPath: string) => void;
|
||||
@@ -323,7 +322,6 @@ interface TreeNodeProps {
|
||||
onDuplicateHost: (host: Host) => void;
|
||||
onDeleteHost: (host: Host) => void;
|
||||
onCopyCredentials: (host: Host) => void;
|
||||
onCopyHostname?: (host: Host) => void;
|
||||
onNewGroup: (parentPath?: string) => void;
|
||||
onRenameGroup: (groupPath: string) => void;
|
||||
onEditGroup: (groupPath: string) => void;
|
||||
@@ -369,7 +367,6 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
onDuplicateHost,
|
||||
onDeleteHost,
|
||||
onCopyCredentials,
|
||||
onCopyHostname,
|
||||
onNewGroup,
|
||||
onRenameGroup,
|
||||
onEditGroup,
|
||||
@@ -580,7 +577,6 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onDeleteHost={onDeleteHost}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
onCopyHostname={onCopyHostname}
|
||||
onNewGroup={onNewGroup}
|
||||
onRenameGroup={onRenameGroup}
|
||||
onEditGroup={onEditGroup}
|
||||
@@ -623,7 +619,6 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onDeleteHost={onDeleteHost}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
onCopyHostname={onCopyHostname}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
@@ -656,7 +651,6 @@ interface HostTreeItemProps {
|
||||
onDuplicateHost: (host: Host) => void;
|
||||
onDeleteHost: (host: Host) => void;
|
||||
onCopyCredentials: (host: Host) => void;
|
||||
onCopyHostname?: (host: Host) => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
|
||||
isMultiSelectMode?: boolean;
|
||||
@@ -702,7 +696,6 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
onDuplicateHost,
|
||||
onDeleteHost,
|
||||
onCopyCredentials,
|
||||
onCopyHostname,
|
||||
moveHostToGroup: _moveHostToGroup,
|
||||
|
||||
isMultiSelectMode,
|
||||
@@ -759,11 +752,7 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
: -1}
|
||||
onFocus={() => onActiveTreeItemChange(`host:${host.id}`)}
|
||||
draggable={!isMultiSelectMode}
|
||||
onDragStart={(e) => {
|
||||
// copyMove: vault reorder uses move; focus-sidebar append uses copy.
|
||||
e.dataTransfer.effectAllowed = "copyMove";
|
||||
e.dataTransfer.setData("host-id", host.id);
|
||||
}}
|
||||
onDragStart={(e) => e.dataTransfer.setData("host-id", host.id)}
|
||||
onClick={() => {
|
||||
const action = resolveHostActivateAction({
|
||||
behavior: normalizedHostClickBehavior,
|
||||
@@ -860,9 +849,7 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
<HostTreeHostContextMenuContent
|
||||
host={host}
|
||||
onConnect={onConnect}
|
||||
onEditHost={onEditHost}
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onCopyHostname={onCopyHostname}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
onDeleteHost={onDeleteHost}
|
||||
/>
|
||||
@@ -883,7 +870,6 @@ const HostTreeViewInner: React.FC<HostTreeViewProps> = ({
|
||||
onDuplicateHost,
|
||||
onDeleteHost,
|
||||
onCopyCredentials,
|
||||
onCopyHostname,
|
||||
onNewGroup,
|
||||
onRenameGroup,
|
||||
onEditGroup,
|
||||
@@ -1192,7 +1178,6 @@ const HostTreeViewInner: React.FC<HostTreeViewProps> = ({
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onDeleteHost={onDeleteHost}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
onCopyHostname={onCopyHostname}
|
||||
onNewGroup={onNewGroup}
|
||||
onRenameGroup={onRenameGroup}
|
||||
onEditGroup={onEditGroup}
|
||||
@@ -1233,7 +1218,6 @@ const HostTreeViewInner: React.FC<HostTreeViewProps> = ({
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onDeleteHost={onDeleteHost}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
onCopyHostname={onCopyHostname}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
@@ -1282,7 +1266,6 @@ function hostTreeViewAreEqual(prev: HostTreeViewProps, next: HostTreeViewProps):
|
||||
&& prev.onDuplicateHost === next.onDuplicateHost
|
||||
&& prev.onDeleteHost === next.onDeleteHost
|
||||
&& prev.onCopyCredentials === next.onCopyCredentials
|
||||
&& prev.onCopyHostname === next.onCopyHostname
|
||||
&& prev.onNewGroup === next.onNewGroup
|
||||
&& prev.onRenameGroup === next.onRenameGroup
|
||||
&& prev.onEditGroup === next.onEditGroup
|
||||
|
||||
@@ -11,7 +11,6 @@ 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,
|
||||
@@ -95,11 +94,6 @@ 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,
|
||||
@@ -318,12 +312,6 @@ 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
|
||||
@@ -616,7 +604,8 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
{/* Main Content */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 min-w-0 flex flex-col min-h-0",
|
||||
"flex-1 flex flex-col min-h-0",
|
||||
showWizard || showEditPanel || showNewForm ? "mr-[360px]" : "",
|
||||
)}
|
||||
>
|
||||
<VaultPageHeader className="z-20">
|
||||
@@ -784,7 +773,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Edit Panel - shown when a rule is selected */}
|
||||
{showEditPanel && editingRule && !showHostSelector && (
|
||||
{showEditPanel && editingRule && (
|
||||
<EditPanel
|
||||
rule={editingRule}
|
||||
draft={editDraft}
|
||||
@@ -800,12 +789,11 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
}}
|
||||
onDelete={() => handleDeleteRule(editingRule)}
|
||||
onOpenHostSelector={() => setShowHostSelector(true)}
|
||||
{...portForwardingPanelResizeProps}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Wizard Panel */}
|
||||
{showWizard && !showHostSelector && (
|
||||
{showWizard && (
|
||||
<AsidePanel
|
||||
open={true}
|
||||
onClose={() => {
|
||||
@@ -814,8 +802,6 @@ 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()
|
||||
@@ -916,14 +902,11 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
managedSources={managedSources}
|
||||
onSaveHost={onSaveHost}
|
||||
onCreateGroup={_onCreateGroup}
|
||||
width="w-[360px]"
|
||||
layout="inline"
|
||||
{...portForwardingPanelResizeProps}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* New Form Panel (skip wizard mode) */}
|
||||
{showNewForm && !showHostSelector && (
|
||||
{showNewForm && (
|
||||
<NewFormPanel
|
||||
draft={newFormDraft}
|
||||
hosts={hosts}
|
||||
@@ -935,7 +918,6 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
onOpenHostSelector={() => setShowHostSelector(true)}
|
||||
onOpenWizard={openWizardFromForm}
|
||||
isValid={isNewFormValid()}
|
||||
{...portForwardingPanelResizeProps}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -53,14 +53,10 @@ test("scripts side panel offers run-on-all-tabs for every snippet, not only auto
|
||||
assert.match(source, /openPackageDialog/);
|
||||
});
|
||||
|
||||
test("scripts side panel exposes create actions as inline toolbar icons", () => {
|
||||
assert.match(source, /snippets\.action\.newSnippet/);
|
||||
assert.match(source, /snippets\.action\.newPackage/);
|
||||
test("scripts side panel add control is a split button with secondary create actions", () => {
|
||||
assert.match(source, /ChevronDown/);
|
||||
assert.match(source, /snippets\.action\.newScript/);
|
||||
assert.match(source, /openPackageDialog/);
|
||||
assert.match(source, /handleAddScript/);
|
||||
assert.doesNotMatch(source, /ChevronDown/);
|
||||
assert.doesNotMatch(source, /DropdownTrigger/);
|
||||
assert.match(source, /snippets\.action\.newSnippet/);
|
||||
});
|
||||
|
||||
test("collectScriptsSidePanelPackagePaths includes implied ancestors", () => {
|
||||
@@ -73,29 +69,13 @@ test("collectScriptsSidePanelPackagePaths includes implied ancestors", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("scripts side panel search is icon-toggled and expands below the toolbar", () => {
|
||||
assert.match(source, /searchExpanded/);
|
||||
assert.match(source, /setSearchExpanded/);
|
||||
assert.match(source, /max-h-0 opacity-0/);
|
||||
assert.match(source, /snippets\.searchPlaceholder/);
|
||||
});
|
||||
|
||||
test("scripts side panel shows delete in a selection bar only when snippets are selected", () => {
|
||||
assert.match(source, /selectedSnippetIds\.size > 0/);
|
||||
assert.match(source, /snippets\.selection\.selected[\s\S]*?deleteSelectedSnippets/);
|
||||
assert.match(source, /snippets\.selection\.deleteSelected/);
|
||||
// Trash must not sit between multi-select and add on the permanent icon row.
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/snippets\.action\.selectSnippets[\s\S]*?Trash2[\s\S]*?snippets\.action\.newSnippet/,
|
||||
);
|
||||
});
|
||||
|
||||
test("scripts side panel toolbar exposes expand and collapse actions", () => {
|
||||
test("scripts side panel toolbar exposes expand, collapse, and delete-selected actions", () => {
|
||||
assert.match(source, /vault\.tree\.expandAll/);
|
||||
assert.match(source, /vault\.tree\.collapseAll/);
|
||||
assert.match(source, /snippets\.selection\.deleteSelected/);
|
||||
assert.match(source, /expandAllGroups/);
|
||||
assert.match(source, /collapseAllGroups/);
|
||||
assert.match(source, /deleteSelectedSnippets/);
|
||||
assert.match(source, /isMultiSelectMode/);
|
||||
});
|
||||
|
||||
|
||||
+136
-188
@@ -3,12 +3,13 @@
|
||||
*
|
||||
* Shows snippets organized by package hierarchy as a single tree view.
|
||||
* Packages expand / collapse via a chevron; clicking a snippet executes it
|
||||
* in the focused terminal session. Search is icon-toggled (expands below the
|
||||
* toolbar); typing flattens matches regardless of package nesting.
|
||||
* in the focused terminal session. Typing in the search box flattens to a
|
||||
* list of matching snippets regardless of package nesting.
|
||||
*/
|
||||
|
||||
import {
|
||||
CheckSquare,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Edit2,
|
||||
Expand,
|
||||
@@ -20,7 +21,6 @@ import {
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
X,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from './ui/context-menu';
|
||||
import { Dropdown, DropdownContent, DropdownTrigger } from './ui/dropdown';
|
||||
import { FixedSizeVirtualList } from './ui/FixedSizeVirtualList';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
@@ -287,7 +288,6 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [search, setSearch] = useState('');
|
||||
const [searchExpanded, setSearchExpanded] = useState(false);
|
||||
const [subView, setSubView] = useState<'library' | 'running'>('library');
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
|
||||
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);
|
||||
@@ -296,17 +296,9 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
const [isPackageDialogOpen, setIsPackageDialogOpen] = useState(false);
|
||||
const [newPackageName, setNewPackageName] = useState('');
|
||||
const [packageError, setPackageError] = useState('');
|
||||
const [addMenuOpen, setAddMenuOpen] = useState(false);
|
||||
const packageDialogRef = useRef<HTMLDivElement>(null);
|
||||
const packageNameInputRef = useRef<HTMLInputElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchExpanded) return;
|
||||
const frame = requestAnimationFrame(() => {
|
||||
searchInputRef.current?.focus();
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [searchExpanded]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: Event) => {
|
||||
@@ -879,159 +871,152 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Icon toolbar + expandable search */}
|
||||
<div className="shrink-0 border-b border-border/50">
|
||||
<div className="px-2 py-1.5 flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
toolbarIconButtonClass,
|
||||
(searchExpanded || hasSearch) && 'bg-muted/70 text-foreground',
|
||||
)}
|
||||
aria-label={t('snippets.searchPlaceholder')}
|
||||
aria-pressed={searchExpanded}
|
||||
onClick={() => setSearchExpanded((open) => !open)}
|
||||
>
|
||||
<Search size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t('snippets.searchPlaceholder')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={toolbarIconButtonClass}
|
||||
disabled={!canExpandCollapse}
|
||||
aria-label={t('vault.tree.expandAll')}
|
||||
onClick={expandAllGroups}
|
||||
>
|
||||
<Expand size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t('vault.tree.expandAll')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={toolbarIconButtonClass}
|
||||
disabled={!canExpandCollapse}
|
||||
aria-label={t('vault.tree.collapseAll')}
|
||||
onClick={collapseAllGroups}
|
||||
>
|
||||
<Minimize2 size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t('vault.tree.collapseAll')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
toolbarIconButtonClass,
|
||||
isMultiSelectMode && 'bg-muted/70 text-foreground',
|
||||
)}
|
||||
aria-label={t('snippets.action.selectSnippets')}
|
||||
aria-pressed={isMultiSelectMode}
|
||||
onClick={() => {
|
||||
if (isMultiSelectMode) {
|
||||
clearSnippetSelection();
|
||||
} else {
|
||||
setIsMultiSelectMode(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CheckSquare size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t('snippets.action.selectSnippets')}</TooltipContent>
|
||||
</Tooltip>
|
||||
{/* Search + tree actions + Add */}
|
||||
<div className="shrink-0 px-2 py-1.5 border-b border-border/50 flex items-center gap-1">
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('snippets.searchPlaceholder')}
|
||||
className="h-7 pl-7 text-xs bg-muted/30 border-none"
|
||||
/>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={toolbarIconButtonClass}
|
||||
disabled={!canExpandCollapse}
|
||||
aria-label={t('vault.tree.expandAll')}
|
||||
onClick={expandAllGroups}
|
||||
>
|
||||
<Expand size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t('vault.tree.expandAll')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={toolbarIconButtonClass}
|
||||
disabled={!canExpandCollapse}
|
||||
aria-label={t('vault.tree.collapseAll')}
|
||||
onClick={collapseAllGroups}
|
||||
>
|
||||
<Minimize2 size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t('vault.tree.collapseAll')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
toolbarIconButtonClass,
|
||||
isMultiSelectMode && 'bg-muted/70 text-foreground',
|
||||
)}
|
||||
aria-label={t('snippets.action.selectSnippets')}
|
||||
aria-pressed={isMultiSelectMode}
|
||||
onClick={() => {
|
||||
if (isMultiSelectMode) {
|
||||
clearSnippetSelection();
|
||||
} else {
|
||||
setIsMultiSelectMode(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CheckSquare size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t('snippets.action.selectSnippets')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
toolbarIconButtonClass,
|
||||
selectedSnippetIds.size > 0 && 'text-destructive hover:text-destructive',
|
||||
)}
|
||||
disabled={selectedSnippetIds.size === 0}
|
||||
aria-label={t('snippets.selection.deleteSelected', { count: selectedSnippetIds.size })}
|
||||
onClick={deleteSelectedSnippets}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{t('snippets.selection.deleteSelected', { count: selectedSnippetIds.size })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{/* Split add control: primary = new snippet; menu = package / automation script */}
|
||||
<div className="shrink-0 flex items-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddSnippet}
|
||||
aria-label={t('snippets.action.newSnippet')}
|
||||
className={cn(toolbarIconButtonClass, 'ml-auto')}
|
||||
className="h-7 w-7 flex items-center justify-center rounded-l-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t('snippets.action.newSnippet')}</TooltipContent>
|
||||
<TooltipContent>{t('snippets.action.newSnippet')}</TooltipContent>
|
||||
</Tooltip>
|
||||
{onPackagesChange ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openPackageDialog}
|
||||
aria-label={t('snippets.action.newPackage')}
|
||||
className={toolbarIconButtonClass}
|
||||
>
|
||||
<FolderPlus size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t('snippets.action.newPackage')}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Dropdown open={addMenuOpen} onOpenChange={setAddMenuOpen}>
|
||||
<DropdownTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddScript}
|
||||
aria-label={t('snippets.action.newScript')}
|
||||
className={toolbarIconButtonClass}
|
||||
aria-label={t('common.more')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={addMenuOpen}
|
||||
className="h-7 w-5 flex items-center justify-center rounded-r-md border-l border-border/40 text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
>
|
||||
<Play size={14} />
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t('snippets.action.newScript')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden transition-[max-height,opacity] duration-200 ease-out',
|
||||
searchExpanded ? 'max-h-9 opacity-100' : 'max-h-0 opacity-0',
|
||||
)}
|
||||
>
|
||||
<div className="h-9 flex items-center gap-0.5 px-2 border-t border-border/50">
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<Search
|
||||
size={12}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none"
|
||||
/>
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('snippets.searchPlaceholder')}
|
||||
className="h-7 pl-7 text-xs bg-muted/30 border-none"
|
||||
/>
|
||||
</div>
|
||||
{hasSearch ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={toolbarIconButtonClass}
|
||||
aria-label={t('common.clear')}
|
||||
onClick={() => {
|
||||
setSearch('');
|
||||
searchInputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t('common.clear')}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
</DropdownTrigger>
|
||||
<DropdownContent className="w-48 p-1" align="end">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-md hover:bg-muted transition-colors"
|
||||
onClick={() => {
|
||||
setAddMenuOpen(false);
|
||||
handleAddSnippet();
|
||||
}}
|
||||
>
|
||||
<Zap size={12} className="text-muted-foreground" />
|
||||
{t('snippets.action.newSnippet')}
|
||||
</button>
|
||||
{onPackagesChange ? (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-md hover:bg-muted transition-colors"
|
||||
onClick={() => {
|
||||
setAddMenuOpen(false);
|
||||
openPackageDialog();
|
||||
}}
|
||||
>
|
||||
<FolderPlus size={12} className="text-muted-foreground" />
|
||||
{t('snippets.action.newPackage')}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-md hover:bg-muted transition-colors"
|
||||
onClick={() => {
|
||||
setAddMenuOpen(false);
|
||||
handleAddScript();
|
||||
}}
|
||||
>
|
||||
<Play size={12} className="text-primary" />
|
||||
{t('snippets.action.newScript')}
|
||||
</button>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1134,43 +1119,6 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{selectedSnippetIds.size > 0 ? (
|
||||
<div className="shrink-0 px-2 py-1.5 border-t border-border/50 flex items-center gap-2">
|
||||
<span className="flex-1 min-w-0 text-[11px] text-muted-foreground truncate">
|
||||
{t('snippets.selection.selected', { count: selectedSnippetIds.size })}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(toolbarIconButtonClass, 'text-destructive hover:text-destructive')}
|
||||
aria-label={t('snippets.selection.deleteSelected', {
|
||||
count: selectedSnippetIds.size,
|
||||
})}
|
||||
onClick={deleteSelectedSnippets}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{t('snippets.selection.deleteSelected', { count: selectedSnippetIds.size })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={toolbarIconButtonClass}
|
||||
aria-label={t('snippets.selection.deselectAll')}
|
||||
onClick={clearSnippetSelection}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{t('snippets.selection.deselectAll')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="shrink-0 px-2 py-2 border-t border-border/50 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -25,7 +25,6 @@ interface SelectHostPanelProps {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
className?: string;
|
||||
width?: string;
|
||||
layout?: AsidePanelLayout;
|
||||
resizable?: boolean;
|
||||
persistWidthStorageKey?: string;
|
||||
@@ -51,7 +50,6 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
title,
|
||||
subtitle,
|
||||
className,
|
||||
width,
|
||||
layout = "overlay",
|
||||
resizable = false,
|
||||
persistWidthStorageKey,
|
||||
@@ -81,7 +79,6 @@ 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 rounded-lg px-2.5 transition-colors',
|
||||
'flex h-full min-h-0 items-center gap-2.5 overflow-hidden px-2.5 rounded-lg 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 / .rpm / .pacman)";
|
||||
: "GitHub Release (.dmg / .exe / .AppImage / .deb)";
|
||||
params.set("install_source", installSource);
|
||||
|
||||
const ua = typeof navigator !== "undefined" ? navigator.userAgent : "unknown";
|
||||
|
||||
@@ -572,8 +572,6 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
setHotkeyScheme={settings.setHotkeyScheme}
|
||||
shellOnlyTabNumberShortcuts={settings.shellOnlyTabNumberShortcuts}
|
||||
setShellOnlyTabNumberShortcuts={settings.setShellOnlyTabNumberShortcuts}
|
||||
showTabNumberBadges={settings.showTabNumberBadges}
|
||||
setShowTabNumberBadges={settings.setShowTabNumberBadges}
|
||||
disableTerminalFontZoom={settings.disableTerminalFontZoom}
|
||||
setDisableTerminalFontZoom={settings.setDisableTerminalFontZoom}
|
||||
keyBindings={settings.keyBindings}
|
||||
|
||||
@@ -128,11 +128,6 @@ 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;
|
||||
@@ -182,7 +177,6 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
onActiveExternalEditsChange,
|
||||
showWorkspaceHostHeader = false,
|
||||
isVisible = true,
|
||||
ownerPanelOpen = false,
|
||||
renderOverlays = true,
|
||||
pendingUpload = null,
|
||||
onPendingUploadHandled,
|
||||
@@ -264,11 +258,8 @@ 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,
|
||||
}),
|
||||
@@ -284,7 +275,6 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
fileWatchHandlers,
|
||||
hasOwnedEditorTab,
|
||||
isVisible,
|
||||
ownerPanelOpen,
|
||||
transferOwnerId,
|
||||
sftpUseCompressedUpload,
|
||||
sftpShowHiddenFiles,
|
||||
@@ -1871,7 +1861,6 @@ 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,9 +126,8 @@ 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 parks only after the panel is closed (not when
|
||||
// switching History/System while the chrome stays open).
|
||||
// (e.g. Terminal ↔ SFTP) must not soft-close every tab's session;
|
||||
// the terminal side panel still parks when its panel is hidden.
|
||||
// Bulk transfers use dedicated pool sessions regardless.
|
||||
interactive: true,
|
||||
useCompressedUpload: sftpUseCompressedUpload,
|
||||
|
||||
+21
-140
@@ -225,16 +225,8 @@ 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";
|
||||
@@ -278,7 +270,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
sessionId,
|
||||
workspaceId,
|
||||
restoreState,
|
||||
vaultInitializedOverride,
|
||||
pendingInitialCwd,
|
||||
shellType,
|
||||
lastCwd,
|
||||
@@ -368,15 +359,10 @@ 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 sharedVaultInitialized = useVaultSnapshotField("isVaultInitialized");
|
||||
const vaultInitialized = resolveTerminalVaultInitialized(
|
||||
sharedVaultInitialized,
|
||||
vaultInitializedOverride,
|
||||
);
|
||||
const vaultInitialized = useVaultSnapshotField("isVaultInitialized");
|
||||
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);
|
||||
@@ -789,12 +775,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const sudoHintRef = useRef<((active: boolean) => boolean) | undefined>(undefined);
|
||||
|
||||
const terminalBackend = useTerminalBackend();
|
||||
const {
|
||||
chooseManualSessionLogPath,
|
||||
startManualSessionLog,
|
||||
stopManualSessionLog,
|
||||
getManualSessionLogStatus,
|
||||
} = useSessionLogBackend();
|
||||
const { startManualSessionLog, stopManualSessionLog, getManualSessionLogStatus } = useSessionLogBackend();
|
||||
const {
|
||||
resizeSession,
|
||||
receiveSerialYmodem,
|
||||
@@ -2525,19 +2506,9 @@ 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
|
||||
? []
|
||||
@@ -2566,10 +2537,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
})) {
|
||||
connectScriptsConsumedRef.current = true;
|
||||
}
|
||||
if (connectScriptsBatchRef.current === batch) {
|
||||
connectScriptsBatchRef.current = null;
|
||||
connectScriptsInFlightRef.current = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2578,13 +2545,11 @@ 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,
|
||||
@@ -2592,7 +2557,6 @@ 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);
|
||||
}
|
||||
@@ -2606,7 +2570,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
if (!batchStillActive()) return;
|
||||
const resolvedAfterRun = resolveConnectScriptsForHost(host, snippets);
|
||||
const doneAfterRun = resolvedAfterRun.length === 0
|
||||
|| resolvedAfterRun.every(
|
||||
@@ -2617,7 +2580,6 @@ 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;
|
||||
@@ -2633,10 +2595,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
await runConnectScriptsSequential({
|
||||
scripts: [pendingScriptToMark],
|
||||
sessionId,
|
||||
signal: batch.controller.signal,
|
||||
onCancelableRunChange: (stopCurrentRun) => (
|
||||
trackConnectAutomationStop(batch, stopCurrentRun)
|
||||
),
|
||||
sessionMeta: {
|
||||
connected: true,
|
||||
name: scriptSessionName,
|
||||
@@ -2644,7 +2602,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
username: host.username,
|
||||
},
|
||||
onScriptComplete: (snippet) => {
|
||||
if (!batchStillActive()) return;
|
||||
if (snippet.id) {
|
||||
pendingScriptRunIdRef.current = snippet.id;
|
||||
} else {
|
||||
@@ -2653,32 +2610,16 @@ 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(() => {
|
||||
if (
|
||||
!batch.controller.signal.aborted
|
||||
&& connectScriptsBatchRef.current === batch
|
||||
) {
|
||||
connectScriptsBatchRef.current = null;
|
||||
connectScriptsInFlightRef.current = false;
|
||||
}
|
||||
connectScriptsInFlightRef.current = false;
|
||||
});
|
||||
}, 400);
|
||||
|
||||
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(() => {});
|
||||
}
|
||||
};
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [effectiveTerminalProtocol, host, isPendingScriptAlreadyHandled, moshShellReady, pendingScript, pendingScriptId, scriptSessionName, sessionId, snippets, status, t]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -3406,8 +3347,20 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return;
|
||||
}
|
||||
if (!termRef.current) return;
|
||||
// Claim the retry before awaiting either script cancellation or backend
|
||||
// cleanup. A second reconnect/cancel invalidates this continuation.
|
||||
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.
|
||||
const retryToken = Symbol("retry");
|
||||
retryTokenRef.current = retryToken;
|
||||
reconnectPreparationTokenRef.current = retryToken;
|
||||
@@ -3418,59 +3371,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const connectAutomationBatch = connectScriptsBatchRef.current;
|
||||
if (connectAutomationBatch) {
|
||||
try {
|
||||
await cancelConnectAutomationBatch(connectAutomationBatch);
|
||||
} catch (error) {
|
||||
finishReconnectPreparation();
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
toast.error(message);
|
||||
if (mode === "auto" && retryTokenStillCurrent()) {
|
||||
// Return to disconnected so the existing auto-reconnect loop can
|
||||
// schedule another attempt, including another exact stop request.
|
||||
updateStatus("disconnected");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!retryTokenStillCurrent()) {
|
||||
finishReconnectPreparation();
|
||||
return;
|
||||
}
|
||||
if (connectScriptsBatchRef.current === connectAutomationBatch) {
|
||||
connectScriptsBatchRef.current = null;
|
||||
connectScriptsInFlightRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === "manual") {
|
||||
clearAutoReconnect();
|
||||
// Manual reconnect skips the disconnected status transition that normally
|
||||
// clears these refs, so onConnect scripts would otherwise stay consumed.
|
||||
if (shouldResetConnectAutomationOnReconnect(
|
||||
restoreState === "restored-disconnected" ? "restored" : "manual",
|
||||
)) {
|
||||
// The prior batch is now stopped, so its callbacks cannot mutate these
|
||||
// newly allocated guards for the fresh manual connection.
|
||||
connectScriptsConsumedRef.current = false;
|
||||
connectScriptsCompletedIdsRef.current = new Set();
|
||||
connectScriptsInFlightRef.current = false;
|
||||
}
|
||||
prepareRestoredReconnect();
|
||||
// A clone's first connection can fail (auth/host-key/transport) before the
|
||||
// inherited `cd` is consumed. prepareRestoredReconnect() just cleared the
|
||||
// intent for non-restored sessions, so re-arm it here; the callback no-ops
|
||||
// once the cwd was consumed or when there is no pending inherited cwd.
|
||||
prepareInitialCwdIntent();
|
||||
} else {
|
||||
// An automatic reconnect replaces the transport but remains the same user
|
||||
// session. Stop old automation above, then preserve the existing policy of
|
||||
// not starting onConnect scripts again on the replacement transport.
|
||||
connectScriptsConsumedRef.current = true;
|
||||
restoreCwdIntentRef.current = null;
|
||||
suppressHostStartupCommandRef.current = shouldSuppressHostStartupCommandOnReconnect("automatic");
|
||||
}
|
||||
|
||||
try {
|
||||
await cleanupSession();
|
||||
} catch (error) {
|
||||
@@ -3660,31 +3560,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// 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({
|
||||
const startResult = await startManualSessionLog({
|
||||
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;
|
||||
@@ -3697,7 +3579,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
toast.error("Failed to toggle session log");
|
||||
}
|
||||
}, [
|
||||
chooseManualSessionLogPath,
|
||||
getManualSessionLogStatus,
|
||||
host.hostname,
|
||||
host.label,
|
||||
|
||||
@@ -93,7 +93,6 @@ import {
|
||||
shouldCloseSftpSidePanel,
|
||||
shouldClearSftpPanelAfterTransferChange,
|
||||
shouldKeepSftpMountedAfterClose,
|
||||
shouldMarkSftpPaneClosed,
|
||||
shouldScheduleSftpRetainedPanelCleanup,
|
||||
terminalSftpTransferOwnerId,
|
||||
} from './terminalLayer/sftpPanelLifecycle';
|
||||
@@ -220,7 +219,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onCreateWorkspaceFromSessions,
|
||||
onAddSessionToWorkspace,
|
||||
onRequestAddToWorkspace,
|
||||
onAppendHostToWorkspace,
|
||||
onUpdateSplitSizes,
|
||||
onSetDraggingSessionId,
|
||||
onToggleWorkspaceViewMode,
|
||||
@@ -270,7 +268,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
setSidePanelOpenTabs,
|
||||
sidePanelOpenTabsRef,
|
||||
sidePanelLayouts,
|
||||
setSidePanelLayouts,
|
||||
sidePanelLayoutsRef,
|
||||
focusPane: focusSidePanelPaneForTab,
|
||||
splitPane: splitSidePanelPaneForTab,
|
||||
@@ -413,7 +410,6 @@ 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
|
||||
? {
|
||||
@@ -581,7 +577,6 @@ 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());
|
||||
@@ -617,7 +612,6 @@ 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;
|
||||
@@ -835,7 +829,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
sftpRetainedCleanupTimersRef.current.delete(tabId);
|
||||
}
|
||||
sftpRetainedAfterCloseTabIdsRef.current.delete(tabId);
|
||||
sftpPaneClosedTabIdsRef.current.delete(tabId);
|
||||
|
||||
setSidePanelOpenTabs(prev => {
|
||||
const next = new Map(prev);
|
||||
@@ -1335,7 +1328,6 @@ 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
|
||||
@@ -1417,22 +1409,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const handleCloseSidePanelPane = useCallback((paneId: string) => {
|
||||
const tabId = activeTabIdRef.current;
|
||||
if (!tabId) return;
|
||||
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) {
|
||||
if (closeSidePanelPaneForTab(tabId, paneId)) {
|
||||
handleCloseSidePanel();
|
||||
return;
|
||||
}
|
||||
if (shouldMarkSftpPaneClosed({
|
||||
closingPaneTool: closingPane?.tool,
|
||||
closesWholePanel: closesWholePanel,
|
||||
})) {
|
||||
sftpPaneClosedTabIdsRef.current.add(tabId);
|
||||
}
|
||||
}, [closeSidePanelPaneForTab, handleCloseSidePanel, sidePanelLayoutsRef]);
|
||||
}, [closeSidePanelPaneForTab, handleCloseSidePanel]);
|
||||
|
||||
const handleResizeSidePanelSplit = useCallback((splitId: string, sizes: number[]) => {
|
||||
const tabId = activeTabIdRef.current;
|
||||
@@ -2108,7 +2088,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onCopySession,
|
||||
onCopySessionToNewWindow,
|
||||
onRequestAddToWorkspace,
|
||||
onAppendHostToWorkspace,
|
||||
onSessionData,
|
||||
onSetDraggingSessionId,
|
||||
onSetWorkspaceFocusedSession,
|
||||
@@ -2157,7 +2136,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
setSystemMountedTabIds,
|
||||
setThemeMountedTabIds,
|
||||
setSidePanelOpenTabs,
|
||||
setSidePanelLayouts,
|
||||
setSidePanelWidth,
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpHostForTab,
|
||||
@@ -2175,8 +2153,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
sftpHostForTab,
|
||||
sftpInitialLocationForTab,
|
||||
sftpPendingUploadsForTab,
|
||||
sftpPaneClosedTabIdsRef,
|
||||
sftpRetainedAfterCloseTabIdsRef,
|
||||
sftpShowHiddenFiles,
|
||||
SftpSidePanel,
|
||||
sftpUseCompressedUpload,
|
||||
|
||||
@@ -141,10 +141,6 @@ 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,7 +467,6 @@ function TerminalPopupPageInner() {
|
||||
accentMode={settings.accentMode}
|
||||
customAccent={settings.customAccent}
|
||||
terminalSettings={settings.terminalSettings}
|
||||
vaultInitializedOverride={vaultInitialized}
|
||||
disableTerminalFontZoom={settings.disableTerminalFontZoom}
|
||||
sessionId={sessionId}
|
||||
startupCommand={isAttachMode ? undefined : config.startupCommand}
|
||||
|
||||
@@ -361,14 +361,6 @@ 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"],
|
||||
|
||||
+5
-89
@@ -1,17 +1,11 @@
|
||||
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, toEditorTabId, useActiveTabId } from '../application/state/activeTabStore';
|
||||
import { fromEditorTabId, isEditorTabId, 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 {
|
||||
appendHostFromWorkspaceDrop,
|
||||
resolveFocusSidebarDragKind,
|
||||
} from '../domain/focusSidebarHostDrop';
|
||||
import type { DynamicTabTitleMode, KeyBinding } from '../domain/models';
|
||||
import type { DynamicTabTitleMode } from '../domain/models';
|
||||
|
||||
import { getTopTabInsertionTarget, getWorkspaceSessionDragId, hasWorkspaceSessionDrag } from '../application/state/terminalDragData';
|
||||
import {
|
||||
@@ -21,7 +15,6 @@ 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';
|
||||
@@ -160,10 +153,8 @@ 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[];
|
||||
@@ -206,10 +197,8 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onEndSessionDrag,
|
||||
onReorderTabs,
|
||||
onRemoveSessionFromWorkspace,
|
||||
onAppendHostToWorkspace,
|
||||
showSftpTab,
|
||||
showHostTreeSidebar,
|
||||
switchTabKeyBinding,
|
||||
dynamicTabTitleMode,
|
||||
editorTabs,
|
||||
pluginViewTabs,
|
||||
@@ -219,28 +208,6 @@ 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();
|
||||
@@ -266,7 +233,6 @@ 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);
|
||||
|
||||
@@ -528,28 +494,12 @@ 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);
|
||||
@@ -567,40 +517,16 @@ 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 next = e.relatedTarget;
|
||||
if (next instanceof Node && e.currentTarget.contains(next)) return;
|
||||
setHostDropWorkspaceId(null);
|
||||
const handleTabDragLeave = useCallback((_e: React.DragEvent) => {
|
||||
// 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);
|
||||
@@ -629,7 +555,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
|
||||
setDropIndicator(null);
|
||||
setIsDraggingForReorder(false);
|
||||
}, [dropIndicator, onAppendHostToWorkspace, onEndSessionDrag, onRemoveSessionFromWorkspace, onReorderTabs, sessions, workspaceMap, workspaces]);
|
||||
}, [dropIndicator, onEndSessionDrag, onRemoveSessionFromWorkspace, onReorderTabs, sessions, workspaces]);
|
||||
|
||||
const handleTabBarDrop = useCallback((e: React.DragEvent) => {
|
||||
if (!hasWorkspaceSessionDrag(e.dataTransfer)) return;
|
||||
@@ -826,7 +752,6 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onTabDragLeave={handleTabDragLeave}
|
||||
onTabDrop={handleTabDrop}
|
||||
tabAnimationClass={getTabAnimationClass(tabId)}
|
||||
shortcutNumber={tabShortcutNumbers?.get(tabId)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -851,7 +776,6 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onTabDragLeave={handleTabDragLeave}
|
||||
onTabDrop={handleTabDrop}
|
||||
tabAnimationClass={getTabAnimationClass(tabId)}
|
||||
shortcutNumber={tabShortcutNumbers?.get(tabId)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -887,7 +811,6 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
dynamicTabTitleMode={dynamicTabTitleMode}
|
||||
t={t}
|
||||
tabAnimationClass={getTabAnimationClass(session.id)}
|
||||
shortcutNumber={tabShortcutNumbers?.get(session.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -919,7 +842,6 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
shiftStyle={shiftStyle}
|
||||
showDropIndicatorBefore={showDropIndicatorBefore}
|
||||
showDropIndicatorAfter={showDropIndicatorAfter}
|
||||
isHostDropTarget={hostDropWorkspaceId === workspace.id}
|
||||
onTabDragStart={handleTabDragStart}
|
||||
onTabDragEnd={handleTabDragEnd}
|
||||
onTabDragOver={handleTabDragOver}
|
||||
@@ -932,7 +854,6 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
renderBulkCloseItems={renderBulkCloseItems}
|
||||
t={t}
|
||||
tabAnimationClass={getTabAnimationClass(workspace.id)}
|
||||
shortcutNumber={tabShortcutNumbers?.get(workspace.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -961,7 +882,6 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onTabDrop={handleTabDrop}
|
||||
t={t}
|
||||
tabAnimationClass={getTabAnimationClass(logView.id)}
|
||||
shortcutNumber={tabShortcutNumbers?.get(logView.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1013,7 +933,6 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
icon={<FolderLock size={14} />}
|
||||
className="rounded"
|
||||
compact={rootTabsCompact}
|
||||
shortcutNumber={tabShortcutNumbers?.get('vault')}
|
||||
/>
|
||||
{showSftpTab && (
|
||||
<RootTopTab
|
||||
@@ -1022,7 +941,6 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
icon={<Folder size={14} />}
|
||||
className="rounded-t-md"
|
||||
compact={rootTabsCompact}
|
||||
shortcutNumber={tabShortcutNumbers?.get('sftp')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -1256,7 +1174,6 @@ 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 &&
|
||||
@@ -1269,7 +1186,6 @@ 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-[300px] max-w-full aspect-[300/170]">
|
||||
<div className="relative w-full h-full">
|
||||
{/* 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" viewBox="0 0 300 170" preserveAspectRatio="none" style={{ zIndex: 1 }}>
|
||||
<svg className="absolute inset-0 w-full h-full" style={{ zIndex: 1 }}>
|
||||
{/* App to Firewall - blocked red line (with gap) */}
|
||||
<AnimatedLine x1={78} y1={44} x2={122} y2={44} isAnimating={false} isBlocked />
|
||||
<AnimatedLine x1={78} y1={40} x2={127} y2={40} isAnimating={false} isBlocked />
|
||||
{/* App to SSH Server - blue animated (with gap) */}
|
||||
<AnimatedLine x1={76} y1={64} x2={126} y2={110} isAnimating={isAnimating} />
|
||||
<AnimatedLine x1={78} y1={58} x2={145} y2={138} isAnimating={isAnimating} />
|
||||
{/* SSH Server to targets - blue animated (with gap) */}
|
||||
<AnimatedLine x1={174} y1={112} x2={242} y2={28} isAnimating={isAnimating} />
|
||||
<AnimatedLine x1={178} y1={120} x2={242} y2={68} isAnimating={isAnimating} />
|
||||
<AnimatedLine x1={178} y1={148} x2={238} y2={58} isAnimating={isAnimating} />
|
||||
<AnimatedLine x1={178} y1={152} x2={238} y2={88} isAnimating={isAnimating} />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ========== REMOTE FORWARDING ========== */}
|
||||
{type === 'remote' && (
|
||||
<div className="relative w-[300px] max-w-full aspect-[300/170]">
|
||||
<div className="relative w-full h-full">
|
||||
{/* 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" viewBox="0 0 300 170" preserveAspectRatio="none" style={{ zIndex: 1 }}>
|
||||
<svg className="absolute inset-0 w-full h-full" style={{ zIndex: 1 }}>
|
||||
{/* Left server to Firewall - blocked (with gap) */}
|
||||
<AnimatedLine x1={70} y1={40} x2={122} y2={40} isAnimating={false} isBlocked />
|
||||
<AnimatedLine x1={68} y1={38} x2={128} y2={38} isAnimating={false} isBlocked />
|
||||
{/* Left server to App - blue animated (with gap) */}
|
||||
<AnimatedLine x1={68} y1={58} x2={124} y2={128} isAnimating={isAnimating} />
|
||||
<AnimatedLine x1={58} y1={58} x2={145} y2={135} isAnimating={isAnimating} />
|
||||
{/* Right server to App - blue animated (with gap) */}
|
||||
<AnimatedLine x1={250} y1={58} x2={176} y2={128} isAnimating={isAnimating} reverse />
|
||||
<AnimatedLine x1={262} y1={58} x2={175} y2={135} isAnimating={isAnimating} reverse />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ========== DYNAMIC FORWARDING ========== */}
|
||||
{type === 'dynamic' && (
|
||||
<div className="relative w-[300px] max-w-full aspect-[300/170]">
|
||||
<div className="relative w-full h-full">
|
||||
{/* 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,16 +175,15 @@ export const TrafficDiagram: React.FC<TrafficDiagramProps> = ({ type, isAnimatin
|
||||
</div>
|
||||
|
||||
{/* SVG Lines */}
|
||||
<svg className="absolute inset-0 w-full h-full" viewBox="0 0 300 170" preserveAspectRatio="none" style={{ zIndex: 1 }}>
|
||||
<svg className="absolute inset-0 w-full h-full" style={{ zIndex: 1 }}>
|
||||
{/* App to Firewall - blocked (with gap) */}
|
||||
<AnimatedLine x1={78} y1={44} x2={122} y2={44} isAnimating={false} isBlocked />
|
||||
<AnimatedLine x1={78} y1={42} x2={128} y2={42} isAnimating={false} isBlocked />
|
||||
{/* App to SSH Server - blue animated (with gap) */}
|
||||
<AnimatedLine x1={76} y1={64} x2={126} y2={110} isAnimating={isAnimating} />
|
||||
<AnimatedLine x1={78} y1={58} x2={145} y2={138} isAnimating={isAnimating} />
|
||||
{/* SSH Server to clouds - blue animated (with gap) */}
|
||||
<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} />
|
||||
<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} />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -61,7 +61,6 @@ import {
|
||||
} from "../domain/groupConfig";
|
||||
import {
|
||||
getEffectiveHostDistro,
|
||||
getHostAddressForClipboard,
|
||||
resolveTelnetPassword,
|
||||
resolveTelnetPort,
|
||||
resolveTelnetUsername,
|
||||
@@ -729,28 +728,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
}
|
||||
}, [hosts, keys, t]);
|
||||
|
||||
// Copy hostname/IP for cross-host paste without opening the editor
|
||||
const handleCopyHostname = useCallback(
|
||||
(host: Host) => {
|
||||
const hostname = getHostAddressForClipboard(host);
|
||||
if (!hostname) {
|
||||
toast.error(t("terminal.statusbar.copyHostname.error"));
|
||||
return;
|
||||
}
|
||||
void navigator.clipboard.writeText(hostname).then(
|
||||
() => {
|
||||
toast.success(
|
||||
t("terminal.statusbar.copyHostname.toast", { hostname }),
|
||||
);
|
||||
},
|
||||
() => {
|
||||
toast.error(t("terminal.statusbar.copyHostname.error"));
|
||||
},
|
||||
);
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
// Copy host credentials to clipboard
|
||||
const handleCopyCredentials = useCallback(
|
||||
(host: Host) => {
|
||||
@@ -1342,7 +1319,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
|
||||
useRegisterVaultHostTreeActions({
|
||||
handleCopyCredentials,
|
||||
handleCopyHostname,
|
||||
handleDuplicateHost,
|
||||
startInlineRenameHost,
|
||||
onDeleteHost,
|
||||
@@ -1467,7 +1443,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
groupedDisplayHosts,
|
||||
handleConnectClick,
|
||||
handleCopyCredentials,
|
||||
handleCopyHostname,
|
||||
handleDeleteTag,
|
||||
handleDuplicateHost,
|
||||
handleEditGroupConfig,
|
||||
|
||||
@@ -38,16 +38,24 @@ test("resolved approval state retains only tool calls still present in messages"
|
||||
assert.deepEqual([...next], [["live-call", false]]);
|
||||
});
|
||||
|
||||
test("assistant content stays plain only when markdown is hidden", () => {
|
||||
test("assistant content stays plain when markdown is hidden or streaming", () => {
|
||||
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 Streamdown for the streaming assistant message", () => {
|
||||
test("ChatMessageList renders plain text for the streaming assistant message", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{
|
||||
id: "user-1",
|
||||
@@ -75,16 +83,16 @@ test("ChatMessageList renders Streamdown for the streaming assistant message", (
|
||||
),
|
||||
);
|
||||
|
||||
assert.match(markup, /data-ai-content="markdown"/);
|
||||
assert.match(markup, /data-ai-content="plain"/);
|
||||
assert.match(markup, /streaming-body/);
|
||||
assert.doesNotMatch(markup, /data-ai-content="plain"/);
|
||||
assert.doesNotMatch(markup, /data-ai-content="markdown"/);
|
||||
});
|
||||
|
||||
test("streaming assistant content keeps Streamdown live with isAnimating", () => {
|
||||
test("streaming assistant content skips Streamdown while the turn is live", () => {
|
||||
const source = readFileSync(new URL("./ChatMessageList.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /isAnimating=\{!!isThisStreaming\}/);
|
||||
assert.doesNotMatch(source, /isStreaming: !!isThisStreaming/);
|
||||
assert.match(source, /isStreaming: !!isThisStreaming/);
|
||||
assert.match(source, /shouldRenderAssistantAsPlainText\(\{[\s\S]*?isStreaming/);
|
||||
});
|
||||
|
||||
test("ChatMessageList hydrates markdown after streaming settles", () => {
|
||||
|
||||
@@ -113,10 +113,9 @@ export function shouldProvideVaultArtifactNavigation({
|
||||
|
||||
export function shouldRenderAssistantAsPlainText(options: {
|
||||
hideMarkdown: boolean;
|
||||
isStreaming?: boolean;
|
||||
}): boolean {
|
||||
// Streaming stays on Streamdown with isAnimating so incomplete markdown
|
||||
// updates live. Only diagnostic hideMarkdown forces plain text.
|
||||
return options.hideMarkdown;
|
||||
return options.hideMarkdown || !!options.isStreaming;
|
||||
}
|
||||
|
||||
const ASSISTANT_PLAIN_TEXT_CLASS = 'whitespace-pre-wrap break-words text-[13px] leading-[1.45]';
|
||||
@@ -702,7 +701,10 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
{message.content && (
|
||||
isUser
|
||||
? <div className={ASSISTANT_PLAIN_TEXT_CLASS}>{message.content}</div>
|
||||
: shouldRenderAssistantAsPlainText({ hideMarkdown })
|
||||
: shouldRenderAssistantAsPlainText({
|
||||
hideMarkdown,
|
||||
isStreaming: !!isThisStreaming,
|
||||
})
|
||||
? (
|
||||
<div
|
||||
className={ASSISTANT_PLAIN_TEXT_CLASS}
|
||||
@@ -714,7 +716,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
: (
|
||||
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Markdown')}>
|
||||
<div data-ai-content="markdown">
|
||||
<LazyMessageResponse isAnimating={!!isThisStreaming}>
|
||||
<LazyMessageResponse isAnimating={false}>
|
||||
{message.content}
|
||||
</LazyMessageResponse>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
panelViewsEqual,
|
||||
resolveDisplayedPanelView,
|
||||
resolveDisplayedSession,
|
||||
shouldForceDraftViewSync,
|
||||
} from "./aiPanelViewState.ts";
|
||||
|
||||
function createSession(id: string): AISession {
|
||||
@@ -76,54 +75,6 @@ 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")];
|
||||
|
||||
@@ -169,44 +120,18 @@ 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 without an active chat", () => {
|
||||
test("missing explicit panel view prefers the draft when unsent input exists", () => {
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.deepEqual(
|
||||
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",
|
||||
),
|
||||
resolveDisplayedPanelView(undefined, true, sessions),
|
||||
{ mode: "draft" },
|
||||
);
|
||||
});
|
||||
|
||||
test("draft state is used when there is no implicit history to resume", () => {
|
||||
assert.deepEqual(
|
||||
resolveDisplayedPanelView(undefined, true, [], null, "workspace"),
|
||||
resolveDisplayedPanelView(undefined, true, []),
|
||||
{ mode: "draft" },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -44,28 +44,22 @@ 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;
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Honour the persisted active-session selection (survives cold mount)
|
||||
// before falling back to the newest history entry.
|
||||
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 };
|
||||
}
|
||||
@@ -86,33 +80,6 @@ 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,31 +47,6 @@ 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,16 +10,12 @@ 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(',')
|
||||
: '';
|
||||
const memberKey = scopeType === 'workspace' && workspaceMemberTerminalIds
|
||||
? [...workspaceMemberTerminalIds].sort().join(',')
|
||||
: '';
|
||||
return `${scopeType}:${scopeTargetId ?? ''}:${hostKey}:${terminalKey}:${memberKey}`;
|
||||
return `${scopeType}:${scopeTargetId ?? ''}:${hostKey}:${terminalKey}`;
|
||||
}
|
||||
|
||||
export function getScopedHistorySessions(
|
||||
@@ -28,7 +24,6 @@ export function getScopedHistorySessions(
|
||||
scopeTargetId: string | undefined,
|
||||
scopeHostIds: string[] | undefined,
|
||||
activeTerminalSessionIds: Set<string>,
|
||||
workspaceMemberTerminalIds?: Set<string>,
|
||||
): AISession[] {
|
||||
let scopeCache = historyCache.get(sessions);
|
||||
if (!scopeCache) {
|
||||
@@ -41,7 +36,6 @@ export function getScopedHistorySessions(
|
||||
scopeTargetId,
|
||||
scopeHostIds,
|
||||
activeTerminalSessionIds,
|
||||
workspaceMemberTerminalIds,
|
||||
);
|
||||
const cached = scopeCache.get(cacheKey);
|
||||
if (cached) {
|
||||
@@ -59,7 +53,6 @@ export function getScopedHistorySessions(
|
||||
scopeTargetId,
|
||||
scopeHostIds,
|
||||
activeTerminalSessionIds,
|
||||
workspaceMemberTerminalIds,
|
||||
),
|
||||
}))
|
||||
.filter(({ matchRank }) => matchRank > 0)
|
||||
|
||||
@@ -114,35 +114,3 @@ 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,25 +12,7 @@ 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;
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import React from 'react';
|
||||
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { sanitizeHost } from '../../domain/host';
|
||||
import { isPluginHostProtocol } from '../../domain/pluginConnection';
|
||||
import type { Host } from '../../types';
|
||||
import { ContextMenuContent, ContextMenuItem, ContextMenuShortcut } from '../ui/context-menu';
|
||||
import { collectOwnedPluginMenus, comparePluginMenus, usePluginContributions } from '../../application/state/usePluginContributions';
|
||||
@@ -14,7 +13,6 @@ export interface HostTreeHostContextMenuHandlers {
|
||||
onEditHost?: (host: Host) => void;
|
||||
onRenameHost?: (host: Host) => void;
|
||||
onDuplicateHost: (host: Host) => void;
|
||||
onCopyHostname?: (host: Host) => void;
|
||||
onCopyCredentials: (host: Host) => void;
|
||||
onDeleteHost: (host: Host) => void;
|
||||
}
|
||||
@@ -27,7 +25,6 @@ export const HostTreeHostContextMenuContent: React.FC<
|
||||
onEditHost,
|
||||
onRenameHost,
|
||||
onDuplicateHost,
|
||||
onCopyHostname,
|
||||
onCopyCredentials,
|
||||
onDeleteHost,
|
||||
}) => {
|
||||
@@ -43,7 +40,6 @@ export const HostTreeHostContextMenuContent: React.FC<
|
||||
const pluginMenus = collectOwnedPluginMenus(pluginContributions.snapshot.plugins)
|
||||
.filter((menu) => menu.location === 'host/context' && menu.visible)
|
||||
.sort(comparePluginMenus);
|
||||
const canCopyHostname = Boolean(onCopyHostname) && !isPluginHostProtocol(safeHost.protocol);
|
||||
|
||||
return (
|
||||
<ContextMenuContent>
|
||||
@@ -63,11 +59,6 @@ export const HostTreeHostContextMenuContent: React.FC<
|
||||
<ContextMenuItem onClick={() => onDuplicateHost(host)}>
|
||||
<Copy className="mr-2 h-4 w-4" /> {t('action.duplicate')}
|
||||
</ContextMenuItem>
|
||||
{canCopyHostname ? (
|
||||
<ContextMenuItem onClick={() => onCopyHostname?.(host)}>
|
||||
<Copy className="mr-2 h-4 w-4" /> {t('terminal.statusbar.copyHostname.label')}
|
||||
</ContextMenuItem>
|
||||
) : null}
|
||||
<ContextMenuItem onClick={() => onCopyCredentials(host)}>
|
||||
<Server className="mr-2 h-4 w-4" /> {t('vault.hosts.copyCredentials')}
|
||||
</ContextMenuItem>
|
||||
|
||||
@@ -4,11 +4,8 @@ import { readFileSync } from "node:fs";
|
||||
|
||||
import {
|
||||
getHostPickerTriggerRange,
|
||||
isNoteSmallImageWidth,
|
||||
isSupportedNoteExternalHref,
|
||||
isPointerInsideLinkActionHoverZone,
|
||||
linkActionStatesEqual,
|
||||
NOTE_SMALL_IMAGE_MAX_WIDTH,
|
||||
resolveHostPickerPopupPosition,
|
||||
shouldInsertClipboardTextAsMarkdown,
|
||||
shouldHandleHostPickerNavigationKey,
|
||||
@@ -56,24 +53,6 @@ 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);
|
||||
@@ -125,9 +104,7 @@ 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);
|
||||
// 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);
|
||||
assert.equal(shouldInsertClipboardTextAsMarkdown(""), false);
|
||||
});
|
||||
|
||||
test("note editor registers a code block editor for pasted fenced code", () => {
|
||||
@@ -141,23 +118,13 @@ 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 /);
|
||||
@@ -189,12 +156,6 @@ 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,
|
||||
@@ -225,15 +186,10 @@ 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]*toggleTaskListItemAtIndex[\s\S]*const handled = openLink\(href, label\);/,
|
||||
"preview click path handles task toggles then openable links",
|
||||
);
|
||||
assert.match(source, /if \(editorMode !== "preview"\) \{[\s\S]*scheduleHostPickerUpdate\(\);[\s\S]*return;/);
|
||||
assert.match(source, /const handled = openLink\(href, label\);[\s\S]*if \(!handled\) return;[\s\S]*event\.preventDefault\(\);/);
|
||||
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, /onClickCapture=\{handleClickCapture\}/);
|
||||
assert.match(source, /if \(editorMode !== "edit"\) \{[\s\S]*setLinkAction\(null\);[\s\S]*return;/);
|
||||
assert.match(source, /\{editorMode === "edit" && linkAction && \(/);
|
||||
});
|
||||
|
||||
@@ -364,14 +320,15 @@ test("annotateNoteCodeBlockCopyButtons adds a copy action to code blocks", () =>
|
||||
assert.match(source, /onCopy\(text\)/);
|
||||
});
|
||||
|
||||
test("note preview uses MDX readOnly with code-copy chrome", () => {
|
||||
test("note code blocks expose a hover copy action only in preview mode", () => {
|
||||
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, /readOnly=\{editorMode === "preview"\}/);
|
||||
assert.match(source, /annotateCodeBlockCopyButtons/);
|
||||
assert.match(source, /if \(editorMode !== "preview"\) \{[\s\S]*removeNoteCodeBlockCopyButtons/);
|
||||
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,25 +8,6 @@ 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,
|
||||
@@ -91,63 +72,6 @@ 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),
|
||||
@@ -158,21 +82,7 @@ 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.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, /\[annotateHostLinks, editorMode\]/);
|
||||
assert.match(
|
||||
editorSource,
|
||||
/syncedPropValueRef/,
|
||||
@@ -183,26 +93,3 @@ 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,7 +8,6 @@ import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
|
||||
import type { VaultNote } from "../../types.ts";
|
||||
import { TooltipProvider } from "../ui/tooltip.tsx";
|
||||
import {
|
||||
clampNotesTreeWidth,
|
||||
getFallbackNoteSelectionState,
|
||||
getNoteActionTargetGroup,
|
||||
getNoteGroupSelectionState,
|
||||
@@ -57,14 +56,7 @@ test("NotesManager renders notes tree and selected markdown editor", () => {
|
||||
|
||||
assert.match(markup, /Ops/);
|
||||
assert.match(markup, /Postgres failover checklist/);
|
||||
// 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)",
|
||||
);
|
||||
assert.match(markup, /data-notes-editor-loading="true"/);
|
||||
});
|
||||
|
||||
test("NotesManager marks selected notebook rows with shared tree state", () => {
|
||||
@@ -84,36 +76,6 @@ 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);
|
||||
@@ -335,12 +297,7 @@ test("NotesManager sidebar mode opens the requested note without selecting its f
|
||||
);
|
||||
|
||||
assert.match(markup, /Deploy overlay/);
|
||||
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.match(markup, /data-notes-editor-loading="true"/);
|
||||
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,16 +100,11 @@ 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;
|
||||
/** Narrow enough for nested folders + ellipsis; toolbar scrolls if needed. */
|
||||
const NOTES_TREE_MIN_WIDTH = 160;
|
||||
const NOTES_TREE_MIN_WIDTH = 220;
|
||||
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;
|
||||
|
||||
@@ -118,7 +113,7 @@ const isNoteEditorMode = (value: string | null): value is NoteEditorMode =>
|
||||
|
||||
const InlineMarkdownEditorFallback = () => (
|
||||
<div
|
||||
className="min-h-[420px] bg-background"
|
||||
className="netcatty-lazy-fade-in min-h-[420px]"
|
||||
data-notes-editor-loading="true"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
@@ -393,9 +388,6 @@ 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);
|
||||
@@ -1240,7 +1232,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
return (
|
||||
<div
|
||||
key={`new-folder-${parent || "root"}`}
|
||||
className="flex h-7 min-w-0 items-center px-2 text-sm"
|
||||
className="flex h-7 items-center px-2 text-sm"
|
||||
style={{ paddingLeft: depth * 16 + 4 }}
|
||||
>
|
||||
<div className="mr-1 h-5 w-4 shrink-0" />
|
||||
@@ -1452,53 +1444,23 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
event.stopPropagation();
|
||||
|
||||
const startX = event.clientX;
|
||||
const startWidth = treeWidthRef.current;
|
||||
const startWidth = treeWidth;
|
||||
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 applyWidth = (width: number) => {
|
||||
latestWidth = width;
|
||||
if (aside) {
|
||||
aside.style.width = `${width}px`;
|
||||
}
|
||||
};
|
||||
const clampWidth = (value: number) =>
|
||||
Math.max(NOTES_TREE_MIN_WIDTH, Math.min(NOTES_TREE_MAX_WIDTH, value));
|
||||
|
||||
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);
|
||||
});
|
||||
setTreeWidth(clampWidth(startWidth + moveEvent.clientX - startX));
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
if (frame) {
|
||||
window.cancelAnimationFrame(frame);
|
||||
frame = 0;
|
||||
}
|
||||
const nextWidth = clampNotesTreeWidth(latestWidth);
|
||||
applyWidth(nextWidth);
|
||||
if (aside) {
|
||||
aside.style.willChange = "";
|
||||
}
|
||||
treeWidthRef.current = nextWidth;
|
||||
const handlePointerUp = (upEvent: PointerEvent) => {
|
||||
const nextWidth = clampWidth(startWidth + upEvent.clientX - startX);
|
||||
setTreeWidth(nextWidth);
|
||||
persistTreeWidth(nextWidth);
|
||||
setIsTreeResizing(false);
|
||||
@@ -1512,7 +1474,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", handlePointerUp);
|
||||
window.addEventListener("pointercancel", handlePointerUp);
|
||||
}, [persistTreeWidth, setTreeWidth]);
|
||||
}, [persistTreeWidth, setTreeWidth, treeWidth]);
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full min-h-0 flex-col overflow-hidden">
|
||||
@@ -1529,20 +1491,16 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{shouldShowNotesTree && (
|
||||
<aside
|
||||
ref={treeAsideRef}
|
||||
className={cn(
|
||||
// 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",
|
||||
"relative flex flex-col bg-background",
|
||||
isSidebarMode ? "min-w-0 flex-1" : "shrink-0 border-r border-border/60",
|
||||
)}
|
||||
style={isSidebarMode ? undefined : { width: treeWidth }}
|
||||
>
|
||||
<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="flex-shrink-0">
|
||||
<div className={cn(
|
||||
TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS,
|
||||
"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",
|
||||
"flex items-center gap-1 border-b border-border/60 px-1.5",
|
||||
)}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -1687,13 +1645,9 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 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">
|
||||
<ScrollArea className="flex-1">
|
||||
<div
|
||||
className="min-h-full min-w-0 space-y-1 overflow-hidden px-1.5 pt-1.5 pb-4"
|
||||
className="min-h-full space-y-1 px-1.5 pt-1.5 pb-4"
|
||||
data-notes-drop-zone="root"
|
||||
onDragOver={(event) => {
|
||||
if (!hasNotesTreeDrag(event.dataTransfer)) return;
|
||||
@@ -1713,7 +1667,6 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
{!isSidebarMode && (
|
||||
<div
|
||||
role="separator"
|
||||
@@ -1732,14 +1685,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
)}
|
||||
|
||||
{!isSidebarMode && (
|
||||
<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",
|
||||
)}
|
||||
>
|
||||
<main className="flex min-w-0 flex-1 flex-col bg-background">
|
||||
{selectedNoteView ? (
|
||||
<>
|
||||
<div className="flex min-h-[54px] shrink-0 items-center gap-3 px-8 pt-6 pb-1" data-note-title-row>
|
||||
@@ -1766,10 +1712,10 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LazyLoadBoundary name="Notes editor" resetKey="notes-editor">
|
||||
<LazyLoadBoundary name="Notes editor" resetKey={selectedNoteView.id}>
|
||||
<Suspense fallback={<InlineMarkdownEditorFallback />}>
|
||||
<InlineMarkdownEditor
|
||||
noteId={selectedNoteView.id}
|
||||
key={selectedNoteView.id}
|
||||
value={selectedNoteView.content}
|
||||
placeholder={t("notes.editor.placeholder")}
|
||||
editorMode={noteEditorMode}
|
||||
@@ -1857,10 +1803,10 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LazyLoadBoundary name="Notes editor" resetKey="notes-overlay-editor">
|
||||
<LazyLoadBoundary name="Notes editor" resetKey={overlayNoteView.id}>
|
||||
<Suspense fallback={<InlineMarkdownEditorFallback />}>
|
||||
<InlineMarkdownEditor
|
||||
noteId={overlayNoteView.id}
|
||||
key={overlayNoteView.id}
|
||||
value={overlayNoteView.content}
|
||||
placeholder={t("notes.editor.placeholder")}
|
||||
editorMode={noteEditorMode}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* 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";
|
||||
@@ -1,47 +0,0 @@
|
||||
/** 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;
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -2,16 +2,9 @@ import assert from "node:assert/strict";
|
||||
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";
|
||||
import { shouldInterceptNoteMarkdownPaste } from "./InlineMarkdownEditor.tsx";
|
||||
|
||||
test("markdown paste intercepts structured clipboard text in edit mode even without a Lexical selection", () => {
|
||||
test("markdown paste intercept requires a live Lexical selection", () => {
|
||||
assert.equal(
|
||||
shouldInterceptNoteMarkdownPaste({
|
||||
editorMode: "edit",
|
||||
@@ -28,7 +21,7 @@ test("markdown paste intercepts structured clipboard text in edit mode even with
|
||||
clipboardText: "# Heading\n\n- item",
|
||||
canInsertMarkdownAtSelection: false,
|
||||
}),
|
||||
true,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldInterceptNoteMarkdownPaste({
|
||||
@@ -41,103 +34,17 @@ test("markdown paste intercepts structured clipboard text in edit mode even with
|
||||
);
|
||||
});
|
||||
|
||||
test("document paste merge preserves first-line indentation and appends after current body", () => {
|
||||
assert.equal(
|
||||
mergeNoteMarkdownDocumentPaste("Existing note", "# Pasted\n\n- item"),
|
||||
"Existing note\n\n# Pasted\n\n- item",
|
||||
);
|
||||
assert.equal(
|
||||
mergeNoteMarkdownDocumentPaste(" ", "# Only paste"),
|
||||
"# Only paste",
|
||||
);
|
||||
assert.equal(
|
||||
mergeNoteMarkdownDocumentPaste("- parent", " - child"),
|
||||
"- parent\n\n - child",
|
||||
);
|
||||
assert.equal(
|
||||
mergeNoteMarkdownDocumentPaste("Existing", "\n\n# Pasted\n"),
|
||||
"Existing\n\n# Pasted",
|
||||
);
|
||||
});
|
||||
|
||||
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, /resolveNoteClipboardPaste/);
|
||||
assert.match(source, /shouldInterceptResolvedNotePaste/);
|
||||
assert.match(source, /shouldInterceptNoteMarkdownPaste/);
|
||||
assert.match(source, /hasActiveLexicalTextSelection/);
|
||||
assert.match(source, /mergeNoteMarkdownDocumentPaste/);
|
||||
assert.match(source, /setMarkdown\(/);
|
||||
assert.match(source, /resolveNoteMarkdownPasteStrategy/);
|
||||
assert.match(source, /text\/html/);
|
||||
assert.match(
|
||||
source,
|
||||
/shouldInterceptResolvedNotePaste\([\s\S]*?\)[\s\S]*?event\.preventDefault\(\)/,
|
||||
/shouldInterceptNoteMarkdownPaste\([\s\S]*?\)[\s\S]*?event\.preventDefault\(\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/if \(\s*!shouldInterceptResolvedNotePaste\([\s\S]*?\)\s*\{\s*return;\s*\}/,
|
||||
);
|
||||
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\(\)/);
|
||||
// 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/,
|
||||
/if \(\s*!shouldInterceptNoteMarkdownPaste\([\s\S]*?\)\s*\{\s*return;\s*\}/,
|
||||
);
|
||||
});
|
||||
@@ -8,21 +8,13 @@ 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,
|
||||
type AsidePanelLayout,
|
||||
type AsidePanelResizeProps,
|
||||
} from '../ui/aside-panel';
|
||||
import { AsideActionMenu,AsideActionMenuItem,AsidePanel,AsidePanelContent,AsidePanelFooter } 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 extends AsidePanelResizeProps {
|
||||
export interface EditPanelProps {
|
||||
rule: PortForwardingRule;
|
||||
draft: Partial<PortForwardingRule>;
|
||||
hosts: Host[];
|
||||
@@ -32,7 +24,6 @@ export interface EditPanelProps extends AsidePanelResizeProps {
|
||||
onDuplicate: () => void;
|
||||
onDelete: () => void;
|
||||
onOpenHostSelector: () => void;
|
||||
layout?: AsidePanelLayout;
|
||||
}
|
||||
|
||||
export const EditPanel: React.FC<EditPanelProps> = ({
|
||||
@@ -45,10 +36,6 @@ 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);
|
||||
@@ -59,10 +46,6 @@ 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
|
||||
@@ -134,7 +117,7 @@ export const EditPanel: React.FC<EditPanelProps> = ({
|
||||
<DistroAvatar
|
||||
host={selectedHost}
|
||||
fallback={selectedHost.os[0].toUpperCase()}
|
||||
size="tree"
|
||||
className="h-6 w-6"
|
||||
/>
|
||||
<span>{selectedHost.label}</span>
|
||||
</div>
|
||||
|
||||
@@ -9,13 +9,7 @@ import { Host,PortForwardingRule,PortForwardingType } from '../../domain/models'
|
||||
import { cn } from '../../lib/utils';
|
||||
import { DistroAvatar } from '../DistroAvatar';
|
||||
import { TrafficDiagram } from '../TrafficDiagram';
|
||||
import {
|
||||
AsidePanel,
|
||||
AsidePanelContent,
|
||||
AsidePanelFooter,
|
||||
type AsidePanelLayout,
|
||||
type AsidePanelResizeProps,
|
||||
} from '../ui/aside-panel';
|
||||
import { AsidePanel,AsidePanelContent,AsidePanelFooter } from '../ui/aside-panel';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
@@ -23,7 +17,7 @@ import { Label } from '../ui/label';
|
||||
import { Switch } from '../ui/switch';
|
||||
import { getTypeLabel } from './utils';
|
||||
|
||||
export interface NewFormPanelProps extends AsidePanelResizeProps {
|
||||
export interface NewFormPanelProps {
|
||||
draft: Partial<PortForwardingRule>;
|
||||
hosts: Host[];
|
||||
onDraftChange: (updates: Partial<PortForwardingRule>) => void;
|
||||
@@ -32,7 +26,6 @@ export interface NewFormPanelProps extends AsidePanelResizeProps {
|
||||
onOpenHostSelector: () => void;
|
||||
onOpenWizard: () => void;
|
||||
isValid: boolean;
|
||||
layout?: AsidePanelLayout;
|
||||
}
|
||||
|
||||
export const NewFormPanel: React.FC<NewFormPanelProps> = ({
|
||||
@@ -44,10 +37,6 @@ 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);
|
||||
@@ -58,10 +47,6 @@ 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 */}
|
||||
@@ -134,7 +119,7 @@ export const NewFormPanel: React.FC<NewFormPanelProps> = ({
|
||||
<DistroAvatar
|
||||
host={selectedHost}
|
||||
fallback={selectedHost.os[0].toUpperCase()}
|
||||
size="tree"
|
||||
className="h-6 w-6"
|
||||
/>
|
||||
<span>{selectedHost.label}</span>
|
||||
</div>
|
||||
|
||||
@@ -210,8 +210,7 @@ export const SettingRow: React.FC<SettingRowProps> = ({
|
||||
id={anchorId ? settingsAnchorDomId(anchorId) : undefined}
|
||||
data-settings-anchor={anchorId}
|
||||
className={cn(
|
||||
// Keep square corners: rounded rows bend divide-y separators at both ends.
|
||||
"flex justify-between py-3 gap-4",
|
||||
"flex justify-between py-3 gap-4 rounded-md",
|
||||
align === "start" ? "items-start" : "items-center",
|
||||
)}
|
||||
>
|
||||
@@ -234,8 +233,7 @@ export const SettingsAnchor: React.FC<{
|
||||
<div
|
||||
id={settingsAnchorDomId(anchorId)}
|
||||
data-settings-anchor={anchorId}
|
||||
// No default rounded-md: inside divide-y cards, child border-radius bends separators.
|
||||
className={className}
|
||||
className={cn("rounded-md", className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
@@ -246,11 +244,7 @@ export const SettingsTabContent: React.FC<{
|
||||
children: React.ReactNode;
|
||||
}> = ({ value, children }) => (
|
||||
<TabsContent value={value} className="flex-1 m-0 h-full overflow-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="h-full overflow-y-auto overflow-x-hidden">
|
||||
<div className="p-6 space-y-6">{children}</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
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,8 +2,6 @@ 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;
|
||||
@@ -41,65 +39,12 @@ 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.
|
||||
@@ -146,7 +91,7 @@ export function focusSettingsAnchor(
|
||||
return;
|
||||
}
|
||||
|
||||
scrollSettingsAnchorIntoView(el, "smooth");
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
el.classList.remove(HIGHLIGHT_CLASS);
|
||||
void el.offsetWidth;
|
||||
el.classList.add(HIGHLIGHT_CLASS);
|
||||
|
||||
@@ -12,8 +12,6 @@ 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[];
|
||||
@@ -27,8 +25,6 @@ export default function SettingsShortcutsTab(props: {
|
||||
setHotkeyScheme,
|
||||
shellOnlyTabNumberShortcuts,
|
||||
setShellOnlyTabNumberShortcuts,
|
||||
showTabNumberBadges,
|
||||
setShowTabNumberBadges,
|
||||
disableTerminalFontZoom,
|
||||
setDisableTerminalFontZoom,
|
||||
keyBindings,
|
||||
@@ -169,16 +165,6 @@ export default function SettingsShortcutsTab(props: {
|
||||
onChange={setShellOnlyTabNumberShortcuts}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
anchorId="shortcuts-show-tab-number-badges"
|
||||
label={t("settings.shortcuts.showTabNumberBadges.label")}
|
||||
description={t("settings.shortcuts.showTabNumberBadges.desc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={showTabNumberBadges}
|
||||
onChange={setShowTabNumberBadges}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
{hotkeyScheme !== "disabled" && (
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Pencil, Upload, RotateCcw, X, RefreshCw } from "lucide-react";
|
||||
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Pencil, Upload, RotateCcw, X } from "lucide-react";
|
||||
import type { ProviderConfig, ProviderAdvancedParams, ProviderStyle } from "../../../../infrastructure/ai/types";
|
||||
import { PROVIDER_PRESETS, resolveProviderStyle } from "../../../../infrastructure/ai/types";
|
||||
import { sanitizeContextWindow } from "../../../../infrastructure/ai/contextCompaction";
|
||||
import {
|
||||
probeProviderConnection,
|
||||
validateProviderProbeInputs,
|
||||
type ProviderProbeHealth,
|
||||
} from "../../../../infrastructure/ai/providerConnectionProbe";
|
||||
import { encryptField, decryptField } from "../../../../infrastructure/persistence/secureFieldAdapter";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import type { BuiltinProviderIcon } from "./types";
|
||||
import { BUILTIN_PROVIDER_ICONS, getFetchBridge } from "./types";
|
||||
import { BUILTIN_PROVIDER_ICONS } from "./types";
|
||||
import type { ProviderFormState } from "./types";
|
||||
import { ModelSelector } from "./ModelSelector";
|
||||
import { mergeModelContextWindow } from "./modelMetadata";
|
||||
@@ -82,12 +77,6 @@ export const ProviderConfigForm: React.FC<{
|
||||
const [iconError, setIconError] = useState<string | null>(null);
|
||||
const [contextWindowError, setContextWindowError] = useState<string | null>(null);
|
||||
const [apiKeySourceVersion, setApiKeySourceVersion] = useState(0);
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [probeResult, setProbeResult] = useState<{
|
||||
health: ProviderProbeHealth;
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const probeRequestIdRef = useRef(0);
|
||||
|
||||
const preset = PROVIDER_PRESETS[provider.providerId];
|
||||
const resolvedStyle: ProviderStyle = form.style || resolveProviderStyle({ providerId: provider.providerId });
|
||||
@@ -107,22 +96,7 @@ export const ProviderConfigForm: React.FC<{
|
||||
preset?.modelsEndpoint,
|
||||
resolvedStyle,
|
||||
]);
|
||||
const probeFingerprint = useMemo(() => JSON.stringify({
|
||||
baseURL: form.baseURL || preset?.defaultBaseURL || "",
|
||||
apiKey: form.apiKey,
|
||||
style: resolvedStyle,
|
||||
skipTLSVerify: form.skipTLSVerify,
|
||||
modelsEndpoint: preset?.modelsEndpoint ?? "",
|
||||
}), [
|
||||
form.apiKey,
|
||||
form.baseURL,
|
||||
form.skipTLSVerify,
|
||||
preset?.defaultBaseURL,
|
||||
preset?.modelsEndpoint,
|
||||
resolvedStyle,
|
||||
]);
|
||||
const modelMetadataSourceKeyRef = useRef<string | null>(null);
|
||||
const probeFingerprintRef = useRef<string | null>(null);
|
||||
const previewProvider: Pick<ProviderConfig, "providerId" | "name" | "iconId" | "iconDataUrl"> = {
|
||||
providerId: provider.providerId,
|
||||
name: form.name,
|
||||
@@ -159,19 +133,6 @@ export const ProviderConfigForm: React.FC<{
|
||||
: prev);
|
||||
}, [modelMetadataSourceKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (probeFingerprintRef.current == null) {
|
||||
probeFingerprintRef.current = probeFingerprint;
|
||||
return;
|
||||
}
|
||||
if (probeFingerprintRef.current === probeFingerprint) return;
|
||||
|
||||
probeFingerprintRef.current = probeFingerprint;
|
||||
probeRequestIdRef.current += 1;
|
||||
setProbeResult(null);
|
||||
setIsTesting(false);
|
||||
}, [probeFingerprint]);
|
||||
|
||||
const [advancedParamRaw, setAdvancedParamRaw] = useState<Record<string, string>>({});
|
||||
const handleAdvancedParam = useCallback((key: keyof ProviderAdvancedParams, raw: string) => {
|
||||
setAdvancedParamRaw((prev) => ({ ...prev, [key]: raw }));
|
||||
@@ -219,90 +180,6 @@ export const ProviderConfigForm: React.FC<{
|
||||
setForm((prev) => ({ ...prev, apiKey: value }));
|
||||
}, []);
|
||||
|
||||
const handleTestConnection = useCallback(async () => {
|
||||
const baseURL = form.baseURL || preset?.defaultBaseURL || "";
|
||||
const inputCheck = validateProviderProbeInputs({
|
||||
baseURL,
|
||||
apiKey: form.apiKey,
|
||||
providerId: provider.providerId,
|
||||
});
|
||||
if (!inputCheck.ok) {
|
||||
probeRequestIdRef.current += 1;
|
||||
setIsTesting(false);
|
||||
setProbeResult({
|
||||
health: "error",
|
||||
message: t(
|
||||
inputCheck.reason === "missing_base_url"
|
||||
? "ai.providers.test.missingBaseUrl"
|
||||
: "ai.providers.test.missingApiKey",
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = ++probeRequestIdRef.current;
|
||||
setIsTesting(true);
|
||||
setProbeResult(null);
|
||||
try {
|
||||
const run = await probeProviderConnection({
|
||||
bridge: getFetchBridge(),
|
||||
baseURL,
|
||||
apiKey: form.apiKey,
|
||||
providerId: provider.providerId,
|
||||
style: resolvedStyle,
|
||||
presetModelsEndpoint: preset?.modelsEndpoint,
|
||||
skipTLSVerify: form.skipTLSVerify,
|
||||
});
|
||||
if (probeRequestIdRef.current !== requestId) return;
|
||||
if (!run.ok) {
|
||||
setProbeResult({
|
||||
health: "error",
|
||||
message: t(
|
||||
run.reason === "missing_base_url"
|
||||
? "ai.providers.test.missingBaseUrl"
|
||||
: run.reason === "missing_api_key"
|
||||
? "ai.providers.test.missingApiKey"
|
||||
: "ai.providers.test.unavailable",
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const classified = run.classification;
|
||||
const latency = String(classified.latencyMs);
|
||||
if (classified.health === "ok") {
|
||||
setProbeResult({
|
||||
health: "ok",
|
||||
message: t("ai.providers.test.ok", { latency }),
|
||||
});
|
||||
} else if (classified.health === "warn") {
|
||||
const warnKey = classified.modelCount === 0 || classified.error
|
||||
? "ai.providers.test.warn"
|
||||
: "ai.providers.test.warnSlow";
|
||||
setProbeResult({
|
||||
health: "warn",
|
||||
message: t(warnKey, { latency }),
|
||||
});
|
||||
} else {
|
||||
const detail = classified.error
|
||||
|| (classified.statusCode ? `HTTP ${classified.statusCode}` : "error");
|
||||
setProbeResult({
|
||||
health: "error",
|
||||
message: t("ai.providers.test.error", { detail }),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (probeRequestIdRef.current !== requestId) return;
|
||||
setProbeResult({
|
||||
health: "error",
|
||||
message: t("ai.providers.test.error", {
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
if (probeRequestIdRef.current === requestId) setIsTesting(false);
|
||||
}
|
||||
}, [form.apiKey, form.baseURL, form.skipTLSVerify, preset?.defaultBaseURL, preset?.modelsEndpoint, provider.providerId, resolvedStyle, t]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const cleanedParams: ProviderAdvancedParams = {};
|
||||
const ap = form.advancedParams;
|
||||
@@ -657,40 +534,14 @@ export const ProviderConfigForm: React.FC<{
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-2 pt-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="default" size="sm" onClick={() => void handleSave()}>
|
||||
<Check size={14} className="mr-1.5" />
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void handleTestConnection()}
|
||||
disabled={isTesting || isDecrypting}
|
||||
>
|
||||
<RefreshCw size={14} className={cn("mr-1.5", isTesting && "animate-spin")} />
|
||||
{isTesting ? t('ai.providers.test.testing') : t('ai.providers.test')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onCancel}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
{(isTesting || probeResult) && (
|
||||
<p
|
||||
className={cn(
|
||||
"text-[11px]",
|
||||
isTesting && "text-muted-foreground",
|
||||
probeResult?.health === "ok" && "text-emerald-500",
|
||||
probeResult?.health === "warn" && "text-amber-500",
|
||||
probeResult?.health === "error" && "text-destructive",
|
||||
)}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{isTesting ? t('ai.providers.test.testing') : probeResult?.message}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Button variant="default" size="sm" onClick={() => void handleSave()}>
|
||||
<Check size={14} className="mr-1.5" />
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onCancel}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -135,7 +135,7 @@ export interface FetchedModel {
|
||||
}
|
||||
|
||||
export interface FetchBridge {
|
||||
aiFetch?: (url: string, method?: string, headers?: Record<string, string>, body?: string, providerId?: string, skipHostCheck?: boolean, followRedirects?: boolean, skipTLSVerify?: boolean) => Promise<{ ok: boolean; status?: number; data: string; error?: string }>;
|
||||
aiFetch?: (url: string, method?: string, headers?: Record<string, string>, body?: string, providerId?: string, skipHostCheck?: boolean, followRedirects?: boolean, skipTLSVerify?: boolean) => Promise<{ ok: boolean; data: string; error?: string }>;
|
||||
aiAllowlistAddHost?: (baseURL: string) => Promise<{ ok: boolean }>;
|
||||
}
|
||||
|
||||
|
||||
Loaded 100 of 269 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user