mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-07 08:31:00 +00:00
Compare commits
193
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef364b69fd | ||
|
|
4beccc3d61 | ||
|
|
33b3beaea0 | ||
|
|
e65af2fd7b | ||
|
|
5d63a9476c | ||
|
|
58c72ace32 | ||
|
|
75cbda7edc | ||
|
|
6f6d8dc36d | ||
|
|
d0f819efb0 | ||
|
|
380e96eae5 | ||
|
|
02ce011934 | ||
|
|
8e45763e4c | ||
|
|
0d92c5d738 | ||
|
|
6bf4268e8e | ||
|
|
f02b645af7 | ||
|
|
eb3cd06278 | ||
|
|
7ed0b32489 | ||
|
|
2fe46cbf6f | ||
|
|
53ebbec58c | ||
|
|
253ce57d84 | ||
|
|
9beb7d3108 | ||
|
|
525a78e83e | ||
|
|
0e94950a5c | ||
|
|
a2ea0f6464 | ||
|
|
42b174abb3 | ||
|
|
e2ad0f526e | ||
|
|
cfeee2ec89 | ||
|
|
be3e971064 | ||
|
|
35bb83f57c | ||
|
|
5279ac2836 | ||
|
|
793ba54bda | ||
|
|
a1ca1a3443 | ||
|
|
3c528c6ab5 | ||
|
|
6dd703afce | ||
|
|
10c399f47b | ||
|
|
a4ec7bab61 | ||
|
|
79215d8896 | ||
|
|
2012fc53c7 | ||
|
|
2be738b8c2 | ||
|
|
93d7f8e1ec | ||
|
|
924c9a0efa | ||
|
|
0d9da150ec | ||
|
|
d307a2ebc3 | ||
|
|
d97439f878 | ||
|
|
8285d7010b | ||
|
|
1f0ddd2bdb | ||
|
|
98db9e6fea | ||
|
|
8e53ec829f | ||
|
|
549c5534d5 | ||
|
|
8fae84148c | ||
|
|
6d14f1e3d6 | ||
|
|
aebe25ae70 | ||
|
|
df045bf144 | ||
|
|
32447885d9 | ||
|
|
195a23e380 | ||
|
|
459ac09ebb | ||
|
|
a1ea4cc546 | ||
|
|
25224cef7b | ||
|
|
452058bd42 | ||
|
|
50c25d8789 | ||
|
|
96a38593bf | ||
|
|
e3b9e71d8f | ||
|
|
01b9f745b6 | ||
|
|
e96384cc7f | ||
|
|
a5bf1765d4 | ||
|
|
c921e7d9a4 | ||
|
|
a1b7f3708a | ||
|
|
6a4a1ffd92 | ||
|
|
105107755b | ||
|
|
028e86b111 | ||
|
|
151c15b38f | ||
|
|
544613c007 | ||
|
|
b2886ed16b | ||
|
|
57e11822f2 | ||
|
|
ff05c33fc9 | ||
|
|
a2ff82187b | ||
|
|
997bba6e2c | ||
|
|
13979790e8 | ||
|
|
19cce8c82b | ||
|
|
02746754f3 | ||
|
|
f940bbc280 | ||
|
|
7e60b64766 | ||
|
|
18330fd1d1 | ||
|
|
ebfdb645a7 | ||
|
|
b89607dbb0 | ||
|
|
2471013aba | ||
|
|
2343f80ed2 | ||
|
|
8d94252990 | ||
|
|
87b4089812 | ||
|
|
d7377fe301 | ||
|
|
518e4a12ac | ||
|
|
fbaa038b0d | ||
|
|
6c4e27cb38 | ||
|
|
5dc87ee178 | ||
|
|
a363ac5812 | ||
|
|
02bfea153c | ||
|
|
43534c34f1 | ||
|
|
a19bf708ac | ||
|
|
bbfa2ac6bf | ||
|
|
16f0304a5b | ||
|
|
e78d78b428 | ||
|
|
f6d846fbda | ||
|
|
b9702b043a | ||
|
|
89b3e27329 | ||
|
|
7e600d54e0 | ||
|
|
87aa2ea931 | ||
|
|
ac3993b789 | ||
|
|
4935066e53 | ||
|
|
315e596e08 | ||
|
|
5f874a3156 | ||
|
|
ae58d60f8f | ||
|
|
8856c7e5ab | ||
|
|
c47fcce17d | ||
|
|
53da304e39 | ||
|
|
2df408a571 | ||
|
|
a965a94f0d | ||
|
|
f9803f3642 | ||
|
|
ddfeb0a1a8 | ||
|
|
0d6fc5f7f7 | ||
|
|
2b381c87be | ||
|
|
193a14febb | ||
|
|
8ffdc671b9 | ||
|
|
837f4ddfaa | ||
|
|
f8bdd950bc | ||
|
|
4fb0f0c470 | ||
|
|
afc27e759b | ||
|
|
ce41379796 | ||
|
|
27b2c1954f | ||
|
|
b36642994c | ||
|
|
701ef1449b | ||
|
|
25291591fa | ||
|
|
0ad1713f25 | ||
|
|
d8efadc9fd | ||
|
|
c0f1504b4a | ||
|
|
b41728968e | ||
|
|
589abaaa90 | ||
|
|
cc8ba20839 | ||
|
|
589001cfb7 | ||
|
|
fcd122d4a1 | ||
|
|
9589f10ffb | ||
|
|
8d14f0f3c2 | ||
|
|
3f67ccf3b2 | ||
|
|
daa3490654 | ||
|
|
eebd9acfa2 | ||
|
|
8b84a13d9f | ||
|
|
249468b095 | ||
|
|
f4bb1775b3 | ||
|
|
f2154a853a | ||
|
|
82c339246c | ||
|
|
2169c2f95c | ||
|
|
d189692a93 | ||
|
|
fee778a775 | ||
|
|
d03f8873c5 | ||
|
|
5f464b1b8e | ||
|
|
68385b42b9 | ||
|
|
e60db5e08a | ||
|
|
71c64e3c6c | ||
|
|
d27fc24cae | ||
|
|
593da6f52d | ||
|
|
0527df7268 | ||
|
|
9ffb0d8642 | ||
|
|
d45b4dd65b | ||
|
|
63278d5d40 | ||
|
|
b6321eea6f | ||
|
|
39138096c0 | ||
|
|
5c355da8c1 | ||
|
|
e96a51bfe4 | ||
|
|
96628e8bd0 | ||
|
|
352de0131e | ||
|
|
df9284b9ab | ||
|
|
4f46a3728b | ||
|
|
5294f63544 | ||
|
|
7e843f6f2e | ||
|
|
2004f26c3e | ||
|
|
7c478215c8 | ||
|
|
01b818d598 | ||
|
|
0d453a42c4 | ||
|
|
5dcba28621 | ||
|
|
3513addb9c | ||
|
|
c9bc39d1a1 | ||
|
|
03acd48585 | ||
|
|
fd295e7529 | ||
|
|
f4e14afc9d | ||
|
|
93dce066fd | ||
|
|
49b88241db | ||
|
|
b67ff061c2 | ||
|
|
8a482dc928 | ||
|
|
3d433c8727 | ||
|
|
ea765ce074 | ||
|
|
8ee55963d8 | ||
|
|
b1a0a26d01 | ||
|
|
c718d042ac | ||
|
|
3490b0673d |
No files matched your search
@@ -11,6 +11,7 @@ import { ExternalMcpApprovalsHost } from './components/ai/ExternalMcpApprovalsHo
|
||||
import { PluginAuthenticationHost } from './components/plugins/PluginAuthenticationHost';
|
||||
import { useExternalMcpGrantPersister } from './components/ai/useExternalMcpGrantPersister';
|
||||
import { setupMcpApprovalBridge } from './infrastructure/ai/shared/approvalGate';
|
||||
import { setupCodexAppServerInteractionBridge } from './infrastructure/ai/shared/codexAppServerInteractions';
|
||||
import { netcattyBridge } from './infrastructure/services/netcattyBridge';
|
||||
import { AppShell } from './application/app/AppShell';
|
||||
import { AppLocalStateProvider } from './application/app/AppLocalState';
|
||||
@@ -75,6 +76,10 @@ function AppWithProviders() {
|
||||
return setupMcpApprovalBridge();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return setupCodexAppServerInteractionBridge();
|
||||
}, []);
|
||||
|
||||
useExternalMcpGrantPersister();
|
||||
|
||||
return (
|
||||
|
||||
+3
-2
@@ -3,6 +3,7 @@
|
||||
## [Unreleased] - 2026-03-11
|
||||
|
||||
### 功能
|
||||
- Linux deb/rpm/pacman 安装包启用应用内自动更新;未标记的开发包和 Snap 继续提供手动下载入口
|
||||
- 终端新增"粘贴时自动上传剪贴板图片"选项:剪贴板含图片时,在远程会话中粘贴(快捷键/右键/中键)自动通过 SFTP 上传图片到远端 `.netcatty-paste-images/` 目录并输入远端路径,否则保持原有文本粘贴行为(设置 > 终端 > 行为,默认关闭)
|
||||
- 修复自动更新 IPC 事件仅发送到单个窗口的问题,改为广播所有窗口(主窗口 + 设置窗口均可收到)
|
||||
- 统一手动检查更新与自动更新的状态机,消除三套并行状态
|
||||
@@ -13,7 +14,7 @@
|
||||
- 下载完成后弹出持久 toast 通知,用户点击"立即重启"即可安装
|
||||
- 下载失败时弹出错误 toast,提供"打开 Releases"降级入口
|
||||
- Settings > System 进度条实时展示自动下载进度,由 `useUpdateCheck` 统一驱动
|
||||
- Linux deb/rpm/snap 等不支持 electron-updater 的平台自动跳过,保持原有 GitHub API 通知行为
|
||||
- Linux Snap、未标记的开发包等不支持 electron-updater 的平台自动跳过,保持原有 GitHub API 通知行为
|
||||
|
||||
### 设计原理
|
||||
- `broadcastToAllWindows` 替换 `getSenderWindow` 单点发送,保证所有窗口都能收到 IPC 事件
|
||||
@@ -29,5 +30,5 @@
|
||||
|
||||
### 注意事项
|
||||
- `checkNow` 语义:使用 GitHub API(`performCheck`)检测是否有新版本,若发现更新且 electron-updater 尚未开始下载,则异步触发 `bridge.checkForUpdate()` 启动自动下载流程
|
||||
- 此功能仅对打包后的应用(Windows NSIS、macOS dmg/zip、Linux AppImage)生效,dev 模式需配合 `forceDevUpdateConfig=true` + `dev-app-update.yml` 测试(见 `.gitignore`)
|
||||
- 此功能仅对打包后的应用(Windows NSIS、macOS dmg/zip、Linux AppImage/deb/rpm/pacman)生效,dev 模式需配合 `forceDevUpdateConfig=true` + `dev-app-update.yml` 测试(见 `.gitignore`)
|
||||
- `hasUpdate` 旧 toast 在 `autoDownloadStatus !== 'idle'` 时自动抑制,避免与新 toast 重复
|
||||
@@ -272,6 +272,55 @@ test('next, previous, and number shortcuts include native plugin view tabs', ()
|
||||
assert.deepEqual(selected, [pluginTabId, 'session-1', pluginTabId]);
|
||||
});
|
||||
|
||||
test('switchToTab uses physical Digit code when Shift remaps e.key', () => {
|
||||
let activeTabId = 'session-1';
|
||||
const noop = () => {};
|
||||
const context = {
|
||||
IS_DEV: false,
|
||||
MOVE_FOCUS_DEBOUNCE_MS: 0,
|
||||
activeTabStore: { getActiveTabId: () => activeTabId },
|
||||
addConnectionLogRef: { current: noop },
|
||||
closeSession: noop,
|
||||
closeTabInFlightRef: { current: false },
|
||||
closeWorkspace: noop,
|
||||
collectSessionIds: () => [],
|
||||
confirmIfBusyLocalTerminal: async () => true,
|
||||
createLocalTerminalWithCurrentShell: noop,
|
||||
editorTabs: [],
|
||||
fromEditorTabId: () => null,
|
||||
handleOpenSettingsRef: { current: noop },
|
||||
handleRequestCloseEditorTabRef: { current: noop },
|
||||
isEditorTabId: () => false,
|
||||
isQuickSwitcherOpen: false,
|
||||
lastMoveFocusTimeRef: { current: 0 },
|
||||
moveFocusInWorkspace: noop,
|
||||
orderedTabs: ['session-1', 'session-2', 'session-3'],
|
||||
resolveCloseIntent: () => ({ kind: 'noop' }),
|
||||
resolveSnippetsShortcutIntent: () => ({ kind: 'noop' }),
|
||||
sessions: [],
|
||||
setActiveTabId: (id: string) => { activeTabId = id; },
|
||||
setAddToWorkspaceDialog: noop,
|
||||
setIsQuickSwitcherOpen: noop,
|
||||
setNavigateToSection: noop,
|
||||
settings: { showSftpTab: false, shellOnlyTabNumberShortcuts: false },
|
||||
splitSessionWithCurrentShell: noop,
|
||||
systemInfoRef: { current: { username: 'user', hostname: 'host' } },
|
||||
toEditorTabId: (id: string) => `editor:${id}`,
|
||||
toggleBroadcast: noop,
|
||||
toggleScriptsSidePanelRef: { current: noop },
|
||||
toggleSidePanelRef: { current: noop },
|
||||
toggleWorkspaceViewMode: noop,
|
||||
workspaces: [],
|
||||
};
|
||||
|
||||
executeHotkeyActionImpl(
|
||||
() => context,
|
||||
'switchToTab',
|
||||
{ key: '@', code: 'Digit3', ctrlKey: true, shiftKey: true } as KeyboardEvent,
|
||||
);
|
||||
assert.equal(activeTabId, 'session-2');
|
||||
});
|
||||
|
||||
test('next tab includes pinned tabs when shell-only shortcut mode is disabled', () => {
|
||||
let activeTabId = '';
|
||||
const noop = () => {};
|
||||
|
||||
@@ -38,6 +38,7 @@ test("follow-app terminal theme selection updates the matching UI theme via Them
|
||||
assert.match(appSideEffectsSource, /connectionLogs: connectionLogsRef\.current/);
|
||||
assert.match(terminalHostSource, /useTerminalAppearanceInjection/);
|
||||
assert.match(terminalHostSource, /includeChromeSurfaces: followAppTerminalTheme/);
|
||||
assert.match(terminalHostSource, /useTerminalAppearanceInjection\(accentedGlobalAppearance/);
|
||||
assert.match(terminalHostSource, /clearThemeIntent\(\)/);
|
||||
assert.match(runtimeSource, /injectTerminalAppearanceVars\(appearance\.theme, \{ includeChromeSurfaces \}\)/);
|
||||
assert.doesNotMatch(settingsSource, /pendingFollowAppTerminalThemeId/);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getEffectiveHostDistro, classifyDistroId, shouldProbeSessionCwd } from
|
||||
import { sanitizeHostIconFields } from '../../domain/hostIcon';
|
||||
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
|
||||
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
|
||||
import { tabShortcutDigitFromEvent } from '../../domain/models/keyBindings';
|
||||
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
|
||||
import { captureInheritedCwd } from '../state/inheritedCwd';
|
||||
|
||||
@@ -496,7 +497,14 @@ async function captureCtxInheritedCwd(getCtx: AppContextGetter, sessionId: strin
|
||||
}
|
||||
}
|
||||
|
||||
const probe = async (id: string, options?: { allowHomeFallback?: boolean; timeoutMs?: number }) =>
|
||||
const probe = async (
|
||||
id: string,
|
||||
options?: {
|
||||
allowHomeFallback?: boolean;
|
||||
allowLoginShellFallback?: boolean;
|
||||
timeoutMs?: number;
|
||||
},
|
||||
) =>
|
||||
(await bridge?.getSessionPwd?.(id, options)) ?? { success: false };
|
||||
return captureInheritedCwd(source, probe, { liveCwd, allowSshProbe });
|
||||
}
|
||||
@@ -673,12 +681,10 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
});
|
||||
switch (action) {
|
||||
case 'switchToTab': {
|
||||
// Get the number key pressed (1-9)
|
||||
const num = parseInt(e.key, 10);
|
||||
if (num >= 1 && num <= 9) {
|
||||
if (num <= shortcutTabs.length) {
|
||||
setActiveTabId(shortcutTabs[num - 1]);
|
||||
}
|
||||
// Prefer physical Digit code so Shift+[1...9] works when e.key is "!" etc.
|
||||
const num = tabShortcutDigitFromEvent(e);
|
||||
if (num !== null && num <= shortcutTabs.length) {
|
||||
setActiveTabId(shortcutTabs[num - 1]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -193,6 +193,31 @@ test('Hosts assemble bags field-by-field without spreading prepared domains', ()
|
||||
}
|
||||
});
|
||||
|
||||
test('published Host bags omit notes, accent, and connectionLogs churn fields', () => {
|
||||
const vaultHost = hostSources['VaultHost.tsx'];
|
||||
const terminalHost = hostSources['TerminalHost.tsx'];
|
||||
assert.ok(vaultHost && terminalHost);
|
||||
|
||||
// Notes / connection logs live in dedicated stores — VaultHost must not
|
||||
// publish them into the vault domain bag that AppView consumes.
|
||||
assert.doesNotMatch(vaultHost, /\bnotes\s*,/);
|
||||
assert.doesNotMatch(vaultHost, /\bconnectionLogs\s*,/);
|
||||
assert.doesNotMatch(vaultHost, /notesStore|connectionLogsStore|useNotesStore|useConnectionLogs/);
|
||||
|
||||
// Accent feeds useThemeRuntime for local injection only; the published
|
||||
// terminal domain bag must not list accentMode/customAccent fields.
|
||||
const domainStart = terminalHost.indexOf('const terminalDomain = useMemo');
|
||||
assert.notEqual(domainStart, -1);
|
||||
const domain = terminalHost.slice(domainStart, terminalHost.indexOf('useLayoutEffect(() => {\n if (terminalDomain)', domainStart));
|
||||
assert.doesNotMatch(domain, /accentMode/);
|
||||
assert.doesNotMatch(domain, /customAccent/);
|
||||
assert.match(domain, /currentTerminalTheme,/);
|
||||
assert.match(
|
||||
terminalHost,
|
||||
/useTerminalAppearanceInjection\(accentedGlobalAppearance/,
|
||||
);
|
||||
});
|
||||
|
||||
test('AppShell uses default memo (store-driven), not always-rerender comparator', () => {
|
||||
assert.match(appShellSource, /memo\s*\(\s*AppShellView\s*\)/);
|
||||
assert.doesNotMatch(appShellSource, /memo\s*\(\s*AppShellView\s*,\s*\(\s*\)\s*=>\s*false\s*\)/);
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const source = readFileSync(new URL("./AppSideEffects.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("snippets delete handler cleans host bindings via deleteSelectedSnippets", () => {
|
||||
assert.match(source, /collectSnippetDeleteIds/);
|
||||
assert.match(source, /deleteSelectedSnippets/);
|
||||
assert.match(
|
||||
source,
|
||||
/netcatty:snippets:delete[\s\S]*void deleteSelectedSnippets\(ids\)/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/updateSnippets\(snippets\.filter\(\(s\) => !ids\.has\(s\.id\)\)\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("snippets delete handler uses vault live snapshot instead of component refs", () => {
|
||||
// Component-level snippetsRef/hostsRef lag concurrent vault mutations that
|
||||
// already advanced useVaultState refs before React re-renders AppSideEffects.
|
||||
// Deletion must go through the vault hook's atomic live-snapshot path.
|
||||
assert.match(source, /deleteSelectedSnippets,/);
|
||||
assert.doesNotMatch(source, /snippetsRef\.current\s*=\s*snippets/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/deleteSelectedSnippetsFromVault\(\s*snippetsRef\.current/,
|
||||
);
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { getNotesSnapshot } from '../state/notesStore';
|
||||
import { useVaultAgentBridge } from '../state/useVaultAgentBridge';
|
||||
import { useWindowControls } from '../state/useWindowControls';
|
||||
import { useTerminalKeyboardFocus } from '../state/useTerminalKeyboardFocus';
|
||||
import { useEditorTabChromeList } from '../state/editorTabStore';
|
||||
import {
|
||||
isPluginViewTabId,
|
||||
@@ -82,6 +83,7 @@ import { useDiscoveredShells, resolveShellSetting } from '../../lib/useDiscovere
|
||||
import { Host, HostProtocol, KnownHost, SerialConfig, Snippet, SSHKey, TerminalSession } from '../../types';
|
||||
import { resolveSnippetCommand } from '../../components/SnippetExecutionProvider';
|
||||
import { isScriptSnippet } from '../../domain/snippetScript.ts';
|
||||
import { collectSnippetDeleteIds } from '../../domain/snippetSelection.ts';
|
||||
import { useAppStartupEffects } from './useAppStartupEffects';
|
||||
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl } from './AppHandlers';
|
||||
|
||||
@@ -131,12 +133,15 @@ export function AppSideEffects() {
|
||||
terminalSettings,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
disableTerminalFontZoom,
|
||||
isHotkeyRecording,
|
||||
showSftpTab,
|
||||
shellOnlyTabNumberShortcuts,
|
||||
workspaceFocusStyle,
|
||||
} = settings;
|
||||
|
||||
useTerminalKeyboardFocus(hotkeyScheme !== 'disabled' && !disableTerminalFontZoom);
|
||||
|
||||
const discoveredShells = useDiscoveredShells();
|
||||
|
||||
// Sync workspace focus indicator style to DOM for CSS targeting
|
||||
@@ -184,6 +189,7 @@ export function AppSideEffects() {
|
||||
updateHosts,
|
||||
updateKeys,
|
||||
updateSnippets,
|
||||
deleteSelectedSnippets,
|
||||
updateCustomGroups,
|
||||
updateKnownHosts,
|
||||
updateManagedSources,
|
||||
@@ -646,8 +652,17 @@ export function AppSideEffects() {
|
||||
};
|
||||
}, [isPeerSessionWindow]);
|
||||
|
||||
const pendingTrayConnectFlushKeyRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVaultInitialized || pendingTrayPanelConnectHostIds.length === 0) return;
|
||||
if (!isVaultInitialized) return;
|
||||
if (pendingTrayPanelConnectHostIds.length === 0) {
|
||||
pendingTrayConnectFlushKeyRef.current = null;
|
||||
return;
|
||||
}
|
||||
const flushKey = pendingTrayPanelConnectHostIds.join('\0');
|
||||
if (pendingTrayConnectFlushKeyRef.current === flushKey) return;
|
||||
pendingTrayConnectFlushKeyRef.current = flushKey;
|
||||
flushQueuedTrayPanelConnectHostsImpl(() => ({
|
||||
connectNow: _handleTrayPanelConnect,
|
||||
pendingHostIds: pendingTrayPanelConnectHostIds,
|
||||
@@ -1591,18 +1606,22 @@ export function AppSideEffects() {
|
||||
};
|
||||
}, [handleOpenSettings, t]);
|
||||
|
||||
// Delete-from-sidepanel plumbing: ScriptsSidePanel's right-click menu
|
||||
// dispatches `netcatty:snippets:delete` with the snippet id. Handled here
|
||||
// (rather than in QuickAddSnippetDialog) because delete needs no UI.
|
||||
// Delete-from-sidepanel plumbing: ScriptsSidePanel dispatches
|
||||
// `netcatty:snippets:delete` with `id` (single) or `ids` (bulk). Handled
|
||||
// here (rather than in QuickAddSnippetDialog) because delete needs no UI.
|
||||
// Goes through useVaultState.deleteSelectedSnippets so login/connect script
|
||||
// bindings clear with the snippets (SnippetsManager parity) against the
|
||||
// latest persisted vault snapshot under the shared cross-window lock.
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const id = (e as CustomEvent<{ id?: string }>).detail?.id;
|
||||
if (!id) return;
|
||||
updateSnippets(snippets.filter((s) => s.id !== id));
|
||||
const detail = (e as CustomEvent<{ id?: string; ids?: string[] }>).detail;
|
||||
const ids = collectSnippetDeleteIds(detail);
|
||||
if (ids.size === 0) return;
|
||||
void deleteSelectedSnippets(ids);
|
||||
};
|
||||
window.addEventListener('netcatty:snippets:delete', handler);
|
||||
return () => window.removeEventListener('netcatty:snippets:delete', handler);
|
||||
}, [snippets, updateSnippets]);
|
||||
}, [deleteSelectedSnippets]);
|
||||
|
||||
const handleEndSessionDrag = useCallback(() => {
|
||||
setDraggingSessionId(null);
|
||||
|
||||
@@ -34,6 +34,7 @@ import { useMainWindowInputFocusRecovery } from '../state/useMainWindowInputFocu
|
||||
import { useExternalMcpToggleState } from '../state/useExternalMcpToggleState';
|
||||
import { selectPluginThemeTokens } from '../state/pluginContributionEnvironment';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { resolveEffectiveTerminalHost } from '../../domain/terminalHostResolution';
|
||||
import { pluginViewTabStore, usePluginViewTabs } from '../state/pluginViewTabStore';
|
||||
import { buildPluginSettingScopeCatalog } from '../state/usePluginSettingScopeCatalog';
|
||||
import { useWorkSurfaceHostEditor } from '../state/useWorkSurfaceHostEditor';
|
||||
@@ -317,6 +318,29 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
setAddToWorkspaceDialog({ mode: 'append', workspaceId });
|
||||
}, [setAddToWorkspaceDialog]);
|
||||
|
||||
const validProxyProfileIds = useMemo(
|
||||
() => new Set(proxyProfiles.map((profile) => profile.id)),
|
||||
[proxyProfiles],
|
||||
);
|
||||
|
||||
const resolveWorkspaceAppendHost = useCallback((host: typeof hosts[number]) => (
|
||||
resolveEffectiveTerminalHost({
|
||||
host,
|
||||
groupConfigs,
|
||||
proxyProfiles,
|
||||
validProxyProfileIds,
|
||||
})
|
||||
), [groupConfigs, proxyProfiles, validProxyProfileIds]);
|
||||
|
||||
const handleAppendHostToWorkspace = useCallback((workspaceId: string, hostId: string) => {
|
||||
const host = hosts.find((entry) => entry.id === hostId);
|
||||
if (!host) return;
|
||||
const ws = workspaces.find((entry) => entry.id === workspaceId);
|
||||
if (!ws) return;
|
||||
const rootDir = ws.root.type === 'split' ? ws.root.direction : 'vertical';
|
||||
appendHostToWorkspace(workspaceId, resolveWorkspaceAppendHost(host), rootDir);
|
||||
}, [appendHostToWorkspace, hosts, resolveWorkspaceAppendHost, workspaces]);
|
||||
|
||||
const isPeerSessionWindow = typeof window !== 'undefined'
|
||||
&& window.location.hash.startsWith('#/session-window');
|
||||
const externalMcpToggle = useExternalMcpToggleState();
|
||||
@@ -471,8 +495,10 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onEndSessionDrag={handleEndSessionDrag}
|
||||
onReorderTabs={reorderWorkTabs}
|
||||
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
|
||||
onAppendHostToWorkspace={handleAppendHostToWorkspace}
|
||||
showSftpTab={showSftpTab}
|
||||
showHostTreeSidebar={showHostTreeSidebar}
|
||||
switchTabKeyBinding={keyBindings.find((binding) => binding.action === 'switchToTab') ?? null}
|
||||
dynamicTabTitleMode={dynamicTabTitleMode}
|
||||
editorTabs={editorTabs}
|
||||
pluginViewTabs={pluginViewTabs}
|
||||
@@ -658,6 +684,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
|
||||
onAddSessionToWorkspace={addSessionToWorkspace}
|
||||
onRequestAddToWorkspace={handleRequestAddToWorkspace}
|
||||
onAppendHostToWorkspace={handleAppendHostToWorkspace}
|
||||
onUpdateSplitSizes={updateSplitSizes}
|
||||
onSetDraggingSessionId={setDraggingSessionId}
|
||||
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
|
||||
@@ -779,13 +806,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
<AddToWorkspaceDialog
|
||||
open
|
||||
onOpenChange={(open) => { if (!open) setAddToWorkspaceDialog(null); }}
|
||||
// Filter serial hosts only in append mode — appendHostToWorkspace
|
||||
// has no serial code path. Create mode goes through
|
||||
// createWorkspaceFromTargets, which builds a SerialConfig-backed
|
||||
// session for serial hosts, so those should remain pickable.
|
||||
hosts={addToWorkspaceDialog.mode === 'append'
|
||||
? hosts.filter((h) => h.protocol !== 'serial')
|
||||
: hosts}
|
||||
hosts={hosts}
|
||||
workspaceTitle={
|
||||
addToWorkspaceDialog.mode === 'append'
|
||||
? workspaces.find((w) => w.id === addToWorkspaceDialog.workspaceId)?.title
|
||||
@@ -804,7 +825,11 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
if (target.kind === 'local') {
|
||||
appendLocalTerminalToWorkspace(addToWorkspaceDialog.workspaceId, undefined, rootDir);
|
||||
} else {
|
||||
appendHostToWorkspace(addToWorkspaceDialog.workspaceId, target.host, rootDir);
|
||||
appendHostToWorkspace(
|
||||
addToWorkspaceDialog.workspaceId,
|
||||
resolveWorkspaceAppendHost(target.host),
|
||||
rootDir,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const source = readFileSync(new URL('./AppView.tsx', import.meta.url), 'utf8');
|
||||
|
||||
test('workspace append resolves group defaults before creating host sessions', () => {
|
||||
assert.match(source, /resolveEffectiveTerminalHost\(\{/);
|
||||
assert.match(source, /groupConfigs,/);
|
||||
assert.match(source, /proxyProfiles,/);
|
||||
assert.match(
|
||||
source,
|
||||
/appendHostToWorkspace\(workspaceId, resolveWorkspaceAppendHost\(host\), rootDir\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/appendHostToWorkspace\([\s\S]*?resolveWorkspaceAppendHost\(target\.host\),[\s\S]*?rootDir/,
|
||||
);
|
||||
});
|
||||
@@ -78,6 +78,30 @@ test("editor tabs use the followed terminal theme when follow-app terminal theme
|
||||
assert.equal(resolved?.id, currentTheme.id);
|
||||
});
|
||||
|
||||
test("follow-app chrome applies custom accent onto the published base theme", () => {
|
||||
const editorTab = {
|
||||
id: "editor-1",
|
||||
hostId: "host-1",
|
||||
sessionId: "sftp-1",
|
||||
};
|
||||
|
||||
const resolved = resolveActiveChromeTheme({
|
||||
...baseInput,
|
||||
accentMode: "custom",
|
||||
customAccent: "0 100% 50%",
|
||||
activeTabId: toEditorTabId(editorTab.id),
|
||||
editorTabs: [editorTab as unknown as EditorTab],
|
||||
followAppTerminalTheme: true,
|
||||
hostById: new Map([
|
||||
["host-1", { id: "host-1", theme: hostTheme.id } as unknown as Host],
|
||||
]),
|
||||
});
|
||||
|
||||
assert.equal(resolved?.id, currentTheme.id);
|
||||
assert.notEqual(resolved?.colors.cursor, currentTheme.colors.cursor);
|
||||
assert.notEqual(resolved, currentTheme);
|
||||
});
|
||||
|
||||
test("log tabs use the saved log theme when available", () => {
|
||||
const resolved = resolveActiveChromeTheme({
|
||||
...baseInput,
|
||||
|
||||
@@ -68,7 +68,9 @@ export function resolveActiveChromeTheme({
|
||||
};
|
||||
|
||||
const resolveHostTheme = (hostId: string): TerminalTheme => {
|
||||
if (followAppTerminalTheme) return currentTerminalTheme;
|
||||
if (followAppTerminalTheme) {
|
||||
return applyCustomAccentToTerminalTheme(currentTerminalTheme, accentMode, customAccent);
|
||||
}
|
||||
if (resolveSessionAppearance) {
|
||||
return resolveSessionAppearance(resolveHostScope(hostId)).theme;
|
||||
}
|
||||
@@ -90,12 +92,15 @@ export function resolveActiveChromeTheme({
|
||||
const logView = logViews.find((item) => item.id === activeTabId);
|
||||
if (logView) {
|
||||
const explicitThemeId = logView.log.themeId;
|
||||
return explicitThemeId ? themeById.get(explicitThemeId) ?? currentTerminalTheme : currentTerminalTheme;
|
||||
const base = explicitThemeId ? themeById.get(explicitThemeId) ?? currentTerminalTheme : currentTerminalTheme;
|
||||
return applyCustomAccentToTerminalTheme(base, accentMode, customAccent);
|
||||
}
|
||||
|
||||
const workspace = workspaceById.get(activeTabId);
|
||||
if (workspace) {
|
||||
if (followAppTerminalTheme) return currentTerminalTheme;
|
||||
if (followAppTerminalTheme) {
|
||||
return applyCustomAccentToTerminalTheme(currentTerminalTheme, accentMode, customAccent);
|
||||
}
|
||||
|
||||
if (workspace.viewMode === "focus") {
|
||||
const focusedSession = resolveWorkspaceTargetSessionFromMap(workspace, sessionById);
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
useSessionSnapshotActions,
|
||||
} from '../../state/sessionSnapshotStore';
|
||||
import { useSettingsChromeStore } from '../../state/settingsChromeStore';
|
||||
import { useAppearanceChromeStore } from '../../state/appearanceChromeStore';
|
||||
import { useVaultSnapshot } from '../../state/vaultSnapshotStore';
|
||||
import {
|
||||
getTerminalSettingsActions,
|
||||
@@ -40,8 +39,6 @@ const IS_MAC_CLIENT =
|
||||
export function ChromeHost() {
|
||||
const { t } = useI18n();
|
||||
const settingsChrome = useSettingsChromeStore();
|
||||
const appearance = useAppearanceChromeStore();
|
||||
void appearance; // accent leaves subscribe directly; keep import for island contract
|
||||
const session = useSessionSnapshot();
|
||||
const sessionActions = useSessionSnapshotActions();
|
||||
const vault = useVaultSnapshot();
|
||||
|
||||
@@ -85,6 +85,7 @@ export function TerminalHost() {
|
||||
|
||||
const {
|
||||
globalAppearance,
|
||||
accentedGlobalAppearance,
|
||||
clearIntent: clearThemeIntent,
|
||||
settleManualIntent: settleManualThemeIntent,
|
||||
pickTheme: pickTerminalTheme,
|
||||
@@ -92,7 +93,9 @@ export function TerminalHost() {
|
||||
currentTerminalTheme,
|
||||
} = themeRuntime;
|
||||
|
||||
useTerminalAppearanceInjection(globalAppearance, {
|
||||
// Inject live accent into CSS vars without publishing accented theme identity
|
||||
// into the terminal domain bag (accent drag must not rebuild AppShell).
|
||||
useTerminalAppearanceInjection(accentedGlobalAppearance, {
|
||||
includeChromeSurfaces: followAppTerminalTheme,
|
||||
});
|
||||
|
||||
@@ -103,6 +106,8 @@ export function TerminalHost() {
|
||||
clearThemeIntent();
|
||||
}, [followAppTerminalTheme, clearThemeIntent]);
|
||||
|
||||
// Bridge exposes the stable base theme only — ChromeHost must not republish
|
||||
// when accentedGlobalAppearance identity churns during color-picker drag.
|
||||
const themeBridgeActions = useMemo(() => ({
|
||||
clearThemeIntent,
|
||||
settleManualThemeIntent,
|
||||
|
||||
@@ -126,3 +126,146 @@ test('cancelled startup update check resets its latch instead of skipping foreve
|
||||
assert.match(tail, /startupCheckTimeoutRef\.current = setTimeout\(async \(\) => \{\s*\n\s*checkArmed = false;/);
|
||||
assert.match(tail, /if \(checkArmed\) \{\s*\n\s*hasCheckedOnStartupRef\.current = false;\s*\n\s*\}/);
|
||||
});
|
||||
|
||||
test('terminal popup config survives StrictMode unsubscribe/resubscribe', () => {
|
||||
const preloadSource = readFileSync(new URL('../../electron/preload.cjs', import.meta.url), 'utf8');
|
||||
const apiSource = readFileSync(new URL('../../electron/preload/api.cjs', import.meta.url), 'utf8');
|
||||
|
||||
assert.match(preloadSource, /lastPayload:\s*null/);
|
||||
assert.match(
|
||||
preloadSource,
|
||||
/terminalPopupConfigState\.lastPayload = payload/,
|
||||
'incoming popup config must be retained beyond the one-shot pending slot',
|
||||
);
|
||||
|
||||
const subscribeAt = apiSource.indexOf('onTerminalPopupConfig:');
|
||||
assert.notEqual(subscribeAt, -1);
|
||||
const subscribe = apiSource.slice(subscribeAt, subscribeAt + 700);
|
||||
assert.match(
|
||||
subscribe,
|
||||
/terminalPopupConfigState\.pending \?\? terminalPopupConfigState\.lastPayload/,
|
||||
'resubscribe must replay lastPayload after pending was drained',
|
||||
);
|
||||
assert.match(subscribe, /terminalPopupConfigState\.pending = null/);
|
||||
assert.doesNotMatch(
|
||||
subscribe,
|
||||
/terminalPopupConfigState\.lastPayload = null/,
|
||||
'StrictMode remount must not clear lastPayload on subscribe',
|
||||
);
|
||||
});
|
||||
|
||||
test('vault init cancels the superseded StrictMode effect before publishing ready', () => {
|
||||
const vaultSource = readFileSync(
|
||||
new URL('../state/useVaultState.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const initAt = vaultSource.indexOf('let cancelled = false;');
|
||||
assert.notEqual(initAt, -1, 'vault init must track cancellation');
|
||||
const initSlice = vaultSource.slice(initAt, initAt + 12000);
|
||||
assert.match(initSlice, /return \(\) => \{\s*\n\s*cancelled = true;\s*\n\s*\};/);
|
||||
assert.match(
|
||||
initSlice,
|
||||
/if \(!cancelled\) \{\s*\n\s*setIsInitialized\(true\);\s*\n\s*setVaultInitialized\(true\);\s*\n\s*\}/,
|
||||
'only the surviving init may mark the vault ready',
|
||||
);
|
||||
assert.match(initSlice, /if \(cancelled\) return;/);
|
||||
});
|
||||
|
||||
test('global hotkey registration cleans up across StrictMode remount', () => {
|
||||
const systemEffectsSource = readFileSync(
|
||||
new URL('../state/systemSettingsEffects.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const hotkeyAt = systemEffectsSource.indexOf('Persist and sync toggle window hotkey setting');
|
||||
assert.notEqual(hotkeyAt, -1);
|
||||
const hotkeyEffect = systemEffectsSource.slice(hotkeyAt, hotkeyAt + 2200);
|
||||
assert.match(hotkeyEffect, /let cancelled = false;/);
|
||||
assert.match(hotkeyEffect, /if \(cancelled\) return;/);
|
||||
assert.match(
|
||||
hotkeyEffect,
|
||||
/if \(didRegister\) \{\s*\n\s*bridge\?\.unregisterGlobalHotkey/,
|
||||
'cleanup must unregister a registration started by this effect',
|
||||
);
|
||||
// Early return before notify must not skip returning the cleanup function.
|
||||
assert.doesNotMatch(
|
||||
hotkeyEffect,
|
||||
/if \(!persistMountedRef\.current\) return;\s*\n\s*notifySettingsChanged/,
|
||||
);
|
||||
});
|
||||
|
||||
test('settings persistMountedRef resets on StrictMode cleanup', () => {
|
||||
const settingsSource = readFileSync(
|
||||
new URL('../state/useSettingsState.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const markAt = settingsSource.indexOf('Mark persist effects mounted AFTER all persist useEffects');
|
||||
assert.notEqual(markAt, -1);
|
||||
const markEffect = settingsSource.slice(markAt, markAt + 500);
|
||||
assert.match(markEffect, /persistMountedRef\.current = true;/);
|
||||
assert.match(
|
||||
markEffect,
|
||||
/return \(\) => \{\s*\n\s*persistMountedRef\.current = false;\s*\n\s*\};/,
|
||||
'remount must treat boot as a fresh mount, not a settings change',
|
||||
);
|
||||
});
|
||||
|
||||
test('tray panel connect flush latches against StrictMode double invoke', () => {
|
||||
const sideEffectsSource = readFileSync(
|
||||
new URL('./AppSideEffects.tsx', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(sideEffectsSource, /pendingTrayConnectFlushKeyRef/);
|
||||
assert.match(
|
||||
sideEffectsSource,
|
||||
/if \(pendingTrayConnectFlushKeyRef\.current === flushKey\) return;/,
|
||||
);
|
||||
});
|
||||
|
||||
test('ssh transport idle TTL notify latches against StrictMode double invoke', () => {
|
||||
const settingsSource = readFileSync(
|
||||
new URL('../state/useSettingsState.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(settingsSource, /lastPushedSshTransportIdleTtlRef/);
|
||||
assert.match(
|
||||
settingsSource,
|
||||
/if \(lastPushedSshTransportIdleTtlRef\.current === sshTransportIdleTtlMs\) return;/,
|
||||
);
|
||||
assert.match(
|
||||
settingsSource,
|
||||
/lastPushedSshTransportIdleTtlRef\.current = sshTransportIdleTtlMs;/,
|
||||
);
|
||||
});
|
||||
|
||||
test('terminal selection Ask-AI payload is consumed once under StrictMode', () => {
|
||||
const hostSource = readFileSync(
|
||||
new URL('../../components/terminalLayer/TerminalLayerSupport.tsx', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(hostSource, /consumedTerminalSelectionRequestIds/);
|
||||
assert.doesNotMatch(
|
||||
hostSource,
|
||||
/consumedTerminalSelectionRequestIdRef/,
|
||||
'component refs reset on StrictMode remount; use a module Set',
|
||||
);
|
||||
assert.match(
|
||||
hostSource,
|
||||
/if \(consumedTerminalSelectionRequestIds\.has\(pendingTerminalSelection\.requestId\)\)/,
|
||||
);
|
||||
const latchAt = hostSource.indexOf('markTerminalSelectionRequestConsumed(pendingTerminalSelection.requestId)');
|
||||
const draftAt = hostSource.indexOf('updateDraft(scopeKey, defaultAgentId');
|
||||
assert.ok(latchAt > 0 && draftAt > latchAt, 'must latch before mutating the draft');
|
||||
});
|
||||
|
||||
test('Codex App Server interaction bridge is app-singleton like MCP approvals', () => {
|
||||
assert.match(appSource, /setupCodexAppServerInteractionBridge/);
|
||||
const panelSource = readFileSync(
|
||||
new URL('../../components/AIChatSidePanel.tsx', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
panelSource,
|
||||
/setupCodexAppServerInteractionBridge/,
|
||||
'per-panel Codex IPC listeners fan out approvals under retained multi-tab mounts',
|
||||
);
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { buildNumberShortcutTabTargets } from './tabShortcutTargets.ts';
|
||||
import { buildNumberShortcutTabTargets, buildTabShortcutNumberById } from './tabShortcutTargets.ts';
|
||||
|
||||
test('number shortcut tabs include vault and sftp by default', () => {
|
||||
assert.deepEqual(
|
||||
@@ -62,3 +62,43 @@ test('pinned tabs cannot be duplicated by a malformed persisted work ordering',
|
||||
['vault', 'sftp', 'session-1'],
|
||||
);
|
||||
});
|
||||
|
||||
test('shortcut number map uses 1-based indices matching Ctrl/Cmd+[1...9]', () => {
|
||||
const map = buildTabShortcutNumberById({
|
||||
showSftpTab: true,
|
||||
shellOnlyTabNumberShortcuts: false,
|
||||
orderedTabs: ['session-1', 'workspace-1'],
|
||||
editorTabIds: [],
|
||||
});
|
||||
assert.equal(map.get('vault'), 1);
|
||||
assert.equal(map.get('sftp'), 2);
|
||||
assert.equal(map.get('session-1'), 3);
|
||||
assert.equal(map.get('workspace-1'), 4);
|
||||
});
|
||||
|
||||
test('shortcut number map skips pinned tabs in shell-only mode', () => {
|
||||
const map = buildTabShortcutNumberById({
|
||||
showSftpTab: true,
|
||||
shellOnlyTabNumberShortcuts: true,
|
||||
orderedTabs: ['session-1', 'workspace-1'],
|
||||
editorTabIds: [],
|
||||
});
|
||||
assert.equal(map.has('vault'), false);
|
||||
assert.equal(map.has('sftp'), false);
|
||||
assert.equal(map.get('session-1'), 1);
|
||||
assert.equal(map.get('workspace-1'), 2);
|
||||
});
|
||||
|
||||
test('shortcut number map caps at nine entries', () => {
|
||||
const orderedTabs = Array.from({ length: 12 }, (_, index) => `session-${index + 1}`);
|
||||
const map = buildTabShortcutNumberById({
|
||||
showSftpTab: false,
|
||||
shellOnlyTabNumberShortcuts: true,
|
||||
orderedTabs,
|
||||
editorTabIds: [],
|
||||
});
|
||||
assert.equal(map.size, 9);
|
||||
assert.equal(map.get('session-1'), 1);
|
||||
assert.equal(map.get('session-9'), 9);
|
||||
assert.equal(map.has('session-10'), false);
|
||||
});
|
||||
@@ -12,3 +12,22 @@ export function buildNumberShortcutTabTargets(params: {
|
||||
const pinnedTabs = params.showSftpTab ? ['vault', 'sftp'] : ['vault'];
|
||||
return [...new Set([...pinnedTabs, ...workTabs])];
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps tab ids to Cmd/Ctrl+[1...9] shortcut indices (1-based).
|
||||
* Only the first nine shortcut targets receive a number.
|
||||
*/
|
||||
export function buildTabShortcutNumberById(params: {
|
||||
showSftpTab: boolean;
|
||||
shellOnlyTabNumberShortcuts: boolean;
|
||||
orderedTabs: readonly string[];
|
||||
editorTabIds: readonly string[];
|
||||
}): ReadonlyMap<string, number> {
|
||||
const targets = buildNumberShortcutTabTargets(params);
|
||||
const map = new Map<string, number>();
|
||||
const limit = Math.min(9, targets.length);
|
||||
for (let index = 0; index < limit; index += 1) {
|
||||
map.set(targets[index], index + 1);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
@@ -267,6 +267,32 @@ test('shared host tree uses the followed terminal theme when follow-app terminal
|
||||
assert.equal(resolved.id, currentTheme.id);
|
||||
});
|
||||
|
||||
test('follow-app host tree applies custom accent onto the published base theme', () => {
|
||||
const currentTheme = makeTheme('app-light', 'light', '#ffffff');
|
||||
const host = {
|
||||
id: 'host-1',
|
||||
label: 'Host',
|
||||
hostname: 'host.local',
|
||||
username: 'root',
|
||||
tags: [],
|
||||
os: 'linux',
|
||||
} as Host;
|
||||
|
||||
const resolved = resolveWorkTabHostTreeTheme({
|
||||
activeHostId: host.id,
|
||||
accentMode: 'custom',
|
||||
currentTerminalTheme: currentTheme,
|
||||
customAccent: '0 100% 50%',
|
||||
followAppTerminalTheme: true,
|
||||
hostById: new Map([[host.id, host]]),
|
||||
themeById: new Map([[currentTheme.id, currentTheme]]),
|
||||
});
|
||||
|
||||
assert.equal(resolved.id, currentTheme.id);
|
||||
assert.notEqual(resolved.colors.cursor, currentTheme.colors.cursor);
|
||||
assert.notEqual(resolved, currentTheme);
|
||||
});
|
||||
|
||||
test('shared host tree falls back to the current terminal theme without an active host', () => {
|
||||
const currentTheme = makeTheme('app-dark', 'dark', '#111111');
|
||||
|
||||
|
||||
@@ -179,7 +179,9 @@ export function resolveWorkTabHostTreeTheme({
|
||||
hostById: ReadonlyMap<string, Host>;
|
||||
themeById: ReadonlyMap<string, TerminalTheme>;
|
||||
}): TerminalTheme {
|
||||
if (!activeHostId || followAppTerminalTheme) return currentTerminalTheme;
|
||||
if (!activeHostId || followAppTerminalTheme) {
|
||||
return applyCustomAccentToTerminalTheme(currentTerminalTheme, accentMode, customAccent);
|
||||
}
|
||||
|
||||
const host = hostById.get(activeHostId) ?? null;
|
||||
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
|
||||
|
||||
@@ -61,6 +61,15 @@ 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...',
|
||||
|
||||
@@ -89,8 +89,12 @@ export const enCoreMessages: Messages = {
|
||||
'settings.tab.appearance': 'Appearance',
|
||||
'settings.tab.terminal': 'Terminal',
|
||||
'settings.tab.shortcuts': 'Shortcuts',
|
||||
'settings.tab.ai': 'AI',
|
||||
'settings.tab.syncCloud': 'Sync & Cloud',
|
||||
'settings.tab.system': 'System',
|
||||
'settings.search.open': 'Search settings',
|
||||
'settings.search.placeholder': 'Search settings…',
|
||||
'settings.search.noResults': 'No matching settings',
|
||||
|
||||
// Settings > System
|
||||
'settings.system.title': 'System',
|
||||
@@ -234,6 +238,7 @@ export const enCoreMessages: Messages = {
|
||||
'tray.status.active': 'Active',
|
||||
'tray.status.inactive': 'Inactive',
|
||||
'tray.status.error': 'Error',
|
||||
'tray.status.unknown': 'Unknown',
|
||||
'tray.recentHosts': 'Recent Hosts',
|
||||
'tray.empty.title': 'Nothing here yet',
|
||||
'tray.empty.subtitle': 'Go connect to a server, they miss you 🚀',
|
||||
@@ -784,6 +789,8 @@ Highlight the focused split pane:
|
||||
'settings.shortcuts.disableTerminalFontZoom.desc': 'Turn off terminal font zoom shortcuts, including Cmd/Ctrl + mouse wheel.',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': 'Tab shortcuts skip pinned tabs',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': 'When enabled, Cmd/Ctrl+[1...9] and Ctrl+Tab switch only work tabs (terminals, workspaces, editors), not the pinned Vault or SFTP tabs.',
|
||||
'settings.shortcuts.showTabNumberBadges.label': 'Show tab number badges',
|
||||
'settings.shortcuts.showTabNumberBadges.desc': 'While holding Cmd/Ctrl, show 1-9 on tabs that match Cmd/Ctrl+[1...9], so you can jump accurately.',
|
||||
'settings.shortcuts.section.custom': 'Custom Shortcuts',
|
||||
'settings.shortcuts.resetAll': 'Reset All',
|
||||
'settings.shortcuts.recording': 'Press keys...',
|
||||
|
||||
@@ -8,9 +8,12 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.empty': 'No data available.',
|
||||
'systemManager.tabs.overview': 'Overview',
|
||||
'systemManager.tabs.processes': 'Processes',
|
||||
'systemManager.tabs.ports': 'Ports',
|
||||
'systemManager.tabs.services': 'Services',
|
||||
'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.',
|
||||
|
||||
@@ -25,6 +28,8 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDockerImages': 'Failed to load images',
|
||||
'systemManager.errors.loadOverview': 'Failed to load system overview',
|
||||
'systemManager.errors.loadGpu': 'Failed to load GPU / NPU stats',
|
||||
'systemManager.errors.loadPorts': 'Failed to load listening ports',
|
||||
'systemManager.errors.loadServices': 'Failed to load systemd services',
|
||||
'systemManager.errors.sshChannelUnavailable': 'The server refused to open a new execution channel. Try again later, or reconnect this host.',
|
||||
|
||||
'systemManager.overview.empty': 'No system overview data yet.',
|
||||
@@ -189,6 +194,34 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.start': 'Start',
|
||||
'systemManager.docker.stop': 'Stop',
|
||||
|
||||
'systemManager.ports.unavailable': 'No listening-port tools (ss / netstat) detected on this host.',
|
||||
'systemManager.ports.loading': 'Loading listening ports…',
|
||||
'systemManager.ports.empty': 'No listening ports reported.',
|
||||
'systemManager.ports.search': 'Search ports…',
|
||||
'systemManager.ports.meta': '{{count}} listener(s)',
|
||||
'systemManager.ports.filter.all': 'All',
|
||||
'systemManager.ports.unknownProcess': 'Unknown process',
|
||||
'systemManager.ports.terminate': 'Terminate',
|
||||
'systemManager.ports.confirmTerminate': 'Send SIGTERM to process {{pid}} that holds this port?',
|
||||
|
||||
'systemManager.services.unavailable': 'systemctl is not available on this host.',
|
||||
'systemManager.services.loading': 'Loading systemd services…',
|
||||
'systemManager.services.empty': 'No systemd services found.',
|
||||
'systemManager.services.search': 'Search services…',
|
||||
'systemManager.services.meta': '{{count}} service(s)',
|
||||
'systemManager.services.filter.all': 'All',
|
||||
'systemManager.services.filter.running': 'Running',
|
||||
'systemManager.services.filter.failed': 'Failed',
|
||||
'systemManager.services.filter.inactive': 'Inactive',
|
||||
'systemManager.services.start': 'Start',
|
||||
'systemManager.services.stop': 'Stop',
|
||||
'systemManager.services.restart': 'Restart',
|
||||
'systemManager.services.enable': 'Enable',
|
||||
'systemManager.services.disable': 'Disable',
|
||||
'systemManager.services.reload': 'Reload',
|
||||
'systemManager.services.scope.user': 'user',
|
||||
'systemManager.services.confirmAction': '{{action}} {{name}}?',
|
||||
|
||||
'systemManager.gpu.unavailable': 'No NVIDIA GPU or Ascend NPU tools detected on this host.',
|
||||
'systemManager.gpu.loading': 'Loading accelerator stats…',
|
||||
'systemManager.gpu.empty': 'Accelerator tools are present, but no devices were reported.',
|
||||
|
||||
@@ -325,6 +325,7 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.transfers.dragToResize': 'Drag to resize',
|
||||
'sftp.goUp': 'Go up',
|
||||
'sftp.goToTerminalCwd': 'Go to terminal directory',
|
||||
'sftp.locatePathInTerminal': 'Open path in terminal',
|
||||
'sftp.followTerminalCwd': 'Follow terminal directory',
|
||||
'sftp.followTerminalCwd.enable': 'Enable follow terminal directory',
|
||||
'sftp.followTerminalCwd.disable': 'Disable follow terminal directory',
|
||||
|
||||
@@ -42,6 +42,15 @@ 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': 'Загрузка моделей...',
|
||||
|
||||
@@ -88,8 +88,12 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.tab.appearance': 'Внешний вид',
|
||||
'settings.tab.terminal': 'Терминал',
|
||||
'settings.tab.shortcuts': 'Горячие клавиши',
|
||||
'settings.tab.ai': 'AI',
|
||||
'settings.tab.syncCloud': 'Синхронизация и облако',
|
||||
'settings.tab.system': 'Система',
|
||||
'settings.search.open': 'Поиск настроек',
|
||||
'settings.search.placeholder': 'Искать настройки…',
|
||||
'settings.search.noResults': 'Совпадений не найдено',
|
||||
|
||||
// Settings > System
|
||||
'settings.system.title': 'Система',
|
||||
@@ -222,6 +226,7 @@ export const ruCoreMessages: Messages = {
|
||||
'tray.status.active': 'Активно',
|
||||
'tray.status.inactive': 'Неактивно',
|
||||
'tray.status.error': 'Ошибка',
|
||||
'tray.status.unknown': 'Неизвестно',
|
||||
'tray.recentHosts': 'Недавние хосты',
|
||||
'tray.empty.title': 'Пока здесь ничего нет',
|
||||
'tray.empty.subtitle': 'Подключитесь к серверу, они по вам скучают 🚀',
|
||||
@@ -729,6 +734,8 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.shortcuts.disableTerminalFontZoom.desc': 'Отключает быстрый масштаб текста в терминале, включая Cmd/Ctrl + колесо мыши.',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': 'Сочетания без закреплённых вкладок',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': 'Если включено, Cmd/Ctrl+[1...9] и Ctrl+Tab переключают только рабочие вкладки (терминалы, рабочие области, редакторы), а не закреплённые Vault и SFTP.',
|
||||
'settings.shortcuts.showTabNumberBadges.label': 'Номера на вкладках',
|
||||
'settings.shortcuts.showTabNumberBadges.desc': 'Показывать 1-9 на вкладках при удержании Cmd/Ctrl, чтобы точнее переключаться через Cmd/Ctrl+[1...9].',
|
||||
'settings.shortcuts.section.custom': 'Пользовательские сочетания',
|
||||
'settings.shortcuts.resetAll': 'Сбросить все',
|
||||
'settings.shortcuts.recording': 'Нажмите клавиши...',
|
||||
|
||||
@@ -8,9 +8,12 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.empty': 'Нет данных.',
|
||||
'systemManager.tabs.overview': 'Обзор',
|
||||
'systemManager.tabs.processes': 'Процессы',
|
||||
'systemManager.tabs.ports': 'Порты',
|
||||
'systemManager.tabs.services': 'Службы',
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
'systemManager.tabs.gpu': 'GPU',
|
||||
'systemManager.tabs.ariaLabel': 'Разделы системного менеджера',
|
||||
'systemManager.popup.loading': 'Открытие терминала…',
|
||||
'systemManager.popup.startupFailed': 'Команда запуска не была выполнена успешно. Проверьте, что цель доступна, и повторите попытку.',
|
||||
|
||||
@@ -25,6 +28,8 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDockerImages': 'Не удалось загрузить образы',
|
||||
'systemManager.errors.loadOverview': 'Не удалось загрузить обзор системы',
|
||||
'systemManager.errors.loadGpu': 'Не удалось загрузить статистику GPU / NPU',
|
||||
'systemManager.errors.loadPorts': 'Не удалось загрузить слушающие порты',
|
||||
'systemManager.errors.loadServices': 'Не удалось загрузить службы systemd',
|
||||
'systemManager.errors.sshChannelUnavailable': 'Сервер отказался открыть новый канал выполнения. Повторите попытку позже или переподключите этот хост.',
|
||||
|
||||
'systemManager.overview.empty': 'Данных обзора пока нет.',
|
||||
@@ -189,6 +194,34 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.start': 'Запустить',
|
||||
'systemManager.docker.stop': 'Остановить',
|
||||
|
||||
'systemManager.ports.unavailable': 'На этом хосте не обнаружены инструменты портов (ss / netstat).',
|
||||
'systemManager.ports.loading': 'Загрузка слушающих портов…',
|
||||
'systemManager.ports.empty': 'Слушающие порты не найдены.',
|
||||
'systemManager.ports.search': 'Поиск портов…',
|
||||
'systemManager.ports.meta': '{{count}} слушающих',
|
||||
'systemManager.ports.filter.all': 'Все',
|
||||
'systemManager.ports.unknownProcess': 'Неизвестный процесс',
|
||||
'systemManager.ports.terminate': 'Завершить',
|
||||
'systemManager.ports.confirmTerminate': 'Отправить SIGTERM процессу {{pid}}, занимающему этот порт?',
|
||||
|
||||
'systemManager.services.unavailable': 'systemctl недоступен на этом хосте.',
|
||||
'systemManager.services.loading': 'Загрузка служб systemd…',
|
||||
'systemManager.services.empty': 'Службы systemd не найдены.',
|
||||
'systemManager.services.search': 'Поиск служб…',
|
||||
'systemManager.services.meta': '{{count}} служб',
|
||||
'systemManager.services.filter.all': 'Все',
|
||||
'systemManager.services.filter.running': 'Запущены',
|
||||
'systemManager.services.filter.failed': 'С ошибкой',
|
||||
'systemManager.services.filter.inactive': 'Неактивны',
|
||||
'systemManager.services.start': 'Запустить',
|
||||
'systemManager.services.stop': 'Остановить',
|
||||
'systemManager.services.restart': 'Перезапустить',
|
||||
'systemManager.services.enable': 'Включить автозапуск',
|
||||
'systemManager.services.disable': 'Отключить автозапуск',
|
||||
'systemManager.services.reload': 'Перезагрузить',
|
||||
'systemManager.services.scope.user': 'user',
|
||||
'systemManager.services.confirmAction': '{{action}} {{name}}?',
|
||||
|
||||
'systemManager.gpu.unavailable': 'На этом хосте не обнаружены инструменты NVIDIA GPU или Ascend NPU.',
|
||||
'systemManager.gpu.loading': 'Загрузка статистики ускорителей…',
|
||||
'systemManager.gpu.empty': 'Инструменты ускорителей найдены, но устройства не сообщены.',
|
||||
|
||||
@@ -361,6 +361,7 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.transfers.dragToResize': 'Перетащите для изменения размера',
|
||||
'sftp.goUp': 'Наверх',
|
||||
'sftp.goToTerminalCwd': 'Перейти в каталог терминала',
|
||||
'sftp.locatePathInTerminal': 'Открыть путь в терминале',
|
||||
'sftp.followTerminalCwd': 'Следовать за каталогом терминала',
|
||||
'sftp.followTerminalCwd.enable': 'Включить следование за каталогом терминала',
|
||||
'sftp.followTerminalCwd.disable': 'Отключить следование за каталогом терминала',
|
||||
|
||||
@@ -61,6 +61,15 @@ 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': '加载模型中...',
|
||||
|
||||
@@ -72,8 +72,12 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.tab.appearance': '外观',
|
||||
'settings.tab.terminal': '终端',
|
||||
'settings.tab.shortcuts': '快捷键',
|
||||
'settings.tab.ai': 'AI',
|
||||
'settings.tab.syncCloud': '同步与云',
|
||||
'settings.tab.system': '系统',
|
||||
'settings.search.open': '搜索设置',
|
||||
'settings.search.placeholder': '搜索设置项…',
|
||||
'settings.search.noResults': '没有匹配的设置项',
|
||||
'settings.terminal.connection.verifyHostKeys': '校验 SSH 主机密钥',
|
||||
'settings.terminal.connection.verifyHostKeys.desc': '连接到新的或已变化的 SSH 主机密钥前先询问。只建议在可信内网中关闭。',
|
||||
'settings.terminal.section.sidePanel': '侧边栏',
|
||||
@@ -231,6 +235,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'tray.status.active': '已启用',
|
||||
'tray.status.inactive': '未启用',
|
||||
'tray.status.error': '错误',
|
||||
'tray.status.unknown': '未知',
|
||||
'tray.recentHosts': '最近连接的主机',
|
||||
'tray.empty.title': '一切都很安静',
|
||||
'tray.empty.subtitle': '去连接个服务器吧,它们想念你了 🚀',
|
||||
@@ -919,6 +924,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'sftp.transfers.dragToResize': '拖拽调整高度',
|
||||
'sftp.goUp': '上一级',
|
||||
'sftp.goToTerminalCwd': '定位到终端当前目录',
|
||||
'sftp.locatePathInTerminal': '定位路径到终端',
|
||||
'sftp.followTerminalCwd': '追随终端目录',
|
||||
'sftp.followTerminalCwd.enable': '开启追随终端目录',
|
||||
'sftp.followTerminalCwd.disable': '关闭追随终端目录',
|
||||
|
||||
@@ -8,9 +8,12 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.empty': '暂无数据。',
|
||||
'systemManager.tabs.overview': '概览',
|
||||
'systemManager.tabs.processes': '进程',
|
||||
'systemManager.tabs.ports': '端口',
|
||||
'systemManager.tabs.services': '服务',
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
'systemManager.tabs.gpu': 'GPU',
|
||||
'systemManager.tabs.ariaLabel': '系统管理分区',
|
||||
'systemManager.popup.loading': '正在打开终端…',
|
||||
'systemManager.popup.startupFailed': '启动命令未成功。请确认目标仍然可用后重试。',
|
||||
|
||||
@@ -25,6 +28,8 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDockerImages': '加载镜像列表失败',
|
||||
'systemManager.errors.loadOverview': '加载系统概览失败',
|
||||
'systemManager.errors.loadGpu': '加载 GPU / NPU 状态失败',
|
||||
'systemManager.errors.loadPorts': '加载监听端口失败',
|
||||
'systemManager.errors.loadServices': '加载 systemd 服务失败',
|
||||
'systemManager.errors.sshChannelUnavailable': '服务器拒绝打开新的执行通道。请稍后重试,或重新连接当前主机。',
|
||||
|
||||
'systemManager.overview.empty': '暂无系统概览数据。',
|
||||
@@ -189,6 +194,34 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.start': '启动',
|
||||
'systemManager.docker.stop': '停止',
|
||||
|
||||
'systemManager.ports.unavailable': '此主机未检测到端口工具(ss / netstat)。',
|
||||
'systemManager.ports.loading': '正在加载监听端口…',
|
||||
'systemManager.ports.empty': '暂无监听端口。',
|
||||
'systemManager.ports.search': '搜索端口…',
|
||||
'systemManager.ports.meta': '{{count}} 个监听',
|
||||
'systemManager.ports.filter.all': '全部',
|
||||
'systemManager.ports.unknownProcess': '未知进程',
|
||||
'systemManager.ports.terminate': '结束进程',
|
||||
'systemManager.ports.confirmTerminate': '向占用该端口的进程 {{pid}} 发送 SIGTERM?',
|
||||
|
||||
'systemManager.services.unavailable': '此主机未检测到 systemctl。',
|
||||
'systemManager.services.loading': '正在加载 systemd 服务…',
|
||||
'systemManager.services.empty': '暂无 systemd 服务。',
|
||||
'systemManager.services.search': '搜索服务…',
|
||||
'systemManager.services.meta': '{{count}} 个服务',
|
||||
'systemManager.services.filter.all': '全部',
|
||||
'systemManager.services.filter.running': '运行中',
|
||||
'systemManager.services.filter.failed': '失败',
|
||||
'systemManager.services.filter.inactive': '未运行',
|
||||
'systemManager.services.start': '启动',
|
||||
'systemManager.services.stop': '停止',
|
||||
'systemManager.services.restart': '重启',
|
||||
'systemManager.services.enable': '开机启用',
|
||||
'systemManager.services.disable': '禁用开机',
|
||||
'systemManager.services.reload': '重载',
|
||||
'systemManager.services.scope.user': '用户',
|
||||
'systemManager.services.confirmAction': '确认对 {{name}} 执行「{{action}}」?',
|
||||
|
||||
'systemManager.gpu.unavailable': '此主机未检测到 NVIDIA GPU 或昇腾 NPU 工具。',
|
||||
'systemManager.gpu.loading': '正在加载加速卡状态…',
|
||||
'systemManager.gpu.empty': '已检测到加速卡工具,但未返回设备信息。',
|
||||
|
||||
@@ -513,6 +513,8 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.shortcuts.disableTerminalFontZoom.desc': '关闭终端文字缩放快捷操作,包括 Cmd/Ctrl 加滚轮。',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': '标签快捷键跳过固定标签',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': '开启后,Cmd/Ctrl+[1...9] 和 Ctrl+Tab 仅在终端、工作区、编辑器等可关闭标签页之间切换,不包括固定的 Vault 和 SFTP 标签页。',
|
||||
'settings.shortcuts.showTabNumberBadges.label': '显示标签页序号角标',
|
||||
'settings.shortcuts.showTabNumberBadges.desc': '按住 Cmd/Ctrl 时,在对应 Cmd/Ctrl+[1...9] 的标签页上显示 1-9,方便准确切换。',
|
||||
'settings.shortcuts.section.custom': '自定义快捷键',
|
||||
'settings.shortcuts.resetAll': '全部重置',
|
||||
'settings.shortcuts.recording': '请按键...',
|
||||
|
||||
@@ -61,6 +61,15 @@ 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': '載入模型中...',
|
||||
|
||||
@@ -72,8 +72,12 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.tab.appearance': '外觀',
|
||||
'settings.tab.terminal': '終端',
|
||||
'settings.tab.shortcuts': '快捷鍵',
|
||||
'settings.tab.ai': 'AI',
|
||||
'settings.tab.syncCloud': '同步與雲',
|
||||
'settings.tab.system': '系統',
|
||||
'settings.search.open': '搜尋設定',
|
||||
'settings.search.placeholder': '搜尋設定項…',
|
||||
'settings.search.noResults': '沒有符合的設定項',
|
||||
'settings.terminal.connection.verifyHostKeys': '驗證 SSH 主機金鑰',
|
||||
'settings.terminal.connection.verifyHostKeys.desc': '連線到新的或已變化的 SSH 主機金鑰前先詢問。只建議在可信內網中關閉。',
|
||||
'settings.terminal.section.sidePanel': '側邊欄',
|
||||
@@ -231,6 +235,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'tray.status.active': '已啟用',
|
||||
'tray.status.inactive': '未啟用',
|
||||
'tray.status.error': '錯誤',
|
||||
'tray.status.unknown': '未知',
|
||||
'tray.recentHosts': '最近連線的主機',
|
||||
'tray.empty.title': '一切都很安靜',
|
||||
'tray.empty.subtitle': '去連線個伺服器吧,它們想念你了 🚀',
|
||||
@@ -919,6 +924,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'sftp.transfers.dragToResize': '拖拽調整高度',
|
||||
'sftp.goUp': '上一級',
|
||||
'sftp.goToTerminalCwd': '定位到終端目前目錄',
|
||||
'sftp.locatePathInTerminal': '定位路徑到終端',
|
||||
'sftp.followTerminalCwd': '追隨終端目錄',
|
||||
'sftp.followTerminalCwd.enable': '開啟追隨終端目錄',
|
||||
'sftp.followTerminalCwd.disable': '關閉追隨終端目錄',
|
||||
|
||||
@@ -8,9 +8,12 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.empty': '暫無資料。',
|
||||
'systemManager.tabs.overview': '概覽',
|
||||
'systemManager.tabs.processes': '程序',
|
||||
'systemManager.tabs.ports': '連接埠',
|
||||
'systemManager.tabs.services': '服務',
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
'systemManager.tabs.gpu': 'GPU',
|
||||
'systemManager.tabs.ariaLabel': '系統管理分區',
|
||||
'systemManager.popup.loading': '正在開啟終端…',
|
||||
'systemManager.popup.startupFailed': '啟動指令未順利完成。請確認目標仍可使用後再試一次。',
|
||||
|
||||
@@ -25,6 +28,8 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDockerImages': '載入映像列表失敗',
|
||||
'systemManager.errors.loadOverview': '載入系統概覽失敗',
|
||||
'systemManager.errors.loadGpu': '載入 GPU / NPU 狀態失敗',
|
||||
'systemManager.errors.loadPorts': '載入監聽連接埠失敗',
|
||||
'systemManager.errors.loadServices': '載入 systemd 服務失敗',
|
||||
'systemManager.errors.sshChannelUnavailable': '伺服器拒絕開啟新的執行通道。請稍後重試,或重新連線目前主機。',
|
||||
|
||||
'systemManager.overview.empty': '暫無系統概覽資料。',
|
||||
@@ -189,6 +194,34 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.start': '啟動',
|
||||
'systemManager.docker.stop': '停止',
|
||||
|
||||
'systemManager.ports.unavailable': '此主機未偵測到連接埠工具(ss / netstat)。',
|
||||
'systemManager.ports.loading': '正在載入監聽連接埠…',
|
||||
'systemManager.ports.empty': '暫無監聽連接埠。',
|
||||
'systemManager.ports.search': '搜尋連接埠…',
|
||||
'systemManager.ports.meta': '{{count}} 個監聽',
|
||||
'systemManager.ports.filter.all': '全部',
|
||||
'systemManager.ports.unknownProcess': '未知程序',
|
||||
'systemManager.ports.terminate': '結束程序',
|
||||
'systemManager.ports.confirmTerminate': '向佔用該連接埠的程序 {{pid}} 傳送 SIGTERM?',
|
||||
|
||||
'systemManager.services.unavailable': '此主機未偵測到 systemctl。',
|
||||
'systemManager.services.loading': '正在載入 systemd 服務…',
|
||||
'systemManager.services.empty': '暫無 systemd 服務。',
|
||||
'systemManager.services.search': '搜尋服務…',
|
||||
'systemManager.services.meta': '{{count}} 個服務',
|
||||
'systemManager.services.filter.all': '全部',
|
||||
'systemManager.services.filter.running': '執行中',
|
||||
'systemManager.services.filter.failed': '失敗',
|
||||
'systemManager.services.filter.inactive': '未執行',
|
||||
'systemManager.services.start': '啟動',
|
||||
'systemManager.services.stop': '停止',
|
||||
'systemManager.services.restart': '重新啟動',
|
||||
'systemManager.services.enable': '開機啟用',
|
||||
'systemManager.services.disable': '停用開機',
|
||||
'systemManager.services.reload': '重新載入',
|
||||
'systemManager.services.scope.user': '使用者',
|
||||
'systemManager.services.confirmAction': '確認對 {{name}} 執行「{{action}}」?',
|
||||
|
||||
'systemManager.gpu.unavailable': '此主機未偵測到 NVIDIA GPU 或昇騰 NPU 工具。',
|
||||
'systemManager.gpu.loading': '正在載入加速卡狀態…',
|
||||
'systemManager.gpu.empty': '已偵測到加速卡工具,但未回傳裝置資訊。',
|
||||
|
||||
@@ -513,6 +513,8 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.shortcuts.disableTerminalFontZoom.desc': '關閉終端文字縮放快捷操作,包括 Cmd/Ctrl 加滾輪。',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': '標籤快捷鍵跳過固定標籤',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': '開啟後,Cmd/Ctrl+[1...9] 和 Ctrl+Tab 僅在終端、工作區、編輯器等可關閉標籤頁之間切換,不包括固定的 Vault 和 SFTP 標籤頁。',
|
||||
'settings.shortcuts.showTabNumberBadges.label': '顯示標籤頁序號角標',
|
||||
'settings.shortcuts.showTabNumberBadges.desc': '按住 Cmd/Ctrl 時,在對應 Cmd/Ctrl+[1...9] 的標籤頁上顯示 1-9,方便準確切換。',
|
||||
'settings.shortcuts.section.custom': '自訂快捷鍵',
|
||||
'settings.shortcuts.resetAll': '全部重置',
|
||||
'settings.shortcuts.recording': '請按鍵...',
|
||||
|
||||
@@ -2,7 +2,11 @@ import type { TerminalSession } from "../../domain/models";
|
||||
|
||||
export type SessionPwdProbe = (
|
||||
sessionId: string,
|
||||
options?: { allowHomeFallback?: boolean; timeoutMs?: number },
|
||||
options?: {
|
||||
allowHomeFallback?: boolean;
|
||||
allowLoginShellFallback?: boolean;
|
||||
timeoutMs?: number;
|
||||
},
|
||||
) => Promise<{ success: boolean; cwd?: string }>;
|
||||
|
||||
type CaptureSession = Pick<TerminalSession, "id" | "protocol" | "status" | "lastCwd" | "localStartDir">;
|
||||
|
||||
@@ -81,6 +81,19 @@ test('notesStore gated helpers stay empty and never notify', () => {
|
||||
unsub();
|
||||
});
|
||||
|
||||
test('useNotesStore source gates subscribe when enabled is false', async () => {
|
||||
const { readFileSync } = await import('node:fs');
|
||||
const source = readFileSync(new URL('./notesStore.ts', import.meta.url), 'utf8');
|
||||
assert.match(source, /enabled \? subscribeNotes : subscribeNotesNoop/);
|
||||
assert.match(source, /enabled \? subscribeNotesActions : subscribeNotesNoop/);
|
||||
assert.match(source, /getNotesSnapshot/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/enabled \? getNotesSnapshot : getEmptyNotesSnapshot/,
|
||||
'hidden notes mounts should keep the last live snapshot, not flash empty',
|
||||
);
|
||||
});
|
||||
|
||||
test('registerNotesActions exposes update handlers', () => {
|
||||
const calls: string[] = [];
|
||||
registerNotesActions({
|
||||
|
||||
@@ -112,20 +112,25 @@ const noopUpdateNoteGroups: NotesActions['updateNoteGroups'] = () => {};
|
||||
|
||||
/**
|
||||
* Subscribe to notes catalog + vault mutation actions for Notes / AI panels.
|
||||
*
|
||||
* Pass `{ enabled: false }` for retained-but-hidden mounts (e.g. terminal
|
||||
* notes side panel) so vault note publishes do not re-render them. Snapshot
|
||||
* reads still use the live store so reopen does not flash empty data.
|
||||
*/
|
||||
export function useNotesStore(): {
|
||||
export function useNotesStore(options?: { enabled?: boolean }): {
|
||||
notes: VaultNote[];
|
||||
noteGroups: string[];
|
||||
updateNotes: NotesActions['updateNotes'];
|
||||
updateNoteGroups: NotesActions['updateNoteGroups'];
|
||||
} {
|
||||
const enabled = options?.enabled !== false;
|
||||
const snapshot = useSyncExternalStore(
|
||||
subscribeNotes,
|
||||
enabled ? subscribeNotes : subscribeNotesNoop,
|
||||
getNotesSnapshot,
|
||||
getNotesSnapshot,
|
||||
);
|
||||
const actions = useSyncExternalStore(
|
||||
subscribeNotesActions,
|
||||
enabled ? subscribeNotesActions : subscribeNotesNoop,
|
||||
getNotesActions,
|
||||
getNotesActions,
|
||||
);
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
runConnectScriptsSequential,
|
||||
selectScriptOverlayRun,
|
||||
setScriptRuns,
|
||||
waitForScriptRun,
|
||||
} from './scriptAutomationCoordinator.ts';
|
||||
import type { ScriptRun } from '@/types/global/netcatty-bridge-script.d.ts';
|
||||
import type { Snippet } from '@/domain/models';
|
||||
import { netcattyBridge } from '@/infrastructure/services/netcattyBridge.ts';
|
||||
|
||||
test('waitForScriptRun resolves when run is already completed on subscribe', async () => {
|
||||
const runId = 'run-already-done';
|
||||
@@ -64,3 +67,182 @@ test('selectScriptOverlayRun does not resurface older completed runs after dismi
|
||||
dismissedRunIds.add(latestRun.runId);
|
||||
assert.equal(selectScriptOverlayRun([olderRun, latestRun], 'sess1', dismissedRunIds), undefined);
|
||||
});
|
||||
|
||||
test('runConnectScriptsSequential cancels only its own queued run and waits for stop', async () => {
|
||||
const originalGet = netcattyBridge.get;
|
||||
const sessionId = 'sess-connect-abort';
|
||||
let queuedRunId: string | undefined;
|
||||
let releaseStop: (() => void) | undefined;
|
||||
const scriptStopCalls: string[] = [];
|
||||
const storage = new Map<string, string>();
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { storage.set(key, String(value)); },
|
||||
removeItem: (key: string) => { storage.delete(key); },
|
||||
clear: () => { storage.clear(); },
|
||||
},
|
||||
});
|
||||
|
||||
setScriptRuns([{
|
||||
runId: 'unrelated-active-run',
|
||||
scriptId: 'manual-script',
|
||||
sessionId,
|
||||
status: 'running',
|
||||
startedAt: Date.now(),
|
||||
logs: [],
|
||||
}]);
|
||||
|
||||
netcattyBridge.get = () => ({
|
||||
scriptRun: async (params) => {
|
||||
queuedRunId = params.runId;
|
||||
assert.equal(params.returnWhenQueued, true);
|
||||
return { runId: params.runId!, runIds: [params.runId!] };
|
||||
},
|
||||
scriptStop: (id: string) => new Promise((resolve) => {
|
||||
scriptStopCalls.push(id);
|
||||
releaseStop = () => resolve({ ok: true });
|
||||
}),
|
||||
}) as ReturnType<typeof netcattyBridge.get>;
|
||||
|
||||
const controller = new AbortController();
|
||||
let stopCurrentRun: (() => Promise<void>) | null = null;
|
||||
const snippet: Snippet = {
|
||||
id: 'connect-script',
|
||||
label: 'Connect',
|
||||
command: 'nct.session.sleep(60)',
|
||||
kind: 'script',
|
||||
};
|
||||
|
||||
try {
|
||||
const running = runConnectScriptsSequential({
|
||||
scripts: [snippet],
|
||||
sessionId,
|
||||
signal: controller.signal,
|
||||
onCancelableRunChange: (stop) => { stopCurrentRun = stop; },
|
||||
});
|
||||
void running.catch(() => {});
|
||||
await Promise.resolve();
|
||||
controller.abort();
|
||||
for (let attempt = 0; attempt < 10 && scriptStopCalls.length === 0; attempt += 1) {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
assert.deepEqual(scriptStopCalls, [queuedRunId]);
|
||||
assert.notEqual(queuedRunId, 'unrelated-active-run');
|
||||
assert.equal(typeof stopCurrentRun, 'function');
|
||||
|
||||
let settled = false;
|
||||
void running.finally(() => { settled = true; }).catch(() => {});
|
||||
await Promise.resolve();
|
||||
assert.equal(settled, false);
|
||||
|
||||
releaseStop?.();
|
||||
await assert.rejects(
|
||||
() => running,
|
||||
(error: unknown) => error instanceof DOMException && error.name === 'AbortError',
|
||||
);
|
||||
} finally {
|
||||
netcattyBridge.get = originalGet;
|
||||
setScriptRuns([]);
|
||||
Reflect.deleteProperty(globalThis, 'localStorage');
|
||||
}
|
||||
});
|
||||
|
||||
test('runConnectScriptsSequential retries the exact stop after a transient failure', async () => {
|
||||
const originalGet = netcattyBridge.get;
|
||||
const storage = new Map<string, string>();
|
||||
const stopCalls: string[] = [];
|
||||
let stopCurrentRun: (() => Promise<void>) | null = null;
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { storage.set(key, String(value)); },
|
||||
removeItem: (key: string) => { storage.delete(key); },
|
||||
clear: () => { storage.clear(); },
|
||||
},
|
||||
});
|
||||
|
||||
netcattyBridge.get = () => ({
|
||||
scriptRun: async (params) => ({ runId: params.runId!, runIds: [params.runId!] }),
|
||||
scriptStop: async (id: string) => {
|
||||
stopCalls.push(id);
|
||||
return { ok: stopCalls.length > 1 };
|
||||
},
|
||||
}) as ReturnType<typeof netcattyBridge.get>;
|
||||
|
||||
const controller = new AbortController();
|
||||
try {
|
||||
const running = runConnectScriptsSequential({
|
||||
scripts: [{ id: 'retry-stop', label: 'Retry stop', command: 'await nct.sleep(60)', kind: 'script' }],
|
||||
sessionId: 'sess-retry-stop',
|
||||
signal: controller.signal,
|
||||
onCancelableRunChange: (stop) => { stopCurrentRun = stop; },
|
||||
});
|
||||
void running.catch(() => {});
|
||||
for (let attempt = 0; attempt < 10 && !stopCurrentRun; attempt += 1) {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
controller.abort();
|
||||
await assert.rejects(() => running, /could not be stopped/);
|
||||
assert.equal(typeof stopCurrentRun, 'function');
|
||||
await stopCurrentRun!();
|
||||
assert.equal(stopCalls.length, 2);
|
||||
} finally {
|
||||
netcattyBridge.get = originalGet;
|
||||
setScriptRuns([]);
|
||||
Reflect.deleteProperty(globalThis, 'localStorage');
|
||||
}
|
||||
});
|
||||
|
||||
test('runConnectScriptsSequential does not treat a script error named Aborted as cancellation', async () => {
|
||||
const originalGet = netcattyBridge.get;
|
||||
const storage = new Map<string, string>();
|
||||
const scriptStopCalls: string[] = [];
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { storage.set(key, String(value)); },
|
||||
removeItem: (key: string) => { storage.delete(key); },
|
||||
clear: () => { storage.clear(); },
|
||||
},
|
||||
});
|
||||
|
||||
netcattyBridge.get = () => ({
|
||||
scriptRun: async (params) => {
|
||||
setScriptRuns([{
|
||||
runId: params.runId!,
|
||||
scriptId: params.scriptId,
|
||||
sessionId: params.sessionId!,
|
||||
status: 'failed',
|
||||
startedAt: Date.now() - 10,
|
||||
endedAt: Date.now(),
|
||||
error: 'Aborted',
|
||||
logs: [],
|
||||
}]);
|
||||
return { runId: params.runId!, runIds: [params.runId!] };
|
||||
},
|
||||
scriptStop: async (id: string) => {
|
||||
scriptStopCalls.push(id);
|
||||
return { ok: true };
|
||||
},
|
||||
}) as ReturnType<typeof netcattyBridge.get>;
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => runConnectScriptsSequential({
|
||||
scripts: [{ id: 'fails', label: 'Fails', command: "throw new Error('Aborted')", kind: 'script' }],
|
||||
sessionId: 'sess-real-error',
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
/Aborted/,
|
||||
);
|
||||
assert.deepEqual(scriptStopCalls, []);
|
||||
} finally {
|
||||
netcattyBridge.get = originalGet;
|
||||
setScriptRuns([]);
|
||||
Reflect.deleteProperty(globalThis, 'localStorage');
|
||||
}
|
||||
});
|
||||
@@ -83,6 +83,8 @@ export function getActiveScriptRunForSession(sessionId: string): ScriptRun | und
|
||||
}
|
||||
|
||||
export async function runAutomationScript(params: {
|
||||
runId?: string;
|
||||
returnWhenQueued?: boolean;
|
||||
snippet: Snippet;
|
||||
sessionId: string;
|
||||
sessionIds?: string[];
|
||||
@@ -104,6 +106,8 @@ export async function runAutomationScript(params: {
|
||||
throw new Error('Script bridge unavailable');
|
||||
}
|
||||
return bridge.scriptRun({
|
||||
runId: params.runId,
|
||||
returnWhenQueued: params.returnWhenQueued,
|
||||
scriptId: params.snippet.id,
|
||||
scriptLabel: params.snippet.label,
|
||||
content: params.snippet.command,
|
||||
@@ -176,6 +180,7 @@ export async function runConnectScriptsSequential(params: {
|
||||
scripts: Snippet[];
|
||||
sessionId: string;
|
||||
signal?: AbortSignal;
|
||||
onCancelableRunChange?: (stopCurrentRun: (() => Promise<void>) | null) => void;
|
||||
onScriptStart?: (snippet: Snippet) => void;
|
||||
onScriptComplete?: (snippet: Snippet) => void;
|
||||
sessionMeta?: {
|
||||
@@ -185,18 +190,63 @@ export async function runConnectScriptsSequential(params: {
|
||||
username?: string;
|
||||
};
|
||||
}): Promise<void> {
|
||||
for (const snippet of params.scripts) {
|
||||
const throwIfAborted = () => {
|
||||
if (params.signal?.aborted) {
|
||||
throw new Error('Aborted');
|
||||
throw new DOMException('Connect script run cancelled', 'AbortError');
|
||||
}
|
||||
};
|
||||
|
||||
for (const snippet of params.scripts) {
|
||||
throwIfAborted();
|
||||
const runId = crypto.randomUUID();
|
||||
let stopPromise: Promise<void> | undefined;
|
||||
let stopped = false;
|
||||
params.onScriptStart?.(snippet);
|
||||
const { runId } = await runAutomationScript({
|
||||
const queueAccepted = runAutomationScript({
|
||||
runId,
|
||||
returnWhenQueued: true,
|
||||
snippet,
|
||||
sessionId: params.sessionId,
|
||||
sessionMeta: params.sessionMeta,
|
||||
});
|
||||
await waitForScriptRun(runId, { signal: params.signal });
|
||||
params.onScriptComplete?.(snippet);
|
||||
const stopThisRun = () => {
|
||||
if (stopPromise) return stopPromise;
|
||||
const attempt = queueAccepted.then(async () => {
|
||||
const result = await stopScriptRun(runId);
|
||||
if (!result.ok) {
|
||||
throw new Error(`Connect script run could not be stopped: ${runId}`);
|
||||
}
|
||||
stopped = true;
|
||||
});
|
||||
stopPromise = attempt;
|
||||
void attempt.catch(() => {
|
||||
if (stopPromise === attempt) stopPromise = undefined;
|
||||
});
|
||||
return attempt;
|
||||
};
|
||||
params.onCancelableRunChange?.(stopThisRun);
|
||||
const onAbort = () => {
|
||||
void stopThisRun().catch(() => {});
|
||||
};
|
||||
params.signal?.addEventListener('abort', onAbort, { once: true });
|
||||
|
||||
try {
|
||||
await queueAccepted;
|
||||
throwIfAborted();
|
||||
await waitForScriptRun(runId, { signal: params.signal });
|
||||
params.onScriptComplete?.(snippet);
|
||||
} catch (err) {
|
||||
if (params.signal?.aborted) {
|
||||
await stopThisRun();
|
||||
throw new DOMException('Connect script run cancelled', 'AbortError');
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
params.signal?.removeEventListener('abort', onAbort);
|
||||
if (!params.signal?.aborted || stopped) {
|
||||
params.onCancelableRunChange?.(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,16 @@ function isExpired(entry: StoreEntry): boolean {
|
||||
return Date.now() > entry.expiresAt;
|
||||
}
|
||||
|
||||
function normalizeCapabilities(capabilities: SessionCapabilities): SessionCapabilities {
|
||||
return {
|
||||
...capabilities,
|
||||
hasSs: capabilities.hasSs === true,
|
||||
hasNetstat: capabilities.hasNetstat === true,
|
||||
hasLsof: capabilities.hasLsof === true,
|
||||
hasSystemctl: capabilities.hasSystemctl === true,
|
||||
};
|
||||
}
|
||||
|
||||
function notifySession(sessionId: string) {
|
||||
listenersBySessionId.get(sessionId)?.forEach((listener) => listener());
|
||||
}
|
||||
@@ -28,13 +38,13 @@ export const sessionCapabilitiesStore = {
|
||||
notifySession(sessionId);
|
||||
return undefined;
|
||||
}
|
||||
return entry.capabilities;
|
||||
return normalizeCapabilities(entry.capabilities);
|
||||
},
|
||||
|
||||
set(sessionId: string, capabilities: SessionCapabilities, ttlMs: number) {
|
||||
const entry: StoreEntry = {
|
||||
capabilities: {
|
||||
...capabilities,
|
||||
...normalizeCapabilities(capabilities),
|
||||
probedAt: Date.now(),
|
||||
},
|
||||
expiresAt: Date.now() + ttlMs,
|
||||
|
||||
@@ -2,9 +2,9 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { Host } from "../../domain/models";
|
||||
import { applyGroupDefaults } from "../../domain/groupConfig";
|
||||
import { prepareSerialConfigForSavedHost } from "../../domain/serialBackspace";
|
||||
import { buildTelnetDeepLinkConnectionHost } from "../../domain/telnetDeepLink";
|
||||
import { resolveEffectiveTerminalHost } from "../../domain/terminalHostResolution";
|
||||
import {
|
||||
createHostTerminalSession,
|
||||
createSerialTerminalSession,
|
||||
@@ -74,7 +74,34 @@ test("serial session factories snapshot effective legacy Backspace behavior", ()
|
||||
assert.equal(explicitDefaultSession.serialConfig?.backspaceBehavior, "default");
|
||||
});
|
||||
|
||||
test("saved quick-connect hosts can inherit Ctrl-H after moving into a group", () => {
|
||||
test("workspace host factory creates a complete serial session", () => {
|
||||
const session = createWorkspaceHostTerminalSession("session-serial", host({
|
||||
protocol: "serial",
|
||||
hostname: "COM7",
|
||||
port: 57600,
|
||||
username: "",
|
||||
serialConfig: {
|
||||
path: "COM7",
|
||||
baudRate: 57600,
|
||||
dataBits: 7,
|
||||
stopBits: 2,
|
||||
parity: "even",
|
||||
},
|
||||
}), "workspace-1");
|
||||
|
||||
assert.equal(session.workspaceId, "workspace-1");
|
||||
assert.equal(session.protocol, "serial");
|
||||
assert.deepEqual(session.serialConfig, {
|
||||
path: "COM7",
|
||||
baudRate: 57600,
|
||||
dataBits: 7,
|
||||
stopBits: 2,
|
||||
parity: "even",
|
||||
backspaceBehavior: "default",
|
||||
});
|
||||
});
|
||||
|
||||
test("workspace append snapshots serial Backspace behavior inherited from a group", () => {
|
||||
const savedHost = host({
|
||||
protocol: "serial",
|
||||
hostname: "COM3",
|
||||
@@ -87,12 +114,15 @@ test("saved quick-connect hosts can inherit Ctrl-H after moving into a group", (
|
||||
backspaceBehavior: "default",
|
||||
}),
|
||||
});
|
||||
const effectiveHost = applyGroupDefaults(savedHost, {
|
||||
backspaceBehavior: "ctrl-h",
|
||||
const effectiveHost = resolveEffectiveTerminalHost({
|
||||
host: savedHost,
|
||||
groupConfigs: [{ path: "network", backspaceBehavior: "ctrl-h" }],
|
||||
proxyProfiles: [],
|
||||
});
|
||||
|
||||
const session = createHostTerminalSession("session-1", effectiveHost);
|
||||
const session = createWorkspaceHostTerminalSession("session-1", effectiveHost, "workspace-1");
|
||||
|
||||
assert.equal(session.workspaceId, "workspace-1");
|
||||
assert.equal(session.serialConfig?.backspaceBehavior, "ctrl-h");
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
import type { UILanguage } from '../../domain/models/connection';
|
||||
import type { HotkeyScheme } from '../../domain/models/keyBindings';
|
||||
import type { DynamicTabTitleMode } from '../../domain/models/terminal';
|
||||
import type { HostClickBehavior } from '../../domain/hostClickBehavior';
|
||||
import type { TerminalSidePanelAutoOpenTab } from '../../domain/terminalSidePanelAutoOpen';
|
||||
@@ -29,6 +30,9 @@ export type SettingsChromeSnapshot = {
|
||||
showOnlyUngroupedHostsInRoot: boolean;
|
||||
dynamicTabTitleMode: DynamicTabTitleMode;
|
||||
disableTerminalFontZoom: boolean;
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
shellOnlyTabNumberShortcuts: boolean;
|
||||
showTabNumberBadges: boolean;
|
||||
restoreTerminalCwd: boolean;
|
||||
terminalSidePanelAutoOpen: boolean;
|
||||
terminalSidePanelAutoOpenTab: TerminalSidePanelAutoOpenTab;
|
||||
@@ -58,6 +62,9 @@ export const DEFAULT_SETTINGS_CHROME_SNAPSHOT: SettingsChromeSnapshot = Object.f
|
||||
showOnlyUngroupedHostsInRoot: false,
|
||||
dynamicTabTitleMode: 'off',
|
||||
disableTerminalFontZoom: false,
|
||||
hotkeyScheme: 'pc',
|
||||
shellOnlyTabNumberShortcuts: false,
|
||||
showTabNumberBadges: true,
|
||||
restoreTerminalCwd: true,
|
||||
terminalSidePanelAutoOpen: false,
|
||||
terminalSidePanelAutoOpenTab: 'ai',
|
||||
@@ -80,6 +87,9 @@ export function settingsChromeSnapshotsEqual(
|
||||
&& a.showOnlyUngroupedHostsInRoot === b.showOnlyUngroupedHostsInRoot
|
||||
&& a.dynamicTabTitleMode === b.dynamicTabTitleMode
|
||||
&& a.disableTerminalFontZoom === b.disableTerminalFontZoom
|
||||
&& a.hotkeyScheme === b.hotkeyScheme
|
||||
&& a.shellOnlyTabNumberShortcuts === b.shellOnlyTabNumberShortcuts
|
||||
&& a.showTabNumberBadges === b.showTabNumberBadges
|
||||
&& a.restoreTerminalCwd === b.restoreTerminalCwd
|
||||
&& a.terminalSidePanelAutoOpen === b.terminalSidePanelAutoOpen
|
||||
&& a.terminalSidePanelAutoOpenTab === b.terminalSidePanelAutoOpenTab;
|
||||
|
||||
@@ -82,6 +82,7 @@ export const DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT = false;
|
||||
export const DEFAULT_SHOW_SFTP_TAB = true;
|
||||
export const DEFAULT_SHOW_HOST_TREE_SIDEBAR = true;
|
||||
export const DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS = false;
|
||||
export const DEFAULT_SHOW_TAB_NUMBER_BADGES = true;
|
||||
export const DEFAULT_DISABLE_TERMINAL_FONT_ZOOM = false;
|
||||
export { DEFAULT_RESTORE_PREVIOUS_SESSION } from './sessionRestoreSettings';
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
|
||||
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
|
||||
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
@@ -92,6 +93,7 @@ interface UseSettingsStorageSyncParams {
|
||||
terminalSidePanelAutoOpen: boolean;
|
||||
terminalSidePanelAutoOpenTab: TerminalSidePanelAutoOpenTab;
|
||||
shellOnlyTabNumberShortcuts: boolean;
|
||||
showTabNumberBadges: boolean;
|
||||
disableTerminalFontZoom: boolean;
|
||||
restorePreviousSession: boolean;
|
||||
restoreTerminalCwd: boolean;
|
||||
@@ -139,6 +141,7 @@ interface UseSettingsStorageSyncParams {
|
||||
setTerminalSidePanelAutoOpenState: Dispatch<SetStateAction<boolean>>;
|
||||
setTerminalSidePanelAutoOpenTabState: Dispatch<SetStateAction<TerminalSidePanelAutoOpenTab>>;
|
||||
setShellOnlyTabNumberShortcutsState: Dispatch<SetStateAction<boolean>>;
|
||||
setShowTabNumberBadgesState: Dispatch<SetStateAction<boolean>>;
|
||||
setDisableTerminalFontZoomState: Dispatch<SetStateAction<boolean>>;
|
||||
setRestorePreviousSessionState: Dispatch<SetStateAction<boolean>>;
|
||||
setRestoreTerminalCwdState: Dispatch<SetStateAction<boolean>>;
|
||||
@@ -169,7 +172,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, applyIncomingCustomAccent,
|
||||
@@ -178,7 +181,7 @@ export function useSettingsStorageSync({
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState, setExplorerContextMenuEnabledState,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
|
||||
@@ -193,7 +196,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
});
|
||||
@@ -203,7 +206,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
};
|
||||
@@ -453,6 +456,12 @@ export function useSettingsStorageSync({
|
||||
setShellOnlyTabNumberShortcutsState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SHOW_TAB_NUMBER_BADGES && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.showTabNumberBadges) {
|
||||
setShowTabNumberBadgesState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.disableTerminalFontZoom) {
|
||||
|
||||
@@ -39,6 +39,32 @@ test("keeps a hidden SFTP owner interactive while its promoted editor tab is ope
|
||||
assert.equal(shouldParkBrowseSessions({ interactive, browseParked: false }), false);
|
||||
});
|
||||
|
||||
test("keeps browse warm while the terminal side panel stays open on another tool", () => {
|
||||
// SFTP→History/System replaces the focused pane tool, so surfaceVisible is
|
||||
// false, but the owner stays mounted for instant switch-back. Parking here
|
||||
// forces reconnect + directory reload (and intermittent blank lists).
|
||||
const interactive = isBrowseSessionInteractive({
|
||||
surfaceVisible: false,
|
||||
ownerPanelOpen: true,
|
||||
hasOwnedEditorTab: false,
|
||||
});
|
||||
|
||||
assert.equal(interactive, true);
|
||||
assert.equal(shouldParkBrowseSessions({ interactive, browseParked: false }), false);
|
||||
});
|
||||
|
||||
test("parks browse when the side panel is closed and nothing else retains it", () => {
|
||||
const interactive = isBrowseSessionInteractive({
|
||||
surfaceVisible: false,
|
||||
ownerPanelOpen: false,
|
||||
hasOwnedEditorTab: false,
|
||||
hasActiveExternalEdit: false,
|
||||
});
|
||||
|
||||
assert.equal(interactive, false);
|
||||
assert.equal(shouldParkBrowseSessions({ interactive, browseParked: false }), true);
|
||||
});
|
||||
|
||||
test("parks browse only when the interactive surface hides and not already parked", () => {
|
||||
assert.equal(shouldParkBrowseSessions({ interactive: false, browseParked: false }), true);
|
||||
assert.equal(shouldParkBrowseSessions({ interactive: false, browseParked: true }), false);
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
/**
|
||||
* Browse vs transfer session lifecycle helpers.
|
||||
*
|
||||
* FileZilla model: the interactive browser can soft-close its SFTP channels
|
||||
* while bulk transfers keep dedicated pool connections (and any leased browse
|
||||
* sessions held by in-flight streams).
|
||||
* FileZilla model: a *closed* terminal SFTP side panel can soft-close its browse
|
||||
* SFTP channels while bulk transfers keep dedicated pool connections (and any
|
||||
* leased browse sessions held by in-flight streams). Switching to another side
|
||||
* panel tool (History / System / …) must keep browse warm — the owner stays
|
||||
* mounted for instant switch-back, and parking would force reconnect + reload.
|
||||
*/
|
||||
|
||||
export function isBrowseSessionInteractive(params: {
|
||||
surfaceVisible: boolean;
|
||||
/**
|
||||
* Terminal side panel still open for this owner (another tool may be focused).
|
||||
* Keeps browse sessions alive across tool switches without treating that as a
|
||||
* full panel dismiss.
|
||||
*/
|
||||
ownerPanelOpen?: boolean;
|
||||
hasOwnedEditorTab: boolean;
|
||||
/** External editor temp files (Notepad++ etc.) still need the browse session. */
|
||||
hasActiveExternalEdit?: boolean;
|
||||
}): boolean {
|
||||
return params.surfaceVisible
|
||||
|| !!params.ownerPanelOpen
|
||||
|| params.hasOwnedEditorTab
|
||||
|| !!params.hasActiveExternalEdit;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
normalizeToolbarItemLayout,
|
||||
partitionToolbarItems,
|
||||
setToolbarItemPlacement,
|
||||
} from '../../domain/toolbarItemLayout.ts';
|
||||
import {
|
||||
SYSTEM_MANAGER_TAB_DEFAULT_ORDER,
|
||||
SYSTEM_MANAGER_TAB_LAYOUT_DEFAULTS,
|
||||
} from './systemManagerTabLayout.ts';
|
||||
|
||||
test('system manager tab layout defaults include every known section', () => {
|
||||
assert.deepEqual(
|
||||
[...SYSTEM_MANAGER_TAB_DEFAULT_ORDER],
|
||||
['overview', 'processes', 'ports', 'services', 'tmux', 'docker', 'gpu'],
|
||||
);
|
||||
assert.deepEqual(SYSTEM_MANAGER_TAB_LAYOUT_DEFAULTS.lockedIds, ['overview']);
|
||||
});
|
||||
|
||||
test('system manager overview cannot be hidden', () => {
|
||||
const layout = normalizeToolbarItemLayout(null, SYSTEM_MANAGER_TAB_LAYOUT_DEFAULTS);
|
||||
const next = setToolbarItemPlacement(
|
||||
layout,
|
||||
'overview',
|
||||
'hide',
|
||||
SYSTEM_MANAGER_TAB_LAYOUT_DEFAULTS,
|
||||
);
|
||||
assert.equal(next.placement.overview, 'show');
|
||||
});
|
||||
|
||||
test('system manager partition filters to available host tabs', () => {
|
||||
const layout = normalizeToolbarItemLayout(
|
||||
{
|
||||
order: [...SYSTEM_MANAGER_TAB_DEFAULT_ORDER],
|
||||
placement: {
|
||||
overview: 'show',
|
||||
processes: 'show',
|
||||
ports: 'collapse',
|
||||
services: 'hide',
|
||||
tmux: 'show',
|
||||
docker: 'show',
|
||||
gpu: 'show',
|
||||
},
|
||||
},
|
||||
SYSTEM_MANAGER_TAB_LAYOUT_DEFAULTS,
|
||||
);
|
||||
|
||||
const part = partitionToolbarItems(layout, ['overview', 'processes', 'ports', 'services']);
|
||||
assert.deepEqual(part.shown, ['overview', 'processes']);
|
||||
assert.deepEqual(part.collapsed, ['ports']);
|
||||
assert.deepEqual(part.hidden, ['services']);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { SystemManagerSubTab } from '../../domain/systemManager/types';
|
||||
import type { ToolbarItemLayoutDefaults } from '../../domain/toolbarItemLayout';
|
||||
|
||||
/** Canonical System Manager sub-tab order (all known sections). */
|
||||
export const SYSTEM_MANAGER_TAB_DEFAULT_ORDER: readonly SystemManagerSubTab[] = [
|
||||
'overview',
|
||||
'processes',
|
||||
'ports',
|
||||
'services',
|
||||
'tmux',
|
||||
'docker',
|
||||
'gpu',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Persistable layout defaults for System Manager sub-tabs.
|
||||
* Overview is locked so the panel never loses every reachable section.
|
||||
*/
|
||||
export const SYSTEM_MANAGER_TAB_LAYOUT_DEFAULTS: ToolbarItemLayoutDefaults = {
|
||||
order: [...SYSTEM_MANAGER_TAB_DEFAULT_ORDER],
|
||||
placement: Object.fromEntries(
|
||||
SYSTEM_MANAGER_TAB_DEFAULT_ORDER.map((id) => [id, 'show' as const]),
|
||||
),
|
||||
lockedIds: ['overview'],
|
||||
};
|
||||
@@ -62,20 +62,25 @@ export function useSystemSettingsEffects({
|
||||
// Persist and sync toggle window hotkey setting
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
let cancelled = false;
|
||||
let didRegister = false;
|
||||
// Register/unregister the global hotkey in main process (needed on mount)
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.registerGlobalHotkey) {
|
||||
if (toggleWindowHotkey && globalHotkeyEnabled) {
|
||||
setHotkeyRegistrationError(null);
|
||||
didRegister = true;
|
||||
bridge
|
||||
.registerGlobalHotkey(toggleWindowHotkey)
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
if (result?.success === false) {
|
||||
console.warn('[GlobalHotkey] Hotkey registration failed:', result.error);
|
||||
setHotkeyRegistrationError(result.error || 'Failed to register hotkey');
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
console.warn('[GlobalHotkey] Failed to register hotkey:', err);
|
||||
setHotkeyRegistrationError(err?.message || 'Failed to register hotkey');
|
||||
});
|
||||
@@ -87,9 +92,20 @@ export function useSystemSettingsEffects({
|
||||
}
|
||||
}
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
|
||||
// Skip IPC on initial mount
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
|
||||
// Skip settings-sync IPC on initial mount; still return cleanup below.
|
||||
if (persistMountedRef.current) {
|
||||
notifySettingsChanged(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
// Drop Mount1's registration before Mount2 re-registers (StrictMode), and
|
||||
// release the accelerator on real unmount / disable transitions.
|
||||
if (didRegister) {
|
||||
bridge?.unregisterGlobalHotkey?.().catch((err) => {
|
||||
console.warn('[GlobalHotkey] Failed to unregister hotkey on cleanup', err);
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [
|
||||
toggleWindowHotkey,
|
||||
enabled,
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
const TERMINAL_KEYBOARD_TARGET_SELECTOR =
|
||||
".xterm, .xterm-helper-textarea, .xterm-screen, .xterm-viewport";
|
||||
|
||||
type FocusDocument = Pick<Document, "activeElement" | "addEventListener" | "removeEventListener">;
|
||||
type FocusWindow = Pick<Window, "addEventListener" | "removeEventListener">;
|
||||
|
||||
export const isTerminalKeyboardTarget = (target: EventTarget | null): boolean => {
|
||||
const element = target as (Element & {
|
||||
closest?: (selector: string) => Element | null;
|
||||
}) | null;
|
||||
return Boolean(element?.closest?.(TERMINAL_KEYBOARD_TARGET_SELECTOR));
|
||||
};
|
||||
|
||||
export function installTerminalKeyboardFocusTracking(
|
||||
documentRef: FocusDocument,
|
||||
windowRef: FocusWindow,
|
||||
onFocusChange: (focused: boolean) => void,
|
||||
): () => void {
|
||||
let pendingPointerTarget: EventTarget | null | undefined;
|
||||
let pendingPointerClearTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let isTracking = true;
|
||||
let windowFocused = true;
|
||||
|
||||
const clearPendingPointerTarget = () => {
|
||||
if (pendingPointerClearTimer !== undefined) {
|
||||
clearTimeout(pendingPointerClearTimer);
|
||||
pendingPointerClearTimer = undefined;
|
||||
}
|
||||
pendingPointerTarget = undefined;
|
||||
};
|
||||
|
||||
const publishForTarget = (target: EventTarget | null) => {
|
||||
if (!isTracking) return;
|
||||
onFocusChange(windowFocused && isTerminalKeyboardTarget(target));
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: Event) => {
|
||||
clearPendingPointerTarget();
|
||||
pendingPointerTarget = event.target;
|
||||
pendingPointerClearTimer = setTimeout(() => {
|
||||
const pointerTarget = pendingPointerTarget;
|
||||
clearPendingPointerTarget();
|
||||
if (pointerTarget !== undefined) {
|
||||
publishForTarget(documentRef.activeElement);
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleFocusIn = (event: Event) => {
|
||||
clearPendingPointerTarget();
|
||||
publishForTarget(event.target);
|
||||
};
|
||||
|
||||
const handleFocusOut = () => {
|
||||
queueMicrotask(() => {
|
||||
if (!isTracking) return;
|
||||
const pointerTarget = pendingPointerTarget;
|
||||
clearPendingPointerTarget();
|
||||
if (!windowFocused) {
|
||||
onFocusChange(false);
|
||||
return;
|
||||
}
|
||||
if (pointerTarget !== undefined) {
|
||||
publishForTarget(documentRef.activeElement);
|
||||
return;
|
||||
}
|
||||
publishForTarget(documentRef.activeElement);
|
||||
});
|
||||
};
|
||||
|
||||
const handleWindowBlur = () => {
|
||||
windowFocused = false;
|
||||
clearPendingPointerTarget();
|
||||
onFocusChange(false);
|
||||
};
|
||||
|
||||
const handleWindowFocus = () => {
|
||||
windowFocused = true;
|
||||
publishForTarget(documentRef.activeElement);
|
||||
};
|
||||
|
||||
publishForTarget(documentRef.activeElement);
|
||||
documentRef.addEventListener("pointerdown", handlePointerDown, true);
|
||||
documentRef.addEventListener("focusin", handleFocusIn, true);
|
||||
documentRef.addEventListener("focusout", handleFocusOut, true);
|
||||
windowRef.addEventListener("blur", handleWindowBlur);
|
||||
windowRef.addEventListener("focus", handleWindowFocus);
|
||||
|
||||
return () => {
|
||||
isTracking = false;
|
||||
documentRef.removeEventListener("pointerdown", handlePointerDown, true);
|
||||
documentRef.removeEventListener("focusin", handleFocusIn, true);
|
||||
documentRef.removeEventListener("focusout", handleFocusOut, true);
|
||||
windowRef.removeEventListener("blur", handleWindowBlur);
|
||||
windowRef.removeEventListener("focus", handleWindowFocus);
|
||||
clearPendingPointerTarget();
|
||||
};
|
||||
}
|
||||
@@ -73,6 +73,11 @@ import {
|
||||
type PanelViewByScope,
|
||||
} from './aiStateSnapshots';
|
||||
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
|
||||
import {
|
||||
handoffDissolvedWorkspaceAIScope,
|
||||
retargetWorkspaceActiveChatAfterMemberLoss,
|
||||
seedWorkspaceAIActiveSessionFromMembers,
|
||||
} from '../../domain/workspaceAiScopeHandoff';
|
||||
export function useAIState() {
|
||||
// ── Provider Config ──
|
||||
const [providers, setProvidersRaw] = useState<ProviderConfig[]>(() =>
|
||||
@@ -968,6 +973,119 @@ export function useAIState() {
|
||||
setPanelViewByScopeRaw(latestAIPanelViewByScopeSnapshot ?? {});
|
||||
}, []);
|
||||
|
||||
const seedWorkspaceActiveSessionFromMembers = useCallback((input: {
|
||||
workspaceId: string;
|
||||
memberTerminalIds: readonly string[];
|
||||
preferredTerminalId?: string | null;
|
||||
}) => {
|
||||
const currentMap =
|
||||
latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {};
|
||||
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? {};
|
||||
const seeded = seedWorkspaceAIActiveSessionFromMembers({
|
||||
activeSessionIdMap: currentMap,
|
||||
panelViewByScope: currentPanelViewByScope,
|
||||
workspaceId: input.workspaceId,
|
||||
memberTerminalIds: input.memberTerminalIds,
|
||||
preferredTerminalId: input.preferredTerminalId,
|
||||
});
|
||||
if (!seeded) return;
|
||||
setLatestAIActiveSessionMapSnapshot(seeded.activeSessionIdMap);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, seeded.activeSessionIdMap);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
setActiveSessionIdMapRaw(seeded.activeSessionIdMap);
|
||||
if (seeded.panelViewChanged) {
|
||||
setLatestAIPanelViewByScopeSnapshot(seeded.panelViewByScope);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
|
||||
setPanelViewByScopeRaw(seeded.panelViewByScope);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handoffDissolvedWorkspaceScope = useCallback((input: {
|
||||
workspaceId: string;
|
||||
terminalIds: readonly string[];
|
||||
preferredTerminalId?: string | null;
|
||||
}) => {
|
||||
const currentMap =
|
||||
latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {};
|
||||
const currentSessions =
|
||||
latestAISessionsSnapshot
|
||||
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
||||
?? [];
|
||||
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? {};
|
||||
const result = handoffDissolvedWorkspaceAIScope({
|
||||
activeSessionIdMap: currentMap,
|
||||
sessions: currentSessions,
|
||||
workspaceId: input.workspaceId,
|
||||
terminalIds: input.terminalIds,
|
||||
preferredTerminalId: input.preferredTerminalId,
|
||||
panelViewByScope: currentPanelViewByScope,
|
||||
});
|
||||
if (!result.changed) return;
|
||||
|
||||
if (result.activeSessionIdMap !== currentMap) {
|
||||
setLatestAIActiveSessionMapSnapshot(result.activeSessionIdMap);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, result.activeSessionIdMap);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
setActiveSessionIdMapRaw(result.activeSessionIdMap);
|
||||
}
|
||||
|
||||
if (result.sessions !== currentSessions) {
|
||||
sessionsRef.current = result.sessions;
|
||||
setLatestAISessionsSnapshot(result.sessions);
|
||||
persistSessions(result.sessions);
|
||||
setSessionsRaw(result.sessions);
|
||||
}
|
||||
|
||||
if (result.panelViewByScope !== currentPanelViewByScope) {
|
||||
setLatestAIPanelViewByScopeSnapshot(result.panelViewByScope);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
|
||||
setPanelViewByScopeRaw(result.panelViewByScope);
|
||||
}
|
||||
}, [persistSessions]);
|
||||
|
||||
const retargetWorkspaceActiveChatForMemberLoss = useCallback((input: {
|
||||
workspaceId: string;
|
||||
previousMemberTerminalIds: readonly string[];
|
||||
currentMemberTerminalIds: readonly string[];
|
||||
preferredTerminalId?: string | null;
|
||||
}) => {
|
||||
const currentMap =
|
||||
latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {};
|
||||
const currentSessions =
|
||||
latestAISessionsSnapshot
|
||||
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
||||
?? [];
|
||||
const result = retargetWorkspaceActiveChatAfterMemberLoss({
|
||||
activeSessionIdMap: currentMap,
|
||||
sessions: currentSessions,
|
||||
workspaceId: input.workspaceId,
|
||||
previousMemberTerminalIds: input.previousMemberTerminalIds,
|
||||
currentMemberTerminalIds: input.currentMemberTerminalIds,
|
||||
preferredTerminalId: input.preferredTerminalId,
|
||||
});
|
||||
if (!result.changed) return;
|
||||
|
||||
if (result.activeSessionIdMap !== currentMap) {
|
||||
setLatestAIActiveSessionMapSnapshot(result.activeSessionIdMap);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, result.activeSessionIdMap);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
setActiveSessionIdMapRaw(result.activeSessionIdMap);
|
||||
}
|
||||
|
||||
if (result.sessions !== currentSessions) {
|
||||
sessionsRef.current = result.sessions;
|
||||
setLatestAISessionsSnapshot(result.sessions);
|
||||
persistSessions(result.sessions);
|
||||
setSessionsRaw(result.sessions);
|
||||
}
|
||||
}, [persistSessions]);
|
||||
|
||||
// ── Provider CRUD helpers ──
|
||||
const addProvider = useCallback((provider: ProviderConfig) => {
|
||||
setProviders(prev => [...prev, provider]);
|
||||
@@ -1071,6 +1189,9 @@ export function useAIState() {
|
||||
updateMessageById,
|
||||
persistContextCompaction,
|
||||
cleanupOrphanedSessions,
|
||||
seedWorkspaceActiveSessionFromMembers,
|
||||
handoffDissolvedWorkspaceScope,
|
||||
retargetWorkspaceActiveChatForMemberLoss,
|
||||
}), [
|
||||
providers,
|
||||
setProviders,
|
||||
@@ -1124,5 +1245,8 @@ export function useAIState() {
|
||||
updateMessageById,
|
||||
persistContextCompaction,
|
||||
cleanupOrphanedSessions,
|
||||
seedWorkspaceActiveSessionFromMembers,
|
||||
handoffDissolvedWorkspaceScope,
|
||||
retargetWorkspaceActiveChatForMemberLoss,
|
||||
]);
|
||||
}
|
||||
@@ -199,6 +199,36 @@ describe('useExternalMcpToggleState startup ready gate', () => {
|
||||
assert.match(hookSource, /if \(isPeerSessionWindow \|\| !enabled\) return;/);
|
||||
});
|
||||
|
||||
it('restores saved permission before enabling persistent External MCP', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
const storageKeys = await import('../../infrastructure/config/storageKeys.ts');
|
||||
globalThis.localStorage.setItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, 'true');
|
||||
globalThis.localStorage.setItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
|
||||
globalThis.localStorage.setItem(storageKeys.STORAGE_KEY_AI_PERMISSION_MODE, 'auto');
|
||||
|
||||
const calls: string[] = [];
|
||||
await syncExternalMcpStartupState({
|
||||
aiMcpSetPermissionMode: async (mode) => {
|
||||
calls.push(`permission:${mode}`);
|
||||
return { ok: true };
|
||||
},
|
||||
externalMcpSetConfig: async () => {
|
||||
calls.push('config');
|
||||
return { ok: true };
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled) => {
|
||||
calls.push(`enabled:${enabled}`);
|
||||
return { ok: true, enabled };
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['permission:auto', 'config', 'enabled:true']);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('re-reads storage after config await so concurrent top-bar toggles win', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
|
||||
@@ -5,8 +5,10 @@ import {
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_MODE,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_SILENT_SESSIONS,
|
||||
STORAGE_KEY_AI_PERMISSION_MODE,
|
||||
STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import type { AIPermissionMode } from '../../infrastructure/ai/types';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
|
||||
@@ -105,6 +107,7 @@ type ExternalMcpConfig = {
|
||||
};
|
||||
|
||||
type ExternalMcpBridge = {
|
||||
aiMcpSetPermissionMode?: (mode: AIPermissionMode) => Promise<unknown> | unknown;
|
||||
externalMcpSetConfig?: (config: ExternalMcpConfig) => Promise<unknown> | unknown;
|
||||
externalMcpSetEnabled?: (enabled: boolean) => Promise<unknown> | unknown;
|
||||
externalMcpGetStatus?: () => Promise<{
|
||||
@@ -126,6 +129,11 @@ export function normalizeExternalMcpMode(value: string | null): ExternalMcpMode
|
||||
return value === 'persistent' ? 'persistent' : 'temporary';
|
||||
}
|
||||
|
||||
function readAIPermissionMode(): AIPermissionMode {
|
||||
const stored = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
|
||||
return stored === 'observer' || stored === 'auto' ? stored : 'confirm';
|
||||
}
|
||||
|
||||
export function normalizeExternalMcpIdleTimeoutMinutes(value: number | null): number {
|
||||
if (!Number.isFinite(value)) return DEFAULT_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES;
|
||||
return Math.min(
|
||||
@@ -280,6 +288,13 @@ export async function syncExternalMcpStartupState(
|
||||
// toggle during boot wins over a stale enable/disable decision.
|
||||
const startupGeneration = externalMcpEnableGeneration;
|
||||
const initialPlan = readExternalMcpStartupSyncPlan();
|
||||
try {
|
||||
// External MCP discovery is written while enabling, so restore the saved
|
||||
// permission first instead of exposing the main-process default (confirm).
|
||||
await Promise.resolve(bridge?.aiMcpSetPermissionMode?.(readAIPermissionMode()));
|
||||
} catch {
|
||||
// Permission sync is best-effort; the main process keeps its safe default.
|
||||
}
|
||||
try {
|
||||
await Promise.resolve(bridge?.externalMcpSetConfig?.(initialPlan.config));
|
||||
} catch {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
syncWithBackend,
|
||||
} from "../../infrastructure/services/portForwardingService";
|
||||
import { logger } from "../../lib/logger";
|
||||
import { applyPortForwardingRuntimeStatus } from "./usePortForwardingState";
|
||||
|
||||
export interface UsePortForwardingAutoStartOptions {
|
||||
enabled?: boolean;
|
||||
@@ -328,24 +329,12 @@ export const usePortForwardingAutoStart = ({
|
||||
[resolveEffectiveHost],
|
||||
);
|
||||
|
||||
// Runtime phases stay in the in-memory projection only (#2288). Writing
|
||||
// active/connecting/error into localStorage would churn sync hashes and
|
||||
// fight the main-process authority model.
|
||||
const updateStoredRuleStatus = useCallback(
|
||||
(ruleId: string, status: PortForwardingRule["status"], error?: string) => {
|
||||
const currentRules = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
) ?? [];
|
||||
|
||||
const updatedRules = currentRules.map((rule) =>
|
||||
rule.id === ruleId
|
||||
? {
|
||||
...rule,
|
||||
status,
|
||||
error,
|
||||
lastUsedAt: status === "active" ? Date.now() : rule.lastUsedAt,
|
||||
}
|
||||
: rule,
|
||||
);
|
||||
|
||||
localStorageAdapter.write(STORAGE_KEY_PORT_FORWARDING, updatedRules);
|
||||
applyPortForwardingRuntimeStatus(ruleId, status, error);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -2,12 +2,14 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type { Host, PortForwardingRule } from "../../domain/models.ts";
|
||||
import { toPersistedPortForwardingRules } from "../../domain/portForwardingPersistence.ts";
|
||||
import { STORAGE_KEY_PORT_FORWARDING } from "../../infrastructure/config/storageKeys.ts";
|
||||
import {
|
||||
startPortForward,
|
||||
stopAndCleanupRuleAndWait,
|
||||
} from "../../infrastructure/services/portForwardingService.ts";
|
||||
import {
|
||||
applyPortForwardingRuntimeStatus,
|
||||
createPortForwardingStorageSyncHandlers,
|
||||
havePortForwardingRuntimeStatesChanged,
|
||||
hasPortForwardingRuntimePresenceChanged,
|
||||
@@ -25,6 +27,7 @@ const rule: PortForwardingRule = {
|
||||
autoStart: true,
|
||||
createdAt: 1,
|
||||
status: "inactive",
|
||||
bindAddress: "127.0.0.1",
|
||||
};
|
||||
|
||||
const host: Host = {
|
||||
@@ -80,32 +83,27 @@ function installEnvironment() {
|
||||
};
|
||||
}
|
||||
|
||||
test("same-window auto-start status writes refresh the visible rule from the live tunnel", async (t) => {
|
||||
test("runtime status updates refresh the visible rule without writing storage", async (t) => {
|
||||
const env = installEnvironment();
|
||||
t.after(async () => {
|
||||
await stopAndCleanupRuleAndWait(rule.id);
|
||||
env.restore();
|
||||
});
|
||||
|
||||
env.storage.setItem(STORAGE_KEY_PORT_FORWARDING, JSON.stringify([rule]));
|
||||
env.storage.setItem(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
JSON.stringify(toPersistedPortForwardingRules([rule])),
|
||||
);
|
||||
await startPortForward(rule, host, [host], [], [], () => undefined, true);
|
||||
env.emitStatus("active");
|
||||
applyPortForwardingRuntimeStatus(rule.id, "active");
|
||||
|
||||
const snapshots: PortForwardingRule[][] = [];
|
||||
const handlers = createPortForwardingStorageSyncHandlers({
|
||||
onRules: (rules) => snapshots.push(rules),
|
||||
});
|
||||
|
||||
handlers.handleAdapterChange({
|
||||
type: "netcatty:local-storage-adapter-changed",
|
||||
detail: { key: STORAGE_KEY_PORT_FORWARDING },
|
||||
} as unknown as CustomEvent<{ key: string }>);
|
||||
|
||||
assert.equal(snapshots.length, 1);
|
||||
assert.equal(snapshots[0]?.[0]?.status, "active");
|
||||
const stored = JSON.parse(env.storage.getItem(STORAGE_KEY_PORT_FORWARDING) || "[]") as PortForwardingRule[];
|
||||
assert.equal(stored[0]?.status, "inactive");
|
||||
assert.equal(stored[0]?.error, undefined);
|
||||
});
|
||||
|
||||
test("cross-window status writes stay active before this window discovers the tunnel", (t) => {
|
||||
test("legacy cross-window active status is migrated away on config sync", (t) => {
|
||||
const env = installEnvironment();
|
||||
t.after(() => env.restore());
|
||||
|
||||
@@ -125,7 +123,20 @@ test("cross-window status writes stay active before this window discovers the tu
|
||||
} as StorageEvent);
|
||||
|
||||
assert.equal(snapshots.length, 1);
|
||||
assert.equal(snapshots[0]?.[0]?.status, "active");
|
||||
// Runtime is no longer trusted from storage. Before an authoritative snapshot
|
||||
// succeeds, the projection must be unknown — never a false inactive.
|
||||
assert.equal(snapshots[0]?.[0]?.status, "unknown");
|
||||
});
|
||||
|
||||
test("normalizeRulesWithConnections stays inactive only after authority succeeds", () => {
|
||||
const normalized = normalizeRulesWithConnections([{
|
||||
...rule,
|
||||
id: "authority-ok-rule",
|
||||
status: "active",
|
||||
}], {
|
||||
snapshotAvailable: true,
|
||||
});
|
||||
assert.equal(normalized[0]?.status, "inactive");
|
||||
});
|
||||
|
||||
test("cross-window stale status cannot override a known live tunnel", async (t) => {
|
||||
@@ -164,6 +175,16 @@ test("same-window synchronization preserves an error without a runtime tunnel",
|
||||
status: "error",
|
||||
error: "Host not found",
|
||||
}]));
|
||||
// Authority already confirmed — diagnostic error may remain without a tunnel.
|
||||
const normalized = normalizeRulesWithConnections([{
|
||||
...rule,
|
||||
id: "failed-start-rule",
|
||||
status: "error",
|
||||
error: "Host not found",
|
||||
}], { snapshotAvailable: true });
|
||||
assert.equal(normalized[0]?.status, "error");
|
||||
assert.equal(normalized[0]?.error, "Host not found");
|
||||
|
||||
const snapshots: PortForwardingRule[][] = [];
|
||||
const handlers = createPortForwardingStorageSyncHandlers({
|
||||
onRules: (rules) => snapshots.push(rules),
|
||||
@@ -174,8 +195,8 @@ test("same-window synchronization preserves an error without a runtime tunnel",
|
||||
detail: { key: STORAGE_KEY_PORT_FORWARDING },
|
||||
} as unknown as CustomEvent<{ key: string }>);
|
||||
|
||||
assert.equal(snapshots[0]?.[0]?.status, "error");
|
||||
assert.equal(snapshots[0]?.[0]?.error, "Host not found");
|
||||
// Without a successful snapshot in this module, storage sync projects unknown.
|
||||
assert.equal(snapshots[0]?.[0]?.status, "unknown");
|
||||
});
|
||||
|
||||
test("heartbeat normalization preserves an error without a runtime tunnel", () => {
|
||||
@@ -184,7 +205,7 @@ test("heartbeat normalization preserves an error without a runtime tunnel", () =
|
||||
id: "heartbeat-error-rule",
|
||||
status: "error",
|
||||
error: "Authentication failed",
|
||||
}]);
|
||||
}], { snapshotAvailable: true });
|
||||
|
||||
assert.equal(normalized[0]?.status, "error");
|
||||
assert.equal(normalized[0]?.error, "Authentication failed");
|
||||
@@ -196,12 +217,27 @@ test("heartbeat normalization clears an error for a reconciled-away tunnel", ()
|
||||
id: "cleanup-error-rule",
|
||||
status: "error",
|
||||
error: "Failed to stop tunnel",
|
||||
}], new Set(["cleanup-error-rule"]));
|
||||
}], {
|
||||
reconciledGoneRuleIds: new Set(["cleanup-error-rule"]),
|
||||
snapshotAvailable: true,
|
||||
});
|
||||
|
||||
assert.equal(normalized[0]?.status, "inactive");
|
||||
assert.equal(normalized[0]?.error, undefined);
|
||||
});
|
||||
|
||||
test("snapshot failure projects unknown instead of inactive", () => {
|
||||
const normalized = normalizeRulesWithConnections([{
|
||||
...rule,
|
||||
id: "stale-rule",
|
||||
status: "inactive",
|
||||
}], {
|
||||
snapshotAvailable: false,
|
||||
});
|
||||
|
||||
assert.equal(normalized[0]?.status, "unknown");
|
||||
});
|
||||
|
||||
test("heartbeat writes only when a visible runtime state changes", () => {
|
||||
const current = [{ ...rule, status: "inactive" as const }];
|
||||
const unchanged = [{ ...rule, status: "inactive" as const }];
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Host, Identity, KnownHost, PortForwardingRule, SSHKey } from "../../domain/models";
|
||||
import {
|
||||
migratePortForwardingRulesFromStorage,
|
||||
toPersistedPortForwardingRules,
|
||||
} from "../../domain/portForwardingPersistence";
|
||||
import { getNextVaultOrder, normalizeVaultOrder, reorderVaultItems, sortByVaultOrder, type VaultOrderPosition } from "../../domain/vaultOrder";
|
||||
import {
|
||||
STORAGE_KEY_PF_PREFER_FORM_MODE,
|
||||
@@ -10,9 +14,11 @@ import {
|
||||
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
|
||||
localStorageAdapter,
|
||||
} from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
import {
|
||||
clearReconnectTimer,
|
||||
getActiveConnection,
|
||||
getPortForwardRuntimeAuthority,
|
||||
initReconnectCancelListener,
|
||||
reconcileWithBackend,
|
||||
startPortForward,
|
||||
@@ -32,6 +38,8 @@ let reconnectCancelListenerRefs = 0;
|
||||
let reconnectCancelCleanup: (() => void) | undefined;
|
||||
let heartbeatRefs = 0;
|
||||
let heartbeatIntervalId: ReturnType<typeof setInterval> | undefined;
|
||||
let runtimeSubscriptionRefs = 0;
|
||||
let runtimeSubscriptionCleanup: (() => void) | undefined;
|
||||
|
||||
export type { ViewMode };
|
||||
|
||||
@@ -91,6 +99,8 @@ export interface UsePortForwardingStateResult {
|
||||
// Global Store State
|
||||
let globalRules: PortForwardingRule[] = [];
|
||||
let isInitialized = false;
|
||||
// Until the first successful authoritative snapshot, treat runtime as unknown.
|
||||
let snapshotAvailable = false;
|
||||
const listeners = new Set<(rules: PortForwardingRule[]) => void>();
|
||||
|
||||
// Store Actions
|
||||
@@ -98,16 +108,50 @@ const notifyListeners = () => {
|
||||
listeners.forEach((listener) => listener(globalRules));
|
||||
};
|
||||
|
||||
const persistPortForwardingConfig = (rules: PortForwardingRule[]) => {
|
||||
localStorageAdapter.write(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
toPersistedPortForwardingRules(rules),
|
||||
);
|
||||
};
|
||||
|
||||
/** Persist configuration only — never write runtime phases to storage. */
|
||||
const setGlobalRules = (newRules: PortForwardingRule[]) => {
|
||||
globalRules = normalizeVaultOrder(newRules);
|
||||
notifyListeners();
|
||||
localStorageAdapter.write(STORAGE_KEY_PORT_FORWARDING, globalRules);
|
||||
persistPortForwardingConfig(globalRules);
|
||||
};
|
||||
|
||||
/** Update the in-memory projection without touching localStorage. */
|
||||
const setRuntimeProjection = (newRules: PortForwardingRule[]) => {
|
||||
globalRules = normalizeVaultOrder(newRules);
|
||||
notifyListeners();
|
||||
};
|
||||
|
||||
export type NormalizeRulesOptions = {
|
||||
reconciledGoneRuleIds?: ReadonlySet<string>;
|
||||
snapshotAvailable?: boolean;
|
||||
};
|
||||
|
||||
const isGoneRuleIdSet = (
|
||||
value: ReadonlySet<string> | NormalizeRulesOptions,
|
||||
): value is ReadonlySet<string> => (
|
||||
typeof (value as ReadonlySet<string>).has === "function"
|
||||
&& !("snapshotAvailable" in (value as object))
|
||||
&& !("reconciledGoneRuleIds" in (value as object))
|
||||
);
|
||||
|
||||
export const normalizeRulesWithConnections = (
|
||||
rules: PortForwardingRule[],
|
||||
reconciledGoneRuleIds: ReadonlySet<string> = new Set(),
|
||||
reconciledGoneRuleIdsOrOptions: ReadonlySet<string> | NormalizeRulesOptions = new Set(),
|
||||
): PortForwardingRule[] => {
|
||||
const options: NormalizeRulesOptions = isGoneRuleIdSet(reconciledGoneRuleIdsOrOptions)
|
||||
? { reconciledGoneRuleIds: reconciledGoneRuleIdsOrOptions }
|
||||
: reconciledGoneRuleIdsOrOptions;
|
||||
|
||||
const reconciledGoneRuleIds = options.reconciledGoneRuleIds ?? new Set<string>();
|
||||
const authorityAvailable = options.snapshotAvailable ?? snapshotAvailable;
|
||||
|
||||
return rules.map((rule): PortForwardingRule => {
|
||||
const connection = getActiveConnection(rule.id);
|
||||
if (connection) {
|
||||
@@ -126,6 +170,13 @@ export const normalizeRulesWithConnections = (
|
||||
};
|
||||
}
|
||||
|
||||
if (!authorityAvailable) {
|
||||
return {
|
||||
...rule,
|
||||
status: "unknown" as const,
|
||||
};
|
||||
}
|
||||
|
||||
if (rule.status === "error") return rule;
|
||||
|
||||
return {
|
||||
@@ -155,15 +206,42 @@ export const hasPortForwardingRuntimePresenceChanged = (reconciliation: {
|
||||
}): boolean => reconciliation.gone.length > 0 || reconciliation.appeared.length > 0;
|
||||
|
||||
const mergeRulesWithKnownConnections = (rules: PortForwardingRule[]): PortForwardingRule[] => {
|
||||
return rules.map((rule): PortForwardingRule => {
|
||||
const connection = getActiveConnection(rule.id);
|
||||
if (!connection) return rule;
|
||||
return normalizeRulesWithConnections(
|
||||
migratePortForwardingRulesFromStorage(rules),
|
||||
{ snapshotAvailable },
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply a runtime status update to the in-memory projection only.
|
||||
* Auto-start and reconnect paths use this instead of writing localStorage.
|
||||
*/
|
||||
export const applyPortForwardingRuntimeStatus = (
|
||||
ruleId: string,
|
||||
status: PortForwardingRule["status"],
|
||||
error?: string,
|
||||
): void => {
|
||||
if (globalRules.length === 0) {
|
||||
const stored = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
);
|
||||
if (stored && Array.isArray(stored)) {
|
||||
globalRules = normalizeVaultOrder(
|
||||
migratePortForwardingRulesFromStorage(stored),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const updated = globalRules.map((rule) => {
|
||||
if (rule.id !== ruleId) return rule;
|
||||
return {
|
||||
...rule,
|
||||
status: connection.status,
|
||||
error: connection.error,
|
||||
status,
|
||||
error,
|
||||
lastUsedAt: status === "active" ? Date.now() : rule.lastUsedAt,
|
||||
};
|
||||
});
|
||||
setRuntimeProjection(updated);
|
||||
};
|
||||
|
||||
const isPortForwardingStorageEvent = (event: Event): boolean => {
|
||||
@@ -199,21 +277,121 @@ export const createPortForwardingStorageSyncHandlers = ({
|
||||
};
|
||||
};
|
||||
|
||||
const applyRuntimeSnapshotProjection = (
|
||||
goneRuleIds: ReadonlySet<string> = new Set(),
|
||||
authorityAvailable = snapshotAvailable,
|
||||
) => {
|
||||
const normalizedRules = normalizeRulesWithConnections(globalRules, {
|
||||
reconciledGoneRuleIds: goneRuleIds,
|
||||
snapshotAvailable: authorityAvailable,
|
||||
});
|
||||
if (havePortForwardingRuntimeStatesChanged(globalRules, normalizedRules)) {
|
||||
setRuntimeProjection(normalizedRules);
|
||||
} else if (hasPortForwardingRuntimePresenceChanged({
|
||||
gone: [...goneRuleIds],
|
||||
appeared: [],
|
||||
})) {
|
||||
globalRules = normalizedRules;
|
||||
notifyListeners();
|
||||
}
|
||||
};
|
||||
|
||||
// Initialization Logic
|
||||
const initializeStore = async () => {
|
||||
if (isInitialized) return;
|
||||
isInitialized = true;
|
||||
|
||||
await syncWithBackend();
|
||||
snapshotAvailable = getPortForwardRuntimeAuthority().available;
|
||||
|
||||
const saved = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
);
|
||||
if (saved && Array.isArray(saved)) {
|
||||
setGlobalRules(normalizeRulesWithConnections(saved));
|
||||
// Hydrate config from storage, then overlay the live runtime projection.
|
||||
// Do not re-persist — that would churn storage with migrated status fields.
|
||||
const migrated = migratePortForwardingRulesFromStorage(saved);
|
||||
setRuntimeProjection(normalizeRulesWithConnections(migrated, {
|
||||
snapshotAvailable,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const subscribeToPortForwardRuntime = (): (() => void) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.subscribePortForwardRuntime || !bridge.onPortForwardRuntime) {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
let unsubscribeEvent: (() => void) | undefined;
|
||||
let observedEpoch: string | undefined;
|
||||
let observedRevision = -1;
|
||||
let syncChain: Promise<void> = Promise.resolve();
|
||||
|
||||
const enqueueRuntimeSync = (task: () => Promise<void>) => {
|
||||
syncChain = syncChain.then(async () => {
|
||||
if (disposed) return;
|
||||
await task();
|
||||
}).catch(() => undefined);
|
||||
return syncChain;
|
||||
};
|
||||
|
||||
const resyncFromSnapshot = async () => {
|
||||
try {
|
||||
const snapshot = await bridge.subscribePortForwardRuntime!();
|
||||
if (disposed) return;
|
||||
observedEpoch = snapshot.epoch;
|
||||
observedRevision = snapshot.revision;
|
||||
// Reconcile (not sync-only) so tunnels absent after an epoch change are pruned.
|
||||
const reconciliation = await reconcileWithBackend();
|
||||
if (disposed) return;
|
||||
snapshotAvailable = reconciliation.snapshotAvailable;
|
||||
if (!reconciliation.snapshotAvailable) {
|
||||
applyRuntimeSnapshotProjection(new Set(), false);
|
||||
return;
|
||||
}
|
||||
applyRuntimeSnapshotProjection(new Set(reconciliation.gone), true);
|
||||
} catch {
|
||||
if (disposed) return;
|
||||
snapshotAvailable = false;
|
||||
applyRuntimeSnapshotProjection(new Set(), false);
|
||||
}
|
||||
};
|
||||
|
||||
unsubscribeEvent = bridge.onPortForwardRuntime((event) => {
|
||||
if (disposed) return;
|
||||
if (observedEpoch && event.epoch !== observedEpoch) {
|
||||
void enqueueRuntimeSync(resyncFromSnapshot);
|
||||
return;
|
||||
}
|
||||
if (observedRevision >= 0 && event.revision > observedRevision + 1) {
|
||||
void enqueueRuntimeSync(resyncFromSnapshot);
|
||||
return;
|
||||
}
|
||||
observedEpoch = event.epoch;
|
||||
observedRevision = event.revision;
|
||||
void enqueueRuntimeSync(async () => {
|
||||
const reconciliation = await reconcileWithBackend();
|
||||
if (disposed) return;
|
||||
snapshotAvailable = reconciliation.snapshotAvailable;
|
||||
if (!reconciliation.snapshotAvailable) {
|
||||
applyRuntimeSnapshotProjection(new Set(), false);
|
||||
return;
|
||||
}
|
||||
applyRuntimeSnapshotProjection(new Set(reconciliation.gone), true);
|
||||
});
|
||||
});
|
||||
|
||||
void enqueueRuntimeSync(resyncFromSnapshot);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
unsubscribeEvent?.();
|
||||
void bridge.unsubscribePortForwardRuntime?.();
|
||||
};
|
||||
};
|
||||
|
||||
export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
const [rules, setRules] = useState<PortForwardingRule[]>(globalRules);
|
||||
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
|
||||
@@ -253,9 +431,9 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
};
|
||||
}, [rules]);
|
||||
|
||||
// Listen for both browser storage events (other windows) and adapter
|
||||
// events (this window). Auto-start writes statuses outside this hook, so
|
||||
// relying on the browser event alone leaves the launching window stale.
|
||||
// Config sync across windows. Runtime phases are no longer written to
|
||||
// storage, so these handlers only refresh rule configuration and overlay
|
||||
// whatever live tunnels this window already knows about.
|
||||
useEffect(() => {
|
||||
const target = globalThis as typeof globalThis & {
|
||||
addEventListener?: (type: string, listener: EventListener) => void;
|
||||
@@ -303,6 +481,22 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Authoritative runtime subscription (snapshot + ordered events). Heartbeat
|
||||
// below remains as a recovery fallback for revision gaps / missed events.
|
||||
useEffect(() => {
|
||||
runtimeSubscriptionRefs++;
|
||||
if (runtimeSubscriptionRefs === 1) {
|
||||
runtimeSubscriptionCleanup = subscribeToPortForwardRuntime();
|
||||
}
|
||||
return () => {
|
||||
runtimeSubscriptionRefs--;
|
||||
if (runtimeSubscriptionRefs === 0 && runtimeSubscriptionCleanup) {
|
||||
runtimeSubscriptionCleanup();
|
||||
runtimeSubscriptionCleanup = undefined;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Periodic heartbeat: reconcile renderer state with the backend every 4s.
|
||||
// Ref-counted — same pattern as the reconnect cancel listener.
|
||||
useEffect(() => {
|
||||
@@ -313,19 +507,24 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
|
||||
const tick = async () => {
|
||||
const reconciliation = await reconcileWithBackend();
|
||||
if (!reconciliation.snapshotAvailable) return;
|
||||
// Always re-derive the visible state. This also repairs a stale
|
||||
// cross-window storage write when the backend map itself did not change.
|
||||
snapshotAvailable = reconciliation.snapshotAvailable;
|
||||
if (!reconciliation.snapshotAvailable) {
|
||||
// Snapshot failure must surface as unknown/stale — never as inactive.
|
||||
applyRuntimeSnapshotProjection(new Set(), false);
|
||||
return;
|
||||
}
|
||||
// Always re-derive the visible state from the live connection map.
|
||||
// Runtime phases stay in memory only.
|
||||
const normalizedRules = normalizeRulesWithConnections(
|
||||
globalRules,
|
||||
new Set(reconciliation.gone),
|
||||
{
|
||||
reconciledGoneRuleIds: new Set(reconciliation.gone),
|
||||
snapshotAvailable: true,
|
||||
},
|
||||
);
|
||||
if (havePortForwardingRuntimeStatesChanged(globalRules, normalizedRules)) {
|
||||
setGlobalRules(normalizedRules);
|
||||
setRuntimeProjection(normalizedRules);
|
||||
} else if (hasPortForwardingRuntimePresenceChanged(reconciliation)) {
|
||||
// Runtime presence affects Start/Stop actions even when the visible
|
||||
// status and error already match. Notify React without rewriting the
|
||||
// unchanged persisted configuration.
|
||||
globalRules = normalizedRules;
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -470,21 +669,14 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
stopAndCleanupRule(existing.id);
|
||||
}
|
||||
}
|
||||
setGlobalRules(normalizeRulesWithConnections(newRules));
|
||||
setGlobalRules(normalizeRulesWithConnections(
|
||||
migratePortForwardingRulesFromStorage(newRules),
|
||||
));
|
||||
}, []);
|
||||
|
||||
const setRuleStatus = useCallback(
|
||||
(id: string, status: PortForwardingRule["status"], error?: string) => {
|
||||
const updated = globalRules.map((r) => {
|
||||
if (r.id !== id) return r;
|
||||
return {
|
||||
...r,
|
||||
status,
|
||||
error,
|
||||
lastUsedAt: status === "active" ? Date.now() : r.lastUsedAt,
|
||||
};
|
||||
});
|
||||
setGlobalRules(updated);
|
||||
applyPortForwardingRuntimeStatus(id, status, error);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export interface DiskInfo {
|
||||
capacityKey?: string; // Filesystem or shared-pool identity for deduplication
|
||||
filesystemType?: string; // Filesystem type reported by df (for network/FUSE filtering)
|
||||
mountPoint: string;
|
||||
used: number; // Used in GB
|
||||
total: number; // Total in GB
|
||||
@@ -183,16 +184,6 @@ function clearServerStatsTimers(session: SharedServerStatsSession): void {
|
||||
session.pollIntervalMs = null;
|
||||
}
|
||||
|
||||
function resetServerStatsSession(session: SharedServerStatsSession): void {
|
||||
clearServerStatsTimers(session);
|
||||
session.connectedAt = 0;
|
||||
session.hasFetched = false;
|
||||
session.fetchGeneration += 1;
|
||||
session.consecutiveFailures = 0;
|
||||
session.givenUp = false;
|
||||
updateServerStatsState(session, createInitialState());
|
||||
}
|
||||
|
||||
function getActiveServerStatsClients(session: SharedServerStatsSession): ServerStatsClient[] {
|
||||
return Array.from(session.clients.values()).filter((client) => (
|
||||
client.enabled && client.isSupportedOs && client.isConnected
|
||||
@@ -312,7 +303,16 @@ function reconcileSharedServerStatsSession(session: SharedServerStatsSession): v
|
||||
const activeClients = getActiveServerStatsClients(session);
|
||||
|
||||
if (activeClients.length === 0) {
|
||||
resetServerStatsSession(session);
|
||||
// Pause polling only. Keep the last successful stats so remounting Overview
|
||||
// (or re-enabling a terminal stats strip) can paint immediately instead of
|
||||
// flashing "No system overview data yet." Still clear give-up / failure so a
|
||||
// later resume can auto-retry (old reset wiped stats + flags together).
|
||||
clearServerStatsTimers(session);
|
||||
session.givenUp = false;
|
||||
session.consecutiveFailures = 0;
|
||||
if (session.state.isLoading) {
|
||||
updateServerStatsState(session, { isLoading: false });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
import { useCallback } from "react";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
|
||||
type ManualChoosePathPayload = {
|
||||
sessionId: string;
|
||||
sessionName?: string;
|
||||
preferredDirectory?: string;
|
||||
format?: "txt" | "raw" | "html";
|
||||
};
|
||||
|
||||
type ManualStartPayload = {
|
||||
sessionId: string;
|
||||
sessionName?: string;
|
||||
preferredDirectory?: string;
|
||||
/**
|
||||
* Opaque token from chooseManualSessionLogPath. Required for the post-dialog
|
||||
* second phase; raw file paths from the renderer are rejected.
|
||||
*/
|
||||
selectionToken?: string;
|
||||
format?: "txt" | "raw" | "html";
|
||||
timestampsEnabled?: boolean;
|
||||
initialLine?: string;
|
||||
alternateScreenActive?: boolean;
|
||||
};
|
||||
|
||||
type ManualStopPayload = {
|
||||
@@ -19,6 +32,12 @@ type ManualStatusPayload = {
|
||||
};
|
||||
|
||||
export const useSessionLogBackend = () => {
|
||||
const chooseManualSessionLogPath = useCallback(async (payload: ManualChoosePathPayload) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.chooseManualSessionLogPath?.(payload)
|
||||
?? { success: false, canceled: false, error: "Session log bridge unavailable" };
|
||||
}, []);
|
||||
|
||||
const startManualSessionLog = useCallback(async (payload: ManualStartPayload) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.startManualSessionLog?.(payload) ?? { success: false, started: false, error: "Session log bridge unavailable" };
|
||||
@@ -34,5 +53,10 @@ export const useSessionLogBackend = () => {
|
||||
return bridge?.getManualSessionLogStatus?.(payload) ?? { success: false, isLogging: false, error: "Session log bridge unavailable" };
|
||||
}, []);
|
||||
|
||||
return { startManualSessionLog, stopManualSessionLog, getManualSessionLogStatus };
|
||||
return {
|
||||
chooseManualSessionLogPath,
|
||||
startManualSessionLog,
|
||||
stopManualSessionLog,
|
||||
getManualSessionLogStatus,
|
||||
};
|
||||
};
|
||||
@@ -797,11 +797,6 @@ export const useSessionState = ({
|
||||
host: Host,
|
||||
direction: SplitDirection = 'vertical',
|
||||
): string | null => {
|
||||
// Serial hosts use a different session constructor; they currently
|
||||
// only enter workspaces via createSerialSession + drag, so reject
|
||||
// them here to avoid a partially-constructed session.
|
||||
if (host.protocol === 'serial') return null;
|
||||
|
||||
// Cheap early-exit using the ref when the workspace is clearly
|
||||
// absent. The authoritative check lives inside the setWorkspaces
|
||||
// updater below so we also cover the concurrent-close race.
|
||||
|
||||
@@ -105,4 +105,8 @@ test('workspace host creation paths use the host session snapshot factory', () =
|
||||
source,
|
||||
/createWorkspaceHostTerminalSession\(newSessionId,\s*host,\s*workspaceId\)/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/if \(host\.protocol === 'serial'\) return null/,
|
||||
);
|
||||
});
|
||||
@@ -75,6 +75,7 @@ import {
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
|
||||
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||
@@ -121,6 +122,7 @@ import {
|
||||
DEFAULT_SHOW_SFTP_TAB,
|
||||
DEFAULT_SHOW_HOST_TREE_SIDEBAR,
|
||||
DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
DEFAULT_SHOW_TAB_NUMBER_BADGES,
|
||||
DEFAULT_DISABLE_TERMINAL_FONT_ZOOM,
|
||||
DEFAULT_SSH_DEBUG_LOGS_ENABLED,
|
||||
DEFAULT_SSH_DEEP_LINK_ENABLED,
|
||||
@@ -413,6 +415,10 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
|
||||
return stored ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS;
|
||||
});
|
||||
const [showTabNumberBadges, setShowTabNumberBadgesState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_TAB_NUMBER_BADGES);
|
||||
return stored ?? DEFAULT_SHOW_TAB_NUMBER_BADGES;
|
||||
});
|
||||
const [disableTerminalFontZoom, setDisableTerminalFontZoomState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM);
|
||||
return stored ?? DEFAULT_DISABLE_TERMINAL_FONT_ZOOM;
|
||||
@@ -756,7 +762,10 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
|
||||
// Push idle TTL to the main-process transport registry on load and whenever
|
||||
// it changes (including cross-window IPC/storage updates to local state).
|
||||
const lastPushedSshTransportIdleTtlRef = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
if (lastPushedSshTransportIdleTtlRef.current === sshTransportIdleTtlMs) return;
|
||||
lastPushedSshTransportIdleTtlRef.current = sshTransportIdleTtlMs;
|
||||
notifySettingsChanged(STORAGE_KEY_SSH_TRANSPORT_IDLE_TTL_MS, sshTransportIdleTtlMs);
|
||||
}, [sshTransportIdleTtlMs, notifySettingsChanged]);
|
||||
|
||||
@@ -938,6 +947,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
);
|
||||
const storedShellOnlyTabNumberShortcuts = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
|
||||
setShellOnlyTabNumberShortcutsState(storedShellOnlyTabNumberShortcuts ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
|
||||
const storedShowTabNumberBadges = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_TAB_NUMBER_BADGES);
|
||||
setShowTabNumberBadgesState(storedShowTabNumberBadges ?? DEFAULT_SHOW_TAB_NUMBER_BADGES);
|
||||
const storedDisableTerminalFontZoom = localStorageAdapter.readBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM);
|
||||
setDisableTerminalFontZoomState(storedDisableTerminalFontZoom ?? DEFAULT_DISABLE_TERMINAL_FONT_ZOOM);
|
||||
const storedRestorePreviousSession = localStorageAdapter.readBoolean(STORAGE_KEY_RESTORE_PREVIOUS_SESSION);
|
||||
@@ -1142,7 +1153,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode,
|
||||
@@ -1152,7 +1163,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize: applyIncomingTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled, setJmsDeepLinkEnabledState: applyIncomingJmsDeepLinkEnabled, setExplorerContextMenuEnabledState: applyIncomingExplorerContextMenuEnabled,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity: applyIncomingWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
|
||||
@@ -1318,6 +1329,13 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
notifySettingsChanged(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setShowTabNumberBadges = useCallback((enabled: boolean) => {
|
||||
setShowTabNumberBadgesState(enabled);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_TAB_NUMBER_BADGES, enabled);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SHOW_TAB_NUMBER_BADGES, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setDisableTerminalFontZoom = useCallback((enabled: boolean) => {
|
||||
setDisableTerminalFontZoomState(enabled);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM, enabled);
|
||||
@@ -1642,11 +1660,13 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
notifySettingsChanged,
|
||||
});
|
||||
|
||||
// Fix 1: Mark all persist effects as mounted.
|
||||
// This MUST be declared AFTER all persist useEffects so that React runs it last
|
||||
// during the initial mount cycle (effects fire in declaration order).
|
||||
// Mark persist effects mounted AFTER all persist useEffects so React runs
|
||||
// this last (declaration order). Cleanup clears the latch for remount.
|
||||
useEffect(() => {
|
||||
persistMountedRef.current = true;
|
||||
return () => {
|
||||
persistMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Get merged key bindings (defaults + custom overrides)
|
||||
@@ -1741,6 +1761,9 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
showOnlyUngroupedHostsInRoot,
|
||||
dynamicTabTitleMode: terminalSettings.dynamicTabTitleMode,
|
||||
disableTerminalFontZoom,
|
||||
hotkeyScheme,
|
||||
shellOnlyTabNumberShortcuts,
|
||||
showTabNumberBadges,
|
||||
restoreTerminalCwd,
|
||||
terminalSidePanelAutoOpen,
|
||||
terminalSidePanelAutoOpenTab,
|
||||
@@ -1749,13 +1772,16 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
darkUiThemeId,
|
||||
disableTerminalFontZoom,
|
||||
hostClickBehavior,
|
||||
hotkeyScheme,
|
||||
lightUiThemeId,
|
||||
resolvedTheme,
|
||||
restoreTerminalCwd,
|
||||
shellOnlyTabNumberShortcuts,
|
||||
showHostTreeSidebar,
|
||||
showOnlyUngroupedHostsInRoot,
|
||||
showRecentHosts,
|
||||
showSftpTab,
|
||||
showTabNumberBadges,
|
||||
terminalSettings.dynamicTabTitleMode,
|
||||
terminalSidePanelAutoOpen,
|
||||
terminalSidePanelAutoOpenTab,
|
||||
@@ -1930,6 +1956,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setTerminalSidePanelAutoOpenTab,
|
||||
shellOnlyTabNumberShortcuts,
|
||||
setShellOnlyTabNumberShortcuts,
|
||||
showTabNumberBadges,
|
||||
setShowTabNumberBadges,
|
||||
disableTerminalFontZoom,
|
||||
setDisableTerminalFontZoom,
|
||||
restorePreviousSession,
|
||||
@@ -1989,7 +2017,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
||||
customKeyBindings, editorWordWrap,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom,
|
||||
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
]),
|
||||
};
|
||||
|
||||
@@ -643,10 +643,11 @@ export const useSftpState = (
|
||||
[resolveTransferConflict, resolveUploadConflict, uploadConflicts],
|
||||
);
|
||||
|
||||
// FileZilla-style: when the browser UI is hidden, soft-close browse SFTP
|
||||
// channels. Defer park while this owner still has unfinished transfers so
|
||||
// pre-lease prep (conflict/stat) cannot race a hard-close of the browse id.
|
||||
// In-flight streams also soft-close via leases; pool handles bulk I/O.
|
||||
// FileZilla-style: when the side panel is closed (not merely showing another
|
||||
// tool), soft-close browse SFTP channels. Defer park while this owner still
|
||||
// has unfinished transfers so pre-lease prep (conflict/stat) cannot race a
|
||||
// hard-close of the browse id. In-flight streams also soft-close via leases;
|
||||
// pool handles bulk I/O.
|
||||
const interactive = options?.interactive !== false;
|
||||
useEffect(() => {
|
||||
const gen = ++browseLifecycleGenRef.current;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
getShortcutModifierRequirements,
|
||||
isShortcutModifierHeld,
|
||||
shouldReleaseShortcutModifier,
|
||||
type ShortcutModifierEvent,
|
||||
} from './useShortcutModifierHeld.ts';
|
||||
|
||||
const event = (overrides: Partial<ShortcutModifierEvent> = {}): ShortcutModifierEvent => ({
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('shortcut modifier requirements follow the effective Mac or PC binding', () => {
|
||||
const mac = getShortcutModifierRequirements('⌘ + [1...9]', 'mac');
|
||||
assert.deepEqual(mac, { metaKey: true, ctrlKey: false, altKey: false, shiftKey: false });
|
||||
assert.equal(isShortcutModifierHeld(event({ metaKey: true }), mac), true);
|
||||
assert.equal(isShortcutModifierHeld(event({ ctrlKey: true }), mac), false);
|
||||
|
||||
const pc = getShortcutModifierRequirements('Alt + Shift + [1...9]', 'pc');
|
||||
assert.deepEqual(pc, { metaKey: false, ctrlKey: false, altKey: true, shiftKey: true });
|
||||
assert.equal(isShortcutModifierHeld(event({ altKey: true, shiftKey: true }), pc), true);
|
||||
assert.equal(isShortcutModifierHeld(event({ altKey: true }), pc), false);
|
||||
assert.equal(isShortcutModifierHeld(event({ altKey: true, metaKey: true, shiftKey: true }), pc), false);
|
||||
});
|
||||
|
||||
test('shortcut number preview is unavailable for disabled, non-range, or modifier-free bindings', () => {
|
||||
assert.equal(getShortcutModifierRequirements('Disabled', 'mac'), null);
|
||||
assert.equal(getShortcutModifierRequirements('⌘ + K', 'mac'), null);
|
||||
assert.equal(getShortcutModifierRequirements('[1...9]', 'mac'), null);
|
||||
assert.equal(getShortcutModifierRequirements('⌘ + [1...9]', 'disabled'), null);
|
||||
});
|
||||
|
||||
test('shortcut modifier state clears when the required combination stops matching', () => {
|
||||
const requirements = getShortcutModifierRequirements('⌘ + [1...9]', 'mac');
|
||||
assert.equal(shouldReleaseShortcutModifier(event({ metaKey: true }), requirements), false);
|
||||
assert.equal(shouldReleaseShortcutModifier(event(), requirements), true);
|
||||
});
|
||||
|
||||
test('shortcut modifier held state tracks exact combo when extras are pressed or released', () => {
|
||||
const requirements = getShortcutModifierRequirements('⌘ + [1...9]', 'mac');
|
||||
// Already matching, then an extra modifier: preview must clear.
|
||||
assert.equal(isShortcutModifierHeld(event({ metaKey: true }), requirements), true);
|
||||
assert.equal(
|
||||
isShortcutModifierHeld(event({ metaKey: true, shiftKey: true }), requirements),
|
||||
false,
|
||||
);
|
||||
// Extra pressed first, then released while required modifiers remain: preview re-arms.
|
||||
assert.equal(
|
||||
isShortcutModifierHeld(event({ metaKey: true, altKey: true }), requirements),
|
||||
false,
|
||||
);
|
||||
assert.equal(isShortcutModifierHeld(event({ metaKey: true }), requirements), true);
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { parseKeyCombo, type HotkeyScheme } from '../../domain/models/keyBindings';
|
||||
|
||||
export type ShortcutModifierEvent = Pick<KeyboardEvent, 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey'>;
|
||||
|
||||
export type ShortcutModifierRequirements = Readonly<ShortcutModifierEvent>;
|
||||
|
||||
const MODIFIER_TOKENS = {
|
||||
mac: {
|
||||
'⌘': 'metaKey',
|
||||
'⌃': 'ctrlKey',
|
||||
'⌥': 'altKey',
|
||||
Shift: 'shiftKey',
|
||||
},
|
||||
pc: {
|
||||
Ctrl: 'ctrlKey',
|
||||
Alt: 'altKey',
|
||||
Shift: 'shiftKey',
|
||||
Win: 'metaKey',
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Resolve the modifier combination for the effective [1...9] binding.
|
||||
* A non-range or disabled binding cannot reveal tab numbers because the
|
||||
* dispatcher does not use it for the number-switch action.
|
||||
*/
|
||||
export function getShortcutModifierRequirements(
|
||||
keyBinding: string | null,
|
||||
scheme: HotkeyScheme,
|
||||
): ShortcutModifierRequirements | null {
|
||||
if (scheme === 'disabled' || !keyBinding || !keyBinding.includes('[1...9]')) return null;
|
||||
const parsed = parseKeyCombo(keyBinding);
|
||||
if (!parsed || parsed.modifiers.length === 0) return null;
|
||||
|
||||
const requirements: Record<keyof ShortcutModifierEvent, boolean> = {
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
};
|
||||
const tokenMap = MODIFIER_TOKENS[scheme];
|
||||
for (const modifier of parsed.modifiers) {
|
||||
const flag = tokenMap[modifier as keyof typeof tokenMap];
|
||||
if (!flag) return null;
|
||||
requirements[flag] = true;
|
||||
}
|
||||
|
||||
return requirements;
|
||||
}
|
||||
|
||||
/** Whether a keyboard event has exactly the modifiers required by the binding. */
|
||||
export function isShortcutModifierHeld(
|
||||
event: ShortcutModifierEvent,
|
||||
requirements: ShortcutModifierRequirements | null,
|
||||
): boolean {
|
||||
if (!requirements) return false;
|
||||
return event.metaKey === requirements.metaKey
|
||||
&& event.ctrlKey === requirements.ctrlKey
|
||||
&& event.altKey === requirements.altKey
|
||||
&& event.shiftKey === requirements.shiftKey;
|
||||
}
|
||||
|
||||
/** Whether a keyup event means the required modifier combination is no longer held. */
|
||||
export function shouldReleaseShortcutModifier(
|
||||
event: ShortcutModifierEvent,
|
||||
requirements: ShortcutModifierRequirements | null,
|
||||
): boolean {
|
||||
return !isShortcutModifierHeld(event, requirements);
|
||||
}
|
||||
|
||||
export function useShortcutModifierHeld(
|
||||
keyBinding: string | null,
|
||||
scheme: HotkeyScheme,
|
||||
): boolean {
|
||||
const [isHeld, setIsHeld] = useState(false);
|
||||
const requirements = useMemo(
|
||||
() => getShortcutModifierRequirements(keyBinding, scheme),
|
||||
[keyBinding, scheme],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setIsHeld(false);
|
||||
if (!requirements) return;
|
||||
|
||||
// Sync on every modifier transition so extras clear the preview and
|
||||
// releasing an extra modifier can re-arm when the exact combo remains.
|
||||
const syncHeldState = (event: KeyboardEvent) => {
|
||||
setIsHeld(isShortcutModifierHeld(event, requirements));
|
||||
};
|
||||
const clearHeldState = () => setIsHeld(false);
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
clearHeldState();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', syncHeldState, true);
|
||||
window.addEventListener('keyup', syncHeldState, true);
|
||||
window.addEventListener('blur', clearHeldState);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', syncHeldState, true);
|
||||
window.removeEventListener('keyup', syncHeldState, true);
|
||||
window.removeEventListener('blur', clearHeldState);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [requirements]);
|
||||
|
||||
return isHeld;
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { DockerContainerAction, DockerImageManageAction, TmuxManageAction } from '../../domain/systemManager/types';
|
||||
import type {
|
||||
DockerContainerAction,
|
||||
DockerImageManageAction,
|
||||
SystemdUnitAction,
|
||||
SystemdUnitInfo,
|
||||
TmuxManageAction,
|
||||
} from '../../domain/systemManager/types';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export function useSystemManagerBackend() {
|
||||
@@ -120,6 +126,35 @@ export function useSystemManagerBackend() {
|
||||
return bridge.listAccelerators(sessionId);
|
||||
}, []);
|
||||
|
||||
const listListeningPorts = useCallback(async (sessionId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.listListeningPorts) {
|
||||
return { success: false as const, error: 'listListeningPorts unavailable' };
|
||||
}
|
||||
return bridge.listListeningPorts(sessionId);
|
||||
}, []);
|
||||
|
||||
const listSystemServices = useCallback(async (sessionId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.listSystemServices) {
|
||||
return { success: false as const, error: 'listSystemServices unavailable' };
|
||||
}
|
||||
return bridge.listSystemServices(sessionId);
|
||||
}, []);
|
||||
|
||||
const systemServiceAction = useCallback(async (options: {
|
||||
sessionId: string;
|
||||
unitName: string;
|
||||
action: SystemdUnitAction;
|
||||
scope?: SystemdUnitInfo['scope'];
|
||||
}) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.systemServiceAction) {
|
||||
return { success: false as const, error: 'systemServiceAction unavailable' };
|
||||
}
|
||||
return bridge.systemServiceAction(options);
|
||||
}, []);
|
||||
|
||||
const dockerInspect = useCallback(async (options: { sessionId: string; containerId: string }) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.dockerInspect) {
|
||||
@@ -181,6 +216,9 @@ export function useSystemManagerBackend() {
|
||||
listDockerImages,
|
||||
getDockerStats,
|
||||
listAccelerators,
|
||||
listListeningPorts,
|
||||
listSystemServices,
|
||||
systemServiceAction,
|
||||
dockerInspect,
|
||||
dockerImageInspect,
|
||||
dockerAction,
|
||||
@@ -200,6 +238,9 @@ export function useSystemManagerBackend() {
|
||||
listDockerImages,
|
||||
getDockerStats,
|
||||
listAccelerators,
|
||||
listListeningPorts,
|
||||
listSystemServices,
|
||||
systemServiceAction,
|
||||
dockerInspect,
|
||||
dockerImageInspect,
|
||||
dockerAction,
|
||||
|
||||
@@ -59,6 +59,7 @@ export function useTerminalAiContexts({
|
||||
.map((sessionId) => sessionById.get(sessionId)?.hostId)
|
||||
.filter((hostId): hostId is string => !!hostId),
|
||||
scopeLabel: workspace.title,
|
||||
focusedSessionId: workspace.focusedSessionId,
|
||||
terminalSessions: sessionIds.map((sessionId) =>
|
||||
buildAITerminalSessionInfo(
|
||||
sessionById.get(sessionId),
|
||||
|
||||
@@ -513,7 +513,10 @@ export const useTerminalBackend = () => {
|
||||
return bridge?.onZmodemEvent?.(sessionId, cb) ?? (() => {});
|
||||
}, []);
|
||||
|
||||
const getSessionPwd = useCallback(async (sessionId: string, options?: { allowHomeFallback?: boolean }) => {
|
||||
const getSessionPwd = useCallback(async (
|
||||
sessionId: string,
|
||||
options?: { allowHomeFallback?: boolean; allowLoginShellFallback?: boolean },
|
||||
) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getSessionPwd) return { success: false, error: 'getSessionPwd unavailable' };
|
||||
return bridge.getSessionPwd(sessionId, options);
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
installTerminalKeyboardFocusTracking,
|
||||
isTerminalKeyboardTarget,
|
||||
} from "./terminalKeyboardFocus";
|
||||
|
||||
const targetWithClosest = (matches: boolean) => ({
|
||||
closest: (selector: string) => {
|
||||
assert.match(selector, /\.xterm/);
|
||||
return matches ? {} : null;
|
||||
},
|
||||
}) as unknown as EventTarget;
|
||||
|
||||
test("terminal keyboard target detection recognizes xterm descendants", () => {
|
||||
assert.equal(isTerminalKeyboardTarget(targetWithClosest(true)), true);
|
||||
assert.equal(isTerminalKeyboardTarget(targetWithClosest(false)), false);
|
||||
assert.equal(isTerminalKeyboardTarget(null), false);
|
||||
});
|
||||
|
||||
test("terminal keyboard focus tracking publishes deduplicated focus transitions and cleans up", async () => {
|
||||
const listeners = new Map<string, (event?: Event) => void>();
|
||||
const removed: string[] = [];
|
||||
const body = targetWithClosest(false);
|
||||
const terminal = targetWithClosest(true);
|
||||
const documentRef = {
|
||||
activeElement: body as unknown as Element,
|
||||
addEventListener: (type: string, listener: (event?: Event) => void) => {
|
||||
listeners.set(type, listener);
|
||||
},
|
||||
removeEventListener: (type: string) => {
|
||||
removed.push(type);
|
||||
},
|
||||
} as unknown as Document;
|
||||
const windowRef = {
|
||||
addEventListener: (type: string, listener: (event?: Event) => void) => {
|
||||
listeners.set(`window:${type}`, listener);
|
||||
},
|
||||
removeEventListener: (type: string) => {
|
||||
removed.push(`window:${type}`);
|
||||
},
|
||||
} as unknown as Window;
|
||||
const changes: boolean[] = [];
|
||||
const cleanup = installTerminalKeyboardFocusTracking(documentRef, windowRef, (focused) => {
|
||||
if (changes.at(-1) !== focused) changes.push(focused);
|
||||
});
|
||||
|
||||
assert.deepEqual(changes, [false]);
|
||||
|
||||
listeners.get("pointerdown")?.({ target: terminal } as unknown as Event);
|
||||
listeners.get("focusin")?.({ target: terminal } as unknown as Event);
|
||||
assert.deepEqual(changes, [false, true]);
|
||||
|
||||
(documentRef as unknown as { activeElement: Element }).activeElement = terminal as unknown as Element;
|
||||
listeners.get("pointerdown")?.({ target: body } as unknown as Event);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(changes, [false, true]);
|
||||
|
||||
(documentRef as unknown as { activeElement: Element }).activeElement = body as unknown as Element;
|
||||
listeners.get("focusout")?.();
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(changes, [false, true, false]);
|
||||
|
||||
listeners.get("window:blur")?.();
|
||||
assert.deepEqual(changes, [false, true, false]);
|
||||
|
||||
(documentRef as unknown as { activeElement: Element }).activeElement = terminal as unknown as Element;
|
||||
listeners.get("window:focus")?.();
|
||||
assert.deepEqual(changes, [false, true, false, true]);
|
||||
|
||||
cleanup();
|
||||
assert.deepEqual(removed.sort(), ["focusin", "focusout", "pointerdown", "window:blur", "window:focus"]);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useEffect } from "react";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
import { installTerminalKeyboardFocusTracking } from "./terminalKeyboardFocus";
|
||||
|
||||
export function useTerminalKeyboardFocus(enabled = true): void {
|
||||
useEffect(() => {
|
||||
if (!enabled) return undefined;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.setTerminalKeyboardFocus) return undefined;
|
||||
|
||||
let lastPublished: boolean | undefined;
|
||||
const publish = (focused: boolean) => {
|
||||
if (focused === lastPublished) return;
|
||||
lastPublished = focused;
|
||||
try {
|
||||
bridge.setTerminalKeyboardFocus?.(focused);
|
||||
} catch {
|
||||
// Browser preview or a disposed Electron bridge.
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupTracking = installTerminalKeyboardFocusTracking(
|
||||
document,
|
||||
window,
|
||||
publish,
|
||||
);
|
||||
|
||||
return () => {
|
||||
cleanupTracking();
|
||||
publish(false);
|
||||
};
|
||||
}, [enabled]);
|
||||
}
|
||||
@@ -123,6 +123,7 @@ export function useTerminalSidePanelLayoutState() {
|
||||
setSidePanelOpenTabs,
|
||||
sidePanelOpenTabsRef,
|
||||
sidePanelLayouts,
|
||||
setSidePanelLayouts,
|
||||
sidePanelLayoutsRef,
|
||||
focusPane,
|
||||
splitPane,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const runtimeSource = readFileSync(new URL('./useThemeRuntime.ts', import.meta.url), 'utf8');
|
||||
const terminalHostSource = readFileSync(
|
||||
new URL('../app/hosts/TerminalHost.tsx', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
test('published currentTerminalTheme resolves without live custom accent', () => {
|
||||
// Base global appearance must use accentMode: 'theme' so HSL drag does not
|
||||
// mint a new theme object into TerminalHost / AppShell domain bags.
|
||||
assert.match(
|
||||
runtimeSource,
|
||||
/accentMode: 'theme',\s*\n\s*customAccent: '',/,
|
||||
'globalAppearance must ignore live custom accent',
|
||||
);
|
||||
assert.match(runtimeSource, /accentedGlobalAppearance/);
|
||||
assert.match(
|
||||
runtimeSource,
|
||||
/currentTerminalTheme: globalAppearance\.theme/,
|
||||
'published theme must come from the stable base appearance',
|
||||
);
|
||||
});
|
||||
|
||||
test('TerminalHost injects accented appearance without bridging it', () => {
|
||||
assert.match(
|
||||
terminalHostSource,
|
||||
/useTerminalAppearanceInjection\(accentedGlobalAppearance/,
|
||||
'CSS vars still track live accent during drag',
|
||||
);
|
||||
const bridgeStart = terminalHostSource.indexOf('const themeBridgeActions = useMemo');
|
||||
assert.notEqual(bridgeStart, -1);
|
||||
const bridge = terminalHostSource.slice(bridgeStart, bridgeStart + 900);
|
||||
assert.match(bridge, /globalAppearance,/);
|
||||
assert.doesNotMatch(
|
||||
bridge,
|
||||
/accentedGlobalAppearance/,
|
||||
'theme bridge must not republish on accented appearance identity churn',
|
||||
);
|
||||
});
|
||||
@@ -65,8 +65,10 @@ export function useThemeRuntime(settings: ThemeRuntimeSettings) {
|
||||
const accentRef = useRef({ accentMode, customAccent });
|
||||
accentRef.current = { accentMode, customAccent };
|
||||
|
||||
// Chrome / injection consumers need live accent on every settings change.
|
||||
const appearanceSettings = useMemo((): TerminalAppearanceSettings => ({
|
||||
// Live accent settings are only for CSS-var injection. The published bag must
|
||||
// resolve against a stable base theme so accent drag does not rebuild
|
||||
// TerminalHost / AppShell domains every HSL tick.
|
||||
const appearanceSettingsForInject = useMemo((): TerminalAppearanceSettings => ({
|
||||
...appearanceSettingsBase,
|
||||
accentMode,
|
||||
customAccent,
|
||||
@@ -74,9 +76,19 @@ export function useThemeRuntime(settings: ThemeRuntimeSettings) {
|
||||
|
||||
const globalAppearance = useMemo(() => resolveGlobalTerminalAppearance({
|
||||
userIntent,
|
||||
settings: appearanceSettings,
|
||||
settings: {
|
||||
...appearanceSettingsBase,
|
||||
accentMode: 'theme',
|
||||
customAccent: '',
|
||||
},
|
||||
customThemes,
|
||||
}), [userIntent, appearanceSettings, customThemes]);
|
||||
}), [userIntent, appearanceSettingsBase, customThemes]);
|
||||
|
||||
const accentedGlobalAppearance = useMemo(() => resolveGlobalTerminalAppearance({
|
||||
userIntent,
|
||||
settings: appearanceSettingsForInject,
|
||||
customThemes,
|
||||
}), [userIntent, appearanceSettingsForInject, customThemes]);
|
||||
|
||||
// Read accent from a ref so this callback identity does not churn on drag.
|
||||
// Callers that invoke it after an accent-only update still get the latest
|
||||
@@ -125,10 +137,12 @@ export function useThemeRuntime(settings: ThemeRuntimeSettings) {
|
||||
}, []);
|
||||
|
||||
// Stable bag identity so App domain memos can depend on members (or the
|
||||
// bag) without thrashing on every parent render.
|
||||
// bag) without thrashing on every parent render. Accented appearance is
|
||||
// intentionally excluded — injection reads it separately.
|
||||
return useMemo(() => ({
|
||||
userIntent,
|
||||
globalAppearance,
|
||||
accentedGlobalAppearance,
|
||||
resolveFocusedAppearance,
|
||||
pickTheme,
|
||||
clearIntent,
|
||||
@@ -137,6 +151,7 @@ export function useThemeRuntime(settings: ThemeRuntimeSettings) {
|
||||
}), [
|
||||
userIntent,
|
||||
globalAppearance,
|
||||
accentedGlobalAppearance,
|
||||
resolveFocusedAppearance,
|
||||
pickTheme,
|
||||
clearIntent,
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const source = readFileSync(new URL("./useVaultState.ts", import.meta.url), "utf8");
|
||||
|
||||
test("deleteSelectedSnippets merges into persisted vault under the shared lock", () => {
|
||||
assert.match(source, /const deleteSelectedSnippets = useCallback\(async/);
|
||||
assert.match(
|
||||
source,
|
||||
/deleteSelectedSnippets[\s\S]*withVaultImportLock\("vault"/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/deleteSelectedSnippetsFromVault\(\s*latestSnippets\s*,\s*latestHosts\s*,\s*selectedSnippetIds,?\s*\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/deleteSelectedSnippets[\s\S]*commitPluginImporterTransaction\(localStorageAdapter, \[[\s\S]*STORAGE_KEY_HOSTS[\s\S]*STORAGE_KEY_SNIPPETS/,
|
||||
);
|
||||
// Persistence rejection must be caught: callers void the promise after the
|
||||
// confirm dialog closes, so an uncaught throw would leave no retry feedback.
|
||||
assert.match(
|
||||
source,
|
||||
/deleteSelectedSnippets[\s\S]*try \{\s*commitPluginImporterTransaction[\s\S]*catch \{[\s\S]*notify\.error\([\s\S]*Snippets could not be deleted/,
|
||||
);
|
||||
// Must not rebuild hosts from a per-window in-memory snapshot (popup race).
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/deleteSelectedSnippetsFromVault\(\s*snippetsRef\.current\s*,\s*hostsRef\.current/,
|
||||
);
|
||||
// Paired writes must not publish in-memory state if only one key lands.
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/deleteSelectedSnippets[\s\S]*localStorageAdapter\.write\(STORAGE_KEY_HOSTS, encryptedHosts\)[\s\S]*localStorageAdapter\.write\(STORAGE_KEY_SNIPPETS/,
|
||||
);
|
||||
});
|
||||
|
||||
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\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/snippetsWritePendingRef\.current = writePromise/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/waitForPendingVaultWrites[\s\S]*snippetsWritePendingRef\.current/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/const updateSnippets = useCallback\(\([\s\S]*?\) => \{[\s\S]*setSnippets\(cleaned\);\s*localStorageAdapter\.write\(STORAGE_KEY_SNIPPETS, cleaned\);\s*\}, \[\]\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test("updateSnippets rebases onto the latest persisted snapshot under the lock", () => {
|
||||
// Web Locks serialize writers but do not merge snapshots. A popup bulk-delete
|
||||
// that lands while a main-window save is queued must not be resurrected.
|
||||
assert.match(
|
||||
source,
|
||||
/rebaseSnippetVaultWrite\(\{\s*base,\s*ours: cleaned,\s*theirs:/,
|
||||
);
|
||||
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\);/,
|
||||
);
|
||||
});
|
||||
@@ -64,6 +64,11 @@ import {
|
||||
type ConnectionLogTerminalDataMap,
|
||||
} from "../../domain/connectionLogTerminalData";
|
||||
import { getNextVaultOrder, normalizeVaultOrder } from "../../domain/vaultOrder";
|
||||
import {
|
||||
deleteSelectedSnippetsFromVault,
|
||||
pruneHostsStaleSnippetBindings,
|
||||
rebaseSnippetVaultWrite,
|
||||
} from "../../domain/snippetSelection";
|
||||
import { loadSanitizedShellHistory } from "./shellHistoryPersistence";
|
||||
import {
|
||||
publishConnectionLogsSnapshot,
|
||||
@@ -290,12 +295,14 @@ export const useVaultState = () => {
|
||||
const customGroupsRef = useRef<string[]>([]);
|
||||
const managedSourcesRef = useRef<ManagedSource[]>([]);
|
||||
const hostsRef = useRef<Host[]>([]);
|
||||
const snippetsRef = useRef<Snippet[]>([]);
|
||||
const notesRef = useRef<VaultNote[]>([]);
|
||||
const noteGroupsRef = useRef<string[]>([]);
|
||||
const notesPersistFailureNotifiedAtRef = useRef(0);
|
||||
customGroupsRef.current = customGroups;
|
||||
managedSourcesRef.current = managedSources;
|
||||
hostsRef.current = hosts;
|
||||
snippetsRef.current = snippets;
|
||||
notesRef.current = notes;
|
||||
noteGroupsRef.current = noteGroups;
|
||||
|
||||
@@ -307,6 +314,18 @@ export const useVaultState = () => {
|
||||
const identitiesWriteVersion = useRef(0);
|
||||
const proxyProfilesWriteVersion = useRef(0);
|
||||
const snippetsWriteVersion = useRef(0);
|
||||
// Tracks the latest local updateSnippets schedule. Storage events also bump
|
||||
// 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.
|
||||
@@ -320,6 +339,7 @@ export const useVaultState = () => {
|
||||
const keysWritePendingRef = useRef<Promise<unknown>>(Promise.resolve());
|
||||
const identitiesWritePendingRef = useRef<Promise<unknown>>(Promise.resolve());
|
||||
const groupConfigsWritePendingRef = useRef<Promise<unknown>>(Promise.resolve());
|
||||
const snippetsWritePendingRef = useRef<Promise<unknown>>(Promise.resolve());
|
||||
|
||||
const waitForPendingVaultWrites = useCallback(async () => {
|
||||
while (true) {
|
||||
@@ -344,6 +364,7 @@ export const useVaultState = () => {
|
||||
keysWritePendingRef.current,
|
||||
identitiesWritePendingRef.current,
|
||||
groupConfigsWritePendingRef.current,
|
||||
snippetsWritePendingRef.current,
|
||||
];
|
||||
await Promise.all(writePending);
|
||||
if (
|
||||
@@ -351,6 +372,7 @@ export const useVaultState = () => {
|
||||
&& writePending[1] === keysWritePendingRef.current
|
||||
&& writePending[2] === identitiesWritePendingRef.current
|
||||
&& writePending[3] === groupConfigsWritePendingRef.current
|
||||
&& writePending[4] === snippetsWritePendingRef.current
|
||||
&& encryptPending[0] === hostsEncryptPendingRef.current
|
||||
&& encryptPending[1] === keysEncryptPendingRef.current
|
||||
&& encryptPending[2] === identitiesEncryptPendingRef.current
|
||||
@@ -451,6 +473,37 @@ 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
|
||||
@@ -483,14 +536,11 @@ export const useVaultState = () => {
|
||||
hostsEncryptPendingRef.current = encryptPromise.then(() => undefined);
|
||||
const writePromise = encryptPromise.then(async (enc) => {
|
||||
if (ver !== hostsWriteVersion.current) return "superseded" as const;
|
||||
return withVaultImportLock("vault", async () => {
|
||||
if (ver !== hostsWriteVersion.current) return "superseded" as const;
|
||||
return localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||||
});
|
||||
return commitEncryptedHostsUnderVaultLock(ver, cleaned, 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
|
||||
@@ -592,13 +642,166 @@ export const useVaultState = () => {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateSnippets = useCallback((data: Snippet[]) => {
|
||||
const updateSnippets = useCallback((
|
||||
data: Snippet[],
|
||||
options?: { replace?: boolean },
|
||||
) => {
|
||||
// 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 cleaned = normalizeVaultOrder(data);
|
||||
++snippetsWriteVersion.current;
|
||||
const ver = ++snippetsWriteVersion.current;
|
||||
snippetsWriteOwnerRef.current = ver;
|
||||
// Keep live snapshot ahead of React commit so same-tick readers (delete,
|
||||
// agent bridge) do not observe a stale pre-write array.
|
||||
snippetsRef.current = cleaned;
|
||||
setSnippets(cleaned);
|
||||
localStorageAdapter.write(STORAGE_KEY_SNIPPETS, cleaned);
|
||||
// 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.
|
||||
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 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;
|
||||
if (snippetsWriteOwnerRef.current === ver) {
|
||||
snippetsRef.current = rebased;
|
||||
setSnippets(rebased);
|
||||
}
|
||||
return "written" as const;
|
||||
});
|
||||
snippetsWritePendingRef.current = writePromise;
|
||||
return writePromise;
|
||||
}, []);
|
||||
|
||||
// Cross-window safe: merge binding cleanup into the latest persisted
|
||||
// hosts/snippets under the shared vault lock. Popup terminals own a separate
|
||||
// useVaultState instance — writing hostsRef from that window can discard a
|
||||
// main-window host edit, or a storage-event version bump can cancel the host
|
||||
// write after snippets were already removed.
|
||||
const deleteSelectedSnippets = useCallback(async (selectedSnippetIds: ReadonlySet<string>) => {
|
||||
if (selectedSnippetIds.size === 0) {
|
||||
return {
|
||||
snippets: snippetsRef.current,
|
||||
hosts: hostsRef.current,
|
||||
deletedCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
while (true) {
|
||||
await waitForPendingVaultWrites();
|
||||
const attempt = await withVaultImportLock("vault", async () => {
|
||||
const writeVersions = {
|
||||
hosts: hostsWriteVersion.current,
|
||||
snippets: snippetsWriteVersion.current,
|
||||
};
|
||||
const rawHosts = localStorageAdapter.readString(STORAGE_KEY_HOSTS);
|
||||
const rawSnippets = localStorageAdapter.readString(STORAGE_KEY_SNIPPETS);
|
||||
const storedHosts = readStoredArray<Host>(STORAGE_KEY_HOSTS, rawHosts);
|
||||
const storedSnippets = readStoredArray<Snippet>(STORAGE_KEY_SNIPPETS, rawSnippets);
|
||||
const latestHosts = normalizeVaultOrder(
|
||||
(await decryptHosts(storedHosts)).map((host) => sanitizeHost(host)),
|
||||
);
|
||||
const latestSnippets = normalizeVaultOrder(storedSnippets);
|
||||
const result = deleteSelectedSnippetsFromVault(
|
||||
latestSnippets,
|
||||
latestHosts,
|
||||
selectedSnippetIds,
|
||||
);
|
||||
if (result.deletedCount === 0) return result;
|
||||
|
||||
const encryptedHosts = await encryptHosts(result.hosts);
|
||||
const changedWhilePreparing = (
|
||||
writeVersions.hosts !== hostsWriteVersion.current
|
||||
|| writeVersions.snippets !== snippetsWriteVersion.current
|
||||
|| localStorageAdapter.readString(STORAGE_KEY_HOSTS) !== rawHosts
|
||||
|| localStorageAdapter.readString(STORAGE_KEY_SNIPPETS) !== rawSnippets
|
||||
);
|
||||
if (changedWhilePreparing) return null;
|
||||
|
||||
++hostsWriteVersion.current;
|
||||
++snippetsWriteVersion.current;
|
||||
// Journaled pair write: a partial hosts/snippets persist would drop
|
||||
// login/connect bindings on restart while leaving the snippets behind.
|
||||
// Callers fire-and-forget this promise after closing the confirm dialog,
|
||||
// so storage rejection must not become an unhandled rejection.
|
||||
try {
|
||||
commitPluginImporterTransaction(localStorageAdapter, [
|
||||
[STORAGE_KEY_HOSTS, encryptedHosts],
|
||||
[STORAGE_KEY_SNIPPETS, result.snippets],
|
||||
]);
|
||||
} catch {
|
||||
notify.error(
|
||||
"Snippets could not be deleted. Free some local storage space and try again.",
|
||||
"Scripts",
|
||||
);
|
||||
return {
|
||||
snippets: latestSnippets,
|
||||
hosts: latestHosts,
|
||||
deletedCount: 0,
|
||||
};
|
||||
}
|
||||
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);
|
||||
snippetsWritePendingRef.current = Promise.resolve("unchanged" as const);
|
||||
return result;
|
||||
});
|
||||
if (attempt !== null) return attempt;
|
||||
}
|
||||
}, [waitForPendingVaultWrites]);
|
||||
|
||||
const updateSnippetPackages = useCallback((data: string[]) => {
|
||||
setSnippetPackages(data);
|
||||
localStorageAdapter.write(STORAGE_KEY_SNIPPET_PACKAGES, data);
|
||||
@@ -835,7 +1038,7 @@ export const useVaultState = () => {
|
||||
updateKeys([]);
|
||||
updateIdentities([]);
|
||||
updateProxyProfiles([]);
|
||||
updateSnippets([]);
|
||||
updateSnippets([], { replace: true });
|
||||
updateSnippetPackages([]);
|
||||
updateNotes([]);
|
||||
updateNoteGroups([]);
|
||||
@@ -1026,24 +1229,23 @@ export const useVaultState = () => {
|
||||
hostsEncryptPendingRef.current = encryptPromise.then(() => undefined);
|
||||
const writePromise = encryptPromise.then(async (enc) => {
|
||||
if (ver !== hostsWriteVersion.current) return;
|
||||
return withVaultImportLock("vault", async () => {
|
||||
if (ver === hostsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||||
});
|
||||
return commitEncryptedHostsUnderVaultLock(ver, updated, enc);
|
||||
});
|
||||
hostsWritePendingRef.current = writePromise;
|
||||
return updated;
|
||||
});
|
||||
|
||||
return newHost;
|
||||
}, [hosts]);
|
||||
}, [commitEncryptedHostsUnderVaultLock, hosts]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const init = async () => {
|
||||
try {
|
||||
await withVaultImportLock("vault", async () => {
|
||||
recoverPluginImporterTransaction(localStorageAdapter, PLUGIN_IMPORT_TRANSACTION_KEYS);
|
||||
});
|
||||
if (cancelled) return;
|
||||
const savedHosts = localStorageAdapter.read<Host[]>(STORAGE_KEY_HOSTS);
|
||||
|
||||
if (savedHosts) {
|
||||
@@ -1052,6 +1254,7 @@ export const useVaultState = () => {
|
||||
// and causes this stale result to be discarded.
|
||||
const ver = ++hostsWriteVersion.current;
|
||||
const decrypted = await decryptHosts(savedHosts);
|
||||
if (cancelled) return;
|
||||
if (ver === hostsWriteVersion.current) {
|
||||
const sanitized = normalizeVaultOrder(
|
||||
migrateHostsFromLegacyLineTimestamps(
|
||||
@@ -1059,15 +1262,21 @@ 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.
|
||||
encryptHosts(sanitized).then((enc) => {
|
||||
if (ver === hostsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||||
// 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);
|
||||
});
|
||||
hostsWritePendingRef.current = writePromise;
|
||||
}
|
||||
} else {
|
||||
updateHosts(INITIAL_HOSTS);
|
||||
@@ -1098,6 +1307,7 @@ export const useVaultState = () => {
|
||||
// Decrypt sensitive fields (passphrase, privateKey)
|
||||
const keyVer = ++keysWriteVersion.current;
|
||||
const decryptedKeys = await decryptKeys(migratedKeys);
|
||||
if (cancelled) return;
|
||||
if (keyVer === keysWriteVersion.current) {
|
||||
const orderedKeys = normalizeVaultOrder(decryptedKeys);
|
||||
setKeys(orderedKeys);
|
||||
@@ -1118,6 +1328,7 @@ export const useVaultState = () => {
|
||||
if (savedIdentities) {
|
||||
const idVer = ++identitiesWriteVersion.current;
|
||||
const decryptedIds = await decryptIdentities(savedIdentities);
|
||||
if (cancelled) return;
|
||||
if (idVer === identitiesWriteVersion.current) {
|
||||
const orderedIdentities = normalizeVaultOrder(decryptedIds);
|
||||
setIdentities(orderedIdentities);
|
||||
@@ -1133,6 +1344,7 @@ export const useVaultState = () => {
|
||||
if (savedProxyProfiles) {
|
||||
const proxyVer = ++proxyProfilesWriteVersion.current;
|
||||
const decryptedProfiles = await decryptProxyProfiles(savedProxyProfiles);
|
||||
if (cancelled) return;
|
||||
if (proxyVer === proxyProfilesWriteVersion.current) {
|
||||
const orderedProfiles = normalizeVaultOrder(decryptedProfiles);
|
||||
setProxyProfiles(orderedProfiles);
|
||||
@@ -1143,6 +1355,8 @@ export const useVaultState = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
// Read remaining non-encrypted data fresh after all async gaps above
|
||||
const savedGroups = localStorageAdapter.read<string[]>(STORAGE_KEY_GROUPS);
|
||||
const savedSnippets =
|
||||
@@ -1155,8 +1369,43 @@ export const useVaultState = () => {
|
||||
|
||||
if (savedSnippets) {
|
||||
const orderedSnippets = normalizeVaultOrder(savedSnippets);
|
||||
snippetsRef.current = orderedSnippets;
|
||||
setSnippets(orderedSnippets);
|
||||
localStorageAdapter.write(STORAGE_KEY_SNIPPETS, 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;
|
||||
}
|
||||
}
|
||||
else updateSnippets(INITIAL_SNIPPETS);
|
||||
|
||||
@@ -1219,6 +1468,7 @@ export const useVaultState = () => {
|
||||
if (savedGroupConfigs) {
|
||||
const gcVer = ++groupConfigsWriteVersion.current;
|
||||
const decryptedGC = await decryptGroupConfigs(savedGroupConfigs);
|
||||
if (cancelled) return;
|
||||
if (gcVer === groupConfigsWriteVersion.current) {
|
||||
const sanitizedGC = normalizeVaultOrder(decryptedGC.map(sanitizeGroupConfig));
|
||||
setGroupConfigs(sanitizedGC);
|
||||
@@ -1229,13 +1479,20 @@ export const useVaultState = () => {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIsInitialized(true);
|
||||
setVaultInitialized(true);
|
||||
// StrictMode remount cancels the first init; only the surviving effect
|
||||
// may publish "vault ready" or terminals can boot against empty keys.
|
||||
if (!cancelled) {
|
||||
setIsInitialized(true);
|
||||
setVaultInitialized(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
init();
|
||||
}, [updateHosts, updateSnippets]);
|
||||
void init();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [commitEncryptedHostsUnderVaultLock, updateHosts, updateSnippets]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInitialized) return;
|
||||
@@ -1340,9 +1597,44 @@ export const useVaultState = () => {
|
||||
}
|
||||
|
||||
if (key === STORAGE_KEY_SNIPPETS) {
|
||||
const next = safeParse<Snippet[]>(event.newValue) ?? [];
|
||||
// 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;
|
||||
setSnippets(normalizeVaultOrder(next));
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1457,15 +1749,12 @@ export const useVaultState = () => {
|
||||
hostsEncryptPendingRef.current = encryptPromise.then(() => undefined);
|
||||
const writePromise = encryptPromise.then(async (enc) => {
|
||||
if (ver !== hostsWriteVersion.current) return;
|
||||
return withVaultImportLock("vault", async () => {
|
||||
if (ver === hostsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||||
});
|
||||
return commitEncryptedHostsUnderVaultLock(ver, next, enc);
|
||||
});
|
||||
hostsWritePendingRef.current = writePromise;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
}, [commitEncryptedHostsUnderVaultLock]);
|
||||
|
||||
const updateHostDistro = useCallback((hostId: string, distro: string) => {
|
||||
const normalized = normalizeDistroId(distro);
|
||||
@@ -1478,15 +1767,12 @@ export const useVaultState = () => {
|
||||
hostsEncryptPendingRef.current = encryptPromise.then(() => undefined);
|
||||
const writePromise = encryptPromise.then(async (enc) => {
|
||||
if (ver !== hostsWriteVersion.current) return;
|
||||
return withVaultImportLock("vault", async () => {
|
||||
if (ver === hostsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||||
});
|
||||
return commitEncryptedHostsUnderVaultLock(ver, next, enc);
|
||||
});
|
||||
hostsWritePendingRef.current = writePromise;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
}, [commitEncryptedHostsUnderVaultLock]);
|
||||
|
||||
const exportData = useCallback(
|
||||
(): ExportableVaultData => ({
|
||||
@@ -1512,7 +1798,13 @@ export const useVaultState = () => {
|
||||
if (payload.keys) encryptedWrites.push(updateKeys(payload.keys));
|
||||
if (payload.identities) encryptedWrites.push(updateIdentities(payload.identities));
|
||||
if (Array.isArray(payload.proxyProfiles)) encryptedWrites.push(updateProxyProfiles(payload.proxyProfiles));
|
||||
if (payload.snippets) updateSnippets(payload.snippets);
|
||||
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),
|
||||
);
|
||||
}
|
||||
if (payload.customGroups) updateCustomGroups(payload.customGroups);
|
||||
if (payload.snippetPackages) updateSnippetPackages(payload.snippetPackages);
|
||||
if (payload.notes) updateNotes(payload.notes);
|
||||
@@ -1659,6 +1951,9 @@ 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);
|
||||
@@ -1704,6 +1999,7 @@ export const useVaultState = () => {
|
||||
updateIdentities,
|
||||
updateProxyProfiles,
|
||||
updateSnippets,
|
||||
deleteSelectedSnippets,
|
||||
updateSnippetPackages,
|
||||
updateNotes,
|
||||
updateNoteGroups,
|
||||
|
||||
@@ -6,6 +6,7 @@ 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;
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
type SyncPayload,
|
||||
} from '../domain/sync';
|
||||
import { migrateHostsFromLegacyLineTimestamps } from '../domain/host';
|
||||
import { toPersistedPortForwardingRules } from '../domain/portForwardingPersistence';
|
||||
import {
|
||||
nextCustomKeyBindingsSyncVersion,
|
||||
parseCustomKeyBindingsStorageRecord,
|
||||
@@ -86,6 +87,7 @@ import {
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
|
||||
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_AI_PROVIDERS,
|
||||
@@ -197,10 +199,10 @@ export function sanitizePortForwardingRulesForSync(
|
||||
rules: PortForwardingRule[] | undefined,
|
||||
): PortForwardingRule[] | undefined {
|
||||
if (!rules) return rules;
|
||||
return rules.map((rule) => ({
|
||||
// Runtime phases are never part of vault/cloud sync. lastUsedAt is also
|
||||
// stripped so runtime activity cannot churn sync hashes.
|
||||
return toPersistedPortForwardingRules(rules).map((rule) => ({
|
||||
...rule,
|
||||
status: 'inactive' as const,
|
||||
error: undefined,
|
||||
lastUsedAt: undefined,
|
||||
}));
|
||||
}
|
||||
@@ -297,6 +299,7 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_AI_PROVIDERS,
|
||||
STORAGE_KEY_AI_ACTIVE_PROVIDER,
|
||||
@@ -518,6 +521,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (showSftpTab != null) settings.showSftpTab = showSftpTab;
|
||||
const shellOnlyTabNumberShortcuts = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
|
||||
if (shellOnlyTabNumberShortcuts != null) settings.shellOnlyTabNumberShortcuts = shellOnlyTabNumberShortcuts;
|
||||
const showTabNumberBadges = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_TAB_NUMBER_BADGES);
|
||||
if (showTabNumberBadges != null) settings.showTabNumberBadges = showTabNumberBadges;
|
||||
const disableTerminalFontZoom = localStorageAdapter.readBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM);
|
||||
if (disableTerminalFontZoom != null) settings.disableTerminalFontZoom = disableTerminalFontZoom;
|
||||
const showHostTreeSidebar = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
|
||||
@@ -756,6 +761,9 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
|
||||
if (settings.shellOnlyTabNumberShortcuts != null) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, settings.shellOnlyTabNumberShortcuts);
|
||||
}
|
||||
if (settings.showTabNumberBadges != null) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_TAB_NUMBER_BADGES, settings.showTabNumberBadges);
|
||||
}
|
||||
if (settings.disableTerminalFontZoom != null) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM, settings.disableTerminalFontZoom);
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -252,3 +252,51 @@ test('AI side panel skips re-render when only a sibling scope session object cha
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('workspace AI panel memo follows visible inherited session, not hidden terminal maps', () => {
|
||||
const visibleMemberChat = session({
|
||||
id: 'chat-visible',
|
||||
scope: { type: 'terminal', targetId: 'terminal-b', hostIds: ['host-b'] },
|
||||
});
|
||||
const hiddenFocusedChat = session({
|
||||
id: 'chat-hidden',
|
||||
scope: { type: 'terminal', targetId: 'terminal-other', hostIds: ['host-other'] },
|
||||
});
|
||||
const prev = baseProps({
|
||||
isVisible: true,
|
||||
scopeType: 'workspace',
|
||||
scopeTargetId: 'ws-1',
|
||||
focusedSessionId: 'terminal-a',
|
||||
terminalSessions: [
|
||||
{
|
||||
sessionId: 'terminal-a',
|
||||
hostId: 'host-a',
|
||||
hostname: 'a.example',
|
||||
label: 'A',
|
||||
connected: true,
|
||||
},
|
||||
{
|
||||
sessionId: 'terminal-b',
|
||||
hostId: 'host-b',
|
||||
hostname: 'b.example',
|
||||
label: 'B',
|
||||
connected: true,
|
||||
},
|
||||
],
|
||||
activeSessionIdMap: {
|
||||
'terminal:terminal-a': 'chat-hidden',
|
||||
'terminal:terminal-b': 'chat-visible',
|
||||
},
|
||||
sessions: [visibleMemberChat, hiddenFocusedChat],
|
||||
});
|
||||
const streamedVisible = session({
|
||||
id: 'chat-visible',
|
||||
scope: { type: 'terminal', targetId: 'terminal-b', hostIds: ['host-b'] },
|
||||
messages: [{ id: 'm1', role: 'assistant', content: 'stream', timestamp: 2 }],
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
aiChatSidePanelPropsAreEqual(prev, { ...prev, sessions: [streamedVisible, hiddenFocusedChat] }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
+118
-22
@@ -1,6 +1,6 @@
|
||||
|
||||
|
||||
import React, { useCallback, useEffect, useDeferredValue, useMemo, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useWindowControls } from '../application/state/useWindowControls';
|
||||
@@ -30,13 +30,13 @@ import { subscribeUserSkillsStatusChanged } from './ai/userSkillsStatusEvents';
|
||||
import {
|
||||
applyDraftEntrySelection,
|
||||
applyHistorySessionSelection,
|
||||
panelViewsEqual,
|
||||
resolveDisplayedPanelView,
|
||||
resolveDisplayedSession,
|
||||
shouldForceDraftViewSync,
|
||||
} from './ai/aiPanelViewState';
|
||||
import {
|
||||
endDraftSend,
|
||||
tryBeginDraftSend,
|
||||
endSendForKey,
|
||||
tryBeginSendForKey,
|
||||
} from './ai/draftSendGate';
|
||||
import { selectDraftForAgentSwitch } from '../application/state/aiDraftState';
|
||||
import {
|
||||
@@ -51,11 +51,11 @@ 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';
|
||||
import { registerGrantPersister } from '../infrastructure/ai/shared/approvalGate';
|
||||
import { setupCodexAppServerInteractionBridge } from '../infrastructure/ai/shared/codexAppServerInteractions';
|
||||
import { stopAgentTurn } from '../infrastructure/ai/harness/agentStop';
|
||||
import { getAgentRuntime } from '../infrastructure/ai/harness/globalAgentRuntime';
|
||||
import { useAIPermissionGrantsState } from '../application/state/useAIPermissionGrantsState';
|
||||
@@ -287,6 +287,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
scopeTargetId,
|
||||
scopeHostIds,
|
||||
scopeLabel,
|
||||
focusedSessionId,
|
||||
terminalSessions = [],
|
||||
resolveExecutorContext,
|
||||
isVisible = true,
|
||||
@@ -346,24 +347,48 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
return sessionIds;
|
||||
}, [activeSessionIdMap, scopeKey]);
|
||||
|
||||
const deferredSessions = useDeferredValue(sessions);
|
||||
const workspaceMemberTerminalIds = useMemo(() => {
|
||||
if (scopeType !== 'workspace') return undefined;
|
||||
return new Set(
|
||||
terminalSessions
|
||||
.map((session) => session.sessionId)
|
||||
.filter((sessionId): sessionId is string => Boolean(sessionId)),
|
||||
);
|
||||
}, [scopeType, terminalSessions]);
|
||||
|
||||
// Use live sessions for history + view resolution. Deferring the list used to
|
||||
// lag one paint behind createSession, so normalizePanelView treated the new
|
||||
// chat as missing and the draft-sync effect forced showDraftView — which
|
||||
// parked the just-sent session into history under StrictMode.
|
||||
const historySessions = useMemo(
|
||||
() => profileAIPanelCalculation(
|
||||
'AIChatSidePanel.historySessions',
|
||||
() => getScopedHistorySessions(
|
||||
deferredSessions,
|
||||
sessions,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeHostIds,
|
||||
activeTerminalSessionIds,
|
||||
workspaceMemberTerminalIds,
|
||||
),
|
||||
),
|
||||
[deferredSessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalSessionIds],
|
||||
[sessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalSessionIds, workspaceMemberTerminalIds],
|
||||
);
|
||||
|
||||
const explicitPanelView = panelViewByScope[scopeKey];
|
||||
const currentDraft = draftsByScope[scopeKey] ?? null;
|
||||
const persistedSessionId = activeSessionIdMap[scopeKey] ?? null;
|
||||
const visibleHistorySessionIds = useMemo(
|
||||
() => new Set(historySessions.map((session) => session.id)),
|
||||
[historySessions],
|
||||
);
|
||||
const persistedSessionId = resolveInheritedAIActiveSessionId({
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
activeSessionIdMap,
|
||||
memberTerminalIds: terminalSessions.map((session) => session.sessionId).filter(Boolean),
|
||||
preferredTerminalId: focusedSessionId,
|
||||
visibleSessionIds: visibleHistorySessionIds,
|
||||
});
|
||||
const normalizedPanelView = useMemo<AIPanelView>(
|
||||
() => resolveDisplayedPanelView(explicitPanelView, currentDraft != null, historySessions, persistedSessionId, scopeType),
|
||||
[explicitPanelView, currentDraft, historySessions, persistedSessionId, scopeType],
|
||||
@@ -385,7 +410,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
currentDraftRef.current = currentDraft;
|
||||
const activeSessionRef = useRef(activeSession);
|
||||
activeSessionRef.current = activeSession;
|
||||
const draftSendInFlightRef = useRef(false);
|
||||
|
||||
const defaultTargetSession = useMemo<DefaultTargetSessionHint | undefined>(() => {
|
||||
const connectedSessions = terminalSessions.filter((session) => session.connected !== false);
|
||||
@@ -426,9 +450,17 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
if (!explicitPanelView || panelViewsEqual(normalizedPanelView, explicitPanelView)) return;
|
||||
// Predicate must match normalizePanelView's list (scoped history), not the
|
||||
// global store — out-of-scope sessions must still demote to draft.
|
||||
if (!shouldForceDraftViewSync(
|
||||
explicitPanelView,
|
||||
normalizedPanelView,
|
||||
(sessionId) => historySessions.some((session) => session.id === sessionId),
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
showDraftView(scopeKey);
|
||||
}, [isVisible, normalizedPanelView, explicitPanelView, scopeKey, showDraftView]);
|
||||
}, [isVisible, normalizedPanelView, explicitPanelView, scopeKey, historySessions, showDraftView]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSession) return;
|
||||
@@ -447,9 +479,24 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
if (normalizedPanelView.mode !== 'draft') return;
|
||||
// Keep ownership while an explicit session is still displayable in this
|
||||
// scope (normalize has not demoted it). Out-of-scope / missing ids clear.
|
||||
if (
|
||||
explicitPanelView?.mode === 'session'
|
||||
&& historySessions.some((session) => session.id === explicitPanelView.sessionId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (persistedSessionId == null) return;
|
||||
setActiveSessionId(null);
|
||||
}, [isVisible, normalizedPanelView.mode, persistedSessionId, setActiveSessionId]);
|
||||
}, [
|
||||
isVisible,
|
||||
normalizedPanelView.mode,
|
||||
explicitPanelView,
|
||||
historySessions,
|
||||
persistedSessionId,
|
||||
setActiveSessionId,
|
||||
]);
|
||||
|
||||
const ensureScopeDraft = useCallback((agentId: string) => {
|
||||
ensureDraftForScope(scopeKey, agentId);
|
||||
@@ -1005,9 +1052,11 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
const modelAttachments = attachments.filter((attachment) => !isTerminalSelectionAttachment(attachment));
|
||||
const isDraftMode = currentPanelView.mode === 'draft';
|
||||
|
||||
if (isDraftMode && !tryBeginDraftSend(draftSendInFlightRef)) {
|
||||
const sendGateKey = currentSessionView?.id ?? `draft:${scopeKey}`;
|
||||
if (!tryBeginSendForKey(sendGateKey)) {
|
||||
return;
|
||||
}
|
||||
let sessionSendGateKey: string | null = null;
|
||||
|
||||
try {
|
||||
let sessionId = currentSessionView?.id ?? null;
|
||||
@@ -1020,12 +1069,20 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
clearScopeDraft();
|
||||
showScopeSessionView(createdSession.id);
|
||||
setActiveSessionId(createdSession.id);
|
||||
sessionSendGateKey = sessionId;
|
||||
if (!tryBeginSendForKey(sessionSendGateKey)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAIChatSessionStreaming(sessionId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isExternalAgent = sendAgentId !== 'catty';
|
||||
|
||||
const sendActiveProvider = isExternalAgent ? activeProvider : effectiveActiveProvider;
|
||||
@@ -1133,8 +1190,9 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
}, modelAttachments.length > 0 ? modelAttachments : undefined);
|
||||
}
|
||||
} finally {
|
||||
if (isDraftMode) {
|
||||
endDraftSend(draftSendInFlightRef);
|
||||
endSendForKey(sendGateKey);
|
||||
if (sessionSendGateKey && sessionSendGateKey !== sendGateKey) {
|
||||
endSendForKey(sessionSendGateKey);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
@@ -1155,12 +1213,15 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
currentAgentId !== 'catty'
|
||||
|| !session
|
||||
|| isStreaming
|
||||
|| isAIChatSessionStreaming(session.id)
|
||||
|| !effectiveActiveProvider
|
||||
|| !effectiveActiveModelId.trim()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tryBeginSendForKey(session.id)) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
abortControllersRef.current.set(session.id, controller);
|
||||
setStreamingForScope(session.id, true);
|
||||
@@ -1198,6 +1259,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
if (abortControllersRef.current.get(session.id) === controller) {
|
||||
abortControllersRef.current.delete(session.id);
|
||||
}
|
||||
endSendForKey(session.id);
|
||||
}
|
||||
}, [
|
||||
abortControllersRef,
|
||||
@@ -1296,8 +1358,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
return registerGrantPersister((rule) => { addGrant(rule); });
|
||||
}, [addGrant]);
|
||||
|
||||
useEffect(() => setupCodexAppServerInteractionBridge(), []);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
if (!activeSessionId) return;
|
||||
setSteeringSessionId(current => current === activeSessionId ? null : current);
|
||||
@@ -1568,6 +1628,7 @@ export function aiChatSidePanelPropsAreEqual(
|
||||
if (prev.scopeType !== next.scopeType) return false;
|
||||
if (prev.scopeTargetId !== next.scopeTargetId) return false;
|
||||
if (prev.scopeLabel !== next.scopeLabel) return false;
|
||||
if ((prev.focusedSessionId ?? '') !== (next.focusedSessionId ?? '')) return false;
|
||||
if ((prev.isVisible ?? true) !== (next.isVisible ?? true)) return false;
|
||||
if (prev.scopeHostIds !== next.scopeHostIds) return false;
|
||||
if (prev.terminalSessions !== next.terminalSessions) return false;
|
||||
@@ -1586,10 +1647,45 @@ export function aiChatSidePanelPropsAreEqual(
|
||||
// resume whose stored scope.targetId is an older terminal.
|
||||
// Fuzzy history still receives the full list; drawer open forces re-render
|
||||
// via isVisible / other prop paths when the user actually needs it.
|
||||
const scopeKey = `${prev.scopeType}:${prev.scopeTargetId ?? ''}`;
|
||||
const selectedSessionId = prev.activeSessionIdMap[scopeKey]
|
||||
?? next.activeSessionIdMap[scopeKey]
|
||||
?? null;
|
||||
// Keep visibleSessionIds in sync with the live panel so inheritance that
|
||||
// skips non-history chats does not leave memo pinned to a different id.
|
||||
const resolveSelectedSessionId = (
|
||||
props: AIChatSidePanelProps,
|
||||
): string | null => {
|
||||
const scopeKey = `${props.scopeType}:${props.scopeTargetId ?? ''}`;
|
||||
const memberTerminalIds = (props.terminalSessions ?? [])
|
||||
.map((session) => session.sessionId)
|
||||
.filter((sessionId): sessionId is string => Boolean(sessionId));
|
||||
const workspaceMemberTerminalIds = props.scopeType === 'workspace'
|
||||
? new Set(memberTerminalIds)
|
||||
: undefined;
|
||||
const activeTerminalSessionIds = new Set<string>();
|
||||
for (const [sessionScopeKey, sessionId] of Object.entries(props.activeSessionIdMap) as Array<[string, string | null]>) {
|
||||
if (!sessionScopeKey.startsWith('terminal:') || !sessionId) continue;
|
||||
if (sessionScopeKey === scopeKey) continue;
|
||||
activeTerminalSessionIds.add(sessionId);
|
||||
}
|
||||
const visibleSessionIds = new Set(
|
||||
getScopedHistorySessions(
|
||||
props.sessions,
|
||||
props.scopeType,
|
||||
props.scopeTargetId,
|
||||
props.scopeHostIds,
|
||||
activeTerminalSessionIds,
|
||||
workspaceMemberTerminalIds,
|
||||
).map((session) => session.id),
|
||||
);
|
||||
return resolveInheritedAIActiveSessionId({
|
||||
scopeType: props.scopeType,
|
||||
scopeTargetId: props.scopeTargetId,
|
||||
activeSessionIdMap: props.activeSessionIdMap,
|
||||
memberTerminalIds,
|
||||
preferredTerminalId: props.focusedSessionId,
|
||||
visibleSessionIds,
|
||||
});
|
||||
};
|
||||
const selectedSessionId = resolveSelectedSessionId(prev)
|
||||
?? resolveSelectedSessionId(next);
|
||||
if (!exactScopeAISessionsEqual(
|
||||
prev.sessions,
|
||||
next.sessions,
|
||||
|
||||
@@ -87,6 +87,8 @@ export interface AIChatSidePanelProps {
|
||||
scopeTargetId?: string;
|
||||
scopeHostIds?: string[];
|
||||
scopeLabel?: string;
|
||||
/** Workspace focused pane; preferred when inheriting AI chat after merge. */
|
||||
focusedSessionId?: string;
|
||||
|
||||
// Terminal session context (from parent)
|
||||
terminalSessions?: Array<{
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
withRestoreBarrier,
|
||||
} from '../application/localVaultBackups';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useOptionalSettingsFocus } from './settings/SettingsFocusContext';
|
||||
import {
|
||||
findSyncPayloadEncryptedCredentialPaths,
|
||||
stripSyncPayloadEncryptedCredentials,
|
||||
@@ -273,6 +274,15 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
// Active tab state — lets the banner's "Restore" button switch to the
|
||||
// local-backups tab without a separate DOM query.
|
||||
const [activeTab, setActiveTab] = useState<'providers' | 'status'>('providers');
|
||||
const settingsFocus = useOptionalSettingsFocus();
|
||||
|
||||
useEffect(() => {
|
||||
const request = settingsFocus?.request;
|
||||
if (request?.tab !== 'sync' || !request.syncSubTab) return;
|
||||
if (request.syncSubTab === 'providers' || request.syncSubTab === 'status') {
|
||||
setActiveTab(request.syncSubTab);
|
||||
}
|
||||
}, [settingsFocus?.request]);
|
||||
|
||||
const [preparedConvergentMigration, setPreparedConvergentMigration] = useState<PreparedConvergentMigration | null>(null);
|
||||
const [convergentPreview, setConvergentPreview] = useState<ConvergentMigrationPreview | null>(null);
|
||||
|
||||
@@ -283,6 +283,7 @@ 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;
|
||||
@@ -322,6 +323,7 @@ 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;
|
||||
@@ -367,6 +369,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
onDuplicateHost,
|
||||
onDeleteHost,
|
||||
onCopyCredentials,
|
||||
onCopyHostname,
|
||||
onNewGroup,
|
||||
onRenameGroup,
|
||||
onEditGroup,
|
||||
@@ -577,6 +580,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onDeleteHost={onDeleteHost}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
onCopyHostname={onCopyHostname}
|
||||
onNewGroup={onNewGroup}
|
||||
onRenameGroup={onRenameGroup}
|
||||
onEditGroup={onEditGroup}
|
||||
@@ -619,6 +623,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onDeleteHost={onDeleteHost}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
onCopyHostname={onCopyHostname}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
@@ -651,6 +656,7 @@ 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;
|
||||
@@ -696,6 +702,7 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
onDuplicateHost,
|
||||
onDeleteHost,
|
||||
onCopyCredentials,
|
||||
onCopyHostname,
|
||||
moveHostToGroup: _moveHostToGroup,
|
||||
|
||||
isMultiSelectMode,
|
||||
@@ -752,7 +759,11 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
: -1}
|
||||
onFocus={() => onActiveTreeItemChange(`host:${host.id}`)}
|
||||
draggable={!isMultiSelectMode}
|
||||
onDragStart={(e) => e.dataTransfer.setData("host-id", host.id)}
|
||||
onDragStart={(e) => {
|
||||
// copyMove: vault reorder uses move; focus-sidebar append uses copy.
|
||||
e.dataTransfer.effectAllowed = "copyMove";
|
||||
e.dataTransfer.setData("host-id", host.id);
|
||||
}}
|
||||
onClick={() => {
|
||||
const action = resolveHostActivateAction({
|
||||
behavior: normalizedHostClickBehavior,
|
||||
@@ -849,7 +860,9 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
<HostTreeHostContextMenuContent
|
||||
host={host}
|
||||
onConnect={onConnect}
|
||||
onEditHost={onEditHost}
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onCopyHostname={onCopyHostname}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
onDeleteHost={onDeleteHost}
|
||||
/>
|
||||
@@ -870,6 +883,7 @@ const HostTreeViewInner: React.FC<HostTreeViewProps> = ({
|
||||
onDuplicateHost,
|
||||
onDeleteHost,
|
||||
onCopyCredentials,
|
||||
onCopyHostname,
|
||||
onNewGroup,
|
||||
onRenameGroup,
|
||||
onEditGroup,
|
||||
@@ -1178,6 +1192,7 @@ const HostTreeViewInner: React.FC<HostTreeViewProps> = ({
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onDeleteHost={onDeleteHost}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
onCopyHostname={onCopyHostname}
|
||||
onNewGroup={onNewGroup}
|
||||
onRenameGroup={onRenameGroup}
|
||||
onEditGroup={onEditGroup}
|
||||
@@ -1218,6 +1233,7 @@ const HostTreeViewInner: React.FC<HostTreeViewProps> = ({
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onDeleteHost={onDeleteHost}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
onCopyHostname={onCopyHostname}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
@@ -1266,6 +1282,7 @@ 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
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const source = readFileSync(new URL('./LogView.tsx', import.meta.url), 'utf8');
|
||||
|
||||
test('LogView applies live custom accent at the leaf from appearanceChromeStore', () => {
|
||||
assert.match(source, /useAppearanceChromeStore/);
|
||||
assert.match(source, /applyCustomAccentToTerminalTheme\(baseTheme, accentMode, customAccent\)/);
|
||||
// Must not rely on an accent-baked defaultTerminalTheme from AppShell bags.
|
||||
assert.match(
|
||||
source,
|
||||
/published defaultTerminalTheme is the stable base catalog/,
|
||||
);
|
||||
});
|
||||
+12
-6
@@ -9,6 +9,8 @@ import { cn } from "../lib/utils";
|
||||
import { ConnectionLog, TerminalTheme } from "../types";
|
||||
import { TERMINAL_THEMES } from "../infrastructure/config/terminalThemes";
|
||||
import { useCustomThemes } from "../application/state/customThemeStore";
|
||||
import { useAppearanceChromeStore } from "../application/state/appearanceChromeStore";
|
||||
import { applyCustomAccentToTerminalTheme } from "../domain/terminalAppearance";
|
||||
import { Button } from "./ui/button";
|
||||
import ThemeCustomizeModal from "./terminal/ThemeCustomizeModal";
|
||||
|
||||
@@ -40,6 +42,10 @@ const LogViewComponent: React.FC<LogViewProps> = ({
|
||||
|
||||
// Subscribe to custom theme changes so editing triggers re-render
|
||||
const customThemes = useCustomThemes();
|
||||
// Leaf accent: published defaultTerminalTheme is the stable base catalog
|
||||
// theme; apply live custom accent here so log replay matches terminal chrome
|
||||
// without thrashing AppShell on every HSL tick.
|
||||
const { accentMode, customAccent } = useAppearanceChromeStore();
|
||||
const explicitThemeId = useMemo(() => {
|
||||
if (!log.themeId) return undefined;
|
||||
const exists = TERMINAL_THEMES.some((theme) => theme.id === log.themeId)
|
||||
@@ -58,13 +64,13 @@ const LogViewComponent: React.FC<LogViewProps> = ({
|
||||
if (previewTheme) {
|
||||
return previewTheme;
|
||||
}
|
||||
if (explicitThemeId) {
|
||||
return TERMINAL_THEMES.find(t => t.id === explicitThemeId)
|
||||
const baseTheme = explicitThemeId
|
||||
? (TERMINAL_THEMES.find(t => t.id === explicitThemeId)
|
||||
|| customThemes.find(t => t.id === explicitThemeId)
|
||||
|| defaultTerminalTheme;
|
||||
}
|
||||
return defaultTerminalTheme;
|
||||
}, [customThemes, defaultTerminalTheme, explicitThemeId, previewTheme]);
|
||||
|| defaultTerminalTheme)
|
||||
: defaultTerminalTheme;
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
}, [accentMode, customAccent, customThemes, defaultTerminalTheme, explicitThemeId, previewTheme]);
|
||||
|
||||
const currentFontSize = log.fontSize ?? defaultFontSize;
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { usePortForwardingState } from "../application/state/usePortForwardingState";
|
||||
import { STORAGE_KEY_PORT_FORWARDING_PANEL_WIDTH } from "../infrastructure/config/storageKeys";
|
||||
import {
|
||||
GroupConfig,
|
||||
Host,
|
||||
@@ -94,6 +95,11 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
terminalSettings,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const portForwardingPanelResizeProps = {
|
||||
resizable: true as const,
|
||||
persistWidthStorageKey: STORAGE_KEY_PORT_FORWARDING_PANEL_WIDTH,
|
||||
resizeAriaLabel: t("vault.panel.resizeWidth"),
|
||||
};
|
||||
const {
|
||||
rules: _rules,
|
||||
selectedRuleId,
|
||||
@@ -312,6 +318,12 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
// Start new rule - wizard or form based on user preference
|
||||
const startNewRule = (type: PortForwardingType) => {
|
||||
setShowNewMenu(false);
|
||||
// The new-rule flow replaces an open editor; never leave both side panels mounted.
|
||||
setShowEditPanel(false);
|
||||
setEditingRule(null);
|
||||
setEditDraft({});
|
||||
setSelectedRuleId(null);
|
||||
setShowHostSelector(false);
|
||||
|
||||
if (preferFormMode) {
|
||||
// Form mode: show all-in-one form
|
||||
@@ -604,8 +616,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
{/* Main Content */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 flex flex-col min-h-0",
|
||||
showWizard || showEditPanel || showNewForm ? "mr-[360px]" : "",
|
||||
"flex-1 min-w-0 flex flex-col min-h-0",
|
||||
)}
|
||||
>
|
||||
<VaultPageHeader className="z-20">
|
||||
@@ -773,7 +784,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Edit Panel - shown when a rule is selected */}
|
||||
{showEditPanel && editingRule && (
|
||||
{showEditPanel && editingRule && !showHostSelector && (
|
||||
<EditPanel
|
||||
rule={editingRule}
|
||||
draft={editDraft}
|
||||
@@ -789,11 +800,12 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
}}
|
||||
onDelete={() => handleDeleteRule(editingRule)}
|
||||
onOpenHostSelector={() => setShowHostSelector(true)}
|
||||
{...portForwardingPanelResizeProps}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Wizard Panel */}
|
||||
{showWizard && (
|
||||
{showWizard && !showHostSelector && (
|
||||
<AsidePanel
|
||||
open={true}
|
||||
onClose={() => {
|
||||
@@ -802,6 +814,8 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
}}
|
||||
title={isEditing ? t("pf.wizard.editTitle") : t("pf.wizard.newTitle")}
|
||||
width="w-[360px]"
|
||||
layout="inline"
|
||||
{...portForwardingPanelResizeProps}
|
||||
showBackButton={!!getPrevStep()}
|
||||
onBack={
|
||||
getPrevStep()
|
||||
@@ -902,11 +916,14 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
managedSources={managedSources}
|
||||
onSaveHost={onSaveHost}
|
||||
onCreateGroup={_onCreateGroup}
|
||||
width="w-[360px]"
|
||||
layout="inline"
|
||||
{...portForwardingPanelResizeProps}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* New Form Panel (skip wizard mode) */}
|
||||
{showNewForm && (
|
||||
{showNewForm && !showHostSelector && (
|
||||
<NewFormPanel
|
||||
draft={newFormDraft}
|
||||
hosts={hosts}
|
||||
@@ -918,6 +935,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
onOpenHostSelector={() => setShowHostSelector(true)}
|
||||
onOpenWizard={openWizardFromForm}
|
||||
isValid={isNewFormValid()}
|
||||
{...portForwardingPanelResizeProps}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { buildScriptsSidePanelRows } from "./ScriptsSidePanel.tsx";
|
||||
import {
|
||||
buildScriptsSidePanelRows,
|
||||
collectScriptsSidePanelPackagePaths,
|
||||
} from "./ScriptsSidePanel.tsx";
|
||||
import type { Snippet } from "../types";
|
||||
|
||||
const snippet = (overrides: Partial<Snippet>): Snippet => ({
|
||||
@@ -50,10 +53,81 @@ test("scripts side panel offers run-on-all-tabs for every snippet, not only auto
|
||||
assert.match(source, /openPackageDialog/);
|
||||
});
|
||||
|
||||
test("scripts side panel add control is a split button with secondary create actions", () => {
|
||||
assert.match(source, /ChevronDown/);
|
||||
assert.match(source, /snippets\.action\.newScript/);
|
||||
test("scripts side panel exposes create actions as inline toolbar icons", () => {
|
||||
assert.match(source, /snippets\.action\.newSnippet/);
|
||||
assert.match(source, /snippets\.action\.newPackage/);
|
||||
assert.match(source, /snippets\.action\.newScript/);
|
||||
assert.match(source, /openPackageDialog/);
|
||||
assert.match(source, /handleAddScript/);
|
||||
assert.doesNotMatch(source, /ChevronDown/);
|
||||
assert.doesNotMatch(source, /DropdownTrigger/);
|
||||
});
|
||||
|
||||
test("collectScriptsSidePanelPackagePaths includes implied ancestors", () => {
|
||||
assert.deepEqual(
|
||||
collectScriptsSidePanelPackagePaths(["ops/linux"], [
|
||||
snippet({ id: "a", package: "ops/linux/disk" }),
|
||||
snippet({ id: "b", package: "" }),
|
||||
]).sort(),
|
||||
["ops", "ops/linux", "ops/linux/disk"],
|
||||
);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
assert.match(source, /vault\.tree\.expandAll/);
|
||||
assert.match(source, /vault\.tree\.collapseAll/);
|
||||
assert.match(source, /expandAllGroups/);
|
||||
assert.match(source, /collapseAllGroups/);
|
||||
assert.match(source, /isMultiSelectMode/);
|
||||
});
|
||||
|
||||
test("scripts side panel confirms bulk delete and routes it through the shared delete event", () => {
|
||||
// Mass-delete must confirm first (SnippetsManager parity) and must not bypass
|
||||
// AppSideEffects via onSnippetsChange filtering — that path skips host binding cleanup.
|
||||
assert.match(source, /VaultDeleteConfirmDialog/);
|
||||
assert.match(source, /snippets\.selection\.deleteConfirmTitle/);
|
||||
assert.match(source, /snippets\.selection\.deleteConfirmDesc/);
|
||||
assert.match(source, /detail:\s*\{\s*ids\s*\}/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/onSnippetsChange\(snippets\.filter\(\(snippet\) => !selectedSnippetIds\.has/,
|
||||
);
|
||||
});
|
||||
|
||||
test("scripts side panel defers bulk-delete confirm when a parent owns the dialog", () => {
|
||||
// Compact TerminalToolbar nests this panel in a Popover; the portalled confirm
|
||||
// must be owned outside that tree so focus cannot unmount the prompt.
|
||||
assert.match(source, /onBulkDeleteRequest\?:/);
|
||||
assert.match(source, /if\s*\(\s*onBulkDeleteRequest\s*\)\s*\{/);
|
||||
assert.match(source, /onBulkDeleteRequest\(ids\)/);
|
||||
assert.match(source, /!onBulkDeleteRequest/);
|
||||
});
|
||||
|
||||
test("scripts side panel clears pending bulk delete when the panel hides", () => {
|
||||
// Returning null while isVisible is false unmounts the confirm dialog; drop
|
||||
// pending deletes so a later re-show does not resurrect a half-dismissed prompt.
|
||||
assert.match(
|
||||
source,
|
||||
/if\s*\(\s*!isVisible\s*\)\s*setPendingDeleteIds\(\s*null\s*\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("scripts side panel package dialog traps focus and exposes dialog close contract", () => {
|
||||
|
||||
+363
-93
@@ -3,14 +3,30 @@
|
||||
*
|
||||
* 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. Typing in the search box flattens to a
|
||||
* list of matching snippets regardless of package nesting.
|
||||
* in the focused terminal session. Search is icon-toggled (expands below the
|
||||
* toolbar); typing flattens matches regardless of package nesting.
|
||||
*/
|
||||
|
||||
import { ChevronDown, ChevronRight, Edit2, FolderPlus, Layers, Package, Play, Plus, Search, Trash2, Zap } from 'lucide-react';
|
||||
import {
|
||||
CheckSquare,
|
||||
ChevronRight,
|
||||
Edit2,
|
||||
Expand,
|
||||
FolderPlus,
|
||||
Layers,
|
||||
Minimize2,
|
||||
Package,
|
||||
Play,
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
X,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { getScriptRecordingSnapshot, subscribeScriptRecording } from '../application/state/scriptRecordingStore.ts';
|
||||
import { VaultDeleteConfirmDialog } from './vault/VaultDeleteConfirmDialog';
|
||||
import { reorderVaultItems, reorderVaultStrings, sortByVaultOrder } from '../domain/vaultOrder';
|
||||
import { isScriptSnippet } from '../domain/snippetScript.ts';
|
||||
import { cn } from '../lib/utils';
|
||||
@@ -24,7 +40,6 @@ 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';
|
||||
@@ -33,6 +48,9 @@ import { SnippetCommandTooltipContent } from './snippets/SnippetCommandTooltipCo
|
||||
import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from './terminalLayer/terminalSidePanelChrome';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
|
||||
|
||||
const toolbarIconButtonClass =
|
||||
'h-7 w-7 shrink-0 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors disabled:opacity-40 disabled:pointer-events-none';
|
||||
|
||||
const SCRIPT_ROW_HEIGHT = 34;
|
||||
|
||||
const isRootPackagePath = (path: string): boolean => {
|
||||
@@ -55,6 +73,12 @@ interface ScriptsSidePanelProps {
|
||||
onResumeRun?: (runId: string) => void;
|
||||
onStartRecording?: () => void;
|
||||
focusedSessionId?: string;
|
||||
/**
|
||||
* When set, bulk-delete confirmation is owned by the parent. Required when
|
||||
* this panel is nested in a Popover — the portalled confirm would otherwise
|
||||
* steal focus, dismiss the popover, and unmount the prompt.
|
||||
*/
|
||||
onBulkDeleteRequest?: (ids: string[]) => void;
|
||||
}
|
||||
|
||||
type TreeRow =
|
||||
@@ -126,15 +150,11 @@ const getVerticalDropIntent = (
|
||||
const hasDragType = (dataTransfer: DataTransfer, type: string) =>
|
||||
Array.from(dataTransfer.types).includes(type);
|
||||
|
||||
export function buildScriptsSidePanelRows({
|
||||
snippets,
|
||||
packages,
|
||||
expandedPaths,
|
||||
}: {
|
||||
snippets: Snippet[];
|
||||
packages: string[];
|
||||
expandedPaths: Set<string>;
|
||||
}): TreeRow[] {
|
||||
/** Collect every package path (including implied ancestors) shown in the tree. */
|
||||
export function collectScriptsSidePanelPackagePaths(
|
||||
packages: string[],
|
||||
snippets: Snippet[],
|
||||
): string[] {
|
||||
const normalizedPackages = new Set<string>();
|
||||
const addWithAncestors = (raw: string) => {
|
||||
const path = raw.trim();
|
||||
@@ -152,6 +172,19 @@ export function buildScriptsSidePanelRows({
|
||||
snippets.forEach((snippet) => {
|
||||
if (snippet.package) addWithAncestors(snippet.package);
|
||||
});
|
||||
return Array.from(normalizedPackages);
|
||||
}
|
||||
|
||||
export function buildScriptsSidePanelRows({
|
||||
snippets,
|
||||
packages,
|
||||
expandedPaths,
|
||||
}: {
|
||||
snippets: Snippet[];
|
||||
packages: string[];
|
||||
expandedPaths: Set<string>;
|
||||
}): TreeRow[] {
|
||||
const normalizedPackages = new Set(collectScriptsSidePanelPackagePaths(packages, snippets));
|
||||
|
||||
const snippetsByPackage = new Map<string, Snippet[]>();
|
||||
const descendantCountByPackage = new Map<string, number>();
|
||||
@@ -250,17 +283,30 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
onResumeRun,
|
||||
onStartRecording,
|
||||
focusedSessionId,
|
||||
onBulkDeleteRequest,
|
||||
}) => {
|
||||
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);
|
||||
const [selectedSnippetIds, setSelectedSnippetIds] = useState<Set<string>>(new Set());
|
||||
const [pendingDeleteIds, setPendingDeleteIds] = useState<string[] | null>(null);
|
||||
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) => {
|
||||
@@ -290,24 +336,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
// (e.g. package "a/b/c" implies roots "a" and "a/b" even when not listed).
|
||||
const normalizedPackages = useMemo(() => {
|
||||
if (!isVisible) return new Set<string>();
|
||||
const set = new Set<string>();
|
||||
const addWithAncestors = (raw: string) => {
|
||||
const path = raw.trim();
|
||||
if (!path) return;
|
||||
const isAbs = path.startsWith('/');
|
||||
const body = isAbs ? path.slice(1) : path;
|
||||
const parts = body.split('/').filter(Boolean);
|
||||
for (let i = 1; i <= parts.length; i++) {
|
||||
const sub = parts.slice(0, i).join('/');
|
||||
set.add(isAbs ? `/${sub}` : sub);
|
||||
}
|
||||
};
|
||||
packages.forEach(addWithAncestors);
|
||||
// A snippet may reference a package path that's not in `packages` yet.
|
||||
snippets.forEach((s) => {
|
||||
if (s.package) addWithAncestors(s.package);
|
||||
});
|
||||
return set;
|
||||
return new Set(collectScriptsSidePanelPackagePaths(packages, snippets));
|
||||
}, [packages, snippets, isVisible]);
|
||||
|
||||
// Track every package we've ever observed so we can tell "new" from
|
||||
@@ -348,6 +377,92 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const expandAllGroups = useCallback(() => {
|
||||
setExpandedPaths(new Set(normalizedPackages));
|
||||
}, [normalizedPackages]);
|
||||
|
||||
const collapseAllGroups = useCallback(() => {
|
||||
setExpandedPaths(new Set());
|
||||
}, []);
|
||||
|
||||
const clearSnippetSelection = useCallback(() => {
|
||||
setSelectedSnippetIds(new Set());
|
||||
setIsMultiSelectMode(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedSnippetIds((prev) => {
|
||||
if (prev.size === 0) return prev;
|
||||
const alive = new Set(snippets.map((snippet) => snippet.id));
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
prev.forEach((id) => {
|
||||
if (alive.has(id)) next.add(id);
|
||||
else changed = true;
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [snippets]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) setPendingDeleteIds(null);
|
||||
}, [isVisible]);
|
||||
|
||||
// Parent-owned confirm (compact toolbar popover) dispatches the shared delete
|
||||
// event; clear local multi-select when that bulk delete lands.
|
||||
useEffect(() => {
|
||||
if (!onBulkDeleteRequest) return;
|
||||
const handler = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ ids?: string[] }>).detail;
|
||||
if (!detail?.ids?.length) return;
|
||||
clearSnippetSelection();
|
||||
};
|
||||
window.addEventListener('netcatty:snippets:delete', handler);
|
||||
return () => window.removeEventListener('netcatty:snippets:delete', handler);
|
||||
}, [clearSnippetSelection, onBulkDeleteRequest]);
|
||||
|
||||
const toggleSnippetSelection = useCallback((id: string) => {
|
||||
setSelectedSnippetIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const deleteSelectedSnippets = useCallback(() => {
|
||||
const ids = snippets
|
||||
.filter((snippet) => selectedSnippetIds.has(snippet.id))
|
||||
.map((snippet) => snippet.id);
|
||||
if (ids.length === 0) return;
|
||||
if (onBulkDeleteRequest) {
|
||||
onBulkDeleteRequest(ids);
|
||||
return;
|
||||
}
|
||||
setPendingDeleteIds(ids);
|
||||
}, [onBulkDeleteRequest, selectedSnippetIds, snippets]);
|
||||
|
||||
const existingPendingDeleteIds = pendingDeleteIds
|
||||
? pendingDeleteIds.filter((id) => snippets.some((snippet) => snippet.id === id))
|
||||
: [];
|
||||
|
||||
const confirmDeleteSelectedSnippets = useCallback(() => {
|
||||
const ids = (pendingDeleteIds ?? []).filter((id) =>
|
||||
snippets.some((snippet) => snippet.id === id),
|
||||
);
|
||||
setPendingDeleteIds(null);
|
||||
if (ids.length === 0) {
|
||||
clearSnippetSelection();
|
||||
return;
|
||||
}
|
||||
// Always route through the shared event so AppSideEffects can clear host
|
||||
// login/connect bindings (onSnippetsChange alone would leave them stale).
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('netcatty:snippets:delete', { detail: { ids } }),
|
||||
);
|
||||
clearSnippetSelection();
|
||||
}, [clearSnippetSelection, pendingDeleteIds, snippets]);
|
||||
|
||||
// When search is active, flatten everything (no tree, no packages).
|
||||
const searchMatches = useMemo(() => {
|
||||
if (!isVisible) return null;
|
||||
@@ -400,6 +515,10 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
|
||||
const handleSnippetClick = useCallback(
|
||||
(snippet: Snippet) => {
|
||||
if (isMultiSelectMode) {
|
||||
toggleSnippetSelection(snippet.id);
|
||||
return;
|
||||
}
|
||||
if (isScriptSnippet(snippet)) {
|
||||
onRunScript?.(snippet);
|
||||
setSubView('running');
|
||||
@@ -407,9 +526,12 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
}
|
||||
onSnippetClick(snippet);
|
||||
},
|
||||
[onRunScript, onSnippetClick],
|
||||
[isMultiSelectMode, onRunScript, onSnippetClick, toggleSnippetSelection],
|
||||
);
|
||||
|
||||
const hasSearch = search.trim().length > 0;
|
||||
const canExpandCollapse = normalizedPackages.size > 0 && !hasSearch;
|
||||
|
||||
const sessionRuns = useMemo(() => {
|
||||
if (!focusedSessionId) return runs;
|
||||
return runs.filter((run) => run.sessionId === focusedSessionId);
|
||||
@@ -757,82 +879,159 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Search + Add */}
|
||||
<div className="shrink-0 px-2 py-1.5 border-b border-border/50 flex items-center gap-1.5">
|
||||
<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>
|
||||
{/* Split add control: primary = new snippet; menu = package / automation script */}
|
||||
<div className="shrink-0 flex items-center">
|
||||
{/* 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>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddSnippet}
|
||||
aria-label={t('snippets.action.newSnippet')}
|
||||
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"
|
||||
className={cn(toolbarIconButtonClass, 'ml-auto')}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('snippets.action.newSnippet')}</TooltipContent>
|
||||
<TooltipContent side="bottom">{t('snippets.action.newSnippet')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Dropdown open={addMenuOpen} onOpenChange={setAddMenuOpen}>
|
||||
<DropdownTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
</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 ? (
|
||||
{onPackagesChange ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<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();
|
||||
}}
|
||||
onClick={openPackageDialog}
|
||||
aria-label={t('snippets.action.newPackage')}
|
||||
className={toolbarIconButtonClass}
|
||||
>
|
||||
<FolderPlus size={12} className="text-muted-foreground" />
|
||||
{t('snippets.action.newPackage')}
|
||||
<FolderPlus size={14} />
|
||||
</button>
|
||||
) : null}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t('snippets.action.newPackage')}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<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();
|
||||
}}
|
||||
onClick={handleAddScript}
|
||||
aria-label={t('snippets.action.newScript')}
|
||||
className={toolbarIconButtonClass}
|
||||
>
|
||||
<Play size={12} className="text-primary" />
|
||||
{t('snippets.action.newScript')}
|
||||
<Play size={14} />
|
||||
</button>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -862,6 +1061,8 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
snippet={item.snippet}
|
||||
depth={0}
|
||||
subtitle={item.snippet.package || t('terminal.toolbar.library')}
|
||||
selected={selectedSnippetIds.has(item.snippet.id)}
|
||||
multiSelect={isMultiSelectMode}
|
||||
draggable={false}
|
||||
sortableTarget={false}
|
||||
onDragOver={handleRowDragOver}
|
||||
@@ -904,7 +1105,9 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
<SnippetRow
|
||||
snippet={item.row.snippet}
|
||||
depth={item.row.depth}
|
||||
draggable={Boolean(onSnippetsChange)}
|
||||
selected={selectedSnippetIds.has(item.row.snippet.id)}
|
||||
multiSelect={isMultiSelectMode}
|
||||
draggable={Boolean(onSnippetsChange) && !isMultiSelectMode}
|
||||
sortableTarget={true}
|
||||
onDragOver={handleRowDragOver}
|
||||
onDrop={handleRowDrop}
|
||||
@@ -931,6 +1134,43 @@ 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"
|
||||
@@ -1051,6 +1291,20 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!onBulkDeleteRequest ? (
|
||||
<VaultDeleteConfirmDialog
|
||||
open={Boolean(pendingDeleteIds)}
|
||||
title={t('snippets.selection.deleteConfirmTitle', {
|
||||
count: existingPendingDeleteIds.length,
|
||||
})}
|
||||
description={t('snippets.selection.deleteConfirmDesc')}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPendingDeleteIds(null);
|
||||
}}
|
||||
onConfirm={confirmDeleteSelectedSnippets}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
@@ -1102,6 +1356,8 @@ interface SnippetRowProps {
|
||||
snippet: Snippet;
|
||||
depth: number;
|
||||
subtitle?: string;
|
||||
selected?: boolean;
|
||||
multiSelect?: boolean;
|
||||
draggable: boolean;
|
||||
sortableTarget: boolean;
|
||||
onDragOver: (event: React.DragEvent<HTMLElement>) => void;
|
||||
@@ -1122,6 +1378,8 @@ const SnippetRow = memo<SnippetRowProps>(({
|
||||
snippet,
|
||||
depth,
|
||||
subtitle,
|
||||
selected = false,
|
||||
multiSelect = false,
|
||||
draggable,
|
||||
sortableTarget,
|
||||
onDragOver,
|
||||
@@ -1157,13 +1415,25 @@ const SnippetRow = memo<SnippetRowProps>(({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="w-full flex items-center gap-1.5 pr-3 py-1.5 text-left hover:bg-accent/50 transition-colors overflow-hidden"
|
||||
aria-pressed={multiSelect ? selected : undefined}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-1.5 pr-3 py-1.5 text-left hover:bg-accent/50 transition-colors overflow-hidden',
|
||||
selected && 'bg-primary/10 hover:bg-primary/15',
|
||||
)}
|
||||
style={{ paddingLeft: 8 + depth * 14 }}
|
||||
>
|
||||
{/* Hidden chevron column mirrors PackageRow's layout so the
|
||||
snippet icon lines up exactly with the package icon above. */}
|
||||
<ChevronRight size={12} className="shrink-0 opacity-0" aria-hidden />
|
||||
{isScriptSnippet(snippet) ? (
|
||||
{multiSelect ? (
|
||||
<CheckSquare
|
||||
size={12}
|
||||
className={cn(
|
||||
'shrink-0',
|
||||
selected ? 'text-primary' : 'text-muted-foreground/70',
|
||||
)}
|
||||
/>
|
||||
) : isScriptSnippet(snippet) ? (
|
||||
<Play size={12} className="shrink-0 text-primary" />
|
||||
) : (
|
||||
<Zap size={12} className="shrink-0 text-muted-foreground" />
|
||||
|
||||
@@ -25,6 +25,7 @@ interface SelectHostPanelProps {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
className?: string;
|
||||
width?: string;
|
||||
layout?: AsidePanelLayout;
|
||||
resizable?: boolean;
|
||||
persistWidthStorageKey?: string;
|
||||
@@ -50,6 +51,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
title,
|
||||
subtitle,
|
||||
className,
|
||||
width,
|
||||
layout = "overlay",
|
||||
resizable = false,
|
||||
persistWidthStorageKey,
|
||||
@@ -79,6 +81,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
showBackButton={true}
|
||||
onBack={onBack}
|
||||
className={cn(layout === "overlay" && "z-40", newHostPanelOpen && "overflow-visible", className)}
|
||||
width={width}
|
||||
layout={layout}
|
||||
resizable={resizable}
|
||||
persistWidthStorageKey={persistWidthStorageKey}
|
||||
|
||||
@@ -417,7 +417,7 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
|
||||
aria-selected={multiSelect ? groupState === 'all' : false}
|
||||
data-active={isActive ? 'true' : undefined}
|
||||
className={cn(
|
||||
'flex h-full min-h-0 items-center gap-2.5 overflow-hidden px-2.5 rounded-lg transition-colors',
|
||||
'flex h-full min-h-0 items-center gap-2.5 overflow-hidden rounded-lg px-2.5 transition-colors',
|
||||
isActive ? 'bg-primary/10 ring-1 ring-primary/40' : 'hover:bg-muted/70',
|
||||
)}
|
||||
onClick={() => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { cn } from "../lib/utils";
|
||||
import { useApplicationBackend } from "../application/state/useApplicationBackend";
|
||||
import type { UpdateState, UseUpdateCheckResult } from "../application/state/useUpdateCheck";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { SettingsTabContent } from "./settings/settings-ui";
|
||||
import { SettingsAnchor, SettingsTabContent } from "./settings/settings-ui";
|
||||
import { toast } from "./ui/toast";
|
||||
|
||||
type AppInfo = {
|
||||
@@ -51,7 +51,7 @@ export const buildIssueUrl = (appInfo: AppInfo) => {
|
||||
const installSource =
|
||||
appInfo.version === "0.0.0"
|
||||
? "Built from source (npm run dev / pack)"
|
||||
: "GitHub Release (.dmg / .exe / .AppImage / .deb)";
|
||||
: "GitHub Release (.dmg / .exe / .AppImage / .deb / .rpm / .pacman)";
|
||||
params.set("install_source", installSource);
|
||||
|
||||
const ua = typeof navigator !== "undefined" ? navigator.userAgent : "unknown";
|
||||
@@ -204,7 +204,7 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<SettingsAnchor anchorId="application-check-updates" className="mt-6">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="gap-2"
|
||||
@@ -223,35 +223,43 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
|
||||
: t("settings.application.checkUpdates")
|
||||
}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsAnchor>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="space-y-2">
|
||||
<ActionRow
|
||||
icon={<Bug size={18} />}
|
||||
title={t("settings.application.reportProblem")}
|
||||
subtitle={t("settings.application.reportProblem.subtitle")}
|
||||
onClick={() => void handleOpenExternal(issueUrl)}
|
||||
/>
|
||||
<ActionRow
|
||||
icon={<MessageCircle size={18} />}
|
||||
title={t("settings.application.community")}
|
||||
subtitle={t("settings.application.community.subtitle")}
|
||||
onClick={() => void handleOpenExternal(discussionsUrl)}
|
||||
/>
|
||||
<ActionRow
|
||||
icon={<Github size={18} />}
|
||||
title="GitHub"
|
||||
subtitle={t("settings.application.github.subtitle")}
|
||||
onClick={() => void handleOpenExternal(REPO_URL)}
|
||||
/>
|
||||
<ActionRow
|
||||
icon={<Newspaper size={18} />}
|
||||
title={t("settings.application.whatsNew")}
|
||||
subtitle={t("settings.application.whatsNew.subtitle")}
|
||||
onClick={() => void handleOpenExternal(releasesUrl)}
|
||||
/>
|
||||
<SettingsAnchor anchorId="application-report-problem">
|
||||
<ActionRow
|
||||
icon={<Bug size={18} />}
|
||||
title={t("settings.application.reportProblem")}
|
||||
subtitle={t("settings.application.reportProblem.subtitle")}
|
||||
onClick={() => void handleOpenExternal(issueUrl)}
|
||||
/>
|
||||
</SettingsAnchor>
|
||||
<SettingsAnchor anchorId="application-community">
|
||||
<ActionRow
|
||||
icon={<MessageCircle size={18} />}
|
||||
title={t("settings.application.community")}
|
||||
subtitle={t("settings.application.community.subtitle")}
|
||||
onClick={() => void handleOpenExternal(discussionsUrl)}
|
||||
/>
|
||||
</SettingsAnchor>
|
||||
<SettingsAnchor anchorId="application-github">
|
||||
<ActionRow
|
||||
icon={<Github size={18} />}
|
||||
title="GitHub"
|
||||
subtitle={t("settings.application.github.subtitle")}
|
||||
onClick={() => void handleOpenExternal(REPO_URL)}
|
||||
/>
|
||||
</SettingsAnchor>
|
||||
<SettingsAnchor anchorId="application-whats-new">
|
||||
<ActionRow
|
||||
icon={<Newspaper size={18} />}
|
||||
title={t("settings.application.whatsNew")}
|
||||
subtitle={t("settings.application.whatsNew.subtitle")}
|
||||
onClick={() => void handleOpenExternal(releasesUrl)}
|
||||
/>
|
||||
</SettingsAnchor>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,9 @@ import { I18nProvider, useI18n } from "../application/i18n/I18nProvider";
|
||||
import { sanitizePortForwardingRulesForSync } from "../application/syncPayload";
|
||||
import { toast } from "./ui/toast";
|
||||
import { SettingsTabContent } from "./settings/settings-ui";
|
||||
import { SettingsFocusProvider, useSettingsFocus } from "./settings/SettingsFocusContext";
|
||||
import { SettingsSearchControl } from "./settings/SettingsSearchControl";
|
||||
import { cancelSettingsFocus, focusSettingsAnchor } from "./settings/settingsFocus";
|
||||
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
|
||||
import { LazyLoadBoundary } from "./ui/lazy-load-boundary";
|
||||
import { ExternalMcpApprovalsHost } from "./ai/ExternalMcpApprovalsHost";
|
||||
@@ -297,6 +300,7 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
|
||||
|
||||
const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }) => {
|
||||
const { t } = useI18n();
|
||||
const { request, clearFocus, openSearch } = useSettingsFocus();
|
||||
const { notifyRendererReady, closeSettingsWindow, onWindowCommandCloseRequested } = useWindowControls();
|
||||
const { updateState, checkNow, installUpdate, openReleasePage, startDownload, isUpdateDemoMode } = useUpdateCheck({
|
||||
autoUpdateEnabled: settings.autoUpdateEnabled,
|
||||
@@ -312,6 +316,17 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
notifyRendererReady();
|
||||
}, [notifyRendererReady]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
const isFind = (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "f";
|
||||
if (!isFind) return;
|
||||
event.preventDefault();
|
||||
openSearch();
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [openSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
return setupMcpApprovalBridge();
|
||||
}, []);
|
||||
@@ -334,6 +349,38 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
});
|
||||
}, [activeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!request) return;
|
||||
if (activeTab !== request.tab) {
|
||||
setActiveTab(request.tab);
|
||||
return;
|
||||
}
|
||||
// Nested tabs (AI / Sync) read `request` to switch sub-tabs. Keep it until
|
||||
// scroll succeeds (or retries are exhausted) so lazy mounts still see it.
|
||||
let cancelled = false;
|
||||
const nestedDelayMs = (request.aiSubTab || request.syncSubTab) ? 80 : 40;
|
||||
const focusHandle = window.setTimeout(() => {
|
||||
void focusSettingsAnchor(request.anchorId, {
|
||||
attempts: 48,
|
||||
delayMs: 50,
|
||||
}).finally(() => {
|
||||
if (!cancelled) clearFocus();
|
||||
});
|
||||
}, nestedDelayMs);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(focusHandle);
|
||||
cancelSettingsFocus();
|
||||
};
|
||||
}, [request, activeTab, clearFocus]);
|
||||
|
||||
const handleTabChange = useCallback((tab: string) => {
|
||||
// Manual sidebar navigation should cancel any pending search jump.
|
||||
cancelSettingsFocus();
|
||||
clearFocus();
|
||||
setActiveTab(tab);
|
||||
}, [clearFocus]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
closeSettingsWindow();
|
||||
}, [closeSettingsWindow]);
|
||||
@@ -364,11 +411,12 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
onValueChange={handleTabChange}
|
||||
orientation="vertical"
|
||||
className="flex-1 flex overflow-hidden"
|
||||
>
|
||||
<div className="w-56 border-r border-border flex flex-col shrink-0 px-3 py-3">
|
||||
<SettingsSearchControl includePlugins={pluginRuntimeAvailable} />
|
||||
<TabsList className="flex flex-col h-auto bg-transparent gap-1 p-0 justify-start">
|
||||
<TabsTrigger
|
||||
value="application"
|
||||
@@ -416,7 +464,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
className={settingsTabTriggerClassName}
|
||||
>
|
||||
<Sparkles size={14} className={settingsTabIconClassName} />
|
||||
<span className={settingsTabLabelClassName}>AI</span>
|
||||
<span className={settingsTabLabelClassName}>{t("settings.tab.ai")}</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="sync"
|
||||
@@ -524,6 +572,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
setHotkeyScheme={settings.setHotkeyScheme}
|
||||
shellOnlyTabNumberShortcuts={settings.shellOnlyTabNumberShortcuts}
|
||||
setShellOnlyTabNumberShortcuts={settings.setShellOnlyTabNumberShortcuts}
|
||||
showTabNumberBadges={settings.showTabNumberBadges}
|
||||
setShowTabNumberBadges={settings.setShowTabNumberBadges}
|
||||
disableTerminalFontZoom={settings.disableTerminalFontZoom}
|
||||
setDisableTerminalFontZoom={settings.setDisableTerminalFontZoom}
|
||||
keyBindings={settings.keyBindings}
|
||||
@@ -615,7 +665,9 @@ export default function SettingsPage() {
|
||||
|
||||
return (
|
||||
<I18nProvider locale={settings.uiLanguage}>
|
||||
<SettingsPageContent settings={settings} />
|
||||
<SettingsFocusProvider>
|
||||
<SettingsPageContent settings={settings} />
|
||||
</SettingsFocusProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
@@ -72,6 +72,17 @@ import {
|
||||
shouldFollowTerminalCwdNavigate,
|
||||
type SftpFollowTerminalCwdBlock,
|
||||
} from "./sftp/sftpFollowTerminalCwd";
|
||||
import {
|
||||
canLocateSftpPathInTerminal,
|
||||
resolveLocateSftpPathInTerminalAction,
|
||||
resolveLocateSftpPathSessionId,
|
||||
} from "../domain/sftpLocatePathInTerminal";
|
||||
import { classifyDistroId } from "../domain/host";
|
||||
import { useTerminalBackend } from "../application/state/useTerminalBackend";
|
||||
import { isTerminalSensitiveInputActive } from "./terminal/runtime/terminalSensitiveInputRegistry";
|
||||
import { isTerminalReadyForCommandInjection } from "./terminal/runtime/terminalCommandInjectionReadyRegistry";
|
||||
import { getNextSftpToolbarDisplayPath } from "./sftp/SftpPaneToolbar";
|
||||
import { scheduleDeferredTerminalFocus } from "./systemManager/tmuxActionFocus";
|
||||
import {
|
||||
connectionKeyMatchesHost,
|
||||
findReusableSftpSidePanelTab,
|
||||
@@ -107,6 +118,8 @@ interface SftpSidePanelProps {
|
||||
activeHost: Host | null;
|
||||
/** The terminal session id whose SSH connection can be reused for SFTP */
|
||||
activeSessionId?: string | null;
|
||||
/** Focused terminal session (includes mosh/et/local) for locate-path writes */
|
||||
focusedSessionId?: string | null;
|
||||
initialLocation?: { hostId: string; path: string } | null;
|
||||
onInitialLocationApplied?: (location: { hostId: string; path: string }) => void;
|
||||
onCurrentPathChange?: (location: { hostId: string; connectionKey: string; path: string }) => void;
|
||||
@@ -115,6 +128,11 @@ interface SftpSidePanelProps {
|
||||
onActiveExternalEditsChange?: (count: number) => void;
|
||||
showWorkspaceHostHeader?: boolean;
|
||||
isVisible?: boolean;
|
||||
/**
|
||||
* Side panel chrome still open for this terminal tab (another tool may be
|
||||
* focused). Keeps browse SFTP sessions warm across History/System switches.
|
||||
*/
|
||||
ownerPanelOpen?: boolean;
|
||||
renderOverlays?: boolean;
|
||||
pendingUpload?: {
|
||||
requestId: string;
|
||||
@@ -156,6 +174,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
sftpDefaultViewMode,
|
||||
activeHost,
|
||||
activeSessionId,
|
||||
focusedSessionId = null,
|
||||
initialLocation,
|
||||
onInitialLocationApplied,
|
||||
onCurrentPathChange,
|
||||
@@ -163,6 +182,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
onActiveExternalEditsChange,
|
||||
showWorkspaceHostHeader = false,
|
||||
isVisible = true,
|
||||
ownerPanelOpen = false,
|
||||
renderOverlays = true,
|
||||
pendingUpload = null,
|
||||
onPendingUploadHandled,
|
||||
@@ -244,8 +264,11 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
// becomes hidden, so its browse channel must stay alive until the editor closes.
|
||||
// External editor temps (Notepad++ etc.) likewise need the session: parking
|
||||
// calls closeSftp which deletes those local files.
|
||||
// Keep browse warm while the side panel stays open on another tool
|
||||
// (History / System / …) so switch-back does not reconnect + reload.
|
||||
interactive: isBrowseSessionInteractive({
|
||||
surfaceVisible: isVisible,
|
||||
ownerPanelOpen,
|
||||
hasOwnedEditorTab,
|
||||
hasActiveExternalEdit: activeExternalEditCountRef.current > 0,
|
||||
}),
|
||||
@@ -261,6 +284,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
fileWatchHandlers,
|
||||
hasOwnedEditorTab,
|
||||
isVisible,
|
||||
ownerPanelOpen,
|
||||
transferOwnerId,
|
||||
sftpUseCompressedUpload,
|
||||
sftpShowHiddenFiles,
|
||||
@@ -881,11 +905,14 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
hosts={hosts}
|
||||
hostWriteSource={hostWriteSource}
|
||||
connectedHosts={connectedHosts}
|
||||
sessions={sessions}
|
||||
updateHosts={updateHosts}
|
||||
sftp={sftp}
|
||||
sftpRef={sftpRef}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
activeHost={activeHost}
|
||||
activeSessionId={activeSessionId}
|
||||
focusedSessionId={focusedSessionId}
|
||||
showWorkspaceHostHeader={showWorkspaceHostHeader}
|
||||
renderOverlays={renderOverlays}
|
||||
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
|
||||
@@ -923,11 +950,14 @@ type SftpSidePanelInteractiveBodyProps = {
|
||||
hosts: Host[];
|
||||
hostWriteSource: Host[];
|
||||
connectedHosts: import("../domain/sftpConnectedHosts").SftpConnectedHostEntry[];
|
||||
sessions: TerminalSession[];
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
sftp: ReturnType<typeof useSftpState>;
|
||||
sftpRef: MutableRefObject<ReturnType<typeof useSftpState>>;
|
||||
sftpDefaultViewMode: "list" | "tree";
|
||||
activeHost: Host | null;
|
||||
activeSessionId?: string | null;
|
||||
focusedSessionId?: string | null;
|
||||
showWorkspaceHostHeader: boolean;
|
||||
renderOverlays: boolean;
|
||||
sftpDoubleClickBehavior: "open" | "transfer";
|
||||
@@ -965,11 +995,14 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
hosts,
|
||||
hostWriteSource,
|
||||
connectedHosts,
|
||||
sessions,
|
||||
updateHosts,
|
||||
sftp,
|
||||
sftpRef,
|
||||
sftpDefaultViewMode,
|
||||
activeHost,
|
||||
activeSessionId = null,
|
||||
focusedSessionId = null,
|
||||
showWorkspaceHostHeader,
|
||||
renderOverlays,
|
||||
hotkeyScheme,
|
||||
@@ -999,6 +1032,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
}) => {
|
||||
const panelRootRef = useRef<HTMLDivElement>(null);
|
||||
const dialogActionScopeIdRef = useRef(`sftp-side-panel:${crypto.randomUUID()}`);
|
||||
const terminalBackend = useTerminalBackend();
|
||||
const [hasPaneFocus, setHasPaneFocus] = useState(false);
|
||||
const [pendingFollowOverride, setPendingFollowOverride] = useState<{
|
||||
hostId: string;
|
||||
@@ -1234,6 +1268,27 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
}
|
||||
}, [connectionId, connectionPath, sftp.leftPane.loading]);
|
||||
|
||||
// Match toolbar path semantics: keep the last confirmed path while navigateTo
|
||||
// has optimistically replaced connection.currentPath during an uncached load.
|
||||
const confirmedLocatePathRef = useRef(connectionPath ?? "");
|
||||
const prevLocateConnectionIdRef = useRef(connectionId ?? undefined);
|
||||
const [confirmedLocatePath, setConfirmedLocatePath] = useState(connectionPath ?? "");
|
||||
useEffect(() => {
|
||||
const previousConnectionId = prevLocateConnectionIdRef.current;
|
||||
prevLocateConnectionIdRef.current = connectionId ?? undefined;
|
||||
setConfirmedLocatePath((previousDisplayPath) => {
|
||||
const next = getNextSftpToolbarDisplayPath({
|
||||
previousDisplayPath,
|
||||
previousConnectionId,
|
||||
connectionId: connectionId ?? undefined,
|
||||
currentPath: connectionPath ?? undefined,
|
||||
loading: sftp.leftPane.loading,
|
||||
});
|
||||
confirmedLocatePathRef.current = next;
|
||||
return next;
|
||||
});
|
||||
}, [connectionId, connectionPath, sftp.leftPane.loading]);
|
||||
|
||||
const handleGoToTerminalCwd = useCallback(async () => {
|
||||
if (!onGetTerminalCwd) return;
|
||||
const cwd = await onGetTerminalCwd({ preferFreshBackend: true });
|
||||
@@ -1248,6 +1303,95 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
}
|
||||
}, [onGetTerminalCwd, sftpRef]);
|
||||
|
||||
const locatePathInTerminalContext = useMemo(() => {
|
||||
const connection = sftp.leftPane.connection;
|
||||
const locateSessionId = resolveLocateSftpPathSessionId({
|
||||
activeSessionId,
|
||||
focusedSessionId,
|
||||
});
|
||||
const session = sessions.find((candidate) => candidate.id === locateSessionId) ?? null;
|
||||
const host = displayHost ?? activeHost;
|
||||
const isNetworkDevice = host?.deviceType === "network"
|
||||
|| classifyDistroId(host?.distro) === "network-device";
|
||||
return {
|
||||
path: confirmedLocatePath || connection?.currentPath,
|
||||
sessionId: locateSessionId,
|
||||
sessionStatus: session?.status,
|
||||
sessionHostId: session?.hostId,
|
||||
sftpHostId: connection?.hostId,
|
||||
sftpIsLocal: Boolean(connection?.isLocal),
|
||||
protocol: session?.protocol ?? host?.protocol,
|
||||
shellType: session?.shellType,
|
||||
isNetworkDevice,
|
||||
moshEnabled: session?.moshEnabled,
|
||||
etEnabled: session?.etEnabled,
|
||||
sessionHostname: session?.hostname,
|
||||
sessionUsername: session?.username,
|
||||
sessionPort: session?.port,
|
||||
sftpHostname: host?.hostname,
|
||||
sftpUsername: host?.username,
|
||||
sftpPort: host?.port,
|
||||
};
|
||||
}, [
|
||||
activeHost,
|
||||
activeSessionId,
|
||||
confirmedLocatePath,
|
||||
displayHost,
|
||||
focusedSessionId,
|
||||
sessions,
|
||||
sftp.leftPane.connection,
|
||||
]);
|
||||
|
||||
const canLocatePathInTerminal = canLocateSftpPathInTerminal(locatePathInTerminalContext);
|
||||
|
||||
const handleLocatePathInTerminal = useCallback(() => {
|
||||
const connection = sftpRef.current.leftPane.connection;
|
||||
const locateSessionId = resolveLocateSftpPathSessionId({
|
||||
activeSessionId,
|
||||
focusedSessionId,
|
||||
});
|
||||
const session = sessions.find((candidate) => candidate.id === locateSessionId) ?? null;
|
||||
const host = displayHost ?? activeHost;
|
||||
const isNetworkDevice = host?.deviceType === "network"
|
||||
|| classifyDistroId(host?.distro) === "network-device";
|
||||
const action = resolveLocateSftpPathInTerminalAction({
|
||||
// Prefer the path shown in the toolbar, not an in-flight optimistic cwd.
|
||||
path: confirmedLocatePathRef.current || connection?.currentPath,
|
||||
sessionId: locateSessionId,
|
||||
sessionStatus: session?.status,
|
||||
sessionHostId: session?.hostId,
|
||||
sftpHostId: connection?.hostId,
|
||||
sftpIsLocal: Boolean(connection?.isLocal),
|
||||
protocol: session?.protocol ?? host?.protocol,
|
||||
shellType: session?.shellType,
|
||||
isNetworkDevice,
|
||||
moshEnabled: session?.moshEnabled,
|
||||
etEnabled: session?.etEnabled,
|
||||
sessionHostname: session?.hostname,
|
||||
sessionUsername: session?.username,
|
||||
sessionPort: session?.port,
|
||||
sftpHostname: host?.hostname,
|
||||
sftpUsername: host?.username,
|
||||
sftpPort: host?.port,
|
||||
});
|
||||
if (!action) return;
|
||||
// Never inject cd into a password/sudo prompt (same guard as snippets/broadcast).
|
||||
if (isTerminalSensitiveInputActive(action.sessionId)) return;
|
||||
// Only submit at an idle shell prompt -- never append into typed input or a TUI.
|
||||
if (!isTerminalReadyForCommandInjection(action.sessionId)) return;
|
||||
terminalBackend.writeToSession(action.sessionId, action.data, { automated: true });
|
||||
scheduleDeferredTerminalFocus(onRequestTerminalFocus);
|
||||
}, [
|
||||
activeHost,
|
||||
activeSessionId,
|
||||
displayHost,
|
||||
focusedSessionId,
|
||||
onRequestTerminalFocus,
|
||||
sessions,
|
||||
sftpRef,
|
||||
terminalBackend,
|
||||
]);
|
||||
|
||||
const syncFollowToTerminalCwd = useCallback(async () => {
|
||||
if (!onGetTerminalCwd || !effectiveFollowTerminalCwd || !canFollowTerminalCwd) {
|
||||
return;
|
||||
@@ -1644,6 +1788,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
forceActive
|
||||
onToggleShowHiddenFiles={() => handleToggleHiddenFiles(pane.id)}
|
||||
onGoToTerminalCwd={onGetTerminalCwd ? handleGoToTerminalCwd : undefined}
|
||||
onLocatePathInTerminal={canLocatePathInTerminal ? handleLocatePathInTerminal : undefined}
|
||||
followTerminalCwd={canFollowTerminalCwd ? effectiveFollowTerminalCwd : undefined}
|
||||
onToggleFollowTerminalCwd={canFollowTerminalCwd ? handleToggleFollowTerminalCwd : undefined}
|
||||
/>
|
||||
@@ -1723,8 +1868,10 @@ const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps):
|
||||
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
|
||||
prev.activeHost === next.activeHost &&
|
||||
prev.activeSessionId === next.activeSessionId &&
|
||||
prev.focusedSessionId === next.focusedSessionId &&
|
||||
prev.showWorkspaceHostHeader === next.showWorkspaceHostHeader &&
|
||||
prev.isVisible === next.isVisible &&
|
||||
prev.ownerPanelOpen === next.ownerPanelOpen &&
|
||||
prev.renderOverlays === next.renderOverlays &&
|
||||
prev.pendingUpload?.requestId === next.pendingUpload?.requestId &&
|
||||
prev.onPendingUploadHandled === next.onPendingUploadHandled &&
|
||||
|
||||
@@ -126,8 +126,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
...fileWatchHandlers,
|
||||
transferOwnerId: "main-sftp-view",
|
||||
// Main SFTP page stays interactive while mounted so top-tab switches
|
||||
// (e.g. Terminal ↔ SFTP) must not soft-close every tab's session;
|
||||
// the terminal side panel still parks when its panel is hidden.
|
||||
// (e.g. Terminal ↔ SFTP) must not soft-close every tab's session.
|
||||
// The terminal side panel parks only after the panel is closed (not when
|
||||
// switching History/System while the chrome stays open).
|
||||
// Bulk transfers use dedicated pool sessions regardless.
|
||||
interactive: true,
|
||||
useCompressedUpload: sftpUseCompressedUpload,
|
||||
|
||||
+163
-21
@@ -117,6 +117,7 @@ import {
|
||||
shouldUseFreshSshConnectionForAutomation,
|
||||
} from "@/domain/hostConnectScripts.ts";
|
||||
import { isVaultInitialized } from "@/application/state/vaultInitStore.ts";
|
||||
import { useVaultSnapshotField } from "@/application/state/vaultSnapshotStore.ts";
|
||||
import { netcattyBridge } from "@/infrastructure/services/netcattyBridge.ts";
|
||||
import { ScriptExecutionOverlay } from "./terminal/ScriptExecutionOverlay";
|
||||
import { isScriptSnippet } from "@/domain/snippetScript.ts";
|
||||
@@ -144,6 +145,9 @@ import {
|
||||
} from "./terminal/runtime/createXTermRuntime";
|
||||
import { clearKittyKeyboardBroadcastSession } from "./terminal/runtime/kittyKeyboardBroadcast";
|
||||
import { registerTerminalSensitiveInputReader } from "./terminal/runtime/terminalSensitiveInputRegistry";
|
||||
import { registerTerminalCommandInjectionReadyReader } from "./terminal/runtime/terminalCommandInjectionReadyRegistry";
|
||||
import { isIdleShellReadyForCommandInjection } from "../domain/terminalCommandInjectionReady";
|
||||
import { detectPrompt } from "./terminal/autocomplete/promptDetector";
|
||||
import { applyUserCursorPreference } from "./terminal/runtime/cursorPreference";
|
||||
import { terminalAltKeyOptions } from "./terminal/runtime/altKeyOptions";
|
||||
import {
|
||||
@@ -221,8 +225,16 @@ import {
|
||||
} from "./terminal/terminalRuntimeMount";
|
||||
import type { CreateXTermRuntimeContext } from "./terminal/runtime/createXTermRuntime";
|
||||
import { TerminalView } from "./terminal/TerminalView";
|
||||
import {
|
||||
cancelConnectAutomationBatch,
|
||||
createConnectAutomationBatch,
|
||||
trackConnectAutomationStop,
|
||||
type ConnectAutomationBatch,
|
||||
} from "./terminal/connectAutomationBatch";
|
||||
import {
|
||||
getInitialTerminalStatus,
|
||||
resolveTerminalVaultInitialized,
|
||||
shouldResetConnectAutomationOnReconnect,
|
||||
shouldSuppressHostStartupCommandOnReconnect,
|
||||
shouldStartTerminalBackend,
|
||||
} from "./terminal/restoredSessionGate";
|
||||
@@ -246,6 +258,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
snippets,
|
||||
snippetPackages = [],
|
||||
compactToolbar = false,
|
||||
onDeleteSnippets,
|
||||
lineTimestampsAvailable = true,
|
||||
chainHosts = EMPTY_CHAIN_HOSTS,
|
||||
appearanceTheme,
|
||||
@@ -265,6 +278,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
sessionId,
|
||||
workspaceId,
|
||||
restoreState,
|
||||
vaultInitializedOverride,
|
||||
pendingInitialCwd,
|
||||
shellType,
|
||||
lastCwd,
|
||||
@@ -352,9 +366,17 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const message = openExternalErrorTextRef.current;
|
||||
toast.error(message.body, message.title);
|
||||
}, []);
|
||||
// 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 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);
|
||||
@@ -531,6 +553,19 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const onTerminalDataCaptureRef = useRef(onTerminalDataCapture);
|
||||
const onSessionExitRef = useRef(onSessionExit);
|
||||
const commandBufferRef = useRef<string>("");
|
||||
useEffect(() => registerTerminalCommandInjectionReadyReader(sessionId, () => {
|
||||
const term = termRef.current;
|
||||
if (!term) return false;
|
||||
const prompt = detectPrompt(term);
|
||||
return isIdleShellReadyForCommandInjection({
|
||||
sensitiveInputActive: passwordPromptActiveRef.current,
|
||||
hasLiveTerminal: true,
|
||||
alternateScreenActive: isTerminalAlternateScreenActive(term),
|
||||
isAtPrompt: prompt.isAtPrompt,
|
||||
userInputLength: prompt.userInput.length,
|
||||
pendingTypedInputLength: commandBufferRef.current.length,
|
||||
});
|
||||
}), [sessionId]);
|
||||
const promptLineBreakStateRef = useRef<PromptLineBreakState>(createPromptLineBreakState());
|
||||
const [hasMouseTracking, setHasMouseTracking] = useState(false);
|
||||
const mouseTrackingRef = useRef(false);
|
||||
@@ -754,7 +789,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const sudoHintRef = useRef<((active: boolean) => boolean) | undefined>(undefined);
|
||||
|
||||
const terminalBackend = useTerminalBackend();
|
||||
const { startManualSessionLog, stopManualSessionLog, getManualSessionLogStatus } = useSessionLogBackend();
|
||||
const {
|
||||
chooseManualSessionLogPath,
|
||||
startManualSessionLog,
|
||||
stopManualSessionLog,
|
||||
getManualSessionLogStatus,
|
||||
} = useSessionLogBackend();
|
||||
const {
|
||||
resizeSession,
|
||||
receiveSerialYmodem,
|
||||
@@ -2485,9 +2525,19 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
if (!shouldEvaluateConnect && !hasPendingWork) return;
|
||||
if (connectScriptsInFlightRef.current) return;
|
||||
|
||||
const batch = createConnectAutomationBatch();
|
||||
connectScriptsBatchRef.current = batch;
|
||||
connectScriptsInFlightRef.current = true;
|
||||
const batchStillActive = () => (
|
||||
!batch.controller.signal.aborted && connectScriptsBatchRef.current === batch
|
||||
);
|
||||
|
||||
// Defer until xterm has rendered login output and the main-process output tap
|
||||
// has populated SessionOutputBuffer (avoids waitForPrompt racing an empty buffer).
|
||||
let timerFired = false;
|
||||
const timer = window.setTimeout(() => {
|
||||
timerFired = true;
|
||||
if (!batchStillActive()) return;
|
||||
const runPending = Boolean(pendingOne);
|
||||
const connectQueueNow = connectScriptsConsumedRef.current
|
||||
? []
|
||||
@@ -2516,6 +2566,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
})) {
|
||||
connectScriptsConsumedRef.current = true;
|
||||
}
|
||||
if (connectScriptsBatchRef.current === batch) {
|
||||
connectScriptsBatchRef.current = null;
|
||||
connectScriptsInFlightRef.current = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2524,11 +2578,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
connectQueueNow.map((item) => item.id).filter((id): id is string => Boolean(id)),
|
||||
);
|
||||
|
||||
connectScriptsInFlightRef.current = true;
|
||||
|
||||
void runConnectScriptsSequential({
|
||||
scripts: scriptsToRun,
|
||||
sessionId,
|
||||
signal: batch.controller.signal,
|
||||
onCancelableRunChange: (stopCurrentRun) => (
|
||||
trackConnectAutomationStop(batch, stopCurrentRun)
|
||||
),
|
||||
sessionMeta: {
|
||||
connected: true,
|
||||
name: scriptSessionName,
|
||||
@@ -2536,6 +2592,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
username: host.username,
|
||||
},
|
||||
onScriptComplete: (snippet) => {
|
||||
if (!batchStillActive()) return;
|
||||
if (snippet.id && connectIdsInBatch.has(snippet.id)) {
|
||||
connectScriptsCompletedIdsRef.current.add(snippet.id);
|
||||
}
|
||||
@@ -2549,6 +2606,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
if (!batchStillActive()) return;
|
||||
const resolvedAfterRun = resolveConnectScriptsForHost(host, snippets);
|
||||
const doneAfterRun = resolvedAfterRun.length === 0
|
||||
|| resolvedAfterRun.every(
|
||||
@@ -2559,6 +2617,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
})
|
||||
.catch(async (err) => {
|
||||
if (!batchStillActive()) return;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
toast.error(message.includes('Observer mode') ? t('scripts.observer.blocked') : message);
|
||||
connectScriptsConsumedRef.current = true;
|
||||
@@ -2574,6 +2633,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
await runConnectScriptsSequential({
|
||||
scripts: [pendingScriptToMark],
|
||||
sessionId,
|
||||
signal: batch.controller.signal,
|
||||
onCancelableRunChange: (stopCurrentRun) => (
|
||||
trackConnectAutomationStop(batch, stopCurrentRun)
|
||||
),
|
||||
sessionMeta: {
|
||||
connected: true,
|
||||
name: scriptSessionName,
|
||||
@@ -2581,6 +2644,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
username: host.username,
|
||||
},
|
||||
onScriptComplete: (snippet) => {
|
||||
if (!batchStillActive()) return;
|
||||
if (snippet.id) {
|
||||
pendingScriptRunIdRef.current = snippet.id;
|
||||
} else {
|
||||
@@ -2589,16 +2653,32 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
},
|
||||
});
|
||||
} catch (pendingErr) {
|
||||
if (!batchStillActive()) return;
|
||||
const pendingMessage = pendingErr instanceof Error ? pendingErr.message : String(pendingErr);
|
||||
toast.error(pendingMessage.includes('Observer mode') ? t('scripts.observer.blocked') : pendingMessage);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
connectScriptsInFlightRef.current = false;
|
||||
if (
|
||||
!batch.controller.signal.aborted
|
||||
&& connectScriptsBatchRef.current === batch
|
||||
) {
|
||||
connectScriptsBatchRef.current = null;
|
||||
connectScriptsInFlightRef.current = false;
|
||||
}
|
||||
});
|
||||
}, 400);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
if (!timerFired && connectScriptsBatchRef.current === batch) {
|
||||
batch.controller.abort();
|
||||
connectScriptsBatchRef.current = null;
|
||||
connectScriptsInFlightRef.current = false;
|
||||
} else if (statusRef.current !== "connected") {
|
||||
void cancelConnectAutomationBatch(batch).catch(() => {});
|
||||
}
|
||||
};
|
||||
}, [effectiveTerminalProtocol, host, isPendingScriptAlreadyHandled, moshShellReady, pendingScript, pendingScriptId, scriptSessionName, sessionId, snippets, status, t]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -3326,20 +3406,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return;
|
||||
}
|
||||
if (!termRef.current) return;
|
||||
if (mode === "manual") {
|
||||
clearAutoReconnect();
|
||||
prepareRestoredReconnect();
|
||||
// A clone's first connection can fail (auth/host-key/transport) before the
|
||||
// inherited `cd` is consumed. prepareRestoredReconnect() just cleared the
|
||||
// intent for non-restored sessions, so re-arm it here; the callback no-ops
|
||||
// once the cwd was consumed or when there is no pending inherited cwd.
|
||||
prepareInitialCwdIntent();
|
||||
} else {
|
||||
restoreCwdIntentRef.current = null;
|
||||
suppressHostStartupCommandRef.current = shouldSuppressHostStartupCommandOnReconnect("automatic");
|
||||
}
|
||||
// Claim the retry before awaiting close. A close/cancel/unmount during the
|
||||
// awaited backend cleanup invalidates this token and stops the continuation.
|
||||
// Claim the retry before awaiting either script cancellation or backend
|
||||
// cleanup. A second reconnect/cancel invalidates this continuation.
|
||||
const retryToken = Symbol("retry");
|
||||
retryTokenRef.current = retryToken;
|
||||
reconnectPreparationTokenRef.current = retryToken;
|
||||
@@ -3350,6 +3418,59 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const connectAutomationBatch = connectScriptsBatchRef.current;
|
||||
if (connectAutomationBatch) {
|
||||
try {
|
||||
await cancelConnectAutomationBatch(connectAutomationBatch);
|
||||
} catch (error) {
|
||||
finishReconnectPreparation();
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
toast.error(message);
|
||||
if (mode === "auto" && retryTokenStillCurrent()) {
|
||||
// Return to disconnected so the existing auto-reconnect loop can
|
||||
// schedule another attempt, including another exact stop request.
|
||||
updateStatus("disconnected");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!retryTokenStillCurrent()) {
|
||||
finishReconnectPreparation();
|
||||
return;
|
||||
}
|
||||
if (connectScriptsBatchRef.current === connectAutomationBatch) {
|
||||
connectScriptsBatchRef.current = null;
|
||||
connectScriptsInFlightRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === "manual") {
|
||||
clearAutoReconnect();
|
||||
// Manual reconnect skips the disconnected status transition that normally
|
||||
// clears these refs, so onConnect scripts would otherwise stay consumed.
|
||||
if (shouldResetConnectAutomationOnReconnect(
|
||||
restoreState === "restored-disconnected" ? "restored" : "manual",
|
||||
)) {
|
||||
// The prior batch is now stopped, so its callbacks cannot mutate these
|
||||
// newly allocated guards for the fresh manual connection.
|
||||
connectScriptsConsumedRef.current = false;
|
||||
connectScriptsCompletedIdsRef.current = new Set();
|
||||
connectScriptsInFlightRef.current = false;
|
||||
}
|
||||
prepareRestoredReconnect();
|
||||
// A clone's first connection can fail (auth/host-key/transport) before the
|
||||
// inherited `cd` is consumed. prepareRestoredReconnect() just cleared the
|
||||
// intent for non-restored sessions, so re-arm it here; the callback no-ops
|
||||
// once the cwd was consumed or when there is no pending inherited cwd.
|
||||
prepareInitialCwdIntent();
|
||||
} else {
|
||||
// An automatic reconnect replaces the transport but remains the same user
|
||||
// session. Stop old automation above, then preserve the existing policy of
|
||||
// not starting onConnect scripts again on the replacement transport.
|
||||
connectScriptsConsumedRef.current = true;
|
||||
restoreCwdIntentRef.current = null;
|
||||
suppressHostStartupCommandRef.current = shouldSuppressHostStartupCommandOnReconnect("automatic");
|
||||
}
|
||||
|
||||
try {
|
||||
await cleanupSession();
|
||||
} catch (error) {
|
||||
@@ -3539,13 +3660,31 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const startResult = await startManualSessionLog({
|
||||
// Choose the destination first, then re-sample terminal state. The save
|
||||
// dialog can stay open long enough for the user to enter/leave vim, so
|
||||
// alternate-screen / initial-line must be captured at stream activation.
|
||||
const chooseResult = await chooseManualSessionLogPath({
|
||||
sessionId: currentSessionId,
|
||||
sessionName: host.label || host.hostname || currentSessionId,
|
||||
preferredDirectory: sessionLog?.directory,
|
||||
format: sessionLog?.format,
|
||||
});
|
||||
if (!chooseResult?.success) {
|
||||
toast.error(chooseResult?.error || "Failed to start session log");
|
||||
return;
|
||||
}
|
||||
if (chooseResult.canceled || !chooseResult.selectionToken) return;
|
||||
|
||||
const startResult = await startManualSessionLog({
|
||||
sessionId: currentSessionId,
|
||||
sessionName: host.label || host.hostname || currentSessionId,
|
||||
selectionToken: chooseResult.selectionToken,
|
||||
format: sessionLog?.format,
|
||||
timestampsEnabled: sessionLog?.timestampsEnabled,
|
||||
initialLine: termRef.current ? getSessionLogInitialLine(termRef.current) : "",
|
||||
alternateScreenActive: termRef.current
|
||||
? isTerminalAlternateScreenActive(termRef.current)
|
||||
: false,
|
||||
});
|
||||
if (startResult?.success) {
|
||||
if (!startResult?.started && startResult?.canceled) return;
|
||||
@@ -3558,6 +3697,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
toast.error("Failed to toggle session log");
|
||||
}
|
||||
}, [
|
||||
chooseManualSessionLogPath,
|
||||
getManualSessionLogStatus,
|
||||
host.hostname,
|
||||
host.label,
|
||||
@@ -3611,6 +3751,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
snippets={snippets}
|
||||
snippetPackages={snippetPackages}
|
||||
onSnippetClick={(snippet) => { void executeSnippet(snippet); }}
|
||||
onDeleteSnippets={onDeleteSnippets}
|
||||
onOpenSFTP={handleOpenSFTP}
|
||||
onSendYmodem={isSerialConnection ? handleSendYmodem : undefined}
|
||||
onReceiveYmodem={isSerialConnection ? handleReceiveYmodem : undefined}
|
||||
@@ -3674,6 +3815,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
isSessionLogging,
|
||||
isWorkspaceComposeBarOpen,
|
||||
onCloseSession,
|
||||
onDeleteSnippets,
|
||||
onOpenScripts,
|
||||
onOpenHistory,
|
||||
onOpenTheme,
|
||||
@@ -4014,7 +4156,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onWake: wakeFromHibernateRuntime,
|
||||
});
|
||||
|
||||
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, chainHosts: resolvedChainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
|
||||
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, chainHosts: resolvedChainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, vaultInitialized, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -93,6 +93,7 @@ import {
|
||||
shouldCloseSftpSidePanel,
|
||||
shouldClearSftpPanelAfterTransferChange,
|
||||
shouldKeepSftpMountedAfterClose,
|
||||
shouldMarkSftpPaneClosed,
|
||||
shouldScheduleSftpRetainedPanelCleanup,
|
||||
terminalSftpTransferOwnerId,
|
||||
} from './terminalLayer/sftpPanelLifecycle';
|
||||
@@ -219,6 +220,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onCreateWorkspaceFromSessions,
|
||||
onAddSessionToWorkspace,
|
||||
onRequestAddToWorkspace,
|
||||
onAppendHostToWorkspace,
|
||||
onUpdateSplitSizes,
|
||||
onSetDraggingSessionId,
|
||||
onToggleWorkspaceViewMode,
|
||||
@@ -268,6 +270,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
setSidePanelOpenTabs,
|
||||
sidePanelOpenTabsRef,
|
||||
sidePanelLayouts,
|
||||
setSidePanelLayouts,
|
||||
sidePanelLayoutsRef,
|
||||
focusPane: focusSidePanelPaneForTab,
|
||||
splitPane: splitSidePanelPaneForTab,
|
||||
@@ -410,6 +413,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
lastSidePanelTabRef.current.set(tabId, targetPanel);
|
||||
|
||||
if (targetPanel === 'sftp') {
|
||||
sftpPaneClosedTabIdsRef.current.delete(tabId);
|
||||
const host = hostsRef.current.find(h => h.id === session.hostId);
|
||||
const hostWithOverrides: Host = host
|
||||
? {
|
||||
@@ -577,6 +581,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const sftpActiveTransfersByTabRef = useRef<Map<string, number>>(new Map());
|
||||
const sftpActiveExternalEditsByTabRef = useRef<Map<string, number>>(new Map());
|
||||
const sftpRetainedAfterCloseTabIdsRef = useRef<Set<string>>(new Set());
|
||||
const sftpPaneClosedTabIdsRef = useRef<Set<string>>(new Set());
|
||||
const sftpOpeningTabIdsRef = useRef<Set<string>>(new Set());
|
||||
const sftpRetainedCleanupTimersRef = useRef<Map<string, number>>(new Map());
|
||||
const sftpLastPathForSourceRef = useRef<Map<string, SftpRememberedLocation>>(new Map());
|
||||
@@ -612,6 +617,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
sftpActiveTransfersByTabRef.current.delete(tabId);
|
||||
sftpActiveExternalEditsByTabRef.current.delete(tabId);
|
||||
sftpRetainedAfterCloseTabIdsRef.current.delete(tabId);
|
||||
sftpPaneClosedTabIdsRef.current.delete(tabId);
|
||||
sftpOpeningTabIdsRef.current.delete(tabId);
|
||||
setSftpHostForTab(prev => {
|
||||
if (!prev.has(tabId)) return prev;
|
||||
@@ -829,6 +835,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
sftpRetainedCleanupTimersRef.current.delete(tabId);
|
||||
}
|
||||
sftpRetainedAfterCloseTabIdsRef.current.delete(tabId);
|
||||
sftpPaneClosedTabIdsRef.current.delete(tabId);
|
||||
|
||||
setSidePanelOpenTabs(prev => {
|
||||
const next = new Map(prev);
|
||||
@@ -1328,6 +1335,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
sftpRetainedCleanupTimersRef.current.delete(tabId);
|
||||
}
|
||||
sftpRetainedAfterCloseTabIdsRef.current.delete(tabId);
|
||||
sftpPaneClosedTabIdsRef.current.delete(tabId);
|
||||
}
|
||||
|
||||
// If switching to SFTP and no host is stored yet, resolve it
|
||||
@@ -1409,10 +1417,22 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const handleCloseSidePanelPane = useCallback((paneId: string) => {
|
||||
const tabId = activeTabIdRef.current;
|
||||
if (!tabId) return;
|
||||
if (closeSidePanelPaneForTab(tabId, paneId)) {
|
||||
const layout = sidePanelLayoutsRef.current.get(tabId);
|
||||
const closingPane = layout
|
||||
? collectSidePanelPanes(layout.root).find((pane) => pane.id === paneId)
|
||||
: undefined;
|
||||
const closesWholePanel = closeSidePanelPaneForTab(tabId, paneId);
|
||||
if (closesWholePanel) {
|
||||
handleCloseSidePanel();
|
||||
return;
|
||||
}
|
||||
}, [closeSidePanelPaneForTab, handleCloseSidePanel]);
|
||||
if (shouldMarkSftpPaneClosed({
|
||||
closingPaneTool: closingPane?.tool,
|
||||
closesWholePanel: closesWholePanel,
|
||||
})) {
|
||||
sftpPaneClosedTabIdsRef.current.add(tabId);
|
||||
}
|
||||
}, [closeSidePanelPaneForTab, handleCloseSidePanel, sidePanelLayoutsRef]);
|
||||
|
||||
const handleResizeSidePanelSplit = useCallback((splitId: string, sizes: number[]) => {
|
||||
const tabId = activeTabIdRef.current;
|
||||
@@ -2088,6 +2108,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onCopySession,
|
||||
onCopySessionToNewWindow,
|
||||
onRequestAddToWorkspace,
|
||||
onAppendHostToWorkspace,
|
||||
onSessionData,
|
||||
onSetDraggingSessionId,
|
||||
onSetWorkspaceFocusedSession,
|
||||
@@ -2136,6 +2157,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
setSystemMountedTabIds,
|
||||
setThemeMountedTabIds,
|
||||
setSidePanelOpenTabs,
|
||||
setSidePanelLayouts,
|
||||
setSidePanelWidth,
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpHostForTab,
|
||||
@@ -2153,6 +2175,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
sftpHostForTab,
|
||||
sftpInitialLocationForTab,
|
||||
sftpPendingUploadsForTab,
|
||||
sftpPaneClosedTabIdsRef,
|
||||
sftpRetainedAfterCloseTabIdsRef,
|
||||
sftpShowHiddenFiles,
|
||||
SftpSidePanel,
|
||||
sftpUseCompressedUpload,
|
||||
|
||||
@@ -133,9 +133,16 @@ test('resolveTerminalPopupReuseId uses the explicit reuse id from the prepared s
|
||||
|
||||
test('popup terminals resolve complete host config and pass jump hosts into Terminal', () => {
|
||||
assert.match(source, /proxyProfiles,\s+knownHosts,\s+snippets,\s+snippetPackages,\s+groupConfigs,/);
|
||||
assert.match(source, /deleteSelectedSnippets,/);
|
||||
assert.match(source, /resolveTerminalPopupHost\(config,\s*hosts,\s*\{\s+groupConfigs,\s+proxyProfiles,/);
|
||||
assert.match(source, /resolveTerminalChainHosts\(\{\s+host,\s+hosts,\s+groupConfigs,\s+proxyProfiles,/);
|
||||
assert.match(source, /chainHosts=\{chainHosts\}/);
|
||||
// Popup has no AppSideEffects listener; bulk delete must hit this vault instance.
|
||||
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', () => {
|
||||
|
||||
@@ -272,6 +272,7 @@ function TerminalPopupPageInner() {
|
||||
snippetPackages,
|
||||
groupConfigs,
|
||||
updateKnownHosts,
|
||||
deleteSelectedSnippets,
|
||||
} = useVaultState();
|
||||
const [config, setConfig] = useState<TerminalPopupPayload | null>(null);
|
||||
const [terminalReady, setTerminalReady] = useState(false);
|
||||
@@ -453,6 +454,7 @@ function TerminalPopupPageInner() {
|
||||
snippetPackages={snippetPackages}
|
||||
chainHosts={chainHosts}
|
||||
compactToolbar
|
||||
onDeleteSnippets={deleteSelectedSnippets}
|
||||
lineTimestampsAvailable={false}
|
||||
knownHosts={effectiveKnownHosts}
|
||||
onAddKnownHost={handleAddKnownHost}
|
||||
@@ -465,6 +467,7 @@ function TerminalPopupPageInner() {
|
||||
accentMode={settings.accentMode}
|
||||
customAccent={settings.customAccent}
|
||||
terminalSettings={settings.terminalSettings}
|
||||
vaultInitializedOverride={vaultInitialized}
|
||||
disableTerminalFontZoom={settings.disableTerminalFontZoom}
|
||||
sessionId={sessionId}
|
||||
startupCommand={isAttachMode ? undefined : config.startupCommand}
|
||||
|
||||
@@ -361,6 +361,14 @@ test("workspace session drag data is recognized with a dedicated drag type", ()
|
||||
assert.equal(getWorkspaceSessionDragId(transfer), "session-1");
|
||||
});
|
||||
|
||||
test("TopTabs wires host drops to workspace tabs without replacing tab reorder", () => {
|
||||
assert.match(topTabsSource, /appendHostFromWorkspaceDrop/);
|
||||
assert.match(topTabsSource, /workspaceMap\.has\(targetTabId\)/);
|
||||
assert.match(topTabsSource, /dropEffect = 'copy'/);
|
||||
assert.match(topTabsSource, /isHostDropTarget=\{hostDropWorkspaceId === workspace\.id\}/);
|
||||
assert.match(appViewSource, /<TopTabs[\s\S]*?onAppendHostToWorkspace=\{handleAppendHostToWorkspace\}/);
|
||||
});
|
||||
|
||||
test("workspace session drag id falls back to the legacy session id", () => {
|
||||
const transfer = {
|
||||
types: ["session-id"],
|
||||
|
||||
+89
-5
@@ -1,11 +1,17 @@
|
||||
import { Folder, FolderLock, Menu, MoreHorizontal, Plus, Settings, Sparkles } from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { fromEditorTabId, isEditorTabId, useActiveTabId } from '../application/state/activeTabStore';
|
||||
import { fromEditorTabId, isEditorTabId, toEditorTabId, useActiveTabId } from '../application/state/activeTabStore';
|
||||
import { topTabsSessionsEqual } from '../domain/topTabsSessionsEqual';
|
||||
import { isHostTreeWorkTabSurface } from '../application/app/workTabSurface';
|
||||
import { buildTabShortcutNumberById } from '../application/app/tabShortcutTargets';
|
||||
import { useShortcutModifierHeld } from '../application/state/useShortcutModifierHeld';
|
||||
import type { EditorTabChrome } from '../application/state/editorTabStore';
|
||||
import { collectSessionIds } from '../domain/workspace';
|
||||
import type { DynamicTabTitleMode } from '../domain/models';
|
||||
import {
|
||||
appendHostFromWorkspaceDrop,
|
||||
resolveFocusSidebarDragKind,
|
||||
} from '../domain/focusSidebarHostDrop';
|
||||
import type { DynamicTabTitleMode, KeyBinding } from '../domain/models';
|
||||
|
||||
import { getTopTabInsertionTarget, getWorkspaceSessionDragId, hasWorkspaceSessionDrag } from '../application/state/terminalDragData';
|
||||
import {
|
||||
@@ -15,6 +21,7 @@ import {
|
||||
} from '../application/state/terminalHostTreeStore';
|
||||
import type { LogView } from '../application/state/logViewState';
|
||||
import { useWindowControls } from '../application/state/useWindowControls';
|
||||
import { useSettingsChromeStore } from '../application/state/settingsChromeStore';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { Host, TerminalSession, Workspace } from '../types';
|
||||
import { cn } from '../lib/utils';
|
||||
@@ -153,8 +160,10 @@ interface TopTabsProps {
|
||||
sessionId: string,
|
||||
tabInsertionTarget?: { tabId: string; position: 'before' | 'after'; additionalTabIds?: readonly string[] },
|
||||
) => void;
|
||||
onAppendHostToWorkspace?: (workspaceId: string, hostId: string) => void;
|
||||
showSftpTab: boolean;
|
||||
showHostTreeSidebar: boolean;
|
||||
switchTabKeyBinding: Pick<KeyBinding, 'mac' | 'pc'> | null;
|
||||
dynamicTabTitleMode?: DynamicTabTitleMode;
|
||||
editorTabs: readonly EditorTabChrome[];
|
||||
pluginViewTabs: readonly PluginViewTab[];
|
||||
@@ -197,8 +206,10 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onEndSessionDrag,
|
||||
onReorderTabs,
|
||||
onRemoveSessionFromWorkspace,
|
||||
onAppendHostToWorkspace,
|
||||
showSftpTab,
|
||||
showHostTreeSidebar,
|
||||
switchTabKeyBinding,
|
||||
dynamicTabTitleMode,
|
||||
editorTabs,
|
||||
pluginViewTabs,
|
||||
@@ -208,6 +219,28 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { maximize, isFullscreen, onFullscreenChanged } = useWindowControls();
|
||||
const {
|
||||
hotkeyScheme,
|
||||
showTabNumberBadges,
|
||||
shellOnlyTabNumberShortcuts,
|
||||
} = useSettingsChromeStore();
|
||||
const switchTabKey = hotkeyScheme === 'mac'
|
||||
? switchTabKeyBinding?.mac ?? null
|
||||
: hotkeyScheme === 'pc'
|
||||
? switchTabKeyBinding?.pc ?? null
|
||||
: null;
|
||||
const shortcutModifierHeld = useShortcutModifierHeld(switchTabKey, hotkeyScheme);
|
||||
const tabShortcutNumbers = useMemo(() => {
|
||||
// Keep the tab bar quiet until the modifier for the active shortcut scheme
|
||||
// is held, while retaining the existing setting as the master toggle.
|
||||
if (!showTabNumberBadges || hotkeyScheme === 'disabled' || !shortcutModifierHeld) return null;
|
||||
return buildTabShortcutNumberById({
|
||||
showSftpTab,
|
||||
shellOnlyTabNumberShortcuts,
|
||||
orderedTabs,
|
||||
editorTabIds: editorTabs.map((tab) => toEditorTabId(tab.id)),
|
||||
});
|
||||
}, [hotkeyScheme, showTabNumberBadges, showSftpTab, shellOnlyTabNumberShortcuts, shortcutModifierHeld, orderedTabs, editorTabs]);
|
||||
const isHostTreeOpen = useTerminalHostTreeOpen();
|
||||
const hostTreeLayoutWidth = useTerminalHostTreeLayoutWidth();
|
||||
const toggleHostTree = useToggleTerminalHostTree();
|
||||
@@ -233,6 +266,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
// Tab reorder drag state
|
||||
const [dropIndicator, setDropIndicator] = useState<{ tabId: string; position: 'before' | 'after' } | null>(null);
|
||||
const [isDraggingForReorder, setIsDraggingForReorder] = useState(false);
|
||||
const [hostDropWorkspaceId, setHostDropWorkspaceId] = useState<string | null>(null);
|
||||
const draggedTabIdRef = useRef<string | null>(null);
|
||||
const [isWindowFullscreen, setIsWindowFullscreen] = useState(false);
|
||||
|
||||
@@ -494,12 +528,28 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
draggedTabIdRef.current = null;
|
||||
setDropIndicator(null);
|
||||
setIsDraggingForReorder(false);
|
||||
setHostDropWorkspaceId(null);
|
||||
onEndSessionDrag();
|
||||
}, [onEndSessionDrag]);
|
||||
|
||||
const handleTabDragOver = useCallback((e: React.DragEvent, tabId: string) => {
|
||||
const dragKind = resolveFocusSidebarDragKind({ types: e.dataTransfer.types });
|
||||
if (dragKind === 'host-append') {
|
||||
if (!onAppendHostToWorkspace || !workspaceMap.has(tabId)) {
|
||||
setHostDropWorkspaceId(null);
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
setDropIndicator(null);
|
||||
setHostDropWorkspaceId(tabId);
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
setHostDropWorkspaceId(null);
|
||||
|
||||
if (hasWorkspaceSessionDrag(e.dataTransfer)) {
|
||||
setDropIndicator(null);
|
||||
@@ -517,16 +567,40 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
|
||||
// Always update drop indicator on drag over to ensure it doesn't get stuck
|
||||
setDropIndicator({ tabId, position });
|
||||
}, []);
|
||||
}, [onAppendHostToWorkspace, workspaceMap]);
|
||||
|
||||
const handleTabDragLeave = useCallback((_e: React.DragEvent) => {
|
||||
const handleTabDragLeave = useCallback((e: React.DragEvent) => {
|
||||
const next = e.relatedTarget;
|
||||
if (next instanceof Node && e.currentTarget.contains(next)) return;
|
||||
setHostDropWorkspaceId(null);
|
||||
// Don't clear drop indicator on drag leave - let onDragOver manage it
|
||||
// This prevents the indicator from flickering/disappearing during fast drags
|
||||
// The indicator will be cleared when drag ends or on drop
|
||||
}, []);
|
||||
|
||||
const handleTabDrop = useCallback((e: React.DragEvent, targetTabId: string) => {
|
||||
const dragKind = resolveFocusSidebarDragKind({ types: e.dataTransfer.types });
|
||||
if (dragKind === 'host-append') {
|
||||
const handled = Boolean(
|
||||
onAppendHostToWorkspace
|
||||
&& workspaceMap.has(targetTabId)
|
||||
&& appendHostFromWorkspaceDrop({
|
||||
types: e.dataTransfer.types,
|
||||
getData: (type) => e.dataTransfer.getData(type),
|
||||
workspaceId: targetTabId,
|
||||
onAppendHostToWorkspace,
|
||||
}),
|
||||
);
|
||||
setHostDropWorkspaceId(null);
|
||||
setDropIndicator(null);
|
||||
if (!handled) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
setHostDropWorkspaceId(null);
|
||||
if (hasWorkspaceSessionDrag(e.dataTransfer)) {
|
||||
const draggedSessionId = getWorkspaceSessionDragId(e.dataTransfer);
|
||||
const draggedSession = sessions.find((s) => s.id === draggedSessionId);
|
||||
@@ -555,7 +629,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
|
||||
setDropIndicator(null);
|
||||
setIsDraggingForReorder(false);
|
||||
}, [dropIndicator, onEndSessionDrag, onRemoveSessionFromWorkspace, onReorderTabs, sessions, workspaces]);
|
||||
}, [dropIndicator, onAppendHostToWorkspace, onEndSessionDrag, onRemoveSessionFromWorkspace, onReorderTabs, sessions, workspaceMap, workspaces]);
|
||||
|
||||
const handleTabBarDrop = useCallback((e: React.DragEvent) => {
|
||||
if (!hasWorkspaceSessionDrag(e.dataTransfer)) return;
|
||||
@@ -752,6 +826,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onTabDragLeave={handleTabDragLeave}
|
||||
onTabDrop={handleTabDrop}
|
||||
tabAnimationClass={getTabAnimationClass(tabId)}
|
||||
shortcutNumber={tabShortcutNumbers?.get(tabId)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -776,6 +851,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onTabDragLeave={handleTabDragLeave}
|
||||
onTabDrop={handleTabDrop}
|
||||
tabAnimationClass={getTabAnimationClass(tabId)}
|
||||
shortcutNumber={tabShortcutNumbers?.get(tabId)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -811,6 +887,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
dynamicTabTitleMode={dynamicTabTitleMode}
|
||||
t={t}
|
||||
tabAnimationClass={getTabAnimationClass(session.id)}
|
||||
shortcutNumber={tabShortcutNumbers?.get(session.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -842,6 +919,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
shiftStyle={shiftStyle}
|
||||
showDropIndicatorBefore={showDropIndicatorBefore}
|
||||
showDropIndicatorAfter={showDropIndicatorAfter}
|
||||
isHostDropTarget={hostDropWorkspaceId === workspace.id}
|
||||
onTabDragStart={handleTabDragStart}
|
||||
onTabDragEnd={handleTabDragEnd}
|
||||
onTabDragOver={handleTabDragOver}
|
||||
@@ -854,6 +932,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
renderBulkCloseItems={renderBulkCloseItems}
|
||||
t={t}
|
||||
tabAnimationClass={getTabAnimationClass(workspace.id)}
|
||||
shortcutNumber={tabShortcutNumbers?.get(workspace.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -882,6 +961,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onTabDrop={handleTabDrop}
|
||||
t={t}
|
||||
tabAnimationClass={getTabAnimationClass(logView.id)}
|
||||
shortcutNumber={tabShortcutNumbers?.get(logView.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -933,6 +1013,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
icon={<FolderLock size={14} />}
|
||||
className="rounded"
|
||||
compact={rootTabsCompact}
|
||||
shortcutNumber={tabShortcutNumbers?.get('vault')}
|
||||
/>
|
||||
{showSftpTab && (
|
||||
<RootTopTab
|
||||
@@ -941,6 +1022,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
icon={<Folder size={14} />}
|
||||
className="rounded-t-md"
|
||||
compact={rootTabsCompact}
|
||||
shortcutNumber={tabShortcutNumbers?.get('sftp')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -1174,6 +1256,7 @@ export const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean
|
||||
prev.onCopySession === next.onCopySession &&
|
||||
prev.onCopySessionToNewWindow === next.onCopySessionToNewWindow &&
|
||||
prev.onEditHost === next.onEditHost &&
|
||||
prev.onAppendHostToWorkspace === next.onAppendHostToWorkspace &&
|
||||
prev.onCopyWorkspace === next.onCopyWorkspace &&
|
||||
prev.onOpenSettings === next.onOpenSettings &&
|
||||
prev.externalMcpEnabled === next.externalMcpEnabled &&
|
||||
@@ -1186,6 +1269,7 @@ export const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean
|
||||
prev.onThemeChange === next.onThemeChange &&
|
||||
prev.showSftpTab === next.showSftpTab &&
|
||||
prev.showHostTreeSidebar === next.showHostTreeSidebar &&
|
||||
prev.switchTabKeyBinding === next.switchTabKeyBinding &&
|
||||
prev.dynamicTabTitleMode === next.dynamicTabTitleMode &&
|
||||
prev.hostById === next.hostById
|
||||
);
|
||||
|
||||
Loaded 100 of 389 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user