Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 abe2b0edc0 fix(terminal): merge pending side-panel writes across urgent renders
Icon switches inside startTransition used to publish absolute maps from
refs, and the next render copied the last committed state back over
those refs. A second tab's switch could then replace the queued map and
drop the first tab's selection and split. Patch each tab onto the queued
map, and leave a pending ref in place until that render commits.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-24 07:13:44 +00:00
Cursor Agentand陈大猫 945147c600 perf(terminal): commit side-panel tool switches in one render
Icon clicks write the focused tool and the layout together through
selectSidePanelTool. External open-tab writes still reconcile in an effect,
and that effect skips when the layout is already the selection.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-24 07:06:52 +00:00
d2ce41cf1c perf(history): keep the history side panel mounted while hidden (#3896)
Notes and SFTP already park behind a hidden class. History returned null,
so each Notes to History switch remounted the virtual list. Keep that tree
mounted, subscribe to shell history only while it is visible, and remeasure
the list when it is shown again.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-24 14:29:27 +08:00
陈大猫 857d20db88 fix(sftp): retain completed uploads with backup cleanup warnings (#3895)
* fix(sftp): preserve successful uploads with backup cleanup warnings

Warn only on confirmed backup cleanup permission refusal after final publication. Preserve actionable warnings across transfer events, history restoration and completion ordering without masking deletion or commit failures.

Narrow follow-up to #3830; related #3879.

* fix(sftp): retain folder cleanup warnings before child completion
2026-10-24 13:20:45 +08:00
陈大猫 43d714b73c feat(serial): support local Windows named pipe consoles (#3894)
* feat(serial): support local Windows named pipe consoles

* test(serial): synchronize pipe acceptance and cover saved-host form

* fix(serial): preserve named-pipe paths with spaces when editing hosts

* test(serial): verify Windows pipes through native Electron IPC

* test(serial): keep Electron listener callbacks in renderer context

* test(serial): cover busy pipe cancellation and bounded slow-reader writes

* test(serial): construct single-instance fixture pipe path correctly
2026-10-24 12:56:58 +08:00
陈大猫 964e225f0f Merge pull request #3893 from binaricat/fix/pr3885-scoped-session-review
fix: complete scoped session opening and readiness follow-up
2026-10-24 11:31:42 +08:00
陈大猫 40ad9b2fff fix: retain connection history for agent workspace opens 2026-10-24 11:08:28 +08:00
陈大猫 b83a0459f3 fix: revalidate privacy before delivering pending PTY input 2026-10-24 10:58:27 +08:00
陈大猫 58418e887a fix: integrate scoped session opening and address review regressions
Preserve the original PR #3885 commits and integrate latest main.
Resolve local shell defaults, acknowledge committed workspace insertion, and preserve background readiness codes.
2026-10-24 10:47:15 +08:00
陈大猫 3e68f7cb3e fix(sftp): keep pending transfer quit checks fail closed (#3892)
* fix(sftp): serialize TERM-SSHD transfers on shared channels

* fix(sftp): serialize TERM-SSHD resume preflight reads

* fix(sftp): keep pending transfer quit checks fail closed

* test(sftp): use complete typed quit-guard fixtures
2026-10-24 10:36:35 +08:00
陈大猫 520c99da77 fix: preserve concurrent connection log updates across windows (#3891)
* fix: preserve concurrent connection log updates across windows

* fix: retire durable log metadata after partial replay writes
2026-10-24 10:35:51 +08:00
陈大猫 7eeb3d5724 fix(sftp): serialize TERM-SSHD transfers on shared channels (#3889)
* fix(sftp): serialize TERM-SSHD transfers on shared channels

* fix(sftp): serialize TERM-SSHD resume preflight reads
2026-10-24 10:35:49 +08:00
陈大猫 ed763c89b3 fix(tabs): activate sub-threshold tab clicks without cancelling dragstart (#3888)
Fixes #3886

Sub-threshold tab pointer moves no longer lose activate: dragstart is not cancelled; reorder still arms at ≥4px; mouseup/dragend activate when click was cancelled and no reorder started.
2026-10-23 23:34:57 +08:00
陈大猫 b884eff429 fix(tabs): prevent tab drag from swallowing close-button click (#3887)
Fixes #3886

Primary-button mousedown on plugin/editor/session/log-view top-tab close buttons now preventDefault+stopPropagation so HTML5 drag on the parent tab cannot swallow the click.
2026-10-23 20:41:48 +08:00
GOUKI9999 ade10ee985 docs: define scoped session continuity and bounded recovery guidance 2026-10-23 19:32:52 +08:00
GOUKI9999 cbcec92d32 test: expect scope failure when a deleted chat reuses a tab 2026-10-23 19:25:54 +08:00
GOUKI9999 5ded1f5308 fix: bind host sessions to workspaces and wait for shell prompts 2026-10-23 19:22:56 +08:00
陈大猫 a3b3e70388 Merge pull request #3884 from binaricat/fix/pr3883-force-refresh
feat(ai): LKG-first model catalogs with reliable manual refresh
2026-10-23 18:20:40 +08:00
陈大猫 e9818a619c Merge pull request #3880 from binaricat/fix/mcp-single-channel-trust-encoding
fix(sftp): verified single-channel transfers with safe trust and encoding inheritance
2026-10-23 17:35:19 +08:00
陈大猫 f7daed70ee fix(sftp): preserve dedicated routing after saving host trust 2026-10-23 17:14:46 +08:00
陈大猫 6e12c314ec fix(sftp): discard TERM channel after uncertain CLOSE 2026-10-23 17:01:54 +08:00
陈大猫 97dfab225e fix(sftp): propagate dedicated dial trust and lifecycle failures
Do not fall back to a fresh GUI login when a dedicated connection rejects.
Capture capability selection before awaiting so source cleanup cannot change
the failure policy. Preserve ordinary reusable-channel fallback and cover
both paths with real bridge regression tests.
2026-10-23 16:40:52 +08:00
陈大猫 90b6569c7a fix(sftp): inherit encoding and accepted host identities for dedicated dials
Preserve PR #3875's original author commits and scoped TERM-SSHD transfer policy.
Copy persisted filename encoding into dedicated file connections. Capture only
host identities actually accepted by the terminal route, including jump hops,
and fail closed on later unverified changes without orphan host-key prompts.
Add first-trust, changed-key, route-scope and encoding regressions.
2026-10-23 16:30:42 +08:00
jichengheng fe7dbbf6b9 fix(sftp): select dedicated terminal dials during GUI reconnect 2026-10-23 14:03:03 +08:00
jichengheng 8afaf60c50 fix(sftp): await sequential verification handle cleanup 2026-10-23 12:53:30 +08:00
jichengheng 88a2019518 fix(sftp): verify sequential TERM-SSHD transfers before publication 2026-10-23 12:50:59 +08:00
jichengheng cb27b3d01b fix(mcp): isolate SFTP dials for single-channel SSH sessions 2026-10-23 12:08:52 +08:00
144 changed files with 6707 additions and 497 deletions

No files matched your search

+6
View File
@@ -82,6 +82,8 @@ 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/**"
@@ -251,6 +253,10 @@ 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
+3
View File
@@ -91,6 +91,9 @@ 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
+2
View File
@@ -1164,6 +1164,7 @@ 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,
@@ -1183,6 +1184,7 @@ 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,
@@ -0,0 +1,67 @@
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);
});
}
+13 -7
View File
@@ -56,6 +56,7 @@ 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';
@@ -255,6 +256,7 @@ export function AppSideEffects() {
createLocalTerminal,
createSerialSession,
connectToHost,
appendHostToWorkspaceConfirmed,
closeSession,
closeSessions,
closeWorkspace,
@@ -1458,7 +1460,7 @@ export function AppSideEffects() {
temporaryConnectionSettingsRef.current = terminalSettings;
// Wrapper to connect to host with logging
const handleConnectToHost = useCallback((host: Host, alreadyEffective = false, hidden = false) => {
const handleConnectToHost = useCallback((host: Host, alreadyEffective = false, hidden = false, workspaceId?: string) => {
const liveTerminalSettings = temporaryConnectionSettingsRef.current;
const connectionHost = prepareConnectionHost(
host,
@@ -1476,7 +1478,9 @@ export function AppSideEffects() {
: resolveEffectiveHost;
return handleConnectToHostImpl(() => ({
addConnectionLog,
connectToHost,
connectToHost: workspaceId
? (candidate: Host) => appendHostToWorkspaceConfirmed(workspaceId, candidate)
: connectToHost,
discoveredShells,
host: connectionHost,
identities,
@@ -1487,7 +1491,7 @@ export function AppSideEffects() {
systemInfoRef,
terminalSettings: liveTerminalSettings,
}), connectionHost, hidden);
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells]);
}, [addConnectionLog, appendHostToWorkspaceConfirmed, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells]);
const handleConnectToHostWithTabName = useCallback((host: Host, tabName?: string) => {
const sessionId = handleConnectToHost(host);
@@ -1497,12 +1501,14 @@ export function AppSideEffects() {
return sessionId;
}, [handleConnectToHost, renameSessionInline]);
const openHostForVaultAgent = useCallback((host: Host, isExternalMcpCall: boolean) => {
const openHostForVaultAgent = useCallback((host: Host, isExternalMcpCall: boolean, options?: { workspaceId?: string }) => {
const target = resolveAgentHostWorkspace(options?.workspaceId, activeTabStore.getActiveTabId(), workspaces.map(workspace => workspace.id));
if (target.error) return { ok: false as const, error: target.error };
// Silent sessions only apply to actual external MCP clients (chatSessionId
// equals the reserved external-MCP scope). The in-app Catty AI chat's
// host_open always opens a visible tab, as documented.
const hidden = isExternalMcpCall && readExternalMcpSilentSessions();
const sessionId = handleConnectToHost(host, true, hidden);
const sessionId = handleConnectToHost(host, true, hidden, target.workspaceId);
if (!sessionId) {
return { ok: false as const, error: `Failed to open host "${host.id}".` };
}
@@ -1511,8 +1517,8 @@ export function AppSideEffects() {
if (readExternalMcpFocusOnHostOpen()) {
void netcattyBridge.get()?.openMainWindow?.();
}
return { ok: true as const, sessionId, host };
}, [handleConnectToHost]);
return { ok: true as const, sessionId, host, workspaceId: target.workspaceId };
}, [handleConnectToHost, workspaces]);
const findExistingSessionForHostForVaultAgent = useHostOpenSessionReuse({
sessions, hosts, keys, identities, resolveEffectiveHost,
@@ -0,0 +1,43 @@
// 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" }));
@@ -59,6 +59,7 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
createWorkspaceFromSessions,
addSessionToWorkspace,
appendHostToWorkspace,
appendHostToWorkspaceConfirmed,
appendLocalTerminalToWorkspace,
createWorkspaceFromTargets,
updateSplitSizes,
@@ -143,6 +144,7 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
createWorkspaceFromSessions,
addSessionToWorkspace,
appendHostToWorkspace,
appendHostToWorkspaceConfirmed,
appendLocalTerminalToWorkspace,
createWorkspaceFromTargets,
updateSplitSizes,
@@ -171,6 +173,7 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
}), [
addSessionToWorkspace,
appendHostToWorkspace,
appendHostToWorkspaceConfirmed,
appendLocalTerminalToWorkspace,
clearSessionFontSizeOverride,
closeLogView,
@@ -0,0 +1,114 @@
/* 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 }));
});
@@ -0,0 +1,52 @@
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,11 +1,28 @@
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");
@@ -13,7 +30,8 @@ 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 = {};
g.window.confirm = () => accept;
let prompts = 0;
g.window.confirm = () => { prompts++; return accept; };
let renderer: ReactTestRenderer | undefined;
let releaseResume!: () => void;
const resumeGate = new Promise<void>((resolve) => { releaseResume = resolve; });
@@ -76,12 +94,159 @@ for (const accept of [false, true]) {
}
assert.equal(resumeSettled, false, "the dirty-check reply must not wait for resume");
if (accept) {
assert.deepEqual(reports, [[false, undefined]]);
assert.deepEqual(calls, [...paused, "report"], "accepted quit leaves every prompted task paused");
assert.deepEqual(reports, [[true, "transfers"], [false, undefined]]);
assert.deepEqual(calls, ["report", ...paused, "report"], "accepted quit leaves every prompted task paused");
} else {
assert.deepEqual(reports, [[true, "transfers"]], "main must be told the prompt was already answered");
assert.deepEqual(calls, [...paused, "report", "resume:running", "resume:queued"],
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"],
"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" });
});
+49 -11
View File
@@ -344,12 +344,26 @@ 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 () => {
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;
// 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
@@ -374,24 +388,44 @@ 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)
));
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;
// 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;
}
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);
bridge.reportDirtyEditorsResult?.(hasDirty, reason, response);
} 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
@@ -399,8 +433,12 @@ 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
@@ -879,6 +879,7 @@ 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,6 +370,7 @@ 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',
+1
View File
@@ -841,6 +841,7 @@ 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',
+1
View File
@@ -370,6 +370,7 @@ 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',
+1
View File
@@ -886,6 +886,7 @@ 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,6 +413,7 @@ 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': 'Передача заблокирована',
@@ -0,0 +1,13 @@
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
View File
@@ -1035,6 +1035,7 @@ Vaults 标签保持扁平,不使用这块背景。
'sftp.transfers.openTargetFolder': '打开目标目录',
'sftp.transfers.openTargetFolderError': '无法打开目标目录',
'sftp.transfers.copyTargetPath': '复制目标路径',
'sftp.transfers.cleanupWarnings': '备份清理警告:{count}。展开文件列表查看详情。',
'sftp.transfers.copyTargetPathSuccess': '已复制目标路径',
'sftp.transfers.copyTargetPathError': '无法复制目标路径',
'sftp.transfers.pathConflict.title': '无法开始传输',
@@ -969,6 +969,7 @@ 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': '虚拟终端',
+1
View File
@@ -1037,6 +1037,7 @@ Vaults 分頁維持扁平,不使用這塊背景。
'sftp.transfers.openTargetFolder': '開啟目標目錄',
'sftp.transfers.openTargetFolderError': '無法開啟目標目錄',
'sftp.transfers.copyTargetPath': '複製目標路徑',
'sftp.transfers.cleanupWarnings': '備份清理警告:{count}。展開檔案清單查看詳情。',
'sftp.transfers.copyTargetPathSuccess': '已複製目標路徑',
'sftp.transfers.copyTargetPathError': '無法複製目標路徑',
'sftp.transfers.pathConflict.title': '無法開始傳輸',
@@ -969,6 +969,7 @@ 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': '虛擬終端',
+14 -1
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 } from "./editorWindowClient.ts";
import { dockDetachedEditorTab, installEditorWindowSourceListeners, popOutEditorTab, saveDetachedEditorTab, subscribeEditorWindowDirtyCheck } from "./editorWindowClient.ts";
import { registerEditorSftpWriterScoped } from "./editorSftpBridge.ts";
import { releaseEditorTabSaveCoordinator } from "./editorTabSave.ts";
import type { EditorWindowSaveRequest, EditorWindowSaveResult } from "./editorWindowTypes.ts";
@@ -145,3 +145,16 @@ 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(() => {
return bridge.onCheckDirtyEditors((request) => {
let hasDirty = false;
try {
hasDirty = cb() === true;
@@ -208,7 +208,7 @@ export function subscribeEditorWindowDirtyCheck(cb: () => boolean): () => void {
hasDirty = false;
}
try {
bridge.reportDirtyEditorsResult?.(hasDirty);
bridge.reportDirtyEditorsResult?.(hasDirty, undefined, { requestId: request?.requestId });
} catch {
// ignore
}
@@ -91,6 +91,7 @@ test('session actions register and unregister', () => {
createWorkspaceFromSessions: noop,
addSessionToWorkspace: noop,
appendHostToWorkspace: noop,
appendHostToWorkspaceConfirmed: noop,
appendLocalTerminalToWorkspace: noop,
createWorkspaceFromTargets: noop,
updateSplitSizes: noop,
@@ -57,6 +57,7 @@ export type SessionSnapshotActions = Pick<
| 'createWorkspaceFromSessions'
| 'addSessionToWorkspace'
| 'appendHostToWorkspace'
| 'appendHostToWorkspaceConfirmed'
| 'appendLocalTerminalToWorkspace'
| 'createWorkspaceFromTargets'
| 'updateSplitSizes'
@@ -163,6 +163,9 @@ 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,6 +135,34 @@ 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,6 +65,7 @@ export interface FileWatchSyncedEvent {
localPath: string;
remotePath: string;
bytesWritten: number;
warning?: string;
}
export interface FileWatchErrorEvent {
@@ -156,6 +156,10 @@ 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,
+12 -2
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 } | undefined;
type StreamResult = { error?: string; cancelled?: boolean; superseded?: boolean; warning?: string } | undefined;
/** A live waiter owns bounded settlement evidence; persisted history stays compact. */
export async function runTransferAndWaitForOwner(
@@ -37,7 +37,17 @@ 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) return result;
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;
}
for (;;) {
if (shouldAbort()) throw new Error("Transfer cancelled");
if (observation.hasIdentityConflict()) throw new TransferOwnerChangedError("Transfer identity changed while waiting for its owner");
@@ -33,6 +33,85 @@ 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();
@@ -3576,6 +3655,46 @@ 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,6 +152,7 @@ export interface SftpTransferCenterStore {
startedAt?: number;
endedAt?: number;
error?: string;
warning?: string;
transferred?: number;
totalBytes?: number;
speed?: number;
@@ -289,6 +290,12 @@ 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;
}
@@ -2274,6 +2281,15 @@ 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;
@@ -2561,6 +2577,7 @@ 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,
+21
View File
@@ -1,4 +1,5 @@
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 {
@@ -915,6 +916,25 @@ 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((
@@ -1453,6 +1473,7 @@ export const useSessionState = ({
addSessionToWorkspace,
removeSessionFromWorkspace,
appendHostToWorkspace,
appendHostToWorkspaceConfirmed,
appendLocalTerminalToWorkspace,
updateSplitSizes,
splitSession,
@@ -0,0 +1,85 @@
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,8 +1,13 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import React, { startTransition, useState } from 'react';
import { flushSync } from 'react-dom';
import { createRoot, type Root } from 'react-dom/client';
import { JSDOM } from 'jsdom';
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';
@@ -130,3 +135,155 @@ 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('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,55 +13,138 @@ 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 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);
sidePanelOpenTabsRef.current = sidePanelOpenTabs;
sidePanelLayoutsRef.current = 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. Functional updates still merge onto queued
// state when the ref and the committed map have diverged.
const renderedOpenTabsRef = useRef(sidePanelOpenTabs);
const renderedLayoutsRef = useRef(sidePanelLayouts);
if (sidePanelOpenTabsRef.current === renderedOpenTabsRef.current) {
sidePanelOpenTabsRef.current = sidePanelOpenTabs;
}
renderedOpenTabsRef.current = sidePanelOpenTabs;
if (sidePanelLayoutsRef.current === renderedLayoutsRef.current) {
sidePanelLayoutsRef.current = sidePanelLayouts;
}
renderedLayoutsRef.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.
// 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.
useEffect(() => {
const base = sidePanelLayoutsRef.current;
const reconciled = reconcileSidePanelLayouts(base, sidePanelOpenTabs);
if (!reconciled) return;
sidePanelLayoutsRef.current = reconciled;
setSidePanelLayouts((previous) => {
let changed = false;
const next = new Map(previous);
for (const tabId of previous.keys()) {
if (!sidePanelOpenTabs.has(tabId)) {
next.delete(tabId);
changed = true;
}
const next = previous === base
? reconciled
: reconcileSidePanelLayouts(previous, sidePanelOpenTabs);
if (!next) {
sidePanelLayoutsRef.current = previous;
return previous;
}
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;
sidePanelLayoutsRef.current = next;
return next;
});
}, [sidePanelOpenTabs]);
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);
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);
sidePanelLayoutsRef.current = layouts;
sidePanelOpenTabsRef.current = openTabs;
setSidePanelLayouts(layouts);
setSidePanelOpenTabs(openTabs);
setSidePanelLayouts((previous) => {
const next = previous === layoutBase
? layouts
: (openTab === null ? deleteMapEntry(previous, tabId) : replaceMapEntry(previous, tabId, layout));
sidePanelLayoutsRef.current = next;
return next;
});
setSidePanelOpenTabs((previous) => {
const next = previous === openTabBase
? openTabs
: (openTab === null ? deleteMapEntry(previous, tabId) : replaceMapEntry(previous, tabId, openTab));
sidePanelOpenTabsRef.current = next;
return next;
});
}, []);
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;
@@ -97,25 +180,23 @@ export function useTerminalSidePanelLayoutState() {
return false;
}
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);
commitMap(tabId, layout, null);
return true;
}, [commitLayout]);
}, [commitLayout, commitMap]);
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 layouts = new Map(sidePanelLayoutsRef.current).set(tabId, nextLayout);
const base = sidePanelLayoutsRef.current;
const layouts = replaceMapEntry(base, tabId, nextLayout);
sidePanelLayoutsRef.current = layouts;
setSidePanelLayouts(layouts);
setSidePanelLayouts((previous) => {
const next = previous === base ? layouts : replaceMapEntry(previous, tabId, nextLayout);
sidePanelLayoutsRef.current = next;
return next;
});
}, []);
return {
@@ -129,5 +210,6 @@ 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) => current.openHost!(host, isExternalMcpCall)
? (host, isExternalMcpCall, options) => current.openHost!(host, isExternalMcpCall, options)
: undefined,
findExistingSessionForHost: current.findExistingSessionForHost,
closeSession: current.closeSession
@@ -25,7 +25,7 @@ test("close-path connection log updates await the indexedDB persist task", () =>
source,
/const updateConnectionLog = useCallback\([\s\S]*?return commitConnectionLogs\(/,
);
assert.match(source, /rebaseQueuedConnectionLogs\(\s*next,\s*removedIds,\s*changedIds,/);
assert.match(source, /rebaseQueuedConnectionLogs\(\s*memory,\s*removedIds,\s*changedIds,/);
assert.match(
source,
/if \(!options\?\.pruneMainBlob\) \{[\s\S]*?rebaseQueuedConnectionLogs\(/,
+107 -30
View File
@@ -62,7 +62,8 @@ import {
} from "../../domain/globalHistory";
import {
buildTerminalDataMapFromLogs,
mergeConnectionLogsFromStorage,
acknowledgePendingConnectionLogWrites,
mergeConnectionLogsWithPendingMutations,
mergeTerminalDataIntoLogs,
mergeTerminalDataMapsForStorage,
pruneInMemoryUnsavedTerminalData,
@@ -70,6 +71,7 @@ import {
retainNewestUnsavedReplays,
pruneTerminalDataMapForStorage,
type ConnectionLogTerminalDataMap,
type PendingConnectionLogWrite,
} from "../../domain/connectionLogTerminalData";
import {
tryLoadAll as tryLoadConnectionLogTerminalData,
@@ -429,6 +431,8 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
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);
@@ -518,17 +522,23 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
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 = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, prunedMain);
mainPersisted = writeMain(prunedMain);
if (!mainPersisted && unsavedTerminalDataPending) {
mainPersisted = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, logs);
mainPersisted = writeMain(logs);
}
}
let sidePersisted = true;
let sidePersisted = false;
if (mainPersisted || !options?.pruneMainBlob) {
sidePersisted = await syncConnectionLogTerminalDataMap(logs, {
usePersistedLogIdsFromDisk: Boolean(options?.pruneMainBlob && mainPersisted),
@@ -545,11 +555,16 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
logs,
options?.removedIds ?? new Set<string>(),
options?.changedIds ?? new Set<string>(),
connectionLogsRef.current,
mergeConnectionLogsWithPendingMutations(
connectionLogsRef.current,
freshLogs,
connectionLogTerminalDataPersistedRef.current,
connectionLogPendingRef.current,
),
freshLogs,
)
: logs;
mainPersisted = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, toWrite);
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,
@@ -560,12 +575,12 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
let fallbackLogs = freshLogs
? mergeTerminalDataIntoLogs(freshLogs, buildTerminalDataMapFromLogs(logs))
: logs;
sidePersisted = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, fallbackLogs);
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 = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, fallbackLogs);
sidePersisted = writeMain(fallbackLogs);
}
}
@@ -578,7 +593,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
"[useVaultState] Failed to persist connection log terminal replay data side store.",
);
}
return mainPersisted && sidePersisted;
return { metadataPersisted: mainPersisted, replayPersisted: sidePersisted, mainReplay };
}, [syncConnectionLogTerminalDataMap]);
const commitConnectionLogs = useCallback((
@@ -589,28 +604,81 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
const next = recipe(prev);
const prevById = new Map(prev.map((log) => [log.id, log]));
const nextIds = new Set<string>();
const changedIds = 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) changedIds.add(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 });
}
}
const removedIds = new Set(prev.filter((log) => !nextIds.has(log.id)).map((log) => log.id));
connectionLogsRef.current = next;
setConnectionLogs(next);
// The task may run after another window writes. Rebase onto the logs that
// exist then. Restore only ids this recipe added or replaced.
return enqueueTerminalDataTask(() => persistConnectionLogState(
rebaseQueuedConnectionLogs(
next,
removedIds,
changedIds,
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,
localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS),
),
{ ...options, removedIds, changedIds },
));
}, [enqueueTerminalDataTask, persistConnectionLogState]);
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[],
@@ -618,7 +686,12 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
terminalDataMap: ConnectionLogTerminalDataMap,
) => {
connectionLogTerminalDataRef.current = terminalDataMap;
return mergeConnectionLogsFromStorage(prev, storedLogs, terminalDataMap);
return mergeConnectionLogsWithPendingMutations(
prev,
storedLogs,
terminalDataMap,
connectionLogPendingRef.current,
);
}, []);
// Encrypt outside the lock, then under the lock prune script bindings against
@@ -2077,7 +2150,10 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
}
if (key === STORAGE_KEY_CONNECTION_LOGS) {
const next = safeParse<ConnectionLog[]>(event.newValue) ?? [];
// 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,
@@ -2123,7 +2199,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
if (!syncConnectionLogTerminalData) return;
if (raw === connectionLogTerminalDataRevRawRef.current) return;
const seq = ++connectionLogTerminalDataLoadSeq.current;
void terminalDataReadyRef.current.then(() => enqueueTerminalDataTask(async () => {
void terminalDataReadyRef.current.then(() => enqueueTerminalDataTask(() => withVaultImportLock("connection-logs", async () => {
if (seq !== connectionLogTerminalDataLoadSeq.current) return;
const map = await loadConnectionLogTerminalData();
if (seq !== connectionLogTerminalDataLoadSeq.current) return;
@@ -2133,14 +2209,15 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
);
connectionLogTerminalDataPersistedRef.current = map;
connectionLogTerminalDataRef.current = map;
const merged = mergeConnectionLogsFromStorage(
connectionLogsRef.current,
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) => {
@@ -0,0 +1,59 @@
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]");
});
+11 -1
View File
@@ -37,6 +37,7 @@ 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. */
@@ -350,7 +351,9 @@ 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" />
<span className="shrink-0 font-mono text-muted-foreground">{detail}</span>
{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>}
{task.status === "transferring" && <Loader2 size={10} className="shrink-0 animate-spin text-primary" />}
</div>
);
@@ -658,6 +661,13 @@ 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
+3 -2
View File
@@ -270,8 +270,8 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
&& state.entries.length === 0;
const showNoGlobalHistory = scope === 'global' && globalEntries.length === 0;
if (!isVisible) return null;
// Stay mounted when the side panel parks this tree. Returning null here
// would remount the virtual list on every Notes <-> History switch.
return (
<div
className="h-full flex flex-col bg-background overflow-hidden"
@@ -394,6 +394,7 @@ 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}`}
+105 -96
View File
@@ -8,6 +8,7 @@ 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';
@@ -111,7 +112,7 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
if (!selectedPort) return;
const config: SerialConfig = {
path: selectedPort,
path: selectedPort.trim(),
baudRate,
dataBits,
stopBits,
@@ -129,7 +130,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,
hostname: selectedPort.trim(),
// For serial hosts, port field stores baud rate as a numeric identifier.
// The full configuration is stored in serialConfig for actual connection.
port: baudRate,
@@ -157,18 +158,15 @@ 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 isPortValid =
trimmedPort.startsWith('/dev/') ||
/^COM\d+$/i.test(trimmedPort) ||
/^\\\\\.\\COM\d+$/i.test(trimmedPort);
const isNamedPipe = isWindowsNamedPipePath(trimmedPort);
const isPortValid = isSerialEndpointPath(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 && isBaudRateValid;
const isValid = isPortValid && (isNamedPipe || isBaudRateValid);
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
@@ -212,6 +210,9 @@ 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')}
@@ -219,32 +220,34 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
)}
</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>
{/* 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>
)}
{/* Advanced Options */}
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
@@ -264,92 +267,98 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-2">
{/* Data Bits */}
<div className="grid grid-cols-2 gap-4">
{/* 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 */}
<div className="space-y-2">
<Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label>
<Label htmlFor="parity">{t('serial.field.parity')}</Label>
<Select
value={String(dataBits)}
onValueChange={(v) => setDataBits(parseInt(v, 10) as 5 | 6 | 7 | 8)}
value={parity}
onValueChange={(v) => setParity(v as SerialParity)}
>
<SelectTrigger id="data-bits">
<SelectTrigger id="parity">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATA_BITS.map((bits) => (
<SelectItem key={bits} value={String(bits)}>
{bits}
{PARITY_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{t(`serial.parity.${option}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Stop Bits */}
{/* Flow Control */}
<div className="space-y-2">
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
<Select
value={String(stopBits)}
onValueChange={(v) => setStopBits(parseFloat(v) as 1 | 1.5 | 2)}
value={flowControl}
onValueChange={(v) => setFlowControl(v as SerialFlowControl)}
>
<SelectTrigger id="stop-bits">
<SelectTrigger id="flow-control">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STOP_BITS.map((bits) => (
<SelectItem key={bits} value={String(bits)}>
{bits}
{FLOW_CONTROL_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{t(`serial.flowControl.${option}`)}
</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">
@@ -0,0 +1,34 @@
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"/);
});
+107 -96
View File
@@ -12,6 +12,8 @@ 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';
@@ -91,7 +93,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.hostname || initialData.serialConfig?.path || '');
const [selectedPort, setSelectedPort] = useState(initialData.serialConfig?.path || initialData.hostname || '');
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);
@@ -131,7 +133,7 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
if (!selectedPort) return;
const config: SerialConfig = {
path: selectedPort,
path: selectedPort.trim(),
baudRate,
dataBits,
stopBits,
@@ -151,7 +153,7 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
const updatedHost: Host = {
...initialData,
label: label.trim() || `Serial: ${portName}`,
hostname: selectedPort,
hostname: selectedPort.trim(),
port: baudRate,
username: username.trim() || undefined,
// An empty password is meaningful for serial auto-login: the detector
@@ -206,12 +208,10 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
// Validation
const trimmedPort = selectedPort.trim();
const isPortValid =
trimmedPort.startsWith('/dev/') ||
/^COM\d+$/i.test(trimmedPort) ||
/^\\\\\.\\COM\d+$/i.test(trimmedPort);
const isNamedPipe = isWindowsNamedPipePath(trimmedPort);
const isPortValid = isSerialEndpointPath(trimmedPort);
const isBaudRateValid = Number.isInteger(baudRate) && baudRate > 0;
const isValid = isPortValid && isBaudRateValid;
const isValid = isPortValid && (isNamedPipe || isBaudRateValid);
// Check if using 1.5 stop bits (limited Windows support)
const isStopBits15 = stopBits === 1.5;
@@ -265,6 +265,9 @@ 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')}
@@ -272,32 +275,34 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
)}
</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>
{/* 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>
)}
{/* Login credentials (auto-login) */}
<div className="space-y-2">
@@ -408,92 +413,98 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-2">
{/* Data Bits */}
<div className="grid grid-cols-2 gap-4">
{/* 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 */}
<div className="space-y-2">
<Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label>
<Label htmlFor="parity">{t('serial.field.parity')}</Label>
<Select
value={String(dataBits)}
onValueChange={(v) => setDataBits(parseInt(v, 10) as 5 | 6 | 7 | 8)}
value={parity}
onValueChange={(v) => setParity(v as SerialParity)}
>
<SelectTrigger id="data-bits">
<SelectTrigger id="parity">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATA_BITS.map((bits) => (
<SelectItem key={bits} value={String(bits)}>
{bits}
{PARITY_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{t(`serial.parity.${option}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Stop Bits */}
{/* Flow Control */}
<div className="space-y-2">
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
<Select
value={String(stopBits)}
onValueChange={(v) => setStopBits(parseFloat(v) as 1 | 1.5 | 2)}
value={flowControl}
onValueChange={(v) => setFlowControl(v as SerialFlowControl)}
>
<SelectTrigger id="stop-bits">
<SelectTrigger id="flow-control">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STOP_BITS.map((bits) => (
<SelectItem key={bits} value={String(bits)}>
{bits}
{FLOW_CONTROL_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{t(`serial.flowControl.${option}`)}
</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">
+3 -2
View File
@@ -265,9 +265,10 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
}, [activeHost, activeSessionId, resolveTransferSourceSessionId, sessions]);
const fileWatchHandlers = useMemo(() => ({
onFileWatchSynced: (payload: { remotePath: string }) => {
onFileWatchSynced: (payload: { remotePath: string; warning?: string }) => {
const fileName = payload.remotePath.split('/').pop() || payload.remotePath;
toast.success(t('sftp.autoSync.success', { fileName }));
if (payload.warning) toast.warning(payload.warning);
else toast.success(t('sftp.autoSync.success', { fileName }));
logger.info("[SFTP] File auto-synced to remote", payload);
},
onFileWatchError: (payload: { error: string }) => {
+21
View File
@@ -52,6 +52,27 @@ 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,
+3 -2
View File
@@ -119,9 +119,10 @@ 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 }) => {
onFileWatchSynced: (payload: { remotePath: string; warning?: string }) => {
const fileName = payload.remotePath.split('/').pop() || payload.remotePath;
toast.success(t('sftp.autoSync.success', { fileName }));
if (payload.warning) toast.warning(payload.warning);
else toast.success(t('sftp.autoSync.success', { fileName }));
logger.info("[SFTP] File auto-synced to remote", payload);
},
onFileWatchError: (payload: { error: string }) => {
+3 -6
View File
@@ -319,6 +319,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
splitPane: splitSidePanelPaneForTab,
closePane: closeSidePanelPaneForTab,
resizeSplit: resizeSidePanelSplitForTab,
selectSidePanelToolForTab,
} = useTerminalSidePanelLayoutState();
useEffect(() => {
pruneComposeBarHistory(sessions.map((session) => session.id));
@@ -1752,13 +1753,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
if (!prepareSidePanelTool(tabId, tab)) return;
startTransition(() => {
setSidePanelOpenTabs(prev => {
const next = new Map(prev);
next.set(tabId, tab);
return next;
});
selectSidePanelToolForTab(tabId, tab);
});
}, [prepareSidePanelTool, setSidePanelOpenTabs, sidePanelOpenTabsRef]);
}, [prepareSidePanelTool, selectSidePanelToolForTab, sidePanelOpenTabsRef]);
const handleFocusSidePanelPane = useCallback((paneId: string) => {
const tabId = activeTabIdRef.current;
+69
View File
@@ -281,6 +281,75 @@ test("duplicate double-click behavior falls back to copying when unavailable", (
assert.deepEqual(copiedSessionIds, ["session-1"]);
});
test("draggable top-tab close buttons cancel the drag gesture on mousedown", () => {
const closeButtonLabels = [
"tabs.closePluginViewAria",
"Close editor tab",
"tabs.closeSessionAria",
"tabs.closeLogViewAria",
];
for (const label of closeButtonLabels) {
const labelIndex = topTabItemsSource.indexOf(label);
assert.notEqual(labelIndex, -1, `missing close label ${label}`);
const buttonStart = topTabItemsSource.lastIndexOf("<button", labelIndex);
const buttonEnd = topTabItemsSource.indexOf(">", labelIndex);
const openingTag = topTabItemsSource.slice(buttonStart, buttonEnd);
assert.match(
openingTag,
/onMouseDown=\{handleTabCloseButtonMouseDown\}/,
`${label} close button must guard mousedown`,
);
}
assert.equal(
topTabItemsSource.match(/onMouseDown=\{handleTabCloseButtonMouseDown\}/g)?.length,
4,
);
const workspaceSource = topTabItemsSource.slice(
topTabItemsSource.indexOf("export const WorkspaceTopTab"),
topTabItemsSource.indexOf("WorkspaceTopTab.displayName"),
);
assert.equal(workspaceSource.includes("handleTabCloseButtonMouseDown"), false);
assert.equal(workspaceSource.includes("<button"), false);
});
test("draggable top tab roots activate sub-threshold presses and still reorder past the threshold", () => {
const tabTypes = ["plugin-view", "editor", "session", "workspace", "logView"];
for (const tabType of tabTypes) {
const marker = `data-tab-type="${tabType}"`;
const start = topTabItemsSource.indexOf(marker);
assert.notEqual(start, -1, marker);
const classNameAt = topTabItemsSource.indexOf("className={cn(", start);
assert.notEqual(classNameAt, -1, tabType);
const openingHandlers = topTabItemsSource.slice(start, classNameAt);
assert.match(openingHandlers, /onMouseDown=\{handleTabDragThresholdMouseDown\}/, tabType);
assert.match(openingHandlers, /onMouseUp=\{\(event\) => handleTabDragThresholdRelease\(event,/, tabType);
assert.match(
openingHandlers,
/onDragStart=\{\(event\) => \{\s*handleTabDragThresholdDragStart\(event\);\s*onTabDragStart\(event,/,
tabType,
);
assert.match(
openingHandlers,
/onDrag=\{\(event\) => \{\s*if \(!handleTabDragThresholdDrag\(event\)\) return;\s*onTabDragStart\(event,/,
tabType,
);
assert.match(
openingHandlers,
/onDragEnd=\{\(event\) => \{\s*handleTabDragThresholdRelease\(event,[\s\S]*?onTabDragEnd\(\);\s*\}\}/,
tabType,
);
assert.equal(openingHandlers.includes("handleTabCloseButtonMouseDown"), false, tabType);
assert.equal(openingHandlers.includes("preventDefault"), false, tabType);
}
assert.equal(topTabItemsSource.match(/onMouseDown=\{handleTabMiddleMouseDown\}/g)?.length ?? 0, 0);
assert.equal(topTabItemsSource.match(/handleTabDragThresholdRelease\(/g)?.length, 10);
});
test("session close button double click stays on the close button", () => {
let stopPropagationCount = 0;
+14 -7
View File
@@ -24,6 +24,7 @@ import { useWindowControls } from '../application/state/useWindowControls';
import { useSettingsChromeStore } from '../application/state/settingsChromeStore';
import { useI18n } from '../application/i18n/I18nProvider';
import { Host, TerminalSession, Workspace } from '../types';
import { isTabDragReorderStarted } from '../lib/tabInteractions';
import { cn } from '../lib/utils';
import { Button } from './ui/button';
import { ContextMenuItem, ContextMenuSeparator } from './ui/context-menu';
@@ -520,14 +521,20 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
]);
const handleTabDragStart = useCallback((e: React.DragEvent, tabId: string) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('tab-reorder-id', tabId);
// Also set session-id for backward compatibility with workspace split functionality
// Only orphan sessions can be dragged to create workspaces
const isOrphanSession = orphanSessionMap.has(tabId);
if (isOrphanSession) {
e.dataTransfer.setData('session-id', tabId);
// Payload must be written on the original dragstart. A slow reorder calls
// this again from `drag`, when DataTransfer is already read-only.
try {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('tab-reorder-id', tabId);
// Also set session-id for backward compatibility with workspace split functionality
// Only orphan sessions can be dragged to create workspaces
if (orphanSessionMap.has(tabId)) {
e.dataTransfer.setData('session-id', tabId);
}
} catch (error) {
if (!(error instanceof DOMException)) throw error;
}
if (!isTabDragReorderStarted()) return;
draggedTabIdRef.current = tabId;
// Use setTimeout to allow the drag image to be captured before we change styles
setTimeout(() => {
+12 -2
View File
@@ -33,6 +33,7 @@ import {
import { Button } from '../ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
import { formatSpeed, formatTransferBytes } from './utils';
import { SftpTransferWarning } from './SftpTransferWarning';
/** Child rows need room for Pause + Cancel (2×24px icons + gap). */
const CHILD_ACTIONS_COLUMN_PX = 56;
@@ -54,6 +55,7 @@ interface SftpTransferItemProps {
canToggleChildren?: boolean;
isExpanded?: boolean;
visibleChildCount?: number;
childWarningCount?: number;
onToggleChildren?: () => void;
onSetNameColumnWidth?: (width: number) => void;
childNameColumnMinWidth?: number;
@@ -124,6 +126,7 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
canToggleChildren = false,
isExpanded = false,
visibleChildCount: _visibleChildCount = 0,
childWarningCount = 0,
onToggleChildren,
onSetNameColumnWidth,
childNameColumnMinWidth = 160,
@@ -288,7 +291,8 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
// pause button never looks dead when the backend refuses.
const showPauseUnavailable = !!task.pauseUnavailableReason
&& (task.status === 'transferring' || task.status === 'queued' || task.status === 'pending');
const hasFooterContent = showTransferSizeCalculation || showFailedError || showPauseUnavailable;
const showWarning = task.status === 'completed' && !!task.warning;
const hasFooterContent = showTransferSizeCalculation || showFailedError || showPauseUnavailable || showWarning || childWarningCount > 0;
const retryActionLabel = t('sftp.transfers.retryAction');
const cancelActionLabel = t('common.cancel');
const pauseActionLabel = t('sftp.transferCenter.pause');
@@ -480,7 +484,7 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
<TooltipContent side="top">{resizeNameColumnLabel}</TooltipContent>
</Tooltip>
<div className="min-w-0 overflow-hidden">
{childProgressBar}
{showWarning ? <SftpTransferWarning warning={task.warning} compact /> : childProgressBar}
</div>
<div className="flex h-full min-w-0 items-center justify-end gap-0.5 pl-1">
{actionButtons}
@@ -609,6 +613,10 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
{showPauseUnavailable && (
<span className="text-amber-600 dark:text-amber-400">{task.pauseUnavailableReason}</span>
)}
{showWarning && <SftpTransferWarning warning={task.warning} />}
{childWarningCount > 0 && (
<span className="text-amber-600 dark:text-amber-400">{t('sftp.transfers.cleanupWarnings', { count: childWarningCount })}</span>
)}
</div>
)}
</div>
@@ -631,6 +639,7 @@ const arePropsEqual = (
if (prev.status !== next.status) return false;
if (prev.error !== next.error) return false;
if (prev.warning !== next.warning) return false;
if (prev.pauseUnavailableReason !== next.pauseUnavailableReason) return false;
if (prev.reconnectRequired !== next.reconnectRequired) return false;
if (prev.resumable !== next.resumable) return false;
@@ -647,6 +656,7 @@ const arePropsEqual = (
if ((prevProps.canToggleChildren ?? false) !== (nextProps.canToggleChildren ?? false)) return false;
if ((prevProps.isExpanded ?? false) !== (nextProps.isExpanded ?? false)) return false;
if ((prevProps.visibleChildCount ?? 0) !== (nextProps.visibleChildCount ?? 0)) return false;
if ((prevProps.childWarningCount ?? 0) !== (nextProps.childWarningCount ?? 0)) return false;
if ((prevProps.childNameColumnMinWidth ?? 160) !== (nextProps.childNameColumnMinWidth ?? 160)) return false;
if ((prevProps.childNameColumnMaxWidth ?? 480) !== (nextProps.childNameColumnMaxWidth ?? 480)) return false;
if ((prevProps.childListId ?? '') !== (nextProps.childListId ?? '')) return false;
+1
View File
@@ -423,6 +423,7 @@ export const SftpTransferQueue: React.FC<SftpTransferQueueProps> = ({
canToggleChildren={childTasks.length > 0}
isExpanded={isExpanded}
visibleChildCount={childTasks.length}
childWarningCount={childTasks.filter((child) => child.status === 'completed' && !!child.warning).length}
childListId={childListId}
onToggleChildren={() => toggleExpanded(task.id)}
onCancel={() => {
+21
View File
@@ -0,0 +1,21 @@
import { AlertTriangle } from "lucide-react";
import React from "react";
import { cn } from "../../lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
/** Cleanup warnings are not transfer errors: retain the completed lifecycle. */
export function SftpTransferWarning({ warning, compact = false }: { warning?: string; compact?: boolean }) {
if (!warning) return null;
const content = (
<span className={cn("flex min-w-0 items-start gap-1 text-[10px] text-amber-600 dark:text-amber-400", !compact && "select-text")} data-section="sftp-transfer-warning">
<AlertTriangle size={12} className="mt-0.5 shrink-0" aria-hidden="true" />
<span className={compact ? "truncate" : "break-all"}>{warning}</span>
</span>
);
return compact ? (
<Tooltip>
<TooltipTrigger asChild><span className="block min-w-0" tabIndex={0}>{content}</span></TooltipTrigger>
<TooltipContent className="max-w-md select-text break-all">{warning}</TooltipContent>
</Tooltip>
) : content;
}
@@ -58,6 +58,21 @@ test("toasts multi-file success count", () => {
assert.deepEqual(calls, [{ type: "success", message: "sftp.uploadFiles: 2" }]);
});
test("successful uploads surface cleanup warnings rather than upload failures", () => {
const calls: Array<{ type: string; message: string }> = [];
const warning = "Upload completed; remove backup /remote/.netcatty-backup-a manually.";
reportSftpUploadResults({
results: [{ fileName: "a", success: true, warning }], t,
toast: {
success: (message) => calls.push({ type: "success", message }),
error: (message) => calls.push({ type: "error", message }),
info: (message) => calls.push({ type: "info", message }),
warning: (message) => calls.push({ type: "warning", message }),
},
});
assert.deepEqual(calls, [{ type: "warning", message: warning }, { type: "success", message: "sftp.upload: a" }]);
});
test("terminal drop success names the exact remote destination", () => {
const calls: Array<{ type: string; message: string }> = [];
reportSftpUploadResults({
@@ -7,6 +7,7 @@ export type ReportSftpUploadResultsOptions = {
success: (message: string, title?: string) => void;
error: (message: string, title?: string) => void;
info: (message: string, title?: string) => void;
warning?: (message: string, title?: string) => void;
};
/** Optional single-item success message override (e.g. folder name). */
successMessage?: string;
@@ -28,6 +29,11 @@ export function reportSftpUploadResults({
const withTargetPath = (message: string): string => (
targetPath ? `${message} (${targetPath})` : message
);
for (const result of results) {
if (result.success && result.warning) {
(toast.warning ?? toast.info)(result.warning, "SFTP");
}
}
if (results.some((result) => result.cancelled)) {
toast.info(t("sftp.upload.cancelled"), "SFTP");
return;
@@ -417,3 +417,19 @@ for (const sleepBeforeCompletion of [true, false]) {
assert.deepEqual(writes, ['show version\r']);
});
}
test("named pipe sessions preserve the endpoint and omit hardware baud information", async () => {
const pipePath = String.raw`\\.\pipe\vm-console`;
const output: string[] = [];
let capturedPath: unknown;
const backend = buildBackend({ startSerialSession: async (options: Record<string, unknown>) => {
capturedPath = options.path;
return "pipe-session";
} });
const ctx = buildCtx(backend);
ctx.serialConfig.path = pipePath;
await createTerminalSessionStarters(ctx as never).startSerial({ ...term, write: (line: string, done?: () => void) => { output.push(line); done?.(); } } as never);
assert.equal(capturedPath, pipePath);
assert.ok(output.some((line) => line.includes(`[Connected to ${pipePath}]`)));
assert.ok(output.every((line) => !line.includes('baud')));
});
@@ -1,3 +1,4 @@
import { isWindowsNamedPipePath } from '../../../domain/serialEndpoint';
import type { Terminal as XTerm } from "@xterm/xterm";
import type { ProviderValidationIssue } from "@netcatty/plugin-contract";
import { logger } from "../../../lib/logger";
@@ -2172,7 +2173,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
autoLoginAttached = true;
ctx.updateStatus("connected");
ctx.setProgressValue(100);
writeTerminalLine(ctx, term, `[Connected to ${ctx.serialConfig.path} at ${ctx.serialConfig.baudRate} baud]`);
writeTerminalLine(ctx, term, isWindowsNamedPipePath(ctx.serialConfig.path)
? `[Connected to ${ctx.serialConfig.path}]`
: `[Connected to ${ctx.serialConfig.path} at ${ctx.serialConfig.baudRate} baud]`);
if (waitsForAutoLogin) {
if (autoLoginCompletedBeforeAttach) {
@@ -7,8 +7,26 @@ import {
resolveConnectionLogCapturePayload,
resolveHibernateSnapshotCapturePayload,
scheduleTerminalCloseTeardown,
scheduleTerminalCloseForGeneration,
} from "./terminalCloseCapture.ts";
test("pending boot cleanup does not close a remounted terminal generation", async () => {
let generation = 1;
let closes = 0;
scheduleTerminalCloseForGeneration(1, () => generation, () => { closes += 1; });
generation = 2;
await new Promise<void>((resolve) => setTimeout(resolve, 0));
assert.equal(closes, 0);
});
test("pending boot cleanup still closes a terminal that stays unmounted", async () => {
let closes = 0;
scheduleTerminalCloseForGeneration(1, () => 1, () => { closes += 1; });
assert.equal(closes, 0);
await new Promise<void>((resolve) => setTimeout(resolve, 0));
assert.equal(closes, 1);
});
test("resolveConnectionLogCapturePayload returns null when finalize produces empty data", () => {
assert.equal(
resolveConnectionLogCapturePayload(() => ""),
@@ -28,6 +28,18 @@ export function isTerminalCloseGenerationCurrent(
return closeGeneration === currentGeneration;
}
export function scheduleTerminalCloseForGeneration(
closeGeneration: number,
getCurrentGeneration: () => number,
teardown: () => void,
): void {
scheduleTerminalCloseTeardown(() => {
if (isTerminalCloseGenerationCurrent(closeGeneration, getCurrentGeneration())) {
teardown();
}
});
}
export function resolveConnectionLogCapturePayload(
finalizeTerminalLogData: () => string,
): TerminalCloseCapturePayload | null {
+18 -12
View File
@@ -63,6 +63,7 @@ import {
isTerminalCloseGenerationCurrent,
resolveConnectionLogCapturePayload,
scheduleTerminalCloseTeardown,
scheduleTerminalCloseForGeneration,
serializeTerminalCloseFallback,
} from './runtime/terminalCloseCapture';
import {
@@ -930,23 +931,28 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
// A boot that never reached "connected" has nothing worth capturing, and
// the teardown below awaits terminal drain plus serialization before it
// closes anything. Tell the main process now so it can abort the pending
// boot (passphrase prompt, TCP dial) for this epoch instead of letting it
// closes anything. Cancel the pending backend boot after giving an immediate
// remount a chance to replace this generation, instead of letting it
// finish against a pane that is already gone. Attached popups never own
// the backend session (skip closeSession) but still sync-dispose xterm.
if (!hasConnectedRef.current) {
if (!attachExistingSession) {
try {
const closeResult = terminalBackend.closeSession(
sessionRef.current ?? sessionId,
{ bootEpoch: bootEpochRef ? bootEpochRef.current : undefined },
);
void Promise.resolve(closeResult).catch((err: unknown) => {
const closingBootEpoch = bootEpochRef ? bootEpochRef.current : undefined;
// StrictMode can remount this pane before a backend boot starts.
// Abort/dispose immediately, but do not report that remount as a tab close.
scheduleTerminalCloseForGeneration(closeGeneration, () => terminalBootCloseGenerationRef.current, () => {
try {
const closeResult = terminalBackend.closeSession(
sessionRef.current ?? sessionId,
{ bootEpoch: closingBootEpoch },
);
void Promise.resolve(closeResult).catch((err: unknown) => {
logger.warn("Failed to cancel pending terminal boot on unmount", err);
});
} catch (err) {
logger.warn("Failed to cancel pending terminal boot on unmount", err);
});
} catch (err) {
logger.warn("Failed to cancel pending terminal boot on unmount", err);
}
}
});
}
disposeOwnedRuntime();
return;
@@ -0,0 +1,446 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { describe, test } from 'node:test';
import React, { act, useLayoutEffect } from 'react';
import {
publishShellHistorySnapshot,
} from '../../application/state/shellHistoryStore.ts';
import {
SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT,
sidePanelLiveStore,
} from '../../application/state/sidePanelLiveStore.ts';
import type { ShellHistoryEntry } from '../../domain/models.ts';
import { HistorySidePanel } from '../HistorySidePanel.tsx';
import {
createDomRenderer,
dispatchDomEvent,
installDomEnvironment,
} from '../test-support/renderReactDom.tsx';
import { SidePanelHistorySlot } from './terminalLayerSidePanelSlots.tsx';
const slotsSource = readFileSync(new URL('./terminalLayerSidePanelSlots.tsx', import.meta.url), 'utf8');
const historySource = readFileSync(new URL('../HistorySidePanel.tsx', import.meta.url), 'utf8');
const listSource = readFileSync(new URL('../ui/VariableSizeVirtualList.tsx', import.meta.url), 'utf8');
function historySlotSource(): string {
const start = slotsSource.indexOf('function SidePanelHistorySlotInner');
const end = slotsSource.indexOf('export const SidePanelHistorySlot');
assert.ok(start >= 0 && end > start);
return slotsSource.slice(start, end);
}
test('history keep-alive source stays mounted and gates the shell-history subscription', () => {
const slot = historySlotSource();
assert.doesNotMatch(slot, /if \(!isVisible\) return null/);
assert.match(slot, /sidePanelHiddenPanelClassName\(!isVisible\)/);
assert.match(slot, /isVisible \? subscribeShellHistory : subscribeShellHistoryNoop/);
assert.match(slot, /getShellHistorySnapshot,\s*getShellHistorySnapshot/);
assert.doesNotMatch(slot, /getEmptyShellHistorySnapshot/);
assert.equal(slot.match(/useRemoteHistoryState\(\)/g)?.length, 1);
assert.match(slot, /isVisible=\{isVisible\}/);
assert.match(slot, /retainedLiveRef/);
assert.match(slot, /live !== SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT/);
assert.doesNotMatch(historySource, /if \(!isVisible\) return null/);
assert.match(historySource, /measureEnabled=\{isVisible\}/);
assert.match(listSource, /remeasure/);
assert.match(listSource, /scrollToIndex\(index, 'auto'\)/);
assert.match(listSource, /if \(!measureEnabledRef\.current && next === 0\) return/);
});
const hostA = {
id: 'host-a',
label: 'a',
hostname: 'a.example',
username: 'root',
tags: [] as string[],
os: 'linux' as const,
protocol: 'ssh' as const,
};
const hostB = { ...hostA, id: 'host-b', label: 'b', hostname: 'b.example' };
function shellEntry(id: string, command: string): ShellHistoryEntry {
return {
id,
command,
hostId: hostA.id,
hostLabel: hostA.label,
sessionId: 'sess-1',
timestamp: 1,
};
}
function liveSnapshot(host: typeof hostA) {
return {
...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT,
focusedHost: host,
focusedSessionId: 'tab-1',
historySessionId: 'sess-1',
};
}
let probeRenders = 0;
function HistoryProbe(props: {
focusedHost?: { id?: string } | null;
globalEntries?: Array<{ command?: string }>;
isVisible?: boolean;
}) {
probeRenders += 1;
return React.createElement('div', {
'data-section': 'history-probe',
'data-host': props.focusedHost?.id ?? '',
'data-count': String(props.globalEntries?.length ?? 0),
'data-visible': String(!!props.isVisible),
'data-command': (props.globalEntries ?? []).map((entry) => entry.command ?? '').join('|'),
});
}
const historySlotCtx = {
HistorySidePanel: HistoryProbe,
handleHistoryPaste() {},
handleHistoryDelete() {},
handleHistoryClear() {},
handleHistoryRun() {},
};
function resetHistoryStores() {
publishShellHistorySnapshot([]);
sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT });
}
describe('history side panel keep-alive', { concurrency: false }, () => {
test('hidden history stays mounted and shell-history publishes do not render it', async (t) => {
resetHistoryStores();
probeRenders = 0;
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
t.after(async () => {
await renderer.unmount();
dom.cleanup();
resetHistoryStores();
});
sidePanelLiveStore.update(liveSnapshot(hostA));
publishShellHistorySnapshot([shellEntry('1', 'ls')]);
const renderSlot = (isVisible: boolean) => renderer.render(React.createElement(SidePanelHistorySlot, {
activeTabId: 'tab-1',
ctx: historySlotCtx,
isVisible,
}));
await renderSlot(true);
const probe = () => dom.document.querySelector('[data-section="history-probe"]') as HTMLElement | null;
const visibleProbe = probe();
assert.ok(visibleProbe);
visibleProbe.dataset.keepalive = 'yes';
assert.equal(visibleProbe.dataset.host, 'host-a');
assert.equal(visibleProbe.dataset.count, '1');
assert.equal(visibleProbe.dataset.visible, 'true');
assert.doesNotMatch(visibleProbe.parentElement?.className ?? '', /\bhidden\b/);
await act(async () => {
publishShellHistorySnapshot([shellEntry('1', 'ls'), shellEntry('2', 'pwd')]);
});
assert.equal(probe()?.dataset.count, '2');
const rendersAfterVisiblePublish = probeRenders;
await renderSlot(false);
const parked = probe();
assert.ok(parked);
assert.equal(parked.dataset.keepalive, 'yes');
assert.equal(parked.dataset.host, 'host-a');
assert.equal(parked.dataset.count, '2');
assert.equal(parked.dataset.command, 'ls|pwd');
assert.match(parked.parentElement?.className ?? '', /\bhidden\b/);
const rendersWhileParked = probeRenders;
await act(async () => {
publishShellHistorySnapshot([
shellEntry('1', 'ls'),
shellEntry('2', 'pwd'),
shellEntry('3', 'whoami'),
]);
sidePanelLiveStore.update(liveSnapshot(hostB));
});
assert.equal(probeRenders, rendersWhileParked);
assert.equal(probe()?.dataset.count, '2');
assert.equal(probe()?.dataset.command, 'ls|pwd');
assert.equal(probe()?.dataset.host, 'host-a');
assert.ok(probeRenders >= rendersAfterVisiblePublish);
await renderSlot(true);
assert.equal(probe()?.dataset.keepalive, 'yes');
assert.equal(probe()?.dataset.count, '3');
assert.equal(probe()?.dataset.command, 'ls|pwd|whoami');
assert.equal(probe()?.dataset.host, 'host-b');
assert.equal(probe()?.dataset.visible, 'true');
assert.doesNotMatch(probe()?.parentElement?.className ?? '', /\bhidden\b/);
});
test('showing history again remeasures the virtual list without remounting or refetching', async (t) => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
const heightBox = { value: 300 };
const resizeCallbacks: ResizeObserverCallback[] = [];
const previousObserver = globalThis.ResizeObserver;
const previousClientHeight = Object.getOwnPropertyDescriptor(dom.window.HTMLElement.prototype, 'clientHeight');
Object.defineProperty(dom.window.HTMLElement.prototype, 'clientHeight', {
configurable: true,
get() {
return heightBox.value;
},
});
class ResizeObserverStub implements ResizeObserver {
constructor(callback: ResizeObserverCallback) {
resizeCallbacks.push(callback);
}
observe() {}
unobserve() {}
disconnect() {}
}
globalThis.ResizeObserver = ResizeObserverStub;
dom.window.ResizeObserver = ResizeObserverStub;
const elementProto = dom.window.HTMLElement.prototype;
elementProto.scrollIntoView ??= () => {};
let fetches = 0;
const onFetch = () => {
fetches += 1;
};
const remoteEntries = Array.from({ length: 40 }, (_, index) => ({
id: `e-${index}`,
command: `echo ${index}`,
source: 'bash' as const,
}));
const host = hostA;
const panelProps = (isVisible: boolean, fetchedAt: number | null) => React.createElement(HistorySidePanel, {
focusedHost: host,
focusedSessionId: 'sess-1',
state: {
entries: remoteEntries,
loading: false,
error: null,
fetchedAt,
},
globalEntries: [],
onFetch,
onPasteToTerminal: () => {},
onRunInTerminal: () => {},
isVisible,
});
const fireResize = () => {
for (const callback of [...resizeCallbacks]) {
callback([], {} as ResizeObserver);
}
};
const panelOf = () => dom.document.querySelector('[data-section="history-panel"]') as HTMLElement | null;
const rowsOf = () => panelOf()?.querySelectorAll('[role="button"]').length ?? 0;
const scrollerOf = () => panelOf()?.querySelector('.overflow-y-auto') as HTMLDivElement | null;
t.after(async () => {
await renderer.unmount();
globalThis.ResizeObserver = previousObserver;
if (previousClientHeight) {
Object.defineProperty(dom.window.HTMLElement.prototype, 'clientHeight', previousClientHeight);
}
dom.cleanup();
});
await renderer.render(panelProps(true, 1));
await act(async () => {
await Promise.resolve();
});
const panel = panelOf();
assert.ok(panel);
panel.dataset.keepalive = 'yes';
const visibleRows = rowsOf();
assert.ok(visibleRows > 8, `visible window should paint rows, got ${visibleRows}`);
assert.equal(fetches, 0);
const scroller = scrollerOf();
assert.ok(scroller);
await act(async () => {
scroller.scrollTop = 600;
scroller.dispatchEvent(new dom.window.Event('scroll'));
});
const scrolledRows = rowsOf();
assert.ok(scrolledRows > 8);
assert.equal(scroller.scrollTop, 600);
heightBox.value = 0;
await renderer.render(panelProps(false, 1));
await act(async () => {
const parkedScroller = scrollerOf();
assert.ok(parkedScroller);
parkedScroller.scrollTop = 0;
parkedScroller.dispatchEvent(new dom.window.Event('scroll'));
fireResize();
});
assert.equal(panelOf()?.dataset.keepalive, 'yes');
assert.equal(rowsOf(), scrolledRows);
assert.equal(fetches, 0);
heightBox.value = 480;
await renderer.render(panelProps(true, 1));
await act(async () => {
await Promise.resolve();
fireResize();
});
const shown = panelOf();
assert.ok(shown);
assert.equal(shown.dataset.keepalive, 'yes');
const shownScroller = scrollerOf();
assert.ok(shownScroller);
assert.equal(shownScroller.scrollTop, 600);
assert.ok(rowsOf() > scrolledRows, `taller viewport should extend the window, got ${rowsOf()} then ${scrolledRows}`);
assert.equal(fetches, 0);
const input = shown.querySelector('input');
assert.ok(input);
const setValue = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, 'value')?.set;
assert.ok(setValue);
setValue.call(input, 'echo 12');
await dispatchDomEvent(input, new dom.window.Event('input', { bubbles: true }));
assert.equal(input.value, 'echo 12');
await renderer.render(panelProps(false, 1));
await renderer.render(panelProps(true, 1));
const restoredInput = panelOf()?.querySelector('input');
assert.equal(restoredInput?.value, 'echo 12');
assert.equal(panelOf()?.dataset.keepalive, 'yes');
assert.equal(fetches, 0);
});
test('hidden history does not fetch, and showing again does not refetch a cached host', async (t) => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
let fetches = 0;
const onFetch = () => {
fetches += 1;
};
const panelProps = (isVisible: boolean, fetchedAt: number | null) => React.createElement(HistorySidePanel, {
focusedHost: hostA,
focusedSessionId: 'sess-1',
state: {
entries: [],
loading: false,
error: null,
fetchedAt,
},
globalEntries: [],
onFetch,
onPasteToTerminal: () => {},
onRunInTerminal: () => {},
isVisible,
});
t.after(async () => {
await renderer.unmount();
dom.cleanup();
});
await renderer.render(panelProps(false, null));
assert.equal(fetches, 0);
assert.ok(dom.document.querySelector('[data-section="history-panel"]'));
await renderer.render(panelProps(true, null));
assert.equal(fetches, 1);
await renderer.render(panelProps(false, 50));
await renderer.render(panelProps(true, 50));
assert.equal(fetches, 1);
});
test('remeasure waits until a parent layout effect gives the list a real height', async (t) => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
const heightBox = { value: 0 };
const previousObserver = globalThis.ResizeObserver;
const previousClientHeight = Object.getOwnPropertyDescriptor(dom.window.HTMLElement.prototype, 'clientHeight');
class ResizeObserverStub implements ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
globalThis.ResizeObserver = ResizeObserverStub;
dom.window.ResizeObserver = ResizeObserverStub;
Object.defineProperty(dom.window.HTMLElement.prototype, 'clientHeight', {
configurable: true,
get() {
return heightBox.value;
},
});
// Child layout effects run before the parent. Height stays 0 until this
// parent effect, matching the portal move out of the hidden parking host.
function DeferredHeightHarness({ visible }: { visible: boolean }) {
useLayoutEffect(() => {
heightBox.value = visible ? 420 : 0;
}, [visible]);
return React.createElement(HistorySidePanel, {
focusedHost: hostA,
focusedSessionId: 'sess-1',
state: {
entries: Array.from({ length: 40 }, (_, index) => ({
id: `e-${index}`,
command: `echo ${index}`,
source: 'bash' as const,
})),
loading: false,
error: null,
fetchedAt: 1,
},
globalEntries: [],
onFetch: () => {},
onPasteToTerminal: () => {},
onRunInTerminal: () => {},
isVisible: visible,
});
}
const panelOf = () => dom.document.querySelector('[data-section="history-panel"]') as HTMLElement | null;
const rowsOf = () => panelOf()?.querySelectorAll('[role="button"]').length ?? 0;
const scrollerOf = () => panelOf()?.querySelector('.overflow-y-auto') as HTMLDivElement | null;
t.after(async () => {
await renderer.unmount();
globalThis.ResizeObserver = previousObserver;
if (previousClientHeight) {
Object.defineProperty(dom.window.HTMLElement.prototype, 'clientHeight', previousClientHeight);
}
dom.cleanup();
});
await renderer.render(React.createElement(DeferredHeightHarness, { visible: true }));
await act(async () => {
await Promise.resolve();
});
assert.ok(rowsOf() > 8, `deferred remeasure should paint rows, got ${rowsOf()}`);
const panel = panelOf();
assert.ok(panel);
panel.dataset.keepalive = 'yes';
const scroller = scrollerOf();
assert.ok(scroller);
await act(async () => {
scroller.scrollTop = 600;
scroller.dispatchEvent(new dom.window.Event('scroll'));
});
const scrolledRows = rowsOf();
await renderer.render(React.createElement(DeferredHeightHarness, { visible: false }));
await act(async () => {
const parked = scrollerOf();
assert.ok(parked);
parked.scrollTop = 0;
parked.dispatchEvent(new dom.window.Event('scroll'));
});
assert.equal(panelOf()?.dataset.keepalive, 'yes');
assert.equal(rowsOf(), scrolledRows);
heightBox.value = 0;
await renderer.render(React.createElement(DeferredHeightHarness, { visible: true }));
await act(async () => {
await Promise.resolve();
});
assert.equal(panelOf()?.dataset.keepalive, 'yes');
assert.equal(scrollerOf()?.scrollTop, 600);
assert.ok(rowsOf() > 8);
});
});
@@ -47,9 +47,7 @@ const navigatorPlatform = typeof navigator !== 'undefined' ? navigator.platform
const EMPTY_VAULT_NOTES: never[] = [];
const EMPTY_VAULT_HOSTS: never[] = [];
const EMPTY_VAULT_SNIPPETS: never[] = [];
const EMPTY_SHELL_HISTORY = Object.freeze([]) as readonly never[];
const subscribeShellHistoryNoop = () => () => {};
const getEmptyShellHistorySnapshot = () => EMPTY_SHELL_HISTORY;
function useSidePanelLiveSnapshotForTab(tabId: string, subscribe: boolean) {
const retainedSnapshot = useRef({ tabId, snapshot: SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT });
@@ -533,14 +531,26 @@ function SidePanelHistorySlotInner({
}) {
const live = useSidePanelLiveSnapshotForTab(activeTabId ?? '', isVisible);
// Own remote-history state here so fetch/loading does not re-render TerminalLayer.
// One instance for the life of the slot; HistorySidePanel fetches only when
// visible and the host cache is still empty.
const remoteHistory = useRemoteHistoryState();
// Gate store subscription while History is hidden so command appends do not
// re-render this retained mount (panel still mounts for fast reopen).
// Subscribe only while visible so command appends do not re-render the parked
// list. Keep reading the live snapshot (same idea as notes) so the hide
// render does not swap in an empty list and unmount rows.
const shellHistory = useSyncExternalStore(
isVisible ? subscribeShellHistory : subscribeShellHistoryNoop,
isVisible ? getShellHistorySnapshot : getEmptyShellHistorySnapshot,
isVisible ? getShellHistorySnapshot : getEmptyShellHistorySnapshot,
getShellHistorySnapshot,
getShellHistorySnapshot,
);
// While hidden the live-store read is the inactive snapshot. Keep the last
// real host/session so search, scroll, and the remote-history cache lookup
// survive parking. Never retain the inactive sentinel itself: a visible
// panel must show `live` (possibly empty) rather than the previous tab.
const retainedLiveRef = useRef(live);
if (isVisible && live !== SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT) {
retainedLiveRef.current = live;
}
const panelLive = isVisible ? live : retainedLiveRef.current;
const {
HistorySidePanel,
@@ -550,21 +560,19 @@ function SidePanelHistorySlotInner({
handleHistoryRun,
} = ctx;
if (!isVisible) return null;
return (
<div className="absolute inset-0 z-10">
<div className={sidePanelHiddenPanelClassName(!isVisible)}>
<HistorySidePanel
focusedHost={live.focusedHost}
focusedSessionId={live.historySessionId}
state={remoteHistory.getState(live.focusedHost?.id, live.historySessionId)}
focusedHost={panelLive.focusedHost}
focusedSessionId={panelLive.historySessionId}
state={remoteHistory.getState(panelLive.focusedHost?.id, panelLive.historySessionId)}
globalEntries={shellHistory as import('../../domain/models').ShellHistoryEntry[]}
onFetch={remoteHistory.fetch}
onDeleteGlobalEntry={handleHistoryDelete}
onClearGlobalHistory={handleHistoryClear}
onPasteToTerminal={handleHistoryPaste}
onRunInTerminal={handleHistoryRun}
isVisible
isVisible={isVisible}
/>
</div>
);
+79 -18
View File
@@ -26,7 +26,7 @@ import { cn } from '../../lib/utils';
import { Host, TerminalSession, Workspace } from '../../types';
import { DISTRO_LOGOS, DISTRO_COLORS } from '../DistroAvatar';
import { getShellIconPath, isMonochromeShellIcon } from '../../lib/useDiscoveredShells';
import { handleTabMiddleClickClose, handleTabMiddleMouseDown } from '../../lib/tabInteractions';
import { handleTabCloseButtonMouseDown, handleTabDragThresholdDrag, handleTabDragThresholdDragStart, handleTabDragThresholdMouseDown, handleTabDragThresholdRelease, handleTabMiddleClickClose } from '../../lib/tabInteractions';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from '../ui/context-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { SessionTabContextMenuContent } from './SessionTabContextMenuContent';
@@ -516,6 +516,9 @@ export const PluginViewTopTab: React.FC<PluginViewTopTabProps> = memo(({
shortcutNumber,
}) => {
const isActive = useIsTabActive(tab.id);
const activate = useCallback(() => {
activeTabStore.setActiveTabId(tab.id);
}, [tab.id]);
const close = useCallback((event?: React.MouseEvent) => {
event?.stopPropagation();
onClose(tab.id);
@@ -526,12 +529,23 @@ export const PluginViewTopTab: React.FC<PluginViewTopTabProps> = memo(({
data-tab-type="plugin-view"
data-state={isActive ? 'active' : 'inactive'}
data-active={topTabActiveValue(isActive)}
onClick={() => activeTabStore.setActiveTabId(tab.id)}
onMouseDown={handleTabMiddleMouseDown}
onClick={activate}
onMouseDown={handleTabDragThresholdMouseDown}
onMouseUp={(event) => handleTabDragThresholdRelease(event, activate)}
onAuxClick={(event) => handleTabMiddleClickClose(event, close)}
draggable
onDragStart={(event) => onTabDragStart(event, tab.id)}
onDragEnd={onTabDragEnd}
onDragStart={(event) => {
handleTabDragThresholdDragStart(event);
onTabDragStart(event, tab.id);
}}
onDrag={(event) => {
if (!handleTabDragThresholdDrag(event)) return;
onTabDragStart(event, tab.id);
}}
onDragEnd={(event) => {
handleTabDragThresholdRelease(event, activate);
onTabDragEnd();
}}
onDragOver={(event) => onTabDragOver(event, tab.id)}
onDragLeave={onTabDragLeave}
onDrop={(event) => onTabDrop(event, tab.id)}
@@ -555,7 +569,7 @@ export const PluginViewTopTab: React.FC<PluginViewTopTabProps> = memo(({
: <PluginContributionIcon pluginId={tab.pluginId} icon={tab.icon} className="shrink-0" />}
<span className="truncate leading-5">{tab.title}</span>
</div>
<button onClick={close} className="flex h-5 w-5 shrink-0 items-center justify-center rounded hover:bg-muted" aria-label={t('tabs.closePluginViewAria', { title: tab.title })}><X size={12} /></button>
<button onMouseDown={handleTabCloseButtonMouseDown} onClick={close} className="flex h-5 w-5 shrink-0 items-center justify-center rounded hover:bg-muted" aria-label={t('tabs.closePluginViewAria', { title: tab.title })}><X size={12} /></button>
</div>
);
return (
@@ -648,11 +662,22 @@ export const EditorTopTab: React.FC<EditorTopTabProps> = memo(({
data-state={isActive ? 'active' : 'inactive'}
data-active={topTabActiveValue(isActive)}
onClick={handleClick}
onMouseDown={handleTabMiddleMouseDown}
onMouseDown={handleTabDragThresholdMouseDown}
onMouseUp={(event) => handleTabDragThresholdRelease(event, handleClick)}
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onRequestCloseEditorTab(editorTab.id))}
draggable
onDragStart={(e) => onTabDragStart(e, tabId)}
onDragEnd={onTabDragEnd}
onDragStart={(event) => {
handleTabDragThresholdDragStart(event);
onTabDragStart(event, tabId);
}}
onDrag={(event) => {
if (!handleTabDragThresholdDrag(event)) return;
onTabDragStart(event, tabId);
}}
onDragEnd={(event) => {
handleTabDragThresholdRelease(event, handleClick);
onTabDragEnd();
}}
onDragOver={(e) => onTabDragOver(e, tabId)}
onDragLeave={onTabDragLeave}
onDrop={(e) => onTabDrop(e, tabId)}
@@ -704,6 +729,7 @@ export const EditorTopTab: React.FC<EditorTopTabProps> = memo(({
{isDetached && <SquareArrowOutUpRight size={11} className="shrink-0 opacity-70" />}
</div>
<button
onMouseDown={handleTabCloseButtonMouseDown}
onClick={handleClose}
className="p-1 rounded-full hover:bg-destructive/10 hover:text-destructive transition-colors"
aria-label="Close editor tab"
@@ -824,11 +850,22 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
data-active={topTabActiveValue(isActive)}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onMouseDown={handleTabMiddleMouseDown}
onMouseDown={handleTabDragThresholdMouseDown}
onMouseUp={(event) => handleTabDragThresholdRelease(event, handleClick)}
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseSession(session.id))}
draggable
onDragStart={(e) => onTabDragStart(e, session.id)}
onDragEnd={onTabDragEnd}
onDragStart={(event) => {
handleTabDragThresholdDragStart(event);
onTabDragStart(event, session.id);
}}
onDrag={(event) => {
if (!handleTabDragThresholdDrag(event)) return;
onTabDragStart(event, session.id);
}}
onDragEnd={(event) => {
handleTabDragThresholdRelease(event, handleClick);
onTabDragEnd();
}}
onDragOver={(e) => onTabDragOver(e, session.id)}
onDragLeave={onTabDragLeave}
onDrop={(e) => onTabDrop(e, session.id)}
@@ -879,6 +916,7 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
<div className="flex-shrink-0">{sessionStatusDot(session.status, hasActivity)}</div>
</div>
<button
onMouseDown={handleTabCloseButtonMouseDown}
onClick={(e) => onCloseSession(session.id, e)}
onDoubleClick={stopCloseButtonDoubleClickPropagation}
className="p-1 rounded-full hover:bg-destructive/10 hover:text-destructive transition-colors"
@@ -1028,11 +1066,22 @@ export const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
data-host-drop-active={isHostDropTarget ? 'true' : 'false'}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onMouseDown={handleTabMiddleMouseDown}
onMouseDown={handleTabDragThresholdMouseDown}
onMouseUp={(event) => handleTabDragThresholdRelease(event, handleClick)}
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseWorkspace(workspace.id))}
draggable
onDragStart={(e) => onTabDragStart(e, workspace.id)}
onDragEnd={onTabDragEnd}
onDragStart={(event) => {
handleTabDragThresholdDragStart(event);
onTabDragStart(event, workspace.id);
}}
onDrag={(event) => {
if (!handleTabDragThresholdDrag(event)) return;
onTabDragStart(event, workspace.id);
}}
onDragEnd={(event) => {
handleTabDragThresholdRelease(event, handleClick);
onTabDragEnd();
}}
onDragOver={(e) => onTabDragOver(e, workspace.id)}
onDragLeave={onTabDragLeave}
onDrop={(e) => onTabDrop(e, workspace.id)}
@@ -1175,11 +1224,22 @@ export const LogViewTopTab: React.FC<LogViewTopTabProps> = memo(({
data-state={isActive ? 'active' : 'inactive'}
data-active={topTabActiveValue(isActive)}
onClick={handleClick}
onMouseDown={handleTabMiddleMouseDown}
onMouseDown={handleTabDragThresholdMouseDown}
onMouseUp={(event) => handleTabDragThresholdRelease(event, handleClick)}
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseLogView(logView.id))}
draggable
onDragStart={(e) => onTabDragStart(e, logView.id)}
onDragEnd={onTabDragEnd}
onDragStart={(event) => {
handleTabDragThresholdDragStart(event);
onTabDragStart(event, logView.id);
}}
onDrag={(event) => {
if (!handleTabDragThresholdDrag(event)) return;
onTabDragStart(event, logView.id);
}}
onDragEnd={(event) => {
handleTabDragThresholdRelease(event, handleClick);
onTabDragEnd();
}}
onDragOver={(e) => onTabDragOver(e, logView.id)}
onDragLeave={onTabDragLeave}
onDrop={(e) => onTabDrop(e, logView.id)}
@@ -1228,6 +1288,7 @@ export const LogViewTopTab: React.FC<LogViewTopTabProps> = memo(({
</span>
</div>
<button
onMouseDown={handleTabCloseButtonMouseDown}
onClick={handleClose}
className="p-1 rounded-full hover:bg-destructive/10 hover:text-destructive transition-colors"
aria-label={t('tabs.closeLogViewAria')}
+112 -23
View File
@@ -3,6 +3,7 @@ import React, {
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState,
@@ -15,6 +16,8 @@ const DEFAULT_OVERSCAN = 6;
export type VariableSizeVirtualListHandle = {
scrollToIndex: (index: number, align?: 'auto' | 'center') => void;
/** Read clientHeight and restore parked scroll after a display:none ancestor. */
remeasure: () => void;
};
interface VariableSizeVirtualListProps<T> {
@@ -25,6 +28,24 @@ interface VariableSizeVirtualListProps<T> {
overscan?: number;
getItemKey: (item: T, index: number) => string;
renderItem: (item: T, index: number) => React.ReactNode;
/**
* When false, a 0 clientHeight does not collapse the window or accept a
* scroll reset. History parks under display:none; turning this back on
* remeasures and re-anchors the parked offset.
*/
measureEnabled?: boolean;
}
function findOffsetIndex(offsets: number[], scrollTop: number): number {
if (offsets.length === 0) return 0;
let lo = 0;
let hi = offsets.length - 1;
while (lo < hi) {
const mid = Math.floor((lo + hi + 1) / 2);
if ((offsets[mid] ?? 0) <= scrollTop) lo = mid;
else hi = mid - 1;
}
return lo;
}
function VariableSizeVirtualListInner<T>(
@@ -36,12 +57,20 @@ function VariableSizeVirtualListInner<T>(
overscan = DEFAULT_OVERSCAN,
getItemKey,
renderItem,
measureEnabled = true,
}: VariableSizeVirtualListProps<T>,
ref: React.ForwardedRef<VariableSizeVirtualListHandle>,
) {
const containerRef = useRef<HTMLDivElement>(null);
const [scrollTop, setScrollTop] = useState(0);
const [viewportHeight, setViewportHeight] = useState(0);
const scrollTopRef = useRef(0);
const measureEnabledRef = useRef(measureEnabled);
const itemsRef = useRef(items);
const getItemHeightRef = useRef(getItemHeight);
measureEnabledRef.current = measureEnabled;
itemsRef.current = items;
getItemHeightRef.current = getItemHeight;
const layout = useMemo(() => {
const offsets: number[] = [];
@@ -52,6 +81,8 @@ function VariableSizeVirtualListInner<T>(
}
return { offsets, totalHeight: total };
}, [getItemHeight, items]);
const layoutRef = useRef(layout);
layoutRef.current = layout;
const effectiveScrollTop = clampScrollTop(
scrollTop,
@@ -66,6 +97,7 @@ function VariableSizeVirtualListInner<T>(
if (container.scrollTop !== effectiveScrollTop) {
container.scrollTop = effectiveScrollTop;
}
scrollTopRef.current = effectiveScrollTop;
if (scrollTop !== effectiveScrollTop) {
setScrollTop(effectiveScrollTop);
}
@@ -76,7 +108,11 @@ function VariableSizeVirtualListInner<T>(
if (!container) return;
const updateViewportHeight = () => {
setViewportHeight(container.clientHeight);
const next = container.clientHeight;
// display:none reports 0. Dropping the window here unmounts the parked
// rows; the next show would rebuild them until the user resizes.
if (!measureEnabledRef.current && next === 0) return;
setViewportHeight((current) => (current === next ? current : next));
};
updateViewportHeight();
@@ -85,32 +121,85 @@ function VariableSizeVirtualListInner<T>(
return () => observer.disconnect();
}, []);
const scrollToIndex = useCallback((index: number, align: 'auto' | 'center' = 'auto') => {
const container = containerRef.current;
const currentItems = itemsRef.current;
const offsets = layoutRef.current.offsets;
if (!container || index < 0 || index >= currentItems.length) return;
const itemTop = offsets[index] ?? 0;
const itemHeight = getItemHeightRef.current(currentItems[index] as T, index);
const itemBottom = itemTop + itemHeight;
const viewTop = container.scrollTop;
const viewBottom = viewTop + container.clientHeight;
if (align === 'center') {
container.scrollTop = Math.max(
0,
itemTop - (container.clientHeight - itemHeight) / 2,
);
} else if (itemTop < viewTop) {
container.scrollTop = itemTop;
} else if (itemBottom > viewBottom) {
container.scrollTop = itemBottom - container.clientHeight;
}
scrollTopRef.current = container.scrollTop;
setScrollTop(container.scrollTop);
}, []);
const remeasure = useCallback(() => {
const container = containerRef.current;
if (!container) return;
const nextHeight = container.clientHeight;
if (!measureEnabledRef.current && nextHeight === 0) return;
if (nextHeight > 0) {
setViewportHeight((current) => (current === nextHeight ? current : nextHeight));
}
const currentItems = itemsRef.current;
const anchor = scrollTopRef.current;
if (nextHeight <= 0 || currentItems.length === 0) return;
const offsets = layoutRef.current.offsets;
const index = findOffsetIndex(offsets, anchor);
const itemTop = offsets[index] ?? 0;
const itemHeight = getItemHeightRef.current(currentItems[index] as T, index);
const itemBottom = itemTop + itemHeight;
const intersects = itemTop < anchor + nextHeight && itemBottom > anchor;
// A display:none ancestor often zeros scrollTop. Put the parked offset
// back before scrollToIndex, or "already visible" is computed against 0
// and the list jumps.
if (container.scrollTop !== anchor) {
container.scrollTop = anchor;
}
if (!intersects) {
scrollToIndex(index, 'auto');
return;
}
scrollTopRef.current = container.scrollTop;
setScrollTop((current) => (current === container.scrollTop ? current : container.scrollTop));
}, [scrollToIndex]);
useImperativeHandle(ref, () => ({
scrollToIndex: (index: number, align = 'auto') => {
const container = containerRef.current;
if (!container || index < 0 || index >= items.length) return;
scrollToIndex,
remeasure,
}), [remeasure, scrollToIndex]);
const itemTop = layout.offsets[index] ?? 0;
const itemHeight = getItemHeight(items[index], index);
const itemBottom = itemTop + itemHeight;
const viewTop = container.scrollTop;
const viewBottom = viewTop + container.clientHeight;
if (align === 'center') {
container.scrollTop = Math.max(
0,
itemTop - (container.clientHeight - itemHeight) / 2,
);
} else if (itemTop < viewTop) {
container.scrollTop = itemTop;
} else if (itemBottom > viewBottom) {
container.scrollTop = itemBottom - container.clientHeight;
}
setScrollTop(container.scrollTop);
},
}), [getItemHeight, items, layout.offsets]);
useLayoutEffect(() => {
if (!measureEnabled) return;
remeasure();
// Parent layout effects move the side-panel portal out of the hidden
// parking host after this effect. Remeasure once that move has landed.
let cancelled = false;
queueMicrotask(() => {
if (!cancelled) remeasure();
});
return () => {
cancelled = true;
};
}, [measureEnabled, remeasure]);
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
if (!measureEnabledRef.current) return;
scrollTopRef.current = event.currentTarget.scrollTop;
setScrollTop(event.currentTarget.scrollTop);
}, []);
+64
View File
@@ -0,0 +1,64 @@
# MCP file operations on single-channel SSH servers
Servers identified as single-channel (for example, `TERM-SSHD`) must not
receive an SFTP subsystem request on the interactive terminal transport.
Session-backed MCP file operations instead open a dedicated file connection
using the original terminal dial profile, with transport reuse disabled.
The profile stays in a non-enumerable session closure. It is not returned by
host discovery or written to the vault. The dedicated connection uses the
same hostname, username, proxy/jump route, credentials and host-key policy.
Normal authentication prompts may be shown again for the new connection.
Host-key verification pins the keys actually accepted for the live terminal
and its jump hosts, including a first-time or updated key accepted during
login. A different key on the dedicated connection fails closed immediately;
reconnect the terminal to verify it before retrying the file operation.
The host's Auto/SFTP/SCP preference is preserved.
Closing the source terminal or cancelling an open aborts pending login and
authentication prompts. Late connections are closed before registration.
The normal MCP operation cleanup closes the dedicated file connection after
the operation. Sessions created before this support was loaded retain the
protective error and must be reconnected.
The GUI's explicit-session and vault reconnect paths validate the endpoint
before selecting this dial. Endpoint identity matching must not treat the
single-channel restriction as a route mismatch: that restriction prohibits
borrowing a channel, not opening an independent connection from a matching
profile. Only sessions that own a dedicated dial capability use this identity
lookup; host/profile, authentication, jump/proxy route and forwarding checks
remain enforced. Vault reconnects without a live pane id can select this
capability too. Actual transport reuse rules remain unchanged.
## TERM-SSHD file semantics
The tested TERM-SSHD gateway acknowledges pipelined writes but produces a
same-sized corrupt file. Repositioning a READ on a consumed handle returns
EOF; opening another handle before closing the first returns General failure.
These observations are consistent with a sequential, cursor-based backend
that does not implement SFTP positioned I/O correctly.
For exactly the TERM-SSHD software identification, range transfers use one
in-flight request and restart checkpoints at zero. Downloads close the body
handle, reopen the file at zero, and compare every byte sequentially with the
local output. Uploads hash the local file and sequentially reread the complete
remote stage before promotion. A size-only acknowledgement is insufficient.
Other SSH software retains its existing concurrency and chunk sizes.
Transfers sharing one TERM-SSHD channel, including pooled aliases, serialize
their complete body and verification phases across uploads and downloads.
Restored transfers serialize remote source fingerprints and checkpoint-prefix
checks too, acquiring the relevant source or target channel before each check.
Cancelling a queued transfer does not close the shared channel. Active transfer
cleanup waits for CLOSE; a failed or timed-out CLOSE discards all aliases of
the uncertain channel before another transfer can use it.
This compatibility policy trades throughput and resume efficiency for correct
contents. Allow sufficient MCP command time for both the transfer and the
second full read. It is not a general promise that every TERM-SSHD version has
the same defect. An explicit SFTP host preference may be necessary when a
gateway advertises but cannot execute SCP. The saved preference is never
silently changed by this patch.
For live acceptance, compare source, remote and downloaded sizes and SHA-256
digests, and confirm the interactive terminal remains usable. A successful
tool response alone is not a substitute for this three-way check.
@@ -0,0 +1,80 @@
# Scoped host opening and shell readiness
The broader objective and staged acceptance criteria are maintained in [workspace-session-continuity.md](workspace-session-continuity.md): sustained, authorized agent work across sessions within a workspace. This PR is its first implementation increment.
## Goal and baseline
Initial base: upstream main 695fd2768, fetched 2026-10-23. Final integration base: e9818a619 (latest upstream at the pre-submission synchronization). Future PRs must fetch upstream first and use its latest main as the base. The old experimental branch and patch are retained outside the maintained repository. Existing changes in the maintained repository remain untouched.
Preserve upstream reuse-first behavior and original session ownership. Add explicit workspace placement for newly created sessions, report failed scope registration, and wait for a live shell prompt before sending a command into a tracked PTY. Raw network/serial terminals and independent SSH exec channels keep their existing paths.
## Sequence
1. Plan and run regressions against current upstream.
2. Port only missing functionality, retaining upstream diagnostics and reuse semantics.
3. Run focused tests, full lint/test/build, and isolated Electron validation.
4. Review diff, commit, push to fork, create PR against upstream main.
## Evidence and state
- Stage: implementation and validation after upstream synchronization.
- New readiness, placement, and scope-reporting regressions failed on upstream before implementation (netcatty-scoped-sessions-upstream-red.log).
- Initial adapted focused run: only the old workspace options expectation needed updating; upstream handles newTab in its existing reuse decision instead of forwarding it to the renderer creation callback.
- Dependency lock differs from the old branch; final checks must use the current lock.
- Previous branch test/build results are historical only, not evidence for this PR.
## Boundaries
current resolves the active main-window workspace at execution time. Reused sessions retain their current placement; request newTab true for a separate pane. This change does not add moving, reconnecting, or replacing existing sessions.
Live prompt matching is a conservative heuristic. Unknown prompts and busy/waiting programs fail without delivery when no recognized prompt is visible. It does not prove exclusive ownership of every manually launched process. Waiting is bounded, cancellation sends no interrupt before delivery, and no delayed command is sent after cancellation.
Trial runtime and output stay under D:/Temp/CodeTemp. No automatic fixture creation on application startup; only explicitly invoked tests may create local sessions. No production profile or remote authentication state changes.
## Validation update
- npm ci --ignore-scripts installed the current lock, followed by repository patch-package/xterm patches and Electron 42.11.12 installation.
- Targeted regression suite: 212 tests passed, no failures (netcatty-upstream-targeted.log).
- Full npm run lint passed; npm run build passed.
- Full TypeScript baseline has 769 diagnostics. Before a small explicit-false narrowing fix, the changed tree had the same diagnostics; the only text difference was the extended host-open success type. Final comparison remains pending.
- Isolated Electron renderer: current workspace placement, scopeBound true, connected sessions, installed-launcher CLI session/exec (NC_CLI_READY), missing-workspace rejection without a leaked session, and foreign-scope rejection passed.
- The trial launcher initially inherited the production CLI discovery path. Corrected the isolated launcher to use a dedicated profile discovery path and clear inherited chat identity. These are uncommitted test harness changes outside the repository.
- The first expanded trial expected a two-pane workspace to survive closing one pane; upstream intentionally collapses it to a standalone tab. Adjust the trial rather than changing product behavior.
- Full suite is running with concurrency 4 and a 180-second test limit to bound hangs. Windows permissions, POSIX shell and SFTP timeout failures must be reported, not counted as passing. The strict-mode source-contract failure also occurs on the clean upstream baseline.
## Final live trial and full-suite outcome
- Isolated Electron 42.11.12 + Vite trial passed all nine checks: workspace/scope binding; installed CLI session/exec; invalid target without leaks; foreign scope rejection; close then fresh standalone open; busy PowerShell waits for its prompt (about 3 seconds); Read-Host rejects an injected command without consuming or interrupting input; UI close revokes ownership; CLI standalone open/close. All fixture sessions were then closed.
- Full suite attempt: 14,907 tests, 14,489 passed, 313 failed, 2 cancelled, 103 skipped. It is not green. The pluginRuntime aggregate retained a live process after its tests and was terminated after several minutes so the runner could finish. A clean upstream isolated reproduction is being checked with an outer deadline.
- Failures include POSIX shell/history on Windows, permissions/symlinks, existing source-text contracts, SFTP timeouts, and timing-sensitive cleanup assertions. Only the focused suite and explicitly compared baseline failures may be described as verified; do not claim every full-suite failure is pre-existing.
- Packaging/install replacement is outside this PR. Existing production binaries, credentials, and maintained-repository working changes remain untouched.
## Submission state
Implementation and validation are complete; upstream review/CI remains pending. The branch is fix/scoped-session-open-upstream-20261023.
- Rebased onto e9818a619, preserving the newly merged single-channel SSH/SFTP changes and vault-note query correction. Final serial regressions including those upstream additions: 272 passed, zero failures.
- Final full production build passed after the rebase.
- Final TypeScript comparison on e9818a619: clean baseline 769 diagnostics, changed tree 768, zero added diagnostics after normalizing checkout paths and line numbers. The explicit-false host-open check removes one existing diagnostic. Full typecheck remains non-green.
- Clean upstream pluginRuntime reproduction hit the 45-second outer deadline after its utility-runtime cases; no unrelated plugin-runtime changes were added here.
- Broader PTY comparison: both trees 73 passed, 48 failed, 14 skipped; identical failure names.
- Parallel regression rerun exposed the existing 20 ms SFTP cleanup timing assertion under scheduling load. Isolated baseline passed and final serial 272-test run passed; it is not represented as a product regression or silently deleted.
- Verified private trial cleanup: zero sessions and restorePreviousSession=false. Closed the trial Electron/Vite processes. Production CLI environment lookup succeeded after isolation cleanup.
- Existing maintained-repository dirty files are unchanged. No installed app or remote authentication/provider configuration was modified by this development task.
## PR 3885 review follow-up
- Codex review reports that it could not complete; no submitted reviews or inline findings are available through the GitHub API. Its summary flags unresolved findings, but does not publish their details, so the underlying service failure cannot be diagnosed from that comment.
- CI on c012823 recorded 14,919 passed, one failed, 48 skipped. The failing deleted-chat/reused-tab test still expected host.open success after binding was rejected. This test was missing from the focused regression list.
- Reproduced the failing assertion locally before editing. Update it to require ok=false, SESSION_SCOPE_BIND_FAILED, and the reused session ID. Retain all checks that the deleted scope stays empty, the original scope retains visibility, no close is requested, and no idle ownership is acquired.
- Fetch latest upstream before updating the PR; include reconnectingSession coverage in the focused regression run. No product behavior is relaxed to make the test pass.
## Follow-up: session recovery within a workspace (not implemented here)
- Expose explicit agent reconnect for disconnected sessions, retaining their workspace, session identity, and scope. Reusing an already auto-reconnecting session does not provide this capability.
- Add moving an existing session into the requested workspace and opening an independent recovery connection there when needed, with host identity and ownership checks.
- Define close/reopen behavior for an unresponsive transport, preserving useful history and rejecting stale generation writes. Closing a connection must not be reported as confirmation that its remote process was terminated.
- Distinguish startup, shell-ready, executing, waiting for input, disconnected, and unknown states before command delivery. Keep bounded waits, per-session serialization, cancellation, and explicit delivery uncertainty; do not automatically resend a potentially executed command.
- Test these lifecycle transitions before implementation in a separate, small follow-up PR. This PR covers new-session placement, visibility/ownership, and shell readiness only.
Review follow-up validation on a3b3e7038: the previously failing reconnecting-session test and related scope/PTY/CLI regressions now pass (219 tests, zero failures). The changed test file passes ESLint. A broader intermediate run was interrupted by a new chat turn and is not counted as completed. The test-only fix does not require a new production build; the pushed update will run upstream CI again.
@@ -0,0 +1,57 @@
# Agent session continuity within a workspace
## Objective
Let an agent continue an authorized task across multiple sessions in its workspace, including recovery from transient disconnects, sleep/wake connection loss, slow shell startup, and an unresponsive terminal. A recoverable session problem should not automatically end the whole task or require the user to repeat its instructions.
The workspace is the collaboration and permission boundary. The host grants access to particular sessions and management operations; the main process enforces those grants across RPC, IPC, and CLI paths. Being displayed in a workspace does not itself grant access, transfer ownership, or authorize closing another chat's session. Existing task authorization should be reusable for permitted recovery without repeated permission questions.
## Current delivery: PR 3885
- Open a new host session in an explicitly selected workspace and bind it to the requesting chat scope, or return an explicit binding failure.
- Preserve upstream reuse-first behavior and original ownership, including visibility of sessions already auto-reconnecting.
- Wait for a live shell prompt before delivering commands to tracked shell-backed PTYs, with bounded waits and cancellation.
- Document bounded recovery decisions using capabilities available in the running build.
Explicit reconnect, moving an existing session, and restarting a connection are not implemented by this PR. The larger objective explains this first increment; it is not a claim that the recovery lifecycle is already complete.
## Next increments
1. **Reconnect within the same tab.** Expose an authorized reconnect operation for disconnected sessions, preserving session identity, workspace placement, useful history, and scope. Reuse an in-flight reconnect instead of starting another connection. Return connected/ready only after those states are observed; surface authentication requirements separately.
2. **Open or attach a recovery session.** Allow a permitted existing session to join the selected workspace, or open an independent session to the same verified host when the old transport is unusable. Preserve its management permissions and scope throughout placement. Coalesce duplicate recovery requests and return whether a session was reused or created.
3. **Recover an unresponsive connection.** Expose explicit replacement/restart where permitted, fence old-generation writes and late callbacks, and preserve diagnostics. Reopening a connection does not prove the old remote process was terminated. Process termination requires a separately verified target and existing authorization.
4. **Coordinate ongoing work.** Keep per-session execution reservations and cancellation; track whether a command was not sent, acknowledged, completed, or has an unknown outcome. Multiple sessions can support independent jobs while tool calls remain serialized wherever the host requires it. Do not replay a possibly executed command to simulate progress.
Each increment starts with lifecycle and scope regressions, reuses existing session actions and permission checks, and is delivered as a small change against freshly fetched upstream main. Preserve observer/confirm/auto behavior and distinguish session visibility from management permission.
## Recovery policy
Use existing session recovery before creating a new tab. Defaults per target session/host in one incident: at most three recovery attempts including the first, wait 2 seconds and then 5 seconds before another attempt, and stop starting attempts after a 120-second elapsed budget including connection and waiting time. A smaller user/host limit takes precedence. A new chat turn or tab does not reset an ongoing incident's budget. Create at most one new recovery session for that incident; reuse it on subsequent checks.
Apply retry only to transient transport/startup conditions when it is known that no task command was delivered, or to an inspection/reconnect operation whose semantics permit retry. Authentication rejection, missing permission, revoked scope, invalid targets, and user cancellation need a different action; they are not transient retry signals. A program returning a nonzero exit code is not evidence that its terminal connection is broken.
After an ambiguous timeout or disconnect during execution, inspect the existing job, marker, process, output, or durable result before considering another execution. Exhausting recovery pauses only the blocked dependency; continue independent authorized work and report the session, observed state, attempted actions, and what remains unknown. Do not skip required work or report it as complete.
The Skill is operator guidance, not a backend retry controller or a grant of permissions. New lifecycle tools must enforce their own deadlines, generation checks, idempotency, and workspace permissions.
## Acceptance scenarios
| Scenario | Required result | Delivery |
| --- | --- | --- |
| Open a new connection for parallel work | Requested workspace, usable chat scope, explicit reuse/create result, no duplicate retry tabs | Placement/scope delivered; existing reuse preserved |
| Slow local shell or SSH shell startup | No command bytes before readiness; bounded cancellation and explicit not-sent failure | Delivered for tracked shell PTYs |
| Brief network loss or sleep/wake disconnect | Reconnect original tab within its grant, preserve scope, resume after observed readiness | Planned explicit reconnect |
| Old terminal is unresponsive | Inspect/cancel appropriately, then use one permitted independent connection in the same workspace | Creation is available where exposed; automated recovery orchestration planned |
| A command may have executed before disconnect | Diagnose outcome; no automatic replay of a possibly mutating command | Skill guidance; richer delivery state planned |
| Permission revoked or user stops the task | No reconnect/reopen/resume that bypasses that decision | Existing enforcement retained; lifecycle regressions required for new tools |
| Same failure persists | Bounded retries/backoff, no tab storm, clear blocked-dependency report | Skill guidance; controller enforcement required with lifecycle tools |
## Status
Planning and Skill guidance for the continuity objective. PR 3885 remains the first implementation increment. No disconnected-session reconnect/restart/move tool is claimed by this document. Apply Skill updates through the application's normal distribution/synchronization path; do not rely on overwriting a user's separately maintained interruption skill.
## Guidance validation
- The bundled skill passes skill-creator quick_validate.py; Markdown references and the existing control/error routes were inspected.
- Scenario walkthroughs: transient disconnect uses the bounded incident budget; a known busy job is polled rather than receiving new input; authentication/scope denial stops retry; uncertain command completion requires inspection before replay; a missing reconnect capability is reported rather than invented; explicit user cancellation is respected.
- This documentation/Skill increment adds no executable lifecycle behavior or installed-user-skill replacement. No runtime test or new production build is claimed for these text-only edits.
+14
View File
@@ -0,0 +1,14 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { resolveAgentHostWorkspace } from './agentHostWorkspace';
test('current resolves only to an existing workspace, not a terminal tab', () => {
assert.deepEqual(resolveAgentHostWorkspace('current', 'ws-1', ['ws-1']), { workspaceId: 'ws-1' });
assert.match(resolveAgentHostWorkspace('current', 'terminal-1', ['ws-1']).error ?? '', /workspace/i);
});
test('explicit missing workspace is rejected without standalone fallback', () => {
assert.match(resolveAgentHostWorkspace('missing', 'ws-1', ['ws-1']).error ?? '', /workspace/i);
assert.deepEqual(resolveAgentHostWorkspace('ws-1', 'other', ['ws-1']), { workspaceId: 'ws-1' });
assert.deepEqual(resolveAgentHostWorkspace(undefined, 'ws-1', ['ws-1']), {});
});
+13
View File
@@ -0,0 +1,13 @@
/** Resolve an explicit placement request without silently changing its target. */
export function resolveAgentHostWorkspace(
requested: string | undefined,
activeTabId: string | undefined,
workspaceIds: readonly string[],
): { workspaceId?: string; error?: string } {
if (requested === undefined) return {};
const workspaceId = requested === 'current' ? activeTabId : requested.trim();
if (!workspaceId || !workspaceIds.includes(workspaceId)) {
return { error: `Workspace "${requested}" is not available. Select a workspace or provide its id.` };
}
return { workspaceId };
}
+197
View File
@@ -5,14 +5,17 @@ import type { ConnectionLog } from "./models.ts";
import { selectConnectionLogForTerminalDataCapture } from "./connectionLog.ts";
import {
MAX_PERSISTED_UNSAVED_TERMINAL_DATA_ENTRIES,
acknowledgePendingConnectionLogWrites,
diffTerminalDataMaps,
mergeConnectionLogsFromStorage,
mergeConnectionLogsWithPendingMutations,
mergeTerminalDataIntoLogs,
mergeTerminalDataMapsForStorage,
pruneInMemoryUnsavedTerminalData,
pruneTerminalDataMapForStorage,
rebaseQueuedConnectionLogs,
retainNewestUnsavedReplays,
type PendingConnectionLogWrite,
} from "./connectionLogTerminalData.ts";
const baseLog: ConnectionLog = {
@@ -29,6 +32,200 @@ const baseLog: ConnectionLog = {
saved: false,
};
test("pending capture survives a peer snapshot and queued persistence rebase", () => {
const local = { ...baseLog, id: "local", startTime: 3000, terminalData: "capture" };
const peer = { ...baseLog, id: "peer", startTime: 2000 };
const memory = mergeConnectionLogsWithPendingMutations(
[local, baseLog], [peer, baseLog], {}, new Map([[local.id, { log: local, changes: local, added: true }]]),
);
const persisted = rebaseQueuedConnectionLogs(
[local, baseLog], new Set(), new Set([local.id]), memory, [peer, baseLog],
);
assert.deepEqual(persisted.map((log) => log.id), ["local", "peer", "log-base"]);
assert.equal(persisted[0].terminalData, "capture");
});
test("pending tombstone wins over a stale peer snapshot without deleting peer-only logs", () => {
const peer = { ...baseLog, id: "peer" };
const merged = mergeConnectionLogsWithPendingMutations(
[], [baseLog, peer], {}, new Map([[baseLog.id, { log: null }]]),
);
assert.deepEqual(merged, [peer]);
});
test("unrelated pending mutation never resurrects a remotely deleted memory row", () => {
const local = { ...baseLog, id: "local", terminalData: "capture" };
const deleted = { ...baseLog, id: "remote-deleted", terminalData: "old" };
const merged = mergeConnectionLogsWithPendingMutations(
[local, deleted], [], {}, new Map([[local.id, { log: local, changes: local, added: true }]]),
);
assert.deepEqual(merged, [local]);
});
test("a pending final capture does not resurrect an existing log a peer deleted", () => {
const captured = { ...baseLog, terminalData: "final capture" };
const merged = mergeConnectionLogsWithPendingMutations(
[captured], [], {},
new Map([[baseLog.id, { log: captured, changes: { terminalData: "final capture" }, added: false }]]),
);
assert.deepEqual(merged, []);
});
test("pending capture preserves a peer bookmark and pending bookmark preserves a peer capture", () => {
const captured = { ...baseLog, terminalData: "new capture", endTime: 2000 };
const bookmarked = { ...baseLog, saved: true };
const captureMerged = mergeConnectionLogsWithPendingMutations(
[captured], [bookmarked], {},
new Map([[baseLog.id, { log: captured, changes: { terminalData: "new capture", endTime: 2000 } }]]),
);
assert.equal(captureMerged[0].saved, true);
assert.equal(captureMerged[0].terminalData, "new capture");
const oldBookmark = { ...bookmarked, terminalData: "old capture" };
const bookmarkMerged = mergeConnectionLogsWithPendingMutations(
[oldBookmark], [captured], {}, new Map([[baseLog.id, { log: oldBookmark, changes: { saved: true } }]]),
);
assert.equal(bookmarkMerged[0].saved, true);
assert.equal(bookmarkMerged[0].terminalData, "new capture");
const sideStoreMerged = mergeConnectionLogsWithPendingMutations(
[oldBookmark], [baseLog], { [baseLog.id]: "new capture" },
new Map([[baseLog.id, { log: oldBookmark, changes: { saved: true } }]]),
);
assert.equal(sideStoreMerged[0].terminalData, "new capture");
});
test("pending replay overlay retains the newest 50 unsaved buffers and all saved buffers", () => {
const logs = Array.from({ length: 65 }, (_, i) => ({
...baseLog, id: `log-${i}`, startTime: i, terminalData: `data-${i}`,
}));
const saved = { ...baseLog, id: "saved", startTime: -1, saved: true, terminalData: "saved data" };
const merged = mergeConnectionLogsWithPendingMutations(
[], [...logs, saved], {}, new Map(logs.map((log) => [log.id, { log, changes: log }])),
);
assert.equal(merged.length, 66);
assert.equal(merged.filter((log) => !log.saved && log.terminalData).length, 50);
assert.equal(merged.find((log) => log.id === "log-14")?.terminalData, undefined);
assert.equal(merged.find((log) => log.id === "log-15")?.terminalData, "data-15");
assert.equal(merged.find((log) => log.id === "saved")?.terminalData, "saved data");
});
test("older successful write retires inherited fields but keeps a later capture generation", () => {
const first: PendingConnectionLogWrite = {
generation: 1, added: true, log: baseLog,
changes: { saved: false, terminalData: "first" }, fieldGenerations: { saved: 1, terminalData: 1 },
};
const later: PendingConnectionLogWrite = {
generation: 2, added: true, log: { ...baseLog, terminalData: "final" },
changes: { saved: false, terminalData: "final" }, fieldGenerations: { saved: 1, terminalData: 2 },
};
const pending = acknowledgePendingConnectionLogWrites(
new Map([[baseLog.id, later]]), new Map([[baseLog.id, first]]),
);
assert.deepEqual(pending.get(baseLog.id)?.changes, { terminalData: "final" });
assert.equal(pending.get(baseLog.id)?.added, false);
const merged = mergeConnectionLogsWithPendingMutations(
[later.log!], [{ ...baseLog, saved: true }], {}, pending,
);
assert.equal(merged[0].saved, true);
assert.equal(merged[0].terminalData, "final");
});
test("older successful capture cannot acknowledge a newer pending deletion", () => {
const first: PendingConnectionLogWrite = { generation: 1, log: baseLog };
const deleted: PendingConnectionLogWrite = { generation: 2, log: null };
const pending = acknowledgePendingConnectionLogWrites(
new Map([[baseLog.id, deleted]]), new Map([[baseLog.id, first]]),
);
assert.equal(pending.get(baseLog.id), deleted);
assert.equal(acknowledgePendingConnectionLogWrites(pending, pending).size, 0);
});
test("acknowledging one commit retains another failed or newer pending id for retry", () => {
const first: PendingConnectionLogWrite = { generation: 1, log: baseLog };
const retry: PendingConnectionLogWrite = { generation: 2, added: true, log: { ...baseLog, id: "retry" } };
const pending = acknowledgePendingConnectionLogWrites(
new Map([[baseLog.id, first], ["retry", retry]]), new Map([[baseLog.id, first]]),
);
assert.deepEqual([...pending.keys()], ["retry"]);
assert.equal(mergeConnectionLogsWithPendingMutations([], [], {}, pending)[0].id, "retry");
});
test("metadata-only success retires add ownership without discarding failed replay", () => {
const captured = { ...baseLog, terminalData: "failed replay" };
const write: PendingConnectionLogWrite = {
generation: 1, added: true, log: captured, changes: captured,
fieldGenerations: Object.fromEntries(Object.keys(captured).map((field) => [field, 1])),
};
const pending = acknowledgePendingConnectionLogWrites(
new Map([[baseLog.id, write]]), new Map([[baseLog.id, write]]),
{ metadataPersisted: true, replayPersisted: false },
);
assert.equal(pending.get(baseLog.id)?.added, false);
assert.deepEqual(pending.get(baseLog.id)?.changes, { terminalData: "failed replay" });
const bookmarked = mergeConnectionLogsWithPendingMutations(
[captured], [{ ...baseLog, saved: true }], {}, pending,
);
assert.equal(bookmarked[0].saved, true);
assert.equal(bookmarked[0].terminalData, "failed replay");
assert.deepEqual(mergeConnectionLogsWithPendingMutations([captured], [], {}, pending), []);
});
test("partial acknowledgment preserves newer metadata and replay generations", () => {
const first: PendingConnectionLogWrite = {
generation: 1, added: true, log: baseLog, changes: { saved: false, terminalData: "first" },
fieldGenerations: { saved: 1, terminalData: 1 },
};
const later: PendingConnectionLogWrite = {
generation: 2, added: true, log: { ...baseLog, saved: true, terminalData: "later" },
changes: { saved: true, terminalData: "later" }, fieldGenerations: { saved: 2, terminalData: 2 },
};
const pending = acknowledgePendingConnectionLogWrites(
new Map([[baseLog.id, later]]), new Map([[baseLog.id, first]]),
{ metadataPersisted: true, replayPersisted: false },
);
assert.equal(pending.get(baseLog.id)?.added, false);
assert.deepEqual(pending.get(baseLog.id)?.changes, later.changes);
});
test("failed metadata keeps add ownership even when the replay is durable", () => {
const captured = { ...baseLog, terminalData: "durable replay" };
const write: PendingConnectionLogWrite = {
generation: 1, added: true, log: captured, changes: captured,
fieldGenerations: Object.fromEntries(Object.keys(captured).map((field) => [field, 1])),
};
const pending = acknowledgePendingConnectionLogWrites(
new Map([[baseLog.id, write]]), new Map([[baseLog.id, write]]),
{ metadataPersisted: false, replayPersisted: true },
);
assert.equal(pending.get(baseLog.id)?.added, true);
assert.equal(pending.get(baseLog.id)?.changes?.saved, false);
assert.equal(Object.hasOwn(pending.get(baseLog.id)?.changes ?? {}, "terminalData"), false);
});
test("durable saved replay and deletion retire ownership despite a failed side store", () => {
const saved: PendingConnectionLogWrite = {
generation: 1, added: true, log: { ...baseLog, saved: true, terminalData: "saved" },
changes: { saved: true, terminalData: "saved" }, fieldGenerations: { saved: 1, terminalData: 1 },
};
const deleted: PendingConnectionLogWrite = { generation: 2, log: null };
const writes = new Map([[baseLog.id, saved], ["deleted", deleted]]);
assert.equal(acknowledgePendingConnectionLogWrites(writes, writes,
{ metadataPersisted: true, replayPersisted: false, mainReplay: { [baseLog.id]: "saved" } }).size, 0);
});
test("a stale saved flag cannot acknowledge replay omitted after a peer unbookmark", () => {
const write: PendingConnectionLogWrite = {
generation: 1, log: { ...baseLog, saved: true, terminalData: "failed final" },
changes: { terminalData: "failed final" }, fieldGenerations: { terminalData: 1 },
};
for (const mainReplay of [{}, { [baseLog.id]: "older durable bytes" }]) {
const pending = acknowledgePendingConnectionLogWrites(
new Map([[baseLog.id, write]]), new Map([[baseLog.id, write]]),
{ metadataPersisted: true, replayPersisted: false, mainReplay },
);
assert.deepEqual(pending.get(baseLog.id)?.changes, { terminalData: "failed final" });
}
});
test("selectConnectionLogForTerminalDataCapture picks the active log for a normal session exit", () => {
const matchingLog = { ...baseLog, id: "active", startTime: 2000 };
const staleLog = {
+93
View File
@@ -49,6 +49,99 @@ export const mergeConnectionLogsFromStorage = (
return changed ? merged : next;
};
/**
* A peer snapshot must not discard a local mutation that has not committed.
* Overlay only owned ids, including explicit deletion tombstones. Unowned
* memory rows stay absent when another window deletes them.
*/
export type PendingConnectionLogMutation = {
log: ConnectionLog | null;
/** Only a new local row may be inserted when disk no longer contains it. */
added?: boolean;
/** Fields actually changed here; a capture must not undo a peer bookmark. */
changes?: Partial<ConnectionLog>;
};
export type PendingConnectionLogWrite = PendingConnectionLogMutation & {
generation: number;
fieldGenerations?: Partial<Record<keyof ConnectionLog, number>>;
};
/** Retire only row/field generations whose storage write actually committed. */
export const acknowledgePendingConnectionLogWrites = (
pending: ReadonlyMap<string, PendingConnectionLogWrite>,
committed: ReadonlyMap<string, PendingConnectionLogWrite>,
durability: {
metadataPersisted: boolean;
replayPersisted: boolean;
mainReplay?: ConnectionLogTerminalDataMap;
} = { metadataPersisted: true, replayPersisted: true },
): Map<string, PendingConnectionLogWrite> => {
const remaining = new Map(pending);
for (const [id, written] of committed) {
const current = remaining.get(id);
if (!current) continue;
if (current.generation === written.generation && durability.metadataPersisted
&& (durability.replayPersisted || written.log === null)) {
remaining.delete(id);
} else if (current.log !== null && written.log !== null) {
const changes = { ...current.changes };
const fieldGenerations = { ...current.fieldGenerations };
for (const field of Object.keys(written.fieldGenerations ?? {}) as Array<keyof ConnectionLog>) {
// Use the bytes actually written, not this pending log's saved flag:
// a peer may have unbookmarked it before the snapshot was rebased.
const durable = field === "terminalData"
? durability.replayPersisted || (Object.hasOwn(durability.mainReplay ?? {}, id)
&& durability.mainReplay?.[id] === written.changes?.terminalData)
: durability.metadataPersisted;
if (durable && fieldGenerations[field] === written.fieldGenerations?.[field]) {
delete changes[field];
delete fieldGenerations[field];
}
}
const added = durability.metadataPersisted && written.added ? false : current.added;
if (Object.keys(changes).length === 0 && !added) remaining.delete(id);
else remaining.set(id, {
...current, changes, fieldGenerations, added,
});
}
}
return remaining;
};
export const mergeConnectionLogsWithPendingMutations = (
prev: ConnectionLog[],
next: ConnectionLog[],
terminalDataMap: ConnectionLogTerminalDataMap,
pending: ReadonlyMap<string, PendingConnectionLogMutation>,
): ConnectionLog[] => {
// Incoming disk/side-store bytes are authoritative unless this window owns
// a newer capture. In particular, bookmarking must not replay stale bytes
// over a final capture that a peer just committed.
const prevById = new Map(prev.map((log) => [log.id, log]));
const hydrated = next.map((log) => {
const terminalData = log.terminalData ?? terminalDataMap[log.id] ?? prevById.get(log.id)?.terminalData;
return terminalData === undefined || terminalData === log.terminalData
? log
: { ...log, terminalData };
});
if (pending.size === 0) return pruneInMemoryUnsavedTerminalData(hydrated);
const byId = new Map(hydrated.map((log) => [log.id, log]));
for (const [id, mutation] of pending) {
if (mutation.log === null) byId.delete(id);
else {
const stored = byId.get(id);
if (!stored && !mutation.added) continue;
byId.set(id, stored && mutation.changes
? { ...stored, ...mutation.changes }
: mutation.log);
}
}
return pruneInMemoryUnsavedTerminalData(
[...byId.values()].sort((a, b) => b.startTime - a.startTime),
);
};
/**
* Apply a queued connection-log snapshot when it runs, not when it was captured.
* Disk may have gained another window's logs during the wait. Keep those logs.
+10
View File
@@ -783,3 +783,13 @@ test("CloudLinker skips the cwd probe without restricting generic Go servers", (
true,
);
});
test("sanitizeHost preserves named-pipe spaces and restores the authoritative serial path", () => {
const path = String.raw`\\.\pipe\VM console`;
const host = makeHost({ protocol: 'serial', hostname: String.raw`\\.\pipe\VM`, serialConfig: {
path, baudRate: 115200, dataBits: 8, stopBits: 1, parity: 'none', flowControl: 'none',
} });
assert.equal(sanitizeHost(host).hostname, path);
assert.equal(sanitizeHost(host).serialConfig?.path, path);
assert.equal(sanitizeHost(makeHost({ hostname: 'example.com extra' })).hostname, 'example.com');
});
+4 -1
View File
@@ -415,7 +415,10 @@ export const hostsEqualForIdentityReuse = (a: Host, b: Host): boolean => {
};
export const sanitizeHost = (host: Host, snippets: Snippet[] = []): Host => {
const cleanHostname = (host.hostname || '').trim().split(/\s+/)[0];
// Serial endpoints are paths, not network hostnames; pipe names may contain spaces.
const cleanHostname = host.protocol === 'serial'
? (host.serialConfig?.path || host.hostname || '').trim()
: (host.hostname || '').trim().split(/\s+/)[0];
const cleanDistro = normalizeDistroId(host.distro);
const cleanManualDistro = normalizeDistroId(host.manualDistro);
const cleanDistroMode =
+2
View File
@@ -80,6 +80,8 @@ export interface TransferTask {
speed: number; // bytes per second
error?: string;
startTime: number;
/** Upload completed, but a generated backup needs manual cleanup. */
warning?: string;
endTime?: number;
isDirectory: boolean;
progressMode?: 'bytes' | 'files';
+21
View File
@@ -0,0 +1,21 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { isSerialEndpointPath, isWindowsNamedPipePath } from './serialEndpoint';
const require = createRequire(import.meta.url);
const backend = require('../electron/bridges/serialNamedPipe.cjs');
test('serial endpoint validation keeps hardware ports and accepts local Windows pipes', () => {
for (const value of ['/dev/ttyS0', '/dev/ttys001', 'COM1', String.raw`\\.\COM12`, String.raw`\\.\pipe\vm`, String.raw`\\?\pipe\vm`]) {
assert.equal(isSerialEndpointPath(value), true, value);
}
for (const value of ['', 'vm', '\\\\.\\pipe\\', String.raw`\\host\pipe\vm`]) {
assert.equal(isSerialEndpointPath(value), false, value);
}
});
test('renderer and backend agree on named pipe detection', () => {
for (const value of ['', '/dev/ttyS0', 'COM1', String.raw`\\.\COM1`, String.raw`\\.\pipe\vm`, String.raw`\\?\pipe\VM console`, String.raw`\\.\PIPE\vm\console`, String.raw`\\host\pipe\vm`, '\\\\.\\pipe\\', '\\\\.\\pipe\\vm\n', '\\\\.\\pipe\\vm\0']) {
assert.equal(isWindowsNamedPipePath(value), backend.isWindowsNamedPipePath(value), value);
}
});
+9
View File
@@ -0,0 +1,9 @@
// Keep in sync with electron/bridges/serialNamedPipe.cjs.
export function isWindowsNamedPipePath(value: string): boolean {
return /^\\\\[.?]\\pipe\\[^\r\n\0]+$/i.test(value);
}
export function isSerialEndpointPath(value: string): boolean {
return value.startsWith('/dev/') || /^COM\d+$/i.test(value)
|| /^\\\\\.\\COM\d+$/i.test(value) || isWindowsNamedPipePath(value);
}
+21
View File
@@ -294,6 +294,27 @@ test("prune upgrades legacy completed children into the parent checkpoint", () =
assert.equal(pruned.find((item) => item.id === "dir")?.directoryResumeCheckpoint?.completedEntries, 5);
});
test("completed cleanup-warning children retain actionable paths across compaction and restart", () => {
const now = Date.now();
const warning = "Upload completed; remove backup /target/.netcatty-backup-file manually.";
const parent: TransferTask = { ...task("warning-folder", "transferring", now), isDirectory: true };
const children: TransferTask[] = [0, 1].map((index) => ({
...task(`warning-child-${index}`, "completed", now),
parentTaskId: parent.id, endTime: now, directoryEntryIndex: index,
directoryEntryIdentity: String(index).padStart(64, "0"),
warning: index === 0 ? warning : undefined,
}));
const pruned = pruneSftpTransferHistory([parent, ...children], now);
assert.equal(pruned.some((row) => row.id === "warning-child-1"), false);
assert.equal(pruned.find((row) => row.id === parent.id)?.directoryResumeCheckpoint?.completedEntries, 1);
const restored = deserializeSftpTransferCenter(serializeSftpTransferCenter(pruned));
const retained = restored.tasks.find((row) => row.id === "warning-child-0");
assert.equal(retained?.status, "completed");
assert.equal(retained?.warning, warning);
assert.equal(retained?.error, undefined);
assert.equal(pruneSftpTransferHistory(restored.tasks, now).find((row) => row.id === retained?.id)?.warning, warning);
});
test("prune compacts 50,000 completed directory children into a bounded parent checkpoint", () => {
const pruneStartedAt = Date.now();
const now = Date.now();
+4 -1
View File
@@ -32,7 +32,7 @@ export interface PersistedSftpTransferCenter {
const SAFE_TASK_KEYS: ReadonlySet<keyof TransferTask> = new Set([
"id", "batchId", "fileName", "originalFileName", "sourcePath", "targetPath",
"sourceConnectionId", "targetConnectionId", "targetHostId", "targetConnectionKey",
"direction", "status", "totalBytes", "transferredBytes", "speed", "error",
"direction", "status", "totalBytes", "transferredBytes", "speed", "error", "warning",
"startTime", "endTime", "isDirectory", "progressMode", "childTasks", "parentTaskId",
"sourceLastModified", "preflightStatSkipped", "skipConflictCheck", "replaceExistingTarget", "retryable",
"ownerId", "sourceHostId", "sourceHostLabel", "targetHostLabel", "origin", "background",
@@ -58,6 +58,7 @@ export function sanitizeSftpTransferTask(value: unknown): TransferTask | null {
if (source[key] !== undefined) sanitized[key] = source[key];
}
const task = sanitized as unknown as TransferTask;
if (typeof task.warning !== "string" || !task.warning.trim()) task.warning = undefined;
if (task.preflightStatSkipped !== true) task.preflightStatSkipped = undefined;
if (!Number.isSafeInteger(task.directoryEntryIndex) || (task.directoryEntryIndex ?? -1) < 0) {
task.directoryEntryIndex = undefined;
@@ -213,6 +214,8 @@ export function pruneSftpTransferHistory(
if (
child.parentTaskId === parent.id
&& child.status === "completed"
// Keep per-file cleanup paths visible instead of losing them in the hash.
&& !child.warning
&& Number.isSafeInteger(child.directoryEntryIndex)
&& (child.directoryEntryIndex ?? checkpoint.coveredEntries) < checkpoint.coveredEntries
&& /^[a-f0-9]{64}$/.test(child.directoryEntryIdentity ?? "")
+120 -12
View File
@@ -65,10 +65,13 @@ function startPtyJob(ptyStream, command, options) {
onInterrupt,
chatSessionId,
abortSignal,
expectedPrompt,
expectedPrompt: initialExpectedPrompt,
jobId = null,
sessionId = null,
diagnosticsSession = null,
getReadyPrompt,
beforeInput,
promptWaitTimeoutMs = 30000,
typedInput = false,
echoCommand,
onEchoSuppressionPrime,
@@ -77,11 +80,16 @@ function startPtyJob(ptyStream, command, options) {
enforceWallTimeout = false,
} = options || {};
let expectedPrompt = initialExpectedPrompt;
let waitingForPrompt = typeof getReadyPrompt === "function";
let readinessFailureCode = null;
let waitingForInputGuard = typeof beforeInput === "function";
const marker = `__NCMCP_${Date.now().toString(36)}_${crypto.randomBytes(16).toString('hex')}__`;
let resolvedShellKind = resolveEffectiveShellKind(shellKind, expectedPrompt, {
loginShellHint,
});
const waitForReturnedPrompt = loginShellHint === "cmd"
let waitForReturnedPrompt = loginShellHint === "cmd"
&& resolvedShellKind === "powershell"
&& Boolean(expectedPrompt);
if (promptRecoveryPendingPtys.has(ptyStream)) {
@@ -101,7 +109,7 @@ function startPtyJob(ptyStream, command, options) {
const CANCEL_RETRY_MS = 5000;
const CANCEL_WALL_TIMEOUT_MS = 30000;
const usesLiveShellProbe = probeLiveShell && ["posix", "fish"].includes(resolvedShellKind);
let usesLiveShellProbe = probeLiveShell && ["posix", "fish"].includes(resolvedShellKind);
let probingShell = usesLiveShellProbe;
let deliveringInput = false;
let probeOutput = "";
@@ -172,6 +180,7 @@ function startPtyJob(ptyStream, command, options) {
}
function armOutputTimeout() {
if (waitingForPrompt || waitingForInputGuard) return;
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
sendInterrupt("inactivity-timeout");
@@ -193,6 +202,13 @@ function startPtyJob(ptyStream, command, options) {
if (!enforceWallTimeout || maxBufferedChars > 0) return;
wallTimeoutId = setTimeout(() => {
if (finished) return;
if (waitingForPrompt || waitingForInputGuard) {
readinessFailureCode = waitingForPrompt ? "SHELL_NOT_READY" : "EXECUTION_NOT_AUTHORIZED";
finish("", -1, waitingForPrompt
? "Shell prompt is not ready; no command was sent"
: "Execution authorization timed out; no command was sent");
return;
}
if (pendingEnd) {
finishWithoutReturnedPrompt();
return;
@@ -300,6 +316,11 @@ function startPtyJob(ptyStream, command, options) {
function requestCancel() {
if (finished || cancelRequested) return;
if (waitingForPrompt || waitingForInputGuard) {
cancelRequested = true;
finish("", 130, "Cancelled");
return;
}
if (pendingEnd) {
// The command already completed. Do not send Ctrl+C into the restoring
// prompt or release the lock before the next shell can be identified.
@@ -565,6 +586,7 @@ function startPtyJob(ptyStream, command, options) {
stderr: "",
exitCode: finalExitCode ?? -1,
error: finalError,
...(readinessFailureCode ? { code: readinessFailureCode } : {}),
outputBaseOffset,
totalOutputChars,
outputTruncated: outputBaseOffset > 0,
@@ -809,7 +831,8 @@ function startPtyJob(ptyStream, command, options) {
cleanupFns.push(() => abortSignal.removeEventListener("abort", onAbort));
}
if (typedInput && typeof echoCommand === "function") {
function echoPendingCommand() {
if (!(typedInput && typeof echoCommand === "function")) return;
try {
echoCommand(command);
} catch {
@@ -910,18 +933,100 @@ function startPtyJob(ptyStream, command, options) {
// _I printf runs. Delivering the _I line over the data channel up front
// suppresses the whole echo; the _S output releases it, and finish() sends
// the _R reset (onProbeAborted) when the command never starts.
if (typeof onEchoSuppressionPrime === "function") {
function beginInputDelivery() {
if (finished || cancelRequested) return;
if (!waitingForInputGuard) return deliverAuthorizedInput();
// Readiness can wait for seconds. Revalidate the chat/privacy snapshot
// immediately before any probe, command, control byte, or synthetic echo.
// The worker guard is async, so cancellation must remain pre-input until
// it settles and a late reply must never resume a cancelled job.
const guardTimer = setTimeout(() => rejectInput(new Error("Execution authorization timed out; no command was sent")), 30000);
cleanupFns.push(() => clearTimeout(guardTimer));
function rejectInput(error) {
clearTimeout(guardTimer);
if (finished || cancelRequested) return;
readinessFailureCode = "EXECUTION_NOT_AUTHORIZED";
finish("", -1, error?.message || "Execution authorization failed; no command was sent");
}
function acceptInput() {
clearTimeout(guardTimer);
if (finished || cancelRequested) return;
// A worker authorization round trip can outlive the prompt itself.
if (typeof getReadyPrompt === "function") {
let prompt;
try { prompt = getReadyPrompt(); } catch { /* fail closed below */ }
if (!prompt) {
readinessFailureCode = "SHELL_NOT_READY";
finish("", -1, "Shell prompt changed while checking authorization; no command was sent");
return;
}
}
waitingForInputGuard = false;
deliverAuthorizedInput();
}
try {
onEchoSuppressionPrime(marker);
} catch {
// Display suppression must never prevent the command from starting.
const pending = beforeInput();
if (pending && typeof pending.then === "function") {
Promise.resolve(pending).then(acceptInput, rejectInput);
} else {
acceptInput();
}
} catch (error) {
rejectInput(error);
}
}
if (probingShell) {
jobDiag.emit("probe-write", { bytes: Buffer.byteLength(buildLiveShellProbe(marker), "utf8") });
writeInput(`${(skipPendingInputClear ? "" : buildPendingInputClearPrefix(resolvedShellKind))}${buildLiveShellProbe(marker)}`);
function deliverAuthorizedInput() {
if (finished || cancelRequested) return;
echoPendingCommand();
if (typeof onEchoSuppressionPrime === "function") {
try {
onEchoSuppressionPrime(marker);
} catch {
// Display suppression must never prevent the command from starting.
}
}
if (probingShell) {
jobDiag.emit("probe-write", { bytes: Buffer.byteLength(buildLiveShellProbe(marker), "utf8") });
writeInput(`${(skipPendingInputClear ? "" : buildPendingInputClearPrefix(resolvedShellKind))}${buildLiveShellProbe(marker)}`);
} else {
writeWrappedCommand();
}
}
if (waitingForPrompt) {
const waitBudget = Math.max(1, Math.min(Number(promptWaitTimeoutMs) || 30000, 30000));
const deadline = Date.now() + waitBudget;
let readinessTimer;
cleanupFns.push(() => clearTimeout(readinessTimer));
const checkReady = () => {
if (finished || cancelRequested) return;
let prompt;
try { prompt = getReadyPrompt(); } catch {
readinessFailureCode = "SHELL_NOT_READY";
finish("", -1, "Could not inspect shell prompt; no command was sent");
return;
}
if (prompt) {
waitingForPrompt = false;
expectedPrompt = prompt;
resolvedShellKind = resolveEffectiveShellKind(shellKind, expectedPrompt, { loginShellHint });
usesLiveShellProbe = probeLiveShell && ["posix", "fish"].includes(resolvedShellKind);
probingShell = usesLiveShellProbe;
waitForReturnedPrompt = loginShellHint === "cmd" && resolvedShellKind === "powershell";
beginInputDelivery();
return;
}
if (Date.now() >= deadline) {
readinessFailureCode = "SHELL_NOT_READY";
finish("", -1, "Shell prompt is not ready (startup, busy program, pending input, or unrecognized prompt); no command was sent");
return;
}
readinessTimer = setTimeout(checkReady, Math.min(50, deadline - Date.now()));
};
checkReady();
} else {
writeWrappedCommand();
beginInputDelivery();
}
return {
@@ -956,6 +1061,9 @@ function startPtyJob(ptyStream, command, options) {
* @param {string} [options.chatSessionId] - Chat session ID for scoped cancellation
* @param {AbortSignal} [options.abortSignal] - AbortSignal to cancel execution
* @param {string} [options.expectedPrompt] - Last observed idle prompt for exact fallback matching
* @param {() => string} [options.getReadyPrompt] - Read current prompt; wait without writing until non-empty
* @param {function} [options.beforeInput] - Revalidate authorization before the first PTY byte (may return a promise)
* @param {number} [options.promptWaitTimeoutMs=30000] - Bounded pre-input wait (maximum 30 seconds)
* @param {boolean} [options.typedInput=false] - Emit synthetic command echo before execution
* @param {(command: string) => void} [options.echoCommand] - Callback used to display synthetic command echo
* @param {string} [options.jobId] - Background job ID for #3443 startup diagnostics keying
@@ -0,0 +1,160 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { EventEmitter } = require('node:events');
const { startPtyJob } = require('./ptyExec.cjs');
class TestPty extends EventEmitter {
writes = [];
write(data) {
this.writes.push(String(data));
const marker = String(data).match(/(__NCMCP_[a-z0-9]+_[0-9a-f]+__)/i)?.[1];
if (marker) queueMicrotask(() => this.emit('data', `${marker}_S\nOK\n${marker}_E:0\n`));
}
}
test('PTY sends no bytes while a slow shell is starting, then runs once ready', async () => {
const pty = new TestPty();
let prompt = '';
const job = startPtyJob(pty, 'echo OK', { shellKind:'posix', getReadyPrompt:()=>prompt, promptWaitTimeoutMs:200, timeoutMs:1000 });
await new Promise(resolve => setTimeout(resolve, 20));
assert.deepEqual(pty.writes, []);
prompt = 'user@host:~$ ';
const result = await job.resultPromise;
assert.equal(result.ok, true);
assert.equal(pty.writes.length, 1);
});
test('PTY readiness timeout does not send command or interrupt to a busy program', async () => {
const pty = new TestPty();
const job = startPtyJob(pty, 'echo OK', { getReadyPrompt:()=>'', promptWaitTimeoutMs:25, timeoutMs:1000 });
const result = await job.resultPromise;
assert.equal(result.ok, false);
assert.equal(result.code, 'SHELL_NOT_READY');
assert.deepEqual(pty.writes, []);
});
test('cancel while waiting for prompt never sends Ctrl+C or a delayed command', async () => {
const pty = new TestPty();
let prompt = '';
const job = startPtyJob(pty, 'echo OK', { getReadyPrompt:()=>prompt, promptWaitTimeoutMs:200, timeoutMs:1000 });
job.cancel();
prompt = 'user@host:~$ ';
const result = await job.resultPromise;
await new Promise(resolve => setTimeout(resolve, 60));
assert.equal(result.error, 'Cancelled');
assert.deepEqual(pty.writes, []);
});
test('closing stream during readiness wait finishes without typing', async () => {
const pty = new TestPty();
const job = startPtyJob(pty, 'echo OK', { getReadyPrompt:()=>'', promptWaitTimeoutMs:200, timeoutMs:1000 });
pty.emit('close');
assert.equal((await job.resultPromise).ok, false);
assert.deepEqual(pty.writes, []);
});
test('readiness failure does not echo a command or interrupt a waiting program', async () => {
const pty = new TestPty();
const echoes = [];
const interrupts = [];
const result = await startPtyJob(pty, 'echo OK', {
getReadyPrompt: () => '', promptWaitTimeoutMs: 25,
typedInput: true, echoCommand: value => echoes.push(value),
onInterrupt: () => interrupts.push(true),
}).resultPromise;
assert.equal(result.code, 'SHELL_NOT_READY');
assert.deepEqual(pty.writes, []);
assert.deepEqual(echoes, []);
assert.deepEqual(interrupts, []);
});
test('wall deadline during startup fails without writing or interrupting', async () => {
const pty = new TestPty();
const result = await startPtyJob(pty, 'echo OK', {
getReadyPrompt: () => '', promptWaitTimeoutMs: 1000,
timeoutMs: 25, enforceWallTimeout: true,
}).resultPromise;
assert.equal(result.code, 'SHELL_NOT_READY');
assert.deepEqual(pty.writes, []);
});
test('a prompt inspection error fails closed without typing', async () => {
const pty = new TestPty();
const result = await startPtyJob(pty, 'echo OK', {
getReadyPrompt: () => { throw new Error('session disappeared'); },
}).resultPromise;
assert.equal(result.code, 'SHELL_NOT_READY');
assert.deepEqual(pty.writes, []);
});
test('cancel during asynchronous input authorization never writes after a late grant', async () => {
const pty = new TestPty();
let authorize;
let entered;
const authorizationStarted = new Promise(resolve => { entered = resolve; });
const job = startPtyJob(pty, 'echo forbidden', {
getReadyPrompt: () => 'user@host:~$ ',
beforeInput: () => {
entered();
return new Promise(resolve => { authorize = resolve; });
},
});
await authorizationStarted;
job.cancel();
assert.equal((await job.resultPromise).error, 'Cancelled');
authorize();
await new Promise(resolve => setImmediate(resolve));
assert.deepEqual(pty.writes, []);
});
test('input authorization rejects without command echo or control bytes', async () => {
const pty = new TestPty();
const echoes = [];
const result = await startPtyJob(pty, 'echo forbidden', {
getReadyPrompt: () => 'user@host:~$ ',
beforeInput: async () => { throw new Error('Authorization revoked'); },
typedInput: true, echoCommand: value => echoes.push(value),
}).resultPromise;
assert.equal(result.code, 'EXECUTION_NOT_AUTHORIZED');
assert.deepEqual(pty.writes, []);
assert.deepEqual(echoes, []);
});
test('a prompt lost during asynchronous authorization fails closed', async () => {
const pty = new TestPty();
let prompt = 'user@host:~$ ';
const result = await startPtyJob(pty, 'echo forbidden', {
getReadyPrompt: () => prompt,
beforeInput: async () => { prompt = ''; },
}).resultPromise;
assert.equal(result.code, 'SHELL_NOT_READY');
assert.deepEqual(pty.writes, []);
});
test('stalled input authorization is bounded without interrupting the PTY', async (t) => {
t.mock.timers.enable({ apis: ['Date', 'setTimeout'] });
const pty = new TestPty();
const job = startPtyJob(pty, 'echo forbidden', {
getReadyPrompt: () => 'user@host:~$ ',
beforeInput: () => new Promise(() => {}),
});
t.mock.timers.tick(30001);
const result = await job.resultPromise;
assert.equal(result.code, 'EXECUTION_NOT_AUTHORIZED');
assert.deepEqual(pty.writes, []);
});
test('wall deadline during authorization reports authorization failure rather than readiness', async (t) => {
t.mock.timers.enable({ apis: ['Date', 'setTimeout'] });
const pty = new TestPty();
const job = startPtyJob(pty, 'echo forbidden', {
getReadyPrompt: () => 'user@host:~$ ',
beforeInput: () => new Promise(() => {}),
timeoutMs: 25, enforceWallTimeout: true,
});
t.mock.timers.tick(26);
const result = await job.resultPromise;
assert.equal(result.code, 'EXECUTION_NOT_AUTHORIZED');
assert.match(result.error, /authorization timed out/);
assert.deepEqual(pty.writes, []);
});
@@ -196,6 +196,7 @@ function registerCattyExecHandlers(ctx) {
return { ok: false, error: `Command blocked by safety policy. Pattern: ${safety.matchedPattern}` };
}
return execViaPty(ptyStream, command, {
beforeInput: beforeExecute,
onInterrupt: () => clearSessionFlowState(session),
stripMarkers: true,
trackForCancellation: mcpServerBridge.activePtyExecs,
@@ -215,6 +216,8 @@ function registerCattyExecHandlers(ctx) {
},
chatSessionId,
expectedPrompt: getFreshIdlePrompt(session),
getReadyPrompt: typeof session._promptTrackTail === "string"
? () => getFreshIdlePrompt(session) : undefined,
typedInput: true,
echoCommand: (rawCommand) => {
const contents = electronModule?.webContents?.fromId?.(session.webContentsId);
@@ -152,3 +152,32 @@ test("Catty terminal rechecks a deleted chat after shell probing and releases it
assert.equal(ctx.effects.length, 0);
assert.deepEqual(ctx.locks, ["released"]);
});
test('Catty main PTY fallback revalidates privacy after prompt readiness', async (t) => {
t.mock.timers.enable({ apis: ['Date', 'setTimeout'] });
const service = createAiPrivacyService();
service.checkSession('pinned');
const pty = new (require('node:events').EventEmitter)();
const writes = [];
pty.write = data => writes.push(data);
let prompt = '';
const ctx = harness(service, {
sessions: new Map([['session', { protocol: 'local', shellKind: 'posix', pty, _promptTrackTail: '' }]]),
ensureSessionShellKindForExec: async () => ({ ok: true }),
execViaPty: require('../ai/ptyExec.cjs').execViaPty,
getFreshIdlePrompt: () => prompt,
});
const pending = ctx.exec('pinned');
await new Promise(resolve => setImmediate(resolve));
assert.deepEqual(writes, []);
service.configure({ enabled: true });
prompt = 'user@host:~$ ';
t.mock.timers.tick(50);
await new Promise(resolve => setImmediate(resolve));
pty.emit('close');
const result = await pending;
assert.deepEqual(writes, []);
assert.equal(result.code, 'EXECUTION_NOT_AUTHORIZED');
assert.match(result.error, /privacy session is unavailable/);
assert.deepEqual(ctx.locks, ['released']);
});
+17 -6
View File
@@ -1,3 +1,5 @@
const { randomUUID } = require("node:crypto");
/**
* Dirty-editor guard helper.
*
@@ -17,7 +19,8 @@
*
* Sends "app:query-dirty-editors" to the given webContents and resolves with
* the renderer's reply. Resolves `false` (fail-open) if the renderer never
* answers within `timeoutMs`, if the webContents can't be messaged, or if the
* answers within `timeoutMs` without acknowledging unfinished transfers,
* if the webContents can't be messaged, or if the
* send throws — in every one of those cases there is no usable UI to surface a
* "save first" warning on, so blocking the quit would only strand the user.
*
@@ -31,7 +34,7 @@
* @param {number} timeoutMs - Max time to wait for the renderer reply.
* @param {{ ipcMain?: import("electron").IpcMain }} [options] - Inject ipcMain
* for tests; defaults to electron's ipcMain.
* @returns {Promise<boolean>} true when the renderer reports unsaved changes.
* @returns {Promise<boolean | "transfers">} Truthy when closing must be refused.
*/
function queryDirtyEditors(webContents, timeoutMs, options = {}) {
const ipcMain = options.ipcMain || resolveIpcMain();
@@ -43,6 +46,8 @@ function queryDirtyEditors(webContents, timeoutMs, options = {}) {
}
return new Promise((resolve) => {
const request = { requestId: randomUUID(), deadline: Date.now() + timeoutMs };
let transfersPending = false;
let settled = false;
let timeoutId = null;
@@ -62,7 +67,11 @@ function queryDirtyEditors(webContents, timeoutMs, options = {}) {
// Use `.on` (not `.once`) so a rogue reply from another window doesn't
// consume the listener slot and let the real reply fall through. A
// missing/falsy sender is anomalous and treated as a wrong-window reply.
if (evt?.sender !== webContents) return;
if (evt?.sender !== webContents || payload?.requestId !== request.requestId) return;
if (payload.pending === true && payload.hasDirty === true && payload.reason === "transfers") {
transfersPending = true;
return;
}
// "transfers": the user already declined the renderer's unfinished-
// transfer quit prompt. Still truthy, so existing callers keep quit
// cancelled; handleBeforeQuit uses it to skip the editor dialog.
@@ -71,11 +80,13 @@ function queryDirtyEditors(webContents, timeoutMs, options = {}) {
ipcMain.on("app:dirty-editors-result", onResult);
// Timeout fallback: if the renderer never replies (crash, unhandled
// exception in its listener, etc.) we must not hang forever. Fail open.
timeoutId = setTimeout(() => settle(false), timeoutMs);
// exception in its listener, etc.) we must not hang forever. Once the
// renderer identifies unfinished transfers, silence is not permission to
// discard them. Keep the ordinary unresponsive-editor fallback unchanged.
timeoutId = setTimeout(() => settle(transfersPending ? "transfers" : false), timeoutMs);
try {
webContents.send("app:query-dirty-editors");
webContents.send("app:query-dirty-editors", request);
} catch (err) {
// webContents.send can throw if the renderer was destroyed between the
// isCrashed?.() check above and this call (a real race when the GPU
+53 -9
View File
@@ -32,7 +32,8 @@ function makeWebContents() {
const sent = [];
return {
sent,
send(channel) {
send(channel, request) {
this.request = request;
sent.push(channel);
},
isDestroyed() {
@@ -52,7 +53,7 @@ test("resolves true when the renderer reports dirty editors", async () => {
// The request is sent on the queried webContents.
assert.deepEqual(wc.sent, ["app:query-dirty-editors"]);
// Renderer replies from the same webContents.
ipcMain.emit("app:dirty-editors-result", { sender: wc }, { hasDirty: true });
ipcMain.emit("app:dirty-editors-result", { sender: wc }, { requestId: wc.request.requestId, hasDirty: true });
assert.equal(await promise, true);
// Listener was torn down.
@@ -64,7 +65,7 @@ test("resolves false when the renderer reports no dirty editors", async () => {
const wc = makeWebContents();
const promise = queryDirtyEditors(wc, 5000, { ipcMain });
ipcMain.emit("app:dirty-editors-result", { sender: wc }, { hasDirty: false });
ipcMain.emit("app:dirty-editors-result", { sender: wc }, { requestId: wc.request.requestId, hasDirty: false });
assert.equal(await promise, false);
assert.equal(ipcMain.listenerCount("app:dirty-editors-result"), 0);
@@ -75,7 +76,7 @@ test("a missing hasDirty payload is treated as not-dirty (fail open)", async ()
const wc = makeWebContents();
const promise = queryDirtyEditors(wc, 5000, { ipcMain });
ipcMain.emit("app:dirty-editors-result", { sender: wc }, undefined);
ipcMain.emit("app:dirty-editors-result", { sender: wc }, { requestId: wc.request.requestId });
assert.equal(await promise, false);
});
@@ -96,7 +97,7 @@ test("ignores replies from a different webContents, then times out to false", as
// A rogue reply from another window claims dirty — it must be ignored, and
// the listener must remain installed (we use .on, not .once).
ipcMain.emit("app:dirty-editors-result", { sender: otherWc }, { hasDirty: true });
ipcMain.emit("app:dirty-editors-result", { sender: otherWc }, { requestId: wc.request.requestId, hasDirty: true });
assert.equal(ipcMain.listenerCount("app:dirty-editors-result"), 1);
// No real reply ever arrives → the timeout fires and resolves false.
@@ -121,7 +122,7 @@ test("a falsy sender on the reply is rejected (treated as wrong window)", async
};
try {
const promise = queryDirtyEditors(wc, 5000, { ipcMain });
ipcMain.emit("app:dirty-editors-result", { sender: null }, { hasDirty: true });
ipcMain.emit("app:dirty-editors-result", { sender: null }, { requestId: wc.request.requestId, hasDirty: true });
// Not settled by the anomalous reply.
assert.equal(ipcMain.listenerCount("app:dirty-editors-result"), 1);
timeoutFn();
@@ -144,7 +145,7 @@ test("a late timeout cannot override an already-received reply", async () => {
try {
const promise = queryDirtyEditors(wc, 5000, { ipcMain });
// Real reply: dirty=true.
ipcMain.emit("app:dirty-editors-result", { sender: wc }, { hasDirty: true });
ipcMain.emit("app:dirty-editors-result", { sender: wc }, { requestId: wc.request.requestId, hasDirty: true });
// A stale timeout fires afterwards — it must be a no-op (settled guard).
timeoutFn();
assert.equal(await promise, true);
@@ -226,10 +227,53 @@ test("carries a declined unfinished-transfer prompt as a truthy \"transfers\" re
const ipcMain = makeIpcMain();
const wc = makeWebContents();
const promise = queryDirtyEditors(wc, 5000, { ipcMain });
ipcMain.emit("app:dirty-editors-result", { sender: wc }, { hasDirty: true, reason: "transfers" });
ipcMain.emit("app:dirty-editors-result", { sender: wc }, { requestId: wc.request.requestId, hasDirty: true, reason: "transfers" });
assert.equal(await promise, "transfers");
// A clean reply that names a reason is still clean.
const clean = queryDirtyEditors(wc, 5000, { ipcMain });
ipcMain.emit("app:dirty-editors-result", { sender: wc }, { hasDirty: false, reason: "transfers" });
ipcMain.emit("app:dirty-editors-result", { sender: wc }, { requestId: wc.request.requestId, hasDirty: false, reason: "transfers" });
assert.equal(await clean, false);
});
test("unfinished-transfer acknowledgement changes only that request's timeout to fail closed", async () => {
const ipcMain = makeIpcMain();
const wc = makeWebContents();
const waiting = queryDirtyEditors(wc, 20, { ipcMain });
ipcMain.emit("app:dirty-editors-result", { sender: wc }, {
requestId: wc.request.requestId, hasDirty: true, reason: "transfers", pending: true,
});
assert.equal(await waiting, "transfers");
assert.equal(ipcMain.listenerCount("app:dirty-editors-result"), 0);
assert.equal(await queryDirtyEditors(wc, 20, { ipcMain }), false);
});
test("an expired query's late acceptance cannot authorize a later close", async () => {
const ipcMain = makeIpcMain();
const wc = makeWebContents();
const first = queryDirtyEditors(wc, 20, { ipcMain });
const oldRequest = wc.request;
ipcMain.emit("app:dirty-editors-result", { sender: wc }, {
requestId: oldRequest.requestId, hasDirty: true, reason: "transfers", pending: true,
});
assert.equal(await first, "transfers");
const second = queryDirtyEditors(wc, 1000, { ipcMain });
assert.notEqual(wc.request.requestId, oldRequest.requestId);
ipcMain.emit("app:dirty-editors-result", { sender: wc }, { requestId: oldRequest.requestId, hasDirty: false });
assert.equal(ipcMain.listenerCount("app:dirty-editors-result"), 1);
ipcMain.emit("app:dirty-editors-result", { sender: wc }, {
requestId: wc.request.requestId, hasDirty: true, reason: "transfers",
});
assert.equal(await second, "transfers");
assert.equal(ipcMain.listenerCount("app:dirty-editors-result"), 0);
});
test("wrong-window pending acknowledgement cannot change the timeout fallback", async () => {
const ipcMain = makeIpcMain();
const wc = makeWebContents();
const waiting = queryDirtyEditors(wc, 20, { ipcMain });
ipcMain.emit("app:dirty-editors-result", { sender: makeWebContents() }, {
requestId: wc.request.requestId, hasDirty: true, reason: "transfers", pending: true,
});
assert.equal(await waiting, false);
});
+4 -1
View File
@@ -306,6 +306,7 @@ async function syncFileChangeOnce(watchId, watchInfo) {
const transferId = `filewatch-${watchId}-${++watchInfo.syncSequence}`;
watchInfo.activeTransferId = transferId;
let warning;
console.log(`[FileWatcher] Streaming ${stat.size} bytes to ${remotePath}`);
try {
const result = await transferBridge.startInternalTransfer(null, {
@@ -324,18 +325,20 @@ async function syncFileChangeOnce(watchId, watchInfo) {
throw new Error("Transfer cancelled");
}
if (result?.error) throw new Error(result.error);
warning = result?.warning;
} finally {
if (watchInfo.activeTransferId === transferId) watchInfo.activeTransferId = null;
}
if (watchInfo.stopped || activeWatchers.get(watchId) !== watchInfo) return;
console.log(`[FileWatcher] Sync complete: ${remotePath}`);
showSystemNotification("Netcatty", `File synced to remote: ${fileName}`);
showSystemNotification("Netcatty", warning || `File synced to remote: ${fileName}`);
notifyWatchOwners(watchInfo, "netcatty:filewatch:synced", {
watchId,
localPath,
remotePath,
bytesWritten: stat.size,
...(warning ? { warning } : {}),
});
}
+6 -2
View File
@@ -229,7 +229,8 @@ test("session close cancels a watch whose initial stat is still pending", async
assert.equal(harness.listenersByPath.has(harness.localPath), false);
});
test("external edit sync streams through the unified transfer runtime without buffering the whole file", async (t) => {
for (const warning of [undefined, "Upload completed; remove backup /remote/.netcatty-backup-edit.txt manually."]) {
test(`external edit sync streams without buffering and ${warning ? "preserves a cleanup warning" : "reports clean success"}`, async (t) => {
const harness = createHarness(t);
const transferBridge = require("./transferBridge.cjs");
const originalStartInternalTransfer = transferBridge.startInternalTransfer;
@@ -241,7 +242,7 @@ test("external edit sync streams through the unified transfer runtime without bu
transferBridge.startInternalTransfer = async (_event, payload) => {
internalTransfers.push(payload);
return { transferId: payload.transferId };
return { transferId: payload.transferId, ...(warning ? { warning } : {}) };
};
fs.promises.readFile = async (...args) => {
wholeFileReadCalls += 1;
@@ -294,7 +295,10 @@ test("external edit sync streams through the unified transfer runtime without bu
assert.equal(internalTransfers[0].targetSftpId, harness.sftpId);
assert.equal(wholeFileReadCalls, 0);
assert.equal(directPutCalls, 0);
assert.equal(sent.find((event) => event.channel === "netcatty:filewatch:synced")?.payload.warning, warning);
assert.equal(sent.some((event) => event.channel === "netcatty:filewatch:error"), false);
});
}
test("closing the SFTP session cancels an active external edit stream", async (t) => {
const harness = createHarness(t);
+5 -4
View File
@@ -1674,17 +1674,17 @@ const dispatchCapabilityRpc = createCapabilityRpcDispatcher({
// The renderer creates the tab before replying to host_open. If the user
// explicitly closes it while that reply is still in flight, the eventual
// result must not revive ownership or scope metadata for the closed tab.
if ((closedTerminalSessionRevisions.get(sessionId) || 0) > generation?.closeRevision) return;
if ((closedTerminalSessionRevisions.get(sessionId) || 0) > generation?.closeRevision) return false;
// Reuse adds visibility only: never grant ownership or change the original
// owner's idle lifecycle merely because another chat referenced the tab.
if (result?.reused === true) {
if (generation?.ownershipGeneration?.revoked) {
// A late renderer merge must not recreate a deleted chat's scope.
scopedMetadata.delete(chatSessionId);
return;
return false;
}
ensureOpenedSessionMetadata(chatSessionId, sessionId, result);
return;
return true;
}
if (!openedSessionOwnership.register(
chatSessionId,
@@ -1704,7 +1704,7 @@ const dispatchCapabilityRpc = createCapabilityRpcDispatcher({
// again. Remove only scope/live metadata here so a deleted chat cannot be
// recreated by the renderer's late merge.
forgetUnownedTerminalSessionMetadata(sessionId);
return;
return false;
}
closedTerminalSessionRevisions.delete(sessionId);
// A full empty scope replace can land after the renderer's best-effort
@@ -1712,6 +1712,7 @@ const dispatchCapabilityRpc = createCapabilityRpcDispatcher({
// succeeds, restore the returned session atomically with its ownership.
ensureOpenedSessionMetadata(chatSessionId, sessionId, result);
sessionIdleManager.track(chatSessionId, sessionId);
return true;
},
});
@@ -327,3 +327,65 @@ test("expired local and unknown jobs do not claim a lost worker route", (t) => {
"job-missing", "job-missing", "job-missing", "job-missing", "scope-mismatch",
]);
});
test("MCP background readiness timeout survives job polling without PTY writes", async (t) => {
t.mock.timers.enable({ apis: ["Date", "setTimeout"] });
const pty = new FakePty();
const session = { protocol: "local", pty, shellKind: "posix", _promptTrackTail: "" };
const ctx = createExecHandlerTestContext({
sessions: new Map([["not-ready", session]]), backgroundJobs: new Map(),
});
ctx.startPtyJob = require("./ai/ptyExec.cjs").startPtyJob;
const api = createExecHandlerApi(ctx);
const started = await api.handleJobStart({
sessionId: "not-ready", chatSessionId: "readiness-chat", command: "echo must-not-run",
});
assert.equal(started.ok, true);
t.mock.timers.tick(30001);
await nextTick();
const polled = api.handleJobPoll({ jobId: started.jobId, chatSessionId: "readiness-chat" });
assert.equal(polled.status, "failed");
assert.equal(polled.completed, true);
assert.equal(polled.code, "SHELL_NOT_READY");
assert.equal(ctx.activeSessionExecutions.size, 0);
assert.deepEqual(pty.writes, []);
ctx.getFreshIdlePrompt = () => "user@host:~$ ";
t.mock.timers.tick(1000);
assert.deepEqual(pty.writes, [], "late readiness must not deliver the failed job");
});
for (const route of ['exec', 'job']) {
test(`MCP ${route} revalidates privacy when the awaited prompt becomes ready`, async (t) => {
t.mock.timers.enable({ apis: ['Date', 'setTimeout'] });
const privacy = createAiPrivacyService();
privacy.checkSession('readiness-chat');
const pty = new FakePty();
const session = { protocol: 'local', pty, shellKind: 'posix', _promptTrackTail: '' };
const ctx = createExecHandlerTestContext({
sessions: new Map([['waiting', session]]), backgroundJobs: new Map(),
});
let prompt = '';
ctx.getFreshIdlePrompt = () => prompt;
Object.assign(ctx, require('./ai/ptyExec.cjs'));
const api = createExecHandlerApi(ctx);
const pending = (route === 'job' ? api.handleJobStart : api.handleExec)({
sessionId: 'waiting', chatSessionId: 'readiness-chat', command: 'echo forbidden',
}, { beforeExecute: () => privacy.restore('readiness-chat', []) });
await nextTick();
assert.deepEqual(pty.writes, []);
privacy.configure({ enabled: true });
prompt = 'user@host:~$ ';
t.mock.timers.tick(50);
await nextTick();
// Also clean up on the old implementation, where a probe was already sent.
pty.emit('close');
const startedOrFinished = await pending;
const result = route === 'job'
? api.handleJobPoll({ jobId: startedOrFinished.jobId, chatSessionId: 'readiness-chat' })
: startedOrFinished;
assert.deepEqual(pty.writes, [], 'retired privacy session must not send even probe/control bytes');
assert.match(result.error, /start a new chat/);
assert.equal(ctx.activeSessionExecutions.size, 0);
});
}
@@ -659,7 +659,9 @@ test("explicit close before host_open returns cannot revive the closed session",
protocol: "ssh",
});
assert.equal((await opening).ok, true);
const rejected = await opening;
assert.equal(rejected.ok, false);
assert.equal(rejected.code, "SESSION_SCOPE_BIND_FAILED");
bridge.updateSessionMetadata([{ sessionId: "sess-original", connected: true }], "chat-close-race");
assert.deepEqual(bridge.getScopedSessionIds("chat-close-race"), ["sess-original"]);
assert.equal(bridge.getSessionMeta("sess-opened", "chat-close-race"), null);
@@ -773,7 +775,9 @@ test("chat cleanup during host_open closes the late-created unowned session", as
protocol: "ssh",
});
assert.equal((await opening).ok, true);
const rejected = await opening;
assert.equal(rejected.ok, false);
assert.equal(rejected.code, "SESSION_SCOPE_BIND_FAILED");
assert.deepEqual(calls.map(({ op }) => op), ["host.open", "session.close"]);
assert.deepEqual(bridge.getScopedSessionIds("chat-deleted"), []);
assert.equal(bridge.getSessionMeta("sess-opened", "chat-deleted"), null);
@@ -809,7 +813,9 @@ test("failed compensating close keeps the late-created session tracked for retry
}], "chat-deleted");
finishOpen({ ok: true, sessionId: "sess-opened", hostId: "host-b", status: "connecting" });
assert.equal((await opening).ok, true);
const rejected = await opening;
assert.equal(rejected.ok, false);
assert.equal(rejected.code, "SESSION_SCOPE_BIND_FAILED");
assert.deepEqual(bridge.getScopedSessionIds("chat-deleted"), []);
assert.equal(bridge.getSessionMeta("sess-opened", "chat-deleted"), null);
assert.equal(
@@ -61,7 +61,10 @@ test('a deleted chat cannot reclaim a reused tab or revive its own scope', async
await bridge.cleanupScopedMetadata('deleted');
bridge.mergeSessionMetadata([{ sessionId: 'user-tab', connected: true }], 'deleted');
finishOpen({ ok: true, reused: true, sessionId: 'user-tab', status: 'connected' });
assert.equal((await opening).ok, true);
const rejected = await opening;
assert.equal(rejected.ok, false);
assert.equal(rejected.code, 'SESSION_SCOPE_BIND_FAILED');
assert.equal(rejected.sessionId, 'user-tab');
assert.deepEqual(calls, ['host.open']);
assert.deepEqual(bridge.getScopedSessionIds('deleted'), []);
assert.deepEqual(bridge.getScopedSessionIds('original'), ['user-tab']);
@@ -279,6 +279,7 @@ function createBackgroundJobApi(ctx) {
completed: job.status !== "running" && job.status !== "stopping",
exitCode: job.exitCode,
error: job.error,
...(job.code ? { code: job.code } : {}),
startedAt: job.startedAt,
updatedAt: job.updatedAt,
output: collapseCarriageReturns(stdout.slice(relativeOffset)),
@@ -201,7 +201,11 @@ function createCapabilityRpcDispatcher(deps) {
approvalContext.beforeExecute?.();
const result = await handler(params, approvalContext);
if (capability.id === "vault.host.open" && result?.ok !== false && result?.sessionId) {
await deps.onHostOpened?.(params?.chatSessionId, result.sessionId, hostOpenGeneration, result);
const bound = await deps.onHostOpened?.(params?.chatSessionId, result.sessionId, hostOpenGeneration, result);
if (bound === false) {
return { ok: false, code: "SESSION_SCOPE_BIND_FAILED", sessionId: result.sessionId, error: "Host opened but its session could not be bound to the requesting chat scope." };
}
if (bound === true) return { ...result, scopeBound: true };
}
return result;
};
@@ -33,6 +33,25 @@ function createTestDispatcher(overrides = {}) {
});
}
test("host open does not report success after its scope binding was revoked", async () => {
const dispatch = createTestDispatcher({
invokeVaultAgent: async () => ({ ok: true, sessionId: "opened-1" }),
onHostOpened: async () => false,
});
const result = await dispatch("vault/hosts/open", { hostId: "host-1", chatSessionId: "chat-1" });
assert.equal(result.ok, false);
assert.equal(result.code, "SESSION_SCOPE_BIND_FAILED");
});
test("host open confirms binding only after the ownership callback completes", async () => {
const dispatch = createTestDispatcher({
invokeVaultAgent: async () => ({ ok: true, sessionId: "opened-1" }),
onHostOpened: async () => true,
});
const result = await dispatch("vault/hosts/open", { hostId: "host-1", chatSessionId: "chat-1" });
assert.equal(result.scopeBound, true);
});
test("dispatchCapabilityRpc returns UNROUTED for netcatty builtin methods", async () => {
const dispatch = createTestDispatcher();
const result = await dispatch("netcatty/exec", { chatSessionId: "chat-1" });
@@ -146,6 +146,7 @@ function createExecHandlerApi(ctx) {
return { ok: false, error: `Command blocked by safety policy. Pattern: ${safety.matchedPattern}` };
}
return execViaPty(ptyStream, command, {
beforeInput: executionContext.beforeExecute,
onInterrupt: () => clearSessionFlowState(session),
trackForCancellation: activePtyExecs,
timeoutMs: commandTimeoutMs,
@@ -157,6 +158,8 @@ function createExecHandlerApi(ctx) {
onProbeAborted: (marker) => echoCommandToSession(session, sessionId, `${marker}_R`, { syntheticEcho: false }),
onEchoSuppressionPrime: (marker) => echoCommandToSession(session, sessionId, `${marker}_I`, { syntheticEcho: false }),
expectedPrompt: getFreshIdlePrompt(session),
getReadyPrompt: typeof session._promptTrackTail === "string"
? () => getFreshIdlePrompt(session) : undefined,
typedInput: true,
echoCommand: (rawCommand) => echoCommandToSession(session, sessionId, rawCommand),
chatSessionId,
@@ -318,6 +321,7 @@ function createExecHandlerApi(ctx) {
try {
executionContext.beforeExecute?.();
handle = startPtyJob(ptyStream, command, {
beforeInput: executionContext.beforeExecute,
onInterrupt: () => clearSessionFlowState(session),
// Intentionally do NOT register in activePtyExecs: terminal_start jobs
// are designed to survive SDK agent "Stop" so the model can stop polling
@@ -336,6 +340,8 @@ function createExecHandlerApi(ctx) {
onEchoSuppressionPrime: (marker) => echoCommandToSession(session, sessionId, `${marker}_I`, { syntheticEcho: false }),
chatSessionId,
expectedPrompt: getFreshIdlePrompt(session),
getReadyPrompt: typeof session._promptTrackTail === "string"
? () => getFreshIdlePrompt(session) : undefined,
typedInput: true,
echoCommand: (rawCommand) => echoCommandToSession(session, sessionId, rawCommand),
maxBufferedChars: MAX_BACKGROUND_JOB_OUTPUT_CHARS,
@@ -356,6 +362,7 @@ function createExecHandlerApi(ctx) {
handle.resultPromise.then((result) => {
job.updatedAt = Date.now();
job.exitCode = result.exitCode ?? null;
job.code = result.code;
storeCompletedJobOutput(job, result.stdout || "", result);
const isForcedCancel = typeof result.error === "string" && result.error.includes("forced");
if (result.error === "Cancelled" || isForcedCancel) {
@@ -493,7 +493,7 @@ function createSftpHandlerApi(ctx) {
return { ok: false, cancelled: true, transferId };
}
if (result?.error) throw new Error(result.error);
reportTransferEvent({ type: "completed", transferId, endedAt: Date.now() });
reportTransferEvent({ type: "completed", transferId, endedAt: Date.now(), ...(result?.warning ? { warning: result.warning } : {}) });
return { ok: true, transferId, ...result };
} catch (error) {
reportTransferEvent({ type: "failed", transferId, endedAt: Date.now(), error: error?.message || String(error) });
+55
View File
@@ -0,0 +1,55 @@
const net = require('node:net');
// Local Windows IPC only: do not interpret remote UNC paths as serial ports.
// Keep in sync with domain/serialEndpoint.ts (covered by the endpoint tests).
function isWindowsNamedPipePath(value) {
return typeof value === 'string' && /^\\\\[.?]\\pipe\\[^\r\n\0]+$/i.test(value);
}
/** A byte stream with the open/close interface used by serial sessions. */
class SerialNamedPipe extends net.Socket {
constructor(path) {
super();
this.path = path;
this.finishOpen = null;
}
open(callback) {
const onConnect = () => finish(null);
const onError = (error) => finish(error);
const finish = (error) => {
if (this.finishOpen !== finish) return;
this.finishOpen = null;
this.removeListener('connect', onConnect);
this.removeListener('error', onError);
callback(error);
};
this.finishOpen = finish;
this.once('connect', onConnect);
this.once('error', onError);
try {
this.connect(this.path);
} catch (error) {
finish(error);
}
}
close() {
// Cancel even before connect completes; a superseded boot must not linger.
this.destroy();
// Match asynchronous SerialPort open failures so the boot abort can settle
// with its superseded error before this transport-level cancellation.
queueMicrotask(() => this.finishOpen?.(new Error('Named pipe connection closed before opening')));
}
}
function createSerialTransport(options, SerialPort, platform = process.platform) {
const path = options.path?.trim();
if (!isWindowsNamedPipePath(path)) return new SerialPort({ ...options, path });
if (platform !== 'win32') {
throw new Error('Windows named pipes are only supported on Windows');
}
return new SerialNamedPipe(path);
}
module.exports = { isWindowsNamedPipePath, SerialNamedPipe, createSerialTransport };
+110
View File
@@ -0,0 +1,110 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const net = require('node:net');
const os = require('node:os');
const path = require('node:path');
const { randomUUID } = require('node:crypto');
const { once } = require('node:events');
const { SerialNamedPipe, isWindowsNamedPipePath, createSerialTransport } = require('./serialNamedPipe.cjs');
const endpoint = () => process.platform === 'win32'
? `\\\\.\\pipe\\netcatty-test-${randomUUID()}`
: path.join(os.tmpdir(), `nc-${randomUUID()}.sock`);
async function echoServer(t) {
const address = endpoint();
const peers = new Set();
const server = net.createServer((socket) => {
peers.add(socket);
socket.on('close', () => peers.delete(socket));
socket.on('error', () => {});
socket.on('data', (data) => socket.write(data));
});
server.listen(address);
await once(server, 'listening');
t.after(async () => {
for (const socket of peers) socket.destroy();
await new Promise((resolve) => server.close(resolve));
});
return { address, peers, server };
}
function open(port) {
return new Promise((resolve, reject) => port.open((error) => error ? reject(error) : resolve()));
}
test('recognizes only local Windows named pipe paths', () => {
for (const value of [String.raw`\\.\pipe\vm`, String.raw`\\?\pipe\VM console`, String.raw`\\.\PIPE\vm\console`]) {
assert.equal(isWindowsNamedPipePath(value), true, value);
}
for (const value of [undefined, '', '\\\\.\\pipe\\', String.raw`\\server\pipe\vm`, 'COM1', '/dev/ttyS0', '\\\\.\\pipe\\vm\n']) {
assert.equal(isWindowsNamedPipePath(value), false, String(value));
}
});
test('real IPC stream carries binary input/output and can reconnect after close', { timeout: 10000 }, async (t) => {
const { address } = await echoServer(t);
for (let attempt = 0; attempt < 2; attempt++) {
const port = new SerialNamedPipe(address);
t.after(() => port.close());
await open(port);
const payload = Buffer.from([0, 1, 8, 13, 27, 127, 128, 255]);
const received = once(port, 'data');
await new Promise((resolve, reject) => port.write(payload, (error) => error ? reject(error) : resolve()));
assert.deepEqual((await received)[0], payload);
const closed = once(port, 'close');
port.close();
await closed;
}
});
test('missing pipe fails opening once without an unhandled error', { timeout: 10000 }, async () => {
const port = new SerialNamedPipe(endpoint());
let callbacks = 0;
const closed = new Promise((resolve) => port.once('close', resolve));
await new Promise((resolve) => port.open((error) => {
callbacks++;
assert.ok(error);
resolve();
}));
await closed;
assert.equal(callbacks, 1);
});
test('closing during open cancels the pending connection once', { timeout: 10000 }, async (t) => {
const { address } = await echoServer(t);
const port = new SerialNamedPipe(address);
let callbacks = 0;
const closed = new Promise((resolve) => port.once('close', resolve));
port.open((error) => {
callbacks++;
assert.match(error.message, /closed before opening/);
});
port.close();
await closed;
assert.equal(callbacks, 1);
assert.equal(port.destroyed, true);
});
test('server disconnection closes the transport', { timeout: 10000 }, async (t) => {
const { address, peers, server } = await echoServer(t);
const port = new SerialNamedPipe(address);
t.after(() => port.close());
await Promise.all([once(server, 'connection'), open(port)]);
const closed = once(port, 'close');
for (const peer of peers) peer.end();
await closed;
assert.equal(port.destroyed, true);
});
test('transport selection preserves hardware settings and trims the path', () => {
class HardwarePort { constructor(options) { this.options = options; } }
const settings = { path: ' COM12 ', baudRate: 9600, parity: 'even', rtscts: true, autoOpen: false };
const hardware = createSerialTransport(settings, HardwarePort, 'win32');
assert.ok(hardware instanceof HardwarePort);
assert.deepEqual(hardware.options, { ...settings, path: 'COM12' });
const pipe = createSerialTransport({ ...settings, path: String.raw`\\.\pipe\vm` }, HardwarePort, 'win32');
assert.ok(pipe instanceof SerialNamedPipe);
pipe.close();
assert.throws(() => createSerialTransport({ path: String.raw`\\.\pipe\vm` }, HardwarePort, 'linux'), /only supported on Windows/);
});
@@ -81,6 +81,10 @@ function loadSftpBridgeWithAuthRetryMocks(t, options = {}) {
setImmediate(() => {
this.client.emit("connect");
this.client.emit("handshake");
if (options.onConnect) {
options.onConnect(this.client);
return;
}
if (options.alwaysSucceed) {
this.client.emit("ready");
return;
@@ -483,3 +487,88 @@ test("single-channel SFTP does not join an in-flight terminal dial", async (t) =
assert.notEqual(MockSftpClient.instances[0].client.ended, true);
assert.equal(sftpClients.has("sftp-during-terminal"), true);
});
test("cancelling a dedicated SFTP login closes its client and rejects before authentication", async (t) => {
const controller = new AbortController();
const { bridge, MockSftpClient } = loadSftpBridgeWithAuthRetryMocks(t, {
onConnect(client) {
controller.abort();
// A racing ready notification must not publish a closed SFTP client.
client.emit("ready");
},
});
const clients = new Map();
bridge.init({ sftpClients: clients, sessions: new Map(), electronModule: {} });
await assert.rejects(bridge.openSftp({ sender: makeSender() }, {
sessionId: "cancelled-login", hostname: "bastion.example", username: "root",
authMethod: "password", password: "saved-login-password", useSshAgent: false,
verifyHostKeys: false, fileProtocol: "sftp", singleChannelSsh: true,
reuseTransport: false, abortSignal: controller.signal,
}), { name: "AbortError" });
assert.equal(MockSftpClient.instances[0].client.ended, true);
assert.equal(MockSftpClient.instances[0].client.destroyed, true);
assert.equal(clients.size, 0);
});
for (const initialTrust of ["unknown", "changed"]) {
test(`dedicated SFTP pins the terminal's accepted ${initialTrust} host key without reconnect`, async (t) => {
const verifier = require("./hostKeyVerifier.cjs");
const { attachSessionSftpDial } = require("./sshBridge/sessionSftpDial.cjs");
const makeKey = (byte) => {
const type = Buffer.from("ssh-ed25519");
const blob = Buffer.alloc(4 + type.length + 4 + 32);
blob.writeUInt32BE(type.length); type.copy(blob, 4);
blob.writeUInt32BE(32, 4 + type.length); blob.fill(byte, 8 + type.length);
return blob;
};
const acceptedRaw = makeKey(1);
const oldRaw = makeKey(2);
let remoteKey = acceptedRaw;
const sender = makeSender();
const acceptedHostKeys = [];
const oldInfo = verifier.describeHostKey(oldRaw);
const options = {
hostname: "gateway.example", port: 22, username: "root",
authMethod: "password", password: "test-only", useSshAgent: false,
verifyHostKeys: true, fileProtocol: "sftp",
knownHosts: initialTrust === "changed" ? [{ hostname: "gateway.example", port: 22, ...oldInfo }] : [],
};
const terminalVerifier = verifier.createHostVerifier({
...options, sender, sessionId: "terminal-trust",
onAccepted: info => acceptedHostKeys.push(info),
});
const terminalAccepted = new Promise(resolve => terminalVerifier(acceptedRaw, resolve));
const request = sender.sent.find(message => message.channel === "netcatty:host-key:verify").payload;
assert.equal(request.status, initialTrust);
verifier.handleResponse({ sender }, { requestId: request.requestId, accept: true, addToKnownHosts: true });
assert.equal(await terminalAccepted, true);
const { bridge } = loadSftpBridgeWithAuthRetryMocks(t, { onConnect(client) {
client.connectOpts.hostVerifier(remoteKey, accept => {
if (accept) client.emit("ready");
else client.emit("error", new Error("Host denied (verification failed)"));
});
} });
const session = { singleChannelSsh: true, conn: Object.assign(new EventEmitter(), { sftp() { throw new Error("must not borrow terminal"); } }), stream: new EventEmitter(), sftpFileProtocol: "sftp" };
const sessions = new Map([["terminal-trust", session]]);
const clients = new Map();
bridge.init({ sessions, sftpClients: clients, electronModule: {} });
attachSessionSftpDial(session, options, {
sessions, sessionId: "terminal-trust", sender, acceptedHostKeys,
openSftp: bridge.openSftp, closeSftp: bridge.closeSftp,
});
// The captured route must not alias mutable caller/collector arrays.
acceptedHostKeys.length = 0;
const opened = await bridge.openSftpForSession(null, { sessionId: "terminal-trust" });
assert.equal(clients.has(opened.sftpId), true);
await bridge.closeSftp(null, opened);
for (const unexpectedKey of [oldRaw, makeKey(3)]) {
remoteKey = unexpectedKey;
await assert.rejects(bridge.openSftpForSession(null, { sessionId: "terminal-trust" }), {
code: "ERR_HOST_KEY_UNVERIFIED", message: /Reconnect the terminal/,
});
}
assert.equal(sender.sent.filter(message => message.channel === "netcatty:host-key:verify").length, 1);
assert.equal(verifier.getRequests().size, 0);
assert.equal(clients.size, 0);
});
}
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff. Show more