Compare commits

..
Author SHA1 Message Date
Cursor Agentand陈大猫 794733c3f1 perf(vault): merge the connection-log replay side store once per persist
Each connection-log write parsed the closed-session terminal-data JSON
twice, merged it twice, and JSON.stringify-compared it twice. Pass the
already-merged map through and compare keys without serializing the
multi-MB replay buffers. Storage format and in-memory state stay the same.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-22 04:25:18 +00:00
432 changed files with 1239 additions and 22627 deletions

No files matched your search

-6
View File
@@ -82,8 +82,6 @@ on:
- "scripts/resolve-et-bin-release.cjs"
- "scripts/resolve-mosh-bin-release.cjs"
- "scripts/tray-panel-layout.live.test.cjs"
- "scripts/serial-named-pipe.live.test.cjs"
- "scripts/fixtures/serial/**"
- "scripts/verify-linux-*.sh"
- "scripts/xterm-macos-column-selection.live.test.cjs"
- "skills/**"
@@ -253,10 +251,6 @@ jobs:
- name: Install deps
run: npm ci
- name: Test Windows serial pipe through native Electron
if: matrix.name == 'windows'
run: npx electron scripts/serial-named-pipe.live.test.cjs
- name: Test macOS Option column selection
if: matrix.name == 'macos'
run: npm run test:xterm-macos-selection
-9
View File
@@ -49,12 +49,6 @@ jobs:
- name: Verify Codex App Server schema
run: npm run check:codex-app-server-schema
- name: Verify plugin contract and runtime boundaries
run: |
npm run check:plugin-contract
npm run test:plugin-runtime
node --test --import tsx components/settings/tabs/Plugin*.test.tsx
- name: Test
run: npm test
@@ -97,9 +91,6 @@ jobs:
- name: Install deps
run: npm ci --ignore-scripts
- name: Exercise serial named pipe transport on Windows
run: node --test electron/bridges/serialNamedPipe.test.cjs
- name: Apply production dependency patches
run: npx patch-package --error-on-fail
+3 -6
View File
@@ -106,13 +106,10 @@ Placement rules (`resolveAgentKinds` in `toolSurfaces.cjs`):
**Harness domain (`catalog/harness.cjs`):** Catty executes these locally in `capabilityTools.executeLocalCattyCapability`. The read-only `harness.terminal.read_context` also exposes `netcatty/readContext` to MCP, with scope validation before and after the existing renderer bridge call. It reuses the sidebar reader through the screen snapshot registry (80 rows by default, maximum 300); no script or command runs. Other harness tools remain Catty-only. `harness.web.search` is omitted when web search is not configured.
## Plugin host runtime
## Plugin host runtime (internal preview)
The plugin host under `electron/plugins/` exposes browser-only packages in normal
Settings. `NETCATTY_PLUGIN_DEV=1` admits advanced development packages, without
granting their permissions. Admission and default placement share
`domain/pluginRuntimePolicy.cjs`; permission explanations shared with native
prompts live in `domain/pluginPermissionDescriptions.cjs`. Public wire and package
The phase-2 plugin host lives under `electron/plugins/` and is disabled unless
`NETCATTY_PLUGIN_DEV=1` is present at application launch. Public wire and package
types still come only from `packages/plugin-contract/schema/`; do not add a
second private RPC shape when extending the host.
+1 -3
View File
@@ -181,9 +181,7 @@ Organize and navigate your hosts using the view that best fits the moment: grid
## Monitoring View
Inspect CPU, memory, disk, network, and latency readings, with GPU details where available. Choose visible columns, resize them, select refresh intervals down to 1 second, and open a terminal directly from a monitored host.
Disk I/O shows aggregate read/write throughput for Linux hardware-backed block devices, without counting partitions or stacked virtual devices twice. Rates use two consecutive samples; `--` means unavailable or warming up, not zero activity. Disk I/O is currently unavailable on macOS; existing macOS monitoring is unchanged. The terminal system overview uses the same shared measurements.
Inspect CPU, memory, disk, network, and latency readings, with GPU details where available. Choose visible columns, resize them, adjust refresh controls, and open a terminal directly from a monitored host.
<img width="3364" height="1982" alt="image" src="docs/assets/readme/monitoring.webp" />
@@ -1,5 +1,4 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import {
@@ -9,11 +8,6 @@ import {
handleGlobalHotkeyKeyDownImpl,
markForwardedNativeShortcutEvent,
} from './app/AppHandlers.ts';
import {
DEFAULT_SETTINGS_CHROME_SNAPSHOT,
getSettingsChromeSnapshot,
publishSettingsChromeSnapshot,
} from './state/settingsChromeStore.ts';
import { matchesKeyBinding } from '../domain/models.ts';
import { DEFAULT_KEY_BINDINGS } from '../domain/models/keyBindings.ts';
@@ -835,201 +829,3 @@ for (const scenario of [
});
}
}
for (const scheme of ['pc', 'mac'] as const) {
test(`focused terminal ${scheme} consumes Shift+B for the host list and leaves plain B to broadcast`, () => {
const target = new FakeHTMLElement();
const shiftedActions: string[] = [];
let shiftedPrevented = false;
let shiftedStopped = false;
handleGlobalHotkeyKeyDownImpl(() => ({
HOTKEY_DEBUG: false,
closeTabKeyStr: '',
executeHotkeyAction: (action: string) => {
shiftedActions.push(action);
return action === 'toggleHostTree';
},
hotkeyScheme: scheme,
keyBindings: DEFAULT_KEY_BINDINGS,
matchesKeyBinding,
}), {
key: 'b',
code: 'KeyB',
ctrlKey: scheme === 'pc',
metaKey: scheme === 'mac',
altKey: false,
shiftKey: true,
target,
composedPath: () => [target],
preventDefault: () => { shiftedPrevented = true; },
stopPropagation: () => { shiftedStopped = true; },
} as unknown as KeyboardEvent);
assert.deepEqual(shiftedActions, ['toggleHostTree']);
assert.equal(shiftedPrevented, true);
assert.equal(shiftedStopped, true);
const plainActions: string[] = [];
let plainPrevented = false;
handleGlobalHotkeyKeyDownImpl(() => ({
HOTKEY_DEBUG: false,
closeTabKeyStr: '',
executeHotkeyAction: (action: string) => {
plainActions.push(action);
return undefined;
},
hotkeyScheme: scheme,
keyBindings: DEFAULT_KEY_BINDINGS,
matchesKeyBinding,
}), {
key: 'b',
code: 'KeyB',
ctrlKey: scheme === 'pc',
metaKey: scheme === 'mac',
altKey: false,
shiftKey: false,
target,
composedPath: () => [target],
preventDefault: () => { plainPrevented = true; },
stopPropagation: () => {},
} as unknown as KeyboardEvent);
assert.deepEqual(plainActions, ['broadcast']);
assert.equal(plainPrevented, false);
});
}
test('focused terminal leaves the host-list chord alone when the toggle does not run', () => {
const target = new FakeHTMLElement();
const actions: string[] = [];
let prevented = false;
let stopped = false;
handleGlobalHotkeyKeyDownImpl(() => ({
HOTKEY_DEBUG: false,
closeTabKeyStr: '',
executeHotkeyAction: (action: string) => {
actions.push(action);
return false;
},
hotkeyScheme: 'pc',
keyBindings: DEFAULT_KEY_BINDINGS,
matchesKeyBinding,
}), {
key: 'b',
code: 'KeyB',
ctrlKey: true,
metaKey: false,
altKey: false,
shiftKey: true,
target,
composedPath: () => [target],
preventDefault: () => { prevented = true; },
stopPropagation: () => { stopped = true; },
} as unknown as KeyboardEvent);
assert.deepEqual(actions, ['toggleHostTree']);
assert.equal(prevented, false);
assert.equal(stopped, false);
});
test('host list shortcut reaches a focused Monaco editor and stays out of plain text fields', () => {
const monaco = new FakeMonacoHTMLElement();
const monacoActions: string[] = [];
let monacoPrevented = false;
handleGlobalHotkeyKeyDownImpl(() => ({
HOTKEY_DEBUG: false,
closeTabKeyStr: '',
executeHotkeyAction: (action: string) => {
monacoActions.push(action);
return true;
},
hotkeyScheme: 'pc',
keyBindings: DEFAULT_KEY_BINDINGS,
matchesKeyBinding,
}), {
key: 'b',
code: 'KeyB',
ctrlKey: true,
metaKey: false,
altKey: false,
shiftKey: true,
target: monaco,
composedPath: () => [monaco],
preventDefault: () => { monacoPrevented = true; },
stopPropagation: () => {},
} as unknown as KeyboardEvent);
assert.deepEqual(monacoActions, ['toggleHostTree']);
assert.equal(monacoPrevented, true);
const input = new FakeInputHTMLElement();
const inputActions: string[] = [];
let inputPrevented = false;
handleGlobalHotkeyKeyDownImpl(() => ({
HOTKEY_DEBUG: false,
closeTabKeyStr: '',
executeHotkeyAction: (action: string) => {
inputActions.push(action);
return true;
},
hotkeyScheme: 'pc',
keyBindings: DEFAULT_KEY_BINDINGS,
matchesKeyBinding,
}), {
key: 'b',
code: 'KeyB',
ctrlKey: true,
metaKey: false,
altKey: false,
shiftKey: true,
target: input,
composedPath: () => [input],
preventDefault: () => { inputPrevented = true; },
stopPropagation: () => {},
} as unknown as KeyboardEvent);
assert.deepEqual(inputActions, []);
assert.equal(inputPrevented, false);
});
test('toggleHostTree uses the host list toggle only while that list can show', () => {
const previous = getSettingsChromeSnapshot();
const calls: string[] = [];
const toggleHostTree = () => { calls.push('toggle'); };
const event = {} as KeyboardEvent;
const contextFor = (activeTabId: string, orderedTabs: string[] = [activeTabId]) => ({
activeTabStore: { getActiveTabId: () => activeTabId },
editorTabs: [],
orderedTabs,
sessions: [{ id: 'session-1' }],
settings: {},
toEditorTabId: (id: string) => id,
toggleHostTree,
workspaces: [{ id: 'workspace-1' }],
});
try {
publishSettingsChromeSnapshot({ ...DEFAULT_SETTINGS_CHROME_SNAPSHOT, showHostTreeSidebar: true });
assert.equal(executeHotkeyActionImpl(() => contextFor('session-1'), 'toggleHostTree', event), true);
assert.equal(executeHotkeyActionImpl(() => contextFor('editor:note', []), 'toggleHostTree', event), true);
assert.equal(executeHotkeyActionImpl(() => contextFor('workspace-1'), 'toggleHostTree', event), true);
assert.deepEqual(calls, ['toggle', 'toggle', 'toggle']);
calls.length = 0;
assert.equal(executeHotkeyActionImpl(() => contextFor('vault', []), 'toggleHostTree', event), false);
assert.equal(executeHotkeyActionImpl(() => contextFor('sftp', []), 'toggleHostTree', event), false);
assert.equal(executeHotkeyActionImpl(() => contextFor('plugin-view:demo:main', ['plugin-view:demo:main']), 'toggleHostTree', event), false);
assert.deepEqual(calls, []);
publishSettingsChromeSnapshot({ ...DEFAULT_SETTINGS_CHROME_SNAPSHOT, showHostTreeSidebar: false });
assert.equal(executeHotkeyActionImpl(() => contextFor('session-1'), 'toggleHostTree', event), false);
assert.deepEqual(calls, []);
} finally {
publishSettingsChromeSnapshot(previous);
}
});
test('host list shortcut calls the same store toggle as the toolbar button', () => {
const source = readFileSync(new URL('./app/AppSideEffects.tsx', import.meta.url), 'utf8');
assert.match(source, /toggleHostTree:\s*\(\)\s*=>\s*terminalHostTreeStore\.toggle\(\)/);
});
+6 -30
View File
@@ -9,8 +9,6 @@ import { getAvailablePaneMagnificationController } from '../../domain/paneMagnif
import { sanitizeHostIconFields } from '../../domain/hostIcon';
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
import { getSettingsChromeSnapshot } from '../state/settingsChromeStore';
import { isHostTreeWorkTabSurface } from './workTabSurface';
import { tabShortcutDigitFromEvent } from '../../domain/models/keyBindings';
import { isPortForwardingAutoReconnectEnabled } from '../../domain/portForwardingReconnect';
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
@@ -278,9 +276,6 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
const paneZoomBinding = keyBindings.find((binding) => binding.action === 'togglePaneZoom');
const paneZoomKeyStr = paneZoomBinding ? (isMac ? paneZoomBinding.mac : paneZoomBinding.pc) : null;
const isPaneZoomHotkey = paneZoomKeyStr ? matchesKeyBinding(e, paneZoomKeyStr, isMac) : false;
const hostTreeBinding = keyBindings.find((binding) => binding.action === 'toggleHostTree');
const hostTreeKeyStr = hostTreeBinding ? (isMac ? hostTreeBinding.mac : hostTreeBinding.pc) : null;
const isHostTreeHotkey = hostTreeKeyStr ? matchesKeyBinding(e, hostTreeKeyStr, isMac) : false;
if (
(isFormElement || isMonacoElement)
@@ -288,7 +283,6 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
&& e.key !== 'Escape'
&& !isQuickSwitchHotkey
&& !isPaneZoomHotkey
&& !(isMonacoElement && isHostTreeHotkey)
&& !forwardedNativeShortcutEvents.has(e)
) {
return;
@@ -328,10 +322,9 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
continue;
}
// An unavailable action must leave the chord to the terminal.
// Broadcast keeps Ctrl/Cmd+B for tmux; the host list keeps Ctrl/Cmd+Shift+B
// when the sidebar cannot show. Dispatch once before consuming it.
if (binding.action === 'broadcast' || binding.action === 'toggleHostTree') {
// An unavailable broadcast action must leave the chord to the terminal
// (notably tmux's Ctrl+B prefix). Dispatch once before consuming it.
if (binding.action === 'broadcast') {
if (executeHotkeyAction(binding.action, e) === true) {
e.preventDefault();
e.stopPropagation();
@@ -808,7 +801,7 @@ export async function closeTabsBatchImpl(getCtx: AppContextGetter, targetIds: st
}
export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string, e: KeyboardEvent) {
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, canUseGlobalBroadcast, closePluginViewTab, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isPluginViewTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, sftpPaneMagnificationRef, splitSessionWithCurrentShell, systemInfoRef, terminalPaneMagnificationRef, toEditorTabId, toggleBroadcast, toggleGlobalBroadcast, toggleHostTree, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWindowOpacity, workspaces } = getCtx();
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, canUseGlobalBroadcast, closePluginViewTab, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isPluginViewTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, sftpPaneMagnificationRef, splitSessionWithCurrentShell, systemInfoRef, terminalPaneMagnificationRef, toEditorTabId, toggleBroadcast, toggleGlobalBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWindowOpacity, workspaces } = getCtx();
{
const shortcutTabs = buildNumberShortcutTabTargets({
showSftpTab: settings.showSftpTab ?? true,
@@ -1004,18 +997,6 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
case 'toggleSidePanel':
toggleSidePanelRef.current?.();
break;
case 'toggleHostTree': {
const currentId = activeTabStore.getActiveTabId();
if (!isHostTreeWorkTabSurface({
enabled: getSettingsChromeSnapshot().showHostTreeSidebar,
activeTabId: currentId,
orderedTabs: orderedTabs ?? [],
sessionIds: new Set((sessions ?? []).map((session: TerminalSession) => session.id)),
workspaceIds: new Set((workspaces ?? []).map((workspace: { id: string }) => workspace.id)),
}) || typeof toggleHostTree !== 'function') return false;
toggleHostTree();
return true;
}
case 'broadcast': {
// Workspace tabs keep their local broadcast state; orphan tabs share
// the global state when at least two visible orphan tabs exist.
@@ -1164,7 +1145,6 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host, hi
if (effectiveHost.protocol === 'serial') {
const portName = host.hostname.split('/').pop() || host.hostname;
const sessionId = connectToHost(effectiveHost, { hidden });
if (!sessionId) return null;
addConnectionLog({
sessionId,
hostId: host.id,
@@ -1184,7 +1164,6 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host, hi
const protocol = resolveEffectiveTerminalProtocol(effectiveHost);
const resolvedAuth = resolveHostAuth({ host: effectiveHost, keys, identities });
const sessionId = connectToHost(effectiveHost, { hidden });
if (!sessionId) return null;
addConnectionLog({
sessionId,
hostId: host.id,
@@ -1205,7 +1184,7 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host, hi
}
}
export async function handleTerminalDataCaptureImpl(getCtx: AppContextGetter, sessionId: string, data: string) {
export function handleTerminalDataCaptureImpl(getCtx: AppContextGetter, sessionId: string, data: string) {
const { IS_DEV, connectionLogs, selectConnectionLogForTerminalDataCapture, sessions, updateConnectionLog } = getCtx();
{
if (IS_DEV) console.log('[handleTerminalDataCapture] Called', { sessionId, dataLength: data.length });
@@ -1221,10 +1200,7 @@ export async function handleTerminalDataCaptureImpl(getCtx: AppContextGetter, se
if (IS_DEV) console.log('[handleTerminalDataCapture] Matching log', matchingLog);
if (matchingLog) {
// The IndexedDB replay write commits asynchronously. Waiting here keeps
// a crash during session teardown from dropping the newest capture any
// more often than the old synchronous localStorage write.
await updateConnectionLog(matchingLog.id, {
updateConnectionLog(matchingLog.id, {
endTime: Date.now(),
terminalData: data,
});
@@ -1,67 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { ConnectionLog, Host } from '../../domain/models';
import { createLocalShellHost } from '../../domain/localShellHost';
import { selectConnectionLogForTerminalDataCapture } from '../../domain/connectionLog';
import { handleConnectToHostImpl, handleTerminalDataCaptureImpl } from './AppHandlers';
test('workspace host launch records resolved metadata and associates replay with its acknowledged session', async () => {
const host = createLocalShellHost({ id: 'saved-local', label: 'Work shell' });
const connectionLogs: ConnectionLog[] = [];
let appendedHost: Host | undefined;
const getCtx = () => ({
resolveEffectiveHost: (candidate: Host) => candidate,
discoveredShells: [{ id: 'zsh', name: 'Z shell', command: '/bin/zsh' }],
terminalSettings: { localShell: 'zsh', localShellArgs: ['-l'], localStartDir: '/tmp/work' },
resolveShellSetting: () => ({ command: '/bin/zsh', args: ['-l'] }),
systemInfoRef: { current: { username: 'local-user', hostname: 'local-machine' } },
keys: [], identities: [], resolveHostAuth: () => ({ username: 'ignored-ssh-user' }),
// The App adapts this creation callback to appendHostToWorkspaceConfirmed.
connectToHost: (candidate: Host) => {
assert.equal(connectionLogs.length, 0, 'do not record history before insertion is acknowledged');
appendedHost = candidate;
return 'workspace-session';
},
addConnectionLog: (entry: Omit<ConnectionLog, 'id'>) => {
connectionLogs.push({ ...entry, id: 'workspace-log' });
},
});
assert.equal(handleConnectToHostImpl(getCtx, host), 'workspace-session');
assert.equal(appendedHost?.localShell, '/bin/zsh');
assert.deepEqual(appendedHost?.localShellArgs, ['-l']);
assert.equal(appendedHost?.localStartDir, '/tmp/work');
assert.equal(connectionLogs.length, 1);
assert.equal(connectionLogs[0].sessionId, 'workspace-session');
assert.equal(connectionLogs[0].hostId, host.id);
assert.equal(connectionLogs[0].hostLabel, 'Work shell');
assert.equal(connectionLogs[0].username, 'local-user');
assert.equal(connectionLogs[0].protocol, 'local');
assert.equal(connectionLogs[0].localHostname, 'local-machine');
const updates: Array<{ id: string; terminalData: string }> = [];
await handleTerminalDataCaptureImpl(() => ({
IS_DEV: false,
sessions: [{ id: 'workspace-session', hostname: 'localhost' }],
// A newer, same-host log must not steal this session's capture.
connectionLogs: [...connectionLogs, { ...connectionLogs[0], id: 'other-log', sessionId: 'other-session', startTime: Date.now() + 100 }],
selectConnectionLogForTerminalDataCapture,
updateConnectionLog: async (id: string, entry: { terminalData: string }) => { updates.push({ id, terminalData: entry.terminalData }); },
}), 'workspace-session', 'captured terminal output');
assert.deepEqual(updates, [{ id: 'workspace-log', terminalData: 'captured terminal output' }]);
});
for (const protocol of ['local', 'ssh', 'serial'] as const) {
test(`refused workspace insertion creates no ${protocol} connection log`, () => {
let logs = 0;
const host = { id: 'host', label: 'Host', hostname: 'target', protocol } as Host;
const sessionId = handleConnectToHostImpl(() => ({
resolveEffectiveHost: (candidate: Host) => candidate,
discoveredShells: [], terminalSettings: {}, resolveShellSetting: () => null,
systemInfoRef: { current: { username: 'local-user', hostname: 'local-machine' } },
keys: [], identities: [], resolveHostAuth: () => ({ username: 'remote-user' }),
connectToHost: () => null,
addConnectionLog: () => { logs += 1; },
}), host);
assert.equal(sessionId, null);
assert.equal(logs, 0);
});
}
+7 -15
View File
@@ -56,7 +56,6 @@ import {
} from '../../domain/terminalAppearance';
import { selectConnectionLogForTerminalDataCapture } from '../../domain/connectionLog';
import { collectSessionIds } from '../../domain/workspace';
import { resolveAgentHostWorkspace } from '../../domain/agentHostWorkspace';
import { useHostOpenSessionReuse } from '../state/useHostOpenSessionReuse';
import type { PaneMagnificationController } from '../../domain/paneMagnification';
import { resolveCloseIntent } from '../state/resolveCloseIntent';
@@ -71,7 +70,6 @@ import {
import { getCredentialProtectionAvailability } from '../../infrastructure/services/credentialProtection';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { terminalHostTreeStore } from '../state/terminalHostTreeStore';
import {
markExternalMcpStartupReady,
readExternalMcpFocusOnHostOpen,
@@ -256,7 +254,6 @@ export function AppSideEffects() {
createLocalTerminal,
createSerialSession,
connectToHost,
appendHostToWorkspaceConfirmed,
closeSession,
closeSessions,
closeWorkspace,
@@ -1129,7 +1126,6 @@ export function AppSideEffects() {
toggleBroadcast,
toggleGlobalBroadcast,
canUseGlobalBroadcast: canUseGlobalBroadcastRef.current,
toggleHostTree: () => terminalHostTreeStore.toggle(),
toggleScriptsSidePanelRef,
toggleSidePanelRef,
toggleWindowOpacity,
@@ -1460,7 +1456,7 @@ export function AppSideEffects() {
temporaryConnectionSettingsRef.current = terminalSettings;
// Wrapper to connect to host with logging
const handleConnectToHost = useCallback((host: Host, alreadyEffective = false, hidden = false, workspaceId?: string) => {
const handleConnectToHost = useCallback((host: Host, alreadyEffective = false, hidden = false) => {
const liveTerminalSettings = temporaryConnectionSettingsRef.current;
const connectionHost = prepareConnectionHost(
host,
@@ -1478,9 +1474,7 @@ export function AppSideEffects() {
: resolveEffectiveHost;
return handleConnectToHostImpl(() => ({
addConnectionLog,
connectToHost: workspaceId
? (candidate: Host) => appendHostToWorkspaceConfirmed(workspaceId, candidate)
: connectToHost,
connectToHost,
discoveredShells,
host: connectionHost,
identities,
@@ -1491,7 +1485,7 @@ export function AppSideEffects() {
systemInfoRef,
terminalSettings: liveTerminalSettings,
}), connectionHost, hidden);
}, [addConnectionLog, appendHostToWorkspaceConfirmed, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells]);
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells]);
const handleConnectToHostWithTabName = useCallback((host: Host, tabName?: string) => {
const sessionId = handleConnectToHost(host);
@@ -1501,14 +1495,12 @@ export function AppSideEffects() {
return sessionId;
}, [handleConnectToHost, renameSessionInline]);
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 };
const openHostForVaultAgent = useCallback((host: Host, isExternalMcpCall: boolean) => {
// Silent sessions only apply to actual external MCP clients (chatSessionId
// equals the reserved external-MCP scope). The in-app Catty AI chat's
// host_open always opens a visible tab, as documented.
const hidden = isExternalMcpCall && readExternalMcpSilentSessions();
const sessionId = handleConnectToHost(host, true, hidden, target.workspaceId);
const sessionId = handleConnectToHost(host, true, hidden);
if (!sessionId) {
return { ok: false as const, error: `Failed to open host "${host.id}".` };
}
@@ -1517,8 +1509,8 @@ export function AppSideEffects() {
if (readExternalMcpFocusOnHostOpen()) {
void netcattyBridge.get()?.openMainWindow?.();
}
return { ok: true as const, sessionId, host, workspaceId: target.workspaceId };
}, [handleConnectToHost, workspaces]);
return { ok: true as const, sessionId, host };
}, [handleConnectToHost]);
const findExistingSessionForHostForVaultAgent = useHostOpenSessionReuse({
sessions, hosts, keys, identities, resolveEffectiveHost,
+1 -5
View File
@@ -272,7 +272,7 @@ function AppViewInner({ domains }: AppViewProps) {
handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit,
handleOpenHostFromVaultNote, handleOpenQuickSwitcher, handleOpenSettings, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleFidoPromptSubmit, handleFidoPromptCancel, handleProtocolSelect,
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleUpdateHostFromTerminal,
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isComposeSendToAll, onToggleComposeSendTarget, isGlobalBroadcastEnabled, isGlobalComposeSendToAll, onToggleGlobalBroadcast, onToggleGlobalComposeSendTarget, canUseGlobalBroadcast, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isGlobalBroadcastEnabled, onToggleGlobalBroadcast, canUseGlobalBroadcast, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
passphraseQueue, fidoPromptQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
resolveEmptyVaultConflict, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
@@ -784,12 +784,8 @@ function AppViewInner({ domains }: AppViewProps) {
onCreateLocalTerminal={handleCreateLocalTerminal}
isBroadcastEnabled={isBroadcastEnabled}
onToggleBroadcast={toggleBroadcast}
isComposeSendToAll={isComposeSendToAll}
onToggleComposeSendTarget={onToggleComposeSendTarget}
isGlobalBroadcastEnabled={isGlobalBroadcastEnabled}
isGlobalComposeSendToAll={isGlobalComposeSendToAll}
onToggleGlobalBroadcast={onToggleGlobalBroadcast}
onToggleGlobalComposeSendTarget={onToggleGlobalComposeSendTarget}
canUseGlobalBroadcast={canUseGlobalBroadcast}
updateHosts={updateTerminalHosts}
updateSnippets={updateSnippets}
@@ -1,43 +0,0 @@
// A separate renderer thread lets the real main-process timeout run while
// window.confirm is synchronously blocked, just like Electron's native dialog.
require("tsx/cjs");
const { parentPort, workerData } = require("node:worker_threads");
const { EventEmitter } = require("node:events");
const React = require("react");
const { act, create } = require("react-test-renderer");
const { useAppStartupEffects } = require("../useAppStartupEffects.ts");
const { sftpTransferCenterStore } = require("../../state/sftpTransferCenterStore.ts");
const { netcattyBridge } = require("../../../infrastructure/services/netcattyBridge.ts");
const { createPreloadApi } = require("../../../electron/preload/api.cjs");
const signal = new Int32Array(workerData.signal);
let resumes = 0;
global.IS_REACT_ACT_ENVIRONMENT = true;
global.window = {
confirm() {
parentPort.postMessage({ type: "prompt" });
Atomics.wait(signal, 0, 0, 10000);
return workerData.accept;
},
};
sftpTransferCenterStore.getSnapshot = () => ({ tasks: [{ id: "held-prompt", status: "transferring" }] });
sftpTransferCenterStore.pause = async () => {};
sftpTransferCenterStore.resume = async () => { resumes++; };
const ipcRenderer = Object.assign(new EventEmitter(), {
send(channel, payload) { parentPort.postMessage({ type: "reply", channel, payload }); },
});
const preload = createPreloadApi({ ipcRenderer, webUtils: {} });
netcattyBridge.get = () => ({
onCheckDirtyEditors(callback) {
return preload.onCheckDirtyEditors((request) => {
void callback(request).then(() => parentPort.postMessage({ type: "done", resumes }));
});
},
reportDirtyEditorsResult: preload.reportDirtyEditorsResult,
});
function Probe() {
useAppStartupEffects({ enabled: false, updateState: {}, sessions: [], hosts: [], keys: [], identities: [], knownHosts: [], proxyProfiles: [], groupConfigs: [], workspaces: [], portForwardingRules: [], t: (key) => key });
return null;
}
parentPort.on("message", ({ channel, payload }) => ipcRenderer.emit(channel, {}, payload));
void act(async () => { create(React.createElement(Probe)); })
.then(() => parentPort.postMessage({ type: "ready" }));
-4
View File
@@ -205,11 +205,7 @@ export function TerminalHost() {
updateTerminalHosts: handlers.updateTerminalHosts,
hotkeyScheme: terminalSettings.hotkeyScheme,
isBroadcastEnabled: sessionActions?.isBroadcastEnabled,
isComposeSendToAll: sessionActions?.isComposeSendToAll,
onToggleComposeSendTarget: sessionActions?.toggleComposeSendTarget,
isGlobalBroadcastEnabled: sessionActions?.isGlobalBroadcastEnabled,
isGlobalComposeSendToAll: sessionActions?.isGlobalComposeSendToAll,
onToggleGlobalComposeSendTarget: sessionActions?.toggleGlobalComposeSendTarget,
canUseGlobalBroadcast: sessionActions?.canUseGlobalBroadcast,
keyBindings: terminalSettings.keyBindings,
openNoteRequest: local.openNoteRequest,
@@ -59,7 +59,6 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
createWorkspaceFromSessions,
addSessionToWorkspace,
appendHostToWorkspace,
appendHostToWorkspaceConfirmed,
appendLocalTerminalToWorkspace,
createWorkspaceFromTargets,
updateSplitSizes,
@@ -71,12 +70,8 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
reorderTabs,
toggleBroadcast,
isBroadcastEnabled,
toggleComposeSendTarget,
isComposeSendToAll,
toggleGlobalBroadcast,
isGlobalBroadcastEnabled,
toggleGlobalComposeSendTarget,
isGlobalComposeSendToAll,
canUseGlobalBroadcast,
copySession,
copyWorkspace,
@@ -144,7 +139,6 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
createWorkspaceFromSessions,
addSessionToWorkspace,
appendHostToWorkspace,
appendHostToWorkspaceConfirmed,
appendLocalTerminalToWorkspace,
createWorkspaceFromTargets,
updateSplitSizes,
@@ -156,12 +150,8 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
reorderTabs,
toggleBroadcast,
isBroadcastEnabled,
toggleComposeSendTarget,
isComposeSendToAll,
toggleGlobalBroadcast,
isGlobalBroadcastEnabled,
toggleGlobalComposeSendTarget,
isGlobalComposeSendToAll,
canUseGlobalBroadcast,
copySession,
copyWorkspace,
@@ -173,7 +163,6 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
}), [
addSessionToWorkspace,
appendHostToWorkspace,
appendHostToWorkspaceConfirmed,
appendLocalTerminalToWorkspace,
clearSessionFontSizeOverride,
closeLogView,
@@ -192,9 +181,7 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
getOrderedWorkTabs,
getSessionRestoreCwd,
isBroadcastEnabled,
isComposeSendToAll,
isGlobalBroadcastEnabled,
isGlobalComposeSendToAll,
moveFocusInWorkspace,
openLogView,
canUseGlobalBroadcast,
@@ -214,9 +201,7 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
submitSessionRename,
submitWorkspaceRename,
toggleBroadcast,
toggleComposeSendTarget,
toggleGlobalBroadcast,
toggleGlobalComposeSendTarget,
toggleWorkspaceViewMode,
updateSessionCodingCliProvider,
updateSessionDynamicTitle,
@@ -165,12 +165,8 @@ test('vault init cancels the superseded StrictMode effect before publishing read
);
const initAt = vaultSource.indexOf('let cancelled = false;');
assert.notEqual(initAt, -1, 'vault init must track cancellation');
const initSlice = vaultSource.slice(initAt, initAt + 20000);
assert.match(
initSlice,
/return \(\) => \{\s*\n\s*cancelled = true;\s*\n\s*resolveTerminalDataReady\(\);\s*\n\s*\};/,
'StrictMode cleanup cancels init and unblocks the terminal-data ready promise',
);
const initSlice = vaultSource.slice(initAt, initAt + 12000);
assert.match(initSlice, /return \(\) => \{\s*\n\s*cancelled = true;\s*\n\s*\};/);
assert.match(
initSlice,
/if \(!cancelled\) \{\s*\n\s*setIsInitialized\(true\);\s*\n\s*setVaultInitialized\(true\);\s*\n\s*\}/,
@@ -1,114 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any -- Electron boundary doubles exercise production modules. */
import test from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { createRequire } from 'node:module';
import { useAppStartupEffects } from './useAppStartupEffects.ts';
import { sftpTransferCenterStore } from '../state/sftpTransferCenterStore.ts';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge.ts';
const require = createRequire(import.meta.url);
const React = require('react');
const { act, create } = require('react-test-renderer');
const { createMainWindowApi } = require('../../electron/bridges/windowManager/mainWindow.cjs');
const { queryDirtyEditors } = require('../../electron/bridges/dirtyEditorGuard.cjs');
const { createPreloadApi } = require('../../electron/preload/api.cjs');
// No Electron UI, sockets, real transfers, or preferences. Only the network
// pause completion is held. Actual renderer hook, window close callback, and
// dirty-query helper run together with the actual 5000ms production deadline.
for (const slowPause of [true, false]) test(`${slowPause ? 'slow pause refuses close at the production deadline' : 'control: fast pause preserves Cancel'}`, { timeout: 8000 }, async t => {
const oldWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const oldAct = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
let confirmations = 0, reports = 0, resumeCalls = 0;
Object.defineProperty(globalThis, 'window', { configurable: true, value: { confirm() { confirmations++; return false; } } });
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
let renderer: any, releasePause: any;
const queryPromises: Promise<void>[] = [];
const pause = new Promise<void>(resolve => { releasePause = resolve; });
if (!slowPause) releasePause();
const ipcMain = new EventEmitter();
let win: any;
t.after(async () => {
releasePause(); await Promise.allSettled(queryPromises);
await act(async () => renderer?.unmount());
if (oldWindow) Object.defineProperty(globalThis, 'window', oldWindow); else Reflect.deleteProperty(globalThis, 'window');
if (oldAct) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', oldAct); else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
});
t.mock.method(sftpTransferCenterStore, 'getSnapshot', () => ({ tasks: [{ id: 'slow-upload', status: 'transferring' }] }) as any);
t.mock.method(sftpTransferCenterStore, 'pause', () => pause);
t.mock.method(sftpTransferCenterStore, 'resume', async () => { resumeCalls++; });
const ipcRenderer = Object.assign(new EventEmitter(), {
send(channel: string, payload: any) { reports++; ipcMain.emit(channel, { sender: win.webContents }, payload); },
});
const preload = createPreloadApi({ ipcRenderer, webUtils: {} });
t.mock.method(netcattyBridge, 'get', () => ({
onCheckDirtyEditors(callback: any) {
return preload.onCheckDirtyEditors((request: any) => { queryPromises.push(callback(request)); });
},
reportDirtyEditorsResult: preload.reportDirtyEditorsResult,
}));
function Probe() {
useAppStartupEffects({ enabled: false, updateState: {}, sessions: [], hosts: [], keys: [], identities: [], knownHosts: [], proxyProfiles: [], groupConfigs: [], workspaces: [], portForwardingRules: [], t: (key: string) => key } as any);
return null;
}
await act(async () => { renderer = create(React.createElement(Probe)); });
class Window extends EventEmitter {
destroyed = false;
webContents = Object.assign(new EventEmitter(), {
id: 71, isDestroyed: () => this.destroyed, isCrashed: () => false,
setWindowOpenHandler() {}, openDevTools() {},
send(channel: string, payload: any) { ipcRenderer.emit(channel, {}, payload); },
});
close() { const event = { prevented: false, preventDefault() { this.prevented = true; } }; this.emit('close', event); if (!event.prevented) { this.destroyed = true; this.emit('closed'); } }
isDestroyed() { return this.destroyed; }
isMaximized() { return false; }
isFullScreen() { return false; }
getBounds() { return { width: 1000, height: 700 }; }
setBackgroundColor() {}
loadURL() { return Promise.resolve(); }
}
const api = createMainWindowApi({
mainWindow: null, electronApp: null, currentTheme: 'light', isQuitting: false,
pendingWindowStateWrite: null, queuedWindowState: null,
DEFAULT_WINDOW_WIDTH: 1400, DEFAULT_WINDOW_HEIGHT: 900, MIN_WINDOW_WIDTH: 1100, MIN_WINDOW_HEIGHT: 640,
V8_CACHE_OPTIONS: 'code', THEME_COLORS: { light: {} },
unhealthyWebContentsIds: new Set(), rendererReadySeenByWebContentsId: new Set(),
__dirname: '/unused/electron/bridges', URL, require, console, setTimeout, clearTimeout,
getGlobalShortcutBridge: () => ({ handleWindowClose: () => false }), getMainWindowCount: () => 1,
debugLog() {}, resolveFrontendBackgroundColor() { return null; }, loadWindowState() { return null; },
getDevRendererBaseUrl: (url: string) => url, getWindowBoundsState() { return null; },
queueWindowStateSave() {}, saveWindowStateSync() {}, setupDeferredShow() {},
createExternalOnlyWindowOpenHandler() { return {}; }, createAppWindowOpenHandler() { return {}; },
attachOAuthLoadingOverlay() {}, registerWindowHandlers() {}, requestWindowCommandClose() {}, shouldCloseWindowFromInput() { return false; },
registerMainWindow() {}, unregisterMainWindow() {}, registerAppContentWindow() {}, unregisterAppContentWindow() {},
notifyAppContentWindowClosed() {}, applyWindowOpacityToWindow() {}, closeSettingsWindow() {}, hideSettingsWindow() {},
hasEditorTabsForSource: () => false, closeEditorTabsForSource: async () => ({ success: true }),
queryDirtyEditors(wc: any, timeout: number, options: any) {
assert.equal(timeout, 5000, 'real close callback supplies the 5-second timeout');
return queryDirtyEditors(wc, timeout, options);
},
});
win = await api.createWindow({ BrowserWindow: Window, nativeTheme: {}, app: {}, screen: {}, shell: {}, ipcMain }, {
preload: '/tmp/unused-preload.cjs', devServerUrl: 'http://localhost:5173', isDev: true, isMac: false,
electronDir: '/unused/electron', registerAsMainWindow: true,
});
win.close();
assert.equal(win.destroyed, false, 'first close is correctly guarded');
await new Promise(resolve => setTimeout(resolve, slowPause ? 5250 : 50));
if (!slowPause) {
assert.equal(confirmations, 1); assert.equal(reports, 2); assert.equal(resumeCalls, 1);
assert.equal(win.destroyed, false, 'Cancel keeps owner alive when pause settles before deadline');
console.log('QUIT_FAST_CONTROL', JSON.stringify({ ownerAlive: true, confirmations, reports, resumeCalls }));
return;
}
assert.equal(confirmations, 0, 'slow pause prevents prompt');
assert.equal(win.destroyed, false, 'deadline refuses close while the pause is pending');
assert.equal(reports, 1, 'only the pending-transfer acknowledgement has been sent');
assert.equal(ipcMain.listenerCount('app:dirty-editors-result'), 0, 'main query is settled, not hung');
releasePause(); await Promise.allSettled(queryPromises);
await new Promise(resolve => setTimeout(resolve, 10));
assert.equal(confirmations, 0, 'the expired request must not show a late prompt');
assert.equal(win.destroyed, false);
assert.equal(resumeCalls, 1);
console.log('QUIT_TIMEOUT_REGRESSION', JSON.stringify({ ownerAlive: true, productionDeadlineMs: 5000, scaling: false, resumeCalls }));
});
@@ -1,52 +0,0 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter, once } = require("node:events");
const { Worker } = require("node:worker_threads");
const { queryDirtyEditors } = require("../../electron/bridges/dirtyEditorGuard.cjs");
for (const accept of [false, true]) {
test(`native confirmation held beyond deadline: late ${accept ? "Accept" : "Cancel"} keeps the owner alive`, { timeout: 10000 }, async (t) => {
const signal = new SharedArrayBuffer(4);
const worker = new Worker(require.resolve("./__fixtures__/transferQuitRenderer.cjs"), {
workerData: { accept, signal },
});
const ipcMain = new EventEmitter();
const events = new EventEmitter();
const replies = [];
let promptShown = false;
let closed = false;
const wc = {
isDestroyed: () => closed,
isCrashed: () => false,
send(channel, payload) { worker.postMessage({ channel, payload }); },
};
t.after(async () => { Atomics.store(new Int32Array(signal), 0, 1); Atomics.notify(new Int32Array(signal), 0); await worker.terminate(); });
worker.on("message", (message) => {
if (message.type === "reply") {
replies.push(message.payload);
ipcMain.emit(message.channel, { sender: wc }, message.payload);
} else {
if (message.type === "prompt") promptShown = true;
events.emit(message.type, message);
}
});
worker.on("error", (error) => events.emit("error", error));
await once(events, "ready");
const started = Date.now();
const result = await queryDirtyEditors(wc, 5000, { ipcMain });
if (!result) closed = true;
assert.equal(promptShown, true);
assert.equal(result, "transfers");
assert.equal(closed, false, "main refuses close while the renderer is blocked in its native prompt");
assert.ok(Date.now() - started >= 4900, "use the unchanged five-second production deadline");
assert.equal(ipcMain.listenerCount("app:dirty-editors-result"), 0, "main settled without hanging");
const done = once(events, "done");
Atomics.store(new Int32Array(signal), 0, 1);
Atomics.notify(new Int32Array(signal), 0);
const [completed] = await done;
assert.equal(completed.resumes, 1);
assert.equal(replies.at(-1).hasDirty, true, "an expired Accept cannot authorize a later close");
assert.equal(replies.at(-1).reason, "transfers");
assert.equal(closed, false);
});
}
@@ -1,28 +1,11 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import type { TransferTask } from "../../domain/models";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { useAppStartupEffects } from "./useAppStartupEffects.ts";
import { sftpTransferCenterStore } from "../state/sftpTransferCenterStore.ts";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge.ts";
const makeTask = (id: string, status: TransferTask["status"]): TransferTask => ({
id,
fileName: `${id}.txt`,
sourcePath: `/source/${id}.txt`,
targetPath: `/target/${id}.txt`,
sourceConnectionId: "local",
targetConnectionId: "remote",
direction: "upload",
status,
totalBytes: 10,
transferredBytes: 2,
speed: 1,
startTime: 1,
isDirectory: false,
});
for (const accept of [false, true]) {
test(`app Quit with an unfinished transfer: ${accept ? "confirm quits" : "declining reports \"transfers\" before resuming only transferring and queued tasks"}`, async (t) => {
const previous = Object.getOwnPropertyDescriptor(globalThis, "IS_REACT_ACT_ENVIRONMENT");
@@ -30,8 +13,7 @@ for (const accept of [false, true]) {
const g = globalThis as unknown as { window?: { confirm?: (m: string) => boolean } };
const hadWindow = "window" in g; const previousConfirm = g.window?.confirm;
if (!g.window) g.window = {};
let prompts = 0;
g.window.confirm = () => { prompts++; return accept; };
g.window.confirm = () => accept;
let renderer: ReactTestRenderer | undefined;
let releaseResume!: () => void;
const resumeGate = new Promise<void>((resolve) => { releaseResume = resolve; });
@@ -94,159 +76,12 @@ for (const accept of [false, true]) {
}
assert.equal(resumeSettled, false, "the dirty-check reply must not wait for resume");
if (accept) {
assert.deepEqual(reports, [[true, "transfers"], [false, undefined]]);
assert.deepEqual(calls, ["report", ...paused, "report"], "accepted quit leaves every prompted task paused");
assert.deepEqual(reports, [[false, undefined]]);
assert.deepEqual(calls, [...paused, "report"], "accepted quit leaves every prompted task paused");
} else {
assert.deepEqual(reports, [[true, "transfers"], [true, "transfers"]], "main must be told the prompt was already answered");
assert.deepEqual(calls, ["report", ...paused, "report", "resume:running", "resume:queued"],
assert.deepEqual(reports, [[true, "transfers"]], "main must be told the prompt was already answered");
assert.deepEqual(calls, [...paused, "report", "resume:running", "resume:queued"],
"report precedes resume, and only transferring/queued rows the prompt paused are resumed");
// Dedicated resume promises can represent the whole transfer lifetime.
// A second close must still prompt while those promises remain pending.
await query();
assert.equal(prompts, 2);
assert.equal(resumeSettled, false);
assert.equal(reports.length, 4);
}
});
}
for (const outcome of ["pause-rejected", "pause-expired", "prompt-expired"] as const) {
test(`transfer quit refuses ${outcome} and resumes only the formerly active task`, async (t) => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
const previousAct = Object.getOwnPropertyDescriptor(globalThis, "IS_REACT_ACT_ENVIRONMENT");
let renderer: ReactTestRenderer | undefined;
let now = 0;
let prompts = 0;
let resumes = 0;
const replies: Array<{ dirty: boolean; reason?: string; requestId?: string; pending?: boolean }> = [];
let query!: (request: { requestId: string; deadline: number }) => Promise<void>;
t.mock.method(Date, "now", () => now);
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
Object.defineProperty(globalThis, "window", { configurable: true, value: {
confirm() { prompts++; now = 6000; return true; },
} });
t.after(async () => {
await act(async () => renderer?.unmount());
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow); else Reflect.deleteProperty(globalThis, "window");
if (previousAct) Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", previousAct); else Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT");
});
t.mock.method(sftpTransferCenterStore, "getSnapshot", () => ({ activeCount: 1, queuedCount: 0, attentionCount: 0, tasks: [makeTask("running", "transferring")] }));
t.mock.method(sftpTransferCenterStore, "pause", async () => {
if (outcome === "pause-rejected") throw new Error("pause failed");
if (outcome === "pause-expired") now = 6000;
});
t.mock.method(sftpTransferCenterStore, "resume", async () => { resumes++; });
t.mock.method(netcattyBridge, "get", () => ({
onCheckDirtyEditors: (callback: typeof query) => { query = callback; return () => {}; },
reportDirtyEditorsResult: (dirty: boolean, reason?: string, options?: { requestId?: string; pending?: boolean }) => {
replies.push({ dirty, reason, ...options });
},
}));
function Probe() {
useAppStartupEffects({ enabled: false, updateState: {}, sessions: [], hosts: [], keys: [], identities: [], knownHosts: [], proxyProfiles: [], groupConfigs: [], workspaces: [], portForwardingRules: [], t: (key: string) => key });
return null;
}
await act(async () => { renderer = create(React.createElement(Probe)); });
await query({ requestId: outcome, deadline: 5000 });
assert.deepEqual(replies, [
{ dirty: true, reason: "transfers", requestId: outcome, pending: true },
{ dirty: true, reason: "transfers", requestId: outcome },
]);
assert.equal(prompts, outcome === "prompt-expired" ? 1 : 0);
assert.equal(resumes, 1);
});
}
test("a cancelled task can close after an expired stuck pause, and its late completion cannot resume it", async (t) => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
const previousAct = Object.getOwnPropertyDescriptor(globalThis, "IS_REACT_ACT_ENVIRONMENT");
let renderer: ReactTestRenderer | undefined;
let now = 0;
let cancelled = false;
let prompts = 0;
let resumes = 0;
let releasePause!: () => void;
const pause = new Promise<void>((resolve) => { releasePause = resolve; });
const replies: Array<{ dirty: boolean; requestId?: string; pending?: boolean }> = [];
let query!: (request: { requestId: string; deadline: number }) => Promise<void>;
t.mock.method(Date, "now", () => now);
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
Object.defineProperty(globalThis, "window", { configurable: true, value: { confirm() { prompts++; return false; } } });
t.after(async () => {
releasePause();
await act(async () => renderer?.unmount());
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow); else Reflect.deleteProperty(globalThis, "window");
if (previousAct) Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", previousAct); else Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT");
});
t.mock.method(sftpTransferCenterStore, "getSnapshot", () => ({ activeCount: cancelled ? 0 : 1, queuedCount: 0, attentionCount: 0, tasks: [makeTask("stuck", cancelled ? "cancelled" : "transferring")] }));
t.mock.method(sftpTransferCenterStore, "pause", () => pause);
t.mock.method(sftpTransferCenterStore, "resume", async () => { resumes++; });
t.mock.method(netcattyBridge, "get", () => ({
onCheckDirtyEditors: (callback: typeof query) => { query = callback; return () => {}; },
reportDirtyEditorsResult: (dirty: boolean, _reason?: string, options?: { requestId?: string; pending?: boolean }) => replies.push({ dirty, ...options }),
}));
function Probe() {
useAppStartupEffects({ enabled: false, updateState: {}, sessions: [], hosts: [], keys: [], identities: [], knownHosts: [], proxyProfiles: [], groupConfigs: [], workspaces: [], portForwardingRules: [], t: (key: string) => key });
return null;
}
await act(async () => { renderer = create(React.createElement(Probe)); });
const first = query({ requestId: "stuck", deadline: 5000 });
await query({ requestId: "overlap", deadline: 5000 });
assert.deepEqual(replies.at(-1), { dirty: true, requestId: "overlap" });
now = 6000;
cancelled = true;
await query({ requestId: "after-cancel", deadline: 11000 });
assert.deepEqual(replies.at(-1), { dirty: false, requestId: "after-cancel" });
const replyCount = replies.length;
releasePause();
await first;
assert.equal(replies.length, replyCount, "superseded attempt must not reply again");
assert.equal(prompts, 0);
assert.equal(resumes, 0, "late completion must not undo the user's cancellation");
});
test("retry Cancel preserves the expired attempt auto-resume ownership", async (t) => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
const previousAct = Object.getOwnPropertyDescriptor(globalThis, "IS_REACT_ACT_ENVIRONMENT");
let renderer: ReactTestRenderer | undefined;
let now = 0;
let status: TransferTask["status"] = "transferring";
let prompts = 0;
let resumes = 0;
let releasePause!: () => void;
const pause = new Promise<void>((resolve) => { releasePause = resolve; });
const replies: Array<{ dirty: boolean; requestId?: string; pending?: boolean }> = [];
let query!: (request: { requestId: string; deadline: number }) => Promise<void>;
t.mock.method(Date, "now", () => now);
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
Object.defineProperty(globalThis, "window", { configurable: true, value: { confirm() { prompts++; return false; } } });
t.after(async () => {
releasePause();
await act(async () => renderer?.unmount());
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow); else Reflect.deleteProperty(globalThis, "window");
if (previousAct) Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", previousAct); else Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT");
});
t.mock.method(sftpTransferCenterStore, "getSnapshot", () => ({ activeCount: status === "transferring" || status === "pausing" ? 1 : 0, queuedCount: 0, attentionCount: 0, tasks: [makeTask("stuck", status)] }));
t.mock.method(sftpTransferCenterStore, "pause", () => { status = "pausing"; return pause; });
t.mock.method(sftpTransferCenterStore, "resume", async () => { resumes++; });
t.mock.method(netcattyBridge, "get", () => ({
onCheckDirtyEditors: (callback: typeof query) => { query = callback; return () => {}; },
reportDirtyEditorsResult: (dirty: boolean, _reason?: string, options?: { requestId?: string; pending?: boolean }) => replies.push({ dirty, ...options }),
}));
function Probe() {
useAppStartupEffects({ enabled: false, updateState: {}, sessions: [], hosts: [], keys: [], identities: [], knownHosts: [], proxyProfiles: [], groupConfigs: [], workspaces: [], portForwardingRules: [], t: (key: string) => key });
return null;
}
await act(async () => { renderer = create(React.createElement(Probe)); });
const first = query({ requestId: "stuck", deadline: 5000 });
await query({ requestId: "overlap", deadline: 5000 });
assert.deepEqual(replies.at(-1), { dirty: true, requestId: "overlap" });
now = 6000;
const retry = query({ requestId: "retry", deadline: 11000 });
status = "paused";
releasePause();
await Promise.all([first, retry]);
assert.equal(prompts, 1);
assert.equal(resumes, 1, "Cancel must restore work running before the first attempt");
assert.deepEqual(replies.at(-1), { dirty: true, requestId: "retry" });
});
+11 -49
View File
@@ -344,26 +344,12 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
};
}, [enabled, hasRuntimeTunnel, hosts, sessions, portForwardingRules, workspaces]);
const transferQuitPendingRef = useRef<{ deadline: number; resumeTaskIds: Set<string> } | null>(null);
// Quit guard: block app exit while any editor tab has unsaved changes.
// Main process sends "app:query-dirty-editors"; we respond with the result.
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onCheckDirtyEditors) return;
const unsub = bridge.onCheckDirtyEditors(async (request) => {
const response = { requestId: request?.requestId };
// Another close request must not start a second pause/prompt while the
// first attempt is still pausing or awaiting a decision.
const previousAttempt = transferQuitPendingRef.current;
if (previousAttempt && Date.now() < previousAttempt.deadline) {
bridge.reportDirtyEditorsResult?.(true, "transfers", response);
return;
}
// An expired pause may never settle. Let a retry re-check current tasks
// (including a user's cancellation), without granting the old attempt
// permission to prompt or resume over that newer decision.
let attempt: typeof previousAttempt = null;
const unsub = bridge.onCheckDirtyEditors(async () => {
// Always report SOMETHING so the main process doesn't time out for
// 5 s on an unhandled exception. If we can't determine the state,
// fail open — losing unsaved work is bad, but stranding the user
@@ -388,44 +374,24 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
if (unfinishedTasks.length > 0) {
const resumable = unfinishedTasks.filter((task) => (
task.status === "transferring" || task.status === "queued"
|| previousAttempt?.resumeTaskIds.has(task.id)
));
// A slow pause must never turn the main-process timeout into
// permission to quit before the user has made a decision.
hasDirty = true;
reason = "transfers";
tasksToResume = resumable;
// A retry inherits the still-unfinished rows the expired attempt
// auto-paused. Their current status may already be pausing/paused.
attempt = {
deadline: request?.deadline ?? Date.now() + 5000,
resumeTaskIds: new Set(resumable.map((task) => task.id)),
};
transferQuitPendingRef.current = attempt;
bridge.reportDirtyEditorsResult?.(true, reason, { ...response, pending: true });
const paused = await Promise.allSettled(unfinishedTasks.map((task) => sftpTransferCenterStore.pause(task.id)));
if (transferQuitPendingRef.current !== attempt) return;
const expired = () => request && Date.now() >= request.deadline;
if (!expired() && paused.every((result) => result.status === "fulfilled")) {
hasDirty = !window.confirm(t('sftp.transferCenter.quitConfirm', { count: unfinishedTasks.length }));
// The native prompt can itself outlive the main-process deadline.
// An expired acceptance is not permission for a later close attempt.
if (expired()) hasDirty = true;
await Promise.allSettled(unfinishedTasks.map((task) => sftpTransferCenterStore.pause(task.id)));
hasDirty = !window.confirm(t('sftp.transferCenter.quitConfirm', { count: unfinishedTasks.length }));
if (hasDirty) {
// The user chose to stay. Main must hear that before any resume:
// resume can outlive the 5s dirty-check timeout, and a late reply
// lets the app quit anyway. The synchronous dialog is also skipped
// because this prompt was already answered.
reason = "transfers";
tasksToResume = resumable;
}
if (!hasDirty) {
reason = undefined;
tasksToResume = [];
}
} else {
// All prior work was finished/cancelled; invalidate its late pause.
transferQuitPendingRef.current = null;
}
}
} catch (err) {
console.error('[App] dirty-editors check failed:', err);
}
try {
bridge.reportDirtyEditorsResult?.(hasDirty, reason, response);
bridge.reportDirtyEditorsResult?.(hasDirty, reason);
} catch (err) {
// Reporting itself shouldn't throw, but if the IPC bridge is in a
// bad state we'd rather log than bubble out of the listener and
@@ -433,12 +399,8 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
console.error('[App] reportDirtyEditorsResult failed:', err);
}
if (tasksToResume.length > 0) {
// Report refusal before recovery, which may itself take a long time.
// A resume promise can span the entire transfer, so do not hold the
// close gate until it settles. A later close must be able to pause again.
void Promise.allSettled(tasksToResume.map((task) => sftpTransferCenterStore.resume(task.id)));
}
if (transferQuitPendingRef.current === attempt) transferQuitPendingRef.current = null;
});
return unsub;
}, [enabled, t]);
-1
View File
@@ -582,7 +582,6 @@ export const enAiMessages: Messages = {
'ai.chat.modelCatalogSourceCached': 'Cached · last success {time}',
'ai.chat.modelCatalogSourceCachedNoTime': 'Cached',
'ai.chat.modelCatalogSourcePresets': 'Built-in default list',
'ai.chat.refreshModelList': 'Refresh model list',
'ai.chat.noMatchingModels': 'No matching models',
'ai.chat.recent': 'Recent',
'ai.chat.viewAll': 'View All',
+2 -103
View File
@@ -966,51 +966,6 @@ Highlight the focused split pane:
'settings.shortcuts.category.app': 'App',
'settings.shortcuts.category.sftp': 'SFTP',
// Settings > Shortcuts -> key bindings
'settings.shortcuts.binding.switch-tab-1-9': 'Switch to Tab [1...9]',
'settings.shortcuts.binding.next-tab': 'Next Tab',
'settings.shortcuts.binding.prev-tab': 'Previous Tab',
'settings.shortcuts.binding.close-tab': 'Close Tab',
'settings.shortcuts.binding.close-session': 'Close Session Pane',
'settings.shortcuts.binding.new-tab': 'New Local Tab',
'settings.shortcuts.binding.copy': 'Copy from Terminal',
'settings.shortcuts.binding.paste': 'Paste to Terminal',
'settings.shortcuts.binding.paste-selection': 'Paste Selection to Terminal',
'settings.shortcuts.binding.select-all': 'Select All in Terminal',
'settings.shortcuts.binding.clear-buffer': 'Clear Terminal Buffer',
'settings.shortcuts.binding.search-terminal': 'Open Terminal Search',
'settings.shortcuts.binding.increase-terminal-font-size': 'Increase Terminal Font Size',
'settings.shortcuts.binding.decrease-terminal-font-size': 'Decrease Terminal Font Size',
'settings.shortcuts.binding.reset-terminal-font-size': 'Reset Terminal Font Size',
'settings.shortcuts.binding.move-focus': 'Move focus between Split View panes',
'settings.shortcuts.binding.split-horizontal': 'Split Horizontal',
'settings.shortcuts.binding.split-vertical': 'Split Vertical',
'settings.shortcuts.binding.toggle-pane-zoom': 'Toggle Pane Zoom',
'settings.shortcuts.binding.open-hosts': 'Open Hosts Page',
'settings.shortcuts.binding.open-local': 'Open Local Terminal',
'settings.shortcuts.binding.open-sftp': 'Open SFTP',
'settings.shortcuts.binding.port-forwarding': 'Open Port Forwarding',
'settings.shortcuts.binding.command-palette': 'Open Command Palette',
'settings.shortcuts.binding.quick-switch': 'Quick Switch',
'settings.shortcuts.binding.new-workspace': 'New Workspace',
'settings.shortcuts.binding.snippets': 'Open Snippets',
'settings.shortcuts.binding.broadcast': 'Switch the Broadcast Mode',
'settings.shortcuts.binding.toggle-side-panel': 'Toggle Side Panel',
'settings.shortcuts.binding.toggle-host-tree': 'Toggle Host List',
'settings.shortcuts.binding.open-settings': 'Open Settings',
'settings.shortcuts.binding.toggle-opacity': 'Toggle Window Transparency',
'settings.shortcuts.binding.sftp-copy': 'Copy Files',
'settings.shortcuts.binding.sftp-cut': 'Cut Files',
'settings.shortcuts.binding.sftp-paste': 'Paste Files',
'settings.shortcuts.binding.sftp-select-all': 'Select All Files',
'settings.shortcuts.binding.sftp-rename': 'Rename File',
'settings.shortcuts.binding.sftp-delete': 'Delete Files',
'settings.shortcuts.binding.sftp-refresh': 'Refresh',
'settings.shortcuts.binding.sftp-new-folder': 'New Folder',
'settings.shortcuts.binding.sftp-open': 'Open File / Enter Directory',
'settings.shortcuts.binding.sftp-go-parent': 'Go to Parent Directory',
'settings.shortcuts.binding.sftp-navigate-to': 'Navigate to Selected Directory',
// Context menus / common actions
'action.newHost': 'New Host',
'action.newSubfolder': 'New Subfolder',
@@ -1235,64 +1190,8 @@ Highlight the focused split pane:
'deepLink.jms.invalid': 'Invalid JumpServer link',
'deepLink.jms.unsupported': 'Unsupported JumpServer protocol: {{protocol}}',
'settings.tab.plugins': 'Plugins',
"settings.plugins.manager.installed": "Installed plugins",
"settings.plugins.manager.import": "Import .ncpkg",
"settings.plugins.manager.preview": "Imported packages stay disabled until you review and enable them.",
"settings.plugins.manager.empty": "Import a local plugin package to get started.",
"settings.plugins.manager.enabled": "Enabled",
"settings.plugins.manager.disabled": "Disabled",
"settings.plugins.manager.enable": "Enable",
"settings.plugins.manager.disable": "Disable",
"settings.plugins.manager.remove": "Uninstall",
"settings.plugins.manager.removeNote": "Uninstall removes package code and local KV data. User-owned settings, secrets and sync sidecars are retained.",
"settings.plugins.permissions.title": "Active grants",
"settings.plugins.permissions.refresh": "Refresh",
"settings.plugins.permissions.loading": "Loading grants...",
"settings.plugins.permissions.empty": "No active grants. One-use decisions are not retained.",
"settings.plugins.permissions.always": "Until revoked",
"settings.plugins.permissions.application": "Until Netcatty exits",
"settings.plugins.permissions.session": "Until the session closes",
"settings.plugins.permissions.audit": "Recent security events (up to 100)",
"settings.plugins.permissions.revoke": "Disable and revoke all grants",
"settings.plugins.permissions.revokeNote": "This disables the plugin, cancels its work, and removes saved grants. Enable it again to request new access.",
"settings.plugins.permissions.unavailable": "Plugin permission management is unavailable",
"settings.plugins.manager.permissions": "Declared permissions",
"settings.plugins.manager.permissionNote": "From the manifest, not a grant. Enabled allows lazy activation; required permissions are requested before first activation and optional permissions when used. Sensitive operations still pass host checks.",
"settings.plugins.manager.noPermissions": "No privileged permissions declared.",
"settings.plugins.manager.required": "Required",
"settings.plugins.manager.optional": "Optional",
"settings.plugins.manager.browser": "Browser runtime: privileged access goes through host permission brokers.",
"settings.plugins.manager.advanced": "Advanced runtime: full Node filesystem and network authority. Broker permissions cannot restrict this access. Development preview only.",
"settings.plugins.manager.data": "Local KV stays on this device. Only non-secret settings explicitly marked for sync participate in encrypted cloud sidecars. Packages and permission grants do not sync.",
"settings.plugins.market.results": "{count} plugins",
"settings.plugins.manager.lastError": "Last recorded error",
"settings.plugins.market.offline": "Registry unavailable. Showing the last valid catalog or bundled examples. Installation is unavailable until refresh succeeds.",
"settings.plugins.manager.details": "View details",
"settings.plugins.manager.noDescription": "No description provided.",
"settings.plugins.market.installed": "Installed",
"settings.plugins.market.sourceBadge": "Source example",
"settings.plugins.market.title": "Marketplace",
"settings.plugins.market.note": "Curated open-source plugins. Review is not a security certification. Updates are manual.",
"settings.plugins.market.back": "All plugins",
"settings.plugins.market.install": "Install this version (disabled)",
"settings.plugins.market.installNote": "This replaces and stops the installed version. Review its permissions in Installed plugins before enabling.",
"settings.plugins.market.sourceOnly": "First-party source example. Build with the local CLI and import the .ncpkg; no downloadable release has been published.",
"settings.plugins.market.readmeNote": "Pinned to the commit above. HTML is inert and remote images are not loaded. Written by the plugin author.",
"settings.plugins.manager.noVault": "System key storage is unavailable. Secret and credential operations are blocked; browsing and non-secret plugins still work.",
"settings.plugins.manager.sensitive": "Sensitive access: review the data and resource scope before granting.",
"settings.plugins.manager.dual": "Browser is the default runtime. This package also includes a Node entrypoint with full device authority; developer mode only.",
"settings.plugins.manager.advancedDeclaration": "Browser entrypoint with advanced permission declarations. Not eligible for normal browser-only mode; review every declared permission.",
"settings.plugins.market.retry": "Retry",
"settings.plugins.market.empty": "No catalog entries are available. You can still import a local browser plugin.",
"settings.plugins.market.emptyReadme": "This README is empty.",
"settings.plugins.market.search": "Search name, author or description",
"settings.plugins.market.noResults": "No matching plugins. Try another search.",
"settings.plugins.market.manifest": "Compatibility and permissions",
"settings.plugins.manager.unbounded": "No narrower resource scope is declared. Check the actual scope in the permission prompt.",
"settings.plugins.openView": "Open",
"settings.plugins.openManager": "Open plugin manager",
"settings.plugins.title": "Plugins",
"settings.plugins.description": "Manage your plugins and explore the catalog.",
'settings.plugins.title': 'Plugin settings',
'settings.plugins.description': 'Native, validated settings contributed by development plugins.',
'settings.plugins.loading': 'Loading plugin settings…',
'settings.plugins.empty': 'No enabled plugin contributes settings.',
'settings.plugins.scopeContext': 'Open this setting in its {scope} context.',
-5
View File
@@ -1,9 +1,4 @@
export const enMonitorMessages = {
"monitor.diskIo": "Disk I/O",
"monitor.diskRead": "Read",
"monitor.diskWrite": "Write",
"monitor.diskIoHint": "Aggregate Linux hardware-backed block-device I/O, excluding partitions and stacked virtual devices. Unavailable until two valid samples; not supported on macOS.",
"monitor.actions": "Panel actions",
"monitor.unavailable": "Monitoring is unavailable. Try starting again when the connection is restored.",
"monitor.username": "Username",
-12
View File
@@ -1,15 +1,6 @@
import type { Messages } from '../types';
export const enTerminalMessages: Messages = {
'terminal.toolbar.openSessionLog': 'Open session log',
'terminal.toolbar.sessionLogActions': 'Session log actions',
'terminal.toolbar.sessionLog.menu': 'Session log',
'terminal.toolbar.sessionLog.open': 'Open',
'terminal.toolbar.sessionLog.reveal': 'Show in folder',
'terminal.toolbar.sessionLog.chooseApplication': 'Open with…',
'terminal.toolbar.sessionLog.systemDefault': 'Open with system default',
'terminal.toolbar.openSessionLogFailed': 'Failed to open session log.',
'terminal.menu.saveScreen': 'Save current screen',
'terminal.saveScreen.failed': 'Failed to save terminal screen.',
// Multi-line paste confirmation dialog (#3398)
@@ -77,8 +68,6 @@ export const enTerminalMessages: Messages = {
'terminal.composeBar.send': 'Send',
'terminal.composeBar.close': 'Close compose bar',
'terminal.composeBar.broadcasting': 'Broadcasting to all sessions',
'terminal.composeBar.sendToAll': 'Send to all sessions',
'terminal.composeBar.sendToActive': 'Send to the active session only',
'terminal.composeBar.broadcastAllowPassword': 'Broadcast without password protection',
'terminal.composeBar.resize': 'Resize compose bar height',
'terminal.composeBar.manageSnippets': 'Manage quick snippets',
@@ -879,7 +868,6 @@ export const enTerminalMessages: Messages = {
'serial.field.flowControl': 'Flow Control',
'serial.noPorts': 'No serial ports detected. Connect a device and refresh.',
'serial.field.customPort': 'Custom Port Path',
'serial.field.namedPipeHint': 'Windows VM console: enter \\\\.\\pipe\\name. The VM must create the pipe as a server. Baud rate and framing are ignored.',
'serial.field.customPortPlaceholder': 'e.g. /dev/ttys001 or COM1',
'serial.type.hardware': 'Hardware',
'serial.type.pseudo': 'Pseudo Terminal',
-1
View File
@@ -370,7 +370,6 @@ export const enVaultMessages: Messages = {
'sftp.transfers.openTargetFolder': 'Open target folder',
'sftp.transfers.openTargetFolderError': 'Could not open target folder',
'sftp.transfers.copyTargetPath': 'Copy target path',
'sftp.transfers.cleanupWarnings': 'Backup cleanup warnings: {count}. Show files for details.',
'sftp.transfers.copyTargetPathSuccess': 'Target path copied',
'sftp.transfers.copyTargetPathError': 'Could not copy target path',
'sftp.transfers.pathConflict.title': 'Transfer blocked',
-21
View File
@@ -90,15 +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.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',
'ai.providers.headers.value': 'Valor de la cabecera',
'ai.providers.headers.add': 'Agregar cabecera',
'ai.providers.headers.remove': 'Quitar cabecera',
'ai.providers.headers.invalid': 'Usa cabeceras HTTP válidas y únicas. Content-Length y Transfer-Encoding se gestionan automáticamente.',
'ai.providers.headers.loadError': 'No se pudieron desbloquear las cabeceras guardadas. Cancela y vuelve a intentarlo después de desbloquear el llavero del sistema.',
'ai.providers.headers.saveError': 'No se pudo guardar de forma segura. Comprueba que el llavero del sistema esté disponible y vuelve a intentarlo.',
// AI Codex
'ai.codex': 'Codex',
@@ -289,14 +280,6 @@ export const esAiMessages: Messages = {
'ai.opencode.customPathPlaceholder': 'p. ej. /usr/local/bin/opencode',
'ai.opencode.check': 'Verificar',
'ai.opencode.resetPath': 'Restablecer',
'ai.opencode.v2.startupTimeout': 'OpenCode 2 no estuvo listo a tiempo. Confirma que `opencode serve` funciona en una terminal y vuelve a intentarlo.',
'ai.opencode.v2.exited': 'OpenCode 2 se detuvo antes de que el chat pudiera iniciarse.',
'ai.opencode.v2.unauthorized': 'OpenCode 2 rechazó la conexión. Vuelve a intentar el chat.',
'ai.opencode.v2.unavailable': 'No se pudo contactar con OpenCode 2. Comprueba que esté instalado y vuelve a intentarlo.',
'ai.opencode.v2.sessionFailed': 'OpenCode 2 no pudo completar este turno.',
'ai.opencode.v2.empty': 'OpenCode 2 devolvió una respuesta vacía. Ejecuta `opencode` en una terminal para configurar la autenticación y los modelos.',
'ai.opencode.v2.contextSkipped': 'OpenCode 2 no pudo aplicar el contexto de la conversación de Netcatty.',
'ai.opencode.v2.mcpSkipped': 'OpenCode 2 no pudo adjuntar una herramienta MCP. El chat continuará sin ella.',
// AI MiMo Code (CLI de Xiaomi MiMo Code — fork de OpenCode)
'ai.mimo.title': 'MiMo Code',
@@ -582,7 +565,6 @@ export const esAiMessages: Messages = {
'ai.chat.modelCatalogSourceCached': 'En caché · último éxito {time}',
'ai.chat.modelCatalogSourceCachedNoTime': 'En caché',
'ai.chat.modelCatalogSourcePresets': 'Lista predeterminada integrada',
'ai.chat.refreshModelList': 'Actualizar la lista de modelos',
'ai.chat.noMatchingModels': 'No hay modelos coincidentes',
'ai.chat.recent': 'Recientes',
'ai.chat.viewAll': 'Ver todo',
@@ -711,9 +693,6 @@ export const esAiMessages: Messages = {
'terminal.layer.splitHorizontal': 'Dividir arriba y abajo',
'terminal.layer.splitVertical': 'Dividir izquierda y derecha',
'terminal.layer.openInNewSplit': 'Abrir en una división nueva',
'terminal.layer.saveLayoutAsDefault': 'Guardar diseño como predeterminado',
'terminal.layer.layoutSavedAsDefault': 'Diseño guardado como predeterminado',
'terminal.layer.layoutSaveFailed': 'No se pudo guardar el diseño',
'terminal.layer.hostTree.search': 'Buscar hosts...',
'terminal.layer.hostTree.searchButton': 'Buscar',
'terminal.layer.hostTree.tagsButton': 'Filtrar por etiquetas',
+2 -144
View File
@@ -84,32 +84,6 @@ export const esCoreMessages: Messages = {
'credentials.protectionUnavailable.message': 'Las contraseñas y claves guardadas no se pueden descifrar automáticamente en este dispositivo. Vuelve a ingresar las credenciales antes de conectarte.',
'credentials.protectionUnavailable.action': 'Abrir configuración',
// App Lock
'appLock.title': 'Netcatty está bloqueado',
'appLock.reason.default': 'Ingresa tu contraseña de bloqueo para continuar.',
'appLock.reason.startup': 'Netcatty se bloquea cada vez que se abre esta ventana.',
'appLock.reason.idle': 'Netcatty se bloqueó tras el tiempo de inactividad.',
'appLock.reason.manual': 'Netcatty se bloqueó manualmente.',
'appLock.passwordLabel': 'Contraseña de bloqueo',
'appLock.passwordPlaceholder': 'Ingresa la contraseña para desbloquear',
'appLock.unlock': 'Desbloquear',
'appLock.unlocking': 'Desbloqueando...',
'appLock.usePassword': 'Usar contraseña',
'appLock.error.emptyPassword': 'Ingresa la contraseña de bloqueo.',
'appLock.error.incorrectPassword': 'La contraseña de bloqueo es incorrecta.',
'appLock.systemUnlock.unlockWith': 'Desbloquear con {label}',
'appLock.systemUnlock.preparing': 'Preparando {label}...',
'appLock.systemUnlock.verifying': 'Esperando a {label}...',
'appLock.systemUnlock.error': 'No se completó el desbloqueo del sistema. Usa la contraseña de bloqueo para continuar.',
'appLock.logoLabel': 'Netcatty',
'appLock.reset.title': 'Restablecer el bloqueo de la aplicación',
'appLock.reset.description': 'Esto elimina la contraseña de bloqueo actual y desactiva el bloqueo de la aplicación. Crea una nueva contraseña desde Configuración cuando la necesites.',
'appLock.reset.cancel': 'Cancelar',
'appLock.reset.confirm': 'Restablecer el bloqueo de la aplicación',
'appLock.reset.resetting': 'Restableciendo...',
'appLock.reset.error': 'No se pudo restablecer el bloqueo de la aplicación. Inténtalo de nuevo.',
'topTabs.lockApp': 'Bloquear Netcatty',
// Settings shell
'settings.title': 'Configuración',
'settings.tab.application': 'Aplicación',
@@ -145,65 +119,6 @@ export const esCoreMessages: Messages = {
'settings.system.credentials.unavailableHint': 'Las credenciales cifradas en otro perfil de usuario o máquina no se pueden descifrar aquí. Vuelve a ingresar y guarda las credenciales en este dispositivo.',
'settings.system.credentials.portabilityHint': 'La sincronización en la nube es portátil porque usa tu cifrado de clave maestra. El cifrado local de safeStorage está limitado al dispositivo y al usuario.',
// Settings > System > App Lock
'settings.appLock.title': 'Bloqueo de la aplicación',
'settings.appLock.description': 'Solicita una contraseña de bloqueo local cuando Netcatty se abre, tras un periodo de inactividad o cuando lo bloqueas manualmente.',
'settings.appLock.setupTitle': 'Define una contraseña de bloqueo',
'settings.appLock.setupDescription': 'Crea la contraseña a continuación. El bloqueo de la aplicación se activa en cuanto se guarda.',
'settings.appLock.manageTitle': 'El bloqueo de la aplicación está listo',
'settings.appLock.enabledStatus': 'Activado. Netcatty se bloquea al iniciar, tras un periodo de inactividad y cuando lo bloqueas manualmente.',
'settings.appLock.disabledStatus': 'Contraseña guardada, pero el bloqueo de la aplicación está desactivado.',
'settings.appLock.disableTitle': 'Desactivar el bloqueo de la aplicación',
'settings.appLock.disableDescription': 'Ingresa la contraseña de bloqueo actual para dejar de bloquear Netcatty. La contraseña guardada se eliminará; para volver a activar el bloqueo tendrás que crear una nueva.',
'settings.appLock.disable': 'Desactivar el bloqueo de la aplicación',
'settings.appLock.disabling': 'Desactivando...',
'settings.appLock.setupPasswordTitle': 'Crear contraseña de bloqueo',
'settings.appLock.setupPasswordDescription': 'Esta contraseña es necesaria para desbloquear Netcatty y para cambiar o desactivar el bloqueo de la aplicación más adelante.',
'settings.appLock.changePasswordTitle': 'Cambiar la contraseña de bloqueo',
'settings.appLock.changePasswordDescription': 'Ingresa la contraseña de bloqueo actual y luego elige una nueva.',
'settings.appLock.enable': 'Activar el bloqueo de la aplicación',
'settings.appLock.enableDesc': 'Bloquea al iniciar y tras el tiempo de inactividad seleccionado.',
'settings.appLock.timeout': 'Tiempo de inactividad',
'settings.appLock.timeoutDesc': 'Bloquea tras un periodo de inactividad o al cerrar la ventana en la bandeja. Nunca omite ambos casos.',
'settings.appLock.timeout.0': 'Nunca bloquear automáticamente',
'settings.appLock.timeout.1': '1 minuto',
'settings.appLock.timeout.5': '5 minutos',
'settings.appLock.timeout.15': '15 minutos',
'settings.appLock.timeout.30': '30 minutos',
'settings.appLock.timeout.60': '60 minutos',
'settings.appLock.systemUnlock.label': 'Desbloqueo con {label}',
'settings.appLock.systemUnlock.desc': 'Usa {label} como forma alternativa de desbloquear Netcatty en este dispositivo.',
'settings.appLock.systemUnlock.unavailableDesc': '{label} no está disponible en este dispositivo ahora mismo.',
'settings.appLock.systemUnlock.unavailable': 'El desbloqueo del sistema no está disponible en este dispositivo.',
'settings.appLock.systemUnlock.locked': 'Desbloquea Netcatty antes de cambiar el desbloqueo del sistema.',
'settings.appLock.systemUnlock.autoPrompt.label': 'Abrir {label} automáticamente',
'settings.appLock.systemUnlock.autoPrompt.desc': 'Muestra el aviso de {label} automáticamente cuando Netcatty está esperando para desbloquearse.',
'settings.appLock.currentPassword': 'Contraseña actual',
'settings.appLock.currentPasswordPlaceholder': 'Necesaria para desactivar o reemplazar',
'settings.appLock.currentPasswordForDisablePlaceholder': 'Contraseña actual para desactivar el bloqueo de la aplicación',
'settings.appLock.currentPasswordForChangePlaceholder': 'Contraseña actual para cambiarla',
'settings.appLock.newPassword': 'Nueva contraseña',
'settings.appLock.newPasswordPlaceholder': 'Define la contraseña de bloqueo',
'settings.appLock.confirmPassword': 'Confirmar contraseña',
'settings.appLock.confirmPasswordPlaceholder': 'Repite la contraseña',
'settings.appLock.savePassword': 'Definir y activar',
'settings.appLock.savingPassword': 'Guardando...',
'settings.appLock.passwordSet': 'Contraseña configurada',
'settings.appLock.replacePassword': 'Reemplazar contraseña',
'settings.appLock.enableAfterPassword': 'Define una contraseña de bloqueo antes de activar el bloqueo de la aplicación.',
'settings.appLock.localOnlyHint': 'Bloqueo de privacidad local del dispositivo. No se sincroniza y no es una barrera de seguridad del sistema operativo.',
'settings.appLock.validation.currentRequired': 'Ingresa la contraseña de bloqueo actual.',
'settings.appLock.validation.newRequired': 'Ingresa una nueva contraseña de bloqueo.',
'settings.appLock.validation.confirmRequired': 'Confirma la nueva contraseña de bloqueo.',
'settings.appLock.validation.mismatch': 'Las contraseñas nuevas no coinciden.',
'settings.appLock.validation.incorrect': 'La contraseña de bloqueo actual es incorrecta.',
// Settings > System > Auto Launch
'settings.autoLaunch.title': 'Abrir al iniciar sesión',
'settings.autoLaunch.enabled': 'Iniciar al arrancar el sistema',
'settings.autoLaunch.enabledDesc': 'Inicia Netcatty automáticamente al iniciar sesión, oculto en la bandeja del sistema.',
'settings.autoLaunch.unsupportedDesc': 'No disponible en esta plataforma ni en una compilación de desarrollo.',
// Settings > System > Network Proxy
'settings.system.networkProxy.title': 'Proxy de red',
'settings.system.networkProxy.description': 'Proxy HTTP(S) para la sincronización en la nube y los proveedores de IA. Esto es independiente de los perfiles de ProxyJump / ProxyCommand de SSH.',
@@ -998,7 +913,6 @@ Resalta el panel dividido enfocado:
'settings.shortcuts.binding.broadcast': 'Alternar modo de transmisión',
'settings.shortcuts.binding.toggle-opacity': 'Alternar la transparencia de la ventana',
'settings.shortcuts.binding.toggle-side-panel': 'Alternar panel lateral',
'settings.shortcuts.binding.toggle-host-tree': 'Alternar lista de hosts',
'settings.shortcuts.binding.sftp-copy': 'Copiar archivos',
'settings.shortcuts.binding.sftp-cut': 'Cortar archivos',
'settings.shortcuts.binding.sftp-paste': 'Pegar archivos',
@@ -1235,64 +1149,8 @@ Resalta el panel dividido enfocado:
'deepLink.jms.invalid': 'Enlace JumpServer inválido',
'deepLink.jms.unsupported': 'Protocolo JumpServer no compatible: {{protocol}}',
'settings.tab.plugins': 'Plugins',
"settings.plugins.manager.installed": "Plugins instalados",
"settings.plugins.manager.import": "Importar .ncpkg",
"settings.plugins.manager.preview": "Los paquetes importados permanecen desactivados hasta que los revises y actives.",
"settings.plugins.manager.empty": "Importa un paquete local para empezar.",
"settings.plugins.manager.enabled": "Activado",
"settings.plugins.manager.disabled": "Desactivado",
"settings.plugins.manager.enable": "Activar",
"settings.plugins.manager.disable": "Desactivar",
"settings.plugins.manager.remove": "Desinstalar",
"settings.plugins.manager.removeNote": "La desinstalación elimina el código y los datos KV locales. Se conservan los ajustes del usuario, los secretos y los datos auxiliares de sincronización.",
"settings.plugins.permissions.title": "Autorizaciones activas",
"settings.plugins.permissions.refresh": "Actualizar",
"settings.plugins.permissions.loading": "Cargando autorizaciones...",
"settings.plugins.permissions.empty": "No hay autorizaciones activas. Las decisiones de un solo uso no se conservan.",
"settings.plugins.permissions.always": "Hasta su revocación",
"settings.plugins.permissions.application": "Hasta cerrar Netcatty",
"settings.plugins.permissions.session": "Hasta cerrar la sesión",
"settings.plugins.permissions.audit": "Eventos de seguridad recientes (hasta 100)",
"settings.plugins.permissions.revoke": "Desactivar y revocar todas las autorizaciones",
"settings.plugins.permissions.revokeNote": "Desactiva el complemento, cancela su trabajo y elimina las autorizaciones guardadas. Actívelo para solicitar acceso de nuevo.",
"settings.plugins.permissions.unavailable": "La gestión de permisos no está disponible",
"settings.plugins.manager.permissions": "Permisos declarados",
"settings.plugins.manager.permissionNote": "Del manifiesto, no es una autorización. Activado permite la activación diferida; los permisos obligatorios se solicitan antes del primer uso y los opcionales cuando se necesitan. Las operaciones sensibles siguen verificándose.",
"settings.plugins.manager.noPermissions": "No se han declarado permisos privilegiados.",
"settings.plugins.manager.required": "Obligatorio",
"settings.plugins.manager.optional": "Opcional",
"settings.plugins.manager.browser": "Entorno de navegador: el acceso privilegiado pasa por los controles de permisos del anfitrión.",
"settings.plugins.manager.advanced": "Entorno avanzado: acceso completo de Node al sistema de archivos y a la red. Los permisos del intermediario no restringen este acceso. Solo para desarrollo.",
"settings.plugins.manager.data": "Los datos KV permanecen en este dispositivo. Solo los ajustes no secretos marcados para sincronización participan en los datos auxiliares cifrados. Los paquetes y las autorizaciones no se sincronizan.",
"settings.plugins.market.results": "{count} plugins",
"settings.plugins.manager.lastError": "Último error registrado",
"settings.plugins.market.offline": "El registro no está disponible. Se muestra el último catálogo válido o los ejemplos incluidos. La instalación requiere una actualización correcta.",
"settings.plugins.manager.details": "Ver detalles",
"settings.plugins.manager.noDescription": "No se proporcionó una descripción.",
"settings.plugins.market.installed": "Instalado",
"settings.plugins.market.sourceBadge": "Ejemplo de código",
"settings.plugins.market.title": "Catálogo",
"settings.plugins.market.note": "Plugins de código abierto seleccionados. La revisión no certifica su seguridad. Las actualizaciones son manuales.",
"settings.plugins.market.back": "Todos los plugins",
"settings.plugins.market.install": "Instalar esta versión (desactivada)",
"settings.plugins.market.installNote": "Esto reemplaza y detiene la versión instalada. Revisa sus permisos antes de activarla.",
"settings.plugins.market.sourceOnly": "Ejemplo propio con código fuente. Compílalo con la CLI local e importa el .ncpkg; no hay versión publicada para descargar.",
"settings.plugins.market.readmeNote": "Fijado al commit indicado. El HTML no se ejecuta y las imágenes remotas no se cargan. Contenido del autor.",
"settings.plugins.manager.noVault": "El almacén de claves del sistema no está disponible. Se bloquean secretos y credenciales; el catálogo y los plugins que no los usan siguen disponibles.",
"settings.plugins.manager.sensitive": "Acceso sensible: revisa los datos y el alcance antes de autorizar.",
"settings.plugins.manager.dual": "El navegador es la opción predeterminada. El paquete también incluye una entrada Node con acceso completo al dispositivo; solo para desarrollo.",
"settings.plugins.manager.advancedDeclaration": "Entrada de navegador con permisos avanzados declarados. No se admite en el modo normal; revisa cada permiso.",
"settings.plugins.market.retry": "Reintentar",
"settings.plugins.market.empty": "No hay entradas en el catálogo. Puedes importar un plugin local de navegador.",
"settings.plugins.market.emptyReadme": "Este README está vacío.",
"settings.plugins.market.search": "Buscar nombre, autor o descripción",
"settings.plugins.market.noResults": "No hay plugins coincidentes. Prueba otra búsqueda.",
"settings.plugins.market.manifest": "Compatibilidad y permisos",
"settings.plugins.manager.unbounded": "No se declara un alcance de recursos más limitado. Revisa el alcance real en la solicitud de permiso.",
"settings.plugins.openView": "Abrir",
"settings.plugins.openManager": "Abrir gestor de plugins",
"settings.plugins.title": "Plugins",
"settings.plugins.description": "Gestiona tus plugins y explora el catálogo.",
'settings.plugins.title': 'Configuración de plugins',
'settings.plugins.description': 'Ajustes nativos y validados que aportan los plugins de desarrollo.',
'settings.plugins.loading': 'Cargando ajustes del plugin…',
'settings.plugins.empty': 'Ningún plugin activado aporta ajustes.',
'settings.plugins.scopeContext': 'Abre este ajuste en su contexto de {scope}.',
-5
View File
@@ -1,9 +1,4 @@
export const esMonitorMessages = {
"monitor.diskIo": "E/S de disco",
"monitor.diskRead": "Lectura",
"monitor.diskWrite": "Escritura",
"monitor.diskIoHint": "E/S agregada de dispositivos de bloques físicos de Linux, sin contar particiones ni capas virtuales. No disponible hasta tener dos muestras válidas; no compatible con macOS.",
"monitor.actions": "Acciones del panel",
"monitor.unavailable": "La monitorización no está disponible. Iníciala de nuevo cuando se restablezca la conexión.",
"monitor.username": "Usuario",
@@ -33,9 +33,6 @@ export const esSystemManagerMessages: Messages = {
'systemManager.errors.loadPorts': 'No se pudieron cargar los puertos en escucha',
'systemManager.errors.loadServices': 'No se pudieron cargar los servicios de systemd',
'systemManager.errors.sshChannelUnavailable': 'El servidor se negó a abrir un nuevo canal de ejecución. Inténtalo de nuevo más tarde o vuelve a conectar este host.',
'systemManager.errors.nerdctlUnreachable': 'nerdctl no puede conectarse a containerd',
'systemManager.errors.clusterUnreachable': 'No se puede conectar al clúster para el contexto {{context}}',
'systemManager.errors.clusterUnreachableGeneric': 'No se puede conectar al clúster',
'systemManager.unsupportedRemote': 'Este host no permite canales SSH de ejecución adicionales, por lo que el administrador del sistema no está disponible. Abrir este panel ya no desconectará la terminal.',
'systemManager.overview.empty': 'Aún no hay datos de resumen del sistema.',
@@ -215,20 +212,6 @@ export const esSystemManagerMessages: Messages = {
'systemManager.k8s.restarts': '{{count}} reinicios',
'systemManager.k8s.chooseContainer': 'Elegir un contenedor',
'systemManager.k8s.noContext': 'No hay un contexto de Kubernetes en este host',
'systemManager.k8s.status.Running': 'En ejecución',
'systemManager.k8s.status.Pending': 'Pendiente',
'systemManager.k8s.status.Completed': 'Completado',
'systemManager.k8s.status.Succeeded': 'Con éxito',
'systemManager.k8s.status.Failed': 'Fallido',
'systemManager.k8s.status.Unknown': 'Desconocido',
'systemManager.k8s.status.CrashLoopBackOff': 'Bucle de fallos',
'systemManager.k8s.status.ContainerCreating': 'Creando contenedor',
'systemManager.k8s.status.Terminating': 'Terminando',
'systemManager.k8s.status.ImagePullBackOff': 'Reintentando descarga de imagen',
'systemManager.k8s.status.ErrImagePull': 'Error al descargar la imagen',
'systemManager.k8s.status.Error': 'Error',
'systemManager.k8s.status.Evicted': 'Desalojado',
'systemManager.k8s.status.OOMKilled': 'Sin memoria',
'systemManager.ports.unavailable': 'No se detectaron herramientas de puertos en escucha (ss / netstat) en este host.',
'systemManager.ports.loading': 'Cargando puertos en escucha…',
-52
View File
@@ -1,15 +1,6 @@
import type { Messages } from '../types';
export const esTerminalMessages: Messages = {
'terminal.toolbar.openSessionLog': 'Abrir registro de sesión',
'terminal.toolbar.sessionLogActions': 'Acciones del registro',
'terminal.toolbar.sessionLog.menu': 'Registro de sesión',
'terminal.toolbar.sessionLog.open': 'Abrir',
'terminal.toolbar.sessionLog.reveal': 'Mostrar en carpeta',
'terminal.toolbar.sessionLog.chooseApplication': 'Abrir con…',
'terminal.toolbar.sessionLog.systemDefault': 'Abrir con la aplicación predeterminada',
'terminal.toolbar.openSessionLogFailed': 'No se pudo abrir el registro.',
'terminal.menu.saveScreen': 'Guardar pantalla actual',
'terminal.saveScreen.failed': 'No se pudo guardar la pantalla del terminal.',
// Diálogo de confirmación de pegado multilínea (#3398)
@@ -77,8 +68,6 @@ export const esTerminalMessages: Messages = {
'terminal.composeBar.send': 'Enviar',
'terminal.composeBar.close': 'Cerrar barra de redacción',
'terminal.composeBar.broadcasting': 'Difundiendo a todas las sesiones',
'terminal.composeBar.sendToAll': 'Enviar a todas las sesiones',
'terminal.composeBar.sendToActive': 'Enviar solo a la sesión activa',
'terminal.composeBar.broadcastAllowPassword': 'Difundir sin protección de contraseña',
'terminal.composeBar.resize': 'Cambiar la altura de la barra de redacción',
'terminal.composeBar.manageSnippets': 'Administrar snippets rápidos',
@@ -219,7 +208,6 @@ export const esTerminalMessages: Messages = {
'terminal.progress.timeoutIn': 'Tiempo de espera en {seconds}s',
'terminal.progress.waitingForUserInput': 'Esperando entrada del usuario',
'terminal.progress.disconnected': 'Desconectado',
'terminal.progress.connected': 'Conectado',
'terminal.progress.cancelling': 'Cancelando...',
'terminal.progress.startOver': 'Empezar de nuevo',
'terminal.progress.enterReconnectHint': 'Presiona Enter para reconectar',
@@ -676,45 +664,6 @@ export const esTerminalMessages: Messages = {
'keychain.keyDialog.editTitle': 'Editar clave',
'keychain.keyDialog.editDesc': 'Actualizar esta clave SSH',
'keychain.keyDialog.updateKey': 'Actualizar clave',
'keychain.generate.standardKeys': 'Claves estándar',
'keychain.generate.securityKeys': 'Llaves de seguridad · FIDO2',
'keychain.generate.residentDesc': 'Permite recuperar las credenciales desde esta llave de seguridad.',
'keychain.generate.verifyRequiredDesc': 'Solicita el PIN del dispositivo antes de firmar.',
'keychain.generate.generating': 'Generando…',
'keychain.generate.fidoHint': 'Conecta una llave de seguridad FIDO2. Es posible que se te solicite su PIN o un toque.',
'keychain.generate.resident': 'Guardar las credenciales en la llave',
'keychain.generate.verifyRequired': 'Verificar el PIN cada vez',
'keychain.action.loadResident': 'Cargar claves residentes desde la llave de seguridad',
// FIDO2 security key prompts and recovery states
'fido.prompt.pinTouchDesc': 'Ingresa tu PIN y luego toca la llave para confirmar.',
'fido.prompt.details': 'Detalles de la solicitud',
'fido.prompt.showPin': 'Mostrar PIN',
'fido.prompt.hidePin': 'Ocultar PIN',
'fido.prompt.verifying': 'Verificando…',
'fido.prompt.pinTitle': 'PIN de la llave de seguridad',
'fido.prompt.touchTitle': 'Toca tu llave de seguridad',
'fido.prompt.pinDesc': 'Ingresa el PIN de tu llave de seguridad para continuar.',
'fido.prompt.touchDesc': 'Toca tu llave de seguridad para confirmar tu identidad.',
'fido.prompt.pinLabel': 'PIN',
'fido.prompt.touchWaiting': 'Esperando tu llave…',
'fido.prompt.touchAutomatic': 'Continúa automáticamente después de que la toques',
'fido.prompt.cancelled': 'Se canceló la autenticación con llave de seguridad.',
'fido.prompt.touchDone': 'Ya la toqué',
'fido.prompt.timeout': 'El aviso de FIDO agotó el tiempo de espera. Intenta conectarte de nuevo.',
'fido.recovery.device-unavailable.title': 'Conecta la llave de seguridad correspondiente',
'fido.recovery.device-unavailable.desc': 'Conecta el dispositivo que contiene la credencial de esta llave y vuelve a intentarlo.',
'fido.recovery.timeout.title': 'Se agotó el tiempo de confirmación de la llave de seguridad',
'fido.recovery.timeout.desc': 'La operación del dispositivo se detuvo. Comprueba que tu llave esté conectada, vuelve a intentarlo y sigue el aviso de toque o verificación.',
'fido.recovery.pin-failed.title': 'No se aceptó el PIN de la llave de seguridad',
'fido.recovery.pin-failed.desc': 'Verifica el PIN FIDO2 de este dispositivo antes de reintentar. Varios intentos incorrectos pueden bloquear el dispositivo.',
'fido.recovery.failed.title': 'No se completó la verificación de la llave de seguridad',
'fido.recovery.failed.desc': 'Comprueba que el dispositivo correspondiente esté conectado y admita esta llave. Reintenta o cancela para revisar el dispositivo y la configuración de OpenSSH.',
'fido.recovery.retry': 'Reintentar verificación',
'fido.recovery.usePassword': 'Usar la contraseña configurada',
'fido.recovery.passwordFallback': 'Probando la contraseña configurada para este host',
'fido.error.opensshMissing': 'Se requiere OpenSSH con FIDO/libfido2. En macOS: brew install openssh libfido2.',
'fido.error.agentUnavailable': 'No se pudo iniciar un agente SSH compatible con FIDO.',
// Tabs
'tabs.closeSessionAria': 'Cerrar sesión',
@@ -881,7 +830,6 @@ export const esTerminalMessages: Messages = {
'serial.field.flowControl': 'Control de flujo',
'serial.noPorts': 'No se detectaron puertos seriales. Conecta un dispositivo y actualiza.',
'serial.field.customPort': 'Ruta de puerto personalizada',
'serial.field.namedPipeHint': 'Consola de VM en Windows: introduce \\\\.\\pipe\\name. La VM debe crear la canalización como servidor. Se ignoran la velocidad y la trama.',
'serial.field.customPortPlaceholder': 'p. ej., /dev/ttys001 o COM1',
'serial.type.hardware': 'Hardware',
'serial.type.pseudo': 'Pseudoterminal',
-13
View File
@@ -370,7 +370,6 @@ export const esVaultMessages: Messages = {
'sftp.transfers.openTargetFolder': 'Abrir carpeta de destino',
'sftp.transfers.openTargetFolderError': 'No se pudo abrir la carpeta de destino',
'sftp.transfers.copyTargetPath': 'Copiar ruta de destino',
'sftp.transfers.cleanupWarnings': 'Avisos de limpieza de copias de seguridad: {count}. Muestra los archivos para ver los detalles.',
'sftp.transfers.copyTargetPathSuccess': 'Ruta de destino copiada',
'sftp.transfers.copyTargetPathError': 'No se pudo copiar la ruta de destino',
'sftp.transfers.pathConflict.title': 'Transferencia bloqueada',
@@ -1044,18 +1043,6 @@ export const esVaultMessages: Messages = {
'hostDetails.addTag': 'Agregar una etiqueta...',
'hostDetails.createTag': 'Crear etiqueta',
'hostDetails.createGroup': 'Crear grupo',
// Host connection test
'hostConnectionTest.title': 'Probar conexión',
'hostConnectionTest.test': 'Probar',
'hostConnectionTest.chainOf': 'Cadena {current} de {total}',
'hostConnectionTest.success': 'Conexión correcta',
'hostConnectionTest.successHint': 'El host aceptó tus credenciales. Puedes cerrar esta ventana.',
'hostConnectionTest.connecting': 'Conectando… tiempo de espera en {seconds}s',
'hostConnectionTest.failed': 'No se pudo conectar.',
'hostConnectionTest.retry': 'Reintentar',
'hostConnectionTest.credentialsUnavailable': 'Las credenciales guardadas no se pueden descifrar en este dispositivo. Vuelve a ingresarlas abajo.',
// Entradas de shell local (CMD) en la bóveda
'localShell.subtitle': 'Shell local',
'localShell.panel.title': 'Nueva shell local',
@@ -1,120 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DEFAULT_KEY_BINDINGS } from "../../../domain/models/keyBindings.ts";
import en from "./en.ts";
import es from "./es.ts";
import ru from "./ru.ts";
import zhCN from "./zh-CN.ts";
import zhTW from "./zh-TW.ts";
// Locales that are not at full key parity with `en` yet. They are tracked here
// so the remaining debt stays visible instead of silently rendering in English
// through the `DEFAULT_UI_LOCALE` fallback in `I18nProvider.resolveMessage`.
// Move a locale out of this list once it covers every `en` key.
const LOCALES_PENDING_PARITY = ["ru", "zh-CN", "zh-TW"] as const;
const hasKey = (messages: Record<string, string>, key: string) =>
Object.hasOwn(messages, key);
const interpolationTokens = (value: string) =>
[...value.matchAll(/\{\{\s*\w+\s*\}\}|\{\s*\w+\s*\}/g)]
.map((match) => match[0].replace(/[{}]/g, "").trim())
.sort();
test("es defines every message key that en defines", () => {
const missing = Object.keys(en).filter((key) => !hasKey(es, key));
assert.deepEqual(
missing,
[],
`es is missing ${missing.length} message keys`,
);
});
test("es defines no message keys that en does not define", () => {
const extra = Object.keys(es).filter((key) => !hasKey(en, key));
assert.deepEqual(
extra,
[],
`es defines ${extra.length} keys that no longer exist in en`,
);
});
test("es keeps the same interpolation placeholders as en", () => {
const mismatched: string[] = [];
for (const key of Object.keys(es)) {
if (!hasKey(en, key)) continue;
const expected = interpolationTokens(en[key] ?? "").join(",");
const actual = interpolationTokens(es[key] ?? "").join(",");
if (expected !== actual) mismatched.push(key);
}
assert.deepEqual(
mismatched,
[],
`es changes interpolation placeholders for ${mismatched.length} keys`,
);
});
test("every default shortcut has a label in en and es", () => {
const locales = [
{ name: "en", messages: en },
{ name: "es", messages: es },
];
for (const locale of locales) {
const missing = DEFAULT_KEY_BINDINGS.map(
(binding) => `settings.shortcuts.binding.${binding.id}`,
).filter((key) => !hasKey(locale.messages, key));
assert.deepEqual(
missing,
[],
`${locale.name} is missing ${missing.length} shortcut labels`,
);
}
});
test("en shortcut labels stay identical to DEFAULT_KEY_BINDINGS", () => {
// SettingsShortcutsTab falls back to `binding.label` only while the locale
// resolves the key to itself. Once `en` defines the key, the locale becomes
// the rendered source, so the two must not drift apart.
for (const binding of DEFAULT_KEY_BINDINGS) {
const key = `settings.shortcuts.binding.${binding.id}`;
assert.equal(
en[key],
binding.label,
`en label drifted from DEFAULT_KEY_BINDINGS for ${binding.id}`,
);
}
});
test("every locale outside the pending list is at full parity with en", () => {
const locales = [
{ name: "es", messages: es },
{ name: "ru", messages: ru },
{ name: "zh-CN", messages: zhCN },
{ name: "zh-TW", messages: zhTW },
];
for (const locale of locales) {
if ((LOCALES_PENDING_PARITY as readonly string[]).includes(locale.name)) {
continue;
}
const missing = Object.keys(en).filter(
(key) => !hasKey(locale.messages, key),
);
assert.deepEqual(
missing,
[],
`${locale.name} is missing ${missing.length} message keys`,
);
}
});
-1
View File
@@ -521,7 +521,6 @@ export const ruAiMessages: Messages = {
'ai.chat.modelCatalogSourceCached': 'Кэш · обновлён {time}',
'ai.chat.modelCatalogSourceCachedNoTime': 'Кэш',
'ai.chat.modelCatalogSourcePresets': 'Встроенный список моделей',
'ai.chat.refreshModelList': 'Обновить список моделей',
'ai.chat.noMatchingModels': 'Нет подходящих моделей',
'ai.chat.recent': 'Недавние',
'ai.chat.viewAll': 'Показать всё',
+2 -59
View File
@@ -938,7 +938,6 @@ export const ruCoreMessages: Messages = {
'settings.shortcuts.binding.broadcast': 'Переключить режим трансляции',
'settings.shortcuts.binding.toggle-opacity': 'Переключить прозрачность окна',
'settings.shortcuts.binding.toggle-side-panel': 'Переключить боковую панель',
'settings.shortcuts.binding.toggle-host-tree': 'Переключить список хостов',
'settings.shortcuts.binding.sftp-copy': 'Копировать файл',
'settings.shortcuts.binding.sftp-cut': 'Вырезать файл',
'settings.shortcuts.binding.sftp-paste': 'Вставить файл',
@@ -1130,64 +1129,8 @@ export const ruCoreMessages: Messages = {
'deepLink.jms.unsupported': 'Неподдерживаемый протокол JumpServer: {{protocol}}',
'settings.tab.plugins': 'Плагины',
"settings.plugins.manager.installed": "Установленные плагины",
"settings.plugins.manager.import": "Импорт .ncpkg",
"settings.plugins.manager.preview": "Импортированные пакеты отключены до проверки и включения.",
"settings.plugins.manager.empty": "Импортируйте локальный пакет плагина.",
"settings.plugins.manager.enabled": "Включён",
"settings.plugins.manager.disabled": "Отключён",
"settings.plugins.manager.enable": "Включить",
"settings.plugins.manager.disable": "Отключить",
"settings.plugins.manager.remove": "Удалить",
"settings.plugins.manager.removeNote": "Удаление очищает код пакета и локальные данные KV. Настройки пользователя, секреты и данные синхронизации сохраняются.",
"settings.plugins.permissions.title": "Действующие разрешения",
"settings.plugins.permissions.refresh": "Обновить",
"settings.plugins.permissions.loading": "Загрузка разрешений...",
"settings.plugins.permissions.empty": "Действующих разрешений нет. Одноразовые решения не сохраняются.",
"settings.plugins.permissions.always": "До отзыва",
"settings.plugins.permissions.application": "До выхода из Netcatty",
"settings.plugins.permissions.session": "До закрытия сеанса",
"settings.plugins.permissions.audit": "Последние события безопасности (до 100)",
"settings.plugins.permissions.revoke": "Отключить и отозвать все разрешения",
"settings.plugins.permissions.revokeNote": "Плагин будет отключён, его задачи отменены, а сохранённые разрешения удалены. Включите его для нового запроса доступа.",
"settings.plugins.permissions.unavailable": "Управление разрешениями недоступно",
"settings.plugins.manager.permissions": "Заявленные разрешения",
"settings.plugins.manager.permissionNote": "Из манифеста, а не предоставленные права. Включение допускает отложенную активацию; обязательные разрешения запрашиваются перед ней, необязательные — при использовании. Чувствительные операции проверяются приложением.",
"settings.plugins.manager.noPermissions": "Привилегированные разрешения не заявлены.",
"settings.plugins.manager.required": "Обязательно",
"settings.plugins.manager.optional": "Необязательно",
"settings.plugins.manager.browser": "Браузерная среда: привилегированный доступ проходит через проверку разрешений приложения.",
"settings.plugins.manager.advanced": "Расширенная среда: полный доступ Node к файловой системе и сети. Разрешения посредника не ограничивают этот доступ. Только для разработки.",
"settings.plugins.manager.data": "Данные KV остаются на устройстве. Синхронизируются только явно отмеченные несекретные настройки в зашифрованных данных. Пакеты и разрешения не синхронизируются.",
"settings.plugins.market.results": "Плагинов: {count}",
"settings.plugins.manager.lastError": "Последняя записанная ошибка",
"settings.plugins.market.offline": "Реестр недоступен. Показан последний корректный каталог или встроенные примеры. Для установки необходимо успешно обновить каталог.",
"settings.plugins.manager.details": "Подробнее",
"settings.plugins.manager.noDescription": "Описание не предоставлено.",
"settings.plugins.market.installed": "Установлен",
"settings.plugins.market.sourceBadge": "Пример исходного кода",
"settings.plugins.market.title": "Каталог",
"settings.plugins.market.note": "Отобранные плагины с открытым кодом. Проверка не гарантирует безопасность. Обновления выполняются вручную.",
"settings.plugins.market.back": "Все плагины",
"settings.plugins.market.install": "Установить эту версию (отключённой)",
"settings.plugins.market.installNote": "Установленная версия будет заменена и остановлена. Проверьте разрешения перед включением.",
"settings.plugins.market.sourceOnly": "Пример из этого проекта. Соберите локальной CLI и импортируйте .ncpkg; готовый пакет для скачивания не опубликован.",
"settings.plugins.market.readmeNote": "Зафиксирован указанный коммит. HTML не выполняется, внешние изображения не загружаются. Текст автора плагина.",
"settings.plugins.manager.noVault": "Системное хранилище ключей недоступно. Операции с секретами и учётными данными заблокированы; каталог и плагины без секретов доступны.",
"settings.plugins.manager.sensitive": "Чувствительный доступ: проверьте данные и область ресурсов перед разрешением.",
"settings.plugins.manager.dual": "По умолчанию используется браузерная среда. Пакет также содержит вход Node с полным доступом к устройству; только для разработчиков.",
"settings.plugins.manager.advancedDeclaration": "Браузерная точка входа с заявленными расширенными разрешениями. Не допускается в обычном режиме; проверьте все разрешения.",
"settings.plugins.market.retry": "Повторить",
"settings.plugins.market.empty": "Каталог пуст. Можно импортировать локальный браузерный плагин.",
"settings.plugins.market.emptyReadme": "Этот README пуст.",
"settings.plugins.market.search": "Поиск по имени, автору или описанию",
"settings.plugins.market.noResults": "Совпадений нет. Измените запрос.",
"settings.plugins.market.manifest": "Совместимость и разрешения",
"settings.plugins.manager.unbounded": "Более узкая область ресурсов не заявлена. Проверьте фактическую область в запросе разрешения.",
"settings.plugins.openView": "Открыть",
"settings.plugins.openManager": "Открыть управление плагинами",
"settings.plugins.title": "Плагины",
"settings.plugins.description": "Управляйте плагинами и просматривайте каталог.",
'settings.plugins.title': 'Настройки плагинов',
'settings.plugins.description': 'Проверенные нативные настройки от плагинов разработки.',
'settings.plugins.loading': 'Загрузка настроек плагинов…',
'settings.plugins.empty': 'Включённые плагины не предоставляют настройки.',
'settings.plugins.scopeContext': 'Откройте эту настройку в контексте {scope}.',
-5
View File
@@ -1,9 +1,4 @@
export const ruMonitorMessages = {
"monitor.diskIo": "Дисковый ввод-вывод",
"monitor.diskRead": "Чтение",
"monitor.diskWrite": "Запись",
"monitor.diskIoHint": "Суммарный ввод-вывод физических блочных устройств Linux, без разделов и виртуальных слоёв. Доступен после двух корректных замеров; macOS не поддерживается.",
"monitor.actions": "Действия панели",
"monitor.unavailable": "Мониторинг недоступен. Запустите его снова после восстановления связи.",
"monitor.username": "Пользователь",
-12
View File
@@ -1,15 +1,6 @@
import type { Messages } from '../types';
export const ruTerminalMessages: Messages = {
'terminal.toolbar.openSessionLog': 'Открыть журнал сессии',
'terminal.toolbar.sessionLogActions': 'Действия с журналом',
'terminal.toolbar.sessionLog.menu': 'Журнал сессии',
'terminal.toolbar.sessionLog.open': 'Открыть',
'terminal.toolbar.sessionLog.reveal': 'Показать в папке',
'terminal.toolbar.sessionLog.chooseApplication': 'Открыть с помощью…',
'terminal.toolbar.sessionLog.systemDefault': 'Открыть по умолчанию',
'terminal.toolbar.openSessionLogFailed': 'Не удалось открыть журнал.',
'terminal.menu.saveScreen': 'Сохранить текущий экран',
'terminal.saveScreen.failed': 'Не удалось сохранить экран терминала.',
// Диалог подтверждения многострочной вставки (#3398)
@@ -98,8 +89,6 @@ export const ruTerminalMessages: Messages = {
'terminal.composeBar.send': 'Отправить',
'terminal.composeBar.close': 'Закрыть строку ввода',
'terminal.composeBar.broadcasting': 'Трансляция во все сессии',
'terminal.composeBar.sendToAll': 'Отправить во все сессии',
'terminal.composeBar.sendToActive': 'Отправить только в активную сессию',
'terminal.composeBar.broadcastAllowPassword': 'Транслировать без защиты пароля',
'terminal.composeBar.resize': 'Изменить высоту строки ввода',
'terminal.composeBar.manageSnippets': 'Управление быстрыми сниппетами',
@@ -886,7 +875,6 @@ export const ruTerminalMessages: Messages = {
'serial.field.flowControl': 'Управление потоком',
'serial.noPorts': 'Последовательные порты не обнаружены. Подключите устройство и обновите список.',
'serial.field.customPort': 'Путь к пользовательскому порту',
'serial.field.namedPipeHint': 'Консоль ВМ Windows: введите \\\\.\\pipe\\name. ВМ должна создать канал как сервер. Скорость и параметры кадра игнорируются.',
'serial.field.customPortPlaceholder': 'например, /dev/ttys001 или COM1',
'serial.type.hardware': 'Аппаратный',
'serial.type.pseudo': 'Псевдотерминал',
-1
View File
@@ -413,7 +413,6 @@ export const ruVaultMessages: Messages = {
'sftp.transfers.openTargetFolder': 'Открыть целевую папку',
'sftp.transfers.openTargetFolderError': 'Не удалось открыть целевую папку',
'sftp.transfers.copyTargetPath': 'Копировать целевой путь',
'sftp.transfers.cleanupWarnings': 'Предупреждения об удалении резервных копий: {count}. Откройте список файлов для подробностей.',
'sftp.transfers.copyTargetPathSuccess': 'Целевой путь скопирован',
'sftp.transfers.copyTargetPathError': 'Не удалось скопировать целевой путь',
'sftp.transfers.pathConflict.title': 'Передача заблокирована',
@@ -18,19 +18,12 @@ const LOCALIZED_SETTINGS_LOCALES = [
const APP_LOCK_LOCALES = [
{ name: "en", messages: en },
{ name: "es", messages: es },
{ name: "ru", messages: ru },
{ name: "zh-CN", messages: zhCN },
{ name: "zh-TW", messages: zhTW },
];
const ALL_SETTINGS_LOCALES = [
{ name: "en", messages: en },
...LOCALIZED_SETTINGS_LOCALES,
{ name: "ru", messages: ru },
];
test("localized settings include names for every default shortcut", () => {
for (const locale of ALL_SETTINGS_LOCALES) {
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
const missing = DEFAULT_KEY_BINDINGS
.map((binding) => `settings.shortcuts.binding.${binding.id}`)
.filter((key) => !locale.messages[key]);
@@ -1,13 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import en from "./en";
import es from "./es";
import ru from "./ru";
import zhCN from "./zh-CN";
import zhTW from "./zh-TW";
test("folder cleanup-warning counts are localized in every supported locale", () => {
for (const [locale, messages] of Object.entries({ en, es, ru, zhCN, zhTW })) {
assert.match(messages["sftp.transfers.cleanupWarnings"] ?? "", /\{count\}/, locale);
}
});
@@ -1,32 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import en from './en';
import es from './es';
import ru from './ru';
import zhCN from './zh-CN';
import zhTW from './zh-TW';
const keys = [
'terminal.toolbar.openSessionLog',
'terminal.toolbar.sessionLogActions',
'terminal.toolbar.sessionLog.menu',
'terminal.toolbar.sessionLog.open',
'terminal.toolbar.sessionLog.reveal',
'terminal.toolbar.sessionLog.chooseApplication',
'terminal.toolbar.sessionLog.systemDefault',
'terminal.toolbar.openSessionLogFailed',
] as const;
test('saved session log actions are translated in every shipped locale', () => {
for (const [locale, messages] of Object.entries({ en, es, ru, 'zh-CN': zhCN, 'zh-TW': zhTW })) {
for (const key of keys) assert.ok(messages[key], `${locale} is missing ${key}`);
assert.equal(messages['terminal.toolbar.sessionLog.rememberApplication'], undefined);
}
assert.equal(en['terminal.toolbar.sessionLog.menu'], 'Session log');
assert.equal(en['terminal.toolbar.sessionLog.open'], 'Open');
assert.equal(en['terminal.toolbar.sessionLog.reveal'], 'Show in folder');
assert.equal(en['terminal.toolbar.sessionLog.chooseApplication'], 'Open with…');
assert.equal(en['terminal.toolbar.sessionLog.systemDefault'], 'Open with system default');
assert.equal(zhCN['terminal.toolbar.sessionLog.menu'], '会话日志');
assert.equal(zhTW['terminal.toolbar.sessionLog.menu'], '工作階段日誌');
});
-1
View File
@@ -582,7 +582,6 @@ export const zhCNAiMessages: Messages = {
'ai.chat.modelCatalogSourceCached': '缓存 · 上次成功 {time}',
'ai.chat.modelCatalogSourceCachedNoTime': '缓存',
'ai.chat.modelCatalogSourcePresets': '内置默认列表',
'ai.chat.refreshModelList': '刷新模型列表',
'ai.chat.noMatchingModels': '没有匹配的模型',
'ai.chat.recent': '最近',
'ai.chat.viewAll': '查看全部',
+2 -59
View File
@@ -1035,7 +1035,6 @@ Vaults 标签保持扁平,不使用这块背景。
'sftp.transfers.openTargetFolder': '打开目标目录',
'sftp.transfers.openTargetFolderError': '无法打开目标目录',
'sftp.transfers.copyTargetPath': '复制目标路径',
'sftp.transfers.cleanupWarnings': '备份清理警告:{count}。展开文件列表查看详情。',
'sftp.transfers.copyTargetPathSuccess': '已复制目标路径',
'sftp.transfers.copyTargetPathError': '无法复制目标路径',
'sftp.transfers.pathConflict.title': '无法开始传输',
@@ -1150,64 +1149,8 @@ Vaults 标签保持扁平,不使用这块背景。
'qs.localShells': '本地 Shell',
'qs.default': '默认',
'settings.tab.plugins': '插件',
"settings.plugins.manager.installed": "已安装插件",
"settings.plugins.manager.import": "导入 .ncpkg",
"settings.plugins.manager.preview": "导入的插件保持停用,请检查后再启用。",
"settings.plugins.manager.empty": "导入本地插件包以开始使用。",
"settings.plugins.manager.enabled": "已启用",
"settings.plugins.manager.disabled": "已停用",
"settings.plugins.manager.enable": "启用",
"settings.plugins.manager.disable": "停用",
"settings.plugins.manager.remove": "卸载",
"settings.plugins.manager.removeNote": "卸载会移除插件代码和本地 KV 数据,保留用户设置、秘密存储和同步 sidecar。",
"settings.plugins.permissions.title": "实际授权",
"settings.plugins.permissions.refresh": "刷新",
"settings.plugins.permissions.loading": "正在读取授权...",
"settings.plugins.permissions.empty": "没有有效授权。单次决定不会保留。",
"settings.plugins.permissions.always": "直到撤销",
"settings.plugins.permissions.application": "直到退出 Netcatty",
"settings.plugins.permissions.session": "直到会话关闭",
"settings.plugins.permissions.audit": "最近的安全事件(最多 100 条)",
"settings.plugins.permissions.revoke": "停用并撤销全部授权",
"settings.plugins.permissions.revokeNote": "此操作会停用插件、取消其工作并删除已保存的授权。重新启用后可申请新授权。",
"settings.plugins.permissions.unavailable": "插件授权管理不可用",
"settings.plugins.manager.permissions": "声明的权限",
"settings.plugins.manager.permissionNote": "来自 manifest,不代表已授权。启用表示允许按需激活;必需权限在首次激活前申请,可选权限在用到时申请。敏感操作仍受宿主检查。",
"settings.plugins.manager.noPermissions": "未声明特权权限。",
"settings.plugins.manager.required": "必需",
"settings.plugins.manager.optional": "可选",
"settings.plugins.manager.browser": "浏览器运行时:特权访问通过宿主权限代理。",
"settings.plugins.manager.advanced": "高级运行时:拥有完整 Node 文件系统和网络能力,代理权限无法限制这些访问。仅供开发预览。",
"settings.plugins.manager.data": "本地 KV 仅保留在此设备。只有明确标记为同步的非秘密设置进入加密云 sidecar。插件包和权限授权不参与同步。",
"settings.plugins.market.results": "{count} 个插件",
"settings.plugins.manager.lastError": "上次记录的错误",
"settings.plugins.market.offline": "官方目录暂时不可用,正在显示上次有效目录或内置示例。刷新成功前不能从市场安装。",
"settings.plugins.manager.details": "查看详情",
"settings.plugins.manager.noDescription": "作者暂未提供功能简介。",
"settings.plugins.market.installed": "已安装",
"settings.plugins.market.sourceBadge": "源码示例",
"settings.plugins.market.title": "插件市场",
"settings.plugins.market.note": "精选开源插件。审核不代表安全认证,更新由你手动确认。",
"settings.plugins.market.back": "所有插件",
"settings.plugins.market.install": "安装此版本(保持停用)",
"settings.plugins.market.installNote": "这会替换并停止已安装版本。请在已安装列表检查权限后再启用。",
"settings.plugins.market.sourceOnly": "第一方源码示例。请用本地 CLI 构建并导入 .ncpkg;尚未发布可下载发行包。",
"settings.plugins.market.readmeNote": "固定到上面的提交。HTML 不执行,远程图片不加载。内容由插件作者提供。",
"settings.plugins.manager.noVault": "系统密钥存储不可用。秘密和凭据操作被阻止;浏览市场和不使用秘密的插件仍可使用。",
"settings.plugins.manager.sensitive": "敏感访问:授权前请检查涉及的数据和资源范围。",
"settings.plugins.manager.dual": "默认使用浏览器运行时,但包中还包含拥有完整设备权限的 Node 入口;仅限开发者模式。",
"settings.plugins.manager.advancedDeclaration": "浏览器入口声明了高级权限。不适用于普通浏览器插件模式;请逐项检查声明权限。",
"settings.plugins.market.retry": "重试",
"settings.plugins.market.empty": "目录暂无插件,仍可导入本地浏览器插件。",
"settings.plugins.market.emptyReadme": "README 内容为空。",
"settings.plugins.market.search": "搜索名称、作者或描述",
"settings.plugins.market.noResults": "没有匹配插件,请尝试其他关键词。",
"settings.plugins.market.manifest": "兼容性与权限",
"settings.plugins.manager.unbounded": "未声明更细的资源范围。请在权限提示中检查实际授权范围。",
"settings.plugins.openView": "打开",
"settings.plugins.openManager": "打开插件管理",
"settings.plugins.title": "插件",
"settings.plugins.description": "管理已安装插件,浏览精选目录。",
'settings.plugins.title': '插件设置',
'settings.plugins.description': '由开发插件提供、经 Netcatty 原生组件验证的设置。',
'settings.plugins.loading': '正在加载插件设置…',
'settings.plugins.empty': '当前没有已启用插件提供设置。',
'settings.plugins.scopeContext': '请在对应的 {scope} 上下文中打开此设置。',
@@ -1,9 +1,4 @@
export const zhCNMonitorMessages = {
"monitor.diskIo": "磁盘 I/O",
"monitor.diskRead": "读取",
"monitor.diskWrite": "写入",
"monitor.diskIoHint": "Linux 底层块设备的汇总 I/O,不重复统计分区和叠加的虚拟设备。取得两次有效采样前不可用;暂不支持 macOS。",
"monitor.actions": "面板操作",
"monitor.unavailable": "监控暂不可用,连接恢复后请重新开始。",
"monitor.username": "用户名",
@@ -1,15 +1,6 @@
import type { Messages } from '../types';
export const zhCNTerminalMessages: Messages = {
'terminal.toolbar.openSessionLog': '打开会话日志',
'terminal.toolbar.sessionLogActions': '会话日志操作',
'terminal.toolbar.sessionLog.menu': '会话日志',
'terminal.toolbar.sessionLog.open': '打开',
'terminal.toolbar.sessionLog.reveal': '在文件夹中显示',
'terminal.toolbar.sessionLog.chooseApplication': '打开方式…',
'terminal.toolbar.sessionLog.systemDefault': '用系统默认应用打开',
'terminal.toolbar.openSessionLogFailed': '无法打开会话日志。',
'terminal.menu.saveScreen': '保存当前屏幕',
'terminal.saveScreen.failed': '保存终端屏幕失败。',
// 多行粘贴确认对话框(#3398)
@@ -621,7 +612,6 @@ export const zhCNTerminalMessages: Messages = {
'settings.shortcuts.binding.broadcast': '切换广播模式',
'settings.shortcuts.binding.toggle-opacity': '切换窗口透明度',
'settings.shortcuts.binding.toggle-side-panel': '切换侧边栏',
'settings.shortcuts.binding.toggle-host-tree': '切换主机列表',
'settings.shortcuts.binding.sftp-copy': '复制文件',
'settings.shortcuts.binding.sftp-cut': '剪切文件',
'settings.shortcuts.binding.sftp-paste': '粘贴文件',
@@ -969,7 +959,6 @@ export const zhCNTerminalMessages: Messages = {
'serial.field.flowControl': '流控制',
'serial.noPorts': '未检测到串口设备。请连接设备后刷新。',
'serial.field.customPort': '自定义串口路径',
'serial.field.namedPipeHint': 'Windows 虚拟机控制台:输入 \\\\.\\pipe\\name。虚拟机需作为服务端创建管道,波特率和串口帧设置不生效。',
'serial.field.customPortPlaceholder': '例如 /dev/ttys001 或 COM1',
'serial.type.hardware': '硬件',
'serial.type.pseudo': '虚拟终端',
-2
View File
@@ -524,8 +524,6 @@ export const zhCNVaultMessages: Messages = {
'terminal.composeBar.send': '发送',
'terminal.composeBar.close': '关闭撰写栏',
'terminal.composeBar.broadcasting': '正在广播到所有会话',
'terminal.composeBar.sendToAll': '发送到所有会话',
'terminal.composeBar.sendToActive': '仅发送到当前会话',
'terminal.composeBar.broadcastAllowPassword': '广播时不对密码进行保护',
'terminal.composeBar.resize': '拖拽调整撰写栏高度',
'terminal.composeBar.manageSnippets': '管理快捷代码片段',
-1
View File
@@ -572,7 +572,6 @@ export const zhTWAiMessages: Messages = {
'ai.chat.modelCatalogSourceCached': '快取 · 上次成功 {time}',
'ai.chat.modelCatalogSourceCachedNoTime': '快取',
'ai.chat.modelCatalogSourcePresets': '內建預設清單',
'ai.chat.refreshModelList': '重新整理模型清單',
'ai.chat.noMatchingModels': '沒有符合的模型',
'ai.chat.recent': '最近',
'ai.chat.viewAll': '檢視全部',
+2 -59
View File
@@ -1037,7 +1037,6 @@ Vaults 分頁維持扁平,不使用這塊背景。
'sftp.transfers.openTargetFolder': '開啟目標目錄',
'sftp.transfers.openTargetFolderError': '無法開啟目標目錄',
'sftp.transfers.copyTargetPath': '複製目標路徑',
'sftp.transfers.cleanupWarnings': '備份清理警告:{count}。展開檔案清單查看詳情。',
'sftp.transfers.copyTargetPathSuccess': '已複製目標路徑',
'sftp.transfers.copyTargetPathError': '無法複製目標路徑',
'sftp.transfers.pathConflict.title': '無法開始傳輸',
@@ -1153,65 +1152,9 @@ Vaults 分頁維持扁平,不使用這塊背景。
'qs.default': '預設',
'settings.tab.plugins': '外掛程式',
"settings.plugins.manager.installed": "已安裝插件",
"settings.plugins.manager.import": "導入 .ncpkg",
"settings.plugins.manager.preview": "導入的插件保持停用,請檢查後再啟用。",
"settings.plugins.manager.empty": "導入本地插件包以開始使用。",
"settings.plugins.manager.enabled": "已啟用",
"settings.plugins.manager.disabled": "已停用",
"settings.plugins.manager.enable": "啟用",
"settings.plugins.manager.disable": "停用",
"settings.plugins.manager.remove": "卸載",
"settings.plugins.manager.removeNote": "卸載會移除插件代碼和本地 KV 數據,保留用戶設置、秘密存儲和同步 sidecar。",
"settings.plugins.permissions.title": "實際授權",
"settings.plugins.permissions.refresh": "重新整理",
"settings.plugins.permissions.loading": "正在讀取授權...",
"settings.plugins.permissions.empty": "沒有有效授權。單次決定不會保留。",
"settings.plugins.permissions.always": "直到撤銷",
"settings.plugins.permissions.application": "直到退出 Netcatty",
"settings.plugins.permissions.session": "直到工作階段關閉",
"settings.plugins.permissions.audit": "最近的安全事件(最多 100 筆)",
"settings.plugins.permissions.revoke": "停用並撤銷全部授權",
"settings.plugins.permissions.revokeNote": "此操作會停用外掛、取消其工作並刪除已儲存的授權。重新啟用後可申請新授權。",
"settings.plugins.permissions.unavailable": "外掛授權管理無法使用",
"settings.plugins.manager.permissions": "聲明的權限",
"settings.plugins.manager.permissionNote": "來自 manifest,不代表已授權。啟用表示允許按需激活;必需權限在首次激活前申請,可選權限在用到時申請。敏感操作仍受宿主檢查。",
"settings.plugins.manager.noPermissions": "未聲明特權權限。",
"settings.plugins.manager.required": "必需",
"settings.plugins.manager.optional": "可選",
"settings.plugins.manager.browser": "瀏覽器運行時:特權訪問通過宿主權限代理。",
"settings.plugins.manager.advanced": "高級運行時:擁有完整 Node 文件系統和網絡能力,代理權限無法限制這些訪問。僅供開發預覽。",
"settings.plugins.manager.data": "本地 KV 僅保留在此設備。只有明確標記為同步的非秘密設置進入加密雲 sidecar。插件包和權限授權不參與同步。",
"settings.plugins.manager.unbounded": "未宣告更細的資源範圍。請在權限提示中檢查實際授權範圍。",
"settings.plugins.openView": "開啟",
"settings.plugins.openManager": "開啟插件管理",
"settings.plugins.title": "插件",
"settings.plugins.description": "管理已安裝插件,瀏覽精選目錄。",
'settings.plugins.title': '外掛程式設定',
'settings.plugins.description': '由開發外掛程式提供、經 Netcatty 原生元件驗證的設定。',
'settings.plugins.loading': '正在載入外掛程式設定…',
"settings.plugins.market.results": "{count} 個插件",
"settings.plugins.manager.lastError": "上次記錄的錯誤",
"settings.plugins.market.offline": "官方目錄暫時無法使用,正在顯示上次有效目錄或內建範例。重新整理成功前無法從市場安裝。",
"settings.plugins.manager.details": "查看詳情",
"settings.plugins.manager.noDescription": "作者尚未提供功能簡介。",
"settings.plugins.market.installed": "已安裝",
"settings.plugins.market.sourceBadge": "原始碼範例",
"settings.plugins.market.title": "插件市場",
"settings.plugins.market.note": "精選開源插件。審核不代表安全認證,更新由你手動確認。",
"settings.plugins.market.back": "所有插件",
"settings.plugins.market.install": "安裝此版本(保持停用)",
"settings.plugins.market.installNote": "這會替換並停止已安裝版本。請在已安裝列表檢查權限後再啟用。",
"settings.plugins.market.sourceOnly": "第一方源碼示例。請用本地 CLI 構建並導入 .ncpkg;尚未發佈可下載發行包。",
"settings.plugins.market.readmeNote": "固定到上面的提交。HTML 不執行,遠程圖片不加載。內容由插件作者提供。",
"settings.plugins.manager.noVault": "系統密鑰存儲不可用。秘密和憑據操作被阻止;瀏覽市場和不使用秘密的插件仍可使用。",
"settings.plugins.manager.sensitive": "敏感訪問:授權前請檢查涉及的數據和資源範圍。",
"settings.plugins.manager.dual": "默認使用瀏覽器運行時,但包中還包含擁有完整設備權限的 Node 入口;僅限開發者模式。",
"settings.plugins.manager.advancedDeclaration": "瀏覽器入口聲明瞭高級權限。不適用於普通瀏覽器插件模式;請逐項檢查聲明權限。",
"settings.plugins.market.retry": "重試",
"settings.plugins.market.empty": "目錄暫無插件,仍可導入本地瀏覽器插件。",
"settings.plugins.market.emptyReadme": "README 內容為空。",
"settings.plugins.market.search": "搜索名稱、作者或描述",
"settings.plugins.market.noResults": "沒有匹配插件,請嘗試其他關鍵詞。",
"settings.plugins.market.manifest": "兼容性與權限",
'settings.plugins.empty': '目前沒有已啟用外掛程式提供設定。',
'settings.plugins.scopeContext': '請在對應的 {scope} 情境中開啟此設定。',
'settings.plugins.pressKeybinding': '請按下組合鍵',
@@ -1,9 +1,4 @@
export const zhTWMonitorMessages = {
"monitor.diskIo": "磁碟 I/O",
"monitor.diskRead": "讀取",
"monitor.diskWrite": "寫入",
"monitor.diskIoHint": "Linux 底層區塊裝置的彙總 I/O,不重複統計分割區與疊加的虛擬裝置。取得兩次有效取樣前無法使用;暫不支援 macOS。",
"monitor.actions": "面板操作",
"monitor.unavailable": "監控暫時無法使用,連線恢復後請重新開始。",
"monitor.username": "使用者名稱",
@@ -1,15 +1,6 @@
import type { Messages } from '../types';
export const zhTWTerminalMessages: Messages = {
'terminal.toolbar.openSessionLog': '開啟工作階段日誌',
'terminal.toolbar.sessionLogActions': '工作階段日誌操作',
'terminal.toolbar.sessionLog.menu': '工作階段日誌',
'terminal.toolbar.sessionLog.open': '開啟',
'terminal.toolbar.sessionLog.reveal': '在資料夾中顯示',
'terminal.toolbar.sessionLog.chooseApplication': '開啟方式…',
'terminal.toolbar.sessionLog.systemDefault': '用系統預設應用程式開啟',
'terminal.toolbar.openSessionLogFailed': '無法開啟工作階段日誌。',
'terminal.menu.saveScreen': '儲存目前畫面',
'terminal.saveScreen.failed': '儲存終端畫面失敗。',
// 多行貼上確認對話框(#3398)
@@ -621,7 +612,6 @@ export const zhTWTerminalMessages: Messages = {
'settings.shortcuts.binding.broadcast': '切換廣播模式',
'settings.shortcuts.binding.toggle-opacity': '切換視窗透明度',
'settings.shortcuts.binding.toggle-side-panel': '切換側邊欄',
'settings.shortcuts.binding.toggle-host-tree': '切換主機列表',
'settings.shortcuts.binding.sftp-copy': '複製檔案',
'settings.shortcuts.binding.sftp-cut': '剪下檔案',
'settings.shortcuts.binding.sftp-paste': '貼上檔案',
@@ -969,7 +959,6 @@ export const zhTWTerminalMessages: Messages = {
'serial.field.flowControl': '流量控制',
'serial.noPorts': '未偵測到序列埠裝置。請連線裝置後重新整理。',
'serial.field.customPort': '自訂序列埠路徑',
'serial.field.namedPipeHint': 'Windows 虛擬機主控台:輸入 \\\\.\\pipe\\name。虛擬機需作為伺服器端建立管道,鮑率與串口框架設定不生效。',
'serial.field.customPortPlaceholder': '例如 /dev/ttys001 或 COM1',
'serial.type.hardware': '硬體',
'serial.type.pseudo': '虛擬終端',
-2
View File
@@ -524,8 +524,6 @@ export const zhTWVaultMessages: Messages = {
'terminal.composeBar.send': '傳送',
'terminal.composeBar.close': '關閉撰寫欄',
'terminal.composeBar.broadcasting': '正在廣播到所有工作階段',
'terminal.composeBar.sendToAll': '傳送到所有工作階段',
'terminal.composeBar.sendToActive': '僅傳送到目前工作階段',
'terminal.composeBar.broadcastAllowPassword': '廣播時不對密碼進行保護',
'terminal.composeBar.resize': '拖曳調整撰寫欄高度',
'terminal.composeBar.manageSnippets': '管理快捷程式碼片段',
+1 -14
View File
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge.ts";
import { editorTabStore } from "./editorTabStore.ts";
import { dockDetachedEditorTab, installEditorWindowSourceListeners, popOutEditorTab, saveDetachedEditorTab, subscribeEditorWindowDirtyCheck } from "./editorWindowClient.ts";
import { dockDetachedEditorTab, installEditorWindowSourceListeners, popOutEditorTab, saveDetachedEditorTab } from "./editorWindowClient.ts";
import { registerEditorSftpWriterScoped } from "./editorSftpBridge.ts";
import { releaseEditorTabSaveCoordinator } from "./editorTabSave.ts";
import type { EditorWindowSaveRequest, EditorWindowSaveResult } from "./editorWindowTypes.ts";
@@ -145,16 +145,3 @@ test("a closing destination rejects a popout without releasing source content",
assert.equal(tab.baselineContent, "v1");
assert.equal(editorTabStore.isDirty(tab.id), true);
});
test("detached editor dirty-check replies echo the main-process request", (t) => {
let query!: (request: { requestId: string; deadline: number }) => void;
const replies: unknown[] = [];
t.mock.method(netcattyBridge, "get", () => ({
onCheckDirtyEditors: (callback: typeof query) => { query = callback; return () => {}; },
reportDirtyEditorsResult: (...args: unknown[]) => replies.push(args),
}));
t.after(subscribeEditorWindowDirtyCheck(() => true));
query({ requestId: "detached-check", deadline: Date.now() + 5000 });
assert.deepEqual(replies, [[true, undefined, { requestId: "detached-check" }]]);
});
+2 -2
View File
@@ -200,7 +200,7 @@ export function reportEditorWindowCloseTabsResult(
export function subscribeEditorWindowDirtyCheck(cb: () => boolean): () => void {
const bridge = netcattyBridge.get();
if (!bridge?.onCheckDirtyEditors) return () => {};
return bridge.onCheckDirtyEditors((request) => {
return bridge.onCheckDirtyEditors(() => {
let hasDirty = false;
try {
hasDirty = cb() === true;
@@ -208,7 +208,7 @@ export function subscribeEditorWindowDirtyCheck(cb: () => boolean): () => void {
hasDirty = false;
}
try {
bridge.reportDirtyEditorsResult?.(hasDirty, undefined, { requestId: request?.requestId });
bridge.reportDirtyEditorsResult?.(hasDirty);
} catch {
// ignore
}
-255
View File
@@ -1,255 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { decideHiddenTabKeepCountAction } from "../../domain/terminalHibernate.ts";
import { retainLeavingTabAfterEviction } from "./hiddenTabEviction.ts";
import { terminalHiddenRendererStore } from "./terminalHiddenRendererStore.ts";
test("a refused eviction tries the next candidate, then full-hibernates", async () => {
const attempts: string[] = [];
const outcomes: string[] = [];
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["images", "plain"],
keepCount: 2,
stillWantsRetention: () => true,
softHide: () => outcomes.push("soft-hide"),
fullHibernate: () => outcomes.push("full-hibernate"),
evict: async (sessionId) => {
attempts.push(sessionId);
return sessionId === "plain";
},
});
assert.deepEqual(attempts, ["images", "plain"]);
assert.deepEqual(outcomes, ["soft-hide"]);
const blocked: string[] = [];
const blockedOutcomes: string[] = [];
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["images", "also-images"],
keepCount: 2,
stillWantsRetention: () => true,
softHide: () => blockedOutcomes.push("soft-hide"),
fullHibernate: () => blockedOutcomes.push("full-hibernate"),
evict: async (sessionId) => {
blocked.push(sessionId);
return false;
},
});
assert.deepEqual(blocked, ["images", "also-images"]);
assert.deepEqual(blockedOutcomes, ["full-hibernate"]);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
});
test("hibernate failure after the soft-hidden marker drops does not retain the leaving tab", async () => {
const attempts: string[] = [];
const outcomes: string[] = [];
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim"],
keepCount: 2,
stillWantsRetention: () => true,
softHide: () => outcomes.push("soft-hide"),
fullHibernate: () => outcomes.push("full-hibernate"),
evict: async (sessionId) => {
attempts.push(sessionId);
await Promise.resolve();
return false;
},
});
assert.deepEqual(attempts, ["victim"]);
assert.deepEqual(outcomes, ["full-hibernate"]);
const recovered: string[] = [];
const recoveredOutcomes: string[] = [];
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim", "next"],
keepCount: 2,
stillWantsRetention: () => true,
softHide: () => recoveredOutcomes.push("soft-hide"),
fullHibernate: () => recoveredOutcomes.push("full-hibernate"),
evict: async (sessionId) => {
recovered.push(sessionId);
await Promise.resolve();
return sessionId === "next";
},
});
assert.deepEqual(recovered, ["victim", "next"]);
assert.deepEqual(recoveredOutcomes, ["soft-hide"]);
});
test("a live keep count that still fits after eviction soft-hides the leaving tab", async () => {
let keepCount = 0;
const outcomes: string[] = [];
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim"],
keepCount,
resolveKeepCount: () => keepCount,
stillWantsRetention: () => true,
softHide: () => outcomes.push("soft-hide"),
fullHibernate: () => outcomes.push("full-hibernate"),
evict: async () => {
keepCount = 2;
await Promise.resolve();
return true;
},
});
assert.deepEqual(outcomes, ["soft-hide"]);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
});
test("a keep count dropped to zero during eviction full-hibernates the leaving tab", async () => {
let keepCount = 2;
const outcomes: string[] = [];
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim"],
keepCount,
resolveKeepCount: () => keepCount,
stillWantsRetention: () => true,
softHide: () => outcomes.push("soft-hide"),
fullHibernate: () => outcomes.push("full-hibernate"),
evict: async () => {
keepCount = 0;
await Promise.resolve();
return true;
},
});
assert.deepEqual(outcomes, ["full-hibernate"]);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
});
test("a keep count that no longer fits after eviction full-hibernates the leaving tab", async () => {
const sessions = ["victim", "other", "leaving"];
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
terminalHiddenRendererStore.markSoftHidden("other");
let keepCount = 2;
const outcomes: string[] = [];
try {
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim"],
keepCount,
resolveKeepCount: () => keepCount,
stillWantsRetention: () => true,
softHide: () => outcomes.push("soft-hide"),
fullHibernate: () => outcomes.push("full-hibernate"),
evict: async () => {
keepCount = 1;
await Promise.resolve();
return true;
},
});
assert.deepEqual(outcomes, ["full-hibernate"]);
assert.equal(terminalHiddenRendererStore.isSoftHidden("leaving"), false);
} finally {
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
}
});
test("a leaving tab that no longer wants retention is not soft-hidden or hibernated", async () => {
const outcomes: string[] = [];
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim"],
keepCount: 2,
stillWantsRetention: () => false,
softHide: () => outcomes.push("soft-hide"),
fullHibernate: () => outcomes.push("full-hibernate"),
evict: async () => true,
});
assert.deepEqual(outcomes, []);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
});
test("a pending reservation keeps a second tab from seeing spare capacity", async () => {
const sessions = ["victim", "other", "leaving"];
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
terminalHiddenRendererStore.markSoftHidden("victim");
terminalHiddenRendererStore.markSoftHidden("other");
const unsubscribe = terminalHiddenRendererStore.subscribe(() => {
if (!terminalHiddenRendererStore.consumeEvictionRequest("victim")) return;
terminalHiddenRendererStore.clearSoftHidden("victim");
});
const outcomes: string[] = [];
const pending = retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim"],
keepCount: 2,
stillWantsRetention: () => true,
softHide: () => {
terminalHiddenRendererStore.markSoftHidden("leaving");
outcomes.push("soft-hide");
},
fullHibernate: () => outcomes.push("full-hibernate"),
evict: (sessionId) => terminalHiddenRendererStore.evictSoftHidden(sessionId),
});
try {
assert.equal(terminalHiddenRendererStore.isSoftHidden("victim"), false);
assert.equal(terminalHiddenRendererStore.getSoftHiddenCount(), 1);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
assert.deepEqual(decideHiddenTabKeepCountAction({
keepCount: 2,
softHiddenCount: terminalHiddenRendererStore.getSoftHiddenOccupancy(),
sessionId: "second",
candidatesOldestFirst: terminalHiddenRendererStore.listSoftHiddenOldestFirst(),
}), { action: "await-eviction", candidates: ["other"] });
terminalHiddenRendererStore.settleEviction("victim", true);
await pending;
assert.deepEqual(outcomes, ["soft-hide"]);
assert.equal(terminalHiddenRendererStore.getSoftHiddenCount(), 2);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
} finally {
unsubscribe();
terminalHiddenRendererStore.settleEviction("victim", false);
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
await pending.catch(() => undefined);
}
});
test("two leaving tabs share one freed slot and stay within keep-count", async () => {
const sessions = ["victim", "other", "leaving-a", "leaving-b"];
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
terminalHiddenRendererStore.markSoftHidden("victim");
terminalHiddenRendererStore.markSoftHidden("other");
const unsubscribe = terminalHiddenRendererStore.subscribe(() => {
if (!terminalHiddenRendererStore.consumeEvictionRequest("victim")) return;
terminalHiddenRendererStore.clearSoftHidden("victim");
queueMicrotask(() => {
terminalHiddenRendererStore.settleEviction("victim", true);
});
});
const retained: string[] = [];
const hibernated: string[] = [];
const retain = (sessionId: string) => retainLeavingTabAfterEviction({
sessionId,
candidates: ["victim"],
keepCount: 2,
stillWantsRetention: () => true,
softHide: () => {
terminalHiddenRendererStore.markSoftHidden(sessionId);
retained.push(sessionId);
},
fullHibernate: () => hibernated.push(sessionId),
});
try {
await Promise.all([retain("leaving-a"), retain("leaving-b")]);
assert.equal(retained.length, 1);
assert.equal(hibernated.length, 1);
assert.equal(terminalHiddenRendererStore.getSoftHiddenCount(), 2);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
} finally {
unsubscribe();
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
}
});
-53
View File
@@ -1,53 +0,0 @@
import { terminalHiddenRendererStore } from "./terminalHiddenRendererStore.ts";
/**
* Walk keep-count victims until one hibernate completes, then retain the
* leaving tab only if its reserved slot still fits.
*
* The reservation is taken before the first await. Clearing a victim marker
* during wake would otherwise look like spare capacity to a second hidden-tab
* timer, and both tabs would soft-hide once the same eviction resolved.
*/
export async function retainLeavingTabAfterEviction(input: {
sessionId: string;
candidates: readonly string[];
/** Count captured when this leave started. Used only if resolveKeepCount is absent. */
keepCount: number;
/**
* Live keep-count read after eviction settles. Settings can change while the
* victim drains or serializes; retaining against the captured count would
* soft-hide a tab the user no longer wants kept.
*/
resolveKeepCount?: () => number;
stillWantsRetention: () => boolean;
softHide: () => void;
fullHibernate: () => void;
evict?: (sessionId: string) => Promise<boolean> | boolean;
}): Promise<void> {
const token = terminalHiddenRendererStore.reserveLeavingSlot(input.sessionId);
try {
const evict = input.evict ?? ((sessionId: string) => (
terminalHiddenRendererStore.evictSoftHidden(sessionId)
));
let completed = false;
for (const candidate of input.candidates) {
if (!candidate || candidate === input.sessionId) continue;
if (await evict(candidate)) {
completed = true;
break;
}
}
if (!input.stillWantsRetention()) return;
const keepCount = input.resolveKeepCount?.() ?? input.keepCount;
if (
completed
&& terminalHiddenRendererStore.leavingSlotStillFits(input.sessionId, keepCount)
) {
input.softHide();
return;
}
input.fullHibernate();
} finally {
terminalHiddenRendererStore.releaseLeavingSlot(input.sessionId, token);
}
}
@@ -1,82 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { JSDOM } from 'jsdom';
import { performSessionLogAction } from './sessionLogActions';
import { STORAGE_KEY_SESSION_LOG_APPLICATION } from '../../infrastructure/config/storageKeys';
test('session log actions resolve and flush the selected session, remember apps and reset to default', async () => {
const dom = new JSDOM('', { url: 'http://localhost' });
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const previousStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
Object.defineProperty(globalThis, 'window', { configurable: true, value: dom.window });
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: dom.window.localStorage });
const calls: unknown[][] = [];
let picked: { path: string; name: string } | null = { path: '/Applications/My Editor.app', name: 'My Editor' };
let available = true;
let failOpen = false;
const bridge = {
prepareSessionLog: async (payload: { sessionId: string }) => {
calls.push(['prepare', payload]);
return available ? { success: true, filePath: `/logs/${payload.sessionId}.html` }
: { success: false, error: 'No saved log for this session' };
},
revealSessionLog: async (payload: { sessionId: string }) => {
calls.push(['reveal', payload]);
return { success: true };
},
selectApplication: async () => { calls.push(['choose']); return picked; },
openWithApplication: async (file: string, app: string) => {
calls.push(['application', file, app]);
if (failOpen) throw new Error('Launch failed');
return true;
},
openWithSystemDefault: async (file: string) => { calls.push(['default', file]); return { success: true }; },
};
Object.assign(dom.window, { netcatty: bridge });
try {
await performSessionLogAction('first', 'open');
assert.deepEqual(calls.splice(0), [['prepare', { sessionId: 'first' }], ['default', '/logs/first.html']]);
await performSessionLogAction('second', 'chooseApplication');
assert.deepEqual(calls.splice(0), [
['prepare', { sessionId: 'second' }], ['choose'], ['prepare', { sessionId: 'second' }],
['application', '/logs/second.html', picked!.path],
]);
assert.equal(JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_SESSION_LOG_APPLICATION)!).name, 'My Editor');
await performSessionLogAction('first', 'open');
assert.deepEqual(calls.splice(0), [['prepare', { sessionId: 'first' }], ['application', '/logs/first.html', picked!.path]]);
await performSessionLogAction('first', 'reveal');
assert.deepEqual(calls.splice(0), [['reveal', { sessionId: 'first' }]]);
await performSessionLogAction('first', 'systemDefault');
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_SESSION_LOG_APPLICATION), null);
calls.length = 0;
picked = null;
await performSessionLogAction('first', 'chooseApplication');
assert.deepEqual(calls.splice(0), [['prepare', { sessionId: 'first' }], ['choose']]);
available = false;
await assert.rejects(performSessionLogAction('missing', 'chooseApplication'), /No saved log/);
assert.deepEqual(calls.splice(0), [['prepare', { sessionId: 'missing' }]]);
available = true;
picked = { path: '/missing-editor', name: 'Missing' };
failOpen = true;
await assert.rejects(performSessionLogAction('first', 'chooseApplication'), /Launch failed/);
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_SESSION_LOG_APPLICATION), null);
} finally {
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow);
else Reflect.deleteProperty(globalThis, 'window');
if (previousStorage) Object.defineProperty(globalThis, 'localStorage', previousStorage);
else Reflect.deleteProperty(globalThis, 'localStorage');
dom.window.close();
}
});
// The integration boundary must translate backend errors, including ordinary
// Error instances. Raw filesystem/app-launch details belong only in diagnostics.
test('terminal log action errors use the translated toast instead of raw backend text', () => {
const source = readFileSync(new URL('../../components/Terminal.tsx', import.meta.url), 'utf8');
const handler = source.slice(source.indexOf(' const handleSessionLogAction ='),
source.indexOf(' const handleToggleSessionLog ='));
assert.match(handler, /logger\.error\("\[Terminal\] Failed to open or reveal session log:", err\)/);
assert.match(handler, /toast\.error\(t\('terminal\.toolbar\.openSessionLogFailed'\)\)/);
assert.doesNotMatch(handler, /err\.message/);
});
-48
View File
@@ -1,48 +0,0 @@
import { STORAGE_KEY_SESSION_LOG_APPLICATION } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export type SessionLogAction = 'open' | 'reveal' | 'chooseApplication' | 'systemDefault';
type LogApplication = { path: string; name: string };
/** Resolve the session's actual log in its owning process before opening it. */
export async function performSessionLogAction(sessionId: string, action: SessionLogAction): Promise<void> {
const bridge = netcattyBridge.get();
if (!bridge?.prepareSessionLog) throw new Error('Session log bridge unavailable');
if (action === 'reveal') {
const result = await bridge.revealSessionLog?.({ sessionId });
if (!result?.success) throw new Error(result?.error || 'Failed to reveal session log');
return;
}
// Check availability before presenting an application picker. Resolve again
// afterwards so any output received while the picker was open is flushed.
let result = await bridge.prepareSessionLog({ sessionId });
if (!result.success || !result.filePath) throw new Error(result.error || 'No saved log for this session');
let application = localStorageAdapter.read<LogApplication>(STORAGE_KEY_SESSION_LOG_APPLICATION);
if (action === 'chooseApplication') {
if (!bridge.selectApplication) throw new Error('Application picker unavailable');
application = await bridge.selectApplication();
if (!application) return;
result = await bridge.prepareSessionLog({ sessionId });
if (!result.success || !result.filePath) throw new Error(result.error || 'No saved log for this session');
} else if (action === 'systemDefault') {
application = null;
}
if (application?.path) {
if (!bridge.openWithApplication) throw new Error('Application opening unavailable');
const opened = await bridge.openWithApplication(result.filePath, application.path);
if (!opened) throw new Error('Failed to open session log');
} else {
const opened = await bridge.openWithSystemDefault?.(result.filePath);
if (!opened?.success) throw new Error(opened?.error || 'Failed to open session log');
}
// Only remember a choice after a successful launch; failed launches leave
// the previous preference intact and can be retried or reset from the menu.
if (action === 'chooseApplication') {
localStorageAdapter.write(STORAGE_KEY_SESSION_LOG_APPLICATION, application);
} else if (action === 'systemDefault') {
localStorageAdapter.remove(STORAGE_KEY_SESSION_LOG_APPLICATION);
}
}
@@ -91,7 +91,6 @@ test('session actions register and unregister', () => {
createWorkspaceFromSessions: noop,
addSessionToWorkspace: noop,
appendHostToWorkspace: noop,
appendHostToWorkspaceConfirmed: noop,
appendLocalTerminalToWorkspace: noop,
createWorkspaceFromTargets: noop,
updateSplitSizes: noop,
@@ -103,10 +102,6 @@ test('session actions register and unregister', () => {
reorderTabs: noop,
toggleBroadcast: noop,
isBroadcastEnabled: noop,
toggleComposeSendTarget: noop,
isComposeSendToAll: noop,
toggleGlobalComposeSendTarget: noop,
isGlobalComposeSendToAll: false,
copySession: noop,
copyWorkspace: noop,
createSessionFromCloneSource: noop,
@@ -57,7 +57,6 @@ export type SessionSnapshotActions = Pick<
| 'createWorkspaceFromSessions'
| 'addSessionToWorkspace'
| 'appendHostToWorkspace'
| 'appendHostToWorkspaceConfirmed'
| 'appendLocalTerminalToWorkspace'
| 'createWorkspaceFromTargets'
| 'updateSplitSizes'
@@ -69,12 +68,8 @@ export type SessionSnapshotActions = Pick<
| 'reorderTabs'
| 'toggleBroadcast'
| 'isBroadcastEnabled'
| 'toggleComposeSendTarget'
| 'isComposeSendToAll'
| 'toggleGlobalBroadcast'
| 'isGlobalBroadcastEnabled'
| 'toggleGlobalComposeSendTarget'
| 'isGlobalComposeSendToAll'
| 'canUseGlobalBroadcast'
| 'copySession'
| 'copyWorkspace'
@@ -10,18 +10,6 @@ const failed = (over: Partial<TransferTask> = {}): TransferTask => ({
speed: 0, startTime: 1, isDirectory: false, error: "SFTP channel closed", ...over,
});
test("canRetryFromCheckpoint resumes the channel-loss forms the bridge already classifies", () => {
for (const error of [
"read ECONNABORTED",
"socket hang up",
"ERR_SFTP_CHANNEL_CLOSED",
"SFTP pipelined upload failed: socket hang up",
"client-socket",
]) {
assert.equal(canRetryFromCheckpoint(failed({ error })), true, error);
}
});
test("canRetryFromCheckpoint only for a top-level file that lost its connection with progress", () => {
assert.equal(canRetryFromCheckpoint(failed()), true);
assert.equal(canRetryFromCheckpoint(failed({ error: "read ECONNRESET" })), true);
@@ -4,11 +4,7 @@ import type { TransferTask } from "../../../domain/models";
// .part is intact up to the checkpoint, so Retry can continue from it instead
// of restarting at byte 0 (which also strands the old stage on the server,
// because the new attempt stages under a fresh transfer id).
// Keep this identical to UPLOAD_PREFIX_SESSION_LOSS in transferBridge.cjs, and
// cover every code or message isSftpChannelLossError already treats as channel
// loss (including ECONNABORTED, socket hang up, ERR_SFTP_CHANNEL_CLOSED, and
// client-socket). A miss here restarts at byte 0 and strands the .part.
const SESSION_LOSS_ERROR = /SFTP (channel closed|session lost)|ERR_SFTP_CHANNEL_CLOSED|ECONNRESET|ECONNABORTED|EPIPE|Not connected|No response from server|Keepalive timeout|socket hang up|client-socket|connection (lost|closed|reset)/i;
const SESSION_LOSS_ERROR = /SFTP (channel closed|session lost)|ECONNRESET|EPIPE|Not connected|No response from server|Keepalive timeout|connection (lost|closed|reset)/i;
/**
* True when Retry should resume the same task from its checkpoint.
@@ -163,9 +163,6 @@ export async function restoreSftpTransferHistoryCooperatively(
const child = parentChildren[childIndex];
if (
child.status === "completed"
// Match canonical pruning: warning rows carry recovery paths that
// cannot be represented by the directory's compact manifest.
&& !child.warning
&& Number.isSafeInteger(child.directoryEntryIndex)
&& (child.directoryEntryIndex ?? checkpoint.coveredEntries) < checkpoint.coveredEntries
&& /^[a-f0-9]{64}$/.test(child.directoryEntryIdentity ?? "")
@@ -135,34 +135,6 @@ test("completed dispatch consumes only exact identity and never restarts", async
assert.equal(store.getTask(task.id)?.status, "completed");
});
test("successful invoke warnings reach the child before local completion can compact it", async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
const task = { ...child(), id: "warning-result-child", parentTaskId: "warning-result-root" };
const root = { ...task, id: task.parentTaskId, parentTaskId: undefined, isDirectory: true };
const warning = "Upload completed; remove backup /target/.netcatty-backup-a manually.";
store.upsertTasks([root, task]);
t.after(() => store.dismiss(root.id));
const result = await runTransferAndWaitForOwner(task, async () => ({ warning }), () => false);
assert.equal(result?.warning, warning);
assert.equal(store.getTask(task.id)?.warning, warning, "capture metadata before the caller finishes the child");
store.patchTask(task.id, { status: "completed", transferredBytes: 1 });
assert.equal(store.getTask(task.id)?.status, "completed", "warning-bearing children stay available for inspection");
assert.equal(store.getTask(task.id)?.warning, warning);
});
for (const outcome of [{ error: "write failed" }, { cancelled: true }]) {
test(`unsuccessful invoke results do not add cleanup warnings (${JSON.stringify(outcome)})`, async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
const task = { ...child(), id: `unsuccessful-warning-${"error" in outcome ? "failed" : "cancelled"}`, parentTaskId: undefined };
store.upsertTasks([task]);
t.after(() => store.dismiss(task.id));
await runTransferAndWaitForOwner(task, async () => ({ ...outcome, warning: "not a committed upload" }), () => false);
assert.equal(store.getTask(task.id)?.warning, undefined);
});
}
test("a resumed retry does not inherit failed settlement captured while admission was paused", async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
-1
View File
@@ -65,7 +65,6 @@ export interface FileWatchSyncedEvent {
localPath: string;
remotePath: string;
bytesWritten: number;
warning?: string;
}
export interface FileWatchErrorEvent {
@@ -156,10 +156,6 @@ export const createUploadTaskCallbacks = ({
phase: phase === "compressed" ? "transferring" : phase as TransferTask["phase"],
});
},
onTaskWarning: (taskId: string, warning: string) => {
// Preserve cleanup details before completion can compact a child row.
store.patchTask(taskId, { warning });
},
onTaskCompleted: (taskId: string, totalBytes: number) => {
store.patchTask(taskId, {
status: "completed" as TransferStatus,
+2 -12
View File
@@ -6,7 +6,7 @@ export class TransferOwnerChangedError extends Error {}
export type TransferOwnerObservation = ReturnType<typeof sftpTransferCenterStore.observeTaskSettlement>;
type StreamResult = { error?: string; cancelled?: boolean; superseded?: boolean; warning?: string } | undefined;
type StreamResult = { error?: string; cancelled?: boolean; superseded?: boolean } | undefined;
/** A live waiter owns bounded settlement evidence; persisted history stays compact. */
export async function runTransferAndWaitForOwner(
@@ -37,17 +37,7 @@ export async function runTransferAndWaitForOwner(
observation = sftpTransferCenterStore.observeTaskSettlement(task, undefined, onOwnerChanged);
const result = await start();
if (observation.hasIdentityConflict()) throw new TransferOwnerChangedError("Transfer identity changed while waiting for its owner");
if (!result?.superseded) {
// The invoke response may arrive before completed IPC. Preserve cleanup
// details before the caller completes (and may compact) this child.
if (result?.warning && !result.error && !result.cancelled) {
const latest = sftpTransferCenterStore.getTask(task.id);
if (latest && latest.status !== "failed" && latest.status !== "cancelled") {
sftpTransferCenterStore.patchTask(task.id, { warning: result.warning });
}
}
return result;
}
if (!result?.superseded) return result;
for (;;) {
if (shouldAbort()) throw new Error("Transfer cancelled");
if (observation.hasIdentityConflict()) throw new TransferOwnerChangedError("Transfer identity changed while waiting for its owner");
@@ -33,85 +33,6 @@ test("store aggregates owner snapshots without duplicating tasks", () => {
]);
});
test("completion cleanup warnings survive owner snapshots and restored history", () => {
let persisted: string | null = null;
const store = createSftpTransferCenterStore({ read: () => persisted, write: (value) => { persisted = value; } });
const source = makeTask("cleanup-warning");
const warning = "Upload completed; remove backup /target/.netcatty-backup-file manually.";
store.publishOwner("panel", [source]);
store.ingestBackgroundEvent({ type: "completed", transferId: source.id, transferred: 10, warning });
assert.equal(store.getTask(source.id)?.status, "completed");
assert.equal(store.getTask(source.id)?.warning, warning);
assert.equal(store.getTask(source.id)?.reconnectRequired, false);
store.publishOwner("panel", [{ ...source, status: "completed", transferredBytes: 10, endTime: Date.now() }]);
assert.equal(store.getTask(source.id)?.warning, warning, "a stale renderer completion cannot erase cleanup details");
const restored = createSftpTransferCenterStore({ read: () => persisted, write: () => {} });
assert.equal(restored.getTask(source.id)?.warning, warning);
assert.equal(restored.getTask(source.id)?.status, "completed");
assert.equal(restored.getTask(source.id)?.error, undefined);
});
test("invoke cleanup warnings survive stale live and completion snapshots until an explicit restart", () => {
const store = createSftpTransferCenterStore();
const source = makeTask("invoke-warning-snapshot");
const warning = "Upload completed; remove backup /target/.netcatty-backup-file manually.";
store.publishOwner("panel", [source]);
store.patchTask(source.id, { warning });
store.publishOwner("panel", [{ ...source, transferredBytes: 9 }]);
assert.equal(store.getTask(source.id)?.warning, warning);
store.publishOwner("panel", [{ ...source, status: "completed", transferredBytes: 10, endTime: Date.now() }]);
assert.equal(store.getTask(source.id)?.warning, warning);
store.publishOwner("panel", [{ ...source, status: "queued", transferredBytes: 0, checkpointBytes: 0 }]);
assert.equal(store.getTask(source.id)?.warning, undefined, "new attempts do not inherit cleanup warnings");
});
test("a clean completed event cannot erase a warning already returned by the invoke", () => {
const store = createSftpTransferCenterStore();
const source = makeTask("invoke-warning-event");
const warning = "Upload completed; remove backup /target/.netcatty-backup-file manually.";
store.publishOwner("panel", [source]);
store.patchTask(source.id, { warning });
store.ingestBackgroundEvent({ type: "completed", transferId: source.id, transferred: 10 });
assert.equal(store.getTask(source.id)?.status, "completed");
assert.equal(store.getTask(source.id)?.warning, warning);
});
test("late completion enriches a locally completed task with its cleanup warning", () => {
let persisted: string | null = null;
const store = createSftpTransferCenterStore({ read: () => persisted, write: (value) => { persisted = value; } });
const source = { ...makeTask("result-first-warning"), phase: "transferring" as const };
const warning = "Upload completed; remove backup /target/.netcatty-backup-file manually.";
const endTime = Date.now();
store.publishOwner("panel", [source]);
// The invoke response can settle the renderer before the completed IPC arrives.
store.patchTask(source.id, { status: "completed", transferredBytes: 10, endTime });
let notifications = 0;
const unsubscribe = store.subscribe(() => { notifications += 1; });
store.ingestBackgroundEvent({ type: "completed", transferId: source.id, transferred: 10, endedAt: endTime + 1, warning });
assert.equal(store.getTask(source.id)?.warning, warning);
assert.equal(store.getTask(source.id)?.status, "completed");
assert.equal(store.getTask(source.id)?.endTime, endTime, "late metadata does not replace the terminal outcome");
assert.equal(notifications, 1, "the warning is visible without another transfer triggering a render");
store.ingestBackgroundEvent({ type: "completed", transferId: source.id, transferred: 10 });
assert.equal(store.getTask(source.id)?.warning, warning, "a duplicate clean event cannot erase the warning");
const restored = createSftpTransferCenterStore({ read: () => persisted, write: () => {} });
assert.equal(restored.getTask(source.id)?.warning, warning);
unsubscribe();
});
for (const status of ["failed", "cancelled"] as const) {
test(`late cleanup warnings cannot replace a ${status} terminal outcome`, () => {
const store = createSftpTransferCenterStore();
const endTime = Date.now();
const task = { ...makeTask(`late-warning-${status}`, status), error: "original outcome", endTime };
store.publishOwner("panel", [task]);
store.ingestBackgroundEvent({ type: "completed", transferId: task.id, warning: "cleanup warning" });
assert.equal(store.getTask(task.id)?.status, status);
assert.equal(store.getTask(task.id)?.error, "original outcome");
assert.equal(store.getTask(task.id)?.warning, undefined);
});
}
test("store routes cancel/retry/prioritize/dismiss to the task owner; pause/resume are process-global", async () => {
const calls: string[] = [];
const store = createSftpTransferCenterStore();
@@ -179,10 +100,6 @@ test("background-agent retry after restart uses fresh dedicated recovery instead
for (const [label, error, expectResume] of [
["dead channel", "SFTP channel closed", true],
["lost session", "SFTP session lost. Please reconnect.", true],
["aborted socket", "read ECONNABORTED", true],
["hung up socket", "socket hang up", true],
["closed channel code", "ERR_SFTP_CHANNEL_CLOSED", true],
["client socket", "client-socket", true],
["content failure", "Permission denied", false],
] as const) {
test(`panel Retry after ${label} ${expectResume ? "resumes the same task from its checkpoint" : "keeps the restart-from-0 retry"}`, async () => {
@@ -3655,46 +3572,6 @@ test("legacy 50,000-file history restores cooperatively and converges to one per
assert.ok(persisted.length < 10_000, "migrated localStorage payload must stay bounded");
});
for (const indexed of [false, true]) {
test(`cooperative ${indexed ? "indexed" : "legacy"} restore retains completed child cleanup warnings`, async () => {
const now = Date.now();
const warning = "Upload completed; remove backup /target/.netcatty-backup-file manually.";
const parent: TransferTask = { ...makeTask("restore-warning-folder", "paused"), isDirectory: true };
const children: TransferTask[] = [0, 1].map((index) => ({
...makeTask(`restore-warning-child-${index}`, "completed"),
parentTaskId: parent.id, endTime: now,
warning: index === 0 ? warning : undefined,
...(indexed ? { directoryEntryIndex: index, directoryEntryIdentity: String(index).padStart(64, "0") } : {}),
}));
let persisted = JSON.stringify({ version: 1, padding: "x".repeat(600_000), tasks: [parent, ...children] });
const store = createSftpTransferCenterStore({ read: () => persisted, write: (value) => { persisted = value; } });
assert.equal(store.getSnapshot().tasks.length, 0, "large history must take cooperative restoration");
await new Promise<void>((resolve, reject) => {
let unsubscribe = () => {};
const timeout = setTimeout(() => {
unsubscribe();
reject(new Error("timed out waiting for cleanup-warning history migration"));
}, 10_000);
const check = () => {
if (!store.getTask(parent.id)) return;
clearTimeout(timeout);
unsubscribe();
resolve();
};
unsubscribe = store.subscribe(check);
check();
});
assert.equal(store.getTask("restore-warning-child-0")?.warning, warning);
assert.equal(store.getTask("restore-warning-child-0")?.status, "completed");
assert.equal(store.getTask("restore-warning-child-1"), undefined, "clean completed children still compact");
assert.equal(store.getTask(parent.id)?.directoryResumeCheckpoint?.coveredEntries, 2);
assert.equal(store.getTask(parent.id)?.directoryResumeCheckpoint?.completedEntries, 1);
const restarted = createSftpTransferCenterStore({ read: () => persisted, write: () => {} });
assert.equal(restarted.getTask("restore-warning-child-0")?.warning, warning);
assert.equal(restarted.getTask(parent.id)?.directoryResumeCheckpoint?.completedEntries, 1);
});
}
test("live mutations wait for cooperative restore and merge without erasing old rows", async () => {
const legacyOnly = makeTask("legacy-only", "paused");
const staleCollision = makeTask("collision", "paused");
@@ -152,7 +152,6 @@ export interface SftpTransferCenterStore {
startedAt?: number;
endedAt?: number;
error?: string;
warning?: string;
transferred?: number;
totalBytes?: number;
speed?: number;
@@ -290,12 +289,6 @@ export function mergeOwnerPublishedTask(
updatedAt: incoming.updatedAt ?? Date.now(),
};
// A panel snapshot may predate cleanup metadata returned by the invoke or
// completed IPC. Keep it through this attempt, but not a restart or failure.
if (!incomingReset && incoming.status !== "failed" && incoming.status !== "cancelled") {
merged.warning = incoming.warning ?? existing.warning;
}
if (incomingReset || incomingTerminal) {
return merged;
}
@@ -2281,15 +2274,6 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
if (terminal && event.type !== "cancelled") {
// Allow a late explicit cancel to stick; ignore everything else.
if (event.type === "completed" || event.type === "failed") {
// An invoke response can complete the renderer row before its IPC
// event arrives. Enrich that same success with cleanup metadata,
// without replacing any terminal outcome or reviving stopped work.
if (existing.status === "completed" && event.type === "completed"
&& event.warning && event.warning !== existing.warning) {
tasks = tasks.map((task) => task.id === existing.id ? { ...task, warning: event.warning } : task);
emit(true);
return;
}
// Keep existing terminal state (prefer cancelled over late completed).
publishRepairedHierarchy();
return;
@@ -2577,7 +2561,6 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
? task.checkpointBytes
: Math.max(task.checkpointBytes ?? 0, Number(event.checkpointBytes) || 0),
error: event.error,
warning: event.type === "completed" ? event.warning ?? task.warning : undefined,
endTime: event.endedAt ?? Date.now(),
speed: 0,
reconnectRequired: event.type === "failed" ? task.reconnectRequired : false,
@@ -9,106 +9,6 @@ test("terminalHiddenRendererStore evicts oldest soft-hidden session", () => {
terminalHiddenRendererStore.markSoftHidden("a");
terminalHiddenRendererStore.markSoftHidden("b");
assert.equal(terminalHiddenRendererStore.pickEvictionCandidate(2), "a");
assert.deepEqual(terminalHiddenRendererStore.listSoftHiddenOldestFirst(), ["a", "b"]);
terminalHiddenRendererStore.clearSoftHidden("a");
terminalHiddenRendererStore.clearSoftHidden("b");
});
test("evictSoftHidden reports refusal and drops an unconsumed request", async () => {
const sessions = ["images", "plain", "other"];
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
terminalHiddenRendererStore.markSoftHidden("images");
terminalHiddenRendererStore.markSoftHidden("plain");
const unsubscribe = terminalHiddenRendererStore.subscribe(() => {
if (terminalHiddenRendererStore.consumeEvictionRequest("images")) return;
if (terminalHiddenRendererStore.consumeEvictionRequest("plain")) {
terminalHiddenRendererStore.clearSoftHidden("plain");
terminalHiddenRendererStore.settleEviction("plain", true);
}
});
try {
assert.equal(await terminalHiddenRendererStore.evictSoftHidden("images"), false);
assert.equal(terminalHiddenRendererStore.isSoftHidden("images"), true);
assert.equal(await terminalHiddenRendererStore.evictSoftHidden("plain"), true);
assert.equal(terminalHiddenRendererStore.isSoftHidden("plain"), false);
} finally {
unsubscribe();
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
}
terminalHiddenRendererStore.markSoftHidden("images");
assert.equal(await terminalHiddenRendererStore.evictSoftHidden("images"), false);
const delivered: string[] = [];
const later = terminalHiddenRendererStore.subscribe(() => {
if (terminalHiddenRendererStore.consumeEvictionRequest("images")) delivered.push("images");
});
try {
terminalHiddenRendererStore.markSoftHidden("other");
assert.deepEqual(delivered, []);
assert.equal(terminalHiddenRendererStore.isSoftHidden("images"), true);
} finally {
later();
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
}
});
test("a reserved leaving slot counts toward occupancy until it is released", () => {
const sessions = ["a", "b", "c", "d"];
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
terminalHiddenRendererStore.markSoftHidden("a");
terminalHiddenRendererStore.markSoftHidden("b");
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
const alreadyHidden = terminalHiddenRendererStore.reserveLeavingSlot("a");
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
terminalHiddenRendererStore.releaseLeavingSlot("a", alreadyHidden);
const tokenC = terminalHiddenRendererStore.reserveLeavingSlot("c");
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 3);
assert.equal(terminalHiddenRendererStore.leavingSlotStillFits("c", 2), false);
terminalHiddenRendererStore.clearSoftHidden("a");
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
assert.equal(terminalHiddenRendererStore.leavingSlotStillFits("c", 2), true);
const tokenD = terminalHiddenRendererStore.reserveLeavingSlot("d");
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 3);
assert.equal(terminalHiddenRendererStore.leavingSlotStillFits("c", 2), false);
assert.equal(terminalHiddenRendererStore.leavingSlotStillFits("d", 2), false);
terminalHiddenRendererStore.releaseLeavingSlot("c", tokenD);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 3);
terminalHiddenRendererStore.releaseLeavingSlot("c", tokenC);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
terminalHiddenRendererStore.releaseLeavingSlot("d", tokenD);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 1);
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
});
test("evictSoftHidden waits for hibernate after the marker is cleared", async () => {
terminalHiddenRendererStore.clearSoftHidden("victim");
terminalHiddenRendererStore.markSoftHidden("victim");
let markerClearedBeforeSettle = false;
const unsubscribe = terminalHiddenRendererStore.subscribe(() => {
if (!terminalHiddenRendererStore.consumeEvictionRequest("victim")) return;
terminalHiddenRendererStore.clearSoftHidden("victim");
markerClearedBeforeSettle = !terminalHiddenRendererStore.isSoftHidden("victim");
queueMicrotask(() => {
terminalHiddenRendererStore.markSoftHidden("victim");
terminalHiddenRendererStore.settleEviction("victim", false);
});
});
try {
const completed = await terminalHiddenRendererStore.evictSoftHidden("victim");
assert.equal(markerClearedBeforeSettle, true);
assert.equal(completed, false);
assert.equal(terminalHiddenRendererStore.isSoftHidden("victim"), true);
} finally {
unsubscribe();
terminalHiddenRendererStore.clearSoftHidden("victim");
}
});
@@ -3,85 +3,14 @@ type Listener = () => void;
const softHiddenSessions = new Map<string, number>();
const listeners = new Set<Listener>();
let evictionRequestSessionId: string | null = null;
/** Leaving tabs that have claimed a keep slot while a victim hibernate is in flight. */
const reservedLeavingSlots = new Map<string, number>();
let reservationToken = 0;
function reservedLeavingCount(): number {
let extra = 0;
for (const sessionId of reservedLeavingSlots.keys()) {
if (!softHiddenSessions.has(sessionId)) extra += 1;
}
return extra;
}
type EvictionWaiter = {
promise: Promise<boolean>;
resolve: (completed: boolean) => void;
settled: boolean;
};
const evictionWaiters = new Map<string, EvictionWaiter>();
function finishEviction(sessionId: string, completed: boolean): void {
const waiter = evictionWaiters.get(sessionId);
if (!waiter || waiter.settled) return;
waiter.settled = true;
evictionWaiters.delete(sessionId);
if (evictionRequestSessionId === sessionId) evictionRequestSessionId = null;
waiter.resolve(completed);
}
function emit(): void {
listeners.forEach((listener) => listener());
}
function listSoftHiddenOldestFirst(): string[] {
return [...softHiddenSessions.entries()]
.sort((left, right) => left[1] - right[1])
.map(([sessionId]) => sessionId);
}
export const terminalHiddenRendererStore = {
getSoftHiddenCount: () => softHiddenSessions.size,
/**
* Soft-hidden markers plus leaving tabs that reserved a slot and are not
* yet in the marker set. Keep-count decisions use this so a second timer
* cannot take the slot a pending eviction already claimed.
*/
getSoftHiddenOccupancy: () => softHiddenSessions.size + reservedLeavingCount(),
/**
* Claim one keep slot for a leaving tab before awaiting eviction.
* A session already in the marker set is not counted twice. A later claim
* for the same id replaces the token; only that token can release it.
*/
reserveLeavingSlot: (sessionId: string): number => {
const token = ++reservationToken;
if (sessionId && !softHiddenSessions.has(sessionId)) {
reservedLeavingSlots.set(sessionId, token);
}
return token;
},
releaseLeavingSlot: (sessionId: string, token: number) => {
if (reservedLeavingSlots.get(sessionId) === token) {
reservedLeavingSlots.delete(sessionId);
}
},
/**
* True when retaining this leaving tab would stay within keepCount.
* Its own reservation is not counted against the slots held by others.
*/
leavingSlotStillFits: (sessionId: string, keepCount: number): boolean => {
if (!(keepCount > 0)) return false;
const reservedHere = reservedLeavingSlots.has(sessionId) && !softHiddenSessions.has(sessionId);
const others = softHiddenSessions.size + reservedLeavingCount() - (reservedHere ? 1 : 0);
return others < keepCount;
},
isSoftHidden: (sessionId: string) => softHiddenSessions.has(sessionId),
markSoftHidden: (sessionId: string) => {
@@ -94,12 +23,18 @@ export const terminalHiddenRendererStore = {
emit();
},
listSoftHiddenOldestFirst: (): string[] => listSoftHiddenOldestFirst(),
/** Returns the oldest soft-hidden session id when over the keep limit. */
pickEvictionCandidate: (keepCount: number): string | null => {
if (softHiddenSessions.size < keepCount) return null;
return listSoftHiddenOldestFirst()[0] ?? null;
let oldestId: string | null = null;
let oldestTs = Number.POSITIVE_INFINITY;
for (const [sessionId, ts] of softHiddenSessions) {
if (ts < oldestTs) {
oldestTs = ts;
oldestId = sessionId;
}
}
return oldestId;
},
requestEviction: (sessionId: string) => {
@@ -107,44 +42,6 @@ export const terminalHiddenRendererStore = {
emit();
},
/** True while this session's hibernate has been requested and not settled. */
hasPendingEviction: (sessionId: string): boolean => {
const waiter = evictionWaiters.get(sessionId);
return waiter != null && !waiter.settled;
},
/**
* Ask one soft-hidden session to upgrade. Resolves true only when
* settleEviction reports that hibernation finished. Clearing the marker
* during wake is not success: drain, snapshot, or alternate-screen failure
* can still restore that renderer. A synchronous refusal (marker still
* present, including inline images) resolves false and drops an unconsumed
* request so a later emit cannot upgrade it.
*/
evictSoftHidden: (sessionId: string): Promise<boolean> => {
const existing = evictionWaiters.get(sessionId);
if (existing) return existing.promise;
if (!softHiddenSessions.has(sessionId)) return Promise.resolve(false);
let resolvePromise!: (completed: boolean) => void;
const promise = new Promise<boolean>((resolve) => {
resolvePromise = resolve;
});
const waiter: EvictionWaiter = { promise, resolve: resolvePromise, settled: false };
evictionWaiters.set(sessionId, waiter);
evictionRequestSessionId = sessionId;
emit();
if (!waiter.settled && softHiddenSessions.has(sessionId)) {
finishEviction(sessionId, false);
}
return promise;
},
/** Record whether the in-flight eviction's hibernate actually completed. */
settleEviction: (sessionId: string, completed: boolean) => {
finishEviction(sessionId, completed);
},
consumeEvictionRequest: (sessionId: string): boolean => {
if (evictionRequestSessionId !== sessionId) return false;
evictionRequestSessionId = null;
@@ -125,55 +125,6 @@ test('overlapping nonmodal menus yield with a picture; modal dialogs yield even
await f.tick(); assert.equal(f.layouts.at(-1)?.occluded, true);
});
function panel(f: Awaited<ReturnType<typeof fixture>>, x: number, y: number, width: number, height: number) {
const node = f.overlay('<div data-section="app-host-editor-layer"><div data-native-view-occluder></div></div>').firstElementChild!;
node.getBoundingClientRect = () => new f.env.window.DOMRect(x, y, width, height);
return node;
}
test('an overlay host editor panel narrows the live page beside it, then restores the pane', async (t) => {
const f = await fixture(t);
panel(f, 900, 0, 400, 900); await f.tick();
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 200, y: 100, width: 500, height: 600 }, 'a panel beside the pane changes nothing');
const editor = panel(f, 450, 0, 600, 900); await f.tick();
assert.equal(f.layouts.at(-1)?.visible, true);
assert.equal(f.layouts.at(-1)?.occluded, false, 'the page stays live instead of becoming a picture');
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 200, y: 100, width: 250, height: 600 });
assert.deepEqual(f.panel().snapshot, []);
editor.getBoundingClientRect = () => new f.env.window.DOMRect(400, 0, 650, 900);
f.resize(); await f.tick();
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 200, y: 100, width: 200, height: 600 }, 'resizing the panel follows it');
editor.parentElement!.remove(); await f.tick();
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 200, y: 100, width: 500, height: 600 });
assert.equal(f.layouts.at(-1)?.occluded, false);
});
test('a popper inside the panel leaves the narrowed page live; one over the page keeps aligned pictures', async (t) => {
const f = await fixture(t);
panel(f, 0, 0, 300, 900); await f.tick();
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 300, y: 100, width: 400, height: 600 });
const select = f.overlay(tooltip, 140, 110); await f.tick();
assert.equal(f.layouts.at(-1)?.occluded, false, 'a select inside the left panel does not reach the page');
select.remove();
f.overlay(tooltip, 320, 110); await f.tick();
assert.equal(f.layouts.at(-1)?.occluded, true);
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 300, y: 100, width: 400, height: 600 });
assert.deepEqual(f.panel().snapshot.map((layer) => layer.bounds), [{ x: 100, y: 0, width: 500, height: 600 }],
'pictures are placed where the narrowed page sits inside the pane');
});
test('a panel over the middle of the pane or leaving too little room falls back to a picture', async (t) => {
const f = await fixture(t);
const middle = panel(f, 300, 0, 100, 900); await f.tick();
assert.equal(f.layouts.at(-1)?.occluded, true);
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 200, y: 100, width: 500, height: 600 });
assert.deepEqual(f.panel().snapshot, layers);
middle.parentElement!.remove(); await f.tick();
assert.equal(f.layouts.at(-1)?.occluded, false);
panel(f, 250, 0, 600, 900); await f.tick();
assert.equal(f.layouts.at(-1)?.occluded, true, 'a 50px strip is not worth a live page');
});
test('closing a menu while capture is pending cannot restore stale pictures', async (t) => {
const f = await fixture(t);
let complete: (result: Result) => void = () => assert.fail('capture was not started');
+9 -26
View File
@@ -1,17 +1,16 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { currentBrowserThemeTokens, mountHostBrowser } from '../hostBrowser';
import { resolveHostBrowserPaneBesidePanels, type HostBrowserPaneRect } from '../../domain/hostBrowser';
import type { Host, Identity, KnownHost, SSHKey, TerminalSettings } from '../../domain/models';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import type { HostBrowserPanelSnapshotLayer } from '../../types/global/netcatty-bridge-sync';
function overlapsBrowser(rect: HostBrowserPaneRect, element: Element): boolean {
function overlapsBrowser(rect: DOMRect, element: Element): boolean {
const style = getComputedStyle(element);
if (style.display === 'none' || style.visibility === 'hidden' || element.closest('[hidden], [inert], [data-state="closed"]')) return false;
const overlay = element.getBoundingClientRect();
return overlay.width > 0 && overlay.height > 0
&& overlay.x < rect.x + rect.width && overlay.right > rect.x
&& overlay.y < rect.y + rect.height && overlay.bottom > rect.y;
&& overlay.x < rect.right && overlay.right > rect.x
&& overlay.y < rect.bottom && overlay.bottom > rect.y;
}
interface BrowserPanelOptions {
@@ -71,21 +70,12 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
const rect = element?.getBoundingClientRect();
// Native views sit above renderer portals. Only overlapping poppers need
// to yield; dialogs are modal even when their content is beside the pane.
// Overlay panels stay open while the user works, so the live page moves
// beside them when it can and only falls back to a picture otherwise.
const locked = !!document.querySelector('[data-app-lock-overlay]');
const modal = !!document.querySelector('[role="dialog"][aria-modal="true"], [role="alertdialog"]');
const overlays = Array.from(document.querySelectorAll(
'[role="dialog"], [role="alertdialog"], [role="menu"][data-state="open"], [data-radix-popper-content-wrapper] > *, '
+ '[data-native-view-occluder]',
'[role="dialog"], [role="alertdialog"], [role="menu"][data-state="open"], [data-radix-popper-content-wrapper] > *',
));
const isPanel = (overlay: Element) => overlay.matches('[data-native-view-occluder]');
const panels = rect ? overlays.filter((overlay) => isPanel(overlay) && overlapsBrowser(rect, overlay)) : [];
const live = rect && resolveHostBrowserPaneBesidePanels(rect, panels.map((panel) => panel.getBoundingClientRect()));
// Poppers over the part of the pane hidden behind a panel (e.g. a select
// inside the host editor) no longer touch the live page.
const covered = modal || (!!rect && !live)
|| (!!live && overlays.some((overlay) => !isPanel(overlay) && overlapsBrowser(live, overlay)));
const covered = modal || (!!rect && overlays.some((overlay) => overlapsBrowser(rect, overlay)));
const targets = new Set<Element>(overlays);
for (let target: Element | null = element; target; target = target.parentElement) targets.add(target);
for (const target of observed) {
@@ -100,11 +90,7 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
const visible = isVisible && !!element?.isConnected && !!rect
&& rect.width > 0 && rect.height > 0 && !element?.closest('[inert]') && !locked;
if (!visible) setSnapshot(null);
const pane = live || rect;
const bounds = pane ? { x: pane.x, y: pane.y, width: pane.width, height: pane.height } : undefined;
// Pictures are relative to the native page, which may sit beside a panel.
const offsetX = rect && bounds ? bounds.x - rect.x : 0;
const offsetY = rect && bounds ? bounds.y - rect.y : 0;
const bounds = rect ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : undefined;
const theme = element && getComputedStyle(element).colorScheme === 'light' ? 'light' : 'dark';
const themeTokens = currentBrowserThemeTokens(element ?? undefined);
const layout: Parameters<NonNullable<NetcattyBridge['setHostBrowserPanelLayout']>>[0] = {
@@ -116,9 +102,7 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
const revision = ++layoutRevision;
void bridge?.setHostBrowserPanelLayout?.(layout).then((result) => {
if (disposed || layoutRevision !== revision) return;
setSnapshot(visible && covered && typeof result === 'object' ? { browserId, layers: result.layers.map((layer) => ({
...layer, bounds: { ...layer.bounds, x: layer.bounds.x + offsetX, y: layer.bounds.y + offsetY },
})) } : null);
setSnapshot(visible && covered && typeof result === 'object' ? { browserId, layers: result.layers } : null);
}).catch(() => {});
}
};
@@ -135,15 +119,14 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
if (doc) mutationObserver?.observe(doc.documentElement, {
subtree: true, childList: true, attributes: true,
attributeFilter: ['style', 'class', 'hidden', 'inert', 'role', 'aria-modal', 'data-state',
'data-app-lock-overlay', 'data-radix-popper-content-wrapper', 'data-native-view-occluder',
'href', 'media', 'disabled'],
'data-app-lock-overlay', 'data-radix-popper-content-wrapper', 'href', 'media', 'disabled'],
});
const onMotion = (event: Event) => {
const target = event.target;
if (!element || !view || !(target instanceof view.Element)) return;
if (!target.contains(element) && !target.matches(
'[role="dialog"], [role="alertdialog"], [role="menu"], [data-app-lock-overlay], '
+ '[data-radix-popper-content-wrapper], [data-radix-popper-content-wrapper] > *, [data-native-view-occluder]',
+ '[data-radix-popper-content-wrapper], [data-radix-popper-content-wrapper] > *',
)) return;
trackAnimations(target);
schedule();
-19
View File
@@ -1,19 +0,0 @@
import { useEffect, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
type Raster = { light: string; dark?: string };
export function usePluginBrandIcon(plugin?: NetcattyInstalledPlugin): Raster | null {
const pluginId = plugin?.id;
const kind = plugin?.manifest?.icon?.kind;
const key = JSON.stringify([pluginId, plugin?.activeVersion, plugin?.archiveSha256, plugin?.runtime?.quarantinedAt, plugin?.manifest?.icon]);
const [loaded, setLoaded] = useState<{ key: string; value: Raster | null } | null>(null);
useEffect(() => {
if (!pluginId || kind !== 'package') return;
let cancelled = false;
void netcattyBridge.get()?.getPluginBrandIcon?.(pluginId).then((value) => {
if (!cancelled) setLoaded({ key, value });
}).catch(() => { if (!cancelled) setLoaded({ key, value: null }); });
return () => { cancelled = true; };
}, [key, pluginId, kind]);
return loaded?.key === key ? loaded.value : null;
}
@@ -1,84 +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 { usePluginCatalog } from './usePluginCatalog';
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (cause: Error) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
test('README failures clear on retry/switch and stale requests cannot replace the selected entry', async (context) => {
const original = Object.getOwnPropertyDescriptor(globalThis, 'window');
const calls: Array<{ id: string; request: ReturnType<typeof deferred<string>> }> = [];
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
netcatty: {
listPluginCatalog: async () => ({ entries: [], source: "builtin", error: null }),
getPluginCatalogReadme: (id: string) => { const request = deferred<string>(); calls.push({ id, request }); return request.promise; },
},
} });
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
context.after(() => {
if (original) Object.defineProperty(globalThis, 'window', original); else Reflect.deleteProperty(globalThis, 'window');
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
});
let state!: ReturnType<typeof usePluginCatalog>;
let renders = 0;
function Probe({ id }: { id: string }) { state = usePluginCatalog(id); renders++; return null; }
let renderer!: ReactTestRenderer;
await act(async () => { renderer = create(React.createElement(Probe, { id: 'first' })); });
assert.equal(state.readmeStatus, 'loading');
await act(async () => calls[0].request.reject(new Error('temporary failure')));
assert.equal(state.readmeStatus, 'error');
assert.match(state.readmeError!, /temporary failure/);
await act(async () => state.retryReadme());
assert.equal(state.readmeStatus, 'loading'); assert.equal(state.readmeError, null);
await act(async () => calls[1].request.resolve(''));
assert.equal(state.readmeStatus, 'ready'); assert.equal(state.readme, ''); assert.equal(state.readmeError, null);
await act(async () => state.retryReadme());
await act(async () => renderer.update(React.createElement(Probe, { id: 'second' })));
assert.equal(state.readmeError, null);
await act(async () => calls[3].request.resolve('# Second'));
await act(async () => calls[2].request.reject(new Error('stale failure')));
assert.equal(state.readme, '# Second'); assert.equal(state.readmeError, null);
await act(async () => state.retryReadme());
await act(async () => renderer.unmount());
const unmountedRenders = renders;
await act(async () => calls[4].request.resolve('late content'));
assert.equal(renders, unmountedRenders);
});
test('offline snapshot stays browsable, retry clears warning, and operations bind the selected token', async (context) => {
const original = Object.getOwnPropertyDescriptor(globalThis, 'window');
const entry = { id: 'com.test.registry', selectionToken: 'first' } as NetcattyPluginCatalogEntry;
let offline = true;
const reads: unknown[][] = []; const installs: unknown[][] = [];
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
dispatchEvent() {}, netcatty: {
listPluginCatalog: async () => ({ entries: [{ ...entry, selectionToken: offline ? 'first' : 'second' }], source: offline ? 'cached' : 'registry', error: offline ? 'offline' : null }),
getPluginCatalogReadme: async (...args: unknown[]) => { reads.push(args); return '# Pinned'; },
installCatalogPlugin: async (...args: unknown[]) => { installs.push(args); },
},
} });
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
context.after(() => {
if (original) Object.defineProperty(globalThis, 'window', original); else Reflect.deleteProperty(globalThis, 'window');
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
});
let state!: ReturnType<typeof usePluginCatalog>;
function Probe() { state = usePluginCatalog(entry.id); return null; }
let renderer!: ReactTestRenderer;
await act(async () => { renderer = create(React.createElement(Probe)); });
assert.equal(state.entries.length, 1); assert.equal(state.listStatus, 'ready'); assert.equal(state.listError, 'offline');
assert.equal(state.source, 'cached');
offline = false;
await act(async () => state.retryList());
assert.equal(state.listError, null); assert.equal(state.source, 'registry');
assert.deepEqual(reads.at(-1), [entry.id, 'second']);
await act(async () => state.install(entry.id));
assert.deepEqual(installs, [[entry.id, 'second']]);
await act(async () => renderer.unmount());
});
-70
View File
@@ -1,70 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
type ReadmeState = { id: string; token?: string; attempt: number; status: 'loading' | 'ready' | 'error'; text: string; error: string | null };
export function usePluginCatalog(selectedId: string | null) {
const [entries, setEntries] = useState<NetcattyPluginCatalogEntry[]>([]);
const [listStatus, setListStatus] = useState<'loading' | 'ready' | 'error'>('loading');
const [source, setSource] = useState<NetcattyPluginCatalogSnapshot['source']>('builtin');
const [listError, setListError] = useState<string | null>(null);
const [listAttempt, setListAttempt] = useState(0);
const [readmeState, setReadmeState] = useState<ReadmeState | null>(null);
const [readmeAttempt, setReadmeAttempt] = useState(0);
const [installError, setInstallError] = useState<{ id: string; message: string } | null>(null);
const [busy, setBusy] = useState(false);
const installing = useRef(false);
const mounted = useRef(false);
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
useEffect(() => {
let active = true;
setListStatus('loading'); setListError(null);
void (async () => {
try {
const bridge = netcattyBridge.get();
if (!bridge?.listPluginCatalog) throw new Error('Catalog browsing unavailable');
const list = await bridge.listPluginCatalog();
if (active) { setEntries(list.entries); setSource(list.source); setListError(list.error); setListStatus('ready'); }
} catch (cause) { if (active) { setListStatus('error'); setListError(String(cause)); } }
})();
return () => { active = false; };
}, [listAttempt]);
const selectionToken = entries.find((entry) => entry.id === selectedId)?.selectionToken;
useEffect(() => {
if (!selectedId) return;
let active = true;
const base = { id: selectedId, token: selectionToken, attempt: readmeAttempt, text: '', error: null };
setReadmeState({ ...base, status: 'loading' });
void (async () => {
try {
const bridge = netcattyBridge.get();
if (!bridge?.getPluginCatalogReadme) throw new Error('README retrieval unavailable');
const text = await bridge.getPluginCatalogReadme(selectedId, selectionToken);
if (active) setReadmeState({ ...base, status: 'ready', text });
} catch (cause) { if (active) setReadmeState({ ...base, status: 'error', error: String(cause) }); }
})();
return () => { active = false; };
}, [selectedId, selectionToken, readmeAttempt]);
const currentReadme = readmeState?.id === selectedId && readmeState.token === selectionToken && readmeState.attempt === readmeAttempt ? readmeState : null;
return {
entries, listStatus, listError, source,
readme: currentReadme?.status === 'ready' ? currentReadme.text : null,
readmeStatus: currentReadme?.status ?? (selectedId ? 'loading' : 'idle'),
readmeError: currentReadme?.error ?? null,
error: installError?.id === selectedId ? installError.message : null,
busy,
retryList: () => setListAttempt((attempt) => attempt + 1),
retryReadme: () => setReadmeAttempt((attempt) => attempt + 1),
async install(id: string) {
if (installing.current) return;
installing.current = true;
setBusy(true); setInstallError(null);
try {
const bridge = netcattyBridge.require();
if (!bridge.installCatalogPlugin) throw new Error('Catalog installation unavailable');
await bridge.installCatalogPlugin(id, entries.find((entry) => entry.id === id)?.selectionToken);
window.dispatchEvent(new Event('netcatty:plugin-packages-changed'));
} catch (cause) { if (mounted.current) setInstallError({ id, message: String(cause) }); }
finally { installing.current = false; if (mounted.current) setBusy(false); }
},
};
}
-59
View File
@@ -1,59 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function usePluginManager() {
const [plugins, setPlugins] = useState<NetcattyInstalledPlugin[]>([]);
const [available, setAvailable] = useState(false);
const [credentialStorageAvailable, setCredentialStorageAvailable] = useState<boolean | undefined>();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const generation = useRef(0);
const mutation = useRef(false);
const refresh = useCallback(async () => {
const current = ++generation.current;
const bridge = netcattyBridge.get();
const status = await bridge?.getPluginRuntimeStatus?.();
const list = status?.available ? await bridge?.listPlugins?.() ?? [] : [];
if (current !== generation.current) return;
setAvailable(status?.available === true);
setCredentialStorageAvailable(status?.credentialStorageAvailable);
setPlugins(list);
}, []);
useEffect(() => {
const update = () => { void refresh().catch((cause) => setError(String(cause))); };
update();
const unsubscribe = netcattyBridge.get()?.onPluginContributionsChanged?.(update);
window.addEventListener('netcatty:plugin-packages-changed', update);
const refreshGeneration = generation;
return () => { refreshGeneration.current++; unsubscribe?.(); window.removeEventListener('netcatty:plugin-packages-changed', update); };
}, [refresh]);
const run = useCallback(async (action: (bridge: NetcattyBridge) => Promise<unknown>) => {
if (mutation.current) return;
mutation.current = true;
setBusy(true);
setError(null);
try { await action(netcattyBridge.require()); }
catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
finally {
try { await refresh(); } catch (cause) { setError(String(cause)); }
mutation.current = false;
setBusy(false);
}
}, [refresh]);
return {
plugins, available, busy, error, credentialStorageAvailable, refresh,
importPackage: () => run(async (bridge) => {
if (!bridge.selectFile || !bridge.installPluginPackage) throw new Error('Plugin import unavailable');
const path = await bridge.selectFile('Import plugin package', undefined, [{ name: 'Netcatty plugin', extensions: ['ncpkg'] }]);
if (path) await bridge.installPluginPackage(path, { enable: false });
}),
setEnabled: (id: string, enabled: boolean) => run(async (bridge) => {
if (!bridge.setPluginEnabled) throw new Error('Plugin management unavailable');
return bridge.setPluginEnabled(id, enabled);
}),
uninstall: (id: string) => run(async (bridge) => {
if (!bridge.uninstallPlugin) throw new Error('Plugin management unavailable');
return bridge.uninstallPlugin(id);
}),
};
}
-4
View File
@@ -54,8 +54,6 @@ export interface ServerStats {
swapTotal: number | null; // Total swap in MB
swapUsed: number | null; // Used swap in MB
topProcesses: ProcessInfo[]; // Top 10 processes by memory
diskReadSpeed?: number | null; // Aggregate Linux hardware-backed block I/O, bytes/sec
diskWriteSpeed?: number | null;
diskPercent: number | null; // Disk usage percentage for root partition
diskUsed: number | null; // Disk used in GB
diskTotal: number | null; // Total disk in GB
@@ -242,8 +240,6 @@ function normalizeServerStats(stats: Partial<ServerStats>): ServerStats {
diskPercent: stats.diskPercent ?? null,
diskUsed: stats.diskUsed ?? null,
diskTotal: stats.diskTotal ?? null,
diskReadSpeed: stats.diskReadSpeed ?? null,
diskWriteSpeed: stats.diskWriteSpeed ?? null,
disks: stats.disks || [],
netRxSpeed: stats.netRxSpeed || 0,
netTxSpeed: stats.netTxSpeed || 0,
+1 -1
View File
@@ -50,7 +50,7 @@ export const useSessionLogBackend = () => {
const getManualSessionLogStatus = useCallback(async (payload: ManualStatusPayload) => {
const bridge = netcattyBridge.get();
return bridge?.getManualSessionLogStatus?.(payload) ?? { success: false, isLogging: false, hasSessionLog: false, error: "Session log bridge unavailable" };
return bridge?.getManualSessionLogStatus?.(payload) ?? { success: false, isLogging: false, error: "Session log bridge unavailable" };
}, []);
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 { useSessionState } from "./useSessionState.ts";
type SessionState = ReturnType<typeof useSessionState>;
test("compose send target follows toolbar broadcast and then flips independently", async () => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
const eventTarget = new EventTarget() as EventTarget & Record<string, unknown>;
Object.assign(eventTarget, { setTimeout, clearTimeout });
Object.defineProperty(globalThis, "window", {
configurable: true,
value: eventTarget,
});
const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
let state: SessionState | null = null;
let renderer: ReactTestRenderer | null = null;
const current = (): SessionState => {
assert.ok(state);
return state;
};
function Probe() {
state = useSessionState({ persistSessionRestore: false });
return null;
}
try {
await act(async () => {
renderer = create(React.createElement(Probe));
});
await act(async () => { current().createLocalTerminal({ shellName: "01" }); });
await act(async () => { current().createLocalTerminal({ shellName: "02" }); });
const [first, second] = current().orphanSessions;
await act(async () => {
current().createWorkspaceFromSessions(first.id, second.id, "right");
});
const workspaceId = current().workspaces[0]?.id;
assert.ok(workspaceId);
assert.equal(current().isBroadcastEnabled(workspaceId), false);
assert.equal(current().isComposeSendToAll(workspaceId), false);
await act(async () => { current().toggleBroadcast(workspaceId); });
assert.equal(current().isBroadcastEnabled(workspaceId), true);
assert.equal(current().isComposeSendToAll(workspaceId), true);
await act(async () => { current().toggleComposeSendTarget(workspaceId); });
assert.equal(current().isBroadcastEnabled(workspaceId), true);
assert.equal(current().isComposeSendToAll(workspaceId), false);
await act(async () => { current().toggleComposeSendTarget(workspaceId); });
assert.equal(current().isComposeSendToAll(workspaceId), true);
assert.equal(current().isBroadcastEnabled(workspaceId), true);
await act(async () => { current().toggleBroadcast(workspaceId); });
assert.equal(current().isBroadcastEnabled(workspaceId), false);
assert.equal(current().isComposeSendToAll(workspaceId), false);
await act(async () => { current().toggleComposeSendTarget(workspaceId); });
assert.equal(current().isBroadcastEnabled(workspaceId), false);
assert.equal(current().isComposeSendToAll(workspaceId), true);
await act(async () => { current().toggleGlobalBroadcast(); });
assert.equal(current().isGlobalBroadcastEnabled, false);
assert.equal(current().isGlobalComposeSendToAll, false);
assert.equal(current().canUseGlobalBroadcast, false);
} finally {
await act(async () => { renderer?.unmount(); });
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
}
});
test("global compose send target follows the toolbar switch for orphan sessions", async () => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
const eventTarget = new EventTarget() as EventTarget & Record<string, unknown>;
Object.assign(eventTarget, { setTimeout, clearTimeout });
Object.defineProperty(globalThis, "window", {
configurable: true,
value: eventTarget,
});
const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
let state: SessionState | null = null;
let renderer: ReactTestRenderer | null = null;
const current = (): SessionState => {
assert.ok(state);
return state;
};
function Probe() {
state = useSessionState({ persistSessionRestore: false });
return null;
}
try {
await act(async () => {
renderer = create(React.createElement(Probe));
});
await act(async () => { current().createLocalTerminal({ shellName: "01" }); });
await act(async () => { current().createLocalTerminal({ shellName: "02" }); });
assert.equal(current().canUseGlobalBroadcast, true);
assert.equal(current().isGlobalComposeSendToAll, false);
await act(async () => { current().toggleGlobalBroadcast(); });
assert.equal(current().isGlobalBroadcastEnabled, true);
assert.equal(current().isGlobalComposeSendToAll, true);
await act(async () => { current().toggleGlobalComposeSendTarget(); });
assert.equal(current().isGlobalBroadcastEnabled, true);
assert.equal(current().isGlobalComposeSendToAll, false);
await act(async () => { current().toggleGlobalBroadcast(); });
assert.equal(current().isGlobalBroadcastEnabled, false);
assert.equal(current().isGlobalComposeSendToAll, false);
const closedId = current().orphanSessions[1].id;
await act(async () => { current().toggleGlobalComposeSendTarget(); });
assert.equal(current().isGlobalComposeSendToAll, true);
await act(async () => { current().closeSession(closedId); });
assert.equal(current().canUseGlobalBroadcast, false);
assert.equal(current().isGlobalBroadcastEnabled, false);
assert.equal(current().isGlobalComposeSendToAll, false);
} finally {
await act(async () => { renderer?.unmount(); });
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
}
});
+11 -76
View File
@@ -1,5 +1,4 @@
import { MouseEvent,useCallback,useEffect,useMemo,useRef,useState } from 'react';
import { flushSync } from 'react-dom';
import { ConnectionLog,Host,SerialConfig,Snippet,TerminalSession,Workspace,WorkspaceNode,WorkspaceViewMode } from '../../domain/models';
import { addLogView, getLogViewTabId, removeLogView, type LogView } from './logViewState';
import {
@@ -26,10 +25,6 @@ SplitHint,
updateWorkspaceSplitSizes,
} from '../../domain/workspace';
import { clearSessionFontSizeOverride as clearSessionFontSizeOverrideFields } from '../../domain/terminalAppearance';
import {
composeSendTargetAfterToolbarToggle,
flipComposeSendTarget,
} from '../../domain/terminalBroadcast';
import { buildOrderedWorkTabIds, reorderWorkTabIds } from '../app/workTabSurface';
import { activeTabStore } from './activeTabStore';
import {
@@ -240,12 +235,8 @@ export const useSessionState = ({
const [tabOrder, setTabOrder] = useState<string[]>(initialRestoreState.tabOrder);
// Broadcast mode: stores workspace IDs that have broadcast enabled
const [broadcastWorkspaceIds, setBroadcastWorkspaceIds] = useState<Set<string>>(new Set());
// Compose-bar send target. Toolbar toggles reset it; the compose bar can
// then flip it without changing keystroke fan-out.
const [composeSendAllWorkspaceIds, setComposeSendAllWorkspaceIds] = useState<Set<string>>(new Set());
// Global broadcast mode: enables broadcast for all orphan sessions (not in workspaces)
const [globalBroadcastEnabled, setGlobalBroadcastEnabled] = useState<boolean>(false);
const [globalComposeSendToAll, setGlobalComposeSendToAll] = useState<boolean>(false);
// Log views: stores open log replay tabs
const [logViews, setLogViews] = useState<LogView[]>([]);
const [restorePreviousSessionRevision, setRestorePreviousSessionRevision] = useState(0);
@@ -916,25 +907,6 @@ export const useSessionState = ({
return newSessionId;
}, [setActiveTabId]);
// Agent callers bind scope immediately, so they need committed insertion,
// not just the ID allocated by the ordinary batched UI append operation.
const appendHostToWorkspaceConfirmed = useCallback((
workspaceId: string,
host: Host,
direction: SplitDirection = 'vertical',
): string | null => {
let sessionId: string | null = null;
flushSync(() => {
sessionId = appendHostToWorkspace(workspaceId, host, direction);
});
if (!sessionId) return null;
const committedWorkspace = workspacesRef.current.find(w => w.id === workspaceId);
const inserted = committedWorkspace
&& collectSessionIds(committedWorkspace.root).includes(sessionId)
&& sessionsRef.current.some(s => s.id === sessionId && s.workspaceId === workspaceId);
return inserted ? sessionId : null;
}, [appendHostToWorkspace]);
// Atomic "append a local terminal pane" — mirror of appendHostToWorkspace
// but constructs a local-protocol session instead of an SSH one.
const appendLocalTerminalToWorkspace = useCallback((
@@ -1165,10 +1137,8 @@ export const useSessionState = ({
const canUseGlobalBroadcast = orphanSessions.length >= 2;
// Losing the group must disarm broadcast before another tab can join it.
// Compose-bar send target follows the toolbar switch back to active-only.
if (!canUseGlobalBroadcast && (globalBroadcastEnabled || globalComposeSendToAll)) {
if (globalBroadcastEnabled) setGlobalBroadcastEnabled(false);
if (globalComposeSendToAll) setGlobalComposeSendToAll(false);
if (!canUseGlobalBroadcast && globalBroadcastEnabled) {
setGlobalBroadcastEnabled(false);
}
const openLogView = useCallback((log: ConnectionLog) => {
@@ -1303,61 +1273,31 @@ export const useSessionState = ({
return newSessionId;
}, [setActiveTabId]);
const setWorkspaceComposeSendTarget = useCallback((workspaceId: string, target: 'all' | 'active') => {
setComposeSendAllWorkspaceIds((prev) => {
const hasAll = prev.has(workspaceId);
if ((target === 'all') === hasAll) return prev;
// Toggle broadcast mode for a workspace
const toggleBroadcast = useCallback((workspaceId: string) => {
setBroadcastWorkspaceIds(prev => {
const next = new Set(prev);
if (target === 'all') next.add(workspaceId);
else next.delete(workspaceId);
if (next.has(workspaceId)) {
next.delete(workspaceId);
} else {
next.add(workspaceId);
}
return next;
});
}, []);
// Toggle broadcast mode for a workspace. Compose-bar send target follows.
const toggleBroadcast = useCallback((workspaceId: string) => {
const enabling = !broadcastWorkspaceIds.has(workspaceId);
setBroadcastWorkspaceIds(prev => {
const next = new Set(prev);
if (prev.has(workspaceId)) next.delete(workspaceId);
else next.add(workspaceId);
return next;
});
setWorkspaceComposeSendTarget(workspaceId, composeSendTargetAfterToolbarToggle(enabling));
}, [broadcastWorkspaceIds, setWorkspaceComposeSendTarget]);
// Check if a workspace has broadcast enabled
const isBroadcastEnabled = useCallback((workspaceId: string) => {
return broadcastWorkspaceIds.has(workspaceId);
}, [broadcastWorkspaceIds]);
const toggleComposeSendTarget = useCallback((workspaceId: string) => {
setWorkspaceComposeSendTarget(
workspaceId,
flipComposeSendTarget(composeSendAllWorkspaceIds.has(workspaceId) ? 'all' : 'active'),
);
}, [composeSendAllWorkspaceIds, setWorkspaceComposeSendTarget]);
const isComposeSendToAll = useCallback((workspaceId: string) => {
return composeSendAllWorkspaceIds.has(workspaceId);
}, [composeSendAllWorkspaceIds]);
// Toggle global broadcast mode for orphan sessions
const toggleGlobalBroadcast = useCallback(() => {
setGlobalBroadcastEnabled(prev => {
const next = !prev;
setGlobalComposeSendToAll(composeSendTargetAfterToolbarToggle(next) === 'all');
return next;
});
}, []);
const toggleGlobalComposeSendTarget = useCallback(() => {
setGlobalComposeSendToAll((prev) => flipComposeSendTarget(prev ? 'all' : 'active') === 'all');
setGlobalBroadcastEnabled(prev => !prev);
}, []);
// Global broadcast enabled state - direct boolean value
const isGlobalBroadcastEnabled = globalBroadcastEnabled;
const isGlobalComposeSendToAll = globalComposeSendToAll;
const baseWorkTabIds = useMemo(() => [
...orphanSessions.map(s => s.id),
@@ -1473,7 +1413,6 @@ export const useSessionState = ({
addSessionToWorkspace,
removeSessionFromWorkspace,
appendHostToWorkspace,
appendHostToWorkspaceConfirmed,
appendLocalTerminalToWorkspace,
updateSplitSizes,
splitSession,
@@ -1487,12 +1426,8 @@ export const useSessionState = ({
// Broadcast mode
toggleBroadcast,
isBroadcastEnabled,
toggleComposeSendTarget,
isComposeSendToAll,
toggleGlobalBroadcast,
isGlobalBroadcastEnabled,
toggleGlobalComposeSendTarget,
isGlobalComposeSendToAll,
orderedTabs,
getOrderedWorkTabs,
reorderTabs,
@@ -1,85 +0,0 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { JSDOM } from 'jsdom';
import { createLocalShellHost, resolveHostDefaultLocalShell } from '../../domain/localShellHost';
import { collectSessionIds } from '../../domain/workspace';
import { activeTabStore } from './activeTabStore';
import { useSessionState } from './useSessionState';
test('workspace host opening acknowledges committed insertion and rejects queued closure', async (t) => {
const dom = new JSDOM('<div id="root"></div>', { url: 'http://localhost' });
const globals = {
window: dom.window,
document: dom.window.document,
localStorage: dom.window.localStorage,
CustomEvent: dom.window.CustomEvent,
Event: dom.window.Event,
IS_REACT_ACT_ENVIRONMENT: true,
};
const previous = new Map<string, PropertyDescriptor | undefined>();
for (const [key, value] of Object.entries(globals)) {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, value });
}
const root = createRoot(document.getElementById('root')!);
t.after(async () => {
await act(async () => root.unmount());
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
activeTabStore.setActiveTabId('vault');
});
let state: ReturnType<typeof useSessionState>;
function Probe() {
state = useSessionState({ persistSessionRestore: false });
return null;
}
await act(async () => root.render(React.createElement(React.StrictMode, null, React.createElement(Probe))));
const host = createLocalShellHost({ id: 'local-inheriting' });
await act(async () => state.createWorkspaceWithHosts('Target', [host]));
const workspaceId = state!.workspaces[0].id;
const resolvedHost = resolveHostDefaultLocalShell(host, {
terminalSettings: { localShell: '/bin/zsh', localShellArgs: ['-l'], localStartDir: '/tmp/project' },
});
let ordinaryId: string | null = null;
await act(async () => {
ordinaryId = state.appendHostToWorkspace(workspaceId, resolvedHost);
});
assert.ok(ordinaryId);
assert.ok(state!.sessions.some(session => session.id === ordinaryId));
assert.ok(collectSessionIds(state!.workspaces[0].root).includes(ordinaryId));
let insertedId: string | null = null;
await act(async () => {
insertedId = state.appendHostToWorkspaceConfirmed(workspaceId, resolvedHost);
assert.ok(insertedId, 'success is returned after both state updates commit');
const session = state.sessions.find(s => s.id === insertedId);
assert.equal(session?.localShell, '/bin/zsh');
assert.deepEqual(session?.localShellArgs, ['-l']);
assert.equal(session?.localStartDir, '/tmp/project');
assert.equal(state.sessions.filter(s => s.id === insertedId).length, 1);
assert.equal(collectSessionIds(state.workspaces[0].root).filter(id => id === insertedId).length, 1);
});
await act(async () => {
// The rendered ref still contains the workspace; its close updater is queued.
state.closeWorkspace(workspaceId);
const result = state.appendHostToWorkspaceConfirmed(workspaceId, resolvedHost);
assert.equal(result, null, 'must not acknowledge an allocated but uninserted ID');
});
assert.equal(state!.workspaces.length, 0);
assert.equal(state!.sessions.length, 0, 'no orphan session survives the queued close');
assert.equal(activeTabStore.getActiveTabId(), 'vault');
await act(async () => {
assert.equal(state.appendHostToWorkspaceConfirmed(workspaceId, resolvedHost), null);
});
});
test('agent workspace host opening uses the common launch and history handler', () => {
const source = readFileSync(new URL('../app/AppSideEffects.tsx', import.meta.url), 'utf8');
assert.match(source, /const sessionId = handleConnectToHost\(host, true, hidden, target\.workspaceId\)/);
assert.match(source, /connectToHost: workspaceId\s*\? \(candidate: Host\) => appendHostToWorkspaceConfirmed\(workspaceId, candidate\)\s*: connectToHost/);
});
@@ -1,13 +1,8 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React, { startTransition, useState } from 'react';
import { flushSync } from 'react-dom';
import { createRoot, type Root } from 'react-dom/client';
import { JSDOM } from 'jsdom';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { readFileSync } from 'node:fs';
import { collectSidePanelPanes } from '../../domain/sidePanelLayout.ts';
import { useTerminalSidePanelLayoutState } from './useTerminalSidePanelLayoutState.ts';
@@ -135,231 +130,3 @@ test('closing the final pane removes only that terminal side panel state', async
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
test('selecting a side panel tool commits open tabs and layout in one render', async () => {
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
let state: ReturnType<typeof useTerminalSidePanelLayoutState> | null = null;
let renderer: ReactTestRenderer | null = null;
let renders = 0;
const Probe = () => {
state = useTerminalSidePanelLayoutState();
renders += 1;
return null;
};
try {
await act(async () => {
renderer = create(React.createElement(Probe));
});
renders = 0;
await act(async () => {
state?.selectSidePanelToolForTab('terminal-a', 'notes');
});
assert.equal(renders, 1, 'open tabs and layout share one commit');
assert.equal(state!.sidePanelOpenTabs.get('terminal-a'), 'notes');
const layout = state!.sidePanelLayouts.get('terminal-a')!;
assert.equal(
collectSidePanelPanes(layout.root).find((pane) => pane.id === layout.focusedPaneId)?.tool,
'notes',
);
await act(async () => {
state?.splitPane('terminal-a', 'ai', 'vertical', {
paneId: 'pane-ai',
splitId: 'split-a',
}, 400);
});
const beforeFocus = state!.sidePanelLayouts.get('terminal-a')!;
await act(async () => {
state?.selectSidePanelToolForTab('terminal-a', 'notes');
});
const focusedExisting = state!.sidePanelLayouts.get('terminal-a')!;
assert.deepEqual(
collectSidePanelPanes(focusedExisting.root).map((pane) => pane.tool),
['notes', 'ai'],
);
assert.equal(
collectSidePanelPanes(focusedExisting.root).find((pane) => pane.id === focusedExisting.focusedPaneId)?.tool,
'notes',
);
assert.equal(state!.sidePanelOpenTabs.get('terminal-a'), 'notes');
assert.notEqual(focusedExisting, beforeFocus);
assert.equal(focusedExisting.root, beforeFocus.root, 'focusing an open tool must not rebuild the split');
const settled = state!.sidePanelLayouts;
renders = 0;
await act(async () => {
state?.selectSidePanelToolForTab('terminal-a', 'notes');
});
assert.equal(renders, 0);
assert.equal(state!.sidePanelLayouts, settled);
} finally {
await act(async () => {
renderer?.unmount();
});
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
test('a pending side-panel write survives an urgent render on another tab', async () => {
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
Object.defineProperty(globalThis, 'window', { configurable: true, value: dom.window });
Object.defineProperty(globalThis, 'document', { configurable: true, value: dom.window.document });
const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
let state: ReturnType<typeof useTerminalSidePanelLayoutState> | null = null;
let bump: ((value: number) => void) | null = null;
const Probe = () => {
state = useTerminalSidePanelLayoutState();
const [, setTick] = useState(0);
bump = setTick;
return null;
};
const container = document.createElement('div');
document.body.appendChild(container);
const root: Root = createRoot(container);
const tools = (tabId: string, fromRef = false) => {
const layout = fromRef
? state?.sidePanelLayoutsRef.current.get(tabId)
: state?.sidePanelLayouts.get(tabId);
return layout ? collectSidePanelPanes(layout.root).map((pane) => pane.tool) : [];
};
try {
await act(async () => {
root.render(React.createElement(Probe));
});
await act(async () => {
state?.selectSidePanelToolForTab('terminal-a', 'notes');
state?.selectSidePanelToolForTab('terminal-b', 'scripts');
});
await act(async () => {
state?.splitPane('terminal-a', 'ai', 'vertical', {
paneId: 'pane-ai',
splitId: 'split-a',
}, 400);
});
await act(async () => {
startTransition(() => {
state?.selectSidePanelToolForTab('terminal-a', 'history');
});
flushSync(() => {
bump?.(1);
});
assert.deepEqual(tools('terminal-a'), ['notes', 'ai']);
assert.deepEqual(tools('terminal-a', true), ['notes', 'history']);
startTransition(() => {
state?.selectSidePanelToolForTab('terminal-b', 'sftp');
});
});
assert.deepEqual(tools('terminal-a'), ['notes', 'history']);
assert.equal(state!.sidePanelOpenTabs.get('terminal-a'), 'history');
assert.deepEqual(tools('terminal-b'), ['sftp']);
assert.equal(state!.sidePanelOpenTabs.get('terminal-b'), 'sftp');
} finally {
await act(async () => {
root.unmount();
});
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
dom.window.close();
}
});
test('a pending tool switch does not leave another tab ref behind an urgent open', async () => {
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
Object.defineProperty(globalThis, 'window', { configurable: true, value: dom.window });
Object.defineProperty(globalThis, 'document', { configurable: true, value: dom.window.document });
const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
let state: ReturnType<typeof useTerminalSidePanelLayoutState> | null = null;
const Probe = () => {
state = useTerminalSidePanelLayoutState();
return null;
};
const container = document.createElement('div');
document.body.appendChild(container);
const root: Root = createRoot(container);
const focusedTool = (tabId: string, fromRef = false) => {
const layout = fromRef
? state?.sidePanelLayoutsRef.current.get(tabId)
: state?.sidePanelLayouts.get(tabId);
if (!layout) return null;
return collectSidePanelPanes(layout.root).find((pane) => pane.id === layout.focusedPaneId)?.tool ?? null;
};
try {
await act(async () => {
root.render(React.createElement(Probe));
});
await act(async () => {
state?.selectSidePanelToolForTab('terminal-a', 'notes');
state?.selectSidePanelToolForTab('terminal-b', 'scripts');
});
await act(async () => {
startTransition(() => {
state?.selectSidePanelToolForTab('terminal-a', 'history');
});
flushSync(() => {
state?.setSidePanelOpenTabs((previous) => new Map(previous).set('terminal-b', 'browser'));
});
});
assert.equal(state!.sidePanelOpenTabs.get('terminal-a'), 'history');
assert.equal(state!.sidePanelOpenTabs.get('terminal-b'), 'browser');
assert.equal(state!.sidePanelOpenTabsRef.current.get('terminal-a'), 'history');
assert.equal(
state!.sidePanelOpenTabsRef.current.get('terminal-b'),
'browser',
'B stays rendered as browser while its ref still reports the pre-queue tool',
);
assert.equal(focusedTool('terminal-b', true), 'browser');
// handleSwitchSidePanelTab returns before select when the ref already
// matches the clicked tool. A stale scripts ref would ignore this click.
assert.notEqual(state!.sidePanelOpenTabsRef.current.get('terminal-b'), 'scripts');
await act(async () => {
state?.selectSidePanelToolForTab('terminal-b', 'scripts');
});
assert.equal(state!.sidePanelOpenTabs.get('terminal-b'), 'scripts');
assert.equal(state!.sidePanelOpenTabsRef.current.get('terminal-b'), 'scripts');
assert.equal(focusedTool('terminal-b'), 'scripts');
assert.equal(state!.sidePanelOpenTabs.get('terminal-a'), 'history');
} finally {
await act(async () => {
root.unmount();
});
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
dom.window.close();
}
});
test('side panel icon clicks write the tool through selectSidePanelToolForTab', () => {
const layerSource = readFileSync(new URL('../../components/TerminalLayer.tsx', import.meta.url), 'utf8');
const start = layerSource.indexOf('const handleSwitchSidePanelTab = useCallback');
const end = layerSource.indexOf('const handleFocusSidePanelPane = useCallback');
const body = layerSource.slice(start, end);
assert.match(body, /startTransition\(\(\) => \{\s*selectSidePanelToolForTab\(tabId, tab\);/);
assert.doesNotMatch(body, /setSidePanelOpenTabs/);
});
@@ -13,215 +13,55 @@ import {
type SidePanelTool,
} from '../../domain/sidePanelLayout';
function replaceMapEntry<T>(previous: Map<string, T>, key: string, value: T): Map<string, T> {
if (previous.get(key) === value) return previous;
const next = new Map(previous);
next.set(key, value);
return next;
}
function deleteMapEntry<T>(previous: Map<string, T>, key: string): Map<string, T> {
if (!previous.has(key)) return previous;
const next = new Map(previous);
next.delete(key);
return next;
}
function entryMatches<T>(left: Map<string, T>, right: Map<string, T>, key: string): boolean {
return left.has(key) === right.has(key) && left.get(key) === right.get(key);
}
/**
* Keep a ref aligned with committed state without dropping a write React has
* not rendered yet. An urgent update can rebase onto a transition: the
* transition updater stores an intermediate map in the ref, and the later
* updater changes only state. Copy committed entries the pending map did not
* change, and follow committed once it moves past that pending value.
*/
function syncTrackedMap<T>(
pending: Map<string, T>,
lastRendered: Map<string, T>,
committed: Map<string, T>,
): Map<string, T> {
if (pending === lastRendered) return committed;
if (pending === committed) return pending;
let changed = false;
const merged = new Map(pending);
for (const key of committed.keys()) {
if (entryMatches(pending, committed, key)) continue;
const pendingMatchesRendered = entryMatches(pending, lastRendered, key);
const committedMatchesRendered = entryMatches(committed, lastRendered, key);
if (pendingMatchesRendered || !committedMatchesRendered) {
merged.set(key, committed.get(key) as T);
changed = true;
}
}
for (const key of pending.keys()) {
if (committed.has(key)) continue;
if (entryMatches(pending, lastRendered, key)) {
merged.delete(key);
changed = true;
}
}
return changed ? merged : pending;
}
function publishQueuedMap<T>(
live: Map<string, T>,
capturedBase: Map<string, T>,
previous: Map<string, T>,
stateNext: Map<string, T>,
tabId: string,
value: T | null,
): Map<string, T> {
// The captured map is the synchronous write. Replaying it after an
// intervening render drops entries that render already merged. When the ref
// has moved ahead of this updater's base, reapply only this tab.
if (live === capturedBase || live === previous) return stateNext;
return value === null ? deleteMapEntry(live, tabId) : replaceMapEntry(live, tabId, value);
}
function reconcileSidePanelLayouts(
layouts: Map<string, SidePanelLayout>,
openTabs: Map<string, SidePanelTool>,
): Map<string, SidePanelLayout> | null {
let changed = false;
const next = new Map(layouts);
for (const tabId of layouts.keys()) {
if (!openTabs.has(tabId)) {
next.delete(tabId);
changed = true;
}
}
for (const [tabId, tool] of openTabs) {
const current = next.get(tabId);
const updated = current
? selectSidePanelTool(current, tool)
: createSidePanelLayout(tool, crypto.randomUUID());
if (updated !== current) {
next.set(tabId, updated);
changed = true;
}
}
return changed ? next : null;
}
export function useTerminalSidePanelLayoutState() {
const [sidePanelOpenTabs, setSidePanelOpenTabs] = useState<Map<string, SidePanelTool>>(new Map());
const [sidePanelLayouts, setSidePanelLayouts] = useState<Map<string, SidePanelLayout>>(new Map());
const sidePanelOpenTabsRef = useRef(sidePanelOpenTabs);
const sidePanelLayoutsRef = useRef(sidePanelLayouts);
// Last maps this hook rendered. A transition can update the refs before React
// commits them; an urgent render in between must not copy the stale state
// back over that pending write. Once the queue commits, entries the pending
// write did not touch still have to catch up, or the ref stays on the
// transition updater's intermediate map.
const renderedOpenTabsRef = useRef(sidePanelOpenTabs);
const renderedLayoutsRef = useRef(sidePanelLayouts);
sidePanelOpenTabsRef.current = syncTrackedMap(
sidePanelOpenTabsRef.current,
renderedOpenTabsRef.current,
sidePanelOpenTabs,
);
renderedOpenTabsRef.current = sidePanelOpenTabs;
sidePanelLayoutsRef.current = syncTrackedMap(
sidePanelLayoutsRef.current,
renderedLayoutsRef.current,
sidePanelLayouts,
);
renderedLayoutsRef.current = sidePanelLayouts;
sidePanelOpenTabsRef.current = sidePanelOpenTabs;
sidePanelLayoutsRef.current = sidePanelLayouts;
// Legacy and external open paths write the focused-tool map. Reconcile that
// public application boundary into the per-tab tree without disturbing the
// other panes in an existing layout. Skip the setState when the click path
// already stored this selection: scheduling an identical update still
// renders again after a paired openTabs write.
// other panes in an existing layout.
useEffect(() => {
const base = sidePanelLayoutsRef.current;
const reconciled = reconcileSidePanelLayouts(base, sidePanelOpenTabs);
if (!reconciled) return;
sidePanelLayoutsRef.current = reconciled;
setSidePanelLayouts((previous) => {
const next = previous === base
? reconciled
: reconcileSidePanelLayouts(previous, sidePanelOpenTabs);
if (!next) {
sidePanelLayoutsRef.current = previous;
return previous;
let changed = false;
const next = new Map(previous);
for (const tabId of previous.keys()) {
if (!sidePanelOpenTabs.has(tabId)) {
next.delete(tabId);
changed = true;
}
}
sidePanelLayoutsRef.current = next;
return next;
for (const [tabId, tool] of sidePanelOpenTabs) {
const current = next.get(tabId);
const updated = current
? selectSidePanelTool(current, tool)
: createSidePanelLayout(tool, crypto.randomUUID());
if (updated !== current) {
next.set(tabId, updated);
changed = true;
}
}
if (changed) sidePanelLayoutsRef.current = next;
return changed ? next : previous;
});
}, [sidePanelOpenTabs]);
const commitMap = useCallback((
tabId: string,
layout: SidePanelLayout,
openTab: SidePanelTool | null,
) => {
const layoutBase = sidePanelLayoutsRef.current;
const openTabBase = sidePanelOpenTabsRef.current;
const layouts = openTab === null
? deleteMapEntry(layoutBase, tabId)
: replaceMapEntry(layoutBase, tabId, layout);
const openTabs = openTab === null
? deleteMapEntry(openTabBase, tabId)
: replaceMapEntry(openTabBase, tabId, openTab);
const commitLayout = useCallback((tabId: string, layout: SidePanelLayout) => {
const layouts = new Map(sidePanelLayoutsRef.current).set(tabId, layout);
const focusedTool = getFocusedSidePanelPane(layout).tool;
const openTabs = new Map(sidePanelOpenTabsRef.current).set(tabId, focusedTool);
sidePanelLayoutsRef.current = layouts;
sidePanelOpenTabsRef.current = openTabs;
setSidePanelLayouts((previous) => {
const next = previous === layoutBase
? layouts
: (openTab === null ? deleteMapEntry(previous, tabId) : replaceMapEntry(previous, tabId, layout));
sidePanelLayoutsRef.current = publishQueuedMap(
sidePanelLayoutsRef.current,
layoutBase,
previous,
next,
tabId,
openTab === null ? null : layout,
);
return next;
});
setSidePanelOpenTabs((previous) => {
const next = previous === openTabBase
? openTabs
: (openTab === null ? deleteMapEntry(previous, tabId) : replaceMapEntry(previous, tabId, openTab));
sidePanelOpenTabsRef.current = publishQueuedMap(
sidePanelOpenTabsRef.current,
openTabBase,
previous,
next,
tabId,
openTab,
);
return next;
});
setSidePanelLayouts(layouts);
setSidePanelOpenTabs(openTabs);
}, []);
const commitLayout = useCallback((tabId: string, layout: SidePanelLayout) => {
commitMap(tabId, layout, getFocusedSidePanelPane(layout).tool);
}, [commitMap]);
// Click path writes the focused tool and the layout together. The effect
// above still reconciles external openTabs writes, and skips when
// selectSidePanelTool returns the layout this call just stored.
const selectSidePanelToolForTab = useCallback((tabId: string, tool: SidePanelTool) => {
const current = sidePanelLayoutsRef.current.get(tabId);
const updated = current
? selectSidePanelTool(current, tool)
: createSidePanelLayout(tool, crypto.randomUUID());
if (updated === current && sidePanelOpenTabsRef.current.get(tabId) === tool) return;
commitLayout(tabId, updated);
}, [commitLayout]);
const focusPane = useCallback((tabId: string, paneId: string) => {
const layout = sidePanelLayoutsRef.current.get(tabId);
if (!layout) return;
@@ -257,23 +97,25 @@ export function useTerminalSidePanelLayoutState() {
return false;
}
commitMap(tabId, layout, null);
const layouts = new Map(sidePanelLayoutsRef.current);
const openTabs = new Map(sidePanelOpenTabsRef.current);
layouts.delete(tabId);
openTabs.delete(tabId);
sidePanelLayoutsRef.current = layouts;
sidePanelOpenTabsRef.current = openTabs;
setSidePanelLayouts(layouts);
setSidePanelOpenTabs(openTabs);
return true;
}, [commitLayout, commitMap]);
}, [commitLayout]);
const resizeSplit = useCallback((tabId: string, splitId: string, sizes: number[]) => {
const layout = sidePanelLayoutsRef.current.get(tabId);
if (!layout) return;
const nextLayout = resizeSidePanelSplit(layout, splitId, sizes);
if (nextLayout === layout) return;
const base = sidePanelLayoutsRef.current;
const layouts = replaceMapEntry(base, tabId, nextLayout);
const layouts = new Map(sidePanelLayoutsRef.current).set(tabId, nextLayout);
sidePanelLayoutsRef.current = layouts;
setSidePanelLayouts((previous) => {
const next = previous === base ? layouts : replaceMapEntry(previous, tabId, nextLayout);
sidePanelLayoutsRef.current = next;
return next;
});
setSidePanelLayouts(layouts);
}, []);
return {
@@ -287,6 +129,5 @@ export function useTerminalSidePanelLayoutState() {
splitPane,
closePane,
resizeSplit,
selectSidePanelToolForTab,
};
}
+1 -1
View File
@@ -270,7 +270,7 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
stopTunnel: current.stopTunnel,
stopRuleTunnels: current.stopRuleTunnels,
openHost: current.openHost
? (host, isExternalMcpCall, options) => current.openHost!(host, isExternalMcpCall, options)
? (host, isExternalMcpCall) => current.openHost!(host, isExternalMcpCall)
: undefined,
findExistingSessionForHost: current.findExistingSessionForHost,
closeSession: current.closeSession
@@ -1,60 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
const source = readFileSync(new URL("./useVaultState.ts", import.meta.url), "utf8");
const terminalLayerSource = readFileSync(
new URL("../../components/TerminalLayer.tsx", import.meta.url),
"utf8",
);
test("connection log replay blobs persist through IndexedDB instead of the legacy localStorage blob", () => {
assert.match(source, /await migrateLegacyConnectionLogTerminalData\(/);
assert.match(source, /await writeTerminalDataMapDiff\(/);
assert.match(source, /STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV/);
assert.match(source, /syncConnectionLogTerminalData !== false|syncConnectionLogTerminalData\)/);
assert.doesNotMatch(source, /writeTerminalDataMapWithQuotaRetry/);
assert.doesNotMatch(
source,
/localStorageAdapter\.write\(\s*STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA\s*,/,
);
});
test("close-path connection log updates await the indexedDB persist task", () => {
assert.match(
source,
/const updateConnectionLog = useCallback\([\s\S]*?return commitConnectionLogs\(/,
);
assert.match(source, /rebaseQueuedConnectionLogs\(\s*memory,\s*removedIds,\s*changedIds,/);
assert.match(
source,
/if \(!options\?\.pruneMainBlob\) \{[\s\S]*?rebaseQueuedConnectionLogs\(/,
);
assert.match(source, /retainNewestUnsavedReplays\(fallbackLogs, keep\)/);
assert.match(source, /if \(!map\) return;/);
assert.match(
source,
/if \(key === STORAGE_KEY_CONNECTION_LOGS\) \{\s*\/\/ This window already updated connectionLogsRef before setItem\.[\s\S]*?return;\s*\}/,
);
assert.match(
terminalLayerSource,
/const handleTerminalDataCapture = useCallback\(\(sessionId: string, data: string\) => \{\s*return onTerminalDataCapture\?\.\(sessionId, data\);/,
);
});
test("boot re-reads connection logs after indexedDB load and falls back to the main blob", () => {
const revAt = source.indexOf("let revSeen = readTerminalDataRev()");
const bootAt = source.indexOf("const loaded = await migrateLegacyConnectionLogTerminalData");
assert.notEqual(revAt, -1);
assert.ok(revAt < bootAt);
const bootSlice = source.slice(bootAt, bootAt + 2800);
assert.match(bootSlice, /revNow !== revSeen[\s\S]*?await loadConnectionLogTerminalData\(\)/);
assert.match(
bootSlice,
/localStorageAdapter\.read<ConnectionLog\[\]>\(\s*STORAGE_KEY_CONNECTION_LOGS,\s*\)[\s\S]*?pruneTerminalDataMapForStorage\(connectionLogsForBoot/,
);
assert.match(
source,
/!sidePersisted && unsavedTerminalDataPending && mainPersisted\) \{\s*\/\/[\s\S]*?localStorageAdapter\.read<ConnectionLog\[\]>\(\s*STORAGE_KEY_CONNECTION_LOGS\s*\)[\s\S]*?mergeTerminalDataIntoLogs\(freshLogs, buildTerminalDataMapFromLogs\(logs\)\)/,
);
});
+120 -421
View File
@@ -37,7 +37,6 @@ import {
import {
STORAGE_KEY_CONNECTION_LOGS,
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA,
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV,
STORAGE_KEY_GROUP_CONFIGS,
STORAGE_KEY_GROUPS,
STORAGE_KEY_HOSTS,
@@ -62,23 +61,11 @@ import {
} from "../../domain/globalHistory";
import {
buildTerminalDataMapFromLogs,
acknowledgePendingConnectionLogWrites,
mergeConnectionLogsWithPendingMutations,
mergeConnectionLogsFromStorage,
mergeTerminalDataIntoLogs,
mergeTerminalDataMapsForStorage,
pruneInMemoryUnsavedTerminalData,
rebaseQueuedConnectionLogs,
retainNewestUnsavedReplays,
pruneTerminalDataMapForStorage,
type ConnectionLogTerminalDataMap,
type PendingConnectionLogWrite,
} from "../../domain/connectionLogTerminalData";
import {
tryLoadAll as tryLoadConnectionLogTerminalData,
migrateLegacyConnectionLogTerminalData,
writeTerminalDataMapDiff,
type ConnectionLogTerminalDataRev,
} from "../../infrastructure/persistence/connectionLogTerminalDataStore";
import { getNextVaultOrder, normalizeVaultOrder } from "../../domain/vaultOrder";
import {
deleteSelectedSnippetsFromVault,
@@ -240,13 +227,6 @@ const safeParse = <T,>(value: string | null): T | null => {
}
};
/** `null` means the read failed. `{}` is a successful empty store. Retries once. */
const loadConnectionLogTerminalData = async (): Promise<ConnectionLogTerminalDataMap | null> => {
const first = await tryLoadConnectionLogTerminalData();
if (first) return first;
return tryLoadConnectionLogTerminalData();
};
/**
* Strip the bulky `terminalData` replay buffer from transient (unsaved)
* connection logs before persisting. `terminalData` is the full terminal
@@ -275,6 +255,9 @@ const readLegacyLineTimestampsEnabled = (): boolean => {
return stored?.showLineTimestamps === true;
};
const readConnectionLogTerminalDataMap = (): ConnectionLogTerminalDataMap =>
localStorageAdapter.read<ConnectionLogTerminalDataMap>(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA) ?? {};
const readPersistedConnectionLogIds = (): Set<string> => {
const stored = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS) ?? [];
return new Set(stored.map((log) => log.id));
@@ -293,18 +276,35 @@ const buildPersistedLogIds = (
return ids;
};
export type UseVaultStateOptions = {
/**
* When false, skip loading and cross-window sync of the bulky
* connection-log terminal-data side store (~30–50 MB in localStorage).
* Pass false from windows that never show connection-log replay
* (Settings, tray panel, terminal popup). Default true.
*/
syncConnectionLogTerminalData?: boolean;
/**
* Same result as comparing `JSON.stringify` of both maps (own keys in the
* same order, equal values) without serializing the multi-MB replay data.
*/
const isSameTerminalDataMap = (
a: ConnectionLogTerminalDataMap,
b: ConnectionLogTerminalDataMap,
): boolean => {
if (typeof a !== "object" || a === null || Array.isArray(a)) {
return JSON.stringify(a) === JSON.stringify(b);
}
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
if (aKeys.length !== bKeys.length) return false;
for (let i = 0; i < aKeys.length; i += 1) {
if (aKeys[i] !== bKeys[i] || a[aKeys[i]] !== b[bKeys[i]]) return false;
}
return true;
};
export const useVaultState = (options?: UseVaultStateOptions) => {
const syncConnectionLogTerminalData = options?.syncConnectionLogTerminalData !== false;
const writeConnectionLogTerminalDataMap = (
existing: ConnectionLogTerminalDataMap,
pruned: ConnectionLogTerminalDataMap,
): boolean => {
if (isSameTerminalDataMap(existing, pruned)) return true;
return localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA, pruned);
};
export const useVaultState = () => {
const [isInitialized, setIsInitialized] = useState(false);
const [hosts, setHosts] = useState<Host[]>([]);
const [keys, setKeys] = useState<SSHKey[]>([]);
@@ -430,158 +430,63 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
const identitiesReadSeq = useRef(0);
const proxyProfilesReadSeq = useRef(0);
const groupConfigsReadSeq = useRef(0);
const connectionLogsRef = useRef<ConnectionLog[]>([]);
const connectionLogWriteGenerationRef = useRef(0);
const connectionLogPendingRef = useRef(new Map<string, PendingConnectionLogWrite>());
const connectionLogTerminalDataRef = useRef<ConnectionLogTerminalDataMap>({});
const connectionLogTerminalDataPersistedRef = useRef<ConnectionLogTerminalDataMap>({});
const connectionLogTerminalDataRevRawRef = useRef<string | null>(null);
const connectionLogTerminalDataLoadSeq = useRef(0);
const terminalDataQueueRef = useRef<Promise<void>>(Promise.resolve());
const terminalDataReadyRef = useRef<Promise<void>>(Promise.resolve());
const pluginCredentialCatalogVersion = useRef(0);
const enqueueTerminalDataTask = useCallback(<T,>(task: () => Promise<T>): Promise<T> => {
const run = terminalDataQueueRef.current.then(task, task);
terminalDataQueueRef.current = run.then(() => undefined, () => undefined);
return run;
}, []);
const bumpConnectionLogTerminalDataRev = useCallback((ids: readonly string[]) => {
const current = localStorageAdapter.read<ConnectionLogTerminalDataRev>(
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV,
);
const prev = typeof current?.rev === "number" && Number.isFinite(current.rev) ? current.rev : 0;
const payload: ConnectionLogTerminalDataRev = {
rev: Math.max(prev + 1, Date.now()),
ids: [...ids].sort(),
nonce: crypto.randomUUID(),
};
// Record the exact JSON before setItem so this window ignores its own echo.
connectionLogTerminalDataRevRawRef.current = JSON.stringify(payload);
const written = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV, payload);
if (!written) {
connectionLogTerminalDataRevRawRef.current = localStorageAdapter.readString(
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV,
);
}
}, []);
const absorbRemoteTerminalDataIfNeeded = useCallback(async () => {
const raw = localStorageAdapter.readString(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV);
if (raw === connectionLogTerminalDataRevRawRef.current) return;
const map = await loadConnectionLogTerminalData();
if (!map) return;
connectionLogTerminalDataRevRawRef.current = raw;
connectionLogTerminalDataPersistedRef.current = map;
connectionLogTerminalDataRef.current = {
...map,
...connectionLogTerminalDataRef.current,
};
}, []);
const syncConnectionLogTerminalDataMap = useCallback(async (
const syncConnectionLogTerminalDataMap = useCallback((
logs: ConnectionLog[],
options?: { usePersistedLogIdsFromDisk?: boolean },
) => {
if (!syncConnectionLogTerminalData) return true;
await absorbRemoteTerminalDataIfNeeded();
const baseline = connectionLogTerminalDataPersistedRef.current;
const usePersistedLogIdsFromDisk = options?.usePersistedLogIdsFromDisk ?? false;
const localMaps = [
connectionLogTerminalDataRef.current,
buildTerminalDataMapFromLogs(logs),
];
const existing = readConnectionLogTerminalDataMap();
const merged = mergeTerminalDataMapsForStorage(
logs,
baseline,
[
connectionLogTerminalDataRef.current,
buildTerminalDataMapFromLogs(logs),
],
buildPersistedLogIds(logs, options?.usePersistedLogIdsFromDisk ?? false),
existing,
localMaps,
buildPersistedLogIds(logs, usePersistedLogIdsFromDisk),
);
const written = await writeTerminalDataMapDiff(baseline, merged);
// Keep the capped map in memory even when IndexedDB rejects the write, so
// the next close retries from the last committed baseline.
connectionLogTerminalDataRef.current = merged;
if (!written.changed) return true;
if (!written.persisted) return false;
connectionLogTerminalDataPersistedRef.current = merged;
bumpConnectionLogTerminalDataRev(Object.keys(merged));
return true;
}, [
absorbRemoteTerminalDataIfNeeded,
bumpConnectionLogTerminalDataRev,
syncConnectionLogTerminalData,
]);
const persisted = writeConnectionLogTerminalDataMap(existing, merged);
if (persisted) {
connectionLogTerminalDataRef.current = merged;
} else {
connectionLogTerminalDataRef.current = {
...connectionLogTerminalDataRef.current,
...buildTerminalDataMapFromLogs(logs),
};
}
return { map: connectionLogTerminalDataRef.current, persisted };
}, []);
const persistConnectionLogState = useCallback(async (
const persistConnectionLogState = useCallback((
logs: ConnectionLog[],
options?: {
pruneMainBlob?: boolean;
removedIds?: ReadonlySet<string>;
changedIds?: ReadonlySet<string>;
},
options?: { pruneMainBlob?: boolean },
) => {
const unsavedTerminalDataPending = logs.some(
(log) => !log.saved && log.terminalData !== undefined,
);
let mainReplay: ConnectionLogTerminalDataMap = {};
const writeMain = (value: ConnectionLog[]) => {
const written = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, value);
if (written) mainReplay = buildTerminalDataMapFromLogs(value);
return written;
};
let mainPersisted = true;
if (options?.pruneMainBlob) {
const prunedMain = pruneConnectionLogsForStorage(logs);
mainPersisted = writeMain(prunedMain);
mainPersisted = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, prunedMain);
if (!mainPersisted && unsavedTerminalDataPending) {
mainPersisted = writeMain(logs);
mainPersisted = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, logs);
}
}
let sidePersisted = false;
let sidePersisted = true;
if (mainPersisted || !options?.pruneMainBlob) {
sidePersisted = await syncConnectionLogTerminalDataMap(logs, {
({ persisted: sidePersisted } = syncConnectionLogTerminalDataMap(logs, {
usePersistedLogIdsFromDisk: Boolean(options?.pruneMainBlob && mainPersisted),
});
}));
}
if (!options?.pruneMainBlob) {
// The side-store await can overlap another window's metadata write.
// Rebase this snapshot onto the blob that exists now, immediately before
// replacing it. A failed read keeps the snapshot already rebased at enqueue.
const freshLogs = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS);
const toWrite = freshLogs
? rebaseQueuedConnectionLogs(
logs,
options?.removedIds ?? new Set<string>(),
options?.changedIds ?? new Set<string>(),
mergeConnectionLogsWithPendingMutations(
connectionLogsRef.current,
freshLogs,
connectionLogTerminalDataPersistedRef.current,
connectionLogPendingRef.current,
),
freshLogs,
)
: logs;
mainPersisted = writeMain(toWrite);
} else if (!sidePersisted && unsavedTerminalDataPending && mainPersisted) {
// IndexedDB rejected the replay after the pruned metadata write. Put the
// buffers back on the current main blob so a restart still has them,
// without replacing logs another window wrote during the await.
// If that blob exceeds quota, drop older unsaved replays and retry so
// the newest capture still has a durable copy.
const freshLogs = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS);
let fallbackLogs = freshLogs
? mergeTerminalDataIntoLogs(freshLogs, buildTerminalDataMapFromLogs(logs))
: logs;
sidePersisted = writeMain(fallbackLogs);
let keep = fallbackLogs.filter((log) => !log.saved && log.terminalData).length;
while (!sidePersisted && keep > 1) {
keep = Math.max(1, Math.floor(keep / 2));
fallbackLogs = retainNewestUnsavedReplays(fallbackLogs, keep);
sidePersisted = writeMain(fallbackLogs);
}
mainPersisted = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, logs);
}
if (!mainPersisted && unsavedTerminalDataPending) {
@@ -593,105 +498,15 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
"[useVaultState] Failed to persist connection log terminal replay data side store.",
);
}
return { metadataPersisted: mainPersisted, replayPersisted: sidePersisted, mainReplay };
}, [syncConnectionLogTerminalDataMap]);
const commitConnectionLogs = useCallback((
recipe: (prev: ConnectionLog[]) => ConnectionLog[],
options?: { pruneMainBlob?: boolean },
) => {
const prev = connectionLogsRef.current;
const next = recipe(prev);
const prevById = new Map(prev.map((log) => [log.id, log]));
const nextIds = new Set<string>();
const generation = ++connectionLogWriteGenerationRef.current;
for (const log of next) {
nextIds.add(log.id);
const before = prevById.get(log.id);
if (!before || before !== log) {
const previous = connectionLogPendingRef.current.get(log.id);
const changes: Partial<ConnectionLog> = { ...previous?.changes };
const fieldGenerations = { ...previous?.fieldGenerations };
for (const field of Object.keys({ ...before, ...log }) as Array<keyof ConnectionLog>) {
if (!before || before[field] !== log[field]) {
Object.assign(changes, { [field]: log[field] });
fieldGenerations[field] = generation;
}
}
connectionLogPendingRef.current.set(log.id, {
generation, log, changes, fieldGenerations, added: previous?.added ?? !before,
});
}
}
for (const log of prev) {
if (!nextIds.has(log.id)) {
connectionLogPendingRef.current.set(log.id, { generation, log: null });
}
}
connectionLogsRef.current = next;
setConnectionLogs(next);
return enqueueTerminalDataTask(() => withVaultImportLock("connection-logs", async () => {
// The shared lock covers the read/rebase as well as both storage writes.
// Include uncommitted generations from failed or newer queued captures.
await absorbRemoteTerminalDataIfNeeded();
const pending = new Map(connectionLogPendingRef.current);
const removedIds = new Set<string>();
const changedIds = new Set<string>();
for (const [id, mutation] of pending) {
if (mutation.log === null) removedIds.add(id);
else changedIds.add(id);
}
const storedBefore = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS);
const memory = mergeConnectionLogsWithPendingMutations(
connectionLogsRef.current,
storedBefore ?? connectionLogsRef.current,
connectionLogTerminalDataPersistedRef.current,
pending,
);
const persisted = await persistConnectionLogState(
rebaseQueuedConnectionLogs(
memory,
removedIds,
changedIds,
memory,
storedBefore,
),
{ ...options, removedIds, changedIds },
);
// A replay/quota failure must not retain already-committed add or
// metadata ownership: retrying it could undo a peer bookmark/deletion.
connectionLogPendingRef.current = acknowledgePendingConnectionLogWrites(
connectionLogPendingRef.current, pending, persisted,
);
if (persisted.metadataPersisted || persisted.replayPersisted) {
const stored = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS);
if (stored) {
const merged = mergeConnectionLogsWithPendingMutations(
connectionLogsRef.current,
stored,
connectionLogTerminalDataRef.current,
connectionLogPendingRef.current,
);
connectionLogsRef.current = merged;
setConnectionLogs(merged);
}
}
return persisted.metadataPersisted && persisted.replayPersisted;
}));
}, [absorbRemoteTerminalDataIfNeeded, enqueueTerminalDataTask, persistConnectionLogState]);
const applyConnectionLogsFromStorage = useCallback((
prev: ConnectionLog[],
storedLogs: ConnectionLog[],
terminalDataMap: ConnectionLogTerminalDataMap,
) => {
connectionLogTerminalDataRef.current = terminalDataMap;
return mergeConnectionLogsWithPendingMutations(
prev,
storedLogs,
terminalDataMap,
connectionLogPendingRef.current,
);
return mergeConnectionLogsFromStorage(prev, storedLogs, terminalDataMap);
}, []);
// Encrypt outside the lock, then under the lock prune script bindings against
@@ -1485,56 +1300,63 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
...log,
id: crypto.randomUUID(),
};
void commitConnectionLogs((prev) => {
setConnectionLogs((prev) => {
// Keep only the last 500 non-saved entries plus all saved entries
const savedLogs = prev.filter((l) => l.saved);
const unsavedLogs = prev.filter((l) => !l.saved);
const updated = [newLog, ...unsavedLogs].slice(0, 500);
return pruneInMemoryUnsavedTerminalData(
[...updated, ...savedLogs].sort(
(a, b) => b.startTime - a.startTime
),
const final = [...updated, ...savedLogs].sort(
(a, b) => b.startTime - a.startTime
);
}, { pruneMainBlob: true });
persistConnectionLogState(final, { pruneMainBlob: true });
return final;
});
return newLog.id;
},
[commitConnectionLogs],
[persistConnectionLogState],
);
const updateConnectionLog = useCallback(
(id: string, updates: Partial<ConnectionLog>) => {
return commitConnectionLogs((prev) => (
pruneInMemoryUnsavedTerminalData(
prev.map((log) => (log.id === id ? { ...log, ...updates } : log)),
)
), { pruneMainBlob: true });
setConnectionLogs((prev) => {
const updated = prev.map((log) =>
log.id === id ? { ...log, ...updates } : log
);
persistConnectionLogState(updated, { pruneMainBlob: true });
return updated;
});
},
[commitConnectionLogs],
[persistConnectionLogState],
);
const toggleConnectionLogSaved = useCallback((id: string) => {
void commitConnectionLogs((prev) => (
pruneInMemoryUnsavedTerminalData(
prev.map((log) =>
log.id === id ? { ...log, saved: !log.saved } : log
),
)
), { pruneMainBlob: false });
}, [commitConnectionLogs]);
setConnectionLogs((prev) => {
const updated = prev.map((log) =>
log.id === id ? { ...log, saved: !log.saved } : log
);
persistConnectionLogState(updated, { pruneMainBlob: false });
return updated;
});
}, [persistConnectionLogState]);
const deleteConnectionLog = useCallback((id: string) => {
void commitConnectionLogs(
(prev) => prev.filter((log) => log.id !== id),
{ pruneMainBlob: true },
);
}, [commitConnectionLogs]);
setConnectionLogs((prev) => {
const updated = prev.filter((log) => log.id !== id);
const map = { ...connectionLogTerminalDataRef.current };
delete map[id];
connectionLogTerminalDataRef.current = map;
persistConnectionLogState(updated, { pruneMainBlob: true });
return updated;
});
}, [persistConnectionLogState]);
const clearUnsavedConnectionLogs = useCallback(() => {
void commitConnectionLogs(
(prev) => prev.filter((log) => log.saved),
{ pruneMainBlob: true },
);
}, [commitConnectionLogs]);
setConnectionLogs((prev) => {
const saved = prev.filter((log) => log.saved);
persistConnectionLogState(saved, { pruneMainBlob: true });
return saved;
});
}, [persistConnectionLogState]);
// Connection logs for Vault logs section — keep App domain bags off session churn.
useLayoutEffect(() => {
@@ -1601,11 +1423,6 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
useEffect(() => {
let cancelled = false;
let resolveTerminalDataReady = () => {};
const terminalDataReady = new Promise<void>((resolve) => {
resolveTerminalDataReady = resolve;
});
terminalDataReadyRef.current = terminalDataReady;
const init = async () => {
try {
await withVaultImportLock("vault", async () => {
@@ -1815,91 +1632,14 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
publishShellHistorySnapshot(savedShellHistory);
}
// Load connection logs. Replay blobs live in IndexedDB, one row per
// log id. Windows that never render replay skip that read
// (Settings / tray / popup).
// Load connection logs
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
STORAGE_KEY_CONNECTION_LOGS,
);
let connectionLogsForBoot = savedConnectionLogs;
let terminalDataMap: ConnectionLogTerminalDataMap = {};
let durableTerminalData = true;
if (syncConnectionLogTerminalData) {
const readTerminalDataRev = () => localStorageAdapter.readString(
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV,
);
// A peer can commit replay and bump the rev while this load is in
// flight. Capture the rev first and reload when it moves, so stamping
// the key later does not mark an unread snapshot as consumed.
let revSeen = readTerminalDataRev();
const loaded = await migrateLegacyConnectionLogTerminalData({
read: () => localStorageAdapter.read<ConnectionLogTerminalDataMap>(
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA,
),
remove: () => {
localStorageAdapter.remove(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA);
},
});
if (cancelled) return;
terminalDataMap = loaded.map;
let revNow = readTerminalDataRev();
if (revNow !== revSeen) {
const reloaded = await loadConnectionLogTerminalData();
if (cancelled) return;
if (reloaded) {
if (Object.keys(reloaded).length > 0) {
terminalDataMap = { ...terminalDataMap, ...reloaded };
}
revSeen = revNow;
}
}
// Another window can append a log while IndexedDB is opening. Prune
// against that newer metadata so this boot does not delete its replay.
connectionLogsForBoot = localStorageAdapter.read<ConnectionLog[]>(
STORAGE_KEY_CONNECTION_LOGS,
) ?? savedConnectionLogs;
const pruned = connectionLogsForBoot
? pruneTerminalDataMapForStorage(connectionLogsForBoot, terminalDataMap)
: terminalDataMap;
// A legacy map that never reached IndexedDB is not a diff baseline.
// Comparing against it would persist only the changed ids and then
// drop the rest of the legacy blob on the next boot.
const baseline = loaded.durable ? terminalDataMap : {};
const written = await writeTerminalDataMapDiff(baseline, pruned);
if (cancelled) return;
if (written.persisted) {
terminalDataMap = pruned;
if (!loaded.durable) {
localStorageAdapter.remove(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA);
}
}
durableTerminalData = written.persisted || loaded.durable;
if (loaded.migrated || (written.changed && written.persisted)) {
bumpConnectionLogTerminalDataRev(Object.keys(terminalDataMap));
} else {
revNow = readTerminalDataRev();
if (revNow !== revSeen) {
const reloaded = await loadConnectionLogTerminalData();
if (cancelled) return;
if (reloaded) {
if (Object.keys(reloaded).length > 0) {
terminalDataMap = { ...terminalDataMap, ...reloaded };
}
revSeen = revNow;
connectionLogsForBoot = localStorageAdapter.read<ConnectionLog[]>(
STORAGE_KEY_CONNECTION_LOGS,
) ?? connectionLogsForBoot;
}
}
connectionLogTerminalDataRevRawRef.current = revSeen;
}
}
const terminalDataMap = readConnectionLogTerminalDataMap();
connectionLogTerminalDataRef.current = terminalDataMap;
connectionLogTerminalDataPersistedRef.current = durableTerminalData ? terminalDataMap : {};
if (connectionLogsForBoot) {
const hydrated = mergeTerminalDataIntoLogs(connectionLogsForBoot, terminalDataMap);
connectionLogsRef.current = hydrated;
setConnectionLogs(hydrated);
if (savedConnectionLogs) {
setConnectionLogs(mergeTerminalDataIntoLogs(savedConnectionLogs, terminalDataMap));
}
// Load managed sources
@@ -1928,7 +1668,6 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
}
}
} finally {
resolveTerminalDataReady();
// StrictMode remount cancels the first init; only the surviving effect
// may publish "vault ready" or terminals can boot against empty keys.
if (!cancelled) {
@@ -1941,15 +1680,8 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
void init();
return () => {
cancelled = true;
resolveTerminalDataReady();
};
}, [
bumpConnectionLogTerminalDataRev,
commitEncryptedHostsUnderVaultLock,
syncConnectionLogTerminalData,
updateHosts,
updateSnippets,
]);
}, [commitEncryptedHostsUnderVaultLock, updateHosts, updateSnippets]);
useEffect(() => {
if (!isInitialized) return;
@@ -2150,22 +1882,17 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
}
if (key === STORAGE_KEY_CONNECTION_LOGS) {
// A queued peer event may predate our successful commit. Read the
// current blob, then preserve only still-owned local generations.
const next = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS);
if (!next) return;
const merged = applyConnectionLogsFromStorage(
connectionLogsRef.current,
next,
connectionLogTerminalDataRef.current,
const next = safeParse<ConnectionLog[]>(event.newValue) ?? [];
setConnectionLogs((prev) =>
applyConnectionLogsFromStorage(prev, next, connectionLogTerminalDataRef.current),
);
connectionLogsRef.current = merged;
setConnectionLogs(merged);
return;
}
if (key === STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV) {
applyTerminalDataRev(event.newValue);
if (key === STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA) {
const next = safeParse<ConnectionLogTerminalDataMap>(event.newValue) ?? {};
connectionLogTerminalDataRef.current = next;
setConnectionLogs((prev) => mergeConnectionLogsFromStorage(prev, prev, next));
return;
}
@@ -2193,47 +1920,19 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
}
};
const applyTerminalDataRev = (raw: string | null) => {
// Settings, tray, and popup mounts never replay logs and must not
// pull the side store across windows.
if (!syncConnectionLogTerminalData) return;
if (raw === connectionLogTerminalDataRevRawRef.current) return;
const seq = ++connectionLogTerminalDataLoadSeq.current;
void terminalDataReadyRef.current.then(() => enqueueTerminalDataTask(() => withVaultImportLock("connection-logs", async () => {
if (seq !== connectionLogTerminalDataLoadSeq.current) return;
const map = await loadConnectionLogTerminalData();
if (seq !== connectionLogTerminalDataLoadSeq.current) return;
if (!map) return;
connectionLogTerminalDataRevRawRef.current = localStorageAdapter.readString(
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV,
);
connectionLogTerminalDataPersistedRef.current = map;
connectionLogTerminalDataRef.current = map;
const merged = mergeConnectionLogsWithPendingMutations(
connectionLogsRef.current,
localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS) ?? connectionLogsRef.current,
map,
connectionLogPendingRef.current,
);
connectionLogsRef.current = merged;
setConnectionLogs(merged);
})));
};
const handleLocalStorageAdapterChanged = (event: Event) => {
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
if (key === STORAGE_KEY_CONNECTION_LOGS) {
// This window already updated connectionLogsRef before setItem. The
// adapter notifies on a timer, so this blob can be older than a log
// added or finalized while that write was still queued. Applying it
// would drop that log from the ref, and the follow-up recipe would
// record no changed id. Other windows still arrive through "storage".
const next = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS) ?? [];
setConnectionLogs((prev) =>
applyConnectionLogsFromStorage(prev, next, connectionLogTerminalDataRef.current),
);
return;
}
if (key === STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV) {
applyTerminalDataRev(localStorageAdapter.readString(
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV,
));
if (key === STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA) {
const next = readConnectionLogTerminalDataMap();
connectionLogTerminalDataRef.current = next;
setConnectionLogs((prev) => mergeConnectionLogsFromStorage(prev, prev, next));
}
};
@@ -2243,7 +1942,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
window.removeEventListener("storage", handleStorage);
window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handleLocalStorageAdapterChanged);
};
}, [applyConnectionLogsFromStorage, enqueueTerminalDataTask, syncConnectionLogTerminalData]);
}, [applyConnectionLogsFromStorage]);
const updateHostLastConnected = useCallback((hostId: string) => {
setHosts((prev) => {
-2
View File
@@ -411,8 +411,6 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
modelCatalogLoading={modelCatalogLoading}
modelCatalogSource={modelCatalogSource}
modelCatalogFetchedAt={modelCatalogFetchedAt}
modelCatalogWarning={modelCatalogWarning}
onRefreshModelCatalog={handleModelCatalogRetry}
allowCustomModelEntry={allowCustomModelEntry}
selectedModelId={selectedAgentModel}
onModelSelect={handleAgentModelSelect}
-3
View File
@@ -1090,9 +1090,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
target.agentEnv,
target.agentCommand,
target.codexRuntime,
// Manual refresh (retry banner / picker button) must bypass the
// main-side LKG-first policy and go live.
options.force === true,
);
if (!result?.ok || !Array.isArray(result.models)) {
throw new Error(result?.error || 'Failed to load SDK agent models');
@@ -620,34 +620,6 @@ test('SDK runtime model cache preserves degraded warnings until a successful ret
assert.equal(cache.read('copilot')?.warning, undefined);
});
test('SDK runtime model cache re-queries main past the TTL so a clean LKG serve clears a stale banner', async () => {
let now = 1_000;
const cache = createSdkRuntimeModelCache({ ttlMs: 100, now: () => now });
// A degraded serve (warning) is cached and replayed within the TTL.
await cache.refresh('claude:/opt/bin/claude', async () => ({
currentModelId: 'claude-opus-5-5',
models: [{ id: 'claude-opus-5-5', name: 'Opus 5.5' }],
warning: 'live fetch failed',
}));
assert.equal(cache.read('claude:/opt/bin/claude')?.warning, 'live fetch failed');
// Past the TTL the entry is evicted and the next automatic load re-queries
// main; with the LKG-first policy that round-trip returns a clean
// last-known-good serve, so the stale banner clears.
now = 1_150;
const cleared = await cache.refresh('claude:/opt/bin/claude', async () => ({
currentModelId: 'claude-opus-5-5',
models: [{ id: 'claude-opus-5-5', name: 'Opus 5.5' }],
source: 'last-known-good',
fetchedAt: '2026-10-23T08:00:00.000Z',
}));
assert.equal(cleared.source, 'last-known-good');
assert.equal(cleared.warning, undefined);
assert.equal(cache.read('claude:/opt/bin/claude')?.warning, undefined);
assert.equal(cache.read('claude:/opt/bin/claude')?.fetchedAt, '2026-10-23T08:00:00.000Z');
});
// #3584 alignment: OpenCode must render build-time presets while its live
// catalog load is pending, exactly like every other backend. The old host
// code suppressed presets for opencode while `runtimeModelLoading` was set,
-4
View File
@@ -247,10 +247,6 @@ export function createSdkRuntimeModelCache(options: SdkRuntimeModelCacheOptions
pruneEntries();
const cached = entries.get(key);
if (!refreshOptions.force && cached && now() - cached.updatedAt < ttlMs) {
// The LKG-first policy lives in main: once a persisted catalog exists
// there, a TTL-expired non-force refresh is answered from disk with a
// clean last-known-good serve (no live attempt, no warning), so the
// next automatic load here clears a stale banner.
touchEntry(key, cached);
return Promise.resolve(cloneCatalog(cached));
}
@@ -1,59 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { createDomRenderer, dispatchDomEvent, flushEffects, installDomEnvironment } from "./test-support/renderReactDom";
test("global completed rows and expanded folder children show cleanup paths without a failed status", async (t) => {
const env = installDomEnvironment();
const previous = new Map<string, PropertyDescriptor | undefined>();
for (const [key, value] of Object.entries({
MutationObserver: env.window.MutationObserver, NodeFilter: env.window.NodeFilter,
HTMLInputElement: env.window.HTMLInputElement, Element: env.window.Element,
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0),
cancelAnimationFrame: clearTimeout,
})) {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
}
const { I18nProvider } = await import("../application/i18n/I18nProvider");
const { sftpTransferCenterStore: store } = await import("../application/state/sftpTransferCenterStore");
const { GlobalSftpTransferCenter } = await import("./GlobalSftpTransferCenter");
const { TooltipProvider } = await import("./ui/tooltip");
const renderer = await createDomRenderer(env.document);
t.after(async () => {
await renderer.unmount();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
env.cleanup();
});
const warning = "Upload completed; remove backup /remote/.netcatty-backup-file.bin manually.";
const base = {
fileName: "file.bin", sourcePath: "/local/file.bin", targetPath: "/remote/file.bin",
sourceConnectionId: "local", targetConnectionId: "remote", direction: "upload" as const,
status: "completed" as const, totalBytes: 10, transferredBytes: 10, speed: 0,
startTime: Date.now(), endTime: Date.now(), isDirectory: false,
};
store.publishOwner("cleanup-ui", [
{ ...base, id: "warning-file", warning },
{ ...base, id: "warning-folder", fileName: "folder", isDirectory: true },
{ ...base, id: "warning-child", parentTaskId: "warning-folder", warning },
]);
await renderer.render(<I18nProvider locale="en"><TooltipProvider><GlobalSftpTransferCenter /></TooltipProvider></I18nProvider>);
const click = async (selector: string) => {
const node = env.document.querySelector(selector);
assert.ok(node, selector);
await dispatchDomEvent(node, new env.window.MouseEvent("click", { bubbles: true }));
await flushEffects();
};
await click("[data-section=global-sftp-transfer-toggle]");
assert.match(env.document.body.textContent ?? "", /Backup cleanup warnings: 1/);
await click('button[aria-label="Show files"]');
const notices = env.document.querySelectorAll('[data-section="sftp-transfer-warning"]');
assert.equal(notices.length, 2);
for (const notice of notices) assert.equal(notice.textContent, warning);
assert.equal(env.document.querySelector('[data-transfer-status="failed"]'), null);
assert.equal(store.getTask("warning-child")?.status, "completed");
await click("[data-section=global-sftp-transfer-toggle]");
});
+1 -11
View File
@@ -37,7 +37,6 @@ import { cn } from "../lib/utils";
import { Button } from "./ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { SftpTransferWarning } from "./sftp/SftpTransferWarning";
/** Stable empty snapshot while the popover is closed — progress ticks must not
* re-render TopTabs just to update hidden list rows. */
@@ -351,9 +350,7 @@ const TransferChildRow = React.memo(function TransferChildRow({ task: snapshotTa
return (
<div className="flex h-6 items-center gap-2 rounded px-1 text-[10px] hover:bg-muted/40" data-transfer-status={task.status}>
<TruncatedTextWithTooltip text={task.fileName} tooltip={task.targetPath || task.fileName} className="flex-1 text-muted-foreground" />
{task.status === "completed" && task.warning
? <div className="min-w-0 flex-1"><SftpTransferWarning warning={task.warning} compact /></div>
: <span className="shrink-0 font-mono text-muted-foreground">{detail}</span>}
<span className="shrink-0 font-mono text-muted-foreground">{detail}</span>
{task.status === "transferring" && <Loader2 size={10} className="shrink-0 animate-spin text-primary" />}
</div>
);
@@ -661,13 +658,6 @@ function TransferRow({
</span>
</div>
{task.status === "completed" && task.warning && <div className="mt-1"><SftpTransferWarning warning={task.warning} /></div>}
{childTasks.some((child) => child.status === "completed" && !!child.warning) && (
<p className="mt-1 text-[10px] text-amber-600 dark:text-amber-400">
{t("sftp.transfers.cleanupWarnings", { count: childTasks.filter((child) => child.status === "completed" && !!child.warning).length })}
</p>
)}
{/* Collapsed: show currently transferring child file(s) without expanding the whole tree. */}
{isDirParent && !expanded && displayChildren.length > 0 && (
<div
+2 -3
View File
@@ -270,8 +270,8 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
&& state.entries.length === 0;
const showNoGlobalHistory = scope === 'global' && globalEntries.length === 0;
// Stay mounted when the side panel parks this tree. Returning null here
// would remount the virtual list on every Notes <-> History switch.
if (!isVisible) return null;
return (
<div
className="h-full flex flex-col bg-background overflow-hidden"
@@ -394,7 +394,6 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
<VariableSizeVirtualList
ref={listRef}
items={listRows}
measureEnabled={isVisible}
getItemHeight={getRowHeight}
getItemKey={(row, index) =>
row.type === 'entry' ? row.entry.id : `detail-${row.entry.id}-${index}`}
@@ -1,69 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { JSDOM } from "jsdom";
import type { GroupNode } from "../types";
test("HostTreeView React updates invalidate order when configs or sort mode change", async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="scroll"><div id="root"></div></div></body></html>', {
url: "http://localhost", pretendToBeVisual: true,
});
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
for (const key of ["window", "document", "navigator", "HTMLElement", "Element", "Node", "MutationObserver", "Event", "CustomEvent", "MouseEvent", "KeyboardEvent", "FocusEvent", "localStorage"] as const) {
install(key, key === "window" ? dom.window : dom.window[key]);
}
install("getComputedStyle", dom.window.getComputedStyle.bind(dom.window));
install("requestAnimationFrame", dom.window.requestAnimationFrame.bind(dom.window));
install("cancelAnimationFrame", dom.window.cancelAnimationFrame.bind(dom.window));
install("ResizeObserver", ResizeObserverStub);
install("IS_REACT_ACT_ENVIRONMENT", true);
const scroller = dom.window.document.getElementById("scroll")!;
Object.defineProperties(scroller, { offsetHeight: { value: 800 }, offsetWidth: { value: 1280 } });
const { act, createElement } = await import("react");
const { createRoot } = await import("react-dom/client");
const { HostTreeView } = await import("./HostTreeView.tsx");
const noop = () => undefined;
const groupTree: GroupNode[] = ["alpha", "beta"].map((path) => ({ name: path, path, hosts: [], children: {} }));
const common = {
groupTree, hosts: [], expandedPaths: new Set<string>(), scrollRef: { current: scroller },
onConnect: noop, onEditHost: noop, onDuplicateHost: noop, onDeleteHost: noop,
onCopyCredentials: noop, onNewGroup: noop, onRenameGroup: noop, onEditGroup: noop,
onDeleteGroup: noop, moveHostToGroup: noop, moveGroup: noop,
};
const root = createRoot(dom.window.document.getElementById("root")!);
const paths = () => [...dom.window.document.querySelectorAll("[data-group-path]")].map((node) => node.getAttribute("data-group-path"));
try {
await act(async () => {
root.render(createElement(HostTreeView, { ...common, sortMode: "manual", groupConfigs: [{ path: "beta", order: -1 }] }));
});
assert.deepEqual(paths(), ["beta", "alpha"]);
await act(async () => {
root.render(createElement(HostTreeView, { ...common, sortMode: "manual", groupConfigs: [{ path: "alpha", order: -1 }] }));
});
assert.deepEqual(paths(), ["alpha", "beta"]);
const groupConfigs = [{ path: "beta", order: -1 }];
await act(async () => {
root.render(createElement(HostTreeView, { ...common, sortMode: "az", groupConfigs }));
});
assert.deepEqual(paths(), ["alpha", "beta"]);
await act(async () => {
root.render(createElement(HostTreeView, { ...common, sortMode: "manual", groupConfigs }));
});
assert.deepEqual(paths(), ["beta", "alpha"]);
} finally {
await act(async () => { root.unmount(); });
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else delete (globalThis as Record<string, unknown>)[key];
}
}
});
-72
View File
@@ -498,75 +498,3 @@ test("HostTreeView IP sorting stays inside each group and leaves manual order in
assert.deepEqual(keys("ip"), ["group:prod", "group:prod/east", "host:east-two", "host:east-ten", "host:prod-two", "host:prod-ten", "host:root-two", "host:root-ten"]);
assert.deepEqual(keys("manual"), ["group:prod", "group:prod/east", "host:east-ten", "host:east-two", "host:prod-ten", "host:prod-two", "host:root-ten", "host:root-two"]);
});
// Deterministic work-count assertions, not a wall-clock benchmark.
test("HostTreeView scans manual group order only once across expanded leaf groups", () => {
const groupTree: GroupNode[] = ["c", "b", "a"].map((path) => ({
name: path, path, children: {}, hosts: [],
}));
const groupConfigs: GroupConfig[] = groupTree.map((node, order) => ({ path: node.path, order }));
let configVisits = 0;
Object.defineProperty(groupConfigs, "filter", {
value: (predicate: (value: GroupConfig, index: number, array: GroupConfig[]) => unknown) =>
Array.prototype.filter.call(groupConfigs, (value: GroupConfig, index: number, array: GroupConfig[]) => {
configVisits += 1;
return predicate(value, index, array);
}),
});
for (const expandedPaths of [new Set<string>(), new Set(["a", "b", "c"])]) {
configVisits = 0;
const manual = buildVisibleHostTreeItems({ groupTree, groupConfigs, expandedPaths, ungroupedHosts: [], sortMode: "manual" });
assert.equal(configVisits, 3);
assert.deepEqual(manual.map((item) => item.key), ["group:c", "group:b", "group:a"]);
for (const sortMode of ["az", "za", "newest", "oldest", "group", "ip"] as const) {
configVisits = 0;
const items = buildVisibleHostTreeItems({ groupTree, groupConfigs, expandedPaths, ungroupedHosts: [], sortMode });
assert.equal(configVisits, 0, `${sortMode} must not construct a manual order index`);
assert.deepEqual(items.map((item) => item.key), sortMode === "za"
? ["group:c", "group:b", "group:a"]
: ["group:a", "group:b", "group:c"]);
}
}
});
test("HostTreeView shared order index preserves valid duplicates, ties and nested sibling metadata", () => {
const leaf = (path: string): GroupNode => ({ name: path.split("/").at(-1)!, path, children: {}, hosts: [] });
const parent: GroupNode = {
...leaf("parent"),
children: { z: leaf("parent/z"), a: leaf("parent/a"), b: leaf("parent/b") },
hosts: [{ ...baseHost, id: "child-host", group: "parent" }],
};
const groupTree = [leaf("unordered-z"), leaf("tie-b"), parent, leaf("tie-a"), leaf("unordered-a")];
const groupConfigs: GroupConfig[] = [
{ path: "parent", order: 10 }, { path: "parent", order: -1 },
{ path: "parent", order: Number.NaN }, { path: "parent", order: Infinity },
{ path: "tie-b", order: 0 }, { path: "tie-a", order: 0 },
{ path: "unordered-a", order: Number.NaN },
{ path: "parent/a", order: 0 }, { path: "parent/b", order: 0 },
{ path: "parent/z", order: -2 },
];
const ungroupedHosts = [{ ...baseHost, id: "root-host", group: "" }];
const expandedPaths = new Set(["parent", "parent/z", "parent/a", "parent/b"]);
const inputBefore = structuredClone({ groupTree, groupConfigs, ungroupedHosts, expandedPaths });
const items = buildVisibleHostTreeItems({ groupTree, groupConfigs, ungroupedHosts, expandedPaths, sortMode: "manual" });
assert.deepEqual(items.map(({ key, depth, posInSet, setSize }) => ({ key, depth, posInSet, setSize })), [
{ key: "group:parent", depth: 0, posInSet: 1, setSize: 6 },
{ key: "group:parent/z", depth: 1, posInSet: 1, setSize: 4 },
{ key: "group:parent/b", depth: 1, posInSet: 2, setSize: 4 },
{ key: "group:parent/a", depth: 1, posInSet: 3, setSize: 4 },
{ key: "host:child-host", depth: 1, posInSet: 4, setSize: 4 },
{ key: "group:tie-a", depth: 0, posInSet: 2, setSize: 6 },
{ key: "group:tie-b", depth: 0, posInSet: 3, setSize: 6 },
{ key: "group:unordered-z", depth: 0, posInSet: 4, setSize: 6 },
{ key: "group:unordered-a", depth: 0, posInSet: 5, setSize: 6 },
{ key: "host:root-host", depth: 0, posInSet: 6, setSize: 6 },
]);
assert.equal(items[0].kind === "group" && items[0].node, parent);
assert.equal(items[4].kind === "host" && items[4].host, parent.hosts[0]);
assert.deepEqual({ groupTree, groupConfigs, ungroupedHosts, expandedPaths }, inputBefore);
const changed = buildVisibleHostTreeItems({
groupTree, groupConfigs: [...groupConfigs, { path: "tie-a", order: -10 }],
ungroupedHosts, expandedPaths, sortMode: "manual",
});
assert.equal(changed[0].key, "group:tie-a");
});
+12 -16
View File
@@ -64,25 +64,22 @@ export type VisibleHostTreeItem =
setSize: number;
};
const buildHostTreeGroupOrder = (groupConfigs: GroupConfig[]): Map<string, number> => (
new Map(
groupConfigs
.filter((config) => typeof config.order === 'number' && Number.isFinite(config.order))
.map((config) => [config.path, config.order as number]),
)
);
const sortHostTreeGroups = (
nodes: GroupNode[],
sortMode: HostTreeSortMode,
orderByPath?: ReadonlyMap<string, number>,
groupConfigs: GroupConfig[],
): GroupNode[] => {
const originalIndex = new Map(nodes.map((node, index) => [node.path, index]));
const orderByPath = new Map(
groupConfigs
.filter((config) => typeof config.order === 'number' && Number.isFinite(config.order))
.map((config) => [config.path, config.order as number]),
);
return [...nodes].sort((a, b) => {
if (sortMode === 'za') return b.name.localeCompare(a.name);
if (sortMode === 'manual') {
const orderA = orderByPath?.get(a.path);
const orderB = orderByPath?.get(b.path);
const orderA = orderByPath.get(a.path);
const orderB = orderByPath.get(b.path);
if (orderA !== undefined && orderB !== undefined && orderA !== orderB) return orderA - orderB;
if (orderA !== undefined) return -1;
if (orderB !== undefined) return 1;
@@ -118,9 +115,8 @@ export function buildVisibleHostTreeItems({
groupConfigs: GroupConfig[];
}): VisibleHostTreeItem[] {
const items: VisibleHostTreeItem[] = [];
const orderByPath = sortMode === 'manual' ? buildHostTreeGroupOrder(groupConfigs) : undefined;
const visitGroups = (nodes: GroupNode[], depth: number) => {
const sortedGroups = sortHostTreeGroups(nodes, sortMode, orderByPath);
const sortedGroups = sortHostTreeGroups(nodes, sortMode, groupConfigs);
// Root-level ungrouped hosts are siblings of top-level groups.
const rootUngrouped = depth === 0 ? sortHostTreeHosts(ungroupedHosts, sortMode) : [];
const siblingSetSize = sortedGroups.length + rootUngrouped.length;
@@ -137,7 +133,7 @@ export function buildVisibleHostTreeItems({
const childGroups = sortHostTreeGroups(
Object.values(node.children ?? {}) as GroupNode[],
sortMode,
orderByPath,
groupConfigs,
);
const childHosts = sortHostTreeHosts(node.hosts, sortMode);
const childSetSize = childGroups.length + childHosts.length;
@@ -186,7 +182,7 @@ export function buildVisibleHostTreeItems({
const childGroups = sortHostTreeGroups(
Object.values(node.children ?? {}) as GroupNode[],
sortMode,
orderByPath,
groupConfigs,
);
const childHosts = sortHostTreeHosts(node.hosts, sortMode);
const childSetSize = childGroups.length + childHosts.length;
@@ -443,7 +439,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
const childNodes = useMemo(() => sortHostTreeGroups(
Object.values(node.children ?? {}) as GroupNode[],
sortMode,
sortMode === 'manual' ? buildHostTreeGroupOrder(groupConfigs) : undefined,
groupConfigs,
), [groupConfigs, node.children, sortMode]);
const sortedHosts = useMemo(
() => sortHostTreeHosts(node.hosts, sortMode),
@@ -151,24 +151,3 @@ test('plugin palette items keep repeated placements independently addressable',
commandId: 'com.example.palette.run',
}]);
});
test('packaged Starter contributions are discoverable through the real palette builder', async (context) => {
const { createRequire } = await import('node:module');
const { mkdtempSync, rmSync } = await import('node:fs');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
const require = createRequire(import.meta.url);
const { PluginDatabase } = require('../electron/plugins/database.cjs');
const { PluginContributionService } = require('../electron/plugins/contributionService.cjs');
const manifest = JSON.parse(readFileSync(new URL('../examples/plugins/starter-netcatty/netcatty.plugin.json', import.meta.url), 'utf8'));
const root = mkdtempSync(join(tmpdir(), 'starter-palette-'));
const database = new PluginDatabase(join(root, 'plugins.sqlite'));
context.after(() => { database.close(); rmSync(root, { recursive: true, force: true }); });
database.installVersion({ pluginId: manifest.id, version: manifest.version, manifest, archiveSha256: 'a'.repeat(64), packageRelativePath: 'starter' }, { enable: true });
const service = new PluginContributionService({ database, runtimeSupervisor: {}, secretStore: {}, getLocale: () => 'en' });
const items = buildPluginPaletteItems(service.snapshot().plugins, 'Starter');
assert.ok(items.some((item) => item.type === 'plugin-command' && item.commandId === 'com.netcatty.starter.greet'));
assert.ok(items.some((item) => item.type === 'plugin-view' && item.id === 'com.netcatty.starter.panel'));
database.setEnabled(manifest.id, false);
assert.equal(buildPluginPaletteItems(service.snapshot().plugins, 'Starter').length, 0);
});
+96 -105
View File
@@ -8,7 +8,6 @@ import { useI18n } from '../application/i18n/I18nProvider';
import { useTerminalBackend } from '../application/state/useTerminalBackend';
import type { Host, SerialConfig, SerialFlowControl, SerialParity } from '../domain/models';
import { prepareSerialConfigForSavedHost } from '../domain/serialBackspace';
import { isSerialEndpointPath, isWindowsNamedPipePath } from '../domain/serialEndpoint';
import { cn } from '../lib/utils';
import { Button } from './ui/button';
import { Combobox, type ComboboxOption } from './ui/combobox';
@@ -112,7 +111,7 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
if (!selectedPort) return;
const config: SerialConfig = {
path: selectedPort.trim(),
path: selectedPort,
baudRate,
dataBits,
stopBits,
@@ -130,7 +129,7 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
const host: Host = {
id: `serial-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`,
label: configLabel.trim() || `Serial: ${portName}`,
hostname: selectedPort.trim(),
hostname: selectedPort,
// For serial hosts, port field stores baud rate as a numeric identifier.
// The full configuration is stored in serialConfig for actual connection.
port: baudRate,
@@ -158,15 +157,18 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
}));
}, [ports]);
// Validate: port path must start with /dev/ (Unix/macOS) or COM/\\.\COM (Windows)
const trimmedPort = selectedPort.trim();
const isNamedPipe = isWindowsNamedPipePath(trimmedPort);
const isPortValid = isSerialEndpointPath(trimmedPort);
const isPortValid =
trimmedPort.startsWith('/dev/') ||
/^COM\d+$/i.test(trimmedPort) ||
/^\\\\\.\\COM\d+$/i.test(trimmedPort);
// Allow custom baud rates as long as they are positive integers
const isBaudRateValid = Number.isInteger(baudRate) && baudRate > 0;
// Check if using 1.5 stop bits (limited Windows support)
const isStopBits15 = stopBits === 1.5;
const isValid = isPortValid && (isNamedPipe || isBaudRateValid);
const isValid = isPortValid && isBaudRateValid;
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
@@ -210,9 +212,6 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
icon={<Usb size={14} className="text-muted-foreground" />}
/>
<p className="text-xs text-muted-foreground">
{t('serial.field.namedPipeHint')}
</p>
{!isPortValid && selectedPort && (
<p className="text-xs text-destructive">
{t('serial.field.customPortPlaceholder')}
@@ -220,34 +219,32 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
)}
</div>
{/* Baud Rate (not used by named pipes) */}
{!isNamedPipe && (
<div className="space-y-2">
<Label htmlFor="baud-rate">{t('serial.field.baudRate')}</Label>
<Combobox
options={BAUD_RATES.map((rate) => ({
value: String(rate),
label: String(rate),
}))}
value={String(baudRate)}
onValueChange={(val) => {
const parsed = parseInt(val, 10);
if (!isNaN(parsed) && parsed > 0) {
setBaudRate(parsed);
}
}}
placeholder={t('serial.field.baudRatePlaceholder')}
emptyText={t('serial.field.baudRateEmpty')}
allowCreate
createText={t('common.use')}
/>
{baudRate > 0 && !BAUD_RATES.includes(baudRate) && (
<p className="text-xs text-muted-foreground">
{t('serial.field.customBaudRate')}
</p>
)}
</div>
)}
{/* Baud Rate */}
<div className="space-y-2">
<Label htmlFor="baud-rate">{t('serial.field.baudRate')}</Label>
<Combobox
options={BAUD_RATES.map((rate) => ({
value: String(rate),
label: String(rate),
}))}
value={String(baudRate)}
onValueChange={(val) => {
const parsed = parseInt(val, 10);
if (!isNaN(parsed) && parsed > 0) {
setBaudRate(parsed);
}
}}
placeholder={t('serial.field.baudRatePlaceholder')}
emptyText={t('serial.field.baudRateEmpty')}
allowCreate
createText={t('common.use')}
/>
{baudRate > 0 && !BAUD_RATES.includes(baudRate) && (
<p className="text-xs text-muted-foreground">
{t('serial.field.customBaudRate')}
</p>
)}
</div>
{/* Advanced Options */}
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
@@ -267,98 +264,92 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-2">
{/* Hardware-only serial framing is not used by named pipes. */}
{!isNamedPipe && (
<>
{/* Data Bits */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label>
<Select
value={String(dataBits)}
onValueChange={(v) => setDataBits(parseInt(v, 10) as 5 | 6 | 7 | 8)}
>
<SelectTrigger id="data-bits">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATA_BITS.map((bits) => (
<SelectItem key={bits} value={String(bits)}>
{bits}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Stop Bits */}
<div className="space-y-2">
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
<Select
value={String(stopBits)}
onValueChange={(v) => setStopBits(parseFloat(v) as 1 | 1.5 | 2)}
>
<SelectTrigger id="stop-bits">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STOP_BITS.map((bits) => (
<SelectItem key={bits} value={String(bits)}>
{bits}
</SelectItem>
))}
</SelectContent>
</Select>
{isStopBits15 && (
<p className="text-xs text-yellow-500">
{t('serial.field.stopBits15Warning')}
</p>
)}
</div>
</div>
{/* Parity */}
{/* Data Bits */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="parity">{t('serial.field.parity')}</Label>
<Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label>
<Select
value={parity}
onValueChange={(v) => setParity(v as SerialParity)}
value={String(dataBits)}
onValueChange={(v) => setDataBits(parseInt(v, 10) as 5 | 6 | 7 | 8)}
>
<SelectTrigger id="parity">
<SelectTrigger id="data-bits">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PARITY_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{t(`serial.parity.${option}`)}
{DATA_BITS.map((bits) => (
<SelectItem key={bits} value={String(bits)}>
{bits}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Flow Control */}
{/* Stop Bits */}
<div className="space-y-2">
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
<Select
value={flowControl}
onValueChange={(v) => setFlowControl(v as SerialFlowControl)}
value={String(stopBits)}
onValueChange={(v) => setStopBits(parseFloat(v) as 1 | 1.5 | 2)}
>
<SelectTrigger id="flow-control">
<SelectTrigger id="stop-bits">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FLOW_CONTROL_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{t(`serial.flowControl.${option}`)}
{STOP_BITS.map((bits) => (
<SelectItem key={bits} value={String(bits)}>
{bits}
</SelectItem>
))}
</SelectContent>
</Select>
{isStopBits15 && (
<p className="text-xs text-yellow-500">
{t('serial.field.stopBits15Warning')}
</p>
)}
</div>
</div>
</>
)}
{/* Parity */}
<div className="space-y-2">
<Label htmlFor="parity">{t('serial.field.parity')}</Label>
<Select
value={parity}
onValueChange={(v) => setParity(v as SerialParity)}
>
<SelectTrigger id="parity">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PARITY_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{t(`serial.parity.${option}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Flow Control */}
<div className="space-y-2">
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
<Select
value={flowControl}
onValueChange={(v) => setFlowControl(v as SerialFlowControl)}
>
<SelectTrigger id="flow-control">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FLOW_CONTROL_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{t(`serial.flowControl.${option}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Terminal Options */}
<div className="space-y-3 pt-2 border-t border-border/60">
@@ -1,34 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { I18nProvider } from '../application/i18n/I18nProvider';
import type { Host } from '../domain/models';
import { SerialHostDetailsPanel } from './SerialHostDetailsPanel';
function renderPanel(path: string, hostname = path) {
const host: Host = {
id: 'serial-test', label: 'VM console', hostname, protocol: 'serial',
port: 115200, username: '', tags: [], os: 'linux',
serialConfig: { path, baudRate: 115200, dataBits: 8, stopBits: 1, parity: 'none', flowControl: 'none' },
};
return renderToStaticMarkup(<I18nProvider locale="en"><SerialHostDetailsPanel
initialData={host} onSave={() => {}} onCancel={() => {}}
/></I18nProvider>);
}
test('saved named-pipe host is editable without hardware baud controls', () => {
const html = renderPanel(String.raw`\\.\pipe\vm-console`);
assert.match(html, /VM must create the pipe as a server/);
assert.doesNotMatch(html, /for="baud-rate"/);
assert.doesNotMatch(html, /text-destructive/);
});
test('hardware serial host retains its baud-rate controls', () => {
assert.match(renderPanel('COM3'), /for="baud-rate"/);
});
test('editor prefers the stored pipe path over a stale hardware hostname', () => {
const html = renderPanel(String.raw`\\.\pipe\VM console`, 'COM3');
assert.doesNotMatch(html, /for="baud-rate"/);
});
+96 -107
View File
@@ -12,8 +12,6 @@ import {
resolveSerialBackspaceOverrideOnSave,
} from '../domain/serialBackspace';
import { isSerialEndpointPath, isWindowsNamedPipePath } from '../domain/serialEndpoint';
import { Button } from './ui/button';
import { Combobox, ComboboxOption, MultiCombobox } from './ui/combobox';
import { Input } from './ui/input';
@@ -93,7 +91,7 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
// input alone cannot restore `password` to undefined — the user needs a
// dedicated control to delete the credential entirely.
const [passwordCleared, setPasswordCleared] = useState(false);
const [selectedPort, setSelectedPort] = useState(initialData.serialConfig?.path || initialData.hostname || '');
const [selectedPort, setSelectedPort] = useState(initialData.hostname || initialData.serialConfig?.path || '');
const [baudRate, setBaudRate] = useState(initialData.serialConfig?.baudRate || initialData.port || 115200);
const [dataBits, setDataBits] = useState<5 | 6 | 7 | 8>(initialData.serialConfig?.dataBits || 8);
const [stopBits, setStopBits] = useState<1 | 1.5 | 2>(initialData.serialConfig?.stopBits || 1);
@@ -133,7 +131,7 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
if (!selectedPort) return;
const config: SerialConfig = {
path: selectedPort.trim(),
path: selectedPort,
baudRate,
dataBits,
stopBits,
@@ -153,7 +151,7 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
const updatedHost: Host = {
...initialData,
label: label.trim() || `Serial: ${portName}`,
hostname: selectedPort.trim(),
hostname: selectedPort,
port: baudRate,
username: username.trim() || undefined,
// An empty password is meaningful for serial auto-login: the detector
@@ -208,10 +206,12 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
// Validation
const trimmedPort = selectedPort.trim();
const isNamedPipe = isWindowsNamedPipePath(trimmedPort);
const isPortValid = isSerialEndpointPath(trimmedPort);
const isPortValid =
trimmedPort.startsWith('/dev/') ||
/^COM\d+$/i.test(trimmedPort) ||
/^\\\\\.\\COM\d+$/i.test(trimmedPort);
const isBaudRateValid = Number.isInteger(baudRate) && baudRate > 0;
const isValid = isPortValid && (isNamedPipe || isBaudRateValid);
const isValid = isPortValid && isBaudRateValid;
// Check if using 1.5 stop bits (limited Windows support)
const isStopBits15 = stopBits === 1.5;
@@ -265,9 +265,6 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
createText={t('common.use')}
icon={<Usb size={14} className="text-muted-foreground" />}
/>
<p className="text-xs text-muted-foreground">
{t('serial.field.namedPipeHint')}
</p>
{!isPortValid && selectedPort && (
<p className="text-xs text-destructive">
{t('serial.field.customPortPlaceholder')}
@@ -275,34 +272,32 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
)}
</div>
{/* Baud Rate (not used by named pipes) */}
{!isNamedPipe && (
<div className="space-y-2">
<Label htmlFor="baud-rate">{t('serial.field.baudRate')}</Label>
<Combobox
options={BAUD_RATES.map((rate) => ({
value: String(rate),
label: String(rate),
}))}
value={String(baudRate)}
onValueChange={(val) => {
const parsed = parseInt(val, 10);
if (!isNaN(parsed) && parsed > 0) {
setBaudRate(parsed);
}
}}
placeholder={t('serial.field.baudRatePlaceholder')}
emptyText={t('serial.field.baudRateEmpty')}
allowCreate
createText={t('common.use')}
/>
{baudRate > 0 && !BAUD_RATES.includes(baudRate) && (
<p className="text-xs text-muted-foreground">
{t('serial.field.customBaudRate')}
</p>
)}
</div>
)}
{/* Baud Rate */}
<div className="space-y-2">
<Label htmlFor="baud-rate">{t('serial.field.baudRate')}</Label>
<Combobox
options={BAUD_RATES.map((rate) => ({
value: String(rate),
label: String(rate),
}))}
value={String(baudRate)}
onValueChange={(val) => {
const parsed = parseInt(val, 10);
if (!isNaN(parsed) && parsed > 0) {
setBaudRate(parsed);
}
}}
placeholder={t('serial.field.baudRatePlaceholder')}
emptyText={t('serial.field.baudRateEmpty')}
allowCreate
createText={t('common.use')}
/>
{baudRate > 0 && !BAUD_RATES.includes(baudRate) && (
<p className="text-xs text-muted-foreground">
{t('serial.field.customBaudRate')}
</p>
)}
</div>
{/* Login credentials (auto-login) */}
<div className="space-y-2">
@@ -413,98 +408,92 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-2">
{/* Hardware-only serial framing is not used by named pipes. */}
{!isNamedPipe && (
<>
{/* Data Bits */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label>
<Select
value={String(dataBits)}
onValueChange={(v) => setDataBits(parseInt(v, 10) as 5 | 6 | 7 | 8)}
>
<SelectTrigger id="data-bits">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATA_BITS.map((bits) => (
<SelectItem key={bits} value={String(bits)}>
{bits}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Stop Bits */}
<div className="space-y-2">
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
<Select
value={String(stopBits)}
onValueChange={(v) => setStopBits(parseFloat(v) as 1 | 1.5 | 2)}
>
<SelectTrigger id="stop-bits">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STOP_BITS.map((bits) => (
<SelectItem key={bits} value={String(bits)}>
{bits}
</SelectItem>
))}
</SelectContent>
</Select>
{isStopBits15 && (
<p className="text-xs text-yellow-500">
{t('serial.field.stopBits15Warning')}
</p>
)}
</div>
</div>
{/* Parity */}
{/* Data Bits */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="parity">{t('serial.field.parity')}</Label>
<Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label>
<Select
value={parity}
onValueChange={(v) => setParity(v as SerialParity)}
value={String(dataBits)}
onValueChange={(v) => setDataBits(parseInt(v, 10) as 5 | 6 | 7 | 8)}
>
<SelectTrigger id="parity">
<SelectTrigger id="data-bits">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PARITY_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{t(`serial.parity.${option}`)}
{DATA_BITS.map((bits) => (
<SelectItem key={bits} value={String(bits)}>
{bits}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Flow Control */}
{/* Stop Bits */}
<div className="space-y-2">
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
<Select
value={flowControl}
onValueChange={(v) => setFlowControl(v as SerialFlowControl)}
value={String(stopBits)}
onValueChange={(v) => setStopBits(parseFloat(v) as 1 | 1.5 | 2)}
>
<SelectTrigger id="flow-control">
<SelectTrigger id="stop-bits">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FLOW_CONTROL_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{t(`serial.flowControl.${option}`)}
{STOP_BITS.map((bits) => (
<SelectItem key={bits} value={String(bits)}>
{bits}
</SelectItem>
))}
</SelectContent>
</Select>
{isStopBits15 && (
<p className="text-xs text-yellow-500">
{t('serial.field.stopBits15Warning')}
</p>
)}
</div>
</div>
</>
)}
{/* Parity */}
<div className="space-y-2">
<Label htmlFor="parity">{t('serial.field.parity')}</Label>
<Select
value={parity}
onValueChange={(v) => setParity(v as SerialParity)}
>
<SelectTrigger id="parity">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PARITY_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{t(`serial.parity.${option}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Flow Control */}
<div className="space-y-2">
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
<Select
value={flowControl}
onValueChange={(v) => setFlowControl(v as SerialFlowControl)}
>
<SelectTrigger id="flow-control">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FLOW_CONTROL_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{t(`serial.flowControl.${option}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Terminal Options */}
<div className="space-y-3 pt-2 border-t border-border/60">
+2 -3
View File
@@ -270,7 +270,7 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
groupConfigs,
importDataFromString,
clearVaultData,
} = useVaultState({ syncConnectionLogTerminalData: false });
} = useVaultState();
const { rules: portForwardingRules, importRules: importPortForwardingRules } = usePortForwardingState();
@@ -523,8 +523,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
</TabsList>
</div>
{/* min-w-0: row flex item must shrink so long Agent paths truncate inside the pane. */}
<div className="flex-1 h-full flex flex-col min-h-0 min-w-0 bg-muted/10">
<div className="flex-1 h-full flex flex-col min-h-0 bg-muted/10">
{mountedTabs.has("application") && (
<SettingsLazyTab value="application">
<LazySettingsApplicationTab
+2 -3
View File
@@ -265,10 +265,9 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
}, [activeHost, activeSessionId, resolveTransferSourceSessionId, sessions]);
const fileWatchHandlers = useMemo(() => ({
onFileWatchSynced: (payload: { remotePath: string; warning?: string }) => {
onFileWatchSynced: (payload: { remotePath: string }) => {
const fileName = payload.remotePath.split('/').pop() || payload.remotePath;
if (payload.warning) toast.warning(payload.warning);
else toast.success(t('sftp.autoSync.success', { fileName }));
toast.success(t('sftp.autoSync.success', { fileName }));
logger.info("[SFTP] File auto-synced to remote", payload);
},
onFileWatchError: (payload: { error: string }) => {
-21
View File
@@ -52,27 +52,6 @@ test("renders failed transfer actions with custom tooltips and readable labels",
assert.match(markup, /focus-visible:ring-1/);
});
for (const isChild of [false, true]) {
test(`completed ${isChild ? "child" : "file"} shows the cleanup path without a retry action`, () => {
const warning = "Upload completed; remove backup /remote/.netcatty-backup-archive.tar.gz manually.";
const markup = renderTransferItem({
...baseTask, status: "completed", error: undefined, warning, transferredBytes: 1024,
}, { isChild });
assert.match(markup, /data-transfer-status="completed"/);
assert.match(markup, /data-section="sftp-transfer-warning"/);
assert.ok(markup.includes(warning));
assert.doesNotMatch(markup, /aria-label="Retry:/);
assert.doesNotMatch(markup, /text-destructive/);
});
}
test("folder rows disclose retained child cleanup warnings", () => {
const markup = renderTransferItem({ ...baseTask, status: "completed", error: undefined, isDirectory: true }, {
childWarningCount: 2, canToggleChildren: true, onToggleChildren: () => {},
});
assert.match(markup, /Backup cleanup warnings: 2/);
});
test("renders active transfer cancel action with an item-specific label", () => {
const markup = renderTransferItem({
...baseTask,
+2 -3
View File
@@ -119,10 +119,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
// File watch event handlers (stable refs to avoid re-creating the useSftpState options)
const fileWatchHandlers = useMemo(() => ({
onFileWatchSynced: (payload: { remotePath: string; warning?: string }) => {
onFileWatchSynced: (payload: { remotePath: string }) => {
const fileName = payload.remotePath.split('/').pop() || payload.remotePath;
if (payload.warning) toast.warning(payload.warning);
else toast.success(t('sftp.autoSync.success', { fileName }));
toast.success(t('sftp.autoSync.success', { fileName }));
logger.info("[SFTP] File auto-synced to remote", payload);
},
onFileWatchError: (payload: { error: string }) => {
Loaded 100 of 432 files, more files were not shown because too many files have changed in this diff. Show more