Compare commits

..
Author SHA1 Message Date
陈奇 6e4465f47a Revert "feat(plugins): share borderless installed settings rows"
This reverts commit e4eb33856b.
2026-10-27 13:04:03 +00:00
陈奇 530ff03180 Revert "fix(plugins): restore subtle icon outlines in installed rows"
This reverts commit 07b141725f.
2026-10-27 13:04:03 +00:00
陈奇 07b141725f fix(plugins): restore subtle icon outlines in installed rows
Apply the exact three-file correction supplied by implement_plugin_row_layout against e4eb33856. Patch SHA-256: 4e1a5d95774a16d84214792549f63f6fa2bf15d4406f6e2870e6abc6c635eed4. Keep the row open; restore a token-based rounded 48px outline with a 24px inner icon. Prior e4 screenshots remain pre-correction evidence.
2026-10-27 12:57:35 +00:00
818 changed files with 7244 additions and 46777 deletions

No files matched your search

-3
View File
@@ -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/
-5
View File
@@ -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.
-3
View File
@@ -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 />
-1
View File
@@ -141,7 +141,6 @@ Catty Agent にサーバーの健全性チェックを依頼すると、適切
### 📁 SFTP + 内蔵エディタ
- **ファイル作業** — ドラッグ&ドロップでアップロード/ダウンロード
- **その場で編集** — 内蔵エディタで小さな修正を素早く
- **リモート圧縮** — ファイルやフォルダーを1つ以上選び、右クリックの **Compress...** から、表示された保存先に名前を指定して `.tar.gz` または `.zip` アーカイブを作成できます。サーバーには選択した形式に対応する `tar`/`gzip` または `zip` が必要です。
### 🎨 パーソナライズ
- **カスタムテーマ** — UI の見た目を好みに調整
-1
View File
@@ -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
-1
View File
@@ -141,7 +141,6 @@
### 📁 SFTP + 内置编辑器
- **文件工作流** —— 拖拽上传/下载更直观
- **就地编辑** —— 内置编辑器快速修改文件
- **远程压缩** —— 单选或多选文件、文件夹,右键选择“压缩…”,在对话框显示的保存目录生成指定名称的 `.tar.gz` 或 `.zip` 压缩包;服务器需安装对应的 `tar`/`gzip` 或 `zip`。
### 🎨 个性化
- **自定义主题** —— 按喜好调整应用外观
-1
View File
@@ -141,7 +141,6 @@
### 📁 SFTP + 內建編輯器
- **檔案工作流** —— 拖放上傳/下載
- **就地編輯** —— 內建編輯器,快速修改檔案
- **遠端壓縮** —— 單選或多選檔案、資料夾,按右鍵選擇「壓縮…」,在對話方塊顯示的儲存目錄建立指定名稱的 `.tar.gz` 或 `.zip` 壓縮檔;伺服器需安裝對應的 `tar`/`gzip` 或 `zip`。
### 🎨 個人化
- **自訂主題** —— 依你的喜好調整外觀
+20 -19
View File
@@ -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');
});
+17 -14
View File
@@ -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('');
}
}
}
+5 -15
View File
@@ -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
+2 -11
View File
@@ -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\)/);
});
+20 -46
View File
@@ -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 -57
View File
@@ -27,7 +27,6 @@ import { AppHostTreeLayer } from './AppHostTreeLayer';
import { AppHostEditorLayer } from './AppHostEditorLayer';
import { AppPluginKeybindingHost } from './AppPluginKeybindingHost';
import { PluginAIContextHost } from '../../components/plugins/PluginAIContextHost';
import { PluginAgentHost } from '../../components/plugins/PluginAgentHost';
import { shouldOpenHostEditOnWorkSurface } from './workTabSurface';
import { useConnectionLogsStore } from '../state/connectionLogsStore';
import {
@@ -46,7 +45,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 +52,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 +62,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 +241,12 @@ function AppViewInner({ domains }: AppViewProps) {
setIsCreateWorkspaceOpen,
setIsQuickSwitcherOpen,
setProtocolSelectHost,
setQuickSearch,
} = ctx;
const dismissTransientOverlays = useCallback(() => {
setIsQuickSwitcherOpen(false);
setQuickSearch('');
setIsCreateWorkspaceOpen(false);
setProtocolSelectHost(null);
setAddToWorkspaceDialog(null);
@@ -259,12 +259,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 +276,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 +505,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 +582,7 @@ function AppViewInner({ domains }: AppViewProps) {
onClosePluginViewTab={closePluginViewTab}
onRequestCloseEditorTab={handleRequestCloseEditorTab}
onPopOutEditorTab={handlePopOutEditorTab}
onFocusEditorWindowTab={focusEditorWindowTab}
onFocusEditorWindowTab={(id) => { void focusEditorInWindow(id); }}
hostById={hostById}
/>
@@ -774,14 +765,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 +846,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>
@@ -869,7 +858,6 @@ function AppViewInner({ domains }: AppViewProps) {
workspaces={workspaces}
/>
<PluginAIContextHost sessions={sessions} workspaces={workspaces} />
<PluginAgentHost sessions={sessions} />
</div>
{/* Global "quick add / edit snippet" modal, triggered by the
@@ -947,39 +935,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
-48
View File
@@ -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);
});
-45
View File
@@ -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);
}
});
-31
View File
@@ -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);
}
+3
View File
@@ -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
+29 -3
View File
@@ -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(() => {
-2
View File
@@ -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'));
});
+13 -20
View File
@@ -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(),
-22
View File
@@ -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;
+12 -46
View File
@@ -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>;
};
-54
View File
@@ -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',
@@ -526,8 +525,6 @@ export const enAiMessages: Messages = {
'ai.chat.alwaysAllow': 'Always',
'ai.chat.slashStopDesc': 'Stop the current AI turn and cancel in-flight tools',
'ai.chat.slashCompactDesc': 'Summarize earlier conversation context',
'ai.chat.slashRenameDesc': 'Rename the current chat (/rename <title>)',
'ai.chat.slashChatsDesc': 'List the chats visible to this panel',
'ai.chat.reject': 'Reject',
'ai.chat.toolLabel': 'Tool',
'ai.chat.targetLabel': 'Target',
@@ -590,28 +587,6 @@ export const enAiMessages: Messages = {
'ai.chat.recent': 'Recent',
'ai.chat.viewAll': 'View All',
'ai.chat.untitled': 'Untitled',
'ai.chat.renameChat': 'Rename chat',
'ai.chat.renameUsage': 'Usage: /rename <new title>',
'ai.chat.renameNoActive': 'No active chat to rename.',
'ai.chat.renameDone': 'Chat renamed to "{title}".',
'ai.chat.exportSessions': 'Export AI chats',
'ai.chat.importSessions': 'Import AI chats',
'ai.chat.exportDone': 'AI chats exported to {path}.',
'ai.chat.exportDoneNoPath': 'AI chats exported.',
'ai.chat.exportUnavailable': 'AI chat export is unavailable in this environment.',
'ai.chat.importDone': 'Imported {n} AI chat(s).',
'ai.chat.importNone': 'No AI chats found in the selected file.',
'ai.chat.chatsHeader': 'Chats visible in this panel:',
'ai.chat.chatsEmpty': 'No chats visible in this panel.',
'ai.chat.chatsScopeWorkspace': 'Workspace',
'ai.chat.chatsScopeTerminal': 'Terminal',
'ai.chat.chatsScopeGlobal': 'Global',
'ai.chat.chatsScopeDangling': 'Dissociated',
'ai.chat.slashRebindDesc': 'Rebind a disconnected chat to this panel (/rebind <n>)',
'ai.chat.chatsRebindHint': 'Disconnected chats can be restored with /rebind <n>.',
'ai.chat.rebindUsage': 'Usage: /rebind <number from /chats>',
'ai.chat.rebindDone': 'Chat "{title}" rebound to this panel.',
'ai.chat.rebindUnavailable': 'No rebind target in this panel scope.',
'ai.chat.justNow': 'Just now',
'ai.chat.minutesAgo': '{n}m ago',
'ai.chat.hoursAgo': '{n}h ago',
@@ -661,10 +636,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 +691,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',
@@ -826,27 +795,4 @@ export const enAiMessages: Messages = {
'zmodem.overwrite.skip': 'Skip',
'zmodem.overwrite.cancel': 'Cancel',
'settings.shortcuts.resetToDefault': 'Reset to default',
"ai.toolSettings.immediate": "Changes take effect immediately. Closing this window does not undo them.",
"ai.toolSettings.privacyOn": "Text redaction enabled",
"ai.toolSettings.privacyOff": "Text redaction disabled",
"ai.toolSettings.ruleSummary": "{cidrs} additional IP ranges · {patterns} custom patterns",
"ai.toolSettings.configurePrivacy": "Configure privacy",
"ai.toolSettings.unsaved": "Unsaved changes",
"ai.toolSettings.viewSkills": "View CLI guidance",
"ai.toolSettings.viewPrompt": "View setup prompt",
"ai.toolSettings.behavior": "MCP behavior",
"ai.toolSettings.configureBehavior": "Configure behavior",
"ai.toolSettings.configureClients": "Configure clients",
"ai.toolSettings.httpOn": "HTTP enabled",
"ai.toolSettings.httpOff": "HTTP disabled",
"ai.toolSettings.loopback": "Local connections only (127.0.0.1)",
"ai.toolSettings.configureHttp": "Configure HTTP",
"ai.toolSettings.hideSecret": "Hide secret",
"ai.toolSettings.showSecret": "Show secret",
"ai.toolSettings.manualSetup": "Manual setup",
"ai.toolSettings.format": "Format",
"ai.toolSettings.connectionDetails": "Connection details",
"ai.toolSettings.conflict": "Settings changed in another window. Your draft is preserved. Cancel and reopen to load the saved settings.",
"ai.toolSettings.retry": "Retry",
"ai.toolSettings.applyingHttp": "Applying HTTP configuration...",
};
+1 -50
View File
@@ -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}.",
@@ -199,12 +161,7 @@ export const enCoreMessages: Messages = {
'settings.system.clearTempFiles': 'Clear temp files',
'settings.system.clearing': 'Clearing...',
'settings.system.clearResult': 'Deleted {deleted} file(s), {failed} failed.',
'settings.system.tempDirChoose': 'Choose folder',
'settings.system.tempDirManual': 'Enter an absolute folder path',
'settings.system.tempDirApply': 'Apply',
'settings.system.tempDirReset': 'Reset to default',
'settings.system.tempDirError': 'Unable to use this folder.',
'settings.system.tempDirectoryHint': 'Netcatty creates a Netcatty temp subfolder in the chosen folder for SFTP staging and external editing. Changing the folder during a transfer may affect that transfer.',
'settings.system.tempDirectoryHint': 'Temporary files are created when opening remote files with external applications. They are automatically cleaned up when SFTP sessions close.',
'settings.system.credentials.title': 'Credential Protection',
'settings.system.credentials.status': 'Status',
'settings.system.credentials.checking': 'Checking...',
@@ -625,8 +582,6 @@ Highlight the focused split pane:
'settings.appearance.uiFont.desc': 'Choose the font for the application interface',
'settings.appearance.windowOpacity': 'Window Opacity',
'settings.appearance.windowOpacity.desc': 'Adjust the transparency of the entire application window. Lower values also fade terminal text. Some Linux desktop environments may not support this.',
'settings.appearance.windowBackgroundBlur': 'Frosted glass background',
'settings.appearance.windowBackgroundBlur.desc': 'Blur the desktop behind the window. At 100% opacity the effect is hidden, but your choice is preserved.',
'settings.appearance.appIcon': 'App Icon',
'settings.appearance.appIcon.desc': 'Choose a dock/taskbar icon style to make Netcatty easier to spot when switching apps. On Windows, the notification area icon uses the same style. Applies at runtime; the installed app bundle icon is unchanged.',
'settings.appearance.appIcon.original': 'Original',
@@ -1418,9 +1373,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 +1391,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.',
-4
View File
@@ -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",
-20
View File
@@ -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.',
-19
View File
@@ -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',
-54
View File
@@ -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',
@@ -526,8 +525,6 @@ export const esAiMessages: Messages = {
'ai.chat.alwaysAllow': 'Siempre',
'ai.chat.slashStopDesc': 'Detener el turno actual de IA y cancelar las herramientas en curso',
'ai.chat.slashCompactDesc': 'Resumir el contexto anterior de la conversación',
'ai.chat.slashRenameDesc': 'Renombrar el chat actual (/rename <título>)',
'ai.chat.slashChatsDesc': 'Listar los chats visibles en este panel',
'ai.chat.reject': 'Rechazar',
'ai.chat.toolLabel': 'Herramienta',
'ai.chat.targetLabel': 'Destino',
@@ -590,28 +587,6 @@ export const esAiMessages: Messages = {
'ai.chat.recent': 'Recientes',
'ai.chat.viewAll': 'Ver todo',
'ai.chat.untitled': 'Sin título',
'ai.chat.renameChat': 'Renombrar chat',
'ai.chat.renameUsage': 'Uso: /rename <nuevo título>',
'ai.chat.renameNoActive': 'No hay ningún chat activo para renombrar.',
'ai.chat.renameDone': 'Chat renombrado a «{title}».',
'ai.chat.exportSessions': 'Exportar chats de IA',
'ai.chat.importSessions': 'Importar chats de IA',
'ai.chat.exportDone': 'Chats de IA exportados a {path}.',
'ai.chat.exportDoneNoPath': 'Chats de IA exportados.',
'ai.chat.exportUnavailable': 'La exportación de chats de IA no está disponible en este entorno.',
'ai.chat.importDone': 'Se importaron {n} chat(s) de IA.',
'ai.chat.importNone': 'No se encontraron chats de IA en el archivo seleccionado.',
'ai.chat.chatsHeader': 'Chats visibles en este panel:',
'ai.chat.chatsEmpty': 'No hay chats visibles en este panel.',
'ai.chat.chatsScopeWorkspace': 'Workspace',
'ai.chat.chatsScopeTerminal': 'Terminal',
'ai.chat.chatsScopeGlobal': 'Global',
'ai.chat.chatsScopeDangling': 'Desasociado',
'ai.chat.slashRebindDesc': 'Reasociar un chat desconectado a este panel (/rebind <n>)',
'ai.chat.chatsRebindHint': 'Los chats desconectados se pueden restaurar con /rebind <n>.',
'ai.chat.rebindUsage': 'Uso: /rebind <número de /chats>',
'ai.chat.rebindDone': 'Chat «{title}» reasociado a este panel.',
'ai.chat.rebindUnavailable': 'No hay destino de reasociación en el ámbito de este panel.',
'ai.chat.justNow': 'Ahora mismo',
'ai.chat.minutesAgo': 'hace {n} min',
'ai.chat.hoursAgo': 'hace {n} h',
@@ -661,10 +636,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 +691,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',
@@ -826,27 +795,4 @@ export const esAiMessages: Messages = {
'zmodem.overwrite.skip': 'Omitir',
'zmodem.overwrite.cancel': 'Cancelar',
'settings.shortcuts.resetToDefault': 'Restablecer al predeterminado',
"ai.toolSettings.immediate": "Los cambios se aplican inmediatamente. Cerrar esta ventana no los deshace.",
"ai.toolSettings.privacyOn": "Ocultación de texto activada",
"ai.toolSettings.privacyOff": "Ocultación de texto desactivada",
"ai.toolSettings.ruleSummary": "{cidrs} rangos IP adicionales · {patterns} patrones personalizados",
"ai.toolSettings.configurePrivacy": "Configurar privacidad",
"ai.toolSettings.unsaved": "Cambios sin guardar",
"ai.toolSettings.viewSkills": "Ver instrucciones de CLI",
"ai.toolSettings.viewPrompt": "Ver instrucciones de conexión",
"ai.toolSettings.behavior": "Comportamiento de MCP",
"ai.toolSettings.configureBehavior": "Configurar comportamiento",
"ai.toolSettings.configureClients": "Configurar clientes",
"ai.toolSettings.httpOn": "HTTP activado",
"ai.toolSettings.httpOff": "HTTP desactivado",
"ai.toolSettings.loopback": "Solo conexiones locales (127.0.0.1)",
"ai.toolSettings.configureHttp": "Configurar HTTP",
"ai.toolSettings.hideSecret": "Ocultar secreto",
"ai.toolSettings.showSecret": "Mostrar secreto",
"ai.toolSettings.manualSetup": "Configuración manual",
"ai.toolSettings.format": "Formato",
"ai.toolSettings.connectionDetails": "Detalles de conexión",
"ai.toolSettings.conflict": "La configuración cambió en otra ventana. Se conserva el borrador. Cancela y vuelve a abrir para cargar la configuración guardada.",
"ai.toolSettings.retry": "Reintentar",
"ai.toolSettings.applyingHttp": "Aplicando la configuración HTTP...",
};
+1 -50
View File
@@ -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}.",
@@ -199,12 +161,7 @@ export const esCoreMessages: Messages = {
'settings.system.clearTempFiles': 'Limpiar archivos temporales',
'settings.system.clearing': 'Limpiando...',
'settings.system.clearResult': 'Se eliminaron {deleted} archivo(s), {failed} fallaron.',
'settings.system.tempDirChoose': 'Elegir carpeta',
'settings.system.tempDirManual': 'Introduce una ruta de carpeta absoluta',
'settings.system.tempDirApply': 'Aplicar',
'settings.system.tempDirReset': 'Restablecer predeterminado',
'settings.system.tempDirError': 'No se puede usar esta carpeta.',
'settings.system.tempDirectoryHint': 'Se usa para el almacenamiento temporal de transferencias SFTP y archivos abiertos con aplicaciones externas. Cambiar la carpeta durante una transferencia puede afectar esa transferencia.',
'settings.system.tempDirectoryHint': 'Los archivos temporales se crean al abrir archivos remotos con aplicaciones externas. Se limpian automáticamente cuando las sesiones SFTP se cierran.',
'settings.system.credentials.title': 'Protección de credenciales',
'settings.system.credentials.status': 'Estado',
'settings.system.credentials.checking': 'Comprobando...',
@@ -625,8 +582,6 @@ Resalta el panel dividido enfocado:
'settings.appearance.uiFont.desc': 'Elige la fuente para la interfaz de la aplicación',
'settings.appearance.windowOpacity': 'Opacidad de la ventana',
'settings.appearance.windowOpacity.desc': 'Ajusta la transparencia de toda la ventana de la aplicación. Los valores más bajos también atenúan el texto de la terminal. Algunos entornos de escritorio de Linux pueden no admitir esto.',
'settings.appearance.windowBackgroundBlur': 'Fondo de cristal esmerilado',
'settings.appearance.windowBackgroundBlur.desc': 'Desenfoca el escritorio detrás de la ventana. Con una opacidad del 100 %, el efecto no es visible, pero se conserva tu elección.',
'settings.appearance.appIcon': 'Ícono de la aplicación',
'settings.appearance.appIcon.desc': 'Elige un estilo de ícono para el dock o la barra de tareas que haga más fácil distinguir Netcatty al cambiar de aplicación. En Windows, el icono del área de notificación usa el mismo estilo. Se aplica en tiempo de ejecución; el ícono del paquete instalado no cambia.',
'settings.appearance.appIcon.original': 'Original',
@@ -1418,9 +1373,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 +1391,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.',
-4
View File
@@ -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",
-20
View File
@@ -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.',
-19
View File
@@ -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');
});
-54
View File
@@ -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',
@@ -465,8 +464,6 @@ export const ruAiMessages: Messages = {
'ai.chat.alwaysAllow': 'Всегда разрешать',
'ai.chat.slashStopDesc': 'Остановить текущий ход AI и отменить выполняющиеся инструменты',
'ai.chat.slashCompactDesc': 'Сжать ранний контекст разговора',
'ai.chat.slashRenameDesc': 'Переименовать текущий чат (/rename <название>)',
'ai.chat.slashChatsDesc': 'Показать чаты, видимые в этой панели',
'ai.chat.reject': 'Отклонить',
'ai.chat.toolLabel': 'Инструмент',
'ai.chat.targetLabel': 'Цель',
@@ -529,28 +526,6 @@ export const ruAiMessages: Messages = {
'ai.chat.recent': 'Недавние',
'ai.chat.viewAll': 'Показать всё',
'ai.chat.untitled': 'Без названия',
'ai.chat.renameChat': 'Переименовать чат',
'ai.chat.renameUsage': 'Использование: /rename <новое название>',
'ai.chat.renameNoActive': 'Нет активного чата для переименования.',
'ai.chat.renameDone': 'Чат переименован в «{title}».',
'ai.chat.exportSessions': 'Экспортировать чаты ИИ',
'ai.chat.importSessions': 'Импортировать чаты ИИ',
'ai.chat.exportDone': 'Чаты ИИ экспортированы в {path}.',
'ai.chat.exportDoneNoPath': 'Чаты ИИ экспортированы.',
'ai.chat.exportUnavailable': 'Экспорт чатов ИИ недоступен в этой среде.',
'ai.chat.importDone': 'Импортировано чатов ИИ: {n}.',
'ai.chat.importNone': 'В выбранном файле не найдено чатов ИИ.',
'ai.chat.chatsHeader': 'Чаты, видимые в этой панели:',
'ai.chat.chatsEmpty': 'В этой панели нет видимых чатов.',
'ai.chat.chatsScopeWorkspace': 'Workspace',
'ai.chat.chatsScopeTerminal': 'Терминал',
'ai.chat.chatsScopeGlobal': 'Глобально',
'ai.chat.chatsScopeDangling': 'Связь потеряна',
'ai.chat.slashRebindDesc': 'Привязать потерянный чат к этой панели (/rebind <n>)',
'ai.chat.chatsRebindHint': 'Потерянные чаты можно восстановить командой /rebind <n>.',
'ai.chat.rebindUsage': 'Использование: /rebind <номер из /chats>',
'ai.chat.rebindDone': 'Чат «{title}» привязан к этой панели.',
'ai.chat.rebindUnavailable': 'В области этой панели нет цели для привязки.',
'ai.chat.justNow': 'Только что',
'ai.chat.minutesAgo': '{n}м назад',
'ai.chat.hoursAgo': '{n}ч назад',
@@ -600,10 +575,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 +630,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': 'Скрипты',
@@ -755,27 +724,4 @@ export const ruAiMessages: Messages = {
'zmodem.overwrite.skip': 'Пропустить',
'zmodem.overwrite.cancel': 'Отмена',
'settings.shortcuts.resetToDefault': 'Сбросить по умолчанию',
"ai.toolSettings.immediate": "Изменения применяются сразу. Закрытие окна не отменяет их.",
"ai.toolSettings.privacyOn": "Скрытие текста включено",
"ai.toolSettings.privacyOff": "Скрытие текста выключено",
"ai.toolSettings.ruleSummary": "Дополнительные диапазоны IP: {cidrs} · Пользовательские шаблоны: {patterns}",
"ai.toolSettings.configurePrivacy": "Настроить приватность",
"ai.toolSettings.unsaved": "Есть несохранённые изменения",
"ai.toolSettings.viewSkills": "Инструкции CLI",
"ai.toolSettings.viewPrompt": "Инструкции подключения",
"ai.toolSettings.behavior": "Поведение MCP",
"ai.toolSettings.configureBehavior": "Настроить поведение",
"ai.toolSettings.configureClients": "Настроить клиенты",
"ai.toolSettings.httpOn": "HTTP включён",
"ai.toolSettings.httpOff": "HTTP выключен",
"ai.toolSettings.loopback": "Только локальные подключения (127.0.0.1)",
"ai.toolSettings.configureHttp": "Настроить HTTP",
"ai.toolSettings.hideSecret": "Скрыть секрет",
"ai.toolSettings.showSecret": "Показать секрет",
"ai.toolSettings.manualSetup": "Ручная настройка",
"ai.toolSettings.format": "Формат",
"ai.toolSettings.connectionDetails": "Параметры подключения",
"ai.toolSettings.conflict": "Настройки изменены в другом окне. Черновик сохранён. Отмените и откройте окно снова, чтобы загрузить сохранённые настройки.",
"ai.toolSettings.retry": "Повторить",
"ai.toolSettings.applyingHttp": "Применение настроек HTTP...",
};
+1 -50
View File
@@ -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': 'Отмена',
@@ -172,12 +134,7 @@ export const ruCoreMessages: Messages = {
'settings.system.clearTempFiles': 'Очистить временные файлы',
'settings.system.clearing': 'Очистка...',
'settings.system.clearResult': 'Удалено файлов: {deleted}, ошибок: {failed}.',
'settings.system.tempDirChoose': 'Выбрать папку',
'settings.system.tempDirManual': 'Введите абсолютный путь к папке',
'settings.system.tempDirApply': 'Применить',
'settings.system.tempDirReset': 'Сбросить по умолчанию',
'settings.system.tempDirError': 'Не удалось использовать эту папку.',
'settings.system.tempDirectoryHint': 'Используется для промежуточного хранения SFTP-передач и файлов, открытых во внешних приложениях. Смена папки во время передачи может повлиять на эту передачу.',
'settings.system.tempDirectoryHint': 'Временные файлы создаются при открытии удалённых файлов во внешних приложениях. Они автоматически очищаются при закрытии SFTP-сессий.',
'settings.system.credentials.title': 'Защита учётных данных',
'settings.system.credentials.status': 'Статус',
'settings.system.credentials.checking': 'Проверка...',
@@ -568,8 +525,6 @@ export const ruCoreMessages: Messages = {
'settings.appearance.uiFont.desc': 'Выберите шрифт для интерфейса приложения',
'settings.appearance.windowOpacity': 'Прозрачность окна',
'settings.appearance.windowOpacity.desc': 'Настройте прозрачность всего окна приложения. При низких значениях текст терминала тоже бледнеет. В некоторых средах Linux это может не поддерживаться.',
'settings.appearance.windowBackgroundBlur': 'Матовое стекло',
'settings.appearance.windowBackgroundBlur.desc': 'Размывает рабочий стол за окном. При непрозрачности 100 % эффект не виден, но ваш выбор сохраняется.',
'settings.appearance.appIcon': 'Значок приложения',
'settings.appearance.appIcon.desc': 'Выберите стиль значка для Dock/панели задач, чтобы Netcatty было проще найти при переключении приложений. В Windows значок области уведомлений использует тот же стиль. Применяется во время работы; значок установленного пакета не меняется.',
'settings.appearance.appIcon.original': 'Оригинал',
@@ -1283,9 +1238,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 +1256,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': 'Включённые плагины не предоставляют настройки.',
-4
View File
@@ -1,8 +1,4 @@
export const ruMonitorMessages = {
"monitor.systemDisks": "Системные разделы ({count})",
"monitor.systemDisksAlert": "Использование системного раздела превышает 80 %; группа раскрыта автоматически.",
"monitor.diskIoTotal": "Всего",
"monitor.partitionIoHint": "Ввод-вывод блочного устройства точки монтирования. Точки одного устройства показывают одинаковые скорости. Недоступно до двух корректных замеров или без определённого устройства; macOS не поддерживается.",
"monitor.diskIo": "Дисковый ввод-вывод",
"monitor.diskRead": "Чтение",
"monitor.diskWrite": "Запись",
-20
View File
@@ -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': 'Ваши текущие хосты, ключи, сниппеты и настройки будут заменены содержимым этой резервной копии. Перед этим автоматически создаётся защитный снимок текущих данных.',
-19
View File
@@ -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': 'Текущий путь скопирован',
-54
View File
@@ -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',
@@ -526,8 +525,6 @@ export const zhCNAiMessages: Messages = {
'ai.chat.alwaysAllow': '始终',
'ai.chat.slashStopDesc': '停止当前 AI 回合并取消进行中的工具',
'ai.chat.slashCompactDesc': '压缩较早的会话上下文',
'ai.chat.slashRenameDesc': '重命名当前聊天(/rename <标题>)',
'ai.chat.slashChatsDesc': '列出此面板可见的聊天',
'ai.chat.reject': '拒绝',
'ai.chat.toolLabel': '工具',
'ai.chat.targetLabel': '目标',
@@ -590,28 +587,6 @@ export const zhCNAiMessages: Messages = {
'ai.chat.recent': '最近',
'ai.chat.viewAll': '查看全部',
'ai.chat.untitled': '无标题',
'ai.chat.renameChat': '重命名聊天',
'ai.chat.renameUsage': '用法:/rename <新标题>',
'ai.chat.renameNoActive': '当前没有可重命名的聊天。',
'ai.chat.renameDone': '聊天已重命名为「{title}」。',
'ai.chat.exportSessions': '导出 AI 会话',
'ai.chat.importSessions': '导入 AI 会话',
'ai.chat.exportDone': 'AI 会话已导出到 {path}。',
'ai.chat.exportDoneNoPath': 'AI 会话已导出。',
'ai.chat.exportUnavailable': '当前环境不支持导出 AI 会话。',
'ai.chat.importDone': '已导入 {n} 个 AI 会话。',
'ai.chat.importNone': '所选文件中没有可导入的 AI 会话。',
'ai.chat.chatsHeader': '此面板可见的聊天:',
'ai.chat.chatsEmpty': '此面板没有可见的聊天。',
'ai.chat.chatsScopeWorkspace': '工作区',
'ai.chat.chatsScopeTerminal': '终端',
'ai.chat.chatsScopeGlobal': '全局',
'ai.chat.chatsScopeDangling': '已断开关联',
'ai.chat.slashRebindDesc': '将断联的聊天重绑到此面板(/rebind <序号>)',
'ai.chat.chatsRebindHint': '断联的聊天可用 /rebind <序号> 恢复。',
'ai.chat.rebindUsage': '用法:/rebind <序号>',
'ai.chat.rebindDone': '聊天“{title}”已重绑到此面板。',
'ai.chat.rebindUnavailable': '当前面板范围内没有可用的重绑目标。',
'ai.chat.justNow': '刚刚',
'ai.chat.minutesAgo': '{n}分钟前',
'ai.chat.hoursAgo': '{n}小时前',
@@ -661,10 +636,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 +691,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': '脚本',
@@ -826,27 +795,4 @@ export const zhCNAiMessages: Messages = {
'zmodem.overwrite.skip': '跳过',
'zmodem.overwrite.cancel': '取消',
'settings.shortcuts.resetToDefault': '重置为默认',
"ai.toolSettings.immediate": "更改立即生效,关闭此窗口不会撤销。",
"ai.toolSettings.privacyOn": "已启用文本脱敏",
"ai.toolSettings.privacyOff": "未启用文本脱敏",
"ai.toolSettings.ruleSummary": "{cidrs} 个附加 IP 范围 · {patterns} 条自定义规则",
"ai.toolSettings.configurePrivacy": "配置隐私",
"ai.toolSettings.unsaved": "有未保存的更改",
"ai.toolSettings.viewSkills": "查看 CLI 指引",
"ai.toolSettings.viewPrompt": "查看接入提示词",
"ai.toolSettings.behavior": "MCP 行为",
"ai.toolSettings.configureBehavior": "配置行为",
"ai.toolSettings.configureClients": "配置客户端",
"ai.toolSettings.httpOn": "HTTP 已启用",
"ai.toolSettings.httpOff": "HTTP 未启用",
"ai.toolSettings.loopback": "仅本地连接(127.0.0.1)",
"ai.toolSettings.configureHttp": "配置 HTTP",
"ai.toolSettings.hideSecret": "隐藏密钥",
"ai.toolSettings.showSecret": "显示密钥",
"ai.toolSettings.manualSetup": "手动配置",
"ai.toolSettings.format": "格式",
"ai.toolSettings.connectionDetails": "连接详情",
"ai.toolSettings.conflict": "设置已在另一窗口更改,当前草稿已保留。请取消并重新打开以加载已保存的设置。",
"ai.toolSettings.retry": "重试",
"ai.toolSettings.applyingHttp": "正在应用 HTTP 配置…",
};
+1 -69
View File
@@ -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}。",
@@ -203,12 +165,7 @@ export const zhCNCoreMessages: Messages = {
'settings.system.clearTempFiles': '清理临时文件',
'settings.system.clearing': '清理中...',
'settings.system.clearResult': '已删除 {deleted} 个文件,{failed} 个失败。',
'settings.system.tempDirChoose': '选择文件夹',
'settings.system.tempDirManual': '输入绝对文件夹路径',
'settings.system.tempDirApply': '应用',
'settings.system.tempDirReset': '恢复默认',
'settings.system.tempDirError': '无法使用此文件夹。',
'settings.system.tempDirectoryHint': 'Netcatty 在所选文件夹中创建 Netcatty 临时子文件夹,用于 SFTP 传输暂存和外部编辑文件。传输期间更改文件夹可能影响该传输。',
'settings.system.tempDirectoryHint': '临时文件在使用外部应用打开远程文件时创建。SFTP 会话关闭时会自动清理。',
'settings.system.credentials.title': '凭据保护',
'settings.system.credentials.status': '状态',
'settings.system.credentials.checking': '检查中...',
@@ -629,8 +586,6 @@ Vaults 标签保持扁平,不使用这块背景。
'settings.appearance.uiFont.desc': '选择软件界面使用的字体',
'settings.appearance.windowOpacity': '窗口透明度',
'settings.appearance.windowOpacity.desc': '调节整个应用窗口的透明度,方便叠在其他内容上方。较低时终端文字也会变淡;部分 Linux 桌面环境可能不支持。',
'settings.appearance.windowBackgroundBlur': '毛玻璃背景',
'settings.appearance.windowBackgroundBlur.desc': '模糊窗口后方的桌面。100% 不透明时效果不可见,但会保留你的开关设置。',
'settings.appearance.appIcon': '应用图标',
'settings.appearance.appIcon.desc': '选择 Dock/任务栏图标风格,切换应用时更容易找到 Netcatty。Windows 通知区域图标会使用同一套风格。运行时生效,安装包内的图标不会改变。',
'settings.appearance.appIcon.original': '原始',
@@ -1053,26 +1008,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 +1288,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 +1306,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': '会话日志',
-2
View File
@@ -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': '当前的主机、密钥、代码片段与设置将被替换为此备份中的内容。系统会先自动创建一个保护性快照,便于撤销。',
-54
View File
@@ -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',
@@ -516,8 +515,6 @@ export const zhTWAiMessages: Messages = {
'ai.chat.alwaysAllow': '始終',
'ai.chat.slashStopDesc': '停止目前 AI 回合並取消進行中的工具',
'ai.chat.slashCompactDesc': '壓縮較早的對話上下文',
'ai.chat.slashRenameDesc': '重新命名目前聊天(/rename <標題>)',
'ai.chat.slashChatsDesc': '列出此面板可見的聊天',
'ai.chat.reject': '拒絕',
'ai.chat.toolLabel': '工具',
'ai.chat.targetLabel': '目標',
@@ -580,28 +577,6 @@ export const zhTWAiMessages: Messages = {
'ai.chat.recent': '最近',
'ai.chat.viewAll': '檢視全部',
'ai.chat.untitled': '無標題',
'ai.chat.renameChat': '重新命名聊天',
'ai.chat.renameUsage': '用法:/rename <新標題>',
'ai.chat.renameNoActive': '目前沒有可重新命名的聊天。',
'ai.chat.renameDone': '聊天已重新命名為「{title}」。',
'ai.chat.exportSessions': '匯出 AI 會話',
'ai.chat.importSessions': '匯入 AI 會話',
'ai.chat.exportDone': 'AI 會話已匯出到 {path}。',
'ai.chat.exportDoneNoPath': 'AI 會話已匯出。',
'ai.chat.exportUnavailable': '目前環境不支援匯出 AI 會話。',
'ai.chat.importDone': '已匯入 {n} 個 AI 會話。',
'ai.chat.importNone': '所選檔案中沒有可匯入的 AI 會話。',
'ai.chat.chatsHeader': '此面板可見的聊天:',
'ai.chat.chatsEmpty': '此面板沒有可見的聊天。',
'ai.chat.chatsScopeWorkspace': '工作區',
'ai.chat.chatsScopeTerminal': '終端',
'ai.chat.chatsScopeGlobal': '全域',
'ai.chat.chatsScopeDangling': '已斷開關聯',
'ai.chat.slashRebindDesc': '將斷開關聯的聊天重新綁定到此面板(/rebind <序號>)',
'ai.chat.chatsRebindHint': '斷開關聯的聊天可用 /rebind <序號> 恢復。',
'ai.chat.rebindUsage': '用法:/rebind <序號>',
'ai.chat.rebindDone': '聊天「{title}」已重新綁定到此面板。',
'ai.chat.rebindUnavailable': '目前面板範圍內沒有可用的重新綁定目標。',
'ai.chat.justNow': '剛剛',
'ai.chat.minutesAgo': '{n}分鐘前',
'ai.chat.hoursAgo': '{n}小時前',
@@ -651,10 +626,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 +681,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': '指令碼',
@@ -816,27 +785,4 @@ export const zhTWAiMessages: Messages = {
'zmodem.overwrite.skip': '跳過',
'zmodem.overwrite.cancel': '取消',
'settings.shortcuts.resetToDefault': '重設為預設值',
"ai.toolSettings.immediate": "變更立即生效,關閉此視窗不會復原。",
"ai.toolSettings.privacyOn": "已啟用文字遮蔽",
"ai.toolSettings.privacyOff": "未啟用文字遮蔽",
"ai.toolSettings.ruleSummary": "{cidrs} 個額外 IP 範圍 · {patterns} 條自訂規則",
"ai.toolSettings.configurePrivacy": "設定隱私",
"ai.toolSettings.unsaved": "有未儲存的變更",
"ai.toolSettings.viewSkills": "查看 CLI 指引",
"ai.toolSettings.viewPrompt": "查看接入提示詞",
"ai.toolSettings.behavior": "MCP 行為",
"ai.toolSettings.configureBehavior": "設定行為",
"ai.toolSettings.configureClients": "設定用戶端",
"ai.toolSettings.httpOn": "HTTP 已啟用",
"ai.toolSettings.httpOff": "HTTP 未啟用",
"ai.toolSettings.loopback": "僅本機連線(127.0.0.1)",
"ai.toolSettings.configureHttp": "設定 HTTP",
"ai.toolSettings.hideSecret": "隱藏金鑰",
"ai.toolSettings.showSecret": "顯示金鑰",
"ai.toolSettings.manualSetup": "手動設定",
"ai.toolSettings.format": "格式",
"ai.toolSettings.connectionDetails": "連線詳情",
"ai.toolSettings.conflict": "設定已在其他視窗變更,目前草稿已保留。請取消並重新開啟以載入已儲存的設定。",
"ai.toolSettings.retry": "重試",
"ai.toolSettings.applyingHttp": "正在套用 HTTP 設定…",
};
+1 -69
View File
@@ -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}。",
@@ -205,12 +167,7 @@ export const zhTWCoreMessages: Messages = {
'settings.system.clearTempFiles': '清理臨時檔案',
'settings.system.clearing': '清理中...',
'settings.system.clearResult': '已刪除 {deleted} 個檔案,{failed} 個失敗。',
'settings.system.tempDirChoose': '選擇資料夾',
'settings.system.tempDirManual': '輸入絕對資料夾路徑',
'settings.system.tempDirApply': '套用',
'settings.system.tempDirReset': '恢復預設',
'settings.system.tempDirError': '無法使用此資料夾。',
'settings.system.tempDirectoryHint': '用於 SFTP 傳輸暫存與外部編輯檔案。傳輸期間變更資料夾可能影響該傳輸。',
'settings.system.tempDirectoryHint': '臨時檔案在使用外部應用程式開啟遠端檔案時建立。SFTP 工作階段關閉時會自動清理。',
'settings.system.credentials.title': '憑證保護',
'settings.system.credentials.status': '狀態',
'settings.system.credentials.checking': '檢查中...',
@@ -631,8 +588,6 @@ Vaults 分頁維持扁平,不使用這塊背景。
'settings.appearance.uiFont.desc': '選擇軟體介面使用的字型',
'settings.appearance.windowOpacity': '視窗透明度',
'settings.appearance.windowOpacity.desc': '調節整個應用程式視窗的透明度,方便疊在其他內容上方。較低時終端文字也會變淡;部分 Linux 桌面環境可能不支援。',
'settings.appearance.windowBackgroundBlur': '毛玻璃背景',
'settings.appearance.windowBackgroundBlur.desc': '模糊視窗後方的桌面。100% 不透明時效果不可見,但會保留你的開關設定。',
'settings.appearance.appIcon': '應用程式圖示',
'settings.appearance.appIcon.desc': '選擇 Dock/工作列圖示風格,切換應用程式時更容易找到 Netcatty。Windows 通知區域圖示會使用同一套風格。執行時生效,安裝檔內的圖示不會改變。',
'settings.appearance.appIcon.original': '原始',
@@ -1055,26 +1010,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 +1259,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 +1277,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': '工作階段日誌',
-2
View File
@@ -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': '目前的主機、金鑰、程式碼片段與設定將被替換為此備份中的內容。系統會先自動建立一個保護性快照,便於復原。',
+11 -72
View File
@@ -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);
}
+1 -1
View File
@@ -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 };
}
+47 -1
View File
@@ -2,9 +2,14 @@ import test from "node:test";
import assert from "node:assert/strict";
import type {
AIPanelView,
AISession,
} from "../../infrastructure/ai/types.ts";
import { pruneInactiveScopedSessions } from "./aiScopeCleanup.ts";
import { createEmptyDraft } from "./aiDraftState.ts";
import {
pruneInactiveScopedSessions,
pruneInactiveScopedTransientState,
} from "./aiScopeCleanup.ts";
function createSession(id: string, scope: AISession["scope"], externalSessionId?: string): AISession {
return {
@@ -19,6 +24,47 @@ function createSession(id: string, scope: AISession["scope"], externalSessionId?
};
}
test("pruneInactiveScopedTransientState removes closed workspace and terminal scope state", () => {
const activeSessionIdMap = {
"terminal:open-terminal": "session-open",
"terminal:closed-terminal": "session-closed-terminal",
"workspace:open-workspace": "session-open-workspace",
"workspace:closed-workspace": "session-closed-workspace",
};
const draftsByScope = {
"terminal:open-terminal": createEmptyDraft("catty"),
"terminal:closed-terminal": createEmptyDraft("catty"),
"workspace:open-workspace": createEmptyDraft("catty"),
"workspace:closed-workspace": createEmptyDraft("catty"),
};
const panelViewByScope = {
"terminal:open-terminal": { mode: "draft" },
"terminal:closed-terminal": { mode: "session", sessionId: "session-closed-terminal" },
"workspace:open-workspace": { mode: "draft" },
"workspace:closed-workspace": { mode: "session", sessionId: "session-closed-workspace" },
} satisfies Record<string, AIPanelView>;
const next = pruneInactiveScopedTransientState(
activeSessionIdMap,
draftsByScope,
panelViewByScope,
new Set(["open-terminal", "open-workspace"]),
);
assert.deepEqual(next.activeSessionIdMap, {
"terminal:open-terminal": "session-open",
"workspace:open-workspace": "session-open-workspace",
});
assert.deepEqual(next.draftsByScope, {
"terminal:open-terminal": draftsByScope["terminal:open-terminal"],
"workspace:open-workspace": draftsByScope["workspace:open-workspace"],
});
assert.deepEqual(next.panelViewByScope, {
"terminal:open-terminal": panelViewByScope["terminal:open-terminal"],
"workspace:open-workspace": panelViewByScope["workspace:open-workspace"],
});
});
test("pruneInactiveScopedSessions reports inactive targets without deleting persisted history", () => {
const sessions = [
createSession("terminal-restorable", {
+36
View File
@@ -6,6 +6,7 @@ import type {
type DraftsByScope = Partial<Record<string, AIDraft>>;
type PanelViewByScope = Partial<Record<string, AIPanelView>>;
type ActiveSessionIdMap = Record<string, string | null>;
function isInactiveScopedTarget(
scopeKey: string,
@@ -54,6 +55,41 @@ export function pruneInactiveScopedState(
};
}
export function pruneInactiveScopedTransientState(
activeSessionIdMap: ActiveSessionIdMap,
draftsByScope: DraftsByScope,
panelViewByScope: PanelViewByScope,
activeTargetIds: Set<string>,
): {
activeSessionIdMap: ActiveSessionIdMap;
draftsByScope: DraftsByScope;
panelViewByScope: PanelViewByScope;
} {
let activeSessionMapChanged = false;
const nextActiveSessionIdMap: ActiveSessionIdMap = {};
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
if (isInactiveScopedTarget(scopeKey, activeTargetIds)) {
activeSessionMapChanged = true;
continue;
}
nextActiveSessionIdMap[scopeKey] = sessionId;
}
const nextScopedState = pruneInactiveScopedState(
draftsByScope,
panelViewByScope,
activeTargetIds,
);
return {
activeSessionIdMap: activeSessionMapChanged ? nextActiveSessionIdMap : activeSessionIdMap,
draftsByScope: nextScopedState.draftsByScope,
panelViewByScope: nextScopedState.panelViewByScope,
};
}
export function pruneInactiveScopedSessions(
sessions: AISession[],
activeTargetIds: Set<string>,
@@ -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();
}
});
-372
View File
@@ -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;
}
}
-149
View File
@@ -1,149 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { AISession } from '../../infrastructure/ai/types';
import {
DebouncedBackupMirror,
isMissingOrCorruptSessionsStore,
mergeImportedSessions,
parseBackupSessions,
readLiveSessionsArray,
shouldRestoreFromBackup,
} from './aiSessionsBackup';
import { prepareRestoredSessions, pruneSessionsForStorage } from './aiStateSnapshots';
function makeSession(id: string, updatedAt = 1_000): AISession {
return {
id,
title: id,
agentId: 'catty',
scope: { type: 'workspace', targetId: 'ws-1' },
messages: [],
createdAt: updatedAt,
updatedAt,
} as never;
}
test('isMissingOrCorruptSessionsStore distinguishes missing or corrupt stores from valid arrays', () => {
assert.equal(isMissingOrCorruptSessionsStore(null), true);
assert.equal(isMissingOrCorruptSessionsStore(''), true);
assert.equal(isMissingOrCorruptSessionsStore('[]'), false);
assert.equal(isMissingOrCorruptSessionsStore('{corrupt'), true);
assert.equal(isMissingOrCorruptSessionsStore('{}'), true);
assert.equal(isMissingOrCorruptSessionsStore('[{"id":"s1"}]'), false);
});
test('readLiveSessionsArray normalizes missing and corrupt stores', () => {
for (const raw of [null, undefined, '', '{}', 'not-json']) {
assert.deepEqual(readLiveSessionsArray(raw), []);
}
const sessions = [makeSession('s1')];
assert.deepEqual(readLiveSessionsArray(JSON.stringify(sessions)), sessions);
});
test('parseBackupSessions ignores corrupt payloads and drops malformed entries', () => {
assert.equal(parseBackupSessions(null), null);
assert.equal(parseBackupSessions(''), null);
assert.equal(parseBackupSessions('not json'), null);
assert.equal(parseBackupSessions('{"id":"s1"}'), null);
assert.equal(parseBackupSessions(JSON.stringify([{ id: 's1' }])), null);
const sessions = parseBackupSessions(JSON.stringify([
{ id: 's1' },
{ nope: true },
{ id: 'missing-scope', title: 'Missing scope', agentId: 'catty', messages: [], updatedAt: 1000 },
{ ...makeSession('null-scope'), scope: null },
{ ...makeSession('missing-title'), title: undefined },
{ ...makeSession('missing-agent'), agentId: undefined },
{ ...makeSession('null-message'), messages: [null] },
{ ...makeSession('incomplete-message'), messages: [{ id: 'm' }] },
{ ...makeSession('missing-content'), messages: [{ id: 'm', role: 'user', timestamp: 1000 }] },
{ ...makeSession('invalid-content'), messages: [{ id: 'm', role: 'user', timestamp: 1000, content: null }] },
{ ...makeSession('invalid-timestamp'), messages: [{ id: 'm', role: 'user', timestamp: null, content: 'hello' }] },
null,
{ ...makeSession('s2'), messages: [{ id: 'm', role: 'user', timestamp: 1000, content: 'hello' }] },
]));
assert.deepEqual(sessions?.map((session) => session.id), ['s2']);
});
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));
assert.deepEqual(prepareRestoredSessions(sessions, true), sessions);
const local = prepareRestoredSessions(sessions, false);
assert.equal(local.length, 1000);
assert.equal(local.some(session => session.id === 's0'), false);
});
test('malformed imports cannot reach pruning or inject broken sessions', () => {
const current = [makeSession('live')];
const malformed = parseBackupSessions(JSON.stringify([{ id: 's1' }]));
assert.deepEqual(mergeImportedSessions(current, pruneSessionsForStorage(malformed ?? [])), current);
const valid = parseBackupSessions(JSON.stringify([{ id: 's1' }, makeSession('valid')]));
const merged = mergeImportedSessions(current, pruneSessionsForStorage(valid ?? []));
assert.deepEqual(new Set(merged.map((session) => session.id)), new Set(['live', 'valid']));
});
test('restore happens only on a missing or corrupt live store with a non-empty backup', () => {
const backup = JSON.stringify([makeSession('s1')]);
// Empty (or wiped) store -> restore.
assert.equal(shouldRestoreFromBackup(null, backup), true);
assert.equal(shouldRestoreFromBackup('', backup), true);
assert.equal(shouldRestoreFromBackup('[]', backup), false);
assert.equal(shouldRestoreFromBackup('{corrupt', backup), true);
// Non-empty live store -> never restore (explicit deletions must win).
assert.equal(shouldRestoreFromBackup('[{"id":"live"}]', backup), false);
// Empty/corrupt backup -> nothing to restore.
assert.equal(shouldRestoreFromBackup(null, null), false);
assert.equal(shouldRestoreFromBackup(null, '[]'), false);
assert.equal(shouldRestoreFromBackup(null, '{corrupt'), false);
});
test('mergeImportedSessions keys by id with the newer updatedAt winning', () => {
const current = [makeSession('keep', 100), makeSession('older', 100)];
const imported = [makeSession('newer', 200), makeSession('older', 50), makeSession('added', 1)];
const merged = mergeImportedSessions(current, imported);
const byId = new Map(merged.map((session) => [session.id, session]));
assert.equal(byId.get('older')?.updatedAt, 100);
assert.equal(byId.get('newer')?.updatedAt, 200);
assert.equal(byId.has('added'), true);
assert.equal(byId.has('keep'), true);
// Sorted newest-first for stable history ordering.
assert.deepEqual(
merged.map((session) => session.updatedAt),
[...merged.map((session) => session.updatedAt)].sort((a, b) => b - a),
);
});
test('DebouncedBackupMirror writes once per batch with the last payload', async () => {
const writes: string[] = [];
const mirror = new DebouncedBackupMirror((json) => writes.push(json), 15);
mirror.schedule('{"gen":1}');
mirror.schedule('{"gen":2}');
await new Promise((resolve) => setTimeout(resolve, 30));
mirror.schedule('{"gen":3}');
mirror.schedule('{"gen":4}');
await new Promise((resolve) => setTimeout(resolve, 40));
assert.deepEqual(writes, ['{"gen":2}', '{"gen":4}']);
});
test('DebouncedBackupMirror flushNow writes the pending payload immediately', () => {
const writes: string[] = [];
const mirror = new DebouncedBackupMirror((json) => writes.push(json), 60_000);
mirror.schedule('{"gen":1}');
mirror.flushNow();
assert.deepEqual(writes, ['{"gen":1}']);
// A second flush with nothing pending is a no-op.
mirror.flushNow();
assert.deepEqual(writes, ['{"gen":1}']);
});
test('readLiveSessionsArray treats non-array JSON as empty (corrupt)', () => {
assert.deepEqual(readLiveSessionsArray(null), []);
assert.deepEqual(readLiveSessionsArray(''), []);
assert.deepEqual(readLiveSessionsArray('{}'), []);
assert.deepEqual(readLiveSessionsArray('{"sessions":[]}'), []);
assert.deepEqual(readLiveSessionsArray('not-json'), []);
assert.deepEqual(readLiveSessionsArray('[]'), []);
assert.equal(readLiveSessionsArray('[{"id":"a"}]').length, 1);
});
-162
View File
@@ -1,162 +0,0 @@
import type { AISession } from '../../infrastructure/ai/types';
/**
* Renderer-side logic for the AI session durability layer (issue #4015 R2
* follow-up): a disk mirror of the localStorage session store plus a
* restore-on-empty-store boot path. The IPC plumbing lives in
* `electron/bridges/aiSessionsBackupBridge.cjs`; this module keeps the
* decisions pure so they can be tested without Electron.
*/
/** Debounce window: at most one backup write per mutation burst (~2s). */
export const AI_SESSIONS_BACKUP_DEBOUNCE_MS = 2000;
export interface AiSessionsBackupBridge {
aiSessionsBackupWrite?: (payload: { json: string }) => Promise<{ ok: boolean }>;
aiSessionsBackupReadLatest?: () => Promise<{ ok: boolean; json?: string }>;
aiSessionsBackupExport?: (payload: { json: string }) => Promise<{ success: boolean; filePath?: string }>;
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;
}
/** 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;
}
/**
* Parse a backup payload into sessions. Returns null when the payload is
* corrupt or malformed (ignored gracefully); also drops entries without a
* usable required fields. Backup generations are written by the same serializer as the
* live store, so entries are expected to be full AISession objects.
*/
export function parseBackupSessions(json: string | null | undefined): AISession[] | null {
if (typeof json !== 'string' || json === '') return null;
let parsed: unknown;
try {
parsed = JSON.parse(json);
} catch {
return null;
}
if (!Array.isArray(parsed)) return null;
const sessions = parsed.filter(
(entry): entry is AISession => (
typeof entry === 'object' && entry !== null
&& typeof (entry as AISession).id === 'string'
&& typeof (entry as AISession).title === 'string'
&& typeof (entry as AISession).agentId === 'string'
&& typeof (entry as AISession).scope === 'object'
&& (entry as AISession).scope !== null
&& typeof (entry as AISession).scope.type === 'string'
&& Array.isArray((entry as AISession).messages)
&& (entry as AISession).messages.every(message => (
typeof message === 'object' && message !== null
&& typeof message.id === 'string'
&& typeof message.role === 'string'
&& typeof message.content === 'string'
&& typeof message.timestamp === 'number'
&& Number.isFinite(message.timestamp)
))
&& typeof (entry as AISession).updatedAt === 'number'
&& Number.isFinite((entry as AISession).updatedAt)
),
);
return sessions.length > 0 ? sessions : null;
}
/**
* Decide whether to restore on boot: only when the live store is missing or corrupt AND
* the backup actually contains at least one session.
*/
export function shouldRestoreFromBackup(
liveRaw: string | null | undefined,
backupJson: string | null | undefined,
): boolean {
if (!isMissingOrCorruptSessionsStore(liveRaw)) return false;
const sessions = parseBackupSessions(backupJson);
return sessions != null && sessions.length > 0;
}
/**
* Merge an imported backup into the current store: entries are keyed by id
* and the newer `updatedAt` wins. Explicit user action, so it may overwrite
* the live store (unlike the boot restore, which is empty-only).
*/
export function mergeImportedSessions(current: AISession[], imported: AISession[]): AISession[] {
const byId = new Map<string, AISession>();
for (const session of current) byId.set(session.id, session);
for (const session of imported) {
const existing = byId.get(session.id);
if (!existing || (session.updatedAt ?? 0) >= (existing.updatedAt ?? 0)) {
byId.set(session.id, session);
}
}
return Array.from(byId.values()).sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
}
/**
* Debounced mirror scheduler. One rolling timer per process: the last
* mutation batch wins, and the write happens `debounceMs` after the batch
* settles (at most once per batch, per the durability spec).
*/
export class DebouncedBackupMirror {
private timer: ReturnType<typeof setTimeout> | null = null;
private pendingJson: string | null = null;
constructor(
private readonly write: (json: string) => void,
private readonly debounceMs: number = AI_SESSIONS_BACKUP_DEBOUNCE_MS,
) {}
schedule(json: string) {
this.pendingJson = json;
if (this.timer) clearTimeout(this.timer);
this.timer = setTimeout(() => {
this.timer = null;
const json = this.pendingJson;
this.pendingJson = null;
if (json != null) this.write(json);
}, this.debounceMs);
}
flushNow() {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
const json = this.pendingJson;
this.pendingJson = null;
if (json != null) this.write(json);
}
reset() {
if (this.timer) clearTimeout(this.timer);
this.timer = null;
this.pendingJson = null;
}
}
File diff suppressed because it is too large. Load diff
+352 -365
View File
@@ -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,
@@ -24,26 +20,14 @@ import {
} from './aiDraftState';
import {
pruneInactiveScopedSessions,
pruneInactiveScopedState,
pruneInactiveScopedTransientState,
} from './aiScopeCleanup';
import { emitAIStateChanged } from './aiStateEvents';
import { peekAgentRuntimeCleanup } from '../../infrastructure/ai/harness/agentRuntimeHandle';
import { setReversibleModelPrivacy } from '../../infrastructure/ai/harness/modelSecretRedaction';
import { DebouncedBackupMirror, readLiveSessionsArray } from './aiSessionsBackup';
import {
idbDeleteSessionIds,
idbPutMeta,
idbPutSessions,
isIndexedDbAvailable,
openAiSessionsIdb,
publishAiSessionsIdbChange,
resetAiSessionsIdbForTests,
} from './aiSessionIdbStorage';
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime';
/** 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;
@@ -51,11 +35,6 @@ export interface AIBridge {
aiMcpSetCommandBlocklist?: (blocklist: string[]) => Promise<unknown> | unknown;
aiMcpSetCommandTimeout?: (timeout: number) => Promise<unknown> | unknown;
aiMcpSetMaxIterations?: (maxIterations: number) => Promise<unknown> | unknown;
/** Durability mirror: write one timestamped backup generation to disk. */
aiSessionsBackupWrite?: (payload: { json: string }) => Promise<{ ok: boolean }>;
aiSessionsBackupReadLatest?: () => Promise<{ ok: boolean; json?: string }>;
aiSessionsBackupExport?: (payload: { json: string }) => Promise<{ success: boolean; filePath?: string }>;
aiSessionsBackupImport?: () => Promise<{ success: boolean; json?: string }>;
}
export function getAIBridge() {
@@ -75,29 +54,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 +66,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(() => {});
}
}
@@ -122,16 +82,10 @@ function isScopeKeyActive(scopeKey: string, activeTargetIds: Set<string>) {
return activeTargetIds.has(targetId);
}
/** Last live terminal/workspace target ids observed by orphan cleanup. */
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
?? localStorageAdapter.read<AISession[]>(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 —
@@ -157,114 +111,224 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
cleanupSdkAgentSessions(nextSessionCleanup.orphanedSessionIds);
}
// Issue #4015 R2: a dropped chat is recoverable now, so mark it dangling
// instead of stripping its record or pointer. `/chats` lists dangling chats
// with a rebind affordance; the flag clears when the chat is rebound or its
// scope target becomes live again.
const orphanedIds = new Set(nextSessionCleanup.orphanedSessionIds);
let sessionsMarked = false;
const markedSessions = currentSessions.map((session) => {
if (orphanedIds.has(session.id)) {
if (session.scopeDangling) return session;
sessionsMarked = true;
return { ...session, scopeDangling: true };
}
if (
session.scopeDangling
&& (!session.scope.targetId || activeTargetIds.has(session.scope.targetId))
) {
sessionsMarked = true;
const { scopeDangling: _cleared, ...rest } = session;
return rest as AISession;
}
return session;
});
if (sessionsMarked) {
setLatestAISessionsSnapshot(markedSessions);
writeSessionsForStorage(markedSessions);
if (nextSessionCleanup.sessions !== currentSessions) {
setLatestAISessionsSnapshot(nextSessionCleanup.sessions);
writeSessionsForStorage(nextSessionCleanup.sessions);
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
}
// Issue #4015 R2: keep active-pointer entries for scopes whose target
// closed. Clearing them used to make dangling chats invisible (rank 0) and
// unrecoverable from the UI; the pointer is rewritten on rebind instead.
const activeSessionIdMap = preCleanupActiveSessionMap;
let activeSessionMapChanged = false;
const nextActiveSessionIdMap = { ...activeSessionIdMap };
for (const scopeKey of Object.keys(activeSessionIdMap)) {
if (isScopeKeyActive(scopeKey, activeTargetIds)) continue;
delete nextActiveSessionIdMap[scopeKey];
activeSessionMapChanged = true;
}
if (activeSessionMapChanged) {
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
}
const currentActiveSessionIdMap = activeSessionMapChanged
? nextActiveSessionIdMap
: activeSessionIdMap;
const currentDraftsByScope = latestAIDraftsByScopeSnapshot ?? {};
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? {};
const prunedScopedState = pruneInactiveScopedState(
const prunedScopedTransientState = pruneInactiveScopedTransientState(
currentActiveSessionIdMap,
currentDraftsByScope,
currentPanelViewByScope,
activeTargetIds,
);
if (prunedScopedState.draftsByScope !== currentDraftsByScope) {
if (prunedScopedTransientState.activeSessionIdMap !== currentActiveSessionIdMap) {
setLatestAIActiveSessionMapSnapshot(prunedScopedTransientState.activeSessionIdMap);
localStorageAdapter.write(
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
prunedScopedTransientState.activeSessionIdMap,
);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
}
if (prunedScopedTransientState.draftsByScope !== currentDraftsByScope) {
for (const scopeKey of Object.keys(currentDraftsByScope)) {
if (scopeKey in prunedScopedState.draftsByScope) continue;
if (scopeKey in prunedScopedTransientState.draftsByScope) continue;
bumpDraftMutationVersion(scopeKey);
bumpDraftUploadGeneration(scopeKey);
}
setLatestAIDraftsByScopeSnapshot(prunedScopedState.draftsByScope);
setLatestAIDraftsByScopeSnapshot(prunedScopedTransientState.draftsByScope);
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
}
if (prunedScopedState.panelViewByScope !== currentPanelViewByScope) {
if (prunedScopedTransientState.panelViewByScope !== currentPanelViewByScope) {
for (const scopeKey of Object.keys(currentPanelViewByScope)) {
if (scopeKey in prunedScopedState.panelViewByScope) continue;
if (scopeKey in prunedScopedTransientState.panelViewByScope) continue;
bumpDraftMutationVersion(scopeKey);
}
setLatestAIPanelViewByScopeSnapshot(prunedScopedState.panelViewByScope);
setLatestAIPanelViewByScopeSnapshot(prunedScopedTransientState.panelViewByScope);
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
}
}
/** 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
? {
@@ -285,234 +349,179 @@ export function pruneSessionsForStorage(
});
}
export function prepareRestoredSessions(sessions: AISession[], syncEnabled: boolean): AISession[] {
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.
/** 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();
/** 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();
}
/** Test hook: resolve once every queued IndexedDB sync has settled. */
export function __flushIdbWritesForTests(): Promise<void> {
return idbWriteQueue.then(() => undefined, () => undefined);
}
// ── 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.
let aiSessionsHydrationComplete = false;
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]));
}
/**
* 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).
* 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 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).
const backupMirror = new DebouncedBackupMirror((json) => {
try {
void getAIBridge()?.aiSessionsBackupWrite?.({ json })?.catch(() => {});
} catch {
// Backup failures must never break chat persistence.
}
});
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();
} catch {
// Unload and persistence must remain fail-safe.
}
}
export function __resetBackupMirrorForTests() {
backupMirror.reset();
aiSessionsBackupRecoveryPending = false;
}
export function scheduleBackupMirror(json: string) {
if (aiSessionsBackupRecoveryPending) return;
try {
backupMirror.schedule(json);
} catch {
// ignore
}
}
/** 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.
}
}
/**
* 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(
export function serializeSessionsForStorage(
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);
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) };
});
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.`,
);
}
// 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);
return true;
} catch (error) {
console.warn('[AIState] Failed to persist AI sessions:', error);
return false;
// 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),
};
}
// 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,
};
});
// 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;
// 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;
}
// 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,
};
}
}
return {
json: `[${serialized.map(entry => entry.json).join(',')}]`,
sessions: serialized.map(entry => entry.session),
};
}
/**
* 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 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) {
return localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, sessions);
}
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)) return true;
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)
) {
return true;
}
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;
@@ -522,28 +531,6 @@ export let latestAIPanelViewByScopeSnapshot: PanelViewByScope | null = null;
let latestAIDraftMutationVersionByScopeSnapshot: Record<string, number> = {};
let latestAIDraftUploadGenerationByScopeSnapshot: Record<string, number> = {};
/**
* Issue #4015 R2: rewrite the active-session map when rebinding a dangling
* chat. Drops every pointer that still names `sessionId` under a different
* scope key, then sets the new scope as active. Callers must persist and
* publish the returned map (do not rebuild from a stale snapshot).
*/
export function rebindActiveSessionMap(
activeSessionIdMap: Record<string, string | null>,
sessionId: string,
scopeKey: string,
): Record<string, string | null> {
let next = activeSessionIdMap;
for (const [key, id] of Object.entries(activeSessionIdMap)) {
if (id !== sessionId || key === scopeKey) continue;
if (next === activeSessionIdMap) next = { ...activeSessionIdMap };
delete next[key];
}
if (next[scopeKey] === sessionId) return next;
return { ...next, [scopeKey]: sessionId };
}
export function setLatestAISessionsSnapshot(sessions: AISession[]) {
latestAISessionsSnapshot = sessions;
}
@@ -554,10 +541,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");
});
}
+103 -126
View File
@@ -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());
});
+1 -23
View File
@@ -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();
}
-5
View File
@@ -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: [] });
});
-41
View File
@@ -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; },
};
}
+133 -2
View File
@@ -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);
});
+246
View File
@@ -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();
});
-78
View File
@@ -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'
+136 -111
View File
@@ -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",
);
});
+126 -74
View File
@@ -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 &&
-7
View File
@@ -49,7 +49,6 @@ import {
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN,
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB,
STORAGE_KEY_WINDOW_OPACITY,
STORAGE_KEY_WINDOW_BACKGROUND_BLUR,
STORAGE_KEY_APP_ICON_VARIANT,
STORAGE_KEY_HTTP_NETWORK_PROXY,
STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
@@ -100,7 +99,6 @@ interface UseSettingsIpcSyncParams {
setIsHotkeyRecordingState: Dispatch<SetStateAction<boolean>>;
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
setWindowOpacity: (raw: unknown) => void;
setWindowBackgroundBlur: Dispatch<SetStateAction<boolean>>;
setAppIconVariant: Dispatch<SetStateAction<AppIconVariant>>;
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
setAutoLaunchEnabled: Dispatch<SetStateAction<boolean>>;
@@ -150,7 +148,6 @@ export function useSettingsIpcSync({
setIsHotkeyRecordingState,
setGlobalHotkeyEnabled,
setWindowOpacity,
setWindowBackgroundBlur,
setAppIconVariant,
setAutoUpdateEnabled,
setAutoLaunchEnabled,
@@ -282,9 +279,6 @@ export function useSettingsIpcSync({
if (key === STORAGE_KEY_WINDOW_OPACITY) {
setWindowOpacity(value);
}
if (key === STORAGE_KEY_WINDOW_BACKGROUND_BLUR && typeof value === 'boolean') {
setWindowBackgroundBlur((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_APP_ICON_VARIANT) {
const nextVariant = resolveAppIconVariant(value);
setAppIconVariant((prev) => (prev === nextVariant ? prev : nextVariant));
@@ -377,7 +371,6 @@ export function useSettingsIpcSync({
setFollowAppTerminalThemeState,
setGlobalHotkeyEnabled,
setWindowOpacity,
setWindowBackgroundBlur,
setAppIconVariant,
setHotkeyScheme,
setIsHotkeyRecordingState,
+4 -14
View File
@@ -53,7 +53,6 @@ import {
STORAGE_KEY_UI_LANGUAGE,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_WINDOW_OPACITY,
STORAGE_KEY_WINDOW_BACKGROUND_BLUR,
STORAGE_KEY_APP_ICON_VARIANT,
} from '../../infrastructure/config/storageKeys';
import { resolveAppIconVariant, type AppIconVariant } from '../../domain/appIconVariant';
@@ -117,7 +116,6 @@ interface UseSettingsStorageSyncParams {
globalHotkeyEnabled: boolean;
autoUpdateEnabled: boolean;
windowOpacity: number;
windowBackgroundBlur: boolean;
appIconVariant: AppIconVariant;
setTheme: Dispatch<SetStateAction<'dark' | 'light' | 'system'>>;
setLightUiThemeId: Dispatch<SetStateAction<string>>;
@@ -168,7 +166,6 @@ interface UseSettingsStorageSyncParams {
setExplorerContextMenuEnabledState: (enabled: boolean) => void;
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
setWindowOpacity: (raw: unknown) => void;
setWindowBackgroundBlur: Dispatch<SetStateAction<boolean>>;
setAppIconVariant: Dispatch<SetStateAction<AppIconVariant>>;
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
@@ -187,7 +184,7 @@ export function useSettingsStorageSync({
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, windowBackgroundBlur, appIconVariant,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, applyIncomingCustomAccent,
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
@@ -196,7 +193,7 @@ export function useSettingsStorageSync({
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setLocalShellSidePanelAutoOpenState, setLocalShellSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState, setExplorerContextMenuEnabledState,
setGlobalHotkeyEnabled, setWindowOpacity, setWindowBackgroundBlur, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
}: UseSettingsStorageSyncParams) {
@@ -211,7 +208,7 @@ export function useSettingsStorageSync({
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, windowBackgroundBlur, appIconVariant,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
});
settingsSnapshotRef.current = {
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentVersion,
@@ -221,7 +218,7 @@ export function useSettingsStorageSync({
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, windowBackgroundBlur, appIconVariant,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
};
// Listen for storage changes from other windows (cross-window sync)
@@ -526,12 +523,6 @@ export function useSettingsStorageSync({
if (e.key === STORAGE_KEY_WINDOW_OPACITY && e.newValue !== null) {
setWindowOpacity(e.newValue);
}
if (e.key === STORAGE_KEY_WINDOW_BACKGROUND_BLUR && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.windowBackgroundBlur) {
setWindowBackgroundBlur(newValue);
}
}
if (e.key === STORAGE_KEY_APP_ICON_VARIANT && e.newValue !== null) {
const newValue = resolveAppIconVariant(e.newValue);
if (newValue !== s.appIconVariant) {
@@ -575,7 +566,6 @@ export function useSettingsStorageSync({
setGlobalHotkeyEnabled,
setHostClickBehaviorState,
setWindowOpacity,
setWindowBackgroundBlur,
setAppIconVariant,
setHotkeyScheme,
setLightUiThemeId,
@@ -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,
+3 -42
View File
@@ -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[]>;
@@ -7,7 +7,6 @@ import {
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
STORAGE_KEY_WINDOW_OPACITY,
STORAGE_KEY_WINDOW_BACKGROUND_BLUR,
STORAGE_KEY_APP_ICON_VARIANT,
STORAGE_KEY_HTTP_NETWORK_PROXY,
} from '../../infrastructure/config/storageKeys';
@@ -18,8 +17,6 @@ import {
} from '../../domain/httpNetworkProxy';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { isMacPlatform } from '../../lib/utils';
import { isWindowBackgroundBlurActive } from '../../domain/windowBackgroundBlur';
import {
parseWindowOpacityRecord,
serializeWindowOpacityRecord,
@@ -50,7 +47,6 @@ interface UseSystemSettingsEffectsParams {
autoLaunchEnabled: boolean;
windowOpacityRecord: WindowOpacityRecord;
windowOpacityMutationSourceRef: MutableRefObject<WindowOpacityMutationSource>;
windowBackgroundBlur: boolean;
appIconVariant: AppIconVariant;
autoUpdateEnabled: boolean;
httpNetworkProxy: HttpNetworkProxySettings;
@@ -72,7 +68,6 @@ export function useSystemSettingsEffects({
autoLaunchEnabled,
windowOpacityRecord,
windowOpacityMutationSourceRef,
windowBackgroundBlur,
appIconVariant,
autoUpdateEnabled,
httpNetworkProxy,
@@ -305,49 +300,6 @@ export function useSystemSettingsEffects({
persistMountedRef,
]);
// Window chrome also applies in detached sessions, independently of the
// OS-level effects disabled there (tray, global shortcuts, autolaunch, ...).
// Only the windows that own settings persistence drive the shared native
// state; detached/editor windows consume it (see applySharedWindowSetting).
useEffect(() => {
if (!enabled) return;
localStorageAdapter.writeBoolean(STORAGE_KEY_WINDOW_BACKGROUND_BLUR, windowBackgroundBlur);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_WINDOW_BACKGROUND_BLUR, windowBackgroundBlur);
}, [
enabled,
windowBackgroundBlur,
notifySettingsChanged,
persistMountedRef,
]);
const windowBackgroundBlurActive = isMacPlatform() && isWindowBackgroundBlurActive(
windowBackgroundBlur,
windowOpacityRecord.opacity,
);
// Applies in every app-content window, including detached sessions: the
// transparent backing only looks right when the renderer class matches the
// native vibrancy the main process put on that window.
useEffect(() => {
if (typeof document === 'undefined') return;
document.documentElement.classList.toggle('window-background-blur', windowBackgroundBlurActive);
document.documentElement.style.setProperty('--window-content-opacity', String(windowOpacityRecord.opacity));
return () => {
document.documentElement.classList.remove('window-background-blur');
document.documentElement.style.removeProperty('--window-content-opacity');
};
}, [windowBackgroundBlurActive, windowOpacityRecord.opacity]);
// setWindowBackgroundBlur is a main-process handler that retargets every
// app-content window, so a single owner must drive it.
useEffect(() => {
if (!enabled) return;
netcattyBridge.get()?.setWindowBackgroundBlur?.(windowBackgroundBlur).catch((err) => {
console.warn('[WindowBackgroundBlur] Failed to apply macOS vibrancy:', err);
});
}, [enabled, windowBackgroundBlur]);
// Persist and sync app icon variant
useEffect(() => {
if (!enabled) return;
@@ -1,139 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { JSDOM } from 'jsdom';
import { STORAGE_KEY_WINDOW_BACKGROUND_BLUR } from '../../infrastructure/config/storageKeys';
import { DEFAULT_HTTP_NETWORK_PROXY } from '../../domain/httpNetworkProxy';
import { useSystemSettingsEffects } from './systemSettingsEffects';
type Harness = {
dom: JSDOM;
params: Parameters<typeof useSystemSettingsEffects>[0];
bridgeRequests: boolean[];
blurNotifications: unknown[];
restore: () => void;
};
function setUp(enabled: boolean, windowBackgroundBlur: boolean): Harness {
const dom = new JSDOM('<html><body><div id="root"></div></body></html>', { url: 'http://localhost' });
Object.defineProperty(dom.window.navigator, 'platform', { value: 'MacIntel' });
const previousGlobals = new Map<string, PropertyDescriptor | undefined>();
const replacements = {
window: dom.window,
document: dom.window.document,
navigator: dom.window.navigator,
localStorage: dom.window.localStorage,
IS_REACT_ACT_ENVIRONMENT: true,
};
for (const [key, value] of Object.entries(replacements)) {
previousGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { value, configurable: true });
}
const bridgeRequests: boolean[] = [];
const blurNotifications: unknown[] = [];
Object.assign(dom.window, {
netcatty: {
setWindowBackgroundBlur: async (value: boolean) => { bridgeRequests.push(value); },
},
});
const noop = () => {};
const params: Parameters<typeof useSystemSettingsEffects>[0] = {
enabled,
toggleWindowHotkey: '',
globalHotkeyEnabled: false,
closeToTray: false,
showTrayIcon: false,
autoLaunchEnabled: false,
windowOpacityRecord: { opacity: 0.7, preferredOpacity: 0.7, version: 1 },
windowOpacityMutationSourceRef: { current: 'local' },
windowBackgroundBlur,
appIconVariant: 'original',
autoUpdateEnabled: false,
httpNetworkProxy: DEFAULT_HTTP_NETWORK_PROXY,
persistMountedRef: { current: true },
setHotkeyRegistrationError: noop,
setAutoUpdateEnabled: noop,
setAppIconVariant: noop,
setAutoLaunchEnabled: noop,
setAutoLaunchSupported: noop,
notifySettingsChanged: (key, value) => {
if (key === STORAGE_KEY_WINDOW_BACKGROUND_BLUR) blurNotifications.push(value);
},
};
return {
dom,
params,
bridgeRequests,
blurNotifications,
restore: () => {
for (const [key, descriptor] of previousGlobals) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
dom.window.close();
},
};
}
const Probe = (props: Parameters<typeof useSystemSettingsEffects>[0]) => {
useSystemSettingsEffects(props);
return null;
};
test('opacity changes and remounts preserve the chosen blur preference', async () => {
const harness = setUp(true, true);
const { dom, params, bridgeRequests, blurNotifications, restore } = harness;
let renderer: ReactTestRenderer | undefined;
try {
await act(async () => { renderer = create(React.createElement(Probe, params)); });
assert.equal(document.documentElement.classList.contains('window-background-blur'), true);
const opaqueParams = { ...params, windowOpacityRecord: { ...params.windowOpacityRecord, opacity: 1, version: 2 } };
await act(async () => { renderer!.update(React.createElement(Probe, opaqueParams)); });
assert.equal(document.documentElement.classList.contains('window-background-blur'), false);
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_WINDOW_BACKGROUND_BLUR), 'true');
assert.deepEqual(bridgeRequests, [true]);
assert.deepEqual(blurNotifications, [true]);
await act(async () => { renderer!.unmount(); });
await act(async () => { renderer = create(React.createElement(Probe, opaqueParams)); });
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_WINDOW_BACKGROUND_BLUR), 'true');
await act(async () => { renderer!.update(React.createElement(Probe, params)); });
assert.equal(document.documentElement.classList.contains('window-background-blur'), true);
await act(async () => { renderer!.update(React.createElement(Probe, { ...params, windowBackgroundBlur: false })); });
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_WINDOW_BACKGROUND_BLUR), 'false');
assert.equal(document.documentElement.classList.contains('window-background-blur'), false);
assert.deepEqual(bridgeRequests, [true, true, false]);
} finally {
await act(async () => { renderer?.unmount(); });
restore();
}
});
test('detached session windows only apply the blur class, never own the setting', async () => {
// Peer session windows run with enableSystemEffects off. They must still
// paint the transparent backing, but persistence, cross-window broadcast and
// the (all-windows) native vibrancy IPC stay with the settings owners.
const harness = setUp(false, true);
const { dom, params, bridgeRequests, blurNotifications, restore } = harness;
let renderer: ReactTestRenderer | undefined;
try {
await act(async () => { renderer = create(React.createElement(Probe, params)); });
assert.equal(document.documentElement.classList.contains('window-background-blur'), true);
assert.equal(document.documentElement.style.getPropertyValue('--window-content-opacity'), '0.7');
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_WINDOW_BACKGROUND_BLUR), null);
assert.deepEqual(bridgeRequests, []);
assert.deepEqual(blurNotifications, []);
await act(async () => {
renderer!.update(React.createElement(Probe, { ...params, windowBackgroundBlur: false }));
});
assert.equal(document.documentElement.classList.contains('window-background-blur'), false);
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_WINDOW_BACKGROUND_BLUR), null);
assert.deepEqual(bridgeRequests, []);
assert.deepEqual(blurNotifications, []);
} finally {
await act(async () => { renderer?.unmount(); });
restore();
}
});
Loaded 100 of 818 files, more files were not shown because too many files have changed in this diff. Show more