Compare commits

..
Author SHA1 Message Date
Cursor Agentand陈大猫 fa8412d70c fix: rebase queued snippet saves onto latest vault snapshot
Locking updateSnippets alone still wrote a pre-delete full array, so a
popup bulk-delete could be resurrected (or a storage-event version bump
could cancel the local edit). Rebase under the vault lock and supersede
only newer local writers via snippetsWriteOwnerRef.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-09 14:48:17 +00:00
Cursor Agentand陈大猫 7bb4e96c55 Merge remote-tracking branch 'origin/main' into cursor/fix-snippets-bulk-delete-lock-e2cf
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-09 14:44:16 +00:00
Cursor Agentand陈大猫 7a66161355 fix: serialize snippet saves with bulk-delete vault lock
updateSnippets wrote STORAGE_KEY_SNIPPETS without withVaultImportLock, so a
concurrent edit could land between bulk-delete's snapshot read and journal
commit and be discarded. Queue ordinary snippet writes on the shared lock and
include them in waitForPendingVaultWrites.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-09 14:36:20 +00:00
netcatty-bot edfe5ba517 fix: address Codex review on PR #2828 2026-08-09 05:07:53 +00:00
netcatty-bot 7bce9e55e3 fix: address Codex review on PR #2828 2026-08-09 04:41:03 +00:00
netcatty-bot 0cb692b17e fix: address Codex review on PR #2828 2026-08-09 04:07:09 +00:00
netcatty-bot 337191382a fix: address Codex review on PR #2828 2026-08-09 03:26:42 +00:00
netcatty-bot 18b19b1ccc fix: address Codex review on PR #2828 2026-08-09 02:45:25 +00:00
Cursor Agentand陈大猫 ce2611d868 fix(scripts): hoist bulk-delete confirm outside toolbar popover
Portalled Dialog focus dismissed the compact Scripts popover and cleared
pendingDeleteIds before confirm. Own the confirm in TerminalToolbar and
defer via onBulkDeleteRequest when nested in that popover.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-09 02:19:33 +00:00
netcatty-bot a0dce456fa fix: address Codex review on PR #2828 2026-08-08 21:17:25 +00:00
Cursor Agentand陈大猫 2c7c0f9fc4 fix(scripts): keep delete refs current within the same tick
Update snippetsRef/hostsRef immediately after applying a delete so a
same-tick second event cannot rebuild vault state from the pre-delete
snapshot and resurrect removed snippets or host bindings.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-08 07:26:20 +00:00
Cursor Agentand陈大猫 db2152dcfa fix(scripts): harden delete handler against stale vault state
Read snippets/hosts from refs in the shared delete listener so rapid
confirms cannot resurrect already-removed items, and clear pending bulk
delete when the scripts panel hides so the confirm dialog does not
resurface after a mid-prompt hide.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-08 07:25:27 +00:00
Cursor Agentand陈大猫 877df24f59 fix(scripts): confirm bulk delete and clear host bindings
Scripts side-panel mass-delete skipped confirmation and, when
onSnippetsChange was wired, filtered snippets without clearing host
login/connect script bindings. Confirm first, always dispatch the shared
delete event, and apply deletes via deleteSelectedSnippetsFromVault.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-08 07:24:27 +00:00
netcatty-bot 40d114eab6 fix(#2826): automated Cursor CLI fix 2026-08-08 02:29:37 +00:00
269 changed files with 1439 additions and 17403 deletions

No files matched your search

+2 -3
View File
@@ -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 -13
View File
@@ -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;
}
-4
View File
@@ -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
+8 -33
View File
@@ -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 -41
View File
@@ -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);
});
-19
View File
@@ -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;
}
-9
View File
@@ -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...',
-2
View File
@@ -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.',
-9
View File
@@ -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': 'Загрузка моделей...',
-2
View File
@@ -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': 'Команда запуска не была выполнена успешно. Проверьте, что цель доступна, и повторите попытку.',
-9
View File
@@ -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': '请按键...',
-9
View File
@@ -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': '請按鍵...',
+1 -5
View File
@@ -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);
}
}
+5 -35
View File
@@ -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");
});
-10
View File
@@ -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';
+4 -13
View File
@@ -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();
};
}
-124
View File
@@ -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 {
+11 -11
View File
@@ -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 -25
View File
@@ -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 };
};
+5
View File
@@ -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/,
);
});
+3 -26
View File
@@ -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,
]),
};
+4 -5
View File
@@ -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),
+1 -4
View File
@@ -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\);/,
);
});
+38 -190
View File
@@ -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;
-7
View File
@@ -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);
}
BIN
View File
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,
);
});
+13 -98
View File
@@ -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,
-2
View File
@@ -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<{
+1 -18
View File
@@ -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
+5 -23
View File
@@ -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}
/>
)}
+6 -26
View File
@@ -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
View File
@@ -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"
-3
View File
@@ -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}
+1 -1
View File
@@ -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={() => {
+1 -1
View File
@@ -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";
-2
View File
@@ -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}
-11
View File
@@ -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 &&
+2 -3
View File
@@ -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
View File
@@ -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,
+2 -26
View File
@@ -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,
-4
View File
@@ -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(
-1
View File
@@ -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}
-8
View File
@@ -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
View File
@@ -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
);
+18 -19
View File
@@ -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>
)}
-25
View File
@@ -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,
+15 -7
View File
@@ -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", () => {
+7 -5
View File
@@ -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>
+3 -78
View File
@@ -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" },
);
});
+6 -39
View File
@@ -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",
+1 -8
View File
@@ -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)
-32
View File
@@ -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,
);
});
-18
View File
@@ -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;
-9
View File
@@ -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>
+8 -51
View File
@@ -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("![logo](https://example.com/logo.png)"), true);
assert.equal(shouldInsertClipboardTextAsMarkdown('<img alt="logo" src="https://example.com/logo.png" />'), true);
assert.equal(shouldInsertClipboardTextAsMarkdown("![logo](https://example.com/logo.png)"), 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
+1 -114
View File
@@ -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"\]/);
});
+2 -45
View File
@@ -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\](?:&gt;|>)div\]:!block/,
);
assert.match(
markup,
/data-radix-scroll-area-viewport\](?:&gt;|>)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"/);
+21 -75
View File
@@ -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);
});
-31
View File
@@ -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";
-47
View File
@@ -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;
}
});
};
+6 -99
View File
@@ -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*\}/,
);
});
+3 -20
View File
@@ -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>
+3 -18
View File
@@ -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>
+3 -9
View File
@@ -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>
-72
View File
@@ -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");
});
+1 -56
View File
@@ -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>
);
+1 -1
View File
@@ -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