mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-12 09:24:24 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f07220649 |
No files matched your search
@@ -82,8 +82,6 @@ on:
|
||||
- "scripts/resolve-et-bin-release.cjs"
|
||||
- "scripts/resolve-mosh-bin-release.cjs"
|
||||
- "scripts/tray-panel-layout.live.test.cjs"
|
||||
- "scripts/serial-named-pipe.live.test.cjs"
|
||||
- "scripts/fixtures/serial/**"
|
||||
- "scripts/verify-linux-*.sh"
|
||||
- "scripts/xterm-macos-column-selection.live.test.cjs"
|
||||
- "skills/**"
|
||||
@@ -253,10 +251,6 @@ jobs:
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Test Windows serial pipe through native Electron
|
||||
if: matrix.name == 'windows'
|
||||
run: npx electron scripts/serial-named-pipe.live.test.cjs
|
||||
|
||||
- name: Test macOS Option column selection
|
||||
if: matrix.name == 'macos'
|
||||
run: npm run test:xterm-macos-selection
|
||||
|
||||
@@ -91,9 +91,6 @@ jobs:
|
||||
- name: Install deps
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Exercise serial named pipe transport on Windows
|
||||
run: node --test electron/bridges/serialNamedPipe.test.cjs
|
||||
|
||||
- name: Apply production dependency patches
|
||||
run: npx patch-package --error-on-fail
|
||||
|
||||
|
||||
@@ -1164,7 +1164,6 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host, hi
|
||||
if (effectiveHost.protocol === 'serial') {
|
||||
const portName = host.hostname.split('/').pop() || host.hostname;
|
||||
const sessionId = connectToHost(effectiveHost, { hidden });
|
||||
if (!sessionId) return null;
|
||||
addConnectionLog({
|
||||
sessionId,
|
||||
hostId: host.id,
|
||||
@@ -1184,7 +1183,6 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host, hi
|
||||
const protocol = resolveEffectiveTerminalProtocol(effectiveHost);
|
||||
const resolvedAuth = resolveHostAuth({ host: effectiveHost, keys, identities });
|
||||
const sessionId = connectToHost(effectiveHost, { hidden });
|
||||
if (!sessionId) return null;
|
||||
addConnectionLog({
|
||||
sessionId,
|
||||
hostId: host.id,
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { ConnectionLog, Host } from '../../domain/models';
|
||||
import { createLocalShellHost } from '../../domain/localShellHost';
|
||||
import { selectConnectionLogForTerminalDataCapture } from '../../domain/connectionLog';
|
||||
import { handleConnectToHostImpl, handleTerminalDataCaptureImpl } from './AppHandlers';
|
||||
|
||||
test('workspace host launch records resolved metadata and associates replay with its acknowledged session', async () => {
|
||||
const host = createLocalShellHost({ id: 'saved-local', label: 'Work shell' });
|
||||
const connectionLogs: ConnectionLog[] = [];
|
||||
let appendedHost: Host | undefined;
|
||||
const getCtx = () => ({
|
||||
resolveEffectiveHost: (candidate: Host) => candidate,
|
||||
discoveredShells: [{ id: 'zsh', name: 'Z shell', command: '/bin/zsh' }],
|
||||
terminalSettings: { localShell: 'zsh', localShellArgs: ['-l'], localStartDir: '/tmp/work' },
|
||||
resolveShellSetting: () => ({ command: '/bin/zsh', args: ['-l'] }),
|
||||
systemInfoRef: { current: { username: 'local-user', hostname: 'local-machine' } },
|
||||
keys: [], identities: [], resolveHostAuth: () => ({ username: 'ignored-ssh-user' }),
|
||||
// The App adapts this creation callback to appendHostToWorkspaceConfirmed.
|
||||
connectToHost: (candidate: Host) => {
|
||||
assert.equal(connectionLogs.length, 0, 'do not record history before insertion is acknowledged');
|
||||
appendedHost = candidate;
|
||||
return 'workspace-session';
|
||||
},
|
||||
addConnectionLog: (entry: Omit<ConnectionLog, 'id'>) => {
|
||||
connectionLogs.push({ ...entry, id: 'workspace-log' });
|
||||
},
|
||||
});
|
||||
assert.equal(handleConnectToHostImpl(getCtx, host), 'workspace-session');
|
||||
assert.equal(appendedHost?.localShell, '/bin/zsh');
|
||||
assert.deepEqual(appendedHost?.localShellArgs, ['-l']);
|
||||
assert.equal(appendedHost?.localStartDir, '/tmp/work');
|
||||
assert.equal(connectionLogs.length, 1);
|
||||
assert.equal(connectionLogs[0].sessionId, 'workspace-session');
|
||||
assert.equal(connectionLogs[0].hostId, host.id);
|
||||
assert.equal(connectionLogs[0].hostLabel, 'Work shell');
|
||||
assert.equal(connectionLogs[0].username, 'local-user');
|
||||
assert.equal(connectionLogs[0].protocol, 'local');
|
||||
assert.equal(connectionLogs[0].localHostname, 'local-machine');
|
||||
const updates: Array<{ id: string; terminalData: string }> = [];
|
||||
await handleTerminalDataCaptureImpl(() => ({
|
||||
IS_DEV: false,
|
||||
sessions: [{ id: 'workspace-session', hostname: 'localhost' }],
|
||||
// A newer, same-host log must not steal this session's capture.
|
||||
connectionLogs: [...connectionLogs, { ...connectionLogs[0], id: 'other-log', sessionId: 'other-session', startTime: Date.now() + 100 }],
|
||||
selectConnectionLogForTerminalDataCapture,
|
||||
updateConnectionLog: async (id: string, entry: { terminalData: string }) => { updates.push({ id, terminalData: entry.terminalData }); },
|
||||
}), 'workspace-session', 'captured terminal output');
|
||||
assert.deepEqual(updates, [{ id: 'workspace-log', terminalData: 'captured terminal output' }]);
|
||||
});
|
||||
|
||||
for (const protocol of ['local', 'ssh', 'serial'] as const) {
|
||||
test(`refused workspace insertion creates no ${protocol} connection log`, () => {
|
||||
let logs = 0;
|
||||
const host = { id: 'host', label: 'Host', hostname: 'target', protocol } as Host;
|
||||
const sessionId = handleConnectToHostImpl(() => ({
|
||||
resolveEffectiveHost: (candidate: Host) => candidate,
|
||||
discoveredShells: [], terminalSettings: {}, resolveShellSetting: () => null,
|
||||
systemInfoRef: { current: { username: 'local-user', hostname: 'local-machine' } },
|
||||
keys: [], identities: [], resolveHostAuth: () => ({ username: 'remote-user' }),
|
||||
connectToHost: () => null,
|
||||
addConnectionLog: () => { logs += 1; },
|
||||
}), host);
|
||||
assert.equal(sessionId, null);
|
||||
assert.equal(logs, 0);
|
||||
});
|
||||
}
|
||||
@@ -56,7 +56,6 @@ import {
|
||||
} from '../../domain/terminalAppearance';
|
||||
import { selectConnectionLogForTerminalDataCapture } from '../../domain/connectionLog';
|
||||
import { collectSessionIds } from '../../domain/workspace';
|
||||
import { resolveAgentHostWorkspace } from '../../domain/agentHostWorkspace';
|
||||
import { useHostOpenSessionReuse } from '../state/useHostOpenSessionReuse';
|
||||
import type { PaneMagnificationController } from '../../domain/paneMagnification';
|
||||
import { resolveCloseIntent } from '../state/resolveCloseIntent';
|
||||
@@ -256,7 +255,6 @@ export function AppSideEffects() {
|
||||
createLocalTerminal,
|
||||
createSerialSession,
|
||||
connectToHost,
|
||||
appendHostToWorkspaceConfirmed,
|
||||
closeSession,
|
||||
closeSessions,
|
||||
closeWorkspace,
|
||||
@@ -1460,7 +1458,7 @@ export function AppSideEffects() {
|
||||
temporaryConnectionSettingsRef.current = terminalSettings;
|
||||
|
||||
// Wrapper to connect to host with logging
|
||||
const handleConnectToHost = useCallback((host: Host, alreadyEffective = false, hidden = false, workspaceId?: string) => {
|
||||
const handleConnectToHost = useCallback((host: Host, alreadyEffective = false, hidden = false) => {
|
||||
const liveTerminalSettings = temporaryConnectionSettingsRef.current;
|
||||
const connectionHost = prepareConnectionHost(
|
||||
host,
|
||||
@@ -1478,9 +1476,7 @@ export function AppSideEffects() {
|
||||
: resolveEffectiveHost;
|
||||
return handleConnectToHostImpl(() => ({
|
||||
addConnectionLog,
|
||||
connectToHost: workspaceId
|
||||
? (candidate: Host) => appendHostToWorkspaceConfirmed(workspaceId, candidate)
|
||||
: connectToHost,
|
||||
connectToHost,
|
||||
discoveredShells,
|
||||
host: connectionHost,
|
||||
identities,
|
||||
@@ -1491,7 +1487,7 @@ export function AppSideEffects() {
|
||||
systemInfoRef,
|
||||
terminalSettings: liveTerminalSettings,
|
||||
}), connectionHost, hidden);
|
||||
}, [addConnectionLog, appendHostToWorkspaceConfirmed, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells]);
|
||||
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells]);
|
||||
|
||||
const handleConnectToHostWithTabName = useCallback((host: Host, tabName?: string) => {
|
||||
const sessionId = handleConnectToHost(host);
|
||||
@@ -1501,14 +1497,12 @@ export function AppSideEffects() {
|
||||
return sessionId;
|
||||
}, [handleConnectToHost, renameSessionInline]);
|
||||
|
||||
const openHostForVaultAgent = useCallback((host: Host, isExternalMcpCall: boolean, options?: { workspaceId?: string }) => {
|
||||
const target = resolveAgentHostWorkspace(options?.workspaceId, activeTabStore.getActiveTabId(), workspaces.map(workspace => workspace.id));
|
||||
if (target.error) return { ok: false as const, error: target.error };
|
||||
const openHostForVaultAgent = useCallback((host: Host, isExternalMcpCall: boolean) => {
|
||||
// Silent sessions only apply to actual external MCP clients (chatSessionId
|
||||
// equals the reserved external-MCP scope). The in-app Catty AI chat's
|
||||
// host_open always opens a visible tab, as documented.
|
||||
const hidden = isExternalMcpCall && readExternalMcpSilentSessions();
|
||||
const sessionId = handleConnectToHost(host, true, hidden, target.workspaceId);
|
||||
const sessionId = handleConnectToHost(host, true, hidden);
|
||||
if (!sessionId) {
|
||||
return { ok: false as const, error: `Failed to open host "${host.id}".` };
|
||||
}
|
||||
@@ -1517,8 +1511,8 @@ export function AppSideEffects() {
|
||||
if (readExternalMcpFocusOnHostOpen()) {
|
||||
void netcattyBridge.get()?.openMainWindow?.();
|
||||
}
|
||||
return { ok: true as const, sessionId, host, workspaceId: target.workspaceId };
|
||||
}, [handleConnectToHost, workspaces]);
|
||||
return { ok: true as const, sessionId, host };
|
||||
}, [handleConnectToHost]);
|
||||
|
||||
const findExistingSessionForHostForVaultAgent = useHostOpenSessionReuse({
|
||||
sessions, hosts, keys, identities, resolveEffectiveHost,
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
// A separate renderer thread lets the real main-process timeout run while
|
||||
// window.confirm is synchronously blocked, just like Electron's native dialog.
|
||||
require("tsx/cjs");
|
||||
const { parentPort, workerData } = require("node:worker_threads");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const React = require("react");
|
||||
const { act, create } = require("react-test-renderer");
|
||||
const { useAppStartupEffects } = require("../useAppStartupEffects.ts");
|
||||
const { sftpTransferCenterStore } = require("../../state/sftpTransferCenterStore.ts");
|
||||
const { netcattyBridge } = require("../../../infrastructure/services/netcattyBridge.ts");
|
||||
const { createPreloadApi } = require("../../../electron/preload/api.cjs");
|
||||
const signal = new Int32Array(workerData.signal);
|
||||
let resumes = 0;
|
||||
global.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
global.window = {
|
||||
confirm() {
|
||||
parentPort.postMessage({ type: "prompt" });
|
||||
Atomics.wait(signal, 0, 0, 10000);
|
||||
return workerData.accept;
|
||||
},
|
||||
};
|
||||
sftpTransferCenterStore.getSnapshot = () => ({ tasks: [{ id: "held-prompt", status: "transferring" }] });
|
||||
sftpTransferCenterStore.pause = async () => {};
|
||||
sftpTransferCenterStore.resume = async () => { resumes++; };
|
||||
const ipcRenderer = Object.assign(new EventEmitter(), {
|
||||
send(channel, payload) { parentPort.postMessage({ type: "reply", channel, payload }); },
|
||||
});
|
||||
const preload = createPreloadApi({ ipcRenderer, webUtils: {} });
|
||||
netcattyBridge.get = () => ({
|
||||
onCheckDirtyEditors(callback) {
|
||||
return preload.onCheckDirtyEditors((request) => {
|
||||
void callback(request).then(() => parentPort.postMessage({ type: "done", resumes }));
|
||||
});
|
||||
},
|
||||
reportDirtyEditorsResult: preload.reportDirtyEditorsResult,
|
||||
});
|
||||
function Probe() {
|
||||
useAppStartupEffects({ enabled: false, updateState: {}, sessions: [], hosts: [], keys: [], identities: [], knownHosts: [], proxyProfiles: [], groupConfigs: [], workspaces: [], portForwardingRules: [], t: (key) => key });
|
||||
return null;
|
||||
}
|
||||
parentPort.on("message", ({ channel, payload }) => ipcRenderer.emit(channel, {}, payload));
|
||||
void act(async () => { create(React.createElement(Probe)); })
|
||||
.then(() => parentPort.postMessage({ type: "ready" }));
|
||||
@@ -59,7 +59,6 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
createWorkspaceFromSessions,
|
||||
addSessionToWorkspace,
|
||||
appendHostToWorkspace,
|
||||
appendHostToWorkspaceConfirmed,
|
||||
appendLocalTerminalToWorkspace,
|
||||
createWorkspaceFromTargets,
|
||||
updateSplitSizes,
|
||||
@@ -144,7 +143,6 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
createWorkspaceFromSessions,
|
||||
addSessionToWorkspace,
|
||||
appendHostToWorkspace,
|
||||
appendHostToWorkspaceConfirmed,
|
||||
appendLocalTerminalToWorkspace,
|
||||
createWorkspaceFromTargets,
|
||||
updateSplitSizes,
|
||||
@@ -173,7 +171,6 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
}), [
|
||||
addSessionToWorkspace,
|
||||
appendHostToWorkspace,
|
||||
appendHostToWorkspaceConfirmed,
|
||||
appendLocalTerminalToWorkspace,
|
||||
clearSessionFontSizeOverride,
|
||||
closeLogView,
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- Electron boundary doubles exercise production modules. */
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { createRequire } from 'node:module';
|
||||
import { useAppStartupEffects } from './useAppStartupEffects.ts';
|
||||
import { sftpTransferCenterStore } from '../state/sftpTransferCenterStore.ts';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge.ts';
|
||||
const require = createRequire(import.meta.url);
|
||||
const React = require('react');
|
||||
const { act, create } = require('react-test-renderer');
|
||||
const { createMainWindowApi } = require('../../electron/bridges/windowManager/mainWindow.cjs');
|
||||
const { queryDirtyEditors } = require('../../electron/bridges/dirtyEditorGuard.cjs');
|
||||
const { createPreloadApi } = require('../../electron/preload/api.cjs');
|
||||
|
||||
// No Electron UI, sockets, real transfers, or preferences. Only the network
|
||||
// pause completion is held. Actual renderer hook, window close callback, and
|
||||
// dirty-query helper run together with the actual 5000ms production deadline.
|
||||
for (const slowPause of [true, false]) test(`${slowPause ? 'slow pause refuses close at the production deadline' : 'control: fast pause preserves Cancel'}`, { timeout: 8000 }, async t => {
|
||||
const oldWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const oldAct = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
let confirmations = 0, reports = 0, resumeCalls = 0;
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: { confirm() { confirmations++; return false; } } });
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
|
||||
let renderer: any, releasePause: any;
|
||||
const queryPromises: Promise<void>[] = [];
|
||||
const pause = new Promise<void>(resolve => { releasePause = resolve; });
|
||||
if (!slowPause) releasePause();
|
||||
const ipcMain = new EventEmitter();
|
||||
let win: any;
|
||||
t.after(async () => {
|
||||
releasePause(); await Promise.allSettled(queryPromises);
|
||||
await act(async () => renderer?.unmount());
|
||||
if (oldWindow) Object.defineProperty(globalThis, 'window', oldWindow); else Reflect.deleteProperty(globalThis, 'window');
|
||||
if (oldAct) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', oldAct); else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
t.mock.method(sftpTransferCenterStore, 'getSnapshot', () => ({ tasks: [{ id: 'slow-upload', status: 'transferring' }] }) as any);
|
||||
t.mock.method(sftpTransferCenterStore, 'pause', () => pause);
|
||||
t.mock.method(sftpTransferCenterStore, 'resume', async () => { resumeCalls++; });
|
||||
const ipcRenderer = Object.assign(new EventEmitter(), {
|
||||
send(channel: string, payload: any) { reports++; ipcMain.emit(channel, { sender: win.webContents }, payload); },
|
||||
});
|
||||
const preload = createPreloadApi({ ipcRenderer, webUtils: {} });
|
||||
t.mock.method(netcattyBridge, 'get', () => ({
|
||||
onCheckDirtyEditors(callback: any) {
|
||||
return preload.onCheckDirtyEditors((request: any) => { queryPromises.push(callback(request)); });
|
||||
},
|
||||
reportDirtyEditorsResult: preload.reportDirtyEditorsResult,
|
||||
}));
|
||||
function Probe() {
|
||||
useAppStartupEffects({ enabled: false, updateState: {}, sessions: [], hosts: [], keys: [], identities: [], knownHosts: [], proxyProfiles: [], groupConfigs: [], workspaces: [], portForwardingRules: [], t: (key: string) => key } as any);
|
||||
return null;
|
||||
}
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
class Window extends EventEmitter {
|
||||
destroyed = false;
|
||||
webContents = Object.assign(new EventEmitter(), {
|
||||
id: 71, isDestroyed: () => this.destroyed, isCrashed: () => false,
|
||||
setWindowOpenHandler() {}, openDevTools() {},
|
||||
send(channel: string, payload: any) { ipcRenderer.emit(channel, {}, payload); },
|
||||
});
|
||||
close() { const event = { prevented: false, preventDefault() { this.prevented = true; } }; this.emit('close', event); if (!event.prevented) { this.destroyed = true; this.emit('closed'); } }
|
||||
isDestroyed() { return this.destroyed; }
|
||||
isMaximized() { return false; }
|
||||
isFullScreen() { return false; }
|
||||
getBounds() { return { width: 1000, height: 700 }; }
|
||||
setBackgroundColor() {}
|
||||
loadURL() { return Promise.resolve(); }
|
||||
}
|
||||
const api = createMainWindowApi({
|
||||
mainWindow: null, electronApp: null, currentTheme: 'light', isQuitting: false,
|
||||
pendingWindowStateWrite: null, queuedWindowState: null,
|
||||
DEFAULT_WINDOW_WIDTH: 1400, DEFAULT_WINDOW_HEIGHT: 900, MIN_WINDOW_WIDTH: 1100, MIN_WINDOW_HEIGHT: 640,
|
||||
V8_CACHE_OPTIONS: 'code', THEME_COLORS: { light: {} },
|
||||
unhealthyWebContentsIds: new Set(), rendererReadySeenByWebContentsId: new Set(),
|
||||
__dirname: '/unused/electron/bridges', URL, require, console, setTimeout, clearTimeout,
|
||||
getGlobalShortcutBridge: () => ({ handleWindowClose: () => false }), getMainWindowCount: () => 1,
|
||||
debugLog() {}, resolveFrontendBackgroundColor() { return null; }, loadWindowState() { return null; },
|
||||
getDevRendererBaseUrl: (url: string) => url, getWindowBoundsState() { return null; },
|
||||
queueWindowStateSave() {}, saveWindowStateSync() {}, setupDeferredShow() {},
|
||||
createExternalOnlyWindowOpenHandler() { return {}; }, createAppWindowOpenHandler() { return {}; },
|
||||
attachOAuthLoadingOverlay() {}, registerWindowHandlers() {}, requestWindowCommandClose() {}, shouldCloseWindowFromInput() { return false; },
|
||||
registerMainWindow() {}, unregisterMainWindow() {}, registerAppContentWindow() {}, unregisterAppContentWindow() {},
|
||||
notifyAppContentWindowClosed() {}, applyWindowOpacityToWindow() {}, closeSettingsWindow() {}, hideSettingsWindow() {},
|
||||
hasEditorTabsForSource: () => false, closeEditorTabsForSource: async () => ({ success: true }),
|
||||
queryDirtyEditors(wc: any, timeout: number, options: any) {
|
||||
assert.equal(timeout, 5000, 'real close callback supplies the 5-second timeout');
|
||||
return queryDirtyEditors(wc, timeout, options);
|
||||
},
|
||||
});
|
||||
win = await api.createWindow({ BrowserWindow: Window, nativeTheme: {}, app: {}, screen: {}, shell: {}, ipcMain }, {
|
||||
preload: '/tmp/unused-preload.cjs', devServerUrl: 'http://localhost:5173', isDev: true, isMac: false,
|
||||
electronDir: '/unused/electron', registerAsMainWindow: true,
|
||||
});
|
||||
win.close();
|
||||
assert.equal(win.destroyed, false, 'first close is correctly guarded');
|
||||
await new Promise(resolve => setTimeout(resolve, slowPause ? 5250 : 50));
|
||||
if (!slowPause) {
|
||||
assert.equal(confirmations, 1); assert.equal(reports, 2); assert.equal(resumeCalls, 1);
|
||||
assert.equal(win.destroyed, false, 'Cancel keeps owner alive when pause settles before deadline');
|
||||
console.log('QUIT_FAST_CONTROL', JSON.stringify({ ownerAlive: true, confirmations, reports, resumeCalls }));
|
||||
return;
|
||||
}
|
||||
assert.equal(confirmations, 0, 'slow pause prevents prompt');
|
||||
assert.equal(win.destroyed, false, 'deadline refuses close while the pause is pending');
|
||||
assert.equal(reports, 1, 'only the pending-transfer acknowledgement has been sent');
|
||||
assert.equal(ipcMain.listenerCount('app:dirty-editors-result'), 0, 'main query is settled, not hung');
|
||||
releasePause(); await Promise.allSettled(queryPromises);
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
assert.equal(confirmations, 0, 'the expired request must not show a late prompt');
|
||||
assert.equal(win.destroyed, false);
|
||||
assert.equal(resumeCalls, 1);
|
||||
console.log('QUIT_TIMEOUT_REGRESSION', JSON.stringify({ ownerAlive: true, productionDeadlineMs: 5000, scaling: false, resumeCalls }));
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter, once } = require("node:events");
|
||||
const { Worker } = require("node:worker_threads");
|
||||
const { queryDirtyEditors } = require("../../electron/bridges/dirtyEditorGuard.cjs");
|
||||
|
||||
for (const accept of [false, true]) {
|
||||
test(`native confirmation held beyond deadline: late ${accept ? "Accept" : "Cancel"} keeps the owner alive`, { timeout: 10000 }, async (t) => {
|
||||
const signal = new SharedArrayBuffer(4);
|
||||
const worker = new Worker(require.resolve("./__fixtures__/transferQuitRenderer.cjs"), {
|
||||
workerData: { accept, signal },
|
||||
});
|
||||
const ipcMain = new EventEmitter();
|
||||
const events = new EventEmitter();
|
||||
const replies = [];
|
||||
let promptShown = false;
|
||||
let closed = false;
|
||||
const wc = {
|
||||
isDestroyed: () => closed,
|
||||
isCrashed: () => false,
|
||||
send(channel, payload) { worker.postMessage({ channel, payload }); },
|
||||
};
|
||||
t.after(async () => { Atomics.store(new Int32Array(signal), 0, 1); Atomics.notify(new Int32Array(signal), 0); await worker.terminate(); });
|
||||
worker.on("message", (message) => {
|
||||
if (message.type === "reply") {
|
||||
replies.push(message.payload);
|
||||
ipcMain.emit(message.channel, { sender: wc }, message.payload);
|
||||
} else {
|
||||
if (message.type === "prompt") promptShown = true;
|
||||
events.emit(message.type, message);
|
||||
}
|
||||
});
|
||||
worker.on("error", (error) => events.emit("error", error));
|
||||
await once(events, "ready");
|
||||
const started = Date.now();
|
||||
const result = await queryDirtyEditors(wc, 5000, { ipcMain });
|
||||
if (!result) closed = true;
|
||||
assert.equal(promptShown, true);
|
||||
assert.equal(result, "transfers");
|
||||
assert.equal(closed, false, "main refuses close while the renderer is blocked in its native prompt");
|
||||
assert.ok(Date.now() - started >= 4900, "use the unchanged five-second production deadline");
|
||||
assert.equal(ipcMain.listenerCount("app:dirty-editors-result"), 0, "main settled without hanging");
|
||||
const done = once(events, "done");
|
||||
Atomics.store(new Int32Array(signal), 0, 1);
|
||||
Atomics.notify(new Int32Array(signal), 0);
|
||||
const [completed] = await done;
|
||||
assert.equal(completed.resumes, 1);
|
||||
assert.equal(replies.at(-1).hasDirty, true, "an expired Accept cannot authorize a later close");
|
||||
assert.equal(replies.at(-1).reason, "transfers");
|
||||
assert.equal(closed, false);
|
||||
});
|
||||
}
|
||||
@@ -1,28 +1,11 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import type { TransferTask } from "../../domain/models";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
import { useAppStartupEffects } from "./useAppStartupEffects.ts";
|
||||
import { sftpTransferCenterStore } from "../state/sftpTransferCenterStore.ts";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge.ts";
|
||||
|
||||
const makeTask = (id: string, status: TransferTask["status"]): TransferTask => ({
|
||||
id,
|
||||
fileName: `${id}.txt`,
|
||||
sourcePath: `/source/${id}.txt`,
|
||||
targetPath: `/target/${id}.txt`,
|
||||
sourceConnectionId: "local",
|
||||
targetConnectionId: "remote",
|
||||
direction: "upload",
|
||||
status,
|
||||
totalBytes: 10,
|
||||
transferredBytes: 2,
|
||||
speed: 1,
|
||||
startTime: 1,
|
||||
isDirectory: false,
|
||||
});
|
||||
|
||||
for (const accept of [false, true]) {
|
||||
test(`app Quit with an unfinished transfer: ${accept ? "confirm quits" : "declining reports \"transfers\" before resuming only transferring and queued tasks"}`, async (t) => {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, "IS_REACT_ACT_ENVIRONMENT");
|
||||
@@ -30,8 +13,7 @@ for (const accept of [false, true]) {
|
||||
const g = globalThis as unknown as { window?: { confirm?: (m: string) => boolean } };
|
||||
const hadWindow = "window" in g; const previousConfirm = g.window?.confirm;
|
||||
if (!g.window) g.window = {};
|
||||
let prompts = 0;
|
||||
g.window.confirm = () => { prompts++; return accept; };
|
||||
g.window.confirm = () => accept;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
let releaseResume!: () => void;
|
||||
const resumeGate = new Promise<void>((resolve) => { releaseResume = resolve; });
|
||||
@@ -94,159 +76,12 @@ for (const accept of [false, true]) {
|
||||
}
|
||||
assert.equal(resumeSettled, false, "the dirty-check reply must not wait for resume");
|
||||
if (accept) {
|
||||
assert.deepEqual(reports, [[true, "transfers"], [false, undefined]]);
|
||||
assert.deepEqual(calls, ["report", ...paused, "report"], "accepted quit leaves every prompted task paused");
|
||||
assert.deepEqual(reports, [[false, undefined]]);
|
||||
assert.deepEqual(calls, [...paused, "report"], "accepted quit leaves every prompted task paused");
|
||||
} else {
|
||||
assert.deepEqual(reports, [[true, "transfers"], [true, "transfers"]], "main must be told the prompt was already answered");
|
||||
assert.deepEqual(calls, ["report", ...paused, "report", "resume:running", "resume:queued"],
|
||||
assert.deepEqual(reports, [[true, "transfers"]], "main must be told the prompt was already answered");
|
||||
assert.deepEqual(calls, [...paused, "report", "resume:running", "resume:queued"],
|
||||
"report precedes resume, and only transferring/queued rows the prompt paused are resumed");
|
||||
// Dedicated resume promises can represent the whole transfer lifetime.
|
||||
// A second close must still prompt while those promises remain pending.
|
||||
await query();
|
||||
assert.equal(prompts, 2);
|
||||
assert.equal(resumeSettled, false);
|
||||
assert.equal(reports.length, 4);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const outcome of ["pause-rejected", "pause-expired", "prompt-expired"] as const) {
|
||||
test(`transfer quit refuses ${outcome} and resumes only the formerly active task`, async (t) => {
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
const previousAct = Object.getOwnPropertyDescriptor(globalThis, "IS_REACT_ACT_ENVIRONMENT");
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
let now = 0;
|
||||
let prompts = 0;
|
||||
let resumes = 0;
|
||||
const replies: Array<{ dirty: boolean; reason?: string; requestId?: string; pending?: boolean }> = [];
|
||||
let query!: (request: { requestId: string; deadline: number }) => Promise<void>;
|
||||
t.mock.method(Date, "now", () => now);
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
|
||||
Object.defineProperty(globalThis, "window", { configurable: true, value: {
|
||||
confirm() { prompts++; now = 6000; return true; },
|
||||
} });
|
||||
t.after(async () => {
|
||||
await act(async () => renderer?.unmount());
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow); else Reflect.deleteProperty(globalThis, "window");
|
||||
if (previousAct) Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", previousAct); else Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT");
|
||||
});
|
||||
t.mock.method(sftpTransferCenterStore, "getSnapshot", () => ({ activeCount: 1, queuedCount: 0, attentionCount: 0, tasks: [makeTask("running", "transferring")] }));
|
||||
t.mock.method(sftpTransferCenterStore, "pause", async () => {
|
||||
if (outcome === "pause-rejected") throw new Error("pause failed");
|
||||
if (outcome === "pause-expired") now = 6000;
|
||||
});
|
||||
t.mock.method(sftpTransferCenterStore, "resume", async () => { resumes++; });
|
||||
t.mock.method(netcattyBridge, "get", () => ({
|
||||
onCheckDirtyEditors: (callback: typeof query) => { query = callback; return () => {}; },
|
||||
reportDirtyEditorsResult: (dirty: boolean, reason?: string, options?: { requestId?: string; pending?: boolean }) => {
|
||||
replies.push({ dirty, reason, ...options });
|
||||
},
|
||||
}));
|
||||
function Probe() {
|
||||
useAppStartupEffects({ enabled: false, updateState: {}, sessions: [], hosts: [], keys: [], identities: [], knownHosts: [], proxyProfiles: [], groupConfigs: [], workspaces: [], portForwardingRules: [], t: (key: string) => key });
|
||||
return null;
|
||||
}
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await query({ requestId: outcome, deadline: 5000 });
|
||||
assert.deepEqual(replies, [
|
||||
{ dirty: true, reason: "transfers", requestId: outcome, pending: true },
|
||||
{ dirty: true, reason: "transfers", requestId: outcome },
|
||||
]);
|
||||
assert.equal(prompts, outcome === "prompt-expired" ? 1 : 0);
|
||||
assert.equal(resumes, 1);
|
||||
});
|
||||
}
|
||||
|
||||
test("a cancelled task can close after an expired stuck pause, and its late completion cannot resume it", async (t) => {
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
const previousAct = Object.getOwnPropertyDescriptor(globalThis, "IS_REACT_ACT_ENVIRONMENT");
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
let now = 0;
|
||||
let cancelled = false;
|
||||
let prompts = 0;
|
||||
let resumes = 0;
|
||||
let releasePause!: () => void;
|
||||
const pause = new Promise<void>((resolve) => { releasePause = resolve; });
|
||||
const replies: Array<{ dirty: boolean; requestId?: string; pending?: boolean }> = [];
|
||||
let query!: (request: { requestId: string; deadline: number }) => Promise<void>;
|
||||
t.mock.method(Date, "now", () => now);
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
|
||||
Object.defineProperty(globalThis, "window", { configurable: true, value: { confirm() { prompts++; return false; } } });
|
||||
t.after(async () => {
|
||||
releasePause();
|
||||
await act(async () => renderer?.unmount());
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow); else Reflect.deleteProperty(globalThis, "window");
|
||||
if (previousAct) Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", previousAct); else Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT");
|
||||
});
|
||||
t.mock.method(sftpTransferCenterStore, "getSnapshot", () => ({ activeCount: cancelled ? 0 : 1, queuedCount: 0, attentionCount: 0, tasks: [makeTask("stuck", cancelled ? "cancelled" : "transferring")] }));
|
||||
t.mock.method(sftpTransferCenterStore, "pause", () => pause);
|
||||
t.mock.method(sftpTransferCenterStore, "resume", async () => { resumes++; });
|
||||
t.mock.method(netcattyBridge, "get", () => ({
|
||||
onCheckDirtyEditors: (callback: typeof query) => { query = callback; return () => {}; },
|
||||
reportDirtyEditorsResult: (dirty: boolean, _reason?: string, options?: { requestId?: string; pending?: boolean }) => replies.push({ dirty, ...options }),
|
||||
}));
|
||||
function Probe() {
|
||||
useAppStartupEffects({ enabled: false, updateState: {}, sessions: [], hosts: [], keys: [], identities: [], knownHosts: [], proxyProfiles: [], groupConfigs: [], workspaces: [], portForwardingRules: [], t: (key: string) => key });
|
||||
return null;
|
||||
}
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
const first = query({ requestId: "stuck", deadline: 5000 });
|
||||
await query({ requestId: "overlap", deadline: 5000 });
|
||||
assert.deepEqual(replies.at(-1), { dirty: true, requestId: "overlap" });
|
||||
now = 6000;
|
||||
cancelled = true;
|
||||
await query({ requestId: "after-cancel", deadline: 11000 });
|
||||
assert.deepEqual(replies.at(-1), { dirty: false, requestId: "after-cancel" });
|
||||
const replyCount = replies.length;
|
||||
releasePause();
|
||||
await first;
|
||||
assert.equal(replies.length, replyCount, "superseded attempt must not reply again");
|
||||
assert.equal(prompts, 0);
|
||||
assert.equal(resumes, 0, "late completion must not undo the user's cancellation");
|
||||
});
|
||||
|
||||
test("retry Cancel preserves the expired attempt auto-resume ownership", async (t) => {
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
const previousAct = Object.getOwnPropertyDescriptor(globalThis, "IS_REACT_ACT_ENVIRONMENT");
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
let now = 0;
|
||||
let status: TransferTask["status"] = "transferring";
|
||||
let prompts = 0;
|
||||
let resumes = 0;
|
||||
let releasePause!: () => void;
|
||||
const pause = new Promise<void>((resolve) => { releasePause = resolve; });
|
||||
const replies: Array<{ dirty: boolean; requestId?: string; pending?: boolean }> = [];
|
||||
let query!: (request: { requestId: string; deadline: number }) => Promise<void>;
|
||||
t.mock.method(Date, "now", () => now);
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
|
||||
Object.defineProperty(globalThis, "window", { configurable: true, value: { confirm() { prompts++; return false; } } });
|
||||
t.after(async () => {
|
||||
releasePause();
|
||||
await act(async () => renderer?.unmount());
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow); else Reflect.deleteProperty(globalThis, "window");
|
||||
if (previousAct) Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", previousAct); else Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT");
|
||||
});
|
||||
t.mock.method(sftpTransferCenterStore, "getSnapshot", () => ({ activeCount: status === "transferring" || status === "pausing" ? 1 : 0, queuedCount: 0, attentionCount: 0, tasks: [makeTask("stuck", status)] }));
|
||||
t.mock.method(sftpTransferCenterStore, "pause", () => { status = "pausing"; return pause; });
|
||||
t.mock.method(sftpTransferCenterStore, "resume", async () => { resumes++; });
|
||||
t.mock.method(netcattyBridge, "get", () => ({
|
||||
onCheckDirtyEditors: (callback: typeof query) => { query = callback; return () => {}; },
|
||||
reportDirtyEditorsResult: (dirty: boolean, _reason?: string, options?: { requestId?: string; pending?: boolean }) => replies.push({ dirty, ...options }),
|
||||
}));
|
||||
function Probe() {
|
||||
useAppStartupEffects({ enabled: false, updateState: {}, sessions: [], hosts: [], keys: [], identities: [], knownHosts: [], proxyProfiles: [], groupConfigs: [], workspaces: [], portForwardingRules: [], t: (key: string) => key });
|
||||
return null;
|
||||
}
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
const first = query({ requestId: "stuck", deadline: 5000 });
|
||||
await query({ requestId: "overlap", deadline: 5000 });
|
||||
assert.deepEqual(replies.at(-1), { dirty: true, requestId: "overlap" });
|
||||
now = 6000;
|
||||
const retry = query({ requestId: "retry", deadline: 11000 });
|
||||
status = "paused";
|
||||
releasePause();
|
||||
await Promise.all([first, retry]);
|
||||
assert.equal(prompts, 1);
|
||||
assert.equal(resumes, 1, "Cancel must restore work running before the first attempt");
|
||||
assert.deepEqual(replies.at(-1), { dirty: true, requestId: "retry" });
|
||||
});
|
||||
@@ -344,26 +344,12 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
};
|
||||
}, [enabled, hasRuntimeTunnel, hosts, sessions, portForwardingRules, workspaces]);
|
||||
|
||||
const transferQuitPendingRef = useRef<{ deadline: number; resumeTaskIds: Set<string> } | null>(null);
|
||||
|
||||
// Quit guard: block app exit while any editor tab has unsaved changes.
|
||||
// Main process sends "app:query-dirty-editors"; we respond with the result.
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onCheckDirtyEditors) return;
|
||||
const unsub = bridge.onCheckDirtyEditors(async (request) => {
|
||||
const response = { requestId: request?.requestId };
|
||||
// Another close request must not start a second pause/prompt while the
|
||||
// first attempt is still pausing or awaiting a decision.
|
||||
const previousAttempt = transferQuitPendingRef.current;
|
||||
if (previousAttempt && Date.now() < previousAttempt.deadline) {
|
||||
bridge.reportDirtyEditorsResult?.(true, "transfers", response);
|
||||
return;
|
||||
}
|
||||
// An expired pause may never settle. Let a retry re-check current tasks
|
||||
// (including a user's cancellation), without granting the old attempt
|
||||
// permission to prompt or resume over that newer decision.
|
||||
let attempt: typeof previousAttempt = null;
|
||||
const unsub = bridge.onCheckDirtyEditors(async () => {
|
||||
// Always report SOMETHING so the main process doesn't time out for
|
||||
// 5 s on an unhandled exception. If we can't determine the state,
|
||||
// fail open — losing unsaved work is bad, but stranding the user
|
||||
@@ -388,44 +374,24 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
if (unfinishedTasks.length > 0) {
|
||||
const resumable = unfinishedTasks.filter((task) => (
|
||||
task.status === "transferring" || task.status === "queued"
|
||||
|| previousAttempt?.resumeTaskIds.has(task.id)
|
||||
));
|
||||
// A slow pause must never turn the main-process timeout into
|
||||
// permission to quit before the user has made a decision.
|
||||
hasDirty = true;
|
||||
reason = "transfers";
|
||||
tasksToResume = resumable;
|
||||
// A retry inherits the still-unfinished rows the expired attempt
|
||||
// auto-paused. Their current status may already be pausing/paused.
|
||||
attempt = {
|
||||
deadline: request?.deadline ?? Date.now() + 5000,
|
||||
resumeTaskIds: new Set(resumable.map((task) => task.id)),
|
||||
};
|
||||
transferQuitPendingRef.current = attempt;
|
||||
bridge.reportDirtyEditorsResult?.(true, reason, { ...response, pending: true });
|
||||
const paused = await Promise.allSettled(unfinishedTasks.map((task) => sftpTransferCenterStore.pause(task.id)));
|
||||
if (transferQuitPendingRef.current !== attempt) return;
|
||||
const expired = () => request && Date.now() >= request.deadline;
|
||||
if (!expired() && paused.every((result) => result.status === "fulfilled")) {
|
||||
hasDirty = !window.confirm(t('sftp.transferCenter.quitConfirm', { count: unfinishedTasks.length }));
|
||||
// The native prompt can itself outlive the main-process deadline.
|
||||
// An expired acceptance is not permission for a later close attempt.
|
||||
if (expired()) hasDirty = true;
|
||||
await Promise.allSettled(unfinishedTasks.map((task) => sftpTransferCenterStore.pause(task.id)));
|
||||
hasDirty = !window.confirm(t('sftp.transferCenter.quitConfirm', { count: unfinishedTasks.length }));
|
||||
if (hasDirty) {
|
||||
// The user chose to stay. Main must hear that before any resume:
|
||||
// resume can outlive the 5s dirty-check timeout, and a late reply
|
||||
// lets the app quit anyway. The synchronous dialog is also skipped
|
||||
// because this prompt was already answered.
|
||||
reason = "transfers";
|
||||
tasksToResume = resumable;
|
||||
}
|
||||
if (!hasDirty) {
|
||||
reason = undefined;
|
||||
tasksToResume = [];
|
||||
}
|
||||
} else {
|
||||
// All prior work was finished/cancelled; invalidate its late pause.
|
||||
transferQuitPendingRef.current = null;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[App] dirty-editors check failed:', err);
|
||||
}
|
||||
try {
|
||||
bridge.reportDirtyEditorsResult?.(hasDirty, reason, response);
|
||||
bridge.reportDirtyEditorsResult?.(hasDirty, reason);
|
||||
} catch (err) {
|
||||
// Reporting itself shouldn't throw, but if the IPC bridge is in a
|
||||
// bad state we'd rather log than bubble out of the listener and
|
||||
@@ -433,12 +399,8 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
console.error('[App] reportDirtyEditorsResult failed:', err);
|
||||
}
|
||||
if (tasksToResume.length > 0) {
|
||||
// Report refusal before recovery, which may itself take a long time.
|
||||
// A resume promise can span the entire transfer, so do not hold the
|
||||
// close gate until it settles. A later close must be able to pause again.
|
||||
void Promise.allSettled(tasksToResume.map((task) => sftpTransferCenterStore.resume(task.id)));
|
||||
}
|
||||
if (transferQuitPendingRef.current === attempt) transferQuitPendingRef.current = null;
|
||||
});
|
||||
return unsub;
|
||||
}, [enabled, t]);
|
||||
|
||||
@@ -964,52 +964,8 @@ Highlight the focused split pane:
|
||||
'settings.shortcuts.category.terminal': 'Terminal',
|
||||
'settings.shortcuts.category.navigation': 'Navigation',
|
||||
'settings.shortcuts.category.app': 'App',
|
||||
'settings.shortcuts.category.sftp': 'SFTP',
|
||||
|
||||
// Settings > Shortcuts -> key bindings
|
||||
'settings.shortcuts.binding.switch-tab-1-9': 'Switch to Tab [1...9]',
|
||||
'settings.shortcuts.binding.next-tab': 'Next Tab',
|
||||
'settings.shortcuts.binding.prev-tab': 'Previous Tab',
|
||||
'settings.shortcuts.binding.close-tab': 'Close Tab',
|
||||
'settings.shortcuts.binding.close-session': 'Close Session Pane',
|
||||
'settings.shortcuts.binding.new-tab': 'New Local Tab',
|
||||
'settings.shortcuts.binding.copy': 'Copy from Terminal',
|
||||
'settings.shortcuts.binding.paste': 'Paste to Terminal',
|
||||
'settings.shortcuts.binding.paste-selection': 'Paste Selection to Terminal',
|
||||
'settings.shortcuts.binding.select-all': 'Select All in Terminal',
|
||||
'settings.shortcuts.binding.clear-buffer': 'Clear Terminal Buffer',
|
||||
'settings.shortcuts.binding.search-terminal': 'Open Terminal Search',
|
||||
'settings.shortcuts.binding.increase-terminal-font-size': 'Increase Terminal Font Size',
|
||||
'settings.shortcuts.binding.decrease-terminal-font-size': 'Decrease Terminal Font Size',
|
||||
'settings.shortcuts.binding.reset-terminal-font-size': 'Reset Terminal Font Size',
|
||||
'settings.shortcuts.binding.move-focus': 'Move focus between Split View panes',
|
||||
'settings.shortcuts.binding.split-horizontal': 'Split Horizontal',
|
||||
'settings.shortcuts.binding.split-vertical': 'Split Vertical',
|
||||
'settings.shortcuts.binding.toggle-pane-zoom': 'Toggle Pane Zoom',
|
||||
'settings.shortcuts.binding.open-hosts': 'Open Hosts Page',
|
||||
'settings.shortcuts.binding.open-local': 'Open Local Terminal',
|
||||
'settings.shortcuts.binding.open-sftp': 'Open SFTP',
|
||||
'settings.shortcuts.binding.port-forwarding': 'Open Port Forwarding',
|
||||
'settings.shortcuts.binding.command-palette': 'Open Command Palette',
|
||||
'settings.shortcuts.binding.quick-switch': 'Quick Switch',
|
||||
'settings.shortcuts.binding.new-workspace': 'New Workspace',
|
||||
'settings.shortcuts.binding.snippets': 'Open Snippets',
|
||||
'settings.shortcuts.binding.broadcast': 'Switch the Broadcast Mode',
|
||||
'settings.shortcuts.binding.toggle-side-panel': 'Toggle Side Panel',
|
||||
'settings.shortcuts.binding.toggle-host-tree': 'Toggle Host List',
|
||||
'settings.shortcuts.binding.open-settings': 'Open Settings',
|
||||
'settings.shortcuts.binding.toggle-opacity': 'Toggle Window Transparency',
|
||||
'settings.shortcuts.binding.sftp-copy': 'Copy Files',
|
||||
'settings.shortcuts.binding.sftp-cut': 'Cut Files',
|
||||
'settings.shortcuts.binding.sftp-paste': 'Paste Files',
|
||||
'settings.shortcuts.binding.sftp-select-all': 'Select All Files',
|
||||
'settings.shortcuts.binding.sftp-rename': 'Rename File',
|
||||
'settings.shortcuts.binding.sftp-delete': 'Delete Files',
|
||||
'settings.shortcuts.binding.sftp-refresh': 'Refresh',
|
||||
'settings.shortcuts.binding.sftp-new-folder': 'New Folder',
|
||||
'settings.shortcuts.binding.sftp-open': 'Open File / Enter Directory',
|
||||
'settings.shortcuts.binding.sftp-go-parent': 'Go to Parent Directory',
|
||||
'settings.shortcuts.binding.sftp-navigate-to': 'Navigate to Selected Directory',
|
||||
'settings.shortcuts.category.sftp': 'SFTP',
|
||||
|
||||
// Context menus / common actions
|
||||
'action.newHost': 'New Host',
|
||||
|
||||
@@ -879,7 +879,6 @@ export const enTerminalMessages: Messages = {
|
||||
'serial.field.flowControl': 'Flow Control',
|
||||
'serial.noPorts': 'No serial ports detected. Connect a device and refresh.',
|
||||
'serial.field.customPort': 'Custom Port Path',
|
||||
'serial.field.namedPipeHint': 'Windows VM console: enter \\\\.\\pipe\\name. The VM must create the pipe as a server. Baud rate and framing are ignored.',
|
||||
'serial.field.customPortPlaceholder': 'e.g. /dev/ttys001 or COM1',
|
||||
'serial.type.hardware': 'Hardware',
|
||||
'serial.type.pseudo': 'Pseudo Terminal',
|
||||
|
||||
@@ -370,7 +370,6 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.transfers.openTargetFolder': 'Open target folder',
|
||||
'sftp.transfers.openTargetFolderError': 'Could not open target folder',
|
||||
'sftp.transfers.copyTargetPath': 'Copy target path',
|
||||
'sftp.transfers.cleanupWarnings': 'Backup cleanup warnings: {count}. Show files for details.',
|
||||
'sftp.transfers.copyTargetPathSuccess': 'Target path copied',
|
||||
'sftp.transfers.copyTargetPathError': 'Could not copy target path',
|
||||
'sftp.transfers.pathConflict.title': 'Transfer blocked',
|
||||
|
||||
@@ -90,15 +90,6 @@ export const esAiMessages: Messages = {
|
||||
'ai.providers.advancedParams.hint': 'Déjalo en blanco para usar los valores predeterminados del proveedor.',
|
||||
'ai.providers.advancedParams.maxTokens.placeholder': 'p. ej. 4096',
|
||||
'ai.providers.advancedParams.default': 'Predeterminado del proveedor',
|
||||
'ai.providers.headers': 'Cabeceras HTTP personalizadas',
|
||||
'ai.providers.headers.help': 'Cabeceras opcionales para este proveedor. Los valores se ocultan y se cifran al guardarlos.',
|
||||
'ai.providers.headers.name': 'Nombre de la cabecera',
|
||||
'ai.providers.headers.value': 'Valor de la cabecera',
|
||||
'ai.providers.headers.add': 'Agregar cabecera',
|
||||
'ai.providers.headers.remove': 'Quitar cabecera',
|
||||
'ai.providers.headers.invalid': 'Usa cabeceras HTTP válidas y únicas. Content-Length y Transfer-Encoding se gestionan automáticamente.',
|
||||
'ai.providers.headers.loadError': 'No se pudieron desbloquear las cabeceras guardadas. Cancela y vuelve a intentarlo después de desbloquear el llavero del sistema.',
|
||||
'ai.providers.headers.saveError': 'No se pudo guardar de forma segura. Comprueba que el llavero del sistema esté disponible y vuelve a intentarlo.',
|
||||
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
@@ -289,14 +280,6 @@ export const esAiMessages: Messages = {
|
||||
'ai.opencode.customPathPlaceholder': 'p. ej. /usr/local/bin/opencode',
|
||||
'ai.opencode.check': 'Verificar',
|
||||
'ai.opencode.resetPath': 'Restablecer',
|
||||
'ai.opencode.v2.startupTimeout': 'OpenCode 2 no estuvo listo a tiempo. Confirma que `opencode serve` funciona en una terminal y vuelve a intentarlo.',
|
||||
'ai.opencode.v2.exited': 'OpenCode 2 se detuvo antes de que el chat pudiera iniciarse.',
|
||||
'ai.opencode.v2.unauthorized': 'OpenCode 2 rechazó la conexión. Vuelve a intentar el chat.',
|
||||
'ai.opencode.v2.unavailable': 'No se pudo contactar con OpenCode 2. Comprueba que esté instalado y vuelve a intentarlo.',
|
||||
'ai.opencode.v2.sessionFailed': 'OpenCode 2 no pudo completar este turno.',
|
||||
'ai.opencode.v2.empty': 'OpenCode 2 devolvió una respuesta vacía. Ejecuta `opencode` en una terminal para configurar la autenticación y los modelos.',
|
||||
'ai.opencode.v2.contextSkipped': 'OpenCode 2 no pudo aplicar el contexto de la conversación de Netcatty.',
|
||||
'ai.opencode.v2.mcpSkipped': 'OpenCode 2 no pudo adjuntar una herramienta MCP. El chat continuará sin ella.',
|
||||
|
||||
// AI MiMo Code (CLI de Xiaomi MiMo Code — fork de OpenCode)
|
||||
'ai.mimo.title': 'MiMo Code',
|
||||
@@ -711,9 +694,6 @@ export const esAiMessages: Messages = {
|
||||
'terminal.layer.splitHorizontal': 'Dividir arriba y abajo',
|
||||
'terminal.layer.splitVertical': 'Dividir izquierda y derecha',
|
||||
'terminal.layer.openInNewSplit': 'Abrir en una división nueva',
|
||||
'terminal.layer.saveLayoutAsDefault': 'Guardar diseño como predeterminado',
|
||||
'terminal.layer.layoutSavedAsDefault': 'Diseño guardado como predeterminado',
|
||||
'terminal.layer.layoutSaveFailed': 'No se pudo guardar el diseño',
|
||||
'terminal.layer.hostTree.search': 'Buscar hosts...',
|
||||
'terminal.layer.hostTree.searchButton': 'Buscar',
|
||||
'terminal.layer.hostTree.tagsButton': 'Filtrar por etiquetas',
|
||||
|
||||
@@ -84,32 +84,6 @@ export const esCoreMessages: Messages = {
|
||||
'credentials.protectionUnavailable.message': 'Las contraseñas y claves guardadas no se pueden descifrar automáticamente en este dispositivo. Vuelve a ingresar las credenciales antes de conectarte.',
|
||||
'credentials.protectionUnavailable.action': 'Abrir configuración',
|
||||
|
||||
// App Lock
|
||||
'appLock.title': 'Netcatty está bloqueado',
|
||||
'appLock.reason.default': 'Ingresa tu contraseña de bloqueo para continuar.',
|
||||
'appLock.reason.startup': 'Netcatty se bloquea cada vez que se abre esta ventana.',
|
||||
'appLock.reason.idle': 'Netcatty se bloqueó tras el tiempo de inactividad.',
|
||||
'appLock.reason.manual': 'Netcatty se bloqueó manualmente.',
|
||||
'appLock.passwordLabel': 'Contraseña de bloqueo',
|
||||
'appLock.passwordPlaceholder': 'Ingresa la contraseña para desbloquear',
|
||||
'appLock.unlock': 'Desbloquear',
|
||||
'appLock.unlocking': 'Desbloqueando...',
|
||||
'appLock.usePassword': 'Usar contraseña',
|
||||
'appLock.error.emptyPassword': 'Ingresa la contraseña de bloqueo.',
|
||||
'appLock.error.incorrectPassword': 'La contraseña de bloqueo es incorrecta.',
|
||||
'appLock.systemUnlock.unlockWith': 'Desbloquear con {label}',
|
||||
'appLock.systemUnlock.preparing': 'Preparando {label}...',
|
||||
'appLock.systemUnlock.verifying': 'Esperando a {label}...',
|
||||
'appLock.systemUnlock.error': 'No se completó el desbloqueo del sistema. Usa la contraseña de bloqueo para continuar.',
|
||||
'appLock.logoLabel': 'Netcatty',
|
||||
'appLock.reset.title': 'Restablecer el bloqueo de la aplicación',
|
||||
'appLock.reset.description': 'Esto elimina la contraseña de bloqueo actual y desactiva el bloqueo de la aplicación. Crea una nueva contraseña desde Configuración cuando la necesites.',
|
||||
'appLock.reset.cancel': 'Cancelar',
|
||||
'appLock.reset.confirm': 'Restablecer el bloqueo de la aplicación',
|
||||
'appLock.reset.resetting': 'Restableciendo...',
|
||||
'appLock.reset.error': 'No se pudo restablecer el bloqueo de la aplicación. Inténtalo de nuevo.',
|
||||
'topTabs.lockApp': 'Bloquear Netcatty',
|
||||
|
||||
// Settings shell
|
||||
'settings.title': 'Configuración',
|
||||
'settings.tab.application': 'Aplicación',
|
||||
@@ -145,65 +119,6 @@ export const esCoreMessages: Messages = {
|
||||
'settings.system.credentials.unavailableHint': 'Las credenciales cifradas en otro perfil de usuario o máquina no se pueden descifrar aquí. Vuelve a ingresar y guarda las credenciales en este dispositivo.',
|
||||
'settings.system.credentials.portabilityHint': 'La sincronización en la nube es portátil porque usa tu cifrado de clave maestra. El cifrado local de safeStorage está limitado al dispositivo y al usuario.',
|
||||
|
||||
// Settings > System > App Lock
|
||||
'settings.appLock.title': 'Bloqueo de la aplicación',
|
||||
'settings.appLock.description': 'Solicita una contraseña de bloqueo local cuando Netcatty se abre, tras un periodo de inactividad o cuando lo bloqueas manualmente.',
|
||||
'settings.appLock.setupTitle': 'Define una contraseña de bloqueo',
|
||||
'settings.appLock.setupDescription': 'Crea la contraseña a continuación. El bloqueo de la aplicación se activa en cuanto se guarda.',
|
||||
'settings.appLock.manageTitle': 'El bloqueo de la aplicación está listo',
|
||||
'settings.appLock.enabledStatus': 'Activado. Netcatty se bloquea al iniciar, tras un periodo de inactividad y cuando lo bloqueas manualmente.',
|
||||
'settings.appLock.disabledStatus': 'Contraseña guardada, pero el bloqueo de la aplicación está desactivado.',
|
||||
'settings.appLock.disableTitle': 'Desactivar el bloqueo de la aplicación',
|
||||
'settings.appLock.disableDescription': 'Ingresa la contraseña de bloqueo actual para dejar de bloquear Netcatty. La contraseña guardada se eliminará; para volver a activar el bloqueo tendrás que crear una nueva.',
|
||||
'settings.appLock.disable': 'Desactivar el bloqueo de la aplicación',
|
||||
'settings.appLock.disabling': 'Desactivando...',
|
||||
'settings.appLock.setupPasswordTitle': 'Crear contraseña de bloqueo',
|
||||
'settings.appLock.setupPasswordDescription': 'Esta contraseña es necesaria para desbloquear Netcatty y para cambiar o desactivar el bloqueo de la aplicación más adelante.',
|
||||
'settings.appLock.changePasswordTitle': 'Cambiar la contraseña de bloqueo',
|
||||
'settings.appLock.changePasswordDescription': 'Ingresa la contraseña de bloqueo actual y luego elige una nueva.',
|
||||
'settings.appLock.enable': 'Activar el bloqueo de la aplicación',
|
||||
'settings.appLock.enableDesc': 'Bloquea al iniciar y tras el tiempo de inactividad seleccionado.',
|
||||
'settings.appLock.timeout': 'Tiempo de inactividad',
|
||||
'settings.appLock.timeoutDesc': 'Bloquea tras un periodo de inactividad o al cerrar la ventana en la bandeja. Nunca omite ambos casos.',
|
||||
'settings.appLock.timeout.0': 'Nunca bloquear automáticamente',
|
||||
'settings.appLock.timeout.1': '1 minuto',
|
||||
'settings.appLock.timeout.5': '5 minutos',
|
||||
'settings.appLock.timeout.15': '15 minutos',
|
||||
'settings.appLock.timeout.30': '30 minutos',
|
||||
'settings.appLock.timeout.60': '60 minutos',
|
||||
'settings.appLock.systemUnlock.label': 'Desbloqueo con {label}',
|
||||
'settings.appLock.systemUnlock.desc': 'Usa {label} como forma alternativa de desbloquear Netcatty en este dispositivo.',
|
||||
'settings.appLock.systemUnlock.unavailableDesc': '{label} no está disponible en este dispositivo ahora mismo.',
|
||||
'settings.appLock.systemUnlock.unavailable': 'El desbloqueo del sistema no está disponible en este dispositivo.',
|
||||
'settings.appLock.systemUnlock.locked': 'Desbloquea Netcatty antes de cambiar el desbloqueo del sistema.',
|
||||
'settings.appLock.systemUnlock.autoPrompt.label': 'Abrir {label} automáticamente',
|
||||
'settings.appLock.systemUnlock.autoPrompt.desc': 'Muestra el aviso de {label} automáticamente cuando Netcatty está esperando para desbloquearse.',
|
||||
'settings.appLock.currentPassword': 'Contraseña actual',
|
||||
'settings.appLock.currentPasswordPlaceholder': 'Necesaria para desactivar o reemplazar',
|
||||
'settings.appLock.currentPasswordForDisablePlaceholder': 'Contraseña actual para desactivar el bloqueo de la aplicación',
|
||||
'settings.appLock.currentPasswordForChangePlaceholder': 'Contraseña actual para cambiarla',
|
||||
'settings.appLock.newPassword': 'Nueva contraseña',
|
||||
'settings.appLock.newPasswordPlaceholder': 'Define la contraseña de bloqueo',
|
||||
'settings.appLock.confirmPassword': 'Confirmar contraseña',
|
||||
'settings.appLock.confirmPasswordPlaceholder': 'Repite la contraseña',
|
||||
'settings.appLock.savePassword': 'Definir y activar',
|
||||
'settings.appLock.savingPassword': 'Guardando...',
|
||||
'settings.appLock.passwordSet': 'Contraseña configurada',
|
||||
'settings.appLock.replacePassword': 'Reemplazar contraseña',
|
||||
'settings.appLock.enableAfterPassword': 'Define una contraseña de bloqueo antes de activar el bloqueo de la aplicación.',
|
||||
'settings.appLock.localOnlyHint': 'Bloqueo de privacidad local del dispositivo. No se sincroniza y no es una barrera de seguridad del sistema operativo.',
|
||||
'settings.appLock.validation.currentRequired': 'Ingresa la contraseña de bloqueo actual.',
|
||||
'settings.appLock.validation.newRequired': 'Ingresa una nueva contraseña de bloqueo.',
|
||||
'settings.appLock.validation.confirmRequired': 'Confirma la nueva contraseña de bloqueo.',
|
||||
'settings.appLock.validation.mismatch': 'Las contraseñas nuevas no coinciden.',
|
||||
'settings.appLock.validation.incorrect': 'La contraseña de bloqueo actual es incorrecta.',
|
||||
|
||||
// Settings > System > Auto Launch
|
||||
'settings.autoLaunch.title': 'Abrir al iniciar sesión',
|
||||
'settings.autoLaunch.enabled': 'Iniciar al arrancar el sistema',
|
||||
'settings.autoLaunch.enabledDesc': 'Inicia Netcatty automáticamente al iniciar sesión, oculto en la bandeja del sistema.',
|
||||
'settings.autoLaunch.unsupportedDesc': 'No disponible en esta plataforma ni en una compilación de desarrollo.',
|
||||
|
||||
// Settings > System > Network Proxy
|
||||
'settings.system.networkProxy.title': 'Proxy de red',
|
||||
'settings.system.networkProxy.description': 'Proxy HTTP(S) para la sincronización en la nube y los proveedores de IA. Esto es independiente de los perfiles de ProxyJump / ProxyCommand de SSH.',
|
||||
|
||||
@@ -33,9 +33,6 @@ export const esSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadPorts': 'No se pudieron cargar los puertos en escucha',
|
||||
'systemManager.errors.loadServices': 'No se pudieron cargar los servicios de systemd',
|
||||
'systemManager.errors.sshChannelUnavailable': 'El servidor se negó a abrir un nuevo canal de ejecución. Inténtalo de nuevo más tarde o vuelve a conectar este host.',
|
||||
'systemManager.errors.nerdctlUnreachable': 'nerdctl no puede conectarse a containerd',
|
||||
'systemManager.errors.clusterUnreachable': 'No se puede conectar al clúster para el contexto {{context}}',
|
||||
'systemManager.errors.clusterUnreachableGeneric': 'No se puede conectar al clúster',
|
||||
'systemManager.unsupportedRemote': 'Este host no permite canales SSH de ejecución adicionales, por lo que el administrador del sistema no está disponible. Abrir este panel ya no desconectará la terminal.',
|
||||
|
||||
'systemManager.overview.empty': 'Aún no hay datos de resumen del sistema.',
|
||||
@@ -215,20 +212,6 @@ export const esSystemManagerMessages: Messages = {
|
||||
'systemManager.k8s.restarts': '{{count}} reinicios',
|
||||
'systemManager.k8s.chooseContainer': 'Elegir un contenedor',
|
||||
'systemManager.k8s.noContext': 'No hay un contexto de Kubernetes en este host',
|
||||
'systemManager.k8s.status.Running': 'En ejecución',
|
||||
'systemManager.k8s.status.Pending': 'Pendiente',
|
||||
'systemManager.k8s.status.Completed': 'Completado',
|
||||
'systemManager.k8s.status.Succeeded': 'Con éxito',
|
||||
'systemManager.k8s.status.Failed': 'Fallido',
|
||||
'systemManager.k8s.status.Unknown': 'Desconocido',
|
||||
'systemManager.k8s.status.CrashLoopBackOff': 'Bucle de fallos',
|
||||
'systemManager.k8s.status.ContainerCreating': 'Creando contenedor',
|
||||
'systemManager.k8s.status.Terminating': 'Terminando',
|
||||
'systemManager.k8s.status.ImagePullBackOff': 'Reintentando descarga de imagen',
|
||||
'systemManager.k8s.status.ErrImagePull': 'Error al descargar la imagen',
|
||||
'systemManager.k8s.status.Error': 'Error',
|
||||
'systemManager.k8s.status.Evicted': 'Desalojado',
|
||||
'systemManager.k8s.status.OOMKilled': 'Sin memoria',
|
||||
|
||||
'systemManager.ports.unavailable': 'No se detectaron herramientas de puertos en escucha (ss / netstat) en este host.',
|
||||
'systemManager.ports.loading': 'Cargando puertos en escucha…',
|
||||
|
||||
@@ -219,7 +219,6 @@ export const esTerminalMessages: Messages = {
|
||||
'terminal.progress.timeoutIn': 'Tiempo de espera en {seconds}s',
|
||||
'terminal.progress.waitingForUserInput': 'Esperando entrada del usuario',
|
||||
'terminal.progress.disconnected': 'Desconectado',
|
||||
'terminal.progress.connected': 'Conectado',
|
||||
'terminal.progress.cancelling': 'Cancelando...',
|
||||
'terminal.progress.startOver': 'Empezar de nuevo',
|
||||
'terminal.progress.enterReconnectHint': 'Presiona Enter para reconectar',
|
||||
@@ -676,45 +675,6 @@ export const esTerminalMessages: Messages = {
|
||||
'keychain.keyDialog.editTitle': 'Editar clave',
|
||||
'keychain.keyDialog.editDesc': 'Actualizar esta clave SSH',
|
||||
'keychain.keyDialog.updateKey': 'Actualizar clave',
|
||||
'keychain.generate.standardKeys': 'Claves estándar',
|
||||
'keychain.generate.securityKeys': 'Llaves de seguridad · FIDO2',
|
||||
'keychain.generate.residentDesc': 'Permite recuperar las credenciales desde esta llave de seguridad.',
|
||||
'keychain.generate.verifyRequiredDesc': 'Solicita el PIN del dispositivo antes de firmar.',
|
||||
'keychain.generate.generating': 'Generando…',
|
||||
'keychain.generate.fidoHint': 'Conecta una llave de seguridad FIDO2. Es posible que se te solicite su PIN o un toque.',
|
||||
'keychain.generate.resident': 'Guardar las credenciales en la llave',
|
||||
'keychain.generate.verifyRequired': 'Verificar el PIN cada vez',
|
||||
'keychain.action.loadResident': 'Cargar claves residentes desde la llave de seguridad',
|
||||
|
||||
// FIDO2 security key prompts and recovery states
|
||||
'fido.prompt.pinTouchDesc': 'Ingresa tu PIN y luego toca la llave para confirmar.',
|
||||
'fido.prompt.details': 'Detalles de la solicitud',
|
||||
'fido.prompt.showPin': 'Mostrar PIN',
|
||||
'fido.prompt.hidePin': 'Ocultar PIN',
|
||||
'fido.prompt.verifying': 'Verificando…',
|
||||
'fido.prompt.pinTitle': 'PIN de la llave de seguridad',
|
||||
'fido.prompt.touchTitle': 'Toca tu llave de seguridad',
|
||||
'fido.prompt.pinDesc': 'Ingresa el PIN de tu llave de seguridad para continuar.',
|
||||
'fido.prompt.touchDesc': 'Toca tu llave de seguridad para confirmar tu identidad.',
|
||||
'fido.prompt.pinLabel': 'PIN',
|
||||
'fido.prompt.touchWaiting': 'Esperando tu llave…',
|
||||
'fido.prompt.touchAutomatic': 'Continúa automáticamente después de que la toques',
|
||||
'fido.prompt.cancelled': 'Se canceló la autenticación con llave de seguridad.',
|
||||
'fido.prompt.touchDone': 'Ya la toqué',
|
||||
'fido.prompt.timeout': 'El aviso de FIDO agotó el tiempo de espera. Intenta conectarte de nuevo.',
|
||||
'fido.recovery.device-unavailable.title': 'Conecta la llave de seguridad correspondiente',
|
||||
'fido.recovery.device-unavailable.desc': 'Conecta el dispositivo que contiene la credencial de esta llave y vuelve a intentarlo.',
|
||||
'fido.recovery.timeout.title': 'Se agotó el tiempo de confirmación de la llave de seguridad',
|
||||
'fido.recovery.timeout.desc': 'La operación del dispositivo se detuvo. Comprueba que tu llave esté conectada, vuelve a intentarlo y sigue el aviso de toque o verificación.',
|
||||
'fido.recovery.pin-failed.title': 'No se aceptó el PIN de la llave de seguridad',
|
||||
'fido.recovery.pin-failed.desc': 'Verifica el PIN FIDO2 de este dispositivo antes de reintentar. Varios intentos incorrectos pueden bloquear el dispositivo.',
|
||||
'fido.recovery.failed.title': 'No se completó la verificación de la llave de seguridad',
|
||||
'fido.recovery.failed.desc': 'Comprueba que el dispositivo correspondiente esté conectado y admita esta llave. Reintenta o cancela para revisar el dispositivo y la configuración de OpenSSH.',
|
||||
'fido.recovery.retry': 'Reintentar verificación',
|
||||
'fido.recovery.usePassword': 'Usar la contraseña configurada',
|
||||
'fido.recovery.passwordFallback': 'Probando la contraseña configurada para este host',
|
||||
'fido.error.opensshMissing': 'Se requiere OpenSSH con FIDO/libfido2. En macOS: brew install openssh libfido2.',
|
||||
'fido.error.agentUnavailable': 'No se pudo iniciar un agente SSH compatible con FIDO.',
|
||||
|
||||
// Tabs
|
||||
'tabs.closeSessionAria': 'Cerrar sesión',
|
||||
@@ -881,7 +841,6 @@ export const esTerminalMessages: Messages = {
|
||||
'serial.field.flowControl': 'Control de flujo',
|
||||
'serial.noPorts': 'No se detectaron puertos seriales. Conecta un dispositivo y actualiza.',
|
||||
'serial.field.customPort': 'Ruta de puerto personalizada',
|
||||
'serial.field.namedPipeHint': 'Consola de VM en Windows: introduce \\\\.\\pipe\\name. La VM debe crear la canalización como servidor. Se ignoran la velocidad y la trama.',
|
||||
'serial.field.customPortPlaceholder': 'p. ej., /dev/ttys001 o COM1',
|
||||
'serial.type.hardware': 'Hardware',
|
||||
'serial.type.pseudo': 'Pseudoterminal',
|
||||
|
||||
@@ -370,7 +370,6 @@ export const esVaultMessages: Messages = {
|
||||
'sftp.transfers.openTargetFolder': 'Abrir carpeta de destino',
|
||||
'sftp.transfers.openTargetFolderError': 'No se pudo abrir la carpeta de destino',
|
||||
'sftp.transfers.copyTargetPath': 'Copiar ruta de destino',
|
||||
'sftp.transfers.cleanupWarnings': 'Avisos de limpieza de copias de seguridad: {count}. Muestra los archivos para ver los detalles.',
|
||||
'sftp.transfers.copyTargetPathSuccess': 'Ruta de destino copiada',
|
||||
'sftp.transfers.copyTargetPathError': 'No se pudo copiar la ruta de destino',
|
||||
'sftp.transfers.pathConflict.title': 'Transferencia bloqueada',
|
||||
@@ -1044,18 +1043,6 @@ export const esVaultMessages: Messages = {
|
||||
'hostDetails.addTag': 'Agregar una etiqueta...',
|
||||
'hostDetails.createTag': 'Crear etiqueta',
|
||||
'hostDetails.createGroup': 'Crear grupo',
|
||||
|
||||
// Host connection test
|
||||
'hostConnectionTest.title': 'Probar conexión',
|
||||
'hostConnectionTest.test': 'Probar',
|
||||
'hostConnectionTest.chainOf': 'Cadena {current} de {total}',
|
||||
'hostConnectionTest.success': 'Conexión correcta',
|
||||
'hostConnectionTest.successHint': 'El host aceptó tus credenciales. Puedes cerrar esta ventana.',
|
||||
'hostConnectionTest.connecting': 'Conectando… tiempo de espera en {seconds}s',
|
||||
'hostConnectionTest.failed': 'No se pudo conectar.',
|
||||
'hostConnectionTest.retry': 'Reintentar',
|
||||
'hostConnectionTest.credentialsUnavailable': 'Las credenciales guardadas no se pueden descifrar en este dispositivo. Vuelve a ingresarlas abajo.',
|
||||
|
||||
// Entradas de shell local (CMD) en la bóveda
|
||||
'localShell.subtitle': 'Shell local',
|
||||
'localShell.panel.title': 'Nueva shell local',
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { DEFAULT_KEY_BINDINGS } from "../../../domain/models/keyBindings.ts";
|
||||
import en from "./en.ts";
|
||||
import es from "./es.ts";
|
||||
import ru from "./ru.ts";
|
||||
import zhCN from "./zh-CN.ts";
|
||||
import zhTW from "./zh-TW.ts";
|
||||
|
||||
// Locales that are not at full key parity with `en` yet. They are tracked here
|
||||
// so the remaining debt stays visible instead of silently rendering in English
|
||||
// through the `DEFAULT_UI_LOCALE` fallback in `I18nProvider.resolveMessage`.
|
||||
// Move a locale out of this list once it covers every `en` key.
|
||||
const LOCALES_PENDING_PARITY = ["ru", "zh-CN", "zh-TW"] as const;
|
||||
|
||||
const hasKey = (messages: Record<string, string>, key: string) =>
|
||||
Object.hasOwn(messages, key);
|
||||
|
||||
const interpolationTokens = (value: string) =>
|
||||
[...value.matchAll(/\{\{\s*\w+\s*\}\}|\{\s*\w+\s*\}/g)]
|
||||
.map((match) => match[0].replace(/[{}]/g, "").trim())
|
||||
.sort();
|
||||
|
||||
test("es defines every message key that en defines", () => {
|
||||
const missing = Object.keys(en).filter((key) => !hasKey(es, key));
|
||||
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
`es is missing ${missing.length} message keys`,
|
||||
);
|
||||
});
|
||||
|
||||
test("es defines no message keys that en does not define", () => {
|
||||
const extra = Object.keys(es).filter((key) => !hasKey(en, key));
|
||||
|
||||
assert.deepEqual(
|
||||
extra,
|
||||
[],
|
||||
`es defines ${extra.length} keys that no longer exist in en`,
|
||||
);
|
||||
});
|
||||
|
||||
test("es keeps the same interpolation placeholders as en", () => {
|
||||
const mismatched: string[] = [];
|
||||
|
||||
for (const key of Object.keys(es)) {
|
||||
if (!hasKey(en, key)) continue;
|
||||
|
||||
const expected = interpolationTokens(en[key] ?? "").join(",");
|
||||
const actual = interpolationTokens(es[key] ?? "").join(",");
|
||||
if (expected !== actual) mismatched.push(key);
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
mismatched,
|
||||
[],
|
||||
`es changes interpolation placeholders for ${mismatched.length} keys`,
|
||||
);
|
||||
});
|
||||
|
||||
test("every default shortcut has a label in en and es", () => {
|
||||
const locales = [
|
||||
{ name: "en", messages: en },
|
||||
{ name: "es", messages: es },
|
||||
];
|
||||
|
||||
for (const locale of locales) {
|
||||
const missing = DEFAULT_KEY_BINDINGS.map(
|
||||
(binding) => `settings.shortcuts.binding.${binding.id}`,
|
||||
).filter((key) => !hasKey(locale.messages, key));
|
||||
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
`${locale.name} is missing ${missing.length} shortcut labels`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("en shortcut labels stay identical to DEFAULT_KEY_BINDINGS", () => {
|
||||
// SettingsShortcutsTab falls back to `binding.label` only while the locale
|
||||
// resolves the key to itself. Once `en` defines the key, the locale becomes
|
||||
// the rendered source, so the two must not drift apart.
|
||||
for (const binding of DEFAULT_KEY_BINDINGS) {
|
||||
const key = `settings.shortcuts.binding.${binding.id}`;
|
||||
|
||||
assert.equal(
|
||||
en[key],
|
||||
binding.label,
|
||||
`en label drifted from DEFAULT_KEY_BINDINGS for ${binding.id}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("every locale outside the pending list is at full parity with en", () => {
|
||||
const locales = [
|
||||
{ name: "es", messages: es },
|
||||
{ name: "ru", messages: ru },
|
||||
{ name: "zh-CN", messages: zhCN },
|
||||
{ name: "zh-TW", messages: zhTW },
|
||||
];
|
||||
|
||||
for (const locale of locales) {
|
||||
if ((LOCALES_PENDING_PARITY as readonly string[]).includes(locale.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const missing = Object.keys(en).filter(
|
||||
(key) => !hasKey(locale.messages, key),
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
`${locale.name} is missing ${missing.length} message keys`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -886,7 +886,6 @@ export const ruTerminalMessages: Messages = {
|
||||
'serial.field.flowControl': 'Управление потоком',
|
||||
'serial.noPorts': 'Последовательные порты не обнаружены. Подключите устройство и обновите список.',
|
||||
'serial.field.customPort': 'Путь к пользовательскому порту',
|
||||
'serial.field.namedPipeHint': 'Консоль ВМ Windows: введите \\\\.\\pipe\\name. ВМ должна создать канал как сервер. Скорость и параметры кадра игнорируются.',
|
||||
'serial.field.customPortPlaceholder': 'например, /dev/ttys001 или COM1',
|
||||
'serial.type.hardware': 'Аппаратный',
|
||||
'serial.type.pseudo': 'Псевдотерминал',
|
||||
|
||||
@@ -413,7 +413,6 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.transfers.openTargetFolder': 'Открыть целевую папку',
|
||||
'sftp.transfers.openTargetFolderError': 'Не удалось открыть целевую папку',
|
||||
'sftp.transfers.copyTargetPath': 'Копировать целевой путь',
|
||||
'sftp.transfers.cleanupWarnings': 'Предупреждения об удалении резервных копий: {count}. Откройте список файлов для подробностей.',
|
||||
'sftp.transfers.copyTargetPathSuccess': 'Целевой путь скопирован',
|
||||
'sftp.transfers.copyTargetPathError': 'Не удалось скопировать целевой путь',
|
||||
'sftp.transfers.pathConflict.title': 'Передача заблокирована',
|
||||
|
||||
@@ -18,19 +18,12 @@ const LOCALIZED_SETTINGS_LOCALES = [
|
||||
|
||||
const APP_LOCK_LOCALES = [
|
||||
{ name: "en", messages: en },
|
||||
{ name: "es", messages: es },
|
||||
{ name: "ru", messages: ru },
|
||||
{ name: "zh-CN", messages: zhCN },
|
||||
{ name: "zh-TW", messages: zhTW },
|
||||
];
|
||||
|
||||
const ALL_SETTINGS_LOCALES = [
|
||||
{ name: "en", messages: en },
|
||||
...LOCALIZED_SETTINGS_LOCALES,
|
||||
{ name: "ru", messages: ru },
|
||||
];
|
||||
|
||||
test("localized settings include names for every default shortcut", () => {
|
||||
for (const locale of ALL_SETTINGS_LOCALES) {
|
||||
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
|
||||
const missing = DEFAULT_KEY_BINDINGS
|
||||
.map((binding) => `settings.shortcuts.binding.${binding.id}`)
|
||||
.filter((key) => !locale.messages[key]);
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import en from "./en";
|
||||
import es from "./es";
|
||||
import ru from "./ru";
|
||||
import zhCN from "./zh-CN";
|
||||
import zhTW from "./zh-TW";
|
||||
|
||||
test("folder cleanup-warning counts are localized in every supported locale", () => {
|
||||
for (const [locale, messages] of Object.entries({ en, es, ru, zhCN, zhTW })) {
|
||||
assert.match(messages["sftp.transfers.cleanupWarnings"] ?? "", /\{count\}/, locale);
|
||||
}
|
||||
});
|
||||
@@ -1035,7 +1035,6 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
'sftp.transfers.openTargetFolder': '打开目标目录',
|
||||
'sftp.transfers.openTargetFolderError': '无法打开目标目录',
|
||||
'sftp.transfers.copyTargetPath': '复制目标路径',
|
||||
'sftp.transfers.cleanupWarnings': '备份清理警告:{count}。展开文件列表查看详情。',
|
||||
'sftp.transfers.copyTargetPathSuccess': '已复制目标路径',
|
||||
'sftp.transfers.copyTargetPathError': '无法复制目标路径',
|
||||
'sftp.transfers.pathConflict.title': '无法开始传输',
|
||||
|
||||
@@ -969,7 +969,6 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'serial.field.flowControl': '流控制',
|
||||
'serial.noPorts': '未检测到串口设备。请连接设备后刷新。',
|
||||
'serial.field.customPort': '自定义串口路径',
|
||||
'serial.field.namedPipeHint': 'Windows 虚拟机控制台:输入 \\\\.\\pipe\\name。虚拟机需作为服务端创建管道,波特率和串口帧设置不生效。',
|
||||
'serial.field.customPortPlaceholder': '例如 /dev/ttys001 或 COM1',
|
||||
'serial.type.hardware': '硬件',
|
||||
'serial.type.pseudo': '虚拟终端',
|
||||
|
||||
@@ -1037,7 +1037,6 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
'sftp.transfers.openTargetFolder': '開啟目標目錄',
|
||||
'sftp.transfers.openTargetFolderError': '無法開啟目標目錄',
|
||||
'sftp.transfers.copyTargetPath': '複製目標路徑',
|
||||
'sftp.transfers.cleanupWarnings': '備份清理警告:{count}。展開檔案清單查看詳情。',
|
||||
'sftp.transfers.copyTargetPathSuccess': '已複製目標路徑',
|
||||
'sftp.transfers.copyTargetPathError': '無法複製目標路徑',
|
||||
'sftp.transfers.pathConflict.title': '無法開始傳輸',
|
||||
|
||||
@@ -969,7 +969,6 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'serial.field.flowControl': '流量控制',
|
||||
'serial.noPorts': '未偵測到序列埠裝置。請連線裝置後重新整理。',
|
||||
'serial.field.customPort': '自訂序列埠路徑',
|
||||
'serial.field.namedPipeHint': 'Windows 虛擬機主控台:輸入 \\\\.\\pipe\\name。虛擬機需作為伺服器端建立管道,鮑率與串口框架設定不生效。',
|
||||
'serial.field.customPortPlaceholder': '例如 /dev/ttys001 或 COM1',
|
||||
'serial.type.hardware': '硬體',
|
||||
'serial.type.pseudo': '虛擬終端',
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge.ts";
|
||||
import { editorTabStore } from "./editorTabStore.ts";
|
||||
import { dockDetachedEditorTab, installEditorWindowSourceListeners, popOutEditorTab, saveDetachedEditorTab, subscribeEditorWindowDirtyCheck } from "./editorWindowClient.ts";
|
||||
import { dockDetachedEditorTab, installEditorWindowSourceListeners, popOutEditorTab, saveDetachedEditorTab } from "./editorWindowClient.ts";
|
||||
import { registerEditorSftpWriterScoped } from "./editorSftpBridge.ts";
|
||||
import { releaseEditorTabSaveCoordinator } from "./editorTabSave.ts";
|
||||
import type { EditorWindowSaveRequest, EditorWindowSaveResult } from "./editorWindowTypes.ts";
|
||||
@@ -145,16 +145,3 @@ test("a closing destination rejects a popout without releasing source content",
|
||||
assert.equal(tab.baselineContent, "v1");
|
||||
assert.equal(editorTabStore.isDirty(tab.id), true);
|
||||
});
|
||||
|
||||
|
||||
test("detached editor dirty-check replies echo the main-process request", (t) => {
|
||||
let query!: (request: { requestId: string; deadline: number }) => void;
|
||||
const replies: unknown[] = [];
|
||||
t.mock.method(netcattyBridge, "get", () => ({
|
||||
onCheckDirtyEditors: (callback: typeof query) => { query = callback; return () => {}; },
|
||||
reportDirtyEditorsResult: (...args: unknown[]) => replies.push(args),
|
||||
}));
|
||||
t.after(subscribeEditorWindowDirtyCheck(() => true));
|
||||
query({ requestId: "detached-check", deadline: Date.now() + 5000 });
|
||||
assert.deepEqual(replies, [[true, undefined, { requestId: "detached-check" }]]);
|
||||
});
|
||||
@@ -200,7 +200,7 @@ export function reportEditorWindowCloseTabsResult(
|
||||
export function subscribeEditorWindowDirtyCheck(cb: () => boolean): () => void {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onCheckDirtyEditors) return () => {};
|
||||
return bridge.onCheckDirtyEditors((request) => {
|
||||
return bridge.onCheckDirtyEditors(() => {
|
||||
let hasDirty = false;
|
||||
try {
|
||||
hasDirty = cb() === true;
|
||||
@@ -208,7 +208,7 @@ export function subscribeEditorWindowDirtyCheck(cb: () => boolean): () => void {
|
||||
hasDirty = false;
|
||||
}
|
||||
try {
|
||||
bridge.reportDirtyEditorsResult?.(hasDirty, undefined, { requestId: request?.requestId });
|
||||
bridge.reportDirtyEditorsResult?.(hasDirty);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
@@ -1,255 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { decideHiddenTabKeepCountAction } from "../../domain/terminalHibernate.ts";
|
||||
import { retainLeavingTabAfterEviction } from "./hiddenTabEviction.ts";
|
||||
import { terminalHiddenRendererStore } from "./terminalHiddenRendererStore.ts";
|
||||
|
||||
test("a refused eviction tries the next candidate, then full-hibernates", async () => {
|
||||
const attempts: string[] = [];
|
||||
const outcomes: string[] = [];
|
||||
await retainLeavingTabAfterEviction({
|
||||
sessionId: "leaving",
|
||||
candidates: ["images", "plain"],
|
||||
keepCount: 2,
|
||||
stillWantsRetention: () => true,
|
||||
softHide: () => outcomes.push("soft-hide"),
|
||||
fullHibernate: () => outcomes.push("full-hibernate"),
|
||||
evict: async (sessionId) => {
|
||||
attempts.push(sessionId);
|
||||
return sessionId === "plain";
|
||||
},
|
||||
});
|
||||
assert.deepEqual(attempts, ["images", "plain"]);
|
||||
assert.deepEqual(outcomes, ["soft-hide"]);
|
||||
|
||||
const blocked: string[] = [];
|
||||
const blockedOutcomes: string[] = [];
|
||||
await retainLeavingTabAfterEviction({
|
||||
sessionId: "leaving",
|
||||
candidates: ["images", "also-images"],
|
||||
keepCount: 2,
|
||||
stillWantsRetention: () => true,
|
||||
softHide: () => blockedOutcomes.push("soft-hide"),
|
||||
fullHibernate: () => blockedOutcomes.push("full-hibernate"),
|
||||
evict: async (sessionId) => {
|
||||
blocked.push(sessionId);
|
||||
return false;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(blocked, ["images", "also-images"]);
|
||||
assert.deepEqual(blockedOutcomes, ["full-hibernate"]);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
|
||||
});
|
||||
|
||||
test("hibernate failure after the soft-hidden marker drops does not retain the leaving tab", async () => {
|
||||
const attempts: string[] = [];
|
||||
const outcomes: string[] = [];
|
||||
await retainLeavingTabAfterEviction({
|
||||
sessionId: "leaving",
|
||||
candidates: ["victim"],
|
||||
keepCount: 2,
|
||||
stillWantsRetention: () => true,
|
||||
softHide: () => outcomes.push("soft-hide"),
|
||||
fullHibernate: () => outcomes.push("full-hibernate"),
|
||||
evict: async (sessionId) => {
|
||||
attempts.push(sessionId);
|
||||
await Promise.resolve();
|
||||
return false;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(attempts, ["victim"]);
|
||||
assert.deepEqual(outcomes, ["full-hibernate"]);
|
||||
|
||||
const recovered: string[] = [];
|
||||
const recoveredOutcomes: string[] = [];
|
||||
await retainLeavingTabAfterEviction({
|
||||
sessionId: "leaving",
|
||||
candidates: ["victim", "next"],
|
||||
keepCount: 2,
|
||||
stillWantsRetention: () => true,
|
||||
softHide: () => recoveredOutcomes.push("soft-hide"),
|
||||
fullHibernate: () => recoveredOutcomes.push("full-hibernate"),
|
||||
evict: async (sessionId) => {
|
||||
recovered.push(sessionId);
|
||||
await Promise.resolve();
|
||||
return sessionId === "next";
|
||||
},
|
||||
});
|
||||
assert.deepEqual(recovered, ["victim", "next"]);
|
||||
assert.deepEqual(recoveredOutcomes, ["soft-hide"]);
|
||||
});
|
||||
|
||||
test("a live keep count that still fits after eviction soft-hides the leaving tab", async () => {
|
||||
let keepCount = 0;
|
||||
const outcomes: string[] = [];
|
||||
await retainLeavingTabAfterEviction({
|
||||
sessionId: "leaving",
|
||||
candidates: ["victim"],
|
||||
keepCount,
|
||||
resolveKeepCount: () => keepCount,
|
||||
stillWantsRetention: () => true,
|
||||
softHide: () => outcomes.push("soft-hide"),
|
||||
fullHibernate: () => outcomes.push("full-hibernate"),
|
||||
evict: async () => {
|
||||
keepCount = 2;
|
||||
await Promise.resolve();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(outcomes, ["soft-hide"]);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
|
||||
});
|
||||
|
||||
test("a keep count dropped to zero during eviction full-hibernates the leaving tab", async () => {
|
||||
let keepCount = 2;
|
||||
const outcomes: string[] = [];
|
||||
await retainLeavingTabAfterEviction({
|
||||
sessionId: "leaving",
|
||||
candidates: ["victim"],
|
||||
keepCount,
|
||||
resolveKeepCount: () => keepCount,
|
||||
stillWantsRetention: () => true,
|
||||
softHide: () => outcomes.push("soft-hide"),
|
||||
fullHibernate: () => outcomes.push("full-hibernate"),
|
||||
evict: async () => {
|
||||
keepCount = 0;
|
||||
await Promise.resolve();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(outcomes, ["full-hibernate"]);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
|
||||
});
|
||||
|
||||
test("a keep count that no longer fits after eviction full-hibernates the leaving tab", async () => {
|
||||
const sessions = ["victim", "other", "leaving"];
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
terminalHiddenRendererStore.markSoftHidden("other");
|
||||
let keepCount = 2;
|
||||
const outcomes: string[] = [];
|
||||
try {
|
||||
await retainLeavingTabAfterEviction({
|
||||
sessionId: "leaving",
|
||||
candidates: ["victim"],
|
||||
keepCount,
|
||||
resolveKeepCount: () => keepCount,
|
||||
stillWantsRetention: () => true,
|
||||
softHide: () => outcomes.push("soft-hide"),
|
||||
fullHibernate: () => outcomes.push("full-hibernate"),
|
||||
evict: async () => {
|
||||
keepCount = 1;
|
||||
await Promise.resolve();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(outcomes, ["full-hibernate"]);
|
||||
assert.equal(terminalHiddenRendererStore.isSoftHidden("leaving"), false);
|
||||
} finally {
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
test("a leaving tab that no longer wants retention is not soft-hidden or hibernated", async () => {
|
||||
const outcomes: string[] = [];
|
||||
await retainLeavingTabAfterEviction({
|
||||
sessionId: "leaving",
|
||||
candidates: ["victim"],
|
||||
keepCount: 2,
|
||||
stillWantsRetention: () => false,
|
||||
softHide: () => outcomes.push("soft-hide"),
|
||||
fullHibernate: () => outcomes.push("full-hibernate"),
|
||||
evict: async () => true,
|
||||
});
|
||||
assert.deepEqual(outcomes, []);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
|
||||
});
|
||||
|
||||
test("a pending reservation keeps a second tab from seeing spare capacity", async () => {
|
||||
const sessions = ["victim", "other", "leaving"];
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
terminalHiddenRendererStore.markSoftHidden("victim");
|
||||
terminalHiddenRendererStore.markSoftHidden("other");
|
||||
|
||||
const unsubscribe = terminalHiddenRendererStore.subscribe(() => {
|
||||
if (!terminalHiddenRendererStore.consumeEvictionRequest("victim")) return;
|
||||
terminalHiddenRendererStore.clearSoftHidden("victim");
|
||||
});
|
||||
|
||||
const outcomes: string[] = [];
|
||||
const pending = retainLeavingTabAfterEviction({
|
||||
sessionId: "leaving",
|
||||
candidates: ["victim"],
|
||||
keepCount: 2,
|
||||
stillWantsRetention: () => true,
|
||||
softHide: () => {
|
||||
terminalHiddenRendererStore.markSoftHidden("leaving");
|
||||
outcomes.push("soft-hide");
|
||||
},
|
||||
fullHibernate: () => outcomes.push("full-hibernate"),
|
||||
evict: (sessionId) => terminalHiddenRendererStore.evictSoftHidden(sessionId),
|
||||
});
|
||||
|
||||
try {
|
||||
assert.equal(terminalHiddenRendererStore.isSoftHidden("victim"), false);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenCount(), 1);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
|
||||
assert.deepEqual(decideHiddenTabKeepCountAction({
|
||||
keepCount: 2,
|
||||
softHiddenCount: terminalHiddenRendererStore.getSoftHiddenOccupancy(),
|
||||
sessionId: "second",
|
||||
candidatesOldestFirst: terminalHiddenRendererStore.listSoftHiddenOldestFirst(),
|
||||
}), { action: "await-eviction", candidates: ["other"] });
|
||||
|
||||
terminalHiddenRendererStore.settleEviction("victim", true);
|
||||
await pending;
|
||||
assert.deepEqual(outcomes, ["soft-hide"]);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenCount(), 2);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
terminalHiddenRendererStore.settleEviction("victim", false);
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
await pending.catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("two leaving tabs share one freed slot and stay within keep-count", async () => {
|
||||
const sessions = ["victim", "other", "leaving-a", "leaving-b"];
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
terminalHiddenRendererStore.markSoftHidden("victim");
|
||||
terminalHiddenRendererStore.markSoftHidden("other");
|
||||
|
||||
const unsubscribe = terminalHiddenRendererStore.subscribe(() => {
|
||||
if (!terminalHiddenRendererStore.consumeEvictionRequest("victim")) return;
|
||||
terminalHiddenRendererStore.clearSoftHidden("victim");
|
||||
queueMicrotask(() => {
|
||||
terminalHiddenRendererStore.settleEviction("victim", true);
|
||||
});
|
||||
});
|
||||
|
||||
const retained: string[] = [];
|
||||
const hibernated: string[] = [];
|
||||
const retain = (sessionId: string) => retainLeavingTabAfterEviction({
|
||||
sessionId,
|
||||
candidates: ["victim"],
|
||||
keepCount: 2,
|
||||
stillWantsRetention: () => true,
|
||||
softHide: () => {
|
||||
terminalHiddenRendererStore.markSoftHidden(sessionId);
|
||||
retained.push(sessionId);
|
||||
},
|
||||
fullHibernate: () => hibernated.push(sessionId),
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.all([retain("leaving-a"), retain("leaving-b")]);
|
||||
assert.equal(retained.length, 1);
|
||||
assert.equal(hibernated.length, 1);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenCount(), 2);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
}
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
import { terminalHiddenRendererStore } from "./terminalHiddenRendererStore.ts";
|
||||
|
||||
/**
|
||||
* Walk keep-count victims until one hibernate completes, then retain the
|
||||
* leaving tab only if its reserved slot still fits.
|
||||
*
|
||||
* The reservation is taken before the first await. Clearing a victim marker
|
||||
* during wake would otherwise look like spare capacity to a second hidden-tab
|
||||
* timer, and both tabs would soft-hide once the same eviction resolved.
|
||||
*/
|
||||
export async function retainLeavingTabAfterEviction(input: {
|
||||
sessionId: string;
|
||||
candidates: readonly string[];
|
||||
/** Count captured when this leave started. Used only if resolveKeepCount is absent. */
|
||||
keepCount: number;
|
||||
/**
|
||||
* Live keep-count read after eviction settles. Settings can change while the
|
||||
* victim drains or serializes; retaining against the captured count would
|
||||
* soft-hide a tab the user no longer wants kept.
|
||||
*/
|
||||
resolveKeepCount?: () => number;
|
||||
stillWantsRetention: () => boolean;
|
||||
softHide: () => void;
|
||||
fullHibernate: () => void;
|
||||
evict?: (sessionId: string) => Promise<boolean> | boolean;
|
||||
}): Promise<void> {
|
||||
const token = terminalHiddenRendererStore.reserveLeavingSlot(input.sessionId);
|
||||
try {
|
||||
const evict = input.evict ?? ((sessionId: string) => (
|
||||
terminalHiddenRendererStore.evictSoftHidden(sessionId)
|
||||
));
|
||||
let completed = false;
|
||||
for (const candidate of input.candidates) {
|
||||
if (!candidate || candidate === input.sessionId) continue;
|
||||
if (await evict(candidate)) {
|
||||
completed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!input.stillWantsRetention()) return;
|
||||
const keepCount = input.resolveKeepCount?.() ?? input.keepCount;
|
||||
if (
|
||||
completed
|
||||
&& terminalHiddenRendererStore.leavingSlotStillFits(input.sessionId, keepCount)
|
||||
) {
|
||||
input.softHide();
|
||||
return;
|
||||
}
|
||||
input.fullHibernate();
|
||||
} finally {
|
||||
terminalHiddenRendererStore.releaseLeavingSlot(input.sessionId, token);
|
||||
}
|
||||
}
|
||||
@@ -91,7 +91,6 @@ test('session actions register and unregister', () => {
|
||||
createWorkspaceFromSessions: noop,
|
||||
addSessionToWorkspace: noop,
|
||||
appendHostToWorkspace: noop,
|
||||
appendHostToWorkspaceConfirmed: noop,
|
||||
appendLocalTerminalToWorkspace: noop,
|
||||
createWorkspaceFromTargets: noop,
|
||||
updateSplitSizes: noop,
|
||||
|
||||
@@ -57,7 +57,6 @@ export type SessionSnapshotActions = Pick<
|
||||
| 'createWorkspaceFromSessions'
|
||||
| 'addSessionToWorkspace'
|
||||
| 'appendHostToWorkspace'
|
||||
| 'appendHostToWorkspaceConfirmed'
|
||||
| 'appendLocalTerminalToWorkspace'
|
||||
| 'createWorkspaceFromTargets'
|
||||
| 'updateSplitSizes'
|
||||
|
||||
@@ -163,9 +163,6 @@ export async function restoreSftpTransferHistoryCooperatively(
|
||||
const child = parentChildren[childIndex];
|
||||
if (
|
||||
child.status === "completed"
|
||||
// Match canonical pruning: warning rows carry recovery paths that
|
||||
// cannot be represented by the directory's compact manifest.
|
||||
&& !child.warning
|
||||
&& Number.isSafeInteger(child.directoryEntryIndex)
|
||||
&& (child.directoryEntryIndex ?? checkpoint.coveredEntries) < checkpoint.coveredEntries
|
||||
&& /^[a-f0-9]{64}$/.test(child.directoryEntryIdentity ?? "")
|
||||
|
||||
@@ -135,34 +135,6 @@ test("completed dispatch consumes only exact identity and never restarts", async
|
||||
assert.equal(store.getTask(task.id)?.status, "completed");
|
||||
});
|
||||
|
||||
test("successful invoke warnings reach the child before local completion can compact it", async (t) => {
|
||||
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
|
||||
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
|
||||
const task = { ...child(), id: "warning-result-child", parentTaskId: "warning-result-root" };
|
||||
const root = { ...task, id: task.parentTaskId, parentTaskId: undefined, isDirectory: true };
|
||||
const warning = "Upload completed; remove backup /target/.netcatty-backup-a manually.";
|
||||
store.upsertTasks([root, task]);
|
||||
t.after(() => store.dismiss(root.id));
|
||||
const result = await runTransferAndWaitForOwner(task, async () => ({ warning }), () => false);
|
||||
assert.equal(result?.warning, warning);
|
||||
assert.equal(store.getTask(task.id)?.warning, warning, "capture metadata before the caller finishes the child");
|
||||
store.patchTask(task.id, { status: "completed", transferredBytes: 1 });
|
||||
assert.equal(store.getTask(task.id)?.status, "completed", "warning-bearing children stay available for inspection");
|
||||
assert.equal(store.getTask(task.id)?.warning, warning);
|
||||
});
|
||||
|
||||
for (const outcome of [{ error: "write failed" }, { cancelled: true }]) {
|
||||
test(`unsuccessful invoke results do not add cleanup warnings (${JSON.stringify(outcome)})`, async (t) => {
|
||||
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
|
||||
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
|
||||
const task = { ...child(), id: `unsuccessful-warning-${"error" in outcome ? "failed" : "cancelled"}`, parentTaskId: undefined };
|
||||
store.upsertTasks([task]);
|
||||
t.after(() => store.dismiss(task.id));
|
||||
await runTransferAndWaitForOwner(task, async () => ({ ...outcome, warning: "not a committed upload" }), () => false);
|
||||
assert.equal(store.getTask(task.id)?.warning, undefined);
|
||||
});
|
||||
}
|
||||
|
||||
test("a resumed retry does not inherit failed settlement captured while admission was paused", async (t) => {
|
||||
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
|
||||
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
|
||||
|
||||
@@ -65,7 +65,6 @@ export interface FileWatchSyncedEvent {
|
||||
localPath: string;
|
||||
remotePath: string;
|
||||
bytesWritten: number;
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
export interface FileWatchErrorEvent {
|
||||
|
||||
@@ -156,10 +156,6 @@ export const createUploadTaskCallbacks = ({
|
||||
phase: phase === "compressed" ? "transferring" : phase as TransferTask["phase"],
|
||||
});
|
||||
},
|
||||
onTaskWarning: (taskId: string, warning: string) => {
|
||||
// Preserve cleanup details before completion can compact a child row.
|
||||
store.patchTask(taskId, { warning });
|
||||
},
|
||||
onTaskCompleted: (taskId: string, totalBytes: number) => {
|
||||
store.patchTask(taskId, {
|
||||
status: "completed" as TransferStatus,
|
||||
|
||||
@@ -6,7 +6,7 @@ export class TransferOwnerChangedError extends Error {}
|
||||
|
||||
export type TransferOwnerObservation = ReturnType<typeof sftpTransferCenterStore.observeTaskSettlement>;
|
||||
|
||||
type StreamResult = { error?: string; cancelled?: boolean; superseded?: boolean; warning?: string } | undefined;
|
||||
type StreamResult = { error?: string; cancelled?: boolean; superseded?: boolean } | undefined;
|
||||
|
||||
/** A live waiter owns bounded settlement evidence; persisted history stays compact. */
|
||||
export async function runTransferAndWaitForOwner(
|
||||
@@ -37,17 +37,7 @@ export async function runTransferAndWaitForOwner(
|
||||
observation = sftpTransferCenterStore.observeTaskSettlement(task, undefined, onOwnerChanged);
|
||||
const result = await start();
|
||||
if (observation.hasIdentityConflict()) throw new TransferOwnerChangedError("Transfer identity changed while waiting for its owner");
|
||||
if (!result?.superseded) {
|
||||
// The invoke response may arrive before completed IPC. Preserve cleanup
|
||||
// details before the caller completes (and may compact) this child.
|
||||
if (result?.warning && !result.error && !result.cancelled) {
|
||||
const latest = sftpTransferCenterStore.getTask(task.id);
|
||||
if (latest && latest.status !== "failed" && latest.status !== "cancelled") {
|
||||
sftpTransferCenterStore.patchTask(task.id, { warning: result.warning });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (!result?.superseded) return result;
|
||||
for (;;) {
|
||||
if (shouldAbort()) throw new Error("Transfer cancelled");
|
||||
if (observation.hasIdentityConflict()) throw new TransferOwnerChangedError("Transfer identity changed while waiting for its owner");
|
||||
|
||||
@@ -33,85 +33,6 @@ test("store aggregates owner snapshots without duplicating tasks", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("completion cleanup warnings survive owner snapshots and restored history", () => {
|
||||
let persisted: string | null = null;
|
||||
const store = createSftpTransferCenterStore({ read: () => persisted, write: (value) => { persisted = value; } });
|
||||
const source = makeTask("cleanup-warning");
|
||||
const warning = "Upload completed; remove backup /target/.netcatty-backup-file manually.";
|
||||
store.publishOwner("panel", [source]);
|
||||
store.ingestBackgroundEvent({ type: "completed", transferId: source.id, transferred: 10, warning });
|
||||
assert.equal(store.getTask(source.id)?.status, "completed");
|
||||
assert.equal(store.getTask(source.id)?.warning, warning);
|
||||
assert.equal(store.getTask(source.id)?.reconnectRequired, false);
|
||||
store.publishOwner("panel", [{ ...source, status: "completed", transferredBytes: 10, endTime: Date.now() }]);
|
||||
assert.equal(store.getTask(source.id)?.warning, warning, "a stale renderer completion cannot erase cleanup details");
|
||||
const restored = createSftpTransferCenterStore({ read: () => persisted, write: () => {} });
|
||||
assert.equal(restored.getTask(source.id)?.warning, warning);
|
||||
assert.equal(restored.getTask(source.id)?.status, "completed");
|
||||
assert.equal(restored.getTask(source.id)?.error, undefined);
|
||||
});
|
||||
|
||||
test("invoke cleanup warnings survive stale live and completion snapshots until an explicit restart", () => {
|
||||
const store = createSftpTransferCenterStore();
|
||||
const source = makeTask("invoke-warning-snapshot");
|
||||
const warning = "Upload completed; remove backup /target/.netcatty-backup-file manually.";
|
||||
store.publishOwner("panel", [source]);
|
||||
store.patchTask(source.id, { warning });
|
||||
store.publishOwner("panel", [{ ...source, transferredBytes: 9 }]);
|
||||
assert.equal(store.getTask(source.id)?.warning, warning);
|
||||
store.publishOwner("panel", [{ ...source, status: "completed", transferredBytes: 10, endTime: Date.now() }]);
|
||||
assert.equal(store.getTask(source.id)?.warning, warning);
|
||||
store.publishOwner("panel", [{ ...source, status: "queued", transferredBytes: 0, checkpointBytes: 0 }]);
|
||||
assert.equal(store.getTask(source.id)?.warning, undefined, "new attempts do not inherit cleanup warnings");
|
||||
});
|
||||
|
||||
test("a clean completed event cannot erase a warning already returned by the invoke", () => {
|
||||
const store = createSftpTransferCenterStore();
|
||||
const source = makeTask("invoke-warning-event");
|
||||
const warning = "Upload completed; remove backup /target/.netcatty-backup-file manually.";
|
||||
store.publishOwner("panel", [source]);
|
||||
store.patchTask(source.id, { warning });
|
||||
store.ingestBackgroundEvent({ type: "completed", transferId: source.id, transferred: 10 });
|
||||
assert.equal(store.getTask(source.id)?.status, "completed");
|
||||
assert.equal(store.getTask(source.id)?.warning, warning);
|
||||
});
|
||||
|
||||
test("late completion enriches a locally completed task with its cleanup warning", () => {
|
||||
let persisted: string | null = null;
|
||||
const store = createSftpTransferCenterStore({ read: () => persisted, write: (value) => { persisted = value; } });
|
||||
const source = { ...makeTask("result-first-warning"), phase: "transferring" as const };
|
||||
const warning = "Upload completed; remove backup /target/.netcatty-backup-file manually.";
|
||||
const endTime = Date.now();
|
||||
store.publishOwner("panel", [source]);
|
||||
// The invoke response can settle the renderer before the completed IPC arrives.
|
||||
store.patchTask(source.id, { status: "completed", transferredBytes: 10, endTime });
|
||||
let notifications = 0;
|
||||
const unsubscribe = store.subscribe(() => { notifications += 1; });
|
||||
store.ingestBackgroundEvent({ type: "completed", transferId: source.id, transferred: 10, endedAt: endTime + 1, warning });
|
||||
assert.equal(store.getTask(source.id)?.warning, warning);
|
||||
assert.equal(store.getTask(source.id)?.status, "completed");
|
||||
assert.equal(store.getTask(source.id)?.endTime, endTime, "late metadata does not replace the terminal outcome");
|
||||
assert.equal(notifications, 1, "the warning is visible without another transfer triggering a render");
|
||||
store.ingestBackgroundEvent({ type: "completed", transferId: source.id, transferred: 10 });
|
||||
assert.equal(store.getTask(source.id)?.warning, warning, "a duplicate clean event cannot erase the warning");
|
||||
const restored = createSftpTransferCenterStore({ read: () => persisted, write: () => {} });
|
||||
assert.equal(restored.getTask(source.id)?.warning, warning);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
for (const status of ["failed", "cancelled"] as const) {
|
||||
test(`late cleanup warnings cannot replace a ${status} terminal outcome`, () => {
|
||||
const store = createSftpTransferCenterStore();
|
||||
const endTime = Date.now();
|
||||
const task = { ...makeTask(`late-warning-${status}`, status), error: "original outcome", endTime };
|
||||
store.publishOwner("panel", [task]);
|
||||
store.ingestBackgroundEvent({ type: "completed", transferId: task.id, warning: "cleanup warning" });
|
||||
assert.equal(store.getTask(task.id)?.status, status);
|
||||
assert.equal(store.getTask(task.id)?.error, "original outcome");
|
||||
assert.equal(store.getTask(task.id)?.warning, undefined);
|
||||
});
|
||||
}
|
||||
|
||||
test("store routes cancel/retry/prioritize/dismiss to the task owner; pause/resume are process-global", async () => {
|
||||
const calls: string[] = [];
|
||||
const store = createSftpTransferCenterStore();
|
||||
@@ -3655,46 +3576,6 @@ test("legacy 50,000-file history restores cooperatively and converges to one per
|
||||
assert.ok(persisted.length < 10_000, "migrated localStorage payload must stay bounded");
|
||||
});
|
||||
|
||||
for (const indexed of [false, true]) {
|
||||
test(`cooperative ${indexed ? "indexed" : "legacy"} restore retains completed child cleanup warnings`, async () => {
|
||||
const now = Date.now();
|
||||
const warning = "Upload completed; remove backup /target/.netcatty-backup-file manually.";
|
||||
const parent: TransferTask = { ...makeTask("restore-warning-folder", "paused"), isDirectory: true };
|
||||
const children: TransferTask[] = [0, 1].map((index) => ({
|
||||
...makeTask(`restore-warning-child-${index}`, "completed"),
|
||||
parentTaskId: parent.id, endTime: now,
|
||||
warning: index === 0 ? warning : undefined,
|
||||
...(indexed ? { directoryEntryIndex: index, directoryEntryIdentity: String(index).padStart(64, "0") } : {}),
|
||||
}));
|
||||
let persisted = JSON.stringify({ version: 1, padding: "x".repeat(600_000), tasks: [parent, ...children] });
|
||||
const store = createSftpTransferCenterStore({ read: () => persisted, write: (value) => { persisted = value; } });
|
||||
assert.equal(store.getSnapshot().tasks.length, 0, "large history must take cooperative restoration");
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let unsubscribe = () => {};
|
||||
const timeout = setTimeout(() => {
|
||||
unsubscribe();
|
||||
reject(new Error("timed out waiting for cleanup-warning history migration"));
|
||||
}, 10_000);
|
||||
const check = () => {
|
||||
if (!store.getTask(parent.id)) return;
|
||||
clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
resolve();
|
||||
};
|
||||
unsubscribe = store.subscribe(check);
|
||||
check();
|
||||
});
|
||||
assert.equal(store.getTask("restore-warning-child-0")?.warning, warning);
|
||||
assert.equal(store.getTask("restore-warning-child-0")?.status, "completed");
|
||||
assert.equal(store.getTask("restore-warning-child-1"), undefined, "clean completed children still compact");
|
||||
assert.equal(store.getTask(parent.id)?.directoryResumeCheckpoint?.coveredEntries, 2);
|
||||
assert.equal(store.getTask(parent.id)?.directoryResumeCheckpoint?.completedEntries, 1);
|
||||
const restarted = createSftpTransferCenterStore({ read: () => persisted, write: () => {} });
|
||||
assert.equal(restarted.getTask("restore-warning-child-0")?.warning, warning);
|
||||
assert.equal(restarted.getTask(parent.id)?.directoryResumeCheckpoint?.completedEntries, 1);
|
||||
});
|
||||
}
|
||||
|
||||
test("live mutations wait for cooperative restore and merge without erasing old rows", async () => {
|
||||
const legacyOnly = makeTask("legacy-only", "paused");
|
||||
const staleCollision = makeTask("collision", "paused");
|
||||
|
||||
@@ -152,7 +152,6 @@ export interface SftpTransferCenterStore {
|
||||
startedAt?: number;
|
||||
endedAt?: number;
|
||||
error?: string;
|
||||
warning?: string;
|
||||
transferred?: number;
|
||||
totalBytes?: number;
|
||||
speed?: number;
|
||||
@@ -290,12 +289,6 @@ export function mergeOwnerPublishedTask(
|
||||
updatedAt: incoming.updatedAt ?? Date.now(),
|
||||
};
|
||||
|
||||
// A panel snapshot may predate cleanup metadata returned by the invoke or
|
||||
// completed IPC. Keep it through this attempt, but not a restart or failure.
|
||||
if (!incomingReset && incoming.status !== "failed" && incoming.status !== "cancelled") {
|
||||
merged.warning = incoming.warning ?? existing.warning;
|
||||
}
|
||||
|
||||
if (incomingReset || incomingTerminal) {
|
||||
return merged;
|
||||
}
|
||||
@@ -2281,15 +2274,6 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
if (terminal && event.type !== "cancelled") {
|
||||
// Allow a late explicit cancel to stick; ignore everything else.
|
||||
if (event.type === "completed" || event.type === "failed") {
|
||||
// An invoke response can complete the renderer row before its IPC
|
||||
// event arrives. Enrich that same success with cleanup metadata,
|
||||
// without replacing any terminal outcome or reviving stopped work.
|
||||
if (existing.status === "completed" && event.type === "completed"
|
||||
&& event.warning && event.warning !== existing.warning) {
|
||||
tasks = tasks.map((task) => task.id === existing.id ? { ...task, warning: event.warning } : task);
|
||||
emit(true);
|
||||
return;
|
||||
}
|
||||
// Keep existing terminal state (prefer cancelled over late completed).
|
||||
publishRepairedHierarchy();
|
||||
return;
|
||||
@@ -2577,7 +2561,6 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
? task.checkpointBytes
|
||||
: Math.max(task.checkpointBytes ?? 0, Number(event.checkpointBytes) || 0),
|
||||
error: event.error,
|
||||
warning: event.type === "completed" ? event.warning ?? task.warning : undefined,
|
||||
endTime: event.endedAt ?? Date.now(),
|
||||
speed: 0,
|
||||
reconnectRequired: event.type === "failed" ? task.reconnectRequired : false,
|
||||
|
||||
@@ -9,106 +9,6 @@ test("terminalHiddenRendererStore evicts oldest soft-hidden session", () => {
|
||||
terminalHiddenRendererStore.markSoftHidden("a");
|
||||
terminalHiddenRendererStore.markSoftHidden("b");
|
||||
assert.equal(terminalHiddenRendererStore.pickEvictionCandidate(2), "a");
|
||||
assert.deepEqual(terminalHiddenRendererStore.listSoftHiddenOldestFirst(), ["a", "b"]);
|
||||
terminalHiddenRendererStore.clearSoftHidden("a");
|
||||
terminalHiddenRendererStore.clearSoftHidden("b");
|
||||
});
|
||||
|
||||
test("evictSoftHidden reports refusal and drops an unconsumed request", async () => {
|
||||
const sessions = ["images", "plain", "other"];
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
terminalHiddenRendererStore.markSoftHidden("images");
|
||||
terminalHiddenRendererStore.markSoftHidden("plain");
|
||||
|
||||
const unsubscribe = terminalHiddenRendererStore.subscribe(() => {
|
||||
if (terminalHiddenRendererStore.consumeEvictionRequest("images")) return;
|
||||
if (terminalHiddenRendererStore.consumeEvictionRequest("plain")) {
|
||||
terminalHiddenRendererStore.clearSoftHidden("plain");
|
||||
terminalHiddenRendererStore.settleEviction("plain", true);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
assert.equal(await terminalHiddenRendererStore.evictSoftHidden("images"), false);
|
||||
assert.equal(terminalHiddenRendererStore.isSoftHidden("images"), true);
|
||||
assert.equal(await terminalHiddenRendererStore.evictSoftHidden("plain"), true);
|
||||
assert.equal(terminalHiddenRendererStore.isSoftHidden("plain"), false);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
}
|
||||
|
||||
terminalHiddenRendererStore.markSoftHidden("images");
|
||||
assert.equal(await terminalHiddenRendererStore.evictSoftHidden("images"), false);
|
||||
const delivered: string[] = [];
|
||||
const later = terminalHiddenRendererStore.subscribe(() => {
|
||||
if (terminalHiddenRendererStore.consumeEvictionRequest("images")) delivered.push("images");
|
||||
});
|
||||
try {
|
||||
terminalHiddenRendererStore.markSoftHidden("other");
|
||||
assert.deepEqual(delivered, []);
|
||||
assert.equal(terminalHiddenRendererStore.isSoftHidden("images"), true);
|
||||
} finally {
|
||||
later();
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
test("a reserved leaving slot counts toward occupancy until it is released", () => {
|
||||
const sessions = ["a", "b", "c", "d"];
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
terminalHiddenRendererStore.markSoftHidden("a");
|
||||
terminalHiddenRendererStore.markSoftHidden("b");
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
|
||||
|
||||
const alreadyHidden = terminalHiddenRendererStore.reserveLeavingSlot("a");
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
|
||||
terminalHiddenRendererStore.releaseLeavingSlot("a", alreadyHidden);
|
||||
|
||||
const tokenC = terminalHiddenRendererStore.reserveLeavingSlot("c");
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 3);
|
||||
assert.equal(terminalHiddenRendererStore.leavingSlotStillFits("c", 2), false);
|
||||
|
||||
terminalHiddenRendererStore.clearSoftHidden("a");
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
|
||||
assert.equal(terminalHiddenRendererStore.leavingSlotStillFits("c", 2), true);
|
||||
|
||||
const tokenD = terminalHiddenRendererStore.reserveLeavingSlot("d");
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 3);
|
||||
assert.equal(terminalHiddenRendererStore.leavingSlotStillFits("c", 2), false);
|
||||
assert.equal(terminalHiddenRendererStore.leavingSlotStillFits("d", 2), false);
|
||||
terminalHiddenRendererStore.releaseLeavingSlot("c", tokenD);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 3);
|
||||
terminalHiddenRendererStore.releaseLeavingSlot("c", tokenC);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
|
||||
terminalHiddenRendererStore.releaseLeavingSlot("d", tokenD);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 1);
|
||||
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
|
||||
});
|
||||
|
||||
test("evictSoftHidden waits for hibernate after the marker is cleared", async () => {
|
||||
terminalHiddenRendererStore.clearSoftHidden("victim");
|
||||
terminalHiddenRendererStore.markSoftHidden("victim");
|
||||
let markerClearedBeforeSettle = false;
|
||||
const unsubscribe = terminalHiddenRendererStore.subscribe(() => {
|
||||
if (!terminalHiddenRendererStore.consumeEvictionRequest("victim")) return;
|
||||
terminalHiddenRendererStore.clearSoftHidden("victim");
|
||||
markerClearedBeforeSettle = !terminalHiddenRendererStore.isSoftHidden("victim");
|
||||
queueMicrotask(() => {
|
||||
terminalHiddenRendererStore.markSoftHidden("victim");
|
||||
terminalHiddenRendererStore.settleEviction("victim", false);
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
const completed = await terminalHiddenRendererStore.evictSoftHidden("victim");
|
||||
assert.equal(markerClearedBeforeSettle, true);
|
||||
assert.equal(completed, false);
|
||||
assert.equal(terminalHiddenRendererStore.isSoftHidden("victim"), true);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
terminalHiddenRendererStore.clearSoftHidden("victim");
|
||||
}
|
||||
});
|
||||
@@ -3,85 +3,14 @@ type Listener = () => void;
|
||||
const softHiddenSessions = new Map<string, number>();
|
||||
const listeners = new Set<Listener>();
|
||||
let evictionRequestSessionId: string | null = null;
|
||||
/** Leaving tabs that have claimed a keep slot while a victim hibernate is in flight. */
|
||||
const reservedLeavingSlots = new Map<string, number>();
|
||||
let reservationToken = 0;
|
||||
|
||||
function reservedLeavingCount(): number {
|
||||
let extra = 0;
|
||||
for (const sessionId of reservedLeavingSlots.keys()) {
|
||||
if (!softHiddenSessions.has(sessionId)) extra += 1;
|
||||
}
|
||||
return extra;
|
||||
}
|
||||
|
||||
type EvictionWaiter = {
|
||||
promise: Promise<boolean>;
|
||||
resolve: (completed: boolean) => void;
|
||||
settled: boolean;
|
||||
};
|
||||
|
||||
const evictionWaiters = new Map<string, EvictionWaiter>();
|
||||
|
||||
function finishEviction(sessionId: string, completed: boolean): void {
|
||||
const waiter = evictionWaiters.get(sessionId);
|
||||
if (!waiter || waiter.settled) return;
|
||||
waiter.settled = true;
|
||||
evictionWaiters.delete(sessionId);
|
||||
if (evictionRequestSessionId === sessionId) evictionRequestSessionId = null;
|
||||
waiter.resolve(completed);
|
||||
}
|
||||
|
||||
function emit(): void {
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
function listSoftHiddenOldestFirst(): string[] {
|
||||
return [...softHiddenSessions.entries()]
|
||||
.sort((left, right) => left[1] - right[1])
|
||||
.map(([sessionId]) => sessionId);
|
||||
}
|
||||
|
||||
export const terminalHiddenRendererStore = {
|
||||
getSoftHiddenCount: () => softHiddenSessions.size,
|
||||
|
||||
/**
|
||||
* Soft-hidden markers plus leaving tabs that reserved a slot and are not
|
||||
* yet in the marker set. Keep-count decisions use this so a second timer
|
||||
* cannot take the slot a pending eviction already claimed.
|
||||
*/
|
||||
getSoftHiddenOccupancy: () => softHiddenSessions.size + reservedLeavingCount(),
|
||||
|
||||
/**
|
||||
* Claim one keep slot for a leaving tab before awaiting eviction.
|
||||
* A session already in the marker set is not counted twice. A later claim
|
||||
* for the same id replaces the token; only that token can release it.
|
||||
*/
|
||||
reserveLeavingSlot: (sessionId: string): number => {
|
||||
const token = ++reservationToken;
|
||||
if (sessionId && !softHiddenSessions.has(sessionId)) {
|
||||
reservedLeavingSlots.set(sessionId, token);
|
||||
}
|
||||
return token;
|
||||
},
|
||||
|
||||
releaseLeavingSlot: (sessionId: string, token: number) => {
|
||||
if (reservedLeavingSlots.get(sessionId) === token) {
|
||||
reservedLeavingSlots.delete(sessionId);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* True when retaining this leaving tab would stay within keepCount.
|
||||
* Its own reservation is not counted against the slots held by others.
|
||||
*/
|
||||
leavingSlotStillFits: (sessionId: string, keepCount: number): boolean => {
|
||||
if (!(keepCount > 0)) return false;
|
||||
const reservedHere = reservedLeavingSlots.has(sessionId) && !softHiddenSessions.has(sessionId);
|
||||
const others = softHiddenSessions.size + reservedLeavingCount() - (reservedHere ? 1 : 0);
|
||||
return others < keepCount;
|
||||
},
|
||||
|
||||
isSoftHidden: (sessionId: string) => softHiddenSessions.has(sessionId),
|
||||
|
||||
markSoftHidden: (sessionId: string) => {
|
||||
@@ -94,12 +23,18 @@ export const terminalHiddenRendererStore = {
|
||||
emit();
|
||||
},
|
||||
|
||||
listSoftHiddenOldestFirst: (): string[] => listSoftHiddenOldestFirst(),
|
||||
|
||||
/** Returns the oldest soft-hidden session id when over the keep limit. */
|
||||
pickEvictionCandidate: (keepCount: number): string | null => {
|
||||
if (softHiddenSessions.size < keepCount) return null;
|
||||
return listSoftHiddenOldestFirst()[0] ?? null;
|
||||
let oldestId: string | null = null;
|
||||
let oldestTs = Number.POSITIVE_INFINITY;
|
||||
for (const [sessionId, ts] of softHiddenSessions) {
|
||||
if (ts < oldestTs) {
|
||||
oldestTs = ts;
|
||||
oldestId = sessionId;
|
||||
}
|
||||
}
|
||||
return oldestId;
|
||||
},
|
||||
|
||||
requestEviction: (sessionId: string) => {
|
||||
@@ -107,44 +42,6 @@ export const terminalHiddenRendererStore = {
|
||||
emit();
|
||||
},
|
||||
|
||||
/** True while this session's hibernate has been requested and not settled. */
|
||||
hasPendingEviction: (sessionId: string): boolean => {
|
||||
const waiter = evictionWaiters.get(sessionId);
|
||||
return waiter != null && !waiter.settled;
|
||||
},
|
||||
|
||||
/**
|
||||
* Ask one soft-hidden session to upgrade. Resolves true only when
|
||||
* settleEviction reports that hibernation finished. Clearing the marker
|
||||
* during wake is not success: drain, snapshot, or alternate-screen failure
|
||||
* can still restore that renderer. A synchronous refusal (marker still
|
||||
* present, including inline images) resolves false and drops an unconsumed
|
||||
* request so a later emit cannot upgrade it.
|
||||
*/
|
||||
evictSoftHidden: (sessionId: string): Promise<boolean> => {
|
||||
const existing = evictionWaiters.get(sessionId);
|
||||
if (existing) return existing.promise;
|
||||
if (!softHiddenSessions.has(sessionId)) return Promise.resolve(false);
|
||||
|
||||
let resolvePromise!: (completed: boolean) => void;
|
||||
const promise = new Promise<boolean>((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
const waiter: EvictionWaiter = { promise, resolve: resolvePromise, settled: false };
|
||||
evictionWaiters.set(sessionId, waiter);
|
||||
evictionRequestSessionId = sessionId;
|
||||
emit();
|
||||
if (!waiter.settled && softHiddenSessions.has(sessionId)) {
|
||||
finishEviction(sessionId, false);
|
||||
}
|
||||
return promise;
|
||||
},
|
||||
|
||||
/** Record whether the in-flight eviction's hibernate actually completed. */
|
||||
settleEviction: (sessionId: string, completed: boolean) => {
|
||||
finishEviction(sessionId, completed);
|
||||
},
|
||||
|
||||
consumeEvictionRequest: (sessionId: string): boolean => {
|
||||
if (evictionRequestSessionId !== sessionId) return false;
|
||||
evictionRequestSessionId = null;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { MouseEvent,useCallback,useEffect,useMemo,useRef,useState } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { ConnectionLog,Host,SerialConfig,Snippet,TerminalSession,Workspace,WorkspaceNode,WorkspaceViewMode } from '../../domain/models';
|
||||
import { addLogView, getLogViewTabId, removeLogView, type LogView } from './logViewState';
|
||||
import {
|
||||
@@ -916,25 +915,6 @@ export const useSessionState = ({
|
||||
return newSessionId;
|
||||
}, [setActiveTabId]);
|
||||
|
||||
// Agent callers bind scope immediately, so they need committed insertion,
|
||||
// not just the ID allocated by the ordinary batched UI append operation.
|
||||
const appendHostToWorkspaceConfirmed = useCallback((
|
||||
workspaceId: string,
|
||||
host: Host,
|
||||
direction: SplitDirection = 'vertical',
|
||||
): string | null => {
|
||||
let sessionId: string | null = null;
|
||||
flushSync(() => {
|
||||
sessionId = appendHostToWorkspace(workspaceId, host, direction);
|
||||
});
|
||||
if (!sessionId) return null;
|
||||
const committedWorkspace = workspacesRef.current.find(w => w.id === workspaceId);
|
||||
const inserted = committedWorkspace
|
||||
&& collectSessionIds(committedWorkspace.root).includes(sessionId)
|
||||
&& sessionsRef.current.some(s => s.id === sessionId && s.workspaceId === workspaceId);
|
||||
return inserted ? sessionId : null;
|
||||
}, [appendHostToWorkspace]);
|
||||
|
||||
// Atomic "append a local terminal pane" — mirror of appendHostToWorkspace
|
||||
// but constructs a local-protocol session instead of an SSH one.
|
||||
const appendLocalTerminalToWorkspace = useCallback((
|
||||
@@ -1473,7 +1453,6 @@ export const useSessionState = ({
|
||||
addSessionToWorkspace,
|
||||
removeSessionFromWorkspace,
|
||||
appendHostToWorkspace,
|
||||
appendHostToWorkspaceConfirmed,
|
||||
appendLocalTerminalToWorkspace,
|
||||
updateSplitSizes,
|
||||
splitSession,
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { createLocalShellHost, resolveHostDefaultLocalShell } from '../../domain/localShellHost';
|
||||
import { collectSessionIds } from '../../domain/workspace';
|
||||
import { activeTabStore } from './activeTabStore';
|
||||
import { useSessionState } from './useSessionState';
|
||||
|
||||
test('workspace host opening acknowledges committed insertion and rejects queued closure', async (t) => {
|
||||
const dom = new JSDOM('<div id="root"></div>', { url: 'http://localhost' });
|
||||
const globals = {
|
||||
window: dom.window,
|
||||
document: dom.window.document,
|
||||
localStorage: dom.window.localStorage,
|
||||
CustomEvent: dom.window.CustomEvent,
|
||||
Event: dom.window.Event,
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
};
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
for (const [key, value] of Object.entries(globals)) {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, value });
|
||||
}
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
t.after(async () => {
|
||||
await act(async () => root.unmount());
|
||||
dom.window.close();
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
activeTabStore.setActiveTabId('vault');
|
||||
});
|
||||
let state: ReturnType<typeof useSessionState>;
|
||||
function Probe() {
|
||||
state = useSessionState({ persistSessionRestore: false });
|
||||
return null;
|
||||
}
|
||||
await act(async () => root.render(React.createElement(React.StrictMode, null, React.createElement(Probe))));
|
||||
const host = createLocalShellHost({ id: 'local-inheriting' });
|
||||
await act(async () => state.createWorkspaceWithHosts('Target', [host]));
|
||||
const workspaceId = state!.workspaces[0].id;
|
||||
const resolvedHost = resolveHostDefaultLocalShell(host, {
|
||||
terminalSettings: { localShell: '/bin/zsh', localShellArgs: ['-l'], localStartDir: '/tmp/project' },
|
||||
});
|
||||
let ordinaryId: string | null = null;
|
||||
await act(async () => {
|
||||
ordinaryId = state.appendHostToWorkspace(workspaceId, resolvedHost);
|
||||
});
|
||||
assert.ok(ordinaryId);
|
||||
assert.ok(state!.sessions.some(session => session.id === ordinaryId));
|
||||
assert.ok(collectSessionIds(state!.workspaces[0].root).includes(ordinaryId));
|
||||
let insertedId: string | null = null;
|
||||
await act(async () => {
|
||||
insertedId = state.appendHostToWorkspaceConfirmed(workspaceId, resolvedHost);
|
||||
assert.ok(insertedId, 'success is returned after both state updates commit');
|
||||
const session = state.sessions.find(s => s.id === insertedId);
|
||||
assert.equal(session?.localShell, '/bin/zsh');
|
||||
assert.deepEqual(session?.localShellArgs, ['-l']);
|
||||
assert.equal(session?.localStartDir, '/tmp/project');
|
||||
assert.equal(state.sessions.filter(s => s.id === insertedId).length, 1);
|
||||
assert.equal(collectSessionIds(state.workspaces[0].root).filter(id => id === insertedId).length, 1);
|
||||
});
|
||||
await act(async () => {
|
||||
// The rendered ref still contains the workspace; its close updater is queued.
|
||||
state.closeWorkspace(workspaceId);
|
||||
const result = state.appendHostToWorkspaceConfirmed(workspaceId, resolvedHost);
|
||||
assert.equal(result, null, 'must not acknowledge an allocated but uninserted ID');
|
||||
});
|
||||
assert.equal(state!.workspaces.length, 0);
|
||||
assert.equal(state!.sessions.length, 0, 'no orphan session survives the queued close');
|
||||
assert.equal(activeTabStore.getActiveTabId(), 'vault');
|
||||
await act(async () => {
|
||||
assert.equal(state.appendHostToWorkspaceConfirmed(workspaceId, resolvedHost), null);
|
||||
});
|
||||
});
|
||||
|
||||
test('agent workspace host opening uses the common launch and history handler', () => {
|
||||
const source = readFileSync(new URL('../app/AppSideEffects.tsx', import.meta.url), 'utf8');
|
||||
assert.match(source, /const sessionId = handleConnectToHost\(host, true, hidden, target\.workspaceId\)/);
|
||||
assert.match(source, /connectToHost: workspaceId\s*\? \(candidate: Host\) => appendHostToWorkspaceConfirmed\(workspaceId, candidate\)\s*: connectToHost/);
|
||||
});
|
||||
@@ -1,13 +1,8 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React, { startTransition, useState } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import { collectSidePanelPanes } from '../../domain/sidePanelLayout.ts';
|
||||
import { useTerminalSidePanelLayoutState } from './useTerminalSidePanelLayoutState.ts';
|
||||
|
||||
@@ -135,231 +130,3 @@ test('closing the final pane removes only that terminal side panel state', async
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
}
|
||||
});
|
||||
|
||||
test('selecting a side panel tool commits open tabs and layout in one render', async () => {
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
};
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let state: ReturnType<typeof useTerminalSidePanelLayoutState> | null = null;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
let renders = 0;
|
||||
|
||||
const Probe = () => {
|
||||
state = useTerminalSidePanelLayoutState();
|
||||
renders += 1;
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
});
|
||||
renders = 0;
|
||||
await act(async () => {
|
||||
state?.selectSidePanelToolForTab('terminal-a', 'notes');
|
||||
});
|
||||
assert.equal(renders, 1, 'open tabs and layout share one commit');
|
||||
assert.equal(state!.sidePanelOpenTabs.get('terminal-a'), 'notes');
|
||||
const layout = state!.sidePanelLayouts.get('terminal-a')!;
|
||||
assert.equal(
|
||||
collectSidePanelPanes(layout.root).find((pane) => pane.id === layout.focusedPaneId)?.tool,
|
||||
'notes',
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
state?.splitPane('terminal-a', 'ai', 'vertical', {
|
||||
paneId: 'pane-ai',
|
||||
splitId: 'split-a',
|
||||
}, 400);
|
||||
});
|
||||
const beforeFocus = state!.sidePanelLayouts.get('terminal-a')!;
|
||||
await act(async () => {
|
||||
state?.selectSidePanelToolForTab('terminal-a', 'notes');
|
||||
});
|
||||
const focusedExisting = state!.sidePanelLayouts.get('terminal-a')!;
|
||||
assert.deepEqual(
|
||||
collectSidePanelPanes(focusedExisting.root).map((pane) => pane.tool),
|
||||
['notes', 'ai'],
|
||||
);
|
||||
assert.equal(
|
||||
collectSidePanelPanes(focusedExisting.root).find((pane) => pane.id === focusedExisting.focusedPaneId)?.tool,
|
||||
'notes',
|
||||
);
|
||||
assert.equal(state!.sidePanelOpenTabs.get('terminal-a'), 'notes');
|
||||
assert.notEqual(focusedExisting, beforeFocus);
|
||||
assert.equal(focusedExisting.root, beforeFocus.root, 'focusing an open tool must not rebuild the split');
|
||||
|
||||
const settled = state!.sidePanelLayouts;
|
||||
renders = 0;
|
||||
await act(async () => {
|
||||
state?.selectSidePanelToolForTab('terminal-a', 'notes');
|
||||
});
|
||||
assert.equal(renders, 0);
|
||||
assert.equal(state!.sidePanelLayouts, settled);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
}
|
||||
});
|
||||
|
||||
test('a pending side-panel write survives an urgent render on another tab', async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
|
||||
const previousWindow = globalThis.window;
|
||||
const previousDocument = globalThis.document;
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: dom.window });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: dom.window.document });
|
||||
const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let state: ReturnType<typeof useTerminalSidePanelLayoutState> | null = null;
|
||||
let bump: ((value: number) => void) | null = null;
|
||||
const Probe = () => {
|
||||
state = useTerminalSidePanelLayoutState();
|
||||
const [, setTick] = useState(0);
|
||||
bump = setTick;
|
||||
return null;
|
||||
};
|
||||
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root: Root = createRoot(container);
|
||||
const tools = (tabId: string, fromRef = false) => {
|
||||
const layout = fromRef
|
||||
? state?.sidePanelLayoutsRef.current.get(tabId)
|
||||
: state?.sidePanelLayouts.get(tabId);
|
||||
return layout ? collectSidePanelPanes(layout.root).map((pane) => pane.tool) : [];
|
||||
};
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Probe));
|
||||
});
|
||||
await act(async () => {
|
||||
state?.selectSidePanelToolForTab('terminal-a', 'notes');
|
||||
state?.selectSidePanelToolForTab('terminal-b', 'scripts');
|
||||
});
|
||||
await act(async () => {
|
||||
state?.splitPane('terminal-a', 'ai', 'vertical', {
|
||||
paneId: 'pane-ai',
|
||||
splitId: 'split-a',
|
||||
}, 400);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
startTransition(() => {
|
||||
state?.selectSidePanelToolForTab('terminal-a', 'history');
|
||||
});
|
||||
flushSync(() => {
|
||||
bump?.(1);
|
||||
});
|
||||
assert.deepEqual(tools('terminal-a'), ['notes', 'ai']);
|
||||
assert.deepEqual(tools('terminal-a', true), ['notes', 'history']);
|
||||
startTransition(() => {
|
||||
state?.selectSidePanelToolForTab('terminal-b', 'sftp');
|
||||
});
|
||||
});
|
||||
|
||||
assert.deepEqual(tools('terminal-a'), ['notes', 'history']);
|
||||
assert.equal(state!.sidePanelOpenTabs.get('terminal-a'), 'history');
|
||||
assert.deepEqual(tools('terminal-b'), ['sftp']);
|
||||
assert.equal(state!.sidePanelOpenTabs.get('terminal-b'), 'sftp');
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('a pending tool switch does not leave another tab ref behind an urgent open', async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
|
||||
const previousWindow = globalThis.window;
|
||||
const previousDocument = globalThis.document;
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: dom.window });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: dom.window.document });
|
||||
const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let state: ReturnType<typeof useTerminalSidePanelLayoutState> | null = null;
|
||||
const Probe = () => {
|
||||
state = useTerminalSidePanelLayoutState();
|
||||
return null;
|
||||
};
|
||||
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root: Root = createRoot(container);
|
||||
const focusedTool = (tabId: string, fromRef = false) => {
|
||||
const layout = fromRef
|
||||
? state?.sidePanelLayoutsRef.current.get(tabId)
|
||||
: state?.sidePanelLayouts.get(tabId);
|
||||
if (!layout) return null;
|
||||
return collectSidePanelPanes(layout.root).find((pane) => pane.id === layout.focusedPaneId)?.tool ?? null;
|
||||
};
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Probe));
|
||||
});
|
||||
await act(async () => {
|
||||
state?.selectSidePanelToolForTab('terminal-a', 'notes');
|
||||
state?.selectSidePanelToolForTab('terminal-b', 'scripts');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
startTransition(() => {
|
||||
state?.selectSidePanelToolForTab('terminal-a', 'history');
|
||||
});
|
||||
flushSync(() => {
|
||||
state?.setSidePanelOpenTabs((previous) => new Map(previous).set('terminal-b', 'browser'));
|
||||
});
|
||||
});
|
||||
|
||||
assert.equal(state!.sidePanelOpenTabs.get('terminal-a'), 'history');
|
||||
assert.equal(state!.sidePanelOpenTabs.get('terminal-b'), 'browser');
|
||||
assert.equal(state!.sidePanelOpenTabsRef.current.get('terminal-a'), 'history');
|
||||
assert.equal(
|
||||
state!.sidePanelOpenTabsRef.current.get('terminal-b'),
|
||||
'browser',
|
||||
'B stays rendered as browser while its ref still reports the pre-queue tool',
|
||||
);
|
||||
assert.equal(focusedTool('terminal-b', true), 'browser');
|
||||
|
||||
// handleSwitchSidePanelTab returns before select when the ref already
|
||||
// matches the clicked tool. A stale scripts ref would ignore this click.
|
||||
assert.notEqual(state!.sidePanelOpenTabsRef.current.get('terminal-b'), 'scripts');
|
||||
await act(async () => {
|
||||
state?.selectSidePanelToolForTab('terminal-b', 'scripts');
|
||||
});
|
||||
assert.equal(state!.sidePanelOpenTabs.get('terminal-b'), 'scripts');
|
||||
assert.equal(state!.sidePanelOpenTabsRef.current.get('terminal-b'), 'scripts');
|
||||
assert.equal(focusedTool('terminal-b'), 'scripts');
|
||||
assert.equal(state!.sidePanelOpenTabs.get('terminal-a'), 'history');
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('side panel icon clicks write the tool through selectSidePanelToolForTab', () => {
|
||||
const layerSource = readFileSync(new URL('../../components/TerminalLayer.tsx', import.meta.url), 'utf8');
|
||||
const start = layerSource.indexOf('const handleSwitchSidePanelTab = useCallback');
|
||||
const end = layerSource.indexOf('const handleFocusSidePanelPane = useCallback');
|
||||
const body = layerSource.slice(start, end);
|
||||
assert.match(body, /startTransition\(\(\) => \{\s*selectSidePanelToolForTab\(tabId, tab\);/);
|
||||
assert.doesNotMatch(body, /setSidePanelOpenTabs/);
|
||||
});
|
||||
@@ -13,215 +13,55 @@ import {
|
||||
type SidePanelTool,
|
||||
} from '../../domain/sidePanelLayout';
|
||||
|
||||
function replaceMapEntry<T>(previous: Map<string, T>, key: string, value: T): Map<string, T> {
|
||||
if (previous.get(key) === value) return previous;
|
||||
const next = new Map(previous);
|
||||
next.set(key, value);
|
||||
return next;
|
||||
}
|
||||
|
||||
function deleteMapEntry<T>(previous: Map<string, T>, key: string): Map<string, T> {
|
||||
if (!previous.has(key)) return previous;
|
||||
const next = new Map(previous);
|
||||
next.delete(key);
|
||||
return next;
|
||||
}
|
||||
|
||||
function entryMatches<T>(left: Map<string, T>, right: Map<string, T>, key: string): boolean {
|
||||
return left.has(key) === right.has(key) && left.get(key) === right.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep a ref aligned with committed state without dropping a write React has
|
||||
* not rendered yet. An urgent update can rebase onto a transition: the
|
||||
* transition updater stores an intermediate map in the ref, and the later
|
||||
* updater changes only state. Copy committed entries the pending map did not
|
||||
* change, and follow committed once it moves past that pending value.
|
||||
*/
|
||||
function syncTrackedMap<T>(
|
||||
pending: Map<string, T>,
|
||||
lastRendered: Map<string, T>,
|
||||
committed: Map<string, T>,
|
||||
): Map<string, T> {
|
||||
if (pending === lastRendered) return committed;
|
||||
if (pending === committed) return pending;
|
||||
|
||||
let changed = false;
|
||||
const merged = new Map(pending);
|
||||
|
||||
for (const key of committed.keys()) {
|
||||
if (entryMatches(pending, committed, key)) continue;
|
||||
const pendingMatchesRendered = entryMatches(pending, lastRendered, key);
|
||||
const committedMatchesRendered = entryMatches(committed, lastRendered, key);
|
||||
if (pendingMatchesRendered || !committedMatchesRendered) {
|
||||
merged.set(key, committed.get(key) as T);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of pending.keys()) {
|
||||
if (committed.has(key)) continue;
|
||||
if (entryMatches(pending, lastRendered, key)) {
|
||||
merged.delete(key);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return changed ? merged : pending;
|
||||
}
|
||||
|
||||
function publishQueuedMap<T>(
|
||||
live: Map<string, T>,
|
||||
capturedBase: Map<string, T>,
|
||||
previous: Map<string, T>,
|
||||
stateNext: Map<string, T>,
|
||||
tabId: string,
|
||||
value: T | null,
|
||||
): Map<string, T> {
|
||||
// The captured map is the synchronous write. Replaying it after an
|
||||
// intervening render drops entries that render already merged. When the ref
|
||||
// has moved ahead of this updater's base, reapply only this tab.
|
||||
if (live === capturedBase || live === previous) return stateNext;
|
||||
return value === null ? deleteMapEntry(live, tabId) : replaceMapEntry(live, tabId, value);
|
||||
}
|
||||
|
||||
function reconcileSidePanelLayouts(
|
||||
layouts: Map<string, SidePanelLayout>,
|
||||
openTabs: Map<string, SidePanelTool>,
|
||||
): Map<string, SidePanelLayout> | null {
|
||||
let changed = false;
|
||||
const next = new Map(layouts);
|
||||
|
||||
for (const tabId of layouts.keys()) {
|
||||
if (!openTabs.has(tabId)) {
|
||||
next.delete(tabId);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [tabId, tool] of openTabs) {
|
||||
const current = next.get(tabId);
|
||||
const updated = current
|
||||
? selectSidePanelTool(current, tool)
|
||||
: createSidePanelLayout(tool, crypto.randomUUID());
|
||||
if (updated !== current) {
|
||||
next.set(tabId, updated);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return changed ? next : null;
|
||||
}
|
||||
|
||||
export function useTerminalSidePanelLayoutState() {
|
||||
const [sidePanelOpenTabs, setSidePanelOpenTabs] = useState<Map<string, SidePanelTool>>(new Map());
|
||||
const [sidePanelLayouts, setSidePanelLayouts] = useState<Map<string, SidePanelLayout>>(new Map());
|
||||
const sidePanelOpenTabsRef = useRef(sidePanelOpenTabs);
|
||||
const sidePanelLayoutsRef = useRef(sidePanelLayouts);
|
||||
// Last maps this hook rendered. A transition can update the refs before React
|
||||
// commits them; an urgent render in between must not copy the stale state
|
||||
// back over that pending write. Once the queue commits, entries the pending
|
||||
// write did not touch still have to catch up, or the ref stays on the
|
||||
// transition updater's intermediate map.
|
||||
const renderedOpenTabsRef = useRef(sidePanelOpenTabs);
|
||||
const renderedLayoutsRef = useRef(sidePanelLayouts);
|
||||
sidePanelOpenTabsRef.current = syncTrackedMap(
|
||||
sidePanelOpenTabsRef.current,
|
||||
renderedOpenTabsRef.current,
|
||||
sidePanelOpenTabs,
|
||||
);
|
||||
renderedOpenTabsRef.current = sidePanelOpenTabs;
|
||||
sidePanelLayoutsRef.current = syncTrackedMap(
|
||||
sidePanelLayoutsRef.current,
|
||||
renderedLayoutsRef.current,
|
||||
sidePanelLayouts,
|
||||
);
|
||||
renderedLayoutsRef.current = sidePanelLayouts;
|
||||
sidePanelOpenTabsRef.current = sidePanelOpenTabs;
|
||||
sidePanelLayoutsRef.current = sidePanelLayouts;
|
||||
|
||||
// Legacy and external open paths write the focused-tool map. Reconcile that
|
||||
// public application boundary into the per-tab tree without disturbing the
|
||||
// other panes in an existing layout. Skip the setState when the click path
|
||||
// already stored this selection: scheduling an identical update still
|
||||
// renders again after a paired openTabs write.
|
||||
// other panes in an existing layout.
|
||||
useEffect(() => {
|
||||
const base = sidePanelLayoutsRef.current;
|
||||
const reconciled = reconcileSidePanelLayouts(base, sidePanelOpenTabs);
|
||||
if (!reconciled) return;
|
||||
sidePanelLayoutsRef.current = reconciled;
|
||||
setSidePanelLayouts((previous) => {
|
||||
const next = previous === base
|
||||
? reconciled
|
||||
: reconcileSidePanelLayouts(previous, sidePanelOpenTabs);
|
||||
if (!next) {
|
||||
sidePanelLayoutsRef.current = previous;
|
||||
return previous;
|
||||
let changed = false;
|
||||
const next = new Map(previous);
|
||||
|
||||
for (const tabId of previous.keys()) {
|
||||
if (!sidePanelOpenTabs.has(tabId)) {
|
||||
next.delete(tabId);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
sidePanelLayoutsRef.current = next;
|
||||
return next;
|
||||
|
||||
for (const [tabId, tool] of sidePanelOpenTabs) {
|
||||
const current = next.get(tabId);
|
||||
const updated = current
|
||||
? selectSidePanelTool(current, tool)
|
||||
: createSidePanelLayout(tool, crypto.randomUUID());
|
||||
if (updated !== current) {
|
||||
next.set(tabId, updated);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) sidePanelLayoutsRef.current = next;
|
||||
return changed ? next : previous;
|
||||
});
|
||||
}, [sidePanelOpenTabs]);
|
||||
|
||||
const commitMap = useCallback((
|
||||
tabId: string,
|
||||
layout: SidePanelLayout,
|
||||
openTab: SidePanelTool | null,
|
||||
) => {
|
||||
const layoutBase = sidePanelLayoutsRef.current;
|
||||
const openTabBase = sidePanelOpenTabsRef.current;
|
||||
const layouts = openTab === null
|
||||
? deleteMapEntry(layoutBase, tabId)
|
||||
: replaceMapEntry(layoutBase, tabId, layout);
|
||||
const openTabs = openTab === null
|
||||
? deleteMapEntry(openTabBase, tabId)
|
||||
: replaceMapEntry(openTabBase, tabId, openTab);
|
||||
const commitLayout = useCallback((tabId: string, layout: SidePanelLayout) => {
|
||||
const layouts = new Map(sidePanelLayoutsRef.current).set(tabId, layout);
|
||||
const focusedTool = getFocusedSidePanelPane(layout).tool;
|
||||
const openTabs = new Map(sidePanelOpenTabsRef.current).set(tabId, focusedTool);
|
||||
sidePanelLayoutsRef.current = layouts;
|
||||
sidePanelOpenTabsRef.current = openTabs;
|
||||
setSidePanelLayouts((previous) => {
|
||||
const next = previous === layoutBase
|
||||
? layouts
|
||||
: (openTab === null ? deleteMapEntry(previous, tabId) : replaceMapEntry(previous, tabId, layout));
|
||||
sidePanelLayoutsRef.current = publishQueuedMap(
|
||||
sidePanelLayoutsRef.current,
|
||||
layoutBase,
|
||||
previous,
|
||||
next,
|
||||
tabId,
|
||||
openTab === null ? null : layout,
|
||||
);
|
||||
return next;
|
||||
});
|
||||
setSidePanelOpenTabs((previous) => {
|
||||
const next = previous === openTabBase
|
||||
? openTabs
|
||||
: (openTab === null ? deleteMapEntry(previous, tabId) : replaceMapEntry(previous, tabId, openTab));
|
||||
sidePanelOpenTabsRef.current = publishQueuedMap(
|
||||
sidePanelOpenTabsRef.current,
|
||||
openTabBase,
|
||||
previous,
|
||||
next,
|
||||
tabId,
|
||||
openTab,
|
||||
);
|
||||
return next;
|
||||
});
|
||||
setSidePanelLayouts(layouts);
|
||||
setSidePanelOpenTabs(openTabs);
|
||||
}, []);
|
||||
|
||||
const commitLayout = useCallback((tabId: string, layout: SidePanelLayout) => {
|
||||
commitMap(tabId, layout, getFocusedSidePanelPane(layout).tool);
|
||||
}, [commitMap]);
|
||||
|
||||
// Click path writes the focused tool and the layout together. The effect
|
||||
// above still reconciles external openTabs writes, and skips when
|
||||
// selectSidePanelTool returns the layout this call just stored.
|
||||
const selectSidePanelToolForTab = useCallback((tabId: string, tool: SidePanelTool) => {
|
||||
const current = sidePanelLayoutsRef.current.get(tabId);
|
||||
const updated = current
|
||||
? selectSidePanelTool(current, tool)
|
||||
: createSidePanelLayout(tool, crypto.randomUUID());
|
||||
if (updated === current && sidePanelOpenTabsRef.current.get(tabId) === tool) return;
|
||||
commitLayout(tabId, updated);
|
||||
}, [commitLayout]);
|
||||
|
||||
const focusPane = useCallback((tabId: string, paneId: string) => {
|
||||
const layout = sidePanelLayoutsRef.current.get(tabId);
|
||||
if (!layout) return;
|
||||
@@ -257,23 +97,25 @@ export function useTerminalSidePanelLayoutState() {
|
||||
return false;
|
||||
}
|
||||
|
||||
commitMap(tabId, layout, null);
|
||||
const layouts = new Map(sidePanelLayoutsRef.current);
|
||||
const openTabs = new Map(sidePanelOpenTabsRef.current);
|
||||
layouts.delete(tabId);
|
||||
openTabs.delete(tabId);
|
||||
sidePanelLayoutsRef.current = layouts;
|
||||
sidePanelOpenTabsRef.current = openTabs;
|
||||
setSidePanelLayouts(layouts);
|
||||
setSidePanelOpenTabs(openTabs);
|
||||
return true;
|
||||
}, [commitLayout, commitMap]);
|
||||
}, [commitLayout]);
|
||||
|
||||
const resizeSplit = useCallback((tabId: string, splitId: string, sizes: number[]) => {
|
||||
const layout = sidePanelLayoutsRef.current.get(tabId);
|
||||
if (!layout) return;
|
||||
const nextLayout = resizeSidePanelSplit(layout, splitId, sizes);
|
||||
if (nextLayout === layout) return;
|
||||
const base = sidePanelLayoutsRef.current;
|
||||
const layouts = replaceMapEntry(base, tabId, nextLayout);
|
||||
const layouts = new Map(sidePanelLayoutsRef.current).set(tabId, nextLayout);
|
||||
sidePanelLayoutsRef.current = layouts;
|
||||
setSidePanelLayouts((previous) => {
|
||||
const next = previous === base ? layouts : replaceMapEntry(previous, tabId, nextLayout);
|
||||
sidePanelLayoutsRef.current = next;
|
||||
return next;
|
||||
});
|
||||
setSidePanelLayouts(layouts);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
@@ -287,6 +129,5 @@ export function useTerminalSidePanelLayoutState() {
|
||||
splitPane,
|
||||
closePane,
|
||||
resizeSplit,
|
||||
selectSidePanelToolForTab,
|
||||
};
|
||||
}
|
||||
@@ -270,7 +270,7 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
|
||||
stopTunnel: current.stopTunnel,
|
||||
stopRuleTunnels: current.stopRuleTunnels,
|
||||
openHost: current.openHost
|
||||
? (host, isExternalMcpCall, options) => current.openHost!(host, isExternalMcpCall, options)
|
||||
? (host, isExternalMcpCall) => current.openHost!(host, isExternalMcpCall)
|
||||
: undefined,
|
||||
findExistingSessionForHost: current.findExistingSessionForHost,
|
||||
closeSession: current.closeSession
|
||||
|
||||
@@ -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*memory,\s*removedIds,\s*changedIds,/);
|
||||
assert.match(source, /rebaseQueuedConnectionLogs\(\s*next,\s*removedIds,\s*changedIds,/);
|
||||
assert.match(
|
||||
source,
|
||||
/if \(!options\?\.pruneMainBlob\) \{[\s\S]*?rebaseQueuedConnectionLogs\(/,
|
||||
|
||||
@@ -62,8 +62,7 @@ import {
|
||||
} from "../../domain/globalHistory";
|
||||
import {
|
||||
buildTerminalDataMapFromLogs,
|
||||
acknowledgePendingConnectionLogWrites,
|
||||
mergeConnectionLogsWithPendingMutations,
|
||||
mergeConnectionLogsFromStorage,
|
||||
mergeTerminalDataIntoLogs,
|
||||
mergeTerminalDataMapsForStorage,
|
||||
pruneInMemoryUnsavedTerminalData,
|
||||
@@ -71,7 +70,6 @@ import {
|
||||
retainNewestUnsavedReplays,
|
||||
pruneTerminalDataMapForStorage,
|
||||
type ConnectionLogTerminalDataMap,
|
||||
type PendingConnectionLogWrite,
|
||||
} from "../../domain/connectionLogTerminalData";
|
||||
import {
|
||||
tryLoadAll as tryLoadConnectionLogTerminalData,
|
||||
@@ -431,8 +429,6 @@ 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);
|
||||
@@ -522,23 +518,17 @@ 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 = writeMain(prunedMain);
|
||||
mainPersisted = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, prunedMain);
|
||||
if (!mainPersisted && unsavedTerminalDataPending) {
|
||||
mainPersisted = writeMain(logs);
|
||||
mainPersisted = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, logs);
|
||||
}
|
||||
}
|
||||
|
||||
let sidePersisted = false;
|
||||
let sidePersisted = true;
|
||||
if (mainPersisted || !options?.pruneMainBlob) {
|
||||
sidePersisted = await syncConnectionLogTerminalDataMap(logs, {
|
||||
usePersistedLogIdsFromDisk: Boolean(options?.pruneMainBlob && mainPersisted),
|
||||
@@ -555,16 +545,11 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
logs,
|
||||
options?.removedIds ?? new Set<string>(),
|
||||
options?.changedIds ?? new Set<string>(),
|
||||
mergeConnectionLogsWithPendingMutations(
|
||||
connectionLogsRef.current,
|
||||
freshLogs,
|
||||
connectionLogTerminalDataPersistedRef.current,
|
||||
connectionLogPendingRef.current,
|
||||
),
|
||||
connectionLogsRef.current,
|
||||
freshLogs,
|
||||
)
|
||||
: logs;
|
||||
mainPersisted = writeMain(toWrite);
|
||||
mainPersisted = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, 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,
|
||||
@@ -575,12 +560,12 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
let fallbackLogs = freshLogs
|
||||
? mergeTerminalDataIntoLogs(freshLogs, buildTerminalDataMapFromLogs(logs))
|
||||
: logs;
|
||||
sidePersisted = writeMain(fallbackLogs);
|
||||
sidePersisted = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, fallbackLogs);
|
||||
let keep = fallbackLogs.filter((log) => !log.saved && log.terminalData).length;
|
||||
while (!sidePersisted && keep > 1) {
|
||||
keep = Math.max(1, Math.floor(keep / 2));
|
||||
fallbackLogs = retainNewestUnsavedReplays(fallbackLogs, keep);
|
||||
sidePersisted = writeMain(fallbackLogs);
|
||||
sidePersisted = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, fallbackLogs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -593,7 +578,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
"[useVaultState] Failed to persist connection log terminal replay data side store.",
|
||||
);
|
||||
}
|
||||
return { metadataPersisted: mainPersisted, replayPersisted: sidePersisted, mainReplay };
|
||||
return mainPersisted && sidePersisted;
|
||||
}, [syncConnectionLogTerminalDataMap]);
|
||||
|
||||
const commitConnectionLogs = useCallback((
|
||||
@@ -604,81 +589,28 @@ 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 generation = ++connectionLogWriteGenerationRef.current;
|
||||
const changedIds = new Set<string>();
|
||||
for (const log of next) {
|
||||
nextIds.add(log.id);
|
||||
const before = prevById.get(log.id);
|
||||
if (!before || before !== log) {
|
||||
const previous = connectionLogPendingRef.current.get(log.id);
|
||||
const changes: Partial<ConnectionLog> = { ...previous?.changes };
|
||||
const fieldGenerations = { ...previous?.fieldGenerations };
|
||||
for (const field of Object.keys({ ...before, ...log }) as Array<keyof ConnectionLog>) {
|
||||
if (!before || before[field] !== log[field]) {
|
||||
Object.assign(changes, { [field]: log[field] });
|
||||
fieldGenerations[field] = generation;
|
||||
}
|
||||
}
|
||||
connectionLogPendingRef.current.set(log.id, {
|
||||
generation, log, changes, fieldGenerations, added: previous?.added ?? !before,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const log of prev) {
|
||||
if (!nextIds.has(log.id)) {
|
||||
connectionLogPendingRef.current.set(log.id, { generation, log: null });
|
||||
}
|
||||
if (!before || before !== log) changedIds.add(log.id);
|
||||
}
|
||||
const removedIds = new Set(prev.filter((log) => !nextIds.has(log.id)).map((log) => log.id));
|
||||
connectionLogsRef.current = next;
|
||||
setConnectionLogs(next);
|
||||
return enqueueTerminalDataTask(() => withVaultImportLock("connection-logs", async () => {
|
||||
// The shared lock covers the read/rebase as well as both storage writes.
|
||||
// Include uncommitted generations from failed or newer queued captures.
|
||||
await absorbRemoteTerminalDataIfNeeded();
|
||||
const pending = new Map(connectionLogPendingRef.current);
|
||||
const removedIds = new Set<string>();
|
||||
const changedIds = new Set<string>();
|
||||
for (const [id, mutation] of pending) {
|
||||
if (mutation.log === null) removedIds.add(id);
|
||||
else changedIds.add(id);
|
||||
}
|
||||
const storedBefore = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS);
|
||||
const memory = mergeConnectionLogsWithPendingMutations(
|
||||
// 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,
|
||||
connectionLogsRef.current,
|
||||
storedBefore ?? connectionLogsRef.current,
|
||||
connectionLogTerminalDataPersistedRef.current,
|
||||
pending,
|
||||
);
|
||||
const persisted = await persistConnectionLogState(
|
||||
rebaseQueuedConnectionLogs(
|
||||
memory,
|
||||
removedIds,
|
||||
changedIds,
|
||||
memory,
|
||||
storedBefore,
|
||||
),
|
||||
{ ...options, removedIds, changedIds },
|
||||
);
|
||||
// A replay/quota failure must not retain already-committed add or
|
||||
// metadata ownership: retrying it could undo a peer bookmark/deletion.
|
||||
connectionLogPendingRef.current = acknowledgePendingConnectionLogWrites(
|
||||
connectionLogPendingRef.current, pending, persisted,
|
||||
);
|
||||
if (persisted.metadataPersisted || persisted.replayPersisted) {
|
||||
const stored = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS);
|
||||
if (stored) {
|
||||
const merged = mergeConnectionLogsWithPendingMutations(
|
||||
connectionLogsRef.current,
|
||||
stored,
|
||||
connectionLogTerminalDataRef.current,
|
||||
connectionLogPendingRef.current,
|
||||
);
|
||||
connectionLogsRef.current = merged;
|
||||
setConnectionLogs(merged);
|
||||
}
|
||||
}
|
||||
return persisted.metadataPersisted && persisted.replayPersisted;
|
||||
}));
|
||||
}, [absorbRemoteTerminalDataIfNeeded, enqueueTerminalDataTask, persistConnectionLogState]);
|
||||
localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS),
|
||||
),
|
||||
{ ...options, removedIds, changedIds },
|
||||
));
|
||||
}, [enqueueTerminalDataTask, persistConnectionLogState]);
|
||||
|
||||
const applyConnectionLogsFromStorage = useCallback((
|
||||
prev: ConnectionLog[],
|
||||
@@ -686,12 +618,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
terminalDataMap: ConnectionLogTerminalDataMap,
|
||||
) => {
|
||||
connectionLogTerminalDataRef.current = terminalDataMap;
|
||||
return mergeConnectionLogsWithPendingMutations(
|
||||
prev,
|
||||
storedLogs,
|
||||
terminalDataMap,
|
||||
connectionLogPendingRef.current,
|
||||
);
|
||||
return mergeConnectionLogsFromStorage(prev, storedLogs, terminalDataMap);
|
||||
}, []);
|
||||
|
||||
// Encrypt outside the lock, then under the lock prune script bindings against
|
||||
@@ -2150,10 +2077,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
}
|
||||
|
||||
if (key === STORAGE_KEY_CONNECTION_LOGS) {
|
||||
// A queued peer event may predate our successful commit. Read the
|
||||
// current blob, then preserve only still-owned local generations.
|
||||
const next = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS);
|
||||
if (!next) return;
|
||||
const next = safeParse<ConnectionLog[]>(event.newValue) ?? [];
|
||||
const merged = applyConnectionLogsFromStorage(
|
||||
connectionLogsRef.current,
|
||||
next,
|
||||
@@ -2199,7 +2123,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
if (!syncConnectionLogTerminalData) return;
|
||||
if (raw === connectionLogTerminalDataRevRawRef.current) return;
|
||||
const seq = ++connectionLogTerminalDataLoadSeq.current;
|
||||
void terminalDataReadyRef.current.then(() => enqueueTerminalDataTask(() => withVaultImportLock("connection-logs", async () => {
|
||||
void terminalDataReadyRef.current.then(() => enqueueTerminalDataTask(async () => {
|
||||
if (seq !== connectionLogTerminalDataLoadSeq.current) return;
|
||||
const map = await loadConnectionLogTerminalData();
|
||||
if (seq !== connectionLogTerminalDataLoadSeq.current) return;
|
||||
@@ -2209,15 +2133,14 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
);
|
||||
connectionLogTerminalDataPersistedRef.current = map;
|
||||
connectionLogTerminalDataRef.current = map;
|
||||
const merged = mergeConnectionLogsWithPendingMutations(
|
||||
const merged = mergeConnectionLogsFromStorage(
|
||||
connectionLogsRef.current,
|
||||
connectionLogsRef.current,
|
||||
localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS) ?? connectionLogsRef.current,
|
||||
map,
|
||||
connectionLogPendingRef.current,
|
||||
);
|
||||
connectionLogsRef.current = merged;
|
||||
setConnectionLogs(merged);
|
||||
})));
|
||||
}));
|
||||
};
|
||||
|
||||
const handleLocalStorageAdapterChanged = (event: Event) => {
|
||||
|
||||
@@ -1,14 +1,3 @@
|
||||
!macro customInit
|
||||
; #3946: external MCP hosts launch Netcatty.exe with ELECTRON_RUN_AS_NODE=1
|
||||
; via netcatty-external-mcp.cmd. Those processes can outlive the host UI as
|
||||
; orphans and keep $INSTDIR locked, so the in-app updater hangs (AppHangB1).
|
||||
; Force-kill leftover Netcatty.exe before files are replaced. Do not pass /T:
|
||||
; electron-updater spawns this installer as a child of the UI Netcatty.exe,
|
||||
; and /T would terminate the installer itself. Ignore a missing process.
|
||||
nsExec::ExecToLog "taskkill /F /IM Netcatty.exe"
|
||||
Pop $0
|
||||
!macroend
|
||||
|
||||
!macro customInstall
|
||||
; Open a selected folder in a new Netcatty local terminal.
|
||||
; Args go after `--` so Electron/Chromium does not swallow them. Use
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { createDomRenderer, dispatchDomEvent, flushEffects, installDomEnvironment } from "./test-support/renderReactDom";
|
||||
|
||||
test("global completed rows and expanded folder children show cleanup paths without a failed status", async (t) => {
|
||||
const env = installDomEnvironment();
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
for (const [key, value] of Object.entries({
|
||||
MutationObserver: env.window.MutationObserver, NodeFilter: env.window.NodeFilter,
|
||||
HTMLInputElement: env.window.HTMLInputElement, Element: env.window.Element,
|
||||
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0),
|
||||
cancelAnimationFrame: clearTimeout,
|
||||
})) {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||
}
|
||||
const { I18nProvider } = await import("../application/i18n/I18nProvider");
|
||||
const { sftpTransferCenterStore: store } = await import("../application/state/sftpTransferCenterStore");
|
||||
const { GlobalSftpTransferCenter } = await import("./GlobalSftpTransferCenter");
|
||||
const { TooltipProvider } = await import("./ui/tooltip");
|
||||
const renderer = await createDomRenderer(env.document);
|
||||
t.after(async () => {
|
||||
await renderer.unmount();
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
env.cleanup();
|
||||
});
|
||||
const warning = "Upload completed; remove backup /remote/.netcatty-backup-file.bin manually.";
|
||||
const base = {
|
||||
fileName: "file.bin", sourcePath: "/local/file.bin", targetPath: "/remote/file.bin",
|
||||
sourceConnectionId: "local", targetConnectionId: "remote", direction: "upload" as const,
|
||||
status: "completed" as const, totalBytes: 10, transferredBytes: 10, speed: 0,
|
||||
startTime: Date.now(), endTime: Date.now(), isDirectory: false,
|
||||
};
|
||||
store.publishOwner("cleanup-ui", [
|
||||
{ ...base, id: "warning-file", warning },
|
||||
{ ...base, id: "warning-folder", fileName: "folder", isDirectory: true },
|
||||
{ ...base, id: "warning-child", parentTaskId: "warning-folder", warning },
|
||||
]);
|
||||
await renderer.render(<I18nProvider locale="en"><TooltipProvider><GlobalSftpTransferCenter /></TooltipProvider></I18nProvider>);
|
||||
const click = async (selector: string) => {
|
||||
const node = env.document.querySelector(selector);
|
||||
assert.ok(node, selector);
|
||||
await dispatchDomEvent(node, new env.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
};
|
||||
await click("[data-section=global-sftp-transfer-toggle]");
|
||||
assert.match(env.document.body.textContent ?? "", /Backup cleanup warnings: 1/);
|
||||
await click('button[aria-label="Show files"]');
|
||||
const notices = env.document.querySelectorAll('[data-section="sftp-transfer-warning"]');
|
||||
assert.equal(notices.length, 2);
|
||||
for (const notice of notices) assert.equal(notice.textContent, warning);
|
||||
assert.equal(env.document.querySelector('[data-transfer-status="failed"]'), null);
|
||||
assert.equal(store.getTask("warning-child")?.status, "completed");
|
||||
await click("[data-section=global-sftp-transfer-toggle]");
|
||||
});
|
||||
@@ -37,7 +37,6 @@ import { cn } from "../lib/utils";
|
||||
import { Button } from "./ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
import { SftpTransferWarning } from "./sftp/SftpTransferWarning";
|
||||
|
||||
/** Stable empty snapshot while the popover is closed — progress ticks must not
|
||||
* re-render TopTabs just to update hidden list rows. */
|
||||
@@ -351,9 +350,7 @@ const TransferChildRow = React.memo(function TransferChildRow({ task: snapshotTa
|
||||
return (
|
||||
<div className="flex h-6 items-center gap-2 rounded px-1 text-[10px] hover:bg-muted/40" data-transfer-status={task.status}>
|
||||
<TruncatedTextWithTooltip text={task.fileName} tooltip={task.targetPath || task.fileName} className="flex-1 text-muted-foreground" />
|
||||
{task.status === "completed" && task.warning
|
||||
? <div className="min-w-0 flex-1"><SftpTransferWarning warning={task.warning} compact /></div>
|
||||
: <span className="shrink-0 font-mono text-muted-foreground">{detail}</span>}
|
||||
<span className="shrink-0 font-mono text-muted-foreground">{detail}</span>
|
||||
{task.status === "transferring" && <Loader2 size={10} className="shrink-0 animate-spin text-primary" />}
|
||||
</div>
|
||||
);
|
||||
@@ -661,13 +658,6 @@ function TransferRow({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{task.status === "completed" && task.warning && <div className="mt-1"><SftpTransferWarning warning={task.warning} /></div>}
|
||||
{childTasks.some((child) => child.status === "completed" && !!child.warning) && (
|
||||
<p className="mt-1 text-[10px] text-amber-600 dark:text-amber-400">
|
||||
{t("sftp.transfers.cleanupWarnings", { count: childTasks.filter((child) => child.status === "completed" && !!child.warning).length })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Collapsed: show currently transferring child file(s) without expanding the whole tree. */}
|
||||
{isDirParent && !expanded && displayChildren.length > 0 && (
|
||||
<div
|
||||
|
||||
@@ -270,8 +270,8 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
|
||||
&& state.entries.length === 0;
|
||||
const showNoGlobalHistory = scope === 'global' && globalEntries.length === 0;
|
||||
|
||||
// Stay mounted when the side panel parks this tree. Returning null here
|
||||
// would remount the virtual list on every Notes <-> History switch.
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full flex flex-col bg-background overflow-hidden"
|
||||
@@ -394,7 +394,6 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
|
||||
<VariableSizeVirtualList
|
||||
ref={listRef}
|
||||
items={listRows}
|
||||
measureEnabled={isVisible}
|
||||
getItemHeight={getRowHeight}
|
||||
getItemKey={(row, index) =>
|
||||
row.type === 'entry' ? row.entry.id : `detail-${row.entry.id}-${index}`}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useTerminalBackend } from '../application/state/useTerminalBackend';
|
||||
import type { Host, SerialConfig, SerialFlowControl, SerialParity } from '../domain/models';
|
||||
import { prepareSerialConfigForSavedHost } from '../domain/serialBackspace';
|
||||
import { isSerialEndpointPath, isWindowsNamedPipePath } from '../domain/serialEndpoint';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Button } from './ui/button';
|
||||
import { Combobox, type ComboboxOption } from './ui/combobox';
|
||||
@@ -112,7 +111,7 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
if (!selectedPort) return;
|
||||
|
||||
const config: SerialConfig = {
|
||||
path: selectedPort.trim(),
|
||||
path: selectedPort,
|
||||
baudRate,
|
||||
dataBits,
|
||||
stopBits,
|
||||
@@ -130,7 +129,7 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
const host: Host = {
|
||||
id: `serial-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`,
|
||||
label: configLabel.trim() || `Serial: ${portName}`,
|
||||
hostname: selectedPort.trim(),
|
||||
hostname: selectedPort,
|
||||
// For serial hosts, port field stores baud rate as a numeric identifier.
|
||||
// The full configuration is stored in serialConfig for actual connection.
|
||||
port: baudRate,
|
||||
@@ -158,15 +157,18 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
}));
|
||||
}, [ports]);
|
||||
|
||||
// Validate: port path must start with /dev/ (Unix/macOS) or COM/\\.\COM (Windows)
|
||||
const trimmedPort = selectedPort.trim();
|
||||
const isNamedPipe = isWindowsNamedPipePath(trimmedPort);
|
||||
const isPortValid = isSerialEndpointPath(trimmedPort);
|
||||
const isPortValid =
|
||||
trimmedPort.startsWith('/dev/') ||
|
||||
/^COM\d+$/i.test(trimmedPort) ||
|
||||
/^\\\\\.\\COM\d+$/i.test(trimmedPort);
|
||||
// Allow custom baud rates as long as they are positive integers
|
||||
const isBaudRateValid = Number.isInteger(baudRate) && baudRate > 0;
|
||||
|
||||
// Check if using 1.5 stop bits (limited Windows support)
|
||||
const isStopBits15 = stopBits === 1.5;
|
||||
const isValid = isPortValid && (isNamedPipe || isBaudRateValid);
|
||||
const isValid = isPortValid && isBaudRateValid;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
@@ -210,9 +212,6 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
icon={<Usb size={14} className="text-muted-foreground" />}
|
||||
/>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('serial.field.namedPipeHint')}
|
||||
</p>
|
||||
{!isPortValid && selectedPort && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t('serial.field.customPortPlaceholder')}
|
||||
@@ -220,34 +219,32 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Baud Rate (not used by named pipes) */}
|
||||
{!isNamedPipe && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="baud-rate">{t('serial.field.baudRate')}</Label>
|
||||
<Combobox
|
||||
options={BAUD_RATES.map((rate) => ({
|
||||
value: String(rate),
|
||||
label: String(rate),
|
||||
}))}
|
||||
value={String(baudRate)}
|
||||
onValueChange={(val) => {
|
||||
const parsed = parseInt(val, 10);
|
||||
if (!isNaN(parsed) && parsed > 0) {
|
||||
setBaudRate(parsed);
|
||||
}
|
||||
}}
|
||||
placeholder={t('serial.field.baudRatePlaceholder')}
|
||||
emptyText={t('serial.field.baudRateEmpty')}
|
||||
allowCreate
|
||||
createText={t('common.use')}
|
||||
/>
|
||||
{baudRate > 0 && !BAUD_RATES.includes(baudRate) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('serial.field.customBaudRate')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Baud Rate */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="baud-rate">{t('serial.field.baudRate')}</Label>
|
||||
<Combobox
|
||||
options={BAUD_RATES.map((rate) => ({
|
||||
value: String(rate),
|
||||
label: String(rate),
|
||||
}))}
|
||||
value={String(baudRate)}
|
||||
onValueChange={(val) => {
|
||||
const parsed = parseInt(val, 10);
|
||||
if (!isNaN(parsed) && parsed > 0) {
|
||||
setBaudRate(parsed);
|
||||
}
|
||||
}}
|
||||
placeholder={t('serial.field.baudRatePlaceholder')}
|
||||
emptyText={t('serial.field.baudRateEmpty')}
|
||||
allowCreate
|
||||
createText={t('common.use')}
|
||||
/>
|
||||
{baudRate > 0 && !BAUD_RATES.includes(baudRate) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('serial.field.customBaudRate')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Advanced Options */}
|
||||
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
|
||||
@@ -267,98 +264,92 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-4 pt-2">
|
||||
{/* Hardware-only serial framing is not used by named pipes. */}
|
||||
{!isNamedPipe && (
|
||||
<>
|
||||
{/* Data Bits */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label>
|
||||
<Select
|
||||
value={String(dataBits)}
|
||||
onValueChange={(v) => setDataBits(parseInt(v, 10) as 5 | 6 | 7 | 8)}
|
||||
>
|
||||
<SelectTrigger id="data-bits">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DATA_BITS.map((bits) => (
|
||||
<SelectItem key={bits} value={String(bits)}>
|
||||
{bits}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Stop Bits */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
|
||||
<Select
|
||||
value={String(stopBits)}
|
||||
onValueChange={(v) => setStopBits(parseFloat(v) as 1 | 1.5 | 2)}
|
||||
>
|
||||
<SelectTrigger id="stop-bits">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STOP_BITS.map((bits) => (
|
||||
<SelectItem key={bits} value={String(bits)}>
|
||||
{bits}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isStopBits15 && (
|
||||
<p className="text-xs text-yellow-500">
|
||||
{t('serial.field.stopBits15Warning')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Parity */}
|
||||
{/* Data Bits */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="parity">{t('serial.field.parity')}</Label>
|
||||
<Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label>
|
||||
<Select
|
||||
value={parity}
|
||||
onValueChange={(v) => setParity(v as SerialParity)}
|
||||
value={String(dataBits)}
|
||||
onValueChange={(v) => setDataBits(parseInt(v, 10) as 5 | 6 | 7 | 8)}
|
||||
>
|
||||
<SelectTrigger id="parity">
|
||||
<SelectTrigger id="data-bits">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PARITY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{t(`serial.parity.${option}`)}
|
||||
{DATA_BITS.map((bits) => (
|
||||
<SelectItem key={bits} value={String(bits)}>
|
||||
{bits}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Flow Control */}
|
||||
{/* Stop Bits */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
|
||||
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
|
||||
<Select
|
||||
value={flowControl}
|
||||
onValueChange={(v) => setFlowControl(v as SerialFlowControl)}
|
||||
value={String(stopBits)}
|
||||
onValueChange={(v) => setStopBits(parseFloat(v) as 1 | 1.5 | 2)}
|
||||
>
|
||||
<SelectTrigger id="flow-control">
|
||||
<SelectTrigger id="stop-bits">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FLOW_CONTROL_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{t(`serial.flowControl.${option}`)}
|
||||
{STOP_BITS.map((bits) => (
|
||||
<SelectItem key={bits} value={String(bits)}>
|
||||
{bits}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isStopBits15 && (
|
||||
<p className="text-xs text-yellow-500">
|
||||
{t('serial.field.stopBits15Warning')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</>
|
||||
)}
|
||||
{/* Parity */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="parity">{t('serial.field.parity')}</Label>
|
||||
<Select
|
||||
value={parity}
|
||||
onValueChange={(v) => setParity(v as SerialParity)}
|
||||
>
|
||||
<SelectTrigger id="parity">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PARITY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{t(`serial.parity.${option}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Flow Control */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
|
||||
<Select
|
||||
value={flowControl}
|
||||
onValueChange={(v) => setFlowControl(v as SerialFlowControl)}
|
||||
>
|
||||
<SelectTrigger id="flow-control">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FLOW_CONTROL_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{t(`serial.flowControl.${option}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Terminal Options */}
|
||||
<div className="space-y-3 pt-2 border-t border-border/60">
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { I18nProvider } from '../application/i18n/I18nProvider';
|
||||
import type { Host } from '../domain/models';
|
||||
import { SerialHostDetailsPanel } from './SerialHostDetailsPanel';
|
||||
|
||||
function renderPanel(path: string, hostname = path) {
|
||||
const host: Host = {
|
||||
id: 'serial-test', label: 'VM console', hostname, protocol: 'serial',
|
||||
port: 115200, username: '', tags: [], os: 'linux',
|
||||
serialConfig: { path, baudRate: 115200, dataBits: 8, stopBits: 1, parity: 'none', flowControl: 'none' },
|
||||
};
|
||||
return renderToStaticMarkup(<I18nProvider locale="en"><SerialHostDetailsPanel
|
||||
initialData={host} onSave={() => {}} onCancel={() => {}}
|
||||
/></I18nProvider>);
|
||||
}
|
||||
|
||||
test('saved named-pipe host is editable without hardware baud controls', () => {
|
||||
const html = renderPanel(String.raw`\\.\pipe\vm-console`);
|
||||
assert.match(html, /VM must create the pipe as a server/);
|
||||
assert.doesNotMatch(html, /for="baud-rate"/);
|
||||
assert.doesNotMatch(html, /text-destructive/);
|
||||
});
|
||||
|
||||
test('hardware serial host retains its baud-rate controls', () => {
|
||||
assert.match(renderPanel('COM3'), /for="baud-rate"/);
|
||||
});
|
||||
|
||||
test('editor prefers the stored pipe path over a stale hardware hostname', () => {
|
||||
const html = renderPanel(String.raw`\\.\pipe\VM console`, 'COM3');
|
||||
assert.doesNotMatch(html, /for="baud-rate"/);
|
||||
});
|
||||
@@ -12,8 +12,6 @@ import {
|
||||
resolveSerialBackspaceOverrideOnSave,
|
||||
} from '../domain/serialBackspace';
|
||||
|
||||
import { isSerialEndpointPath, isWindowsNamedPipePath } from '../domain/serialEndpoint';
|
||||
|
||||
import { Button } from './ui/button';
|
||||
import { Combobox, ComboboxOption, MultiCombobox } from './ui/combobox';
|
||||
import { Input } from './ui/input';
|
||||
@@ -93,7 +91,7 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
|
||||
// input alone cannot restore `password` to undefined — the user needs a
|
||||
// dedicated control to delete the credential entirely.
|
||||
const [passwordCleared, setPasswordCleared] = useState(false);
|
||||
const [selectedPort, setSelectedPort] = useState(initialData.serialConfig?.path || initialData.hostname || '');
|
||||
const [selectedPort, setSelectedPort] = useState(initialData.hostname || initialData.serialConfig?.path || '');
|
||||
const [baudRate, setBaudRate] = useState(initialData.serialConfig?.baudRate || initialData.port || 115200);
|
||||
const [dataBits, setDataBits] = useState<5 | 6 | 7 | 8>(initialData.serialConfig?.dataBits || 8);
|
||||
const [stopBits, setStopBits] = useState<1 | 1.5 | 2>(initialData.serialConfig?.stopBits || 1);
|
||||
@@ -133,7 +131,7 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
|
||||
if (!selectedPort) return;
|
||||
|
||||
const config: SerialConfig = {
|
||||
path: selectedPort.trim(),
|
||||
path: selectedPort,
|
||||
baudRate,
|
||||
dataBits,
|
||||
stopBits,
|
||||
@@ -153,7 +151,7 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
|
||||
const updatedHost: Host = {
|
||||
...initialData,
|
||||
label: label.trim() || `Serial: ${portName}`,
|
||||
hostname: selectedPort.trim(),
|
||||
hostname: selectedPort,
|
||||
port: baudRate,
|
||||
username: username.trim() || undefined,
|
||||
// An empty password is meaningful for serial auto-login: the detector
|
||||
@@ -208,10 +206,12 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
|
||||
|
||||
// Validation
|
||||
const trimmedPort = selectedPort.trim();
|
||||
const isNamedPipe = isWindowsNamedPipePath(trimmedPort);
|
||||
const isPortValid = isSerialEndpointPath(trimmedPort);
|
||||
const isPortValid =
|
||||
trimmedPort.startsWith('/dev/') ||
|
||||
/^COM\d+$/i.test(trimmedPort) ||
|
||||
/^\\\\\.\\COM\d+$/i.test(trimmedPort);
|
||||
const isBaudRateValid = Number.isInteger(baudRate) && baudRate > 0;
|
||||
const isValid = isPortValid && (isNamedPipe || isBaudRateValid);
|
||||
const isValid = isPortValid && isBaudRateValid;
|
||||
|
||||
// Check if using 1.5 stop bits (limited Windows support)
|
||||
const isStopBits15 = stopBits === 1.5;
|
||||
@@ -265,9 +265,6 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
|
||||
createText={t('common.use')}
|
||||
icon={<Usb size={14} className="text-muted-foreground" />}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('serial.field.namedPipeHint')}
|
||||
</p>
|
||||
{!isPortValid && selectedPort && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t('serial.field.customPortPlaceholder')}
|
||||
@@ -275,34 +272,32 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Baud Rate (not used by named pipes) */}
|
||||
{!isNamedPipe && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="baud-rate">{t('serial.field.baudRate')}</Label>
|
||||
<Combobox
|
||||
options={BAUD_RATES.map((rate) => ({
|
||||
value: String(rate),
|
||||
label: String(rate),
|
||||
}))}
|
||||
value={String(baudRate)}
|
||||
onValueChange={(val) => {
|
||||
const parsed = parseInt(val, 10);
|
||||
if (!isNaN(parsed) && parsed > 0) {
|
||||
setBaudRate(parsed);
|
||||
}
|
||||
}}
|
||||
placeholder={t('serial.field.baudRatePlaceholder')}
|
||||
emptyText={t('serial.field.baudRateEmpty')}
|
||||
allowCreate
|
||||
createText={t('common.use')}
|
||||
/>
|
||||
{baudRate > 0 && !BAUD_RATES.includes(baudRate) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('serial.field.customBaudRate')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Baud Rate */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="baud-rate">{t('serial.field.baudRate')}</Label>
|
||||
<Combobox
|
||||
options={BAUD_RATES.map((rate) => ({
|
||||
value: String(rate),
|
||||
label: String(rate),
|
||||
}))}
|
||||
value={String(baudRate)}
|
||||
onValueChange={(val) => {
|
||||
const parsed = parseInt(val, 10);
|
||||
if (!isNaN(parsed) && parsed > 0) {
|
||||
setBaudRate(parsed);
|
||||
}
|
||||
}}
|
||||
placeholder={t('serial.field.baudRatePlaceholder')}
|
||||
emptyText={t('serial.field.baudRateEmpty')}
|
||||
allowCreate
|
||||
createText={t('common.use')}
|
||||
/>
|
||||
{baudRate > 0 && !BAUD_RATES.includes(baudRate) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('serial.field.customBaudRate')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Login credentials (auto-login) */}
|
||||
<div className="space-y-2">
|
||||
@@ -413,98 +408,92 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-4 pt-2">
|
||||
{/* Hardware-only serial framing is not used by named pipes. */}
|
||||
{!isNamedPipe && (
|
||||
<>
|
||||
{/* Data Bits */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label>
|
||||
<Select
|
||||
value={String(dataBits)}
|
||||
onValueChange={(v) => setDataBits(parseInt(v, 10) as 5 | 6 | 7 | 8)}
|
||||
>
|
||||
<SelectTrigger id="data-bits">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DATA_BITS.map((bits) => (
|
||||
<SelectItem key={bits} value={String(bits)}>
|
||||
{bits}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Stop Bits */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
|
||||
<Select
|
||||
value={String(stopBits)}
|
||||
onValueChange={(v) => setStopBits(parseFloat(v) as 1 | 1.5 | 2)}
|
||||
>
|
||||
<SelectTrigger id="stop-bits">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STOP_BITS.map((bits) => (
|
||||
<SelectItem key={bits} value={String(bits)}>
|
||||
{bits}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isStopBits15 && (
|
||||
<p className="text-xs text-yellow-500">
|
||||
{t('serial.field.stopBits15Warning')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Parity */}
|
||||
{/* Data Bits */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="parity">{t('serial.field.parity')}</Label>
|
||||
<Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label>
|
||||
<Select
|
||||
value={parity}
|
||||
onValueChange={(v) => setParity(v as SerialParity)}
|
||||
value={String(dataBits)}
|
||||
onValueChange={(v) => setDataBits(parseInt(v, 10) as 5 | 6 | 7 | 8)}
|
||||
>
|
||||
<SelectTrigger id="parity">
|
||||
<SelectTrigger id="data-bits">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PARITY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{t(`serial.parity.${option}`)}
|
||||
{DATA_BITS.map((bits) => (
|
||||
<SelectItem key={bits} value={String(bits)}>
|
||||
{bits}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Flow Control */}
|
||||
{/* Stop Bits */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
|
||||
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
|
||||
<Select
|
||||
value={flowControl}
|
||||
onValueChange={(v) => setFlowControl(v as SerialFlowControl)}
|
||||
value={String(stopBits)}
|
||||
onValueChange={(v) => setStopBits(parseFloat(v) as 1 | 1.5 | 2)}
|
||||
>
|
||||
<SelectTrigger id="flow-control">
|
||||
<SelectTrigger id="stop-bits">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FLOW_CONTROL_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{t(`serial.flowControl.${option}`)}
|
||||
{STOP_BITS.map((bits) => (
|
||||
<SelectItem key={bits} value={String(bits)}>
|
||||
{bits}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isStopBits15 && (
|
||||
<p className="text-xs text-yellow-500">
|
||||
{t('serial.field.stopBits15Warning')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</>
|
||||
)}
|
||||
{/* Parity */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="parity">{t('serial.field.parity')}</Label>
|
||||
<Select
|
||||
value={parity}
|
||||
onValueChange={(v) => setParity(v as SerialParity)}
|
||||
>
|
||||
<SelectTrigger id="parity">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PARITY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{t(`serial.parity.${option}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Flow Control */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
|
||||
<Select
|
||||
value={flowControl}
|
||||
onValueChange={(v) => setFlowControl(v as SerialFlowControl)}
|
||||
>
|
||||
<SelectTrigger id="flow-control">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FLOW_CONTROL_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{t(`serial.flowControl.${option}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Terminal Options */}
|
||||
<div className="space-y-3 pt-2 border-t border-border/60">
|
||||
|
||||
@@ -523,8 +523,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* min-w-0: row flex item must shrink so long Agent paths truncate inside the pane. */}
|
||||
<div className="flex-1 h-full flex flex-col min-h-0 min-w-0 bg-muted/10">
|
||||
<div className="flex-1 h-full flex flex-col min-h-0 bg-muted/10">
|
||||
{mountedTabs.has("application") && (
|
||||
<SettingsLazyTab value="application">
|
||||
<LazySettingsApplicationTab
|
||||
|
||||
@@ -265,10 +265,9 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
}, [activeHost, activeSessionId, resolveTransferSourceSessionId, sessions]);
|
||||
|
||||
const fileWatchHandlers = useMemo(() => ({
|
||||
onFileWatchSynced: (payload: { remotePath: string; warning?: string }) => {
|
||||
onFileWatchSynced: (payload: { remotePath: string }) => {
|
||||
const fileName = payload.remotePath.split('/').pop() || payload.remotePath;
|
||||
if (payload.warning) toast.warning(payload.warning);
|
||||
else toast.success(t('sftp.autoSync.success', { fileName }));
|
||||
toast.success(t('sftp.autoSync.success', { fileName }));
|
||||
logger.info("[SFTP] File auto-synced to remote", payload);
|
||||
},
|
||||
onFileWatchError: (payload: { error: string }) => {
|
||||
|
||||
@@ -52,27 +52,6 @@ test("renders failed transfer actions with custom tooltips and readable labels",
|
||||
assert.match(markup, /focus-visible:ring-1/);
|
||||
});
|
||||
|
||||
for (const isChild of [false, true]) {
|
||||
test(`completed ${isChild ? "child" : "file"} shows the cleanup path without a retry action`, () => {
|
||||
const warning = "Upload completed; remove backup /remote/.netcatty-backup-archive.tar.gz manually.";
|
||||
const markup = renderTransferItem({
|
||||
...baseTask, status: "completed", error: undefined, warning, transferredBytes: 1024,
|
||||
}, { isChild });
|
||||
assert.match(markup, /data-transfer-status="completed"/);
|
||||
assert.match(markup, /data-section="sftp-transfer-warning"/);
|
||||
assert.ok(markup.includes(warning));
|
||||
assert.doesNotMatch(markup, /aria-label="Retry:/);
|
||||
assert.doesNotMatch(markup, /text-destructive/);
|
||||
});
|
||||
}
|
||||
|
||||
test("folder rows disclose retained child cleanup warnings", () => {
|
||||
const markup = renderTransferItem({ ...baseTask, status: "completed", error: undefined, isDirectory: true }, {
|
||||
childWarningCount: 2, canToggleChildren: true, onToggleChildren: () => {},
|
||||
});
|
||||
assert.match(markup, /Backup cleanup warnings: 2/);
|
||||
});
|
||||
|
||||
test("renders active transfer cancel action with an item-specific label", () => {
|
||||
const markup = renderTransferItem({
|
||||
...baseTask,
|
||||
|
||||
@@ -119,10 +119,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
|
||||
// File watch event handlers (stable refs to avoid re-creating the useSftpState options)
|
||||
const fileWatchHandlers = useMemo(() => ({
|
||||
onFileWatchSynced: (payload: { remotePath: string; warning?: string }) => {
|
||||
onFileWatchSynced: (payload: { remotePath: string }) => {
|
||||
const fileName = payload.remotePath.split('/').pop() || payload.remotePath;
|
||||
if (payload.warning) toast.warning(payload.warning);
|
||||
else toast.success(t('sftp.autoSync.success', { fileName }));
|
||||
toast.success(t('sftp.autoSync.success', { fileName }));
|
||||
logger.info("[SFTP] File auto-synced to remote", payload);
|
||||
},
|
||||
onFileWatchError: (payload: { error: string }) => {
|
||||
|
||||
+22
-82
@@ -243,7 +243,6 @@ import {
|
||||
import {
|
||||
canHibernateTerminalRuntimeSession,
|
||||
canHibernateTerminalRuntimeStatus,
|
||||
decideHiddenTabKeepCountAction,
|
||||
isTerminalFileTransferActive,
|
||||
resolveHibernateKeepRendererCount,
|
||||
resolveHibernatePreferWasmSerialize,
|
||||
@@ -255,7 +254,6 @@ import {
|
||||
} from "../domain/terminalHibernate";
|
||||
import { getLastChar, removeLastChar } from "../domain/serialCharMetrics";
|
||||
import { stringCellWidth } from "./terminal/autocomplete/terminalStringCellWidth";
|
||||
import { retainLeavingTabAfterEviction } from "../application/state/hiddenTabEviction";
|
||||
import { terminalHiddenRendererStore } from "../application/state/terminalHiddenRendererStore";
|
||||
import {
|
||||
wakeTerminalFromHibernate,
|
||||
@@ -524,7 +522,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const hibernateAlternateScreenRef = useRef(false);
|
||||
const hibernateRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const fullHibernateRuntimeRef = useRef<(() => Promise<boolean>) | null>(null);
|
||||
const hibernateEvictionAttemptRef = useRef(0);
|
||||
const wakeSoftHiddenRuntimeRef = useRef<(() => void) | null>(null);
|
||||
const resumeRendererAfterCancelledHibernateUpgradeRef = useRef<(() => void) | null>(null);
|
||||
const wakeInProgressRef = useRef(false);
|
||||
@@ -2300,6 +2297,20 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
]);
|
||||
fullHibernateRuntimeRef.current = fullHibernateRuntime;
|
||||
|
||||
const upgradeSoftHiddenRuntimeToHibernate = useCallback(() => {
|
||||
if (!wakeSoftHiddenRuntimeRef.current) return;
|
||||
wakeSoftHiddenRuntimeRef.current?.();
|
||||
void fullHibernateRuntime().then(
|
||||
(completed) => {
|
||||
if (!completed) resumeRendererAfterCancelledHibernateUpgradeRef.current?.();
|
||||
},
|
||||
(error) => {
|
||||
logger.error("[Terminal] Failed to upgrade soft-hidden runtime to hibernate", { sessionId, error });
|
||||
resumeRendererAfterCancelledHibernateUpgradeRef.current?.();
|
||||
},
|
||||
);
|
||||
}, [fullHibernateRuntime, sessionId]);
|
||||
|
||||
const hideRuntimeOnly = useCallback(() => {
|
||||
if (hibernatedRef.current || softHiddenRef.current || !hasRuntimeRef.current) return;
|
||||
xtermRuntimeRef.current?.suspendWebglRenderer();
|
||||
@@ -2308,39 +2319,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
logger.info("[Terminal] Soft-hidden runtime", { sessionId });
|
||||
}, [sessionId]);
|
||||
|
||||
const upgradeSoftHiddenRuntimeToHibernate = useCallback(() => {
|
||||
const pendingEviction = terminalHiddenRendererStore.hasPendingEviction(sessionId);
|
||||
if (!wakeSoftHiddenRuntimeRef.current) {
|
||||
terminalHiddenRendererStore.settleEviction(sessionId, false);
|
||||
return;
|
||||
}
|
||||
wakeSoftHiddenRuntimeRef.current?.();
|
||||
void fullHibernateRuntime().then(
|
||||
(completed) => {
|
||||
if (!completed) {
|
||||
resumeRendererAfterCancelledHibernateUpgradeRef.current?.();
|
||||
// Wake already dropped the marker. Restore the soft-hidden slot only
|
||||
// while this tab is still hidden and hibernate is still enabled.
|
||||
// Selecting the victim during the drain makes fullHibernateRuntime
|
||||
// return false because the tab is visible; soft-hiding it again
|
||||
// would hide the tab the user just opened.
|
||||
if (pendingEviction && !isVisibleRef.current && hibernateEnabledRef.current) {
|
||||
hideRuntimeOnly();
|
||||
}
|
||||
}
|
||||
terminalHiddenRendererStore.settleEviction(sessionId, completed);
|
||||
},
|
||||
(error) => {
|
||||
logger.error("[Terminal] Failed to upgrade soft-hidden runtime to hibernate", { sessionId, error });
|
||||
resumeRendererAfterCancelledHibernateUpgradeRef.current?.();
|
||||
if (pendingEviction && !isVisibleRef.current && hibernateEnabledRef.current) {
|
||||
hideRuntimeOnly();
|
||||
}
|
||||
terminalHiddenRendererStore.settleEviction(sessionId, false);
|
||||
},
|
||||
);
|
||||
}, [fullHibernateRuntime, hideRuntimeOnly, sessionId]);
|
||||
|
||||
const hibernateRuntime = useCallback(() => {
|
||||
if (hibernatedRef.current || !termRef.current) return;
|
||||
|
||||
@@ -2355,14 +2333,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Disconnected sessions never stay soft-hidden. A connected session whose
|
||||
// keep count dropped to zero takes the same upgrade. A positive count stays
|
||||
// retained until a later leave evicts overflow.
|
||||
if (softHiddenRef.current) {
|
||||
const retainedKeepCount = statusRef.current === "connected"
|
||||
? resolveHibernateKeepRendererCount(terminalSettingsRef.current)
|
||||
: 0;
|
||||
if (statusRef.current === "disconnected" || !(retainedKeepCount > 0)) {
|
||||
if (statusRef.current === "disconnected") {
|
||||
upgradeSoftHiddenRuntimeToHibernate();
|
||||
}
|
||||
return;
|
||||
@@ -2371,47 +2343,16 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const keepCount = statusRef.current === "connected"
|
||||
? resolveHibernateKeepRendererCount(terminalSettings)
|
||||
: 0;
|
||||
const keepDecision = decideHiddenTabKeepCountAction({
|
||||
keepCount,
|
||||
softHiddenCount: terminalHiddenRendererStore.getSoftHiddenOccupancy(),
|
||||
sessionId,
|
||||
candidatesOldestFirst: keepCount > 0
|
||||
? terminalHiddenRendererStore.listSoftHiddenOldestFirst()
|
||||
: [],
|
||||
});
|
||||
if (keepDecision.action === "soft-hide") {
|
||||
if (keepCount > 0 && terminalHiddenRendererStore.getSoftHiddenCount() < keepCount) {
|
||||
hideRuntimeOnly();
|
||||
return;
|
||||
}
|
||||
if (keepDecision.action === "await-eviction") {
|
||||
const attempt = ++hibernateEvictionAttemptRef.current;
|
||||
// Reserves this tab's slot before the first await, then soft-hides only
|
||||
// if a victim hibernate completes and the slot still fits. A sync marker
|
||||
// clear is not enough: alt-screen and drain/snapshot failure restore the
|
||||
// victim, and this tab stays out of the soft-hidden set.
|
||||
void retainLeavingTabAfterEviction({
|
||||
sessionId,
|
||||
candidates: keepDecision.candidates,
|
||||
keepCount,
|
||||
// Re-read after the victim drain. The count above was captured when
|
||||
// this timer fired; a zero or tighter setting must full-hibernate.
|
||||
resolveKeepCount: () => (
|
||||
statusRef.current === "connected"
|
||||
? resolveHibernateKeepRendererCount(terminalSettingsRef.current)
|
||||
: 0
|
||||
),
|
||||
stillWantsRetention: () => (
|
||||
hibernateEvictionAttemptRef.current === attempt
|
||||
&& !isVisibleRef.current
|
||||
&& !hibernatedRef.current
|
||||
&& !softHiddenRef.current
|
||||
),
|
||||
softHide: () => hideRuntimeOnly(),
|
||||
fullHibernate: () => {
|
||||
void fullHibernateRuntime();
|
||||
},
|
||||
});
|
||||
return;
|
||||
|
||||
if (keepCount > 0) {
|
||||
const victim = terminalHiddenRendererStore.pickEvictionCandidate(keepCount);
|
||||
if (victim && victim !== sessionId) {
|
||||
terminalHiddenRendererStore.requestEviction(victim);
|
||||
}
|
||||
}
|
||||
|
||||
void fullHibernateRuntime();
|
||||
@@ -4769,7 +4710,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
isSearchOpen,
|
||||
hibernateEnabled: hibernateEnabled,
|
||||
hibernateDelayMs: resolveTerminalHibernateDelayMs(terminalSettings),
|
||||
hibernateKeepRendererCount: resolveHibernateKeepRendererCount(terminalSettings),
|
||||
fileTransferActive: hibernateFileTransferActive,
|
||||
hibernatedRef,
|
||||
softHiddenRef,
|
||||
|
||||
@@ -319,7 +319,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
splitPane: splitSidePanelPaneForTab,
|
||||
closePane: closeSidePanelPaneForTab,
|
||||
resizeSplit: resizeSidePanelSplitForTab,
|
||||
selectSidePanelToolForTab,
|
||||
} = useTerminalSidePanelLayoutState();
|
||||
useEffect(() => {
|
||||
pruneComposeBarHistory(sessions.map((session) => session.id));
|
||||
@@ -1753,9 +1752,13 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
if (!prepareSidePanelTool(tabId, tab)) return;
|
||||
|
||||
startTransition(() => {
|
||||
selectSidePanelToolForTab(tabId, tab);
|
||||
setSidePanelOpenTabs(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(tabId, tab);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
}, [prepareSidePanelTool, selectSidePanelToolForTab, sidePanelOpenTabsRef]);
|
||||
}, [prepareSidePanelTool, setSidePanelOpenTabs, sidePanelOpenTabsRef]);
|
||||
|
||||
const handleFocusSidePanelPane = useCallback((paneId: string) => {
|
||||
const tabId = activeTabIdRef.current;
|
||||
|
||||
@@ -315,41 +315,6 @@ test("draggable top-tab close buttons cancel the drag gesture on mousedown", ()
|
||||
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;
|
||||
|
||||
|
||||
+7
-14
@@ -24,7 +24,6 @@ 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';
|
||||
@@ -521,20 +520,14 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
]);
|
||||
|
||||
const handleTabDragStart = useCallback((e: React.DragEvent, tabId: string) => {
|
||||
// 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;
|
||||
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);
|
||||
}
|
||||
if (!isTabDragReorderStarted()) return;
|
||||
draggedTabIdRef.current = tabId;
|
||||
// Use setTimeout to allow the drag image to be captured before we change styles
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -43,26 +43,6 @@ test("note content drafts stay in refs so MDX keystrokes do not rebuild the shel
|
||||
);
|
||||
});
|
||||
|
||||
test("hidden notes flush drafts on the isActive edge without rebuilding the tree", () => {
|
||||
assert.match(
|
||||
managerSource,
|
||||
/useLayoutEffect\(\(\) => \{\s*if \(isActive\) return;\s*flushNoteDraftRef\.current\(\);\s*\}, \[isActive\]\);/,
|
||||
);
|
||||
assert.match(managerSource, /const NotesTreePane = memo\(/);
|
||||
assert.match(managerSource, /notesTreePanePropsEqual/);
|
||||
assert.match(managerSource, /prev\.selectedNoteId === next\.selectedNoteId/);
|
||||
assert.match(managerSource, /prev\.editingNoteId === next\.editingNoteId/);
|
||||
assert.match(managerSource, /prev\.editingGroupPath === next\.editingGroupPath/);
|
||||
assert.match(managerSource, /prev\.expandedGroups === next\.expandedGroups/);
|
||||
assert.match(managerSource, /<NotesTreePane/);
|
||||
assert.doesNotMatch(managerSource, /key=\{isActive\}/);
|
||||
assert.doesNotMatch(
|
||||
managerSource,
|
||||
/if \(!isActive\) return null;/,
|
||||
"hiding notes must not unmount NotesManager",
|
||||
);
|
||||
});
|
||||
|
||||
test("NotesManager teardown flush uses a stable ref under StrictMode", () => {
|
||||
assert.match(managerSource, /flushNoteDraftRef\.current = flushNoteDraft/);
|
||||
assert.match(
|
||||
@@ -93,21 +73,6 @@ test("hidden terminal notes side panel does not subscribe to notes publishes", (
|
||||
"utf8",
|
||||
);
|
||||
assert.match(slotsSource, /useNotesStore\(\{\s*enabled:\s*isVisible\s*\}\)/);
|
||||
const notesSlot = slotsSource.slice(
|
||||
slotsSource.indexOf("function SidePanelNotesSlotInner"),
|
||||
slotsSource.indexOf("export const SidePanelNotesSlot"),
|
||||
);
|
||||
assert.doesNotMatch(notesSlot, /if \(!isVisible\) return null/);
|
||||
assert.match(notesSlot, /<NotesManager/);
|
||||
const notesStoreSource = readFileSync(
|
||||
new URL("../../application/state/notesStore.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
notesStoreSource,
|
||||
/enabled \? getNotesSnapshot : getEmptyNotesSnapshot/,
|
||||
"enabled:false must keep the live snapshot so reopen does not flash empty",
|
||||
);
|
||||
});
|
||||
|
||||
test("notes manager prefetches the MDXEditor chunk when becoming active", () => {
|
||||
|
||||
@@ -22,9 +22,7 @@ import {
|
||||
isNoteFolderTreeSelected,
|
||||
isNoteEditorMode,
|
||||
normalizeNoteEditorMode,
|
||||
notesTreePanePropsEqual,
|
||||
NotesManager,
|
||||
type NotesTreePaneProps,
|
||||
} from "./NotesManager.tsx";
|
||||
|
||||
const note = (overrides: Partial<VaultNote> = {}): VaultNote => ({
|
||||
@@ -371,40 +369,10 @@ test("NotesManager flushes drafts when retained mounts become inactive", () => {
|
||||
const source = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
|
||||
assert.match(source, /isActive\?: boolean/);
|
||||
assert.match(source, /if \(isActive\) return;/);
|
||||
assert.match(source, /useLayoutEffect\(\(\) => \{\s*if \(isActive\) return;\s*flushNoteDraftRef\.current\(\);/);
|
||||
assert.match(source, /useLayoutEffect\(\(\) => \{\s*if \(isActive\) return;/);
|
||||
assert.match(source, /visibilitychange/);
|
||||
});
|
||||
|
||||
const treePaneProps = (overrides: Partial<NotesTreePaneProps> = {}): NotesTreePaneProps => ({
|
||||
notes: [note()],
|
||||
noteGroups: ["Ops"],
|
||||
selectedNoteId: "note-1",
|
||||
selectedGroup: null,
|
||||
expandedGroups: new Set(["Ops"]),
|
||||
editingNoteId: null,
|
||||
editingGroupPath: null,
|
||||
query: "",
|
||||
locale: "en",
|
||||
hosts: [],
|
||||
draggingNoteId: null,
|
||||
draggingGroupPath: null,
|
||||
creatingGroupParent: undefined,
|
||||
isSidebarMode: true,
|
||||
renderRows: () => null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("notes tree pane equality ignores render callbacks and compares selection state", () => {
|
||||
const base = treePaneProps();
|
||||
assert.equal(notesTreePanePropsEqual(base, { ...base, renderRows: () => "other" }), true);
|
||||
assert.equal(notesTreePanePropsEqual(base, { ...base, selectedNoteId: "note-2" }), false);
|
||||
assert.equal(notesTreePanePropsEqual(base, { ...base, selectedGroup: "Ops" }), false);
|
||||
assert.equal(notesTreePanePropsEqual(base, { ...base, editingNoteId: "note-1" }), false);
|
||||
assert.equal(notesTreePanePropsEqual(base, { ...base, editingGroupPath: "Ops" }), false);
|
||||
assert.equal(notesTreePanePropsEqual(base, { ...base, expandedGroups: new Set(["Ops"]) }), false);
|
||||
assert.equal(notesTreePanePropsEqual(base, { ...base, notes: [note({ title: "Renamed" })] }), false);
|
||||
});
|
||||
|
||||
test("NotesManager tree rename allows clearing note titles", () => {
|
||||
const source = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
|
||||
const renameBlock = source.match(/onRenameCommit=\{\(name\) => \{[\s\S]*?\}\}/)?.[0] ?? "";
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import React, { lazy, memo, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import { useApplicationBackend } from "../../application/state/useApplicationBackend";
|
||||
import { useStoredNumber } from "../../application/state/useStoredNumber";
|
||||
@@ -357,57 +357,6 @@ export const getValidatedNoteSelectionState = (
|
||||
return null;
|
||||
};
|
||||
|
||||
export type NotesTreePaneProps = {
|
||||
notes: VaultNote[];
|
||||
noteGroups: string[];
|
||||
selectedNoteId: string | null;
|
||||
selectedGroup: string | null;
|
||||
expandedGroups: ReadonlySet<string>;
|
||||
editingNoteId: string | null;
|
||||
editingGroupPath: string | null;
|
||||
query: string;
|
||||
locale: string;
|
||||
hosts: Host[];
|
||||
draggingNoteId: string | null;
|
||||
draggingGroupPath: string | null;
|
||||
creatingGroupParent: string | null | undefined;
|
||||
isSidebarMode: boolean;
|
||||
/**
|
||||
* Row elements. Identity changes every parent render; equality ignores it so
|
||||
* a visibility flip does not rebuild the tree. Event handlers close over the
|
||||
* render that last changed one of the compared fields.
|
||||
*/
|
||||
renderRows: () => React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hide/show passes a new `isActive` into NotesManager, but that flag is not a
|
||||
* tree input. Equality therefore keys off the data the rows actually paint.
|
||||
* `selected` / `editing` / `expanded` are required so a memo bailout cannot
|
||||
* freeze selection, rename, or folder expansion.
|
||||
*/
|
||||
export function notesTreePanePropsEqual(prev: NotesTreePaneProps, next: NotesTreePaneProps): boolean {
|
||||
return prev.notes === next.notes
|
||||
&& prev.noteGroups === next.noteGroups
|
||||
&& prev.selectedNoteId === next.selectedNoteId
|
||||
&& prev.selectedGroup === next.selectedGroup
|
||||
&& prev.expandedGroups === next.expandedGroups
|
||||
&& prev.editingNoteId === next.editingNoteId
|
||||
&& prev.editingGroupPath === next.editingGroupPath
|
||||
&& prev.query === next.query
|
||||
&& prev.locale === next.locale
|
||||
&& prev.hosts === next.hosts
|
||||
&& prev.draggingNoteId === next.draggingNoteId
|
||||
&& prev.draggingGroupPath === next.draggingGroupPath
|
||||
&& prev.creatingGroupParent === next.creatingGroupParent
|
||||
&& prev.isSidebarMode === next.isSidebarMode;
|
||||
}
|
||||
|
||||
const NotesTreePane = memo(function NotesTreePane({ renderRows }: NotesTreePaneProps) {
|
||||
return <>{renderRows()}</>;
|
||||
}, notesTreePanePropsEqual);
|
||||
NotesTreePane.displayName = "NotesTreePane";
|
||||
|
||||
export const getNotesGroupDropAction = (
|
||||
sourceGroup: string | null,
|
||||
targetGroup: string,
|
||||
@@ -432,7 +381,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
openNoteRequestId = null,
|
||||
onOpenNoteIdHandled,
|
||||
}) => {
|
||||
const { t, locale } = useI18n();
|
||||
const { t } = useI18n();
|
||||
const { openExternal } = useApplicationBackend();
|
||||
const isSidebarMode = displayMode === "sidebar";
|
||||
const initialOpenNoteId = openNoteId && notes.some((note) => note.id === openNoteId)
|
||||
@@ -612,12 +561,10 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
flushNoteDraftRef.current();
|
||||
}, []);
|
||||
|
||||
// Hide edge only. The ref is the latest flush, so this does not subscribe
|
||||
// the tree render to draft-writer identity, and it does not remount the editor.
|
||||
useLayoutEffect(() => {
|
||||
if (isActive) return;
|
||||
flushNoteDraftRef.current();
|
||||
}, [isActive]);
|
||||
flushNoteDraft();
|
||||
}, [flushNoteDraft, isActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return;
|
||||
@@ -1539,28 +1486,11 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
// Built only when NotesTreePane's compared props change. isActive / the
|
||||
// hidden class stay on the parent, so a side-panel hide does not reconcile rows.
|
||||
const renderTreeRows = () => {
|
||||
const visibleRootNotes = noteTree.rootNotes.filter(noteMatches);
|
||||
const visibleTree = noteTree.children
|
||||
.map((child) => renderFolderRow(child, 0))
|
||||
.filter(Boolean);
|
||||
const treeIsEmpty = visibleRootNotes.length === 0 && visibleTree.length === 0;
|
||||
return (
|
||||
<>
|
||||
{renderCreateGroupRow(null, 0)}
|
||||
{visibleTree}
|
||||
{visibleRootNotes.map((note) => renderNoteRow(note, 0))}
|
||||
{treeIsEmpty && (
|
||||
<div className="rounded-lg border border-dashed border-border/70 p-4 text-center text-sm text-muted-foreground">
|
||||
<Search size={20} className="mx-auto mb-2 opacity-60" />
|
||||
{query.trim() ? t("notes.search.noResults") : t("notes.empty.group")}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
const visibleRootNotes = noteTree.rootNotes.filter(noteMatches);
|
||||
const visibleTree = noteTree.children
|
||||
.map((child) => renderFolderRow(child, 0))
|
||||
.filter(Boolean);
|
||||
const treeIsEmpty = visibleRootNotes.length === 0 && visibleTree.length === 0;
|
||||
const hasSearch = query.trim().length > 0;
|
||||
const canExpandCollapse = allGroupPaths.length > 0 && !hasSearch;
|
||||
const shouldShowNotesTree = isSidebarMode || sortedNotes.length > 0;
|
||||
@@ -1820,23 +1750,15 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
}}
|
||||
onDrop={(event) => handleGroupDrop(null, event)}
|
||||
>
|
||||
<NotesTreePane
|
||||
notes={sortedNotes}
|
||||
noteGroups={groups}
|
||||
selectedNoteId={selectedNoteId}
|
||||
selectedGroup={selectedGroup}
|
||||
expandedGroups={expandedGroups}
|
||||
editingNoteId={editingNoteId}
|
||||
editingGroupPath={editingGroupPath}
|
||||
query={query}
|
||||
locale={locale}
|
||||
hosts={hosts}
|
||||
draggingNoteId={draggingNoteId}
|
||||
draggingGroupPath={draggingGroupPath}
|
||||
creatingGroupParent={creatingGroupParent}
|
||||
isSidebarMode={isSidebarMode}
|
||||
renderRows={renderTreeRows}
|
||||
/>
|
||||
{renderCreateGroupRow(null, 0)}
|
||||
{visibleTree}
|
||||
{visibleRootNotes.map((note) => renderNoteRow(note, 0))}
|
||||
{treeIsEmpty && (
|
||||
<div className="rounded-lg border border-dashed border-border/70 p-4 text-center text-sm text-muted-foreground">
|
||||
<Search size={20} className="mx-auto mb-2 opacity-60" />
|
||||
{query.trim() ? t("notes.search.noResults") : t("notes.empty.group")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
@@ -2126,7 +2048,6 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Kept mounted while the panel is hidden. Do not key this on isActive. */}
|
||||
{isSidebarMode && overlayNoteView && (
|
||||
<div className="absolute inset-0 z-30 flex min-h-0 flex-col bg-background text-foreground">
|
||||
<div className={cn(
|
||||
|
||||
@@ -8,40 +8,3 @@ test("SFTP settings imports the shared Toggle used by its option rows", () => {
|
||||
assert.match(source, /<Toggle\b/);
|
||||
assert.match(source, /Toggle,\s*\n\s*Select,/);
|
||||
});
|
||||
|
||||
function functionBody(functionName: string): string {
|
||||
const start = source.indexOf(`const ${functionName} = useCallback`);
|
||||
assert.notEqual(start, -1, `${functionName} should exist`);
|
||||
const next = source.indexOf("\n const ", start + 1);
|
||||
return source.slice(start, next === -1 ? source.length : next);
|
||||
}
|
||||
|
||||
test("removing an SFTP file association confirms with the in-app dialog", () => {
|
||||
assert.match(source, /import \{ ConfirmDialog \} from "\.\.\/\.\.\/ui\/confirm-dialog";/);
|
||||
assert.doesNotMatch(source, /window\.confirm|globalThis\.confirm|\bconfirm\(/);
|
||||
assert.match(
|
||||
source,
|
||||
/const \[removeConfirmExt, setRemoveConfirmExt\] = useState<string \| null>\(null\);/,
|
||||
);
|
||||
|
||||
const handleRemove = functionBody("handleRemove");
|
||||
assert.match(handleRemove, /setRemoveConfirmExt\(extension\)/);
|
||||
assert.doesNotMatch(handleRemove, /removeAssociation\(/);
|
||||
|
||||
const handleConfirm = functionBody("handleConfirmRemove");
|
||||
assert.match(handleConfirm, /if \(!removeConfirmExt\) return;/);
|
||||
assert.match(handleConfirm, /removeAssociation\(removeConfirmExt\);\s*setRemoveConfirmExt\(null\);/);
|
||||
|
||||
const dialogStart = source.indexOf("<ConfirmDialog");
|
||||
assert.notEqual(dialogStart, -1, "ConfirmDialog should be rendered");
|
||||
const dialog = source.slice(dialogStart);
|
||||
assert.match(dialog, /open=\{removeConfirmExt !== null\}/);
|
||||
assert.match(dialog, /settings\.sftpFileAssociations\.removeConfirm/);
|
||||
assert.match(dialog, /confirmLabel=\{t\('action\.remove'\)\}/);
|
||||
assert.match(dialog, /\bdestructive\b/);
|
||||
assert.match(dialog, /onConfirm=\{handleConfirmRemove\}/);
|
||||
|
||||
const onOpenChange = dialog.slice(dialog.indexOf("onOpenChange"), dialog.indexOf("onConfirm"));
|
||||
assert.match(onOpenChange, /if \(!open\) setRemoveConfirmExt\(null\);/);
|
||||
assert.doesNotMatch(onOpenChange, /removeAssociation\(/);
|
||||
});
|
||||
@@ -9,7 +9,6 @@ import { useSettingsState } from "../../../application/state/useSettingsState";
|
||||
import type { FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { Button } from "../../ui/button";
|
||||
import { ConfirmDialog } from "../../ui/confirm-dialog";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip";
|
||||
import {
|
||||
SectionHeader,
|
||||
@@ -53,7 +52,6 @@ export default function SettingsFileAssociationsTab() {
|
||||
const defaultOpener = getDefaultOpener();
|
||||
const [editingExtension, setEditingExtension] = useState<string | null>(null);
|
||||
const [isSelectingDefaultApp, setIsSelectingDefaultApp] = useState(false);
|
||||
const [removeConfirmExt, setRemoveConfirmExt] = useState<string | null>(null);
|
||||
|
||||
const defaultOpenerValue = useMemo(() => {
|
||||
if (!defaultOpener) return 'ask';
|
||||
@@ -62,14 +60,10 @@ export default function SettingsFileAssociationsTab() {
|
||||
}, [defaultOpener]);
|
||||
|
||||
const handleRemove = useCallback((extension: string) => {
|
||||
setRemoveConfirmExt(extension);
|
||||
}, []);
|
||||
|
||||
const handleConfirmRemove = useCallback(() => {
|
||||
if (!removeConfirmExt) return;
|
||||
removeAssociation(removeConfirmExt);
|
||||
setRemoveConfirmExt(null);
|
||||
}, [removeAssociation, removeConfirmExt]);
|
||||
if (confirm(t('settings.sftpFileAssociations.removeConfirm', { ext: extension === 'file' ? t('sftp.opener.noExtension') : extension }))) {
|
||||
removeAssociation(extension);
|
||||
}
|
||||
}, [removeAssociation, t]);
|
||||
|
||||
const handleSelectDefaultSystemApp = useCallback(async () => {
|
||||
setIsSelectingDefaultApp(true);
|
||||
@@ -378,16 +372,6 @@ export default function SettingsFileAssociationsTab() {
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={removeConfirmExt !== null}
|
||||
title={removeConfirmExt ? t('settings.sftpFileAssociations.removeConfirm', { ext: removeConfirmExt === 'file' ? t('sftp.opener.noExtension') : removeConfirmExt }) : ''}
|
||||
confirmLabel={t('action.remove')}
|
||||
destructive
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setRemoveConfirmExt(null);
|
||||
}}
|
||||
onConfirm={handleConfirmRemove}
|
||||
/>
|
||||
</SettingsTabContent>
|
||||
);
|
||||
}
|
||||
@@ -77,13 +77,13 @@ export const ClaudeCodeCard: React.FC<{
|
||||
</div>
|
||||
|
||||
{found && (
|
||||
<div className="flex items-center gap-2 text-xs min-w-0">
|
||||
<span className="text-muted-foreground shrink-0">{t('ai.claude.path')}</span>
|
||||
<span className="font-mono text-foreground min-w-0 flex-1 truncate" title={pathInfo.path ?? undefined}>{pathInfo.path}</span>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">{t('ai.claude.path')}</span>
|
||||
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
|
||||
{pathInfo.version && (
|
||||
<>
|
||||
<span className="text-muted-foreground shrink-0">|</span>
|
||||
<span className="text-muted-foreground shrink-0">{pathInfo.version}</span>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="text-muted-foreground">{pathInfo.version}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -102,7 +102,7 @@ export const ClaudeCodeCard: React.FC<{
|
||||
value={customPath}
|
||||
onChange={(e) => onCustomPathChange(e.target.value)}
|
||||
placeholder={t('ai.claude.customPathPlaceholder')}
|
||||
className="flex-1 min-w-0 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!customPath.trim()}>
|
||||
<RefreshCw size={14} className="mr-1.5" />
|
||||
|
||||
@@ -117,13 +117,13 @@ export const CodebuddyCard: React.FC<{
|
||||
</div>
|
||||
|
||||
{found && (
|
||||
<div className="flex items-center gap-2 text-xs min-w-0">
|
||||
<span className="text-muted-foreground shrink-0">{t('ai.codebuddy.path')}</span>
|
||||
<span className="font-mono text-foreground min-w-0 flex-1 truncate" title={pathInfo.path ?? undefined}>{pathInfo.path}</span>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">{t('ai.codebuddy.path')}</span>
|
||||
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
|
||||
{pathInfo.version && (
|
||||
<>
|
||||
<span className="text-muted-foreground shrink-0">|</span>
|
||||
<span className="text-muted-foreground shrink-0">{pathInfo.version}</span>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="text-muted-foreground">{pathInfo.version}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -142,7 +142,7 @@ export const CodebuddyCard: React.FC<{
|
||||
value={customPath}
|
||||
onChange={(e) => onCustomPathChange(e.target.value)}
|
||||
placeholder={t('ai.codebuddy.customPathPlaceholder')}
|
||||
className="flex-1 min-w-0 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!customPath.trim()}>
|
||||
<RefreshCw size={14} className="mr-1.5" />
|
||||
|
||||
@@ -108,13 +108,13 @@ export const CodexConnectionCard: React.FC<{
|
||||
</div>
|
||||
|
||||
{found && (
|
||||
<div className="flex items-center gap-2 text-xs min-w-0">
|
||||
<span className="text-muted-foreground shrink-0">{t('ai.codex.path')}</span>
|
||||
<span className="font-mono text-foreground min-w-0 flex-1 truncate" title={pathInfo.path ?? undefined}>{pathInfo.path}</span>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">{t('ai.codex.path')}</span>
|
||||
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
|
||||
{pathInfo.version && (
|
||||
<>
|
||||
<span className="text-muted-foreground shrink-0">|</span>
|
||||
<span className="text-muted-foreground shrink-0">{pathInfo.version}</span>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="text-muted-foreground">{pathInfo.version}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -133,7 +133,7 @@ export const CodexConnectionCard: React.FC<{
|
||||
value={customPath}
|
||||
onChange={(e) => onCustomPathChange(e.target.value)}
|
||||
placeholder={t('ai.codex.customPathPlaceholder')}
|
||||
className="flex-1 min-w-0 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!customPath.trim()}>
|
||||
<RefreshCw size={14} className="mr-1.5" />
|
||||
|
||||
@@ -61,13 +61,13 @@ export const CopilotCliCard: React.FC<{
|
||||
</div>
|
||||
|
||||
{found && (
|
||||
<div className="flex items-center gap-2 text-xs min-w-0">
|
||||
<span className="text-muted-foreground shrink-0">{t(`${i18nPrefix}.path`)}</span>
|
||||
<span className="font-mono text-foreground min-w-0 flex-1 truncate" title={pathInfo.path ?? undefined}>{pathInfo.path}</span>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">{t(`${i18nPrefix}.path`)}</span>
|
||||
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
|
||||
{pathInfo.version && (
|
||||
<>
|
||||
<span className="text-muted-foreground shrink-0">|</span>
|
||||
<span className="text-muted-foreground shrink-0">{pathInfo.version}</span>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="text-muted-foreground">{pathInfo.version}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -87,7 +87,7 @@ export const CopilotCliCard: React.FC<{
|
||||
value={customPath}
|
||||
onChange={(e) => onCustomPathChange(e.target.value)}
|
||||
placeholder={t(`${i18nPrefix}.customPathPlaceholder`)}
|
||||
className="flex-1 min-w-0 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!allowEmptyCheck && !customPath.trim()}>
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { ClaudeCodeCard } from "./ClaudeCodeCard";
|
||||
import { CodebuddyCard } from "./CodebuddyCard";
|
||||
import { CodexConnectionCard } from "./CodexConnectionCard";
|
||||
import { CopilotCliCard } from "./CopilotCliCard";
|
||||
|
||||
const LONG_PATH = "C:\\Users\\70541\\AppData\\Local\\Microsoft\\WinGet\\Packages\\Anthropic.ClaudeCode_Microsoft.Winget.Source_8wekyb3d8bbwe\\claude.exe";
|
||||
const VERSION = "9.9.9-test";
|
||||
|
||||
function classList(tag: string): Set<string> {
|
||||
const match = tag.match(/class="([^"]*)"/);
|
||||
assert.ok(match, `missing class on ${tag}`);
|
||||
return new Set(match[1].split(/\s+/));
|
||||
}
|
||||
|
||||
function assertClasses(tag: string, expected: string[]) {
|
||||
const have = classList(tag);
|
||||
for (const name of expected) {
|
||||
assert.ok(have.has(name), `expected ${name} on ${tag}`);
|
||||
}
|
||||
}
|
||||
|
||||
function openingTagContaining(markup: string, text: string): string {
|
||||
const idx = markup.indexOf(`>${text}<`);
|
||||
assert.ok(idx >= 0, `missing text node ${text}`);
|
||||
const start = markup.lastIndexOf("<", idx);
|
||||
return markup.slice(start, idx + 1);
|
||||
}
|
||||
|
||||
function pathRowClass(markup: string): string {
|
||||
const idx = markup.indexOf(LONG_PATH);
|
||||
assert.ok(idx >= 0, "missing path text");
|
||||
const matches = [...markup.slice(0, idx).matchAll(/<div\b[^>]*class="([^"]*)"/g)];
|
||||
assert.ok(matches.length > 0, "missing path row");
|
||||
return matches[matches.length - 1][1];
|
||||
}
|
||||
|
||||
function assertLongPathTruncates(markup: string) {
|
||||
const pathTag = openingTagContaining(markup, LONG_PATH);
|
||||
assertClasses(pathTag, ["truncate", "min-w-0", "flex-1"]);
|
||||
assert.match(pathTag, new RegExp(`title="${LONG_PATH.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"`));
|
||||
assert.ok(pathRowClass(markup).split(/\s+/).includes("min-w-0"), "path row missing min-w-0");
|
||||
|
||||
const versionTag = openingTagContaining(markup, VERSION);
|
||||
assertClasses(versionTag, ["shrink-0"]);
|
||||
|
||||
const input = markup.match(/<input\b[^>]*>/);
|
||||
assert.ok(input, "missing custom path input");
|
||||
assertClasses(input[0], ["flex-1", "min-w-0"]);
|
||||
assert.match(input[0], new RegExp(`value="${LONG_PATH.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"`));
|
||||
}
|
||||
|
||||
test("Claude Code card truncates a long CLI path inside the card", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<ClaudeCodeCard
|
||||
pathInfo={{ path: LONG_PATH, version: VERSION, available: true }}
|
||||
isResolvingPath={false}
|
||||
customPath={LONG_PATH}
|
||||
onCustomPathChange={() => {}}
|
||||
onRecheckPath={() => {}}
|
||||
onResetPath={() => {}}
|
||||
configDir=""
|
||||
onConfigDirChange={() => {}}
|
||||
settingsPath=""
|
||||
onSettingsPathChange={() => {}}
|
||||
envText=""
|
||||
onEnvTextChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
assertLongPathTruncates(markup);
|
||||
});
|
||||
|
||||
test("Codex card truncates a long CLI path inside the card", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<CodexConnectionCard
|
||||
pathInfo={{ path: LONG_PATH, version: VERSION, available: true }}
|
||||
isResolvingPath={false}
|
||||
customPath={LONG_PATH}
|
||||
onCustomPathChange={() => {}}
|
||||
onRecheckPath={() => {}}
|
||||
onResetPath={() => {}}
|
||||
integration={null}
|
||||
loginSession={null}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
onRefresh={() => {}}
|
||||
onConnect={() => {}}
|
||||
onCancel={() => {}}
|
||||
onOpenUrl={() => {}}
|
||||
onLogout={() => {}}
|
||||
appServerRuntime="sdk"
|
||||
appServerStatus={null}
|
||||
onAppServerRuntimeChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
assertLongPathTruncates(markup);
|
||||
});
|
||||
|
||||
test("Codebuddy card truncates a long CLI path inside the card", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<CodebuddyCard
|
||||
pathInfo={{ path: LONG_PATH, version: VERSION, available: true }}
|
||||
isResolvingPath={false}
|
||||
customPath={LONG_PATH}
|
||||
onCustomPathChange={() => {}}
|
||||
onRecheckPath={() => {}}
|
||||
onResetPath={() => {}}
|
||||
internetEnv=""
|
||||
onInternetEnvChange={() => {}}
|
||||
envText=""
|
||||
onEnvTextChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
assertLongPathTruncates(markup);
|
||||
});
|
||||
|
||||
test("Copilot CLI card truncates a long CLI path inside the card", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<CopilotCliCard
|
||||
pathInfo={{ path: LONG_PATH, version: VERSION, available: true }}
|
||||
isResolvingPath={false}
|
||||
customPath={LONG_PATH}
|
||||
onCustomPathChange={() => {}}
|
||||
onRecheckPath={() => {}}
|
||||
onResetPath={() => {}}
|
||||
/>,
|
||||
);
|
||||
assertLongPathTruncates(markup);
|
||||
});
|
||||
@@ -33,7 +33,6 @@ 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;
|
||||
@@ -55,7 +54,6 @@ interface SftpTransferItemProps {
|
||||
canToggleChildren?: boolean;
|
||||
isExpanded?: boolean;
|
||||
visibleChildCount?: number;
|
||||
childWarningCount?: number;
|
||||
onToggleChildren?: () => void;
|
||||
onSetNameColumnWidth?: (width: number) => void;
|
||||
childNameColumnMinWidth?: number;
|
||||
@@ -126,7 +124,6 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
|
||||
canToggleChildren = false,
|
||||
isExpanded = false,
|
||||
visibleChildCount: _visibleChildCount = 0,
|
||||
childWarningCount = 0,
|
||||
onToggleChildren,
|
||||
onSetNameColumnWidth,
|
||||
childNameColumnMinWidth = 160,
|
||||
@@ -291,8 +288,7 @@ 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 showWarning = task.status === 'completed' && !!task.warning;
|
||||
const hasFooterContent = showTransferSizeCalculation || showFailedError || showPauseUnavailable || showWarning || childWarningCount > 0;
|
||||
const hasFooterContent = showTransferSizeCalculation || showFailedError || showPauseUnavailable;
|
||||
const retryActionLabel = t('sftp.transfers.retryAction');
|
||||
const cancelActionLabel = t('common.cancel');
|
||||
const pauseActionLabel = t('sftp.transferCenter.pause');
|
||||
@@ -484,7 +480,7 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
|
||||
<TooltipContent side="top">{resizeNameColumnLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="min-w-0 overflow-hidden">
|
||||
{showWarning ? <SftpTransferWarning warning={task.warning} compact /> : childProgressBar}
|
||||
{childProgressBar}
|
||||
</div>
|
||||
<div className="flex h-full min-w-0 items-center justify-end gap-0.5 pl-1">
|
||||
{actionButtons}
|
||||
@@ -613,10 +609,6 @@ 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>
|
||||
@@ -639,7 +631,6 @@ 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;
|
||||
@@ -656,7 +647,6 @@ 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;
|
||||
|
||||
@@ -423,7 +423,6 @@ 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={() => {
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
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,21 +58,6 @@ 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,7 +7,6 @@ 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;
|
||||
@@ -29,11 +28,6 @@ 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;
|
||||
|
||||
@@ -84,7 +84,7 @@ test("a session holding inline images degrades to soft-hide instead of full hibe
|
||||
const gateIndex = hibernateBody.indexOf("if (runtimeHasInlineImages()) {");
|
||||
const softHideIndex = hibernateBody.indexOf("hideRuntimeOnly();", gateIndex);
|
||||
const keepCountIndex = hibernateBody.indexOf("resolveHibernateKeepRendererCount(terminalSettings)");
|
||||
const evictionIndex = hibernateBody.indexOf("retainLeavingTabAfterEviction(");
|
||||
const evictionIndex = hibernateBody.indexOf("terminalHiddenRendererStore.pickEvictionCandidate(");
|
||||
const fullHibernateIndex = hibernateBody.indexOf("void fullHibernateRuntime()");
|
||||
|
||||
assert.notEqual(gateIndex, -1, "hibernate must check for inline images");
|
||||
|
||||
@@ -5,7 +5,6 @@ import { createRequire } from "node:module";
|
||||
import type { SerializeAddon as SerializeAddonType } from "@xterm/addon-serialize";
|
||||
import type { Terminal as XTermType } from "@xterm/xterm";
|
||||
|
||||
import { DEFAULT_KEYWORD_HIGHLIGHT_RULES } from "../../domain/models/terminal.ts";
|
||||
import { writeTerminalDataWithLineTimestamps } from "./runtime/terminalLineTimestamps.ts";
|
||||
import {
|
||||
noteTerminalOutputPressureData,
|
||||
@@ -82,76 +81,6 @@ test("new output is colored on the written cells without decorations", async ()
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("keyword highlight skips cells whose PTY foreground is already colored", async () => {
|
||||
// Starship directory segments set a truecolor foreground (#3940). Those
|
||||
// cells must keep the PTY color; only default-foreground matches are recolored.
|
||||
const unixPath = DEFAULT_KEYWORD_HIGHLIGHT_RULES.find((entry) => entry.id === "unix-path");
|
||||
assert.ok(unixPath);
|
||||
const keywordRgb = Number.parseInt(unixPath.color.slice(1), 16);
|
||||
const cream = 0xfbf1c7;
|
||||
const yellow = 0xfabd2f;
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules([unixPath], true);
|
||||
await write(
|
||||
term,
|
||||
"\x1b[38;2;251;241;199m\x1b[48;2;250;189;47m/opt\x1b[0m"
|
||||
+ " \x1b[38;5;220m/mnt\x1b[0m"
|
||||
+ " \x1b[31m/bin\x1b[0m"
|
||||
+ " /var",
|
||||
);
|
||||
|
||||
const tokenCells = (text: string) => {
|
||||
const line = term.buffer.active.getLine(0);
|
||||
const index = (line?.translateToString(true) ?? "").indexOf(text);
|
||||
assert.ok(index >= 0, text);
|
||||
return Array.from({ length: text.length }, (_, offset) => {
|
||||
const cell = line?.getCell(index + offset);
|
||||
assert.ok(cell, `${text}[${offset}]`);
|
||||
return cell;
|
||||
});
|
||||
};
|
||||
|
||||
for (const cell of tokenCells("/opt")) {
|
||||
assert.equal(cell.isFgRGB(), true);
|
||||
assert.equal(cell.getFgColorMode(), 0x3000000);
|
||||
assert.equal(cell.getFgColor(), cream);
|
||||
assert.equal(cell.isBgRGB(), true);
|
||||
assert.equal(cell.getBgColor(), yellow);
|
||||
}
|
||||
for (const cell of tokenCells("/mnt")) {
|
||||
assert.equal(cell.isFgPalette(), true);
|
||||
assert.equal(cell.getFgColorMode(), 0x2000000);
|
||||
assert.equal(cell.getFgColor(), 220);
|
||||
}
|
||||
for (const cell of tokenCells("/bin")) {
|
||||
assert.equal(cell.isFgPalette(), true);
|
||||
assert.equal(cell.getFgColorMode(), 0x1000000);
|
||||
assert.equal(cell.getFgColor(), 1);
|
||||
}
|
||||
for (const cell of tokenCells("/var")) {
|
||||
assert.equal(cell.isFgRGB(), true);
|
||||
assert.equal(cell.getFgColor(), keywordRgb);
|
||||
}
|
||||
|
||||
highlighter.setRules([{ ...unixPath, color: "#60A5FA" }], true);
|
||||
await highlighter.whenSettled();
|
||||
assert.equal(tokenCells("/opt")[0]?.getFgColor(), cream);
|
||||
assert.equal(tokenCells("/mnt")[0]?.getFgColor(), 220);
|
||||
assert.equal(tokenCells("/bin")[0]?.getFgColor(), 1);
|
||||
assert.equal(tokenCells("/var")[0]?.getFgColor(), BLUE);
|
||||
|
||||
highlighter.setRules([{ ...unixPath, color: "#60A5FA" }], false);
|
||||
await highlighter.whenSettled();
|
||||
assert.equal(tokenCells("/opt")[0]?.getFgColor(), cream);
|
||||
assert.equal(tokenCells("/mnt")[0]?.getFgColor(), 220);
|
||||
assert.equal(tokenCells("/bin")[0]?.getFgColor(), 1);
|
||||
assert.equal(tokenCells("/var")[0]?.getFgColor(), -1);
|
||||
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("ordinary Enter output does not revisit existing history", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
|
||||
@@ -67,7 +67,6 @@ const CELL_INDICES = 3;
|
||||
const CELL_CONTENT = 0;
|
||||
const CELL_FG = 1;
|
||||
const STYLE_MASK = 0xfc000000;
|
||||
const CM_MASK = 0x3000000;
|
||||
const CM_RGB = 0x3000000;
|
||||
const MAX_PLUGIN_HIGHLIGHT_SCAN_CHARS = 4_096;
|
||||
const MAX_PLUGIN_HIGHLIGHT_MATCHES_PER_WRITE = 256;
|
||||
@@ -1062,13 +1061,7 @@ export class KeywordHighlighter implements IDisposable {
|
||||
const dataIndex = x * CELL_INDICES;
|
||||
const content = internal._data[dataIndex + CELL_CONTENT];
|
||||
const currentFg = internal._data[dataIndex + CELL_FG];
|
||||
// A cell already carrying a PTY palette/truecolor foreground (starship
|
||||
// directory segments, ANSI logs) must keep that color. Keyword RGB is
|
||||
// only for the default foreground.
|
||||
const maskedForContent = originals.mask[x] === 1 && originals.content[x] === content;
|
||||
const ptyFg = maskedForContent ? originals.fg[x] : currentFg;
|
||||
if ((ptyFg & CM_MASK) !== 0) continue;
|
||||
if (!maskedForContent) {
|
||||
if (!originals.mask[x] || originals.content[x] !== content) {
|
||||
originals.fg[x] = currentFg;
|
||||
originals.content[x] = content;
|
||||
originals.mask[x] = 1;
|
||||
|
||||
@@ -348,62 +348,6 @@ test("startSerial does not arm the fallback when auto-login is cancelled before
|
||||
assert.deepEqual(writtenCommands, []);
|
||||
});
|
||||
|
||||
test("restored serial attach ignores a startup command hydrated while the port open is pending", async () => {
|
||||
const writtenCommands: string[] = [];
|
||||
let autoLoginComplete: ((evt: { sessionId: string }) => void) | null = null;
|
||||
let releaseOpen: (() => void) | undefined;
|
||||
const openPending = new Promise<void>((resolve) => {
|
||||
releaseOpen = resolve;
|
||||
});
|
||||
const bootHost = {
|
||||
id: "serial-1",
|
||||
hostname: "/dev/ttyUSB0",
|
||||
protocol: "serial" as const,
|
||||
username: "admin",
|
||||
password: "secret",
|
||||
};
|
||||
const hostRef = { current: bootHost };
|
||||
|
||||
const backend = buildBackend({
|
||||
startSerialSession: async () => {
|
||||
await openPending;
|
||||
return "serial-session";
|
||||
},
|
||||
onTelnetAutoLoginComplete: (
|
||||
_sessionId: string,
|
||||
cb: (evt: { sessionId: string }) => void,
|
||||
) => {
|
||||
autoLoginComplete = cb;
|
||||
return noop;
|
||||
},
|
||||
onTelnetAutoLoginCancelled: () => noop,
|
||||
writeToSession: (_sessionId: string, data: string) => {
|
||||
writtenCommands.push(data);
|
||||
},
|
||||
});
|
||||
|
||||
const ctx = buildCtx(backend, {
|
||||
host: bootHost,
|
||||
hostRef,
|
||||
terminalSettings: { startupCommandDelayMs: 0 },
|
||||
});
|
||||
|
||||
const started = createTerminalSessionStarters(ctx as never).startSerial(term as never);
|
||||
// Vault hydration can replace the fallback host after preflight has already
|
||||
// decided there is no command to hold for auto-login.
|
||||
hostRef.current = { ...bootHost, startupCommand: "show version" };
|
||||
releaseOpen?.();
|
||||
await started;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
autoLoginComplete?.({ sessionId: "session-1" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(autoLoginComplete, null);
|
||||
assert.equal(ctx.hasRunStartupCommandRef.current, false);
|
||||
assert.deepEqual(writtenCommands, []);
|
||||
});
|
||||
|
||||
for (const runMode of ['paste', 'lineDelay', 'noAutoRun']) {
|
||||
test(`serial reconnect discards the previous delayed startup command (${runMode})`, async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
@@ -473,19 +417,3 @@ 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')));
|
||||
});
|
||||
@@ -3653,207 +3653,6 @@ test("restored local reconnect runs the host startup command while automatic ret
|
||||
assert.deepEqual(sessionWrites, []);
|
||||
});
|
||||
|
||||
test("restored SSH attach runs the live host startup command when the boot snapshot predates vault hydration", async () => {
|
||||
const sessionWrites: Array<{ id: string; data: string; automated?: boolean }> = [];
|
||||
// Session restore mounts the pane before the vault host (and group defaults)
|
||||
// replace the fallback snapshot. The dial can still succeed from that
|
||||
// snapshot; the startup command is only known on the effective host by the
|
||||
// time the shell is attached.
|
||||
const bootHost = {
|
||||
id: "host-1",
|
||||
label: "Target",
|
||||
hostname: "target.example.test",
|
||||
username: "alice",
|
||||
port: 22,
|
||||
protocol: "ssh" as const,
|
||||
password: "secret",
|
||||
};
|
||||
const effectiveHost = {
|
||||
...bootHost,
|
||||
startupCommand: "echo restored-startup",
|
||||
startupCommandRunMode: "paste" as const,
|
||||
};
|
||||
const hostRef = { current: bootHost };
|
||||
let releaseDial: (() => void) | undefined;
|
||||
const dialStarted = new Promise<void>((resolve) => {
|
||||
releaseDial = resolve;
|
||||
});
|
||||
const terminalBackend = {
|
||||
backendAvailable: () => true,
|
||||
startSSHSession: async () => {
|
||||
await dialStarted;
|
||||
return "ssh-session";
|
||||
},
|
||||
onSessionData: () => noop,
|
||||
onSessionExit: () => noop,
|
||||
onChainProgress: () => noop,
|
||||
writeToSession: (id: string, data: string, options?: { automated?: boolean }) => {
|
||||
sessionWrites.push({ id, data, automated: options?.automated });
|
||||
},
|
||||
resizeSession: noop,
|
||||
closeSession: noop,
|
||||
};
|
||||
const ctx = createStarterContext({
|
||||
host: bootHost,
|
||||
hostRef,
|
||||
terminalSettings: { startupCommandDelayMs: 0 },
|
||||
terminalBackend,
|
||||
startupCommand: undefined,
|
||||
suppressHostStartupCommandRef: {
|
||||
current: shouldSuppressHostStartupCommandOnReconnect("restored"),
|
||||
},
|
||||
promptLineBreakStateRef: undefined,
|
||||
});
|
||||
|
||||
const started = createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
||||
hostRef.current = effectiveHost;
|
||||
releaseDial?.();
|
||||
await started;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(sessionWrites, [
|
||||
{ id: "ssh-session", data: "echo restored-startup\r", automated: true },
|
||||
]);
|
||||
|
||||
sessionWrites.length = 0;
|
||||
hostRef.current = effectiveHost;
|
||||
const automaticCtx = createStarterContext({
|
||||
...ctx,
|
||||
sessionRef: { current: null },
|
||||
hasRunStartupCommandRef: { current: false },
|
||||
suppressHostStartupCommandRef: {
|
||||
current: shouldSuppressHostStartupCommandOnReconnect("automatic"),
|
||||
},
|
||||
});
|
||||
await createTerminalSessionStarters(automaticCtx as never).startSSH(createTermStub() as never);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(sessionWrites, []);
|
||||
});
|
||||
|
||||
test("restored SSH attach ignores a live startup command for a different dial target", async () => {
|
||||
const bootHost = {
|
||||
id: "host-1",
|
||||
label: "Target",
|
||||
hostname: "target.example.test",
|
||||
username: "alice",
|
||||
port: 22,
|
||||
protocol: "ssh" as const,
|
||||
password: "secret",
|
||||
};
|
||||
const mismatches = [
|
||||
{ label: "hostname", patch: { hostname: "moved.example.test" } },
|
||||
{ label: "port", patch: { port: 2222 } },
|
||||
{ label: "username", patch: { username: "bob" } },
|
||||
{ label: "protocol", patch: { protocol: "telnet" as const } },
|
||||
];
|
||||
|
||||
for (const { label, patch } of mismatches) {
|
||||
const sessionWrites: Array<{ id: string; data: string; automated?: boolean }> = [];
|
||||
const hostRef = { current: bootHost };
|
||||
let releaseDial: (() => void) | undefined;
|
||||
const dialStarted = new Promise<void>((resolve) => {
|
||||
releaseDial = resolve;
|
||||
});
|
||||
const terminalBackend = {
|
||||
backendAvailable: () => true,
|
||||
startSSHSession: async () => {
|
||||
await dialStarted;
|
||||
return "ssh-session";
|
||||
},
|
||||
onSessionData: () => noop,
|
||||
onSessionExit: () => noop,
|
||||
onChainProgress: () => noop,
|
||||
writeToSession: (id: string, data: string, options?: { automated?: boolean }) => {
|
||||
sessionWrites.push({ id, data, automated: options?.automated });
|
||||
},
|
||||
resizeSession: noop,
|
||||
closeSession: noop,
|
||||
};
|
||||
const ctx = createStarterContext({
|
||||
host: bootHost,
|
||||
hostRef,
|
||||
terminalSettings: { startupCommandDelayMs: 0 },
|
||||
terminalBackend,
|
||||
startupCommand: undefined,
|
||||
suppressHostStartupCommandRef: {
|
||||
current: shouldSuppressHostStartupCommandOnReconnect("restored"),
|
||||
},
|
||||
promptLineBreakStateRef: undefined,
|
||||
});
|
||||
|
||||
const started = createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
||||
hostRef.current = {
|
||||
...bootHost,
|
||||
...patch,
|
||||
startupCommand: "echo moved-startup",
|
||||
};
|
||||
releaseDial?.();
|
||||
await started;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(ctx.hasRunStartupCommandRef.current, false, label);
|
||||
assert.deepEqual(sessionWrites, [], label);
|
||||
}
|
||||
});
|
||||
|
||||
test("restored SSH attach still runs a hydrated startup command when only the default port is materialized", async () => {
|
||||
const sessionWrites: Array<{ id: string; data: string; automated?: boolean }> = [];
|
||||
const bootHost = {
|
||||
id: "host-1",
|
||||
label: "Target",
|
||||
hostname: "target.example.test",
|
||||
username: "alice",
|
||||
port: 22,
|
||||
protocol: "ssh" as const,
|
||||
password: "secret",
|
||||
};
|
||||
const hostRef = { current: bootHost };
|
||||
let releaseDial: (() => void) | undefined;
|
||||
const dialStarted = new Promise<void>((resolve) => {
|
||||
releaseDial = resolve;
|
||||
});
|
||||
const terminalBackend = {
|
||||
backendAvailable: () => true,
|
||||
startSSHSession: async () => {
|
||||
await dialStarted;
|
||||
return "ssh-session";
|
||||
},
|
||||
onSessionData: () => noop,
|
||||
onSessionExit: () => noop,
|
||||
onChainProgress: () => noop,
|
||||
writeToSession: (id: string, data: string, options?: { automated?: boolean }) => {
|
||||
sessionWrites.push({ id, data, automated: options?.automated });
|
||||
},
|
||||
resizeSession: noop,
|
||||
closeSession: noop,
|
||||
};
|
||||
const ctx = createStarterContext({
|
||||
host: bootHost,
|
||||
hostRef,
|
||||
terminalSettings: { startupCommandDelayMs: 0 },
|
||||
terminalBackend,
|
||||
startupCommand: undefined,
|
||||
suppressHostStartupCommandRef: {
|
||||
current: shouldSuppressHostStartupCommandOnReconnect("restored"),
|
||||
},
|
||||
promptLineBreakStateRef: undefined,
|
||||
});
|
||||
|
||||
const started = createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
||||
hostRef.current = {
|
||||
...bootHost,
|
||||
port: undefined,
|
||||
startupCommand: "echo restored-startup",
|
||||
};
|
||||
releaseDial?.();
|
||||
await started;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(sessionWrites, [
|
||||
{ id: "ssh-session", data: "echo restored-startup\r", automated: true },
|
||||
]);
|
||||
});
|
||||
|
||||
for (const { name, hostDirectory, globalDirectory, expectedDirectory } of [
|
||||
{ name: "uses saved directory before global default", hostDirectory: "/Users/alice/project", globalDirectory: "/Users/alice/default", expectedDirectory: "/Users/alice/project" },
|
||||
{ name: "inherits global directory when unset", hostDirectory: undefined, globalDirectory: "/Users/alice/default", expectedDirectory: "/Users/alice/default" },
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
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";
|
||||
@@ -2173,9 +2172,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
autoLoginAttached = true;
|
||||
ctx.updateStatus("connected");
|
||||
ctx.setProgressValue(100);
|
||||
writeTerminalLine(ctx, term, isWindowsNamedPipePath(ctx.serialConfig.path)
|
||||
? `[Connected to ${ctx.serialConfig.path}]`
|
||||
: `[Connected to ${ctx.serialConfig.path} at ${ctx.serialConfig.baudRate} baud]`);
|
||||
writeTerminalLine(ctx, term, `[Connected to ${ctx.serialConfig.path} at ${ctx.serialConfig.baudRate} baud]`);
|
||||
|
||||
if (waitsForAutoLogin) {
|
||||
if (autoLoginCompletedBeforeAttach) {
|
||||
|
||||
@@ -7,26 +7,8 @@ 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,18 +28,6 @@ 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 {
|
||||
|
||||
@@ -3,56 +3,6 @@ import { normalizeLineEndings, wrapBracketedPaste } from "../../../lib/utils";
|
||||
import { markPromptLineBreakCommandPending } from "./promptLineBreak";
|
||||
import type { TerminalSessionStartersContext } from "./createTerminalSessionStarters.types";
|
||||
|
||||
/**
|
||||
* Host startup is scheduled only after the shell attaches. Restored SSH panes
|
||||
* boot from the session fallback first; vault hydration (including group
|
||||
* defaults) replaces `hostRef` while that dial is still in flight. `startSSH`
|
||||
* keeps dialing the boot snapshot (`ctx.host`). The startup command may come
|
||||
* from the live host only when that object is still this dial: same id,
|
||||
* hostname, username, and dial port (`port || 22`), and the same protocol
|
||||
* when the live host sets one. A missing protocol is a legacy SSH host.
|
||||
* A saved host edited onto another endpoint since the workspace was saved
|
||||
* keeps the snapshot, so its command is not typed into the old session.
|
||||
*
|
||||
* Serial preflight uses this same resolver to decide whether to wait for
|
||||
* auto-login, then schedules again after the port opens. Non-SSH transports
|
||||
* stay on the snapshot, so a command that appears on `hostRef` during the
|
||||
* open is not typed into a saved username/password exchange.
|
||||
* A ref that only carries a write-path toggle (no id) is not a host snapshot.
|
||||
* A different host id is a later pane, not this dial, so keep the snapshot.
|
||||
*/
|
||||
const prefersLiveStartupHost = (protocol: string | undefined): boolean =>
|
||||
protocol === "ssh" || protocol === undefined;
|
||||
|
||||
const sshDialPort = (port: number | undefined): number => port || 22;
|
||||
|
||||
const sameSshDialTarget = (
|
||||
dial: TerminalSessionStartersContext["host"],
|
||||
live: TerminalSessionStartersContext["host"],
|
||||
): boolean => {
|
||||
if (live.hostname !== dial.hostname) return false;
|
||||
if ((live.username ?? "") !== (dial.username ?? "")) return false;
|
||||
if (sshDialPort(live.port) !== sshDialPort(dial.port)) return false;
|
||||
// A live protocol, when set, must still be the protocol this dial used.
|
||||
if (live.protocol !== undefined && live.protocol !== (dial.protocol || "ssh")) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const resolveStartupHost = (
|
||||
ctx: TerminalSessionStartersContext,
|
||||
): TerminalSessionStartersContext["host"] => {
|
||||
const live = ctx.hostRef?.current;
|
||||
if (
|
||||
prefersLiveStartupHost(ctx.host.protocol)
|
||||
&& live
|
||||
&& live.id === ctx.host.id
|
||||
&& sameSshDialTarget(ctx.host, live)
|
||||
) {
|
||||
return live;
|
||||
}
|
||||
return ctx.host;
|
||||
};
|
||||
|
||||
const STARTUP_COMMAND_DEFAULT_DELAY_MS = 600;
|
||||
const STARTUP_COMMAND_MAX_DELAY_MS = 10000;
|
||||
|
||||
@@ -88,7 +38,7 @@ export const resolveStartupCommand = (
|
||||
ctx: TerminalSessionStartersContext,
|
||||
options?: { consumeSuppressHostStartupCommand?: boolean },
|
||||
): string | undefined => {
|
||||
const command = ctx.startupCommand || (ctx.suppressHostStartupCommandRef?.current ? undefined : resolveStartupHost(ctx).startupCommand);
|
||||
const command = ctx.startupCommand || (ctx.suppressHostStartupCommandRef?.current ? undefined : ctx.host.startupCommand);
|
||||
if (options?.consumeSuppressHostStartupCommand && ctx.suppressHostStartupCommandRef) {
|
||||
ctx.suppressHostStartupCommandRef.current = false;
|
||||
}
|
||||
@@ -146,7 +96,7 @@ export const scheduleStartupCommand = (
|
||||
|
||||
const runMode = ctx.startupCommand
|
||||
? (ctx.multiLineRunMode ?? "paste")
|
||||
: (resolveStartupHost(ctx).startupCommandRunMode ?? "paste");
|
||||
: (ctx.host.startupCommandRunMode ?? "paste");
|
||||
if (runMode === "paste") {
|
||||
timeoutId = setTimeout(() => {
|
||||
if (cancelled) return;
|
||||
|
||||
@@ -211,7 +211,7 @@ test("ended soft-hidden and alternate-screen sessions can fully hibernate", () =
|
||||
);
|
||||
assert.match(
|
||||
hibernateBody,
|
||||
/if \(softHiddenRef\.current\) \{\s*const retainedKeepCount = statusRef\.current === "connected"\s*\? resolveHibernateKeepRendererCount\(terminalSettingsRef\.current\)\s*: 0;\s*if \(statusRef\.current === "disconnected" \|\| !\(retainedKeepCount > 0\)\) \{\s*upgradeSoftHiddenRuntimeToHibernate\(\);\s*\}\s*return;/,
|
||||
/if \(softHiddenRef\.current\) \{\s*if \(statusRef\.current === "disconnected"\) \{\s*upgradeSoftHiddenRuntimeToHibernate\(\);\s*\}\s*return;/,
|
||||
);
|
||||
assert.match(
|
||||
alternateSkipBody,
|
||||
@@ -227,7 +227,7 @@ test("a cancelled soft-hidden upgrade resumes its renderer", () => {
|
||||
|
||||
assert.match(
|
||||
hibernateBody,
|
||||
/if \(softHiddenRef\.current\) \{\s*const retainedKeepCount = statusRef\.current === "connected"\s*\? resolveHibernateKeepRendererCount\(terminalSettingsRef\.current\)\s*: 0;\s*if \(statusRef\.current === "disconnected" \|\| !\(retainedKeepCount > 0\)\) \{\s*upgradeSoftHiddenRuntimeToHibernate\(\);/,
|
||||
/if \(softHiddenRef\.current\) \{\s*if \(statusRef\.current === "disconnected"\) \{\s*upgradeSoftHiddenRuntimeToHibernate\(\);/,
|
||||
);
|
||||
const wakeIndex = upgradeBody.indexOf("wakeSoftHiddenRuntimeRef.current?.()");
|
||||
const upgradeIndex = upgradeBody.indexOf("fullHibernateRuntime().then(");
|
||||
@@ -239,16 +239,6 @@ test("a cancelled soft-hidden upgrade resumes its renderer", () => {
|
||||
assert.match(helperBody, /ensureWebglRenderer\(\)/);
|
||||
assert.match(helperBody, /clearTextureAtlas\(\)/);
|
||||
assert.match(helperBody, /safeFitRef\.current\(\{ force: true \}\)/);
|
||||
const restoreGuard = "if (pendingEviction && !isVisibleRef.current && hibernateEnabledRef.current)";
|
||||
assert.equal(
|
||||
upgradeBody.split(restoreGuard).length - 1,
|
||||
2,
|
||||
"both the failed hibernate and the rejection path must skip soft-hide when the tab is visible or hibernate is off",
|
||||
);
|
||||
assert.ok(
|
||||
upgradeBody.indexOf(restoreGuard) > upgradeIndex,
|
||||
"the visibility guard belongs on the settled upgrade, after the renderer was resumed",
|
||||
);
|
||||
});
|
||||
|
||||
test("snapshot and handoff paths reject output that misses the settle deadline", () => {
|
||||
@@ -292,34 +282,3 @@ test("snapshot and handoff paths reject output that misses the settle deadline",
|
||||
/const flushed = await flushPendingTerminalWritesBeforeHibernate\(snapshotTerm\);\s*if \(!flushed\) \{\s*throw new Error\("Terminal output did not settle before closing the attached display"\);\s*\}/,
|
||||
);
|
||||
});
|
||||
|
||||
test("a full keep count soft-hides the leaving session instead of snapshotting it", () => {
|
||||
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
|
||||
const body = readFunctionBody(source, "const hibernateRuntime = useCallback(() =>");
|
||||
const imageGate = body.indexOf("if (runtimeHasInlineImages()) {");
|
||||
const alternateGate = body.indexOf("shouldSkipHibernateForActiveAlternateScreen(termRef.current)");
|
||||
const decision = body.indexOf("decideHiddenTabKeepCountAction(");
|
||||
const softHide = body.indexOf('if (keepDecision.action === "soft-hide")');
|
||||
const hide = body.indexOf("hideRuntimeOnly();", softHide);
|
||||
const full = body.indexOf("void fullHibernateRuntime()");
|
||||
|
||||
assert.ok(alternateGate !== -1 && alternateGate < imageGate);
|
||||
assert.ok(imageGate !== -1 && imageGate < decision);
|
||||
assert.ok(decision < softHide && softHide < hide && hide < full);
|
||||
const awaited = body.indexOf("retainLeavingTabAfterEviction({");
|
||||
assert.ok(awaited > decision, "a full keep set retains the leaving tab from the application eviction walk");
|
||||
assert.match(body, /getSoftHiddenOccupancy\(\)/);
|
||||
assert.match(body, /stillWantsRetention:/);
|
||||
assert.match(body, /softHide: \(\) => hideRuntimeOnly\(\)/);
|
||||
assert.match(
|
||||
body,
|
||||
/resolveKeepCount: \(\) => \(\s*statusRef\.current === "connected"\s*\? resolveHibernateKeepRendererCount\(terminalSettingsRef\.current\)\s*: 0\s*\)/,
|
||||
"retention after eviction must read the live keep count, not the captured one",
|
||||
);
|
||||
assert.doesNotMatch(body, /resolveHiddenTabEviction/);
|
||||
assert.doesNotMatch(body, /requestEviction\(keepDecision/);
|
||||
assert.match(
|
||||
body,
|
||||
/if \(softHiddenRef\.current\) \{\s*const retainedKeepCount = statusRef\.current === "connected"\s*\? resolveHibernateKeepRendererCount\(terminalSettingsRef\.current\)\s*: 0;\s*if \(statusRef\.current === "disconnected" \|\| !\(retainedKeepCount > 0\)\) \{\s*upgradeSoftHiddenRuntimeToHibernate\(\);\s*\}\s*return;\s*\}/,
|
||||
);
|
||||
});
|
||||
@@ -63,7 +63,6 @@ import {
|
||||
isTerminalCloseGenerationCurrent,
|
||||
resolveConnectionLogCapturePayload,
|
||||
scheduleTerminalCloseTeardown,
|
||||
scheduleTerminalCloseForGeneration,
|
||||
serializeTerminalCloseFallback,
|
||||
} from './runtime/terminalCloseCapture';
|
||||
import {
|
||||
@@ -931,28 +930,23 @@ 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. Cancel the pending backend boot after giving an immediate
|
||||
// remount a chance to replace this generation, instead of letting 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
|
||||
// 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) {
|
||||
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) {
|
||||
try {
|
||||
const closeResult = terminalBackend.closeSession(
|
||||
sessionRef.current ?? sessionId,
|
||||
{ bootEpoch: bootEpochRef ? bootEpochRef.current : undefined },
|
||||
);
|
||||
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);
|
||||
}
|
||||
}
|
||||
disposeOwnedRuntime();
|
||||
return;
|
||||
|
||||
@@ -20,8 +20,6 @@ type UseTerminalHibernateEffectOptions = {
|
||||
isSearchOpen: boolean;
|
||||
hibernateEnabled: boolean;
|
||||
hibernateDelayMs: number;
|
||||
/** Current retained-renderer limit. Zero rechecks soft-hidden tabs immediately. */
|
||||
hibernateKeepRendererCount?: number;
|
||||
fileTransferActive: boolean;
|
||||
hibernatedRef: React.MutableRefObject<boolean>;
|
||||
softHiddenRef: React.MutableRefObject<boolean>;
|
||||
@@ -51,7 +49,6 @@ export function useTerminalHibernateEffect({
|
||||
isSearchOpen,
|
||||
hibernateEnabled,
|
||||
hibernateDelayMs,
|
||||
hibernateKeepRendererCount,
|
||||
fileTransferActive,
|
||||
hibernatedRef,
|
||||
softHiddenRef,
|
||||
@@ -213,27 +210,4 @@ export function useTerminalHibernateEffect({
|
||||
softHiddenRef,
|
||||
status,
|
||||
]);
|
||||
|
||||
// Separate from the visibility timer so a keep-count edit does not clear an
|
||||
// in-flight leave. Already soft-hidden tabs have no timer left to notice a
|
||||
// later zero; this one re-enters hibernate without stacking a second snapshot
|
||||
// when StrictMode runs the effect twice.
|
||||
useEffect(() => {
|
||||
if (!hibernateEnabled || hibernateKeepRendererCount !== 0) return;
|
||||
if (!softHiddenRef.current) return;
|
||||
if (resolvePaneVisible(sessionId, isVisibleRef.current)) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
if (resolvePaneVisible(sessionId, isVisibleRef.current)) return;
|
||||
if (!softHiddenRef.current) return;
|
||||
onHibernateRef.current();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [
|
||||
hibernateEnabled,
|
||||
hibernateKeepRendererCount,
|
||||
isVisible,
|
||||
isVisibleRef,
|
||||
sessionId,
|
||||
softHiddenRef,
|
||||
]);
|
||||
}
|
||||
@@ -58,90 +58,6 @@ test("soft-hidden wake keeps its marker until the runtime has resumed", () => {
|
||||
assert.doesNotMatch(softWakeBranch, /softHiddenRef\.current\s*=\s*false/);
|
||||
});
|
||||
|
||||
test("a keep-count of zero rechecks a soft-hidden tab while hibernate stays enabled", async (t) => {
|
||||
const sessionId = "keep-count-zero-recheck";
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
const previousActEnvironment = Object.getOwnPropertyDescriptor(
|
||||
globalThis,
|
||||
"IS_REACT_ACT_ENVIRONMENT",
|
||||
);
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { setTimeout, clearTimeout },
|
||||
});
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
t.after(() => {
|
||||
removePaneVisible(sessionId);
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, "window");
|
||||
if (previousActEnvironment) {
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", previousActEnvironment);
|
||||
} else {
|
||||
Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT");
|
||||
}
|
||||
});
|
||||
|
||||
let hibernateCalls = 0;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
const softHiddenRef = { current: true };
|
||||
const ref = <T>(current: T) => ({ current });
|
||||
|
||||
function Probe({ keepCount }: { keepCount: number }) {
|
||||
useTerminalHibernateEffect({
|
||||
sessionId,
|
||||
isVisible: false,
|
||||
isVisibleRef: ref(false),
|
||||
getSessionConnectedRef: ref(() => true),
|
||||
status: "connected",
|
||||
isSearchOpen: false,
|
||||
hibernateEnabled: true,
|
||||
hibernateDelayMs: 60_000,
|
||||
hibernateKeepRendererCount: keepCount,
|
||||
fileTransferActive: false,
|
||||
hibernatedRef: ref(false),
|
||||
softHiddenRef,
|
||||
hibernatePendingBufferRef: ref(""),
|
||||
hibernateSnapshotRef: ref(""),
|
||||
hibernateViewportSnapshotRef: ref(""),
|
||||
hibernateScrollbackSnapshotRef: ref(""),
|
||||
hibernateContextSnapshotRef: ref(""),
|
||||
hibernateContextViewportSnapshotRef: ref(""),
|
||||
hibernateContextScrollbackSnapshotRef: ref(""),
|
||||
hibernateAlternateScreenRef: ref(false),
|
||||
hasRuntimeRef: ref(true),
|
||||
onHibernate: () => { hibernateCalls += 1; },
|
||||
onSoftHideWake: () => {},
|
||||
onWake: () => true,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const element = (keepCount: number) => React.createElement(
|
||||
React.StrictMode,
|
||||
null,
|
||||
React.createElement(Probe, { keepCount }),
|
||||
);
|
||||
|
||||
await act(async () => { renderer = create(element(2)); });
|
||||
assert.equal(hibernateCalls, 0, "a positive keep count must leave the retained runtime alone");
|
||||
|
||||
await act(async () => { renderer!.update(element(1)); });
|
||||
assert.equal(hibernateCalls, 0, "a still-positive keep count must not drop every retained runtime");
|
||||
|
||||
await act(async () => {
|
||||
renderer!.update(element(0));
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
assert.equal(hibernateCalls, 1, "zero retention must re-enter hibernate for a soft-hidden tab");
|
||||
|
||||
await act(async () => renderer!.unmount());
|
||||
});
|
||||
|
||||
test("hidden disconnected remote terminals release their retained runtime", async (t) => {
|
||||
const sessionId = "disconnected-hidden-hibernate-test";
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
|
||||
@@ -1,446 +0,0 @@
|
||||
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,7 +47,9 @@ 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 });
|
||||
@@ -531,26 +533,14 @@ 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();
|
||||
// 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.
|
||||
// Gate store subscription while History is hidden so command appends do not
|
||||
// re-render this retained mount (panel still mounts for fast reopen).
|
||||
const shellHistory = useSyncExternalStore(
|
||||
isVisible ? subscribeShellHistory : subscribeShellHistoryNoop,
|
||||
getShellHistorySnapshot,
|
||||
getShellHistorySnapshot,
|
||||
isVisible ? getShellHistorySnapshot : getEmptyShellHistorySnapshot,
|
||||
isVisible ? getShellHistorySnapshot : getEmptyShellHistorySnapshot,
|
||||
);
|
||||
// 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,
|
||||
@@ -560,19 +550,21 @@ function SidePanelHistorySlotInner({
|
||||
handleHistoryRun,
|
||||
} = ctx;
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div className={sidePanelHiddenPanelClassName(!isVisible)}>
|
||||
<div className="absolute inset-0 z-10">
|
||||
<HistorySidePanel
|
||||
focusedHost={panelLive.focusedHost}
|
||||
focusedSessionId={panelLive.historySessionId}
|
||||
state={remoteHistory.getState(panelLive.focusedHost?.id, panelLive.historySessionId)}
|
||||
focusedHost={live.focusedHost}
|
||||
focusedSessionId={live.historySessionId}
|
||||
state={remoteHistory.getState(live.focusedHost?.id, live.historySessionId)}
|
||||
globalEntries={shellHistory as import('../../domain/models').ShellHistoryEntry[]}
|
||||
onFetch={remoteHistory.fetch}
|
||||
onDeleteGlobalEntry={handleHistoryDelete}
|
||||
onClearGlobalHistory={handleHistoryClear}
|
||||
onPasteToTerminal={handleHistoryPaste}
|
||||
onRunInTerminal={handleHistoryRun}
|
||||
isVisible={isVisible}
|
||||
isVisible
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import type { Workspace } from "../../types";
|
||||
import { terminalPaneSessionsEqual } from "../../domain/terminalPaneSessionsEqual.ts";
|
||||
import {
|
||||
terminalLayerFocusSidebarPropsEqual,
|
||||
terminalLayerSidePanelCtxEqual,
|
||||
@@ -104,27 +102,6 @@ test("terminal layer memo ignores activeWorkspace-only tab flips (live store own
|
||||
);
|
||||
});
|
||||
|
||||
test("solo tab id change still invalidates workspace ctx; panes host does not read it", () => {
|
||||
const sessions = [{ id: "session-a" }, { id: "session-b" }];
|
||||
assert.equal(
|
||||
terminalLayerWorkspaceCtxEqual(
|
||||
{ activeTabId: "session-a", sessions },
|
||||
{ activeTabId: "session-b", sessions },
|
||||
),
|
||||
false,
|
||||
);
|
||||
|
||||
const hostSource = readFileSync(new URL("./TerminalLayerSupport.tsx", import.meta.url), "utf8");
|
||||
const equalStart = hostSource.indexOf("const terminalPanesHostPropsAreEqual");
|
||||
const equalEnd = hostSource.indexOf("export const TerminalPanesHost");
|
||||
const equalBody = hostSource.slice(equalStart, equalEnd);
|
||||
assert.ok(equalStart !== -1 && equalEnd > equalStart);
|
||||
assert.doesNotMatch(equalBody, /prev\.activeTabId/);
|
||||
assert.doesNotMatch(equalBody, /next\.activeTabId/);
|
||||
assert.match(equalBody, /terminalPaneSessionsEqual\(prev\.sessions, next\.sessions\)/);
|
||||
assert.equal(terminalPaneSessionsEqual(sessions, sessions), true);
|
||||
});
|
||||
|
||||
test("terminal layer memo re-renders when active resizers change", () => {
|
||||
const base = {
|
||||
activeResizers: [
|
||||
|
||||
@@ -343,11 +343,6 @@ const SIDE_PANEL_STABLE_CTX_KEYS = [
|
||||
] as const;
|
||||
|
||||
const WORKSPACE_CTX_KEYS = [
|
||||
// Stay. Solo SSH A->B already keeps TerminalPanesHost equal when the sessions
|
||||
// ref is unchanged (this compare does not feed the panes host). The visible
|
||||
// pane still pays reveal flush + repaint, and focus retries at 50ms.
|
||||
// Dropping activeTabId only skips one workspace-section pass; the live-store
|
||||
// host/theme update still renders the section. That is not the settle cost.
|
||||
'activeTabId',
|
||||
'workspaceInnerRef',
|
||||
'workspaceOuterRef',
|
||||
|
||||
@@ -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 { handleTabCloseButtonMouseDown, handleTabDragThresholdDrag, handleTabDragThresholdDragStart, handleTabDragThresholdMouseDown, handleTabDragThresholdRelease, handleTabMiddleClickClose } from '../../lib/tabInteractions';
|
||||
import { handleTabCloseButtonMouseDown, handleTabMiddleClickClose, handleTabMiddleMouseDown } 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,9 +516,6 @@ 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);
|
||||
@@ -529,23 +526,12 @@ export const PluginViewTopTab: React.FC<PluginViewTopTabProps> = memo(({
|
||||
data-tab-type="plugin-view"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
data-active={topTabActiveValue(isActive)}
|
||||
onClick={activate}
|
||||
onMouseDown={handleTabDragThresholdMouseDown}
|
||||
onMouseUp={(event) => handleTabDragThresholdRelease(event, activate)}
|
||||
onClick={() => activeTabStore.setActiveTabId(tab.id)}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onAuxClick={(event) => handleTabMiddleClickClose(event, close)}
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
handleTabDragThresholdDragStart(event);
|
||||
onTabDragStart(event, tab.id);
|
||||
}}
|
||||
onDrag={(event) => {
|
||||
if (!handleTabDragThresholdDrag(event)) return;
|
||||
onTabDragStart(event, tab.id);
|
||||
}}
|
||||
onDragEnd={(event) => {
|
||||
handleTabDragThresholdRelease(event, activate);
|
||||
onTabDragEnd();
|
||||
}}
|
||||
onDragStart={(event) => onTabDragStart(event, tab.id)}
|
||||
onDragEnd={onTabDragEnd}
|
||||
onDragOver={(event) => onTabDragOver(event, tab.id)}
|
||||
onDragLeave={onTabDragLeave}
|
||||
onDrop={(event) => onTabDrop(event, tab.id)}
|
||||
@@ -662,22 +648,11 @@ export const EditorTopTab: React.FC<EditorTopTabProps> = memo(({
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
data-active={topTabActiveValue(isActive)}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleTabDragThresholdMouseDown}
|
||||
onMouseUp={(event) => handleTabDragThresholdRelease(event, handleClick)}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onRequestCloseEditorTab(editorTab.id))}
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
handleTabDragThresholdDragStart(event);
|
||||
onTabDragStart(event, tabId);
|
||||
}}
|
||||
onDrag={(event) => {
|
||||
if (!handleTabDragThresholdDrag(event)) return;
|
||||
onTabDragStart(event, tabId);
|
||||
}}
|
||||
onDragEnd={(event) => {
|
||||
handleTabDragThresholdRelease(event, handleClick);
|
||||
onTabDragEnd();
|
||||
}}
|
||||
onDragStart={(e) => onTabDragStart(e, tabId)}
|
||||
onDragEnd={onTabDragEnd}
|
||||
onDragOver={(e) => onTabDragOver(e, tabId)}
|
||||
onDragLeave={onTabDragLeave}
|
||||
onDrop={(e) => onTabDrop(e, tabId)}
|
||||
@@ -850,22 +825,11 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
|
||||
data-active={topTabActiveValue(isActive)}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onMouseDown={handleTabDragThresholdMouseDown}
|
||||
onMouseUp={(event) => handleTabDragThresholdRelease(event, handleClick)}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseSession(session.id))}
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
handleTabDragThresholdDragStart(event);
|
||||
onTabDragStart(event, session.id);
|
||||
}}
|
||||
onDrag={(event) => {
|
||||
if (!handleTabDragThresholdDrag(event)) return;
|
||||
onTabDragStart(event, session.id);
|
||||
}}
|
||||
onDragEnd={(event) => {
|
||||
handleTabDragThresholdRelease(event, handleClick);
|
||||
onTabDragEnd();
|
||||
}}
|
||||
onDragStart={(e) => onTabDragStart(e, session.id)}
|
||||
onDragEnd={onTabDragEnd}
|
||||
onDragOver={(e) => onTabDragOver(e, session.id)}
|
||||
onDragLeave={onTabDragLeave}
|
||||
onDrop={(e) => onTabDrop(e, session.id)}
|
||||
@@ -1066,22 +1030,11 @@ export const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
|
||||
data-host-drop-active={isHostDropTarget ? 'true' : 'false'}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onMouseDown={handleTabDragThresholdMouseDown}
|
||||
onMouseUp={(event) => handleTabDragThresholdRelease(event, handleClick)}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseWorkspace(workspace.id))}
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
handleTabDragThresholdDragStart(event);
|
||||
onTabDragStart(event, workspace.id);
|
||||
}}
|
||||
onDrag={(event) => {
|
||||
if (!handleTabDragThresholdDrag(event)) return;
|
||||
onTabDragStart(event, workspace.id);
|
||||
}}
|
||||
onDragEnd={(event) => {
|
||||
handleTabDragThresholdRelease(event, handleClick);
|
||||
onTabDragEnd();
|
||||
}}
|
||||
onDragStart={(e) => onTabDragStart(e, workspace.id)}
|
||||
onDragEnd={onTabDragEnd}
|
||||
onDragOver={(e) => onTabDragOver(e, workspace.id)}
|
||||
onDragLeave={onTabDragLeave}
|
||||
onDrop={(e) => onTabDrop(e, workspace.id)}
|
||||
@@ -1224,22 +1177,11 @@ export const LogViewTopTab: React.FC<LogViewTopTabProps> = memo(({
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
data-active={topTabActiveValue(isActive)}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleTabDragThresholdMouseDown}
|
||||
onMouseUp={(event) => handleTabDragThresholdRelease(event, handleClick)}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseLogView(logView.id))}
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
handleTabDragThresholdDragStart(event);
|
||||
onTabDragStart(event, logView.id);
|
||||
}}
|
||||
onDrag={(event) => {
|
||||
if (!handleTabDragThresholdDrag(event)) return;
|
||||
onTabDragStart(event, logView.id);
|
||||
}}
|
||||
onDragEnd={(event) => {
|
||||
handleTabDragThresholdRelease(event, handleClick);
|
||||
onTabDragEnd();
|
||||
}}
|
||||
onDragStart={(e) => onTabDragStart(e, logView.id)}
|
||||
onDragEnd={onTabDragEnd}
|
||||
onDragOver={(e) => onTabDragOver(e, logView.id)}
|
||||
onDragLeave={onTabDragLeave}
|
||||
onDrop={(e) => onTabDrop(e, logView.id)}
|
||||
|
||||
@@ -3,7 +3,6 @@ import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -16,8 +15,6 @@ 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> {
|
||||
@@ -28,24 +25,6 @@ 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>(
|
||||
@@ -57,20 +36,12 @@ 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[] = [];
|
||||
@@ -81,8 +52,6 @@ function VariableSizeVirtualListInner<T>(
|
||||
}
|
||||
return { offsets, totalHeight: total };
|
||||
}, [getItemHeight, items]);
|
||||
const layoutRef = useRef(layout);
|
||||
layoutRef.current = layout;
|
||||
|
||||
const effectiveScrollTop = clampScrollTop(
|
||||
scrollTop,
|
||||
@@ -97,7 +66,6 @@ function VariableSizeVirtualListInner<T>(
|
||||
if (container.scrollTop !== effectiveScrollTop) {
|
||||
container.scrollTop = effectiveScrollTop;
|
||||
}
|
||||
scrollTopRef.current = effectiveScrollTop;
|
||||
if (scrollTop !== effectiveScrollTop) {
|
||||
setScrollTop(effectiveScrollTop);
|
||||
}
|
||||
@@ -108,11 +76,7 @@ function VariableSizeVirtualListInner<T>(
|
||||
if (!container) return;
|
||||
|
||||
const updateViewportHeight = () => {
|
||||
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));
|
||||
setViewportHeight(container.clientHeight);
|
||||
};
|
||||
|
||||
updateViewportHeight();
|
||||
@@ -121,85 +85,32 @@ 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,
|
||||
remeasure,
|
||||
}), [remeasure, scrollToIndex]);
|
||||
scrollToIndex: (index: number, align = 'auto') => {
|
||||
const container = containerRef.current;
|
||||
if (!container || index < 0 || index >= items.length) return;
|
||||
|
||||
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 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]);
|
||||
|
||||
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
|
||||
if (!measureEnabledRef.current) return;
|
||||
scrollTopRef.current = event.currentTarget.scrollTop;
|
||||
setScrollTop(event.currentTarget.scrollTop);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -31,17 +31,20 @@
|
||||
<text x="85" y="85.0" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">7K</text>
|
||||
<text x="43" y="271.5" fill="#e6edf3" font-size="17" font-family="Handlee, cursive" font-weight="bold" transform="rotate(-90, 43, 271.5)">GitHub Stars</text>
|
||||
<text x="90.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jan 2026</text>
|
||||
<text x="220.4" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
|
||||
<text x="291.4" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
|
||||
<text x="430.9" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
|
||||
<text x="570.5" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
|
||||
<text x="710.1" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Oct 2026</text>
|
||||
<text x="223.6" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
|
||||
<text x="296.2" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
|
||||
<text x="366.6" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
|
||||
<text x="439.2" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
|
||||
<text x="509.5" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
|
||||
<text x="582.2" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
|
||||
<text x="654.8" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
|
||||
<text x="725.2" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Oct 2026</text>
|
||||
<text x="50%" y="525" fill="#e6edf3" font-size="17" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Date</text>
|
||||
<line x1="90" y1="80" x2="90" y2="463" stroke="#30363d" stroke-width="2.5" filter="url(#xkcdify)"/>
|
||||
<line x1="90" y1="463" x2="765" y2="463" stroke="#30363d" stroke-width="2.5" filter="url(#xkcdify)"/>
|
||||
<path d="M 90.0,462.2 C 90.4,462.1 91.9,461.8 92.3,461.7 C 93.1,461.6 93.8,461.4 94.6,461.2 C 95.3,461.1 96.1,460.8 96.9,460.8 C 97.6,460.7 98.4,460.7 99.2,460.7 C 99.9,460.7 100.7,460.6 101.4,460.6 C 102.2,460.5 103.0,460.5 103.7,460.4 C 104.5,460.4 104.9,460.3 106.0,460.3 C 107.2,460.2 109.4,460.1 110.6,460.1 C 111.7,460.1 112.1,460.0 112.9,460.0 C 113.6,460.0 114.4,460.0 115.2,459.9 C 115.9,459.9 116.7,459.9 117.5,459.9 C 118.2,459.9 119.0,459.9 119.7,459.8 C 120.5,459.8 121.3,459.7 122.0,459.7 C 122.8,459.6 123.6,459.6 124.3,459.4 C 125.1,459.3 125.8,459.0 126.6,458.7 C 127.4,458.3 128.1,457.6 128.9,457.3 C 129.7,457.1 130.4,457.2 131.2,457.1 C 131.9,457.1 132.7,457.1 133.5,457.0 C 134.2,457.0 134.6,457.0 135.8,456.9 C 136.9,456.9 138.8,456.9 140.3,456.9 C 141.9,456.8 143.8,456.8 144.9,456.8 C 146.1,456.7 146.4,456.7 147.2,456.7 C 148.0,456.6 148.7,456.6 149.5,456.5 C 150.3,456.5 151.0,456.5 151.8,456.4 C 152.5,456.4 153.3,456.4 154.1,456.4 C 154.8,456.4 155.2,456.4 156.4,456.3 C 157.5,456.3 159.0,456.2 160.9,456.2 C 162.8,456.2 165.9,456.2 167.8,456.2 C 169.7,456.1 170.1,456.2 172.4,456.1 C 174.7,456.1 179.6,455.9 181.5,455.9 C 183.4,455.8 182.7,455.9 183.8,455.8 C 185.0,455.8 186.5,455.8 188.4,455.8 C 190.3,455.8 191.4,455.7 195.3,455.7 C 199.1,455.6 208.2,455.5 211.3,455.5 C 214.3,455.5 212.8,455.5 213.6,455.4 C 214.3,455.4 214.7,456.1 215.8,455.4 C 217.0,454.7 219.3,454.1 220.4,451.3 C 221.6,448.4 221.9,440.7 222.7,438.3 C 223.5,435.9 224.2,437.3 225.0,436.9 C 225.8,436.5 226.5,436.3 227.3,436.0 C 228.1,435.8 228.8,435.6 229.6,435.4 C 230.3,435.3 231.1,435.3 231.9,435.2 C 232.6,435.1 233.4,434.9 234.2,434.8 C 234.9,434.8 235.7,434.8 236.4,434.8 C 237.2,434.7 238.0,434.5 238.7,434.4 C 239.5,434.3 240.3,434.3 241.0,434.2 C 241.8,434.1 242.5,433.7 243.3,433.6 C 244.1,433.5 244.8,433.5 245.6,433.5 C 246.4,433.4 246.7,433.2 247.9,433.1 C 249.0,433.0 251.3,433.0 252.5,432.9 C 253.6,432.9 254.0,432.8 254.7,432.7 C 255.5,432.7 256.3,432.7 257.0,432.6 C 257.8,432.5 258.6,432.2 259.3,432.1 C 260.1,431.9 260.8,432.6 261.6,431.7 C 262.4,430.8 263.1,427.6 263.9,426.6 C 264.7,425.5 265.4,425.7 266.2,425.5 C 266.9,425.3 267.7,425.5 268.5,425.4 C 269.2,425.3 270.0,424.9 270.8,424.6 C 271.5,424.4 272.3,424.2 273.1,423.7 C 273.8,423.2 274.6,422.2 275.3,421.6 C 276.1,421.0 276.9,420.5 277.6,420.0 C 278.4,419.6 279.2,419.3 279.9,419.0 C 280.7,418.8 281.4,418.6 282.2,418.5 C 283.0,418.4 283.7,418.4 284.5,418.3 C 285.3,418.2 286.0,418.0 286.8,417.9 C 287.5,417.8 288.3,417.8 289.1,417.8 C 289.8,417.7 290.6,417.7 291.4,417.5 C 292.1,417.4 292.9,417.5 293.6,417.0 C 294.4,416.6 295.2,415.6 295.9,415.1 C 296.7,414.6 297.5,414.4 298.2,414.0 C 299.0,413.7 299.7,414.4 300.5,412.8 C 301.3,411.3 302.0,406.5 302.8,404.6 C 303.6,402.8 304.3,402.5 305.1,401.7 C 305.8,400.9 306.6,400.4 307.4,399.9 C 308.1,399.3 308.9,398.8 309.7,398.4 C 310.4,398.0 311.2,397.6 311.9,397.3 C 312.7,397.1 313.5,397.0 314.2,396.8 C 315.0,396.6 315.8,396.3 316.5,396.0 C 317.3,395.7 318.1,395.2 318.8,395.0 C 319.6,394.7 320.3,394.8 321.1,394.5 C 321.9,394.2 322.6,393.8 323.4,393.5 C 324.2,393.1 324.9,392.9 325.7,392.6 C 326.4,392.4 327.2,392.2 328.0,392.0 C 328.7,391.9 329.5,391.8 330.3,391.7 C 331.0,391.6 331.8,391.4 332.5,391.2 C 333.3,391.0 334.1,390.9 334.8,390.7 C 335.6,390.5 336.4,390.3 337.1,390.0 C 337.9,389.6 338.6,389.0 339.4,388.8 C 340.2,388.5 340.9,388.4 341.7,388.2 C 342.5,388.1 343.2,387.9 344.0,387.8 C 344.7,387.7 345.5,387.6 346.3,387.5 C 347.0,387.4 347.8,387.2 348.6,387.2 C 349.3,387.1 350.1,387.1 350.8,387.0 C 351.6,386.9 352.4,386.7 353.1,386.4 C 353.9,386.1 354.7,385.6 355.4,385.3 C 356.2,385.0 356.9,384.6 357.7,384.4 C 358.5,384.2 359.2,384.1 360.0,384.0 C 360.8,383.9 361.5,383.8 362.3,383.6 C 363.1,383.3 363.8,382.8 364.6,382.6 C 365.3,382.4 366.1,382.4 366.9,382.3 C 367.6,382.2 368.4,382.0 369.2,381.8 C 369.9,381.6 370.7,381.4 371.4,381.3 C 372.2,381.1 373.0,380.8 373.7,380.7 C 374.5,380.5 375.3,380.4 376.0,380.2 C 376.8,379.9 377.5,379.6 378.3,379.3 C 379.1,379.1 379.8,379.0 380.6,378.7 C 381.4,378.5 382.1,378.2 382.9,377.9 C 383.6,377.6 384.4,377.4 385.2,377.1 C 385.9,376.8 386.7,376.8 387.5,376.4 C 388.2,376.0 389.0,375.1 389.7,374.7 C 390.5,374.3 391.3,374.3 392.0,374.1 C 392.8,374.0 393.6,374.0 394.3,373.9 C 395.1,373.8 395.8,373.7 396.6,373.5 C 397.4,373.4 398.1,373.6 398.9,373.3 C 399.7,372.9 400.4,371.8 401.2,371.4 C 401.9,371.0 402.7,371.1 403.5,370.8 C 404.2,370.6 405.0,370.1 405.8,369.9 C 406.5,369.7 407.3,369.8 408.1,369.6 C 408.8,369.4 409.6,368.9 410.3,368.7 C 411.1,368.5 411.9,368.5 412.6,368.4 C 413.4,368.3 414.2,368.3 414.9,368.1 C 415.7,367.9 416.4,367.6 417.2,367.1 C 418.0,366.6 418.7,365.5 419.5,365.0 C 420.3,364.5 421.0,364.2 421.8,363.9 C 422.5,363.5 423.3,363.4 424.1,362.7 C 424.8,361.9 425.6,360.2 426.4,359.4 C 427.1,358.6 427.9,358.3 428.6,357.8 C 429.4,357.4 430.2,357.3 430.9,356.6 C 431.7,355.9 432.5,354.2 433.2,353.5 C 434.0,352.8 434.7,352.7 435.5,352.3 C 436.3,351.9 437.0,351.6 437.8,351.1 C 438.6,35Line truncated
|
||||
<path d="M 90.0,462.2 C 90.4,462.1 91.9,461.8 92.3,461.7 C 93.1,461.6 93.8,461.4 94.6,461.2 C 95.3,461.1 96.1,460.8 96.9,460.8 C 97.6,460.7 98.4,460.7 99.2,460.7 C 99.9,460.7 100.7,460.6 101.4,460.6 C 102.2,460.5 103.0,460.5 103.7,460.4 C 104.5,460.4 104.9,460.3 106.0,460.3 C 107.2,460.2 109.4,460.1 110.6,460.1 C 111.7,460.1 112.1,460.0 112.9,460.0 C 113.6,460.0 114.4,460.0 115.2,459.9 C 115.9,459.9 116.7,459.9 117.5,459.9 C 118.2,459.9 119.0,459.9 119.7,459.8 C 120.5,459.8 121.3,459.7 122.0,459.7 C 122.8,459.6 123.6,459.6 124.3,459.4 C 125.1,459.3 125.8,459.0 126.6,458.7 C 127.4,458.3 128.1,457.6 128.9,457.3 C 129.7,457.1 130.4,457.2 131.2,457.1 C 131.9,457.1 132.7,457.1 133.5,457.0 C 134.2,457.0 134.6,457.0 135.8,456.9 C 136.9,456.9 138.8,456.9 140.3,456.9 C 141.9,456.8 143.8,456.8 144.9,456.8 C 146.1,456.7 146.4,456.7 147.2,456.7 C 148.0,456.6 148.7,456.6 149.5,456.5 C 150.3,456.5 151.0,456.5 151.8,456.4 C 152.5,456.4 153.3,456.4 154.1,456.4 C 154.8,456.4 155.2,456.4 156.4,456.3 C 157.5,456.3 159.0,456.2 160.9,456.2 C 162.8,456.2 165.9,456.2 167.8,456.2 C 169.7,456.1 170.1,456.2 172.4,456.1 C 174.7,456.1 179.6,455.9 181.5,455.9 C 183.4,455.8 182.7,455.9 183.8,455.8 C 185.0,455.8 186.5,455.8 188.4,455.8 C 190.3,455.8 191.4,455.7 195.3,455.7 C 199.1,455.6 208.2,455.5 211.3,455.5 C 214.3,455.5 212.8,455.5 213.6,455.4 C 214.3,455.4 214.7,456.1 215.8,455.4 C 217.0,454.7 219.3,454.1 220.4,451.3 C 221.6,448.4 221.9,440.7 222.7,438.3 C 223.5,435.9 224.2,437.3 225.0,436.9 C 225.8,436.5 226.5,436.3 227.3,436.0 C 228.1,435.8 228.8,435.6 229.6,435.4 C 230.3,435.3 231.1,435.3 231.9,435.2 C 232.6,435.1 233.4,434.9 234.2,434.8 C 234.9,434.8 235.7,434.8 236.4,434.8 C 237.2,434.7 238.0,434.5 238.7,434.4 C 239.5,434.3 240.3,434.3 241.0,434.2 C 241.8,434.1 242.5,433.7 243.3,433.6 C 244.1,433.5 244.8,433.5 245.6,433.5 C 246.4,433.4 246.7,433.2 247.9,433.1 C 249.0,433.0 251.3,433.0 252.5,432.9 C 253.6,432.9 254.0,432.8 254.7,432.7 C 255.5,432.7 256.3,432.7 257.0,432.6 C 257.8,432.5 258.6,432.2 259.3,432.1 C 260.1,431.9 260.8,432.6 261.6,431.7 C 262.4,430.8 263.1,427.6 263.9,426.6 C 264.7,425.5 265.4,425.7 266.2,425.5 C 266.9,425.3 267.7,425.5 268.5,425.4 C 269.2,425.3 270.0,424.9 270.8,424.6 C 271.5,424.4 272.3,424.2 273.1,423.7 C 273.8,423.2 274.6,422.2 275.3,421.6 C 276.1,421.0 276.9,420.5 277.6,420.0 C 278.4,419.6 279.2,419.3 279.9,419.0 C 280.7,418.8 281.4,418.6 282.2,418.5 C 283.0,418.4 283.7,418.4 284.5,418.3 C 285.3,418.2 286.0,418.0 286.8,417.9 C 287.5,417.8 288.3,417.8 289.1,417.8 C 289.8,417.7 290.6,417.7 291.4,417.5 C 292.1,417.4 292.9,417.5 293.6,417.0 C 294.4,416.6 295.2,415.6 295.9,415.1 C 296.7,414.6 297.5,414.4 298.2,414.0 C 299.0,413.7 299.7,414.4 300.5,412.8 C 301.3,411.3 302.0,406.5 302.8,404.6 C 303.6,402.8 304.3,402.5 305.1,401.7 C 305.8,400.9 306.6,400.4 307.4,399.9 C 308.1,399.3 308.9,398.8 309.7,398.4 C 310.4,398.0 311.2,397.6 311.9,397.3 C 312.7,397.1 313.5,397.0 314.2,396.8 C 315.0,396.6 315.8,396.3 316.5,396.0 C 317.3,395.7 318.1,395.2 318.8,395.0 C 319.6,394.7 320.3,394.8 321.1,394.5 C 321.9,394.2 322.6,393.8 323.4,393.5 C 324.2,393.1 324.9,392.9 325.7,392.6 C 326.4,392.4 327.2,392.2 328.0,392.0 C 328.7,391.9 329.5,391.8 330.3,391.7 C 331.0,391.6 331.8,391.4 332.5,391.2 C 333.3,391.0 334.1,390.9 334.8,390.7 C 335.6,390.5 336.4,390.3 337.1,390.0 C 337.9,389.6 338.6,389.0 339.4,388.8 C 340.2,388.5 340.9,388.4 341.7,388.2 C 342.5,388.1 343.2,387.9 344.0,387.8 C 344.7,387.7 345.5,387.6 346.3,387.5 C 347.0,387.4 347.8,387.2 348.6,387.2 C 349.3,387.1 350.1,387.1 350.8,387.0 C 351.6,386.9 352.4,386.7 353.1,386.4 C 353.9,386.1 354.7,385.6 355.4,385.3 C 356.2,385.0 356.9,384.6 357.7,384.4 C 358.5,384.2 359.2,384.1 360.0,384.0 C 360.8,383.9 361.5,383.8 362.3,383.6 C 363.1,383.3 363.8,382.8 364.6,382.6 C 365.3,382.4 366.1,382.4 366.9,382.3 C 367.6,382.2 368.4,382.0 369.2,381.8 C 369.9,381.6 370.7,381.4 371.4,381.3 C 372.2,381.1 373.0,380.8 373.7,380.7 C 374.5,380.5 375.3,380.4 376.0,380.2 C 376.8,379.9 377.5,379.6 378.3,379.3 C 379.1,379.1 379.8,379.0 380.6,378.7 C 381.4,378.5 382.1,378.2 382.9,377.9 C 383.6,377.6 384.4,377.4 385.2,377.1 C 385.9,376.8 386.7,376.8 387.5,376.4 C 388.2,376.0 389.0,375.1 389.7,374.7 C 390.5,374.3 391.3,374.3 392.0,374.1 C 392.8,374.0 393.6,374.0 394.3,373.9 C 395.1,373.8 395.8,373.7 396.6,373.5 C 397.4,373.4 398.1,373.6 398.9,373.3 C 399.7,372.9 400.4,371.8 401.2,371.4 C 401.9,371.0 402.7,371.1 403.5,370.8 C 404.2,370.6 405.0,370.1 405.8,369.9 C 406.5,369.7 407.3,369.8 408.1,369.6 C 408.8,369.4 409.6,368.9 410.3,368.7 C 411.1,368.5 411.9,368.5 412.6,368.4 C 413.4,368.3 414.2,368.3 414.9,368.1 C 415.7,367.9 416.4,367.6 417.2,367.1 C 418.0,366.6 418.7,365.5 419.5,365.0 C 420.3,364.5 421.0,364.2 421.8,363.9 C 422.5,363.5 423.3,363.4 424.1,362.7 C 424.8,361.9 425.6,360.2 426.4,359.4 C 427.1,358.6 427.9,358.3 428.6,357.8 C 429.4,357.4 430.2,357.3 430.9,356.6 C 431.7,355.9 432.5,354.2 433.2,353.5 C 434.0,352.8 434.7,352.7 435.5,352.3 C 436.3,351.9 437.0,351.6 437.8,351.1 C 438.6,35Line truncated
|
||||
<circle cx="765.0" cy="90.8" r="5" fill="#dd4528" stroke="#0d1117" stroke-width="2" filter="url(#xkcdify)"/>
|
||||
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6802</text>
|
||||
<path d="M 90.0,462.2 C 90.4,462.1 92.0,461.8 92.3,461.7 C 93.1,461.6 93.9,461.4 94.7,461.2 C 95.5,461.1 96.2,460.8 97.0,460.8 C 97.8,460.7 98.6,460.7 99.4,460.7 C 100.2,460.7 100.9,460.6 101.7,460.6 C 102.5,460.5 103.3,460.5 104.1,460.4 C 104.8,460.4 105.2,460.3 106.4,460.3 C 107.6,460.2 109.9,460.1 111.1,460.1 C 112.3,460.1 112.7,460.0 113.4,460.0 C 114.2,460.0 115.0,460.0 115.8,459.9 C 116.6,459.9 117.3,459.9 118.1,459.9 C 118.9,459.9 119.7,459.9 120.5,459.8 C 121.2,459.8 122.0,459.7 122.8,459.7 C 123.6,459.6 124.4,459.6 125.2,459.4 C 125.9,459.3 126.7,459.0 127.5,458.7 C 128.3,458.3 129.1,457.6 129.8,457.3 C 130.6,457.1 131.4,457.2 132.2,457.1 C 133.0,457.1 133.8,457.1 134.5,457.0 C 135.3,457.0 135.7,457.0 136.9,456.9 C 138.0,456.9 140.0,456.9 141.6,456.9 C 143.1,456.8 145.1,456.8 146.2,456.8 C 147.4,456.7 147.8,456.7 148.6,456.7 C 149.4,456.6 150.2,456.6 150.9,456.5 C 151.7,456.5 152.5,456.5 153.3,456.4 C 154.1,456.4 154.8,456.4 155.6,456.4 C 156.4,456.4 156.8,456.4 158.0,456.3 C 159.1,456.3 160.7,456.2 162.7,456.2 C 164.6,456.2 167.7,456.2 169.7,456.2 C 171.6,456.1 172.0,456.2 174.4,456.1 C 176.7,456.1 181.8,455.9 183.8,455.9 C 185.7,455.8 184.9,455.9 186.1,455.8 C 187.3,455.8 188.8,455.8 190.8,455.8 C 192.7,455.8 193.9,455.7 197.8,455.7 C 201.7,455.6 211.1,455.5 214.2,455.5 C 217.3,455.5 215.8,455.5 216.6,455.4 C 217.3,455.4 217.7,456.1 218.9,455.4 C 220.1,454.7 222.4,454.1 223.6,451.3 C 224.8,448.4 225.2,440.7 225.9,438.3 C 226.7,435.9 227.5,437.3 228.3,436.9 C 229.1,436.5 229.8,436.3 230.6,436.0 C 231.4,435.8 232.2,435.6 233.0,435.4 C 233.8,435.3 234.5,435.3 235.3,435.2 C 236.1,435.1 236.9,434.9 237.7,434.8 C 238.4,434.8 239.2,434.8 240.0,434.8 C 240.8,434.7 241.6,434.5 242.3,434.4 C 243.1,434.3 243.9,434.3 244.7,434.2 C 245.5,434.1 246.2,433.7 247.0,433.6 C 247.8,433.5 248.6,433.5 249.4,433.5 C 250.2,433.4 250.5,433.2 251.7,433.1 C 252.9,433.0 255.2,433.0 256.4,432.9 C 257.6,432.9 258.0,432.8 258.8,432.7 C 259.5,432.7 260.3,432.7 261.1,432.6 C 261.9,432.5 262.7,432.2 263.4,432.1 C 264.2,431.9 265.0,432.6 265.8,431.7 C 266.6,430.8 267.3,427.6 268.1,426.6 C 268.9,425.5 269.7,425.7 270.5,425.5 C 271.2,425.3 272.0,425.5 272.8,425.4 C 273.6,425.3 274.4,424.9 275.2,424.6 C 275.9,424.4 276.7,424.2 277.5,423.7 C 278.3,423.2 279.1,422.2 279.8,421.6 C 280.6,421.0 281.4,420.4 282.2,420.0 C 283.0,419.5 283.8,419.2 284.5,419.0 C 285.3,418.7 286.1,418.6 286.9,418.5 C 287.7,418.3 288.4,418.4 289.2,418.2 C 290.0,418.1 290.8,417.9 291.6,417.8 C 292.3,417.7 293.1,417.8 293.9,417.7 C 294.7,417.6 295.5,417.6 296.2,417.5 C 297.0,417.4 297.8,417.4 298.6,417.0 C 299.4,416.6 300.2,415.5 300.9,415.0 C 301.7,414.5 302.5,414.3 303.3,414.0 C 304.1,413.6 304.8,414.3 305.6,412.8 C 306.4,411.2 307.2,406.4 308.0,404.6 C 308.8,402.7 309.5,402.4 310.3,401.7 C 311.1,400.9 311.9,400.4 312.7,399.9 C 313.4,399.3 314.2,398.8 315.0,398.4 C 315.8,398.0 316.6,397.6 317.3,397.3 C 318.1,397.1 318.9,397.0 319.7,396.8 C 320.5,396.6 321.2,396.3 322.0,396.0 C 322.8,395.7 323.6,395.2 324.4,395.0 C 325.2,394.7 325.9,394.8 326.7,394.5 C 327.5,394.2 328.3,393.8 329.1,393.5 C 329.8,393.1 330.6,392.9 331.4,392.6 C 332.2,392.4 333.0,392.2 333.8,392.0 C 334.5,391.9 335.3,391.8 336.1,391.7 C 336.9,391.6 337.7,391.4 338.4,391.2 C 339.2,391.0 340.0,390.9 340.8,390.7 C 341.6,390.5 342.3,390.3 343.1,390.0 C 343.9,389.6 344.7,389.0 345.5,388.8 C 346.2,388.5 347.0,388.4 347.8,388.2 C 348.6,388.1 349.4,387.9 350.2,387.8 C 350.9,387.7 351.7,387.6 352.5,387.5 C 353.3,387.4 354.1,387.2 354.8,387.2 C 355.6,387.1 356.4,387.1 357.2,387.0 C 358.0,386.9 358.8,386.7 359.5,386.4 C 360.3,386.1 361.1,385.6 361.9,385.3 C 362.7,385.0 363.4,384.6 364.2,384.4 C 365.0,384.2 365.8,384.1 366.6,384.0 C 367.3,383.9 368.1,383.8 368.9,383.6 C 369.7,383.3 370.5,382.8 371.2,382.6 C 372.0,382.4 372.8,382.4 373.6,382.3 C 374.4,382.2 375.2,382.0 375.9,381.8 C 376.7,381.6 377.5,381.4 378.3,381.3 C 379.1,381.1 379.8,380.8 380.6,380.7 C 381.4,380.5 382.2,380.4 383.0,380.2 C 383.8,379.9 384.5,379.6 385.3,379.3 C 386.1,379.1 386.9,379.0 387.7,378.7 C 388.4,378.5 389.2,378.2 390.0,377.9 C 390.8,377.6 391.6,377.4 392.3,377.1 C 393.1,376.8 393.9,376.8 394.7,376.4 C 395.5,376.0 396.2,375.1 397.0,374.7 C 397.8,374.3 398.6,374.3 399.4,374.1 C 400.2,374.0 400.9,374.0 401.7,373.9 C 402.5,373.8 403.3,373.7 404.1,373.5 C 404.8,373.4 405.6,373.6 406.4,373.3 C 407.2,372.9 408.0,371.8 408.8,371.4 C 409.5,371.0 410.3,371.1 411.1,370.8 C 411.9,370.6 412.7,370.1 413.4,369.9 C 414.2,369.7 415.0,369.8 415.8,369.6 C 416.6,369.4 417.3,368.9 418.1,368.7 C 418.9,368.5 419.7,368.5 420.5,368.4 C 421.2,368.3 422.0,368.3 422.8,368.1 C 423.6,367.9 424.4,367.6 425.2,367.1 C 425.9,366.6 426.7,365.5 427.5,365.0 C 428.3,364.5 429.1,364.2 429.8,363.9 C 430.6,363.5 431.4,363.4 432.2,362.7 C 433.0,361.9 433.8,360.2 434.5,359.4 C 435.3,358.6 436.1,358.3 436.9,357.8 C 437.7,357.3 438.4,357.2 439.2,356.5 C 440.0,355.8 440.8,354.2 441.6,353.5 C 442.3,352.8 443.1,352.7 443.9,352.3 C 444.7,351.9 445.5,351.5 446.2,351.1 C 447.0,3Line truncated
|
||||
<path d="M 90.0,462.2 C 90.4,462.1 92.0,461.8 92.3,461.7 C 93.1,461.6 93.9,461.4 94.7,461.2 C 95.5,461.1 96.2,460.8 97.0,460.8 C 97.8,460.7 98.6,460.7 99.4,460.7 C 100.2,460.7 100.9,460.6 101.7,460.6 C 102.5,460.5 103.3,460.5 104.1,460.4 C 104.8,460.4 105.2,460.3 106.4,460.3 C 107.6,460.2 109.9,460.1 111.1,460.1 C 112.3,460.1 112.7,460.0 113.4,460.0 C 114.2,460.0 115.0,460.0 115.8,459.9 C 116.6,459.9 117.3,459.9 118.1,459.9 C 118.9,459.9 119.7,459.9 120.5,459.8 C 121.2,459.8 122.0,459.7 122.8,459.7 C 123.6,459.6 124.4,459.6 125.2,459.4 C 125.9,459.3 126.7,459.0 127.5,458.7 C 128.3,458.3 129.1,457.6 129.8,457.3 C 130.6,457.1 131.4,457.2 132.2,457.1 C 133.0,457.1 133.8,457.1 134.5,457.0 C 135.3,457.0 135.7,457.0 136.9,456.9 C 138.0,456.9 140.0,456.9 141.6,456.9 C 143.1,456.8 145.1,456.8 146.2,456.8 C 147.4,456.7 147.8,456.7 148.6,456.7 C 149.4,456.6 150.2,456.6 150.9,456.5 C 151.7,456.5 152.5,456.5 153.3,456.4 C 154.1,456.4 154.8,456.4 155.6,456.4 C 156.4,456.4 156.8,456.4 158.0,456.3 C 159.1,456.3 160.7,456.2 162.7,456.2 C 164.6,456.2 167.7,456.2 169.7,456.2 C 171.6,456.1 172.0,456.2 174.4,456.1 C 176.7,456.1 181.8,455.9 183.8,455.9 C 185.7,455.8 184.9,455.9 186.1,455.8 C 187.3,455.8 188.8,455.8 190.8,455.8 C 192.7,455.8 193.9,455.7 197.8,455.7 C 201.7,455.6 211.1,455.5 214.2,455.5 C 217.3,455.5 215.8,455.5 216.6,455.4 C 217.3,455.4 217.7,456.1 218.9,455.4 C 220.1,454.7 222.4,454.1 223.6,451.3 C 224.8,448.4 225.2,440.7 225.9,438.3 C 226.7,435.9 227.5,437.3 228.3,436.9 C 229.1,436.5 229.8,436.3 230.6,436.0 C 231.4,435.8 232.2,435.6 233.0,435.4 C 233.8,435.3 234.5,435.3 235.3,435.2 C 236.1,435.1 236.9,434.9 237.7,434.8 C 238.4,434.8 239.2,434.8 240.0,434.8 C 240.8,434.7 241.6,434.5 242.3,434.4 C 243.1,434.3 243.9,434.3 244.7,434.2 C 245.5,434.1 246.2,433.7 247.0,433.6 C 247.8,433.5 248.6,433.5 249.4,433.5 C 250.2,433.4 250.5,433.2 251.7,433.1 C 252.9,433.0 255.2,433.0 256.4,432.9 C 257.6,432.9 258.0,432.8 258.8,432.7 C 259.5,432.7 260.3,432.7 261.1,432.6 C 261.9,432.5 262.7,432.2 263.4,432.1 C 264.2,431.9 265.0,432.6 265.8,431.7 C 266.6,430.8 267.3,427.6 268.1,426.6 C 268.9,425.5 269.7,425.7 270.5,425.5 C 271.2,425.3 272.0,425.5 272.8,425.4 C 273.6,425.3 274.4,424.9 275.2,424.6 C 275.9,424.4 276.7,424.2 277.5,423.7 C 278.3,423.2 279.1,422.2 279.8,421.6 C 280.6,421.0 281.4,420.4 282.2,420.0 C 283.0,419.5 283.8,419.2 284.5,419.0 C 285.3,418.7 286.1,418.6 286.9,418.5 C 287.7,418.3 288.4,418.4 289.2,418.2 C 290.0,418.1 290.8,417.9 291.6,417.8 C 292.3,417.7 293.1,417.8 293.9,417.7 C 294.7,417.6 295.5,417.6 296.2,417.5 C 297.0,417.4 297.8,417.4 298.6,417.0 C 299.4,416.6 300.2,415.5 300.9,415.0 C 301.7,414.5 302.5,414.3 303.3,414.0 C 304.1,413.6 304.8,414.3 305.6,412.8 C 306.4,411.2 307.2,406.4 308.0,404.6 C 308.8,402.7 309.5,402.4 310.3,401.7 C 311.1,400.9 311.9,400.4 312.7,399.9 C 313.4,399.3 314.2,398.8 315.0,398.4 C 315.8,398.0 316.6,397.6 317.3,397.3 C 318.1,397.1 318.9,397.0 319.7,396.8 C 320.5,396.6 321.2,396.3 322.0,396.0 C 322.8,395.7 323.6,395.2 324.4,395.0 C 325.2,394.7 325.9,394.8 326.7,394.5 C 327.5,394.2 328.3,393.8 329.1,393.5 C 329.8,393.1 330.6,392.9 331.4,392.6 C 332.2,392.4 333.0,392.2 333.8,392.0 C 334.5,391.9 335.3,391.8 336.1,391.7 C 336.9,391.6 337.7,391.4 338.4,391.2 C 339.2,391.0 340.0,390.9 340.8,390.7 C 341.6,390.5 342.3,390.3 343.1,390.0 C 343.9,389.6 344.7,389.0 345.5,388.8 C 346.2,388.5 347.0,388.4 347.8,388.2 C 348.6,388.1 349.4,387.9 350.2,387.8 C 350.9,387.7 351.7,387.6 352.5,387.5 C 353.3,387.4 354.1,387.2 354.8,387.2 C 355.6,387.1 356.4,387.1 357.2,387.0 C 358.0,386.9 358.8,386.7 359.5,386.4 C 360.3,386.1 361.1,385.6 361.9,385.3 C 362.7,385.0 363.4,384.6 364.2,384.4 C 365.0,384.2 365.8,384.1 366.6,384.0 C 367.3,383.9 368.1,383.8 368.9,383.6 C 369.7,383.3 370.5,382.8 371.2,382.6 C 372.0,382.4 372.8,382.4 373.6,382.3 C 374.4,382.2 375.2,382.0 375.9,381.8 C 376.7,381.6 377.5,381.4 378.3,381.3 C 379.1,381.1 379.8,380.8 380.6,380.7 C 381.4,380.5 382.2,380.4 383.0,380.2 C 383.8,379.9 384.5,379.6 385.3,379.3 C 386.1,379.1 386.9,379.0 387.7,378.7 C 388.4,378.5 389.2,378.2 390.0,377.9 C 390.8,377.6 391.6,377.4 392.3,377.1 C 393.1,376.8 393.9,376.8 394.7,376.4 C 395.5,376.0 396.2,375.1 397.0,374.7 C 397.8,374.3 398.6,374.3 399.4,374.1 C 400.2,374.0 400.9,374.0 401.7,373.9 C 402.5,373.8 403.3,373.7 404.1,373.5 C 404.8,373.4 405.6,373.6 406.4,373.3 C 407.2,372.9 408.0,371.8 408.8,371.4 C 409.5,371.0 410.3,371.1 411.1,370.8 C 411.9,370.6 412.7,370.1 413.4,369.9 C 414.2,369.7 415.0,369.8 415.8,369.6 C 416.6,369.4 417.3,368.9 418.1,368.7 C 418.9,368.5 419.7,368.5 420.5,368.4 C 421.2,368.3 422.0,368.3 422.8,368.1 C 423.6,367.9 424.4,367.6 425.2,367.1 C 425.9,366.6 426.7,365.5 427.5,365.0 C 428.3,364.5 429.1,364.2 429.8,363.9 C 430.6,363.5 431.4,363.4 432.2,362.7 C 433.0,361.9 433.8,360.2 434.5,359.4 C 435.3,358.6 436.1,358.3 436.9,357.8 C 437.7,357.3 438.4,357.2 439.2,356.5 C 440.0,355.8 440.8,354.2 441.6,353.5 C 442.3,352.8 443.1,352.7 443.9,352.3 C 444.7,351.9 445.5,351.5 446.2,351.1 C 447.0,3Line truncated
|
||||
<circle cx="765.0" cy="99.0" r="5" fill="#dd4528" stroke="#0d1117" stroke-width="2" filter="url(#xkcdify)"/>
|
||||
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6652</text>
|
||||
<a href="https://github.com/carsteneu/mystarhistory" target="_blank" rel="noopener"><text x="763" y="527" font-size="13" font-family="Handlee, cursive" fill="#fff" text-anchor="end">Made with carsteneu/mystarhistory</text></a>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
Loaded 100 of 193 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user