Compare commits

..
319 changed files with 906 additions and 15522 deletions

No files matched your search

-15
View File
@@ -82,8 +82,6 @@ on:
- "scripts/resolve-et-bin-release.cjs"
- "scripts/resolve-mosh-bin-release.cjs"
- "scripts/tray-panel-layout.live.test.cjs"
- "scripts/serial-named-pipe.live.test.cjs"
- "scripts/fixtures/serial/**"
- "scripts/verify-linux-*.sh"
- "scripts/xterm-macos-column-selection.live.test.cjs"
- "skills/**"
@@ -253,10 +251,6 @@ jobs:
- name: Install deps
run: npm ci
- name: Test Windows serial pipe through native Electron
if: matrix.name == 'windows'
run: npx electron scripts/serial-named-pipe.live.test.cjs
- name: Test macOS Option column selection
if: matrix.name == 'macos'
run: npm run test:xterm-macos-selection
@@ -330,9 +324,6 @@ jobs:
APPLE_TEAM_ID: ${{ matrix.name == 'macos' && secrets.APPLE_TEAM_ID || '' }}
run: npm run ${{ matrix.pack_script }}
- name: Test packaged SSH cipher negotiation
run: node scripts/test-packaged-ssh-ciphers.cjs
- name: Test tray panel layout
run: npm run test:tray-panel-layout
@@ -634,9 +625,6 @@ jobs:
done
npm run pack:linux-x64
- name: Test packaged SSH cipher negotiation
run: node scripts/test-packaged-ssh-ciphers.cjs
- name: Verify packaged node-pty Linux runtime
run: bash scripts/ensure-node-pty-linux.sh verify x64
@@ -771,9 +759,6 @@ jobs:
ELECTRON_BUILDER_PUBLISH: "never"
run: npm run pack:linux-arm64
- name: Test packaged SSH cipher negotiation
run: node scripts/test-packaged-ssh-ciphers.cjs
- name: Verify packaged node-pty Linux runtime
run: bash scripts/ensure-node-pty-linux.sh verify arm64
-13
View File
@@ -52,11 +52,6 @@ jobs:
- name: Test
run: npm test
- name: Test SSH cipher negotiation in Electron's crypto runtime
env:
NETCATTY_SSH_TEST_RUNTIME: electron
run: node --test electron/bridges/sshChaChaTransport.test.cjs
- name: Test browser first-link lifecycle
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox electron/hostBrowser/hostBrowser.firstLink.live.test.cjs
@@ -96,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
@@ -143,10 +135,5 @@ jobs:
- name: Build
run: npm run build
- name: Test SSH cipher negotiation in Windows Electron's crypto runtime
env:
NETCATTY_SSH_TEST_RUNTIME: electron
run: node --test electron/bridges/sshChaChaTransport.test.cjs
- name: Exercise two SSH password prompts in the desktop app
run: node scripts/terminal-broadcast-ssh.live.cjs
+1 -3
View File
@@ -181,9 +181,7 @@ Organize and navigate your hosts using the view that best fits the moment: grid
## Monitoring View
Inspect CPU, memory, disk, network, and latency readings, with GPU details where available. Choose visible columns, resize them, select refresh intervals down to 1 second, and open a terminal directly from a monitored host.
Disk I/O shows aggregate read/write throughput for Linux hardware-backed block devices, without counting partitions or stacked virtual devices twice. Rates use two consecutive samples; `--` means unavailable or warming up, not zero activity. Disk I/O is currently unavailable on macOS; existing macOS monitoring is unchanged. The terminal system overview uses the same shared measurements.
Inspect CPU, memory, disk, network, and latency readings, with GPU details where available. Choose visible columns, resize them, adjust refresh controls, and open a terminal directly from a monitored host.
<img width="3364" height="1982" alt="image" src="docs/assets/readme/monitoring.webp" />
+1 -215
View File
@@ -7,9 +7,8 @@ import {
handleKeyboardInteractiveSubmitImpl,
handleTrayPanelConnectRequestImpl,
} from './app/AppHandlers.ts';
import { resolveHostAuth } from '../domain/sshAuth.ts';
import { resolveShellSetting } from '../lib/useDiscoveredShells.ts';
import type { Host, Identity } from '../types';
import type { Host } from '../types';
const baseHost: Host = {
id: 'host-1',
@@ -195,219 +194,6 @@ test('keyboard-interactive submit can save login password for the session host',
assert.equal(bridgeResponses.length, 1);
});
test('keyboard-interactive submit detaches keychain identity when remembering a password change', async () => {
const sharedIdentity: Identity = {
id: 'id-1',
label: 'shared',
username: 'root',
authMethod: 'password',
password: 'stale-keychain-password',
created: 0,
};
let hosts: Host[] = [{
...baseHost,
identityId: 'id-1',
password: 'old-host-password',
savePassword: false,
}];
let queue = [{
requestId: 'ki-rotate',
sessionId: 'session-1',
hostname: baseHost.hostname,
allowSavePassword: true,
}];
let identityUpdates = 0;
// resolveHostAuth prefers identity.password, so the stale keychain secret
// shadows whatever is already stored on the host.
assert.equal(
resolveHostAuth({ host: hosts[0], keys: [], identities: [sharedIdentity] }).password,
'stale-keychain-password',
);
await handleKeyboardInteractiveSubmitImpl(
() => ({
hosts,
identities: [sharedIdentity],
keyboardInteractiveQueue: queue,
netcattyBridge: {
get: () => ({
respondKeyboardInteractive: () => ({ success: true }),
}),
},
sessions: [{
id: 'session-1',
hostId: baseHost.id,
hostname: baseHost.hostname,
}],
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => {
queue = updater(queue);
},
t: (key: string) => key,
toast: { error: () => {} },
updateHosts: (nextHosts: Host[]) => {
hosts = nextHosts;
},
updateIdentities: () => {
identityUpdates += 1;
},
}),
'ki-rotate',
['rotated-password'],
'rotated-password',
);
assert.equal(hosts[0].identityId, '');
assert.equal(hosts[0].password, 'rotated-password');
assert.equal(hosts[0].savePassword, true);
assert.equal(hosts[0].username, baseHost.username);
assert.equal(identityUpdates, 0);
assert.equal(sharedIdentity.password, 'stale-keychain-password');
assert.equal(
resolveHostAuth({ host: hosts[0], keys: [], identities: [sharedIdentity] }).password,
'rotated-password',
);
assert.deepEqual(queue, []);
});
test('keyboard-interactive submit snapshots a different keychain username when remembering a password', async () => {
const sharedIdentity: Identity = {
id: 'id-1',
label: 'shared',
username: 'deploy',
authMethod: 'password',
password: 'stale-keychain-password',
created: 0,
};
let hosts: Host[] = [{
...baseHost,
username: 'root',
identityId: 'id-1',
password: 'old-host-password',
savePassword: false,
}];
let queue = [{
requestId: 'ki-rotate-user',
sessionId: 'session-1',
hostname: baseHost.hostname,
allowSavePassword: true,
}];
let identityUpdates = 0;
await handleKeyboardInteractiveSubmitImpl(
() => ({
hosts,
identities: [sharedIdentity],
keyboardInteractiveQueue: queue,
netcattyBridge: {
get: () => ({
respondKeyboardInteractive: () => ({ success: true }),
}),
},
sessions: [{
id: 'session-1',
hostId: baseHost.id,
hostname: baseHost.hostname,
}],
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => {
queue = updater(queue);
},
t: (key: string) => key,
toast: { error: () => {} },
updateHosts: (nextHosts: Host[]) => {
hosts = nextHosts;
},
updateIdentities: () => {
identityUpdates += 1;
},
}),
'ki-rotate-user',
['new-password'],
'new-password',
);
assert.equal(hosts[0].identityId, '');
assert.equal(hosts[0].password, 'new-password');
assert.equal(hosts[0].savePassword, true);
assert.equal(hosts[0].username, 'deploy');
assert.equal(identityUpdates, 0);
assert.equal(sharedIdentity.username, 'deploy');
assert.equal(sharedIdentity.password, 'stale-keychain-password');
const resolved = resolveHostAuth({ host: hosts[0], keys: [], identities: [sharedIdentity] });
assert.equal(resolved.password, 'new-password');
assert.equal(resolved.username, 'deploy');
assert.deepEqual(queue, []);
});
test('keyboard-interactive submit fills a blank host username from the keychain identity', async () => {
const sharedIdentity: Identity = {
id: 'id-1',
label: 'shared',
username: 'alice',
authMethod: 'password',
password: 'stale-keychain-password',
created: 0,
};
let hosts: Host[] = [{
...baseHost,
username: '',
identityId: 'id-1',
password: 'old-host-password',
savePassword: false,
}];
let queue = [{
requestId: 'ki-blank-user',
sessionId: 'session-1',
hostname: baseHost.hostname,
allowSavePassword: true,
}];
let identityUpdates = 0;
await handleKeyboardInteractiveSubmitImpl(
() => ({
hosts,
identities: [sharedIdentity],
keyboardInteractiveQueue: queue,
netcattyBridge: {
get: () => ({
respondKeyboardInteractive: () => ({ success: true }),
}),
},
sessions: [{
id: 'session-1',
hostId: baseHost.id,
hostname: baseHost.hostname,
}],
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => {
queue = updater(queue);
},
t: (key: string) => key,
toast: { error: () => {} },
updateHosts: (nextHosts: Host[]) => {
hosts = nextHosts;
},
updateIdentities: () => {
identityUpdates += 1;
},
}),
'ki-blank-user',
['new-password'],
'new-password',
);
assert.equal(hosts[0].identityId, '');
assert.equal(hosts[0].username, 'alice');
assert.equal(hosts[0].password, 'new-password');
assert.equal(hosts[0].savePassword, true);
assert.equal(identityUpdates, 0);
assert.equal(sharedIdentity.username, 'alice');
assert.equal(sharedIdentity.password, 'stale-keychain-password');
const resolved = resolveHostAuth({ host: hosts[0], keys: [], identities: [sharedIdentity] });
assert.equal(resolved.password, 'new-password');
assert.equal(resolved.username, 'alice');
assert.deepEqual(queue, []);
});
test('keyboard-interactive submit does not save secondary password when allowSavePassword is false', async () => {
let hosts: Host[] = [{
...baseHost,
+5 -23
View File
@@ -393,7 +393,6 @@ export async function handleKeyboardInteractiveSubmitImpl(
const {
hosts,
hostsRef,
identities,
keyboardInteractiveQueue,
sessions,
setKeyboardInteractiveQueue,
@@ -425,27 +424,12 @@ export async function handleKeyboardInteractiveSubmitImpl(
// Save password to host if requested - never for second-factor / EDR prompts
// (allowSavePassword === false) so a secondary secret cannot overwrite the
// host login password (#2150 / Codex review on #2151).
// Detach a Keychain identity when one is attached. resolveHostAuth prefers
// identity.password and identity.username, so snapshot the identity username
// onto the host before clearing identityId (#3982). Same as
// applyVaultHostUpdate's password detach. Do not mutate the identity.
if (savePassword && host && request?.allowSavePassword !== false) {
updateHosts(latestHosts.map((h: Host) => {
if (h.id !== host.id) return h;
const identity = h.identityId
? identities?.find((item: { id?: string; username?: string }) => item.id === h.identityId)
: undefined;
const detachedUsername = identity?.username?.trim();
return {
...h,
password: savePassword,
savePassword: true,
...(h.identityId ? {
identityId: '',
...(detachedUsername ? { username: detachedUsername } : {}),
} : {}),
};
}));
updateHosts(latestHosts.map((h: Host) => h.id === host.id ? {
...h,
password: savePassword,
savePassword: true,
} : h));
}
// Remove from queue by requestId
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
@@ -1180,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,
@@ -1200,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);
});
}
+8 -14
View File
@@ -56,7 +56,6 @@ import {
} from '../../domain/terminalAppearance';
import { selectConnectionLogForTerminalDataCapture } from '../../domain/connectionLog';
import { collectSessionIds } from '../../domain/workspace';
import { resolveAgentHostWorkspace } from '../../domain/agentHostWorkspace';
import { useHostOpenSessionReuse } from '../state/useHostOpenSessionReuse';
import type { PaneMagnificationController } from '../../domain/paneMagnification';
import { resolveCloseIntent } from '../state/resolveCloseIntent';
@@ -256,7 +255,6 @@ export function AppSideEffects() {
createLocalTerminal,
createSerialSession,
connectToHost,
appendHostToWorkspaceConfirmed,
closeSession,
closeSessions,
closeWorkspace,
@@ -716,7 +714,7 @@ export function AppSideEffects() {
}, [isVaultInitialized, pendingTrayPanelConnectHostIds]);
// Handle keyboard-interactive submit
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[], savePassword?: string) => { return handleKeyboardInteractiveSubmitImpl(() => ({ hosts, hostsRef, identities, keyboardInteractiveQueue, netcattyBridge, requestId, responses, savePassword, sessions, setKeyboardInteractiveQueue, t, toast, updateHosts }), requestId, responses, savePassword); }, [identities, keyboardInteractiveQueue, sessions, hosts, t, updateHosts]);
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[], savePassword?: string) => { return handleKeyboardInteractiveSubmitImpl(() => ({ hosts, hostsRef, keyboardInteractiveQueue, netcattyBridge, requestId, responses, savePassword, sessions, setKeyboardInteractiveQueue, t, toast, updateHosts }), requestId, responses, savePassword); }, [keyboardInteractiveQueue, sessions, hosts, t, updateHosts]);
// Handle keyboard-interactive cancel
const handleKeyboardInteractiveCancel = useCallback((requestId: string) => { return handleKeyboardInteractiveCancelImpl(() => ({ netcattyBridge, requestId, setKeyboardInteractiveQueue, t, toast }), requestId); }, [t]);
@@ -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" });
});
+11 -49
View File
@@ -344,26 +344,12 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
};
}, [enabled, hasRuntimeTunnel, hosts, sessions, portForwardingRules, workspaces]);
const transferQuitPendingRef = useRef<{ deadline: number; resumeTaskIds: Set<string> } | null>(null);
// Quit guard: block app exit while any editor tab has unsaved changes.
// Main process sends "app:query-dirty-editors"; we respond with the result.
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onCheckDirtyEditors) return;
const unsub = bridge.onCheckDirtyEditors(async (request) => {
const response = { requestId: request?.requestId };
// Another close request must not start a second pause/prompt while the
// first attempt is still pausing or awaiting a decision.
const previousAttempt = transferQuitPendingRef.current;
if (previousAttempt && Date.now() < previousAttempt.deadline) {
bridge.reportDirtyEditorsResult?.(true, "transfers", response);
return;
}
// An expired pause may never settle. Let a retry re-check current tasks
// (including a user's cancellation), without granting the old attempt
// permission to prompt or resume over that newer decision.
let attempt: typeof previousAttempt = null;
const unsub = bridge.onCheckDirtyEditors(async () => {
// Always report SOMETHING so the main process doesn't time out for
// 5 s on an unhandled exception. If we can't determine the state,
// fail open — losing unsaved work is bad, but stranding the user
@@ -388,44 +374,24 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
if (unfinishedTasks.length > 0) {
const resumable = unfinishedTasks.filter((task) => (
task.status === "transferring" || task.status === "queued"
|| previousAttempt?.resumeTaskIds.has(task.id)
));
// A slow pause must never turn the main-process timeout into
// permission to quit before the user has made a decision.
hasDirty = true;
reason = "transfers";
tasksToResume = resumable;
// A retry inherits the still-unfinished rows the expired attempt
// auto-paused. Their current status may already be pausing/paused.
attempt = {
deadline: request?.deadline ?? Date.now() + 5000,
resumeTaskIds: new Set(resumable.map((task) => task.id)),
};
transferQuitPendingRef.current = attempt;
bridge.reportDirtyEditorsResult?.(true, reason, { ...response, pending: true });
const paused = await Promise.allSettled(unfinishedTasks.map((task) => sftpTransferCenterStore.pause(task.id)));
if (transferQuitPendingRef.current !== attempt) return;
const expired = () => request && Date.now() >= request.deadline;
if (!expired() && paused.every((result) => result.status === "fulfilled")) {
hasDirty = !window.confirm(t('sftp.transferCenter.quitConfirm', { count: unfinishedTasks.length }));
// The native prompt can itself outlive the main-process deadline.
// An expired acceptance is not permission for a later close attempt.
if (expired()) hasDirty = true;
await Promise.allSettled(unfinishedTasks.map((task) => sftpTransferCenterStore.pause(task.id)));
hasDirty = !window.confirm(t('sftp.transferCenter.quitConfirm', { count: unfinishedTasks.length }));
if (hasDirty) {
// The user chose to stay. Main must hear that before any resume:
// resume can outlive the 5s dirty-check timeout, and a late reply
// lets the app quit anyway. The synchronous dialog is also skipped
// because this prompt was already answered.
reason = "transfers";
tasksToResume = resumable;
}
if (!hasDirty) {
reason = undefined;
tasksToResume = [];
}
} else {
// All prior work was finished/cancelled; invalidate its late pause.
transferQuitPendingRef.current = null;
}
}
} catch (err) {
console.error('[App] dirty-editors check failed:', err);
}
try {
bridge.reportDirtyEditorsResult?.(hasDirty, reason, response);
bridge.reportDirtyEditorsResult?.(hasDirty, reason);
} catch (err) {
// Reporting itself shouldn't throw, but if the IPC bridge is in a
// bad state we'd rather log than bubble out of the listener and
@@ -433,12 +399,8 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
console.error('[App] reportDirtyEditorsResult failed:', err);
}
if (tasksToResume.length > 0) {
// Report refusal before recovery, which may itself take a long time.
// A resume promise can span the entire transfer, so do not hold the
// close gate until it settles. A later close must be able to pause again.
void Promise.allSettled(tasksToResume.map((task) => sftpTransferCenterStore.resume(task.id)));
}
if (transferQuitPendingRef.current === attempt) transferQuitPendingRef.current = null;
});
return unsub;
}, [enabled, t]);
-1
View File
@@ -582,7 +582,6 @@ export const enAiMessages: Messages = {
'ai.chat.modelCatalogSourceCached': 'Cached · last success {time}',
'ai.chat.modelCatalogSourceCachedNoTime': 'Cached',
'ai.chat.modelCatalogSourcePresets': 'Built-in default list',
'ai.chat.refreshModelList': 'Refresh model list',
'ai.chat.noMatchingModels': 'No matching models',
'ai.chat.recent': 'Recent',
'ai.chat.viewAll': 'View All',
+1 -47
View File
@@ -556,8 +556,6 @@ Highlight the focused split pane:
'settings.appearance.uiFont.desc': 'Choose the font for the application interface',
'settings.appearance.windowOpacity': 'Window Opacity',
'settings.appearance.windowOpacity.desc': 'Adjust the transparency of the entire application window. Lower values also fade terminal text. Some Linux desktop environments may not support this.',
'settings.appearance.windowBackgroundBlur': 'Frosted glass background',
'settings.appearance.windowBackgroundBlur.desc': 'Blur the desktop behind the window. At 100% opacity the effect is hidden, but your choice is preserved.',
'settings.appearance.appIcon': 'App Icon',
'settings.appearance.appIcon.desc': 'Choose a dock/taskbar icon style to make Netcatty easier to spot when switching apps. On Windows, the notification area icon uses the same style. Applies at runtime; the installed app bundle icon is unchanged.',
'settings.appearance.appIcon.original': 'Original',
@@ -966,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',
-5
View File
@@ -1,9 +1,4 @@
export const enMonitorMessages = {
"monitor.diskIo": "Disk I/O",
"monitor.diskRead": "Read",
"monitor.diskWrite": "Write",
"monitor.diskIoHint": "Aggregate Linux hardware-backed block-device I/O, excluding partitions and stacked virtual devices. Unavailable until two valid samples; not supported on macOS.",
"monitor.actions": "Panel actions",
"monitor.unavailable": "Monitoring is unavailable. Try starting again when the connection is restored.",
"monitor.username": "Username",
-1
View File
@@ -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',
-4
View File
@@ -370,7 +370,6 @@ export const enVaultMessages: Messages = {
'sftp.transfers.openTargetFolder': 'Open target folder',
'sftp.transfers.openTargetFolderError': 'Could not open target folder',
'sftp.transfers.copyTargetPath': 'Copy target path',
'sftp.transfers.cleanupWarnings': 'Backup cleanup warnings: {count}. Show files for details.',
'sftp.transfers.copyTargetPathSuccess': 'Target path copied',
'sftp.transfers.copyTargetPathError': 'Could not copy target path',
'sftp.transfers.pathConflict.title': 'Transfer blocked',
@@ -975,9 +974,6 @@ export const enVaultMessages: Messages = {
'hostDetails.sshTimeouts.tcpConnect.desc': 'Seconds to wait for the network connection to this host.',
'hostDetails.sshTimeouts.authReady': 'SSH auth timeout',
'hostDetails.sshTimeouts.authReady.desc': 'Seconds to wait after connecting for SSH negotiation and authentication to finish.',
'hostDetails.sshInputCompatibility': 'Input compatibility: send character by character',
'hostDetails.sshInputCompatibility.desc': 'Try enabling this if Chinese input or short pastes lose characters.',
'hostDetails.sshInputCompatibility.reconnect': 'Saved changes take effect after reconnecting.',
'hostDetails.backspaceBehavior': 'Backspace Behavior',
'hostDetails.backspaceBehavior.default': 'Default',
'hostDetails.jumpHosts': 'Proxy via Hosts',
-21
View File
@@ -90,15 +90,6 @@ export const esAiMessages: Messages = {
'ai.providers.advancedParams.hint': 'Déjalo en blanco para usar los valores predeterminados del proveedor.',
'ai.providers.advancedParams.maxTokens.placeholder': 'p. ej. 4096',
'ai.providers.advancedParams.default': 'Predeterminado del proveedor',
'ai.providers.headers': 'Cabeceras HTTP personalizadas',
'ai.providers.headers.help': 'Cabeceras opcionales para este proveedor. Los valores se ocultan y se cifran al guardarlos.',
'ai.providers.headers.name': 'Nombre de la cabecera',
'ai.providers.headers.value': 'Valor de la cabecera',
'ai.providers.headers.add': 'Agregar cabecera',
'ai.providers.headers.remove': 'Quitar cabecera',
'ai.providers.headers.invalid': 'Usa cabeceras HTTP válidas y únicas. Content-Length y Transfer-Encoding se gestionan automáticamente.',
'ai.providers.headers.loadError': 'No se pudieron desbloquear las cabeceras guardadas. Cancela y vuelve a intentarlo después de desbloquear el llavero del sistema.',
'ai.providers.headers.saveError': 'No se pudo guardar de forma segura. Comprueba que el llavero del sistema esté disponible y vuelve a intentarlo.',
// AI Codex
'ai.codex': 'Codex',
@@ -289,14 +280,6 @@ export const esAiMessages: Messages = {
'ai.opencode.customPathPlaceholder': 'p. ej. /usr/local/bin/opencode',
'ai.opencode.check': 'Verificar',
'ai.opencode.resetPath': 'Restablecer',
'ai.opencode.v2.startupTimeout': 'OpenCode 2 no estuvo listo a tiempo. Confirma que `opencode serve` funciona en una terminal y vuelve a intentarlo.',
'ai.opencode.v2.exited': 'OpenCode 2 se detuvo antes de que el chat pudiera iniciarse.',
'ai.opencode.v2.unauthorized': 'OpenCode 2 rechazó la conexión. Vuelve a intentar el chat.',
'ai.opencode.v2.unavailable': 'No se pudo contactar con OpenCode 2. Comprueba que esté instalado y vuelve a intentarlo.',
'ai.opencode.v2.sessionFailed': 'OpenCode 2 no pudo completar este turno.',
'ai.opencode.v2.empty': 'OpenCode 2 devolvió una respuesta vacía. Ejecuta `opencode` en una terminal para configurar la autenticación y los modelos.',
'ai.opencode.v2.contextSkipped': 'OpenCode 2 no pudo aplicar el contexto de la conversación de Netcatty.',
'ai.opencode.v2.mcpSkipped': 'OpenCode 2 no pudo adjuntar una herramienta MCP. El chat continuará sin ella.',
// AI MiMo Code (CLI de Xiaomi MiMo Code — fork de OpenCode)
'ai.mimo.title': 'MiMo Code',
@@ -582,7 +565,6 @@ export const esAiMessages: Messages = {
'ai.chat.modelCatalogSourceCached': 'En caché · último éxito {time}',
'ai.chat.modelCatalogSourceCachedNoTime': 'En caché',
'ai.chat.modelCatalogSourcePresets': 'Lista predeterminada integrada',
'ai.chat.refreshModelList': 'Actualizar la lista de modelos',
'ai.chat.noMatchingModels': 'No hay modelos coincidentes',
'ai.chat.recent': 'Recientes',
'ai.chat.viewAll': 'Ver todo',
@@ -711,9 +693,6 @@ export const esAiMessages: Messages = {
'terminal.layer.splitHorizontal': 'Dividir arriba y abajo',
'terminal.layer.splitVertical': 'Dividir izquierda y derecha',
'terminal.layer.openInNewSplit': 'Abrir en una división nueva',
'terminal.layer.saveLayoutAsDefault': 'Guardar diseño como predeterminado',
'terminal.layer.layoutSavedAsDefault': 'Diseño guardado como predeterminado',
'terminal.layer.layoutSaveFailed': 'No se pudo guardar el diseño',
'terminal.layer.hostTree.search': 'Buscar hosts...',
'terminal.layer.hostTree.searchButton': 'Buscar',
'terminal.layer.hostTree.tagsButton': 'Filtrar por etiquetas',
-87
View File
@@ -84,32 +84,6 @@ export const esCoreMessages: Messages = {
'credentials.protectionUnavailable.message': 'Las contraseñas y claves guardadas no se pueden descifrar automáticamente en este dispositivo. Vuelve a ingresar las credenciales antes de conectarte.',
'credentials.protectionUnavailable.action': 'Abrir configuración',
// App Lock
'appLock.title': 'Netcatty está bloqueado',
'appLock.reason.default': 'Ingresa tu contraseña de bloqueo para continuar.',
'appLock.reason.startup': 'Netcatty se bloquea cada vez que se abre esta ventana.',
'appLock.reason.idle': 'Netcatty se bloqueó tras el tiempo de inactividad.',
'appLock.reason.manual': 'Netcatty se bloqueó manualmente.',
'appLock.passwordLabel': 'Contraseña de bloqueo',
'appLock.passwordPlaceholder': 'Ingresa la contraseña para desbloquear',
'appLock.unlock': 'Desbloquear',
'appLock.unlocking': 'Desbloqueando...',
'appLock.usePassword': 'Usar contraseña',
'appLock.error.emptyPassword': 'Ingresa la contraseña de bloqueo.',
'appLock.error.incorrectPassword': 'La contraseña de bloqueo es incorrecta.',
'appLock.systemUnlock.unlockWith': 'Desbloquear con {label}',
'appLock.systemUnlock.preparing': 'Preparando {label}...',
'appLock.systemUnlock.verifying': 'Esperando a {label}...',
'appLock.systemUnlock.error': 'No se completó el desbloqueo del sistema. Usa la contraseña de bloqueo para continuar.',
'appLock.logoLabel': 'Netcatty',
'appLock.reset.title': 'Restablecer el bloqueo de la aplicación',
'appLock.reset.description': 'Esto elimina la contraseña de bloqueo actual y desactiva el bloqueo de la aplicación. Crea una nueva contraseña desde Configuración cuando la necesites.',
'appLock.reset.cancel': 'Cancelar',
'appLock.reset.confirm': 'Restablecer el bloqueo de la aplicación',
'appLock.reset.resetting': 'Restableciendo...',
'appLock.reset.error': 'No se pudo restablecer el bloqueo de la aplicación. Inténtalo de nuevo.',
'topTabs.lockApp': 'Bloquear Netcatty',
// Settings shell
'settings.title': 'Configuración',
'settings.tab.application': 'Aplicación',
@@ -145,65 +119,6 @@ export const esCoreMessages: Messages = {
'settings.system.credentials.unavailableHint': 'Las credenciales cifradas en otro perfil de usuario o máquina no se pueden descifrar aquí. Vuelve a ingresar y guarda las credenciales en este dispositivo.',
'settings.system.credentials.portabilityHint': 'La sincronización en la nube es portátil porque usa tu cifrado de clave maestra. El cifrado local de safeStorage está limitado al dispositivo y al usuario.',
// Settings > System > App Lock
'settings.appLock.title': 'Bloqueo de la aplicación',
'settings.appLock.description': 'Solicita una contraseña de bloqueo local cuando Netcatty se abre, tras un periodo de inactividad o cuando lo bloqueas manualmente.',
'settings.appLock.setupTitle': 'Define una contraseña de bloqueo',
'settings.appLock.setupDescription': 'Crea la contraseña a continuación. El bloqueo de la aplicación se activa en cuanto se guarda.',
'settings.appLock.manageTitle': 'El bloqueo de la aplicación está listo',
'settings.appLock.enabledStatus': 'Activado. Netcatty se bloquea al iniciar, tras un periodo de inactividad y cuando lo bloqueas manualmente.',
'settings.appLock.disabledStatus': 'Contraseña guardada, pero el bloqueo de la aplicación está desactivado.',
'settings.appLock.disableTitle': 'Desactivar el bloqueo de la aplicación',
'settings.appLock.disableDescription': 'Ingresa la contraseña de bloqueo actual para dejar de bloquear Netcatty. La contraseña guardada se eliminará; para volver a activar el bloqueo tendrás que crear una nueva.',
'settings.appLock.disable': 'Desactivar el bloqueo de la aplicación',
'settings.appLock.disabling': 'Desactivando...',
'settings.appLock.setupPasswordTitle': 'Crear contraseña de bloqueo',
'settings.appLock.setupPasswordDescription': 'Esta contraseña es necesaria para desbloquear Netcatty y para cambiar o desactivar el bloqueo de la aplicación más adelante.',
'settings.appLock.changePasswordTitle': 'Cambiar la contraseña de bloqueo',
'settings.appLock.changePasswordDescription': 'Ingresa la contraseña de bloqueo actual y luego elige una nueva.',
'settings.appLock.enable': 'Activar el bloqueo de la aplicación',
'settings.appLock.enableDesc': 'Bloquea al iniciar y tras el tiempo de inactividad seleccionado.',
'settings.appLock.timeout': 'Tiempo de inactividad',
'settings.appLock.timeoutDesc': 'Bloquea tras un periodo de inactividad o al cerrar la ventana en la bandeja. Nunca omite ambos casos.',
'settings.appLock.timeout.0': 'Nunca bloquear automáticamente',
'settings.appLock.timeout.1': '1 minuto',
'settings.appLock.timeout.5': '5 minutos',
'settings.appLock.timeout.15': '15 minutos',
'settings.appLock.timeout.30': '30 minutos',
'settings.appLock.timeout.60': '60 minutos',
'settings.appLock.systemUnlock.label': 'Desbloqueo con {label}',
'settings.appLock.systemUnlock.desc': 'Usa {label} como forma alternativa de desbloquear Netcatty en este dispositivo.',
'settings.appLock.systemUnlock.unavailableDesc': '{label} no está disponible en este dispositivo ahora mismo.',
'settings.appLock.systemUnlock.unavailable': 'El desbloqueo del sistema no está disponible en este dispositivo.',
'settings.appLock.systemUnlock.locked': 'Desbloquea Netcatty antes de cambiar el desbloqueo del sistema.',
'settings.appLock.systemUnlock.autoPrompt.label': 'Abrir {label} automáticamente',
'settings.appLock.systemUnlock.autoPrompt.desc': 'Muestra el aviso de {label} automáticamente cuando Netcatty está esperando para desbloquearse.',
'settings.appLock.currentPassword': 'Contraseña actual',
'settings.appLock.currentPasswordPlaceholder': 'Necesaria para desactivar o reemplazar',
'settings.appLock.currentPasswordForDisablePlaceholder': 'Contraseña actual para desactivar el bloqueo de la aplicación',
'settings.appLock.currentPasswordForChangePlaceholder': 'Contraseña actual para cambiarla',
'settings.appLock.newPassword': 'Nueva contraseña',
'settings.appLock.newPasswordPlaceholder': 'Define la contraseña de bloqueo',
'settings.appLock.confirmPassword': 'Confirmar contraseña',
'settings.appLock.confirmPasswordPlaceholder': 'Repite la contraseña',
'settings.appLock.savePassword': 'Definir y activar',
'settings.appLock.savingPassword': 'Guardando...',
'settings.appLock.passwordSet': 'Contraseña configurada',
'settings.appLock.replacePassword': 'Reemplazar contraseña',
'settings.appLock.enableAfterPassword': 'Define una contraseña de bloqueo antes de activar el bloqueo de la aplicación.',
'settings.appLock.localOnlyHint': 'Bloqueo de privacidad local del dispositivo. No se sincroniza y no es una barrera de seguridad del sistema operativo.',
'settings.appLock.validation.currentRequired': 'Ingresa la contraseña de bloqueo actual.',
'settings.appLock.validation.newRequired': 'Ingresa una nueva contraseña de bloqueo.',
'settings.appLock.validation.confirmRequired': 'Confirma la nueva contraseña de bloqueo.',
'settings.appLock.validation.mismatch': 'Las contraseñas nuevas no coinciden.',
'settings.appLock.validation.incorrect': 'La contraseña de bloqueo actual es incorrecta.',
// Settings > System > Auto Launch
'settings.autoLaunch.title': 'Abrir al iniciar sesión',
'settings.autoLaunch.enabled': 'Iniciar al arrancar el sistema',
'settings.autoLaunch.enabledDesc': 'Inicia Netcatty automáticamente al iniciar sesión, oculto en la bandeja del sistema.',
'settings.autoLaunch.unsupportedDesc': 'No disponible en esta plataforma ni en una compilación de desarrollo.',
// Settings > System > Network Proxy
'settings.system.networkProxy.title': 'Proxy de red',
'settings.system.networkProxy.description': 'Proxy HTTP(S) para la sincronización en la nube y los proveedores de IA. Esto es independiente de los perfiles de ProxyJump / ProxyCommand de SSH.',
@@ -556,8 +471,6 @@ Resalta el panel dividido enfocado:
'settings.appearance.uiFont.desc': 'Elige la fuente para la interfaz de la aplicación',
'settings.appearance.windowOpacity': 'Opacidad de la ventana',
'settings.appearance.windowOpacity.desc': 'Ajusta la transparencia de toda la ventana de la aplicación. Los valores más bajos también atenúan el texto de la terminal. Algunos entornos de escritorio de Linux pueden no admitir esto.',
'settings.appearance.windowBackgroundBlur': 'Fondo de cristal esmerilado',
'settings.appearance.windowBackgroundBlur.desc': 'Desenfoca el escritorio detrás de la ventana. Con una opacidad del 100 %, el efecto no es visible, pero se conserva tu elección.',
'settings.appearance.appIcon': 'Ícono de la aplicación',
'settings.appearance.appIcon.desc': 'Elige un estilo de ícono para el dock o la barra de tareas que haga más fácil distinguir Netcatty al cambiar de aplicación. En Windows, el icono del área de notificación usa el mismo estilo. Se aplica en tiempo de ejecución; el ícono del paquete instalado no cambia.',
'settings.appearance.appIcon.original': 'Original',
-5
View File
@@ -1,9 +1,4 @@
export const esMonitorMessages = {
"monitor.diskIo": "E/S de disco",
"monitor.diskRead": "Lectura",
"monitor.diskWrite": "Escritura",
"monitor.diskIoHint": "E/S agregada de dispositivos de bloques físicos de Linux, sin contar particiones ni capas virtuales. No disponible hasta tener dos muestras válidas; no compatible con macOS.",
"monitor.actions": "Acciones del panel",
"monitor.unavailable": "La monitorización no está disponible. Iníciala de nuevo cuando se restablezca la conexión.",
"monitor.username": "Usuario",
@@ -33,9 +33,6 @@ export const esSystemManagerMessages: Messages = {
'systemManager.errors.loadPorts': 'No se pudieron cargar los puertos en escucha',
'systemManager.errors.loadServices': 'No se pudieron cargar los servicios de systemd',
'systemManager.errors.sshChannelUnavailable': 'El servidor se negó a abrir un nuevo canal de ejecución. Inténtalo de nuevo más tarde o vuelve a conectar este host.',
'systemManager.errors.nerdctlUnreachable': 'nerdctl no puede conectarse a containerd',
'systemManager.errors.clusterUnreachable': 'No se puede conectar al clúster para el contexto {{context}}',
'systemManager.errors.clusterUnreachableGeneric': 'No se puede conectar al clúster',
'systemManager.unsupportedRemote': 'Este host no permite canales SSH de ejecución adicionales, por lo que el administrador del sistema no está disponible. Abrir este panel ya no desconectará la terminal.',
'systemManager.overview.empty': 'Aún no hay datos de resumen del sistema.',
@@ -215,20 +212,6 @@ export const esSystemManagerMessages: Messages = {
'systemManager.k8s.restarts': '{{count}} reinicios',
'systemManager.k8s.chooseContainer': 'Elegir un contenedor',
'systemManager.k8s.noContext': 'No hay un contexto de Kubernetes en este host',
'systemManager.k8s.status.Running': 'En ejecución',
'systemManager.k8s.status.Pending': 'Pendiente',
'systemManager.k8s.status.Completed': 'Completado',
'systemManager.k8s.status.Succeeded': 'Con éxito',
'systemManager.k8s.status.Failed': 'Fallido',
'systemManager.k8s.status.Unknown': 'Desconocido',
'systemManager.k8s.status.CrashLoopBackOff': 'Bucle de fallos',
'systemManager.k8s.status.ContainerCreating': 'Creando contenedor',
'systemManager.k8s.status.Terminating': 'Terminando',
'systemManager.k8s.status.ImagePullBackOff': 'Reintentando descarga de imagen',
'systemManager.k8s.status.ErrImagePull': 'Error al descargar la imagen',
'systemManager.k8s.status.Error': 'Error',
'systemManager.k8s.status.Evicted': 'Desalojado',
'systemManager.k8s.status.OOMKilled': 'Sin memoria',
'systemManager.ports.unavailable': 'No se detectaron herramientas de puertos en escucha (ss / netstat) en este host.',
'systemManager.ports.loading': 'Cargando puertos en escucha…',
-41
View File
@@ -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',
-16
View File
@@ -370,7 +370,6 @@ export const esVaultMessages: Messages = {
'sftp.transfers.openTargetFolder': 'Abrir carpeta de destino',
'sftp.transfers.openTargetFolderError': 'No se pudo abrir la carpeta de destino',
'sftp.transfers.copyTargetPath': 'Copiar ruta de destino',
'sftp.transfers.cleanupWarnings': 'Avisos de limpieza de copias de seguridad: {count}. Muestra los archivos para ver los detalles.',
'sftp.transfers.copyTargetPathSuccess': 'Ruta de destino copiada',
'sftp.transfers.copyTargetPathError': 'No se pudo copiar la ruta de destino',
'sftp.transfers.pathConflict.title': 'Transferencia bloqueada',
@@ -965,9 +964,6 @@ export const esVaultMessages: Messages = {
'hostDetails.sshTimeouts.tcpConnect.desc': 'Segundos de espera para la conexión de red a este host.',
'hostDetails.sshTimeouts.authReady': 'Tiempo de espera de autenticación SSH',
'hostDetails.sshTimeouts.authReady.desc': 'Segundos de espera después de conectar para que terminen la negociación y la autenticación SSH.',
'hostDetails.sshInputCompatibility': 'Compatibilidad de entrada: enviar carácter por carácter',
'hostDetails.sshInputCompatibility.desc': 'Actívalo si se pierden caracteres al escribir chino o pegar textos cortos.',
'hostDetails.sshInputCompatibility.reconnect': 'Los cambios guardados se aplican al volver a conectar.',
'hostDetails.backspaceBehavior': 'Comportamiento de la tecla Retroceso',
'hostDetails.backspaceBehavior.default': 'Predeterminado',
'hostDetails.jumpHosts': 'Proxy mediante hosts',
@@ -1047,18 +1043,6 @@ export const esVaultMessages: Messages = {
'hostDetails.addTag': 'Agregar una etiqueta...',
'hostDetails.createTag': 'Crear etiqueta',
'hostDetails.createGroup': 'Crear grupo',
// Host connection test
'hostConnectionTest.title': 'Probar conexión',
'hostConnectionTest.test': 'Probar',
'hostConnectionTest.chainOf': 'Cadena {current} de {total}',
'hostConnectionTest.success': 'Conexión correcta',
'hostConnectionTest.successHint': 'El host aceptó tus credenciales. Puedes cerrar esta ventana.',
'hostConnectionTest.connecting': 'Conectando… tiempo de espera en {seconds}s',
'hostConnectionTest.failed': 'No se pudo conectar.',
'hostConnectionTest.retry': 'Reintentar',
'hostConnectionTest.credentialsUnavailable': 'Las credenciales guardadas no se pueden descifrar en este dispositivo. Vuelve a ingresarlas abajo.',
// Entradas de shell local (CMD) en la bóveda
'localShell.subtitle': 'Shell local',
'localShell.panel.title': 'Nueva shell local',
@@ -1,120 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DEFAULT_KEY_BINDINGS } from "../../../domain/models/keyBindings.ts";
import en from "./en.ts";
import es from "./es.ts";
import ru from "./ru.ts";
import zhCN from "./zh-CN.ts";
import zhTW from "./zh-TW.ts";
// Locales that are not at full key parity with `en` yet. They are tracked here
// so the remaining debt stays visible instead of silently rendering in English
// through the `DEFAULT_UI_LOCALE` fallback in `I18nProvider.resolveMessage`.
// Move a locale out of this list once it covers every `en` key.
const LOCALES_PENDING_PARITY = ["ru", "zh-CN", "zh-TW"] as const;
const hasKey = (messages: Record<string, string>, key: string) =>
Object.hasOwn(messages, key);
const interpolationTokens = (value: string) =>
[...value.matchAll(/\{\{\s*\w+\s*\}\}|\{\s*\w+\s*\}/g)]
.map((match) => match[0].replace(/[{}]/g, "").trim())
.sort();
test("es defines every message key that en defines", () => {
const missing = Object.keys(en).filter((key) => !hasKey(es, key));
assert.deepEqual(
missing,
[],
`es is missing ${missing.length} message keys`,
);
});
test("es defines no message keys that en does not define", () => {
const extra = Object.keys(es).filter((key) => !hasKey(en, key));
assert.deepEqual(
extra,
[],
`es defines ${extra.length} keys that no longer exist in en`,
);
});
test("es keeps the same interpolation placeholders as en", () => {
const mismatched: string[] = [];
for (const key of Object.keys(es)) {
if (!hasKey(en, key)) continue;
const expected = interpolationTokens(en[key] ?? "").join(",");
const actual = interpolationTokens(es[key] ?? "").join(",");
if (expected !== actual) mismatched.push(key);
}
assert.deepEqual(
mismatched,
[],
`es changes interpolation placeholders for ${mismatched.length} keys`,
);
});
test("every default shortcut has a label in en and es", () => {
const locales = [
{ name: "en", messages: en },
{ name: "es", messages: es },
];
for (const locale of locales) {
const missing = DEFAULT_KEY_BINDINGS.map(
(binding) => `settings.shortcuts.binding.${binding.id}`,
).filter((key) => !hasKey(locale.messages, key));
assert.deepEqual(
missing,
[],
`${locale.name} is missing ${missing.length} shortcut labels`,
);
}
});
test("en shortcut labels stay identical to DEFAULT_KEY_BINDINGS", () => {
// SettingsShortcutsTab falls back to `binding.label` only while the locale
// resolves the key to itself. Once `en` defines the key, the locale becomes
// the rendered source, so the two must not drift apart.
for (const binding of DEFAULT_KEY_BINDINGS) {
const key = `settings.shortcuts.binding.${binding.id}`;
assert.equal(
en[key],
binding.label,
`en label drifted from DEFAULT_KEY_BINDINGS for ${binding.id}`,
);
}
});
test("every locale outside the pending list is at full parity with en", () => {
const locales = [
{ name: "es", messages: es },
{ name: "ru", messages: ru },
{ name: "zh-CN", messages: zhCN },
{ name: "zh-TW", messages: zhTW },
];
for (const locale of locales) {
if ((LOCALES_PENDING_PARITY as readonly string[]).includes(locale.name)) {
continue;
}
const missing = Object.keys(en).filter(
(key) => !hasKey(locale.messages, key),
);
assert.deepEqual(
missing,
[],
`${locale.name} is missing ${missing.length} message keys`,
);
}
});
-1
View File
@@ -521,7 +521,6 @@ export const ruAiMessages: Messages = {
'ai.chat.modelCatalogSourceCached': 'Кэш · обновлён {time}',
'ai.chat.modelCatalogSourceCachedNoTime': 'Кэш',
'ai.chat.modelCatalogSourcePresets': 'Встроенный список моделей',
'ai.chat.refreshModelList': 'Обновить список моделей',
'ai.chat.noMatchingModels': 'Нет подходящих моделей',
'ai.chat.recent': 'Недавние',
'ai.chat.viewAll': 'Показать всё',
-2
View File
@@ -525,8 +525,6 @@ export const ruCoreMessages: Messages = {
'settings.appearance.uiFont.desc': 'Выберите шрифт для интерфейса приложения',
'settings.appearance.windowOpacity': 'Прозрачность окна',
'settings.appearance.windowOpacity.desc': 'Настройте прозрачность всего окна приложения. При низких значениях текст терминала тоже бледнеет. В некоторых средах Linux это может не поддерживаться.',
'settings.appearance.windowBackgroundBlur': 'Матовое стекло',
'settings.appearance.windowBackgroundBlur.desc': 'Размывает рабочий стол за окном. При непрозрачности 100 % эффект не виден, но ваш выбор сохраняется.',
'settings.appearance.appIcon': 'Значок приложения',
'settings.appearance.appIcon.desc': 'Выберите стиль значка для Dock/панели задач, чтобы Netcatty было проще найти при переключении приложений. В Windows значок области уведомлений использует тот же стиль. Применяется во время работы; значок установленного пакета не меняется.',
'settings.appearance.appIcon.original': 'Оригинал',
-5
View File
@@ -1,9 +1,4 @@
export const ruMonitorMessages = {
"monitor.diskIo": "Дисковый ввод-вывод",
"monitor.diskRead": "Чтение",
"monitor.diskWrite": "Запись",
"monitor.diskIoHint": "Суммарный ввод-вывод физических блочных устройств Linux, без разделов и виртуальных слоёв. Доступен после двух корректных замеров; macOS не поддерживается.",
"monitor.actions": "Действия панели",
"monitor.unavailable": "Мониторинг недоступен. Запустите его снова после восстановления связи.",
"monitor.username": "Пользователь",
-1
View File
@@ -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': 'Псевдотерминал',
-4
View File
@@ -413,7 +413,6 @@ export const ruVaultMessages: Messages = {
'sftp.transfers.openTargetFolder': 'Открыть целевую папку',
'sftp.transfers.openTargetFolderError': 'Не удалось открыть целевую папку',
'sftp.transfers.copyTargetPath': 'Копировать целевой путь',
'sftp.transfers.cleanupWarnings': 'Предупреждения об удалении резервных копий: {count}. Откройте список файлов для подробностей.',
'sftp.transfers.copyTargetPathSuccess': 'Целевой путь скопирован',
'sftp.transfers.copyTargetPathError': 'Не удалось скопировать целевой путь',
'sftp.transfers.pathConflict.title': 'Передача заблокирована',
@@ -1014,9 +1013,6 @@ export const ruVaultMessages: Messages = {
'hostDetails.sshTimeouts.tcpConnect.desc': 'Сколько секунд ждать сетевого подключения к этому хосту.',
'hostDetails.sshTimeouts.authReady': 'Тайм-аут SSH-входа',
'hostDetails.sshTimeouts.authReady.desc': 'Сколько секунд ждать согласования SSH и завершения аутентификации после подключения.',
'hostDetails.sshInputCompatibility': 'Совместимость ввода: отправка по символам',
'hostDetails.sshInputCompatibility.desc': 'Включите, если при вводе китайского текста или короткой вставке теряются символы.',
'hostDetails.sshInputCompatibility.reconnect': 'Сохранённые изменения применяются после повторного подключения.',
'hostDetails.backspaceBehavior': 'Поведение Backspace',
'hostDetails.backspaceBehavior.default': 'По умолчанию',
'hostDetails.jumpHosts': 'Прокси через хосты',
@@ -18,19 +18,12 @@ const LOCALIZED_SETTINGS_LOCALES = [
const APP_LOCK_LOCALES = [
{ name: "en", messages: en },
{ name: "es", messages: es },
{ name: "ru", messages: ru },
{ name: "zh-CN", messages: zhCN },
{ name: "zh-TW", messages: zhTW },
];
const ALL_SETTINGS_LOCALES = [
{ name: "en", messages: en },
...LOCALIZED_SETTINGS_LOCALES,
{ name: "ru", messages: ru },
];
test("localized settings include names for every default shortcut", () => {
for (const locale of ALL_SETTINGS_LOCALES) {
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
const missing = DEFAULT_KEY_BINDINGS
.map((binding) => `settings.shortcuts.binding.${binding.id}`)
.filter((key) => !locale.messages[key]);
@@ -1,13 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import en from "./en";
import es from "./es";
import ru from "./ru";
import zhCN from "./zh-CN";
import zhTW from "./zh-TW";
test("folder cleanup-warning counts are localized in every supported locale", () => {
for (const [locale, messages] of Object.entries({ en, es, ru, zhCN, zhTW })) {
assert.match(messages["sftp.transfers.cleanupWarnings"] ?? "", /\{count\}/, locale);
}
});
-1
View File
@@ -582,7 +582,6 @@ export const zhCNAiMessages: Messages = {
'ai.chat.modelCatalogSourceCached': '缓存 · 上次成功 {time}',
'ai.chat.modelCatalogSourceCachedNoTime': '缓存',
'ai.chat.modelCatalogSourcePresets': '内置默认列表',
'ai.chat.refreshModelList': '刷新模型列表',
'ai.chat.noMatchingModels': '没有匹配的模型',
'ai.chat.recent': '最近',
'ai.chat.viewAll': '查看全部',
-3
View File
@@ -560,8 +560,6 @@ Vaults 标签保持扁平,不使用这块背景。
'settings.appearance.uiFont.desc': '选择软件界面使用的字体',
'settings.appearance.windowOpacity': '窗口透明度',
'settings.appearance.windowOpacity.desc': '调节整个应用窗口的透明度,方便叠在其他内容上方。较低时终端文字也会变淡;部分 Linux 桌面环境可能不支持。',
'settings.appearance.windowBackgroundBlur': '毛玻璃背景',
'settings.appearance.windowBackgroundBlur.desc': '模糊窗口后方的桌面。100% 不透明时效果不可见,但会保留你的开关设置。',
'settings.appearance.appIcon': '应用图标',
'settings.appearance.appIcon.desc': '选择 Dock/任务栏图标风格,切换应用时更容易找到 Netcatty。Windows 通知区域图标会使用同一套风格。运行时生效,安装包内的图标不会改变。',
'settings.appearance.appIcon.original': '原始',
@@ -1037,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': '无法开始传输',
@@ -1,9 +1,4 @@
export const zhCNMonitorMessages = {
"monitor.diskIo": "磁盘 I/O",
"monitor.diskRead": "读取",
"monitor.diskWrite": "写入",
"monitor.diskIoHint": "Linux 底层块设备的汇总 I/O,不重复统计分区和叠加的虚拟设备。取得两次有效采样前不可用;暂不支持 macOS。",
"monitor.actions": "面板操作",
"monitor.unavailable": "监控暂不可用,连接恢复后请重新开始。",
"monitor.username": "用户名",
@@ -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': '虚拟终端',
-3
View File
@@ -384,9 +384,6 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.sshTimeouts.tcpConnect.desc': '等待与该主机建立网络连接的最长时间。',
'hostDetails.sshTimeouts.authReady': '认证超时(秒)',
'hostDetails.sshTimeouts.authReady.desc': '网络连接建立后,等待 SSH 协商和认证完成的最长时间。',
'hostDetails.sshInputCompatibility': '输入兼容模式:逐字发送',
'hostDetails.sshInputCompatibility.desc': '中文输入或短粘贴出现丢字时尝试开启。',
'hostDetails.sshInputCompatibility.reconnect': '保存后重新连接生效。',
'hostDetails.backspaceBehavior': 'Backspace 行为',
'hostDetails.backspaceBehavior.default': '默认',
'hostDetails.jumpHosts': '通过主机代理',
-1
View File
@@ -572,7 +572,6 @@ export const zhTWAiMessages: Messages = {
'ai.chat.modelCatalogSourceCached': '快取 · 上次成功 {time}',
'ai.chat.modelCatalogSourceCachedNoTime': '快取',
'ai.chat.modelCatalogSourcePresets': '內建預設清單',
'ai.chat.refreshModelList': '重新整理模型清單',
'ai.chat.noMatchingModels': '沒有符合的模型',
'ai.chat.recent': '最近',
'ai.chat.viewAll': '檢視全部',
-3
View File
@@ -562,8 +562,6 @@ Vaults 分頁維持扁平,不使用這塊背景。
'settings.appearance.uiFont.desc': '選擇軟體介面使用的字型',
'settings.appearance.windowOpacity': '視窗透明度',
'settings.appearance.windowOpacity.desc': '調節整個應用程式視窗的透明度,方便疊在其他內容上方。較低時終端文字也會變淡;部分 Linux 桌面環境可能不支援。',
'settings.appearance.windowBackgroundBlur': '毛玻璃背景',
'settings.appearance.windowBackgroundBlur.desc': '模糊視窗後方的桌面。100% 不透明時效果不可見,但會保留你的開關設定。',
'settings.appearance.appIcon': '應用程式圖示',
'settings.appearance.appIcon.desc': '選擇 Dock/工作列圖示風格,切換應用程式時更容易找到 Netcatty。Windows 通知區域圖示會使用同一套風格。執行時生效,安裝檔內的圖示不會改變。',
'settings.appearance.appIcon.original': '原始',
@@ -1039,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': '無法開始傳輸',
@@ -1,9 +1,4 @@
export const zhTWMonitorMessages = {
"monitor.diskIo": "磁碟 I/O",
"monitor.diskRead": "讀取",
"monitor.diskWrite": "寫入",
"monitor.diskIoHint": "Linux 底層區塊裝置的彙總 I/O,不重複統計分割區與疊加的虛擬裝置。取得兩次有效取樣前無法使用;暫不支援 macOS。",
"monitor.actions": "面板操作",
"monitor.unavailable": "監控暫時無法使用,連線恢復後請重新開始。",
"monitor.username": "使用者名稱",
@@ -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': '虛擬終端',
-3
View File
@@ -384,9 +384,6 @@ export const zhTWVaultMessages: Messages = {
'hostDetails.sshTimeouts.tcpConnect.desc': '等待與該主機建立網路連線的最長時間。',
'hostDetails.sshTimeouts.authReady': '驗證逾時(秒)',
'hostDetails.sshTimeouts.authReady.desc': '網路連線建立後,等待 SSH 交握和驗證完成的最長時間。',
'hostDetails.sshInputCompatibility': '輸入相容模式:逐字傳送',
'hostDetails.sshInputCompatibility.desc': '中文輸入或短貼上出現漏字時嘗試開啟。',
'hostDetails.sshInputCompatibility.reconnect': '儲存後重新連線生效。',
'hostDetails.backspaceBehavior': 'Backspace 行為',
'hostDetails.backspaceBehavior.default': '預設',
'hostDetails.jumpHosts': '透過主機代理',
+1 -14
View File
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge.ts";
import { editorTabStore } from "./editorTabStore.ts";
import { dockDetachedEditorTab, installEditorWindowSourceListeners, popOutEditorTab, saveDetachedEditorTab, subscribeEditorWindowDirtyCheck } from "./editorWindowClient.ts";
import { dockDetachedEditorTab, installEditorWindowSourceListeners, popOutEditorTab, saveDetachedEditorTab } from "./editorWindowClient.ts";
import { registerEditorSftpWriterScoped } from "./editorSftpBridge.ts";
import { releaseEditorTabSaveCoordinator } from "./editorTabSave.ts";
import type { EditorWindowSaveRequest, EditorWindowSaveResult } from "./editorWindowTypes.ts";
@@ -145,16 +145,3 @@ test("a closing destination rejects a popout without releasing source content",
assert.equal(tab.baselineContent, "v1");
assert.equal(editorTabStore.isDirty(tab.id), true);
});
test("detached editor dirty-check replies echo the main-process request", (t) => {
let query!: (request: { requestId: string; deadline: number }) => void;
const replies: unknown[] = [];
t.mock.method(netcattyBridge, "get", () => ({
onCheckDirtyEditors: (callback: typeof query) => { query = callback; return () => {}; },
reportDirtyEditorsResult: (...args: unknown[]) => replies.push(args),
}));
t.after(subscribeEditorWindowDirtyCheck(() => true));
query({ requestId: "detached-check", deadline: Date.now() + 5000 });
assert.deepEqual(replies, [[true, undefined, { requestId: "detached-check" }]]);
});
+2 -2
View File
@@ -200,7 +200,7 @@ export function reportEditorWindowCloseTabsResult(
export function subscribeEditorWindowDirtyCheck(cb: () => boolean): () => void {
const bridge = netcattyBridge.get();
if (!bridge?.onCheckDirtyEditors) return () => {};
return bridge.onCheckDirtyEditors((request) => {
return bridge.onCheckDirtyEditors(() => {
let hasDirty = false;
try {
hasDirty = cb() === true;
@@ -208,7 +208,7 @@ export function subscribeEditorWindowDirtyCheck(cb: () => boolean): () => void {
hasDirty = false;
}
try {
bridge.reportDirtyEditorsResult?.(hasDirty, undefined, { requestId: request?.requestId });
bridge.reportDirtyEditorsResult?.(hasDirty);
} catch {
// ignore
}
-255
View File
@@ -1,255 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { decideHiddenTabKeepCountAction } from "../../domain/terminalHibernate.ts";
import { retainLeavingTabAfterEviction } from "./hiddenTabEviction.ts";
import { terminalHiddenRendererStore } from "./terminalHiddenRendererStore.ts";
test("a refused eviction tries the next candidate, then full-hibernates", async () => {
const attempts: string[] = [];
const outcomes: string[] = [];
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["images", "plain"],
keepCount: 2,
stillWantsRetention: () => true,
softHide: () => outcomes.push("soft-hide"),
fullHibernate: () => outcomes.push("full-hibernate"),
evict: async (sessionId) => {
attempts.push(sessionId);
return sessionId === "plain";
},
});
assert.deepEqual(attempts, ["images", "plain"]);
assert.deepEqual(outcomes, ["soft-hide"]);
const blocked: string[] = [];
const blockedOutcomes: string[] = [];
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["images", "also-images"],
keepCount: 2,
stillWantsRetention: () => true,
softHide: () => blockedOutcomes.push("soft-hide"),
fullHibernate: () => blockedOutcomes.push("full-hibernate"),
evict: async (sessionId) => {
blocked.push(sessionId);
return false;
},
});
assert.deepEqual(blocked, ["images", "also-images"]);
assert.deepEqual(blockedOutcomes, ["full-hibernate"]);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
});
test("hibernate failure after the soft-hidden marker drops does not retain the leaving tab", async () => {
const attempts: string[] = [];
const outcomes: string[] = [];
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim"],
keepCount: 2,
stillWantsRetention: () => true,
softHide: () => outcomes.push("soft-hide"),
fullHibernate: () => outcomes.push("full-hibernate"),
evict: async (sessionId) => {
attempts.push(sessionId);
await Promise.resolve();
return false;
},
});
assert.deepEqual(attempts, ["victim"]);
assert.deepEqual(outcomes, ["full-hibernate"]);
const recovered: string[] = [];
const recoveredOutcomes: string[] = [];
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim", "next"],
keepCount: 2,
stillWantsRetention: () => true,
softHide: () => recoveredOutcomes.push("soft-hide"),
fullHibernate: () => recoveredOutcomes.push("full-hibernate"),
evict: async (sessionId) => {
recovered.push(sessionId);
await Promise.resolve();
return sessionId === "next";
},
});
assert.deepEqual(recovered, ["victim", "next"]);
assert.deepEqual(recoveredOutcomes, ["soft-hide"]);
});
test("a live keep count that still fits after eviction soft-hides the leaving tab", async () => {
let keepCount = 0;
const outcomes: string[] = [];
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim"],
keepCount,
resolveKeepCount: () => keepCount,
stillWantsRetention: () => true,
softHide: () => outcomes.push("soft-hide"),
fullHibernate: () => outcomes.push("full-hibernate"),
evict: async () => {
keepCount = 2;
await Promise.resolve();
return true;
},
});
assert.deepEqual(outcomes, ["soft-hide"]);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
});
test("a keep count dropped to zero during eviction full-hibernates the leaving tab", async () => {
let keepCount = 2;
const outcomes: string[] = [];
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim"],
keepCount,
resolveKeepCount: () => keepCount,
stillWantsRetention: () => true,
softHide: () => outcomes.push("soft-hide"),
fullHibernate: () => outcomes.push("full-hibernate"),
evict: async () => {
keepCount = 0;
await Promise.resolve();
return true;
},
});
assert.deepEqual(outcomes, ["full-hibernate"]);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
});
test("a keep count that no longer fits after eviction full-hibernates the leaving tab", async () => {
const sessions = ["victim", "other", "leaving"];
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
terminalHiddenRendererStore.markSoftHidden("other");
let keepCount = 2;
const outcomes: string[] = [];
try {
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim"],
keepCount,
resolveKeepCount: () => keepCount,
stillWantsRetention: () => true,
softHide: () => outcomes.push("soft-hide"),
fullHibernate: () => outcomes.push("full-hibernate"),
evict: async () => {
keepCount = 1;
await Promise.resolve();
return true;
},
});
assert.deepEqual(outcomes, ["full-hibernate"]);
assert.equal(terminalHiddenRendererStore.isSoftHidden("leaving"), false);
} finally {
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
}
});
test("a leaving tab that no longer wants retention is not soft-hidden or hibernated", async () => {
const outcomes: string[] = [];
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim"],
keepCount: 2,
stillWantsRetention: () => false,
softHide: () => outcomes.push("soft-hide"),
fullHibernate: () => outcomes.push("full-hibernate"),
evict: async () => true,
});
assert.deepEqual(outcomes, []);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
});
test("a pending reservation keeps a second tab from seeing spare capacity", async () => {
const sessions = ["victim", "other", "leaving"];
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
terminalHiddenRendererStore.markSoftHidden("victim");
terminalHiddenRendererStore.markSoftHidden("other");
const unsubscribe = terminalHiddenRendererStore.subscribe(() => {
if (!terminalHiddenRendererStore.consumeEvictionRequest("victim")) return;
terminalHiddenRendererStore.clearSoftHidden("victim");
});
const outcomes: string[] = [];
const pending = retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim"],
keepCount: 2,
stillWantsRetention: () => true,
softHide: () => {
terminalHiddenRendererStore.markSoftHidden("leaving");
outcomes.push("soft-hide");
},
fullHibernate: () => outcomes.push("full-hibernate"),
evict: (sessionId) => terminalHiddenRendererStore.evictSoftHidden(sessionId),
});
try {
assert.equal(terminalHiddenRendererStore.isSoftHidden("victim"), false);
assert.equal(terminalHiddenRendererStore.getSoftHiddenCount(), 1);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
assert.deepEqual(decideHiddenTabKeepCountAction({
keepCount: 2,
softHiddenCount: terminalHiddenRendererStore.getSoftHiddenOccupancy(),
sessionId: "second",
candidatesOldestFirst: terminalHiddenRendererStore.listSoftHiddenOldestFirst(),
}), { action: "await-eviction", candidates: ["other"] });
terminalHiddenRendererStore.settleEviction("victim", true);
await pending;
assert.deepEqual(outcomes, ["soft-hide"]);
assert.equal(terminalHiddenRendererStore.getSoftHiddenCount(), 2);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
} finally {
unsubscribe();
terminalHiddenRendererStore.settleEviction("victim", false);
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
await pending.catch(() => undefined);
}
});
test("two leaving tabs share one freed slot and stay within keep-count", async () => {
const sessions = ["victim", "other", "leaving-a", "leaving-b"];
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
terminalHiddenRendererStore.markSoftHidden("victim");
terminalHiddenRendererStore.markSoftHidden("other");
const unsubscribe = terminalHiddenRendererStore.subscribe(() => {
if (!terminalHiddenRendererStore.consumeEvictionRequest("victim")) return;
terminalHiddenRendererStore.clearSoftHidden("victim");
queueMicrotask(() => {
terminalHiddenRendererStore.settleEviction("victim", true);
});
});
const retained: string[] = [];
const hibernated: string[] = [];
const retain = (sessionId: string) => retainLeavingTabAfterEviction({
sessionId,
candidates: ["victim"],
keepCount: 2,
stillWantsRetention: () => true,
softHide: () => {
terminalHiddenRendererStore.markSoftHidden(sessionId);
retained.push(sessionId);
},
fullHibernate: () => hibernated.push(sessionId),
});
try {
await Promise.all([retain("leaving-a"), retain("leaving-b")]);
assert.equal(retained.length, 1);
assert.equal(hibernated.length, 1);
assert.equal(terminalHiddenRendererStore.getSoftHiddenCount(), 2);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
} finally {
unsubscribe();
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
}
});
-53
View File
@@ -1,53 +0,0 @@
import { terminalHiddenRendererStore } from "./terminalHiddenRendererStore.ts";
/**
* Walk keep-count victims until one hibernate completes, then retain the
* leaving tab only if its reserved slot still fits.
*
* The reservation is taken before the first await. Clearing a victim marker
* during wake would otherwise look like spare capacity to a second hidden-tab
* timer, and both tabs would soft-hide once the same eviction resolved.
*/
export async function retainLeavingTabAfterEviction(input: {
sessionId: string;
candidates: readonly string[];
/** Count captured when this leave started. Used only if resolveKeepCount is absent. */
keepCount: number;
/**
* Live keep-count read after eviction settles. Settings can change while the
* victim drains or serializes; retaining against the captured count would
* soft-hide a tab the user no longer wants kept.
*/
resolveKeepCount?: () => number;
stillWantsRetention: () => boolean;
softHide: () => void;
fullHibernate: () => void;
evict?: (sessionId: string) => Promise<boolean> | boolean;
}): Promise<void> {
const token = terminalHiddenRendererStore.reserveLeavingSlot(input.sessionId);
try {
const evict = input.evict ?? ((sessionId: string) => (
terminalHiddenRendererStore.evictSoftHidden(sessionId)
));
let completed = false;
for (const candidate of input.candidates) {
if (!candidate || candidate === input.sessionId) continue;
if (await evict(candidate)) {
completed = true;
break;
}
}
if (!input.stillWantsRetention()) return;
const keepCount = input.resolveKeepCount?.() ?? input.keepCount;
if (
completed
&& terminalHiddenRendererStore.leavingSlotStillFits(input.sessionId, keepCount)
) {
input.softHide();
return;
}
input.fullHibernate();
} finally {
terminalHiddenRendererStore.releaseLeavingSlot(input.sessionId, token);
}
}
@@ -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'
-7
View File
@@ -49,7 +49,6 @@ import {
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN,
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB,
STORAGE_KEY_WINDOW_OPACITY,
STORAGE_KEY_WINDOW_BACKGROUND_BLUR,
STORAGE_KEY_APP_ICON_VARIANT,
STORAGE_KEY_HTTP_NETWORK_PROXY,
STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
@@ -100,7 +99,6 @@ interface UseSettingsIpcSyncParams {
setIsHotkeyRecordingState: Dispatch<SetStateAction<boolean>>;
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
setWindowOpacity: (raw: unknown) => void;
setWindowBackgroundBlur: Dispatch<SetStateAction<boolean>>;
setAppIconVariant: Dispatch<SetStateAction<AppIconVariant>>;
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
setAutoLaunchEnabled: Dispatch<SetStateAction<boolean>>;
@@ -150,7 +148,6 @@ export function useSettingsIpcSync({
setIsHotkeyRecordingState,
setGlobalHotkeyEnabled,
setWindowOpacity,
setWindowBackgroundBlur,
setAppIconVariant,
setAutoUpdateEnabled,
setAutoLaunchEnabled,
@@ -282,9 +279,6 @@ export function useSettingsIpcSync({
if (key === STORAGE_KEY_WINDOW_OPACITY) {
setWindowOpacity(value);
}
if (key === STORAGE_KEY_WINDOW_BACKGROUND_BLUR && typeof value === 'boolean') {
setWindowBackgroundBlur((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_APP_ICON_VARIANT) {
const nextVariant = resolveAppIconVariant(value);
setAppIconVariant((prev) => (prev === nextVariant ? prev : nextVariant));
@@ -377,7 +371,6 @@ export function useSettingsIpcSync({
setFollowAppTerminalThemeState,
setGlobalHotkeyEnabled,
setWindowOpacity,
setWindowBackgroundBlur,
setAppIconVariant,
setHotkeyScheme,
setIsHotkeyRecordingState,
+4 -14
View File
@@ -53,7 +53,6 @@ import {
STORAGE_KEY_UI_LANGUAGE,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_WINDOW_OPACITY,
STORAGE_KEY_WINDOW_BACKGROUND_BLUR,
STORAGE_KEY_APP_ICON_VARIANT,
} from '../../infrastructure/config/storageKeys';
import { resolveAppIconVariant, type AppIconVariant } from '../../domain/appIconVariant';
@@ -117,7 +116,6 @@ interface UseSettingsStorageSyncParams {
globalHotkeyEnabled: boolean;
autoUpdateEnabled: boolean;
windowOpacity: number;
windowBackgroundBlur: boolean;
appIconVariant: AppIconVariant;
setTheme: Dispatch<SetStateAction<'dark' | 'light' | 'system'>>;
setLightUiThemeId: Dispatch<SetStateAction<string>>;
@@ -168,7 +166,6 @@ interface UseSettingsStorageSyncParams {
setExplorerContextMenuEnabledState: (enabled: boolean) => void;
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
setWindowOpacity: (raw: unknown) => void;
setWindowBackgroundBlur: Dispatch<SetStateAction<boolean>>;
setAppIconVariant: Dispatch<SetStateAction<AppIconVariant>>;
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
@@ -187,7 +184,7 @@ export function useSettingsStorageSync({
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, windowBackgroundBlur, appIconVariant,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, applyIncomingCustomAccent,
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
@@ -196,7 +193,7 @@ export function useSettingsStorageSync({
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setLocalShellSidePanelAutoOpenState, setLocalShellSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState, setExplorerContextMenuEnabledState,
setGlobalHotkeyEnabled, setWindowOpacity, setWindowBackgroundBlur, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
}: UseSettingsStorageSyncParams) {
@@ -211,7 +208,7 @@ export function useSettingsStorageSync({
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, windowBackgroundBlur, appIconVariant,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
});
settingsSnapshotRef.current = {
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentVersion,
@@ -221,7 +218,7 @@ export function useSettingsStorageSync({
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, windowBackgroundBlur, appIconVariant,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
};
// Listen for storage changes from other windows (cross-window sync)
@@ -526,12 +523,6 @@ export function useSettingsStorageSync({
if (e.key === STORAGE_KEY_WINDOW_OPACITY && e.newValue !== null) {
setWindowOpacity(e.newValue);
}
if (e.key === STORAGE_KEY_WINDOW_BACKGROUND_BLUR && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.windowBackgroundBlur) {
setWindowBackgroundBlur(newValue);
}
}
if (e.key === STORAGE_KEY_APP_ICON_VARIANT && e.newValue !== null) {
const newValue = resolveAppIconVariant(e.newValue);
if (newValue !== s.appIconVariant) {
@@ -575,7 +566,6 @@ export function useSettingsStorageSync({
setGlobalHotkeyEnabled,
setHostClickBehaviorState,
setWindowOpacity,
setWindowBackgroundBlur,
setAppIconVariant,
setHotkeyScheme,
setLightUiThemeId,
@@ -163,9 +163,6 @@ export async function restoreSftpTransferHistoryCooperatively(
const child = parentChildren[childIndex];
if (
child.status === "completed"
// Match canonical pruning: warning rows carry recovery paths that
// cannot be represented by the directory's compact manifest.
&& !child.warning
&& Number.isSafeInteger(child.directoryEntryIndex)
&& (child.directoryEntryIndex ?? checkpoint.coveredEntries) < checkpoint.coveredEntries
&& /^[a-f0-9]{64}$/.test(child.directoryEntryIdentity ?? "")
@@ -135,34 +135,6 @@ test("completed dispatch consumes only exact identity and never restarts", async
assert.equal(store.getTask(task.id)?.status, "completed");
});
test("successful invoke warnings reach the child before local completion can compact it", async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
const task = { ...child(), id: "warning-result-child", parentTaskId: "warning-result-root" };
const root = { ...task, id: task.parentTaskId, parentTaskId: undefined, isDirectory: true };
const warning = "Upload completed; remove backup /target/.netcatty-backup-a manually.";
store.upsertTasks([root, task]);
t.after(() => store.dismiss(root.id));
const result = await runTransferAndWaitForOwner(task, async () => ({ warning }), () => false);
assert.equal(result?.warning, warning);
assert.equal(store.getTask(task.id)?.warning, warning, "capture metadata before the caller finishes the child");
store.patchTask(task.id, { status: "completed", transferredBytes: 1 });
assert.equal(store.getTask(task.id)?.status, "completed", "warning-bearing children stay available for inspection");
assert.equal(store.getTask(task.id)?.warning, warning);
});
for (const outcome of [{ error: "write failed" }, { cancelled: true }]) {
test(`unsuccessful invoke results do not add cleanup warnings (${JSON.stringify(outcome)})`, async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
const task = { ...child(), id: `unsuccessful-warning-${"error" in outcome ? "failed" : "cancelled"}`, parentTaskId: undefined };
store.upsertTasks([task]);
t.after(() => store.dismiss(task.id));
await runTransferAndWaitForOwner(task, async () => ({ ...outcome, warning: "not a committed upload" }), () => false);
assert.equal(store.getTask(task.id)?.warning, undefined);
});
}
test("a resumed retry does not inherit failed settlement captured while admission was paused", async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
-1
View File
@@ -65,7 +65,6 @@ export interface FileWatchSyncedEvent {
localPath: string;
remotePath: string;
bytesWritten: number;
warning?: string;
}
export interface FileWatchErrorEvent {
@@ -156,10 +156,6 @@ export const createUploadTaskCallbacks = ({
phase: phase === "compressed" ? "transferring" : phase as TransferTask["phase"],
});
},
onTaskWarning: (taskId: string, warning: string) => {
// Preserve cleanup details before completion can compact a child row.
store.patchTask(taskId, { warning });
},
onTaskCompleted: (taskId: string, totalBytes: number) => {
store.patchTask(taskId, {
status: "completed" as TransferStatus,
+2 -12
View File
@@ -6,7 +6,7 @@ export class TransferOwnerChangedError extends Error {}
export type TransferOwnerObservation = ReturnType<typeof sftpTransferCenterStore.observeTaskSettlement>;
type StreamResult = { error?: string; cancelled?: boolean; superseded?: boolean; warning?: string } | undefined;
type StreamResult = { error?: string; cancelled?: boolean; superseded?: boolean } | undefined;
/** A live waiter owns bounded settlement evidence; persisted history stays compact. */
export async function runTransferAndWaitForOwner(
@@ -37,17 +37,7 @@ export async function runTransferAndWaitForOwner(
observation = sftpTransferCenterStore.observeTaskSettlement(task, undefined, onOwnerChanged);
const result = await start();
if (observation.hasIdentityConflict()) throw new TransferOwnerChangedError("Transfer identity changed while waiting for its owner");
if (!result?.superseded) {
// The invoke response may arrive before completed IPC. Preserve cleanup
// details before the caller completes (and may compact) this child.
if (result?.warning && !result.error && !result.cancelled) {
const latest = sftpTransferCenterStore.getTask(task.id);
if (latest && latest.status !== "failed" && latest.status !== "cancelled") {
sftpTransferCenterStore.patchTask(task.id, { warning: result.warning });
}
}
return result;
}
if (!result?.superseded) return result;
for (;;) {
if (shouldAbort()) throw new Error("Transfer cancelled");
if (observation.hasIdentityConflict()) throw new TransferOwnerChangedError("Transfer identity changed while waiting for its owner");
@@ -33,85 +33,6 @@ test("store aggregates owner snapshots without duplicating tasks", () => {
]);
});
test("completion cleanup warnings survive owner snapshots and restored history", () => {
let persisted: string | null = null;
const store = createSftpTransferCenterStore({ read: () => persisted, write: (value) => { persisted = value; } });
const source = makeTask("cleanup-warning");
const warning = "Upload completed; remove backup /target/.netcatty-backup-file manually.";
store.publishOwner("panel", [source]);
store.ingestBackgroundEvent({ type: "completed", transferId: source.id, transferred: 10, warning });
assert.equal(store.getTask(source.id)?.status, "completed");
assert.equal(store.getTask(source.id)?.warning, warning);
assert.equal(store.getTask(source.id)?.reconnectRequired, false);
store.publishOwner("panel", [{ ...source, status: "completed", transferredBytes: 10, endTime: Date.now() }]);
assert.equal(store.getTask(source.id)?.warning, warning, "a stale renderer completion cannot erase cleanup details");
const restored = createSftpTransferCenterStore({ read: () => persisted, write: () => {} });
assert.equal(restored.getTask(source.id)?.warning, warning);
assert.equal(restored.getTask(source.id)?.status, "completed");
assert.equal(restored.getTask(source.id)?.error, undefined);
});
test("invoke cleanup warnings survive stale live and completion snapshots until an explicit restart", () => {
const store = createSftpTransferCenterStore();
const source = makeTask("invoke-warning-snapshot");
const warning = "Upload completed; remove backup /target/.netcatty-backup-file manually.";
store.publishOwner("panel", [source]);
store.patchTask(source.id, { warning });
store.publishOwner("panel", [{ ...source, transferredBytes: 9 }]);
assert.equal(store.getTask(source.id)?.warning, warning);
store.publishOwner("panel", [{ ...source, status: "completed", transferredBytes: 10, endTime: Date.now() }]);
assert.equal(store.getTask(source.id)?.warning, warning);
store.publishOwner("panel", [{ ...source, status: "queued", transferredBytes: 0, checkpointBytes: 0 }]);
assert.equal(store.getTask(source.id)?.warning, undefined, "new attempts do not inherit cleanup warnings");
});
test("a clean completed event cannot erase a warning already returned by the invoke", () => {
const store = createSftpTransferCenterStore();
const source = makeTask("invoke-warning-event");
const warning = "Upload completed; remove backup /target/.netcatty-backup-file manually.";
store.publishOwner("panel", [source]);
store.patchTask(source.id, { warning });
store.ingestBackgroundEvent({ type: "completed", transferId: source.id, transferred: 10 });
assert.equal(store.getTask(source.id)?.status, "completed");
assert.equal(store.getTask(source.id)?.warning, warning);
});
test("late completion enriches a locally completed task with its cleanup warning", () => {
let persisted: string | null = null;
const store = createSftpTransferCenterStore({ read: () => persisted, write: (value) => { persisted = value; } });
const source = { ...makeTask("result-first-warning"), phase: "transferring" as const };
const warning = "Upload completed; remove backup /target/.netcatty-backup-file manually.";
const endTime = Date.now();
store.publishOwner("panel", [source]);
// The invoke response can settle the renderer before the completed IPC arrives.
store.patchTask(source.id, { status: "completed", transferredBytes: 10, endTime });
let notifications = 0;
const unsubscribe = store.subscribe(() => { notifications += 1; });
store.ingestBackgroundEvent({ type: "completed", transferId: source.id, transferred: 10, endedAt: endTime + 1, warning });
assert.equal(store.getTask(source.id)?.warning, warning);
assert.equal(store.getTask(source.id)?.status, "completed");
assert.equal(store.getTask(source.id)?.endTime, endTime, "late metadata does not replace the terminal outcome");
assert.equal(notifications, 1, "the warning is visible without another transfer triggering a render");
store.ingestBackgroundEvent({ type: "completed", transferId: source.id, transferred: 10 });
assert.equal(store.getTask(source.id)?.warning, warning, "a duplicate clean event cannot erase the warning");
const restored = createSftpTransferCenterStore({ read: () => persisted, write: () => {} });
assert.equal(restored.getTask(source.id)?.warning, warning);
unsubscribe();
});
for (const status of ["failed", "cancelled"] as const) {
test(`late cleanup warnings cannot replace a ${status} terminal outcome`, () => {
const store = createSftpTransferCenterStore();
const endTime = Date.now();
const task = { ...makeTask(`late-warning-${status}`, status), error: "original outcome", endTime };
store.publishOwner("panel", [task]);
store.ingestBackgroundEvent({ type: "completed", transferId: task.id, warning: "cleanup warning" });
assert.equal(store.getTask(task.id)?.status, status);
assert.equal(store.getTask(task.id)?.error, "original outcome");
assert.equal(store.getTask(task.id)?.warning, undefined);
});
}
test("store routes cancel/retry/prioritize/dismiss to the task owner; pause/resume are process-global", async () => {
const calls: string[] = [];
const store = createSftpTransferCenterStore();
@@ -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,
@@ -7,7 +7,6 @@ import {
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
STORAGE_KEY_WINDOW_OPACITY,
STORAGE_KEY_WINDOW_BACKGROUND_BLUR,
STORAGE_KEY_APP_ICON_VARIANT,
STORAGE_KEY_HTTP_NETWORK_PROXY,
} from '../../infrastructure/config/storageKeys';
@@ -18,8 +17,6 @@ import {
} from '../../domain/httpNetworkProxy';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { isMacPlatform } from '../../lib/utils';
import { isWindowBackgroundBlurActive } from '../../domain/windowBackgroundBlur';
import {
parseWindowOpacityRecord,
serializeWindowOpacityRecord,
@@ -50,7 +47,6 @@ interface UseSystemSettingsEffectsParams {
autoLaunchEnabled: boolean;
windowOpacityRecord: WindowOpacityRecord;
windowOpacityMutationSourceRef: MutableRefObject<WindowOpacityMutationSource>;
windowBackgroundBlur: boolean;
appIconVariant: AppIconVariant;
autoUpdateEnabled: boolean;
httpNetworkProxy: HttpNetworkProxySettings;
@@ -72,7 +68,6 @@ export function useSystemSettingsEffects({
autoLaunchEnabled,
windowOpacityRecord,
windowOpacityMutationSourceRef,
windowBackgroundBlur,
appIconVariant,
autoUpdateEnabled,
httpNetworkProxy,
@@ -305,49 +300,6 @@ export function useSystemSettingsEffects({
persistMountedRef,
]);
// Window chrome also applies in detached sessions, independently of the
// OS-level effects disabled there (tray, global shortcuts, autolaunch, ...).
// Only the windows that own settings persistence drive the shared native
// state; detached/editor windows consume it (see applySharedWindowSetting).
useEffect(() => {
if (!enabled) return;
localStorageAdapter.writeBoolean(STORAGE_KEY_WINDOW_BACKGROUND_BLUR, windowBackgroundBlur);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_WINDOW_BACKGROUND_BLUR, windowBackgroundBlur);
}, [
enabled,
windowBackgroundBlur,
notifySettingsChanged,
persistMountedRef,
]);
const windowBackgroundBlurActive = isMacPlatform() && isWindowBackgroundBlurActive(
windowBackgroundBlur,
windowOpacityRecord.opacity,
);
// Applies in every app-content window, including detached sessions: the
// transparent backing only looks right when the renderer class matches the
// native vibrancy the main process put on that window.
useEffect(() => {
if (typeof document === 'undefined') return;
document.documentElement.classList.toggle('window-background-blur', windowBackgroundBlurActive);
document.documentElement.style.setProperty('--window-content-opacity', String(windowOpacityRecord.opacity));
return () => {
document.documentElement.classList.remove('window-background-blur');
document.documentElement.style.removeProperty('--window-content-opacity');
};
}, [windowBackgroundBlurActive, windowOpacityRecord.opacity]);
// setWindowBackgroundBlur is a main-process handler that retargets every
// app-content window, so a single owner must drive it.
useEffect(() => {
if (!enabled) return;
netcattyBridge.get()?.setWindowBackgroundBlur?.(windowBackgroundBlur).catch((err) => {
console.warn('[WindowBackgroundBlur] Failed to apply macOS vibrancy:', err);
});
}, [enabled, windowBackgroundBlur]);
// Persist and sync app icon variant
useEffect(() => {
if (!enabled) return;
@@ -1,139 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { JSDOM } from 'jsdom';
import { STORAGE_KEY_WINDOW_BACKGROUND_BLUR } from '../../infrastructure/config/storageKeys';
import { DEFAULT_HTTP_NETWORK_PROXY } from '../../domain/httpNetworkProxy';
import { useSystemSettingsEffects } from './systemSettingsEffects';
type Harness = {
dom: JSDOM;
params: Parameters<typeof useSystemSettingsEffects>[0];
bridgeRequests: boolean[];
blurNotifications: unknown[];
restore: () => void;
};
function setUp(enabled: boolean, windowBackgroundBlur: boolean): Harness {
const dom = new JSDOM('<html><body><div id="root"></div></body></html>', { url: 'http://localhost' });
Object.defineProperty(dom.window.navigator, 'platform', { value: 'MacIntel' });
const previousGlobals = new Map<string, PropertyDescriptor | undefined>();
const replacements = {
window: dom.window,
document: dom.window.document,
navigator: dom.window.navigator,
localStorage: dom.window.localStorage,
IS_REACT_ACT_ENVIRONMENT: true,
};
for (const [key, value] of Object.entries(replacements)) {
previousGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { value, configurable: true });
}
const bridgeRequests: boolean[] = [];
const blurNotifications: unknown[] = [];
Object.assign(dom.window, {
netcatty: {
setWindowBackgroundBlur: async (value: boolean) => { bridgeRequests.push(value); },
},
});
const noop = () => {};
const params: Parameters<typeof useSystemSettingsEffects>[0] = {
enabled,
toggleWindowHotkey: '',
globalHotkeyEnabled: false,
closeToTray: false,
showTrayIcon: false,
autoLaunchEnabled: false,
windowOpacityRecord: { opacity: 0.7, preferredOpacity: 0.7, version: 1 },
windowOpacityMutationSourceRef: { current: 'local' },
windowBackgroundBlur,
appIconVariant: 'original',
autoUpdateEnabled: false,
httpNetworkProxy: DEFAULT_HTTP_NETWORK_PROXY,
persistMountedRef: { current: true },
setHotkeyRegistrationError: noop,
setAutoUpdateEnabled: noop,
setAppIconVariant: noop,
setAutoLaunchEnabled: noop,
setAutoLaunchSupported: noop,
notifySettingsChanged: (key, value) => {
if (key === STORAGE_KEY_WINDOW_BACKGROUND_BLUR) blurNotifications.push(value);
},
};
return {
dom,
params,
bridgeRequests,
blurNotifications,
restore: () => {
for (const [key, descriptor] of previousGlobals) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
dom.window.close();
},
};
}
const Probe = (props: Parameters<typeof useSystemSettingsEffects>[0]) => {
useSystemSettingsEffects(props);
return null;
};
test('opacity changes and remounts preserve the chosen blur preference', async () => {
const harness = setUp(true, true);
const { dom, params, bridgeRequests, blurNotifications, restore } = harness;
let renderer: ReactTestRenderer | undefined;
try {
await act(async () => { renderer = create(React.createElement(Probe, params)); });
assert.equal(document.documentElement.classList.contains('window-background-blur'), true);
const opaqueParams = { ...params, windowOpacityRecord: { ...params.windowOpacityRecord, opacity: 1, version: 2 } };
await act(async () => { renderer!.update(React.createElement(Probe, opaqueParams)); });
assert.equal(document.documentElement.classList.contains('window-background-blur'), false);
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_WINDOW_BACKGROUND_BLUR), 'true');
assert.deepEqual(bridgeRequests, [true]);
assert.deepEqual(blurNotifications, [true]);
await act(async () => { renderer!.unmount(); });
await act(async () => { renderer = create(React.createElement(Probe, opaqueParams)); });
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_WINDOW_BACKGROUND_BLUR), 'true');
await act(async () => { renderer!.update(React.createElement(Probe, params)); });
assert.equal(document.documentElement.classList.contains('window-background-blur'), true);
await act(async () => { renderer!.update(React.createElement(Probe, { ...params, windowBackgroundBlur: false })); });
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_WINDOW_BACKGROUND_BLUR), 'false');
assert.equal(document.documentElement.classList.contains('window-background-blur'), false);
assert.deepEqual(bridgeRequests, [true, true, false]);
} finally {
await act(async () => { renderer?.unmount(); });
restore();
}
});
test('detached session windows only apply the blur class, never own the setting', async () => {
// Peer session windows run with enableSystemEffects off. They must still
// paint the transparent backing, but persistence, cross-window broadcast and
// the (all-windows) native vibrancy IPC stay with the settings owners.
const harness = setUp(false, true);
const { dom, params, bridgeRequests, blurNotifications, restore } = harness;
let renderer: ReactTestRenderer | undefined;
try {
await act(async () => { renderer = create(React.createElement(Probe, params)); });
assert.equal(document.documentElement.classList.contains('window-background-blur'), true);
assert.equal(document.documentElement.style.getPropertyValue('--window-content-opacity'), '0.7');
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_WINDOW_BACKGROUND_BLUR), null);
assert.deepEqual(bridgeRequests, []);
assert.deepEqual(blurNotifications, []);
await act(async () => {
renderer!.update(React.createElement(Probe, { ...params, windowBackgroundBlur: false }));
});
assert.equal(document.documentElement.classList.contains('window-background-blur'), false);
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_WINDOW_BACKGROUND_BLUR), null);
assert.deepEqual(bridgeRequests, []);
assert.deepEqual(blurNotifications, []);
} finally {
await act(async () => { renderer?.unmount(); });
restore();
}
});
@@ -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;
-4
View File
@@ -54,8 +54,6 @@ export interface ServerStats {
swapTotal: number | null; // Total swap in MB
swapUsed: number | null; // Used swap in MB
topProcesses: ProcessInfo[]; // Top 10 processes by memory
diskReadSpeed?: number | null; // Aggregate Linux hardware-backed block I/O, bytes/sec
diskWriteSpeed?: number | null;
diskPercent: number | null; // Disk usage percentage for root partition
diskUsed: number | null; // Disk used in GB
diskTotal: number | null; // Total disk in GB
@@ -242,8 +240,6 @@ function normalizeServerStats(stats: Partial<ServerStats>): ServerStats {
diskPercent: stats.diskPercent ?? null,
diskUsed: stats.diskUsed ?? null,
diskTotal: stats.diskTotal ?? null,
diskReadSpeed: stats.diskReadSpeed ?? null,
diskWriteSpeed: stats.diskWriteSpeed ?? null,
disks: stats.disks || [],
netRxSpeed: stats.netRxSpeed || 0,
netTxSpeed: stats.netTxSpeed || 0,
-21
View File
@@ -1,5 +1,4 @@
import { MouseEvent,useCallback,useEffect,useMemo,useRef,useState } from 'react';
import { flushSync } from 'react-dom';
import { ConnectionLog,Host,SerialConfig,Snippet,TerminalSession,Workspace,WorkspaceNode,WorkspaceViewMode } from '../../domain/models';
import { addLogView, getLogViewTabId, removeLogView, type LogView } from './logViewState';
import {
@@ -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/);
});
+4 -18
View File
@@ -73,7 +73,6 @@ import {
STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
STORAGE_KEY_WINDOW_OPACITY,
STORAGE_KEY_WINDOW_BACKGROUND_BLUR,
STORAGE_KEY_APP_ICON_VARIANT,
STORAGE_KEY_AUTO_UPDATE_ENABLED,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
@@ -644,9 +643,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
windowOpacityMutationSourceRef.current = 'local';
setWindowOpacityRecord(toggleWindowOpacityRecord);
}, []);
const [windowBackgroundBlur, setWindowBackgroundBlur] = useState<boolean>(() => {
return readStoredString(STORAGE_KEY_WINDOW_BACKGROUND_BLUR) === 'true';
});
const [appIconVariant, setAppIconVariantState] = useState<AppIconVariant>(() => {
const stored = readStoredString(STORAGE_KEY_APP_ICON_VARIANT);
return resolveAppIconVariant(stored ?? DEFAULT_APP_ICON_VARIANT);
@@ -1278,7 +1274,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setIsHotkeyRecordingState,
setGlobalHotkeyEnabled,
setWindowOpacity: applyIncomingWindowOpacity,
setWindowBackgroundBlur,
setAppIconVariant,
setAutoUpdateEnabled,
setAutoLaunchEnabled,
@@ -1301,18 +1296,12 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setSshTransportIdleTtlMsState,
});
// Detached sessions share window chrome (opacity, background blur) and the
// shortcut configuration needed to operate it. Keep general settings
// listeners and OS effects disabled there.
// Detached sessions share opacity and the shortcut configuration needed to
// operate it. Keep general settings listeners and OS effects disabled there.
useEffect(() => {
if (enableSettingsSync) return;
const applySharedWindowSetting = (key: string, value: unknown) => {
if (key === STORAGE_KEY_WINDOW_OPACITY) applyIncomingWindowOpacity(value);
if (key === STORAGE_KEY_WINDOW_BACKGROUND_BLUR) {
// Storage events deliver the raw string; IPC delivers a boolean.
const incoming = typeof value === 'boolean' ? value : value === 'true';
setWindowBackgroundBlur(incoming);
}
if (key === STORAGE_KEY_HOTKEY_SCHEME && (value === 'disabled' || value === 'mac' || value === 'pc')) {
setHotkeyScheme(value);
}
@@ -1386,7 +1375,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, windowBackgroundBlur, appIconVariant,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode,
applyIncomingCustomAccent,
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
@@ -1396,7 +1385,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setLocalShellSidePanelAutoOpenState, setLocalShellSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled, setJmsDeepLinkEnabledState: applyIncomingJmsDeepLinkEnabled, setExplorerContextMenuEnabledState: applyIncomingExplorerContextMenuEnabled,
setGlobalHotkeyEnabled, setWindowOpacity: applyIncomingWindowOpacity, setWindowBackgroundBlur, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setGlobalHotkeyEnabled, setWindowOpacity: applyIncomingWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
});
@@ -1905,7 +1894,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
autoLaunchEnabled,
windowOpacityRecord,
windowOpacityMutationSourceRef,
windowBackgroundBlur,
appIconVariant,
autoUpdateEnabled,
httpNetworkProxy,
@@ -2285,8 +2273,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
windowOpacity,
setWindowOpacity,
toggleWindowOpacity,
windowBackgroundBlur,
setWindowBackgroundBlur,
appIconVariant,
setAppIconVariant,
rehydrateAllFromStorage,
-1
View File
@@ -335,7 +335,6 @@ export const useTerminalBackend = () => {
contextViewportSnapshot: string;
contextScrollbackSnapshot: string;
alternateScreen: boolean;
perCharacterInput?: boolean;
kittyKeyboardModeState?: NetcattyKittyKeyboardModeState;
kittyKeyboardProtocolEnabled?: boolean;
},
@@ -1,13 +1,8 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React, { startTransition, useState } from 'react';
import { flushSync } from 'react-dom';
import { createRoot, type Root } from 'react-dom/client';
import { JSDOM } from 'jsdom';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { readFileSync } from 'node:fs';
import { collectSidePanelPanes } from '../../domain/sidePanelLayout.ts';
import { useTerminalSidePanelLayoutState } from './useTerminalSidePanelLayoutState.ts';
@@ -135,231 +130,3 @@ test('closing the final pane removes only that terminal side panel state', async
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
test('selecting a side panel tool commits open tabs and layout in one render', async () => {
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
let state: ReturnType<typeof useTerminalSidePanelLayoutState> | null = null;
let renderer: ReactTestRenderer | null = null;
let renders = 0;
const Probe = () => {
state = useTerminalSidePanelLayoutState();
renders += 1;
return null;
};
try {
await act(async () => {
renderer = create(React.createElement(Probe));
});
renders = 0;
await act(async () => {
state?.selectSidePanelToolForTab('terminal-a', 'notes');
});
assert.equal(renders, 1, 'open tabs and layout share one commit');
assert.equal(state!.sidePanelOpenTabs.get('terminal-a'), 'notes');
const layout = state!.sidePanelLayouts.get('terminal-a')!;
assert.equal(
collectSidePanelPanes(layout.root).find((pane) => pane.id === layout.focusedPaneId)?.tool,
'notes',
);
await act(async () => {
state?.splitPane('terminal-a', 'ai', 'vertical', {
paneId: 'pane-ai',
splitId: 'split-a',
}, 400);
});
const beforeFocus = state!.sidePanelLayouts.get('terminal-a')!;
await act(async () => {
state?.selectSidePanelToolForTab('terminal-a', 'notes');
});
const focusedExisting = state!.sidePanelLayouts.get('terminal-a')!;
assert.deepEqual(
collectSidePanelPanes(focusedExisting.root).map((pane) => pane.tool),
['notes', 'ai'],
);
assert.equal(
collectSidePanelPanes(focusedExisting.root).find((pane) => pane.id === focusedExisting.focusedPaneId)?.tool,
'notes',
);
assert.equal(state!.sidePanelOpenTabs.get('terminal-a'), 'notes');
assert.notEqual(focusedExisting, beforeFocus);
assert.equal(focusedExisting.root, beforeFocus.root, 'focusing an open tool must not rebuild the split');
const settled = state!.sidePanelLayouts;
renders = 0;
await act(async () => {
state?.selectSidePanelToolForTab('terminal-a', 'notes');
});
assert.equal(renders, 0);
assert.equal(state!.sidePanelLayouts, settled);
} finally {
await act(async () => {
renderer?.unmount();
});
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
test('a pending side-panel write survives an urgent render on another tab', async () => {
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
Object.defineProperty(globalThis, 'window', { configurable: true, value: dom.window });
Object.defineProperty(globalThis, 'document', { configurable: true, value: dom.window.document });
const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
let state: ReturnType<typeof useTerminalSidePanelLayoutState> | null = null;
let bump: ((value: number) => void) | null = null;
const Probe = () => {
state = useTerminalSidePanelLayoutState();
const [, setTick] = useState(0);
bump = setTick;
return null;
};
const container = document.createElement('div');
document.body.appendChild(container);
const root: Root = createRoot(container);
const tools = (tabId: string, fromRef = false) => {
const layout = fromRef
? state?.sidePanelLayoutsRef.current.get(tabId)
: state?.sidePanelLayouts.get(tabId);
return layout ? collectSidePanelPanes(layout.root).map((pane) => pane.tool) : [];
};
try {
await act(async () => {
root.render(React.createElement(Probe));
});
await act(async () => {
state?.selectSidePanelToolForTab('terminal-a', 'notes');
state?.selectSidePanelToolForTab('terminal-b', 'scripts');
});
await act(async () => {
state?.splitPane('terminal-a', 'ai', 'vertical', {
paneId: 'pane-ai',
splitId: 'split-a',
}, 400);
});
await act(async () => {
startTransition(() => {
state?.selectSidePanelToolForTab('terminal-a', 'history');
});
flushSync(() => {
bump?.(1);
});
assert.deepEqual(tools('terminal-a'), ['notes', 'ai']);
assert.deepEqual(tools('terminal-a', true), ['notes', 'history']);
startTransition(() => {
state?.selectSidePanelToolForTab('terminal-b', 'sftp');
});
});
assert.deepEqual(tools('terminal-a'), ['notes', 'history']);
assert.equal(state!.sidePanelOpenTabs.get('terminal-a'), 'history');
assert.deepEqual(tools('terminal-b'), ['sftp']);
assert.equal(state!.sidePanelOpenTabs.get('terminal-b'), 'sftp');
} finally {
await act(async () => {
root.unmount();
});
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
dom.window.close();
}
});
test('a pending tool switch does not leave another tab ref behind an urgent open', async () => {
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
Object.defineProperty(globalThis, 'window', { configurable: true, value: dom.window });
Object.defineProperty(globalThis, 'document', { configurable: true, value: dom.window.document });
const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
let state: ReturnType<typeof useTerminalSidePanelLayoutState> | null = null;
const Probe = () => {
state = useTerminalSidePanelLayoutState();
return null;
};
const container = document.createElement('div');
document.body.appendChild(container);
const root: Root = createRoot(container);
const focusedTool = (tabId: string, fromRef = false) => {
const layout = fromRef
? state?.sidePanelLayoutsRef.current.get(tabId)
: state?.sidePanelLayouts.get(tabId);
if (!layout) return null;
return collectSidePanelPanes(layout.root).find((pane) => pane.id === layout.focusedPaneId)?.tool ?? null;
};
try {
await act(async () => {
root.render(React.createElement(Probe));
});
await act(async () => {
state?.selectSidePanelToolForTab('terminal-a', 'notes');
state?.selectSidePanelToolForTab('terminal-b', 'scripts');
});
await act(async () => {
startTransition(() => {
state?.selectSidePanelToolForTab('terminal-a', 'history');
});
flushSync(() => {
state?.setSidePanelOpenTabs((previous) => new Map(previous).set('terminal-b', 'browser'));
});
});
assert.equal(state!.sidePanelOpenTabs.get('terminal-a'), 'history');
assert.equal(state!.sidePanelOpenTabs.get('terminal-b'), 'browser');
assert.equal(state!.sidePanelOpenTabsRef.current.get('terminal-a'), 'history');
assert.equal(
state!.sidePanelOpenTabsRef.current.get('terminal-b'),
'browser',
'B stays rendered as browser while its ref still reports the pre-queue tool',
);
assert.equal(focusedTool('terminal-b', true), 'browser');
// handleSwitchSidePanelTab returns before select when the ref already
// matches the clicked tool. A stale scripts ref would ignore this click.
assert.notEqual(state!.sidePanelOpenTabsRef.current.get('terminal-b'), 'scripts');
await act(async () => {
state?.selectSidePanelToolForTab('terminal-b', 'scripts');
});
assert.equal(state!.sidePanelOpenTabs.get('terminal-b'), 'scripts');
assert.equal(state!.sidePanelOpenTabsRef.current.get('terminal-b'), 'scripts');
assert.equal(focusedTool('terminal-b'), 'scripts');
assert.equal(state!.sidePanelOpenTabs.get('terminal-a'), 'history');
} finally {
await act(async () => {
root.unmount();
});
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
dom.window.close();
}
});
test('side panel icon clicks write the tool through selectSidePanelToolForTab', () => {
const layerSource = readFileSync(new URL('../../components/TerminalLayer.tsx', import.meta.url), 'utf8');
const start = layerSource.indexOf('const handleSwitchSidePanelTab = useCallback');
const end = layerSource.indexOf('const handleFocusSidePanelPane = useCallback');
const body = layerSource.slice(start, end);
assert.match(body, /startTransition\(\(\) => \{\s*selectSidePanelToolForTab\(tabId, tab\);/);
assert.doesNotMatch(body, /setSidePanelOpenTabs/);
});
@@ -13,215 +13,55 @@ import {
type SidePanelTool,
} from '../../domain/sidePanelLayout';
function replaceMapEntry<T>(previous: Map<string, T>, key: string, value: T): Map<string, T> {
if (previous.get(key) === value) return previous;
const next = new Map(previous);
next.set(key, value);
return next;
}
function deleteMapEntry<T>(previous: Map<string, T>, key: string): Map<string, T> {
if (!previous.has(key)) return previous;
const next = new Map(previous);
next.delete(key);
return next;
}
function entryMatches<T>(left: Map<string, T>, right: Map<string, T>, key: string): boolean {
return left.has(key) === right.has(key) && left.get(key) === right.get(key);
}
/**
* Keep a ref aligned with committed state without dropping a write React has
* not rendered yet. An urgent update can rebase onto a transition: the
* transition updater stores an intermediate map in the ref, and the later
* updater changes only state. Copy committed entries the pending map did not
* change, and follow committed once it moves past that pending value.
*/
function syncTrackedMap<T>(
pending: Map<string, T>,
lastRendered: Map<string, T>,
committed: Map<string, T>,
): Map<string, T> {
if (pending === lastRendered) return committed;
if (pending === committed) return pending;
let changed = false;
const merged = new Map(pending);
for (const key of committed.keys()) {
if (entryMatches(pending, committed, key)) continue;
const pendingMatchesRendered = entryMatches(pending, lastRendered, key);
const committedMatchesRendered = entryMatches(committed, lastRendered, key);
if (pendingMatchesRendered || !committedMatchesRendered) {
merged.set(key, committed.get(key) as T);
changed = true;
}
}
for (const key of pending.keys()) {
if (committed.has(key)) continue;
if (entryMatches(pending, lastRendered, key)) {
merged.delete(key);
changed = true;
}
}
return changed ? merged : pending;
}
function publishQueuedMap<T>(
live: Map<string, T>,
capturedBase: Map<string, T>,
previous: Map<string, T>,
stateNext: Map<string, T>,
tabId: string,
value: T | null,
): Map<string, T> {
// The captured map is the synchronous write. Replaying it after an
// intervening render drops entries that render already merged. When the ref
// has moved ahead of this updater's base, reapply only this tab.
if (live === capturedBase || live === previous) return stateNext;
return value === null ? deleteMapEntry(live, tabId) : replaceMapEntry(live, tabId, value);
}
function reconcileSidePanelLayouts(
layouts: Map<string, SidePanelLayout>,
openTabs: Map<string, SidePanelTool>,
): Map<string, SidePanelLayout> | null {
let changed = false;
const next = new Map(layouts);
for (const tabId of layouts.keys()) {
if (!openTabs.has(tabId)) {
next.delete(tabId);
changed = true;
}
}
for (const [tabId, tool] of openTabs) {
const current = next.get(tabId);
const updated = current
? selectSidePanelTool(current, tool)
: createSidePanelLayout(tool, crypto.randomUUID());
if (updated !== current) {
next.set(tabId, updated);
changed = true;
}
}
return changed ? next : null;
}
export function useTerminalSidePanelLayoutState() {
const [sidePanelOpenTabs, setSidePanelOpenTabs] = useState<Map<string, SidePanelTool>>(new Map());
const [sidePanelLayouts, setSidePanelLayouts] = useState<Map<string, SidePanelLayout>>(new Map());
const sidePanelOpenTabsRef = useRef(sidePanelOpenTabs);
const sidePanelLayoutsRef = useRef(sidePanelLayouts);
// Last maps this hook rendered. A transition can update the refs before React
// commits them; an urgent render in between must not copy the stale state
// back over that pending write. Once the queue commits, entries the pending
// write did not touch still have to catch up, or the ref stays on the
// transition updater's intermediate map.
const renderedOpenTabsRef = useRef(sidePanelOpenTabs);
const renderedLayoutsRef = useRef(sidePanelLayouts);
sidePanelOpenTabsRef.current = syncTrackedMap(
sidePanelOpenTabsRef.current,
renderedOpenTabsRef.current,
sidePanelOpenTabs,
);
renderedOpenTabsRef.current = sidePanelOpenTabs;
sidePanelLayoutsRef.current = syncTrackedMap(
sidePanelLayoutsRef.current,
renderedLayoutsRef.current,
sidePanelLayouts,
);
renderedLayoutsRef.current = sidePanelLayouts;
sidePanelOpenTabsRef.current = sidePanelOpenTabs;
sidePanelLayoutsRef.current = sidePanelLayouts;
// Legacy and external open paths write the focused-tool map. Reconcile that
// public application boundary into the per-tab tree without disturbing the
// other panes in an existing layout. Skip the setState when the click path
// already stored this selection: scheduling an identical update still
// renders again after a paired openTabs write.
// other panes in an existing layout.
useEffect(() => {
const base = sidePanelLayoutsRef.current;
const reconciled = reconcileSidePanelLayouts(base, sidePanelOpenTabs);
if (!reconciled) return;
sidePanelLayoutsRef.current = reconciled;
setSidePanelLayouts((previous) => {
const next = previous === base
? reconciled
: reconcileSidePanelLayouts(previous, sidePanelOpenTabs);
if (!next) {
sidePanelLayoutsRef.current = previous;
return previous;
let changed = false;
const next = new Map(previous);
for (const tabId of previous.keys()) {
if (!sidePanelOpenTabs.has(tabId)) {
next.delete(tabId);
changed = true;
}
}
sidePanelLayoutsRef.current = next;
return next;
for (const [tabId, tool] of sidePanelOpenTabs) {
const current = next.get(tabId);
const updated = current
? selectSidePanelTool(current, tool)
: createSidePanelLayout(tool, crypto.randomUUID());
if (updated !== current) {
next.set(tabId, updated);
changed = true;
}
}
if (changed) sidePanelLayoutsRef.current = next;
return changed ? next : previous;
});
}, [sidePanelOpenTabs]);
const commitMap = useCallback((
tabId: string,
layout: SidePanelLayout,
openTab: SidePanelTool | null,
) => {
const layoutBase = sidePanelLayoutsRef.current;
const openTabBase = sidePanelOpenTabsRef.current;
const layouts = openTab === null
? deleteMapEntry(layoutBase, tabId)
: replaceMapEntry(layoutBase, tabId, layout);
const openTabs = openTab === null
? deleteMapEntry(openTabBase, tabId)
: replaceMapEntry(openTabBase, tabId, openTab);
const commitLayout = useCallback((tabId: string, layout: SidePanelLayout) => {
const layouts = new Map(sidePanelLayoutsRef.current).set(tabId, layout);
const focusedTool = getFocusedSidePanelPane(layout).tool;
const openTabs = new Map(sidePanelOpenTabsRef.current).set(tabId, focusedTool);
sidePanelLayoutsRef.current = layouts;
sidePanelOpenTabsRef.current = openTabs;
setSidePanelLayouts((previous) => {
const next = previous === layoutBase
? layouts
: (openTab === null ? deleteMapEntry(previous, tabId) : replaceMapEntry(previous, tabId, layout));
sidePanelLayoutsRef.current = publishQueuedMap(
sidePanelLayoutsRef.current,
layoutBase,
previous,
next,
tabId,
openTab === null ? null : layout,
);
return next;
});
setSidePanelOpenTabs((previous) => {
const next = previous === openTabBase
? openTabs
: (openTab === null ? deleteMapEntry(previous, tabId) : replaceMapEntry(previous, tabId, openTab));
sidePanelOpenTabsRef.current = publishQueuedMap(
sidePanelOpenTabsRef.current,
openTabBase,
previous,
next,
tabId,
openTab,
);
return next;
});
setSidePanelLayouts(layouts);
setSidePanelOpenTabs(openTabs);
}, []);
const commitLayout = useCallback((tabId: string, layout: SidePanelLayout) => {
commitMap(tabId, layout, getFocusedSidePanelPane(layout).tool);
}, [commitMap]);
// Click path writes the focused tool and the layout together. The effect
// above still reconciles external openTabs writes, and skips when
// selectSidePanelTool returns the layout this call just stored.
const selectSidePanelToolForTab = useCallback((tabId: string, tool: SidePanelTool) => {
const current = sidePanelLayoutsRef.current.get(tabId);
const updated = current
? selectSidePanelTool(current, tool)
: createSidePanelLayout(tool, crypto.randomUUID());
if (updated === current && sidePanelOpenTabsRef.current.get(tabId) === tool) return;
commitLayout(tabId, updated);
}, [commitLayout]);
const focusPane = useCallback((tabId: string, paneId: string) => {
const layout = sidePanelLayoutsRef.current.get(tabId);
if (!layout) return;
@@ -257,23 +97,25 @@ export function useTerminalSidePanelLayoutState() {
return false;
}
commitMap(tabId, layout, null);
const layouts = new Map(sidePanelLayoutsRef.current);
const openTabs = new Map(sidePanelOpenTabsRef.current);
layouts.delete(tabId);
openTabs.delete(tabId);
sidePanelLayoutsRef.current = layouts;
sidePanelOpenTabsRef.current = openTabs;
setSidePanelLayouts(layouts);
setSidePanelOpenTabs(openTabs);
return true;
}, [commitLayout, commitMap]);
}, [commitLayout]);
const resizeSplit = useCallback((tabId: string, splitId: string, sizes: number[]) => {
const layout = sidePanelLayoutsRef.current.get(tabId);
if (!layout) return;
const nextLayout = resizeSidePanelSplit(layout, splitId, sizes);
if (nextLayout === layout) return;
const base = sidePanelLayoutsRef.current;
const layouts = replaceMapEntry(base, tabId, nextLayout);
const layouts = new Map(sidePanelLayoutsRef.current).set(tabId, nextLayout);
sidePanelLayoutsRef.current = layouts;
setSidePanelLayouts((previous) => {
const next = previous === base ? layouts : replaceMapEntry(previous, tabId, nextLayout);
sidePanelLayoutsRef.current = next;
return next;
});
setSidePanelLayouts(layouts);
}, []);
return {
@@ -287,6 +129,5 @@ export function useTerminalSidePanelLayoutState() {
splitPane,
closePane,
resizeSplit,
selectSidePanelToolForTab,
};
}
+1 -1
View File
@@ -270,7 +270,7 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
stopTunnel: current.stopTunnel,
stopRuleTunnels: current.stopRuleTunnels,
openHost: current.openHost
? (host, isExternalMcpCall, options) => current.openHost!(host, isExternalMcpCall, options)
? (host, isExternalMcpCall) => current.openHost!(host, isExternalMcpCall)
: undefined,
findExistingSessionForHost: current.findExistingSessionForHost,
closeSession: current.closeSession
@@ -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\(/,
@@ -1,108 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { JSDOM } from "jsdom";
import { installBrowserLockFixture } from "../testSupport/browserLockFixture";
import type { Host } from "../../domain/models";
import { STORAGE_KEY_HOSTS } from "../../infrastructure/config/storageKeys";
// Exercise actual vault save/load/JSON import and sync boundaries, including
// the asynchronous encrypted-field adapter (without a native safeStorage key).
test("host input choices survive save, restart, duplicate, backup and cloud sync", async () => {
const dom = new JSDOM("", { url: "http://localhost" });
const globals = ["window", "localStorage", "CustomEvent", "IS_REACT_ACT_ENVIRONMENT", "navigator"];
const previous = new Map(globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
Object.defineProperties(globalThis, {
window: { value: dom.window, configurable: true },
localStorage: { value: dom.window.localStorage, configurable: true },
CustomEvent: { value: dom.window.CustomEvent, configurable: true },
IS_REACT_ACT_ENVIRONMENT: { value: true, configurable: true },
});
installBrowserLockFixture();
const { useVaultState } = await import("./useVaultState");
const { buildSyncPayload, applySyncPayload, buildLocalVaultPayload, applyLocalVaultPayload } = await import("../syncPayload");
const { buildVaultHostFromDraft } = await import("../../domain/vaultHostCreate");
const legacy: Host = { id: "legacy", label: "Legacy", hostname: "localhost", username: "agent", protocol: "ssh", tags: [], os: "linux" };
dom.window.localStorage.setItem(STORAGE_KEY_HOSTS, JSON.stringify([legacy]));
let state: ReturnType<typeof useVaultState>;
let renderer: ReactTestRenderer | undefined;
const Probe = () => { state = useVaultState({ syncConnectionLogTerminalData: false }); return null; };
const mount = async () => {
await act(async () => { renderer = create(React.createElement(Probe)); });
for (let attempt = 0; !state!.isInitialized && attempt < 50; attempt++) {
await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
}
assert.equal(state!.isInitialized, true);
await act(async () => { await state!.readPersistedHosts(); });
};
const choices = () => state!.hosts.map(host => [host.id, host.sshInputCompatibility]);
try {
await mount();
assert.deepEqual(choices(), [["legacy", true]]);
const created = buildVaultHostFromDraft({ hostname: "new.example.test", username: "agent" });
if (created.ok === false) throw Error(created.error);
assert.equal(created.ok, true);
const standard = { ...created.host, id: "standard" };
await act(async () => { await state!.updateHosts([...state!.hosts, standard]); });
assert.deepEqual(choices(), [["legacy", true], ["standard", false]]);
const duplicate = { ...state!.hosts[0], id: "copy", label: "Legacy copy" };
await act(async () => { await state!.updateHosts([...state!.hosts, duplicate]); });
await act(async () => { renderer!.unmount(); });
await mount();
assert.deepEqual(choices(), [["legacy", true], ["standard", false], ["copy", true]]);
// A later deliberate false must survive migration, including another boot.
await act(async () => { await state!.updateHosts(state!.hosts.map(host => host.id === "legacy" ? { ...host, sshInputCompatibility: false } : host)); });
const expected = [["legacy", false], ["standard", false], ["copy", true]];
const exported = JSON.stringify(state!.exportData());
await act(async () => { await state!.updateHosts([]); await state!.importDataFromString(exported); });
assert.deepEqual(choices(), expected);
// Use the real sync payload builder and apply importer, not JSON alone.
const cloud = JSON.parse(JSON.stringify(buildSyncPayload({ ...state!.exportData(), identities: state!.identities, knownHosts: state!.knownHosts })));
await act(async () => { await state!.updateHosts([]); await applySyncPayload(cloud, { importVaultData: state!.importDataFromString }); });
assert.deepEqual(choices(), expected);
const backup = JSON.parse(JSON.stringify(buildLocalVaultPayload({ ...state!.exportData(), identities: state!.identities, knownHosts: state!.knownHosts })));
await act(async () => { await state!.updateHosts([]); await applyLocalVaultPayload(backup, { importVaultData: state!.importDataFromString }); });
assert.deepEqual(choices(), expected);
await act(async () => { renderer!.unmount(); });
await mount();
assert.deepEqual(choices(), expected);
assert.deepEqual((await state!.readPersistedHosts()).map(host => [host.id, host.sshInputCompatibility]), expected);
// Older cloud snapshots enter through the same per-record migration.
cloud.hosts = [{ ...legacy, id: "cloud-legacy" }];
await act(async () => { await applySyncPayload(cloud, { importVaultData: state!.importDataFromString }); });
assert.deepEqual(choices(), [["cloud-legacy", true]]);
await act(async () => { await state!.updateHosts(state!.hosts.map(host => ({ ...host, sshInputCompatibility: false }))); });
await act(async () => { renderer!.unmount(); });
await mount();
assert.deepEqual(choices(), [["cloud-legacy", false]]);
// These drafts enter HostDetailsPanel as initialData, then the real vault
// save/normalization path. They must not be mistaken for legacy records.
const { buildSshDeepLinkHostDraft, parseSshDeepLink } = await import("../../domain/sshDeepLink");
const { normalizePluginImporterRecords } = await import("../../domain/pluginImporter");
const linkDraft = buildSshDeepLinkHostDraft(parseSshDeepLink("ssh://agent@new.test")!, { id: "new-link", now: 1 });
const imported = normalizePluginImporterRecords([undefined, false, true].map(choice => ({
type: "draft" as const, draft: { kind: "host" as const, value: {
hostname: `import-${choice}.test`, protocol: "ssh",
...(choice === undefined ? {} : { sshInputCompatibility: choice }),
} },
})));
assert.deepEqual(imported.errors, []);
const explicitCopy = { ...imported.hosts[2], id: "copy-explicit-input" };
const drafts = [linkDraft, ...imported.hosts, explicitCopy];
const draftChoices = drafts.map(host => [host.id, host.sshInputCompatibility]);
assert.deepEqual(drafts.map(host => host.sshInputCompatibility), [false, false, false, true, true]);
await act(async () => { await state!.updateHosts([...state!.hosts, ...drafts]); });
await act(async () => { renderer!.unmount(); });
await mount();
assert.deepEqual(choices(), [["cloud-legacy", false], ...draftChoices]);
} finally {
await act(async () => { renderer?.unmount(); });
dom.window.close();
for (const key of globals) {
const descriptor = previous.get(key);
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
});
+30 -108
View File
@@ -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
@@ -1570,7 +1497,6 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
group: "",
tags: [],
protocol: "ssh",
sshInputCompatibility: false,
order: getNextVaultOrder(hosts),
};
@@ -2151,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,
@@ -2200,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;
@@ -2210,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) => {
-11
View File
@@ -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
-2
View File
@@ -411,8 +411,6 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
modelCatalogLoading={modelCatalogLoading}
modelCatalogSource={modelCatalogSource}
modelCatalogFetchedAt={modelCatalogFetchedAt}
modelCatalogWarning={modelCatalogWarning}
onRefreshModelCatalog={handleModelCatalogRetry}
allowCustomModelEntry={allowCustomModelEntry}
selectedModelId={selectedAgentModel}
onModelSelect={handleAgentModelSelect}
-3
View File
@@ -1090,9 +1090,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
target.agentEnv,
target.agentCommand,
target.codexRuntime,
// Manual refresh (retry banner / picker button) must bypass the
// main-side LKG-first policy and go live.
options.force === true,
);
if (!result?.ok || !Array.isArray(result.models)) {
throw new Error(result?.error || 'Failed to load SDK agent models');
@@ -620,34 +620,6 @@ test('SDK runtime model cache preserves degraded warnings until a successful ret
assert.equal(cache.read('copilot')?.warning, undefined);
});
test('SDK runtime model cache re-queries main past the TTL so a clean LKG serve clears a stale banner', async () => {
let now = 1_000;
const cache = createSdkRuntimeModelCache({ ttlMs: 100, now: () => now });
// A degraded serve (warning) is cached and replayed within the TTL.
await cache.refresh('claude:/opt/bin/claude', async () => ({
currentModelId: 'claude-opus-5-5',
models: [{ id: 'claude-opus-5-5', name: 'Opus 5.5' }],
warning: 'live fetch failed',
}));
assert.equal(cache.read('claude:/opt/bin/claude')?.warning, 'live fetch failed');
// Past the TTL the entry is evicted and the next automatic load re-queries
// main; with the LKG-first policy that round-trip returns a clean
// last-known-good serve, so the stale banner clears.
now = 1_150;
const cleared = await cache.refresh('claude:/opt/bin/claude', async () => ({
currentModelId: 'claude-opus-5-5',
models: [{ id: 'claude-opus-5-5', name: 'Opus 5.5' }],
source: 'last-known-good',
fetchedAt: '2026-10-23T08:00:00.000Z',
}));
assert.equal(cleared.source, 'last-known-good');
assert.equal(cleared.warning, undefined);
assert.equal(cache.read('claude:/opt/bin/claude')?.warning, undefined);
assert.equal(cache.read('claude:/opt/bin/claude')?.fetchedAt, '2026-10-23T08:00:00.000Z');
});
// #3584 alignment: OpenCode must render build-time presets while its live
// catalog load is pending, exactly like every other backend. The old host
// code suppressed presets for opencode while `runtimeModelLoading` was set,
-4
View File
@@ -247,10 +247,6 @@ export function createSdkRuntimeModelCache(options: SdkRuntimeModelCacheOptions
pruneEntries();
const cached = entries.get(key);
if (!refreshOptions.force && cached && now() - cached.updatedAt < ttlMs) {
// The LKG-first policy lives in main: once a persisted catalog exists
// there, a TTL-expired non-force refresh is answered from disk with a
// clean last-known-good serve (no live attempt, no warning), so the
// next automatic load here clears a stale banner.
touchEntry(key, cached);
return Promise.resolve(cloneCatalog(cached));
}
@@ -1,59 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { createDomRenderer, dispatchDomEvent, flushEffects, installDomEnvironment } from "./test-support/renderReactDom";
test("global completed rows and expanded folder children show cleanup paths without a failed status", async (t) => {
const env = installDomEnvironment();
const previous = new Map<string, PropertyDescriptor | undefined>();
for (const [key, value] of Object.entries({
MutationObserver: env.window.MutationObserver, NodeFilter: env.window.NodeFilter,
HTMLInputElement: env.window.HTMLInputElement, Element: env.window.Element,
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0),
cancelAnimationFrame: clearTimeout,
})) {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
}
const { I18nProvider } = await import("../application/i18n/I18nProvider");
const { sftpTransferCenterStore: store } = await import("../application/state/sftpTransferCenterStore");
const { GlobalSftpTransferCenter } = await import("./GlobalSftpTransferCenter");
const { TooltipProvider } = await import("./ui/tooltip");
const renderer = await createDomRenderer(env.document);
t.after(async () => {
await renderer.unmount();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
env.cleanup();
});
const warning = "Upload completed; remove backup /remote/.netcatty-backup-file.bin manually.";
const base = {
fileName: "file.bin", sourcePath: "/local/file.bin", targetPath: "/remote/file.bin",
sourceConnectionId: "local", targetConnectionId: "remote", direction: "upload" as const,
status: "completed" as const, totalBytes: 10, transferredBytes: 10, speed: 0,
startTime: Date.now(), endTime: Date.now(), isDirectory: false,
};
store.publishOwner("cleanup-ui", [
{ ...base, id: "warning-file", warning },
{ ...base, id: "warning-folder", fileName: "folder", isDirectory: true },
{ ...base, id: "warning-child", parentTaskId: "warning-folder", warning },
]);
await renderer.render(<I18nProvider locale="en"><TooltipProvider><GlobalSftpTransferCenter /></TooltipProvider></I18nProvider>);
const click = async (selector: string) => {
const node = env.document.querySelector(selector);
assert.ok(node, selector);
await dispatchDomEvent(node, new env.window.MouseEvent("click", { bubbles: true }));
await flushEffects();
};
await click("[data-section=global-sftp-transfer-toggle]");
assert.match(env.document.body.textContent ?? "", /Backup cleanup warnings: 1/);
await click('button[aria-label="Show files"]');
const notices = env.document.querySelectorAll('[data-section="sftp-transfer-warning"]');
assert.equal(notices.length, 2);
for (const notice of notices) assert.equal(notice.textContent, warning);
assert.equal(env.document.querySelector('[data-transfer-status="failed"]'), null);
assert.equal(store.getTask("warning-child")?.status, "completed");
await click("[data-section=global-sftp-transfer-toggle]");
});
+1 -11
View File
@@ -37,7 +37,6 @@ import { cn } from "../lib/utils";
import { Button } from "./ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { SftpTransferWarning } from "./sftp/SftpTransferWarning";
/** Stable empty snapshot while the popover is closed — progress ticks must not
* re-render TopTabs just to update hidden list rows. */
@@ -351,9 +350,7 @@ const TransferChildRow = React.memo(function TransferChildRow({ task: snapshotTa
return (
<div className="flex h-6 items-center gap-2 rounded px-1 text-[10px] hover:bg-muted/40" data-transfer-status={task.status}>
<TruncatedTextWithTooltip text={task.fileName} tooltip={task.targetPath || task.fileName} className="flex-1 text-muted-foreground" />
{task.status === "completed" && task.warning
? <div className="min-w-0 flex-1"><SftpTransferWarning warning={task.warning} compact /></div>
: <span className="shrink-0 font-mono text-muted-foreground">{detail}</span>}
<span className="shrink-0 font-mono text-muted-foreground">{detail}</span>
{task.status === "transferring" && <Loader2 size={10} className="shrink-0 animate-spin text-primary" />}
</div>
);
@@ -661,13 +658,6 @@ function TransferRow({
</span>
</div>
{task.status === "completed" && task.warning && <div className="mt-1"><SftpTransferWarning warning={task.warning} /></div>}
{childTasks.some((child) => child.status === "completed" && !!child.warning) && (
<p className="mt-1 text-[10px] text-amber-600 dark:text-amber-400">
{t("sftp.transfers.cleanupWarnings", { count: childTasks.filter((child) => child.status === "completed" && !!child.warning).length })}
</p>
)}
{/* Collapsed: show currently transferring child file(s) without expanding the whole tree. */}
{isDirParent && !expanded && displayChildren.length > 0 && (
<div
+2 -3
View File
@@ -270,8 +270,8 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
&& state.entries.length === 0;
const showNoGlobalHistory = scope === 'global' && globalEntries.length === 0;
// Stay mounted when the side panel parks this tree. Returning null here
// would remount the virtual list on every Notes <-> History switch.
if (!isVisible) return null;
return (
<div
className="h-full flex flex-col bg-background overflow-hidden"
@@ -394,7 +394,6 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
<VariableSizeVirtualList
ref={listRef}
items={listRows}
measureEnabled={isVisible}
getItemHeight={getRowHeight}
getItemKey={(row, index) =>
row.type === 'entry' ? row.entry.id : `detail-${row.entry.id}-${index}`}
@@ -503,19 +503,6 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
enabled={!!form.showLineTimestamps}
onToggle={() => update("showLineTimestamps", !form.showLineTimestamps)}
/>
{(form.protocol === undefined || form.protocol === "ssh") && !form.moshEnabled && !form.etEnabled && (
<>
<ToggleRow
label={t("hostDetails.sshInputCompatibility")}
hint={t("hostDetails.sshInputCompatibility.desc")}
enabled={form.sshInputCompatibility ?? true}
onToggle={() => update("sshInputCompatibility", !(form.sshInputCompatibility ?? true))}
/>
<p className="text-xs text-muted-foreground">
{t("hostDetails.sshInputCompatibility.reconnect")}
</p>
</>
)}
<HostDetailsSettingRow label={t("hostDetails.backspaceBehavior")}>
<Select
value={form.backspaceBehavior ?? "default"}
-1
View File
@@ -182,7 +182,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
os: "linux",
authMethod: undefined,
authPolicyVersion: 1,
sshInputCompatibility: false,
charset: groupDefaults?.charset ? undefined : "UTF-8",
distroMode: "auto",
createdAt: Date.now(),
@@ -1,138 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import type { Host, Identity } from "../types";
import { handleKeyboardInteractiveSubmitImpl } from "../application/app/AppHandlers";
import { createTerminalSessionStarters } from "./terminal/runtime/createTerminalSessionStarters";
import { createPromptLineBreakState } from "./terminal/runtime/promptLineBreak";
import { createDomRenderer, dispatchDomEvent, installDomEnvironment } from "./test-support/renderReactDom";
const noop = () => {};
test("KI remember input preserves the reconnect account and leaves shared hosts unchanged", async (t) => {
const dom = installDomEnvironment();
const globals = {
MutationObserver: dom.window.MutationObserver,
NodeFilter: dom.window.NodeFilter,
HTMLInputElement: dom.window.HTMLInputElement,
localStorage: dom.window.localStorage,
};
const previous = new Map(Object.keys(globals).map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
for (const [key, value] of Object.entries(globals)) Object.defineProperty(globalThis, key, { configurable: true, value });
const { KeyboardInteractiveModal } = await import("./KeyboardInteractiveModal");
const { I18nProvider } = await import("../application/i18n/I18nProvider");
const renderer = await createDomRenderer(dom.document);
try {
for (const scenario of [
{ name: "differing usernames", hostUsername: "old-account", identityUsername: "fixtureki", remember: true },
{ name: "blank host username", hostUsername: "", identityUsername: "fixtureki", remember: true },
{ name: "blank identity username", hostUsername: "fixtureki", identityUsername: " ", remember: true },
{ name: "no identity", hostUsername: "fixtureki", identityUsername: undefined, remember: true },
{ name: "remember unchecked", hostUsername: "old-account", identityUsername: "fixtureki", remember: false },
{ name: "secondary password", hostUsername: "old-account", identityUsername: "fixtureki", remember: true, allowSavePassword: false },
]) {
await t.test(scenario.name, async () => {
const identity: Identity = {
id: "shared", label: "Fixture", username: scenario.identityUsername ?? "",
authMethod: "password", password: "fixture-old", created: 0,
};
const identities = scenario.identityUsername === undefined ? [] : [identity];
const originalIdentity = { ...identity };
const host: Host = {
id: "changed", label: "Fixture", hostname: "127.0.0.1", username: scenario.hostUsername,
tags: [], os: "linux", protocol: "ssh", authMethod: "password",
password: "fixture-host-old", savePassword: false,
...(identities.length ? { identityId: identity.id } : {}),
};
const otherHost = { ...host, id: "unchanged", label: "Other fixture" };
let hosts = [host, otherHost];
let updates = 0;
const request = {
requestId: `ki-${scenario.name}`, sessionId: "fixture-session", hostname: host.hostname,
name: "Password expiry", instructions: "", prompts: [{ prompt: "New password:", echo: false }],
allowSavePassword: scenario.allowSavePassword ?? true,
};
let queue = [request];
const responses: unknown[][] = [];
await renderer.render(
<I18nProvider locale="en">
<KeyboardInteractiveModal request={request} onCancel={() => false}
onSubmit={(requestId, values, savePassword) => handleKeyboardInteractiveSubmitImpl(() => ({
hosts, identities, keyboardInteractiveQueue: queue,
sessions: [{ id: "fixture-session", hostId: host.id, hostname: host.hostname }],
netcattyBridge: { get: () => ({ respondKeyboardInteractive: (...args: unknown[]) => {
responses.push(args);
return { success: true };
} }) },
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => { queue = updater(queue); },
updateHosts: (next: Host[]) => { updates += 1; hosts = next; },
t: (key: string) => key, toast: { error: (message: string) => assert.fail(message) },
}), requestId, values, savePassword)} />
</I18nProvider>,
);
const input = dom.document.querySelector<HTMLInputElement>("#ki-prompt-0");
assert.ok(input);
Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")!.set!.call(input, "fixture-new");
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
const checkbox = dom.document.querySelector<HTMLInputElement>('input[type="checkbox"]');
if (scenario.allowSavePassword === false) assert.equal(checkbox, null);
else {
assert.ok(checkbox);
assert.equal(checkbox.checked, false);
if (scenario.remember) await dispatchDomEvent(checkbox, new dom.window.MouseEvent("click", { bubbles: true }));
}
const submit = [...dom.document.querySelectorAll("button")].find((button) => button.textContent === "Submit");
assert.ok(submit);
await dispatchDomEvent(submit, new dom.window.MouseEvent("click", { bubbles: true }));
assert.deepEqual(responses, [[request.requestId, ["fixture-new"], false]]);
assert.deepEqual(queue, []);
const saved = scenario.remember && scenario.allowSavePassword !== false;
assert.equal(updates, saved ? 1 : 0);
assert.equal(hosts[0].identityId, saved && identities.length ? "" : host.identityId);
assert.equal(hosts[0].password, saved ? "fixture-new" : host.password);
assert.equal(hosts[0].savePassword, saved);
assert.equal(hosts[1], otherHost);
assert.deepEqual(identity, originalIdentity);
// Exercise the reconnect launcher that builds the actual SSH bridge request.
for (const [index, candidate] of hosts.entries()) {
let started: { username: string; password?: string } | undefined;
const ctx = {
host: candidate, identities, keys: [], knownHosts: [], resolvedChainHosts: [],
sessionId: `reconnect-${index}`, terminalSettings: {},
sessionRef: { current: null }, hasConnectedRef: { current: true },
hasRunStartupCommandRef: { current: true }, disposeDataRef: { current: null },
disposeExitRef: { current: null }, fitAddonRef: { current: null },
serializeAddonRef: { current: null }, pendingAuthRef: { current: null },
promptLineBreakStateRef: { current: createPromptLineBreakState() }, sudoAutofillRef: { current: null },
updateStatus: noop, setStatus: noop, setError: noop, setNeedsAuth: noop,
setAuthRetryMessage: noop, setAuthPassword: noop, setProgressLogs: noop,
setProgressValue: noop, setChainProgress: noop,
terminalBackend: {
backendAvailable: () => true,
startSSHSession: async (options: { username: string; password?: string }) => { started = options; return `reconnect-${index}`; },
onSessionData: () => noop, onSessionExit: () => noop, onChainProgress: () => noop,
writeToSession: noop, resizeSession: noop,
},
};
await createTerminalSessionStarters(ctx as never).startSSH({
cols: 80, rows: 24, write: noop, writeln: noop, scrollToBottom: noop,
} as never);
assert.ok(started);
assert.equal(started.username, scenario.identityUsername?.trim() || scenario.hostUsername);
assert.equal(started.password, saved && index === 0 ? "fixture-new" : identities.length ? "fixture-old" : "fixture-host-old");
}
// A fresh request resets the checkbox and submitting state for the next case.
await renderer.render(<I18nProvider locale="en"><KeyboardInteractiveModal request={null} onSubmit={() => false} onCancel={() => false} /></I18nProvider>);
});
}
} finally {
await renderer.unmount();
await new Promise((resolve) => setTimeout(resolve, 0));
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
dom.cleanup();
}
});
+96 -105
View File
@@ -8,7 +8,6 @@ import { useI18n } from '../application/i18n/I18nProvider';
import { useTerminalBackend } from '../application/state/useTerminalBackend';
import type { Host, SerialConfig, SerialFlowControl, SerialParity } from '../domain/models';
import { prepareSerialConfigForSavedHost } from '../domain/serialBackspace';
import { isSerialEndpointPath, isWindowsNamedPipePath } from '../domain/serialEndpoint';
import { cn } from '../lib/utils';
import { Button } from './ui/button';
import { Combobox, type ComboboxOption } from './ui/combobox';
@@ -112,7 +111,7 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
if (!selectedPort) return;
const config: SerialConfig = {
path: selectedPort.trim(),
path: selectedPort,
baudRate,
dataBits,
stopBits,
@@ -130,7 +129,7 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
const host: Host = {
id: `serial-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`,
label: configLabel.trim() || `Serial: ${portName}`,
hostname: selectedPort.trim(),
hostname: selectedPort,
// For serial hosts, port field stores baud rate as a numeric identifier.
// The full configuration is stored in serialConfig for actual connection.
port: baudRate,
@@ -158,15 +157,18 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
}));
}, [ports]);
// Validate: port path must start with /dev/ (Unix/macOS) or COM/\\.\COM (Windows)
const trimmedPort = selectedPort.trim();
const isNamedPipe = isWindowsNamedPipePath(trimmedPort);
const isPortValid = isSerialEndpointPath(trimmedPort);
const isPortValid =
trimmedPort.startsWith('/dev/') ||
/^COM\d+$/i.test(trimmedPort) ||
/^\\\\\.\\COM\d+$/i.test(trimmedPort);
// Allow custom baud rates as long as they are positive integers
const isBaudRateValid = Number.isInteger(baudRate) && baudRate > 0;
// Check if using 1.5 stop bits (limited Windows support)
const isStopBits15 = stopBits === 1.5;
const isValid = isPortValid && (isNamedPipe || isBaudRateValid);
const isValid = isPortValid && isBaudRateValid;
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
@@ -210,9 +212,6 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
icon={<Usb size={14} className="text-muted-foreground" />}
/>
<p className="text-xs text-muted-foreground">
{t('serial.field.namedPipeHint')}
</p>
{!isPortValid && selectedPort && (
<p className="text-xs text-destructive">
{t('serial.field.customPortPlaceholder')}
@@ -220,34 +219,32 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
)}
</div>
{/* Baud Rate (not used by named pipes) */}
{!isNamedPipe && (
<div className="space-y-2">
<Label htmlFor="baud-rate">{t('serial.field.baudRate')}</Label>
<Combobox
options={BAUD_RATES.map((rate) => ({
value: String(rate),
label: String(rate),
}))}
value={String(baudRate)}
onValueChange={(val) => {
const parsed = parseInt(val, 10);
if (!isNaN(parsed) && parsed > 0) {
setBaudRate(parsed);
}
}}
placeholder={t('serial.field.baudRatePlaceholder')}
emptyText={t('serial.field.baudRateEmpty')}
allowCreate
createText={t('common.use')}
/>
{baudRate > 0 && !BAUD_RATES.includes(baudRate) && (
<p className="text-xs text-muted-foreground">
{t('serial.field.customBaudRate')}
</p>
)}
</div>
)}
{/* Baud Rate */}
<div className="space-y-2">
<Label htmlFor="baud-rate">{t('serial.field.baudRate')}</Label>
<Combobox
options={BAUD_RATES.map((rate) => ({
value: String(rate),
label: String(rate),
}))}
value={String(baudRate)}
onValueChange={(val) => {
const parsed = parseInt(val, 10);
if (!isNaN(parsed) && parsed > 0) {
setBaudRate(parsed);
}
}}
placeholder={t('serial.field.baudRatePlaceholder')}
emptyText={t('serial.field.baudRateEmpty')}
allowCreate
createText={t('common.use')}
/>
{baudRate > 0 && !BAUD_RATES.includes(baudRate) && (
<p className="text-xs text-muted-foreground">
{t('serial.field.customBaudRate')}
</p>
)}
</div>
{/* Advanced Options */}
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
@@ -267,98 +264,92 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-2">
{/* Hardware-only serial framing is not used by named pipes. */}
{!isNamedPipe && (
<>
{/* Data Bits */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label>
<Select
value={String(dataBits)}
onValueChange={(v) => setDataBits(parseInt(v, 10) as 5 | 6 | 7 | 8)}
>
<SelectTrigger id="data-bits">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATA_BITS.map((bits) => (
<SelectItem key={bits} value={String(bits)}>
{bits}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Stop Bits */}
<div className="space-y-2">
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
<Select
value={String(stopBits)}
onValueChange={(v) => setStopBits(parseFloat(v) as 1 | 1.5 | 2)}
>
<SelectTrigger id="stop-bits">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STOP_BITS.map((bits) => (
<SelectItem key={bits} value={String(bits)}>
{bits}
</SelectItem>
))}
</SelectContent>
</Select>
{isStopBits15 && (
<p className="text-xs text-yellow-500">
{t('serial.field.stopBits15Warning')}
</p>
)}
</div>
</div>
{/* Parity */}
{/* Data Bits */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="parity">{t('serial.field.parity')}</Label>
<Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label>
<Select
value={parity}
onValueChange={(v) => setParity(v as SerialParity)}
value={String(dataBits)}
onValueChange={(v) => setDataBits(parseInt(v, 10) as 5 | 6 | 7 | 8)}
>
<SelectTrigger id="parity">
<SelectTrigger id="data-bits">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PARITY_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{t(`serial.parity.${option}`)}
{DATA_BITS.map((bits) => (
<SelectItem key={bits} value={String(bits)}>
{bits}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Flow Control */}
{/* Stop Bits */}
<div className="space-y-2">
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
<Select
value={flowControl}
onValueChange={(v) => setFlowControl(v as SerialFlowControl)}
value={String(stopBits)}
onValueChange={(v) => setStopBits(parseFloat(v) as 1 | 1.5 | 2)}
>
<SelectTrigger id="flow-control">
<SelectTrigger id="stop-bits">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FLOW_CONTROL_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{t(`serial.flowControl.${option}`)}
{STOP_BITS.map((bits) => (
<SelectItem key={bits} value={String(bits)}>
{bits}
</SelectItem>
))}
</SelectContent>
</Select>
{isStopBits15 && (
<p className="text-xs text-yellow-500">
{t('serial.field.stopBits15Warning')}
</p>
)}
</div>
</div>
</>
)}
{/* Parity */}
<div className="space-y-2">
<Label htmlFor="parity">{t('serial.field.parity')}</Label>
<Select
value={parity}
onValueChange={(v) => setParity(v as SerialParity)}
>
<SelectTrigger id="parity">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PARITY_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{t(`serial.parity.${option}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Flow Control */}
<div className="space-y-2">
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
<Select
value={flowControl}
onValueChange={(v) => setFlowControl(v as SerialFlowControl)}
>
<SelectTrigger id="flow-control">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FLOW_CONTROL_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{t(`serial.flowControl.${option}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Terminal Options */}
<div className="space-y-3 pt-2 border-t border-border/60">
@@ -1,34 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { I18nProvider } from '../application/i18n/I18nProvider';
import type { Host } from '../domain/models';
import { SerialHostDetailsPanel } from './SerialHostDetailsPanel';
function renderPanel(path: string, hostname = path) {
const host: Host = {
id: 'serial-test', label: 'VM console', hostname, protocol: 'serial',
port: 115200, username: '', tags: [], os: 'linux',
serialConfig: { path, baudRate: 115200, dataBits: 8, stopBits: 1, parity: 'none', flowControl: 'none' },
};
return renderToStaticMarkup(<I18nProvider locale="en"><SerialHostDetailsPanel
initialData={host} onSave={() => {}} onCancel={() => {}}
/></I18nProvider>);
}
test('saved named-pipe host is editable without hardware baud controls', () => {
const html = renderPanel(String.raw`\\.\pipe\vm-console`);
assert.match(html, /VM must create the pipe as a server/);
assert.doesNotMatch(html, /for="baud-rate"/);
assert.doesNotMatch(html, /text-destructive/);
});
test('hardware serial host retains its baud-rate controls', () => {
assert.match(renderPanel('COM3'), /for="baud-rate"/);
});
test('editor prefers the stored pipe path over a stale hardware hostname', () => {
const html = renderPanel(String.raw`\\.\pipe\VM console`, 'COM3');
assert.doesNotMatch(html, /for="baud-rate"/);
});
+96 -107
View File
@@ -12,8 +12,6 @@ import {
resolveSerialBackspaceOverrideOnSave,
} from '../domain/serialBackspace';
import { isSerialEndpointPath, isWindowsNamedPipePath } from '../domain/serialEndpoint';
import { Button } from './ui/button';
import { Combobox, ComboboxOption, MultiCombobox } from './ui/combobox';
import { Input } from './ui/input';
@@ -93,7 +91,7 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
// input alone cannot restore `password` to undefined — the user needs a
// dedicated control to delete the credential entirely.
const [passwordCleared, setPasswordCleared] = useState(false);
const [selectedPort, setSelectedPort] = useState(initialData.serialConfig?.path || initialData.hostname || '');
const [selectedPort, setSelectedPort] = useState(initialData.hostname || initialData.serialConfig?.path || '');
const [baudRate, setBaudRate] = useState(initialData.serialConfig?.baudRate || initialData.port || 115200);
const [dataBits, setDataBits] = useState<5 | 6 | 7 | 8>(initialData.serialConfig?.dataBits || 8);
const [stopBits, setStopBits] = useState<1 | 1.5 | 2>(initialData.serialConfig?.stopBits || 1);
@@ -133,7 +131,7 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
if (!selectedPort) return;
const config: SerialConfig = {
path: selectedPort.trim(),
path: selectedPort,
baudRate,
dataBits,
stopBits,
@@ -153,7 +151,7 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
const updatedHost: Host = {
...initialData,
label: label.trim() || `Serial: ${portName}`,
hostname: selectedPort.trim(),
hostname: selectedPort,
port: baudRate,
username: username.trim() || undefined,
// An empty password is meaningful for serial auto-login: the detector
@@ -208,10 +206,12 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
// Validation
const trimmedPort = selectedPort.trim();
const isNamedPipe = isWindowsNamedPipePath(trimmedPort);
const isPortValid = isSerialEndpointPath(trimmedPort);
const isPortValid =
trimmedPort.startsWith('/dev/') ||
/^COM\d+$/i.test(trimmedPort) ||
/^\\\\\.\\COM\d+$/i.test(trimmedPort);
const isBaudRateValid = Number.isInteger(baudRate) && baudRate > 0;
const isValid = isPortValid && (isNamedPipe || isBaudRateValid);
const isValid = isPortValid && isBaudRateValid;
// Check if using 1.5 stop bits (limited Windows support)
const isStopBits15 = stopBits === 1.5;
@@ -265,9 +265,6 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
createText={t('common.use')}
icon={<Usb size={14} className="text-muted-foreground" />}
/>
<p className="text-xs text-muted-foreground">
{t('serial.field.namedPipeHint')}
</p>
{!isPortValid && selectedPort && (
<p className="text-xs text-destructive">
{t('serial.field.customPortPlaceholder')}
@@ -275,34 +272,32 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
)}
</div>
{/* Baud Rate (not used by named pipes) */}
{!isNamedPipe && (
<div className="space-y-2">
<Label htmlFor="baud-rate">{t('serial.field.baudRate')}</Label>
<Combobox
options={BAUD_RATES.map((rate) => ({
value: String(rate),
label: String(rate),
}))}
value={String(baudRate)}
onValueChange={(val) => {
const parsed = parseInt(val, 10);
if (!isNaN(parsed) && parsed > 0) {
setBaudRate(parsed);
}
}}
placeholder={t('serial.field.baudRatePlaceholder')}
emptyText={t('serial.field.baudRateEmpty')}
allowCreate
createText={t('common.use')}
/>
{baudRate > 0 && !BAUD_RATES.includes(baudRate) && (
<p className="text-xs text-muted-foreground">
{t('serial.field.customBaudRate')}
</p>
)}
</div>
)}
{/* Baud Rate */}
<div className="space-y-2">
<Label htmlFor="baud-rate">{t('serial.field.baudRate')}</Label>
<Combobox
options={BAUD_RATES.map((rate) => ({
value: String(rate),
label: String(rate),
}))}
value={String(baudRate)}
onValueChange={(val) => {
const parsed = parseInt(val, 10);
if (!isNaN(parsed) && parsed > 0) {
setBaudRate(parsed);
}
}}
placeholder={t('serial.field.baudRatePlaceholder')}
emptyText={t('serial.field.baudRateEmpty')}
allowCreate
createText={t('common.use')}
/>
{baudRate > 0 && !BAUD_RATES.includes(baudRate) && (
<p className="text-xs text-muted-foreground">
{t('serial.field.customBaudRate')}
</p>
)}
</div>
{/* Login credentials (auto-login) */}
<div className="space-y-2">
@@ -413,98 +408,92 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-2">
{/* Hardware-only serial framing is not used by named pipes. */}
{!isNamedPipe && (
<>
{/* Data Bits */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label>
<Select
value={String(dataBits)}
onValueChange={(v) => setDataBits(parseInt(v, 10) as 5 | 6 | 7 | 8)}
>
<SelectTrigger id="data-bits">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATA_BITS.map((bits) => (
<SelectItem key={bits} value={String(bits)}>
{bits}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Stop Bits */}
<div className="space-y-2">
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
<Select
value={String(stopBits)}
onValueChange={(v) => setStopBits(parseFloat(v) as 1 | 1.5 | 2)}
>
<SelectTrigger id="stop-bits">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STOP_BITS.map((bits) => (
<SelectItem key={bits} value={String(bits)}>
{bits}
</SelectItem>
))}
</SelectContent>
</Select>
{isStopBits15 && (
<p className="text-xs text-yellow-500">
{t('serial.field.stopBits15Warning')}
</p>
)}
</div>
</div>
{/* Parity */}
{/* Data Bits */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="parity">{t('serial.field.parity')}</Label>
<Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label>
<Select
value={parity}
onValueChange={(v) => setParity(v as SerialParity)}
value={String(dataBits)}
onValueChange={(v) => setDataBits(parseInt(v, 10) as 5 | 6 | 7 | 8)}
>
<SelectTrigger id="parity">
<SelectTrigger id="data-bits">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PARITY_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{t(`serial.parity.${option}`)}
{DATA_BITS.map((bits) => (
<SelectItem key={bits} value={String(bits)}>
{bits}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Flow Control */}
{/* Stop Bits */}
<div className="space-y-2">
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
<Select
value={flowControl}
onValueChange={(v) => setFlowControl(v as SerialFlowControl)}
value={String(stopBits)}
onValueChange={(v) => setStopBits(parseFloat(v) as 1 | 1.5 | 2)}
>
<SelectTrigger id="flow-control">
<SelectTrigger id="stop-bits">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FLOW_CONTROL_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{t(`serial.flowControl.${option}`)}
{STOP_BITS.map((bits) => (
<SelectItem key={bits} value={String(bits)}>
{bits}
</SelectItem>
))}
</SelectContent>
</Select>
{isStopBits15 && (
<p className="text-xs text-yellow-500">
{t('serial.field.stopBits15Warning')}
</p>
)}
</div>
</div>
</>
)}
{/* Parity */}
<div className="space-y-2">
<Label htmlFor="parity">{t('serial.field.parity')}</Label>
<Select
value={parity}
onValueChange={(v) => setParity(v as SerialParity)}
>
<SelectTrigger id="parity">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PARITY_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{t(`serial.parity.${option}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Flow Control */}
<div className="space-y-2">
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
<Select
value={flowControl}
onValueChange={(v) => setFlowControl(v as SerialFlowControl)}
>
<SelectTrigger id="flow-control">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FLOW_CONTROL_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{t(`serial.flowControl.${option}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Terminal Options */}
<div className="space-y-3 pt-2 border-t border-border/60">
+1 -4
View File
@@ -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
@@ -570,8 +569,6 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
setShowHostTreeSidebar={settings.setShowHostTreeSidebar}
windowOpacity={settings.windowOpacity}
setWindowOpacity={settings.setWindowOpacity}
windowBackgroundBlur={settings.windowBackgroundBlur}
setWindowBackgroundBlur={settings.setWindowBackgroundBlur}
appIconVariant={settings.appIconVariant}
setAppIconVariant={settings.setAppIconVariant}
/>
+2 -3
View File
@@ -265,10 +265,9 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
}, [activeHost, activeSessionId, resolveTransferSourceSessionId, sessions]);
const fileWatchHandlers = useMemo(() => ({
onFileWatchSynced: (payload: { remotePath: string; warning?: string }) => {
onFileWatchSynced: (payload: { remotePath: string }) => {
const fileName = payload.remotePath.split('/').pop() || payload.remotePath;
if (payload.warning) toast.warning(payload.warning);
else toast.success(t('sftp.autoSync.success', { fileName }));
toast.success(t('sftp.autoSync.success', { fileName }));
logger.info("[SFTP] File auto-synced to remote", payload);
},
onFileWatchError: (payload: { error: string }) => {
-21
View File
@@ -52,27 +52,6 @@ test("renders failed transfer actions with custom tooltips and readable labels",
assert.match(markup, /focus-visible:ring-1/);
});
for (const isChild of [false, true]) {
test(`completed ${isChild ? "child" : "file"} shows the cleanup path without a retry action`, () => {
const warning = "Upload completed; remove backup /remote/.netcatty-backup-archive.tar.gz manually.";
const markup = renderTransferItem({
...baseTask, status: "completed", error: undefined, warning, transferredBytes: 1024,
}, { isChild });
assert.match(markup, /data-transfer-status="completed"/);
assert.match(markup, /data-section="sftp-transfer-warning"/);
assert.ok(markup.includes(warning));
assert.doesNotMatch(markup, /aria-label="Retry:/);
assert.doesNotMatch(markup, /text-destructive/);
});
}
test("folder rows disclose retained child cleanup warnings", () => {
const markup = renderTransferItem({ ...baseTask, status: "completed", error: undefined, isDirectory: true }, {
childWarningCount: 2, canToggleChildren: true, onToggleChildren: () => {},
});
assert.match(markup, /Backup cleanup warnings: 2/);
});
test("renders active transfer cancel action with an item-specific label", () => {
const markup = renderTransferItem({
...baseTask,
+2 -3
View File
@@ -119,10 +119,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
// File watch event handlers (stable refs to avoid re-creating the useSftpState options)
const fileWatchHandlers = useMemo(() => ({
onFileWatchSynced: (payload: { remotePath: string; warning?: string }) => {
onFileWatchSynced: (payload: { remotePath: string }) => {
const fileName = payload.remotePath.split('/').pop() || payload.remotePath;
if (payload.warning) toast.warning(payload.warning);
else toast.success(t('sftp.autoSync.success', { fileName }));
toast.success(t('sftp.autoSync.success', { fileName }));
logger.info("[SFTP] File auto-synced to remote", payload);
},
onFileWatchError: (payload: { error: string }) => {
+24 -94
View File
@@ -158,8 +158,6 @@ import {
} from "./terminal/runtime/createTerminalSessionStarters";
import {
createXTermRuntime,
getTerminalPerCharacterInput,
restoreTerminalPerCharacterInput,
resetKittyKeyboardModeStateForSession,
type XTermRuntime,
} from "./terminal/runtime/createXTermRuntime";
@@ -245,7 +243,6 @@ import {
import {
canHibernateTerminalRuntimeSession,
canHibernateTerminalRuntimeStatus,
decideHiddenTabKeepCountAction,
isTerminalFileTransferActive,
resolveHibernateKeepRendererCount,
resolveHibernatePreferWasmSerialize,
@@ -257,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,
@@ -526,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);
@@ -1685,8 +1680,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
passwordPromptActiveRef.current,
knownCwdRef.current ?? null,
terminalTitleRef.current ?? null,
xtermRuntimeRef.current?.getPerCharacterInput()
?? getTerminalPerCharacterInput(terminalSettingsRef),
);
}));
}
@@ -1708,10 +1701,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
throw new Error("Terminal output did not settle before applying the snapshot");
}
}
if (typeof payload.perCharacterInput === "boolean") {
restoreTerminalPerCharacterInput(terminalSettingsRef, payload.perCharacterInput);
xtermRuntimeRef.current?.restorePerCharacterInput(payload.perCharacterInput);
}
if (typeof payload.kittyKeyboardProtocolEnabled === "boolean") {
xtermRuntimeRef.current?.setKittyKeyboardProtocolEnabled(
payload.kittyKeyboardProtocolEnabled,
@@ -1881,8 +1870,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
snap,
{
...finalContext,
perCharacterInput: xtermRuntimeRef.current?.getPerCharacterInput()
?? getTerminalPerCharacterInput(terminalSettingsRef),
kittyKeyboardModeState: xtermRuntimeRef.current?.getKittyKeyboardModeState(),
kittyKeyboardProtocolEnabled:
xtermRuntimeRef.current?.getKittyKeyboardProtocolEnabled(),
@@ -2310,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();
@@ -2318,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;
@@ -2365,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;
@@ -2381,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();
@@ -4073,7 +4004,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return;
}
finishReconnectPreparation();
xtermRuntimeRef.current?.resetConnectionInputState(host);
xtermRuntimeRef.current?.resetKittyConnectionInputState();
resetKittyKeyboardModeStateForSession(terminalSettingsRef);
if (effectiveTerminalProtocol.startsWith("plugin:")) {
sessionStarters.startPluginConnection(term);
@@ -4779,7 +4710,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
isSearchOpen,
hibernateEnabled: hibernateEnabled,
hibernateDelayMs: resolveTerminalHibernateDelayMs(terminalSettings),
hibernateKeepRendererCount: resolveHibernateKeepRendererCount(terminalSettings),
fileTransferActive: hibernateFileTransferActive,
hibernatedRef,
softHiddenRef,
@@ -4801,7 +4731,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return (
<>
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, RefreshCcw, Sparkles, SquareArrowOutUpRight, Unplug, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost: !!onUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI: onAddSelectionToAI ? handleAddSelectionToAI : undefined, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect: (attachExistingSession || compactToolbar) ? undefined : handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOpenHostBrowser, handleOpenSFTP, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, composeSendToAll, onToggleComposeSendTarget, isCancelling, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isReconnectActive, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, identities, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onTogglePaneMagnification, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, RefreshCcw, Sparkles, SquareArrowOutUpRight, Unplug, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost: !!onUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI: onAddSelectionToAI ? handleAddSelectionToAI : undefined, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect: (attachExistingSession || compactToolbar) ? undefined : handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOpenHostBrowser, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, composeSendToAll, onToggleComposeSendTarget, isCancelling, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isReconnectActive, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, identities, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onTogglePaneMagnification, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
<ScriptExecutionOverlay
run={activeScriptRun}
onPause={() => { void pauseScriptRun(activeScriptRun.runId); }}
+6 -3
View File
@@ -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;
+3 -6
View File
@@ -64,8 +64,6 @@ interface TextEditorModalProps {
onClose: () => void;
fileName: string;
initialContent: string;
/** Full path for language detection; the title still uses fileName. */
filePath?: string;
onSave: (content: string) => Promise<void>;
editorWordWrap: boolean;
onToggleWordWrap: () => void;
@@ -81,7 +79,6 @@ const TextEditorModal: React.FC<TextEditorModalProps> = ({
open,
onClose,
fileName,
filePath,
initialContent,
onSave,
editorWordWrap,
@@ -97,7 +94,7 @@ const TextEditorModal: React.FC<TextEditorModalProps> = ({
const [baselineContent, setBaselineContent] = useState(initialContent);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [languageId, setLanguageId] = useState(() => getLanguageId(filePath ?? fileName));
const [languageId, setLanguageId] = useState(() => getLanguageId(fileName));
const contentRef = useRef(initialContent);
const baselineContentRef = useRef(initialContent);
const savingRef = useRef(false);
@@ -152,13 +149,13 @@ const TextEditorModal: React.FC<TextEditorModalProps> = ({
setBaselineContent(initialContent);
setSaveError(null);
setSaving(false);
setLanguageId(getLanguageId(filePath ?? fileName));
setLanguageId(getLanguageId(fileName));
contentRef.current = initialContent;
baselineContentRef.current = initialContent;
savingRef.current = false;
closePromptRef.current = null;
viewStateRef.current = null;
}, [initialContent, fileName, filePath]);
}, [initialContent, fileName]);
const saveContent = useCallback(async (contentToSave = contentRef.current): Promise<boolean> => {
return saveCoordinatorRef.current?.save(contentToSave) ?? false;
-69
View File
@@ -281,75 +281,6 @@ test("duplicate double-click behavior falls back to copying when unavailable", (
assert.deepEqual(copiedSessionIds, ["session-1"]);
});
test("draggable top-tab close buttons cancel the drag gesture on mousedown", () => {
const closeButtonLabels = [
"tabs.closePluginViewAria",
"Close editor tab",
"tabs.closeSessionAria",
"tabs.closeLogViewAria",
];
for (const label of closeButtonLabels) {
const labelIndex = topTabItemsSource.indexOf(label);
assert.notEqual(labelIndex, -1, `missing close label ${label}`);
const buttonStart = topTabItemsSource.lastIndexOf("<button", labelIndex);
const buttonEnd = topTabItemsSource.indexOf(">", labelIndex);
const openingTag = topTabItemsSource.slice(buttonStart, buttonEnd);
assert.match(
openingTag,
/onMouseDown=\{handleTabCloseButtonMouseDown\}/,
`${label} close button must guard mousedown`,
);
}
assert.equal(
topTabItemsSource.match(/onMouseDown=\{handleTabCloseButtonMouseDown\}/g)?.length,
4,
);
const workspaceSource = topTabItemsSource.slice(
topTabItemsSource.indexOf("export const WorkspaceTopTab"),
topTabItemsSource.indexOf("WorkspaceTopTab.displayName"),
);
assert.equal(workspaceSource.includes("handleTabCloseButtonMouseDown"), false);
assert.equal(workspaceSource.includes("<button"), false);
});
test("draggable top tab roots activate sub-threshold presses and still reorder past the threshold", () => {
const tabTypes = ["plugin-view", "editor", "session", "workspace", "logView"];
for (const tabType of tabTypes) {
const marker = `data-tab-type="${tabType}"`;
const start = topTabItemsSource.indexOf(marker);
assert.notEqual(start, -1, marker);
const classNameAt = topTabItemsSource.indexOf("className={cn(", start);
assert.notEqual(classNameAt, -1, tabType);
const openingHandlers = topTabItemsSource.slice(start, classNameAt);
assert.match(openingHandlers, /onMouseDown=\{handleTabDragThresholdMouseDown\}/, tabType);
assert.match(openingHandlers, /onMouseUp=\{\(event\) => handleTabDragThresholdRelease\(event,/, tabType);
assert.match(
openingHandlers,
/onDragStart=\{\(event\) => \{\s*handleTabDragThresholdDragStart\(event\);\s*onTabDragStart\(event,/,
tabType,
);
assert.match(
openingHandlers,
/onDrag=\{\(event\) => \{\s*if \(!handleTabDragThresholdDrag\(event\)\) return;\s*onTabDragStart\(event,/,
tabType,
);
assert.match(
openingHandlers,
/onDragEnd=\{\(event\) => \{\s*handleTabDragThresholdRelease\(event,[\s\S]*?onTabDragEnd\(\);\s*\}\}/,
tabType,
);
assert.equal(openingHandlers.includes("handleTabCloseButtonMouseDown"), false, tabType);
assert.equal(openingHandlers.includes("preventDefault"), false, tabType);
}
assert.equal(topTabItemsSource.match(/onMouseDown=\{handleTabMiddleMouseDown\}/g)?.length ?? 0, 0);
assert.equal(topTabItemsSource.match(/handleTabDragThresholdRelease\(/g)?.length, 10);
});
test("session close button double click stays on the close button", () => {
let stopPropagationCount = 0;
+7 -14
View File
@@ -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(() => {
-10
View File
@@ -212,10 +212,6 @@ interface ChatInputProps {
modelCatalogSource?: 'live' | 'last-known-good' | 'presets';
/** ISO timestamp of the successful fetch backing `modelCatalogSource`. */
modelCatalogFetchedAt?: string;
/** The catalog warning text behind the host's retry banner, when present. */
modelCatalogWarning?: string;
/** Manual refresh action for the picker's refresh button (force path). */
onRefreshModelCatalog?: () => void;
/**
* Offer the picker's "use custom model" action. Disable when the current
* agent locks the model (e.g. managed Codex `model` config overrides the
@@ -290,8 +286,6 @@ const ChatInput: React.FC<ChatInputProps> = ({
modelCatalogLoading = false,
modelCatalogSource,
modelCatalogFetchedAt,
modelCatalogWarning,
onRefreshModelCatalog,
allowCustomModelEntry = true,
selectedModelId,
onModelSelect,
@@ -1614,8 +1608,6 @@ const ChatInput: React.FC<ChatInputProps> = ({
loading={!hasProviderSwitcher ? modelCatalogLoading : undefined}
catalogSource={!hasProviderSwitcher ? modelCatalogSource : undefined}
catalogFetchedAt={!hasProviderSwitcher ? modelCatalogFetchedAt : undefined}
catalogWarning={!hasProviderSwitcher ? modelCatalogWarning : undefined}
onRefreshCatalog={!hasProviderSwitcher ? onRefreshModelCatalog : undefined}
allowCustomEntry={allowCustomModelEntry}
prefs={modelPrefs}
onSelectProviderModel={(providerId, modelId, contextWindow) => {
@@ -1832,8 +1824,6 @@ function chatInputPropsAreEqual(prev: ChatInputProps, next: ChatInputProps): boo
&& prev.modelCatalogLoading === next.modelCatalogLoading
&& prev.modelCatalogSource === next.modelCatalogSource
&& prev.modelCatalogFetchedAt === next.modelCatalogFetchedAt
&& prev.modelCatalogWarning === next.modelCatalogWarning
&& prev.onRefreshModelCatalog === next.onRefreshModelCatalog
&& prev.allowCustomModelEntry === next.allowCustomModelEntry
&& prev.selectedModelId === next.selectedModelId
&& prev.onModelSelect === next.onModelSelect
@@ -156,36 +156,3 @@ test('external agent picker shows the loading row while the live catalog is pend
// keeps built-in presets visible during loading and passes them through.
assert.doesNotMatch(html, /gpt-5\.1/);
});
test('manual refresh button renders for any fetched catalog or warning and stays localized', () => {
const base = {
modelPresets: [{ id: 'm-1', name: 'M1' }],
selectedModelId: 'm-1',
prefs: { recent: [], pinned: [] },
onSelectModel: () => {},
onTogglePinned: () => {},
};
for (const [locale, refreshLabel] of [
['en', 'Refresh model list'],
['zh-CN', '刷新模型列表'],
['zh-TW', '重新整理模型清單'],
['es', 'Actualizar la lista de modelos'],
['ru', 'Обновить список моделей'],
]) {
const html = renderToStaticMarkup(
<I18nProvider locale={locale}>
<ComposerModelPicker {...base} catalogSource="last-known-good" onRefreshCatalog={() => {}} />
</I18nProvider>,
);
assert.ok(html.includes(`aria-label="${refreshLabel}"`));
}
// A plain preset list without a host refresh action stays buttonless.
assert.doesNotMatch(renderToStaticMarkup(<ComposerModelPicker {...base} />), /refreshModelList/);
// A degraded preset serve (warning, no source) still offers the refresh.
const warned = renderToStaticMarkup(
<ComposerModelPicker {...base} catalogWarning="live fetch failed" onRefreshCatalog={() => {}} />,
);
assert.match(warned, /aria-label="ai\.chat\.refreshModelList"/);
});
+4 -43
View File
@@ -1,4 +1,4 @@
import { Check, ChevronLeft, ChevronRight, Loader2, Pin, RotateCw, Search, Star } from 'lucide-react';
import { Check, ChevronLeft, ChevronRight, Loader2, Pin, Search, Star } from 'lucide-react';
import React, { useEffect, useMemo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import {
@@ -37,18 +37,6 @@ export interface ComposerModelPickerProps {
catalogSource?: 'live' | 'last-known-good' | 'presets';
/** ISO timestamp of the successful fetch backing `catalogSource`. */
catalogFetchedAt?: string;
/**
* The catalog warning text behind the host's retry banner, when present.
* Together with `catalogSource` it decides whether the manual refresh
* button renders: any fetched catalog (live or last-known-good) or a
* degraded preset fallback offers a refresh; a plain preset list does not.
*/
catalogWarning?: string | null;
/**
* Manual refresh action (LKG-first policy: the picker hydrates from disk
* and the user refreshes explicitly). Triggers the host's force refresh.
*/
onRefreshCatalog?: () => void;
prefs: ComposerModelPrefs;
/**
* Offer the "use custom model" action. Disable when the host locks the
@@ -119,8 +107,6 @@ export const ComposerModelPicker: React.FC<ComposerModelPickerProps> = ({
loading = false,
catalogSource,
catalogFetchedAt,
catalogWarning,
onRefreshCatalog,
prefs,
allowCustomEntry = true,
onSelectProviderModel,
@@ -188,18 +174,6 @@ export const ComposerModelPicker: React.FC<ComposerModelPickerProps> = ({
? t('ai.chat.modelCatalogSourceCached').replace('{time}', time)
: t('ai.chat.modelCatalogSourceCachedNoTime');
}, [loading, catalogSource, catalogFetchedAt, t]);
// Manual refresh (LKG-first): offered whenever a fetched catalog (any
// source) or the degraded-preset warning is present. Hidden while loading —
// the spinner row is the in-flight feedback — and in provider mode, where
// neither a catalog chip nor a host refresh action exists.
const showRefreshButton = useMemo(() => (
!loading
&& Boolean(onRefreshCatalog)
&& (catalogSource === 'live'
|| catalogSource === 'last-known-good'
|| Boolean(catalogWarning))
), [loading, onRefreshCatalog, catalogSource, catalogWarning]);
const refreshLabel = t('ai.chat.refreshModelList');
// Manual model ids are accepted in both modes: provider-bound catalogs and
// preset lists from external CLI agents whose catalogs lag new models.
const showCustom = Boolean(
@@ -410,22 +384,9 @@ export const ComposerModelPicker: React.FC<ComposerModelPickerProps> = ({
)}
</div>
{(catalogChipText || showRefreshButton) && (
<div className="flex items-center gap-1 border-t border-border/40 px-2.5 py-1 text-[10px] text-muted-foreground/45">
{catalogChipText && (
<span className="min-w-0 flex-1 truncate" title={catalogChipText}>{catalogChipText}</span>
)}
{showRefreshButton && (
<button
type="button"
aria-label={refreshLabel}
title={refreshLabel}
onClick={onRefreshCatalog}
className="shrink-0 rounded p-0.5 text-muted-foreground/55 transition-colors hover:bg-muted/40 hover:text-foreground/75 cursor-pointer"
>
<RotateCw size={11} />
</button>
)}
{catalogChipText && (
<div className="border-t border-border/40 px-2.5 py-1 text-[10px] text-muted-foreground/45" title={catalogChipText}>
{catalogChipText}
</div>
)}
</div>
@@ -8,7 +8,6 @@ import {
getTextEditorContentStats,
isTextEditorCommandWEnabled,
isTextEditorReadOnly,
languageIdToMonaco,
TextEditorPromoteButton,
} from "./TextEditorPane.tsx";
import { TooltipProvider } from "../ui/tooltip.tsx";
@@ -17,11 +16,6 @@ import { DEFAULT_KEY_BINDINGS } from "../../domain/models/keyBindings.ts";
const wrap = (child: React.ReactElement) =>
React.createElement(TooltipProvider, null, child);
test("maps manual Nginx selection to the registered language", () => {
assert.equal(languageIdToMonaco('nginx'), 'nginx');
assert.equal(languageIdToMonaco('ini'), 'ini');
});
test("disables promoting a modal editor to a tab while a save is running", () => {
assert.equal(canPromoteTextEditor({ saving: true }), false);
assert.equal(canPromoteTextEditor({ saving: false }), true);
+1 -4
View File
@@ -30,7 +30,6 @@ import { useClipboardBackend } from '../../application/state/useClipboardBackend
import { isPrimaryModifierWBinding } from '../../application/state/windowCommandClose';
import { HotkeyScheme, KeyBinding, matchesKeyBinding } from '../../domain/models';
import { pasteForMonacoEditorCommand } from '../../infrastructure/monaco/monacoClipboardPaste';
import { registerNginxLanguage } from '../../infrastructure/monaco/nginxLanguage';
import { useNetcattyMonacoTheme } from '../../infrastructure/monaco/useNetcattyMonacoTheme';
import { getLanguageName, getSupportedLanguages } from '../../lib/sftpFileUtils';
import { Button } from '../ui/button';
@@ -38,7 +37,7 @@ import { Combobox } from '../ui/combobox';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
// Map our language IDs to Monaco language IDs
export const languageIdToMonaco = (langId: string): string => {
const languageIdToMonaco = (langId: string): string => {
const mapping: Record<string, string> = {
'javascript': 'javascript',
'typescript': 'typescript',
@@ -74,7 +73,6 @@ export const languageIdToMonaco = (langId: string): string => {
'yaml': 'yaml',
'toml': 'ini',
'ini': 'ini',
'nginx': 'nginx',
'sql': 'sql',
'graphql': 'graphql',
'markdown': 'markdown',
@@ -514,7 +512,6 @@ const TextEditorPaneInner: React.FC<TextEditorPaneProps> = ({
language={monacoLanguage}
value={content}
onChange={handleEditorChange}
beforeMount={registerNginxLanguage}
onMount={handleEditorMount}
theme={customThemeName}
loading={
Loaded 100 of 319 files, more files were not shown because too many files have changed in this diff. Show more