mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-10 16:21:22 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6f32b69a6 | ||
|
|
710c0cb9bb | ||
|
|
7c7f9bbf48 | ||
|
|
016e5bb6f0 | ||
|
|
7937aca5b4 | ||
|
|
d6f77a3429 | ||
|
|
f616bb067e |
No files matched your search
@@ -170,6 +170,3 @@ build_with_vs2022.bat
|
||||
/resources/et/*/et.exe
|
||||
/resources/et/*/et-*-dlls/
|
||||
/resources/et/*/*.dll
|
||||
|
||||
# Local plugin example staging and bundles
|
||||
/examples/plugins/*/.package/
|
||||
@@ -127,11 +127,6 @@ second private RPC shape when extending the host.
|
||||
- `PluginRpcRouter` owns correlation, cancellation, deadlines, stream credit and
|
||||
protocol-failure containment. Runtime identity is assigned by the host and is
|
||||
never accepted from request parameters.
|
||||
- Custom views use only host-rendered DOM: plugin scripts stay in the isolated
|
||||
runtime; the host validates incoming trees, styles, resources and events before
|
||||
rendering. Never reintroduce native child views, iframe UI, or plugin code in
|
||||
the host renderer. See `docs/plugin-platform/host-rendered-ui.md` for authoring,
|
||||
lifecycle, limits and verification.
|
||||
- App quit goes through `runPluginShutdown()` after the dirty-editor guard; do
|
||||
not add another independent quit interception path.
|
||||
|
||||
|
||||
@@ -50,9 +50,6 @@ function SettingsI18nProvider({ children }: { children: React.ReactNode }) {
|
||||
* shell bags; AppSideEffects owns reactive effects + local UI publishing.
|
||||
*/
|
||||
function App() {
|
||||
useEffect(() => {
|
||||
performance.mark('netcatty:renderer:app-mounted');
|
||||
}, []);
|
||||
return (
|
||||
<AppLocalStateProvider>
|
||||
<MonitorRuntimeHost />
|
||||
|
||||
@@ -141,7 +141,6 @@ Catty Agent にサーバーの健全性チェックを依頼すると、適切
|
||||
### 📁 SFTP + 内蔵エディタ
|
||||
- **ファイル作業** — ドラッグ&ドロップでアップロード/ダウンロード
|
||||
- **その場で編集** — 内蔵エディタで小さな修正を素早く
|
||||
- **リモート圧縮** — ファイルやフォルダーを1つ以上選び、右クリックの **Compress...** から、表示された保存先に名前を指定して `.tar.gz` または `.zip` アーカイブを作成できます。サーバーには選択した形式に対応する `tar`/`gzip` または `zip` が必要です。
|
||||
|
||||
### 🎨 パーソナライズ
|
||||
- **カスタムテーマ** — UI の見た目を好みに調整
|
||||
|
||||
@@ -144,7 +144,6 @@ If you regularly work with a fleet of servers, Netcatty is built for speed and f
|
||||
### 📁 SFTP + Built-in Editor
|
||||
- **File workflows** — drag & drop uploads/downloads
|
||||
- **Edit in place** — built-in editor for quick changes
|
||||
- **Create remote archives** — select files or folders, right-click **Compress...**, and create a named `.tar.gz` or `.zip` archive in the displayed destination directory; the server needs `tar`/`gzip` or `zip` for the chosen format.
|
||||
|
||||
### 🎨 Personalization
|
||||
- **Custom themes** — tune the app appearance to your taste
|
||||
|
||||
@@ -141,7 +141,6 @@
|
||||
### 📁 SFTP + 内置编辑器
|
||||
- **文件工作流** —— 拖拽上传/下载更直观
|
||||
- **就地编辑** —— 内置编辑器快速修改文件
|
||||
- **远程压缩** —— 单选或多选文件、文件夹,右键选择“压缩…”,在对话框显示的保存目录生成指定名称的 `.tar.gz` 或 `.zip` 压缩包;服务器需安装对应的 `tar`/`gzip` 或 `zip`。
|
||||
|
||||
### 🎨 个性化
|
||||
- **自定义主题** —— 按喜好调整应用外观
|
||||
|
||||
@@ -141,7 +141,6 @@
|
||||
### 📁 SFTP + 內建編輯器
|
||||
- **檔案工作流** —— 拖放上傳/下載
|
||||
- **就地編輯** —— 內建編輯器,快速修改檔案
|
||||
- **遠端壓縮** —— 單選或多選檔案、資料夾,按右鍵選擇「壓縮…」,在對話方塊顯示的儲存目錄建立指定名稱的 `.tar.gz` 或 `.zip` 壓縮檔;伺服器需安裝對應的 `tar`/`gzip` 或 `zip`。
|
||||
|
||||
### 🎨 個人化
|
||||
- **自訂主題** —— 依你的喜好調整外觀
|
||||
|
||||
@@ -377,7 +377,7 @@ test('move-focus shortcut cannot send input behind a magnified pane', () => {
|
||||
assert.equal(moveCalls, 0);
|
||||
});
|
||||
|
||||
test('Escape leaves magnification unchanged and reaches the terminal', () => {
|
||||
test('Escape restores magnification after transient dialogs are closed', () => {
|
||||
let restores = 0;
|
||||
let prevented = false;
|
||||
let stopped = false;
|
||||
@@ -405,29 +405,30 @@ test('Escape leaves magnification unchanged and reaches the terminal', () => {
|
||||
},
|
||||
}), event);
|
||||
|
||||
assert.equal(restores, 0);
|
||||
assert.equal(prevented, false);
|
||||
assert.equal(stopped, false);
|
||||
assert.equal(restores, 1);
|
||||
assert.equal(prevented, true);
|
||||
assert.equal(stopped, true);
|
||||
});
|
||||
|
||||
test('Escape closes the quick switcher', () => {
|
||||
let open = true;
|
||||
test('consumed Escape does not restore magnification', () => {
|
||||
let restores = 0;
|
||||
handleEscapeKeyDownImpl(() => ({
|
||||
isQuickSwitcherOpen: open,
|
||||
setIsQuickSwitcherOpen: (value: boolean) => { open = value; },
|
||||
}), { key: 'Escape', defaultPrevented: false } as KeyboardEvent);
|
||||
|
||||
assert.equal(open, false);
|
||||
});
|
||||
|
||||
test('consumed Escape leaves the quick switcher open', () => {
|
||||
let open = true;
|
||||
handleEscapeKeyDownImpl(() => ({
|
||||
isQuickSwitcherOpen: open,
|
||||
setIsQuickSwitcherOpen: (value: boolean) => { open = value; },
|
||||
isQuickSwitcherOpen: false,
|
||||
setIsQuickSwitcherOpen: () => {},
|
||||
terminalPaneMagnificationRef: {
|
||||
current: {
|
||||
getState: () => 'focused',
|
||||
focus: () => false,
|
||||
restore: () => {
|
||||
restores += 1;
|
||||
return true;
|
||||
},
|
||||
toggle: () => false,
|
||||
},
|
||||
},
|
||||
}), { key: 'Escape', defaultPrevented: true } as KeyboardEvent);
|
||||
|
||||
assert.equal(open, true);
|
||||
assert.equal(restores, 0);
|
||||
});
|
||||
|
||||
test('close tab hotkey routes native plugin view tabs through their owner', () => {
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { handleCreateLocalTerminalImpl } from './AppHandlers';
|
||||
|
||||
type CreatedLocal = {
|
||||
shell?: string;
|
||||
shellArgs?: string[];
|
||||
shellName?: string;
|
||||
shellIcon?: string;
|
||||
localStartDir?: string;
|
||||
shellType?: string;
|
||||
};
|
||||
|
||||
function createCtx(overrides?: {
|
||||
localShell?: string;
|
||||
localShellArgs?: string[];
|
||||
discoveredShells?: Array<{ id: string; name: string; command: string; args?: string[]; icon?: string }>;
|
||||
}) {
|
||||
const created: CreatedLocal[] = [];
|
||||
const logs: Array<{ hostLabel: string }> = [];
|
||||
const localShell = overrides?.localShell ?? '';
|
||||
const localShellArgs = overrides?.localShellArgs ?? [];
|
||||
const discoveredShells = overrides?.discoveredShells ?? [];
|
||||
const getCtx = () => ({
|
||||
addConnectionLog: (entry: { hostLabel: string }) => {
|
||||
logs.push(entry);
|
||||
},
|
||||
classifyLocalShellType: () => 'unknown',
|
||||
createLocalTerminal: (options: CreatedLocal) => {
|
||||
created.push(options);
|
||||
return 'session-1';
|
||||
},
|
||||
discoveredShells,
|
||||
resolveShellSetting: (
|
||||
value: string,
|
||||
shells: Array<{ id: string; command: string; args?: string[] }>,
|
||||
customArgs?: string[],
|
||||
) => {
|
||||
if (!value) return null;
|
||||
const match = shells.find((shell) => shell.id === value);
|
||||
if (match) {
|
||||
return { command: match.command, args: customArgs?.length ? customArgs : match.args };
|
||||
}
|
||||
return { command: value, args: customArgs?.length ? customArgs : undefined };
|
||||
},
|
||||
systemInfoRef: { current: { username: 'qi', hostname: 'box' } },
|
||||
terminalSettings: { localShell, localShellArgs },
|
||||
});
|
||||
return { getCtx, created, logs };
|
||||
}
|
||||
|
||||
test('create local terminal resolves Settings custom absolute path when no shell arg is passed', () => {
|
||||
const custom = String.raw`C:\Users\lybing\AppData\Local\Programs\nu\bin\nu.exe`;
|
||||
const { getCtx, created } = createCtx({ localShell: custom });
|
||||
handleCreateLocalTerminalImpl(getCtx as never);
|
||||
assert.equal(created.length, 1);
|
||||
assert.equal(created[0].shell, custom);
|
||||
});
|
||||
|
||||
test('create local terminal ignores MouseEvent-like first args and still uses Settings shell (#4085)', () => {
|
||||
const custom = String.raw`C:\Users\lybing\AppData\Local\Programs\nu\bin\nu.exe`;
|
||||
const { getCtx, created } = createCtx({ localShell: custom });
|
||||
// Vault header historically used onClick={handleCreateLocalTerminal}, so React
|
||||
// passes the click event as the first argument.
|
||||
const fakeClickEvent = { type: 'click', target: {}, preventDefault() {} };
|
||||
handleCreateLocalTerminalImpl(getCtx as never, fakeClickEvent as never);
|
||||
assert.equal(created.length, 1);
|
||||
assert.equal(created[0].shell, custom);
|
||||
});
|
||||
|
||||
test('create local terminal still honors an explicit shell object', () => {
|
||||
const { getCtx, created } = createCtx({ localShell: 'pwsh' });
|
||||
handleCreateLocalTerminalImpl(getCtx as never, {
|
||||
command: String.raw`D:\portable\zsh\zsh.exe`,
|
||||
args: ['-l'],
|
||||
name: 'Portable Zsh',
|
||||
icon: 'zsh',
|
||||
});
|
||||
assert.equal(created.length, 1);
|
||||
assert.equal(created[0].shell, String.raw`D:\portable\zsh\zsh.exe`);
|
||||
assert.deepEqual(created[0].shellArgs, ['-l']);
|
||||
assert.equal(created[0].shellName, 'Portable Zsh');
|
||||
assert.equal(created[0].shellIcon, 'zsh');
|
||||
});
|
||||
@@ -16,7 +16,6 @@ import { isPortForwardingAutoReconnectEnabled } from '../../domain/portForwardin
|
||||
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
|
||||
import { captureInheritedCwd } from '../state/inheritedCwd';
|
||||
import { fromEditorTabId, isEditorTabId } from '../state/activeTabStore';
|
||||
import { isPluginViewEditingTarget } from '../state/pluginKeybindings';
|
||||
import { resolveBatchTabCloseFocus } from '../state/pluginViewTabStore';
|
||||
|
||||
type AppContextGetter = () => Record<string, any>;
|
||||
@@ -265,7 +264,7 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
|
||||
}
|
||||
}
|
||||
|
||||
const isFormElement = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable || isPluginViewEditingTarget(target);
|
||||
const isFormElement = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
|
||||
const isMonacoElement =
|
||||
target instanceof HTMLElement &&
|
||||
!!target.closest?.('.monaco-editor, .monaco-diff-editor, .monaco-inputbox');
|
||||
@@ -365,6 +364,8 @@ export function handleEscapeKeyDownImpl(getCtx: AppContextGetter, e: KeyboardEve
|
||||
const {
|
||||
isQuickSwitcherOpen,
|
||||
setIsQuickSwitcherOpen,
|
||||
sftpPaneMagnificationRef,
|
||||
terminalPaneMagnificationRef,
|
||||
} = getCtx();
|
||||
{
|
||||
if (e.key !== 'Escape' || e.defaultPrevented) return;
|
||||
@@ -372,6 +373,13 @@ export function handleEscapeKeyDownImpl(getCtx: AppContextGetter, e: KeyboardEve
|
||||
setIsQuickSwitcherOpen(false);
|
||||
return;
|
||||
}
|
||||
if (getAvailablePaneMagnificationController([
|
||||
sftpPaneMagnificationRef?.current,
|
||||
terminalPaneMagnificationRef?.current,
|
||||
])?.restore()) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1129,18 +1137,11 @@ export function handleCreateLocalTerminalImpl(
|
||||
const { addConnectionLog, classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, systemInfoRef, terminalSettings } = getCtx();
|
||||
{
|
||||
const { username, hostname } = systemInfoRef.current;
|
||||
// Vault/header buttons historically wired `onClick={handleCreateLocalTerminal}`.
|
||||
// React then passes the MouseEvent as the first arg; treat only real shell
|
||||
// objects (string command) as explicit so Settings localShell still applies (#4085).
|
||||
const explicitShell = shell && typeof shell.command === 'string' ? shell : undefined;
|
||||
const resolved = explicitShell
|
||||
?? resolveShellSetting(terminalSettings.localShell, discoveredShells, terminalSettings.localShellArgs);
|
||||
const resolved = shell ?? resolveShellSetting(terminalSettings.localShell, discoveredShells, terminalSettings.localShellArgs);
|
||||
// Match by ID (not command) to avoid WSL distros all sharing wsl.exe
|
||||
const matchedShell = !explicitShell
|
||||
? discoveredShells.find(s => s.id === terminalSettings.localShell)
|
||||
: undefined;
|
||||
const shellName = explicitShell?.name ?? matchedShell?.name;
|
||||
const shellIcon = explicitShell?.icon ?? matchedShell?.icon;
|
||||
const matchedShell = !shell ? discoveredShells.find(s => s.id === terminalSettings.localShell) : undefined;
|
||||
const shellName = shell?.name ?? matchedShell?.name;
|
||||
const shellIcon = shell?.icon ?? matchedShell?.icon;
|
||||
const sessionId = createLocalTerminal({
|
||||
shellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, navigator.userAgent),
|
||||
shell: resolved?.command,
|
||||
@@ -1270,14 +1271,16 @@ export function hasMultipleProtocolsImpl(getCtx: AppContextGetter, host: Host) {
|
||||
}
|
||||
|
||||
export function handleHostConnectWithProtocolCheckImpl(getCtx: AppContextGetter, host: Host) {
|
||||
const { handleConnectToHost, hasMultipleProtocols, resolveEffectiveHost, setIsQuickSwitcherOpen, setProtocolSelectHost } = getCtx();
|
||||
const { handleConnectToHost, hasMultipleProtocols, resolveEffectiveHost, setIsQuickSwitcherOpen, setProtocolSelectHost, setQuickSearch } = getCtx();
|
||||
{
|
||||
if (hasMultipleProtocols(host)) {
|
||||
setProtocolSelectHost(resolveEffectiveHost(host));
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setQuickSearch('');
|
||||
} else {
|
||||
handleConnectToHost(host);
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setQuickSearch('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ import { PluginContributionHost } from '../../components/plugins/PluginContribut
|
||||
import type { TerminalSession, Workspace } from '../../types';
|
||||
import { useActiveTabId } from '../state/activeTabStore';
|
||||
import { resolveActivePluginKeybindingContext } from '../state/pluginContributionContexts';
|
||||
import { getAISessionsSnapshot } from '../state/aiSessionsStore';
|
||||
import { getPersistedAiSessionsSnapshot } from '../state/aiStateSnapshots';
|
||||
|
||||
/**
|
||||
* Leaf host for plugin keybindings so AppView does not subscribe to activeTabId.
|
||||
@@ -25,19 +23,11 @@ export function AppPluginKeybindingHost({
|
||||
workspaces: Workspace[];
|
||||
}) {
|
||||
const activeTabId = useActiveTabId();
|
||||
const keybindingContext = useMemo(() => {
|
||||
// AI chat recency drives the workspace anchor fallback (#4098 phase 2).
|
||||
// Read without subscribing: keybinding context is best-effort and refreshes
|
||||
// on every tab/session/workspaces change that matters here.
|
||||
const live = getAISessionsSnapshot().sessions;
|
||||
const aiSessions = live.length > 0 ? live : getPersistedAiSessionsSnapshot();
|
||||
return resolveActivePluginKeybindingContext({
|
||||
activeTabId,
|
||||
sessions,
|
||||
workspaces,
|
||||
aiSessions,
|
||||
});
|
||||
}, [activeTabId, sessions, workspaces]);
|
||||
const keybindingContext = useMemo(() => resolveActivePluginKeybindingContext({
|
||||
activeTabId,
|
||||
sessions,
|
||||
workspaces,
|
||||
}), [activeTabId, sessions, workspaces]);
|
||||
|
||||
return (
|
||||
<PluginContributionHost
|
||||
|
||||
@@ -139,21 +139,12 @@ test('ChromeHost builds from chrome settings + session/vault snapshots', () => {
|
||||
assert.doesNotMatch(source, /\buseAppSettingsRuntime\s*\(/);
|
||||
});
|
||||
|
||||
test('DialogsHost builds from local dialog state without vault runtime hooks', () => {
|
||||
test('DialogsHost builds from local dialog state + selective vault snapshot', () => {
|
||||
const source = hostSources['DialogsHost.tsx'];
|
||||
assert.ok(source, 'application/app/hosts/DialogsHost.tsx must exist');
|
||||
assert.match(source, /useVaultSnapshot|useVaultSnapshotField|getVaultSnapshot/);
|
||||
assert.doesNotMatch(source, /\buseVaultState\s*\(/);
|
||||
assert.doesNotMatch(source, /\buseAppVaultRuntime\s*\(/);
|
||||
// Quick switcher search is owned by QuickSwitcherOverlay; republishing the
|
||||
// dialogs bag per keystroke re-rendered the whole shell.
|
||||
assert.doesNotMatch(source, /quickSearch|getHostSearchMatch/);
|
||||
});
|
||||
|
||||
test('QuickSwitcherOverlay owns the quick switcher query', () => {
|
||||
const overlay = readFileSync(join(here, 'QuickSwitcherOverlay.tsx'), 'utf8');
|
||||
assert.match(overlay, /useState\(''\)/);
|
||||
const store = readFileSync(join(here, 'appLocalUiStore.ts'), 'utf8');
|
||||
assert.doesNotMatch(store, /quickSearch/);
|
||||
});
|
||||
|
||||
test('AppSideEffects may use runtime hooks; App must not', () => {
|
||||
|
||||
@@ -4,13 +4,12 @@ import test from 'node:test';
|
||||
|
||||
const source = readFileSync(new URL('./AppSideEffects.tsx', import.meta.url), 'utf8');
|
||||
|
||||
test('quick-switcher Escape handling stays in the bubble phase', () => {
|
||||
const effectStart = source.indexOf('const onKeyDown = (e: KeyboardEvent) => {\n _handleEscapeKeyDown(e);');
|
||||
const effectEnd = source.indexOf('}, [appLockLocked]);', effectStart);
|
||||
const effectSource = source.slice(effectStart, effectEnd);
|
||||
test('terminal Escape restoration runs before xterm can stop propagation', () => {
|
||||
const effectStart = source.indexOf("const onCaptureKeyDown = (e: KeyboardEvent) => {");
|
||||
const effectSource = source.slice(effectStart, effectStart + 900);
|
||||
|
||||
assert.notEqual(effectStart, -1);
|
||||
assert.match(effectSource, /window\.addEventListener\('keydown', onKeyDown\)/);
|
||||
assert.match(effectSource, /window\.removeEventListener\('keydown', onKeyDown\)/);
|
||||
assert.doesNotMatch(source, /onCaptureKeyDown/);
|
||||
assert.match(effectSource, /target\.closest\('\.xterm'\)/);
|
||||
assert.match(effectSource, /window\.addEventListener\('keydown', onCaptureKeyDown, true\)/);
|
||||
assert.match(effectSource, /window\.removeEventListener\('keydown', onCaptureKeyDown, true\)/);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useEffectEvent, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { registerAppHandlers, stabilizeAppHandlers, type StableAppHandlerCache } from './appHandlersBridge';
|
||||
import { registerAppHandlers } from './appHandlersBridge';
|
||||
import { publishAppLocalUi } from './appLocalUiStore';
|
||||
import { activeTabStore, toEditorTabId, fromEditorTabId, isEditorTabId } from '../state/activeTabStore';
|
||||
import { useAutoSync } from '../state/useAutoSync';
|
||||
@@ -19,8 +19,6 @@ import {
|
||||
} from '../state/connectionLogsStore';
|
||||
import { getNotesSnapshot } from '../state/notesStore';
|
||||
import { useVaultAgentBridge } from '../state/useVaultAgentBridge';
|
||||
import { terminalReconnectRegistry } from '../state/terminalReconnectRegistry';
|
||||
import { requestAgentSessionReconnect } from '../state/agentSessionReconnect';
|
||||
import { useWindowControls } from '../state/useWindowControls';
|
||||
import { useTerminalKeyboardFocus } from '../state/useTerminalKeyboardFocus';
|
||||
import { editorTabStore, useEditorTabChromeList } from '../state/editorTabStore';
|
||||
@@ -58,12 +56,9 @@ import {
|
||||
} from '../../domain/terminalAppearance';
|
||||
import { selectConnectionLogForTerminalDataCapture } from '../../domain/connectionLog';
|
||||
import { collectSessionIds } from '../../domain/workspace';
|
||||
import { resolveAgentHostOpenWorkspace } from '../../domain/agentHostWorkspace';
|
||||
import { getAISessionsSnapshot } from '../state/aiSessionsStore';
|
||||
import { peekAgentRuntimeCleanup } from '../../infrastructure/ai/harness/agentRuntimeHandle';
|
||||
import { resolveAgentHostWorkspace } from '../../domain/agentHostWorkspace';
|
||||
import { useHostOpenSessionReuse } from '../state/useHostOpenSessionReuse';
|
||||
import type { PaneMagnificationController } from '../../domain/paneMagnification';
|
||||
import { isPluginViewEditingTarget } from '../state/pluginKeybindings';
|
||||
import { resolveCloseIntent } from '../state/resolveCloseIntent';
|
||||
import { resolveSnippetsShortcutIntent } from '../state/resolveSnippetsShortcutIntent';
|
||||
import { isPrimaryModifierWBinding, resolveWindowCommandCloseIntent } from '../state/windowCommandClose';
|
||||
@@ -138,6 +133,7 @@ export function AppSideEffects() {
|
||||
| { mode: 'create' }
|
||||
| null
|
||||
>(null);
|
||||
const [quickSearch, setQuickSearch] = useState('');
|
||||
// Protocol selection dialog state for QuickSwitcher
|
||||
const [protocolSelectHost, setProtocolSelectHost] = useState<Host | null>(null);
|
||||
// Navigation state for VaultView sections
|
||||
@@ -615,7 +611,7 @@ export function AppSideEffects() {
|
||||
const _handleTrayPanelConnect = useEffectEvent((hostId: string) => { return handleTrayPanelConnectImpl(() => ({ addConnectionLog, connectToHost, hostId, hosts, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef, t, toast }), hostId); });
|
||||
const _handleTrayPanelConnectRequest = useEffectEvent((hostId: string) => { return handleTrayPanelConnectRequestImpl(() => ({ connectNow: _handleTrayPanelConnect, hostId, isVaultInitialized, queueConnect: (queuedHostId: string) => setPendingTrayPanelConnectHostIds((prev) => [...prev, queuedHostId]) }), hostId); });
|
||||
const _handleGlobalHotkeyKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleGlobalHotkeyKeyDownImpl(() => ({ HOTKEY_DEBUG, closeTabKeyStr, e, executeHotkeyAction, hotkeyScheme, keyBindings, matchesKeyBinding }), e); });
|
||||
const _handleEscapeKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleEscapeKeyDownImpl(() => ({ e, isQuickSwitcherOpen, setIsQuickSwitcherOpen }), e); });
|
||||
const _handleEscapeKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleEscapeKeyDownImpl(() => ({ e, isQuickSwitcherOpen, setIsQuickSwitcherOpen, sftpPaneMagnificationRef, terminalPaneMagnificationRef }), e); });
|
||||
|
||||
// Vault hosts for tray / auto-start; terminalHosts (vault + ephemeral) only for
|
||||
// dedicated transfer resume so quick-connect rows do not break tray connect.
|
||||
@@ -1160,8 +1156,6 @@ export function AppSideEffects() {
|
||||
const topmostOpenDialog = openDialogs[openDialogs.length - 1] ?? null;
|
||||
const topmostDialogClose = topmostOpenDialog?.querySelector<HTMLElement>('[data-dialog-close="true"]');
|
||||
|
||||
if (!topmostDialogClose && isPluginViewEditingTarget(document.activeElement)) return;
|
||||
|
||||
const intent = resolveWindowCommandCloseIntent({
|
||||
activeTabId: activeTabStore.getActiveTabId(),
|
||||
editorTabIds: editorTabs.map((tab) => toEditorTabId(tab.id)),
|
||||
@@ -1235,11 +1229,19 @@ export function AppSideEffects() {
|
||||
|
||||
useEffect(() => {
|
||||
if (appLockLocked) return;
|
||||
const onCaptureKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
const target = e.target;
|
||||
if (!(target instanceof HTMLElement) || !target.closest('.xterm')) return;
|
||||
_handleEscapeKeyDown(e);
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
_handleEscapeKeyDown(e);
|
||||
};
|
||||
window.addEventListener('keydown', onCaptureKeyDown, true);
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onCaptureKeyDown, true);
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [appLockLocked]);
|
||||
@@ -1499,21 +1501,8 @@ export function AppSideEffects() {
|
||||
return sessionId;
|
||||
}, [handleConnectToHost, renameSessionInline]);
|
||||
|
||||
const openHostForVaultAgent = useCallback((host: Host, isExternalMcpCall: boolean, options?: { workspaceId?: string; chatSessionId?: string }) => {
|
||||
// A resumed history chat can run in a panel other than its saved binding.
|
||||
// Prefer that turn's captured scope, never the window's current focus.
|
||||
// Outside a running turn, retain the saved chat binding as the fallback.
|
||||
const chat = options?.chatSessionId
|
||||
? getAISessionsSnapshot().sessions.find((session) => session.id === options.chatSessionId)
|
||||
: undefined;
|
||||
const turnScope = options?.chatSessionId
|
||||
? peekAgentRuntimeCleanup()?.getActiveScope?.(options.chatSessionId)
|
||||
: undefined;
|
||||
const target = resolveAgentHostOpenWorkspace(options?.workspaceId, turnScope ? { scope: turnScope } : chat, {
|
||||
activeTabId: activeTabStore.getActiveTabId(),
|
||||
workspaceIds: workspaces.map(workspace => workspace.id),
|
||||
terminalSessions: sessionsRef.current,
|
||||
});
|
||||
const openHostForVaultAgent = useCallback((host: Host, isExternalMcpCall: boolean, options?: { workspaceId?: string }) => {
|
||||
const target = resolveAgentHostWorkspace(options?.workspaceId, activeTabStore.getActiveTabId(), workspaces.map(workspace => workspace.id));
|
||||
if (target.error) return { ok: false as const, error: target.error };
|
||||
// Silent sessions only apply to actual external MCP clients (chatSessionId
|
||||
// equals the reserved external-MCP scope). The in-app Catty AI chat's
|
||||
@@ -1535,19 +1524,6 @@ export function AppSideEffects() {
|
||||
sessions, hosts, keys, identities, resolveEffectiveHost,
|
||||
});
|
||||
|
||||
const reconnectSessionForVaultAgent = useCallback((sessionId: string) => {
|
||||
const session = sessions.find((entry) => entry.id === sessionId);
|
||||
return requestAgentSessionReconnect(sessionId, session?.status, terminalReconnectRegistry);
|
||||
}, [sessions]);
|
||||
|
||||
const inspectSessionRecoveryForVaultAgent = useCallback((sessionId: string) => {
|
||||
const session = sessions.find((entry) => entry.id === sessionId);
|
||||
if (!session) return { ok: false, error: `Session "${sessionId}" was not found.` };
|
||||
const live = terminalReconnectRegistry.inspect(sessionId);
|
||||
return { ok: true, sessionId, status: session.status, waitingForInput: false,
|
||||
reconnecting: false, reconnectAllowed: false, ...live };
|
||||
}, [sessions]);
|
||||
|
||||
const closeSessionForVaultAgent = useCallback((sessionId: string) => {
|
||||
if (!sessions.some((session) => session.id === sessionId)) {
|
||||
return { ok: false as const, error: `Session "${sessionId}" was not found.` };
|
||||
@@ -1592,8 +1568,6 @@ export function AppSideEffects() {
|
||||
openHost: openHostForVaultAgent,
|
||||
findExistingSessionForHost: findExistingSessionForHostForVaultAgent,
|
||||
closeSession: closeSessionForVaultAgent,
|
||||
reconnectSession: reconnectSessionForVaultAgent,
|
||||
inspectSessionRecovery: inspectSessionRecoveryForVaultAgent,
|
||||
getScriptSessionMeta: (sessionId) => sessions.find((session) => session.id === sessionId),
|
||||
});
|
||||
|
||||
@@ -1970,7 +1944,7 @@ export function AppSideEffects() {
|
||||
const hasMultipleProtocols = useCallback((host: Host) => { return hasMultipleProtocolsImpl(() => ({ host, resolveEffectiveHost }), host); }, [resolveEffectiveHost]);
|
||||
|
||||
// Handle host connect with protocol selection (used by QuickSwitcher)
|
||||
const handleHostConnectWithProtocolCheck = useCallback((host: Host) => { return handleHostConnectWithProtocolCheckImpl(() => ({ handleConnectToHost, hasMultipleProtocols, host, resolveEffectiveHost, setIsQuickSwitcherOpen, setProtocolSelectHost }), host); }, [hasMultipleProtocols, handleConnectToHost, resolveEffectiveHost]);
|
||||
const handleHostConnectWithProtocolCheck = useCallback((host: Host) => { return handleHostConnectWithProtocolCheckImpl(() => ({ handleConnectToHost, hasMultipleProtocols, host, resolveEffectiveHost, setIsQuickSwitcherOpen, setProtocolSelectHost, setQuickSearch }), host); }, [hasMultipleProtocols, handleConnectToHost, resolveEffectiveHost]);
|
||||
|
||||
// Handle protocol selection from dialog
|
||||
const handleProtocolSelect = useCallback((protocol: HostProtocol, port: number) => { return handleProtocolSelectImpl(() => ({ handleConnectToHost, port, protocol, protocolSelectHost, setProtocolSelectHost }), protocol, port); }, [protocolSelectHost, handleConnectToHost]);
|
||||
@@ -2038,11 +2012,8 @@ export function AppSideEffects() {
|
||||
|
||||
// Hosts assemble domain bags from stores + this flat glue. Do NOT register
|
||||
// catalog arrays or prepared vault/terminal/chrome/dialogs domain bags here.
|
||||
// Handlers are registered behind stable per-key wrappers so session/host
|
||||
// churn does not re-notify every Host (see stabilizeAppHandlers).
|
||||
const appHandlerCacheRef = useRef<StableAppHandlerCache>(new Map());
|
||||
useLayoutEffect(() => {
|
||||
registerAppHandlers(stabilizeAppHandlers(appHandlerCacheRef.current, {
|
||||
registerAppHandlers({
|
||||
// Vault glue
|
||||
handleAddKnownHost,
|
||||
handleDeleteHost,
|
||||
@@ -2102,13 +2073,15 @@ export function AppSideEffects() {
|
||||
setIsCreateWorkspaceOpen,
|
||||
setIsQuickSwitcherOpen,
|
||||
setProtocolSelectHost,
|
||||
setQuickSearch,
|
||||
handleCancelDeleteHost,
|
||||
handleConfirmDeleteHost,
|
||||
}));
|
||||
});
|
||||
publishAppLocalUi({
|
||||
isQuickSwitcherOpen,
|
||||
isCreateWorkspaceOpen,
|
||||
addToWorkspaceDialog,
|
||||
quickSearch,
|
||||
protocolSelectHost,
|
||||
navigateToSection,
|
||||
deepLinkHostDraft,
|
||||
@@ -2174,6 +2147,7 @@ export function AppSideEffects() {
|
||||
isQuickSwitcherOpen,
|
||||
isCreateWorkspaceOpen,
|
||||
addToWorkspaceDialog,
|
||||
quickSearch,
|
||||
protocolSelectHost,
|
||||
navigateToSection,
|
||||
deepLinkHostDraft,
|
||||
|
||||
+56
-55
@@ -46,7 +46,6 @@ import { getAvailablePaneMagnificationController } from '../../domain/paneMagnif
|
||||
import { pluginViewTabStore, usePluginViewTabs } from '../state/pluginViewTabStore';
|
||||
import { buildPluginSettingScopeCatalog } from '../state/usePluginSettingScopeCatalog';
|
||||
import { useWorkSurfaceHostEditor } from '../state/useWorkSurfaceHostEditor';
|
||||
import { QuickSwitcherOverlay } from './QuickSwitcherOverlay';
|
||||
import {
|
||||
appViewDomainsEqual,
|
||||
mergeAppViewDomains,
|
||||
@@ -54,6 +53,9 @@ import {
|
||||
} from './appViewDomains';
|
||||
|
||||
const LazyProtocolSelectDialog = lazy(() => import('../../components/ProtocolSelectDialog'));
|
||||
const LazyQuickSwitcher = lazy(() =>
|
||||
import('../../components/QuickSwitcher').then((m) => ({ default: m.QuickSwitcher })),
|
||||
);
|
||||
const LazyCreateWorkspaceDialog = lazy(() =>
|
||||
import('../../components/CreateWorkspaceDialog').then((m) => ({ default: m.CreateWorkspaceDialog })),
|
||||
);
|
||||
@@ -61,9 +63,6 @@ const LazyTextEditorTabView = lazy(() =>
|
||||
import('../../components/editor/TextEditorTabView').then((m) => ({ default: m.TextEditorTabView })),
|
||||
);
|
||||
|
||||
// Module-level so TopTabs' memo comparator sees a stable callback.
|
||||
const focusEditorWindowTab = (id: string) => { void focusEditorInWindow(id); };
|
||||
|
||||
const TextEditorTabFallback = ({ tabId }: { tabId: string }) => {
|
||||
const isVisible = useIsEditorTabActive(tabId);
|
||||
const hostTreeLayoutWidth = useTerminalHostTreeLayoutWidth();
|
||||
@@ -243,10 +242,12 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
setIsCreateWorkspaceOpen,
|
||||
setIsQuickSwitcherOpen,
|
||||
setProtocolSelectHost,
|
||||
setQuickSearch,
|
||||
} = ctx;
|
||||
|
||||
const dismissTransientOverlays = useCallback(() => {
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setQuickSearch('');
|
||||
setIsCreateWorkspaceOpen(false);
|
||||
setProtocolSelectHost(null);
|
||||
setAddToWorkspaceDialog(null);
|
||||
@@ -259,12 +260,13 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
setIsCreateWorkspaceOpen,
|
||||
setIsQuickSwitcherOpen,
|
||||
setProtocolSelectHost,
|
||||
setQuickSearch,
|
||||
]);
|
||||
|
||||
useMainWindowInputFocusRecovery({ onPageHidden: dismissTransientOverlays });
|
||||
|
||||
const {
|
||||
addShellHistoryEntry, clearAllCommandHistory, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, moveWorkspacePane,
|
||||
addShellHistoryEntry, clearAllCommandHistory, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
|
||||
clearAndRemoveSource, clearAndRemoveSources, closeLogView, closeSession, closeTabsBatch, closeWorkspace, commitPluginImporterData, commitVaultImportTransaction, commitVaultGroupMutation, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell, duplicateSessionWithCurrentShell, copyWorkspaceWithCurrentShell,
|
||||
convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts,
|
||||
customGroups, currentTerminalTheme, deepLinkHostDraft, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
|
||||
@@ -275,11 +277,11 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleUpdateHostFromTerminal,
|
||||
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isComposeSendToAll, onToggleComposeSendTarget, isGlobalBroadcastEnabled, isGlobalComposeSendToAll, onToggleGlobalBroadcast, onToggleGlobalComposeSendTarget, canUseGlobalBroadcast, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
|
||||
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
|
||||
passphraseQueue, fidoPromptQueue, protocolSelectHost, proxyProfiles, portForwardingRules, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
|
||||
passphraseQueue, fidoPromptQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
|
||||
resolveEmptyVaultConflict, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
|
||||
sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
|
||||
setNavigateToSection, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
|
||||
setWorkspaceFocusedSession, setWorkspaceAnchorChat, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
|
||||
setWorkspaceFocusedSession, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
|
||||
sftpShowHiddenFiles, sftpUseCompressedUpload, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename,
|
||||
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById,
|
||||
toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, terminalPaneMagnificationRef, sftpPaneMagnificationRef, toggleWorkspaceViewMode, unmanageSource,
|
||||
@@ -504,16 +506,6 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
await popOutEditorTab(id, host?.label);
|
||||
}, [hostById]);
|
||||
|
||||
// Stable editor-tab callbacks so memoized TextEditorTabView/TextEditorPane
|
||||
// skip re-rendering when unrelated AppView state changes.
|
||||
const requestCloseEditorTabFromView = useCallback(
|
||||
(id: string) => handleRequestCloseEditorTabRef.current(id),
|
||||
[handleRequestCloseEditorTabRef],
|
||||
);
|
||||
const popOutEditorTabFromView = useCallback((id: string) => {
|
||||
void handlePopOutEditorTab(id);
|
||||
}, [handlePopOutEditorTab]);
|
||||
|
||||
const handleSaveSessionRename = useCallback((name: string) => {
|
||||
if (!sessionRenameTarget) return;
|
||||
if (!name.trim()) return;
|
||||
@@ -591,7 +583,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onClosePluginViewTab={closePluginViewTab}
|
||||
onRequestCloseEditorTab={handleRequestCloseEditorTab}
|
||||
onPopOutEditorTab={handlePopOutEditorTab}
|
||||
onFocusEditorWindowTab={focusEditorWindowTab}
|
||||
onFocusEditorWindowTab={(id) => { void focusEditorInWindow(id); }}
|
||||
hostById={hostById}
|
||||
/>
|
||||
|
||||
@@ -774,14 +766,12 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onTerminalDataCapture={handleTerminalDataCapture}
|
||||
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
|
||||
onAddSessionToWorkspace={addSessionToWorkspace}
|
||||
onMoveWorkspacePane={moveWorkspacePane}
|
||||
onRequestAddToWorkspace={handleRequestAddToWorkspace}
|
||||
onAppendHostToWorkspace={handleAppendHostToWorkspace}
|
||||
onUpdateSplitSizes={updateSplitSizes}
|
||||
onSetDraggingSessionId={setDraggingSessionId}
|
||||
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
|
||||
onSetWorkspaceFocusedSession={setWorkspaceFocusedSession}
|
||||
onSetWorkspaceAnchorChat={setWorkspaceAnchorChat}
|
||||
onReorderWorkspaceSessions={reorderWorkspaceSessions}
|
||||
onReorderTabs={reorderWorkTabs}
|
||||
onCopySession={copySessionWithCurrentShell}
|
||||
@@ -857,8 +847,8 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
hostById={hostById}
|
||||
onRequestClose={requestCloseEditorTabFromView}
|
||||
onPopOut={popOutEditorTabFromView}
|
||||
onRequestClose={(id) => handleRequestCloseEditorTabRef.current(id)}
|
||||
onPopOut={(id) => { void handlePopOutEditorTab(id); }}
|
||||
/>
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
@@ -947,39 +937,50 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
)}
|
||||
|
||||
{isQuickSwitcherOpen && (
|
||||
<QuickSwitcherOverlay
|
||||
hosts={hosts}
|
||||
isOpen={isQuickSwitcherOpen}
|
||||
sessions={sessions}
|
||||
workspaces={workspaces}
|
||||
showSftpTab={showSftpTab}
|
||||
onSelect={handleHostConnectWithProtocolCheck}
|
||||
onQuickConnect={openQuickConnectFromInput}
|
||||
onEditHost={(host) => {
|
||||
setIsQuickSwitcherOpen(false);
|
||||
handleEditHostFromOverlay(host);
|
||||
}}
|
||||
onSelectTab={(tabId) => {
|
||||
setActiveTabId(tabId);
|
||||
setIsQuickSwitcherOpen(false);
|
||||
}}
|
||||
onCreateLocalTerminal={(shell) => {
|
||||
handleCreateLocalTerminal(shell);
|
||||
setIsQuickSwitcherOpen(false);
|
||||
}}
|
||||
onCreateWorkspace={() => {
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setAddToWorkspaceDialog({ mode: 'create' });
|
||||
}}
|
||||
onClose={() => {
|
||||
setIsQuickSwitcherOpen(false);
|
||||
}}
|
||||
keyBindings={keyBindings}
|
||||
terminalSettings={terminalSettings}
|
||||
paneMagnificationState={paneMagnificationState}
|
||||
onMagnifyCurrentPane={handleMagnifyCurrentPane}
|
||||
onRestoreMagnifiedPane={handleRestoreMagnifiedPane}
|
||||
/>
|
||||
<LazyLoadBoundary name="Quick switcher" resetKey={quickSearch}>
|
||||
<Suspense fallback={null}>
|
||||
<LazyQuickSwitcher
|
||||
isOpen={isQuickSwitcherOpen}
|
||||
query={quickSearch}
|
||||
results={quickResults}
|
||||
sessions={sessions}
|
||||
workspaces={workspaces}
|
||||
showSftpTab={showSftpTab}
|
||||
onQueryChange={setQuickSearch}
|
||||
onSelect={handleHostConnectWithProtocolCheck}
|
||||
onQuickConnect={openQuickConnectFromInput}
|
||||
onEditHost={(host) => {
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setQuickSearch('');
|
||||
handleEditHostFromOverlay(host);
|
||||
}}
|
||||
onSelectTab={(tabId) => {
|
||||
setActiveTabId(tabId);
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setQuickSearch('');
|
||||
}}
|
||||
onCreateLocalTerminal={(shell) => {
|
||||
handleCreateLocalTerminal(shell);
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setQuickSearch('');
|
||||
}}
|
||||
onCreateWorkspace={() => {
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setQuickSearch('');
|
||||
setAddToWorkspaceDialog({ mode: 'create' });
|
||||
}}
|
||||
onClose={() => {
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setQuickSearch('');
|
||||
}}
|
||||
keyBindings={keyBindings}
|
||||
terminalSettings={terminalSettings}
|
||||
paneMagnificationState={paneMagnificationState}
|
||||
onMagnifyCurrentPane={handleMagnifyCurrentPane}
|
||||
onRestoreMagnifiedPane={handleRestoreMagnifiedPane}
|
||||
/>
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
)}
|
||||
|
||||
<RenameDraftDialog
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import React, { Suspense, lazy, useMemo, useState } from 'react';
|
||||
|
||||
import type { QuickSwitcherProps } from '../../components/QuickSwitcher';
|
||||
import { LazyLoadBoundary } from '../../components/ui/lazy-load-boundary';
|
||||
import { getHostSearchMatch } from '../../lib/searchMatcher';
|
||||
import type { Host } from '../../types';
|
||||
|
||||
const LazyQuickSwitcher = lazy(() =>
|
||||
import('../../components/QuickSwitcher').then((m) => ({ default: m.QuickSwitcher })),
|
||||
);
|
||||
|
||||
/** Hosts matching the quick switcher query, best match first. */
|
||||
export function filterQuickSwitcherHosts(hosts: Host[], query: string): Host[] {
|
||||
const term = query.trim();
|
||||
if (!term) return hosts;
|
||||
return hosts
|
||||
.map((host) => ({ host, match: getHostSearchMatch(term, host) }))
|
||||
.filter((entry) => entry.match.matched)
|
||||
.sort((left, right) => {
|
||||
if (left.match.score !== right.match.score) {
|
||||
return right.match.score - left.match.score;
|
||||
}
|
||||
return left.host.label.localeCompare(right.host.label);
|
||||
})
|
||||
.map((entry) => entry.host);
|
||||
}
|
||||
|
||||
export type QuickSwitcherOverlayProps =
|
||||
Omit<QuickSwitcherProps, 'query' | 'results' | 'onQueryChange'> & { hosts: Host[] };
|
||||
|
||||
/**
|
||||
* Owns the quick switcher query. It used to live in AppSideEffects and travel
|
||||
* through the app-local UI store, so every keystroke re-rendered the whole
|
||||
* shell (AppSideEffects, every host island, AppShell, AppView and TopTabs).
|
||||
* The overlay is only mounted while the switcher is open, so closing it resets
|
||||
* the query just like the old `setQuickSearch('')` calls did.
|
||||
*/
|
||||
export function QuickSwitcherOverlay({ hosts, ...props }: QuickSwitcherOverlayProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const results = useMemo(() => filterQuickSwitcherHosts(hosts, query), [hosts, query]);
|
||||
return (
|
||||
<LazyLoadBoundary name="Quick switcher" resetKey={query}>
|
||||
<Suspense fallback={null}>
|
||||
<LazyQuickSwitcher {...props} query={query} results={results} onQueryChange={setQuery} />
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
);
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { registerHooks } from 'node:module';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import {
|
||||
EMPTY_SESSION_SNAPSHOT,
|
||||
getSessionSnapshotActions,
|
||||
publishSessionSnapshot,
|
||||
} from '../state/sessionSnapshotStore';
|
||||
import { getAppShellPropsSnapshot } from './appShellPropsStore';
|
||||
import { registerAppHandlers } from './appHandlersBridge';
|
||||
|
||||
// Keep both production publishers and the store between them real. Only the
|
||||
// state owner and unrelated theme side effects are replaced with fixtures.
|
||||
const sessionFixtureUrl = `data:text/javascript,${encodeURIComponent(`
|
||||
let session;
|
||||
export function setSession(value) { session = value; }
|
||||
export function useSessionState() { return session; }
|
||||
`)}`;
|
||||
const themeFixtureUrl = `data:text/javascript,${encodeURIComponent(`
|
||||
const noop = () => {};
|
||||
const theme = {};
|
||||
const runtime = {
|
||||
globalAppearance: theme, accentedGlobalAppearance: theme,
|
||||
clearIntent: noop, settleManualIntent: noop, pickTheme: noop,
|
||||
resolveFocusedAppearance: noop, currentTerminalTheme: theme,
|
||||
};
|
||||
export function useThemeRuntime() { return runtime; }
|
||||
export function useTerminalAppearanceInjection() {}
|
||||
`)}`;
|
||||
registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
if (specifier === '../../state/useSessionState' && context.parentURL?.endsWith('/SessionPublisher.tsx')) {
|
||||
return { url: sessionFixtureUrl, shortCircuit: true };
|
||||
}
|
||||
if (specifier === '../../state/useThemeRuntime' && context.parentURL?.endsWith('/TerminalHost.tsx')) {
|
||||
return { url: themeFixtureUrl, shortCircuit: true };
|
||||
}
|
||||
return nextResolve(specifier, context);
|
||||
},
|
||||
});
|
||||
|
||||
const { setSession } = await import(sessionFixtureUrl);
|
||||
const { SessionPublisher } = await import('./publishers/SessionPublisher');
|
||||
const { TerminalHost } = await import('./hosts/TerminalHost');
|
||||
|
||||
test('workspace actions reach the terminal shell through SessionPublisher and TerminalHost', async (t) => {
|
||||
const previousActEnvironment = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
t.after(async () => {
|
||||
await act(async () => renderer?.unmount());
|
||||
registerAppHandlers(null);
|
||||
publishSessionSnapshot(EMPTY_SESSION_SNAPSHOT);
|
||||
if (previousActEnvironment) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', previousActEnvironment);
|
||||
else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
const calls: unknown[][] = [];
|
||||
const moveWorkspacePane = (...args: unknown[]) => calls.push(['move', ...args]);
|
||||
const setWorkspaceAnchorChat = (...args: unknown[]) => calls.push(['anchor', ...args]);
|
||||
setSession({ ...EMPTY_SESSION_SNAPSHOT, moveWorkspacePane, setWorkspaceAnchorChat });
|
||||
registerAppHandlers({});
|
||||
const renderTree = () => React.createElement(
|
||||
SessionPublisher,
|
||||
{ persistSessionRestore: false },
|
||||
React.createElement(TerminalHost),
|
||||
);
|
||||
await act(async () => { renderer = create(renderTree()); });
|
||||
|
||||
assert.equal(getSessionSnapshotActions()?.moveWorkspacePane, moveWorkspacePane);
|
||||
assert.equal(getSessionSnapshotActions()?.setWorkspaceAnchorChat, setWorkspaceAnchorChat);
|
||||
const terminal = getAppShellPropsSnapshot().domains?.terminal;
|
||||
assert.equal(terminal?.moveWorkspacePane, moveWorkspacePane);
|
||||
assert.equal(terminal?.setWorkspaceAnchorChat, setWorkspaceAnchorChat);
|
||||
const hint = { direction: 'horizontal', position: 'top', targetSessionId: 'pane-b' };
|
||||
(terminal?.moveWorkspacePane as typeof moveWorkspacePane)('workspace', 'pane-a', hint);
|
||||
(terminal?.setWorkspaceAnchorChat as typeof setWorkspaceAnchorChat)('workspace', 'chat');
|
||||
assert.deepEqual(calls, [
|
||||
['move', 'workspace', 'pane-a', hint],
|
||||
['anchor', 'workspace', 'chat'],
|
||||
]);
|
||||
|
||||
// A changed state-owner callback must not be trapped in a stale memoized
|
||||
// action bag even when all session/workspace snapshots stay identical.
|
||||
const replacementMove = (...args: unknown[]) => calls.push(['replacement-move', ...args]);
|
||||
const replacementAnchor = (...args: unknown[]) => calls.push(['replacement-anchor', ...args]);
|
||||
setSession({ ...EMPTY_SESSION_SNAPSHOT, moveWorkspacePane: replacementMove, setWorkspaceAnchorChat: replacementAnchor });
|
||||
await act(async () => renderer!.update(renderTree()));
|
||||
assert.equal(getAppShellPropsSnapshot().domains?.terminal.moveWorkspacePane, replacementMove);
|
||||
assert.equal(getAppShellPropsSnapshot().domains?.terminal.setWorkspaceAnchorChat, replacementAnchor);
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
getAppHandlers,
|
||||
registerAppHandlers,
|
||||
stabilizeAppHandlers,
|
||||
subscribeAppHandlers,
|
||||
type StableAppHandlerCache,
|
||||
} from './appHandlersBridge';
|
||||
|
||||
test('stabilized handlers keep their identity and call the latest implementation', () => {
|
||||
const cache: StableAppHandlerCache = new Map();
|
||||
const ref = { current: 1 };
|
||||
const calls: string[] = [];
|
||||
const first = stabilizeAppHandlers(cache, {
|
||||
closeTabsBatch: (id: string) => calls.push(`v1:${id}`),
|
||||
toggleSidePanelRef: ref,
|
||||
});
|
||||
const second = stabilizeAppHandlers(cache, {
|
||||
closeTabsBatch: (id: string) => calls.push(`v2:${id}`),
|
||||
toggleSidePanelRef: ref,
|
||||
});
|
||||
assert.equal(second.closeTabsBatch, first.closeTabsBatch);
|
||||
assert.equal(second.toggleSidePanelRef, ref, 'non-function values pass through');
|
||||
(first.closeTabsBatch as (id: string) => void)('a');
|
||||
assert.deepEqual(calls, ['v2:a']);
|
||||
});
|
||||
|
||||
test('the bridge does not notify Hosts when only handler implementations change', () => {
|
||||
const cache: StableAppHandlerCache = new Map();
|
||||
let notified = 0;
|
||||
const unsubscribe = subscribeAppHandlers(() => { notified += 1; });
|
||||
try {
|
||||
registerAppHandlers(stabilizeAppHandlers(cache, { handleDeleteHost: () => 'v1' }));
|
||||
const afterFirst = notified;
|
||||
// A session status change recreated the callback (it closes over hosts).
|
||||
registerAppHandlers(stabilizeAppHandlers(cache, { handleDeleteHost: () => 'v2' }));
|
||||
assert.equal(notified, afterFirst);
|
||||
assert.equal((getAppHandlers()!.handleDeleteHost as () => string)(), 'v2');
|
||||
} finally {
|
||||
unsubscribe();
|
||||
registerAppHandlers(null);
|
||||
}
|
||||
});
|
||||
@@ -51,37 +51,6 @@ class AppHandlersBridge {
|
||||
|
||||
const bridge = new AppHandlersBridge();
|
||||
|
||||
export type StableAppHandlerCache = Map<string, { latest: unknown; stable: unknown }>;
|
||||
|
||||
/**
|
||||
* Give every function-valued handler a per-key wrapper with a stable identity
|
||||
* that always calls the latest implementation; non-functions (refs) pass
|
||||
* through. Many glue callbacks close over sessions/workspaces/hosts, so their
|
||||
* identities changed on every session status change and host update (connect
|
||||
* bursts, last-connected times), and the bridge re-notified every Host island,
|
||||
* rebuilding all shell bags and re-rendering AppView/TopTabs each time. The
|
||||
* handlers are actions invoked from events, never read during render.
|
||||
*/
|
||||
export function stabilizeAppHandlers(cache: StableAppHandlerCache, handlers: AppHandlers): AppHandlers {
|
||||
const stableHandlers: AppHandlers = {};
|
||||
for (const [key, value] of Object.entries(handlers)) {
|
||||
if (typeof value !== 'function') {
|
||||
stableHandlers[key] = value;
|
||||
continue;
|
||||
}
|
||||
let entry = cache.get(key);
|
||||
if (!entry) {
|
||||
const created: { latest: unknown; stable: unknown } = { latest: value, stable: null };
|
||||
created.stable = (...args: unknown[]) => (created.latest as (...callArgs: unknown[]) => unknown)(...args);
|
||||
cache.set(key, created);
|
||||
entry = created;
|
||||
}
|
||||
entry.latest = value;
|
||||
stableHandlers[key] = entry.stable;
|
||||
}
|
||||
return stableHandlers;
|
||||
}
|
||||
|
||||
export function registerAppHandlers(handlers: AppHandlers | null): void {
|
||||
bridge.set(handlers);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export type AppLocalUiSnapshot = {
|
||||
| { mode: 'append'; workspaceId: string }
|
||||
| { mode: 'create' }
|
||||
| null;
|
||||
quickSearch: string;
|
||||
protocolSelectHost: Host | null;
|
||||
navigateToSection: VaultSection | null;
|
||||
deepLinkHostDraft: Host | null;
|
||||
@@ -42,6 +43,7 @@ export const EMPTY_APP_LOCAL_UI: AppLocalUiSnapshot = Object.freeze({
|
||||
isQuickSwitcherOpen: false,
|
||||
isCreateWorkspaceOpen: false,
|
||||
addToWorkspaceDialog: null,
|
||||
quickSearch: '',
|
||||
protocolSelectHost: null,
|
||||
navigateToSection: null,
|
||||
deepLinkHostDraft: null,
|
||||
@@ -74,6 +76,7 @@ class AppLocalUiStore {
|
||||
prev.isQuickSwitcherOpen === next.isQuickSwitcherOpen
|
||||
&& prev.isCreateWorkspaceOpen === next.isCreateWorkspaceOpen
|
||||
&& prev.addToWorkspaceDialog === next.addToWorkspaceDialog
|
||||
&& prev.quickSearch === next.quickSearch
|
||||
&& prev.protocolSelectHost === next.protocolSelectHost
|
||||
&& prev.navigateToSection === next.navigateToSection
|
||||
&& prev.deepLinkHostDraft === next.deepLinkHostDraft
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useLayoutEffect, useMemo, useSyncExternalStore } from 'react';
|
||||
|
||||
import { getHostSearchMatch } from '../../../lib/searchMatcher';
|
||||
import type { Host } from '../../../types';
|
||||
import { useEditorTabChromeList } from '../../state/editorTabStore';
|
||||
import { useVaultSnapshot } from '../../state/vaultSnapshotStore';
|
||||
import { getAppHandlers, subscribeAppHandlers } from '../appHandlersBridge';
|
||||
import {
|
||||
publishAppShellDomainSlice,
|
||||
@@ -8,14 +11,16 @@ import {
|
||||
} from '../appShellPropsStore';
|
||||
import { useAppLocalUiStore } from '../appLocalUiStore';
|
||||
|
||||
const EMPTY_HOST_RESULTS: Host[] = [];
|
||||
|
||||
/**
|
||||
* Dialogs island: local dialog/queue state from `appLocalUiStore`. The quick
|
||||
* switcher owns its query and host results (QuickSwitcherOverlay), so typing
|
||||
* there no longer republishes this bag.
|
||||
* Dialogs island: local dialog/queue state from `appLocalUiStore`, plus a
|
||||
* selective vault hosts subscription for quick-search results when open.
|
||||
* Assembles dialogs + overlays field-by-field — never spreads a prepared bag.
|
||||
*/
|
||||
export function DialogsHost() {
|
||||
const local = useAppLocalUiStore();
|
||||
const vault = useVaultSnapshot();
|
||||
const editorTabs = useEditorTabChromeList();
|
||||
const handlers = useSyncExternalStore(
|
||||
subscribeAppHandlers,
|
||||
@@ -23,6 +28,22 @@ export function DialogsHost() {
|
||||
getAppHandlers,
|
||||
);
|
||||
|
||||
const quickResults = useMemo(() => {
|
||||
if (!local.isQuickSwitcherOpen) return EMPTY_HOST_RESULTS;
|
||||
const term = local.quickSearch.trim();
|
||||
if (!term) return vault.hosts as Host[];
|
||||
return (vault.hosts as Host[])
|
||||
.map((host) => ({ host, match: getHostSearchMatch(term, host) }))
|
||||
.filter((entry) => entry.match.matched)
|
||||
.sort((left, right) => {
|
||||
if (left.match.score !== right.match.score) {
|
||||
return right.match.score - left.match.score;
|
||||
}
|
||||
return left.host.label.localeCompare(right.host.label);
|
||||
})
|
||||
.map((entry) => entry.host);
|
||||
}, [local.isQuickSwitcherOpen, local.quickSearch, vault.hosts]);
|
||||
|
||||
const dialogsDomain = useMemo(() => {
|
||||
if (!handlers) return null;
|
||||
return {
|
||||
@@ -47,11 +68,14 @@ export function DialogsHost() {
|
||||
passphraseQueue: local.passphraseQueue,
|
||||
fidoPromptQueue: local.fidoPromptQueue,
|
||||
protocolSelectHost: local.protocolSelectHost,
|
||||
quickResults,
|
||||
quickSearch: local.quickSearch,
|
||||
resolveEmptyVaultConflict: handlers.resolveEmptyVaultConflict,
|
||||
setAddToWorkspaceDialog: handlers.setAddToWorkspaceDialog,
|
||||
setIsCreateWorkspaceOpen: handlers.setIsCreateWorkspaceOpen,
|
||||
setIsQuickSwitcherOpen: handlers.setIsQuickSwitcherOpen,
|
||||
setProtocolSelectHost: handlers.setProtocolSelectHost,
|
||||
setQuickSearch: handlers.setQuickSearch,
|
||||
};
|
||||
}, [
|
||||
editorTabs,
|
||||
@@ -64,6 +88,8 @@ export function DialogsHost() {
|
||||
local.passphraseQueue,
|
||||
local.fidoPromptQueue,
|
||||
local.protocolSelectHost,
|
||||
local.quickSearch,
|
||||
quickResults,
|
||||
]);
|
||||
|
||||
const overlays = useMemo(() => {
|
||||
|
||||
@@ -170,7 +170,6 @@ export function TerminalHost() {
|
||||
if (!handlers) return null;
|
||||
return {
|
||||
addSessionToWorkspace: sessionActions?.addSessionToWorkspace,
|
||||
moveWorkspacePane: sessionActions?.moveWorkspacePane,
|
||||
appendHostToWorkspace: sessionActions?.appendHostToWorkspace,
|
||||
appendLocalTerminalToWorkspace: sessionActions?.appendLocalTerminalToWorkspace,
|
||||
clearSessionFontSizeOverride: sessionActions?.clearSessionFontSizeOverride,
|
||||
@@ -228,7 +227,6 @@ export function TerminalHost() {
|
||||
setTerminalFontFamilyId: terminalSettingsActions?.setTerminalFontFamilyId,
|
||||
setTerminalFontSize: terminalSettingsActions?.setTerminalFontSize,
|
||||
setWorkspaceFocusedSession: sessionActions?.setWorkspaceFocusedSession,
|
||||
setWorkspaceAnchorChat: sessionActions?.setWorkspaceAnchorChat,
|
||||
sftpAutoOpenSidebar: terminalSettings.sftpAutoOpenSidebar,
|
||||
sftpFollowTerminalCwd: terminalSettings.sftpFollowTerminalCwd,
|
||||
setSftpFollowTerminalCwd: terminalSettingsActions?.setSftpFollowTerminalCwd,
|
||||
|
||||
@@ -14,22 +14,7 @@ function loadComponent(path: string, name: string, bindings: Record<string, unkn
|
||||
const code = ts.transpile(declaration.getText(ast).replace(/^export /, ''), {
|
||||
target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.React,
|
||||
});
|
||||
// Extracting the function drops imports, so inject bare React hooks/APIs into the sandbox.
|
||||
return vm.runInNewContext(`${code}; ${name}`, {
|
||||
React,
|
||||
useState: React.useState,
|
||||
useEffect: React.useEffect,
|
||||
useLayoutEffect: React.useLayoutEffect,
|
||||
useMemo: React.useMemo,
|
||||
useCallback: React.useCallback,
|
||||
useRef: React.useRef,
|
||||
useReducer: React.useReducer,
|
||||
useContext: React.useContext,
|
||||
memo: React.memo,
|
||||
createElement: React.createElement,
|
||||
Fragment: React.Fragment,
|
||||
...bindings,
|
||||
}) as React.ComponentType;
|
||||
return vm.runInNewContext(`${code}; ${name}`, { React, ...bindings }) as React.ComponentType;
|
||||
}
|
||||
|
||||
test('monitor controls update their runtime without rendering unrelated app siblings', async () => {
|
||||
@@ -47,7 +32,6 @@ test('monitor controls update their runtime without rendering unrelated app sibl
|
||||
};
|
||||
const MonitorRuntimeHost = loadComponent('./MonitorRuntimeHost.tsx', 'MonitorRuntimeHost', { useMonitorRuntime });
|
||||
const App = loadComponent('../../App.tsx', 'App', {
|
||||
performance: { mark: () => {} },
|
||||
useMonitorRuntime,
|
||||
MonitorRuntimeHost,
|
||||
AppLocalStateProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
|
||||
@@ -48,7 +48,6 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
resetWorkspaceRename,
|
||||
removeSessionFromWorkspace,
|
||||
setWorkspaceFocusedSession,
|
||||
setWorkspaceAnchorChat,
|
||||
toggleWorkspaceViewMode,
|
||||
createLocalTerminal,
|
||||
createSerialSession,
|
||||
@@ -59,7 +58,6 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
createWorkspaceWithHosts,
|
||||
createWorkspaceFromSessions,
|
||||
addSessionToWorkspace,
|
||||
moveWorkspacePane,
|
||||
appendHostToWorkspace,
|
||||
appendHostToWorkspaceConfirmed,
|
||||
appendLocalTerminalToWorkspace,
|
||||
@@ -135,7 +133,6 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
resetWorkspaceRename,
|
||||
removeSessionFromWorkspace,
|
||||
setWorkspaceFocusedSession,
|
||||
setWorkspaceAnchorChat,
|
||||
toggleWorkspaceViewMode,
|
||||
createLocalTerminal,
|
||||
createSerialSession,
|
||||
@@ -146,7 +143,6 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
createWorkspaceWithHosts,
|
||||
createWorkspaceFromSessions,
|
||||
addSessionToWorkspace,
|
||||
moveWorkspacePane,
|
||||
appendHostToWorkspace,
|
||||
appendHostToWorkspaceConfirmed,
|
||||
appendLocalTerminalToWorkspace,
|
||||
@@ -176,7 +172,6 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
updateSessionCodingCliProvider,
|
||||
}), [
|
||||
addSessionToWorkspace,
|
||||
moveWorkspacePane,
|
||||
appendHostToWorkspace,
|
||||
appendHostToWorkspaceConfirmed,
|
||||
appendLocalTerminalToWorkspace,
|
||||
@@ -213,7 +208,6 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
setActiveTabId,
|
||||
setDraggingSessionId,
|
||||
setWorkspaceFocusedSession,
|
||||
setWorkspaceAnchorChat,
|
||||
splitSession,
|
||||
startSessionRename,
|
||||
startWorkspaceRename,
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// Guards for what the startup bundle (index.html entry) may pull in. Each of
|
||||
// these used to land in the entry chunk that every window parses at launch.
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const read = (path: string) => readFileSync(join(root, path), 'utf8');
|
||||
const staticImports = (source: string) =>
|
||||
[...source.matchAll(/^\s*import\s+(?!type\b)[^;]*?from\s+['"]([^'"]+)['"]/gm)].map((match) => match[1]);
|
||||
|
||||
test('only the English dictionary is bundled with the startup entry', () => {
|
||||
const imports = staticImports(read('application/i18n/messages.ts'));
|
||||
assert.deepEqual(imports.filter((path) => path.startsWith('./locales/')), ['./locales/en']);
|
||||
});
|
||||
|
||||
test('startup modules do not statically import the AI runtime or SDK providers', () => {
|
||||
const runtimeModules = /harness\/globalAgentRuntime|plugin\/trustedHostRuntime|plugin\/configuredModel|pluginAgent\/trustedAgentRuntime|sdk\/providers/;
|
||||
for (const path of [
|
||||
'application/state/aiStateSnapshots.ts',
|
||||
'application/state/usePluginAIContextHost.ts',
|
||||
'application/state/usePluginAgentHost.ts',
|
||||
]) {
|
||||
const offending = staticImports(read(path)).filter((specifier) => runtimeModules.test(specifier));
|
||||
assert.deepEqual(offending, [], `${path} must load the AI runtime lazily`);
|
||||
}
|
||||
});
|
||||
|
||||
test("Vite's preload helper stays out of the vendor-ai chunk", () => {
|
||||
// The entry imports the helper for its dynamic imports; if it is grouped
|
||||
// with the AI SDK, the whole vendor-ai chunk is preloaded at startup.
|
||||
assert.match(read('vite.config.ts'), /vite\/preload-helper'\)\) return 'preload-helper'/);
|
||||
});
|
||||
|
||||
test('the QR decoder is loaded on demand', () => {
|
||||
assert.ok(!staticImports(read('components/HostTotpFields.tsx')).includes('jsqr'));
|
||||
});
|
||||
@@ -293,10 +293,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
terminalSettings,
|
||||
});
|
||||
|
||||
// Sync tray menu data + handle tray actions. Hosts are only re-sent when the
|
||||
// host list changed: session status updates (connect bursts) used to resend
|
||||
// and re-serialize every host and rebuild the macOS Dock menu each time.
|
||||
const lastTrayHostsRef = useRef<unknown>(null);
|
||||
// Sync tray menu data + handle tray actions
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -319,21 +316,17 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
};
|
||||
});
|
||||
|
||||
const hostsChanged = lastTrayHostsRef.current !== hosts;
|
||||
lastTrayHostsRef.current = hosts;
|
||||
const hostsForSystemMenu = hostsChanged
|
||||
? hosts
|
||||
.filter((host: any) => typeof host?.id === "string" && host.id.length > 0)
|
||||
.map((host: any) => ({
|
||||
id: host.id,
|
||||
label: host.label,
|
||||
hostname: host.hostname,
|
||||
group: host.group,
|
||||
pinned: host.pinned,
|
||||
lastConnectedAt: host.lastConnectedAt,
|
||||
protocol: host.protocol,
|
||||
}))
|
||||
: undefined;
|
||||
const hostsForSystemMenu = hosts
|
||||
.filter((host: any) => typeof host?.id === "string" && host.id.length > 0)
|
||||
.map((host: any) => ({
|
||||
id: host.id,
|
||||
label: host.label,
|
||||
hostname: host.hostname,
|
||||
group: host.group,
|
||||
pinned: host.pinned,
|
||||
lastConnectedAt: host.lastConnectedAt,
|
||||
protocol: host.protocol,
|
||||
}));
|
||||
|
||||
void bridge.updateTrayMenuData({
|
||||
sessions: sessionsForTray,
|
||||
@@ -341,7 +334,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
...rule,
|
||||
canStop: hasRuntimeTunnel(rule.id),
|
||||
})),
|
||||
...(hostsForSystemMenu ? { hosts: hostsForSystemMenu } : {}),
|
||||
hosts: hostsForSystemMenu,
|
||||
});
|
||||
}, 250);
|
||||
|
||||
|
||||
@@ -21,20 +21,13 @@ Object.defineProperty(globalThis, 'localStorage', {
|
||||
});
|
||||
|
||||
const {
|
||||
applyLegacySyncPayload,
|
||||
convergentConflictAddressKey,
|
||||
createConvergentSyncStateFromPayload,
|
||||
dotKey,
|
||||
mergeConvergentSyncStates,
|
||||
planConvergentSyncMigration,
|
||||
refreshConvergentSyncMigrationPlan,
|
||||
stripConvergentSyncEnvelope,
|
||||
} = await import('../domain/convergentSync/index.ts');
|
||||
const { getConvergentSyncLocalConfig } = await import('../infrastructure/services/convergentSyncConfig.ts');
|
||||
const {
|
||||
initializePreparedConvergentMigration,
|
||||
prepareConvergentSyncMigration,
|
||||
resolvePreparedConvergentMigrationConflict,
|
||||
} = await import('./convergentSyncMigration.ts');
|
||||
|
||||
function payload(): SyncPayload {
|
||||
@@ -51,42 +44,6 @@ test.beforeEach(() => {
|
||||
localStorageValues.clear();
|
||||
});
|
||||
|
||||
test('preview resolution uses the manager device identity and only updates the prepared plan', () => {
|
||||
const baseline: SyncPayload = { ...payload(), settings: { theme: 'system' } };
|
||||
const base = createConvergentSyncStateFromPayload(baseline, 'seed', NOW);
|
||||
const state = mergeConvergentSyncStates(
|
||||
applyLegacySyncPayload(base, baseline, { ...baseline, settings: { theme: 'light' } }, 'left', NOW + 1),
|
||||
applyLegacySyncPayload(base, baseline, { ...baseline, settings: { theme: 'dark' } }, 'right', NOW + 2),
|
||||
);
|
||||
const plan = refreshConvergentSyncMigrationPlan(planConvergentSyncMigration({
|
||||
localPayload: baseline, localTrustedBaseline: null, providers: [], deviceId: 'local', now: NOW,
|
||||
}), state);
|
||||
const prepared = { plan, localSnapshot: baseline, providerBaselines: [] };
|
||||
const conflict = plan.preview.conflicts[0];
|
||||
const addressKey = convergentConflictAddressKey(conflict.address);
|
||||
const selected = conflict.candidates.find((candidate) => candidate.value === 'light')!;
|
||||
const manager = { getState: () => ({ deviceId: 'resolver' }) } as unknown as CloudSyncManager;
|
||||
const resolved = resolvePreparedConvergentMigrationConflict(
|
||||
prepared, addressKey, dotKey(selected.dot), manager, NOW + 3,
|
||||
);
|
||||
|
||||
assert.equal(resolved.plan.preview.canInitialize, true);
|
||||
assert.equal(resolved.plan.preview.conflictCount, 0);
|
||||
assert.deepEqual(resolved.plan.preview.blockedReasons, []);
|
||||
assert.equal(resolved.plan.payload?.settings?.theme, 'light');
|
||||
assert.ok(resolved.plan.state?.vector.resolver);
|
||||
assert.equal(resolved.localSnapshot, prepared.localSnapshot);
|
||||
assert.equal(resolved.providerBaselines, prepared.providerBaselines);
|
||||
assert.equal(prepared.plan.preview.conflictCount, 1);
|
||||
assert.equal(prepared.plan.payload, null);
|
||||
assert.throws(() => resolvePreparedConvergentMigrationConflict(
|
||||
prepared, 'missing', dotKey(selected.dot), manager,
|
||||
), /conflict no longer exists/);
|
||||
assert.throws(() => resolvePreparedConvergentMigrationConflict(
|
||||
prepared, addressKey, 'missing', manager,
|
||||
), /candidate no longer exists/);
|
||||
});
|
||||
|
||||
test('preparation seeds a trusted baseline for an unchanged v1 provider', async () => {
|
||||
const remotePayload: SyncPayload = {
|
||||
...payload(),
|
||||
|
||||
@@ -6,11 +6,8 @@ import type {
|
||||
} from '../domain/sync';
|
||||
import {
|
||||
cloudSyncPayloadsEqual,
|
||||
convergentConflictAddressKey,
|
||||
materializeSyncPayloadFromConvergentState,
|
||||
planConvergentSyncMigration,
|
||||
refreshConvergentSyncMigrationPlan,
|
||||
resolveConvergentFieldConflict,
|
||||
stripConvergentSyncEnvelope,
|
||||
validateConvergentSyncPayload,
|
||||
type ConvergentMigrationPlan,
|
||||
@@ -28,25 +25,6 @@ export interface PreparedConvergentMigration {
|
||||
localSnapshot: SyncPayload;
|
||||
}
|
||||
|
||||
export function resolvePreparedConvergentMigrationConflict(
|
||||
prepared: PreparedConvergentMigration,
|
||||
addressKey: string,
|
||||
candidateDot: string,
|
||||
manager: CloudSyncManager = getCloudSyncManager(),
|
||||
now = Date.now(),
|
||||
): PreparedConvergentMigration {
|
||||
const conflict = prepared.plan.preview.conflicts.find(
|
||||
(entry) => convergentConflictAddressKey(entry.address) === addressKey,
|
||||
);
|
||||
if (!prepared.plan.state || !conflict) {
|
||||
throw new Error('The convergent migration conflict no longer exists');
|
||||
}
|
||||
const state = resolveConvergentFieldConflict(
|
||||
prepared.plan.state, conflict, candidateDot, manager.getState().deviceId, now,
|
||||
);
|
||||
return { ...prepared, plan: refreshConvergentSyncMigrationPlan(prepared.plan, state) };
|
||||
}
|
||||
|
||||
interface LegacyProviderBaselineSeed {
|
||||
provider: CloudProvider;
|
||||
remoteVersion: number;
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import React, { createContext, useContext, useEffect, useMemo, useSyncExternalStore } from 'react';
|
||||
import React, { createContext, useContext, useMemo } from 'react';
|
||||
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||
import {
|
||||
getBaseFallbackLocale,
|
||||
getLoadedLocaleMessages,
|
||||
getLocaleMessagesVersion,
|
||||
loadLocaleMessages,
|
||||
subscribeLocaleMessages,
|
||||
type Messages,
|
||||
} from './messages';
|
||||
import { MESSAGES_BY_LOCALE } from './messages';
|
||||
|
||||
type InterpolationValues = Record<string, string | number | boolean | null | undefined>;
|
||||
|
||||
@@ -36,59 +29,32 @@ const interpolate = (template: string, values?: InterpolationValues): string =>
|
||||
.replace(/\{(\w+)\}/g, replaceSingleBraceToken);
|
||||
};
|
||||
|
||||
type LocaleDictionaries = {
|
||||
direct?: Messages;
|
||||
base?: Messages;
|
||||
fallback?: Messages;
|
||||
const resolveMessage = (resolvedLocale: string, key: string): string | undefined => {
|
||||
const direct = MESSAGES_BY_LOCALE[resolvedLocale]?.[key];
|
||||
if (direct) return direct;
|
||||
const base = resolvedLocale.split('-')[0];
|
||||
const baseKey = Object.keys(MESSAGES_BY_LOCALE).find((k) => k === base || k.startsWith(`${base}-`));
|
||||
const baseHit = baseKey ? MESSAGES_BY_LOCALE[baseKey]?.[key] : undefined;
|
||||
if (baseHit) return baseHit;
|
||||
return MESSAGES_BY_LOCALE[DEFAULT_UI_LOCALE]?.[key];
|
||||
};
|
||||
|
||||
/**
|
||||
* Dictionaries consulted for `resolvedLocale`: exact locale, same-base sibling
|
||||
* (zh-TW -> zh-CN), then English. `_loadedVersion` is only a cache key so a
|
||||
* dictionary that finishes loading yields a fresh snapshot.
|
||||
*/
|
||||
const snapshotLocaleDictionaries = (resolvedLocale: string, _loadedVersion: number): LocaleDictionaries => {
|
||||
const baseKey = getBaseFallbackLocale(resolvedLocale);
|
||||
return {
|
||||
direct: getLoadedLocaleMessages(resolvedLocale),
|
||||
base: baseKey ? getLoadedLocaleMessages(baseKey) : undefined,
|
||||
fallback: getLoadedLocaleMessages(DEFAULT_UI_LOCALE),
|
||||
};
|
||||
};
|
||||
|
||||
const resolveMessage = (dictionaries: LocaleDictionaries, key: string): string | undefined =>
|
||||
dictionaries.direct?.[key] || dictionaries.base?.[key] || dictionaries.fallback?.[key];
|
||||
|
||||
export const I18nProvider: React.FC<{ locale: string; children: React.ReactNode }> = ({
|
||||
locale,
|
||||
children,
|
||||
}) => {
|
||||
const resolvedLocale = resolveSupportedLocale(locale || DEFAULT_UI_LOCALE);
|
||||
// Non-English dictionaries load on demand (index.tsx preloads the saved one
|
||||
// before the first render); re-render consumers once a switch finishes.
|
||||
const messagesVersion = useSyncExternalStore(
|
||||
subscribeLocaleMessages,
|
||||
getLocaleMessagesVersion,
|
||||
getLocaleMessagesVersion,
|
||||
);
|
||||
useEffect(() => {
|
||||
void loadLocaleMessages(resolvedLocale).catch(() => {});
|
||||
}, [resolvedLocale]);
|
||||
const dictionaries = useMemo(
|
||||
() => snapshotLocaleDictionaries(resolvedLocale, messagesVersion),
|
||||
[resolvedLocale, messagesVersion],
|
||||
);
|
||||
|
||||
const value = useMemo<I18nContextValue>(() => {
|
||||
return {
|
||||
locale,
|
||||
resolvedLocale,
|
||||
t: (key, values) => {
|
||||
const msg = resolveMessage(dictionaries, key) ?? key;
|
||||
const msg = resolveMessage(resolvedLocale, key) ?? key;
|
||||
return interpolate(msg, values);
|
||||
},
|
||||
};
|
||||
}, [dictionaries, locale, resolvedLocale]);
|
||||
}, [locale, resolvedLocale]);
|
||||
|
||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||
};
|
||||
|
||||
@@ -99,7 +99,6 @@ export const enAiMessages: Messages = {
|
||||
'ai.providers.advancedParams.hint': 'Leave blank to use provider defaults.',
|
||||
'ai.providers.advancedParams.maxTokens.placeholder': 'e.g. 4096',
|
||||
'ai.providers.advancedParams.default': 'Provider default',
|
||||
'ai.providers.advancedParams.reasoningEffort.help': 'xhigh and max are only sent when the selected model supports them; otherwise they are clamped to the deepest supported level.',
|
||||
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
@@ -661,10 +660,6 @@ export const enAiMessages: Messages = {
|
||||
'ai.chatShortcuts.title': 'Chat Shortcuts',
|
||||
'ai.chatShortcuts.selectionAction': 'Show Add to Conversation when selecting terminal text',
|
||||
'ai.chatShortcuts.selectionAction.description': 'Show a small AI button next to selected terminal text.',
|
||||
'ai.chatHistory.title': 'Chat History',
|
||||
'ai.chatHistory.messageCap': 'Messages kept per chat',
|
||||
'ai.chatHistory.messageCap.description': 'Maximum number of messages stored per chat (50–2000). Lowering it drops the oldest messages from storage on the next write. Larger values increase storage and cloud-sync size.',
|
||||
'ai.chatHistory.messageCap.unit': 'messages',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Codex main-process handlers are not loaded yet. Fully restart Netcatty, or restart the Electron dev process, then try again.',
|
||||
@@ -720,8 +715,6 @@ export const enAiMessages: Messages = {
|
||||
|
||||
// Unified tooltips for terminal workspace and top tabs (issue #954)
|
||||
'terminal.layer.addTerminal': 'Add Terminal',
|
||||
'terminal.layer.emptyWorkspace': 'No terminals in this workspace',
|
||||
'terminal.layer.emptyWorkspaceHint': 'Add a local terminal or connect to a host to get started.',
|
||||
'terminal.layer.switchToSplitView': 'Switch to Split View',
|
||||
'terminal.layer.sftp': 'SFTP',
|
||||
'terminal.layer.scripts': 'Scripts',
|
||||
|
||||
@@ -1,44 +1,6 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const enCoreMessages: Messages = {
|
||||
"settings.plugins.github.install": "Install from GitHub",
|
||||
"settings.plugins.github.description": "Download a published .ncpkg release, then review its source and permissions.",
|
||||
"settings.plugins.github.repository": "GitHub repository",
|
||||
"settings.plugins.github.formats": "Accepts https://github.com/owner/repo, github.com/owner/repo or owner/repo.",
|
||||
"settings.plugins.github.busy": "Downloading, validating or installing the package...",
|
||||
"settings.plugins.github.review": "Repository ownership and package integrity do not verify publisher trust. No build scripts run. The plugin installs disabled; review permission modes before enabling.",
|
||||
"settings.plugins.github.accept": "I trust this repository and have reviewed the declared permissions.",
|
||||
"settings.plugins.github.confirm": "Install disabled",
|
||||
"settings.plugins.github.download": "Download and review",
|
||||
"settings.plugins.github.error.invalidRepository": "Enter https://github.com/owner/repo, github.com/owner/repo or owner/repo (repository only; no credentials, query, fragment or extra paths).",
|
||||
"settings.plugins.github.error.unavailable": "GitHub plugin installation is unavailable in this session.",
|
||||
"settings.plugins.github.error.reviewFirst": "Download and review a GitHub plugin first.",
|
||||
"settings.plugins.github.error.network": "Cannot reach GitHub. Check your network connection and try again.",
|
||||
"settings.plugins.github.error.timeout": "The GitHub download timed out. Please try again.",
|
||||
"settings.plugins.github.error.rateLimit": "GitHub API rate limit reached. Try again later.",
|
||||
"settings.plugins.github.error.notFound": "GitHub repository or asset not found. Only accessible public repositories are supported; import private packages locally.",
|
||||
"settings.plugins.github.error.accessDenied": "GitHub access denied. Private repositories and sign-in are not supported.",
|
||||
"settings.plugins.github.error.http": "GitHub download failed (HTTP {status}). Please try again later.",
|
||||
"settings.plugins.github.error.moved": "This GitHub repository has moved. Enter its current repository URL.",
|
||||
"settings.plugins.github.error.redirect": "The GitHub download redirect could not be verified. Download a trusted .ncpkg package and import it locally.",
|
||||
"settings.plugins.github.error.noRelease": "This repository has no published stable release. Ask the author to publish one with a .ncpkg asset, or import a built package locally.",
|
||||
"settings.plugins.github.error.noAsset": "This release has no .ncpkg asset. Ask the author to build and attach one; Netcatty cannot build source repositories.",
|
||||
"settings.plugins.github.error.multipleAssets": "This release contains multiple .ncpkg assets. Download the intended package and import it locally.",
|
||||
"settings.plugins.github.error.metadata": "The GitHub release metadata is invalid. Ask the author to check the release, tag and .ncpkg asset.",
|
||||
"settings.plugins.github.error.identity": "The package does not match the selected GitHub repository and release. Installation was stopped.",
|
||||
"settings.plugins.github.error.commit": "The release tag has no accessible commit. Ask the author to repair the release tag.",
|
||||
"settings.plugins.github.error.size": "The GitHub package size is invalid or exceeds the download limit.",
|
||||
"settings.plugins.github.error.integrity": "The downloaded package failed its size or SHA-256 check. Download and review it again.",
|
||||
"settings.plugins.github.error.package": "GitHub did not return a valid .ncpkg ZIP package or release metadata. Ask the author to check the release asset.",
|
||||
"settings.plugins.github.error.busy": "A GitHub plugin operation is already active. Close the preview and try again.",
|
||||
"settings.plugins.github.error.expired": "The GitHub package preview expired. Download and review it again.",
|
||||
"settings.plugins.github.error.advanced": "This package requires advanced developer mode. Normal plugin management accepts browser-only packages.",
|
||||
"settings.plugins.github.error.incompatible": "This plugin is incompatible with this version of Netcatty. Check the technical details with the author.",
|
||||
"settings.plugins.github.error.source": "This plugin is already bound to another source. Uninstall it and explicitly clear its old source data before changing sources.",
|
||||
"settings.plugins.github.error.failed": "The plugin could not be validated or installed. Check the technical details before trying again.",
|
||||
"settings.plugins.github.error.details": "Technical details",
|
||||
|
||||
|
||||
"settings.plugins.landing.checkUpdates": "Check for updates",
|
||||
"settings.plugins.landing.checkingUpdates": "Checking…",
|
||||
"settings.plugins.landing.updateSummary": "Checked: {checked} · Published updates: {updates} · Not checked: {skipped}.",
|
||||
@@ -1418,9 +1380,6 @@ Highlight the focused split pane:
|
||||
"settings.plugins.openManager": "Open plugin manager",
|
||||
"settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:",
|
||||
"settings.plugins.sources.clearPrivate": "Also clear this source’s private data, grants and secrets to reinstall from another source. This cannot be undone.",
|
||||
"settings.plugins.sources.short.official": "Official",
|
||||
"settings.plugins.sources.short.thirdparty": "Third-party",
|
||||
"settings.plugins.sources.short.local": "Local",
|
||||
"settings.plugins.sources.official": "Official marketplace",
|
||||
"settings.plugins.sources.thirdparty": "Third-party marketplace",
|
||||
"settings.plugins.sources.manage": "Manage sources",
|
||||
@@ -1439,7 +1398,6 @@ Highlight the focused split pane:
|
||||
"settings.plugins.sources.local": "Local import",
|
||||
"settings.plugins.sources.all": "All sources",
|
||||
"settings.plugins.title": "Plugins",
|
||||
"settings.plugins.configuration": "Plugin settings",
|
||||
"settings.plugins.description": "Manage your plugins and explore the catalog.",
|
||||
'settings.plugins.loading': 'Loading plugin settings…',
|
||||
'settings.plugins.empty': 'No enabled plugin contributes settings.',
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
export const enMonitorMessages = {
|
||||
"monitor.systemDisks": "System partitions ({count})",
|
||||
"monitor.systemDisksAlert": "System partition usage exceeds 80%; expanded automatically.",
|
||||
"monitor.diskIoTotal": "Total",
|
||||
"monitor.partitionIoHint": "I/O of the block device backing this mount. Mounts sharing a device show the same rates. Unavailable until two valid samples or when no block device can be identified; not supported on macOS.",
|
||||
"monitor.diskIo": "Disk I/O",
|
||||
"monitor.diskRead": "Read",
|
||||
"monitor.diskWrite": "Write",
|
||||
|
||||
@@ -1,24 +1,6 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const enTerminalMessages: Messages = {
|
||||
'terminal.directory.open': 'Current directory: {path}. Change directory',
|
||||
'terminal.directory.unknown': 'Directory unknown',
|
||||
'terminal.directory.search': 'Search child directories...',
|
||||
'terminal.directory.directories': 'Directories',
|
||||
'terminal.directory.parent': '.. (Parent directory)',
|
||||
'terminal.directory.loading': 'Reading directories...',
|
||||
'terminal.directory.empty': 'No child directories',
|
||||
'terminal.directory.noMatches': 'No matching directories',
|
||||
'terminal.directory.hidden': 'Show hidden directories',
|
||||
'terminal.directory.refresh': 'Refresh',
|
||||
'terminal.directory.unavailable': 'Directory navigation is unavailable for this session.',
|
||||
'terminal.directory.busy': 'Wait for an empty shell prompt.',
|
||||
'terminal.directory.readFailed': 'Could not read this directory. Try refreshing.',
|
||||
'terminal.directory.changeFailed': 'Could not confirm the directory change. Refresh before retrying.',
|
||||
'terminal.directory.unsupportedPath': 'This directory name cannot be sent safely to the terminal.',
|
||||
'settings.terminal.behavior.contextChips': 'Context Chips',
|
||||
'settings.terminal.behavior.contextChips.desc': 'Show the context toolbar below quick commands in the compose bar.',
|
||||
|
||||
'terminal.toolbar.openSessionLog': 'Open session log',
|
||||
'terminal.toolbar.sessionLogActions': 'Session log actions',
|
||||
'terminal.toolbar.sessionLog.menu': 'Session log',
|
||||
@@ -478,7 +460,6 @@ export const enTerminalMessages: Messages = {
|
||||
'cloudSync.localBackups.clearFailed': 'Could not clear all local backups. Please retry.',
|
||||
'cloudSync.localBackups.title': 'Local Backup History',
|
||||
'cloudSync.localBackups.desc': 'Netcatty keeps local restore points before app version changes and before vault restores.',
|
||||
'cloudSync.localBackups.folderWarning': 'This folder alone is not a complete, portable backup. Deleting or wiping the app profile (userData) may make these restore points unreadable.',
|
||||
'cloudSync.localBackups.retentionTitle': 'Backup Retention',
|
||||
'cloudSync.localBackups.retentionDesc': 'Choose how many local backups Netcatty should keep.',
|
||||
'cloudSync.localBackups.maxCount': 'Max backups',
|
||||
@@ -493,7 +474,6 @@ export const enTerminalMessages: Messages = {
|
||||
'cloudSync.localBackups.restoreSuccess': 'Local backup restored.',
|
||||
'cloudSync.localBackups.restoreFailedTitle': 'Restore failed',
|
||||
'cloudSync.localBackups.restoreMissing': 'Backup not found.',
|
||||
'cloudSync.localBackups.decryptFailed': 'The current app profile cannot decrypt this backup. The encryption key may be missing, the profile may have changed, or the backup may be damaged.',
|
||||
'cloudSync.localBackups.protectiveBackupFailed': 'Safety backup could not be created, so the restore was aborted to protect your current data. Resolve the underlying issue (e.g. keychain access) and try again. Details: {message}',
|
||||
'cloudSync.localBackups.restoreConfirmTitle': 'Restore this backup?',
|
||||
'cloudSync.localBackups.restoreConfirmDesc': 'Your current hosts, keys, snippets and settings will be replaced with the contents of this backup. A protective snapshot of your current data is taken automatically first.',
|
||||
|
||||
@@ -316,26 +316,7 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.extract.extracting': 'Extracting {fileName}...',
|
||||
'sftp.extract.success': 'Extracted {fileName}',
|
||||
'sftp.extract.error': 'Failed to extract {fileName}: {error}',
|
||||
'sftp.archive.action': 'Compress...',
|
||||
'sftp.archive.title': 'Compress files',
|
||||
'sftp.archive.description': 'Create an archive on the server from {count} selected items.',
|
||||
'sftp.archive.name': 'Archive name',
|
||||
'sftp.archive.format': 'Format',
|
||||
'sftp.archive.destination': 'Save in',
|
||||
'sftp.archive.create': 'Compress',
|
||||
'sftp.archive.creating': 'Compressing...',
|
||||
'sftp.archive.overwriteTitle': 'Replace existing archive?',
|
||||
'sftp.archive.overwriteDescription': 'An archive named "{name}" already exists. Confirm to replace it with the new archive.',
|
||||
'sftp.archive.overwrite': 'Replace and compress',
|
||||
'sftp.archive.failed': 'Failed to create archive',
|
||||
'sftp.archive.invalidSelection': 'Cannot compress this selection',
|
||||
'sftp.archive.invalidName': 'Enter a valid file name without a directory path.',
|
||||
'sftp.archive.invalidExtension': 'The file extension must match the selected format.',
|
||||
'sftp.archive.connectionChanged': 'The connection has changed. Select the files again.',
|
||||
'sftp.archive.unavailable': 'Archive creation is unavailable for this connection.',
|
||||
'sftp.archive.success': 'Created {fileName}',
|
||||
'sftp.context.copyToOtherPane': 'Copy to other pane',
|
||||
'sftp.context.duplicate': 'Duplicate',
|
||||
'sftp.copyToOtherPane.unavailable': 'Open the two-pane SFTP view and connect the destination pane before copying files.',
|
||||
'sftp.copyCurrentPath': 'Copy current path',
|
||||
'sftp.copyCurrentPath.success': 'Current path copied',
|
||||
|
||||
@@ -90,7 +90,6 @@ export const esAiMessages: Messages = {
|
||||
'ai.providers.advancedParams.hint': 'Déjalo en blanco para usar los valores predeterminados del proveedor.',
|
||||
'ai.providers.advancedParams.maxTokens.placeholder': 'p. ej. 4096',
|
||||
'ai.providers.advancedParams.default': 'Predeterminado del proveedor',
|
||||
'ai.providers.advancedParams.reasoningEffort.help': 'xhigh y max solo se envían cuando el modelo elegido los admite; de lo contrario se ajustan al nivel más profundo compatible.',
|
||||
'ai.providers.headers': 'Cabeceras HTTP personalizadas',
|
||||
'ai.providers.headers.help': 'Cabeceras opcionales para este proveedor. Los valores se ocultan y se cifran al guardarlos.',
|
||||
'ai.providers.headers.name': 'Nombre de la cabecera',
|
||||
@@ -661,10 +660,6 @@ export const esAiMessages: Messages = {
|
||||
'ai.chatShortcuts.title': 'Atajos de chat',
|
||||
'ai.chatShortcuts.selectionAction': 'Mostrar "Agregar a la conversación" al seleccionar texto de la terminal',
|
||||
'ai.chatShortcuts.selectionAction.description': 'Muestra un pequeño botón de IA junto al texto seleccionado de la terminal.',
|
||||
'ai.chatHistory.title': 'Historial de chat',
|
||||
'ai.chatHistory.messageCap': 'Mensajes conservados por chat',
|
||||
'ai.chatHistory.messageCap.description': 'Número máximo de mensajes almacenados por chat (50–2000). Al reducirlo, los mensajes más antiguos se descartan del almacenamiento en la siguiente escritura. Los valores más altos aumentan el almacenamiento y el tamaño de la sincronización en la nube.',
|
||||
'ai.chatHistory.messageCap.unit': 'mensajes',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Los controladores del proceso principal de Codex aún no están cargados. Reinicia Netcatty por completo o reinicia el proceso de desarrollo de Electron y vuelve a intentarlo.',
|
||||
@@ -720,8 +715,6 @@ export const esAiMessages: Messages = {
|
||||
|
||||
// Unified tooltips for terminal workspace and top tabs (issue #954)
|
||||
'terminal.layer.addTerminal': 'Agregar terminal',
|
||||
'terminal.layer.emptyWorkspace': 'No hay terminales en este espacio de trabajo',
|
||||
'terminal.layer.emptyWorkspaceHint': 'Agrega un terminal local o conecta un host para comenzar.',
|
||||
'terminal.layer.switchToSplitView': 'Cambiar a vista dividida',
|
||||
'terminal.layer.sftp': 'SFTP',
|
||||
'terminal.layer.scripts': 'Scripts',
|
||||
|
||||
@@ -1,44 +1,6 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const esCoreMessages: Messages = {
|
||||
"settings.plugins.github.install": "Instalar desde GitHub",
|
||||
"settings.plugins.github.description": "Descarga una versión .ncpkg publicada y revisa su origen y permisos.",
|
||||
"settings.plugins.github.repository": "Repositorio de GitHub",
|
||||
"settings.plugins.github.formats": "Acepta https://github.com/owner/repo, github.com/owner/repo u owner/repo.",
|
||||
"settings.plugins.github.busy": "Descargando, validando o instalando el paquete...",
|
||||
"settings.plugins.github.review": "El origen y la integridad no verifican la confianza en el autor. No se ejecutan scripts de compilación. Se instala desactivado; revisa los permisos antes de activarlo.",
|
||||
"settings.plugins.github.accept": "Confío en este repositorio y he revisado los permisos declarados.",
|
||||
"settings.plugins.github.confirm": "Instalar desactivado",
|
||||
"settings.plugins.github.download": "Descargar y revisar",
|
||||
"settings.plugins.github.error.invalidRepository": "Introduce https://github.com/owner/repo, github.com/owner/repo u owner/repo. Usa solo el repositorio, sin credenciales, parámetros, fragmentos ni rutas adicionales.",
|
||||
"settings.plugins.github.error.unavailable": "La instalación de plugins desde GitHub no está disponible en esta sesión.",
|
||||
"settings.plugins.github.error.reviewFirst": "Primero descarga y revisa un plugin de GitHub.",
|
||||
"settings.plugins.github.error.network": "No se puede conectar con GitHub. Comprueba tu conexión e inténtalo de nuevo.",
|
||||
"settings.plugins.github.error.timeout": "La descarga de GitHub agotó el tiempo de espera. Inténtalo de nuevo.",
|
||||
"settings.plugins.github.error.rateLimit": "Se alcanzó el límite de solicitudes de la API de GitHub. Inténtalo más tarde.",
|
||||
"settings.plugins.github.error.notFound": "No se encontró el repositorio o archivo de GitHub. Solo se admiten repositorios públicos accesibles; importa los paquetes privados desde un archivo local.",
|
||||
"settings.plugins.github.error.accessDenied": "GitHub denegó el acceso. No se admiten repositorios privados ni inicio de sesión.",
|
||||
"settings.plugins.github.error.http": "La descarga de GitHub falló (HTTP {status}). Inténtalo más tarde.",
|
||||
"settings.plugins.github.error.moved": "Este repositorio de GitHub se ha movido. Introduce su dirección actual.",
|
||||
"settings.plugins.github.error.redirect": "No se pudo verificar la redirección de GitHub. Descarga un paquete .ncpkg de confianza e impórtalo desde un archivo local.",
|
||||
"settings.plugins.github.error.noRelease": "Este repositorio no tiene una versión estable publicada. Pide al autor que publique una con un archivo .ncpkg o importa un paquete compilado local.",
|
||||
"settings.plugins.github.error.noAsset": "Esta versión no incluye un archivo .ncpkg. Pide al autor que lo compile y adjunte; Netcatty no puede compilar repositorios de código fuente.",
|
||||
"settings.plugins.github.error.multipleAssets": "Esta versión contiene varios archivos .ncpkg. Descarga el paquete deseado e impórtalo desde un archivo local.",
|
||||
"settings.plugins.github.error.metadata": "Los metadatos de la versión de GitHub no son válidos. Pide al autor que revise la versión, la etiqueta y el archivo .ncpkg.",
|
||||
"settings.plugins.github.error.identity": "El paquete no coincide con el repositorio y la versión de GitHub seleccionados. Se detuvo la instalación.",
|
||||
"settings.plugins.github.error.commit": "La etiqueta de la versión no tiene un commit accesible. Pide al autor que corrija la etiqueta.",
|
||||
"settings.plugins.github.error.size": "El tamaño del paquete de GitHub no es válido o supera el límite de descarga.",
|
||||
"settings.plugins.github.error.integrity": "El paquete descargado no superó la comprobación de tamaño o SHA-256. Descárgalo y revísalo de nuevo.",
|
||||
"settings.plugins.github.error.package": "GitHub no devolvió un paquete ZIP .ncpkg o metadatos de versión válidos. Pide al autor que revise el archivo publicado.",
|
||||
"settings.plugins.github.error.busy": "Ya hay una operación de plugin de GitHub en curso. Cierra la vista previa e inténtalo de nuevo.",
|
||||
"settings.plugins.github.error.expired": "La vista previa del paquete de GitHub caducó. Descárgalo y revísalo de nuevo.",
|
||||
"settings.plugins.github.error.advanced": "Este paquete requiere el modo de desarrollo avanzado. La gestión normal solo admite paquetes para navegador.",
|
||||
"settings.plugins.github.error.incompatible": "Este plugin no es compatible con esta versión de Netcatty. Consulta los detalles técnicos con el autor.",
|
||||
"settings.plugins.github.error.source": "Este plugin ya está vinculado a otro origen. Desinstálalo y borra explícitamente los datos del origen anterior antes de cambiarlo.",
|
||||
"settings.plugins.github.error.failed": "No se pudo validar o instalar el plugin. Revisa los detalles técnicos antes de intentarlo de nuevo.",
|
||||
"settings.plugins.github.error.details": "Detalles técnicos",
|
||||
|
||||
|
||||
"settings.plugins.landing.checkUpdates": "Buscar actualizaciones",
|
||||
"settings.plugins.landing.checkingUpdates": "Buscando…",
|
||||
"settings.plugins.landing.updateSummary": "Comprobados: {checked} · Actualizaciones publicadas: {updates} · Sin comprobar: {skipped}.",
|
||||
@@ -1418,9 +1380,6 @@ Resalta el panel dividido enfocado:
|
||||
"settings.plugins.openManager": "Abrir gestor de plugins",
|
||||
"settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:",
|
||||
"settings.plugins.sources.clearPrivate": "Also clear this source’s private data, grants and secrets to reinstall from another source. This cannot be undone.",
|
||||
"settings.plugins.sources.short.official": "Oficial",
|
||||
"settings.plugins.sources.short.thirdparty": "Terceros",
|
||||
"settings.plugins.sources.short.local": "Local",
|
||||
"settings.plugins.sources.official": "Official marketplace",
|
||||
"settings.plugins.sources.thirdparty": "Third-party marketplace",
|
||||
"settings.plugins.sources.manage": "Manage sources",
|
||||
@@ -1439,7 +1398,6 @@ Resalta el panel dividido enfocado:
|
||||
"settings.plugins.sources.local": "Local import",
|
||||
"settings.plugins.sources.all": "All sources",
|
||||
"settings.plugins.title": "Plugins",
|
||||
"settings.plugins.configuration": "Ajustes del plugin",
|
||||
"settings.plugins.description": "Gestiona tus plugins y explora el catálogo.",
|
||||
'settings.plugins.loading': 'Cargando ajustes del plugin…',
|
||||
'settings.plugins.empty': 'Ningún plugin activado aporta ajustes.',
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
export const esMonitorMessages = {
|
||||
"monitor.systemDisks": "Particiones del sistema ({count})",
|
||||
"monitor.systemDisksAlert": "El uso de una partición del sistema supera el 80 %; se ha expandido automáticamente.",
|
||||
"monitor.diskIoTotal": "Total",
|
||||
"monitor.partitionIoHint": "E/S del dispositivo de bloques de este montaje. Los montajes del mismo dispositivo muestran las mismas tasas. No disponible hasta tener dos muestras válidas o sin un dispositivo identificable; no compatible con macOS.",
|
||||
"monitor.diskIo": "E/S de disco",
|
||||
"monitor.diskRead": "Lectura",
|
||||
"monitor.diskWrite": "Escritura",
|
||||
|
||||
@@ -1,24 +1,6 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const esTerminalMessages: Messages = {
|
||||
'terminal.directory.open': 'Directorio actual: {path}. Cambiar directorio',
|
||||
'terminal.directory.unknown': 'Directorio desconocido',
|
||||
'terminal.directory.search': 'Buscar subdirectorios...',
|
||||
'terminal.directory.directories': 'Directorios',
|
||||
'terminal.directory.parent': '.. (Directorio padre)',
|
||||
'terminal.directory.loading': 'Leyendo directorios...',
|
||||
'terminal.directory.empty': 'Sin subdirectorios',
|
||||
'terminal.directory.noMatches': 'Sin directorios coincidentes',
|
||||
'terminal.directory.hidden': 'Mostrar directorios ocultos',
|
||||
'terminal.directory.refresh': 'Actualizar',
|
||||
'terminal.directory.unavailable': 'La navegación de directorios no está disponible para esta sesión.',
|
||||
'terminal.directory.busy': 'Espera a que el prompt esté vacío.',
|
||||
'terminal.directory.readFailed': 'No se pudo leer el directorio. Actualiza e inténtalo de nuevo.',
|
||||
'terminal.directory.changeFailed': 'No se pudo confirmar el cambio. Actualiza antes de reintentar.',
|
||||
'terminal.directory.unsupportedPath': 'No se puede enviar este nombre de directorio de forma segura.',
|
||||
'settings.terminal.behavior.contextChips': 'Context Chips',
|
||||
'settings.terminal.behavior.contextChips.desc': 'Mostrar la barra de contexto debajo de los comandos rápidos.',
|
||||
|
||||
'terminal.toolbar.openSessionLog': 'Abrir registro de sesión',
|
||||
'terminal.toolbar.sessionLogActions': 'Acciones del registro',
|
||||
'terminal.toolbar.sessionLog.menu': 'Registro de sesión',
|
||||
@@ -478,7 +460,6 @@ export const esTerminalMessages: Messages = {
|
||||
'cloudSync.localBackups.clearFailed': 'No se pudieron eliminar todas las copias locales. Inténtalo de nuevo.',
|
||||
'cloudSync.localBackups.title': 'Historial de copias de seguridad locales',
|
||||
'cloudSync.localBackups.desc': 'Netcatty conserva puntos de restauración locales antes de los cambios de versión de la app y antes de las restauraciones de la bóveda.',
|
||||
'cloudSync.localBackups.folderWarning': 'Esta carpeta por sí sola no es una copia de seguridad completa y portátil. Eliminar o vaciar el perfil de la aplicación (userData) puede impedir la lectura de estos puntos de restauración.',
|
||||
'cloudSync.localBackups.retentionTitle': 'Retención de copias de seguridad',
|
||||
'cloudSync.localBackups.retentionDesc': 'Elige cuántas copias de seguridad locales debe conservar Netcatty.',
|
||||
'cloudSync.localBackups.maxCount': 'Copias máximas',
|
||||
@@ -493,7 +474,6 @@ export const esTerminalMessages: Messages = {
|
||||
'cloudSync.localBackups.restoreSuccess': 'Copia de seguridad local restaurada.',
|
||||
'cloudSync.localBackups.restoreFailedTitle': 'Falló la restauración',
|
||||
'cloudSync.localBackups.restoreMissing': 'No se encontró la copia de seguridad.',
|
||||
'cloudSync.localBackups.decryptFailed': 'El perfil actual de la aplicación no puede descifrar esta copia. Puede faltar la clave de cifrado, haber cambiado el perfil o estar dañada la copia.',
|
||||
'cloudSync.localBackups.protectiveBackupFailed': 'No se pudo crear la copia de seguridad de protección, por lo que la restauración se canceló para proteger tus datos actuales. Resuelve el problema subyacente (p. ej., acceso al keychain) e inténtalo de nuevo. Detalles: {message}',
|
||||
'cloudSync.localBackups.restoreConfirmTitle': '¿Restaurar esta copia de seguridad?',
|
||||
'cloudSync.localBackups.restoreConfirmDesc': 'Tus hosts, claves, snippets y configuraciones actuales se reemplazarán con el contenido de esta copia de seguridad. Se crea automáticamente una instantánea de protección de tus datos actuales primero.',
|
||||
|
||||
@@ -316,26 +316,7 @@ export const esVaultMessages: Messages = {
|
||||
'sftp.extract.extracting': 'Extrayendo {fileName}...',
|
||||
'sftp.extract.success': 'Se extrajo {fileName}',
|
||||
'sftp.extract.error': 'No se pudo extraer {fileName}: {error}',
|
||||
'sftp.archive.action': 'Comprimir...',
|
||||
'sftp.archive.title': 'Comprimir archivos',
|
||||
'sftp.archive.description': 'Crea un archivo comprimido en el servidor con los {count} elementos seleccionados.',
|
||||
'sftp.archive.name': 'Nombre del archivo comprimido',
|
||||
'sftp.archive.format': 'Formato',
|
||||
'sftp.archive.destination': 'Guardar en',
|
||||
'sftp.archive.create': 'Comprimir',
|
||||
'sftp.archive.creating': 'Comprimiendo...',
|
||||
'sftp.archive.overwriteTitle': '¿Reemplazar el archivo comprimido existente?',
|
||||
'sftp.archive.overwriteDescription': 'Ya existe un archivo comprimido llamado "{name}". Confirma para reemplazarlo por el nuevo.',
|
||||
'sftp.archive.overwrite': 'Reemplazar y comprimir',
|
||||
'sftp.archive.failed': 'No se pudo crear el archivo comprimido',
|
||||
'sftp.archive.invalidSelection': 'No se puede comprimir esta selección',
|
||||
'sftp.archive.invalidName': 'Ingresa un nombre de archivo válido sin una ruta de directorio.',
|
||||
'sftp.archive.invalidExtension': 'La extensión del archivo debe coincidir con el formato seleccionado.',
|
||||
'sftp.archive.connectionChanged': 'La conexión cambió. Vuelve a seleccionar los archivos.',
|
||||
'sftp.archive.unavailable': 'No se pueden crear archivos comprimidos en esta conexión.',
|
||||
'sftp.archive.success': 'Se creó {fileName}',
|
||||
'sftp.context.copyToOtherPane': 'Copiar al otro panel',
|
||||
'sftp.context.duplicate': 'Duplicar',
|
||||
'sftp.copyToOtherPane.unavailable': 'Abre la vista SFTP de dos paneles y conecta el panel de destino antes de copiar archivos.',
|
||||
'sftp.copyCurrentPath': 'Copiar ruta actual',
|
||||
'sftp.copyCurrentPath.success': 'Ruta actual copiada',
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
getBaseFallbackLocale,
|
||||
getLoadedLocaleMessages,
|
||||
getLocaleMessagesVersion,
|
||||
loadLocaleMessages,
|
||||
subscribeLocaleMessages,
|
||||
} from '../messages';
|
||||
|
||||
test('only English is loaded until another locale is requested', () => {
|
||||
assert.ok(getLoadedLocaleMessages('en'));
|
||||
assert.equal(getLoadedLocaleMessages('ru'), undefined);
|
||||
});
|
||||
|
||||
test('zh-TW resolves through zh-CN, matching the old static lookup order', () => {
|
||||
assert.equal(getBaseFallbackLocale('zh-TW'), 'zh-CN');
|
||||
assert.equal(getBaseFallbackLocale('zh-CN'), 'zh-CN');
|
||||
assert.equal(getBaseFallbackLocale('es'), 'es');
|
||||
assert.equal(getBaseFallbackLocale('fr'), undefined);
|
||||
});
|
||||
|
||||
test('loading a locale also loads its fallback and notifies subscribers once each', async () => {
|
||||
const before = getLocaleMessagesVersion();
|
||||
let notified = 0;
|
||||
const unsubscribe = subscribeLocaleMessages(() => { notified += 1; });
|
||||
await Promise.all([loadLocaleMessages('zh-TW'), loadLocaleMessages('zh-TW')]);
|
||||
unsubscribe();
|
||||
assert.ok(getLoadedLocaleMessages('zh-TW'));
|
||||
assert.ok(getLoadedLocaleMessages('zh-CN'), 'base fallback is loaded with zh-TW');
|
||||
assert.equal(notified, 2, 'one notification per newly loaded dictionary');
|
||||
assert.equal(getLocaleMessagesVersion(), before + 2);
|
||||
await loadLocaleMessages('zh-TW');
|
||||
assert.equal(getLocaleMessagesVersion(), before + 2, 'already loaded locales are not reloaded');
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { SUPPORTED_UI_LOCALES } from '../../../infrastructure/config/i18n';
|
||||
import { getLoadedLocaleMessages, loadLocaleMessages } from '../messages';
|
||||
import en from './en';
|
||||
import { describePluginGitHubInstallError } from '../../../domain/pluginGitHubInstallError';
|
||||
|
||||
test('GitHub install copy and error interpolation are complete in every supported UI locale', async () => {
|
||||
const keys = Object.keys(en).filter(key => key.startsWith('settings.plugins.github.'));
|
||||
assert.ok(keys.length > 30);
|
||||
for (const { id } of SUPPORTED_UI_LOCALES) {
|
||||
await loadLocaleMessages(id);
|
||||
const messages = getLoadedLocaleMessages(id)!;
|
||||
for (const key of keys) {
|
||||
assert.ok(messages[key]?.trim(), `${id}: ${key}`);
|
||||
assert.deepEqual(messages[key].match(/\{\w+\}/g) ?? [], en[key].match(/\{\w+\}/g) ?? [], `${id}: ${key}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('GitHub error presentation handles Electron wrappers, HTTP status and diagnostic fallback', () => {
|
||||
const expired = describePluginGitHubInstallError("Error invoking remote method 'plugins:github:install': Error: GitHub package preview expired. Download and review it again");
|
||||
assert.equal(expired.key, 'settings.plugins.github.error.expired');
|
||||
assert.equal(expired.details, null);
|
||||
assert.deepEqual(describePluginGitHubInstallError('GitHub download failed (HTTP 503)').values, { status: '503' });
|
||||
const validator = 'manifest version must be a semantic version';
|
||||
assert.equal(describePluginGitHubInstallError(validator).details, validator);
|
||||
assert.equal(describePluginGitHubInstallError(validator).key, 'settings.plugins.github.error.failed');
|
||||
assert.equal(describePluginGitHubInstallError('Plugin is incompatible: engine version').key, 'settings.plugins.github.error.incompatible');
|
||||
});
|
||||
@@ -71,7 +71,6 @@ export const ruAiMessages: Messages = {
|
||||
'ai.providers.advancedParams.hint': 'Оставьте пустым, чтобы использовать настройки провайдера по умолчанию.',
|
||||
'ai.providers.advancedParams.maxTokens.placeholder': 'например, 4096',
|
||||
'ai.providers.advancedParams.default': 'По умолчанию у провайдера',
|
||||
'ai.providers.advancedParams.reasoningEffort.help': 'xhigh и max отправляются только если выбранная модель их поддерживает; иначе значение понижается до максимального поддерживаемого уровня.',
|
||||
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
@@ -600,10 +599,6 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chatShortcuts.title': 'Быстрые действия чата',
|
||||
'ai.chatShortcuts.selectionAction': 'Показывать «Добавить в чат» при выделении в терминале',
|
||||
'ai.chatShortcuts.selectionAction.description': 'Показывать небольшую кнопку AI рядом с выделенным текстом терминала.',
|
||||
'ai.chatHistory.title': 'История чата',
|
||||
'ai.chatHistory.messageCap': 'Сообщений, сохраняемых в чате',
|
||||
'ai.chatHistory.messageCap.description': 'Максимальное число сообщений, хранимых для одного чата (50–2000). При уменьшении самые старые сообщения удаляются из хранилища при следующей записи. Большие значения увеличивают объём хранилища и синхронизации с облаком.',
|
||||
'ai.chatHistory.messageCap.unit': 'сообщений',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Обработчики главного процесса Codex ещё не загружены. Полностью перезапустите Netcatty или dev-процесс Electron и попробуйте снова.',
|
||||
@@ -659,8 +654,6 @@ export const ruAiMessages: Messages = {
|
||||
|
||||
// Unified tooltips for terminal workspace and top tabs (issue #954)
|
||||
'terminal.layer.addTerminal': 'Добавить терминал',
|
||||
'terminal.layer.emptyWorkspace': 'В этом рабочем пространстве пока нет терминалов',
|
||||
'terminal.layer.emptyWorkspaceHint': 'Добавьте локальный терминал или подключитесь к хосту, чтобы начать.',
|
||||
'terminal.layer.switchToSplitView': 'Переключить в режим разделения',
|
||||
'terminal.layer.sftp': 'SFTP',
|
||||
'terminal.layer.scripts': 'Скрипты',
|
||||
|
||||
@@ -1,44 +1,6 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const ruCoreMessages: Messages = {
|
||||
"settings.plugins.github.install": "Установить из GitHub",
|
||||
"settings.plugins.github.description": "Загрузите опубликованный выпуск .ncpkg и проверьте источник и разрешения.",
|
||||
"settings.plugins.github.repository": "Репозиторий GitHub",
|
||||
"settings.plugins.github.formats": "Поддерживаются https://github.com/owner/repo, github.com/owner/repo и owner/repo.",
|
||||
"settings.plugins.github.busy": "Загрузка, проверка или установка пакета...",
|
||||
"settings.plugins.github.review": "Источник и целостность пакета не подтверждают доверие к издателю. Скрипты сборки не запускаются. Плагин устанавливается выключенным; проверьте разрешения перед включением.",
|
||||
"settings.plugins.github.accept": "Я доверяю этому репозиторию и проверил заявленные разрешения.",
|
||||
"settings.plugins.github.confirm": "Установить выключенным",
|
||||
"settings.plugins.github.download": "Загрузить и проверить",
|
||||
"settings.plugins.github.error.invalidRepository": "Введите https://github.com/owner/repo, github.com/owner/repo или owner/repo. Укажите только репозиторий, без учётных данных, параметров, фрагментов и дополнительных путей.",
|
||||
"settings.plugins.github.error.unavailable": "Установка плагинов из GitHub недоступна в этом сеансе.",
|
||||
"settings.plugins.github.error.reviewFirst": "Сначала загрузите и проверьте плагин из GitHub.",
|
||||
"settings.plugins.github.error.network": "Не удалось подключиться к GitHub. Проверьте соединение и повторите попытку.",
|
||||
"settings.plugins.github.error.timeout": "Время ожидания загрузки из GitHub истекло. Повторите попытку.",
|
||||
"settings.plugins.github.error.rateLimit": "Достигнут лимит запросов API GitHub. Повторите попытку позже.",
|
||||
"settings.plugins.github.error.notFound": "Репозиторий или файл GitHub не найден. Поддерживаются только доступные публичные репозитории; импортируйте приватные пакеты из локального файла.",
|
||||
"settings.plugins.github.error.accessDenied": "GitHub отказал в доступе. Приватные репозитории и вход в аккаунт не поддерживаются.",
|
||||
"settings.plugins.github.error.http": "Ошибка загрузки из GitHub (HTTP {status}). Повторите попытку позже.",
|
||||
"settings.plugins.github.error.moved": "Репозиторий GitHub перемещён. Введите его текущий адрес.",
|
||||
"settings.plugins.github.error.redirect": "Не удалось проверить перенаправление загрузки GitHub. Загрузите доверенный пакет .ncpkg и импортируйте его из локального файла.",
|
||||
"settings.plugins.github.error.noRelease": "В репозитории нет опубликованного стабильного выпуска. Попросите автора опубликовать выпуск с файлом .ncpkg или импортируйте готовый пакет из локального файла.",
|
||||
"settings.plugins.github.error.noAsset": "В выпуске нет файла .ncpkg. Попросите автора собрать и прикрепить его; Netcatty не собирает репозитории исходного кода.",
|
||||
"settings.plugins.github.error.multipleAssets": "В выпуске несколько файлов .ncpkg. Загрузите нужный пакет и импортируйте его из локального файла.",
|
||||
"settings.plugins.github.error.metadata": "Некорректные метаданные выпуска GitHub. Попросите автора проверить выпуск, тег и файл .ncpkg.",
|
||||
"settings.plugins.github.error.identity": "Пакет не соответствует выбранному репозиторию и выпуску GitHub. Установка остановлена.",
|
||||
"settings.plugins.github.error.commit": "У тега выпуска нет доступного коммита. Попросите автора исправить тег.",
|
||||
"settings.plugins.github.error.size": "Размер пакета GitHub некорректен или превышает лимит загрузки.",
|
||||
"settings.plugins.github.error.integrity": "Загруженный пакет не прошёл проверку размера или SHA-256. Загрузите и проверьте его снова.",
|
||||
"settings.plugins.github.error.package": "GitHub не вернул корректный ZIP-пакет .ncpkg или метаданные выпуска. Попросите автора проверить файл выпуска.",
|
||||
"settings.plugins.github.error.busy": "Операция с плагином GitHub уже выполняется. Закройте предпросмотр и повторите попытку.",
|
||||
"settings.plugins.github.error.expired": "Предпросмотр пакета GitHub устарел. Загрузите и проверьте его снова.",
|
||||
"settings.plugins.github.error.advanced": "Для этого пакета нужен расширенный режим разработчика. Обычный менеджер принимает только браузерные пакеты.",
|
||||
"settings.plugins.github.error.incompatible": "Плагин несовместим с этой версией Netcatty. Уточните технические подробности у автора.",
|
||||
"settings.plugins.github.error.source": "Плагин уже привязан к другому источнику. Перед сменой источника удалите плагин и явно очистите данные прежнего источника.",
|
||||
"settings.plugins.github.error.failed": "Не удалось проверить или установить плагин. Изучите технические подробности перед повторной попыткой.",
|
||||
"settings.plugins.github.error.details": "Технические подробности",
|
||||
|
||||
|
||||
// Common
|
||||
'common.save': 'Сохранить',
|
||||
'common.cancel': 'Отмена',
|
||||
@@ -1283,9 +1245,6 @@ export const ruCoreMessages: Messages = {
|
||||
"settings.plugins.openManager": "Открыть управление плагинами",
|
||||
"settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:",
|
||||
"settings.plugins.sources.clearPrivate": "Also clear this source’s private data, grants and secrets to reinstall from another source. This cannot be undone.",
|
||||
"settings.plugins.sources.short.official": "Официальный",
|
||||
"settings.plugins.sources.short.thirdparty": "Сторонний",
|
||||
"settings.plugins.sources.short.local": "Локальный",
|
||||
"settings.plugins.sources.official": "Official marketplace",
|
||||
"settings.plugins.sources.thirdparty": "Third-party marketplace",
|
||||
"settings.plugins.sources.manage": "Manage sources",
|
||||
@@ -1304,7 +1263,6 @@ export const ruCoreMessages: Messages = {
|
||||
"settings.plugins.sources.local": "Local import",
|
||||
"settings.plugins.sources.all": "All sources",
|
||||
"settings.plugins.title": "Плагины",
|
||||
"settings.plugins.configuration": "Настройки плагина",
|
||||
"settings.plugins.description": "Управляйте плагинами и просматривайте каталог.",
|
||||
'settings.plugins.loading': 'Загрузка настроек плагинов…',
|
||||
'settings.plugins.empty': 'Включённые плагины не предоставляют настройки.',
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
export const ruMonitorMessages = {
|
||||
"monitor.systemDisks": "Системные разделы ({count})",
|
||||
"monitor.systemDisksAlert": "Использование системного раздела превышает 80 %; группа раскрыта автоматически.",
|
||||
"monitor.diskIoTotal": "Всего",
|
||||
"monitor.partitionIoHint": "Ввод-вывод блочного устройства точки монтирования. Точки одного устройства показывают одинаковые скорости. Недоступно до двух корректных замеров или без определённого устройства; macOS не поддерживается.",
|
||||
"monitor.diskIo": "Дисковый ввод-вывод",
|
||||
"monitor.diskRead": "Чтение",
|
||||
"monitor.diskWrite": "Запись",
|
||||
|
||||
@@ -1,24 +1,6 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const ruTerminalMessages: Messages = {
|
||||
'terminal.directory.open': 'Текущий каталог: {path}. Сменить каталог',
|
||||
'terminal.directory.unknown': 'Каталог неизвестен',
|
||||
'terminal.directory.search': 'Поиск подкаталогов...',
|
||||
'terminal.directory.directories': 'Каталоги',
|
||||
'terminal.directory.parent': '.. (Родительский каталог)',
|
||||
'terminal.directory.loading': 'Чтение каталогов...',
|
||||
'terminal.directory.empty': 'Нет подкаталогов',
|
||||
'terminal.directory.noMatches': 'Нет подходящих каталогов',
|
||||
'terminal.directory.hidden': 'Показывать скрытые каталоги',
|
||||
'terminal.directory.refresh': 'Обновить',
|
||||
'terminal.directory.unavailable': 'Навигация по каталогам недоступна для этого сеанса.',
|
||||
'terminal.directory.busy': 'Дождитесь пустой командной строки.',
|
||||
'terminal.directory.readFailed': 'Не удалось прочитать каталог. Попробуйте обновить.',
|
||||
'terminal.directory.changeFailed': 'Не удалось подтвердить смену каталога. Обновите перед повтором.',
|
||||
'terminal.directory.unsupportedPath': 'Это имя каталога нельзя безопасно передать в терминал.',
|
||||
'settings.terminal.behavior.contextChips': 'Context Chips',
|
||||
'settings.terminal.behavior.contextChips.desc': 'Показывать панель контекста под быстрыми командами.',
|
||||
|
||||
'terminal.toolbar.openSessionLog': 'Открыть журнал сессии',
|
||||
'terminal.toolbar.sessionLogActions': 'Действия с журналом',
|
||||
'terminal.toolbar.sessionLog.menu': 'Журнал сессии',
|
||||
@@ -496,7 +478,6 @@ export const ruTerminalMessages: Messages = {
|
||||
'cloudSync.localBackups.clearFailed': 'Не удалось удалить все локальные копии. Повторите попытку.',
|
||||
'cloudSync.localBackups.title': 'История локальных резервных копий',
|
||||
'cloudSync.localBackups.desc': 'Netcatty сохраняет локальные точки восстановления перед сменой версии приложения и перед восстановлением хранилища.',
|
||||
'cloudSync.localBackups.folderWarning': 'Эта папка сама по себе не является полной переносимой резервной копией. Удаление или очистка профиля приложения (userData) может сделать эти точки восстановления нечитаемыми.',
|
||||
'cloudSync.localBackups.retentionTitle': 'Хранение резервных копий',
|
||||
'cloudSync.localBackups.retentionDesc': 'Выберите, сколько локальных резервных копий должен хранить Netcatty.',
|
||||
'cloudSync.localBackups.maxCount': 'Макс. число копий',
|
||||
@@ -511,7 +492,6 @@ export const ruTerminalMessages: Messages = {
|
||||
'cloudSync.localBackups.restoreSuccess': 'Локальная резервная копия восстановлена.',
|
||||
'cloudSync.localBackups.restoreFailedTitle': 'Ошибка восстановления',
|
||||
'cloudSync.localBackups.restoreMissing': 'Резервная копия не найдена.',
|
||||
'cloudSync.localBackups.decryptFailed': 'Текущий профиль приложения не может расшифровать эту копию. Возможно, отсутствует ключ шифрования, профиль изменился или копия повреждена.',
|
||||
'cloudSync.localBackups.protectiveBackupFailed': 'Не удалось создать защитную резервную копию, поэтому восстановление было прервано для защиты ваших текущих данных. Устраните основную проблему (например, доступ к keychain) и попробуйте снова. Подробности: {message}',
|
||||
'cloudSync.localBackups.restoreConfirmTitle': 'Восстановить эту резервную копию?',
|
||||
'cloudSync.localBackups.restoreConfirmDesc': 'Ваши текущие хосты, ключи, сниппеты и настройки будут заменены содержимым этой резервной копии. Перед этим автоматически создаётся защитный снимок текущих данных.',
|
||||
|
||||
@@ -360,26 +360,7 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.extract.extracting': 'Распаковка {fileName}...',
|
||||
'sftp.extract.success': 'Распакован {fileName}',
|
||||
'sftp.extract.error': 'Не удалось распаковать {fileName}: {error}',
|
||||
'sftp.archive.action': 'Сжать...',
|
||||
'sftp.archive.title': 'Сжать файлы',
|
||||
'sftp.archive.description': 'Создать архив на сервере из выбранных объектов: {count}.',
|
||||
'sftp.archive.name': 'Имя архива',
|
||||
'sftp.archive.format': 'Формат',
|
||||
'sftp.archive.destination': 'Сохранить в',
|
||||
'sftp.archive.create': 'Сжать',
|
||||
'sftp.archive.creating': 'Сжатие...',
|
||||
'sftp.archive.overwriteTitle': 'Заменить существующий архив?',
|
||||
'sftp.archive.overwriteDescription': 'Архив с именем «{name}» уже существует. Подтвердите его замену новым архивом.',
|
||||
'sftp.archive.overwrite': 'Заменить и сжать',
|
||||
'sftp.archive.failed': 'Не удалось создать архив',
|
||||
'sftp.archive.invalidSelection': 'Невозможно сжать выбранные объекты',
|
||||
'sftp.archive.invalidName': 'Введите допустимое имя файла без пути к каталогу.',
|
||||
'sftp.archive.invalidExtension': 'Расширение файла должно соответствовать выбранному формату.',
|
||||
'sftp.archive.connectionChanged': 'Подключение изменилось. Выберите файлы заново.',
|
||||
'sftp.archive.unavailable': 'Создание архивов недоступно для этого подключения.',
|
||||
'sftp.archive.success': 'Создан архив {fileName}',
|
||||
'sftp.context.copyToOtherPane': 'Копировать в другую панель',
|
||||
'sftp.context.duplicate': 'Дублировать',
|
||||
'sftp.copyToOtherPane.unavailable': 'Откройте двухпанельный режим SFTP и подключите панель назначения перед копированием файлов.',
|
||||
'sftp.copyCurrentPath': 'Копировать текущий путь',
|
||||
'sftp.copyCurrentPath.success': 'Текущий путь скопирован',
|
||||
|
||||
@@ -99,7 +99,6 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.providers.advancedParams.hint': '留空则使用提供商默认值。',
|
||||
'ai.providers.advancedParams.maxTokens.placeholder': '例如 4096',
|
||||
'ai.providers.advancedParams.default': '提供商默认',
|
||||
'ai.providers.advancedParams.reasoningEffort.help': '仅当所选模型支持时才会发送 xhigh 和 max,否则自动收敛到该模型支持的最高档位。',
|
||||
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
@@ -661,10 +660,6 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chatShortcuts.title': '聊天快捷入口',
|
||||
'ai.chatShortcuts.selectionAction': '选中终端内容时显示“添加到对话”',
|
||||
'ai.chatShortcuts.selectionAction.description': '在终端里选中文本后显示 AI 快捷按钮。',
|
||||
'ai.chatHistory.title': '聊天历史',
|
||||
'ai.chatHistory.messageCap': '每个会话保留的消息数',
|
||||
'ai.chatHistory.messageCap.description': '每个会话最多存储的消息条数(50–2000)。调低后会在下一次写入时从存储中丢弃最旧的消息。数值越大,存储占用和云同步体积也越大。',
|
||||
'ai.chatHistory.messageCap.unit': '条消息',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Codex 主进程处理器尚未加载。请完全重启 Netcatty 或重启 Electron 开发进程,然后重试。',
|
||||
@@ -720,8 +715,6 @@ export const zhCNAiMessages: Messages = {
|
||||
|
||||
// 统一终端工作区和顶部标签的 tooltip 文案 (issue #954)
|
||||
'terminal.layer.addTerminal': '添加终端',
|
||||
'terminal.layer.emptyWorkspace': '工作区中还没有终端',
|
||||
'terminal.layer.emptyWorkspaceHint': '添加本地终端或连接主机,开始使用这个工作区。',
|
||||
'terminal.layer.switchToSplitView': '切换到分屏视图',
|
||||
'terminal.layer.sftp': '文件传输',
|
||||
'terminal.layer.scripts': '脚本',
|
||||
|
||||
@@ -1,44 +1,6 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhCNCoreMessages: Messages = {
|
||||
"settings.plugins.github.install": "从 GitHub 安装",
|
||||
"settings.plugins.github.description": "下载已发布的 .ncpkg 插件包,安装前请审查来源和所需权限。",
|
||||
"settings.plugins.github.repository": "GitHub 仓库",
|
||||
"settings.plugins.github.formats": "支持 https://github.com/owner/repo、github.com/owner/repo 或 owner/repo。",
|
||||
"settings.plugins.github.busy": "正在下载、验证或安装插件包…",
|
||||
"settings.plugins.github.review": "确认仓库来源并通过完整性校验,并不代表发布者值得信任。安装过程不会运行构建脚本,插件安装后默认禁用。启用前,请确认来源可信并检查各项权限的授权方式。",
|
||||
"settings.plugins.github.accept": "已审查并信任此仓库,且已查看声明的权限。",
|
||||
"settings.plugins.github.confirm": "安装并保持禁用",
|
||||
"settings.plugins.github.download": "下载并审查",
|
||||
"settings.plugins.github.error.invalidRepository": "请输入 https://github.com/owner/repo、github.com/owner/repo 或 owner/repo。请只填写仓库地址,不要包含账号密码、查询参数、片段或额外路径。",
|
||||
"settings.plugins.github.error.unavailable": "当前会话无法从 GitHub 安装插件。",
|
||||
"settings.plugins.github.error.reviewFirst": "请先下载并审查 GitHub 插件。",
|
||||
"settings.plugins.github.error.network": "无法连接 GitHub,请检查网络连接后重试。",
|
||||
"settings.plugins.github.error.timeout": "从 GitHub 下载超时,请重试。",
|
||||
"settings.plugins.github.error.rateLimit": "GitHub API 请求次数已达上限,请稍后重试。",
|
||||
"settings.plugins.github.error.notFound": "未找到 GitHub 仓库或发布附件。仅支持可访问的公开仓库;私有插件包请从本地导入。",
|
||||
"settings.plugins.github.error.accessDenied": "GitHub 拒绝访问。此功能不支持私有仓库或登录验证。",
|
||||
"settings.plugins.github.error.http": "从 GitHub 下载失败(HTTP {status}),请稍后重试。",
|
||||
"settings.plugins.github.error.moved": "此 GitHub 仓库已迁移,请输入当前仓库地址。",
|
||||
"settings.plugins.github.error.redirect": "无法验证 GitHub 下载重定向。请下载可信的 .ncpkg 插件包,再从本地导入。",
|
||||
"settings.plugins.github.error.noRelease": "此仓库尚未发布稳定版本。请作者发布包含 .ncpkg 附件的稳定版本,或从本地导入已构建的插件包。",
|
||||
"settings.plugins.github.error.noAsset": "此版本没有 .ncpkg 附件。请作者构建并上传插件包;Netcatty 无法直接构建源码仓库。",
|
||||
"settings.plugins.github.error.multipleAssets": "此版本包含多个 .ncpkg 附件。请下载需要的插件包,再从本地导入。",
|
||||
"settings.plugins.github.error.metadata": "GitHub 发布信息无效。请作者检查版本发布、标签和 .ncpkg 附件。",
|
||||
"settings.plugins.github.error.identity": "插件包与所选 GitHub 仓库或发布版本不匹配,已停止安装。",
|
||||
"settings.plugins.github.error.commit": "无法访问发布标签对应的提交,请作者修复发布标签。",
|
||||
"settings.plugins.github.error.size": "GitHub 插件包大小无效或超出下载限制。",
|
||||
"settings.plugins.github.error.integrity": "下载的插件包未通过大小或 SHA-256 校验,请重新下载并审查。",
|
||||
"settings.plugins.github.error.package": "GitHub 返回的内容不是有效的 .ncpkg ZIP 插件包或发布信息,请作者检查发布附件。",
|
||||
"settings.plugins.github.error.busy": "已有 GitHub 插件操作正在进行,请关闭预览后重试。",
|
||||
"settings.plugins.github.error.expired": "GitHub 插件包预览已失效,请重新下载并审查。",
|
||||
"settings.plugins.github.error.advanced": "此插件包需要高级开发者模式。普通插件管理仅接受浏览器运行时插件包。",
|
||||
"settings.plugins.github.error.incompatible": "此插件与当前 Netcatty 版本不兼容,请联系作者确认技术详情。",
|
||||
"settings.plugins.github.error.source": "此插件已绑定其他来源。更换来源前,请先卸载插件并明确清除原来源的私有数据。",
|
||||
"settings.plugins.github.error.failed": "无法验证或安装插件,请查看技术详情后再重试。",
|
||||
"settings.plugins.github.error.details": "技术详情",
|
||||
|
||||
|
||||
"settings.plugins.landing.checkUpdates": "检查更新",
|
||||
"settings.plugins.landing.checkingUpdates": "正在检查…",
|
||||
"settings.plugins.landing.updateSummary": "已检查:{checked} · 已发布更新:{updates} · 未检查:{skipped}。",
|
||||
@@ -1053,26 +1015,7 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
'sftp.extract.extracting': '正在解压 {fileName}...',
|
||||
'sftp.extract.success': '已解压 {fileName}',
|
||||
'sftp.extract.error': '解压 {fileName} 失败:{error}',
|
||||
'sftp.archive.action': '压缩…',
|
||||
'sftp.archive.title': '压缩文件',
|
||||
'sftp.archive.description': '将 {count} 个选中项目打包到服务器上的压缩包。',
|
||||
'sftp.archive.name': '压缩包名称',
|
||||
'sftp.archive.format': '格式',
|
||||
'sftp.archive.destination': '保存目录',
|
||||
'sftp.archive.create': '压缩',
|
||||
'sftp.archive.creating': '正在压缩…',
|
||||
'sftp.archive.overwriteTitle': '覆盖现有压缩包?',
|
||||
'sftp.archive.overwriteDescription': '“{name}”已存在。确认后将用新的压缩包替换它。',
|
||||
'sftp.archive.overwrite': '覆盖并压缩',
|
||||
'sftp.archive.failed': '压缩失败',
|
||||
'sftp.archive.invalidSelection': '无法压缩此选区',
|
||||
'sftp.archive.invalidName': '请输入有效的文件名,不要包含目录路径。',
|
||||
'sftp.archive.invalidExtension': '文件扩展名必须与所选格式一致。',
|
||||
'sftp.archive.connectionChanged': '连接已切换,请重新选择文件。',
|
||||
'sftp.archive.unavailable': '当前连接不支持创建压缩包。',
|
||||
'sftp.archive.success': '已创建 {fileName}',
|
||||
'sftp.context.copyToOtherPane': '复制到另一侧',
|
||||
'sftp.context.duplicate': '创建副本',
|
||||
'sftp.copyToOtherPane.unavailable': '请打开双栏 SFTP 文件管理,并连接目标侧后再复制文件。',
|
||||
'sftp.copyCurrentPath': '复制当前路径',
|
||||
'sftp.copyCurrentPath.success': '已复制当前路径',
|
||||
@@ -1352,9 +1295,6 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
"settings.plugins.openManager": "打开插件管理",
|
||||
"settings.plugins.sources.conflict": "此 ID 已从其他来源安装。请先卸载并明确清理旧来源数据,再换源重装。原来源:",
|
||||
"settings.plugins.sources.clearPrivate": "同时清除本来源的私有数据、授权和秘密,以便显式换源重装。此操作不可撤销。",
|
||||
"settings.plugins.sources.short.official": "官方",
|
||||
"settings.plugins.sources.short.thirdparty": "第三方",
|
||||
"settings.plugins.sources.short.local": "本地",
|
||||
"settings.plugins.sources.official": "官方市场",
|
||||
"settings.plugins.sources.thirdparty": "第三方市场",
|
||||
"settings.plugins.sources.manage": "管理来源",
|
||||
@@ -1373,7 +1313,6 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
"settings.plugins.sources.local": "本地导入",
|
||||
"settings.plugins.sources.all": "全部来源",
|
||||
"settings.plugins.title": "插件",
|
||||
"settings.plugins.configuration": "插件设置",
|
||||
"settings.plugins.description": "管理已安装插件,浏览精选目录。",
|
||||
'settings.plugins.loading': '正在加载插件设置…',
|
||||
'settings.plugins.empty': '当前没有已启用插件提供设置。',
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
export const zhCNMonitorMessages = {
|
||||
"monitor.systemDisks": "系统分区({count})",
|
||||
"monitor.systemDisksAlert": "系统分区使用率超过 80%,已自动展开。",
|
||||
"monitor.diskIoTotal": "汇总",
|
||||
"monitor.partitionIoHint": "此挂载点对应块设备的读写速率;共用设备的挂载点显示相同速率。取得两次有效采样前或无法识别块设备时显示 --;暂不支持 macOS。",
|
||||
"monitor.diskIo": "磁盘 I/O",
|
||||
"monitor.diskRead": "读取",
|
||||
"monitor.diskWrite": "写入",
|
||||
|
||||
@@ -1,24 +1,6 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhCNTerminalMessages: Messages = {
|
||||
'terminal.directory.open': '当前目录:{path}。更改目录',
|
||||
'terminal.directory.unknown': '目录未知',
|
||||
'terminal.directory.search': '搜索当前目录下的文件夹…',
|
||||
'terminal.directory.directories': '目录',
|
||||
'terminal.directory.parent': '..(父目录)',
|
||||
'terminal.directory.loading': '正在读取目录…',
|
||||
'terminal.directory.empty': '没有子目录',
|
||||
'terminal.directory.noMatches': '没有匹配的子目录',
|
||||
'terminal.directory.hidden': '显示隐藏目录',
|
||||
'terminal.directory.refresh': '刷新',
|
||||
'terminal.directory.unavailable': '此会话暂不支持目录导航。',
|
||||
'terminal.directory.busy': '请等待终端回到空命令提示符。',
|
||||
'terminal.directory.readFailed': '无法读取此目录,请刷新重试。',
|
||||
'terminal.directory.changeFailed': '未能确认目录切换,请刷新后重试。',
|
||||
'terminal.directory.unsupportedPath': '此目录名称暂不支持快捷切换。',
|
||||
'settings.terminal.behavior.contextChips': 'Context Chips 上下文工具栏',
|
||||
'settings.terminal.behavior.contextChips.desc': '在撰写栏的快捷命令下方显示上下文工具栏。',
|
||||
|
||||
'terminal.toolbar.openSessionLog': '打开会话日志',
|
||||
'terminal.toolbar.sessionLogActions': '会话日志操作',
|
||||
'terminal.toolbar.sessionLog.menu': '会话日志',
|
||||
|
||||
@@ -888,7 +888,6 @@ export const zhCNVaultMessages: Messages = {
|
||||
'cloudSync.localBackups.clearFailed': '未能清空全部本地备份,请重试。',
|
||||
'cloudSync.localBackups.title': '本地备份历史',
|
||||
'cloudSync.localBackups.desc': 'Netcatty 会在版本变化前,以及恢复主机库前,自动留下一份本地恢复点。',
|
||||
'cloudSync.localBackups.folderWarning': '仅复制此文件夹并不构成完整、可迁移的备份。删除或清空应用配置目录(userData)可能导致这些恢复点无法读取。',
|
||||
'cloudSync.localBackups.retentionTitle': '备份保留数量',
|
||||
'cloudSync.localBackups.retentionDesc': '设置 Netcatty 最多保留多少份本地备份。',
|
||||
'cloudSync.localBackups.maxCount': '最多保留',
|
||||
@@ -903,7 +902,6 @@ export const zhCNVaultMessages: Messages = {
|
||||
'cloudSync.localBackups.restoreSuccess': '已恢复本地备份。',
|
||||
'cloudSync.localBackups.restoreFailedTitle': '恢复失败',
|
||||
'cloudSync.localBackups.restoreMissing': '找不到这份备份。',
|
||||
'cloudSync.localBackups.decryptFailed': '当前应用配置无法解密此备份。可能是加密密钥丢失、应用配置已更改,或备份已损坏。',
|
||||
'cloudSync.localBackups.protectiveBackupFailed': '无法创建保护性备份,已中止恢复以避免覆盖当前数据。请先解决底层问题(例如钥匙串访问)后重试。详情:{message}',
|
||||
'cloudSync.localBackups.restoreConfirmTitle': '确认恢复此备份?',
|
||||
'cloudSync.localBackups.restoreConfirmDesc': '当前的主机、密钥、代码片段与设置将被替换为此备份中的内容。系统会先自动创建一个保护性快照,便于撤销。',
|
||||
|
||||
@@ -90,7 +90,6 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.providers.advancedParams.hint': '留空則使用供應商預設值。',
|
||||
'ai.providers.advancedParams.maxTokens.placeholder': '例如 4096',
|
||||
'ai.providers.advancedParams.default': '供應商預設',
|
||||
'ai.providers.advancedParams.reasoningEffort.help': '僅當所選模型支援時才會送出 xhigh 與 max,否則自動收斂到該模型支援的最高檔位。',
|
||||
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
@@ -651,10 +650,6 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.chatShortcuts.title': '聊天快捷入口',
|
||||
'ai.chatShortcuts.selectionAction': '選取終端內容時顯示「新增到對話」',
|
||||
'ai.chatShortcuts.selectionAction.description': '在終端裡選取文字後顯示 AI 快捷按鈕。',
|
||||
'ai.chatHistory.title': '聊天歷史',
|
||||
'ai.chatHistory.messageCap': '每個會話保留的訊息數',
|
||||
'ai.chatHistory.messageCap.description': '每個會話最多儲存的訊息數(50–2000)。調低後會在下一次寫入時從儲存中捨棄最舊的訊息。數值越大,儲存空間與雲端同步體積也越大。',
|
||||
'ai.chatHistory.messageCap.unit': '則訊息',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Codex 主程序處理器尚未載入。請完全重新啟動 Netcatty 或重新啟動 Electron 開發程序,然後重試。',
|
||||
@@ -710,8 +705,6 @@ export const zhTWAiMessages: Messages = {
|
||||
|
||||
// 統一終端工作區和頂部標籤的 tooltip 文案 (issue #954)
|
||||
'terminal.layer.addTerminal': '新增終端',
|
||||
'terminal.layer.emptyWorkspace': '工作區中還沒有終端',
|
||||
'terminal.layer.emptyWorkspaceHint': '新增本機終端或連線至主機,開始使用這個工作區。',
|
||||
'terminal.layer.switchToSplitView': '切換到分割檢視',
|
||||
'terminal.layer.sftp': '檔案傳輸',
|
||||
'terminal.layer.scripts': '指令碼',
|
||||
|
||||
@@ -1,44 +1,6 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhTWCoreMessages: Messages = {
|
||||
"settings.plugins.github.install": "從 GitHub 安裝",
|
||||
"settings.plugins.github.description": "下載已發布的 .ncpkg 外掛套件,安裝前請審查來源與所需權限。",
|
||||
"settings.plugins.github.repository": "GitHub 儲存庫",
|
||||
"settings.plugins.github.formats": "支援 https://github.com/owner/repo、github.com/owner/repo 或 owner/repo。",
|
||||
"settings.plugins.github.busy": "正在下載、驗證或安裝套件…",
|
||||
"settings.plugins.github.review": "確認儲存庫來源並通過完整性驗證,並不代表發布者值得信任。安裝過程不會執行建置腳本,外掛安裝後預設停用。啟用前,請確認來源可信並檢查各項權限的授權方式。",
|
||||
"settings.plugins.github.accept": "已審查並信任此儲存庫,且已查看宣告的權限。",
|
||||
"settings.plugins.github.confirm": "安裝並保持停用",
|
||||
"settings.plugins.github.download": "下載並審查",
|
||||
"settings.plugins.github.error.invalidRepository": "請輸入 https://github.com/owner/repo、github.com/owner/repo 或 owner/repo。請只填寫儲存庫位址,不要包含帳號密碼、查詢參數、片段或額外路徑。",
|
||||
"settings.plugins.github.error.unavailable": "目前的工作階段無法從 GitHub 安裝外掛。",
|
||||
"settings.plugins.github.error.reviewFirst": "請先下載並審查 GitHub 外掛。",
|
||||
"settings.plugins.github.error.network": "無法連線至 GitHub,請檢查網路連線後重試。",
|
||||
"settings.plugins.github.error.timeout": "從 GitHub 下載逾時,請重試。",
|
||||
"settings.plugins.github.error.rateLimit": "GitHub API 請求次數已達上限,請稍後重試。",
|
||||
"settings.plugins.github.error.notFound": "找不到 GitHub 儲存庫或發布附件。僅支援可存取的公開儲存庫;私有外掛套件請從本機匯入。",
|
||||
"settings.plugins.github.error.accessDenied": "GitHub 拒絕存取。此功能不支援私有儲存庫或登入驗證。",
|
||||
"settings.plugins.github.error.http": "從 GitHub 下載失敗(HTTP {status}),請稍後重試。",
|
||||
"settings.plugins.github.error.moved": "此 GitHub 儲存庫已遷移,請輸入目前的儲存庫位址。",
|
||||
"settings.plugins.github.error.redirect": "無法驗證 GitHub 下載重新導向。請下載可信的 .ncpkg 外掛套件,再從本機匯入。",
|
||||
"settings.plugins.github.error.noRelease": "此儲存庫尚未發布穩定版本。請作者發布包含 .ncpkg 附件的穩定版本,或從本機匯入已建置的外掛套件。",
|
||||
"settings.plugins.github.error.noAsset": "此版本沒有 .ncpkg 附件。請作者建置並上傳外掛套件;Netcatty 無法直接建置原始碼儲存庫。",
|
||||
"settings.plugins.github.error.multipleAssets": "此版本包含多個 .ncpkg 附件。請下載需要的外掛套件,再從本機匯入。",
|
||||
"settings.plugins.github.error.metadata": "GitHub 發布資訊無效。請作者檢查版本發布、標籤及 .ncpkg 附件。",
|
||||
"settings.plugins.github.error.identity": "外掛套件與所選 GitHub 儲存庫或發布版本不符,已停止安裝。",
|
||||
"settings.plugins.github.error.commit": "無法存取發布標籤對應的提交,請作者修復發布標籤。",
|
||||
"settings.plugins.github.error.size": "GitHub 外掛套件大小無效或超出下載限制。",
|
||||
"settings.plugins.github.error.integrity": "下載的外掛套件未通過大小或 SHA-256 驗證,請重新下載並審查。",
|
||||
"settings.plugins.github.error.package": "GitHub 傳回的內容不是有效的 .ncpkg ZIP 外掛套件或發布資訊,請作者檢查發布附件。",
|
||||
"settings.plugins.github.error.busy": "已有 GitHub 外掛操作正在進行,請關閉預覽後重試。",
|
||||
"settings.plugins.github.error.expired": "GitHub 外掛套件預覽已失效,請重新下載並審查。",
|
||||
"settings.plugins.github.error.advanced": "此外掛套件需要進階開發者模式。一般外掛管理僅接受瀏覽器執行環境的套件。",
|
||||
"settings.plugins.github.error.incompatible": "此外掛與目前的 Netcatty 版本不相容,請聯絡作者確認技術詳細資料。",
|
||||
"settings.plugins.github.error.source": "此外掛已綁定其他來源。更換來源前,請先解除安裝並明確清除原來源的私有資料。",
|
||||
"settings.plugins.github.error.failed": "無法驗證或安裝外掛,請查看技術詳細資料後再重試。",
|
||||
"settings.plugins.github.error.details": "技術詳細資料",
|
||||
|
||||
|
||||
"settings.plugins.landing.checkUpdates": "檢查更新",
|
||||
"settings.plugins.landing.checkingUpdates": "正在檢查…",
|
||||
"settings.plugins.landing.updateSummary": "已檢查:{checked} · 已發佈更新:{updates} · 未檢查:{skipped}。",
|
||||
@@ -1055,26 +1017,7 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
'sftp.extract.extracting': '正在解壓 {fileName}...',
|
||||
'sftp.extract.success': '已解壓 {fileName}',
|
||||
'sftp.extract.error': '解壓 {fileName} 失敗:{error}',
|
||||
'sftp.archive.action': '壓縮…',
|
||||
'sftp.archive.title': '壓縮檔案',
|
||||
'sftp.archive.description': '將 {count} 個選取項目打包成伺服器上的壓縮檔。',
|
||||
'sftp.archive.name': '壓縮檔名稱',
|
||||
'sftp.archive.format': '格式',
|
||||
'sftp.archive.destination': '儲存目錄',
|
||||
'sftp.archive.create': '壓縮',
|
||||
'sftp.archive.creating': '正在壓縮…',
|
||||
'sftp.archive.overwriteTitle': '覆寫現有壓縮檔?',
|
||||
'sftp.archive.overwriteDescription': '「{name}」已存在。確認後將以新的壓縮檔取代它。',
|
||||
'sftp.archive.overwrite': '覆寫並壓縮',
|
||||
'sftp.archive.failed': '壓縮失敗',
|
||||
'sftp.archive.invalidSelection': '無法壓縮此選取範圍',
|
||||
'sftp.archive.invalidName': '請輸入有效的檔案名稱,不要包含目錄路徑。',
|
||||
'sftp.archive.invalidExtension': '副檔名必須與所選格式一致。',
|
||||
'sftp.archive.connectionChanged': '連線已切換,請重新選取檔案。',
|
||||
'sftp.archive.unavailable': '目前連線不支援建立壓縮檔。',
|
||||
'sftp.archive.success': '已建立 {fileName}',
|
||||
'sftp.context.copyToOtherPane': '複製到另一側',
|
||||
'sftp.context.duplicate': '建立副本',
|
||||
'sftp.copyToOtherPane.unavailable': '請開啟雙欄 SFTP 檔案管理,並連線目標側後再複製檔案。',
|
||||
'sftp.copyCurrentPath': '複製目前路徑',
|
||||
'sftp.copyCurrentPath.success': '已複製目前路徑',
|
||||
@@ -1323,9 +1266,6 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
"settings.plugins.openManager": "開啟插件管理",
|
||||
"settings.plugins.sources.conflict": "此 ID 已从其他来源安装。请先卸载并明确清理旧来源数据,再换源重装。原来源:",
|
||||
"settings.plugins.sources.clearPrivate": "同时清除本来源的私有数据、授权和秘密,以便显式换源重装。此操作不可撤销。",
|
||||
"settings.plugins.sources.short.official": "官方",
|
||||
"settings.plugins.sources.short.thirdparty": "第三方",
|
||||
"settings.plugins.sources.short.local": "本機",
|
||||
"settings.plugins.sources.official": "官方市场",
|
||||
"settings.plugins.sources.thirdparty": "第三方市场",
|
||||
"settings.plugins.sources.manage": "管理来源",
|
||||
@@ -1344,7 +1284,6 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
"settings.plugins.sources.local": "本地导入",
|
||||
"settings.plugins.sources.all": "全部来源",
|
||||
"settings.plugins.title": "插件",
|
||||
"settings.plugins.configuration": "插件設定",
|
||||
"settings.plugins.description": "管理已安裝插件,瀏覽精選目錄。",
|
||||
'settings.plugins.loading': '正在載入外掛程式設定…',
|
||||
"settings.plugins.market.results": "{count} 個插件",
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
export const zhTWMonitorMessages = {
|
||||
"monitor.systemDisks": "系統分割區({count})",
|
||||
"monitor.systemDisksAlert": "系統分割區使用率超過 80%,已自動展開。",
|
||||
"monitor.diskIoTotal": "彙總",
|
||||
"monitor.partitionIoHint": "此掛載點對應區塊裝置的讀寫速率;共用裝置的掛載點顯示相同速率。取得兩次有效取樣前或無法識別區塊裝置時顯示 --;暫不支援 macOS。",
|
||||
"monitor.diskIo": "磁碟 I/O",
|
||||
"monitor.diskRead": "讀取",
|
||||
"monitor.diskWrite": "寫入",
|
||||
|
||||
@@ -1,24 +1,6 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhTWTerminalMessages: Messages = {
|
||||
'terminal.directory.open': '目前目錄:{path}。變更目錄',
|
||||
'terminal.directory.unknown': '目錄未知',
|
||||
'terminal.directory.search': '搜尋目前目錄下的資料夾…',
|
||||
'terminal.directory.directories': '目錄',
|
||||
'terminal.directory.parent': '..(父目錄)',
|
||||
'terminal.directory.loading': '正在讀取目錄…',
|
||||
'terminal.directory.empty': '沒有子目錄',
|
||||
'terminal.directory.noMatches': '沒有符合的子目錄',
|
||||
'terminal.directory.hidden': '顯示隱藏目錄',
|
||||
'terminal.directory.refresh': '重新整理',
|
||||
'terminal.directory.unavailable': '此工作階段暫不支援目錄導覽。',
|
||||
'terminal.directory.busy': '請等待終端回到空白命令提示字元。',
|
||||
'terminal.directory.readFailed': '無法讀取此目錄,請重新整理後重試。',
|
||||
'terminal.directory.changeFailed': '未能確認目錄切換,請重新整理後重試。',
|
||||
'terminal.directory.unsupportedPath': '此目錄名稱暫不支援快速切換。',
|
||||
'settings.terminal.behavior.contextChips': 'Context Chips 上下文工具列',
|
||||
'settings.terminal.behavior.contextChips.desc': '在撰寫列的快捷命令下方顯示上下文工具列。',
|
||||
|
||||
'terminal.toolbar.openSessionLog': '開啟工作階段日誌',
|
||||
'terminal.toolbar.sessionLogActions': '工作階段日誌操作',
|
||||
'terminal.toolbar.sessionLog.menu': '工作階段日誌',
|
||||
|
||||
@@ -888,7 +888,6 @@ export const zhTWVaultMessages: Messages = {
|
||||
'cloudSync.localBackups.clearFailed': '未能清空全部本機備份,請重試。',
|
||||
'cloudSync.localBackups.title': '本機備份歷史',
|
||||
'cloudSync.localBackups.desc': 'Netcatty 會在版本變化前,以及恢復主機庫前,自動留下一份本機恢復點。',
|
||||
'cloudSync.localBackups.folderWarning': '僅複製此資料夾並不構成完整、可攜的備份。刪除或清空應用程式設定目錄(userData)可能導致這些還原點無法讀取。',
|
||||
'cloudSync.localBackups.retentionTitle': '備份保留數量',
|
||||
'cloudSync.localBackups.retentionDesc': '設定 Netcatty 最多保留多少份本機備份。',
|
||||
'cloudSync.localBackups.maxCount': '最多保留',
|
||||
@@ -903,7 +902,6 @@ export const zhTWVaultMessages: Messages = {
|
||||
'cloudSync.localBackups.restoreSuccess': '已恢復本機備份。',
|
||||
'cloudSync.localBackups.restoreFailedTitle': '恢復失敗',
|
||||
'cloudSync.localBackups.restoreMissing': '找不到這份備份。',
|
||||
'cloudSync.localBackups.decryptFailed': '目前的應用程式設定無法解密此備份。可能是加密金鑰遺失、應用程式設定已變更,或備份已損毀。',
|
||||
'cloudSync.localBackups.protectiveBackupFailed': '無法建立保護性備份,已中止恢復以避免覆蓋目前資料。請先解決底層問題(例如鑰匙串存取)後重試。詳情:{message}',
|
||||
'cloudSync.localBackups.restoreConfirmTitle': '確認恢復此備份?',
|
||||
'cloudSync.localBackups.restoreConfirmDesc': '目前的主機、金鑰、程式碼片段與設定將被替換為此備份中的內容。系統會先自動建立一個保護性快照,便於復原。',
|
||||
|
||||
@@ -1,78 +1,17 @@
|
||||
import en, { type Messages } from './locales/en';
|
||||
import zhCN from './locales/zh-CN';
|
||||
import zhTW from './locales/zh-TW';
|
||||
import ru from './locales/ru';
|
||||
import es from './locales/es';
|
||||
|
||||
// Keep keys stable; add new locales by adding a loader below and an entry in
|
||||
// SUPPORTED_UI_LOCALES (infrastructure/config/i18n.ts).
|
||||
// Keep keys stable; add new locales by adding another import and map entry.
|
||||
export { type Messages };
|
||||
|
||||
/**
|
||||
* Lookup order for the "same base language" fallback in I18nProvider (for
|
||||
* example zh-TW falls back to zh-CN, the first `zh*` entry). Matches the key
|
||||
* order of the old static MESSAGES_BY_LOCALE map.
|
||||
*/
|
||||
export const LOCALE_MESSAGE_KEYS = ['en', 'ru', 'es', 'zh-CN', 'zh-TW'] as const;
|
||||
|
||||
/**
|
||||
* Only English is bundled with the startup chunk; other locales are loaded on
|
||||
* demand. All five dictionaries used to be parsed by every window at startup
|
||||
* (~1.3 MB of the entry chunk) even though one locale is in use.
|
||||
*/
|
||||
const LOCALE_LOADERS: Record<string, () => Promise<{ default: Messages }>> = {
|
||||
ru: () => import('./locales/ru'),
|
||||
es: () => import('./locales/es'),
|
||||
'zh-CN': () => import('./locales/zh-CN'),
|
||||
'zh-TW': () => import('./locales/zh-TW'),
|
||||
export const MESSAGES_BY_LOCALE: Record<string, Messages> = {
|
||||
en,
|
||||
ru,
|
||||
es,
|
||||
'zh-CN': zhCN,
|
||||
'zh-TW': zhTW,
|
||||
};
|
||||
|
||||
const loadedMessages: Record<string, Messages> = { en };
|
||||
const pendingLoads = new Map<string, Promise<void>>();
|
||||
const listeners = new Set<() => void>();
|
||||
let messagesVersion = 0;
|
||||
|
||||
export function getLoadedLocaleMessages(locale: string): Messages | undefined {
|
||||
return loadedMessages[locale];
|
||||
}
|
||||
|
||||
export function getLocaleMessagesVersion(): number {
|
||||
return messagesVersion;
|
||||
}
|
||||
|
||||
export function subscribeLocaleMessages(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/** The same-base fallback locale that I18nProvider consults for `locale`. */
|
||||
export function getBaseFallbackLocale(locale: string): string | undefined {
|
||||
const base = locale.split('-')[0];
|
||||
return LOCALE_MESSAGE_KEYS.find((key) => key === base || key.startsWith(`${base}-`));
|
||||
}
|
||||
|
||||
function loadOne(locale: string): Promise<void> {
|
||||
if (loadedMessages[locale]) return Promise.resolve();
|
||||
const loader = LOCALE_LOADERS[locale];
|
||||
if (!loader) return Promise.resolve();
|
||||
let pending = pendingLoads.get(locale);
|
||||
if (!pending) {
|
||||
pending = loader()
|
||||
.then((module) => {
|
||||
loadedMessages[locale] = module.default;
|
||||
messagesVersion += 1;
|
||||
for (const listener of listeners) listener();
|
||||
})
|
||||
.finally(() => {
|
||||
pendingLoads.delete(locale);
|
||||
});
|
||||
pendingLoads.set(locale, pending);
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** Load the dictionaries `locale` resolves through (itself and its base fallback). */
|
||||
export function loadLocaleMessages(locale: string): Promise<void> {
|
||||
const needed = new Set([locale]);
|
||||
const fallback = getBaseFallbackLocale(locale);
|
||||
if (fallback) needed.add(fallback);
|
||||
return Promise.all([...needed].map(loadOne)).then(() => undefined);
|
||||
}
|
||||
@@ -165,7 +165,7 @@ function isSuccessfulApplyResult(result: unknown): boolean {
|
||||
* the DB before collection. Ordinary last-known collect snapshots are never
|
||||
* re-applied, so local settings edits made after the last collect are kept.
|
||||
*/
|
||||
export async function collectPluginSyncSidecarsFromHost(options: {
|
||||
export async function collectPluginSyncSidecarsFromHost(options?: {
|
||||
/**
|
||||
* When true, never fall back to last-known cache. Returns null if the host is
|
||||
* gated off / non-authoritative so callers can omit `pluginSidecars` instead
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { requestAgentSessionReconnect } from './agentSessionReconnect';
|
||||
|
||||
test('reconnect preserves identity and only dispatches disconnected eligible sessions', () => {
|
||||
let calls = 0;
|
||||
const registry = { isActive: () => false, requestIfAvailable: () => { calls++; return true; } };
|
||||
assert.equal(requestAgentSessionReconnect('s', undefined, registry).ok, false);
|
||||
assert.equal(requestAgentSessionReconnect('s', 'connected', registry).status, 'connected');
|
||||
assert.equal(requestAgentSessionReconnect('s', 'connecting', registry).started, false);
|
||||
const result = requestAgentSessionReconnect('s', 'disconnected', registry);
|
||||
assert.deepEqual(result, { ok: true, started: true, sessionId: 's', status: 'reconnecting', shellReady: false });
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('auto/manual reconnect and unavailable terminal cannot queue another recovery', () => {
|
||||
const registry = { isActive: () => true, requestIfAvailable: () => { throw Error('must not dispatch'); } };
|
||||
assert.equal(requestAgentSessionReconnect('s', 'disconnected', registry).started, false);
|
||||
assert.equal(requestAgentSessionReconnect('s', 'disconnected', { isActive: () => false, requestIfAvailable: () => false }).code, 'RECONNECT_UNAVAILABLE');
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
type ReconnectRegistry = {
|
||||
isActive: (sessionId: string) => boolean;
|
||||
requestIfAvailable: (sessionId: string) => boolean;
|
||||
};
|
||||
|
||||
/** Synchronous dispatch only: no queued recovery after scope/terminal disposal. */
|
||||
export function requestAgentSessionReconnect(
|
||||
sessionId: string,
|
||||
status: string | undefined,
|
||||
registry: ReconnectRegistry,
|
||||
): Record<string, unknown> {
|
||||
if (!status) return { ok: false, started: false, error: `Session "${sessionId}" was not found.` };
|
||||
if (status === 'connected') return { ok: true, started: false, sessionId, status: 'connected', shellReady: false };
|
||||
if (status === 'connecting' || registry.isActive(sessionId)) {
|
||||
return { ok: true, started: false, sessionId, status: 'reconnecting' };
|
||||
}
|
||||
if (status !== 'disconnected' || !registry.requestIfAvailable(sessionId)) {
|
||||
return { ok: false, started: false, sessionId, code: 'RECONNECT_UNAVAILABLE', error: 'Reconnect is unavailable: terminal may be unmounted, waiting for authentication, or recovering. Inspect session state and the Netcatty UI.' };
|
||||
}
|
||||
return { ok: true, started: true, sessionId, status: 'reconnecting', shellReady: false };
|
||||
}
|
||||
@@ -1,285 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { AISession } from '../../infrastructure/ai/types';
|
||||
import { installFakeIndexedDb } from './testSupport/fakeIndexedDb';
|
||||
import {
|
||||
AI_SESSIONS_META_LAST_WRITE_KEY,
|
||||
AI_SESSIONS_META_MIGRATION_KEY,
|
||||
AI_SESSIONS_META_SCHEMA_VERSION_KEY,
|
||||
idbClearSessions,
|
||||
idbDeleteSessionIds,
|
||||
idbGetAllSessions,
|
||||
idbGetMeta,
|
||||
idbPutMeta,
|
||||
idbPutSession,
|
||||
idbPutSessions,
|
||||
isIndexedDbAvailable,
|
||||
loadOrMigrateAiSessions,
|
||||
mirrorActiveSessionMapToIdb,
|
||||
openAiSessionsIdb,
|
||||
readActiveSessionMapFromIdb,
|
||||
resetAiSessionsIdbForTests,
|
||||
} from './aiSessionIdbStorage';
|
||||
|
||||
function makeSession(id: string, updatedAt: number): AISession {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
agentId: 'agent',
|
||||
scope: { type: 'terminal', targetId: 't' },
|
||||
messages: [],
|
||||
createdAt: updatedAt,
|
||||
updatedAt,
|
||||
} as AISession;
|
||||
}
|
||||
|
||||
test('module wrapper roundtrips sessions through the object stores', async () => {
|
||||
const idb = installFakeIndexedDb();
|
||||
try {
|
||||
assert.equal(isIndexedDbAvailable(), true);
|
||||
const db = await openAiSessionsIdb();
|
||||
assert.equal(db.objectStoreNames.contains('sessions'), true);
|
||||
assert.equal(db.objectStoreNames.contains('meta'), true);
|
||||
|
||||
await idbPutSessions(db, [makeSession('chat-a', 2), makeSession('chat-b', 1)]);
|
||||
await idbPutSession(db, { ...makeSession('chat-a', 3), title: 'updated' });
|
||||
const all = await idbGetAllSessions(db);
|
||||
assert.deepEqual(all.map((s) => s.id).sort(), ['chat-a', 'chat-b']);
|
||||
assert.equal(all.find((s) => s.id === 'chat-a')?.title, 'updated');
|
||||
|
||||
await idbDeleteSessionIds(db, ['chat-a']);
|
||||
assert.deepEqual((await idbGetAllSessions(db)).map((s) => s.id), ['chat-b']);
|
||||
await idbClearSessions(db);
|
||||
assert.deepEqual(await idbGetAllSessions(db), []);
|
||||
} finally {
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('meta store roundtrips schema and active-map records', async () => {
|
||||
const idb = installFakeIndexedDb();
|
||||
try {
|
||||
const db = await openAiSessionsIdb();
|
||||
await idbPutMeta(db, AI_SESSIONS_META_SCHEMA_VERSION_KEY, 1);
|
||||
await idbPutMeta(db, AI_SESSIONS_META_LAST_WRITE_KEY, 1234);
|
||||
assert.equal(await idbGetMeta<number>(db, AI_SESSIONS_META_SCHEMA_VERSION_KEY), 1);
|
||||
assert.equal(await idbGetMeta<number>(db, AI_SESSIONS_META_LAST_WRITE_KEY), 1234);
|
||||
assert.equal(await idbGetMeta<number>(db, 'missing'), undefined);
|
||||
|
||||
await mirrorActiveSessionMapToIdb({ 'terminal:t': 'chat-a' });
|
||||
// Mirror uses its own connection.
|
||||
assert.deepEqual(await readActiveSessionMapFromIdb(), { 'terminal:t': 'chat-a' });
|
||||
} finally {
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('loadOrMigrateAiSessions reads IndexedDB first and returns its sessions', async () => {
|
||||
const idb = installFakeIndexedDb();
|
||||
try {
|
||||
const db = await openAiSessionsIdb();
|
||||
await idbPutSessions(db, [makeSession('idb-chat', 1)]);
|
||||
const result = await loadOrMigrateAiSessions(() => JSON.stringify([makeSession('legacy-chat', 2)]));
|
||||
assert.equal(result.source, 'indexeddb');
|
||||
assert.equal(result.idbFailed, false);
|
||||
assert.deepEqual(result.sessions.map((s) => s.id), ['idb-chat']);
|
||||
// Legacy key content was not consumed for a migration when IDB has data.
|
||||
assert.equal(idb.db.dumpStore('sessions').length, 1);
|
||||
} finally {
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('loadOrMigrateAiSessions migrates a populated legacy store into empty IndexedDB, keeping the key untouched', async () => {
|
||||
const idb = installFakeIndexedDb();
|
||||
try {
|
||||
const legacyRaw = JSON.stringify([makeSession('legacy-1', 2), makeSession('legacy-2', 1)]);
|
||||
let readCount = 0;
|
||||
const result = await loadOrMigrateAiSessions(() => {
|
||||
readCount += 1;
|
||||
return legacyRaw;
|
||||
});
|
||||
assert.equal(result.source, 'legacy-localstorage');
|
||||
assert.equal(result.idbFailed, false);
|
||||
assert.deepEqual(result.sessions.map((s) => s.id).sort(), ['legacy-1', 'legacy-2']);
|
||||
// Now in IndexedDB (one record per session).
|
||||
assert.deepEqual(
|
||||
idb.db.dumpStore('sessions').map((s) => (s as AISession).id).sort(),
|
||||
['legacy-1', 'legacy-2'],
|
||||
);
|
||||
// Meta records written for the migration.
|
||||
const db = await openAiSessionsIdb();
|
||||
assert.equal(await idbGetMeta<number>(db, AI_SESSIONS_META_SCHEMA_VERSION_KEY), 1);
|
||||
assert.ok(await idbGetMeta<number>(db, AI_SESSIONS_META_LAST_WRITE_KEY));
|
||||
assert.ok(readCount >= 1);
|
||||
// The legacy raw string itself is only read, never rewritten or removed
|
||||
// — the callback is the only interface to the legacy store.
|
||||
assert.equal(readCount >= 1, true);
|
||||
} finally {
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('loadOrMigrateAiSessions returns none for an empty store without legacy data', async () => {
|
||||
const idb = installFakeIndexedDb();
|
||||
try {
|
||||
const result = await loadOrMigrateAiSessions(() => null);
|
||||
assert.equal(result.source, 'none');
|
||||
assert.deepEqual(result.sessions, []);
|
||||
assert.equal(result.idbFailed, false);
|
||||
assert.equal(result.authoritativeEmpty, false, 'a newly created store still permits disk recovery');
|
||||
assert.equal(result.migrationPending, false);
|
||||
} finally {
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('loadOrMigrateAiSessions treats a legacy empty array as an intentional wipe (no migration, no restore source)', async () => {
|
||||
const idb = installFakeIndexedDb();
|
||||
try {
|
||||
const result = await loadOrMigrateAiSessions(() => '[]');
|
||||
assert.equal(result.source, 'none');
|
||||
assert.deepEqual(result.sessions, []);
|
||||
assert.equal(idb.db.dumpStore('sessions').length, 0);
|
||||
} finally {
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('loadOrMigrateAiSessions falls back to legacy localStorage when IndexedDB is corrupt', async () => {
|
||||
const idb = installFakeIndexedDb({ failOpen: true });
|
||||
try {
|
||||
const legacy = [makeSession('legacy-chat', 1)];
|
||||
const result = await loadOrMigrateAiSessions(() => JSON.stringify(legacy));
|
||||
assert.equal(result.source, 'legacy-localstorage');
|
||||
assert.equal(result.idbFailed, true);
|
||||
assert.deepEqual(result.sessions, legacy);
|
||||
} finally {
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('loadOrMigrateAiSessions falls back to legacy localStorage when IndexedDB is unavailable', async () => {
|
||||
const previous = (globalThis as { indexedDB?: unknown }).indexedDB;
|
||||
(globalThis as { indexedDB?: unknown }).indexedDB = undefined;
|
||||
try {
|
||||
const legacy = [makeSession('legacy-chat', 1)];
|
||||
const result = await loadOrMigrateAiSessions(() => JSON.stringify(legacy));
|
||||
assert.equal(isIndexedDbAvailable(), false);
|
||||
assert.equal(result.source, 'legacy-localstorage');
|
||||
assert.equal(result.idbFailed, false);
|
||||
assert.deepEqual(result.sessions, legacy);
|
||||
} finally {
|
||||
(globalThis as { indexedDB?: unknown }).indexedDB = previous;
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Issue #4125 review P1②: the migration is one-shot via a persisted marker ──
|
||||
|
||||
test('a completed migration is one-shot: a chat deleted afterwards stays deleted on the next boot', async () => {
|
||||
const idb = installFakeIndexedDb();
|
||||
try {
|
||||
const legacyRaw = JSON.stringify([makeSession('legacy-1', 2), makeSession('legacy-2', 1)]);
|
||||
const first = await loadOrMigrateAiSessions(() => legacyRaw);
|
||||
assert.equal(first.source, 'legacy-localstorage');
|
||||
// The marker was persisted after the migration data became durable.
|
||||
let db = await openAiSessionsIdb();
|
||||
assert.ok(await idbGetMeta(db, AI_SESSIONS_META_MIGRATION_KEY));
|
||||
// The user deletes a chat; the legacy key stays behind by design.
|
||||
await idbDeleteSessionIds(db, ['legacy-1']);
|
||||
// Next boot: the marker is present, so the legacy key is never
|
||||
// re-migrated and the deletion is not resurrected.
|
||||
const second = await loadOrMigrateAiSessions(() => legacyRaw);
|
||||
assert.equal(second.source, 'indexeddb');
|
||||
assert.deepEqual(second.sessions.map((s) => s.id), ['legacy-2']);
|
||||
assert.equal((await openAiSessionsIdb()).objectStoreNames.contains('sessions'), true);
|
||||
db = await openAiSessionsIdb();
|
||||
assert.deepEqual((await idbGetAllSessions(db)).map((s) => s.id), ['legacy-2']);
|
||||
} finally {
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('an empty IndexedDB store with the migration marker ignores a populated legacy key', async () => {
|
||||
const idb = installFakeIndexedDb();
|
||||
try {
|
||||
// First boot with nothing anywhere records the marker.
|
||||
const first = await loadOrMigrateAiSessions(() => null);
|
||||
assert.equal(first.source, 'none');
|
||||
assert.ok(await idbGetMeta(await openAiSessionsIdb(), AI_SESSIONS_META_MIGRATION_KEY));
|
||||
// The user deleted every chat, but the stale legacy key is still there.
|
||||
const second = await loadOrMigrateAiSessions(
|
||||
() => JSON.stringify([makeSession('resurrected', 1)]),
|
||||
);
|
||||
assert.equal(second.source, 'none');
|
||||
assert.equal(second.authoritativeEmpty, true);
|
||||
assert.deepEqual(second.sessions, []);
|
||||
assert.equal(idb.db.dumpStore('sessions').length, 0);
|
||||
} finally {
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('partial disk migration retries missing IDs beside existing IDB records until all are durable', async () => {
|
||||
const idb = installFakeIndexedDb();
|
||||
try {
|
||||
const primary = makeSession('primary', 3);
|
||||
const diskA = makeSession('disk-a', 2);
|
||||
const diskB = makeSession('disk-b', 1);
|
||||
const diskIds = [diskA.id, diskB.id];
|
||||
const db = await openAiSessionsIdb();
|
||||
const first = await loadOrMigrateAiSessions(() => JSON.stringify([primary]), diskIds);
|
||||
assert.equal(first.migrationPending, true);
|
||||
assert.deepEqual(await idbGetAllSessions(db), [primary]);
|
||||
assert.equal(await idbGetMeta(db, AI_SESSIONS_META_MIGRATION_KEY), undefined);
|
||||
|
||||
const updated = { ...primary, title: 'edited in IndexedDB', updatedAt: 4 };
|
||||
await idbPutSession(db, updated);
|
||||
const partial = await loadOrMigrateAiSessions(() => JSON.stringify([primary, diskA]), diskIds);
|
||||
assert.equal(partial.migrationPending, true, 'one recovered disk ID does not complete migration');
|
||||
assert.deepEqual(partial.sessions, [updated, diskA]);
|
||||
assert.equal(await idbGetMeta(db, AI_SESSIONS_META_MIGRATION_KEY), undefined);
|
||||
|
||||
const complete = await loadOrMigrateAiSessions(() => JSON.stringify([primary, diskA, diskB]), diskIds);
|
||||
assert.equal(complete.source, 'indexeddb');
|
||||
assert.equal(complete.migrationPending, false);
|
||||
assert.deepEqual(complete.sessions, [updated, diskA, diskB]);
|
||||
assert.deepEqual(await idbGetAllSessions(db), [updated, diskA, diskB]);
|
||||
assert.ok(await idbGetMeta(db, AI_SESSIONS_META_MIGRATION_KEY));
|
||||
|
||||
await idbClearSessions(db);
|
||||
const deleted = await loadOrMigrateAiSessions(() => {
|
||||
assert.fail('completed migration must not retry the stale overflow markers');
|
||||
}, diskIds);
|
||||
assert.equal(deleted.authoritativeEmpty, true);
|
||||
assert.deepEqual(deleted.sessions, []);
|
||||
} finally {
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('a pre-marker store that already holds sessions records the marker on boot', async () => {
|
||||
const idb = installFakeIndexedDb();
|
||||
try {
|
||||
const db = await openAiSessionsIdb();
|
||||
await idbPutSessions(db, [makeSession('already-here', 1)]);
|
||||
const result = await loadOrMigrateAiSessions(() => JSON.stringify([makeSession('legacy', 2)]));
|
||||
assert.equal(result.source, 'indexeddb');
|
||||
assert.ok(await idbGetMeta(db, AI_SESSIONS_META_MIGRATION_KEY));
|
||||
assert.equal(idb.db.dumpStore('sessions').length, 1);
|
||||
} finally {
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
@@ -1,372 +0,0 @@
|
||||
import type { AISession } from '../../infrastructure/ai/types';
|
||||
|
||||
/**
|
||||
* IndexedDB persistence for AI chat sessions (issue #4123).
|
||||
*
|
||||
* The legacy store kept every session as one JSON blob in localStorage under
|
||||
* a 2 MB byte budget. Each write re-serialized the whole store, and when the
|
||||
* budget was exceeded the degradation ladder dropped whole oldest sessions —
|
||||
* silently destroying capped chats (~1 MB each) on every write.
|
||||
*
|
||||
* IndexedDB removes both halves of that failure mode: records are written
|
||||
* per session (no global re-serialization), and there is no shared byte
|
||||
* quota to defend, so byte-driven eviction disappears entirely.
|
||||
*
|
||||
* Layout:
|
||||
* - db `netcatty_ai_sessions`
|
||||
* - object store `sessions` (keyPath `id`): one record per AISession
|
||||
* - object store `meta` (keyPath `key`): schemaVersion, lastWriteAt and the
|
||||
* migrated `netcatty_ai_active_session_map_v1` active-pointer map
|
||||
*
|
||||
* The legacy localStorage key `netcatty_ai_sessions_v1` is kept UNTOUCHED for
|
||||
* one release as a read-only fallback; it is no longer written or pruned.
|
||||
*/
|
||||
|
||||
export const AI_SESSIONS_IDB_NAME = 'netcatty_ai_sessions';
|
||||
export const AI_SESSIONS_IDB_VERSION = 1;
|
||||
export const AI_SESSIONS_IDB_SESSION_STORE = 'sessions';
|
||||
export const AI_SESSIONS_IDB_META_STORE = 'meta';
|
||||
export const AI_SESSIONS_META_SCHEMA_VERSION_KEY = 'schemaVersion';
|
||||
export const AI_SESSIONS_META_LAST_WRITE_KEY = 'lastWriteAt';
|
||||
export const AI_SESSIONS_META_ACTIVE_MAP_KEY = 'activeSessionMap';
|
||||
export const AI_SESSIONS_META_MIGRATION_KEY = 'migrationCompleted';
|
||||
|
||||
/** Cross-window change notification (replaces the localStorage `storage` event for sessions). */
|
||||
const AI_SESSIONS_IDB_CHANGE_CHANNEL = 'netcatty_ai_sessions_changed';
|
||||
|
||||
export function isIndexedDbAvailable(): boolean {
|
||||
return typeof indexedDB !== 'undefined' && indexedDB !== null;
|
||||
}
|
||||
|
||||
function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed'));
|
||||
});
|
||||
}
|
||||
|
||||
function transactionDone(transaction: IDBTransaction): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onabort = () => reject(transaction.error ?? new Error('IndexedDB transaction aborted'));
|
||||
transaction.onerror = () => reject(transaction.error ?? new Error('IndexedDB transaction failed'));
|
||||
});
|
||||
}
|
||||
|
||||
let openPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
function upgradeDatabase(db: IDBDatabase) {
|
||||
if (!db.objectStoreNames.contains(AI_SESSIONS_IDB_SESSION_STORE)) {
|
||||
db.createObjectStore(AI_SESSIONS_IDB_SESSION_STORE, { keyPath: 'id' });
|
||||
}
|
||||
if (!db.objectStoreNames.contains(AI_SESSIONS_IDB_META_STORE)) {
|
||||
db.createObjectStore(AI_SESSIONS_IDB_META_STORE, { keyPath: 'key' });
|
||||
}
|
||||
}
|
||||
|
||||
export function openAiSessionsIdb(): Promise<IDBDatabase> {
|
||||
if (!isIndexedDbAvailable()) {
|
||||
return Promise.reject(new Error('IndexedDB is not available'));
|
||||
}
|
||||
if (!openPromise) {
|
||||
openPromise = new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(AI_SESSIONS_IDB_NAME, AI_SESSIONS_IDB_VERSION);
|
||||
request.onupgradeneeded = () => upgradeDatabase(request.result);
|
||||
request.onsuccess = () => {
|
||||
const db = request.result;
|
||||
// Drop the cached handle so a later open() re-establishes it instead
|
||||
// of handing out a closed connection.
|
||||
db.onclose = () => { openPromise = null; };
|
||||
db.onversionchange = () => {
|
||||
db.close();
|
||||
openPromise = null;
|
||||
};
|
||||
resolve(db);
|
||||
};
|
||||
request.onerror = () => {
|
||||
openPromise = null;
|
||||
reject(request.error ?? new Error('Failed to open IndexedDB'));
|
||||
};
|
||||
request.onblocked = () => {
|
||||
openPromise = null;
|
||||
};
|
||||
});
|
||||
}
|
||||
return openPromise;
|
||||
}
|
||||
|
||||
export function resetAiSessionsIdbForTests() {
|
||||
openPromise = null;
|
||||
}
|
||||
|
||||
export async function idbGetAllSessions(db?: IDBDatabase): Promise<AISession[]> {
|
||||
const database = db ?? await openAiSessionsIdb();
|
||||
const transaction = database.transaction(AI_SESSIONS_IDB_SESSION_STORE, 'readonly');
|
||||
const result = await requestToPromise(
|
||||
transaction.objectStore(AI_SESSIONS_IDB_SESSION_STORE).getAll() as IDBRequest<AISession[]>,
|
||||
);
|
||||
return Array.isArray(result) ? result : [];
|
||||
}
|
||||
|
||||
export async function idbPutSessions(db: IDBDatabase, sessions: AISession[]): Promise<void> {
|
||||
if (sessions.length === 0) return;
|
||||
const transaction = db.transaction(AI_SESSIONS_IDB_SESSION_STORE, 'readwrite');
|
||||
const store = transaction.objectStore(AI_SESSIONS_IDB_SESSION_STORE);
|
||||
for (const session of sessions) store.put(session);
|
||||
await transactionDone(transaction);
|
||||
}
|
||||
|
||||
export async function idbPutSession(db: IDBDatabase, session: AISession): Promise<void> {
|
||||
await idbPutSessions(db, [session]);
|
||||
}
|
||||
|
||||
export async function idbDeleteSessionIds(db: IDBDatabase, ids: string[]): Promise<void> {
|
||||
if (ids.length === 0) return;
|
||||
const transaction = db.transaction(AI_SESSIONS_IDB_SESSION_STORE, 'readwrite');
|
||||
const store = transaction.objectStore(AI_SESSIONS_IDB_SESSION_STORE);
|
||||
for (const id of ids) store.delete(id);
|
||||
await transactionDone(transaction);
|
||||
}
|
||||
|
||||
export async function idbClearSessions(db: IDBDatabase): Promise<void> {
|
||||
const transaction = db.transaction(AI_SESSIONS_IDB_SESSION_STORE, 'readwrite');
|
||||
transaction.objectStore(AI_SESSIONS_IDB_SESSION_STORE).clear();
|
||||
await transactionDone(transaction);
|
||||
}
|
||||
|
||||
export async function idbGetMeta<T>(db: IDBDatabase, key: string): Promise<T | undefined> {
|
||||
const transaction = db.transaction(AI_SESSIONS_IDB_META_STORE, 'readonly');
|
||||
const record = await requestToPromise(
|
||||
transaction.objectStore(AI_SESSIONS_IDB_META_STORE).get(key) as IDBRequest<{ key: string; value: T } | undefined>,
|
||||
);
|
||||
return record?.value;
|
||||
}
|
||||
|
||||
export async function idbPutMeta<T>(db: IDBDatabase, key: string, value: T): Promise<void> {
|
||||
const transaction = db.transaction(AI_SESSIONS_IDB_META_STORE, 'readwrite');
|
||||
transaction.objectStore(AI_SESSIONS_IDB_META_STORE).put({ key, value });
|
||||
await transactionDone(transaction);
|
||||
}
|
||||
|
||||
// ── Cross-window change notification ──
|
||||
|
||||
let changeChannel: BroadcastChannel | null = null;
|
||||
|
||||
function getChangeChannel(): BroadcastChannel | null {
|
||||
if (typeof BroadcastChannel === 'undefined') return null;
|
||||
try {
|
||||
if (!changeChannel) {
|
||||
changeChannel = new BroadcastChannel(AI_SESSIONS_IDB_CHANGE_CHANNEL);
|
||||
// The channel must never keep the process alive (Node exposes unref).
|
||||
(changeChannel as unknown as { unref?: () => void }).unref?.();
|
||||
}
|
||||
return changeChannel;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Announce a session-store change to OTHER windows (never the sender). */
|
||||
export function publishAiSessionsIdbChange(): void {
|
||||
try {
|
||||
getChangeChannel()?.postMessage(Date.now());
|
||||
} catch {
|
||||
// Notification is best-effort; the write itself already succeeded.
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeAiSessionsIdbChange(listener: () => void): () => void {
|
||||
const channel = getChangeChannel();
|
||||
if (!channel) return () => {};
|
||||
const handler = () => listener();
|
||||
channel.addEventListener('message', handler);
|
||||
return () => channel.removeEventListener('message', handler);
|
||||
}
|
||||
|
||||
// ── Boot load + one-release legacy migration ──
|
||||
|
||||
export interface AiSessionsBootLoadResult {
|
||||
sessions: AISession[];
|
||||
/** Where the returned sessions came from. */
|
||||
source: 'indexeddb' | 'legacy-localstorage' | 'none';
|
||||
/** True when IndexedDB itself could not be opened (corrupt/blocked). */
|
||||
idbFailed: boolean;
|
||||
/** The store was already initialized before this boot; empty means deleted. */
|
||||
authoritativeEmpty: boolean;
|
||||
/** Marked disk-only records are still missing; keep their backup generations. */
|
||||
migrationPending: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot-time store resolution, shared with tests:
|
||||
* 1. IndexedDB `getAll()` — the primary store.
|
||||
* 2. If empty, migrate the legacy localStorage store into IndexedDB (the
|
||||
* legacy key itself stays untouched as a read-only fallback for one
|
||||
* release).
|
||||
* 3. If IndexedDB is corrupt/unavailable, fall back to legacy localStorage.
|
||||
*
|
||||
* The migration is one-shot (issue #4125 review P1②): a `migrationCompleted`
|
||||
* marker is persisted in the meta store after the first successful boot
|
||||
* resolution. Once present, the legacy key is never consulted again —
|
||||
* otherwise a boot whose migration ran while the legacy key could not be
|
||||
* removed would re-migrate the stale legacy list and resurrect deleted
|
||||
* chats. The marker also distinguishes "intentionally empty" (the user
|
||||
* deleted every chat) from "uninitialized".
|
||||
*
|
||||
* Callers decide what to do when both sources are empty (the #4018 backup
|
||||
* generations remain the last restore step in useAIState).
|
||||
* The legacy reader may await disk-only recovery before migration completes.
|
||||
* Missing disk-only IDs keep migration resumable, including in a populated IDB.
|
||||
*/
|
||||
export async function loadOrMigrateAiSessions(
|
||||
readLegacyRaw: () => string | null | undefined | Promise<string | null | undefined>,
|
||||
diskOnlySessionIds: readonly string[] = [],
|
||||
): Promise<AiSessionsBootLoadResult> {
|
||||
const hasUnresolvedDiskSessions = (sessions: AISession[]) => {
|
||||
const ids = new Set(sessions.map(session => session.id));
|
||||
return diskOnlySessionIds.some(id => !ids.has(id));
|
||||
};
|
||||
const legacyFallback = async (idbFailed: boolean): Promise<AiSessionsBootLoadResult> => {
|
||||
const legacy = parseLegacySessions(await readLegacyRaw());
|
||||
return {
|
||||
sessions: legacy,
|
||||
source: legacy.length > 0 ? 'legacy-localstorage' : 'none',
|
||||
idbFailed,
|
||||
authoritativeEmpty: false,
|
||||
migrationPending: hasUnresolvedDiskSessions(legacy),
|
||||
};
|
||||
};
|
||||
if (!isIndexedDbAvailable()) {
|
||||
return legacyFallback(false);
|
||||
}
|
||||
let db: IDBDatabase;
|
||||
try {
|
||||
db = await openAiSessionsIdb();
|
||||
} catch (error) {
|
||||
console.warn('[AIState] IndexedDB unavailable, falling back to the legacy localStorage store:', error);
|
||||
return legacyFallback(true);
|
||||
}
|
||||
let sessions: AISession[];
|
||||
try {
|
||||
sessions = await idbGetAllSessions(db);
|
||||
} catch (error) {
|
||||
console.warn('[AIState] IndexedDB read failed, falling back to the legacy localStorage store:', error);
|
||||
return legacyFallback(true);
|
||||
}
|
||||
let marker: AiSessionsMigrationMarker | undefined;
|
||||
try {
|
||||
marker = await idbGetMeta<AiSessionsMigrationMarker>(db, AI_SESSIONS_META_MIGRATION_KEY);
|
||||
} catch {
|
||||
marker = undefined;
|
||||
}
|
||||
if (marker || (sessions.length > 0 && !hasUnresolvedDiskSessions(sessions))) {
|
||||
if (!marker) {
|
||||
// First boot on a store that already holds sessions (e.g. migrated by
|
||||
// a build without the marker): record one-shot completion so the
|
||||
// legacy key can never re-seed the store.
|
||||
await writeMigrationMarker(db).catch(() => {});
|
||||
}
|
||||
return {
|
||||
sessions,
|
||||
source: sessions.length > 0 ? 'indexeddb' : 'none',
|
||||
idbFailed: false,
|
||||
authoritativeEmpty: sessions.length === 0,
|
||||
migrationPending: false,
|
||||
};
|
||||
}
|
||||
// Unfinished migration (empty or partially populated IDB). The legacy key is
|
||||
// intentionally NOT removed: it stays as a read-only fallback for one
|
||||
// release.
|
||||
const legacy = parseLegacySessions(await readLegacyRaw());
|
||||
// A partial migration may already have primary/chunk records in IDB. Only
|
||||
// fill missing marked IDs; never replay stale primary data over live IDB.
|
||||
const existingIds = new Set(sessions.map(session => session.id));
|
||||
const diskOnlyIds = new Set(diskOnlySessionIds);
|
||||
const recovered = sessions.length === 0
|
||||
? legacy
|
||||
: legacy.filter(session => diskOnlyIds.has(session.id) && !existingIds.has(session.id));
|
||||
const merged = [...sessions, ...recovered].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
||||
const migrationPending = hasUnresolvedDiskSessions(merged);
|
||||
const result: AiSessionsBootLoadResult = {
|
||||
sessions: merged,
|
||||
source: sessions.length > 0 ? 'indexeddb' : merged.length > 0 ? 'legacy-localstorage' : 'none',
|
||||
idbFailed: false,
|
||||
authoritativeEmpty: false,
|
||||
migrationPending,
|
||||
};
|
||||
if (merged.length === 0) {
|
||||
// Nothing to migrate: mark the store initialized so this state is
|
||||
// distinguishable from "uninitialized" on the next boot.
|
||||
if (!migrationPending) await writeMigrationMarker(db).catch(() => {});
|
||||
return result;
|
||||
}
|
||||
try {
|
||||
await idbPutSessions(db, recovered);
|
||||
await idbPutMeta(db, AI_SESSIONS_META_SCHEMA_VERSION_KEY, AI_SESSIONS_IDB_VERSION);
|
||||
await idbPutMeta(db, AI_SESSIONS_META_LAST_WRITE_KEY, Date.now());
|
||||
// Written AFTER the migration data is durable: a crash before this point
|
||||
// leaves the marker absent and the next boot safely re-migrates.
|
||||
if (!migrationPending) await writeMigrationMarker(db);
|
||||
} catch (error) {
|
||||
console.warn('[AIState] IndexedDB migration write failed; keeping legacy store authoritative:', error);
|
||||
return { ...result, idbFailed: true };
|
||||
}
|
||||
console.info(`[AIState] Migrated ${recovered.length} AI session(s) from localStorage to IndexedDB.`);
|
||||
return result;
|
||||
}
|
||||
|
||||
interface AiSessionsMigrationMarker {
|
||||
migrationCompleted: true;
|
||||
migrationAt: number;
|
||||
}
|
||||
|
||||
async function writeMigrationMarker(db: IDBDatabase): Promise<void> {
|
||||
await idbPutMeta<AiSessionsMigrationMarker>(
|
||||
db,
|
||||
AI_SESSIONS_META_MIGRATION_KEY,
|
||||
{ migrationCompleted: true, migrationAt: Date.now() },
|
||||
);
|
||||
}
|
||||
|
||||
function parseLegacySessions(raw: string | null | undefined): AISession[] {
|
||||
if (raw == null || raw === '') return [];
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed as AISession[] : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Active-session map mirror (meta store) ──
|
||||
|
||||
/**
|
||||
* Mirror the active-pointer map into the IndexedDB meta store. The
|
||||
* localStorage key remains written as before (small, quota-safe) and stays
|
||||
* the read-mostly authority for this release; the mirror makes the pointer
|
||||
* available to future store work without another migration.
|
||||
*/
|
||||
export async function mirrorActiveSessionMapToIdb(
|
||||
map: Record<string, string | null>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const db = await openAiSessionsIdb();
|
||||
await idbPutMeta(db, AI_SESSIONS_META_ACTIVE_MAP_KEY, map);
|
||||
} catch (error) {
|
||||
console.warn('[AIState] Failed to mirror the active-session map to IndexedDB:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the mirrored active-pointer map (boot restore aid when the
|
||||
* localStorage key is missing).
|
||||
*/
|
||||
export async function readActiveSessionMapFromIdb(): Promise<Record<string, string | null> | null> {
|
||||
try {
|
||||
const db = await openAiSessionsIdb();
|
||||
const value = await idbGetMeta<Record<string, string | null>>(db, AI_SESSIONS_META_ACTIVE_MAP_KEY);
|
||||
return value && typeof value === 'object' ? value : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -66,12 +66,10 @@ test('parseBackupSessions ignores corrupt payloads and drops malformed entries',
|
||||
});
|
||||
|
||||
test('restore preserves all sessions when cloud sync is enabled', () => {
|
||||
// Issue #4123: the retention cap is a session count (raised to 1000) —
|
||||
// there is no byte budget any more.
|
||||
const sessions = Array.from({ length: 1001 }, (_, index) => makeSession(`s${index}`, index));
|
||||
const sessions = Array.from({ length: 51 }, (_, index) => makeSession(`s${index}`, index));
|
||||
assert.deepEqual(prepareRestoredSessions(sessions, true), sessions);
|
||||
const local = prepareRestoredSessions(sessions, false);
|
||||
assert.equal(local.length, 1000);
|
||||
assert.equal(local.length, 50);
|
||||
assert.equal(local.some(session => session.id === 's0'), false);
|
||||
});
|
||||
|
||||
|
||||
@@ -18,34 +18,28 @@ export interface AiSessionsBackupBridge {
|
||||
aiSessionsBackupImport?: () => Promise<{ success: boolean; json?: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the live sessions store once, returning both whether it is usable and
|
||||
* its sessions. The history is often several MB; the helpers below used to
|
||||
* parse it twice per call, and mount ran them back to back (five parses).
|
||||
*/
|
||||
export function parseSessionsStore(raw: string | null | undefined): { valid: boolean; sessions: AISession[] } {
|
||||
if (raw == null || raw === '') return { valid: false, sessions: [] };
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed)
|
||||
? { valid: true, sessions: parsed as AISession[] }
|
||||
: { valid: false, sessions: [] };
|
||||
} catch {
|
||||
return { valid: false, sessions: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/** Restore only when the live store is missing or malformed. A valid empty
|
||||
* array represents an intentional deletion and must remain authoritative. */
|
||||
export function isMissingOrCorruptSessionsStore(raw: string | null | undefined): boolean {
|
||||
return !parseSessionsStore(raw).valid;
|
||||
if (raw == null || raw === '') return true;
|
||||
try {
|
||||
return !Array.isArray(JSON.parse(raw));
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the live sessions array from a raw localStorage string. Returns [] when
|
||||
* missing/corrupt (including parseable non-arrays such as `{}`), so callers never
|
||||
* treat a non-array as AISession[]. */
|
||||
export function readLiveSessionsArray(raw: string | null | undefined): AISession[] {
|
||||
return parseSessionsStore(raw).sessions;
|
||||
if (isMissingOrCorruptSessionsStore(raw)) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw as string);
|
||||
return Array.isArray(parsed) ? parsed as AISession[] : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -2,13 +2,8 @@ import { localStorageAdapter } from '../../infrastructure/persistence/localStora
|
||||
import {
|
||||
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
|
||||
STORAGE_KEY_AI_SESSIONS,
|
||||
STORAGE_KEY_AI_SESSION_MESSAGE_CAP,
|
||||
STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import {
|
||||
DEFAULT_AI_SESSION_MESSAGE_CAP,
|
||||
normalizeAiSessionMessageCap,
|
||||
} from '../../domain/aiSessionMessageCap';
|
||||
import type {
|
||||
AIDraft,
|
||||
AIPanelView,
|
||||
@@ -16,6 +11,7 @@ import type {
|
||||
AIPermissionMode,
|
||||
AIToolIntegrationMode,
|
||||
} from '../../infrastructure/ai/types';
|
||||
import type { ProviderContinuationOptions } from '../../infrastructure/ai/providerContinuation';
|
||||
import { findSafeChatMessageCompactionSplitIndex } from '../../infrastructure/ai/contextCompaction';
|
||||
import {
|
||||
bumpDraftMutationVersionState,
|
||||
@@ -27,23 +23,12 @@ import {
|
||||
pruneInactiveScopedState,
|
||||
} from './aiScopeCleanup';
|
||||
import { emitAIStateChanged } from './aiStateEvents';
|
||||
import { peekAgentRuntimeCleanup } from '../../infrastructure/ai/harness/agentRuntimeHandle';
|
||||
import { setReversibleModelPrivacy } from '../../infrastructure/ai/harness/modelSecretRedaction';
|
||||
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime';
|
||||
import { DebouncedBackupMirror, readLiveSessionsArray } from './aiSessionsBackup';
|
||||
import {
|
||||
idbDeleteSessionIds,
|
||||
idbPutMeta,
|
||||
idbPutSessions,
|
||||
isIndexedDbAvailable,
|
||||
openAiSessionsIdb,
|
||||
publishAiSessionsIdbChange,
|
||||
resetAiSessionsIdbForTests,
|
||||
} from './aiSessionIdbStorage';
|
||||
|
||||
/** Typed accessor for the Electron IPC bridge exposed on `window.netcatty`. */
|
||||
export interface AIBridge {
|
||||
aiSdkAgentCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
|
||||
aiPrivacyClearSession?: (chatSessionId: string) => Promise<{ ok: boolean; error?: string }>;
|
||||
deleteChatToolOutputsTemp?: (chatSessionId: string) => Promise<{ deletedCount: number }>;
|
||||
deleteTerminalToolOutputsEverywhereTemp?: (terminalSessionId: string) => Promise<{ deletedCount: number }>;
|
||||
aiMcpSetPermissionMode?: (mode: AIPermissionMode) => Promise<unknown> | unknown;
|
||||
@@ -75,29 +60,11 @@ export async function cleanupSdkAgentSessions(sessionIds: string[]): Promise<voi
|
||||
await Promise.all(sessionIds.map((sessionId) => bridge?.aiSdkAgentCleanup?.(sessionId).catch(() => {})));
|
||||
}
|
||||
|
||||
/**
|
||||
* Session ids explicitly deleted by the user (or by an authoritative cloud
|
||||
* merge). Their disappearance from the persisted set must not trigger the
|
||||
* pre-eviction backup generation — only cap-driven drops should.
|
||||
*/
|
||||
const intentionallyDeletedSessionIds = new Set<string>();
|
||||
|
||||
export function cleanupDeletedAIChatSessions(sessionIds: string[]) {
|
||||
const bridge = getAIBridge();
|
||||
if (sessionIds.length === 0) return;
|
||||
// Only the startup-light handle is imported here; the runtime registers it
|
||||
// when created. Without a runtime there is no in-memory runtime state, but
|
||||
// the reversible-privacy flag (set outside the runtime) and the
|
||||
// main-process privacy mapping may still exist, so always clear those.
|
||||
const runtime = peekAgentRuntimeCleanup();
|
||||
for (const sessionId of sessionIds) {
|
||||
intentionallyDeletedSessionIds.add(sessionId);
|
||||
if (runtime) {
|
||||
runtime.clearChatSession(sessionId);
|
||||
} else {
|
||||
setReversibleModelPrivacy(sessionId, false);
|
||||
void bridge?.aiPrivacyClearSession?.(sessionId).catch(() => {});
|
||||
}
|
||||
getAgentRuntime().clearChatSession(sessionId);
|
||||
void bridge?.aiSdkAgentCleanup?.(sessionId).catch(() => {});
|
||||
void bridge?.deleteChatToolOutputsTemp?.(sessionId).catch(() => {});
|
||||
}
|
||||
@@ -105,9 +72,8 @@ export function cleanupDeletedAIChatSessions(sessionIds: string[]) {
|
||||
|
||||
export function cleanupClosedTerminalSessions(terminalSessionIds: string[]) {
|
||||
const bridge = getAIBridge();
|
||||
const runtime = peekAgentRuntimeCleanup();
|
||||
for (const terminalSessionId of new Set(terminalSessionIds)) {
|
||||
runtime?.clearTerminalSession(terminalSessionId);
|
||||
getAgentRuntime().clearTerminalSession(terminalSessionId);
|
||||
void bridge?.deleteTerminalToolOutputsEverywhereTemp?.(terminalSessionId).catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -127,11 +93,8 @@ export let latestAIActiveTargetIdsSnapshot: Set<string> = new Set();
|
||||
|
||||
export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
latestAIActiveTargetIdsSnapshot = activeTargetIds;
|
||||
// Child maintenance effects run before the provider's boot effects. Keep
|
||||
// the live targets for post-hydration cleanup without adopting stale legacy
|
||||
// history as authoritative state while IndexedDB is still loading.
|
||||
if (!isAiSessionsHydrationComplete()) return;
|
||||
const currentSessions = latestAISessionsSnapshot ?? getPersistedAiSessionsSnapshot();
|
||||
const currentSessions = latestAISessionsSnapshot
|
||||
?? readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS));
|
||||
|
||||
// Sessions shown by a still-live scope must be protected from cleanup
|
||||
// even when their own `scope.targetId` points at a closed terminal —
|
||||
@@ -218,53 +181,160 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
}
|
||||
|
||||
|
||||
/** Maximum number of sessions to keep in localStorage. */
|
||||
const MAX_STORED_SESSIONS = 50;
|
||||
/** Maximum number of messages per session when persisting to localStorage. */
|
||||
const MAX_SESSION_MESSAGES = 200;
|
||||
/**
|
||||
* Maximum number of sessions kept in the persisted store. IndexedDB has no
|
||||
* shared byte quota, so retention is a plain count cap; the cap only exists
|
||||
* to keep history from growing without bound.
|
||||
* Byte budget for the serialized sessions JSON. The localStorage quota is
|
||||
* ~5-10 MB across all keys, and Responses reasoning ciphertext can add tens
|
||||
* of KB per turn, so keep the sessions blob well under the quota with
|
||||
* headroom for the rest of the app's storage keys.
|
||||
*/
|
||||
const MAX_STORED_SESSIONS = 1000;
|
||||
const MAX_SESSIONS_JSON_BYTES = 2 * 1024 * 1024;
|
||||
/** Retry budgets used when the primary budget still fails to persist. */
|
||||
const RETRY_SESSIONS_JSON_BYTES = [
|
||||
1024 * 1024,
|
||||
512 * 1024,
|
||||
256 * 1024,
|
||||
128 * 1024,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Per-session message cap, read from user settings (clamped to 50–2000,
|
||||
* default 200). Read on every write so a lowered cap trims the oldest
|
||||
* messages on the next write; a raised cap stops further trimming.
|
||||
* Best-effort: an unavailable storage layer keeps the default cap.
|
||||
* Maximum persisted length of a single tool result payload. Terminal tool
|
||||
* output can reach hundreds of KB per command, and AI-driven terminal work
|
||||
* accumulates many of them. When storage pressure forces degradation, shedding
|
||||
* this replay bulk keeps every visible conversation persisted instead of
|
||||
* evicting whole chats that happen to carry it.
|
||||
*/
|
||||
export function readAiSessionMessageCap(): number {
|
||||
try {
|
||||
return normalizeAiSessionMessageCap(
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_SESSION_MESSAGE_CAP)
|
||||
?? DEFAULT_AI_SESSION_MESSAGE_CAP,
|
||||
);
|
||||
} catch {
|
||||
return DEFAULT_AI_SESSION_MESSAGE_CAP;
|
||||
const MAX_PERSISTED_TOOL_RESULT_CHARS = 8 * 1024;
|
||||
|
||||
function truncatePersistedToolResultContent(content: string): string {
|
||||
if (content.length <= MAX_PERSISTED_TOOL_RESULT_CHARS) return content;
|
||||
// The marker consumes budget and must stay single-line: JSON-escaped
|
||||
// newlines would cost 2 characters each, letting a slightly-over-cap
|
||||
// result serialize at the same (or larger) size than the original — the
|
||||
// ladder assumes the degraded form is the smallest. Shrink the retained
|
||||
// head+tail until the whole persisted copy fits the cap.
|
||||
let half = Math.floor(MAX_PERSISTED_TOOL_RESULT_CHARS / 2);
|
||||
let omitted = content.length - 2 * half;
|
||||
let marker = `[Netcatty] persisted copy truncated; ${omitted} characters of tool output omitted`;
|
||||
while (2 * half + marker.length > MAX_PERSISTED_TOOL_RESULT_CHARS) {
|
||||
half -= Math.ceil((2 * half + marker.length - MAX_PERSISTED_TOOL_RESULT_CHARS) / 2);
|
||||
omitted = content.length - 2 * half;
|
||||
marker = `[Netcatty] persisted copy truncated; ${omitted} characters of tool output omitted`;
|
||||
}
|
||||
return `${content.slice(0, half)}${marker}${content.slice(content.length - half)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune sessions before writing to the persisted store to bound retention.
|
||||
* Only affects what is persisted — the in-memory state retains all messages
|
||||
* until the session is reloaded.
|
||||
* Remove `reasoningEncryptedContent` ciphertext from a message's persisted
|
||||
* continuation and shed bulk tool output. The ciphertext exists so stateless
|
||||
* Responses turns can replay prior reasoning items; tool output dominates
|
||||
* persisted size in AI-driven terminal chats. When storage pressure forces
|
||||
* it, shedding this bulk keeps the visible conversation intact at the cost of
|
||||
* reasoning replay and full tool output for affected messages.
|
||||
*/
|
||||
function stripReasoningEncryptedContent(
|
||||
options: ProviderContinuationOptions,
|
||||
): ProviderContinuationOptions | undefined {
|
||||
const hasCiphertext = Object.values(options).some(
|
||||
providerOptions => typeof providerOptions?.reasoningEncryptedContent === 'string',
|
||||
);
|
||||
if (!hasCiphertext) return options;
|
||||
const stripped: ProviderContinuationOptions = {};
|
||||
for (const [provider, providerOptions] of Object.entries(options)) {
|
||||
const rest = { ...providerOptions };
|
||||
delete rest.reasoningEncryptedContent;
|
||||
if (Object.keys(rest).length) stripped[provider] = rest;
|
||||
}
|
||||
return Object.keys(stripped).length ? stripped : undefined;
|
||||
}
|
||||
|
||||
function stripReasoningCiphertextFromMessage(message: AISession['messages'][number]) {
|
||||
const continuation = message.providerContinuation;
|
||||
if (!continuation?.reasoningParts) return message;
|
||||
let changed = false;
|
||||
const parts = continuation.reasoningParts.map(part => {
|
||||
if (!part.providerOptions) return part;
|
||||
const providerOptions = stripReasoningEncryptedContent(part.providerOptions);
|
||||
if (providerOptions === part.providerOptions) return part;
|
||||
changed = true;
|
||||
return providerOptions ? { text: part.text, providerOptions } : { text: part.text };
|
||||
});
|
||||
if (!changed) return message;
|
||||
return {
|
||||
...message,
|
||||
providerContinuation: {
|
||||
...continuation,
|
||||
reasoningParts: parts,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function stripEncryptedReasoningFromMessage(message: AISession['messages'][number]) {
|
||||
const strippedContinuationMessage = stripReasoningCiphertextFromMessage(message);
|
||||
const toolResults = strippedContinuationMessage.toolResults;
|
||||
if (!toolResults?.length) return strippedContinuationMessage;
|
||||
let toolResultsChanged = false;
|
||||
const nextToolResults = toolResults.map((result) => {
|
||||
const content = truncatePersistedToolResultContent(result.content);
|
||||
if (content === result.content) return result;
|
||||
toolResultsChanged = true;
|
||||
return { ...result, content };
|
||||
});
|
||||
return toolResultsChanged
|
||||
? { ...strippedContinuationMessage, toolResults: nextToolResults }
|
||||
: strippedContinuationMessage;
|
||||
}
|
||||
|
||||
function stripEncryptedReasoningFromSession(session: AISession): AISession {
|
||||
let changed = false;
|
||||
const messages = session.messages.map(message => {
|
||||
const stripped = stripEncryptedReasoningFromMessage(message);
|
||||
if (stripped !== message) changed = true;
|
||||
return stripped;
|
||||
});
|
||||
return changed ? { ...session, messages } : session;
|
||||
}
|
||||
|
||||
function stripCompactedEncryptedReasoningFromSession(session: AISession): AISession {
|
||||
const compactedMessageCount = Math.min(
|
||||
session.messages.length,
|
||||
Math.max(0, session.contextCompaction?.compactedMessageCount ?? 0),
|
||||
);
|
||||
if (compactedMessageCount === 0) return session;
|
||||
|
||||
let changed = false;
|
||||
const messages = session.messages.map((message, index) => {
|
||||
if (index >= compactedMessageCount) return message;
|
||||
const stripped = stripReasoningCiphertextFromMessage(message);
|
||||
if (stripped !== message) changed = true;
|
||||
return stripped;
|
||||
});
|
||||
return changed ? { ...session, messages } : session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune sessions before writing to localStorage to prevent hitting the
|
||||
* ~5-10 MB storage quota. Only affects what is persisted — the in-memory
|
||||
* state retains all messages until the session is reloaded.
|
||||
*
|
||||
* - Keeps only the MAX_STORED_SESSIONS most-recently-updated sessions.
|
||||
* - Trims each session's messages to the last `messageCap` messages.
|
||||
* - Trims each session's messages to the last MAX_SESSION_MESSAGES.
|
||||
*/
|
||||
export function pruneSessionsForStorage(
|
||||
sessions: AISession[],
|
||||
messageCap: number = readAiSessionMessageCap(),
|
||||
): AISession[] {
|
||||
export function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
|
||||
// Sort by updatedAt descending so we keep the newest
|
||||
const sorted = [...sessions].sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
const limited = sorted.slice(0, MAX_STORED_SESSIONS);
|
||||
return limited.map(s => {
|
||||
if (s.messages.length > messageCap) {
|
||||
if (s.messages.length > MAX_SESSION_MESSAGES) {
|
||||
// Do not start the retained tail with a tool result whose assistant call
|
||||
// was just trimmed. Reuse the same tool-safe boundary logic as context
|
||||
// compaction, even if that retains a few more than the nominal cap.
|
||||
const removedMessageCount = findSafeChatMessageCompactionSplitIndex(
|
||||
s.messages,
|
||||
messageCap,
|
||||
MAX_SESSION_MESSAGES,
|
||||
);
|
||||
const contextCompaction = s.contextCompaction
|
||||
? {
|
||||
@@ -289,119 +359,149 @@ export function prepareRestoredSessions(sessions: AISession[], syncEnabled: bool
|
||||
return syncEnabled ? sessions : pruneSessionsForStorage(sessions);
|
||||
}
|
||||
|
||||
// ── IndexedDB persistence (issue #4123) ──
|
||||
//
|
||||
// Sessions are persisted one record per session. There is no global byte
|
||||
// budget and no re-serialization ladder: the 2 MB localStorage budget existed
|
||||
// to defend a shared quota against the cost of serializing the whole store on
|
||||
// every write, and both are gone with per-record writes. Only a count cap
|
||||
// (MAX_STORED_SESSIONS) and the per-session message cap remain.
|
||||
/**
|
||||
* Serialize sessions for localStorage under a byte budget, escalating pruning
|
||||
* as needed. Returns the JSON to persist plus the (possibly) further-pruned
|
||||
* sessions that JSON represents.
|
||||
*
|
||||
* Degradation ladder, cheapest loss first: strip replay ciphertext, shed bulk
|
||||
* tool output (kept at a bounded head+tail per result), and only then drop
|
||||
* oldest whole sessions. Evicting visible chats to preserve tool bulk is what
|
||||
* emptied history for users under storage pressure.
|
||||
*/
|
||||
export function serializeSessionsForStorage(
|
||||
sessions: AISession[],
|
||||
budgetBytes: number = MAX_SESSIONS_JSON_BYTES,
|
||||
): { json: string; sessions: AISession[] } {
|
||||
const prepared = pruneSessionsForStorage(sessions).map(rawSession => {
|
||||
// These messages are replaced by the durable summary before every later
|
||||
// request, so their replay ciphertext can never be used again. Remove it
|
||||
// before deciding whether the newest session deserves full protection;
|
||||
// otherwise dead metadata can evict other visible conversations.
|
||||
const session = stripCompactedEncryptedReasoningFromSession(rawSession);
|
||||
return { session, json: JSON.stringify(session) };
|
||||
});
|
||||
|
||||
/** Last set of sessions known to be persisted, for per-record diffing. */
|
||||
let lastPersistedSessionsById: Map<string, AISession> = new Map();
|
||||
/** Serialized write queue: IDB operations never interleave. */
|
||||
let idbWriteQueue: Promise<void> = Promise.resolve();
|
||||
// Check whether retained history fits before preparing degradation. Use
|
||||
// the existing JSON string-length budget including array delimiters, and
|
||||
// avoid preparing unused stripped messages and serializing payloads again.
|
||||
const fullLength = 2 + prepared.reduce((total, entry) => total + entry.json.length, 0)
|
||||
+ Math.max(0, prepared.length - 1);
|
||||
if (fullLength <= budgetBytes) {
|
||||
return {
|
||||
json: `[${prepared.map(entry => entry.json).join(',')}]`,
|
||||
sessions: prepared.map(entry => entry.session),
|
||||
};
|
||||
}
|
||||
|
||||
/** Test hook: reset diff state between tests. */
|
||||
export function __resetIdbPersistenceForTests() {
|
||||
lastPersistedSessionsById = new Map();
|
||||
idbWriteQueue = Promise.resolve();
|
||||
// Tests swap the fake indexedDB factory between cases; the cached open
|
||||
// handle must not survive the swap.
|
||||
resetAiSessionsIdbForTests();
|
||||
// Tests simulate the post-boot state by default; the boot-gating cases
|
||||
// call __resetAiSessionsHydrationForTests() to go back to pending.
|
||||
markAiSessionsHydrationComplete();
|
||||
}
|
||||
// Reuse the full representation when the unchanged degradation ladder is
|
||||
// needed, including on quota retries with a smaller budget.
|
||||
const serialized = prepared.map(({ session, json }) => {
|
||||
const degradableMessages = session.messages.flatMap((message, index) => {
|
||||
const degradedMessage = stripEncryptedReasoningFromMessage(message);
|
||||
if (degradedMessage === message) return [];
|
||||
return [{
|
||||
index,
|
||||
degradedMessage,
|
||||
jsonLengthDelta: JSON.stringify(degradedMessage).length - JSON.stringify(message).length,
|
||||
}];
|
||||
});
|
||||
const strippedSession = stripEncryptedReasoningFromSession(session);
|
||||
return {
|
||||
session,
|
||||
json,
|
||||
strippedSession,
|
||||
strippedJson: strippedSession === session
|
||||
? json
|
||||
: JSON.stringify(strippedSession),
|
||||
degradableMessages,
|
||||
};
|
||||
});
|
||||
|
||||
/** Test hook: resolve once every queued IndexedDB sync has settled. */
|
||||
export function __flushIdbWritesForTests(): Promise<void> {
|
||||
return idbWriteQueue.then(() => undefined, () => undefined);
|
||||
}
|
||||
// Preserve the newest session's full continuation whenever it can fit by
|
||||
// itself. That session is the one the user is most likely continuing now;
|
||||
// older visible history must not make its next tool turn unreplayable.
|
||||
const protectNewestContinuation = serialized.length > 0
|
||||
&& serialized[0].json.length + 2 <= budgetBytes;
|
||||
|
||||
// ── Boot hydration signal (issue #4125 review) ──
|
||||
//
|
||||
// Settles once useAIState's boot load has finished (IndexedDB read, legacy
|
||||
// migration, disk-backup restore). Sync collection and the active-pointer
|
||||
// validation must not run before it: a pre-hydration collect reads the
|
||||
// stale legacy localStorage store and would overwrite newer IndexedDB
|
||||
// records, and pointer validation against the initially-empty in-memory
|
||||
// array would null every pointer before the real sessions arrive.
|
||||
// Determine how many sessions can fit using the smallest representation for
|
||||
// older sessions while reserving the newest session's full representation
|
||||
// when possible. This also prevents an old, oversized visible chat that must
|
||||
// be dropped anyway from causing newer replay ciphertext to be stripped.
|
||||
let minimalLength = 2
|
||||
+ serialized.reduce((total, entry, index) => (
|
||||
total + (protectNewestContinuation && index === 0 ? entry.json.length : entry.strippedJson.length)
|
||||
), 0)
|
||||
+ Math.max(0, serialized.length - 1);
|
||||
while (minimalLength > budgetBytes && serialized.length > 1) {
|
||||
const removed = serialized.pop();
|
||||
if (!removed) break;
|
||||
minimalLength -= removed.strippedJson.length + 1;
|
||||
}
|
||||
|
||||
let aiSessionsHydrationComplete = false;
|
||||
// Then keep full continuation data for the retained sessions and shed
|
||||
// replay-only bulk (ciphertext, oversized tool output) from the oldest
|
||||
// retained sessions only as needed, never touching the protected newest
|
||||
// session. Within one session, shed it one message at a time from oldest to
|
||||
// newest so a long active chat can retain its most recent replayable tool
|
||||
// exchange when that still fits.
|
||||
let jsonLength = 2 + serialized.reduce((total, entry) => total + entry.json.length, 0)
|
||||
+ Math.max(0, serialized.length - 1);
|
||||
const firstStrippableIndex = protectNewestContinuation ? 1 : 0;
|
||||
for (
|
||||
let index = serialized.length - 1;
|
||||
index >= firstStrippableIndex && jsonLength > budgetBytes;
|
||||
index -= 1
|
||||
) {
|
||||
const current = serialized[index];
|
||||
let degradedMessageCount = 0;
|
||||
while (jsonLength > budgetBytes && degradedMessageCount < current.degradableMessages.length) {
|
||||
jsonLength += current.degradableMessages[degradedMessageCount].jsonLengthDelta;
|
||||
degradedMessageCount += 1;
|
||||
}
|
||||
if (degradedMessageCount > 0) {
|
||||
const degradedMessagesByIndex = new Map(
|
||||
current.degradableMessages
|
||||
.slice(0, degradedMessageCount)
|
||||
.map(entry => [entry.index, entry.degradedMessage] as const),
|
||||
);
|
||||
const nextSession = {
|
||||
...current.session,
|
||||
messages: current.session.messages.map((message, messageIndex) => (
|
||||
degradedMessagesByIndex.get(messageIndex) ?? message
|
||||
)),
|
||||
};
|
||||
const nextJson = JSON.stringify(nextSession);
|
||||
const projectedJsonLength = current.json.length
|
||||
+ current.degradableMessages
|
||||
.slice(0, degradedMessageCount)
|
||||
.reduce((total, entry) => total + entry.jsonLengthDelta, 0);
|
||||
jsonLength += nextJson.length - projectedJsonLength;
|
||||
serialized[index] = {
|
||||
...current,
|
||||
session: nextSession,
|
||||
json: nextJson,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function isAiSessionsHydrationComplete(): boolean {
|
||||
return aiSessionsHydrationComplete;
|
||||
}
|
||||
|
||||
/** Called by useAIState's boot effect when the boot load settles. */
|
||||
export function markAiSessionsHydrationComplete(): void {
|
||||
aiSessionsHydrationComplete = true;
|
||||
}
|
||||
|
||||
/** Test hook: return the hydration signal to the pre-boot (pending) state. */
|
||||
export function __resetAiSessionsHydrationForTests(): void {
|
||||
aiSessionsHydrationComplete = false;
|
||||
}
|
||||
|
||||
/** Seed the diff baseline after a boot load or migration. */
|
||||
export function seedLastPersistedSessions(sessions: AISession[]) {
|
||||
lastPersistedSessionsById = new Map(sessions.map(session => [session.id, session]));
|
||||
return {
|
||||
json: `[${serialized.map(entry => entry.json).join(',')}]`,
|
||||
sessions: serialized.map(entry => entry.session),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the persisted session set synchronously. Source of truth is the
|
||||
* in-memory snapshot held by useAIState; this accessor returns the last set
|
||||
* handed to `writeSessionsForStorage`, falling back to the read-only legacy
|
||||
* localStorage store (kept untouched for one release).
|
||||
* Persist sessions to localStorage with byte-budgeted pruning and retries.
|
||||
* Returns true when the write succeeded; a false result means the payload
|
||||
* could not be persisted even after escalation (it stays memory-only).
|
||||
*/
|
||||
export function getPersistedAiSessionsSnapshot(): AISession[] {
|
||||
if (lastPersistedSessionsById.size > 0) {
|
||||
return Array.from(lastPersistedSessionsById.values())
|
||||
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
||||
}
|
||||
return readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS));
|
||||
}
|
||||
|
||||
function queueIdbSessionSync(
|
||||
changed: AISession[],
|
||||
removedIds: string[],
|
||||
prevById: Map<string, AISession>,
|
||||
persisted: AISession[],
|
||||
) {
|
||||
if (changed.length === 0 && removedIds.length === 0) return;
|
||||
idbWriteQueue = idbWriteQueue
|
||||
.then(async () => {
|
||||
if (!isIndexedDbAvailable()) return;
|
||||
const db = await openAiSessionsIdb();
|
||||
// Per-record write: only the changed sessions are put, only removed
|
||||
// ids are deleted.
|
||||
if (changed.length > 0) await idbPutSessions(db, changed);
|
||||
if (removedIds.length > 0) await idbDeleteSessionIds(db, removedIds);
|
||||
await idbPutMeta(db, 'schemaVersion', 1);
|
||||
await idbPutMeta(db, 'lastWriteAt', Date.now());
|
||||
publishAiSessionsIdbChange();
|
||||
})
|
||||
.catch((error) => {
|
||||
// IndexedDB failures must never break the in-memory session state.
|
||||
// Reset the diff baseline so the next write retries every record, and
|
||||
// fall back to the legacy localStorage key so a corrupt/unavailable
|
||||
// IndexedDB still leaves a restorable trace (via the disk mirror).
|
||||
console.warn('[AIState] IndexedDB session sync failed:', error);
|
||||
lastPersistedSessionsById = new Map(prevById);
|
||||
try {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, JSON.stringify(persisted));
|
||||
} catch {
|
||||
// Legacy fallback is best-effort too.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Durability mirror (issue #4015 follow-up): every write also schedules a
|
||||
// debounced disk backup so a wiped/recreated profile does not take the chat
|
||||
// history with it. The persisted JSON is mirrored; when the count cap evicts
|
||||
// sessions, a PRE-eviction generation is written first so evicted chats stay
|
||||
// recoverable from disk (the blind spot found in issue #4123).
|
||||
// Durability mirror (issue #4015 follow-up): every successful store write
|
||||
// also schedules a debounced disk backup so a wiped/recreated profile does
|
||||
// not take the chat history with it. The exact JSON that landed in
|
||||
// localStorage is mirrored (already byte-budgeted), throttled to at most one
|
||||
// write per mutation batch.
|
||||
const backupMirror = new DebouncedBackupMirror((json) => {
|
||||
try {
|
||||
void getAIBridge()?.aiSessionsBackupWrite?.({ json })?.catch(() => {});
|
||||
@@ -410,14 +510,6 @@ const backupMirror = new DebouncedBackupMirror((json) => {
|
||||
}
|
||||
});
|
||||
|
||||
let aiSessionsBackupRecoveryPending = false;
|
||||
|
||||
/** Partial migration must not rotate away generations holding disk-only chats. */
|
||||
export function setAiSessionsBackupRecoveryPending(pending: boolean) {
|
||||
aiSessionsBackupRecoveryPending = pending;
|
||||
if (pending) backupMirror.reset();
|
||||
}
|
||||
|
||||
export function flushBackupMirror() {
|
||||
try {
|
||||
backupMirror.flushNow();
|
||||
@@ -428,11 +520,9 @@ export function flushBackupMirror() {
|
||||
|
||||
export function __resetBackupMirrorForTests() {
|
||||
backupMirror.reset();
|
||||
aiSessionsBackupRecoveryPending = false;
|
||||
}
|
||||
|
||||
export function scheduleBackupMirror(json: string) {
|
||||
if (aiSessionsBackupRecoveryPending) return;
|
||||
try {
|
||||
backupMirror.schedule(json);
|
||||
} catch {
|
||||
@@ -440,79 +530,48 @@ export function scheduleBackupMirror(json: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Write one immediate backup generation (bypasses the debounce window). */
|
||||
function writeImmediateBackupGeneration(json: string) {
|
||||
if (aiSessionsBackupRecoveryPending) return;
|
||||
try {
|
||||
backupMirror.schedule(json);
|
||||
backupMirror.flushNow();
|
||||
} catch {
|
||||
// Backup failures must never break chat persistence.
|
||||
export function writeSessionsForStorage(sessions: AISession[]): boolean {
|
||||
// Synced records are authoritative after restart: silently evicting a
|
||||
// session or turn here would turn local retention into cloud deletion.
|
||||
if (localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled) {
|
||||
const syncedOk = localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, sessions);
|
||||
if (syncedOk) scheduleBackupMirror(JSON.stringify(sessions));
|
||||
return syncedOk;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perf (#4121): the exact same immutable sessions array is often written
|
||||
* again by the debounced save path and by cloud-sync collection; skipping
|
||||
* the re-stringify for a reference-identical set keeps that cheap. The
|
||||
* per-record IDB diff below already makes the store write itself cheap.
|
||||
*/
|
||||
let lastMirroredSessions: AISession[] | null = null;
|
||||
|
||||
/**
|
||||
* Persist sessions. Writes go to IndexedDB, one record per session, through a
|
||||
* serialized queue; errors are logged, never thrown. Returns true when the
|
||||
* persisted set was computed (in-memory state remains authoritative either
|
||||
* way).
|
||||
*/
|
||||
export function writeSessionsForStorage(
|
||||
sessions: AISession[],
|
||||
options?: { deletedSessionIds?: readonly string[] },
|
||||
): boolean {
|
||||
try {
|
||||
// Explicitly deleted ids never count as cap eviction (they also must not
|
||||
// trigger a pre-eviction backup generation).
|
||||
for (const id of options?.deletedSessionIds ?? []) intentionallyDeletedSessionIds.add(id);
|
||||
// Synced records are authoritative after restart: never prune them, or
|
||||
// local retention would turn into cloud deletion on the next sync.
|
||||
const syncEnabled = !!localStorageAdapter.read<{ enabled?: boolean }>(
|
||||
STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS,
|
||||
)?.enabled;
|
||||
const persisted = syncEnabled ? sessions : pruneSessionsForStorage(sessions);
|
||||
|
||||
const prevById = lastPersistedSessionsById;
|
||||
const persistedIds = new Set(persisted.map(session => session.id));
|
||||
|
||||
// Count-cap eviction: sessions that drop out of the persisted set without
|
||||
// being explicitly deleted get a pre-eviction disk generation containing
|
||||
// the full pre-eviction store, so they remain recoverable.
|
||||
const evicted = Array.from(prevById.entries())
|
||||
.filter(([id]) => !persistedIds.has(id) && !intentionallyDeletedSessionIds.has(id))
|
||||
.map(([, session]) => session);
|
||||
intentionallyDeletedSessionIds.clear();
|
||||
if (evicted.length > 0) {
|
||||
writeImmediateBackupGeneration(JSON.stringify(sessions));
|
||||
console.warn(
|
||||
`[AIState] Retention cap evicted ${evicted.length} session(s); a pre-eviction backup generation was written to disk.`,
|
||||
);
|
||||
let previousLength = Number.POSITIVE_INFINITY;
|
||||
for (const configuredBudget of [MAX_SESSIONS_JSON_BYTES, ...RETRY_SESSIONS_JSON_BYTES]) {
|
||||
// A real quota failure means the next attempt must be smaller even when
|
||||
// the failed payload was already below the nominal retry budget. Reduce
|
||||
// materially on each bounded retry so several small sessions can be
|
||||
// removed before the retry sequence is exhausted.
|
||||
const reducedBudget = Number.isFinite(previousLength)
|
||||
? Math.floor(previousLength * 0.75)
|
||||
: configuredBudget;
|
||||
const budget = Math.min(configuredBudget, reducedBudget);
|
||||
if (budget < 2) break;
|
||||
const candidate = serializeSessionsForStorage(sessions, budget);
|
||||
if (candidate.json.length >= previousLength) continue;
|
||||
if (localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, candidate.json)) {
|
||||
scheduleBackupMirror(candidate.json);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Per-record diff (synchronous: the baseline must advance even when
|
||||
// IndexedDB is unavailable, and callers read the snapshot right after).
|
||||
const changed = persisted.filter(session => prevById.get(session.id) !== session);
|
||||
const removedIds = Array.from(prevById.keys()).filter(id => !persistedIds.has(id));
|
||||
lastPersistedSessionsById = new Map(persisted.map(session => [session.id, session]));
|
||||
|
||||
if (changed.length > 0 || removedIds.length > 0 || lastMirroredSessions !== persisted) {
|
||||
scheduleBackupMirror(JSON.stringify(persisted));
|
||||
lastMirroredSessions = persisted;
|
||||
}
|
||||
queueIdbSessionSync(changed, removedIds, prevById, persisted);
|
||||
previousLength = candidate.json.length;
|
||||
}
|
||||
// Last resort: attempt the smallest representation this serializer can
|
||||
// produce (the newest session with replay-only ciphertext removed). A very
|
||||
// small amount of shared quota may still be enough to preserve that chat.
|
||||
const minimalCandidate = serializeSessionsForStorage(sessions, 0);
|
||||
if (
|
||||
minimalCandidate.json.length < previousLength
|
||||
&& localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, minimalCandidate.json)
|
||||
) {
|
||||
scheduleBackupMirror(minimalCandidate.json);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('[AIState] Failed to persist AI sessions:', error);
|
||||
return false;
|
||||
}
|
||||
console.warn(
|
||||
'[AIState] Failed to persist AI sessions within the storage quota; recent chat history may not survive a restart.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
export let latestAISessionsSnapshot: AISession[] | null = null;
|
||||
@@ -554,10 +613,10 @@ export function setLatestAIActiveSessionMapSnapshot(activeSessionIdMap: Record<s
|
||||
|
||||
export function prewarmAIStateStorageSnapshots() {
|
||||
try {
|
||||
// Sessions are no longer prewarmed from localStorage: the primary store is
|
||||
// IndexedDB and loads asynchronously in useAIState. Prewarming the stale
|
||||
// read-only legacy key would make the boot loader skip the IndexedDB
|
||||
// contents ("never restore over non-empty live state").
|
||||
if (latestAISessionsSnapshot === null) {
|
||||
latestAISessionsSnapshot =
|
||||
localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? [];
|
||||
}
|
||||
if (latestAIActiveSessionMapSnapshot === null) {
|
||||
latestAIActiveSessionMapSnapshot =
|
||||
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { HistoryEntry } from "./commandHistoryStore";
|
||||
|
||||
import {
|
||||
STORAGE_KEY_COMMAND_HISTORY,
|
||||
@@ -74,9 +73,7 @@ const {
|
||||
getCommandHistoryClearGeneration,
|
||||
hasCommandHistoryBeenCleared,
|
||||
markCommandHistoryCleared,
|
||||
fuzzyQueryHistory,
|
||||
queryHistory,
|
||||
queryRecentHistoryByCommand,
|
||||
recordCommand,
|
||||
subscribeCommandHistoryCleared,
|
||||
} = await import("./commandHistoryStore.ts");
|
||||
@@ -329,172 +326,3 @@ test("an external clear also drops in-memory compose-bar recall", async () => {
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test("saving after a command and peer writes read the clear generation without parsing the history", async (t) => {
|
||||
clearHistory();
|
||||
recordCommand("echo fast-path", "host-fast", "linux");
|
||||
assert.equal(flushCommandHistoryStore(), true);
|
||||
const raw = storage.get(COMMAND_HISTORY_STORAGE_KEY)!;
|
||||
assert.match(raw, /^\{"clearGeneration":\d+,/, "generation is serialized first");
|
||||
|
||||
const parse = t.mock.method(JSON, "parse");
|
||||
recordCommand("echo fast-path-2", "host-fast", "linux");
|
||||
assert.equal(flushCommandHistoryStore(), true);
|
||||
// A peer window's save only needs the generation as well.
|
||||
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, storage.get(COMMAND_HISTORY_STORAGE_KEY)!, raw);
|
||||
assert.equal(parse.mock.callCount(), 0);
|
||||
parse.mock.restore();
|
||||
assert.ok(queryHistory("echo fast", { hostId: "host-fast" }).some((entry) => entry.command === "echo fast-path-2"));
|
||||
});
|
||||
|
||||
const QUERY_NOW = 1_700_000_000_000;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function historyEntry(command: string, overrides: Partial<HistoryEntry> = {}): HistoryEntry {
|
||||
return {
|
||||
command, hostId: "host-1", os: "linux", frequency: 1,
|
||||
createdAt: QUERY_NOW, lastUsedAt: QUERY_NOW, ...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function installHistoryEntries(entries: HistoryEntry[]): void {
|
||||
clearHistory();
|
||||
const raw = JSON.stringify({
|
||||
clearGeneration: getCommandHistoryClearGeneration(), version: 1, entries,
|
||||
});
|
||||
storage.set(COMMAND_HISTORY_STORAGE_KEY, raw);
|
||||
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, raw);
|
||||
}
|
||||
|
||||
test("prefix history preserves relevance, stable ties, host isolation and best duplicate", (t) => {
|
||||
t.mock.method(Date, "now", () => QUERY_NOW);
|
||||
const entries = [
|
||||
historyEntry("vi", { frequency: 100 }),
|
||||
historyEntry("vim duplicate"),
|
||||
historyEntry("vim frequent", { frequency: 8, lastUsedAt: QUERY_NOW - DAY_MS }),
|
||||
historyEntry("Vim case"),
|
||||
historyEntry("vim tied"),
|
||||
historyEntry("vim duplicate", { hostId: "host-2", frequency: 5 }),
|
||||
historyEntry("unrelated", { frequency: 100 }),
|
||||
];
|
||||
installHistoryEntries(entries);
|
||||
|
||||
assert.deepEqual(queryHistory("vi", { limit: 4 }), [entries[5], entries[2], entries[3], entries[4]]);
|
||||
assert.deepEqual(queryHistory("vi", { hostId: "host-1", limit: 3 }), [entries[2], entries[1], entries[3]]);
|
||||
assert.deepEqual(queryHistory("VI", { limit: 1 }), [entries[0]]);
|
||||
assert.deepEqual(queryHistory("missing"), []);
|
||||
assert.deepEqual(queryHistory("vi", { limit: 0 }), []);
|
||||
});
|
||||
|
||||
test("top-k history replaces and evicts duplicates without losing distinct results", (t) => {
|
||||
t.mock.method(Date, "now", () => QUERY_NOW);
|
||||
const entries = [
|
||||
historyEntry("vim a", { frequency: 2 }),
|
||||
historyEntry("vim b", { frequency: 3 }),
|
||||
historyEntry("vim c", { frequency: 4 }),
|
||||
historyEntry("vim b", { hostId: "host-2", frequency: 5 }),
|
||||
historyEntry("vim a", { hostId: "host-2", frequency: 6 }),
|
||||
historyEntry("vim a", { hostId: "host-3", frequency: 1 }),
|
||||
];
|
||||
installHistoryEntries(entries);
|
||||
assert.deepEqual(queryHistory("vi", { limit: 2 }), [entries[4], entries[3]]);
|
||||
assert.deepEqual(fuzzyQueryHistory("vi", { limit: 2 }), [entries[4], entries[3]]);
|
||||
});
|
||||
|
||||
test("fuzzy history preserves subsequence quality, recency, exclusions and stable ties", (t) => {
|
||||
t.mock.method(Date, "now", () => QUERY_NOW);
|
||||
const entries = [
|
||||
historyEntry("vi", { frequency: 100 }),
|
||||
historyEntry("vi alpha"),
|
||||
historyEntry("vi beta"),
|
||||
historyEntry("very indirect", { frequency: 2 }),
|
||||
historyEntry("vi stale", { lastUsedAt: QUERY_NOW - DAY_MS }),
|
||||
historyEntry("vi alpha", { hostId: "host-2", frequency: 3 }),
|
||||
historyEntry("iv does not match"),
|
||||
];
|
||||
installHistoryEntries(entries);
|
||||
assert.deepEqual(fuzzyQueryHistory("vi", { limit: 4 }), [entries[5], entries[3], entries[2], entries[4]]);
|
||||
assert.deepEqual(fuzzyQueryHistory("vi", { hostId: "host-1", limit: 4 }), [entries[3], entries[1], entries[2], entries[4]]);
|
||||
assert.deepEqual(fuzzyQueryHistory(""), []);
|
||||
assert.deepEqual(fuzzyQueryHistory("vi", { limit: -1 }), []);
|
||||
});
|
||||
|
||||
test("recent history preserves timestamps, command boundaries and quoted argument prefixes", () => {
|
||||
const entries = [
|
||||
historyEntry("cd /work/older", { lastUsedAt: QUERY_NOW - DAY_MS }),
|
||||
historyEntry("cd '/work/new folder'"),
|
||||
historyEntry("CD /work/tied"),
|
||||
historyEntry("cd /work/older", { hostId: "host-2", lastUsedAt: QUERY_NOW + 1 }),
|
||||
historyEntry("cd /other", { lastUsedAt: QUERY_NOW + 2 }),
|
||||
historyEntry("cdb /work/wrong", { lastUsedAt: QUERY_NOW + 3 }),
|
||||
historyEntry("cd /work/excluded", { lastUsedAt: QUERY_NOW + 4 }),
|
||||
];
|
||||
installHistoryEntries(entries);
|
||||
const options = { commandName: " CD ", argumentPrefix: "'/work/", excludeCommand: "cd /work/excluded", limit: 3 };
|
||||
assert.deepEqual(queryRecentHistoryByCommand(options), [entries[3], entries[1], entries[2]]);
|
||||
assert.deepEqual(queryRecentHistoryByCommand({ ...options, hostId: "host-1" }), [entries[1], entries[2], entries[0]]);
|
||||
assert.deepEqual(queryRecentHistoryByCommand({ commandName: "" }), []);
|
||||
assert.deepEqual(queryRecentHistoryByCommand({ commandName: "cd", limit: 0 }), []);
|
||||
});
|
||||
|
||||
test("fuzzy scoring preserves consecutive and word-boundary bonuses across long gaps", (t) => {
|
||||
t.mock.method(Date, "now", () => QUERY_NOW);
|
||||
const entries = [
|
||||
historyEntry("av-x"),
|
||||
historyEntry("avx"),
|
||||
historyEntry(`v${"a".repeat(1900)}x`),
|
||||
historyEntry("v-x"),
|
||||
historyEntry("v/x"),
|
||||
historyEntry("v_x"),
|
||||
historyEntry("v x"),
|
||||
historyEntry("vx adjacent"),
|
||||
historyEntry("v missing second character"),
|
||||
historyEntry("xv wrong order"),
|
||||
];
|
||||
installHistoryEntries(entries);
|
||||
assert.deepEqual(fuzzyQueryHistory("vx"), [
|
||||
entries[7], entries[3], entries[4], entries[5], entries[6], entries[2], entries[1], entries[0],
|
||||
]);
|
||||
assert.deepEqual(fuzzyQueryHistory("vv"), []);
|
||||
});
|
||||
|
||||
for (const size of [4_000, 40_000]) {
|
||||
test(`history queries keep five best results with linear scoring for ${size} entries`, (t) => {
|
||||
t.mock.method(Date, "now", () => QUERY_NOW);
|
||||
const entries = Array.from({ length: size }, (_, i) => historyEntry(`vi file-${i}`, {
|
||||
// Unsorted scores/timestamps, with the best match at the end of the store.
|
||||
frequency: i === size - 1 ? size + 1 : (i * 7919) % size + 1,
|
||||
lastUsedAt: QUERY_NOW + (i === size - 1 ? size + 1 : (i * 7919) % size),
|
||||
}));
|
||||
installHistoryEntries(entries);
|
||||
queryHistory("missing"); // Warm the cache before measuring query operations.
|
||||
|
||||
const expected = entries.slice().sort((a, b) => b.frequency - a.frequency).slice(0, 5);
|
||||
const pow = t.mock.method(Math, "pow");
|
||||
const lower = t.mock.method(String.prototype, "toLowerCase");
|
||||
// node:test cannot mock methods directly on arrays (including Array.prototype).
|
||||
const originalSort = Array.prototype.sort;
|
||||
const sortedSizes: number[] = [];
|
||||
Array.prototype.sort = function<T>(this: T[], compare?: (a: T, b: T) => number): T[] {
|
||||
sortedSizes.push(this.length);
|
||||
return originalSort.call(this, compare);
|
||||
};
|
||||
t.after(() => { Array.prototype.sort = originalSort; });
|
||||
const queries = [
|
||||
() => queryHistory("vi", { limit: 5 }),
|
||||
() => fuzzyQueryHistory("vi", { limit: 5 }),
|
||||
() => queryRecentHistoryByCommand({ commandName: "vi", argumentPrefix: "file-", limit: 5 }),
|
||||
];
|
||||
|
||||
for (const query of queries) {
|
||||
const beforePow = pow.mock.callCount();
|
||||
const beforeLower = lower.mock.callCount();
|
||||
const result = query();
|
||||
assert.deepEqual(result, expected);
|
||||
assert.equal(new Set(result.map((entry) => entry.command)).size, 5);
|
||||
assert.ok(pow.mock.callCount() - beforePow <= size, "score each entry at most once");
|
||||
assert.ok(lower.mock.callCount() - beforeLower <= 2 * size + 2, "normalize in one linear pass");
|
||||
}
|
||||
assert.deepEqual(sortedSizes, [], "never sort all matches before limiting");
|
||||
});
|
||||
}
|
||||
@@ -68,75 +68,33 @@ function loadStore(): HistoryStore {
|
||||
}
|
||||
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let saveIdleHandle: number | null = null;
|
||||
|
||||
const CLEAR_GENERATION_PREFIX = /^\{"clearGeneration":(\d+)[,}]/;
|
||||
|
||||
/**
|
||||
* The store's clear generation, read from its raw JSON without parsing the
|
||||
* whole history (up to 10k entries, ~1.5 MB). Stores are serialized with
|
||||
* clearGeneration first (serializeStore); other layouts fall back to a parse.
|
||||
*/
|
||||
function readRawClearGeneration(raw: string | null): number | null {
|
||||
if (!raw) return null;
|
||||
const match = CLEAR_GENERATION_PREFIX.exec(raw.slice(0, 48));
|
||||
if (match) return Number(match[1]);
|
||||
try {
|
||||
return readClearGeneration(JSON.parse(raw) as HistoryStore);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function serializeStore(store: HistoryStore): HistoryStore {
|
||||
// Keep clearGeneration first so readRawClearGeneration can find it.
|
||||
return { clearGeneration: readClearGeneration(store), version: store.version, entries: store.entries };
|
||||
}
|
||||
|
||||
function persistStoreNow(store: HistoryStore): boolean {
|
||||
// A peer clear is visible in storage before its queued event is handled.
|
||||
// Never flush a cached pre-clear snapshot over that newer empty store.
|
||||
// Only the generation is needed on this per-command path; parse the stored
|
||||
// history just in the rare case where it must be adopted.
|
||||
const persistedGeneration = readRawClearGeneration(localStorageAdapter.readString(STORAGE_KEY));
|
||||
if (persistedGeneration !== null && persistedGeneration > readClearGeneration(store)) {
|
||||
const persisted = localStorageAdapter.read<HistoryStore>(STORAGE_KEY);
|
||||
if (persisted && readClearGeneration(persisted) > readClearGeneration(store)) {
|
||||
cachedStore = persisted;
|
||||
knownClearGeneration = readClearGeneration(persisted);
|
||||
notifyCommandHistoryCleared();
|
||||
return true;
|
||||
}
|
||||
const persisted = localStorageAdapter.read<HistoryStore>(STORAGE_KEY);
|
||||
if (persisted && readClearGeneration(persisted) > readClearGeneration(store)) {
|
||||
cachedStore = persisted;
|
||||
knownClearGeneration = readClearGeneration(persisted);
|
||||
notifyCommandHistoryCleared();
|
||||
return true;
|
||||
}
|
||||
const ok = localStorageAdapter.write(STORAGE_KEY, serializeStore(store));
|
||||
const ok = localStorageAdapter.write(STORAGE_KEY, store);
|
||||
if (ok) return true;
|
||||
// Storage full — evict lowest scored entries (not just oldest by insertion)
|
||||
const now = Date.now();
|
||||
store.entries.sort((a, b) => scoreEntryAt(b, now) - scoreEntryAt(a, now));
|
||||
store.entries = store.entries.slice(0, Math.floor(MAX_ENTRIES / 2));
|
||||
return localStorageAdapter.write(STORAGE_KEY, serializeStore(store));
|
||||
}
|
||||
|
||||
function hasPendingSave(): boolean {
|
||||
return saveTimer !== null || saveIdleHandle !== null;
|
||||
return localStorageAdapter.write(STORAGE_KEY, store);
|
||||
}
|
||||
|
||||
function saveStore(store: HistoryStore): void {
|
||||
cachedStore = store;
|
||||
// Debounce saves, then write when the renderer is idle: serializing the
|
||||
// history right after Enter used to land in the middle of command output.
|
||||
cancelPendingSave();
|
||||
// Debounce saves to avoid excessive writes
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(() => {
|
||||
persistStoreNow(store);
|
||||
saveTimer = null;
|
||||
const persist = () => {
|
||||
saveIdleHandle = null;
|
||||
persistStoreNow(store);
|
||||
};
|
||||
if (typeof requestIdleCallback === "function") {
|
||||
saveIdleHandle = requestIdleCallback(persist, { timeout: 2000 });
|
||||
} else {
|
||||
persist();
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
@@ -145,10 +103,6 @@ function cancelPendingSave(): void {
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = null;
|
||||
}
|
||||
if (saveIdleHandle !== null) {
|
||||
if (typeof cancelIdleCallback === "function") cancelIdleCallback(saveIdleHandle);
|
||||
saveIdleHandle = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -175,14 +129,26 @@ function applyExternalCommandHistoryWrite(
|
||||
rawNewValue: string | null,
|
||||
rawOldValue: string | null,
|
||||
): void {
|
||||
// Fires in every other window for each save; read just the generation.
|
||||
const incomingGeneration = readRawClearGeneration(rawNewValue);
|
||||
let incomingGeneration: number | null = null;
|
||||
if (rawNewValue) {
|
||||
try {
|
||||
incomingGeneration = readClearGeneration(JSON.parse(rawNewValue) as HistoryStore);
|
||||
} catch {
|
||||
incomingGeneration = null;
|
||||
}
|
||||
}
|
||||
|
||||
let previousGeneration = knownClearGeneration;
|
||||
if (previousGeneration === null) {
|
||||
// A fresh popup can have compose recall before its first autocomplete query.
|
||||
// The event's old value is its baseline, even after an earlier global clear.
|
||||
previousGeneration = readRawClearGeneration(rawOldValue) ?? 0;
|
||||
try {
|
||||
previousGeneration = rawOldValue
|
||||
? readClearGeneration(JSON.parse(rawOldValue) as HistoryStore)
|
||||
: 0;
|
||||
} catch {
|
||||
previousGeneration = 0;
|
||||
}
|
||||
}
|
||||
if (incomingGeneration !== null && incomingGeneration > previousGeneration) {
|
||||
cancelPendingSave();
|
||||
@@ -190,7 +156,7 @@ function applyExternalCommandHistoryWrite(
|
||||
knownClearGeneration = incomingGeneration;
|
||||
notifyCommandHistoryCleared();
|
||||
} else {
|
||||
if (!hasPendingSave()) cachedStore = null;
|
||||
if (!saveTimer) cachedStore = null;
|
||||
if (incomingGeneration !== null && knownClearGeneration === null) {
|
||||
knownClearGeneration = incomingGeneration;
|
||||
}
|
||||
@@ -352,36 +318,6 @@ export interface RecentHistoryQueryOptions extends HistoryQueryOptions {
|
||||
argumentPrefix?: string;
|
||||
}
|
||||
|
||||
interface RankedHistoryEntry {
|
||||
entry: HistoryEntry;
|
||||
score: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only the best k distinct commands. Queries score each match once and
|
||||
* retain O(k) candidates instead of sorting/allocating all matches (k is 5
|
||||
* for autocomplete). Entries arrive in store order for stable score ties.
|
||||
*/
|
||||
function retainHistoryMatch(
|
||||
best: RankedHistoryEntry[],
|
||||
entry: HistoryEntry,
|
||||
score: number,
|
||||
limit: number,
|
||||
): void {
|
||||
if (best.length >= limit && score <= best[best.length - 1].score) return;
|
||||
|
||||
const duplicate = best.findIndex((candidate) => candidate.entry.command === entry.command);
|
||||
if (duplicate >= 0) {
|
||||
if (score <= best[duplicate].score) return;
|
||||
best.splice(duplicate, 1);
|
||||
}
|
||||
|
||||
// Insert after equal scores to preserve the original store order on ties.
|
||||
const index = best.findIndex((candidate) => score > candidate.score);
|
||||
if (best.length >= limit) best.pop();
|
||||
best.splice(index < 0 ? best.length : index, 0, { entry, score });
|
||||
}
|
||||
|
||||
/**
|
||||
* Query history entries matching a prefix.
|
||||
* Returns entries sorted by relevance (frequency * recency).
|
||||
@@ -396,17 +332,33 @@ export function queryHistory(
|
||||
const lowerPrefix = prefix.toLowerCase();
|
||||
const now = Date.now(); // Cache once per query
|
||||
|
||||
const best: RankedHistoryEntry[] = [];
|
||||
for (const entry of store.entries) {
|
||||
if (hostId && entry.hostId !== hostId) continue;
|
||||
const filtered = store.entries.filter((entry) => {
|
||||
// Must match prefix
|
||||
if (!entry.command.toLowerCase().startsWith(lowerPrefix)) continue;
|
||||
if (!entry.command.toLowerCase().startsWith(lowerPrefix)) return false;
|
||||
// Must not be identical to prefix
|
||||
if (entry.command === prefix) continue;
|
||||
retainHistoryMatch(best, entry, scoreEntryAt(entry, now), limit);
|
||||
if (entry.command === prefix) return false;
|
||||
|
||||
// Host filtering: strict per-host isolation
|
||||
if (hostId) {
|
||||
return entry.hostId === hostId;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Sort by score (frequency * recency)
|
||||
filtered.sort((a, b) => scoreEntryAt(b, now) - scoreEntryAt(a, now));
|
||||
|
||||
// Deduplicate by command text (keep highest scored)
|
||||
const seen = new Set<string>();
|
||||
const results: HistoryEntry[] = [];
|
||||
for (const entry of filtered) {
|
||||
if (seen.has(entry.command)) continue;
|
||||
seen.add(entry.command);
|
||||
results.push(entry);
|
||||
if (results.length >= limit) break;
|
||||
}
|
||||
|
||||
return best.map(({ entry }) => entry);
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -424,7 +376,7 @@ export function fuzzyQueryHistory(
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const now = Date.now(); // Cache once per query
|
||||
|
||||
const best: RankedHistoryEntry[] = [];
|
||||
const scored: { entry: HistoryEntry; matchScore: number }[] = [];
|
||||
|
||||
for (const entry of store.entries) {
|
||||
// Host filtering
|
||||
@@ -434,11 +386,24 @@ export function fuzzyQueryHistory(
|
||||
|
||||
const matchScore = fuzzyScore(lowerQuery, entry.command.toLowerCase());
|
||||
if (matchScore > 0 && entry.command !== query) {
|
||||
retainHistoryMatch(best, entry, matchScore * scoreEntryAt(entry, now), limit);
|
||||
scored.push({ entry, matchScore });
|
||||
}
|
||||
}
|
||||
|
||||
return best.map(({ entry }) => entry);
|
||||
scored.sort((a, b) =>
|
||||
b.matchScore * scoreEntryAt(b.entry, now) - a.matchScore * scoreEntryAt(a.entry, now),
|
||||
);
|
||||
|
||||
const seen = new Set<string>();
|
||||
const results: HistoryEntry[] = [];
|
||||
for (const { entry } of scored) {
|
||||
if (seen.has(entry.command)) continue;
|
||||
seen.add(entry.command);
|
||||
results.push(entry);
|
||||
if (results.length >= limit) break;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -463,26 +428,38 @@ export function queryRecentHistoryByCommand(
|
||||
const commandPrefix = `${trimmedCommandName} `;
|
||||
const normalizedArgumentPrefix = normalizeArgumentToken(argumentPrefix ?? "");
|
||||
|
||||
const best: RankedHistoryEntry[] = [];
|
||||
for (const entry of store.entries) {
|
||||
if (hostId && entry.hostId !== hostId) continue;
|
||||
const filtered = store.entries.filter((entry) => {
|
||||
const lowerCommand = entry.command.toLowerCase();
|
||||
if (lowerCommand !== trimmedCommandName && !lowerCommand.startsWith(commandPrefix)) {
|
||||
continue;
|
||||
return false;
|
||||
}
|
||||
if (excludeCommand && entry.command === excludeCommand) continue;
|
||||
if (excludeCommand && entry.command === excludeCommand) return false;
|
||||
|
||||
if (normalizedArgumentPrefix) {
|
||||
const currentToken = normalizeArgumentToken(getCurrentCommandToken(entry.command));
|
||||
if (!currentToken.startsWith(normalizedArgumentPrefix)) {
|
||||
continue;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
retainHistoryMatch(best, entry, entry.lastUsedAt, limit);
|
||||
if (hostId) {
|
||||
return entry.hostId === hostId;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
filtered.sort((a, b) => b.lastUsedAt - a.lastUsedAt);
|
||||
|
||||
const seen = new Set<string>();
|
||||
const results: HistoryEntry[] = [];
|
||||
for (const entry of filtered) {
|
||||
if (seen.has(entry.command)) continue;
|
||||
seen.add(entry.command);
|
||||
results.push(entry);
|
||||
if (results.length >= limit) break;
|
||||
}
|
||||
|
||||
return best.map(({ entry }) => entry);
|
||||
return results;
|
||||
}
|
||||
|
||||
function getCurrentCommandToken(command: string): string {
|
||||
@@ -558,28 +535,28 @@ function fuzzyScore(query: string, target: string): number {
|
||||
if (query.length > target.length) return 0;
|
||||
|
||||
let score = 0;
|
||||
let searchFrom = 0;
|
||||
let queryIdx = 0;
|
||||
let prevMatchIdx = -2;
|
||||
|
||||
for (let queryIdx = 0; queryIdx < query.length; queryIdx++) {
|
||||
// Skip unmatched runs in native string search, especially for long commands.
|
||||
const i = target.indexOf(query[queryIdx], searchFrom);
|
||||
if (i < 0) return 0;
|
||||
// First character bonus
|
||||
if (i === 0) score += 10;
|
||||
// Consecutive match bonus
|
||||
if (i === prevMatchIdx + 1) score += 5;
|
||||
// Word boundary bonus
|
||||
if (i === 0 || target[i - 1] === " " || target[i - 1] === "/" ||
|
||||
target[i - 1] === "-" || target[i - 1] === "_") {
|
||||
score += 3;
|
||||
for (let i = 0; i < target.length && queryIdx < query.length; i++) {
|
||||
if (target[i] === query[queryIdx]) {
|
||||
queryIdx++;
|
||||
// First character bonus
|
||||
if (i === 0) score += 10;
|
||||
// Consecutive match bonus
|
||||
if (i === prevMatchIdx + 1) score += 5;
|
||||
// Word boundary bonus
|
||||
if (i === 0 || target[i - 1] === " " || target[i - 1] === "/" ||
|
||||
target[i - 1] === "-" || target[i - 1] === "_") {
|
||||
score += 3;
|
||||
}
|
||||
score += 1;
|
||||
prevMatchIdx = i;
|
||||
}
|
||||
score += 1;
|
||||
prevMatchIdx = i;
|
||||
searchFrom = i + 1;
|
||||
}
|
||||
|
||||
return score;
|
||||
// All query characters must be matched
|
||||
return queryIdx === query.length ? score : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -47,45 +47,3 @@ test("renderer cursor acknowledges GPU publication separately from CPU sample an
|
||||
historyId: "history",
|
||||
});
|
||||
});
|
||||
|
||||
test("idle runtime polls do not notify subscribers until something changes", async () => {
|
||||
const React = await import("react");
|
||||
const { act, create } = await import("react-test-renderer");
|
||||
const { useMonitorSnapshot } = await import("./monitorRuntimeStore");
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
const idle = (takenAt: number): MonitorSnapshot => ({
|
||||
historyMode: "replace",
|
||||
runtimeId: "idle-poll-test",
|
||||
epoch: 7,
|
||||
takenAt,
|
||||
running: [],
|
||||
count: 0,
|
||||
hostCount: 0,
|
||||
activeSessions: [],
|
||||
startedAt: {},
|
||||
hosts: {},
|
||||
});
|
||||
let renders = 0;
|
||||
function Probe() {
|
||||
useMonitorSnapshot();
|
||||
renders += 1;
|
||||
return null;
|
||||
}
|
||||
// Later than anything earlier tests published, so this baseline wins.
|
||||
const base = 1_000_000_000;
|
||||
publishMonitorSnapshot(idle(base));
|
||||
let renderer!: ReturnType<typeof create>;
|
||||
act(() => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
});
|
||||
const baseline = renders;
|
||||
act(() => {
|
||||
for (let at = base + 1000; at <= base + 5000; at += 1000) publishMonitorSnapshot(idle(at));
|
||||
});
|
||||
assert.equal(renders, baseline, "unchanged idle replies must not re-render subscribers");
|
||||
act(() => {
|
||||
publishMonitorSnapshot({ ...idle(base + 6000), running: ["panel"], count: 1 });
|
||||
});
|
||||
assert.equal(renders, baseline + 1, "a real change still notifies");
|
||||
act(() => renderer.unmount());
|
||||
});
|
||||
@@ -14,30 +14,8 @@ const empty: MonitorSnapshot = {
|
||||
};
|
||||
let snapshot = empty;
|
||||
const listeners = new Set<() => void>();
|
||||
export function isIdleMonitorSnapshot(value: MonitorSnapshot): boolean {
|
||||
return value.running.length === 0
|
||||
&& value.count === 0
|
||||
&& value.hostCount === 0
|
||||
&& value.activeSessions.length === 0
|
||||
&& Object.keys(value.hosts).length === 0
|
||||
&& Object.keys(value.startedAt).length === 0;
|
||||
}
|
||||
|
||||
/** An idle runtime polled again: only `takenAt` moved, nothing to render. */
|
||||
function isUnchangedIdleSnapshot(previous: MonitorSnapshot, next: MonitorSnapshot): boolean {
|
||||
return isIdleMonitorSnapshot(previous)
|
||||
&& isIdleMonitorSnapshot(next)
|
||||
&& previous.epoch === next.epoch
|
||||
&& previous.runtimeId === next.runtimeId
|
||||
&& Boolean(previous.unavailable) === Boolean(next.unavailable);
|
||||
}
|
||||
|
||||
export function publishMonitorSnapshot(next: MonitorSnapshot) {
|
||||
const merged = mergeMonitorSnapshotHistory(snapshot, next);
|
||||
// The runtime is polled even when nothing is monitored; do not wake every
|
||||
// subscriber (host-key dialog, monitor panels) for an unchanged idle reply.
|
||||
if (merged === snapshot || isUnchangedIdleSnapshot(snapshot, merged)) return;
|
||||
snapshot = merged;
|
||||
snapshot = mergeMonitorSnapshotHistory(snapshot, next);
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
export function getMonitorHistoryCursor(): MonitorHistoryCursor {
|
||||
|
||||
@@ -75,95 +75,6 @@ test('resolves the focused workspace session for global keybindings', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('uses the anchor chat owner when focus is missing (#4098 phase 2)', () => {
|
||||
const splitWorkspace: Workspace = {
|
||||
id: 'workspace-1',
|
||||
title: 'Workspace',
|
||||
anchorChatId: 'chat-2',
|
||||
root: {
|
||||
id: 'split-1',
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
children: [
|
||||
{ id: 'pane-1', type: 'pane', sessionId: 'session-1' },
|
||||
{ id: 'pane-2', type: 'pane', sessionId: 'session-3' },
|
||||
],
|
||||
sizes: [1, 1],
|
||||
},
|
||||
};
|
||||
const chatOwnerSessions: TerminalSession[] = [
|
||||
...sessions,
|
||||
{
|
||||
id: 'session-3',
|
||||
hostId: 'host-3',
|
||||
hostLabel: 'Host 3',
|
||||
username: 'root',
|
||||
hostname: 'example.org',
|
||||
status: 'connected',
|
||||
protocol: 'ssh',
|
||||
workspaceId: 'workspace-1',
|
||||
},
|
||||
];
|
||||
|
||||
assert.equal(
|
||||
resolveActivePluginKeybindingContext({
|
||||
activeTabId: 'workspace-1',
|
||||
sessions: chatOwnerSessions,
|
||||
workspaces: [splitWorkspace],
|
||||
aiSessions: [
|
||||
{ id: 'chat-2', scope: { type: 'terminal', targetId: 'session-3' }, updatedAt: 2 },
|
||||
{ id: 'chat-1', scope: { type: 'terminal', targetId: 'session-1' }, updatedAt: 1 },
|
||||
],
|
||||
})['terminal.sessionId'],
|
||||
// anchorChatId's chat is terminal-scoped to session-3, which wins even
|
||||
// though session-1's chat is older and session-1 is the first member.
|
||||
'session-3',
|
||||
);
|
||||
});
|
||||
|
||||
test('falls back to the most-recently-active member when there is no anchor (#4098 phase 2)', () => {
|
||||
const splitWorkspace: Workspace = {
|
||||
id: 'workspace-1',
|
||||
title: 'Workspace',
|
||||
root: {
|
||||
id: 'split-1',
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
children: [
|
||||
{ id: 'pane-1', type: 'pane', sessionId: 'session-1' },
|
||||
{ id: 'pane-2', type: 'pane', sessionId: 'session-3' },
|
||||
],
|
||||
sizes: [1, 1],
|
||||
},
|
||||
};
|
||||
const chatOwnerSessions: TerminalSession[] = [
|
||||
...sessions,
|
||||
{
|
||||
id: 'session-3',
|
||||
hostId: 'host-3',
|
||||
hostLabel: 'Host 3',
|
||||
username: 'root',
|
||||
hostname: 'example.org',
|
||||
status: 'connected',
|
||||
protocol: 'ssh',
|
||||
workspaceId: 'workspace-1',
|
||||
},
|
||||
];
|
||||
|
||||
assert.equal(
|
||||
resolveActivePluginKeybindingContext({
|
||||
activeTabId: 'workspace-1',
|
||||
sessions: chatOwnerSessions,
|
||||
workspaces: [splitWorkspace],
|
||||
aiSessions: [
|
||||
{ id: 'chat-1', scope: { type: 'terminal', targetId: 'session-1' }, updatedAt: 1 },
|
||||
{ id: 'chat-2', scope: { type: 'terminal', targetId: 'session-3' }, updatedAt: 9 },
|
||||
],
|
||||
})['terminal.sessionId'],
|
||||
'session-3',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolves standalone sessions and fails closed for non-terminal tabs', () => {
|
||||
assert.equal(
|
||||
resolveActivePluginKeybindingContext({ activeTabId: 'session-2', sessions, workspaces })['terminal.sessionId'],
|
||||
@@ -178,18 +89,3 @@ test('resolves standalone sessions and fails closed for non-terminal tabs', () =
|
||||
'netcatty.activeTabId': 'vault',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
test('empty workspace does not target the detached owner of its anchored chat', () => {
|
||||
const context = resolveActivePluginKeybindingContext({
|
||||
activeTabId: 'workspace-1',
|
||||
sessions: sessions.map((session) => ({ ...session, workspaceId: undefined })),
|
||||
workspaces: [{ ...workspaces[0], focusedSessionId: undefined, anchorChatId: 'chat-1', root: {
|
||||
id: 'empty', type: 'split', direction: 'vertical', children: [], sizes: [],
|
||||
} }],
|
||||
aiSessions: [{ id: 'chat-1', scope: { type: 'terminal', targetId: 'session-1' }, updatedAt: 1 }],
|
||||
});
|
||||
assert.equal(context['terminal.sessionId'], undefined);
|
||||
assert.equal(context['host.id'], undefined);
|
||||
assert.equal(context['workspace.id'], 'workspace-1');
|
||||
});
|
||||
@@ -1,6 +1,4 @@
|
||||
import { collectSessionIds } from '../../domain/workspace';
|
||||
import { resolveMostRecentlyActiveMemberTerminalId } from '../../domain/aiSessionRecency';
|
||||
import type { AIChatRecencyEntry } from '../../domain/aiSessionRecency';
|
||||
import type { TerminalSession, Workspace } from '../../types';
|
||||
|
||||
export type PluginContributionContextValue = string | boolean | number | null;
|
||||
@@ -46,17 +44,14 @@ export function resolveActivePluginKeybindingContext({
|
||||
activeTabId,
|
||||
sessions,
|
||||
workspaces,
|
||||
aiSessions,
|
||||
}: {
|
||||
activeTabId: string;
|
||||
sessions: readonly TerminalSession[];
|
||||
workspaces: readonly Workspace[];
|
||||
/** AI chat snapshot: resolves the workspace anchor and recency fallback (#4098 phase 2). */
|
||||
aiSessions?: readonly AIChatRecencyEntry[];
|
||||
}): PluginContributionContext {
|
||||
const workspace = workspaces.find((candidate) => candidate.id === activeTabId);
|
||||
const activeSessionId = workspace
|
||||
? resolveWorkspaceActiveTerminalId(workspace, aiSessions)
|
||||
? (workspace.focusedSessionId ?? collectSessionIds(workspace.root)[0])
|
||||
: sessions.some((candidate) => candidate.id === activeTabId)
|
||||
? activeTabId
|
||||
: undefined;
|
||||
@@ -77,32 +72,3 @@ export function resolveActivePluginKeybindingContext({
|
||||
'netcatty.activeTabId': activeTabId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue #4098 phase 2: the keybinding context's workspace session resolves by
|
||||
* id, never positionally — focused member first, then the terminal member that
|
||||
* owns the anchored chat, then the most-recently-active member by chat recency.
|
||||
*/
|
||||
function resolveWorkspaceActiveTerminalId(
|
||||
workspace: Workspace,
|
||||
aiSessions: readonly AIChatRecencyEntry[] | undefined,
|
||||
): string | undefined {
|
||||
if (workspace.focusedSessionId && collectSessionIds(workspace.root).includes(workspace.focusedSessionId)) {
|
||||
return workspace.focusedSessionId;
|
||||
}
|
||||
|
||||
const memberTerminalIds = collectSessionIds(workspace.root);
|
||||
if (memberTerminalIds.length === 1) {
|
||||
// A single-member workspace has exactly one candidate — not a positional
|
||||
// pick among many.
|
||||
return memberTerminalIds[0];
|
||||
}
|
||||
const anchorChat = workspace.anchorChatId
|
||||
? aiSessions?.find((session) => session.id === workspace.anchorChatId && session.scope?.type === 'terminal')
|
||||
: undefined;
|
||||
|
||||
const anchorOwner = anchorChat?.scope.targetId;
|
||||
return (anchorOwner && memberTerminalIds.includes(anchorOwner) ? anchorOwner : undefined)
|
||||
?? resolveMostRecentlyActiveMemberTerminalId({ memberTerminalIds, aiSessions })
|
||||
?? undefined;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { observePluginHostSecurityVisibility } from './pluginHostSecurityVisibility';
|
||||
|
||||
test('native modal yields to the host chooser, restores on dismissal, and never restores after cleanup', async () => {
|
||||
const dom = new JSDOM('<body><div role="dialog" aria-modal="true"><div id="mount"></div></div></body>');
|
||||
try {
|
||||
const { document } = dom.window;
|
||||
const calls: boolean[] = [];
|
||||
const stop = observePluginHostSecurityVisibility(document.getElementById('mount')!, (visible) => calls.push(visible));
|
||||
const flush = () => new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
|
||||
assert.deepEqual(calls, []); // Its own modal does not hide the plugin.
|
||||
const chooser = document.createElement('div');
|
||||
chooser.dataset.hostSecuritySurface = '';
|
||||
document.body.append(chooser);
|
||||
await flush(); assert.deepEqual(calls, [false]);
|
||||
chooser.remove(); await flush(); assert.deepEqual(calls, [false, true]);
|
||||
document.body.append(chooser); await flush();
|
||||
assert.deepEqual(calls, [false, true, false]);
|
||||
stop(); chooser.remove(); await flush();
|
||||
assert.deepEqual(calls, [false, true, false]);
|
||||
} finally { dom.window.close(); }
|
||||
});
|
||||
|
||||
test('hidden security surfaces do not occlude a native view', async () => {
|
||||
const dom = new JSDOM('<body><div id="mount"></div><div hidden><div data-host-security-surface></div></div></body>');
|
||||
try {
|
||||
const calls: boolean[] = [];
|
||||
const stop = observePluginHostSecurityVisibility(dom.window.document.getElementById('mount')!, (visible) => calls.push(visible));
|
||||
assert.deepEqual(calls, []);
|
||||
dom.window.document.querySelector('[hidden]')!.removeAttribute('hidden');
|
||||
await new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
|
||||
assert.deepEqual(calls, [false]); stop();
|
||||
} finally { dom.window.close(); }
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/** Native plugin views must yield while a host-owned security dialog is open. */
|
||||
export function observePluginHostSecurityVisibility(mount: HTMLElement, publish: (visible: boolean) => void): () => void {
|
||||
const document = mount.ownerDocument;
|
||||
let visible = true;
|
||||
const update = () => {
|
||||
const blocked = [...document.querySelectorAll('[data-host-security-surface]')].some((surface) => {
|
||||
const style = document.defaultView!.getComputedStyle(surface);
|
||||
return style.display !== 'none' && style.visibility !== 'hidden'
|
||||
&& !surface.closest('[hidden], [inert], [aria-hidden="true"]');
|
||||
});
|
||||
if (visible === !blocked) return;
|
||||
visible = !blocked;
|
||||
publish(visible);
|
||||
};
|
||||
const observer = new document.defaultView!.MutationObserver(update);
|
||||
observer.observe(document.body, { childList: true, subtree: true, attributes: true,
|
||||
attributeFilter: ['class', 'style', 'hidden', 'inert', 'aria-hidden', 'data-host-security-surface'] });
|
||||
update();
|
||||
// The view lifecycle owns close/retention. Cleanup must never reveal an old view.
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
@@ -33,7 +33,6 @@ const NAMED_KEYS = new Map<string, string>([
|
||||
['tab', 'tab'],
|
||||
]);
|
||||
const PLUGIN_SHORTCUT_EDITABLE_SELECTOR = [
|
||||
'[data-plugin-editing]',
|
||||
'input',
|
||||
'textarea',
|
||||
'select',
|
||||
@@ -45,10 +44,6 @@ const PLUGIN_SHORTCUT_EDITABLE_SELECTOR = [
|
||||
'.monaco-menu-container',
|
||||
].join(', ');
|
||||
|
||||
export function isPluginViewEditingTarget(node: EventTarget | null): boolean {
|
||||
return Boolean((node as Element | null)?.closest?.('[data-plugin-editing]'));
|
||||
}
|
||||
|
||||
function hasEditableShortcutAncestor(node: unknown): boolean {
|
||||
const closest = (node as { closest?: (selector: string) => unknown } | null)?.closest;
|
||||
return typeof closest === 'function'
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
test('React 19 changes are staged as safe data before any host rendering', async () => {
|
||||
const dom = new JSDOM('<!doctype html><div id="remote"></div>', { url: 'http://localhost', pretendToBeVisual: true });
|
||||
const saved = new Map<string, PropertyDescriptor | undefined>();
|
||||
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'Element', 'Node', 'MutationObserver', 'Event', 'MouseEvent']) {
|
||||
saved.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value: key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window] });
|
||||
}
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, writable: true, value: true });
|
||||
const { default: React, act, useState } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { RemoteMutationObserver } = await import('@remote-dom/core/elements');
|
||||
const { createPluginRemoteTree } = await import('./pluginRemoteTree');
|
||||
const staged = createPluginRemoteTree();
|
||||
const snapshots: ReturnType<typeof staged.snapshot>[] = [];
|
||||
const observer = new RemoteMutationObserver({
|
||||
mutate(records) { snapshots.push(staged.apply(JSON.parse(JSON.stringify(records)))); },
|
||||
call() { throw new Error('Host method calls are unavailable'); },
|
||||
});
|
||||
const container = document.getElementById('remote')!;
|
||||
observer.observe(container);
|
||||
const root = createRoot(container);
|
||||
function Example() {
|
||||
const [count, setCount] = useState(0);
|
||||
return <section className="card" style={{ display: 'grid', gap: '12px' }}><h1>Hello</h1><button type="button" onClick={() => setCount((value) => value + 1)}>Count {count}</button></section>;
|
||||
}
|
||||
try {
|
||||
await act(async () => { root.render(<Example />); await new Promise((resolve) => setTimeout(resolve, 10)); });
|
||||
const first = snapshots.at(-1)!;
|
||||
const section = first.children[0]; assert.equal(section.type, 1);
|
||||
if (section.type !== 1) throw new Error('Expected section');
|
||||
assert.equal(section.attributes.class, 'card');
|
||||
assert.match(section.attributes.style, /display:\s*grid/);
|
||||
await act(async () => { container.querySelector('button')!.click(); await new Promise((resolve) => setTimeout(resolve, 10)); });
|
||||
const updated = JSON.stringify(snapshots.at(-1));
|
||||
assert.match(updated, /Count /); assert.match(updated, /"data":"1"/);
|
||||
assert.equal(staged.isFailed(), false);
|
||||
assert.throws(() => staged.apply([[3, section.id, 'onclick', 'attack()']]));
|
||||
assert.deepEqual(staged.snapshot(), { children: [] });
|
||||
assert.equal(staged.isFailed(), true);
|
||||
assert.throws(() => staged.apply([]));
|
||||
} finally {
|
||||
observer.disconnect(); await act(async () => { root.unmount(); });
|
||||
for (const [key, value] of saved) { if (value) Object.defineProperty(globalThis, key, value); else Reflect.deleteProperty(globalThis, key); }
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT'); dom.window.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('removed attributes do not keep checkboxes, menus or old inline styles active', async () => {
|
||||
const { createPluginRemoteTree } = await import('./pluginRemoteTree');
|
||||
const tree = createPluginRemoteTree();
|
||||
tree.apply([[0, '~', { id: 'control', type: 1, element: 'input', attributes: { type: 'checkbox', checked: '', style: 'color:red' }, children: [] }, 0]]);
|
||||
const updated = tree.apply([[3, 'control', 'checked', null], [3, 'control', 'style', null]]);
|
||||
assert.deepEqual(updated.children[0], { id: 'control', type: 1, element: 'input', attributes: { type: 'checkbox' }, children: [] });
|
||||
});
|
||||
|
||||
test('inserting a child into an existing void element fails the view', async () => {
|
||||
const { createPluginRemoteTree } = await import('./pluginRemoteTree');
|
||||
const tree = createPluginRemoteTree();
|
||||
tree.apply([[0, '~', { id: 'input', type: 1, element: 'input', attributes: {}, children: [] }, 0]]);
|
||||
assert.throws(() => tree.apply([[0, 'input', { id: 'child', type: 3, data: 'invalid' }, 0]]), /Void view elements/);
|
||||
assert.equal(tree.isFailed(), true);
|
||||
assert.deepEqual(tree.snapshot(), { children: [] });
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import { RemoteReceiver } from '@remote-dom/core/receivers';
|
||||
import { validatePluginMutationBatch, validatePluginRemoteTree } from '@/domain/pluginRemoteTreePolicy.cjs';
|
||||
|
||||
type WireNode = { id: string; type: 3 | 8; data: string } | {
|
||||
id: string; type: 1; element: string; attributes: Record<string, string>; children: WireNode[];
|
||||
};
|
||||
type WireTree = { children: WireNode[] };
|
||||
|
||||
/** Data-only staging area: untrusted changes are never applied directly to the host DOM. */
|
||||
export function createPluginRemoteTree() {
|
||||
const receiver = new RemoteReceiver();
|
||||
let current: WireTree = { children: [] };
|
||||
let failed = false;
|
||||
const project = (node: unknown): WireNode => {
|
||||
const value = node as { id: string; type: number; data?: string; element?: string; attributes?: Record<string, string>; children?: unknown[] };
|
||||
if (value.type === 3 || value.type === 8) return { id: value.id, type: value.type, data: value.data ?? '' };
|
||||
return { id: value.id, type: 1, element: value.element ?? '', attributes: Object.fromEntries(Object.entries(value.attributes ?? {}).filter(([, attribute]) => attribute !== null)), children: (value.children ?? []).map(project) };
|
||||
};
|
||||
return {
|
||||
apply(input: unknown): WireTree {
|
||||
if (failed) throw new Error('Plugin view has been closed after an invalid update');
|
||||
try {
|
||||
validatePluginMutationBatch(input);
|
||||
// Ordinary DOM attribute mutations do not specify a channel. RemoteReceiver
|
||||
// defaults those to properties, so explicitly mark them as attributes.
|
||||
const batch = (input as unknown[][]).map((record) => record[0] === 3 && record.length === 4 ? [...record, 2] : record);
|
||||
receiver.connection.mutate(batch as unknown as Parameters<typeof receiver.connection.mutate>[0]);
|
||||
const next = { children: receiver.root.children.map(project) };
|
||||
validatePluginRemoteTree(next);
|
||||
current = next;
|
||||
return next;
|
||||
} catch (error) {
|
||||
failed = true;
|
||||
current = { children: [] };
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
snapshot(): WireTree { return current; },
|
||||
isFailed(): boolean { return failed; },
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,111 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { reconcilePluginViewTabCatalog, resolvePluginViewSnapshotSelection, shouldReconcilePluginViewTabCatalog } from './pluginViewLifecycle';
|
||||
import { PluginViewTabStore } from './pluginViewTabStore';
|
||||
|
||||
import {
|
||||
PluginViewLifecycleController,
|
||||
consumeClosedPluginViewInstance,
|
||||
reconcilePluginViewTabCatalog,
|
||||
markPluginViewOpenTokensClosed,
|
||||
reconcileClosedPluginView,
|
||||
rememberClosedPluginViewInstance,
|
||||
resolvePluginViewSnapshotSelection,
|
||||
shouldReconcilePluginViewTabCatalog,
|
||||
withdrawPluginViewTab,
|
||||
type HostedPluginViewState,
|
||||
} from './pluginViewLifecycle.ts';
|
||||
import { PluginViewTabStore } from './pluginViewTabStore.ts';
|
||||
|
||||
function view(id: string, tabId?: string): HostedPluginViewState {
|
||||
return {
|
||||
id,
|
||||
viewId: `view.${id}`,
|
||||
scopeId: 'window:main',
|
||||
retainContextWhenHidden: false,
|
||||
...(tabId ? { tabId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
test('host close events clear the active renderer instance and identify its native tab', () => {
|
||||
const current = view('active', 'plugin-view:publisher.plugin:view.active');
|
||||
const result = reconcileClosedPluginView({
|
||||
current,
|
||||
retained: new Map([['retained', view('retained')]]),
|
||||
instanceId: 'active',
|
||||
});
|
||||
|
||||
assert.equal(result.current, null);
|
||||
assert.equal(result.matchedCurrent, true);
|
||||
assert.equal(result.closedTabId, current.tabId);
|
||||
assert.equal(result.retained.size, 1);
|
||||
});
|
||||
|
||||
test('host close events remove retained instances without dismissing another active view', () => {
|
||||
const current = view('active');
|
||||
const retainedTab = view('retained-closed', 'plugin-view:publisher.plugin:view.retained');
|
||||
const result = reconcileClosedPluginView({
|
||||
current,
|
||||
retained: new Map([
|
||||
['closed', retainedTab],
|
||||
['kept', view('retained-kept')],
|
||||
]),
|
||||
instanceId: 'retained-closed',
|
||||
});
|
||||
|
||||
assert.equal(result.current, current);
|
||||
assert.equal(result.matchedCurrent, false);
|
||||
assert.equal(result.matchedRetained, true);
|
||||
assert.deepEqual([...result.retained.keys()], ['kept']);
|
||||
assert.equal(result.closedTabId, retainedTab.tabId);
|
||||
});
|
||||
|
||||
test('explicit native-tab close destroys active and retained instances instead of retaining them', () => {
|
||||
const tabId = 'plugin-view:publisher.plugin:view.shared';
|
||||
const result = withdrawPluginViewTab({
|
||||
current: view('active', tabId),
|
||||
retained: new Map([
|
||||
['same-tab', view('retained-same', tabId)],
|
||||
['other-tab', view('retained-other', 'plugin-view:publisher.plugin:view.other')],
|
||||
]),
|
||||
tabId,
|
||||
});
|
||||
|
||||
assert.equal(result.current, null);
|
||||
assert.equal(result.matchedCurrent, true);
|
||||
assert.equal(result.matchedRetained, true);
|
||||
assert.deepEqual(result.instanceIds, ['active', 'retained-same']);
|
||||
assert.deepEqual([...result.retained.keys()], ['other-tab']);
|
||||
});
|
||||
|
||||
test('an early host close tombstone is consumed when the open response arrives later', () => {
|
||||
const tombstones = new Set<string>();
|
||||
rememberClosedPluginViewInstance(tombstones, 'instance-early');
|
||||
assert.equal(consumeClosedPluginViewInstance(tombstones, 'instance-early'), true);
|
||||
assert.equal(consumeClosedPluginViewInstance(tombstones, 'instance-early'), false);
|
||||
|
||||
for (let index = 0; index < 300; index += 1) {
|
||||
rememberClosedPluginViewInstance(tombstones, `instance-${index}`);
|
||||
}
|
||||
assert.equal(tombstones.size, 256);
|
||||
assert.equal(tombstones.has('instance-0'), false);
|
||||
});
|
||||
|
||||
test('explicit close marks only in-flight opens owned by the closed surface', () => {
|
||||
const first = Symbol('first');
|
||||
const second = Symbol('second');
|
||||
const explicitlyClosed = new Set<symbol>();
|
||||
const opening = new Map<string, Set<symbol>>([
|
||||
['window:main\0view.first', new Set([first])],
|
||||
['window:main\0view.second', new Set([second])],
|
||||
]);
|
||||
|
||||
assert.equal(markPluginViewOpenTokensClosed(
|
||||
opening,
|
||||
explicitlyClosed,
|
||||
'window:main\0view.first',
|
||||
), 1);
|
||||
assert.deepEqual([...explicitlyClosed], [first]);
|
||||
assert.equal(markPluginViewOpenTokensClosed(opening, explicitlyClosed, null), 0);
|
||||
});
|
||||
|
||||
test('locale-only snapshot refresh keeps the owned view alive without weakening context fail-closed behavior', () => {
|
||||
const previous = {
|
||||
@@ -80,3 +184,30 @@ test('active-tab context refresh cannot withdraw the plugin tab that triggered i
|
||||
assert.equal(store.getTabs()[0]?.title, 'Localized View');
|
||||
assert.equal(activeTabId, tab.id);
|
||||
});
|
||||
|
||||
test('lifecycle controller owns retained views, open tokens, tombstones, and teardown', () => {
|
||||
const controller = new PluginViewLifecycleController<HostedPluginViewState>();
|
||||
const tabId = 'plugin-view:publisher.plugin:view.shared';
|
||||
const active = view('active', tabId);
|
||||
const retained = view('retained');
|
||||
controller.setCurrent(active);
|
||||
controller.retain('window:main\0view.retained', retained);
|
||||
|
||||
const token = controller.beginOpen({
|
||||
viewKey: 'window:main\0view.active',
|
||||
tabId,
|
||||
});
|
||||
const closed = controller.handleTabClose(tabId);
|
||||
assert.deepEqual(closed.instanceIds, [active.id]);
|
||||
assert.equal(controller.shouldCloseOpen(token), true);
|
||||
controller.finishOpen({ token, viewKey: 'window:main\0view.active', tabId });
|
||||
assert.equal(controller.shouldCloseOpen(token), false);
|
||||
|
||||
const early = controller.handleHostClose('instance-before-open-response');
|
||||
assert.equal(early.matchedCurrent, false);
|
||||
assert.equal(early.matchedRetained, false);
|
||||
assert.equal(controller.consumeHostClose('instance-before-open-response'), true);
|
||||
|
||||
assert.deepEqual(controller.drain().map((candidate) => candidate.id), [retained.id]);
|
||||
assert.equal(controller.getCurrent(), null);
|
||||
});
|
||||
@@ -1,3 +1,11 @@
|
||||
export interface HostedPluginViewState {
|
||||
id: string;
|
||||
viewId: string;
|
||||
scopeId: string;
|
||||
retainContextWhenHidden: boolean;
|
||||
tabId?: string;
|
||||
}
|
||||
|
||||
export interface PluginViewSnapshotSelection<T> {
|
||||
requestViewId: string;
|
||||
contextKey: string;
|
||||
@@ -73,3 +81,241 @@ export function reconcilePluginViewTabCatalog({
|
||||
store.refreshMetadata(entries);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function reconcileClosedPluginView<T extends HostedPluginViewState>({
|
||||
current,
|
||||
retained,
|
||||
instanceId,
|
||||
}: {
|
||||
current: T | null;
|
||||
retained: ReadonlyMap<string, T>;
|
||||
instanceId: string;
|
||||
}): {
|
||||
current: T | null;
|
||||
retained: Map<string, T>;
|
||||
matchedCurrent: boolean;
|
||||
matchedRetained: boolean;
|
||||
closedTabId?: string;
|
||||
} {
|
||||
const matchedCurrent = current?.id === instanceId;
|
||||
let matchedRetained = false;
|
||||
let retainedTabId: string | undefined;
|
||||
const nextRetained = new Map<string, T>();
|
||||
for (const [key, view] of retained) {
|
||||
if (view.id === instanceId) {
|
||||
matchedRetained = true;
|
||||
retainedTabId = view.tabId;
|
||||
}
|
||||
else nextRetained.set(key, view);
|
||||
}
|
||||
return {
|
||||
current: matchedCurrent ? null : current,
|
||||
retained: nextRetained,
|
||||
matchedCurrent,
|
||||
matchedRetained,
|
||||
...((matchedCurrent ? current?.tabId : retainedTabId)
|
||||
? { closedTabId: matchedCurrent ? current?.tabId : retainedTabId }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function withdrawPluginViewTab<T extends HostedPluginViewState>({
|
||||
current,
|
||||
retained,
|
||||
tabId,
|
||||
}: {
|
||||
current: T | null;
|
||||
retained: ReadonlyMap<string, T>;
|
||||
tabId: string;
|
||||
}): {
|
||||
current: T | null;
|
||||
retained: Map<string, T>;
|
||||
instanceIds: string[];
|
||||
matchedCurrent: boolean;
|
||||
matchedRetained: boolean;
|
||||
} {
|
||||
const matchedCurrent = current?.tabId === tabId;
|
||||
let matchedRetained = false;
|
||||
const instanceIds: string[] = [];
|
||||
if (matchedCurrent && current) instanceIds.push(current.id);
|
||||
const nextRetained = new Map<string, T>();
|
||||
for (const [key, view] of retained) {
|
||||
if (view.tabId === tabId) {
|
||||
matchedRetained = true;
|
||||
instanceIds.push(view.id);
|
||||
} else {
|
||||
nextRetained.set(key, view);
|
||||
}
|
||||
}
|
||||
return {
|
||||
current: matchedCurrent ? null : current,
|
||||
retained: nextRetained,
|
||||
instanceIds,
|
||||
matchedCurrent,
|
||||
matchedRetained,
|
||||
};
|
||||
}
|
||||
|
||||
export function rememberClosedPluginViewInstance(
|
||||
tombstones: Set<string>,
|
||||
instanceId: string,
|
||||
limit = 256,
|
||||
): void {
|
||||
if (tombstones.size >= limit) {
|
||||
const oldest = tombstones.values().next().value;
|
||||
if (typeof oldest === 'string') tombstones.delete(oldest);
|
||||
}
|
||||
tombstones.add(instanceId);
|
||||
}
|
||||
|
||||
export function consumeClosedPluginViewInstance(tombstones: Set<string>, instanceId: string): boolean {
|
||||
return tombstones.delete(instanceId);
|
||||
}
|
||||
|
||||
export function markPluginViewOpenTokensClosed(
|
||||
openingTokens: ReadonlyMap<string, ReadonlySet<symbol>>,
|
||||
explicitlyClosedTokens: Set<symbol>,
|
||||
ownerKey: string | null | undefined,
|
||||
): number {
|
||||
if (!ownerKey) return 0;
|
||||
const tokens = openingTokens.get(ownerKey);
|
||||
if (!tokens) return 0;
|
||||
for (const token of tokens) explicitlyClosedTokens.add(token);
|
||||
return tokens.size;
|
||||
}
|
||||
|
||||
export class PluginViewLifecycleController<T extends HostedPluginViewState = HostedPluginViewState> {
|
||||
private current: T | null = null;
|
||||
private retained = new Map<string, T>();
|
||||
private readonly closedInstanceIds = new Set<string>();
|
||||
private readonly openingTokensByTab = new Map<string, Set<symbol>>();
|
||||
private readonly openingTokensByView = new Map<string, Set<symbol>>();
|
||||
private readonly explicitlyClosedOpenTokens = new Set<symbol>();
|
||||
|
||||
getCurrent(): T | null {
|
||||
return this.current;
|
||||
}
|
||||
|
||||
setCurrent(view: T | null): void {
|
||||
this.current = view;
|
||||
}
|
||||
|
||||
takeCurrent(): T | null {
|
||||
const current = this.current;
|
||||
this.current = null;
|
||||
return current;
|
||||
}
|
||||
|
||||
retain(key: string, view: T): void {
|
||||
this.retained.set(key, view);
|
||||
}
|
||||
|
||||
takeRetained(key: string): T | null {
|
||||
const retained = this.retained.get(key) ?? null;
|
||||
if (retained) this.retained.delete(key);
|
||||
return retained;
|
||||
}
|
||||
|
||||
removeRetained(key: string): void {
|
||||
this.retained.delete(key);
|
||||
}
|
||||
|
||||
removeRetainedWhere(predicate: (view: T) => boolean): T[] {
|
||||
const removed: T[] = [];
|
||||
for (const [key, view] of this.retained) {
|
||||
if (!predicate(view)) continue;
|
||||
this.retained.delete(key);
|
||||
removed.push(view);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
handleHostClose(instanceId: string) {
|
||||
const next = reconcileClosedPluginView({
|
||||
current: this.current,
|
||||
retained: this.retained,
|
||||
instanceId,
|
||||
});
|
||||
this.current = next.current;
|
||||
this.retained = next.retained;
|
||||
if (!next.matchedCurrent && !next.matchedRetained) {
|
||||
rememberClosedPluginViewInstance(this.closedInstanceIds, instanceId);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
handleTabClose(tabId: string) {
|
||||
const next = withdrawPluginViewTab({
|
||||
current: this.current,
|
||||
retained: this.retained,
|
||||
tabId,
|
||||
});
|
||||
this.current = next.current;
|
||||
this.retained = next.retained;
|
||||
markPluginViewOpenTokensClosed(
|
||||
this.openingTokensByTab,
|
||||
this.explicitlyClosedOpenTokens,
|
||||
tabId,
|
||||
);
|
||||
return next;
|
||||
}
|
||||
|
||||
markViewClosed(viewKey: string | null | undefined): number {
|
||||
return markPluginViewOpenTokensClosed(
|
||||
this.openingTokensByView,
|
||||
this.explicitlyClosedOpenTokens,
|
||||
viewKey,
|
||||
);
|
||||
}
|
||||
|
||||
beginOpen({
|
||||
viewKey,
|
||||
tabId,
|
||||
label,
|
||||
}: {
|
||||
viewKey: string;
|
||||
tabId?: string;
|
||||
label?: string;
|
||||
}): symbol {
|
||||
const token = Symbol(label ?? tabId ?? viewKey);
|
||||
const viewTokens = this.openingTokensByView.get(viewKey) ?? new Set<symbol>();
|
||||
viewTokens.add(token);
|
||||
this.openingTokensByView.set(viewKey, viewTokens);
|
||||
if (tabId) {
|
||||
const tabTokens = this.openingTokensByTab.get(tabId) ?? new Set<symbol>();
|
||||
tabTokens.add(token);
|
||||
this.openingTokensByTab.set(tabId, tabTokens);
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
finishOpen({ token, viewKey, tabId }: { token: symbol; viewKey: string; tabId?: string }): void {
|
||||
this.explicitlyClosedOpenTokens.delete(token);
|
||||
const viewTokens = this.openingTokensByView.get(viewKey);
|
||||
viewTokens?.delete(token);
|
||||
if (viewTokens?.size === 0) this.openingTokensByView.delete(viewKey);
|
||||
if (!tabId) return;
|
||||
const tabTokens = this.openingTokensByTab.get(tabId);
|
||||
tabTokens?.delete(token);
|
||||
if (tabTokens?.size === 0) this.openingTokensByTab.delete(tabId);
|
||||
}
|
||||
|
||||
shouldCloseOpen(token: symbol): boolean {
|
||||
return this.explicitlyClosedOpenTokens.has(token);
|
||||
}
|
||||
|
||||
consumeHostClose(instanceId: string): boolean {
|
||||
return consumeClosedPluginViewInstance(this.closedInstanceIds, instanceId);
|
||||
}
|
||||
|
||||
drain(): T[] {
|
||||
const values = [this.current, ...this.retained.values()].filter((view): view is T => Boolean(view));
|
||||
this.current = null;
|
||||
this.retained.clear();
|
||||
this.closedInstanceIds.clear();
|
||||
this.openingTokensByTab.clear();
|
||||
this.openingTokensByView.clear();
|
||||
this.explicitlyClosedOpenTokens.clear();
|
||||
return values;
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { HostDrawnViewMessage, HostDrawnViewOpenRequest } from '@netcatty/plugin-contract';
|
||||
type Update = Extract<HostDrawnViewMessage, { kind: 'host-ui.update' }> & { viewId: string };
|
||||
import { PluginViewSessions } from './pluginViewSession';
|
||||
function fixture() {
|
||||
let updates: (data: Update) => void = () => {}; let closed: (data: NetcattyPluginViewClosedEvent) => void = () => {};
|
||||
const opened: HostDrawnViewOpenRequest[] = []; const closes: string[] = []; const events: unknown[] = [];
|
||||
const bridge = { openHostDrawnPluginView: async (request: HostDrawnViewOpenRequest) => { opened.push(request); return { instanceId: request.instanceId }; },
|
||||
closeHostDrawnPluginView: async (id: string) => { closes.push(id); },
|
||||
onHostDrawnPluginViewUpdate: (listener: typeof updates) => { updates = listener; return () => {}; },
|
||||
onPluginViewClosed: (listener: typeof closed) => { closed = listener; return () => {}; },
|
||||
sendHostDrawnPluginViewEvent: async (id: string, event: unknown) => { events.push({ id, event }); } };
|
||||
return { bridge, opened, closes, events, update: (data: Update) => updates(data), close: (data: { instanceId: string }) => closed({ ...data, viewId: request.viewId, pluginId: 'com.example', reason: 'test' }) };
|
||||
}
|
||||
const request = { viewId: 'com.example.view', scopeId: 'window:one', context: {} };
|
||||
test('a hidden view resumes its existing state and plugin instance', async () => {
|
||||
const f = fixture(); const store = new PluginViewSessions();
|
||||
const first = store.acquire('view', f.bridge, request); const id = f.opened[0].instanceId;
|
||||
f.update({ kind: 'host-ui.update', viewId: request.viewId, instanceId: id, sequence: 1, css: '', mutations: [[0, '~', { id: 'text', type: 3, data: 'kept' }, 0]] });
|
||||
first.release(); const next = store.acquire('view', f.bridge, request);
|
||||
assert.equal(next, first); assert.equal(f.opened.length, 1); assert.deepEqual(next.getSnapshot().tree.children[0], { id: 'text', type: 3, data: 'kept' });
|
||||
next.release(); store.dispose(); await Promise.resolve(); assert.equal(new Set(f.closes).size, 1);
|
||||
});
|
||||
test('host invalidation clears retained content and a reopen creates a new instance', () => {
|
||||
const f = fixture(); const store = new PluginViewSessions(); const old = store.acquire('view', f.bridge, request);
|
||||
f.close({ instanceId: f.opened[0].instanceId }); assert.equal(old.getSnapshot().error, true); assert.deepEqual(old.getSnapshot().tree.children, []);
|
||||
const next = store.acquire('view', f.bridge, request); assert.notEqual(old, next); store.dispose();
|
||||
});
|
||||
test('unacknowledged input is not overwritten by an older plugin update', () => {
|
||||
const f = fixture(); const store = new PluginViewSessions(); const view = store.acquire('view', f.bridge, request);
|
||||
view.send({ type: 'input', nodeId: 'field', value: 'latest' }); assert.equal(view.getSnapshot().settled, false);
|
||||
const base = { kind: 'host-ui.update' as const, viewId: request.viewId, instanceId: f.opened[0].instanceId, css: '', mutations: [] };
|
||||
f.update({ ...base, sequence: 1, acknowledgedEvent: 0 }); assert.equal(view.getSnapshot().settled, false);
|
||||
f.update({ ...base, sequence: 2, acknowledgedEvent: 1 }); assert.equal(view.getSnapshot().settled, true); store.dispose();
|
||||
});
|
||||
test('idle instances are evicted while active instances are never silently replaced', () => {
|
||||
const f = fixture(); const store = new PluginViewSessions(1); const first = store.acquire('one', f.bridge, request);
|
||||
assert.throws(() => store.acquire('two', f.bridge, request), /Too many/); first.release();
|
||||
const second = store.acquire('two', f.bridge, request); assert.notEqual(second, first); assert.equal(f.closes.length, 1); store.dispose();
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
import { createPluginRemoteTree } from './pluginRemoteTree';
|
||||
import { validatePluginStylesheet } from '@/domain/pluginViewStylePolicy.cjs';
|
||||
import type { HostDrawnViewEvent } from '@netcatty/plugin-contract';
|
||||
|
||||
type Bridge = Pick<NonNullable<ReturnType<typeof import('../../infrastructure/services/netcattyBridge').netcattyBridge.get>>, 'openHostDrawnPluginView' | 'closeHostDrawnPluginView' | 'onHostDrawnPluginViewUpdate' | 'onPluginViewClosed' | 'sendHostDrawnPluginViewEvent' | 'getPluginViewResource'>;
|
||||
export type PluginViewSnapshot = { tree: ReturnType<ReturnType<typeof createPluginRemoteTree>['snapshot']>; css: string; loading: boolean; error: boolean; settled: boolean };
|
||||
const empty = (): PluginViewSnapshot => ({ tree: { children: [] }, css: '', loading: true, error: false, settled: true });
|
||||
|
||||
/** Owns a window's bounded, retained UI instances independently of React mounts. */
|
||||
export class PluginViewSessions {
|
||||
private sessions = new Map<string, ReturnType<PluginViewSessions['create']>>();
|
||||
constructor(private readonly capacity = 16, private readonly idleMs = 300_000) {}
|
||||
private create(key: string, bridge: Bridge, request: Omit<import('@netcatty/plugin-contract').HostDrawnViewOpenRequest, 'instanceId'>) {
|
||||
const id = crypto.randomUUID();
|
||||
const tree = createPluginRemoteTree();
|
||||
let state = empty(); let sequence = 1; let eventId = 0; let disposed = false; let users = 0;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const listeners = new Set<() => void>();
|
||||
const publish = (next: PluginViewSnapshot) => { state = next; for (const listener of listeners) listener(); };
|
||||
const cleanup: Array<() => void> = [];
|
||||
const dispose = (failed = false) => {
|
||||
if (disposed) return;
|
||||
disposed = true; clearTimeout(timer); cleanup.forEach((stop) => stop());
|
||||
this.sessions.delete(key);
|
||||
publish({ ...empty(), loading: false, error: failed });
|
||||
void bridge.closeHostDrawnPluginView?.(id).catch(() => {});
|
||||
};
|
||||
if (!bridge.openHostDrawnPluginView || !bridge.onHostDrawnPluginViewUpdate || !bridge.onPluginViewClosed) throw new Error('Plugin views unavailable');
|
||||
cleanup.push(bridge.onHostDrawnPluginViewUpdate((update) => {
|
||||
if (disposed || update.kind !== 'host-ui.update' || update.instanceId !== id || update.viewId !== request.viewId) return;
|
||||
try {
|
||||
if (update.sequence !== sequence++) throw new Error('Invalid plugin update order');
|
||||
publish({ tree: tree.apply(update.mutations), css: validatePluginStylesheet(update.css), loading: false, error: false, settled: (update.acknowledgedEvent ?? 0) >= eventId });
|
||||
} catch { dispose(true); }
|
||||
}));
|
||||
cleanup.push(bridge.onPluginViewClosed((event) => { if (event.instanceId === id) dispose(true); }));
|
||||
void bridge.openHostDrawnPluginView({ ...request, instanceId: id }).then(() => {
|
||||
if (disposed) void bridge.closeHostDrawnPluginView?.(id).catch(() => {});
|
||||
}).catch(() => dispose(true));
|
||||
return {
|
||||
viewId: request.viewId,
|
||||
scope: request.scopeId,
|
||||
image: async (path: string) => {
|
||||
if (disposed || !bridge.getPluginViewResource) throw new Error('Plugin image unavailable');
|
||||
const result = await bridge.getPluginViewResource({ instanceId: id, path });
|
||||
if (disposed) throw new Error('Plugin view closed');
|
||||
return result.url;
|
||||
},
|
||||
get idle() { return users === 0; },
|
||||
getSnapshot: () => state,
|
||||
subscribe(listener: () => void) { listeners.add(listener); return () => { listeners.delete(listener); }; },
|
||||
acquire: () => { clearTimeout(timer); users++; },
|
||||
release: () => { if (--users === 0 && !disposed) timer = setTimeout(() => dispose(), this.idleMs); },
|
||||
send: (event: HostDrawnViewEvent) => {
|
||||
if (disposed || users === 0) return;
|
||||
publish({ ...state, settled: false });
|
||||
void bridge.sendHostDrawnPluginViewEvent?.(id, { ...event, eventId: ++eventId }).catch(() => dispose(true));
|
||||
},
|
||||
dispose,
|
||||
};
|
||||
}
|
||||
acquire(key: string, bridge: Bridge, request: Omit<import('@netcatty/plugin-contract').HostDrawnViewOpenRequest, 'instanceId'>) {
|
||||
let session = this.sessions.get(key);
|
||||
if (!session) {
|
||||
while (this.sessions.size >= this.capacity) {
|
||||
const candidate = [...this.sessions.values()].find((item) => item.idle);
|
||||
if (!candidate) throw new Error('Too many open plugin views');
|
||||
candidate.dispose();
|
||||
}
|
||||
session = this.create(key, bridge, request); this.sessions.set(key, session);
|
||||
}
|
||||
session.acquire(); return session;
|
||||
}
|
||||
closeView(viewId: string) { for (const session of [...this.sessions.values()]) if (session.viewId === viewId) session.dispose(); }
|
||||
closeScope(scope: string) { for (const session of [...this.sessions.values()]) if (session.scope === scope) session.dispose(); }
|
||||
dispose() { for (const session of [...this.sessions.values()]) session.dispose(); }
|
||||
}
|
||||
export const pluginViewSessions = new PluginViewSessions();
|
||||
@@ -45,7 +45,6 @@ export type SessionSnapshotActions = Pick<
|
||||
| 'resetWorkspaceRename'
|
||||
| 'removeSessionFromWorkspace'
|
||||
| 'setWorkspaceFocusedSession'
|
||||
| 'setWorkspaceAnchorChat'
|
||||
| 'toggleWorkspaceViewMode'
|
||||
// Terminal / chrome mutators previously read only via App session runtime.
|
||||
| 'createLocalTerminal'
|
||||
@@ -57,7 +56,6 @@ export type SessionSnapshotActions = Pick<
|
||||
| 'createWorkspaceWithHosts'
|
||||
| 'createWorkspaceFromSessions'
|
||||
| 'addSessionToWorkspace'
|
||||
| 'moveWorkspacePane'
|
||||
| 'appendHostToWorkspace'
|
||||
| 'appendHostToWorkspaceConfirmed'
|
||||
| 'appendLocalTerminalToWorkspace'
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { TerminalSession, Workspace } from "../../domain/models";
|
||||
import type { AIChatRecencyEntry } from "../../domain/aiSessionRecency";
|
||||
import {
|
||||
applyCloseSessionToSessions,
|
||||
closeSessionsState,
|
||||
closeSessionWorkspaceLayoutState,
|
||||
detachSessionFromWorkspaceState,
|
||||
replaceDissolvedWorkspaceTabOrder,
|
||||
resolveActiveTabAfterCloseSession,
|
||||
} from "./sessionWorkspaceDetach";
|
||||
|
||||
@@ -14,19 +14,11 @@ const session = (id: string, workspaceId = "ws-1"): TerminalSession => ({
|
||||
id,
|
||||
hostId: id,
|
||||
hostLabel: id,
|
||||
username: "user",
|
||||
hostname: id,
|
||||
status: "connected",
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const chat = (id: string, targetId: string, updatedAt: number): AIChatRecencyEntry => ({
|
||||
id,
|
||||
scope: { type: "terminal", targetId },
|
||||
updatedAt,
|
||||
});
|
||||
|
||||
const workspace = (sessionIds: string[], overrides: Partial<Workspace> = {}): Workspace => ({
|
||||
const workspace = (sessionIds: string[]): Workspace => ({
|
||||
id: "ws-1",
|
||||
title: "Workspace",
|
||||
focusedSessionId: sessionIds[0],
|
||||
@@ -44,17 +36,9 @@ const workspace = (sessionIds: string[], overrides: Partial<Workspace> = {}): Wo
|
||||
})),
|
||||
sizes: sessionIds.map(() => 1),
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const rootPaneSessionIds = (workspace: Workspace): string[] => (
|
||||
workspace.root.type === "pane"
|
||||
? [workspace.root.sessionId]
|
||||
: workspace.root.children.map((child) => child.type === "pane" ? child.sessionId : "")
|
||||
.filter((id): id is string => Boolean(id))
|
||||
);
|
||||
|
||||
test("detaching to one member keeps the workspace alive (#4098 phase 2)", () => {
|
||||
test("detach dissolves the original workspace when one session remains", () => {
|
||||
const result = detachSessionFromWorkspaceState({
|
||||
sessions: [session("s1"), session("s2")],
|
||||
workspaces: [workspace(["s1", "s2"])],
|
||||
@@ -65,126 +49,149 @@ test("detaching to one member keeps the workspace alive (#4098 phase 2)", () =>
|
||||
assert.equal(result.activeTabId, "s1");
|
||||
assert.deepEqual(result.sessions.map((s) => [s.id, s.workspaceId]), [
|
||||
["s1", undefined],
|
||||
// The survivor keeps its workspace binding — no orphan promotion.
|
||||
["s2", "ws-1"],
|
||||
["s2", undefined],
|
||||
]);
|
||||
assert.equal(result.workspaces.length, 1);
|
||||
assert.equal(result.workspaces[0].id, "ws-1");
|
||||
assert.deepEqual(rootPaneSessionIds(result.workspaces[0]), ["s2"]);
|
||||
assert.equal(result.workspaces.length, 0);
|
||||
assert.equal(result.dissolvedWorkspaceId, "ws-1");
|
||||
assert.deepEqual(result.replacementTabIds, ["s1", "s2"]);
|
||||
});
|
||||
|
||||
test("detaching the last member keeps the workspace with an empty root (#4098 phase 2)", () => {
|
||||
test("detach preserves the other sessions in a multi-pane workspace", () => {
|
||||
const result = detachSessionFromWorkspaceState({
|
||||
sessions: [session("s1")],
|
||||
workspaces: [workspace(["s1"])],
|
||||
sessionId: "s1",
|
||||
sessions: [session("s1"), session("s2"), session("s3")],
|
||||
workspaces: [workspace(["s1", "s2", "s3"])],
|
||||
sessionId: "s2",
|
||||
});
|
||||
|
||||
assert.equal(result.changed, true);
|
||||
assert.equal(result.workspaces.length, 1);
|
||||
assert.equal(result.workspaces[0].id, "ws-1");
|
||||
assert.equal(result.workspaces[0].root.type, "split");
|
||||
assert.deepEqual(
|
||||
result.workspaces[0].root.type === "split" ? result.workspaces[0].root.children : null,
|
||||
[],
|
||||
);
|
||||
assert.equal(result.workspaces[0].focusedSessionId, undefined);
|
||||
});
|
||||
|
||||
test("closing one member keeps the workspace and its surviving member bound (#4098 phase 2)", () => {
|
||||
const result = closeSessionsState({
|
||||
sessions: [session("s1"), session("s2")],
|
||||
workspaces: [workspace(["s1", "s2"])],
|
||||
sessionIds: ["s1"],
|
||||
currentActiveTabId: "ws-1",
|
||||
tabOrder: ["ws-1"],
|
||||
});
|
||||
|
||||
assert.equal(result.workspaces.length, 1);
|
||||
assert.equal(result.workspaces[0].id, "ws-1");
|
||||
assert.deepEqual(result.sessions.map((s) => [s.id, s.workspaceId]), [["s2", "ws-1"]]);
|
||||
// The workspace tab is still live, so the active tab does not move.
|
||||
assert.equal(result.activeTabId, undefined);
|
||||
});
|
||||
|
||||
test("member loss hands focus to the most-recently-active survivor, not sessions[0]", () => {
|
||||
const result = closeSessionWorkspaceLayoutState(
|
||||
[workspace(["s1", "s2", "s3"])],
|
||||
"ws-1",
|
||||
"s1",
|
||||
[chat("chat-s2", "s2", 4), chat("chat-s3", "s3", 9)],
|
||||
);
|
||||
|
||||
assert.equal(result.workspaces.length, 1);
|
||||
// remainingSessionIds[0] is s2, but s3's chat is the most recent.
|
||||
assert.equal(result.workspaces[0].focusedSessionId, "s3");
|
||||
});
|
||||
|
||||
test("reordering focus order does not change the survivor retarget", () => {
|
||||
const result = closeSessionWorkspaceLayoutState(
|
||||
[workspace(["s1", "s2", "s3"], { focusSessionOrder: ["s3", "s2", "s1"] })],
|
||||
"ws-1",
|
||||
"s1",
|
||||
[chat("chat-s2", "s2", 4), chat("chat-s3", "s3", 9)],
|
||||
);
|
||||
|
||||
assert.equal(result.workspaces[0].focusedSessionId, "s3");
|
||||
});
|
||||
|
||||
test("closing a non-focused member keeps the focused member", () => {
|
||||
const result = closeSessionWorkspaceLayoutState(
|
||||
[workspace(["s1", "s2", "s3"])],
|
||||
"ws-1",
|
||||
"s3",
|
||||
);
|
||||
|
||||
assert.equal(result.workspaces.length, 1);
|
||||
assert.deepEqual(result.sessions.map((s) => [s.id, s.workspaceId]), [
|
||||
["s1", "ws-1"],
|
||||
["s2", undefined],
|
||||
["s3", "ws-1"],
|
||||
]);
|
||||
assert.deepEqual(result.workspaces[0].focusSessionOrder, ["s1", "s3"]);
|
||||
assert.equal(result.workspaces[0].focusedSessionId, "s1");
|
||||
assert.deepEqual(result.workspaces[0].focusSessionOrder, ["s1", "s2"]);
|
||||
assert.deepEqual(
|
||||
result.workspaces[0].root.type === "split"
|
||||
? result.workspaces[0].root.children.map((child) => child.type === "pane" ? child.sessionId : null)
|
||||
: [],
|
||||
["s1", "s3"],
|
||||
);
|
||||
});
|
||||
|
||||
test("with no AI recency signal focus stays on a surviving terminal", () => {
|
||||
test("dissolved workspace replacement preserves its tab position", () => {
|
||||
assert.deepEqual(
|
||||
replaceDissolvedWorkspaceTabOrder(["log-1", "ws-1", "session-3"], "ws-1", ["s1", "s2"]),
|
||||
["log-1", "s1", "s2", "session-3"],
|
||||
);
|
||||
});
|
||||
|
||||
test("dissolved workspace replacement removes duplicate replacement ids", () => {
|
||||
assert.deepEqual(
|
||||
replaceDissolvedWorkspaceTabOrder(["s1", "ws-1", "session-3"], "ws-1", ["s1", "s2"]),
|
||||
["s1", "s2", "session-3"],
|
||||
);
|
||||
});
|
||||
|
||||
test("dissolved workspace replacement is idempotent", () => {
|
||||
const once = replaceDissolvedWorkspaceTabOrder(["log-1", "ws-1", "session-3"], "ws-1", ["s1", "s2"]);
|
||||
|
||||
assert.deepEqual(
|
||||
replaceDissolvedWorkspaceTabOrder(once, "ws-1", ["s1", "s2"]),
|
||||
once,
|
||||
);
|
||||
});
|
||||
|
||||
test("single remaining session preserves dissolved workspace tab position", () => {
|
||||
assert.deepEqual(
|
||||
replaceDissolvedWorkspaceTabOrder(["log-1", "ws-1", "session-3"], "ws-1", ["s2"]),
|
||||
["log-1", "s2", "session-3"],
|
||||
);
|
||||
});
|
||||
|
||||
test("closing a workspace session dissolves the workspace when one terminal remains", () => {
|
||||
const result = closeSessionWorkspaceLayoutState([workspace(["s1", "s2"])], "ws-1", "s1");
|
||||
|
||||
assert.equal(result.dissolvedWorkspaceId, "ws-1");
|
||||
assert.equal(result.lastRemainingSessionId, "s2");
|
||||
assert.deepEqual(result.workspaces, []);
|
||||
assert.deepEqual(
|
||||
replaceDissolvedWorkspaceTabOrder(
|
||||
["log-1", result.dissolvedWorkspaceId!, "session-3"],
|
||||
result.dissolvedWorkspaceId,
|
||||
result.lastRemainingSessionId ? [result.lastRemainingSessionId] : undefined,
|
||||
),
|
||||
["log-1", "s2", "session-3"],
|
||||
);
|
||||
});
|
||||
|
||||
test("closing the focused session hands focus to a remaining session (#3046)", () => {
|
||||
const result = closeSessionWorkspaceLayoutState(
|
||||
[workspace(["s1", "s2"])],
|
||||
[workspace(["s1", "s2", "s3", "s4"])],
|
||||
"ws-1",
|
||||
"s1",
|
||||
);
|
||||
|
||||
assert.equal(result.workspaces.length, 1);
|
||||
assert.equal(result.workspaces[0].focusedSessionId, "s2");
|
||||
assert.deepEqual(result.workspaces[0].focusSessionOrder, ["s2", "s3", "s4"]);
|
||||
});
|
||||
|
||||
test("closing ALL members keeps the workspace alive with an empty root (#4098 phase 2)", () => {
|
||||
const result = closeSessionsState({
|
||||
sessions: [session("s1"), session("s2")],
|
||||
workspaces: [workspace(["s1", "s2"])],
|
||||
sessionIds: ["s1", "s2"],
|
||||
currentActiveTabId: "ws-1",
|
||||
tabOrder: ["ws-1"],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.sessions, []);
|
||||
assert.equal(result.workspaces.length, 1);
|
||||
assert.equal(result.workspaces[0].id, "ws-1");
|
||||
assert.equal(result.workspaces[0].root.type, "split");
|
||||
assert.deepEqual(
|
||||
result.workspaces[0].root.type === "split" ? result.workspaces[0].root.children : null,
|
||||
[],
|
||||
test("closing a non-focused session keeps the focused session", () => {
|
||||
const result = closeSessionWorkspaceLayoutState(
|
||||
[workspace(["s1", "s2", "s3", "s4"])],
|
||||
"ws-1",
|
||||
"s3",
|
||||
);
|
||||
// The workspace tab survives, so the active tab is left alone.
|
||||
assert.equal(result.activeTabId, undefined);
|
||||
|
||||
assert.equal(result.workspaces.length, 1);
|
||||
assert.equal(result.workspaces[0].focusedSessionId, "s1");
|
||||
assert.deepEqual(result.workspaces[0].focusSessionOrder, ["s1", "s2", "s4"]);
|
||||
});
|
||||
|
||||
test("closing a solo workspace member still replaces the closed tab's activity via fallbacks", () => {
|
||||
test("closing one split pane keeps the other terminal as an orphan tab", () => {
|
||||
const layoutResult = closeSessionWorkspaceLayoutState(
|
||||
[workspace(["s1", "s2"])],
|
||||
"ws-1",
|
||||
"s1",
|
||||
);
|
||||
const nextSessions = applyCloseSessionToSessions(
|
||||
[session("s1"), session("s2")],
|
||||
"s1",
|
||||
layoutResult,
|
||||
);
|
||||
|
||||
assert.deepEqual(nextSessions.map((s) => [s.id, s.workspaceId]), [
|
||||
["s2", undefined],
|
||||
]);
|
||||
assert.equal(
|
||||
resolveActiveTabAfterCloseSession({
|
||||
currentActiveTabId: "ws-1",
|
||||
closedSessionId: "s1",
|
||||
workspaceId: "ws-1",
|
||||
layoutResult,
|
||||
remainingSessions: nextSessions,
|
||||
preferredTabId: "s-outside",
|
||||
}),
|
||||
"s2",
|
||||
);
|
||||
});
|
||||
|
||||
test("closing the last workspace session does not invent a surviving terminal", () => {
|
||||
const layoutResult = closeSessionWorkspaceLayoutState(
|
||||
[workspace(["s1"])],
|
||||
"ws-1",
|
||||
"s1",
|
||||
);
|
||||
const nextSessions = applyCloseSessionToSessions([session("s1")], "s1");
|
||||
const nextSessions = applyCloseSessionToSessions(
|
||||
[session("s1")],
|
||||
"s1",
|
||||
layoutResult,
|
||||
);
|
||||
|
||||
assert.deepEqual(nextSessions, []);
|
||||
// The workspace tab survives, so an active workspace tab needs no fallback.
|
||||
assert.equal(layoutResult.removedWorkspaceId, "ws-1");
|
||||
assert.equal(layoutResult.lastRemainingSessionId, undefined);
|
||||
assert.equal(
|
||||
resolveActiveTabAfterCloseSession({
|
||||
currentActiveTabId: "ws-1",
|
||||
@@ -193,7 +200,25 @@ test("closing a solo workspace member still replaces the closed tab's activity v
|
||||
layoutResult,
|
||||
remainingSessions: nextSessions,
|
||||
}),
|
||||
null,
|
||||
"vault",
|
||||
);
|
||||
});
|
||||
|
||||
test("closing the last visible solo tab does not fall back to a hidden MCP session", () => {
|
||||
const visibleSession: TerminalSession = { ...session("s1", undefined) };
|
||||
const hiddenSession: TerminalSession = { ...session("s2", undefined), hiddenFromTabs: true };
|
||||
const layoutResult = closeSessionWorkspaceLayoutState([], undefined, "s1");
|
||||
const remainingSessions = [hiddenSession];
|
||||
|
||||
assert.equal(
|
||||
resolveActiveTabAfterCloseSession({
|
||||
currentActiveTabId: visibleSession.id,
|
||||
closedSessionId: "s1",
|
||||
workspaceId: undefined,
|
||||
layoutResult,
|
||||
remainingSessions,
|
||||
}),
|
||||
"vault",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import type { TerminalSession, Workspace } from "../../domain/models";
|
||||
import type { AIChatRecencyEntry } from "../../domain/aiSessionRecency";
|
||||
import { resolveMostRecentlyActiveMemberTerminalId } from "../../domain/aiSessionRecency";
|
||||
import { collectSessionIds, createEmptyWorkspaceRoot, pruneWorkspaceNode } from "../../domain/workspace";
|
||||
import { collectSessionIds, pruneWorkspaceNode } from "../../domain/workspace";
|
||||
|
||||
export type DetachSessionFromWorkspaceStateResult = {
|
||||
changed: boolean;
|
||||
sessions: TerminalSession[];
|
||||
workspaces: Workspace[];
|
||||
activeTabId?: string;
|
||||
dissolvedWorkspaceId?: string;
|
||||
replacementTabIds?: string[];
|
||||
};
|
||||
|
||||
export type CloseSessionWorkspaceLayoutResult = {
|
||||
workspaces: Workspace[];
|
||||
removedWorkspaceId?: string;
|
||||
dissolvedWorkspaceId?: string;
|
||||
lastRemainingSessionId?: string;
|
||||
};
|
||||
|
||||
export type CloseSessionsStateResult = {
|
||||
@@ -25,65 +28,82 @@ type DetachSessionFromWorkspaceStateOptions = {
|
||||
sessions: TerminalSession[];
|
||||
workspaces: Workspace[];
|
||||
sessionId: string;
|
||||
/**
|
||||
* AI chat store snapshot used to rank surviving members by chat recency.
|
||||
* Without a recency signal, terminal display still needs a live member.
|
||||
*/
|
||||
aiSessions?: readonly AIChatRecencyEntry[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the member that should receive focus (or an anchor retarget) after a
|
||||
* member loss. Prefers the AI-chat recency winner; falls back to the still-
|
||||
* valid preferred (focused) member because that is explicit user intent, and
|
||||
* falls back to a surviving pane so focus mode never hides all terminals.
|
||||
*/
|
||||
const resolveSurvivingFocusMemberId = (
|
||||
remainingSessionIds: readonly string[],
|
||||
previousFocusedSessionId: string | undefined,
|
||||
aiSessions: readonly AIChatRecencyEntry[] | undefined,
|
||||
): string | undefined => {
|
||||
if (previousFocusedSessionId && remainingSessionIds.includes(previousFocusedSessionId)) {
|
||||
return previousFocusedSessionId;
|
||||
export function replaceDissolvedWorkspaceTabOrder(
|
||||
tabOrder: readonly string[],
|
||||
workspaceId: string | undefined,
|
||||
replacementTabIds: readonly string[] | undefined,
|
||||
): string[] {
|
||||
if (!workspaceId || !replacementTabIds?.length) return [...tabOrder];
|
||||
|
||||
const uniqueReplacementIds = replacementTabIds.filter((tabId, index, list) => (
|
||||
tabId && list.indexOf(tabId) === index
|
||||
));
|
||||
if (uniqueReplacementIds.length === 0) return [...tabOrder];
|
||||
|
||||
if (!tabOrder.includes(workspaceId)) {
|
||||
const hasAllReplacementIds = uniqueReplacementIds.every((tabId) => tabOrder.includes(tabId));
|
||||
return hasAllReplacementIds ? [...tabOrder] : [
|
||||
...tabOrder,
|
||||
...uniqueReplacementIds.filter((tabId) => !tabOrder.includes(tabId)),
|
||||
];
|
||||
}
|
||||
return resolveMostRecentlyActiveMemberTerminalId({
|
||||
memberTerminalIds: remainingSessionIds,
|
||||
aiSessions,
|
||||
}) ?? remainingSessionIds[0];
|
||||
};
|
||||
|
||||
const replacementIdSet = new Set(uniqueReplacementIds);
|
||||
let inserted = false;
|
||||
const nextOrder: string[] = [];
|
||||
|
||||
for (const tabId of tabOrder) {
|
||||
if (tabId === workspaceId) {
|
||||
if (!inserted) {
|
||||
nextOrder.push(...uniqueReplacementIds);
|
||||
inserted = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!replacementIdSet.has(tabId)) {
|
||||
nextOrder.push(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
return nextOrder;
|
||||
}
|
||||
|
||||
export function closeSessionWorkspaceLayoutState(
|
||||
workspaces: readonly Workspace[],
|
||||
workspaceId: string | undefined,
|
||||
sessionId: string,
|
||||
aiSessions?: readonly AIChatRecencyEntry[],
|
||||
): CloseSessionWorkspaceLayoutResult {
|
||||
if (!workspaceId) return { workspaces: [...workspaces] };
|
||||
|
||||
let removedWorkspaceId: string | undefined;
|
||||
let dissolvedWorkspaceId: string | undefined;
|
||||
let lastRemainingSessionId: string | undefined;
|
||||
const nextWorkspaces = workspaces
|
||||
.map((workspace) => {
|
||||
if (workspace.id !== workspaceId) return workspace;
|
||||
const prunedRoot = pruneWorkspaceNode(workspace.root, sessionId);
|
||||
if (!prunedRoot) {
|
||||
// Issue #4098 phase 2: the last member closed, but the workspace is a
|
||||
// first-class entity — it survives with an empty root so its id (and
|
||||
// the AI chats anchored to it) survive restart and member churn.
|
||||
return {
|
||||
...workspace,
|
||||
root: createEmptyWorkspaceRoot(),
|
||||
focusedSessionId: undefined,
|
||||
focusSessionOrder: [],
|
||||
};
|
||||
removedWorkspaceId = workspace.id;
|
||||
return null;
|
||||
}
|
||||
|
||||
const remainingSessionIds = collectSessionIds(prunedRoot);
|
||||
// Closing the focused session hands focus to the most-recently-active
|
||||
// surviving member when AI chat recency is available.
|
||||
const nextFocusedSessionId = resolveSurvivingFocusMemberId(
|
||||
remainingSessionIds,
|
||||
workspace.focusedSessionId,
|
||||
aiSessions,
|
||||
);
|
||||
if (remainingSessionIds.length === 1) {
|
||||
dissolvedWorkspaceId = workspace.id;
|
||||
lastRemainingSessionId = remainingSessionIds[0];
|
||||
return null;
|
||||
}
|
||||
|
||||
// Closing the focused session must hand focus to a remaining one, or a
|
||||
// focus-mode workspace is left with a dangling focusedSessionId: no pane
|
||||
// qualifies as visible and the continuously rendered hidden panes all
|
||||
// paint on top of each other (#3046).
|
||||
const nextFocusedSessionId =
|
||||
workspace.focusedSessionId && !remainingSessionIds.includes(workspace.focusedSessionId)
|
||||
? remainingSessionIds[0]
|
||||
: workspace.focusedSessionId;
|
||||
const nextFocusSessionOrder = workspace.focusSessionOrder?.filter(
|
||||
(candidateId) => remainingSessionIds.includes(candidateId),
|
||||
);
|
||||
@@ -94,23 +114,36 @@ export function closeSessionWorkspaceLayoutState(
|
||||
focusedSessionId: nextFocusedSessionId,
|
||||
focusSessionOrder: nextFocusSessionOrder,
|
||||
};
|
||||
});
|
||||
})
|
||||
.filter((workspace): workspace is Workspace => Boolean(workspace));
|
||||
|
||||
return {
|
||||
workspaces: nextWorkspaces,
|
||||
removedWorkspaceId,
|
||||
dissolvedWorkspaceId,
|
||||
lastRemainingSessionId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a close-session action to the session list using the layout result.
|
||||
* Since issue #4098 phase 2 workspaces survive member loss (no dissolve), the
|
||||
* remaining members keep their workspace binding.
|
||||
* When a 2-pane workspace dissolves to one terminal, that remaining session
|
||||
* must become an orphan tab — not stay bound to a deleted workspaceId.
|
||||
*/
|
||||
export function applyCloseSessionToSessions(
|
||||
sessions: readonly TerminalSession[],
|
||||
sessionId: string,
|
||||
layoutResult: Pick<CloseSessionWorkspaceLayoutResult, "lastRemainingSessionId">,
|
||||
): TerminalSession[] {
|
||||
return sessions.filter((session) => session.id !== sessionId);
|
||||
const remaining = sessions.filter((session) => session.id !== sessionId);
|
||||
const lastRemainingSessionId = layoutResult.lastRemainingSessionId;
|
||||
if (!lastRemainingSessionId) return remaining;
|
||||
|
||||
return remaining.map((session) => (
|
||||
session.id === lastRemainingSessionId
|
||||
? { ...session, workspaceId: undefined }
|
||||
: session
|
||||
));
|
||||
}
|
||||
|
||||
export function isPreferredTabAvailableAfterClose(
|
||||
@@ -128,6 +161,12 @@ export function isPreferredTabAvailableAfterClose(
|
||||
} {
|
||||
const { preferredTabId, closedSessionId, layoutResult, remainingSessions } = input;
|
||||
if (!preferredTabId || preferredTabId === closedSessionId) return false;
|
||||
if (
|
||||
preferredTabId === layoutResult.dissolvedWorkspaceId
|
||||
|| preferredTabId === layoutResult.removedWorkspaceId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (preferredTabId === "vault" || preferredTabId === "sftp") return true;
|
||||
if (layoutResult.workspaces.some((workspace) => workspace.id === preferredTabId)) return true;
|
||||
return remainingSessions.some((session) => (
|
||||
@@ -140,6 +179,7 @@ export function isPreferredTabAvailableAfterClose(
|
||||
export function resolveActiveTabAfterCloseSession({
|
||||
currentActiveTabId,
|
||||
closedSessionId,
|
||||
workspaceId,
|
||||
layoutResult,
|
||||
remainingSessions,
|
||||
preferredTabId,
|
||||
@@ -162,14 +202,18 @@ export function resolveActiveTabAfterCloseSession({
|
||||
const preferredFallback = isPreferredTabAvailableAfterClose(preferredInput)
|
||||
? preferredInput.preferredTabId
|
||||
: undefined;
|
||||
const fallback = preferredFallback
|
||||
const fallback = layoutResult.lastRemainingSessionId
|
||||
?? preferredFallback
|
||||
?? fallbackWorkspace?.id
|
||||
?? fallbackSolo?.id
|
||||
?? "vault";
|
||||
|
||||
// Workspaces survive member loss (#4098 phase 2), so only a closed session
|
||||
// tab that was active needs a replacement.
|
||||
if (currentActiveTabId === closedSessionId) {
|
||||
if (
|
||||
currentActiveTabId === closedSessionId
|
||||
|| (layoutResult.dissolvedWorkspaceId && currentActiveTabId === layoutResult.dissolvedWorkspaceId)
|
||||
|| (layoutResult.removedWorkspaceId && currentActiveTabId === layoutResult.removedWorkspaceId)
|
||||
|| (workspaceId && currentActiveTabId === workspaceId && !layoutResult.workspaces.some((ws) => ws.id === workspaceId))
|
||||
) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
@@ -183,7 +227,6 @@ export function closeSessionsState({
|
||||
currentActiveTabId,
|
||||
preferredTabId,
|
||||
tabOrder,
|
||||
aiSessions,
|
||||
}: {
|
||||
sessions: readonly TerminalSession[];
|
||||
workspaces: readonly Workspace[];
|
||||
@@ -191,7 +234,6 @@ export function closeSessionsState({
|
||||
currentActiveTabId: string | null;
|
||||
preferredTabId?: string | null;
|
||||
tabOrder: readonly string[];
|
||||
aiSessions?: readonly AIChatRecencyEntry[];
|
||||
}): CloseSessionsStateResult {
|
||||
let nextSessions = [...sessions];
|
||||
let nextWorkspaces = [...workspaces];
|
||||
@@ -208,10 +250,17 @@ export function closeSessionsState({
|
||||
nextWorkspaces,
|
||||
workspaceId,
|
||||
sessionId,
|
||||
aiSessions,
|
||||
);
|
||||
nextWorkspaces = layoutResult.workspaces;
|
||||
nextSessions = applyCloseSessionToSessions(nextSessions, sessionId);
|
||||
nextSessions = applyCloseSessionToSessions(nextSessions, sessionId, layoutResult);
|
||||
|
||||
if (layoutResult.dissolvedWorkspaceId && layoutResult.lastRemainingSessionId) {
|
||||
nextTabOrder = replaceDissolvedWorkspaceTabOrder(
|
||||
nextTabOrder,
|
||||
layoutResult.dissolvedWorkspaceId,
|
||||
[layoutResult.lastRemainingSessionId],
|
||||
);
|
||||
}
|
||||
|
||||
const activeTabAfterClose = resolveActiveTabAfterCloseSession({
|
||||
currentActiveTabId: nextActiveTabId,
|
||||
@@ -239,7 +288,6 @@ export function detachSessionFromWorkspaceState({
|
||||
sessions,
|
||||
workspaces,
|
||||
sessionId,
|
||||
aiSessions,
|
||||
}: DetachSessionFromWorkspaceStateOptions): DetachSessionFromWorkspaceStateResult {
|
||||
const session = sessions.find((candidate) => candidate.id === sessionId);
|
||||
if (!session?.workspaceId) {
|
||||
@@ -253,36 +301,40 @@ export function detachSessionFromWorkspaceState({
|
||||
}
|
||||
|
||||
const prunedRoot = pruneWorkspaceNode(targetWorkspace.root, sessionId);
|
||||
const nextSessions = sessions.map((candidate) => (
|
||||
let nextSessions = sessions.map((candidate) => (
|
||||
candidate.id === sessionId ? { ...candidate, workspaceId: undefined } : candidate
|
||||
));
|
||||
|
||||
if (!prunedRoot) {
|
||||
// Last member dragged out: the workspace entity survives with an empty
|
||||
// root (#4098 phase 2) so its id and anchored AI chats stay stable.
|
||||
return {
|
||||
changed: true,
|
||||
sessions: nextSessions,
|
||||
workspaces: workspaces.map((workspace) => (
|
||||
workspace.id === workspaceId
|
||||
? {
|
||||
...workspace,
|
||||
root: createEmptyWorkspaceRoot(),
|
||||
focusedSessionId: undefined,
|
||||
focusSessionOrder: [],
|
||||
}
|
||||
: workspace
|
||||
)),
|
||||
workspaces: workspaces.filter((workspace) => workspace.id !== workspaceId),
|
||||
activeTabId: sessionId,
|
||||
dissolvedWorkspaceId: workspaceId,
|
||||
replacementTabIds: [sessionId],
|
||||
};
|
||||
}
|
||||
|
||||
const remainingSessionIds = collectSessionIds(prunedRoot);
|
||||
const nextFocusedSessionId = resolveSurvivingFocusMemberId(
|
||||
remainingSessionIds,
|
||||
targetWorkspace.focusedSessionId,
|
||||
aiSessions,
|
||||
);
|
||||
if (remainingSessionIds.length === 1) {
|
||||
nextSessions = nextSessions.map((candidate) => (
|
||||
candidate.id === remainingSessionIds[0] ? { ...candidate, workspaceId: undefined } : candidate
|
||||
));
|
||||
|
||||
return {
|
||||
changed: true,
|
||||
sessions: nextSessions,
|
||||
workspaces: workspaces.filter((workspace) => workspace.id !== workspaceId),
|
||||
activeTabId: sessionId,
|
||||
dissolvedWorkspaceId: workspaceId,
|
||||
replacementTabIds: [sessionId, ...remainingSessionIds],
|
||||
};
|
||||
}
|
||||
|
||||
const nextFocusedSessionId = remainingSessionIds.includes(targetWorkspace.focusedSessionId)
|
||||
? targetWorkspace.focusedSessionId
|
||||
: remainingSessionIds[0];
|
||||
const nextFocusSessionOrder = (targetWorkspace.focusSessionOrder ?? [])
|
||||
.filter((candidateId, index, list) => (
|
||||
candidateId !== sessionId &&
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { SftpPane } from "./types";
|
||||
import type { SftpArchiveCreationResult } from "../../../domain/sftpArchiveCreation";
|
||||
import { createSftpArchiveForPane } from "./createSftpArchive.ts";
|
||||
|
||||
function setup() {
|
||||
let pane = {
|
||||
id: "pane-a", connection: { id: "connection-a", isLocal: false }, filenameEncoding: "gb18030",
|
||||
} as SftpPane;
|
||||
const calls: unknown[][] = [];
|
||||
const refreshes: string[] = [];
|
||||
const params = {
|
||||
getPane: () => pane,
|
||||
target: { paneId: "pane-a", connectionId: "connection-a" },
|
||||
options: { directory: "/srv", paths: ["中文", "logs"], archiveName: "backup.zip", format: "zip" as const },
|
||||
getSftpIdForConnection: (id: string) => id === "connection-a" ? "sftp-a" : undefined,
|
||||
bridge: {
|
||||
createSftpArchive: async (...args: unknown[]): Promise<SftpArchiveCreationResult> => {
|
||||
calls.push(args);
|
||||
return { success: true };
|
||||
},
|
||||
},
|
||||
refresh: async (id: string) => { refreshes.push(id); },
|
||||
t: (key: string) => key,
|
||||
};
|
||||
return { params, calls, refreshes, setPane: (value: SftpPane) => { pane = value; } };
|
||||
}
|
||||
|
||||
test("archive creation passes selected paths and encoding to the captured remote session", async () => {
|
||||
const { params, calls, refreshes } = setup();
|
||||
assert.deepEqual(await createSftpArchiveForPane(params), { success: true });
|
||||
assert.deepEqual(calls, [["sftp-a", params.options, "gb18030"]]);
|
||||
assert.deepEqual(refreshes, ["pane-a"]);
|
||||
});
|
||||
|
||||
test("a switched or disconnected pane cannot compress files on another host", async () => {
|
||||
for (const change of [
|
||||
{ id: "pane-b" },
|
||||
{ connection: { id: "connection-b", isLocal: false } },
|
||||
{ connection: null },
|
||||
]) {
|
||||
const { params, calls, setPane } = setup();
|
||||
setPane({ ...params.getPane(), ...change } as SftpPane);
|
||||
await assert.rejects(createSftpArchiveForPane(params), /connectionChanged/);
|
||||
assert.equal(calls.length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("archive creation requires a remote session and a supported bridge", async () => {
|
||||
const { params, calls, setPane } = setup();
|
||||
await assert.rejects(createSftpArchiveForPane({ ...params, bridge: undefined }), /unavailable/);
|
||||
await assert.rejects(createSftpArchiveForPane({ ...params, getSftpIdForConnection: () => undefined }), /unavailable/);
|
||||
setPane({ ...params.getPane(), connection: { id: "connection-a", isLocal: true } } as SftpPane);
|
||||
await assert.rejects(createSftpArchiveForPane(params), /unavailable/);
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test("existing archives are returned for confirmation without retry or refresh", async () => {
|
||||
const { params, refreshes } = setup();
|
||||
const conflict = { success: false, code: "EEXIST", error: "Already exists" };
|
||||
params.bridge.createSftpArchive = async () => conflict;
|
||||
assert.equal(await createSftpArchiveForPane(params), conflict);
|
||||
assert.deepEqual(refreshes, []);
|
||||
});
|
||||
|
||||
test("finishing after a tab switch does not refresh the new connection", async () => {
|
||||
const { params, refreshes, setPane } = setup();
|
||||
params.bridge.createSftpArchive = async () => {
|
||||
setPane({ ...params.getPane(), id: "pane-b" } as SftpPane);
|
||||
return { success: true };
|
||||
};
|
||||
assert.deepEqual(await createSftpArchiveForPane(params), { success: true });
|
||||
assert.deepEqual(refreshes, []);
|
||||
});
|
||||
|
||||
test("a failed operation is not refreshed or converted to success", async () => {
|
||||
const { params, refreshes } = setup();
|
||||
params.bridge.createSftpArchive = async () => { throw new Error("zip is not installed"); };
|
||||
await assert.rejects(createSftpArchiveForPane(params), /zip is not installed/);
|
||||
assert.deepEqual(refreshes, []);
|
||||
});
|
||||
|
||||
test("a listing error after publication does not invite a second archive creation", async () => {
|
||||
const { params, calls } = setup();
|
||||
params.refresh = async () => { throw new Error("Disconnected after publication"); };
|
||||
assert.deepEqual(await createSftpArchiveForPane(params), { success: true });
|
||||
assert.equal(calls.length, 1);
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
import type {
|
||||
SftpArchiveCreationOptions,
|
||||
SftpArchiveCreationResult,
|
||||
SftpArchiveCreationTarget,
|
||||
} from "../../../domain/sftpArchiveCreation";
|
||||
import type { SftpPane } from "./types";
|
||||
import { logger } from "../../../lib/logger";
|
||||
|
||||
interface CreateSftpArchiveParams {
|
||||
getPane: () => Pick<SftpPane, "id" | "connection" | "filenameEncoding">;
|
||||
target: SftpArchiveCreationTarget;
|
||||
options: SftpArchiveCreationOptions;
|
||||
bridge: Pick<NetcattyBridge, "createSftpArchive"> | undefined;
|
||||
getSftpIdForConnection?: (connectionId: string) => string | undefined;
|
||||
refresh: (paneId: string) => Promise<void>;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
/** Bind the selected files to the pane/connection that opened the dialog. */
|
||||
export async function createSftpArchiveForPane({
|
||||
getPane, target, options, bridge, getSftpIdForConnection, refresh, t,
|
||||
}: CreateSftpArchiveParams): Promise<SftpArchiveCreationResult> {
|
||||
const pane = getPane();
|
||||
if (pane.id !== target.paneId || pane.connection?.id !== target.connectionId) {
|
||||
throw new Error(t("sftp.archive.connectionChanged"));
|
||||
}
|
||||
const sftpId = getSftpIdForConnection?.(target.connectionId);
|
||||
if (pane.connection.isLocal || !sftpId || !bridge?.createSftpArchive) {
|
||||
throw new Error(t("sftp.archive.unavailable"));
|
||||
}
|
||||
const result = await bridge.createSftpArchive(sftpId, options, pane.filenameEncoding);
|
||||
if (!result.success) return result;
|
||||
|
||||
// A tab switch during compression must not refresh an unrelated connection.
|
||||
const current = getPane();
|
||||
if (current.id === target.paneId && current.connection?.id === target.connectionId) {
|
||||
try {
|
||||
await refresh(target.paneId);
|
||||
} catch (error) {
|
||||
// The archive has already been published. A listing failure must not
|
||||
// invite the user to repeat a successful operation (or overwrite it).
|
||||
logger.warn("[SFTP] Archive created but directory refresh failed:", error);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
/** @test issue #4132 — a one-click duplicate copies next to the source without a conflict prompt */
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
import { useSftpTransfers } from "./useSftpTransfers";
|
||||
import { createEmptyPane } from "./types";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
|
||||
test("duplicate transfers resolve same-name conflicts to (copy) without prompting", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(process.cwd(), ".duplicate-entries-"));
|
||||
const ownerId = `duplicate-entries-${crypto.randomUUID()}`;
|
||||
const previousWindow = globalThis.window;
|
||||
const previousStorage = globalThis.localStorage;
|
||||
const globals = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previousAct = globals.IS_REACT_ACT_ENVIRONMENT;
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
let ops!: ReturnType<typeof useSftpTransfers>;
|
||||
// A duplicate has to cover both files and folders: the conflict type key
|
||||
// differs between the two and the seeded defaults must match both.
|
||||
const fileNames = ["report.txt"];
|
||||
const folderNames = ["photos"];
|
||||
for (const name of fileNames) {
|
||||
await fs.writeFile(path.join(dir, name), "original data");
|
||||
}
|
||||
for (const name of folderNames) {
|
||||
await fs.mkdir(path.join(dir, name));
|
||||
await fs.writeFile(path.join(dir, name, "nested.txt"), "nested data");
|
||||
}
|
||||
const pane = createEmptyPane("duplicate-pane");
|
||||
pane.connection = { id: ownerId, isLocal: true, currentPath: dir, status: "connected" } as NonNullable<typeof pane.connection>;
|
||||
const statPath = async (value: string) => {
|
||||
try {
|
||||
const stat = await fs.lstat(value);
|
||||
return { type: stat.isSymbolicLink() ? "symlink" : stat.isDirectory() ? "directory" : "file", size: stat.size, lastModified: stat.mtimeMs };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
(globalThis as { window?: unknown }).window = { requestAnimationFrame: (callback: () => void) => setTimeout(callback, 0), cancelAnimationFrame: clearTimeout, netcatty: {
|
||||
realpathLocal: fs.realpath, statLocal: statPath, lstatLocal: statPath,
|
||||
mkdirLocal: async (value: string) => { await fs.mkdir(value, { recursive: true }); },
|
||||
deleteLocalFile: async (value: string) => { await fs.rm(value, { recursive: true, force: true }); },
|
||||
renameLocalFile: async (source: string, target: string) => { await fs.rename(source, target); },
|
||||
startStreamTransfer: async (options: { sourcePath: string; targetPath: string; transferId: string }) => {
|
||||
await fs.copyFile(options.sourcePath, options.targetPath);
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({ type: "completed", transferId: options.transferId, transferred: 13, totalBytes: 13, lifecycleEpoch: 0 });
|
||||
return {};
|
||||
},
|
||||
} };
|
||||
(globalThis as { localStorage?: unknown }).localStorage = { getItem: () => null, setItem: () => undefined, removeItem: () => undefined };
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId, getActivePane: () => pane, getPaneByConnectionId: () => pane, getTabByConnectionId: () => ({ side: "left", tabId: pane.id, pane }),
|
||||
updateTab: () => undefined, refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined, handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map() }, connectionCacheKeyMapRef: { current: new Map() },
|
||||
listRemoteFiles: async () => [],
|
||||
listLocalFiles: async (value: string) => Promise.all((await fs.readdir(value)).map(async name => {
|
||||
const stat = await fs.lstat(path.join(value, name));
|
||||
return { name, type: stat.isSymbolicLink() ? "symlink" as const : stat.isDirectory() ? "directory" as const : "file" as const, size: stat.size, sizeFormatted: "", lastModified: stat.mtimeMs, lastModifiedFormatted: "" };
|
||||
})),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const waitForSettledRoots = async (expected: number) => {
|
||||
await act(async () => {
|
||||
const deadline = Date.now() + 5000;
|
||||
while (Date.now() < deadline) {
|
||||
const roots = sftpTransferCenterStore.getOwnerTasks(ownerId).filter(row => !row.parentTaskId);
|
||||
if (roots.length === expected && roots.every(row => ["completed", "failed"].includes(row.status))) return;
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
}
|
||||
});
|
||||
};
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
const sources = [...fileNames, ...folderNames].map(name => ({ name, isDirectory: folderNames.includes(name) }));
|
||||
await act(async () => {
|
||||
await ops.startTransfer(sources, "left", "left", { sourcePath: dir, targetPath: dir, duplicate: true });
|
||||
});
|
||||
// The duplicate option must resolve conflicts by itself; a prompt here
|
||||
// would defeat the one-click action the issue asks for.
|
||||
assert.deepEqual(ops.conflicts, []);
|
||||
await waitForSettledRoots(sources.length);
|
||||
const roots = sftpTransferCenterStore.getOwnerTasks(ownerId).filter(row => !row.parentTaskId);
|
||||
assert.equal(roots.length, sources.length, JSON.stringify(roots.map(row => row.fileName)));
|
||||
assert.ok(roots.every(row => row.status === "completed"), JSON.stringify(roots.map(row => ({ status: row.status, error: row.error }))));
|
||||
for (const name of fileNames) {
|
||||
assert.equal(await fs.readFile(path.join(dir, name), "utf8"), "original data");
|
||||
}
|
||||
assert.equal(await fs.readFile(path.join(dir, "report (copy).txt"), "utf8"), "original data");
|
||||
assert.equal(await fs.readFile(path.join(dir, "photos (copy)", "nested.txt"), "utf8"), "nested data");
|
||||
|
||||
// A second duplicate must pick the next free name instead of colliding
|
||||
// with the copy created by the first run.
|
||||
await act(async () => {
|
||||
await ops.startTransfer(sources, "left", "left", { sourcePath: dir, targetPath: dir, duplicate: true });
|
||||
});
|
||||
assert.deepEqual(ops.conflicts, []);
|
||||
await waitForSettledRoots(sources.length * 2);
|
||||
assert.equal(await fs.readFile(path.join(dir, "report (copy 2).txt"), "utf8"), "original data");
|
||||
assert.equal(await fs.readFile(path.join(dir, "photos (copy 2)", "nested.txt"), "utf8"), "nested data");
|
||||
|
||||
// The tree's ".." navigation row must never be duplicated: it would copy
|
||||
// an ancestor into itself.
|
||||
const tasksBefore = sftpTransferCenterStore.getOwnerTasks(ownerId).length;
|
||||
let navigationResults: unknown;
|
||||
await act(async () => {
|
||||
navigationResults = await ops.startTransfer(
|
||||
[{ name: "..", isDirectory: true }],
|
||||
"left",
|
||||
"left",
|
||||
{ sourcePath: dir, targetPath: dir, duplicate: true },
|
||||
);
|
||||
});
|
||||
assert.deepEqual(navigationResults, []);
|
||||
assert.equal(sftpTransferCenterStore.getOwnerTasks(ownerId).length, tasksBefore);
|
||||
assert.deepEqual(ops.conflicts, []);
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
for (const row of sftpTransferCenterStore.getOwnerTasks(ownerId)) sftpTransferCenterStore.dismiss(row.id);
|
||||
(globalThis as { window?: unknown }).window = previousWindow;
|
||||
(globalThis as { localStorage?: unknown }).localStorage = previousStorage;
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = previousAct;
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -1,109 +0,0 @@
|
||||
/** @test issue #4132 — a duplicate must not reuse a destination an earlier copy still holds */
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
import { useSftpTransfers } from "./useSftpTransfers";
|
||||
import { createEmptyPane } from "./types";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
|
||||
test("duplicate names around a destination an in-flight copy has not created yet", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(process.cwd(), ".duplicate-inflight-"));
|
||||
const ownerId = `duplicate-inflight-${crypto.randomUUID()}`;
|
||||
const previousWindow = globalThis.window;
|
||||
const previousStorage = globalThis.localStorage;
|
||||
const globals = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previousAct = globals.IS_REACT_ACT_ENVIRONMENT;
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
let ops!: ReturnType<typeof useSftpTransfers>;
|
||||
let releaseFirst: (() => void) | undefined;
|
||||
const firstCopyGate = new Promise<void>((resolve) => { releaseFirst = resolve; });
|
||||
let streamStarts = 0;
|
||||
await fs.writeFile(path.join(dir, "report.txt"), "original data");
|
||||
const pane = createEmptyPane("duplicate-inflight-pane");
|
||||
pane.connection = { id: ownerId, isLocal: true, currentPath: dir, status: "connected" } as NonNullable<typeof pane.connection>;
|
||||
const statPath = async (value: string) => {
|
||||
try {
|
||||
const stat = await fs.lstat(value);
|
||||
return { type: stat.isSymbolicLink() ? "symlink" : stat.isDirectory() ? "directory" : "file", size: stat.size, lastModified: stat.mtimeMs };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
(globalThis as { window?: unknown }).window = { requestAnimationFrame: (callback: () => void) => setTimeout(callback, 0), cancelAnimationFrame: clearTimeout, netcatty: {
|
||||
realpathLocal: fs.realpath, statLocal: statPath, lstatLocal: statPath,
|
||||
mkdirLocal: async (value: string) => { await fs.mkdir(value, { recursive: true }); },
|
||||
deleteLocalFile: async (value: string) => { await fs.rm(value, { recursive: true, force: true }); },
|
||||
renameLocalFile: async (source: string, target: string) => { await fs.rename(source, target); },
|
||||
startStreamTransfer: async (options: { sourcePath: string; targetPath: string; transferId: string }) => {
|
||||
streamStarts += 1;
|
||||
// Hold the first copy before it writes its destination: the second
|
||||
// duplicate then sees no "(copy)" on disk even though one is reserved.
|
||||
if (streamStarts === 1) await firstCopyGate;
|
||||
await fs.copyFile(options.sourcePath, options.targetPath);
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({ type: "completed", transferId: options.transferId, transferred: 13, totalBytes: 13, lifecycleEpoch: 0 });
|
||||
return {};
|
||||
},
|
||||
} };
|
||||
(globalThis as { localStorage?: unknown }).localStorage = { getItem: () => null, setItem: () => undefined, removeItem: () => undefined };
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId, getActivePane: () => pane, getPaneByConnectionId: () => pane, getTabByConnectionId: () => ({ side: "left", tabId: pane.id, pane }),
|
||||
updateTab: () => undefined, refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined, handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map() }, connectionCacheKeyMapRef: { current: new Map() },
|
||||
listRemoteFiles: async () => [],
|
||||
listLocalFiles: async (value: string) => Promise.all((await fs.readdir(value)).map(async name => {
|
||||
const stat = await fs.lstat(path.join(value, name));
|
||||
return { name, type: stat.isSymbolicLink() ? "symlink" as const : stat.isDirectory() ? "directory" as const : "file" as const, size: stat.size, sizeFormatted: "", lastModified: stat.mtimeMs, lastModifiedFormatted: "" };
|
||||
})),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const sources = [{ name: "report.txt", isDirectory: false }];
|
||||
const options = { sourcePath: dir, targetPath: dir, duplicate: true };
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
// First duplicate starts copying and blocks before writing "(copy)".
|
||||
let firstRun: ReturnType<typeof ops.startTransfer> | undefined;
|
||||
await act(async () => {
|
||||
firstRun = ops.startTransfer(sources, "left", "left", options);
|
||||
const deadline = Date.now() + 3000;
|
||||
while (streamStarts === 0 && Date.now() < deadline) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
}
|
||||
});
|
||||
assert.equal(streamStarts, 1, "the first copy should be in flight");
|
||||
|
||||
// Second duplicate runs while "(copy)" is still absent from the disk.
|
||||
await act(async () => { await ops.startTransfer(sources, "left", "left", options); });
|
||||
assert.equal(ops.conflicts.length, 0, "duplicate must not prompt for a reserved name");
|
||||
releaseFirst?.();
|
||||
await act(async () => { await firstRun; });
|
||||
|
||||
await act(async () => {
|
||||
const deadline = Date.now() + 5000;
|
||||
while (Date.now() < deadline) {
|
||||
const rows = sftpTransferCenterStore.getOwnerTasks(ownerId).filter(row => !row.parentTaskId);
|
||||
if (rows.length === 2 && rows.every(row => ["completed", "failed"].includes(row.status))) return;
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
}
|
||||
});
|
||||
const roots = sftpTransferCenterStore.getOwnerTasks(ownerId).filter(row => !row.parentTaskId);
|
||||
assert.deepEqual(roots.map(row => row.fileName).sort(), ["report (copy 2).txt", "report (copy).txt"]);
|
||||
assert.ok(roots.every(row => row.status === "completed"), JSON.stringify(roots.map(row => ({ status: row.status, error: row.error }))));
|
||||
assert.equal(await fs.readFile(path.join(dir, "report (copy).txt"), "utf8"), "original data");
|
||||
assert.equal(await fs.readFile(path.join(dir, "report (copy 2).txt"), "utf8"), "original data");
|
||||
} finally {
|
||||
releaseFirst?.();
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
for (const row of sftpTransferCenterStore.getOwnerTasks(ownerId)) sftpTransferCenterStore.dismiss(row.id);
|
||||
(globalThis as { window?: unknown }).window = previousWindow;
|
||||
(globalThis as { localStorage?: unknown }).localStorage = previousStorage;
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = previousAct;
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -61,11 +61,10 @@ export function useSftpTransferConflictOps() {
|
||||
|
||||
const getDuplicateTarget = useCallback(
|
||||
async (
|
||||
task: Pick<TransferTask, "fileName" | "isDirectory" | "targetPath">,
|
||||
task: TransferTask,
|
||||
targetPane: SftpPane,
|
||||
targetSftpId: string | null,
|
||||
targetEncoding: SftpFilenameEncoding,
|
||||
isTargetTaken?: (targetPath: string) => boolean,
|
||||
) => {
|
||||
const parentPath = getParentPath(task.targetPath);
|
||||
const { baseName, ext } = splitNameForDuplicate(task.fileName, task.isDirectory);
|
||||
@@ -74,9 +73,6 @@ export function useSftpTransferConflictOps() {
|
||||
const suffix = index === 1 ? " (copy)" : ` (copy ${index})`;
|
||||
const fileName = `${baseName}${suffix}${ext}`;
|
||||
const targetPath = joinPath(parentPath, fileName);
|
||||
// A queued/paused duplicate may not have created its destination yet,
|
||||
// so the filesystem alone would hand out the same name twice.
|
||||
if (isTargetTaken?.(targetPath)) continue;
|
||||
// Unsupported LSTAT must propagate — do not treat as a free name.
|
||||
const existing = await statTargetPath(targetPane, targetSftpId, targetPath, targetEncoding);
|
||||
if (!existing) return { fileName, targetPath };
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test, { after } from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
import type { SftpArchiveCreationResult } from "../../../domain/sftpArchiveCreation";
|
||||
import { useSftpArchiveDialog } from "./useSftpArchiveDialog";
|
||||
|
||||
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
|
||||
environment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
after(() => { environment.IS_REACT_ACT_ENVIRONMENT = previous; });
|
||||
|
||||
type Params = Parameters<typeof useSftpArchiveDialog>[0];
|
||||
|
||||
async function mount(overrides: Partial<Params> = {}) {
|
||||
let params: Params = {
|
||||
paneId: "pane-1", connectionId: "conn-1", isLocal: false, isActive: true,
|
||||
t: (key) => key, onCreateArchive: async () => ({ success: true }), onCreated: () => {},
|
||||
...overrides,
|
||||
};
|
||||
let current!: ReturnType<typeof useSftpArchiveDialog>;
|
||||
function Probe() {
|
||||
current = useSftpArchiveDialog(params);
|
||||
return null;
|
||||
}
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
return {
|
||||
get value() { return current; },
|
||||
async update(changes: Partial<Params>) {
|
||||
params = { ...params, ...changes };
|
||||
await act(async () => { renderer.update(React.createElement(Probe)); });
|
||||
},
|
||||
async unmount() { await act(async () => { renderer.unmount(); }); },
|
||||
};
|
||||
}
|
||||
|
||||
test("archive dialog snapshots nested selections, format, and connection identity", async (t) => {
|
||||
const requests: unknown[] = [];
|
||||
const reloaded: string[] = [];
|
||||
const harness = await mount({
|
||||
onCreateArchive: async (options, target) => { requests.push({ options, target }); return { success: true }; },
|
||||
onCreated: (directory) => { reloaded.push(directory); },
|
||||
});
|
||||
t.after(() => harness.unmount());
|
||||
await act(async () => { harness.value.open(["/srv/site/assets", "/srv/site/assets/logo.png", "/srv/site/config/app.json"]); });
|
||||
assert.deepEqual(harness.value.state?.paths, ["assets", "config/app.json"]);
|
||||
assert.equal(harness.value.state?.directory, "/srv/site");
|
||||
await act(async () => { harness.value.setArchiveName("backup.tar.gz"); harness.value.setFormat("zip"); });
|
||||
assert.equal(harness.value.state?.archiveName, "backup.zip");
|
||||
await act(async () => { await harness.value.submit(); });
|
||||
assert.deepEqual(requests, [{
|
||||
options: { directory: "/srv/site", paths: ["assets", "config/app.json"], archiveName: "backup.zip", format: "zip", overwrite: false },
|
||||
target: { paneId: "pane-1", connectionId: "conn-1" },
|
||||
}]);
|
||||
assert.deepEqual(reloaded, ["/srv/site"]);
|
||||
assert.equal(harness.value.state, null);
|
||||
});
|
||||
|
||||
test("archive dialog requires a separate overwrite decision and revokes it on edits", async (t) => {
|
||||
const overwriteFlags: Array<boolean | undefined> = [];
|
||||
const harness = await mount({ onCreateArchive: async (options) => {
|
||||
overwriteFlags.push(options.overwrite);
|
||||
return options.overwrite ? { success: true } : { success: false, code: "EEXIST" };
|
||||
} });
|
||||
t.after(() => harness.unmount());
|
||||
await act(async () => { harness.value.open(["/srv/site"]); });
|
||||
await act(async () => { await harness.value.submit(true); });
|
||||
assert.deepEqual(overwriteFlags, [], "overwrite is unavailable before a collision");
|
||||
await act(async () => { await harness.value.submit(); });
|
||||
assert.equal(harness.value.state?.confirmOverwrite, true);
|
||||
await act(async () => { await harness.value.submit(); });
|
||||
assert.deepEqual(overwriteFlags, [false], "Enter must not confirm overwrite");
|
||||
await act(async () => { harness.value.setArchiveName("other.tar.gz"); });
|
||||
assert.equal(harness.value.state?.confirmOverwrite, false);
|
||||
await act(async () => { await harness.value.submit(true); });
|
||||
assert.deepEqual(overwriteFlags, [false]);
|
||||
await act(async () => { await harness.value.submit(); });
|
||||
await act(async () => { await harness.value.submit(true); });
|
||||
assert.deepEqual(overwriteFlags, [false, false, true]);
|
||||
assert.equal(harness.value.state, null);
|
||||
});
|
||||
|
||||
test("pending archive creation blocks duplicate submits, edits, and dismissal", async (t) => {
|
||||
let finish!: (result: SftpArchiveCreationResult) => void;
|
||||
let requests = 0;
|
||||
const harness = await mount({ onCreateArchive: async () => {
|
||||
requests += 1;
|
||||
return new Promise<SftpArchiveCreationResult>((resolve) => { finish = resolve; });
|
||||
} });
|
||||
t.after(() => harness.unmount());
|
||||
await act(async () => { harness.value.open(["/srv/site"]); });
|
||||
let pending!: Promise<void>;
|
||||
await act(async () => {
|
||||
pending = harness.value.submit();
|
||||
void harness.value.submit();
|
||||
harness.value.close();
|
||||
harness.value.setArchiveName("changed.tar.gz");
|
||||
harness.value.open(["/different"]);
|
||||
});
|
||||
assert.equal(requests, 1);
|
||||
assert.equal(harness.value.state?.busy, true);
|
||||
assert.equal(harness.value.state?.archiveName, "site.tar.gz");
|
||||
await act(async () => { finish({ success: true }); await pending; });
|
||||
assert.equal(harness.value.state, null);
|
||||
});
|
||||
|
||||
test("switching connections dismisses the old dialog and ignores its late result", async (t) => {
|
||||
let finish!: (result: SftpArchiveCreationResult) => void;
|
||||
const reloaded: string[] = [];
|
||||
let requests = 0;
|
||||
const harness = await mount({
|
||||
onCreateArchive: async () => {
|
||||
requests += 1;
|
||||
return new Promise<SftpArchiveCreationResult>((resolve) => { finish = resolve; });
|
||||
},
|
||||
onCreated: (directory) => { reloaded.push(directory); },
|
||||
});
|
||||
t.after(() => harness.unmount());
|
||||
await act(async () => { harness.value.open(["/srv/site"]); });
|
||||
let pending!: Promise<void>;
|
||||
await act(async () => { pending = harness.value.submit(); });
|
||||
await harness.update({ connectionId: "conn-2" });
|
||||
assert.equal(harness.value.state, null);
|
||||
await act(async () => { harness.value.open(["/srv/new-site"]); });
|
||||
await act(async () => { finish({ success: false, code: "EEXIST" }); await pending; });
|
||||
assert.equal(requests, 1);
|
||||
assert.equal(harness.value.state?.archiveName, "new-site.tar.gz");
|
||||
assert.equal(harness.value.state?.confirmOverwrite, false);
|
||||
assert.deepEqual(reloaded, []);
|
||||
});
|
||||
|
||||
test("tab switches invalidate the old form even when its connection stays mounted", async (t) => {
|
||||
let requests = 0;
|
||||
const harness = await mount({ onCreateArchive: async () => { requests += 1; return { success: true }; } });
|
||||
t.after(() => harness.unmount());
|
||||
await act(async () => { harness.value.open(["/srv/site"]); });
|
||||
const staleSubmit = harness.value.submit;
|
||||
await harness.update({ isActive: false });
|
||||
await act(async () => { await staleSubmit(); });
|
||||
assert.equal(harness.value.state, null);
|
||||
assert.equal(requests, 0);
|
||||
await harness.update({ isActive: true, isLocal: true });
|
||||
await act(async () => { harness.value.open(["/srv/site"]); });
|
||||
assert.equal(harness.value.state, null);
|
||||
});
|
||||
|
||||
test("invalid names do not invoke the backend; failures keep a retryable form", async (t) => {
|
||||
let requests = 0;
|
||||
const harness = await mount({ onCreateArchive: async () => { requests += 1; throw new Error("zip is not installed"); } });
|
||||
t.after(() => harness.unmount());
|
||||
await act(async () => { harness.value.open(["/srv/site"]); harness.value.setArchiveName("../backup.tar.gz"); });
|
||||
await act(async () => { await harness.value.submit(); });
|
||||
assert.equal(requests, 0);
|
||||
assert.equal(harness.value.state?.error, "sftp.archive.invalidName");
|
||||
await act(async () => { harness.value.setArchiveName("backup.tar.gz"); });
|
||||
await act(async () => { await harness.value.submit(); });
|
||||
assert.equal(requests, 1);
|
||||
assert.equal(harness.value.state?.busy, false);
|
||||
assert.equal(harness.value.state?.error, "zip is not installed");
|
||||
});
|
||||
@@ -1,162 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
prepareSftpArchiveSelection,
|
||||
validateSftpArchiveName,
|
||||
withSftpArchiveExtension,
|
||||
type SftpArchiveCreationOptions,
|
||||
type SftpArchiveCreationResult,
|
||||
type SftpArchiveCreationTarget,
|
||||
type SftpArchiveFormat,
|
||||
} from "../../../domain/sftpArchiveCreation";
|
||||
|
||||
export type SftpArchiveTarget = SftpArchiveCreationTarget;
|
||||
|
||||
export interface SftpArchiveDialogState {
|
||||
target: SftpArchiveTarget;
|
||||
directory: string;
|
||||
paths: string[];
|
||||
archiveName: string;
|
||||
format: SftpArchiveFormat;
|
||||
busy: boolean;
|
||||
error: string | null;
|
||||
confirmOverwrite: boolean;
|
||||
}
|
||||
|
||||
interface UseSftpArchiveDialogParams {
|
||||
paneId: string;
|
||||
connectionId?: string;
|
||||
isLocal: boolean;
|
||||
isActive: boolean;
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
onCreateArchive?: (
|
||||
options: SftpArchiveCreationOptions,
|
||||
target: SftpArchiveTarget,
|
||||
) => Promise<SftpArchiveCreationResult>;
|
||||
onCreated: (directory: string) => void;
|
||||
}
|
||||
|
||||
export function useSftpArchiveDialog(params: UseSftpArchiveDialogParams) {
|
||||
const [state, setState] = useState<SftpArchiveDialogState | null>(null);
|
||||
const stateRef = useRef(state);
|
||||
const paramsRef = useRef(params);
|
||||
paramsRef.current = params;
|
||||
const generation = useRef(0);
|
||||
|
||||
const update = useCallback((next: SftpArchiveDialogState | null) => {
|
||||
stateRef.current = next;
|
||||
setState(next);
|
||||
}, []);
|
||||
|
||||
const matchesTarget = useCallback((target: SftpArchiveTarget) => {
|
||||
const current = paramsRef.current;
|
||||
return current.isActive && !current.isLocal
|
||||
&& current.paneId === target.paneId
|
||||
&& current.connectionId === target.connectionId;
|
||||
}, []);
|
||||
|
||||
// A dialog belongs to the connection and pane on which it was opened. A tab
|
||||
// switch may leave its component mounted, so visibility is part of identity.
|
||||
useEffect(() => {
|
||||
if (stateRef.current && !matchesTarget(stateRef.current.target)) {
|
||||
generation.current += 1;
|
||||
update(null);
|
||||
}
|
||||
}, [params.paneId, params.connectionId, params.isLocal, params.isActive, matchesTarget, update]);
|
||||
|
||||
useEffect(() => () => { generation.current += 1; }, []);
|
||||
|
||||
const open = useCallback((sourcePaths: string[]) => {
|
||||
const current = paramsRef.current;
|
||||
if (!current.connectionId || current.isLocal || !current.isActive
|
||||
|| !current.onCreateArchive || stateRef.current?.busy) return;
|
||||
generation.current += 1;
|
||||
const target = { paneId: current.paneId, connectionId: current.connectionId };
|
||||
try {
|
||||
const selection = prepareSftpArchiveSelection(sourcePaths);
|
||||
update({
|
||||
target,
|
||||
directory: selection.directory,
|
||||
paths: selection.paths,
|
||||
archiveName: withSftpArchiveExtension(selection.defaultName, "tar.gz"),
|
||||
format: "tar.gz",
|
||||
busy: false,
|
||||
error: null,
|
||||
confirmOverwrite: false,
|
||||
});
|
||||
} catch {
|
||||
update({
|
||||
target, directory: "", paths: [], archiveName: "", format: "tar.gz",
|
||||
busy: false, error: current.t("sftp.archive.invalidSelection"), confirmOverwrite: false,
|
||||
});
|
||||
}
|
||||
}, [update]);
|
||||
|
||||
const close = useCallback(() => {
|
||||
if (stateRef.current?.busy) return;
|
||||
generation.current += 1;
|
||||
update(null);
|
||||
}, [update]);
|
||||
|
||||
const setArchiveName = useCallback((archiveName: string) => {
|
||||
const current = stateRef.current;
|
||||
if (!current || current.busy) return;
|
||||
update({ ...current, archiveName, error: null, confirmOverwrite: false });
|
||||
}, [update]);
|
||||
|
||||
const setFormat = useCallback((format: SftpArchiveFormat) => {
|
||||
const current = stateRef.current;
|
||||
if (!current || current.busy) return;
|
||||
update({
|
||||
...current, format,
|
||||
archiveName: withSftpArchiveExtension(current.archiveName, format),
|
||||
error: null, confirmOverwrite: false,
|
||||
});
|
||||
}, [update]);
|
||||
|
||||
const submit = useCallback(async (overwrite = false) => {
|
||||
const current = stateRef.current;
|
||||
const { onCreateArchive, t } = paramsRef.current;
|
||||
if (!current || current.busy || !onCreateArchive || !matchesTarget(current.target)
|
||||
|| current.paths.length === 0 || (overwrite && !current.confirmOverwrite)) return;
|
||||
// Enter in the name field must not implicitly confirm replacement.
|
||||
if (current.confirmOverwrite && !overwrite) return;
|
||||
const validationError = validateSftpArchiveName(current.archiveName, current.format);
|
||||
if (validationError) {
|
||||
update({ ...current, error: t(validationError) });
|
||||
return;
|
||||
}
|
||||
const requestGeneration = generation.current;
|
||||
update({ ...current, busy: true, error: null });
|
||||
try {
|
||||
const result = await onCreateArchive({
|
||||
directory: current.directory,
|
||||
paths: current.paths,
|
||||
archiveName: current.archiveName,
|
||||
format: current.format,
|
||||
overwrite,
|
||||
}, current.target);
|
||||
if (generation.current !== requestGeneration || !matchesTarget(current.target)) return;
|
||||
if (!result.success) {
|
||||
update({
|
||||
...current, busy: false,
|
||||
confirmOverwrite: result.code === "EEXIST",
|
||||
error: result.code === "EEXIST" ? null : result.error || t("sftp.archive.failed"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
update(null);
|
||||
paramsRef.current.onCreated(current.directory);
|
||||
} catch (error) {
|
||||
if (generation.current !== requestGeneration || !matchesTarget(current.target)) return;
|
||||
update({
|
||||
...current, busy: false, confirmOverwrite: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}, [matchesTarget, update]);
|
||||
|
||||
return {
|
||||
state: state && matchesTarget(state.target) ? state : null,
|
||||
open, close, setArchiveName, setFormat, submit,
|
||||
};
|
||||
}
|
||||
@@ -393,10 +393,12 @@ export function useSftpFollowTerminalCwd({
|
||||
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd: targetPath };
|
||||
}
|
||||
}, [
|
||||
activeSessionId,
|
||||
activeTerminalCwd,
|
||||
activeTerminalCwdTrusted,
|
||||
canFollowTerminalCwd,
|
||||
effectiveFollowTerminalCwd,
|
||||
focusedSessionId,
|
||||
hasActiveWork,
|
||||
isVisible,
|
||||
onGetTerminalCwd,
|
||||
|
||||
@@ -1326,13 +1326,6 @@ export const useSftpTransfers = ({
|
||||
sourcePath?: string;
|
||||
sourceConnectionId?: string;
|
||||
targetPath?: string;
|
||||
/**
|
||||
* Create copies next to the originals instead of prompting for the
|
||||
* same-name conflict. Each source name is resolved to the next free
|
||||
* `name (copy)`, `name (copy 2)`, ... before the transfer starts.
|
||||
* Used by the SFTP context menu's Duplicate action.
|
||||
*/
|
||||
duplicate?: boolean;
|
||||
onTransferComplete?: (result: TransferResult) => void | Promise<void>;
|
||||
},
|
||||
) => {
|
||||
@@ -1436,10 +1429,6 @@ export const useSftpTransfers = ({
|
||||
}
|
||||
|
||||
for (const file of sourceFiles) {
|
||||
// "." / ".." are navigation rows, not real entries. Duplicating one
|
||||
// would resolve to an ancestor (e.g. "/") and could walk the copy into
|
||||
// its own output.
|
||||
if (options?.duplicate && (file.name === "." || file.name === "..")) continue;
|
||||
const direction: TransferDirection =
|
||||
sourcePane.connection!.isLocal && !targetPane.connection!.isLocal
|
||||
? "upload"
|
||||
@@ -1472,36 +1461,8 @@ export const useSftpTransfers = ({
|
||||
: (freshMetadata?.lastModified ?? fileEntry?.lastModified ?? 0);
|
||||
|
||||
const nextSourcePath = joinPath(sourcePath, file.name);
|
||||
const nextTargetPath = joinPath(targetPath, file.name);
|
||||
const targetIsLocal = targetPane.connection!.isLocal;
|
||||
// A duplicate targets the folder the item already lives in, so the
|
||||
// same-name conflict is guaranteed. Resolve the "(copy)" name up front
|
||||
// instead of routing through the conflict dialog: the one-click action
|
||||
// must not prompt, and resolving it inside processTransfer would
|
||||
// re-enter the same task while it is still marked in flight.
|
||||
const duplicateTarget = options?.duplicate
|
||||
? await getDuplicateTarget(
|
||||
{ fileName: file.name, isDirectory: file.isDirectory, targetPath: joinPath(targetPath, file.name) },
|
||||
targetPane,
|
||||
targetIsLocal ? null : (sftpSessionsRef.current.get(targetPane.connection!.id) ?? null),
|
||||
targetIsLocal ? "auto" : (targetPane.filenameEncoding || "auto"),
|
||||
// A queued or paused copy has not created its destination on disk
|
||||
// yet, so the next duplicate would pick the same name and surface
|
||||
// a conflict dialog. Reserve active destinations while naming.
|
||||
(candidatePath) => Boolean(findActivePathConflict(
|
||||
[...sftpTransferCenterStore.getSnapshot().tasks, ...transfersRef.current, ...newTasks],
|
||||
{
|
||||
id: "",
|
||||
targetPath: candidatePath,
|
||||
targetConnectionId: targetIsLocal ? "local" : targetPane.connection!.id,
|
||||
targetHostId: targetIsLocal ? undefined : targetPane.connection!.hostId,
|
||||
targetHostLabel: targetIsLocal ? "Local" : targetPane.connection!.hostLabel,
|
||||
isDirectory: file.isDirectory,
|
||||
},
|
||||
)),
|
||||
)
|
||||
: null;
|
||||
const nextFileName = duplicateTarget?.fileName ?? file.name;
|
||||
const nextTargetPath = duplicateTarget?.targetPath ?? joinPath(targetPath, file.name);
|
||||
const pathTaken = findActivePathConflict(
|
||||
[...sftpTransferCenterStore.getSnapshot().tasks, ...transfersRef.current, ...newTasks],
|
||||
{
|
||||
@@ -1520,7 +1481,7 @@ export const useSftpTransfers = ({
|
||||
const attentionTask: TransferTask = {
|
||||
id: crypto.randomUUID(),
|
||||
batchId,
|
||||
fileName: nextFileName,
|
||||
fileName: file.name,
|
||||
originalFileName: file.name,
|
||||
sourcePath: nextSourcePath,
|
||||
targetPath: nextTargetPath,
|
||||
@@ -1561,7 +1522,7 @@ export const useSftpTransfers = ({
|
||||
newTasks.push({
|
||||
id: taskId,
|
||||
batchId,
|
||||
fileName: nextFileName,
|
||||
fileName: file.name,
|
||||
originalFileName: file.name,
|
||||
sourcePath: nextSourcePath,
|
||||
targetPath: nextTargetPath,
|
||||
|
||||
@@ -36,12 +36,6 @@ export interface UseSftpTransfersResult {
|
||||
sourcePath?: string;
|
||||
sourceConnectionId?: string;
|
||||
targetPath?: string;
|
||||
/**
|
||||
* Create copies next to the originals instead of prompting for the
|
||||
* same-name conflict. Each source name is resolved to the next free
|
||||
* `name (copy)`, `name (copy 2)`, ... before the transfer starts.
|
||||
*/
|
||||
duplicate?: boolean;
|
||||
onTransferComplete?: (result: TransferResult) => void | Promise<void>;
|
||||
},
|
||||
) => Promise<TransferResult[]>;
|
||||
|
||||
@@ -30,20 +30,3 @@ test('terminalCwdStore bumps version only when cwd or provenance changes', () =>
|
||||
unsubscribe();
|
||||
assert.ok(versions.length >= 2);
|
||||
});
|
||||
|
||||
test('session revisions isolate terminals and retain identical OSC 7 reports without rerendering', () => {
|
||||
const a = 'revision-a'; const b = 'revision-b';
|
||||
terminalCwdStore.setCwd(a, '/same', 'osc7');
|
||||
const revisionA = terminalCwdStore.getSessionRevision(a);
|
||||
terminalCwdStore.setCwd(b, '/elsewhere', 'osc7');
|
||||
assert.equal(terminalCwdStore.getSessionRevision(a), revisionA);
|
||||
const globalVersion = terminalCwdStore.getVersion();
|
||||
assert.equal(terminalCwdStore.setCwd(a, '/same', 'osc7'), false);
|
||||
assert.ok(terminalCwdStore.getSessionRevision(a) > revisionA);
|
||||
assert.equal(terminalCwdStore.getVersion(), globalVersion);
|
||||
const revisionB = terminalCwdStore.getSessionRevision(b);
|
||||
terminalCwdStore.prune(new Set([b]));
|
||||
assert.equal(terminalCwdStore.getSessionRevision(a), 0);
|
||||
assert.equal(terminalCwdStore.getSessionRevision(b), revisionB);
|
||||
terminalCwdStore.prune(new Set());
|
||||
});
|
||||
@@ -10,16 +10,11 @@ export type TerminalCwdSource = "osc7" | "inferred" | "backend-strict" | "backen
|
||||
class TerminalCwdStore {
|
||||
private cwdBySession = new Map<string, string>();
|
||||
private sourceBySession = new Map<string, TerminalCwdSource>();
|
||||
private revisionBySession = new Map<string, number>();
|
||||
private revision = 0;
|
||||
private version = 0;
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
getVersion = (): number => this.version;
|
||||
|
||||
/** Tracks reports for one session, including unchanged authoritative OSC 7. */
|
||||
getSessionRevision = (sessionId: string): number => this.revisionBySession.get(sessionId) ?? 0;
|
||||
|
||||
getCwd = (sessionId: string | null | undefined): string | null => {
|
||||
if (!sessionId) return null;
|
||||
return this.cwdBySession.get(sessionId) ?? null;
|
||||
@@ -44,10 +39,7 @@ class TerminalCwdStore {
|
||||
const nextSource = next
|
||||
? (source ?? (current === next ? currentSource : undefined) ?? "unknown")
|
||||
: undefined;
|
||||
if (current === next && currentSource === nextSource) {
|
||||
if (nextSource === 'osc7') this.revisionBySession.set(sessionId, ++this.revision);
|
||||
return false;
|
||||
}
|
||||
if (current === next && currentSource === nextSource) return false;
|
||||
|
||||
if (next) {
|
||||
this.cwdBySession.set(sessionId, next);
|
||||
@@ -56,7 +48,6 @@ class TerminalCwdStore {
|
||||
this.cwdBySession.delete(sessionId);
|
||||
this.sourceBySession.delete(sessionId);
|
||||
}
|
||||
this.revisionBySession.set(sessionId, ++this.revision);
|
||||
this.version += 1;
|
||||
for (const listener of this.listeners) {
|
||||
listener();
|
||||
@@ -70,13 +61,9 @@ class TerminalCwdStore {
|
||||
if (!validSessionIds.has(sessionId)) {
|
||||
this.cwdBySession.delete(sessionId);
|
||||
this.sourceBySession.delete(sessionId);
|
||||
this.revisionBySession.delete(sessionId);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
for (const id of this.revisionBySession.keys()) {
|
||||
if (!validSessionIds.has(id)) this.revisionBySession.delete(id);
|
||||
}
|
||||
if (!changed) return;
|
||||
this.version += 1;
|
||||
for (const listener of this.listeners) {
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createTerminalDirectoryController } from './terminalDirectoryController';
|
||||
import { terminalCwdStore } from './terminalCwdStore';
|
||||
import type { TerminalDirectoryIO } from '../../infrastructure/services/terminalDirectory';
|
||||
|
||||
function setup(overrides: Partial<TerminalDirectoryIO> = {}) {
|
||||
let cwd = '/old'; let ready = true; let current = true;
|
||||
const writes: string[] = [];
|
||||
const published: string[] = [];
|
||||
const controller = createTerminalDirectoryController({
|
||||
sessionId: 'test-directory', confirmationMs: 10,
|
||||
io: { cwd: async () => cwd, list: async () => ({ entries: [], home: null }), realpath: async path => path,
|
||||
write: data => { writes.push(data); cwd = '/next'; }, ...overrides },
|
||||
isCurrent: () => current, isReady: () => ready,
|
||||
publish: value => { published.push(value); terminalCwdStore.setCwd('test-directory', value, 'backend-strict'); },
|
||||
});
|
||||
return { controller, writes, published, busy: () => { ready = false; }, stale: () => { current = false; } };
|
||||
}
|
||||
|
||||
test('navigation writes one quoted cd and waits for actual cwd before confirming', async () => {
|
||||
const ctx = setup(); const accepted: string[] = [];
|
||||
await ctx.controller.change('/next', new AbortController().signal, () => accepted.push('accepted'));
|
||||
assert.deepEqual(ctx.writes, ["cd -- '/next'\r"]);
|
||||
assert.deepEqual(accepted, ['accepted']);
|
||||
assert.deepEqual(ctx.published, ['/next']);
|
||||
ctx.controller.dispose();
|
||||
});
|
||||
|
||||
test('busy, control-character and stale sessions never receive bytes', async () => {
|
||||
const ctx = setup(); ctx.busy();
|
||||
await assert.rejects(ctx.controller.change('/next', new AbortController().signal, () => {}), { code: 'busy' });
|
||||
const invalid = setup();
|
||||
await assert.rejects(invalid.controller.change('/next\n', new AbortController().signal, () => {}), { code: 'unsupportedPath' });
|
||||
const stale = setup(); stale.stale();
|
||||
await assert.rejects(stale.controller.change('/next', new AbortController().signal, () => {}), { code: 'unavailable' });
|
||||
assert.deepEqual([...ctx.writes, ...invalid.writes, ...stale.writes], []);
|
||||
});
|
||||
|
||||
test('rechecks readiness after async path resolution; double submission is rejected', async () => {
|
||||
let finish!: (path: string) => void;
|
||||
const ctx = setup({ realpath: () => new Promise(resolve => { finish = resolve; }) });
|
||||
const first = ctx.controller.change('/next', new AbortController().signal, () => {});
|
||||
await assert.rejects(ctx.controller.change('/other', new AbortController().signal, () => {}), { code: 'busy' });
|
||||
ctx.busy(); finish('/next');
|
||||
await assert.rejects(first, { code: 'busy' });
|
||||
assert.deepEqual(ctx.writes, []);
|
||||
});
|
||||
|
||||
test('a disposed connection rejects delayed reads and cannot publish into a new generation', async () => {
|
||||
let finish!: (cwd: string) => void;
|
||||
const ctx = setup({ cwd: () => new Promise(resolve => { finish = resolve; }) });
|
||||
const read = ctx.controller.refresh();
|
||||
ctx.controller.dispose(); finish('/stale');
|
||||
await assert.rejects(read, { code: 'unavailable' });
|
||||
assert.deepEqual(ctx.published, []);
|
||||
});
|
||||
|
||||
test('a failed cd keeps the observed cwd and never retries the write', async () => {
|
||||
const ctx = setup({ write: () => {}, cwd: async () => '/old' });
|
||||
await assert.rejects(ctx.controller.change('/missing', new AbortController().signal, () => {}), { code: 'changeFailed' });
|
||||
assert.ok(ctx.published.every(value => value === '/old'));
|
||||
});
|
||||
|
||||
test('directory symlinks confirm against physical cwd', async () => {
|
||||
const ctx = setup({ realpath: async path => path === '/link' ? '/real' : path, cwd: async () => '/real' });
|
||||
await ctx.controller.change('/link', new AbortController().signal, () => {});
|
||||
assert.equal(ctx.published.at(-1), '/real');
|
||||
});
|
||||
|
||||
test('output arriving during an old cwd query retains one fresh follow-up query', async () => {
|
||||
let finish!: (cwd: string) => void;
|
||||
let reads = 0;
|
||||
const ctx = setup({ cwd: () => {
|
||||
reads++;
|
||||
return reads === 1 ? new Promise(resolve => { finish = resolve; }) : Promise.resolve('/after-cd');
|
||||
} });
|
||||
const oldRead = ctx.controller.refresh();
|
||||
const queued = ctx.controller.refresh();
|
||||
finish('/before-cd');
|
||||
await Promise.all([oldRead, queued]);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(reads, 2);
|
||||
assert.equal(ctx.published.at(-1), '/after-cd');
|
||||
ctx.controller.dispose();
|
||||
});
|
||||
|
||||
test('another terminal updating cannot override the current terminal strict cwd result', async t => {
|
||||
const id = 'strict-after-other-session';
|
||||
let finish!: (cwd: string) => void;
|
||||
const published: string[] = [];
|
||||
terminalCwdStore.setCwd(id, '/stale-osc7', 'stale');
|
||||
const controller = createTerminalDirectoryController({
|
||||
sessionId: id, isCurrent: () => true, isReady: () => true,
|
||||
io: { cwd: () => new Promise(resolve => { finish = resolve; }),
|
||||
list: async () => ({ entries: [], home: null }), realpath: async value => value, write() {} },
|
||||
publish: value => { published.push(value); terminalCwdStore.setCwd(id, value, 'backend-strict'); },
|
||||
});
|
||||
t.after(() => controller.dispose());
|
||||
const pending = controller.refresh();
|
||||
terminalCwdStore.setCwd('unrelated-session', '/other', 'osc7');
|
||||
finish('/actual-after-cd');
|
||||
assert.equal(await pending, '/actual-after-cd');
|
||||
assert.deepEqual(published, ['/actual-after-cd']);
|
||||
assert.equal(terminalCwdStore.getCwd(id), '/actual-after-cd');
|
||||
});
|
||||
|
||||
test('another terminal cannot turn this submission pre-write OSC 7 into a fresh report', async t => {
|
||||
const id = 'pre-write-osc7-after-other-session';
|
||||
let finish!: (cwd: string) => void;
|
||||
const published: string[] = [];
|
||||
terminalCwdStore.setCwd(id, '/before', 'osc7');
|
||||
const controller = createTerminalDirectoryController({
|
||||
sessionId: id, confirmationMs: 20, isCurrent: () => true, isReady: () => true,
|
||||
io: { cwd: () => new Promise(resolve => { finish = resolve; }),
|
||||
list: async () => ({ entries: [], home: null }), realpath: async value => value, write() {} },
|
||||
publish: cwd => { published.push(cwd); terminalCwdStore.setCwd(id, cwd, 'backend-strict'); },
|
||||
});
|
||||
t.after(() => controller.dispose());
|
||||
const changing = controller.change('/after', new AbortController().signal, () => {});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
terminalCwdStore.setCwd(id + '-other', '/other', 'osc7');
|
||||
finish('/after');
|
||||
await changing;
|
||||
assert.deepEqual(published, ['/after']);
|
||||
assert.equal(terminalCwdStore.getCwd(id), '/after');
|
||||
});
|
||||
|
||||
for (const nextCwd of ['/new-osc7', '/original']) {
|
||||
test(`a later OSC 7 report from this terminal wins (${nextCwd})`, async t => {
|
||||
const id = 'same-session-osc7-' + nextCwd;
|
||||
let finish!: (cwd: string) => void;
|
||||
const published: string[] = [];
|
||||
terminalCwdStore.setCwd(id, '/original', 'stale');
|
||||
const controller = createTerminalDirectoryController({
|
||||
sessionId: id, isCurrent: () => true, isReady: () => true,
|
||||
io: { cwd: () => new Promise(resolve => { finish = resolve; }),
|
||||
list: async () => ({ entries: [], home: null }), realpath: async value => value, write() {} },
|
||||
publish: value => { published.push(value); },
|
||||
});
|
||||
t.after(() => controller.dispose());
|
||||
const pending = controller.refresh();
|
||||
terminalCwdStore.setCwd(id, nextCwd, 'osc7');
|
||||
finish('/older-backend-result');
|
||||
assert.equal(await pending, nextCwd);
|
||||
assert.deepEqual(published, []);
|
||||
});
|
||||
}
|
||||
|
||||
for (const probe of ['fails', 'stalls'] as const) {
|
||||
test(`a fresh post-write OSC 7 confirms navigation when the strict cwd probe ${probe}`, { timeout: 2000 }, async t => {
|
||||
const id = 'osc7-confirm-' + probe;
|
||||
const writes: string[] = [];
|
||||
terminalCwdStore.setCwd(id, '/old', 'osc7');
|
||||
const controller = createTerminalDirectoryController({
|
||||
sessionId: id, confirmationMs: 1000, isCurrent: () => true, isReady: () => true,
|
||||
io: {
|
||||
cwd: () => probe === 'fails' ? Promise.reject(new Error('no process cwd support')) : new Promise(() => {}),
|
||||
list: async () => ({ entries: [], home: null }), realpath: async value => value,
|
||||
write: data => {
|
||||
writes.push(data);
|
||||
setImmediate(() => terminalCwdStore.setCwd(id, '/next', 'osc7'));
|
||||
},
|
||||
},
|
||||
publish() { assert.fail('an unavailable probe must not publish a guessed cwd'); },
|
||||
});
|
||||
t.after(() => controller.dispose());
|
||||
await controller.change('/next', new AbortController().signal, () => {});
|
||||
assert.deepEqual(writes, ["cd -- '/next'\r"]);
|
||||
assert.equal(controller.hasConfirmedCwd(), true);
|
||||
});
|
||||
}
|
||||
|
||||
test('navigation can probe past its pre-write OSC 7 when the shell stops reporting', async t => {
|
||||
const id = 'probe-after-pre-write-osc7';
|
||||
terminalCwdStore.setCwd(id, '/before', 'osc7');
|
||||
const controller = createTerminalDirectoryController({
|
||||
sessionId: id, isCurrent: () => true, isReady: () => true,
|
||||
io: { cwd: async () => '/after', list: async () => ({ entries: [], home: null }),
|
||||
realpath: async value => value, write() {} },
|
||||
publish: cwd => terminalCwdStore.setCwd(id, cwd, 'backend-strict'),
|
||||
});
|
||||
t.after(() => controller.dispose());
|
||||
await controller.change('/after', new AbortController().signal, () => {});
|
||||
assert.equal(terminalCwdStore.getCwd(id), '/after');
|
||||
assert.equal(terminalCwdStore.getSource(id), 'backend-strict');
|
||||
});
|
||||
|
||||
for (const report of ['stale', 'other-session', 'other-source', 'fresh-unchanged'] as const) {
|
||||
test(`OSC 7 confirmation uses this submission's session revision (${report})`, { timeout: 2000 }, async t => {
|
||||
const id = 'osc7-revision-confirm-' + report;
|
||||
terminalCwdStore.setCwd(id, '/next', 'osc7');
|
||||
const controller = createTerminalDirectoryController({
|
||||
sessionId: id, confirmationMs: 20, isCurrent: () => true, isReady: () => true,
|
||||
io: {
|
||||
cwd: async () => { throw new Error('no process cwd support'); },
|
||||
list: async () => ({ entries: [], home: null }), realpath: async value => value,
|
||||
write() {
|
||||
if (report !== 'stale') setImmediate(() => terminalCwdStore.setCwd(
|
||||
report === 'other-session' ? id + '-other' : id, '/next', report === 'other-source' ? 'inferred' : 'osc7',
|
||||
));
|
||||
},
|
||||
},
|
||||
publish() {},
|
||||
});
|
||||
t.after(() => controller.dispose());
|
||||
const change = controller.change('/next', new AbortController().signal, () => {});
|
||||
if (report === 'fresh-unchanged') await change;
|
||||
else await assert.rejects(change, { code: 'changeFailed' });
|
||||
});
|
||||
}
|
||||
|
||||
for (const cancellation of ['abort', 'dispose'] as const) {
|
||||
test(`a fresh OSC 7 cannot confirm a cancelled directory change (${cancellation})`, { timeout: 2000 }, async t => {
|
||||
const id = 'osc7-cancel-' + cancellation;
|
||||
const abort = new AbortController();
|
||||
const controller = createTerminalDirectoryController({
|
||||
sessionId: id, isCurrent: () => true, isReady: () => true,
|
||||
io: { cwd: () => new Promise(() => {}), list: async () => ({ entries: [], home: null }),
|
||||
realpath: async value => value,
|
||||
write() { setImmediate(() => {
|
||||
if (cancellation === 'abort') abort.abort(); else controller.dispose();
|
||||
terminalCwdStore.setCwd(id, '/next', 'osc7');
|
||||
}); } },
|
||||
publish() {},
|
||||
});
|
||||
t.after(() => controller.dispose());
|
||||
await assert.rejects(controller.change('/next', abort.signal, () => {}), { code: 'unavailable' });
|
||||
});
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
import { DirectoryPickerError, isDirectoryPath } from '../../domain/directoryPicker';
|
||||
import { resolveInteractiveTerminalCdIntent } from '../../domain/sessionRestore';
|
||||
import type { TerminalDirectoryIO } from '../../infrastructure/services/terminalDirectory';
|
||||
import { terminalCwdStore } from './terminalCwdStore';
|
||||
|
||||
export type TerminalDirectoryController = ReturnType<typeof createTerminalDirectoryController>;
|
||||
|
||||
/** One registration represents one connected interactive shell generation. */
|
||||
export function createTerminalDirectoryController(options: {
|
||||
sessionId: string;
|
||||
io: TerminalDirectoryIO;
|
||||
isCurrent: () => boolean;
|
||||
isReady: () => boolean;
|
||||
publish: (cwd: string) => void;
|
||||
confirmationMs?: number;
|
||||
}) {
|
||||
let disposed = false;
|
||||
let changing = false;
|
||||
let confirmed = false;
|
||||
let refreshing: Promise<string> | null = null;
|
||||
let refreshAgain = false;
|
||||
const lifetime = new AbortController();
|
||||
const current = () => !disposed && options.isCurrent();
|
||||
function check(signal?: AbortSignal) {
|
||||
if (!current() || signal?.aborted) throw new DirectoryPickerError('unavailable');
|
||||
}
|
||||
function ready() {
|
||||
check();
|
||||
if (!options.isReady()) throw new DirectoryPickerError('busy');
|
||||
}
|
||||
function bounded<T>(task: Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const finish = (error?: unknown, value?: T) => {
|
||||
clearTimeout(timer);
|
||||
lifetime.signal.removeEventListener('abort', cancel);
|
||||
signal?.removeEventListener('abort', cancel);
|
||||
if (error) reject(error); else resolve(value!);
|
||||
};
|
||||
const cancel = () => finish(new DirectoryPickerError('unavailable'));
|
||||
const timer = setTimeout(() => finish(new DirectoryPickerError('readFailed')), 5000);
|
||||
lifetime.signal.addEventListener('abort', cancel, { once: true });
|
||||
signal?.addEventListener('abort', cancel, { once: true });
|
||||
if (signal?.aborted || lifetime.signal.aborted) cancel();
|
||||
task.then(value => finish(undefined, value), error => finish(error));
|
||||
});
|
||||
}
|
||||
async function refresh(submittedRevision?: number): Promise<string> {
|
||||
check();
|
||||
const activeShellCwd = () => {
|
||||
const cwd = terminalCwdStore.getCwd(options.sessionId);
|
||||
return cwd && terminalCwdStore.getSource(options.sessionId) === 'osc7'
|
||||
&& (submittedRevision === undefined || terminalCwdStore.getSessionRevision(options.sessionId) !== submittedRevision)
|
||||
? cwd : null;
|
||||
};
|
||||
// A process probe may read the outer PTY shell while a nested shell is
|
||||
// active. OSC 7 remains authoritative across debounce delays, completion
|
||||
// events and picker remounts. Only a cd submission may probe past its own
|
||||
// pre-write report while waiting for fresh confirmation.
|
||||
const reported = activeShellCwd();
|
||||
if (reported) { confirmed = true; return reported; }
|
||||
if (refreshing) {
|
||||
// Output may settle while a pre-cd query is still in flight. Keep one
|
||||
// follow-up query so that its older result cannot be the last update.
|
||||
refreshAgain = true;
|
||||
return refreshing;
|
||||
}
|
||||
const pending = (async () => {
|
||||
const cwd = await bounded(options.io.cwd());
|
||||
check();
|
||||
const reported = activeShellCwd();
|
||||
const live = terminalCwdStore.getCwd(options.sessionId);
|
||||
confirmed = true;
|
||||
if (reported) return reported;
|
||||
if (live !== cwd || terminalCwdStore.getSource(options.sessionId) === 'stale') options.publish(cwd);
|
||||
return cwd;
|
||||
})();
|
||||
refreshing = pending;
|
||||
try { return await pending; } finally {
|
||||
if (refreshing === pending) refreshing = null;
|
||||
if (refreshAgain && current()) {
|
||||
refreshAgain = false;
|
||||
void refresh().catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
async function change(path: string, signal: AbortSignal, accepted: () => void, expectedCwd?: string) {
|
||||
ready();
|
||||
if (changing) throw new DirectoryPickerError('busy');
|
||||
const intent = isDirectoryPath(path) ? resolveInteractiveTerminalCdIntent(path) : null;
|
||||
if (!intent) throw new DirectoryPickerError('unsupportedPath');
|
||||
changing = true;
|
||||
let confirmationFinished = false;
|
||||
try {
|
||||
const canonical = await bounded(options.io.realpath(path, signal), signal);
|
||||
check(signal);
|
||||
ready(); // The shell can become busy while resolving a remote symlink.
|
||||
if (expectedCwd && terminalCwdStore.getCwd(options.sessionId) !== expectedCwd) throw new DirectoryPickerError('unavailable');
|
||||
const submittedRevision = terminalCwdStore.getSessionRevision(options.sessionId);
|
||||
options.io.write(intent.command + '\r');
|
||||
accepted();
|
||||
const deadline = Date.now() + (options.confirmationMs ?? 8000);
|
||||
const hasFreshOsc7 = () => {
|
||||
const cwd = terminalCwdStore.getCwd(options.sessionId);
|
||||
return terminalCwdStore.getSessionRevision(options.sessionId) !== submittedRevision
|
||||
&& terminalCwdStore.getSource(options.sessionId) === 'osc7'
|
||||
&& (cwd === path || cwd === canonical);
|
||||
};
|
||||
const pause = () => new Promise<void>(resolve => {
|
||||
const done = () => { clearTimeout(timer); lifetime.signal.removeEventListener('abort', done); resolve(); };
|
||||
const timer = setTimeout(done, 200);
|
||||
lifetime.signal.addEventListener('abort', done, { once: true });
|
||||
});
|
||||
const confirmFromProbe = async () => {
|
||||
while (!confirmationFinished && Date.now() < deadline) {
|
||||
check(signal);
|
||||
if (hasFreshOsc7()) return;
|
||||
if (options.isReady()) {
|
||||
try {
|
||||
const cwd = await refresh(submittedRevision);
|
||||
check(signal);
|
||||
if (cwd === path || cwd === canonical || await bounded(options.io.realpath(cwd, signal), signal) === canonical) return;
|
||||
} catch (error) {
|
||||
if (!current() || signal.aborted) throw error;
|
||||
}
|
||||
}
|
||||
await pause();
|
||||
}
|
||||
if (!confirmationFinished) throw new DirectoryPickerError('changeFailed');
|
||||
};
|
||||
// OSC 7 is authoritative even when process cwd lookup is unavailable or
|
||||
// stalled. Poll revisions too: an unchanged OSC 7 path emits no store event.
|
||||
const confirmFromOsc7 = async () => {
|
||||
while (!confirmationFinished) {
|
||||
check(signal);
|
||||
if (hasFreshOsc7()) return;
|
||||
if (Date.now() >= deadline) throw new DirectoryPickerError('changeFailed');
|
||||
await pause();
|
||||
}
|
||||
};
|
||||
await Promise.race([confirmFromProbe(), confirmFromOsc7()]);
|
||||
check(signal);
|
||||
confirmed = true;
|
||||
} finally { confirmationFinished = true; changing = false; }
|
||||
}
|
||||
return {
|
||||
refresh: () => refresh(),
|
||||
hasConfirmedCwd: () => confirmed,
|
||||
async home() {
|
||||
check();
|
||||
const result = await bounded(options.io.home?.() ?? Promise.resolve(null));
|
||||
check(); return result;
|
||||
},
|
||||
async list(path: string, signal: AbortSignal) {
|
||||
check(signal);
|
||||
const result = await bounded(options.io.list(path, signal), signal);
|
||||
check(signal);
|
||||
return result;
|
||||
},
|
||||
change,
|
||||
dispose() { disposed = true; lifetime.abort(); },
|
||||
};
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createTerminalDirectoryController } from './terminalDirectoryController';
|
||||
import { terminalCwdStore } from './terminalCwdStore';
|
||||
import {
|
||||
observeTerminalDirectory, registerTerminalDirectory,
|
||||
refreshObservedTerminalDirectory, scheduleObservedTerminalDirectoryRefresh,
|
||||
} from './terminalDirectoryRegistry';
|
||||
|
||||
test('manual terminal output refreshes cwd without a trusted command-completion event', async t => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let cwd = '/before'; let reads = 0;
|
||||
const published: string[] = [];
|
||||
const controller = createTerminalDirectoryController({
|
||||
sessionId: 'unrecognized-prompt', isCurrent: () => true, isReady: () => false,
|
||||
io: { cwd: async () => { reads++; return cwd; },
|
||||
list: async () => ({ entries: [], home: null }), realpath: async value => value, write() {} },
|
||||
publish: value => { published.push(value); },
|
||||
});
|
||||
const unregister = registerTerminalDirectory('unrecognized-prompt', controller);
|
||||
const stopObserving = observeTerminalDirectory('unrecognized-prompt');
|
||||
t.after(() => { stopObserving(); unregister(); });
|
||||
cwd = '/after-manual-cd';
|
||||
// Multiple prompt/output chunks produce just one read. No trusted prompt
|
||||
// detection, command-completed callback, or parsed `cd` is involved.
|
||||
scheduleObservedTerminalDirectoryRefresh('unrecognized-prompt');
|
||||
t.mock.timers.tick(80);
|
||||
scheduleObservedTerminalDirectoryRefresh('unrecognized-prompt');
|
||||
t.mock.timers.tick(149);
|
||||
assert.equal(reads, 0);
|
||||
t.mock.timers.tick(1);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.deepEqual(published, ['/after-manual-cd']);
|
||||
assert.equal(reads, 1);
|
||||
t.mock.timers.tick(60_000);
|
||||
assert.equal(reads, 1, 'no polling after output has settled');
|
||||
});
|
||||
|
||||
test('settled output and prompt completion preserve the active nested shell OSC 7 cwd', async t => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const id = 'nested-shell-output';
|
||||
const published: string[] = [];
|
||||
const controller = createTerminalDirectoryController({
|
||||
sessionId: id, isCurrent: () => true, isReady: () => true,
|
||||
// The PTY process stays in its original directory while a child shell
|
||||
// runs in /. Its cwd is only a fallback for shells without OSC 7.
|
||||
io: { cwd: async () => '/outer-shell', list: async () => ({ entries: [], home: null }),
|
||||
realpath: async value => value, write() {} },
|
||||
publish: cwd => { published.push(cwd); terminalCwdStore.setCwd(id, cwd, 'backend-strict'); },
|
||||
});
|
||||
const unregister = registerTerminalDirectory(id, controller);
|
||||
const stopObserving = observeTerminalDirectory(id);
|
||||
t.after(() => { stopObserving(); unregister(); });
|
||||
scheduleObservedTerminalDirectoryRefresh(id);
|
||||
terminalCwdStore.setCwd(id, '/', 'osc7');
|
||||
t.mock.timers.tick(150);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(terminalCwdStore.getCwd(id), '/');
|
||||
// Later output chunks, prompt completion and reopening/manual refresh must
|
||||
// not treat that same active-shell report as stale merely due to timing.
|
||||
scheduleObservedTerminalDirectoryRefresh(id);
|
||||
t.mock.timers.tick(150);
|
||||
refreshObservedTerminalDirectory(id);
|
||||
assert.equal(await controller.refresh(), '/');
|
||||
assert.deepEqual(published, []);
|
||||
assert.equal(terminalCwdStore.getSource(id), 'osc7');
|
||||
});
|
||||
|
||||
test('disabling chips cancels a queued output refresh and ignores future terminal output', async t => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let reads = 0;
|
||||
const controller = createTerminalDirectoryController({
|
||||
sessionId: 'hidden-chips', isCurrent: () => true, isReady: () => true,
|
||||
io: { cwd: async () => { reads++; return '/unused'; }, list: async () => ({ entries: [], home: null }),
|
||||
realpath: async value => value, write() {} }, publish() {},
|
||||
});
|
||||
const unregister = registerTerminalDirectory('hidden-chips', controller);
|
||||
t.after(unregister);
|
||||
const stopObserving = observeTerminalDirectory('hidden-chips');
|
||||
scheduleObservedTerminalDirectoryRefresh('hidden-chips');
|
||||
stopObserving();
|
||||
t.mock.timers.tick(1000);
|
||||
scheduleObservedTerminalDirectoryRefresh('hidden-chips');
|
||||
t.mock.timers.tick(1000);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(reads, 0);
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
import type { TerminalDirectoryController } from './terminalDirectoryController';
|
||||
|
||||
const sessions = new Map<string, TerminalDirectoryController>();
|
||||
const consumers = new Map<string, number>();
|
||||
const scheduledRefreshes = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const OUTPUT_SETTLE_MS = 150;
|
||||
|
||||
function cancelScheduledRefresh(id: string) {
|
||||
const timer = scheduledRefreshes.get(id);
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
scheduledRefreshes.delete(id);
|
||||
}
|
||||
const listeners = new Set<() => void>();
|
||||
export const subscribeTerminalDirectories = (listener: () => void) => {
|
||||
listeners.add(listener);
|
||||
return () => { listeners.delete(listener); };
|
||||
};
|
||||
export const getTerminalDirectory = (id: string) => sessions.get(id) ?? null;
|
||||
export function registerTerminalDirectory(id: string, controller: TerminalDirectoryController) {
|
||||
cancelScheduledRefresh(id);
|
||||
sessions.set(id, controller);
|
||||
listeners.forEach(listener => listener());
|
||||
return () => {
|
||||
controller.dispose();
|
||||
if (sessions.get(id) !== controller) return;
|
||||
cancelScheduledRefresh(id);
|
||||
sessions.delete(id);
|
||||
listeners.forEach(listener => listener());
|
||||
};
|
||||
}
|
||||
export function observeTerminalDirectory(id: string) {
|
||||
consumers.set(id, (consumers.get(id) ?? 0) + 1);
|
||||
return () => {
|
||||
const next = (consumers.get(id) ?? 1) - 1;
|
||||
if (next) consumers.set(id, next);
|
||||
else { consumers.delete(id); cancelScheduledRefresh(id); }
|
||||
};
|
||||
}
|
||||
/** Called by the existing command completion path, only while chips are visible. */
|
||||
export function refreshObservedTerminalDirectory(id: string) {
|
||||
cancelScheduledRefresh(id);
|
||||
if (consumers.has(id)) void sessions.get(id)?.refresh().catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shells with custom prompts may emit neither OSC 7/133 nor a trusted command
|
||||
* completion. Debounce actual output/submission activity instead of polling or
|
||||
* interpreting `cd` text. The controller prefers active-shell OSC 7 and uses
|
||||
* the bound shell's process cwd only as a fallback.
|
||||
*/
|
||||
export function scheduleObservedTerminalDirectoryRefresh(id: string) {
|
||||
if (!consumers.has(id) || !sessions.has(id)) return;
|
||||
cancelScheduledRefresh(id);
|
||||
scheduledRefreshes.set(id, setTimeout(() => {
|
||||
scheduledRefreshes.delete(id);
|
||||
refreshObservedTerminalDirectory(id);
|
||||
}, OUTPUT_SETTLE_MS));
|
||||
}
|
||||
Loaded 100 of 702 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user