mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 14:30:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80fe4c19c7 | ||
|
|
695fd27688 | ||
|
|
238fe45ee4 | ||
|
|
e780d44f97 | ||
|
|
9766f21ecf | ||
|
|
e9cd311936 | ||
|
|
d6871dbca9 | ||
|
|
850c5309b6 | ||
|
|
e5bfc12a2e | ||
|
|
6ab684bcd5 | ||
|
|
acecd46d57 | ||
|
|
aee852a9ac | ||
|
|
5bef721ac9 |
No files matched your search
@@ -181,7 +181,9 @@ 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, adjust refresh controls, and open a terminal directly from a monitored host.
|
||||
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.
|
||||
|
||||
<img width="3364" height="1982" alt="image" src="docs/assets/readme/monitoring.webp" />
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
@@ -8,6 +9,11 @@ import {
|
||||
handleGlobalHotkeyKeyDownImpl,
|
||||
markForwardedNativeShortcutEvent,
|
||||
} from './app/AppHandlers.ts';
|
||||
import {
|
||||
DEFAULT_SETTINGS_CHROME_SNAPSHOT,
|
||||
getSettingsChromeSnapshot,
|
||||
publishSettingsChromeSnapshot,
|
||||
} from './state/settingsChromeStore.ts';
|
||||
import { matchesKeyBinding } from '../domain/models.ts';
|
||||
import { DEFAULT_KEY_BINDINGS } from '../domain/models/keyBindings.ts';
|
||||
|
||||
@@ -829,3 +835,201 @@ for (const scenario of [
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const scheme of ['pc', 'mac'] as const) {
|
||||
test(`focused terminal ${scheme} consumes Shift+B for the host list and leaves plain B to broadcast`, () => {
|
||||
const target = new FakeHTMLElement();
|
||||
const shiftedActions: string[] = [];
|
||||
let shiftedPrevented = false;
|
||||
let shiftedStopped = false;
|
||||
handleGlobalHotkeyKeyDownImpl(() => ({
|
||||
HOTKEY_DEBUG: false,
|
||||
closeTabKeyStr: '',
|
||||
executeHotkeyAction: (action: string) => {
|
||||
shiftedActions.push(action);
|
||||
return action === 'toggleHostTree';
|
||||
},
|
||||
hotkeyScheme: scheme,
|
||||
keyBindings: DEFAULT_KEY_BINDINGS,
|
||||
matchesKeyBinding,
|
||||
}), {
|
||||
key: 'b',
|
||||
code: 'KeyB',
|
||||
ctrlKey: scheme === 'pc',
|
||||
metaKey: scheme === 'mac',
|
||||
altKey: false,
|
||||
shiftKey: true,
|
||||
target,
|
||||
composedPath: () => [target],
|
||||
preventDefault: () => { shiftedPrevented = true; },
|
||||
stopPropagation: () => { shiftedStopped = true; },
|
||||
} as unknown as KeyboardEvent);
|
||||
|
||||
assert.deepEqual(shiftedActions, ['toggleHostTree']);
|
||||
assert.equal(shiftedPrevented, true);
|
||||
assert.equal(shiftedStopped, true);
|
||||
|
||||
const plainActions: string[] = [];
|
||||
let plainPrevented = false;
|
||||
handleGlobalHotkeyKeyDownImpl(() => ({
|
||||
HOTKEY_DEBUG: false,
|
||||
closeTabKeyStr: '',
|
||||
executeHotkeyAction: (action: string) => {
|
||||
plainActions.push(action);
|
||||
return undefined;
|
||||
},
|
||||
hotkeyScheme: scheme,
|
||||
keyBindings: DEFAULT_KEY_BINDINGS,
|
||||
matchesKeyBinding,
|
||||
}), {
|
||||
key: 'b',
|
||||
code: 'KeyB',
|
||||
ctrlKey: scheme === 'pc',
|
||||
metaKey: scheme === 'mac',
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
target,
|
||||
composedPath: () => [target],
|
||||
preventDefault: () => { plainPrevented = true; },
|
||||
stopPropagation: () => {},
|
||||
} as unknown as KeyboardEvent);
|
||||
|
||||
assert.deepEqual(plainActions, ['broadcast']);
|
||||
assert.equal(plainPrevented, false);
|
||||
});
|
||||
}
|
||||
|
||||
test('focused terminal leaves the host-list chord alone when the toggle does not run', () => {
|
||||
const target = new FakeHTMLElement();
|
||||
const actions: string[] = [];
|
||||
let prevented = false;
|
||||
let stopped = false;
|
||||
handleGlobalHotkeyKeyDownImpl(() => ({
|
||||
HOTKEY_DEBUG: false,
|
||||
closeTabKeyStr: '',
|
||||
executeHotkeyAction: (action: string) => {
|
||||
actions.push(action);
|
||||
return false;
|
||||
},
|
||||
hotkeyScheme: 'pc',
|
||||
keyBindings: DEFAULT_KEY_BINDINGS,
|
||||
matchesKeyBinding,
|
||||
}), {
|
||||
key: 'b',
|
||||
code: 'KeyB',
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
shiftKey: true,
|
||||
target,
|
||||
composedPath: () => [target],
|
||||
preventDefault: () => { prevented = true; },
|
||||
stopPropagation: () => { stopped = true; },
|
||||
} as unknown as KeyboardEvent);
|
||||
|
||||
assert.deepEqual(actions, ['toggleHostTree']);
|
||||
assert.equal(prevented, false);
|
||||
assert.equal(stopped, false);
|
||||
});
|
||||
|
||||
test('host list shortcut reaches a focused Monaco editor and stays out of plain text fields', () => {
|
||||
const monaco = new FakeMonacoHTMLElement();
|
||||
const monacoActions: string[] = [];
|
||||
let monacoPrevented = false;
|
||||
handleGlobalHotkeyKeyDownImpl(() => ({
|
||||
HOTKEY_DEBUG: false,
|
||||
closeTabKeyStr: '',
|
||||
executeHotkeyAction: (action: string) => {
|
||||
monacoActions.push(action);
|
||||
return true;
|
||||
},
|
||||
hotkeyScheme: 'pc',
|
||||
keyBindings: DEFAULT_KEY_BINDINGS,
|
||||
matchesKeyBinding,
|
||||
}), {
|
||||
key: 'b',
|
||||
code: 'KeyB',
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
shiftKey: true,
|
||||
target: monaco,
|
||||
composedPath: () => [monaco],
|
||||
preventDefault: () => { monacoPrevented = true; },
|
||||
stopPropagation: () => {},
|
||||
} as unknown as KeyboardEvent);
|
||||
|
||||
assert.deepEqual(monacoActions, ['toggleHostTree']);
|
||||
assert.equal(monacoPrevented, true);
|
||||
|
||||
const input = new FakeInputHTMLElement();
|
||||
const inputActions: string[] = [];
|
||||
let inputPrevented = false;
|
||||
handleGlobalHotkeyKeyDownImpl(() => ({
|
||||
HOTKEY_DEBUG: false,
|
||||
closeTabKeyStr: '',
|
||||
executeHotkeyAction: (action: string) => {
|
||||
inputActions.push(action);
|
||||
return true;
|
||||
},
|
||||
hotkeyScheme: 'pc',
|
||||
keyBindings: DEFAULT_KEY_BINDINGS,
|
||||
matchesKeyBinding,
|
||||
}), {
|
||||
key: 'b',
|
||||
code: 'KeyB',
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
shiftKey: true,
|
||||
target: input,
|
||||
composedPath: () => [input],
|
||||
preventDefault: () => { inputPrevented = true; },
|
||||
stopPropagation: () => {},
|
||||
} as unknown as KeyboardEvent);
|
||||
|
||||
assert.deepEqual(inputActions, []);
|
||||
assert.equal(inputPrevented, false);
|
||||
});
|
||||
|
||||
test('toggleHostTree uses the host list toggle only while that list can show', () => {
|
||||
const previous = getSettingsChromeSnapshot();
|
||||
const calls: string[] = [];
|
||||
const toggleHostTree = () => { calls.push('toggle'); };
|
||||
const event = {} as KeyboardEvent;
|
||||
const contextFor = (activeTabId: string, orderedTabs: string[] = [activeTabId]) => ({
|
||||
activeTabStore: { getActiveTabId: () => activeTabId },
|
||||
editorTabs: [],
|
||||
orderedTabs,
|
||||
sessions: [{ id: 'session-1' }],
|
||||
settings: {},
|
||||
toEditorTabId: (id: string) => id,
|
||||
toggleHostTree,
|
||||
workspaces: [{ id: 'workspace-1' }],
|
||||
});
|
||||
|
||||
try {
|
||||
publishSettingsChromeSnapshot({ ...DEFAULT_SETTINGS_CHROME_SNAPSHOT, showHostTreeSidebar: true });
|
||||
assert.equal(executeHotkeyActionImpl(() => contextFor('session-1'), 'toggleHostTree', event), true);
|
||||
assert.equal(executeHotkeyActionImpl(() => contextFor('editor:note', []), 'toggleHostTree', event), true);
|
||||
assert.equal(executeHotkeyActionImpl(() => contextFor('workspace-1'), 'toggleHostTree', event), true);
|
||||
assert.deepEqual(calls, ['toggle', 'toggle', 'toggle']);
|
||||
|
||||
calls.length = 0;
|
||||
assert.equal(executeHotkeyActionImpl(() => contextFor('vault', []), 'toggleHostTree', event), false);
|
||||
assert.equal(executeHotkeyActionImpl(() => contextFor('sftp', []), 'toggleHostTree', event), false);
|
||||
assert.equal(executeHotkeyActionImpl(() => contextFor('plugin-view:demo:main', ['plugin-view:demo:main']), 'toggleHostTree', event), false);
|
||||
assert.deepEqual(calls, []);
|
||||
|
||||
publishSettingsChromeSnapshot({ ...DEFAULT_SETTINGS_CHROME_SNAPSHOT, showHostTreeSidebar: false });
|
||||
assert.equal(executeHotkeyActionImpl(() => contextFor('session-1'), 'toggleHostTree', event), false);
|
||||
assert.deepEqual(calls, []);
|
||||
} finally {
|
||||
publishSettingsChromeSnapshot(previous);
|
||||
}
|
||||
});
|
||||
|
||||
test('host list shortcut calls the same store toggle as the toolbar button', () => {
|
||||
const source = readFileSync(new URL('./app/AppSideEffects.tsx', import.meta.url), 'utf8');
|
||||
assert.match(source, /toggleHostTree:\s*\(\)\s*=>\s*terminalHostTreeStore\.toggle\(\)/);
|
||||
});
|
||||
@@ -9,6 +9,8 @@ import { getAvailablePaneMagnificationController } from '../../domain/paneMagnif
|
||||
import { sanitizeHostIconFields } from '../../domain/hostIcon';
|
||||
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
|
||||
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
|
||||
import { getSettingsChromeSnapshot } from '../state/settingsChromeStore';
|
||||
import { isHostTreeWorkTabSurface } from './workTabSurface';
|
||||
import { tabShortcutDigitFromEvent } from '../../domain/models/keyBindings';
|
||||
import { isPortForwardingAutoReconnectEnabled } from '../../domain/portForwardingReconnect';
|
||||
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
|
||||
@@ -276,6 +278,9 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
|
||||
const paneZoomBinding = keyBindings.find((binding) => binding.action === 'togglePaneZoom');
|
||||
const paneZoomKeyStr = paneZoomBinding ? (isMac ? paneZoomBinding.mac : paneZoomBinding.pc) : null;
|
||||
const isPaneZoomHotkey = paneZoomKeyStr ? matchesKeyBinding(e, paneZoomKeyStr, isMac) : false;
|
||||
const hostTreeBinding = keyBindings.find((binding) => binding.action === 'toggleHostTree');
|
||||
const hostTreeKeyStr = hostTreeBinding ? (isMac ? hostTreeBinding.mac : hostTreeBinding.pc) : null;
|
||||
const isHostTreeHotkey = hostTreeKeyStr ? matchesKeyBinding(e, hostTreeKeyStr, isMac) : false;
|
||||
|
||||
if (
|
||||
(isFormElement || isMonacoElement)
|
||||
@@ -283,6 +288,7 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
|
||||
&& e.key !== 'Escape'
|
||||
&& !isQuickSwitchHotkey
|
||||
&& !isPaneZoomHotkey
|
||||
&& !(isMonacoElement && isHostTreeHotkey)
|
||||
&& !forwardedNativeShortcutEvents.has(e)
|
||||
) {
|
||||
return;
|
||||
@@ -322,9 +328,10 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
|
||||
continue;
|
||||
}
|
||||
|
||||
// An unavailable broadcast action must leave the chord to the terminal
|
||||
// (notably tmux's Ctrl+B prefix). Dispatch once before consuming it.
|
||||
if (binding.action === 'broadcast') {
|
||||
// An unavailable action must leave the chord to the terminal.
|
||||
// Broadcast keeps Ctrl/Cmd+B for tmux; the host list keeps Ctrl/Cmd+Shift+B
|
||||
// when the sidebar cannot show. Dispatch once before consuming it.
|
||||
if (binding.action === 'broadcast' || binding.action === 'toggleHostTree') {
|
||||
if (executeHotkeyAction(binding.action, e) === true) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -801,7 +808,7 @@ export async function closeTabsBatchImpl(getCtx: AppContextGetter, targetIds: st
|
||||
}
|
||||
|
||||
export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string, e: KeyboardEvent) {
|
||||
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, canUseGlobalBroadcast, closePluginViewTab, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isPluginViewTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, sftpPaneMagnificationRef, splitSessionWithCurrentShell, systemInfoRef, terminalPaneMagnificationRef, toEditorTabId, toggleBroadcast, toggleGlobalBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWindowOpacity, workspaces } = getCtx();
|
||||
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, canUseGlobalBroadcast, closePluginViewTab, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isPluginViewTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, sftpPaneMagnificationRef, splitSessionWithCurrentShell, systemInfoRef, terminalPaneMagnificationRef, toEditorTabId, toggleBroadcast, toggleGlobalBroadcast, toggleHostTree, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWindowOpacity, workspaces } = getCtx();
|
||||
{
|
||||
const shortcutTabs = buildNumberShortcutTabTargets({
|
||||
showSftpTab: settings.showSftpTab ?? true,
|
||||
@@ -997,6 +1004,18 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
case 'toggleSidePanel':
|
||||
toggleSidePanelRef.current?.();
|
||||
break;
|
||||
case 'toggleHostTree': {
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
if (!isHostTreeWorkTabSurface({
|
||||
enabled: getSettingsChromeSnapshot().showHostTreeSidebar,
|
||||
activeTabId: currentId,
|
||||
orderedTabs: orderedTabs ?? [],
|
||||
sessionIds: new Set((sessions ?? []).map((session: TerminalSession) => session.id)),
|
||||
workspaceIds: new Set((workspaces ?? []).map((workspace: { id: string }) => workspace.id)),
|
||||
}) || typeof toggleHostTree !== 'function') return false;
|
||||
toggleHostTree();
|
||||
return true;
|
||||
}
|
||||
case 'broadcast': {
|
||||
// Workspace tabs keep their local broadcast state; orphan tabs share
|
||||
// the global state when at least two visible orphan tabs exist.
|
||||
@@ -1184,7 +1203,7 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host, hi
|
||||
}
|
||||
}
|
||||
|
||||
export function handleTerminalDataCaptureImpl(getCtx: AppContextGetter, sessionId: string, data: string) {
|
||||
export async function handleTerminalDataCaptureImpl(getCtx: AppContextGetter, sessionId: string, data: string) {
|
||||
const { IS_DEV, connectionLogs, selectConnectionLogForTerminalDataCapture, sessions, updateConnectionLog } = getCtx();
|
||||
{
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] Called', { sessionId, dataLength: data.length });
|
||||
@@ -1200,7 +1219,10 @@ export function handleTerminalDataCaptureImpl(getCtx: AppContextGetter, sessionI
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] Matching log', matchingLog);
|
||||
|
||||
if (matchingLog) {
|
||||
updateConnectionLog(matchingLog.id, {
|
||||
// The IndexedDB replay write commits asynchronously. Waiting here keeps
|
||||
// a crash during session teardown from dropping the newest capture any
|
||||
// more often than the old synchronous localStorage write.
|
||||
await updateConnectionLog(matchingLog.id, {
|
||||
endTime: Date.now(),
|
||||
terminalData: data,
|
||||
});
|
||||
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
import { getCredentialProtectionAvailability } from '../../infrastructure/services/credentialProtection';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { terminalHostTreeStore } from '../state/terminalHostTreeStore';
|
||||
import {
|
||||
markExternalMcpStartupReady,
|
||||
readExternalMcpFocusOnHostOpen,
|
||||
@@ -1126,6 +1127,7 @@ export function AppSideEffects() {
|
||||
toggleBroadcast,
|
||||
toggleGlobalBroadcast,
|
||||
canUseGlobalBroadcast: canUseGlobalBroadcastRef.current,
|
||||
toggleHostTree: () => terminalHostTreeStore.toggle(),
|
||||
toggleScriptsSidePanelRef,
|
||||
toggleSidePanelRef,
|
||||
toggleWindowOpacity,
|
||||
|
||||
@@ -272,7 +272,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit,
|
||||
handleOpenHostFromVaultNote, handleOpenQuickSwitcher, handleOpenSettings, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleFidoPromptSubmit, handleFidoPromptCancel, handleProtocolSelect,
|
||||
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleUpdateHostFromTerminal,
|
||||
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isGlobalBroadcastEnabled, onToggleGlobalBroadcast, canUseGlobalBroadcast, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
|
||||
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isComposeSendToAll, onToggleComposeSendTarget, isGlobalBroadcastEnabled, isGlobalComposeSendToAll, onToggleGlobalBroadcast, onToggleGlobalComposeSendTarget, canUseGlobalBroadcast, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
|
||||
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
|
||||
passphraseQueue, fidoPromptQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
|
||||
resolveEmptyVaultConflict, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
|
||||
@@ -784,8 +784,12 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onCreateLocalTerminal={handleCreateLocalTerminal}
|
||||
isBroadcastEnabled={isBroadcastEnabled}
|
||||
onToggleBroadcast={toggleBroadcast}
|
||||
isComposeSendToAll={isComposeSendToAll}
|
||||
onToggleComposeSendTarget={onToggleComposeSendTarget}
|
||||
isGlobalBroadcastEnabled={isGlobalBroadcastEnabled}
|
||||
isGlobalComposeSendToAll={isGlobalComposeSendToAll}
|
||||
onToggleGlobalBroadcast={onToggleGlobalBroadcast}
|
||||
onToggleGlobalComposeSendTarget={onToggleGlobalComposeSendTarget}
|
||||
canUseGlobalBroadcast={canUseGlobalBroadcast}
|
||||
updateHosts={updateTerminalHosts}
|
||||
updateSnippets={updateSnippets}
|
||||
|
||||
@@ -205,7 +205,11 @@ export function TerminalHost() {
|
||||
updateTerminalHosts: handlers.updateTerminalHosts,
|
||||
hotkeyScheme: terminalSettings.hotkeyScheme,
|
||||
isBroadcastEnabled: sessionActions?.isBroadcastEnabled,
|
||||
isComposeSendToAll: sessionActions?.isComposeSendToAll,
|
||||
onToggleComposeSendTarget: sessionActions?.toggleComposeSendTarget,
|
||||
isGlobalBroadcastEnabled: sessionActions?.isGlobalBroadcastEnabled,
|
||||
isGlobalComposeSendToAll: sessionActions?.isGlobalComposeSendToAll,
|
||||
onToggleGlobalComposeSendTarget: sessionActions?.toggleGlobalComposeSendTarget,
|
||||
canUseGlobalBroadcast: sessionActions?.canUseGlobalBroadcast,
|
||||
keyBindings: terminalSettings.keyBindings,
|
||||
openNoteRequest: local.openNoteRequest,
|
||||
|
||||
@@ -70,8 +70,12 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
reorderTabs,
|
||||
toggleBroadcast,
|
||||
isBroadcastEnabled,
|
||||
toggleComposeSendTarget,
|
||||
isComposeSendToAll,
|
||||
toggleGlobalBroadcast,
|
||||
isGlobalBroadcastEnabled,
|
||||
toggleGlobalComposeSendTarget,
|
||||
isGlobalComposeSendToAll,
|
||||
canUseGlobalBroadcast,
|
||||
copySession,
|
||||
copyWorkspace,
|
||||
@@ -150,8 +154,12 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
reorderTabs,
|
||||
toggleBroadcast,
|
||||
isBroadcastEnabled,
|
||||
toggleComposeSendTarget,
|
||||
isComposeSendToAll,
|
||||
toggleGlobalBroadcast,
|
||||
isGlobalBroadcastEnabled,
|
||||
toggleGlobalComposeSendTarget,
|
||||
isGlobalComposeSendToAll,
|
||||
canUseGlobalBroadcast,
|
||||
copySession,
|
||||
copyWorkspace,
|
||||
@@ -181,7 +189,9 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
getOrderedWorkTabs,
|
||||
getSessionRestoreCwd,
|
||||
isBroadcastEnabled,
|
||||
isComposeSendToAll,
|
||||
isGlobalBroadcastEnabled,
|
||||
isGlobalComposeSendToAll,
|
||||
moveFocusInWorkspace,
|
||||
openLogView,
|
||||
canUseGlobalBroadcast,
|
||||
@@ -201,7 +211,9 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
submitSessionRename,
|
||||
submitWorkspaceRename,
|
||||
toggleBroadcast,
|
||||
toggleComposeSendTarget,
|
||||
toggleGlobalBroadcast,
|
||||
toggleGlobalComposeSendTarget,
|
||||
toggleWorkspaceViewMode,
|
||||
updateSessionCodingCliProvider,
|
||||
updateSessionDynamicTitle,
|
||||
|
||||
@@ -165,8 +165,12 @@ test('vault init cancels the superseded StrictMode effect before publishing read
|
||||
);
|
||||
const initAt = vaultSource.indexOf('let cancelled = false;');
|
||||
assert.notEqual(initAt, -1, 'vault init must track cancellation');
|
||||
const initSlice = vaultSource.slice(initAt, initAt + 12000);
|
||||
assert.match(initSlice, /return \(\) => \{\s*\n\s*cancelled = true;\s*\n\s*\};/);
|
||||
const initSlice = vaultSource.slice(initAt, initAt + 20000);
|
||||
assert.match(
|
||||
initSlice,
|
||||
/return \(\) => \{\s*\n\s*cancelled = true;\s*\n\s*resolveTerminalDataReady\(\);\s*\n\s*\};/,
|
||||
'StrictMode cleanup cancels init and unblocks the terminal-data ready promise',
|
||||
);
|
||||
assert.match(
|
||||
initSlice,
|
||||
/if \(!cancelled\) \{\s*\n\s*setIsInitialized\(true\);\s*\n\s*setVaultInitialized\(true\);\s*\n\s*\}/,
|
||||
|
||||
@@ -964,6 +964,7 @@ Highlight the focused split pane:
|
||||
'settings.shortcuts.category.terminal': 'Terminal',
|
||||
'settings.shortcuts.category.navigation': 'Navigation',
|
||||
'settings.shortcuts.category.app': 'App',
|
||||
'settings.shortcuts.binding.toggle-host-tree': 'Toggle Host List',
|
||||
'settings.shortcuts.category.sftp': 'SFTP',
|
||||
|
||||
// Context menus / common actions
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
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",
|
||||
|
||||
@@ -77,6 +77,8 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.composeBar.send': 'Send',
|
||||
'terminal.composeBar.close': 'Close compose bar',
|
||||
'terminal.composeBar.broadcasting': 'Broadcasting to all sessions',
|
||||
'terminal.composeBar.sendToAll': 'Send to all sessions',
|
||||
'terminal.composeBar.sendToActive': 'Send to the active session only',
|
||||
'terminal.composeBar.broadcastAllowPassword': 'Broadcast without password protection',
|
||||
'terminal.composeBar.resize': 'Resize compose bar height',
|
||||
'terminal.composeBar.manageSnippets': 'Manage quick snippets',
|
||||
|
||||
@@ -913,6 +913,7 @@ Resalta el panel dividido enfocado:
|
||||
'settings.shortcuts.binding.broadcast': 'Alternar modo de transmisión',
|
||||
'settings.shortcuts.binding.toggle-opacity': 'Alternar la transparencia de la ventana',
|
||||
'settings.shortcuts.binding.toggle-side-panel': 'Alternar panel lateral',
|
||||
'settings.shortcuts.binding.toggle-host-tree': 'Alternar lista de hosts',
|
||||
'settings.shortcuts.binding.sftp-copy': 'Copiar archivos',
|
||||
'settings.shortcuts.binding.sftp-cut': 'Cortar archivos',
|
||||
'settings.shortcuts.binding.sftp-paste': 'Pegar archivos',
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
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",
|
||||
|
||||
@@ -77,6 +77,8 @@ export const esTerminalMessages: Messages = {
|
||||
'terminal.composeBar.send': 'Enviar',
|
||||
'terminal.composeBar.close': 'Cerrar barra de redacción',
|
||||
'terminal.composeBar.broadcasting': 'Difundiendo a todas las sesiones',
|
||||
'terminal.composeBar.sendToAll': 'Enviar a todas las sesiones',
|
||||
'terminal.composeBar.sendToActive': 'Enviar solo a la sesión activa',
|
||||
'terminal.composeBar.broadcastAllowPassword': 'Difundir sin protección de contraseña',
|
||||
'terminal.composeBar.resize': 'Cambiar la altura de la barra de redacción',
|
||||
'terminal.composeBar.manageSnippets': 'Administrar snippets rápidos',
|
||||
|
||||
@@ -938,6 +938,7 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.shortcuts.binding.broadcast': 'Переключить режим трансляции',
|
||||
'settings.shortcuts.binding.toggle-opacity': 'Переключить прозрачность окна',
|
||||
'settings.shortcuts.binding.toggle-side-panel': 'Переключить боковую панель',
|
||||
'settings.shortcuts.binding.toggle-host-tree': 'Переключить список хостов',
|
||||
'settings.shortcuts.binding.sftp-copy': 'Копировать файл',
|
||||
'settings.shortcuts.binding.sftp-cut': 'Вырезать файл',
|
||||
'settings.shortcuts.binding.sftp-paste': 'Вставить файл',
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
export const ruMonitorMessages = {
|
||||
"monitor.diskIo": "Дисковый ввод-вывод",
|
||||
"monitor.diskRead": "Чтение",
|
||||
"monitor.diskWrite": "Запись",
|
||||
"monitor.diskIoHint": "Суммарный ввод-вывод физических блочных устройств Linux, без разделов и виртуальных слоёв. Доступен после двух корректных замеров; macOS не поддерживается.",
|
||||
|
||||
"monitor.actions": "Действия панели",
|
||||
"monitor.unavailable": "Мониторинг недоступен. Запустите его снова после восстановления связи.",
|
||||
"monitor.username": "Пользователь",
|
||||
|
||||
@@ -98,6 +98,8 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.composeBar.send': 'Отправить',
|
||||
'terminal.composeBar.close': 'Закрыть строку ввода',
|
||||
'terminal.composeBar.broadcasting': 'Трансляция во все сессии',
|
||||
'terminal.composeBar.sendToAll': 'Отправить во все сессии',
|
||||
'terminal.composeBar.sendToActive': 'Отправить только в активную сессию',
|
||||
'terminal.composeBar.broadcastAllowPassword': 'Транслировать без защиты пароля',
|
||||
'terminal.composeBar.resize': 'Изменить высоту строки ввода',
|
||||
'terminal.composeBar.manageSnippets': 'Управление быстрыми сниппетами',
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
export const zhCNMonitorMessages = {
|
||||
"monitor.diskIo": "磁盘 I/O",
|
||||
"monitor.diskRead": "读取",
|
||||
"monitor.diskWrite": "写入",
|
||||
"monitor.diskIoHint": "Linux 底层块设备的汇总 I/O,不重复统计分区和叠加的虚拟设备。取得两次有效采样前不可用;暂不支持 macOS。",
|
||||
|
||||
"monitor.actions": "面板操作",
|
||||
"monitor.unavailable": "监控暂不可用,连接恢复后请重新开始。",
|
||||
"monitor.username": "用户名",
|
||||
|
||||
@@ -621,6 +621,7 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.shortcuts.binding.broadcast': '切换广播模式',
|
||||
'settings.shortcuts.binding.toggle-opacity': '切换窗口透明度',
|
||||
'settings.shortcuts.binding.toggle-side-panel': '切换侧边栏',
|
||||
'settings.shortcuts.binding.toggle-host-tree': '切换主机列表',
|
||||
'settings.shortcuts.binding.sftp-copy': '复制文件',
|
||||
'settings.shortcuts.binding.sftp-cut': '剪切文件',
|
||||
'settings.shortcuts.binding.sftp-paste': '粘贴文件',
|
||||
|
||||
@@ -524,6 +524,8 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.composeBar.send': '发送',
|
||||
'terminal.composeBar.close': '关闭撰写栏',
|
||||
'terminal.composeBar.broadcasting': '正在广播到所有会话',
|
||||
'terminal.composeBar.sendToAll': '发送到所有会话',
|
||||
'terminal.composeBar.sendToActive': '仅发送到当前会话',
|
||||
'terminal.composeBar.broadcastAllowPassword': '广播时不对密码进行保护',
|
||||
'terminal.composeBar.resize': '拖拽调整撰写栏高度',
|
||||
'terminal.composeBar.manageSnippets': '管理快捷代码片段',
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
export const zhTWMonitorMessages = {
|
||||
"monitor.diskIo": "磁碟 I/O",
|
||||
"monitor.diskRead": "讀取",
|
||||
"monitor.diskWrite": "寫入",
|
||||
"monitor.diskIoHint": "Linux 底層區塊裝置的彙總 I/O,不重複統計分割區與疊加的虛擬裝置。取得兩次有效取樣前無法使用;暫不支援 macOS。",
|
||||
|
||||
"monitor.actions": "面板操作",
|
||||
"monitor.unavailable": "監控暫時無法使用,連線恢復後請重新開始。",
|
||||
"monitor.username": "使用者名稱",
|
||||
|
||||
@@ -621,6 +621,7 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.shortcuts.binding.broadcast': '切換廣播模式',
|
||||
'settings.shortcuts.binding.toggle-opacity': '切換視窗透明度',
|
||||
'settings.shortcuts.binding.toggle-side-panel': '切換側邊欄',
|
||||
'settings.shortcuts.binding.toggle-host-tree': '切換主機列表',
|
||||
'settings.shortcuts.binding.sftp-copy': '複製檔案',
|
||||
'settings.shortcuts.binding.sftp-cut': '剪下檔案',
|
||||
'settings.shortcuts.binding.sftp-paste': '貼上檔案',
|
||||
|
||||
@@ -524,6 +524,8 @@ export const zhTWVaultMessages: Messages = {
|
||||
'terminal.composeBar.send': '傳送',
|
||||
'terminal.composeBar.close': '關閉撰寫欄',
|
||||
'terminal.composeBar.broadcasting': '正在廣播到所有工作階段',
|
||||
'terminal.composeBar.sendToAll': '傳送到所有工作階段',
|
||||
'terminal.composeBar.sendToActive': '僅傳送到目前工作階段',
|
||||
'terminal.composeBar.broadcastAllowPassword': '廣播時不對密碼進行保護',
|
||||
'terminal.composeBar.resize': '拖曳調整撰寫欄高度',
|
||||
'terminal.composeBar.manageSnippets': '管理快捷程式碼片段',
|
||||
|
||||
@@ -102,6 +102,10 @@ test('session actions register and unregister', () => {
|
||||
reorderTabs: noop,
|
||||
toggleBroadcast: noop,
|
||||
isBroadcastEnabled: noop,
|
||||
toggleComposeSendTarget: noop,
|
||||
isComposeSendToAll: noop,
|
||||
toggleGlobalComposeSendTarget: noop,
|
||||
isGlobalComposeSendToAll: false,
|
||||
copySession: noop,
|
||||
copyWorkspace: noop,
|
||||
createSessionFromCloneSource: noop,
|
||||
|
||||
@@ -68,8 +68,12 @@ export type SessionSnapshotActions = Pick<
|
||||
| 'reorderTabs'
|
||||
| 'toggleBroadcast'
|
||||
| 'isBroadcastEnabled'
|
||||
| 'toggleComposeSendTarget'
|
||||
| 'isComposeSendToAll'
|
||||
| 'toggleGlobalBroadcast'
|
||||
| 'isGlobalBroadcastEnabled'
|
||||
| 'toggleGlobalComposeSendTarget'
|
||||
| 'isGlobalComposeSendToAll'
|
||||
| 'canUseGlobalBroadcast'
|
||||
| 'copySession'
|
||||
| 'copyWorkspace'
|
||||
|
||||
@@ -10,6 +10,18 @@ const failed = (over: Partial<TransferTask> = {}): TransferTask => ({
|
||||
speed: 0, startTime: 1, isDirectory: false, error: "SFTP channel closed", ...over,
|
||||
});
|
||||
|
||||
test("canRetryFromCheckpoint resumes the channel-loss forms the bridge already classifies", () => {
|
||||
for (const error of [
|
||||
"read ECONNABORTED",
|
||||
"socket hang up",
|
||||
"ERR_SFTP_CHANNEL_CLOSED",
|
||||
"SFTP pipelined upload failed: socket hang up",
|
||||
"client-socket",
|
||||
]) {
|
||||
assert.equal(canRetryFromCheckpoint(failed({ error })), true, error);
|
||||
}
|
||||
});
|
||||
|
||||
test("canRetryFromCheckpoint only for a top-level file that lost its connection with progress", () => {
|
||||
assert.equal(canRetryFromCheckpoint(failed()), true);
|
||||
assert.equal(canRetryFromCheckpoint(failed({ error: "read ECONNRESET" })), true);
|
||||
|
||||
@@ -4,7 +4,11 @@ import type { TransferTask } from "../../../domain/models";
|
||||
// .part is intact up to the checkpoint, so Retry can continue from it instead
|
||||
// of restarting at byte 0 (which also strands the old stage on the server,
|
||||
// because the new attempt stages under a fresh transfer id).
|
||||
const SESSION_LOSS_ERROR = /SFTP (channel closed|session lost)|ECONNRESET|EPIPE|Not connected|No response from server|Keepalive timeout|connection (lost|closed|reset)/i;
|
||||
// Keep this identical to UPLOAD_PREFIX_SESSION_LOSS in transferBridge.cjs, and
|
||||
// cover every code or message isSftpChannelLossError already treats as channel
|
||||
// loss (including ECONNABORTED, socket hang up, ERR_SFTP_CHANNEL_CLOSED, and
|
||||
// client-socket). A miss here restarts at byte 0 and strands the .part.
|
||||
const SESSION_LOSS_ERROR = /SFTP (channel closed|session lost)|ERR_SFTP_CHANNEL_CLOSED|ECONNRESET|ECONNABORTED|EPIPE|Not connected|No response from server|Keepalive timeout|socket hang up|client-socket|connection (lost|closed|reset)/i;
|
||||
|
||||
/**
|
||||
* True when Retry should resume the same task from its checkpoint.
|
||||
|
||||
@@ -100,6 +100,10 @@ test("background-agent retry after restart uses fresh dedicated recovery instead
|
||||
for (const [label, error, expectResume] of [
|
||||
["dead channel", "SFTP channel closed", true],
|
||||
["lost session", "SFTP session lost. Please reconnect.", true],
|
||||
["aborted socket", "read ECONNABORTED", true],
|
||||
["hung up socket", "socket hang up", true],
|
||||
["closed channel code", "ERR_SFTP_CHANNEL_CLOSED", true],
|
||||
["client socket", "client-socket", true],
|
||||
["content failure", "Permission denied", false],
|
||||
] as const) {
|
||||
test(`panel Retry after ${label} ${expectResume ? "resumes the same task from its checkpoint" : "keeps the restart-from-0 retry"}`, async () => {
|
||||
|
||||
@@ -54,6 +54,8 @@ 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
|
||||
@@ -240,6 +242,8 @@ 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,
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
|
||||
import { useSessionState } from "./useSessionState.ts";
|
||||
|
||||
type SessionState = ReturnType<typeof useSessionState>;
|
||||
|
||||
test("compose send target follows toolbar broadcast and then flips independently", async () => {
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
const eventTarget = new EventTarget() as EventTarget & Record<string, unknown>;
|
||||
Object.assign(eventTarget, { setTimeout, clearTimeout });
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: eventTarget,
|
||||
});
|
||||
const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let state: SessionState | null = null;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
const current = (): SessionState => {
|
||||
assert.ok(state);
|
||||
return state;
|
||||
};
|
||||
|
||||
function Probe() {
|
||||
state = useSessionState({ persistSessionRestore: false });
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
});
|
||||
await act(async () => { current().createLocalTerminal({ shellName: "01" }); });
|
||||
await act(async () => { current().createLocalTerminal({ shellName: "02" }); });
|
||||
const [first, second] = current().orphanSessions;
|
||||
await act(async () => {
|
||||
current().createWorkspaceFromSessions(first.id, second.id, "right");
|
||||
});
|
||||
const workspaceId = current().workspaces[0]?.id;
|
||||
assert.ok(workspaceId);
|
||||
assert.equal(current().isBroadcastEnabled(workspaceId), false);
|
||||
assert.equal(current().isComposeSendToAll(workspaceId), false);
|
||||
|
||||
await act(async () => { current().toggleBroadcast(workspaceId); });
|
||||
assert.equal(current().isBroadcastEnabled(workspaceId), true);
|
||||
assert.equal(current().isComposeSendToAll(workspaceId), true);
|
||||
|
||||
await act(async () => { current().toggleComposeSendTarget(workspaceId); });
|
||||
assert.equal(current().isBroadcastEnabled(workspaceId), true);
|
||||
assert.equal(current().isComposeSendToAll(workspaceId), false);
|
||||
|
||||
await act(async () => { current().toggleComposeSendTarget(workspaceId); });
|
||||
assert.equal(current().isComposeSendToAll(workspaceId), true);
|
||||
assert.equal(current().isBroadcastEnabled(workspaceId), true);
|
||||
|
||||
await act(async () => { current().toggleBroadcast(workspaceId); });
|
||||
assert.equal(current().isBroadcastEnabled(workspaceId), false);
|
||||
assert.equal(current().isComposeSendToAll(workspaceId), false);
|
||||
|
||||
await act(async () => { current().toggleComposeSendTarget(workspaceId); });
|
||||
assert.equal(current().isBroadcastEnabled(workspaceId), false);
|
||||
assert.equal(current().isComposeSendToAll(workspaceId), true);
|
||||
|
||||
await act(async () => { current().toggleGlobalBroadcast(); });
|
||||
assert.equal(current().isGlobalBroadcastEnabled, false);
|
||||
assert.equal(current().isGlobalComposeSendToAll, false);
|
||||
assert.equal(current().canUseGlobalBroadcast, false);
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, "window");
|
||||
}
|
||||
});
|
||||
|
||||
test("global compose send target follows the toolbar switch for orphan sessions", async () => {
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
const eventTarget = new EventTarget() as EventTarget & Record<string, unknown>;
|
||||
Object.assign(eventTarget, { setTimeout, clearTimeout });
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: eventTarget,
|
||||
});
|
||||
const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let state: SessionState | null = null;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
const current = (): SessionState => {
|
||||
assert.ok(state);
|
||||
return state;
|
||||
};
|
||||
|
||||
function Probe() {
|
||||
state = useSessionState({ persistSessionRestore: false });
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
});
|
||||
await act(async () => { current().createLocalTerminal({ shellName: "01" }); });
|
||||
await act(async () => { current().createLocalTerminal({ shellName: "02" }); });
|
||||
assert.equal(current().canUseGlobalBroadcast, true);
|
||||
assert.equal(current().isGlobalComposeSendToAll, false);
|
||||
|
||||
await act(async () => { current().toggleGlobalBroadcast(); });
|
||||
assert.equal(current().isGlobalBroadcastEnabled, true);
|
||||
assert.equal(current().isGlobalComposeSendToAll, true);
|
||||
|
||||
await act(async () => { current().toggleGlobalComposeSendTarget(); });
|
||||
assert.equal(current().isGlobalBroadcastEnabled, true);
|
||||
assert.equal(current().isGlobalComposeSendToAll, false);
|
||||
|
||||
await act(async () => { current().toggleGlobalBroadcast(); });
|
||||
assert.equal(current().isGlobalBroadcastEnabled, false);
|
||||
assert.equal(current().isGlobalComposeSendToAll, false);
|
||||
|
||||
const closedId = current().orphanSessions[1].id;
|
||||
await act(async () => { current().toggleGlobalComposeSendTarget(); });
|
||||
assert.equal(current().isGlobalComposeSendToAll, true);
|
||||
await act(async () => { current().closeSession(closedId); });
|
||||
assert.equal(current().canUseGlobalBroadcast, false);
|
||||
assert.equal(current().isGlobalBroadcastEnabled, false);
|
||||
assert.equal(current().isGlobalComposeSendToAll, false);
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, "window");
|
||||
}
|
||||
});
|
||||
@@ -25,6 +25,10 @@ SplitHint,
|
||||
updateWorkspaceSplitSizes,
|
||||
} from '../../domain/workspace';
|
||||
import { clearSessionFontSizeOverride as clearSessionFontSizeOverrideFields } from '../../domain/terminalAppearance';
|
||||
import {
|
||||
composeSendTargetAfterToolbarToggle,
|
||||
flipComposeSendTarget,
|
||||
} from '../../domain/terminalBroadcast';
|
||||
import { buildOrderedWorkTabIds, reorderWorkTabIds } from '../app/workTabSurface';
|
||||
import { activeTabStore } from './activeTabStore';
|
||||
import {
|
||||
@@ -235,8 +239,12 @@ export const useSessionState = ({
|
||||
const [tabOrder, setTabOrder] = useState<string[]>(initialRestoreState.tabOrder);
|
||||
// Broadcast mode: stores workspace IDs that have broadcast enabled
|
||||
const [broadcastWorkspaceIds, setBroadcastWorkspaceIds] = useState<Set<string>>(new Set());
|
||||
// Compose-bar send target. Toolbar toggles reset it; the compose bar can
|
||||
// then flip it without changing keystroke fan-out.
|
||||
const [composeSendAllWorkspaceIds, setComposeSendAllWorkspaceIds] = useState<Set<string>>(new Set());
|
||||
// Global broadcast mode: enables broadcast for all orphan sessions (not in workspaces)
|
||||
const [globalBroadcastEnabled, setGlobalBroadcastEnabled] = useState<boolean>(false);
|
||||
const [globalComposeSendToAll, setGlobalComposeSendToAll] = useState<boolean>(false);
|
||||
// Log views: stores open log replay tabs
|
||||
const [logViews, setLogViews] = useState<LogView[]>([]);
|
||||
const [restorePreviousSessionRevision, setRestorePreviousSessionRevision] = useState(0);
|
||||
@@ -1137,8 +1145,10 @@ export const useSessionState = ({
|
||||
const canUseGlobalBroadcast = orphanSessions.length >= 2;
|
||||
|
||||
// Losing the group must disarm broadcast before another tab can join it.
|
||||
if (!canUseGlobalBroadcast && globalBroadcastEnabled) {
|
||||
setGlobalBroadcastEnabled(false);
|
||||
// Compose-bar send target follows the toolbar switch back to active-only.
|
||||
if (!canUseGlobalBroadcast && (globalBroadcastEnabled || globalComposeSendToAll)) {
|
||||
if (globalBroadcastEnabled) setGlobalBroadcastEnabled(false);
|
||||
if (globalComposeSendToAll) setGlobalComposeSendToAll(false);
|
||||
}
|
||||
|
||||
const openLogView = useCallback((log: ConnectionLog) => {
|
||||
@@ -1273,31 +1283,61 @@ export const useSessionState = ({
|
||||
return newSessionId;
|
||||
}, [setActiveTabId]);
|
||||
|
||||
// Toggle broadcast mode for a workspace
|
||||
const toggleBroadcast = useCallback((workspaceId: string) => {
|
||||
setBroadcastWorkspaceIds(prev => {
|
||||
const setWorkspaceComposeSendTarget = useCallback((workspaceId: string, target: 'all' | 'active') => {
|
||||
setComposeSendAllWorkspaceIds((prev) => {
|
||||
const hasAll = prev.has(workspaceId);
|
||||
if ((target === 'all') === hasAll) return prev;
|
||||
const next = new Set(prev);
|
||||
if (next.has(workspaceId)) {
|
||||
next.delete(workspaceId);
|
||||
} else {
|
||||
next.add(workspaceId);
|
||||
}
|
||||
if (target === 'all') next.add(workspaceId);
|
||||
else next.delete(workspaceId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Toggle broadcast mode for a workspace. Compose-bar send target follows.
|
||||
const toggleBroadcast = useCallback((workspaceId: string) => {
|
||||
const enabling = !broadcastWorkspaceIds.has(workspaceId);
|
||||
setBroadcastWorkspaceIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (prev.has(workspaceId)) next.delete(workspaceId);
|
||||
else next.add(workspaceId);
|
||||
return next;
|
||||
});
|
||||
setWorkspaceComposeSendTarget(workspaceId, composeSendTargetAfterToolbarToggle(enabling));
|
||||
}, [broadcastWorkspaceIds, setWorkspaceComposeSendTarget]);
|
||||
|
||||
// Check if a workspace has broadcast enabled
|
||||
const isBroadcastEnabled = useCallback((workspaceId: string) => {
|
||||
return broadcastWorkspaceIds.has(workspaceId);
|
||||
}, [broadcastWorkspaceIds]);
|
||||
|
||||
const toggleComposeSendTarget = useCallback((workspaceId: string) => {
|
||||
setWorkspaceComposeSendTarget(
|
||||
workspaceId,
|
||||
flipComposeSendTarget(composeSendAllWorkspaceIds.has(workspaceId) ? 'all' : 'active'),
|
||||
);
|
||||
}, [composeSendAllWorkspaceIds, setWorkspaceComposeSendTarget]);
|
||||
|
||||
const isComposeSendToAll = useCallback((workspaceId: string) => {
|
||||
return composeSendAllWorkspaceIds.has(workspaceId);
|
||||
}, [composeSendAllWorkspaceIds]);
|
||||
|
||||
// Toggle global broadcast mode for orphan sessions
|
||||
const toggleGlobalBroadcast = useCallback(() => {
|
||||
setGlobalBroadcastEnabled(prev => !prev);
|
||||
setGlobalBroadcastEnabled(prev => {
|
||||
const next = !prev;
|
||||
setGlobalComposeSendToAll(composeSendTargetAfterToolbarToggle(next) === 'all');
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleGlobalComposeSendTarget = useCallback(() => {
|
||||
setGlobalComposeSendToAll((prev) => flipComposeSendTarget(prev ? 'all' : 'active') === 'all');
|
||||
}, []);
|
||||
|
||||
// Global broadcast enabled state - direct boolean value
|
||||
const isGlobalBroadcastEnabled = globalBroadcastEnabled;
|
||||
const isGlobalComposeSendToAll = globalComposeSendToAll;
|
||||
|
||||
const baseWorkTabIds = useMemo(() => [
|
||||
...orphanSessions.map(s => s.id),
|
||||
@@ -1426,8 +1466,12 @@ export const useSessionState = ({
|
||||
// Broadcast mode
|
||||
toggleBroadcast,
|
||||
isBroadcastEnabled,
|
||||
toggleComposeSendTarget,
|
||||
isComposeSendToAll,
|
||||
toggleGlobalBroadcast,
|
||||
isGlobalBroadcastEnabled,
|
||||
toggleGlobalComposeSendTarget,
|
||||
isGlobalComposeSendToAll,
|
||||
orderedTabs,
|
||||
getOrderedWorkTabs,
|
||||
reorderTabs,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const source = readFileSync(new URL("./useVaultState.ts", import.meta.url), "utf8");
|
||||
const terminalLayerSource = readFileSync(
|
||||
new URL("../../components/TerminalLayer.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
test("connection log replay blobs persist through IndexedDB instead of the legacy localStorage blob", () => {
|
||||
assert.match(source, /await migrateLegacyConnectionLogTerminalData\(/);
|
||||
assert.match(source, /await writeTerminalDataMapDiff\(/);
|
||||
assert.match(source, /STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV/);
|
||||
assert.match(source, /syncConnectionLogTerminalData !== false|syncConnectionLogTerminalData\)/);
|
||||
assert.doesNotMatch(source, /writeTerminalDataMapWithQuotaRetry/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/localStorageAdapter\.write\(\s*STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA\s*,/,
|
||||
);
|
||||
});
|
||||
|
||||
test("close-path connection log updates await the indexedDB persist task", () => {
|
||||
assert.match(
|
||||
source,
|
||||
/const updateConnectionLog = useCallback\([\s\S]*?return commitConnectionLogs\(/,
|
||||
);
|
||||
assert.match(source, /rebaseQueuedConnectionLogs\(\s*next,\s*removedIds,\s*changedIds,/);
|
||||
assert.match(
|
||||
source,
|
||||
/if \(!options\?\.pruneMainBlob\) \{[\s\S]*?rebaseQueuedConnectionLogs\(/,
|
||||
);
|
||||
assert.match(source, /retainNewestUnsavedReplays\(fallbackLogs, keep\)/);
|
||||
assert.match(source, /if \(!map\) return;/);
|
||||
assert.match(
|
||||
source,
|
||||
/if \(key === STORAGE_KEY_CONNECTION_LOGS\) \{\s*\/\/ This window already updated connectionLogsRef before setItem\.[\s\S]*?return;\s*\}/,
|
||||
);
|
||||
assert.match(
|
||||
terminalLayerSource,
|
||||
/const handleTerminalDataCapture = useCallback\(\(sessionId: string, data: string\) => \{\s*return onTerminalDataCapture\?\.\(sessionId, data\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test("boot re-reads connection logs after indexedDB load and falls back to the main blob", () => {
|
||||
const revAt = source.indexOf("let revSeen = readTerminalDataRev()");
|
||||
const bootAt = source.indexOf("const loaded = await migrateLegacyConnectionLogTerminalData");
|
||||
assert.notEqual(revAt, -1);
|
||||
assert.ok(revAt < bootAt);
|
||||
const bootSlice = source.slice(bootAt, bootAt + 2800);
|
||||
assert.match(bootSlice, /revNow !== revSeen[\s\S]*?await loadConnectionLogTerminalData\(\)/);
|
||||
assert.match(
|
||||
bootSlice,
|
||||
/localStorageAdapter\.read<ConnectionLog\[\]>\(\s*STORAGE_KEY_CONNECTION_LOGS,\s*\)[\s\S]*?pruneTerminalDataMapForStorage\(connectionLogsForBoot/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/!sidePersisted && unsavedTerminalDataPending && mainPersisted\) \{\s*\/\/[\s\S]*?localStorageAdapter\.read<ConnectionLog\[\]>\(\s*STORAGE_KEY_CONNECTION_LOGS\s*\)[\s\S]*?mergeTerminalDataIntoLogs\(freshLogs, buildTerminalDataMapFromLogs\(logs\)\)/,
|
||||
);
|
||||
});
|
||||
+319
-133
@@ -37,6 +37,7 @@ import {
|
||||
import {
|
||||
STORAGE_KEY_CONNECTION_LOGS,
|
||||
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA,
|
||||
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV,
|
||||
STORAGE_KEY_GROUP_CONFIGS,
|
||||
STORAGE_KEY_GROUPS,
|
||||
STORAGE_KEY_HOSTS,
|
||||
@@ -65,9 +66,17 @@ import {
|
||||
mergeTerminalDataIntoLogs,
|
||||
mergeTerminalDataMapsForStorage,
|
||||
pruneInMemoryUnsavedTerminalData,
|
||||
writeTerminalDataMapWithQuotaRetry,
|
||||
rebaseQueuedConnectionLogs,
|
||||
retainNewestUnsavedReplays,
|
||||
pruneTerminalDataMapForStorage,
|
||||
type ConnectionLogTerminalDataMap,
|
||||
} from "../../domain/connectionLogTerminalData";
|
||||
import {
|
||||
tryLoadAll as tryLoadConnectionLogTerminalData,
|
||||
migrateLegacyConnectionLogTerminalData,
|
||||
writeTerminalDataMapDiff,
|
||||
type ConnectionLogTerminalDataRev,
|
||||
} from "../../infrastructure/persistence/connectionLogTerminalDataStore";
|
||||
import { getNextVaultOrder, normalizeVaultOrder } from "../../domain/vaultOrder";
|
||||
import {
|
||||
deleteSelectedSnippetsFromVault,
|
||||
@@ -229,6 +238,13 @@ const safeParse = <T,>(value: string | null): T | null => {
|
||||
}
|
||||
};
|
||||
|
||||
/** `null` means the read failed. `{}` is a successful empty store. Retries once. */
|
||||
const loadConnectionLogTerminalData = async (): Promise<ConnectionLogTerminalDataMap | null> => {
|
||||
const first = await tryLoadConnectionLogTerminalData();
|
||||
if (first) return first;
|
||||
return tryLoadConnectionLogTerminalData();
|
||||
};
|
||||
|
||||
/**
|
||||
* Strip the bulky `terminalData` replay buffer from transient (unsaved)
|
||||
* connection logs before persisting. `terminalData` is the full terminal
|
||||
@@ -257,9 +273,6 @@ const readLegacyLineTimestampsEnabled = (): boolean => {
|
||||
return stored?.showLineTimestamps === true;
|
||||
};
|
||||
|
||||
const readConnectionLogTerminalDataMap = (): ConnectionLogTerminalDataMap =>
|
||||
localStorageAdapter.read<ConnectionLogTerminalDataMap>(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA) ?? {};
|
||||
|
||||
const readPersistedConnectionLogIds = (): Set<string> => {
|
||||
const stored = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS) ?? [];
|
||||
return new Set(stored.map((log) => log.id));
|
||||
@@ -278,45 +291,6 @@ const buildPersistedLogIds = (
|
||||
return ids;
|
||||
};
|
||||
|
||||
/**
|
||||
* Same result as comparing `JSON.stringify` of both maps (own keys in the
|
||||
* same order, equal values) without serializing the multi-MB replay data.
|
||||
*/
|
||||
const isSameTerminalDataMap = (
|
||||
a: ConnectionLogTerminalDataMap,
|
||||
b: ConnectionLogTerminalDataMap,
|
||||
): boolean => {
|
||||
if (typeof a !== "object" || a === null || Array.isArray(a)) {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
const aKeys = Object.keys(a);
|
||||
const bKeys = Object.keys(b);
|
||||
if (aKeys.length !== bKeys.length) return false;
|
||||
for (let i = 0; i < aKeys.length; i += 1) {
|
||||
if (aKeys[i] !== bKeys[i] || a[aKeys[i]] !== b[bKeys[i]]) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const writeConnectionLogTerminalDataMap = (
|
||||
existing: ConnectionLogTerminalDataMap,
|
||||
pruned: ConnectionLogTerminalDataMap,
|
||||
logs: ConnectionLog[],
|
||||
quotaFailedCharsRef: { current: number | null },
|
||||
): boolean => {
|
||||
if (isSameTerminalDataMap(existing, pruned)) return true;
|
||||
// On quota failure keep the newest replay buffers instead of none.
|
||||
// The hint stays on the hook so the domain helper remains a pure calculation.
|
||||
const result = writeTerminalDataMapWithQuotaRetry(
|
||||
logs,
|
||||
pruned,
|
||||
(next) => localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA, next),
|
||||
quotaFailedCharsRef.current,
|
||||
);
|
||||
quotaFailedCharsRef.current = result.quotaFailedChars;
|
||||
return result.persisted;
|
||||
};
|
||||
|
||||
export type UseVaultStateOptions = {
|
||||
/**
|
||||
* When false, skip loading and cross-window sync of the bulky
|
||||
@@ -454,42 +428,92 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
const identitiesReadSeq = useRef(0);
|
||||
const proxyProfilesReadSeq = useRef(0);
|
||||
const groupConfigsReadSeq = useRef(0);
|
||||
const connectionLogsRef = useRef<ConnectionLog[]>([]);
|
||||
const connectionLogTerminalDataRef = useRef<ConnectionLogTerminalDataMap>({});
|
||||
const connectionLogQuotaFailedCharsRef = useRef<number | null>(null);
|
||||
const connectionLogTerminalDataPersistedRef = useRef<ConnectionLogTerminalDataMap>({});
|
||||
const connectionLogTerminalDataRevRawRef = useRef<string | null>(null);
|
||||
const connectionLogTerminalDataLoadSeq = useRef(0);
|
||||
const terminalDataQueueRef = useRef<Promise<void>>(Promise.resolve());
|
||||
const terminalDataReadyRef = useRef<Promise<void>>(Promise.resolve());
|
||||
const pluginCredentialCatalogVersion = useRef(0);
|
||||
|
||||
const syncConnectionLogTerminalDataMap = useCallback((
|
||||
const enqueueTerminalDataTask = useCallback(<T,>(task: () => Promise<T>): Promise<T> => {
|
||||
const run = terminalDataQueueRef.current.then(task, task);
|
||||
terminalDataQueueRef.current = run.then(() => undefined, () => undefined);
|
||||
return run;
|
||||
}, []);
|
||||
|
||||
const bumpConnectionLogTerminalDataRev = useCallback((ids: readonly string[]) => {
|
||||
const current = localStorageAdapter.read<ConnectionLogTerminalDataRev>(
|
||||
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV,
|
||||
);
|
||||
const prev = typeof current?.rev === "number" && Number.isFinite(current.rev) ? current.rev : 0;
|
||||
const payload: ConnectionLogTerminalDataRev = {
|
||||
rev: Math.max(prev + 1, Date.now()),
|
||||
ids: [...ids].sort(),
|
||||
nonce: crypto.randomUUID(),
|
||||
};
|
||||
// Record the exact JSON before setItem so this window ignores its own echo.
|
||||
connectionLogTerminalDataRevRawRef.current = JSON.stringify(payload);
|
||||
const written = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV, payload);
|
||||
if (!written) {
|
||||
connectionLogTerminalDataRevRawRef.current = localStorageAdapter.readString(
|
||||
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV,
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const absorbRemoteTerminalDataIfNeeded = useCallback(async () => {
|
||||
const raw = localStorageAdapter.readString(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV);
|
||||
if (raw === connectionLogTerminalDataRevRawRef.current) return;
|
||||
const map = await loadConnectionLogTerminalData();
|
||||
if (!map) return;
|
||||
connectionLogTerminalDataRevRawRef.current = raw;
|
||||
connectionLogTerminalDataPersistedRef.current = map;
|
||||
connectionLogTerminalDataRef.current = {
|
||||
...map,
|
||||
...connectionLogTerminalDataRef.current,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const syncConnectionLogTerminalDataMap = useCallback(async (
|
||||
logs: ConnectionLog[],
|
||||
options?: { usePersistedLogIdsFromDisk?: boolean },
|
||||
) => {
|
||||
const usePersistedLogIdsFromDisk = options?.usePersistedLogIdsFromDisk ?? false;
|
||||
const localMaps = [
|
||||
connectionLogTerminalDataRef.current,
|
||||
buildTerminalDataMapFromLogs(logs),
|
||||
];
|
||||
const existing = readConnectionLogTerminalDataMap();
|
||||
if (!syncConnectionLogTerminalData) return true;
|
||||
await absorbRemoteTerminalDataIfNeeded();
|
||||
const baseline = connectionLogTerminalDataPersistedRef.current;
|
||||
const merged = mergeTerminalDataMapsForStorage(
|
||||
logs,
|
||||
existing,
|
||||
localMaps,
|
||||
buildPersistedLogIds(logs, usePersistedLogIdsFromDisk),
|
||||
baseline,
|
||||
[
|
||||
connectionLogTerminalDataRef.current,
|
||||
buildTerminalDataMapFromLogs(logs),
|
||||
],
|
||||
buildPersistedLogIds(logs, options?.usePersistedLogIdsFromDisk ?? false),
|
||||
);
|
||||
const persisted = writeConnectionLogTerminalDataMap(
|
||||
existing,
|
||||
merged,
|
||||
logs,
|
||||
connectionLogQuotaFailedCharsRef,
|
||||
);
|
||||
// On a failed write (e.g. QuotaExceeded once the side store nears ~50 MB)
|
||||
// keep the same capped map instead of accumulating every log's replay
|
||||
// buffer in memory; anything outside it would be pruned on the next write.
|
||||
const written = await writeTerminalDataMapDiff(baseline, merged);
|
||||
// Keep the capped map in memory even when IndexedDB rejects the write, so
|
||||
// the next close retries from the last committed baseline.
|
||||
connectionLogTerminalDataRef.current = merged;
|
||||
return { map: connectionLogTerminalDataRef.current, persisted };
|
||||
}, []);
|
||||
if (!written.changed) return true;
|
||||
if (!written.persisted) return false;
|
||||
connectionLogTerminalDataPersistedRef.current = merged;
|
||||
bumpConnectionLogTerminalDataRev(Object.keys(merged));
|
||||
return true;
|
||||
}, [
|
||||
absorbRemoteTerminalDataIfNeeded,
|
||||
bumpConnectionLogTerminalDataRev,
|
||||
syncConnectionLogTerminalData,
|
||||
]);
|
||||
|
||||
const persistConnectionLogState = useCallback((
|
||||
const persistConnectionLogState = useCallback(async (
|
||||
logs: ConnectionLog[],
|
||||
options?: { pruneMainBlob?: boolean },
|
||||
options?: {
|
||||
pruneMainBlob?: boolean;
|
||||
removedIds?: ReadonlySet<string>;
|
||||
changedIds?: ReadonlySet<string>;
|
||||
},
|
||||
) => {
|
||||
const unsavedTerminalDataPending = logs.some(
|
||||
(log) => !log.saved && log.terminalData !== undefined,
|
||||
@@ -506,13 +530,43 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
|
||||
let sidePersisted = true;
|
||||
if (mainPersisted || !options?.pruneMainBlob) {
|
||||
({ persisted: sidePersisted } = syncConnectionLogTerminalDataMap(logs, {
|
||||
sidePersisted = await syncConnectionLogTerminalDataMap(logs, {
|
||||
usePersistedLogIdsFromDisk: Boolean(options?.pruneMainBlob && mainPersisted),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
if (!options?.pruneMainBlob) {
|
||||
mainPersisted = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, logs);
|
||||
// The side-store await can overlap another window's metadata write.
|
||||
// Rebase this snapshot onto the blob that exists now, immediately before
|
||||
// replacing it. A failed read keeps the snapshot already rebased at enqueue.
|
||||
const freshLogs = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS);
|
||||
const toWrite = freshLogs
|
||||
? rebaseQueuedConnectionLogs(
|
||||
logs,
|
||||
options?.removedIds ?? new Set<string>(),
|
||||
options?.changedIds ?? new Set<string>(),
|
||||
connectionLogsRef.current,
|
||||
freshLogs,
|
||||
)
|
||||
: logs;
|
||||
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,
|
||||
// without replacing logs another window wrote during the await.
|
||||
// If that blob exceeds quota, drop older unsaved replays and retry so
|
||||
// the newest capture still has a durable copy.
|
||||
const freshLogs = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS);
|
||||
let fallbackLogs = freshLogs
|
||||
? mergeTerminalDataIntoLogs(freshLogs, buildTerminalDataMapFromLogs(logs))
|
||||
: logs;
|
||||
sidePersisted = 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 = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, fallbackLogs);
|
||||
}
|
||||
}
|
||||
|
||||
if (!mainPersisted && unsavedTerminalDataPending) {
|
||||
@@ -524,8 +578,40 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
"[useVaultState] Failed to persist connection log terminal replay data side store.",
|
||||
);
|
||||
}
|
||||
return mainPersisted && sidePersisted;
|
||||
}, [syncConnectionLogTerminalDataMap]);
|
||||
|
||||
const commitConnectionLogs = useCallback((
|
||||
recipe: (prev: ConnectionLog[]) => ConnectionLog[],
|
||||
options?: { pruneMainBlob?: boolean },
|
||||
) => {
|
||||
const prev = connectionLogsRef.current;
|
||||
const next = recipe(prev);
|
||||
const prevById = new Map(prev.map((log) => [log.id, log]));
|
||||
const nextIds = new Set<string>();
|
||||
const changedIds = new Set<string>();
|
||||
for (const log of next) {
|
||||
nextIds.add(log.id);
|
||||
const before = prevById.get(log.id);
|
||||
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);
|
||||
// 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,
|
||||
localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS),
|
||||
),
|
||||
{ ...options, removedIds, changedIds },
|
||||
));
|
||||
}, [enqueueTerminalDataTask, persistConnectionLogState]);
|
||||
|
||||
const applyConnectionLogsFromStorage = useCallback((
|
||||
prev: ConnectionLog[],
|
||||
storedLogs: ConnectionLog[],
|
||||
@@ -1326,67 +1412,56 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
...log,
|
||||
id: crypto.randomUUID(),
|
||||
};
|
||||
setConnectionLogs((prev) => {
|
||||
void commitConnectionLogs((prev) => {
|
||||
// Keep only the last 500 non-saved entries plus all saved entries
|
||||
const savedLogs = prev.filter((l) => l.saved);
|
||||
const unsavedLogs = prev.filter((l) => !l.saved);
|
||||
const updated = [newLog, ...unsavedLogs].slice(0, 500);
|
||||
const final = pruneInMemoryUnsavedTerminalData(
|
||||
return pruneInMemoryUnsavedTerminalData(
|
||||
[...updated, ...savedLogs].sort(
|
||||
(a, b) => b.startTime - a.startTime
|
||||
),
|
||||
);
|
||||
persistConnectionLogState(final, { pruneMainBlob: true });
|
||||
return final;
|
||||
});
|
||||
}, { pruneMainBlob: true });
|
||||
return newLog.id;
|
||||
},
|
||||
[persistConnectionLogState],
|
||||
[commitConnectionLogs],
|
||||
);
|
||||
|
||||
const updateConnectionLog = useCallback(
|
||||
(id: string, updates: Partial<ConnectionLog>) => {
|
||||
setConnectionLogs((prev) => {
|
||||
const updated = pruneInMemoryUnsavedTerminalData(
|
||||
return commitConnectionLogs((prev) => (
|
||||
pruneInMemoryUnsavedTerminalData(
|
||||
prev.map((log) => (log.id === id ? { ...log, ...updates } : log)),
|
||||
);
|
||||
persistConnectionLogState(updated, { pruneMainBlob: true });
|
||||
return updated;
|
||||
});
|
||||
)
|
||||
), { pruneMainBlob: true });
|
||||
},
|
||||
[persistConnectionLogState],
|
||||
[commitConnectionLogs],
|
||||
);
|
||||
|
||||
const toggleConnectionLogSaved = useCallback((id: string) => {
|
||||
setConnectionLogs((prev) => {
|
||||
const updated = pruneInMemoryUnsavedTerminalData(
|
||||
void commitConnectionLogs((prev) => (
|
||||
pruneInMemoryUnsavedTerminalData(
|
||||
prev.map((log) =>
|
||||
log.id === id ? { ...log, saved: !log.saved } : log
|
||||
),
|
||||
);
|
||||
persistConnectionLogState(updated, { pruneMainBlob: false });
|
||||
return updated;
|
||||
});
|
||||
}, [persistConnectionLogState]);
|
||||
)
|
||||
), { pruneMainBlob: false });
|
||||
}, [commitConnectionLogs]);
|
||||
|
||||
const deleteConnectionLog = useCallback((id: string) => {
|
||||
setConnectionLogs((prev) => {
|
||||
const updated = prev.filter((log) => log.id !== id);
|
||||
const map = { ...connectionLogTerminalDataRef.current };
|
||||
delete map[id];
|
||||
connectionLogTerminalDataRef.current = map;
|
||||
persistConnectionLogState(updated, { pruneMainBlob: true });
|
||||
return updated;
|
||||
});
|
||||
}, [persistConnectionLogState]);
|
||||
void commitConnectionLogs(
|
||||
(prev) => prev.filter((log) => log.id !== id),
|
||||
{ pruneMainBlob: true },
|
||||
);
|
||||
}, [commitConnectionLogs]);
|
||||
|
||||
const clearUnsavedConnectionLogs = useCallback(() => {
|
||||
setConnectionLogs((prev) => {
|
||||
const saved = prev.filter((log) => log.saved);
|
||||
persistConnectionLogState(saved, { pruneMainBlob: true });
|
||||
return saved;
|
||||
});
|
||||
}, [persistConnectionLogState]);
|
||||
void commitConnectionLogs(
|
||||
(prev) => prev.filter((log) => log.saved),
|
||||
{ pruneMainBlob: true },
|
||||
);
|
||||
}, [commitConnectionLogs]);
|
||||
|
||||
// Connection logs for Vault logs section — keep App domain bags off session churn.
|
||||
useLayoutEffect(() => {
|
||||
@@ -1453,6 +1528,11 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let resolveTerminalDataReady = () => {};
|
||||
const terminalDataReady = new Promise<void>((resolve) => {
|
||||
resolveTerminalDataReady = resolve;
|
||||
});
|
||||
terminalDataReadyRef.current = terminalDataReady;
|
||||
const init = async () => {
|
||||
try {
|
||||
await withVaultImportLock("vault", async () => {
|
||||
@@ -1662,18 +1742,91 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
publishShellHistorySnapshot(savedShellHistory);
|
||||
}
|
||||
|
||||
// Load connection logs
|
||||
// Load connection logs. Replay blobs live in IndexedDB, one row per
|
||||
// log id. Windows that never render replay skip that read
|
||||
// (Settings / tray / popup).
|
||||
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
|
||||
STORAGE_KEY_CONNECTION_LOGS,
|
||||
);
|
||||
// Windows that never render connection-log replay skip the ~30–50 MB
|
||||
// side-store parse on boot (Settings / tray / popup).
|
||||
const terminalDataMap = syncConnectionLogTerminalData
|
||||
? readConnectionLogTerminalDataMap()
|
||||
: {};
|
||||
let connectionLogsForBoot = savedConnectionLogs;
|
||||
let terminalDataMap: ConnectionLogTerminalDataMap = {};
|
||||
let durableTerminalData = true;
|
||||
if (syncConnectionLogTerminalData) {
|
||||
const readTerminalDataRev = () => localStorageAdapter.readString(
|
||||
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV,
|
||||
);
|
||||
// A peer can commit replay and bump the rev while this load is in
|
||||
// flight. Capture the rev first and reload when it moves, so stamping
|
||||
// the key later does not mark an unread snapshot as consumed.
|
||||
let revSeen = readTerminalDataRev();
|
||||
const loaded = await migrateLegacyConnectionLogTerminalData({
|
||||
read: () => localStorageAdapter.read<ConnectionLogTerminalDataMap>(
|
||||
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA,
|
||||
),
|
||||
remove: () => {
|
||||
localStorageAdapter.remove(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA);
|
||||
},
|
||||
});
|
||||
if (cancelled) return;
|
||||
terminalDataMap = loaded.map;
|
||||
let revNow = readTerminalDataRev();
|
||||
if (revNow !== revSeen) {
|
||||
const reloaded = await loadConnectionLogTerminalData();
|
||||
if (cancelled) return;
|
||||
if (reloaded) {
|
||||
if (Object.keys(reloaded).length > 0) {
|
||||
terminalDataMap = { ...terminalDataMap, ...reloaded };
|
||||
}
|
||||
revSeen = revNow;
|
||||
}
|
||||
}
|
||||
// Another window can append a log while IndexedDB is opening. Prune
|
||||
// against that newer metadata so this boot does not delete its replay.
|
||||
connectionLogsForBoot = localStorageAdapter.read<ConnectionLog[]>(
|
||||
STORAGE_KEY_CONNECTION_LOGS,
|
||||
) ?? savedConnectionLogs;
|
||||
const pruned = connectionLogsForBoot
|
||||
? pruneTerminalDataMapForStorage(connectionLogsForBoot, terminalDataMap)
|
||||
: terminalDataMap;
|
||||
// A legacy map that never reached IndexedDB is not a diff baseline.
|
||||
// Comparing against it would persist only the changed ids and then
|
||||
// drop the rest of the legacy blob on the next boot.
|
||||
const baseline = loaded.durable ? terminalDataMap : {};
|
||||
const written = await writeTerminalDataMapDiff(baseline, pruned);
|
||||
if (cancelled) return;
|
||||
if (written.persisted) {
|
||||
terminalDataMap = pruned;
|
||||
if (!loaded.durable) {
|
||||
localStorageAdapter.remove(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA);
|
||||
}
|
||||
}
|
||||
durableTerminalData = written.persisted || loaded.durable;
|
||||
if (loaded.migrated || (written.changed && written.persisted)) {
|
||||
bumpConnectionLogTerminalDataRev(Object.keys(terminalDataMap));
|
||||
} else {
|
||||
revNow = readTerminalDataRev();
|
||||
if (revNow !== revSeen) {
|
||||
const reloaded = await loadConnectionLogTerminalData();
|
||||
if (cancelled) return;
|
||||
if (reloaded) {
|
||||
if (Object.keys(reloaded).length > 0) {
|
||||
terminalDataMap = { ...terminalDataMap, ...reloaded };
|
||||
}
|
||||
revSeen = revNow;
|
||||
connectionLogsForBoot = localStorageAdapter.read<ConnectionLog[]>(
|
||||
STORAGE_KEY_CONNECTION_LOGS,
|
||||
) ?? connectionLogsForBoot;
|
||||
}
|
||||
}
|
||||
connectionLogTerminalDataRevRawRef.current = revSeen;
|
||||
}
|
||||
}
|
||||
connectionLogTerminalDataRef.current = terminalDataMap;
|
||||
if (savedConnectionLogs) {
|
||||
setConnectionLogs(mergeTerminalDataIntoLogs(savedConnectionLogs, terminalDataMap));
|
||||
connectionLogTerminalDataPersistedRef.current = durableTerminalData ? terminalDataMap : {};
|
||||
if (connectionLogsForBoot) {
|
||||
const hydrated = mergeTerminalDataIntoLogs(connectionLogsForBoot, terminalDataMap);
|
||||
connectionLogsRef.current = hydrated;
|
||||
setConnectionLogs(hydrated);
|
||||
}
|
||||
|
||||
// Load managed sources
|
||||
@@ -1702,6 +1855,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
resolveTerminalDataReady();
|
||||
// StrictMode remount cancels the first init; only the surviving effect
|
||||
// may publish "vault ready" or terminals can boot against empty keys.
|
||||
if (!cancelled) {
|
||||
@@ -1714,8 +1868,15 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
void init();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
resolveTerminalDataReady();
|
||||
};
|
||||
}, [commitEncryptedHostsUnderVaultLock, syncConnectionLogTerminalData, updateHosts, updateSnippets]);
|
||||
}, [
|
||||
bumpConnectionLogTerminalDataRev,
|
||||
commitEncryptedHostsUnderVaultLock,
|
||||
syncConnectionLogTerminalData,
|
||||
updateHosts,
|
||||
updateSnippets,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInitialized) return;
|
||||
@@ -1917,19 +2078,18 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
|
||||
if (key === STORAGE_KEY_CONNECTION_LOGS) {
|
||||
const next = safeParse<ConnectionLog[]>(event.newValue) ?? [];
|
||||
setConnectionLogs((prev) =>
|
||||
applyConnectionLogsFromStorage(prev, next, connectionLogTerminalDataRef.current),
|
||||
const merged = applyConnectionLogsFromStorage(
|
||||
connectionLogsRef.current,
|
||||
next,
|
||||
connectionLogTerminalDataRef.current,
|
||||
);
|
||||
connectionLogsRef.current = merged;
|
||||
setConnectionLogs(merged);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA) {
|
||||
// Other windows still receive the storage event; skip the multi-MB
|
||||
// JSON.parse when this mount does not show connection-log replay.
|
||||
if (!syncConnectionLogTerminalData) return;
|
||||
const next = safeParse<ConnectionLogTerminalDataMap>(event.newValue) ?? {};
|
||||
connectionLogTerminalDataRef.current = next;
|
||||
setConnectionLogs((prev) => mergeConnectionLogsFromStorage(prev, prev, next));
|
||||
if (key === STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV) {
|
||||
applyTerminalDataRev(event.newValue);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1957,20 +2117,46 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
}
|
||||
};
|
||||
|
||||
const applyTerminalDataRev = (raw: string | null) => {
|
||||
// Settings, tray, and popup mounts never replay logs and must not
|
||||
// pull the side store across windows.
|
||||
if (!syncConnectionLogTerminalData) return;
|
||||
if (raw === connectionLogTerminalDataRevRawRef.current) return;
|
||||
const seq = ++connectionLogTerminalDataLoadSeq.current;
|
||||
void terminalDataReadyRef.current.then(() => enqueueTerminalDataTask(async () => {
|
||||
if (seq !== connectionLogTerminalDataLoadSeq.current) return;
|
||||
const map = await loadConnectionLogTerminalData();
|
||||
if (seq !== connectionLogTerminalDataLoadSeq.current) return;
|
||||
if (!map) return;
|
||||
connectionLogTerminalDataRevRawRef.current = localStorageAdapter.readString(
|
||||
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV,
|
||||
);
|
||||
connectionLogTerminalDataPersistedRef.current = map;
|
||||
connectionLogTerminalDataRef.current = map;
|
||||
const merged = mergeConnectionLogsFromStorage(
|
||||
connectionLogsRef.current,
|
||||
connectionLogsRef.current,
|
||||
map,
|
||||
);
|
||||
connectionLogsRef.current = merged;
|
||||
setConnectionLogs(merged);
|
||||
}));
|
||||
};
|
||||
|
||||
const handleLocalStorageAdapterChanged = (event: Event) => {
|
||||
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
|
||||
if (key === STORAGE_KEY_CONNECTION_LOGS) {
|
||||
const next = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS) ?? [];
|
||||
setConnectionLogs((prev) =>
|
||||
applyConnectionLogsFromStorage(prev, next, connectionLogTerminalDataRef.current),
|
||||
);
|
||||
// This window already updated connectionLogsRef before setItem. The
|
||||
// adapter notifies on a timer, so this blob can be older than a log
|
||||
// added or finalized while that write was still queued. Applying it
|
||||
// would drop that log from the ref, and the follow-up recipe would
|
||||
// record no changed id. Other windows still arrive through "storage".
|
||||
return;
|
||||
}
|
||||
if (key === STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA) {
|
||||
if (!syncConnectionLogTerminalData) return;
|
||||
const next = readConnectionLogTerminalDataMap();
|
||||
connectionLogTerminalDataRef.current = next;
|
||||
setConnectionLogs((prev) => mergeConnectionLogsFromStorage(prev, prev, next));
|
||||
if (key === STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV) {
|
||||
applyTerminalDataRev(localStorageAdapter.readString(
|
||||
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV,
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1980,7 +2166,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
window.removeEventListener("storage", handleStorage);
|
||||
window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handleLocalStorageAdapterChanged);
|
||||
};
|
||||
}, [applyConnectionLogsFromStorage, syncConnectionLogTerminalData]);
|
||||
}, [applyConnectionLogsFromStorage, enqueueTerminalDataTask, syncConnectionLogTerminalData]);
|
||||
|
||||
const updateHostLastConnected = useCallback((hostId: string) => {
|
||||
setHosts((prev) => {
|
||||
|
||||
+24
-5
@@ -364,6 +364,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onOpenTheme,
|
||||
onOpenSystem,
|
||||
isBroadcastEnabled,
|
||||
composeSendToAll,
|
||||
onToggleComposeSendTarget,
|
||||
onToggleBroadcast,
|
||||
onToggleComposeBar,
|
||||
isWorkspaceComposeBarOpen,
|
||||
@@ -1616,7 +1618,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const capturedData = options?.finalized
|
||||
? data
|
||||
: (finalizeTerminalLogData() || data);
|
||||
captureHandler(capturedSessionId, capturedData);
|
||||
return captureHandler(capturedSessionId, capturedData);
|
||||
}, [finalizeTerminalLogData]);
|
||||
|
||||
const attachHomeWebContentsIdRef = useRef<number | null | undefined>(undefined);
|
||||
@@ -1794,6 +1796,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
if (!closingSessionId) {
|
||||
disposeSessionListeners();
|
||||
// Close preparation already cleared sessionRef after the display handoff.
|
||||
// Unmount teardown calls cleanup again. An attach popup must not
|
||||
// closeSession on that second pass: worker mode emits netcatty:exit to
|
||||
// the home renderer before the boot-epoch check, which drops home input
|
||||
// (sessionRef = null) while a mismatched epoch leaves the PTY alive.
|
||||
if (attachExistingSession) {
|
||||
return;
|
||||
}
|
||||
// Still notify main so in-flight SSH passphrase prompts for this UI
|
||||
// sessionId are aborted even before a backend session was attached.
|
||||
try {
|
||||
@@ -1963,10 +1973,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}, [sessionId]);
|
||||
|
||||
const forceCloseHibernatedSession = useCallback(() => {
|
||||
let pendingCapture: void | Promise<void>;
|
||||
if (!terminalDataCapturedRef.current) {
|
||||
const hibernatedData = hibernateSnapshotRef.current + hibernatePendingBufferRef.current;
|
||||
if (hibernatedData) {
|
||||
handleTerminalDataCaptureOnce(sessionId, hibernatedData);
|
||||
pendingCapture = handleTerminalDataCaptureOnce(sessionId, hibernatedData);
|
||||
}
|
||||
}
|
||||
disposeDataRef.current?.();
|
||||
@@ -2009,6 +2020,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
sessionRef.current = null;
|
||||
clearHibernateRuntimeState();
|
||||
return pendingCapture;
|
||||
}, [attachAuthorization, attachExistingSession, clearHibernateRuntimeState, handleTerminalDataCaptureOnce, sessionId, terminalBackend]);
|
||||
|
||||
const observeTerminalInputPrompt = useCallback((
|
||||
@@ -3435,13 +3447,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// A bypassed password fan-out (#3488) forces the sensitive marker even
|
||||
// when this target has not itself classified its prompt as sensitive.
|
||||
const sensitive = passwordPromptActiveRef.current || options?.sensitive === true;
|
||||
const broadcastThisSend = options?.broadcast !== undefined
|
||||
? options.broadcast === true
|
||||
: isBroadcastEnabledRef.current;
|
||||
if (
|
||||
shouldBroadcastDuringSensitivePrompt({
|
||||
sensitivePromptActive: sensitive,
|
||||
broadcastPasswordBypass: broadcastPasswordBypassRef.current,
|
||||
})
|
||||
&& options?.broadcast !== false
|
||||
&& isBroadcastEnabledRef.current
|
||||
&& broadcastThisSend
|
||||
&& onBroadcastInputRef.current
|
||||
) {
|
||||
const broadcastDeliveredSessionIds = onBroadcastInputRef.current(data, sessionId, {
|
||||
@@ -3451,6 +3465,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// peer writes keep skipping input interceptors.
|
||||
...(sensitive ? { sourceSensitive: true } : {}),
|
||||
...(lineDelayMs ? { lineDelayMs } : {}),
|
||||
// Compose-bar send-to-all while the toolbar switch is off. Keystrokes
|
||||
// and snippets leave this unset and stay on the toolbar gate.
|
||||
...(options?.broadcast === true && !isBroadcastEnabledRef.current
|
||||
? { composePeerFanOut: true }
|
||||
: {}),
|
||||
});
|
||||
// #3491: report the sessions that received the fan-out so callers (the
|
||||
// solo compose bar) can detect a payload delivered into a sensitive
|
||||
@@ -4712,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, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, 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, 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 ? (
|
||||
<ScriptExecutionOverlay
|
||||
run={activeScriptRun}
|
||||
onPause={() => { void pauseScriptRun(activeScriptRun.runId); }}
|
||||
|
||||
@@ -260,8 +260,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onCreateLocalTerminal,
|
||||
isBroadcastEnabled,
|
||||
onToggleBroadcast,
|
||||
isComposeSendToAll,
|
||||
onToggleComposeSendTarget,
|
||||
isGlobalBroadcastEnabled,
|
||||
isGlobalComposeSendToAll,
|
||||
onToggleGlobalBroadcast,
|
||||
onToggleGlobalComposeSendTarget,
|
||||
canUseGlobalBroadcast,
|
||||
updateHosts,
|
||||
updateSnippets,
|
||||
@@ -700,7 +704,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
}, [onAddKnownHost]);
|
||||
|
||||
const handleTerminalDataCapture = useCallback((sessionId: string, data: string) => {
|
||||
onTerminalDataCapture?.(sessionId, data);
|
||||
return onTerminalDataCapture?.(sessionId, data);
|
||||
}, [onTerminalDataCapture]);
|
||||
|
||||
// Terminal backend for broadcast writes
|
||||
@@ -1277,6 +1281,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
sourceSessionId,
|
||||
globalBroadcastEnabled: isGlobalBroadcastEnabled,
|
||||
directTargetSessionIds: options?.kittyKeyboardTargetSessionIds,
|
||||
includeGlobalPeersWithoutToolbar: options?.composePeerFanOut === true,
|
||||
}).filter(id => !paced?.targets || paced.targets.some(target => target.sessionId === id));
|
||||
if (options?.preparePacedBroadcast && paced) {
|
||||
paced.targets = targetSessionIds.filter(id => passwordBypass || !isTerminalSensitiveInputActive(id)).map(id => {
|
||||
@@ -2463,14 +2468,19 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
let anyRecipientSensitive = false;
|
||||
const pendingSends: Promise<boolean>[] = [];
|
||||
const payload = text + '\r';
|
||||
const broadcastEnabled = isBroadcastEnabled?.(activeWorkspace.id);
|
||||
// Compose Enter follows the compose-bar target. Toolbar broadcast stays
|
||||
// the keystroke switch and is only the fallback when no compose target
|
||||
// has been published (older callers).
|
||||
const composeSendToAll = isComposeSendToAll
|
||||
? isComposeSendToAll(activeWorkspace.id) === true
|
||||
: Boolean(isBroadcastEnabled?.(activeWorkspace.id));
|
||||
const focusedSessionId = activeWorkspace.focusedSessionId;
|
||||
const broadcastPasswordBypass = broadcastPasswordBypassRef.current;
|
||||
const focusedSensitive = focusedSessionId
|
||||
? isTerminalSensitiveInputActive(focusedSessionId)
|
||||
: false;
|
||||
|
||||
if (broadcastEnabled && (broadcastPasswordBypass || !focusedSensitive)) {
|
||||
if (composeSendToAll && (broadcastPasswordBypass || !focusedSensitive)) {
|
||||
const allSessionIds = sessionsRef.current
|
||||
.filter((session) => session.workspaceId === activeWorkspace.id)
|
||||
.map((session) => session.id);
|
||||
@@ -2518,7 +2528,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const executor = snippetExecutorsRef.current.get(targetId);
|
||||
if (executor) {
|
||||
const sensitive = isTerminalSensitiveInputActive(targetId);
|
||||
pendingSends.push(Promise.resolve(executor(text, false)).then(
|
||||
// Explicitly local: an omitted broadcast option falls back to the
|
||||
// toolbar switch, which can still be on while compose is active-only.
|
||||
pendingSends.push(Promise.resolve(executor(text, false, { broadcast: false })).then(
|
||||
(sent) => sent && !sensitive && !isTerminalSensitiveInputActive(targetId),
|
||||
));
|
||||
} else {
|
||||
@@ -2539,7 +2551,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
// eligibility for it.
|
||||
if (focusedSensitive || anyRecipientSensitive) return false;
|
||||
return delivered;
|
||||
}, [isBroadcastEnabled, terminalBackend]);
|
||||
}, [isBroadcastEnabled, isComposeSendToAll, terminalBackend]);
|
||||
|
||||
const sessionLogConfig = useMemo(
|
||||
() => ({
|
||||
@@ -2664,7 +2676,11 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
restoreTerminalCwd,
|
||||
identities,
|
||||
isBroadcastEnabled,
|
||||
isComposeSendToAll,
|
||||
onToggleComposeSendTarget,
|
||||
isGlobalBroadcastEnabled,
|
||||
isGlobalComposeSendToAll,
|
||||
onToggleGlobalComposeSendTarget,
|
||||
canUseGlobalBroadcast,
|
||||
isComposeBarOpen,
|
||||
keyBindings,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
|
||||
function rate(value: number | null | undefined) {
|
||||
if (value == null || !Number.isFinite(value) || value < 0) return "--";
|
||||
if (value >= 1024 ** 2) return `${(value / 1024 ** 2).toFixed(1)} MiB/s`;
|
||||
if (value >= 1024) return `${(value / 1024).toFixed(1)} KiB/s`;
|
||||
return `${Math.round(value)} B/s`;
|
||||
}
|
||||
|
||||
/** Same shared-sampler rates in monitor cards, tables, and terminal overview. */
|
||||
export function DiskIoSummary({ stats }: {
|
||||
stats?: { diskReadSpeed?: number | null; diskWriteSpeed?: number | null } | null;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div data-disk-io className="flex flex-wrap gap-x-3 gap-y-0.5 text-[10px] tabular-nums text-muted-foreground" title={t("monitor.diskIoHint")}>
|
||||
<span>{t("monitor.diskIo")}</span>
|
||||
<span>{t("monitor.diskRead")} {rate(stats?.diskReadSpeed)}</span>
|
||||
<span>{t("monitor.diskWrite")} {rate(stats?.diskWriteSpeed)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { aggregateMountedDiskUsage } from '../../domain/systemDiskUsage';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { useServerStats } from '../../application/state/useServerStats';
|
||||
import { ResourceBar } from './ResourceBar';
|
||||
import { DiskIoSummary } from './DiskIoSummary';
|
||||
import {
|
||||
SystemPanelEmpty,
|
||||
SystemPanelError,
|
||||
@@ -335,6 +336,8 @@ export const SystemOverviewTab = memo(function SystemOverviewTab({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3"><DiskIoSummary stats={stats} /></div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<InfoPill label={t('systemManager.overview.load')} value={formatLoad(stats.loadAverage)} />
|
||||
<InfoPill label={t('systemManager.overview.uptime')} value={formatDuration(stats.uptimeSeconds, t)} />
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { I18nProvider } from '../../application/i18n/I18nProvider';
|
||||
import { TerminalComposeBar } from './TerminalComposeBar';
|
||||
import { TooltipProvider } from '../ui/tooltip';
|
||||
import { getComposeBarHistory, pruneComposeBarHistory, recordComposeBarHistory } from '../../application/state/composeBarHistoryStore';
|
||||
@@ -195,3 +196,65 @@ test('empty compose draft Enter reconnects; a typed draft still sends', async (t
|
||||
|
||||
await act(async () => { root.unmount(); });
|
||||
});
|
||||
|
||||
test('compose send target toggle shows all vs active without looking like broadcast when active', async (t) => {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
|
||||
const originals = new Map<string, PropertyDescriptor | undefined>();
|
||||
for (const name of ['window', 'document', 'localStorage']) {
|
||||
originals.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { configurable: true, value: dom.window[name as keyof Window] });
|
||||
}
|
||||
t.after(() => {
|
||||
dom.window.close();
|
||||
for (const [name, descriptor] of originals) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
});
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
const toggles: string[] = [];
|
||||
const render = (composeSendToAll: boolean) => (
|
||||
<I18nProvider locale="en">
|
||||
<TooltipProvider>
|
||||
<TerminalComposeBar
|
||||
sessionId="compose-target"
|
||||
onSend={() => true}
|
||||
onClose={() => {}}
|
||||
isBroadcastEnabled
|
||||
composeSendToAll={composeSendToAll}
|
||||
onToggleComposeSendTarget={() => { toggles.push('toggle'); }}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
let root!: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
root = create(render(true), { createNodeMock: (el) => (el.type === 'textarea' ? { focus() {} } : null) });
|
||||
});
|
||||
const button = root.root.findAll(
|
||||
(node) => node.props?.['data-compose-send-target'] === 'all' || node.props?.['data-compose-send-target'] === 'active',
|
||||
)[0];
|
||||
assert.ok(button);
|
||||
assert.equal(button.props['data-compose-send-target'], 'all');
|
||||
assert.equal(button.props['aria-pressed'], true);
|
||||
assert.match(String(button.props['aria-label']), /all sessions/i);
|
||||
const pulsing = root.root.findAll((node) => typeof node.props?.className === 'string' && node.props.className.includes('animate-pulse'));
|
||||
assert.ok(pulsing.length > 0);
|
||||
await act(async () => {
|
||||
button.props.onClick({
|
||||
preventDefault() {},
|
||||
stopPropagation() {},
|
||||
defaultPrevented: false,
|
||||
nativeEvent: { defaultPrevented: false },
|
||||
});
|
||||
});
|
||||
assert.deepEqual(toggles, ['toggle']);
|
||||
await act(async () => { root.update(render(false)); });
|
||||
const active = root.root.findAll((node) => node.props?.['data-compose-send-target'] === 'active')[0];
|
||||
assert.ok(active);
|
||||
assert.equal(active.props['aria-pressed'], false);
|
||||
assert.match(String(active.props['aria-label']), /active session/i);
|
||||
const stillPulsing = root.root.findAll((node) => typeof node.props?.className === 'string' && node.props.className.includes('animate-pulse'));
|
||||
assert.equal(stillPulsing.length, 0);
|
||||
await act(async () => { root.unmount(); });
|
||||
});
|
||||
@@ -280,6 +280,9 @@ export interface TerminalComposeBarProps {
|
||||
onSnippetClick?: (snippet: Snippet) => void;
|
||||
snippets?: Snippet[];
|
||||
isBroadcastEnabled?: boolean;
|
||||
/** Compose-bar send target. Undefined follows toolbar broadcast. */
|
||||
composeSendToAll?: boolean;
|
||||
onToggleComposeSendTarget?: () => void;
|
||||
themeColors?: {
|
||||
background: string;
|
||||
foreground: string;
|
||||
@@ -293,6 +296,8 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
|
||||
onSnippetClick,
|
||||
snippets = [],
|
||||
isBroadcastEnabled,
|
||||
composeSendToAll,
|
||||
onToggleComposeSendTarget,
|
||||
themeColors,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
@@ -350,8 +355,9 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
|
||||
};
|
||||
}, [broadcastMenuAnchor]);
|
||||
|
||||
const sendToAll = composeSendToAll ?? Boolean(isBroadcastEnabled);
|
||||
const handleBroadcastContextMenu = useCallback((event: React.MouseEvent) => {
|
||||
if (!isBroadcastEnabled) return;
|
||||
if (!isBroadcastEnabled && !sendToAll) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setBroadcastMenuAnchor({
|
||||
@@ -359,7 +365,7 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
|
||||
y: event.clientY,
|
||||
inTextarea: event.target instanceof Element && Boolean(event.target.closest('textarea')),
|
||||
});
|
||||
}, [isBroadcastEnabled]);
|
||||
}, [isBroadcastEnabled, sendToAll]);
|
||||
|
||||
const handleComposeMenuEdit = useCallback(async (action: 'cut' | 'copy' | 'paste' | 'selectAll') => {
|
||||
setBroadcastMenuAnchor(null);
|
||||
@@ -607,14 +613,33 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
|
||||
|
||||
<div className="flex-1 min-h-0 px-3 pt-1.5 pb-2 flex flex-col">
|
||||
<div className="flex flex-1 min-h-0 items-start gap-1.5">
|
||||
{isBroadcastEnabled && (
|
||||
{(onToggleComposeSendTarget || sendToAll) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center cursor-default pt-0.5 flex-shrink-0">
|
||||
<Radio size={14} className="text-amber-400 animate-pulse" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-compose-send-target={sendToAll ? 'all' : 'active'}
|
||||
aria-pressed={sendToAll}
|
||||
aria-label={sendToAll
|
||||
? t('terminal.composeBar.sendToAll')
|
||||
: t('terminal.composeBar.sendToActive')}
|
||||
className="flex items-center pt-0.5 flex-shrink-0 rounded-sm"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => onToggleComposeSendTarget?.()}
|
||||
onContextMenu={handleBroadcastContextMenu}
|
||||
>
|
||||
<Radio
|
||||
size={14}
|
||||
className={sendToAll ? 'text-amber-400 animate-pulse' : undefined}
|
||||
style={sendToAll ? undefined : { color: theme.mutedFg }}
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('terminal.composeBar.broadcasting')}</TooltipContent>
|
||||
<TooltipContent>
|
||||
{sendToAll
|
||||
? t('terminal.composeBar.broadcasting')
|
||||
: t('terminal.composeBar.sendToActive')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
test('SFTP menu opens the clicked pane without a selection and is absent without a handler', async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
|
||||
pretendToBeVisual: true,
|
||||
url: 'http://localhost',
|
||||
});
|
||||
const window = dom.window;
|
||||
const previousGlobals = new Map<string, PropertyDescriptor | undefined>();
|
||||
const installGlobal = (key: string, value: unknown) => {
|
||||
previousGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value,
|
||||
});
|
||||
};
|
||||
|
||||
class ResizeObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
installGlobal('window', window);
|
||||
installGlobal('document', window.document);
|
||||
installGlobal('navigator', window.navigator);
|
||||
installGlobal('HTMLElement', window.HTMLElement);
|
||||
installGlobal('HTMLInputElement', window.HTMLInputElement);
|
||||
installGlobal('HTMLTextAreaElement', window.HTMLTextAreaElement);
|
||||
installGlobal('Element', window.Element);
|
||||
installGlobal('SVGElement', window.SVGElement);
|
||||
installGlobal('Node', window.Node);
|
||||
installGlobal('NodeFilter', window.NodeFilter);
|
||||
installGlobal('MutationObserver', window.MutationObserver);
|
||||
installGlobal('CustomEvent', window.CustomEvent);
|
||||
installGlobal('DOMRect', window.DOMRect);
|
||||
installGlobal('Event', window.Event);
|
||||
installGlobal('KeyboardEvent', window.KeyboardEvent);
|
||||
installGlobal('MouseEvent', window.MouseEvent);
|
||||
installGlobal('getComputedStyle', window.getComputedStyle.bind(window));
|
||||
installGlobal('requestAnimationFrame', window.requestAnimationFrame.bind(window));
|
||||
installGlobal('cancelAnimationFrame', window.cancelAnimationFrame.bind(window));
|
||||
installGlobal('ResizeObserver', ResizeObserverStub);
|
||||
installGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
|
||||
const { default: React, act } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { I18nProvider } = await import('../../application/i18n/I18nProvider.tsx');
|
||||
const { TerminalContextMenu } = await import('./TerminalContextMenu.tsx');
|
||||
const rootNode = window.document.getElementById('root');
|
||||
assert.ok(rootNode);
|
||||
const root = createRoot(rootNode);
|
||||
const actions: string[] = [];
|
||||
|
||||
const openMenu = async (hostId: string) => {
|
||||
const surface = window.document.querySelector<HTMLElement>(`[data-testid="${hostId}"]`);
|
||||
assert.ok(surface);
|
||||
await act(async () => {
|
||||
surface.dispatchEvent(new window.MouseEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
button: 2,
|
||||
clientX: 20,
|
||||
clientY: 20,
|
||||
}));
|
||||
});
|
||||
};
|
||||
|
||||
const findMenuItem = (label: string): HTMLElement => {
|
||||
const item = Array.from(window.document.querySelectorAll<HTMLElement>('[role="menuitem"]'))
|
||||
.find((candidate) => candidate.textContent?.includes(label));
|
||||
assert.ok(item, `${label} menu item should be visible`);
|
||||
return item;
|
||||
};
|
||||
|
||||
const renderPanes = async (enabled = true) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<I18nProvider locale="zh-CN">
|
||||
{['host-a', 'host-b'].map((hostId) => (
|
||||
<TerminalContextMenu
|
||||
key={hostId}
|
||||
sessionId={hostId}
|
||||
hostId={hostId}
|
||||
status="connected"
|
||||
hasSelection={false}
|
||||
onOpenSftp={enabled ? () => actions.push(hostId) : undefined}
|
||||
>
|
||||
<div data-testid={hostId}>Terminal</div>
|
||||
</TerminalContextMenu>
|
||||
))}
|
||||
</I18nProvider>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
await renderPanes();
|
||||
for (const hostId of ['host-b', 'host-a', 'host-b']) {
|
||||
await openMenu(hostId);
|
||||
const item = findMenuItem('打开 SFTP');
|
||||
assert.notEqual(item.getAttribute('aria-disabled'), 'true');
|
||||
assert.equal(findMenuItem('复制').getAttribute('aria-disabled'), 'true');
|
||||
await act(async () => item.click());
|
||||
assert.equal(window.document.querySelector('[role="menu"]'), null);
|
||||
}
|
||||
assert.deepEqual(actions, ['host-b', 'host-a', 'host-b']);
|
||||
|
||||
await renderPanes(false);
|
||||
await openMenu('host-a');
|
||||
assert.equal(window.document.body.textContent?.includes('打开 SFTP'), false);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
dom.window.close();
|
||||
for (const [key, descriptor] of previousGlobals) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else delete (globalThis as Record<string, unknown>)[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Copy,
|
||||
Download,
|
||||
Globe2,
|
||||
FolderInput,
|
||||
Pencil,
|
||||
RefreshCcw,
|
||||
Sparkles,
|
||||
@@ -67,6 +68,7 @@ export interface TerminalContextMenuProps {
|
||||
onReconnect?: () => void;
|
||||
onClose?: () => void;
|
||||
onOpenBrowser?: () => void;
|
||||
onOpenSftp?: () => void;
|
||||
onSelectWord?: () => void;
|
||||
onAddSelectionToAI?: () => void;
|
||||
onRename?: () => void;
|
||||
@@ -227,6 +229,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
onReconnect,
|
||||
onClose,
|
||||
onOpenBrowser,
|
||||
onOpenSftp,
|
||||
onSelectWord,
|
||||
onAddSelectionToAI,
|
||||
onRename,
|
||||
@@ -413,6 +416,14 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
{t('terminal.menu.selectAll')}
|
||||
<ContextMenuShortcut>{selectAllShortcut}</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
{onOpenSftp && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={onOpenSftp}>
|
||||
<FolderInput size={14} className="mr-2" /> {t('terminal.toolbar.openSftp')}
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
{onOpenBrowser && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
|
||||
@@ -652,6 +652,16 @@ test("terminal boot is cancelable and closes eagerly on cleanup", () => {
|
||||
);
|
||||
// Owner panes close the pending backend; attach popups only dispose xterm.
|
||||
assert.match(neverConnectedBranch, /if \(!attachExistingSession\)/);
|
||||
const emptySessionBranch = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
|
||||
const emptyStart = emptySessionBranch.indexOf("if (!closingSessionId)");
|
||||
const emptyEnd = emptySessionBranch.indexOf("Observe/attach popups must not kill", emptyStart);
|
||||
const emptyBranch = emptySessionBranch.slice(emptyStart, emptyEnd);
|
||||
const attachGuardAt = emptyBranch.indexOf("if (attachExistingSession)");
|
||||
const emptyCloseAt = emptyBranch.indexOf("terminalBackend.closeSession(");
|
||||
assert.ok(
|
||||
attachGuardAt !== -1 && emptyCloseAt !== -1 && attachGuardAt < emptyCloseAt,
|
||||
"a second attach cleanup after sessionRef is cleared must not closeSession",
|
||||
);
|
||||
assert.match(effectsSource, /let ownedRuntime:/);
|
||||
assert.match(
|
||||
cleanup,
|
||||
@@ -731,3 +741,11 @@ test("terminal theme updates force xterm renderer to repaint immediately", () =>
|
||||
assert.match(schedulerSource, /term\.options\.theme = \{/);
|
||||
assert.match(schedulerSource, /forceSyncRenderAfterResize\(term\)/);
|
||||
});
|
||||
|
||||
test("SFTP context action reuses the pane toolbar handler and its availability", () => {
|
||||
const source = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
|
||||
const terminalSource = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
|
||||
assert.match(source, /onOpenSftp=\{status === "connected" && !isSerialConnection && !host\?\.id\?\.startsWith\("serial-"\) && !isPluginHostProtocol\(host\?\.protocol\) \? ctx\.handleOpenSFTP : undefined\}/);
|
||||
assert.match(terminalSource, /onOpenSFTP=\{handleOpenSFTP\}/);
|
||||
assert.match(terminalSource, /<TerminalView ctx=\{\{[^\n]*handleOpenSFTP,/);
|
||||
});
|
||||
@@ -390,7 +390,7 @@ function terminalViewCtxEqual(
|
||||
}
|
||||
|
||||
function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalViewContext; isPaneMagnified?: boolean }) {
|
||||
const { Activity, Button, Clock3, Copy, Maximize2, Radio, RefreshCcw, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, Unplug, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTerminalProtocol, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isReconnectActive, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, identities, knownCwdRef, needsHostKeyVerification, onCloseSession, onDetach, onDetachPointerDown, onExpandToFocus, onTogglePaneMagnification, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scriptExecutionOverlay, searchMatchCount, searchFocusToken, sessionDisplayName, sessionId, workspaceId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showDisconnectedTerminalNotice, showConnectionControls, showLogs, showSelectionAIAction, isRestoringSelectionRef, snippets, status, sudoHintRef, sudoHintText, passwordPickerState, onPasswordPickerSelect, passwordPickerTitle, passwordPickerEmptyText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, terminalReconnectAvailable, reconnectNoticeMessage, timeLeft, toast, zmodem } = ctx;
|
||||
const { Activity, Button, Clock3, Copy, Maximize2, Radio, RefreshCcw, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, Unplug, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTerminalProtocol, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, composeSendToAll, onToggleComposeSendTarget, isCancelling, isComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isReconnectActive, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, identities, knownCwdRef, needsHostKeyVerification, onCloseSession, onDetach, onDetachPointerDown, onExpandToFocus, onTogglePaneMagnification, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scriptExecutionOverlay, searchMatchCount, searchFocusToken, sessionDisplayName, sessionId, workspaceId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showDisconnectedTerminalNotice, showConnectionControls, showLogs, showSelectionAIAction, isRestoringSelectionRef, snippets, status, sudoHintRef, sudoHintText, passwordPickerState, onPasswordPickerSelect, passwordPickerTitle, passwordPickerEmptyText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, terminalReconnectAvailable, reconnectNoticeMessage, timeLeft, toast, zmodem } = ctx;
|
||||
// Context menu only needs a snapshot at open; avoid selection state lifting into Terminal.
|
||||
const [contextMenuHasSelection, setContextMenuHasSelection] = useState(false);
|
||||
const isNetworkDevice = host.deviceType === 'network'
|
||||
@@ -591,6 +591,7 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
|
||||
onSaveScreen={terminalContextActions.onSaveScreen}
|
||||
onCopy={terminalContextActions.onCopy}
|
||||
onOpenBrowser={(!host?.protocol || ['ssh', 'mosh', 'local'].includes(host.protocol)) ? () => { void ctx.handleOpenHostBrowser(); } : undefined}
|
||||
onOpenSftp={status === "connected" && !isSerialConnection && !host?.id?.startsWith("serial-") && !isPluginHostProtocol(host?.protocol) ? ctx.handleOpenSFTP : undefined}
|
||||
onPaste={terminalContextActions.onPaste}
|
||||
onUploadClipboardImage={status === "connected" ? terminalContextActions.onUploadClipboardImage : undefined}
|
||||
onPasteSelection={terminalContextActions.onPasteSelection}
|
||||
@@ -1328,6 +1329,10 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
|
||||
let anyRecipientSensitive = false;
|
||||
let broadcastRecipientIds: readonly string[] = [];
|
||||
const sent = await executeSnippetCommand(text, false, {
|
||||
// Explicit target: active-only stays local while toolbar
|
||||
// broadcast (keystrokes) can remain on, and send-to-all
|
||||
// still fans out when the toolbar switch is off.
|
||||
broadcast: composeSendToAll ?? Boolean(isBroadcastEnabled),
|
||||
onBroadcastDelivered: (sessionIds) => {
|
||||
broadcastRecipientIds = sessionIds;
|
||||
if (sessionIds.some((id) => isTerminalSensitiveInputActive(id))) {
|
||||
@@ -1352,6 +1357,8 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
|
||||
termRef.current?.focus();
|
||||
}}
|
||||
isBroadcastEnabled={isBroadcastEnabled}
|
||||
composeSendToAll={composeSendToAll}
|
||||
onToggleComposeSendTarget={onToggleComposeSendTarget}
|
||||
themeColors={effectiveTheme.colors}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1965,7 +1965,14 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
sessionId: ctx.sessionId,
|
||||
dataLength: terminalData.length,
|
||||
});
|
||||
ctx.onTerminalDataCapture(ctx.sessionId, terminalData);
|
||||
const pendingCapture: unknown = ctx.onTerminalDataCapture(ctx.sessionId, terminalData);
|
||||
if (
|
||||
pendingCapture !== undefined
|
||||
&& pendingCapture !== null
|
||||
&& typeof (pendingCapture as Promise<void>).then === "function"
|
||||
) {
|
||||
await (pendingCapture as Promise<void>);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn("Failed to serialize terminal data:", err);
|
||||
}
|
||||
|
||||
@@ -245,7 +245,7 @@ export type TerminalSessionStartersContext = {
|
||||
onSessionAttached?: (sessionId: string) => void;
|
||||
onRestoreCwdIntentConsumed?: (cwd: string) => void;
|
||||
onSessionExit?: (sessionId: string, evt: TerminalSessionExitEvent) => void;
|
||||
onTerminalDataCapture?: (sessionId: string, data: string) => void;
|
||||
onTerminalDataCapture?: (sessionId: string, data: string) => void | Promise<void>;
|
||||
onTerminalLogData?: (data: string) => void;
|
||||
onProgrammaticCommandLogRewrite?: (rewrite: ProgrammaticCommandLogRewrite) => void;
|
||||
onTerminalOutput?: (chunk: string, meta?: TerminalSessionDataMeta) => void;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { shouldBroadcastTerminalUserInput } from "./terminalUserPaste.ts";
|
||||
|
||||
test("toolbar broadcast still fans terminal keystrokes out to peers", () => {
|
||||
const term = {};
|
||||
assert.equal(
|
||||
shouldBroadcastTerminalUserInput(term, "ls", {
|
||||
isBroadcastEnabled: true,
|
||||
hasBroadcastInputHandler: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldBroadcastTerminalUserInput(term, "ls", {
|
||||
isBroadcastEnabled: false,
|
||||
hasBroadcastInputHandler: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("keystroke fan-out stays on the toolbar gate and ignores the compose-bar target", () => {
|
||||
const runtime = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
|
||||
assert.match(runtime, /isBroadcastEnabled: ctx\.isBroadcastEnabledRef\.current/);
|
||||
assert.match(
|
||||
runtime,
|
||||
/onBroadcastInput\?\.\(broadcastData, ctx\.sessionId, sensitive \? \{ sourceSensitive: true \} : undefined\)/,
|
||||
);
|
||||
assert.doesNotMatch(runtime, /composeSendToAll|composePeerFanOut/);
|
||||
|
||||
const layer = readFileSync(new URL("../../TerminalLayer.tsx", import.meta.url), "utf8");
|
||||
assert.match(layer, /includeGlobalPeersWithoutToolbar: options\?\.composePeerFanOut === true/);
|
||||
const keystrokeCall = layer.slice(
|
||||
layer.indexOf("const handleBroadcastInput"),
|
||||
layer.indexOf("const handleCommandSubmitted"),
|
||||
);
|
||||
assert.doesNotMatch(keystrokeCall, /isComposeSendToAll/);
|
||||
});
|
||||
@@ -1000,7 +1000,14 @@ export const attachSessionToTerminal = (
|
||||
try {
|
||||
await ctx.prepareKeywordHighlightSerialization?.();
|
||||
const terminalData = ctx.serializeAddonRef.current.serialize();
|
||||
ctx.onTerminalDataCapture(ctx.sessionId, terminalData);
|
||||
const pendingCapture: unknown = ctx.onTerminalDataCapture(ctx.sessionId, terminalData);
|
||||
if (
|
||||
pendingCapture !== undefined
|
||||
&& pendingCapture !== null
|
||||
&& typeof (pendingCapture as Promise<void>).then === "function"
|
||||
) {
|
||||
await (pendingCapture as Promise<void>);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn("Failed to serialize terminal data:", err);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,11 @@ export interface TerminalBroadcastInputOptions {
|
||||
* the dispatcher tags the payload sourceSensitive.
|
||||
*/
|
||||
sensitive?: boolean;
|
||||
/**
|
||||
* Compose-bar send-to-all for orphan sessions while the toolbar broadcast
|
||||
* switch stays off. Keystroke fan-out never sets this.
|
||||
*/
|
||||
composePeerFanOut?: boolean;
|
||||
/**
|
||||
* Observer for the sessions that actually received the payload (#3491):
|
||||
* the solo compose bar uses it to detect a fan-out delivered into a peer's
|
||||
@@ -198,7 +203,7 @@ export interface TerminalProps {
|
||||
onTerminalFontSizeChange?: (fontSize: number) => void;
|
||||
onStatusChange?: (sessionId: string, status: TerminalSession["status"], sftpHost?: Host) => void;
|
||||
onSessionExit?: (sessionId: string, evt: TerminalSessionExitEvent) => void;
|
||||
onTerminalDataCapture?: (sessionId: string, data: string) => void;
|
||||
onTerminalDataCapture?: (sessionId: string, data: string) => void | Promise<void>;
|
||||
onOsDetected?: (hostId: string, distro: string) => void;
|
||||
onCloseSession?: (sessionId: string) => void;
|
||||
onUpdateHost?: (host: Host) => void;
|
||||
@@ -236,6 +241,9 @@ export interface TerminalProps {
|
||||
onOpenTheme?: () => void;
|
||||
onOpenSystem?: () => void;
|
||||
isBroadcastEnabled?: boolean;
|
||||
/** Compose-bar send target for solo sessions. Workspace compose lives on the layer. */
|
||||
composeSendToAll?: boolean;
|
||||
onToggleComposeSendTarget?: () => void;
|
||||
onToggleBroadcast?: () => void;
|
||||
onToggleComposeBar?: () => void;
|
||||
isWorkspaceComposeBarOpen?: boolean;
|
||||
|
||||
@@ -54,6 +54,8 @@ export const terminalPropsAreEqual = (
|
||||
&& prev.disableTerminalFontZoom === next.disableTerminalFontZoom
|
||||
&& prev.keyBindings === next.keyBindings
|
||||
&& prev.isBroadcastEnabled === next.isBroadcastEnabled
|
||||
&& prev.composeSendToAll === next.composeSendToAll
|
||||
&& prev.onToggleComposeSendTarget === next.onToggleComposeSendTarget
|
||||
&& prev.isWorkspaceComposeBarOpen === next.isWorkspaceComposeBarOpen
|
||||
&& prev.sessionLog === next.sessionLog
|
||||
&& prev.sshDebugLogEnabled === next.sshDebugLogEnabled
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps */
|
||||
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 { useTerminalEffects } from './useTerminalEffects';
|
||||
import { TERMINAL_SESSION_RESTORE_FOCUS_EVENT } from './focusTerminalSession';
|
||||
|
||||
// Exercise the real hook's reveal and settling effects. The injected effect
|
||||
// hooks omit unrelated backend/auth/font setup; no recovery logic is copied.
|
||||
function visibilityEffect(effect: React.EffectCallback) {
|
||||
const body = effect.toString();
|
||||
return body.includes('const becameVisible')
|
||||
|| (/hiddenAtRef\.current\s*=\s*Date\.now\(\)/.test(body))
|
||||
|| ((/hiddenAtRef\.current\s*=\s*null/.test(body)) && body.includes('const timer'))
|
||||
|| (body.includes('const timer1') && body.includes('const timer2'))
|
||||
|| body.includes('const handleRestoreFocus')
|
||||
|| body.includes('const recoverTerminalOnAppResume');
|
||||
}
|
||||
|
||||
function harness(t: any, initiallyHasRuntime = true) {
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let now = 10_000;
|
||||
let nextId = 0;
|
||||
const timers = new Map<number, { at: number; run: () => void }>();
|
||||
const schedule = (run: () => void, delay = 0) => {
|
||||
const id = ++nextId;
|
||||
timers.set(id, { at: now + delay, run });
|
||||
return id;
|
||||
};
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
t.mock.method(globalThis, 'setTimeout', schedule);
|
||||
t.mock.method(globalThis, 'clearTimeout', (id: number) => timers.delete(id));
|
||||
const win = new EventTarget();
|
||||
const doc = Object.assign(new EventTarget(), { visibilityState: 'visible', querySelector: () => null });
|
||||
let fullscreen: ((value: boolean) => void) | undefined;
|
||||
let renderer: ReactTestRenderer;
|
||||
const documentBefore = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: doc });
|
||||
const windowBefore = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: Object.assign(win, { setTimeout: schedule }) });
|
||||
t.after(() => {
|
||||
if (renderer) act(() => renderer.unmount());
|
||||
if (documentBefore) Object.defineProperty(globalThis, 'document', documentBefore);
|
||||
else delete (globalThis as any).document;
|
||||
if (windowBefore) Object.defineProperty(globalThis, 'window', windowBefore);
|
||||
else delete (globalThis as any).window;
|
||||
});
|
||||
const fitTimes: number[] = [];
|
||||
const calls = { ensure: 0, clear: 0, fit: 0, forcedFit: 0, render: 0, focus: 0 };
|
||||
const term = { options: {}, focus() { calls.focus++; }, rows: 24, cols: 80, buffer: { active: { type: 'normal' } }, scrollToBottom() {}, _core: { _renderService: { _renderRows() { calls.render++; } } } };
|
||||
const runtime = { ensureWebglRenderer() { calls.ensure++; }, clearTextureAtlas() { calls.clear++; } };
|
||||
const values: Record<string, any> = {
|
||||
host: { id: 'host', protocol: 'ssh' }, auth: {}, zmodem: {}, effectiveTheme: { id: 'theme', colors: {} },
|
||||
terminalSettings: {}, terminalSettingsRef: { current: {} },
|
||||
terminalBackend: { onWindowFullScreenChanged(callback: (value: boolean) => void) { fullscreen = callback; return () => { fullscreen = undefined; }; } },
|
||||
pluginDecorationRules: [], sessionId: 'session', paneLayoutKey: 'layout',
|
||||
isVisible: false, isVisibleRef: { current: false }, splitResizeActive: false, inWorkspace: false,
|
||||
termRef: { current: initiallyHasRuntime ? term : null }, xtermRuntimeRef: { current: initiallyHasRuntime ? runtime : null },
|
||||
fitAddonRef: { current: {} }, sessionRef: { current: 'pty' },
|
||||
containerRef: { current: { clientWidth: 800, clientHeight: 480 } },
|
||||
lastFittedSizeRef: { current: { width: 800, height: 480 } }, pendingOutputScrollRef: { current: false },
|
||||
safeFit(options: any) {
|
||||
calls.fit++;
|
||||
fitTimes.push(now);
|
||||
if (options?.force) calls.forcedFit++;
|
||||
const container = values.containerRef.current;
|
||||
values.lastFittedSizeRef.current = { width: container.clientWidth, height: container.clientHeight };
|
||||
},
|
||||
forceSyncRenderAfterResize() { calls.render++; }, resizeSession() {},
|
||||
useEffect(effect: React.EffectCallback, deps: React.DependencyList) {
|
||||
React.useEffect(visibilityEffect(effect) ? effect : () => {}, deps);
|
||||
},
|
||||
useLayoutEffect(effect: React.EffectCallback, deps: React.DependencyList) {
|
||||
React.useLayoutEffect(visibilityEffect(effect) ? effect : () => {}, deps);
|
||||
},
|
||||
};
|
||||
const ctx = new Proxy(values, { get(target, key: string) {
|
||||
if (!(key in target)) target[key] = key.endsWith('Ref') ? { current: null } : (() => {});
|
||||
return target[key];
|
||||
} });
|
||||
function Component() { useTerminalEffects(ctx); return null; }
|
||||
act(() => { renderer = create(React.createElement(Component)); });
|
||||
function advance(ms: number) {
|
||||
const end = now + ms;
|
||||
while (true) {
|
||||
const next = [...timers.entries()].filter(([, item]) => item.at <= end).sort((a, b) => a[1].at - b[1].at)[0];
|
||||
if (!next) break;
|
||||
now = next[1].at;
|
||||
timers.delete(next[0]);
|
||||
act(() => next[1].run());
|
||||
}
|
||||
now = end;
|
||||
}
|
||||
return { calls, fitTimes, advance, values,
|
||||
focus() { const event = Object.assign(new Event(TERMINAL_SESSION_RESTORE_FOCUS_EVENT), { detail: { sessionId: 'session' } }); act(() => { win.dispatchEvent(event); }); },
|
||||
resume() { act(() => { doc.dispatchEvent(new Event('visibilitychange')); }); },
|
||||
fullscreen() { act(() => fullscreen?.(true)); },
|
||||
reveal() { values.isVisible = true; values.isVisibleRef.current = true; act(() => renderer.update(React.createElement(Component))); },
|
||||
publishRuntime() { values.termRef.current = term; values.xtermRuntimeRef.current = runtime; },
|
||||
};
|
||||
}
|
||||
|
||||
for (const hiddenMs of [2000, 4000]) {
|
||||
test(`unchanged reveal after ${hiddenMs}ms does not repeat recovery in settling timers`, t => {
|
||||
const h = harness(t);
|
||||
h.advance(hiddenMs);
|
||||
h.reveal();
|
||||
assert.equal(h.calls.clear, hiddenMs >= 3000 ? 1 : 0);
|
||||
h.advance(600);
|
||||
assert.equal(h.calls.clear, hiddenMs >= 3000 ? 1 : 0);
|
||||
assert.equal(h.calls.fit, 0);
|
||||
});
|
||||
}
|
||||
|
||||
test('late changed bounds still receive a settling fit', t => {
|
||||
const h = harness(t);
|
||||
h.advance(4000); h.reveal(); h.advance(200);
|
||||
h.values.containerRef.current.clientHeight = 460;
|
||||
h.advance(400);
|
||||
assert.equal(h.calls.fit, 1);
|
||||
assert.equal(h.calls.forcedFit, 0);
|
||||
assert.deepEqual(h.values.lastFittedSizeRef.current, { width: 800, height: 460 });
|
||||
});
|
||||
|
||||
test('runtime published after reveal still receives delayed recovery', t => {
|
||||
const h = harness(t, false);
|
||||
h.advance(4000); h.reveal();
|
||||
assert.equal(h.calls.clear, 0);
|
||||
h.advance(25); h.publishRuntime(); h.advance(575);
|
||||
assert.equal(h.calls.ensure, 1);
|
||||
assert.equal(h.calls.clear, 1);
|
||||
assert.equal(h.calls.forcedFit, 1);
|
||||
});
|
||||
|
||||
|
||||
test('focus restoration and its 50ms retry do not refit unchanged bounds', t => {
|
||||
const h = harness(t);
|
||||
h.advance(4000); h.reveal(); h.advance(600);
|
||||
h.focus(); h.advance(50); h.focus(); h.advance(500);
|
||||
assert.equal(h.calls.focus, 2);
|
||||
assert.equal(h.calls.fit, 0);
|
||||
assert.equal(h.calls.clear, 1);
|
||||
});
|
||||
|
||||
test('focus restoration still recovers changed bounds', t => {
|
||||
const h = harness(t);
|
||||
h.advance(4000); h.reveal(); h.advance(600);
|
||||
h.values.containerRef.current.clientWidth = 780;
|
||||
h.focus(); h.advance(50); h.focus(); h.advance(100);
|
||||
assert.equal(h.calls.focus, 2);
|
||||
assert.equal(h.calls.forcedFit, 2);
|
||||
assert.deepEqual(h.values.lastFittedSizeRef.current, { width: 780, height: 480 });
|
||||
});
|
||||
|
||||
for (const source of ['resume', 'fullscreen'] as const) {
|
||||
test(`fitted focus retries preserve pending ${source} recovery timers`, t => {
|
||||
const h = harness(t);
|
||||
h.advance(4000); h.reveal(); h.advance(600);
|
||||
h[source](); h.advance(0);
|
||||
assert.equal(h.calls.forcedFit, 1);
|
||||
h.focus(); h.advance(50); h.focus(); h.advance(500);
|
||||
assert.equal(h.calls.focus, 2);
|
||||
assert.equal(h.calls.forcedFit, 3, 'all three explicit recovery passes must survive focus retries');
|
||||
assert.deepEqual(h.fitTimes.map(at => at - h.fitTimes[0]), source === 'resume' ? [0, 100, 300] : [0, 150, 400]);
|
||||
});
|
||||
}
|
||||
|
||||
for (const source of ['resume', 'fullscreen'] as const) {
|
||||
test(`focus during changed bounds preserves pending ${source} settling passes`, t => {
|
||||
const h = harness(t);
|
||||
h.advance(4000); h.reveal(); h.advance(600);
|
||||
h[source](); h.advance(0);
|
||||
h.advance(50);
|
||||
h.values.containerRef.current.clientWidth = 780;
|
||||
h.focus(); h.advance(500);
|
||||
assert.equal(h.calls.focus, 1);
|
||||
assert.equal(h.calls.forcedFit, 3);
|
||||
assert.deepEqual(h.fitTimes.map(at => at - h.fitTimes[0]), source === 'resume' ? [0, 100, 300] : [0, 150, 400]);
|
||||
assert.equal(h.values.lastFittedSizeRef.current.width, 780);
|
||||
});
|
||||
}
|
||||
@@ -115,6 +115,27 @@ test('immediate tab recovery marks webgl recovery to skip the delayed duplicate
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/lastWebglRecoveryLayoutKeyRef\.current === paneLayoutKey\s*&& hiddenMs < CSS_ONLY_TAB_REVEAL_MAX_HIDDEN_MS/,
|
||||
/if \(lastWebglRecoveryLayoutKeyRef\.current === paneLayoutKey\) \{/,
|
||||
);
|
||||
});
|
||||
|
||||
test('reveal does not claim recovery before the terminal runtime exists', () => {
|
||||
const start = source.indexOf('const recoverTerminalAfterBecomeVisible = () => {');
|
||||
const end = source.indexOf('// Refit synchronously', start);
|
||||
const recovery = source.slice(start, end);
|
||||
const runtimeGuard = recovery.indexOf('if (!termRef.current || !xtermRuntimeRef.current) return;');
|
||||
const commit = recovery.indexOf('lastWebglRecoveryLayoutKeyRef.current = paneLayoutKey');
|
||||
assert.ok(runtimeGuard >= 0 && runtimeGuard < commit);
|
||||
});
|
||||
|
||||
test('settling tab-layout probes do not force a fit when bounds are unchanged', () => {
|
||||
const start = source.indexOf('const refitSettledLayout = () => {');
|
||||
const end = source.indexOf('}, [inWorkspace, isVisible]);', start);
|
||||
const settling = source.slice(start, end);
|
||||
assert.ok(start >= 0 && end > start);
|
||||
assert.match(settling, /if \(!isVisibleRef\.current \|\| currentContainerSizeAlreadyFit\(\)\) return;/);
|
||||
assert.match(settling, /safeFit\(\{ requireVisible: true \}\);/);
|
||||
assert.doesNotMatch(settling, /force: true/);
|
||||
assert.match(settling, /setTimeout\(refitSettledLayout, 100\)/);
|
||||
assert.match(settling, /setTimeout\(refitSettledLayout, 350\)/);
|
||||
});
|
||||
@@ -917,8 +917,14 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
bootAbort.abort();
|
||||
isBootActiveRef.current = false;
|
||||
if (hibernatedRef?.current) {
|
||||
forceCloseHibernatedSession?.();
|
||||
disposeOwnedRuntime();
|
||||
const pendingClose = forceCloseHibernatedSession?.() as void | Promise<void>;
|
||||
if (pendingClose && typeof pendingClose.then === "function") {
|
||||
void pendingClose.finally(() => {
|
||||
disposeOwnedRuntime();
|
||||
});
|
||||
} else {
|
||||
disposeOwnedRuntime();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -946,13 +952,20 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
const persistCloseCapture = (data: string, source: string, dataLength: number) => {
|
||||
const persistCloseCapture = async (data: string, source: string, dataLength: number) => {
|
||||
logger.info("[Terminal] Capturing data on unmount", {
|
||||
sessionId,
|
||||
source,
|
||||
dataLength,
|
||||
});
|
||||
handleTerminalDataCaptureOnce(sessionId, data, { finalized: true });
|
||||
const pendingCapture: unknown = handleTerminalDataCaptureOnce(sessionId, data, { finalized: true });
|
||||
if (
|
||||
pendingCapture !== undefined
|
||||
&& pendingCapture !== null
|
||||
&& typeof (pendingCapture as Promise<void>).then === "function"
|
||||
) {
|
||||
await (pendingCapture as Promise<void>);
|
||||
}
|
||||
};
|
||||
|
||||
const term = termRef.current;
|
||||
@@ -991,7 +1004,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
? resolveConnectionLogCapturePayload(finalizeTerminalLogData)
|
||||
: null;
|
||||
if (connectionLogPayload) {
|
||||
persistCloseCapture(
|
||||
await persistCloseCapture(
|
||||
connectionLogPayload.data,
|
||||
connectionLogPayload.source,
|
||||
connectionLogPayload.data.length,
|
||||
@@ -1018,7 +1031,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
return;
|
||||
}
|
||||
if (payload) {
|
||||
persistCloseCapture(payload.data, payload.source, payload.data.length);
|
||||
await persistCloseCapture(payload.data, payload.source, payload.data.length);
|
||||
}
|
||||
ownedRuntime = null;
|
||||
scheduleTerminalCloseTeardown(teardown);
|
||||
@@ -1317,7 +1330,12 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
applyUserCursorBlinkPreference(term, terminalSettingsRef.current);
|
||||
if (hasOpenAppDialog()) return;
|
||||
term.focus();
|
||||
scheduleLayoutRecoveryRefit([0, 100]);
|
||||
// Tab focus retries do not imply a lost renderer or a changed layout.
|
||||
// Leave explicit app-resume/fullscreen recovery timers untouched when
|
||||
// these bounds are already fitted, or recovery is still settling.
|
||||
if (!currentContainerSizeAlreadyFit() && layoutRecoveryTimersRef.current.length === 0) {
|
||||
scheduleLayoutRecoveryRefit([0, 100]);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener(TERMINAL_SESSION_RESTORE_FOCUS_EVENT, handleRestoreFocus);
|
||||
@@ -1387,6 +1405,9 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
|
||||
const recoverTerminalAfterBecomeVisible = () => {
|
||||
flushTerminalWritesAfterBecomeVisible();
|
||||
// A hidden pane can become visible before its asynchronous boot completes.
|
||||
// Do not claim recovery until there is a runtime for the delayed pass to use.
|
||||
if (!termRef.current || !xtermRuntimeRef.current) return;
|
||||
|
||||
if (
|
||||
getHiddenDurationMs() < CSS_ONLY_TAB_REVEAL_MAX_HIDDEN_MS
|
||||
@@ -1463,20 +1484,17 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
|
||||
const hiddenMs = hiddenAtRef.current
|
||||
? Date.now() - hiddenAtRef.current
|
||||
: Number.POSITIVE_INFINITY;
|
||||
hiddenAtRef.current = null;
|
||||
|
||||
if (
|
||||
lastWebglRecoveryLayoutKeyRef.current === paneLayoutKey
|
||||
&& hiddenMs < CSS_ONLY_TAB_REVEAL_MAX_HIDDEN_MS
|
||||
) {
|
||||
// The layout effect already recovers long hides as well as short ones.
|
||||
// Repeating it here discards the freshly populated, potentially shared atlas.
|
||||
if (lastWebglRecoveryLayoutKeyRef.current === paneLayoutKey) {
|
||||
flushPendingOutputScroll();
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (!isVisibleRef.current || !termRef.current || !xtermRuntimeRef.current) return;
|
||||
lastWebglRecoveryLayoutKeyRef.current = paneLayoutKey;
|
||||
// A pane that mounted hidden deferred its WebGL renderer; create it now
|
||||
// that it's visible (no-op if already active or WebGL is disabled).
|
||||
@@ -1687,14 +1705,16 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
// Fit twice: once after initial layout (100ms) and again after layout settles
|
||||
// (350ms) to handle race conditions during split operations where the container
|
||||
// dimensions may not be final on the first pass.
|
||||
const timer1 = setTimeout(() => {
|
||||
const refitSettledLayout = () => {
|
||||
// Keep checking for late split/layout changes, but an unchanged tab does
|
||||
// not need another forced fit (which clears its glyph atlas even when
|
||||
// the row/column count is unchanged).
|
||||
if (!isVisibleRef.current || currentContainerSizeAlreadyFit()) return;
|
||||
safeFit({ requireVisible: true });
|
||||
finishLayoutRecoveryAfterFit();
|
||||
}, 100);
|
||||
const timer2 = setTimeout(() => {
|
||||
safeFit({ force: true, requireVisible: true });
|
||||
finishLayoutRecoveryAfterFit();
|
||||
}, 350);
|
||||
};
|
||||
const timer1 = setTimeout(refitSettledLayout, 100);
|
||||
const timer2 = setTimeout(refitSettledLayout, 350);
|
||||
return () => { clearTimeout(timer1); clearTimeout(timer2); };
|
||||
}, [inWorkspace, isVisible]);
|
||||
|
||||
|
||||
@@ -696,7 +696,7 @@ export interface TerminalLayerProps {
|
||||
onCommandExecuted?: (command: string, hostId: string, hostLabel: string, sessionId: string) => void;
|
||||
onDeleteShellHistoryEntry?: (entryId: string) => void;
|
||||
onClearCommandHistory?: () => void;
|
||||
onTerminalDataCapture?: (sessionId: string, data: string) => void;
|
||||
onTerminalDataCapture?: (sessionId: string, data: string) => void | Promise<void>;
|
||||
onCreateWorkspaceFromSessions: (baseSessionId: string, joiningSessionId: string, hint: Exclude<SplitHint, null>) => void;
|
||||
onAddSessionToWorkspace: (workspaceId: string, sessionId: string, hint: Exclude<SplitHint, null>) => void;
|
||||
onRequestAddToWorkspace?: (workspaceId: string) => void;
|
||||
@@ -719,10 +719,14 @@ export interface TerminalLayerProps {
|
||||
onCreateLocalTerminal?: () => void;
|
||||
// Broadcast mode
|
||||
isBroadcastEnabled?: (workspaceId: string) => boolean;
|
||||
isComposeSendToAll?: (workspaceId: string) => boolean;
|
||||
onToggleComposeSendTarget?: (workspaceId: string) => void;
|
||||
isGlobalBroadcastEnabled?: boolean;
|
||||
isGlobalComposeSendToAll?: boolean;
|
||||
canUseGlobalBroadcast?: boolean;
|
||||
onToggleBroadcast?: (workspaceId: string) => void;
|
||||
onToggleGlobalBroadcast?: () => void;
|
||||
onToggleGlobalComposeSendTarget?: () => void;
|
||||
// SFTP side panel
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
updateSnippets?: (snippets: Snippet[]) => void;
|
||||
@@ -815,7 +819,7 @@ interface TerminalPaneProps {
|
||||
onCloseSession: (sessionId: string) => void;
|
||||
onStatusChange: (sessionId: string, status: TerminalSession['status'], sftpHost?: Host) => void;
|
||||
onSessionExit: (sessionId: string, evt: TerminalSessionExitEvent) => void;
|
||||
onTerminalDataCapture?: (sessionId: string, data: string) => void;
|
||||
onTerminalDataCapture?: (sessionId: string, data: string) => void | Promise<void>;
|
||||
onOsDetected: (hostId: string, distro: string) => void;
|
||||
onUpdateHost: (host: Host) => void;
|
||||
onAddKnownHost?: (knownHost: KnownHost) => void;
|
||||
@@ -824,9 +828,12 @@ interface TerminalPaneProps {
|
||||
onSetWorkspaceFocusedSession?: (workspaceId: string, sessionId: string) => void;
|
||||
onSplitSession?: (sessionId: string, direction: SplitDirection) => void;
|
||||
isBroadcastEnabled?: (workspaceId: string) => boolean;
|
||||
isComposeSendToAll?: (workspaceId: string) => boolean;
|
||||
isGlobalBroadcastEnabled?: boolean;
|
||||
isGlobalComposeSendToAll?: boolean;
|
||||
canUseGlobalBroadcast?: boolean;
|
||||
onToggleGlobalBroadcast?: () => void;
|
||||
onToggleGlobalComposeSendTarget?: () => void;
|
||||
onBroadcastInput: (
|
||||
data: string,
|
||||
sourceSessionId: string,
|
||||
@@ -952,9 +959,12 @@ const terminalPanePropsAreEqual = (
|
||||
prev.onSetWorkspaceFocusedSession === next.onSetWorkspaceFocusedSession &&
|
||||
prev.onSplitSession === next.onSplitSession &&
|
||||
prev.isBroadcastEnabled === next.isBroadcastEnabled &&
|
||||
prev.isComposeSendToAll === next.isComposeSendToAll &&
|
||||
prev.isGlobalBroadcastEnabled === next.isGlobalBroadcastEnabled &&
|
||||
prev.isGlobalComposeSendToAll === next.isGlobalComposeSendToAll &&
|
||||
prev.canUseGlobalBroadcast === next.canUseGlobalBroadcast &&
|
||||
prev.onToggleGlobalBroadcast === next.onToggleGlobalBroadcast &&
|
||||
prev.onToggleGlobalComposeSendTarget === next.onToggleGlobalComposeSendTarget &&
|
||||
prev.onBroadcastInput === next.onBroadcastInput &&
|
||||
prev.onBroadcastInterruptPriorityChange === next.onBroadcastInterruptPriorityChange &&
|
||||
prev.onToggleWorkspaceComposeBar === next.onToggleWorkspaceComposeBar &&
|
||||
@@ -1253,9 +1263,12 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
||||
onSetWorkspaceFocusedSession,
|
||||
onSplitSession,
|
||||
isBroadcastEnabled,
|
||||
isComposeSendToAll,
|
||||
isGlobalBroadcastEnabled,
|
||||
isGlobalComposeSendToAll,
|
||||
canUseGlobalBroadcast,
|
||||
onToggleGlobalBroadcast,
|
||||
onToggleGlobalComposeSendTarget,
|
||||
onBroadcastInput,
|
||||
onBroadcastInterruptPriorityChange,
|
||||
onToggleWorkspaceComposeBar,
|
||||
@@ -1485,6 +1498,12 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
||||
const broadcastEnabled = activeWorkspaceId
|
||||
? !!isBroadcastEnabled?.(activeWorkspaceId)
|
||||
: (isGlobalBroadcastEnabled ?? false);
|
||||
const composeSendToAll = activeWorkspaceId
|
||||
? !!isComposeSendToAll?.(activeWorkspaceId)
|
||||
: !!isGlobalComposeSendToAll;
|
||||
const toggleComposeSendTarget = !activeWorkspaceId && canUseGlobalBroadcast
|
||||
? onToggleGlobalComposeSendTarget
|
||||
: undefined;
|
||||
const isHostEphemeral = !isSavedVaultHost(hostMap.get(host.id));
|
||||
const sessionAppearance = useMemo(
|
||||
() => resolveSessionAppearance({ host, isEphemeral: isHostEphemeral }),
|
||||
@@ -1630,6 +1649,8 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
||||
onSplitHorizontal={onSplitSession ? splitHorizontalHandler : undefined}
|
||||
onSplitVertical={onSplitSession ? splitVerticalHandler : undefined}
|
||||
isBroadcastEnabled={broadcastEnabled}
|
||||
composeSendToAll={inActiveWorkspace ? undefined : composeSendToAll}
|
||||
onToggleComposeSendTarget={toggleComposeSendTarget}
|
||||
onToggleBroadcast={
|
||||
inActiveWorkspace
|
||||
? workspaceBroadcastHandler
|
||||
@@ -1637,7 +1658,7 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
||||
}
|
||||
onToggleComposeBar={inActiveWorkspace ? onToggleWorkspaceComposeBar : undefined}
|
||||
isWorkspaceComposeBarOpen={inActiveWorkspace ? isComposeBarOpen : undefined}
|
||||
onBroadcastInput={broadcastEnabled ? onBroadcastInput : undefined}
|
||||
onBroadcastInput={onBroadcastInput}
|
||||
onBroadcastInterruptPriorityChange={onBroadcastInterruptPriorityChange}
|
||||
onSnippetExecutorChange={onSnippetExecutorChange}
|
||||
onProgrammaticCommandLogRewriteChange={onProgrammaticCommandLogRewriteChange}
|
||||
@@ -1717,7 +1738,7 @@ interface TerminalPanesHostProps {
|
||||
onCloseSession: (sessionId: string) => void;
|
||||
onStatusChange: (sessionId: string, status: TerminalSession['status'], sftpHost?: Host) => void;
|
||||
onSessionExit: (sessionId: string, evt: TerminalSessionExitEvent) => void;
|
||||
onTerminalDataCapture?: (sessionId: string, data: string) => void;
|
||||
onTerminalDataCapture?: (sessionId: string, data: string) => void | Promise<void>;
|
||||
onOsDetected: (hostId: string, distro: string) => void;
|
||||
onUpdateHost: (host: Host) => void;
|
||||
onAddKnownHost?: (knownHost: KnownHost) => void;
|
||||
@@ -1726,9 +1747,12 @@ interface TerminalPanesHostProps {
|
||||
onSetWorkspaceFocusedSession?: (workspaceId: string, sessionId: string) => void;
|
||||
onSplitSession?: (sessionId: string, direction: SplitDirection) => void;
|
||||
isBroadcastEnabled?: (workspaceId: string) => boolean;
|
||||
isComposeSendToAll?: (workspaceId: string) => boolean;
|
||||
isGlobalBroadcastEnabled?: boolean;
|
||||
isGlobalComposeSendToAll?: boolean;
|
||||
canUseGlobalBroadcast?: boolean;
|
||||
onToggleGlobalBroadcast?: () => void;
|
||||
onToggleGlobalComposeSendTarget?: () => void;
|
||||
onBroadcastInput: (
|
||||
data: string,
|
||||
sourceSessionId: string,
|
||||
@@ -1816,9 +1840,12 @@ const terminalPanesHostPropsAreEqual = (
|
||||
if (prev.onSetWorkspaceFocusedSession !== next.onSetWorkspaceFocusedSession) return false;
|
||||
if (prev.onSplitSession !== next.onSplitSession) return false;
|
||||
if (prev.isBroadcastEnabled !== next.isBroadcastEnabled) return false;
|
||||
if (prev.isComposeSendToAll !== next.isComposeSendToAll) return false;
|
||||
if (prev.isGlobalBroadcastEnabled !== next.isGlobalBroadcastEnabled) return false;
|
||||
if (prev.isGlobalComposeSendToAll !== next.isGlobalComposeSendToAll) return false;
|
||||
if (prev.canUseGlobalBroadcast !== next.canUseGlobalBroadcast) return false;
|
||||
if (prev.onToggleGlobalBroadcast !== next.onToggleGlobalBroadcast) return false;
|
||||
if (prev.onToggleGlobalComposeSendTarget !== next.onToggleGlobalComposeSendTarget) return false;
|
||||
if (prev.onBroadcastInput !== next.onBroadcastInput) return false;
|
||||
if (prev.onBroadcastInterruptPriorityChange !== next.onBroadcastInterruptPriorityChange) return false;
|
||||
if (prev.onToggleWorkspaceComposeBar !== next.onToggleWorkspaceComposeBar) return false;
|
||||
|
||||
@@ -547,6 +547,8 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
restoreTerminalCwd: s.restoreTerminalCwd,
|
||||
identities: s.identities,
|
||||
isBroadcastEnabled: s.isBroadcastEnabled,
|
||||
isComposeSendToAll: s.isComposeSendToAll,
|
||||
onToggleComposeSendTarget: s.onToggleComposeSendTarget,
|
||||
isComposeBarOpen: s.isComposeBarOpen,
|
||||
isFocusMode,
|
||||
isSidePanelOpenForCurrentTab,
|
||||
@@ -659,6 +661,8 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
workspaceBroadcastHandlersRef: s.workspaceBroadcastHandlersRef,
|
||||
workspaceById,
|
||||
isGlobalBroadcastEnabled: s.isGlobalBroadcastEnabled,
|
||||
isGlobalComposeSendToAll: s.isGlobalComposeSendToAll,
|
||||
onToggleGlobalComposeSendTarget: s.onToggleGlobalComposeSendTarget,
|
||||
canUseGlobalBroadcast: s.canUseGlobalBroadcast,
|
||||
onToggleGlobalBroadcast: s.onToggleGlobalBroadcastRef.current,
|
||||
// AI scope maintenance (merge/dissolve handoff) needs the full list; do not
|
||||
|
||||
@@ -101,6 +101,8 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
|
||||
onSetWorkspaceFocusedSession,
|
||||
onSplitSession,
|
||||
isBroadcastEnabled,
|
||||
isComposeSendToAll,
|
||||
onToggleComposeSendTarget,
|
||||
handleBroadcastInput,
|
||||
handleBroadcastInterruptPriorityChange,
|
||||
handleToggleWorkspaceComposeBar,
|
||||
@@ -124,8 +126,10 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
|
||||
onStartSessionDrag,
|
||||
onEndSessionDrag,
|
||||
isGlobalBroadcastEnabled,
|
||||
isGlobalComposeSendToAll,
|
||||
canUseGlobalBroadcast,
|
||||
onToggleGlobalBroadcast,
|
||||
onToggleGlobalComposeSendTarget,
|
||||
t,
|
||||
} = ctx;
|
||||
|
||||
@@ -247,6 +251,9 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
|
||||
onSetWorkspaceFocusedSession={onSetWorkspaceFocusedSession}
|
||||
onSplitSession={onSplitSession}
|
||||
isBroadcastEnabled={isBroadcastEnabled}
|
||||
isComposeSendToAll={isComposeSendToAll}
|
||||
isGlobalComposeSendToAll={isGlobalComposeSendToAll}
|
||||
onToggleGlobalComposeSendTarget={onToggleGlobalComposeSendTarget}
|
||||
onBroadcastInput={handleBroadcastInput}
|
||||
onBroadcastInterruptPriorityChange={handleBroadcastInterruptPriorityChange}
|
||||
onToggleWorkspaceComposeBar={handleToggleWorkspaceComposeBar}
|
||||
@@ -349,6 +356,10 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
|
||||
refocusTerminalSession(focusedSessionId);
|
||||
}}
|
||||
isBroadcastEnabled={isBroadcastEnabled?.(activeWorkspace.id)}
|
||||
composeSendToAll={isComposeSendToAll?.(activeWorkspace.id) ?? false}
|
||||
onToggleComposeSendTarget={onToggleComposeSendTarget
|
||||
? () => onToggleComposeSendTarget(activeWorkspace.id)
|
||||
: undefined}
|
||||
themeColors={composeBarThemeColors}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -23,6 +23,7 @@ function setup(
|
||||
sensitive = new Set<string>(),
|
||||
broadcastPasswordBypass = false,
|
||||
restoredDisconnected = new Set<string>(),
|
||||
composeSendToAll?: boolean,
|
||||
) {
|
||||
const writes: string[] = [];
|
||||
const context = {
|
||||
@@ -34,6 +35,7 @@ function setup(
|
||||
restoreState: restoredDisconnected.has(String(index)) ? 'restored-disconnected' : undefined,
|
||||
})) },
|
||||
isBroadcastEnabled: () => broadcast,
|
||||
isComposeSendToAll: composeSendToAll === undefined ? undefined : () => composeSendToAll,
|
||||
isTerminalSensitiveInputActive: (id: string) => sensitive.has(id),
|
||||
broadcastPasswordBypassRef: { current: broadcastPasswordBypass },
|
||||
snippetExecutorsRef: { current: executors },
|
||||
@@ -60,6 +62,53 @@ for (const broadcast of [false, true]) {
|
||||
}
|
||||
}
|
||||
|
||||
test('compose active-only sends to the focused session while toolbar broadcast stays on', async () => {
|
||||
const { send, writes } = setup(
|
||||
['connected', 'connected'], true, new Map(), new Set(), false, new Set(), false,
|
||||
);
|
||||
assert.equal(await send('command'), true);
|
||||
assert.deepEqual(writes, ['0']);
|
||||
});
|
||||
|
||||
test('compose active-only does not run peer executors while toolbar broadcast stays on', async () => {
|
||||
const calls: Array<{ id: string; broadcast: boolean | undefined }> = [];
|
||||
const executors = new Map<string, Executor>([
|
||||
['0', async (_text, _immediate, options) => {
|
||||
calls.push({ id: '0', broadcast: options?.broadcast });
|
||||
return true;
|
||||
}],
|
||||
['1', async () => { calls.push({ id: '1', broadcast: undefined }); return true; }],
|
||||
]);
|
||||
assert.equal(
|
||||
await setup(
|
||||
['connected', 'connected'], true, executors, new Set(), false, new Set(), false,
|
||||
).send('command'),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(calls, [{ id: '0', broadcast: false }]);
|
||||
});
|
||||
|
||||
test('compose send-to-all fans out while toolbar broadcast stays off', async () => {
|
||||
const { send, writes } = setup(
|
||||
['connected', 'connected'], false, new Map(), new Set(), false, new Set(), true,
|
||||
);
|
||||
assert.equal(await send('command'), true);
|
||||
assert.deepEqual(writes, ['0', '1']);
|
||||
});
|
||||
|
||||
test('compose send-to-all keeps a bypassed sensitive peer out of history', async () => {
|
||||
const executors = new Map<string, Executor>([
|
||||
['0', async () => true],
|
||||
['1', async () => true],
|
||||
]);
|
||||
assert.equal(
|
||||
await setup(
|
||||
['connected', 'connected'], false, executors, new Set(['1']), true, new Set(), true,
|
||||
).send('secret'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('a disconnected broadcast peer cannot undo another successful fallback', async () => {
|
||||
assert.equal(await setup(['connected', 'disconnected'], true).send('command'), true);
|
||||
});
|
||||
@@ -169,18 +218,43 @@ for (const result of [true, false, Promise.resolve(true), Promise.resolve(false)
|
||||
sessionId: 'a', sessionRef: { current: 'a' },
|
||||
executeSnippetCommand: () => result,
|
||||
isTerminalSensitiveInputActive: () => false,
|
||||
composeSendToAll: undefined as boolean | undefined,
|
||||
isBroadcastEnabled: false,
|
||||
};
|
||||
runInNewContext(soloCode, context);
|
||||
assert.equal(await context.send('command'), await result);
|
||||
});
|
||||
}
|
||||
|
||||
test('solo compose send uses the compose-bar target while toolbar broadcast stays on', async () => {
|
||||
const calls: Array<boolean | undefined> = [];
|
||||
const context = {
|
||||
send: undefined as unknown as (text: string) => Promise<boolean>,
|
||||
sessionId: 'a', sessionRef: { current: 'a' },
|
||||
executeSnippetCommand: (_text: string, _noAutoRun?: boolean, options?: { broadcast?: boolean }) => {
|
||||
calls.push(options?.broadcast);
|
||||
return true;
|
||||
},
|
||||
isTerminalSensitiveInputActive: () => false,
|
||||
composeSendToAll: false,
|
||||
isBroadcastEnabled: true,
|
||||
};
|
||||
runInNewContext(soloCode, context);
|
||||
assert.equal(await context.send('command'), true);
|
||||
context.composeSendToAll = true;
|
||||
context.isBroadcastEnabled = false;
|
||||
assert.equal(await context.send('command'), true);
|
||||
assert.deepEqual(calls, [false, true]);
|
||||
});
|
||||
|
||||
test('solo missing sessions and sensitive input are excluded; rejection propagates to the compose handler', async () => {
|
||||
const context = {
|
||||
send: undefined as unknown as (text: string) => Promise<boolean>,
|
||||
sessionId: 'a', sessionRef: { current: null as string | null },
|
||||
executeSnippetCommand: async () => true,
|
||||
isTerminalSensitiveInputActive: () => false,
|
||||
composeSendToAll: undefined as boolean | undefined,
|
||||
isBroadcastEnabled: false,
|
||||
};
|
||||
runInNewContext(soloCode, context);
|
||||
assert.equal(await context.send('command'), false);
|
||||
|
||||
@@ -414,6 +414,8 @@ const WORKSPACE_CTX_KEYS = [
|
||||
'onSetWorkspaceFocusedSession',
|
||||
'onSplitSession',
|
||||
'isBroadcastEnabled',
|
||||
'isComposeSendToAll',
|
||||
'onToggleComposeSendTarget',
|
||||
'handleBroadcastInput',
|
||||
'handleBroadcastInterruptPriorityChange',
|
||||
'handleToggleWorkspaceComposeBar',
|
||||
@@ -440,8 +442,10 @@ const WORKSPACE_CTX_KEYS = [
|
||||
'onStartSessionDrag',
|
||||
'onEndSessionDrag',
|
||||
'isGlobalBroadcastEnabled',
|
||||
'isGlobalComposeSendToAll',
|
||||
'canUseGlobalBroadcast',
|
||||
'onToggleGlobalBroadcast',
|
||||
'onToggleGlobalComposeSendTarget',
|
||||
] as const;
|
||||
|
||||
export function terminalLayerSidePanelStableCtxEqual(prev: Ctx, next: Ctx): boolean {
|
||||
@@ -477,6 +481,10 @@ export function terminalLayerViewCtxEqual(prev: Ctx, next: Ctx): boolean {
|
||||
if (prev.refocusTerminalSession !== next.refocusTerminalSession) return false;
|
||||
if (prev.setIsComposeBarOpen !== next.setIsComposeBarOpen) return false;
|
||||
if (prev.isBroadcastEnabled !== next.isBroadcastEnabled) return false;
|
||||
if (prev.isComposeSendToAll !== next.isComposeSendToAll) return false;
|
||||
if (prev.onToggleComposeSendTarget !== next.onToggleComposeSendTarget) return false;
|
||||
if (prev.isGlobalComposeSendToAll !== next.isGlobalComposeSendToAll) return false;
|
||||
if (prev.onToggleGlobalComposeSendTarget !== next.onToggleGlobalComposeSendTarget) return false;
|
||||
if (prev.composeBarThemeColors !== next.composeBarThemeColors) return false;
|
||||
if (prev.workspaceOuterRef !== next.workspaceOuterRef) return false;
|
||||
// Use stable side-panel equal only: LIVE fields (cwd, theme focus, etc.) flow
|
||||
|
||||
@@ -76,9 +76,13 @@ export const terminalLayerAreEqual = (
|
||||
prev.onCreateLocalTerminal === next.onCreateLocalTerminal &&
|
||||
prev.isBroadcastEnabled === next.isBroadcastEnabled &&
|
||||
prev.onToggleBroadcast === next.onToggleBroadcast &&
|
||||
prev.isComposeSendToAll === next.isComposeSendToAll &&
|
||||
prev.onToggleComposeSendTarget === next.onToggleComposeSendTarget &&
|
||||
prev.isGlobalBroadcastEnabled === next.isGlobalBroadcastEnabled &&
|
||||
prev.isGlobalComposeSendToAll === next.isGlobalComposeSendToAll &&
|
||||
prev.canUseGlobalBroadcast === next.canUseGlobalBroadcast &&
|
||||
prev.onToggleGlobalBroadcast === next.onToggleGlobalBroadcast &&
|
||||
prev.onToggleGlobalComposeSendTarget === next.onToggleGlobalComposeSendTarget &&
|
||||
prev.updateSnippets === next.updateSnippets &&
|
||||
prev.updateSnippetPackages === next.updateSnippetPackages &&
|
||||
prev.toggleScriptsSidePanelRef === next.toggleScriptsSidePanelRef &&
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import { DiskIoSummary } from "../systemManager/DiskIoSummary";
|
||||
import type { MonitorStats } from "../../domain/monitorRuntime";
|
||||
import { ResourceBar } from "../systemManager/ResourceBar";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
@@ -15,7 +16,7 @@ export function MonitorDiskUsage({
|
||||
stats,
|
||||
limit,
|
||||
}: {
|
||||
stats?: (Pick<MonitorStats, "disks" | "diskPercent"> & Partial<Pick<MonitorStats, "diskUsed" | "diskTotal">>) | null;
|
||||
stats?: (Pick<MonitorStats, "disks" | "diskPercent"> & Partial<Pick<MonitorStats, "diskUsed" | "diskTotal" | "diskReadSpeed" | "diskWriteSpeed">>) | null;
|
||||
limit?: number;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
@@ -30,6 +31,7 @@ export function MonitorDiskUsage({
|
||||
return (
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<div className={limit === 2 ? "min-h-[38px] space-y-1.5" : "space-y-1.5"}>
|
||||
<DiskIoSummary stats={stats} />
|
||||
{disks.map((disk, index) => (
|
||||
<Tooltip key={`${disk.mountPoint}-${index}`}>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -186,3 +186,19 @@ for (const view of ["list", "charts"] as const) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("disk I/O distinguishes warming up or unsupported from idle and reports both rates", () => {
|
||||
for (const [rates, expected] of [
|
||||
[{}, ["Read --", "Write --"]],
|
||||
[{ diskReadSpeed: null, diskWriteSpeed: null }, ["Read --", "Write --"]],
|
||||
[{ diskReadSpeed: 0, diskWriteSpeed: 0 }, ["Read 0 B/s", "Write 0 B/s"]],
|
||||
[{ diskReadSpeed: 1024, diskWriteSpeed: 2097152 }, ["Read 1.0 KiB/s", "Write 2.0 MiB/s"]],
|
||||
] as const) {
|
||||
const document = new JSDOM(renderToStaticMarkup(
|
||||
<I18nProvider locale="en"><MonitorDiskUsage stats={{ disks: [], diskPercent: null, ...rates }} /></I18nProvider>,
|
||||
)).window.document;
|
||||
const io = document.querySelector("[data-disk-io]")!;
|
||||
for (const text of expected) assert.ok(io.textContent?.includes(text));
|
||||
assert.match(io.getAttribute("title")!, /Linux.*not supported on macOS/);
|
||||
}
|
||||
});
|
||||
@@ -624,7 +624,7 @@ export function MonitorPanels({
|
||||
<SelectValue>{t("monitor.seconds", { count: panel.interval })}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MONITOR_INTERVALS.filter((v) => v >= 3).map((v) => (
|
||||
{MONITOR_INTERVALS.map((v) => (
|
||||
<SelectItem key={v} value={String(v)}>
|
||||
{t("monitor.seconds", { count: v })}
|
||||
</SelectItem>
|
||||
|
||||
+160
-122
@@ -5,12 +5,14 @@ import type { ConnectionLog } from "./models.ts";
|
||||
import { selectConnectionLogForTerminalDataCapture } from "./connectionLog.ts";
|
||||
import {
|
||||
MAX_PERSISTED_UNSAVED_TERMINAL_DATA_ENTRIES,
|
||||
diffTerminalDataMaps,
|
||||
mergeConnectionLogsFromStorage,
|
||||
mergeTerminalDataIntoLogs,
|
||||
mergeTerminalDataMapsForStorage,
|
||||
pruneInMemoryUnsavedTerminalData,
|
||||
pruneTerminalDataMapForStorage,
|
||||
writeTerminalDataMapWithQuotaRetry,
|
||||
rebaseQueuedConnectionLogs,
|
||||
retainNewestUnsavedReplays,
|
||||
} from "./connectionLogTerminalData.ts";
|
||||
|
||||
const baseLog: ConnectionLog = {
|
||||
@@ -234,131 +236,167 @@ test("mergeTerminalDataMapsForStorage drops deleted log replay buffers", () => {
|
||||
assert.equal(merged.deleted, undefined);
|
||||
});
|
||||
|
||||
const quotaLogs = (count: number): ConnectionLog[] =>
|
||||
Array.from({ length: count }, (_, index) => ({
|
||||
test("rebaseQueuedConnectionLogs keeps logs another window wrote while this snapshot waited", () => {
|
||||
const queued = [{ ...baseLog, id: "local", startTime: 2000, terminalData: "local-replay" }];
|
||||
const memory = queued;
|
||||
const disk = [
|
||||
{ ...baseLog, id: "remote", startTime: 3000 },
|
||||
{ ...baseLog, id: "local", startTime: 2000 },
|
||||
];
|
||||
|
||||
const rebased = rebaseQueuedConnectionLogs(queued, new Set(), new Set(["local"]), memory, disk);
|
||||
|
||||
assert.deepEqual(rebased.map((log) => log.id), ["remote", "local"]);
|
||||
assert.equal(rebased.find((log) => log.id === "local")?.terminalData, "local-replay");
|
||||
});
|
||||
|
||||
test("rebaseQueuedConnectionLogs does not restore an id deleted from memory after it was queued", () => {
|
||||
const queued = [{ ...baseLog, id: "local", startTime: 2000, terminalData: "local-replay" }];
|
||||
const disk = [
|
||||
{ ...baseLog, id: "remote", startTime: 3000 },
|
||||
{ ...baseLog, id: "local", startTime: 2000, terminalData: "stale-intermediate" },
|
||||
];
|
||||
|
||||
const rebased = rebaseQueuedConnectionLogs(
|
||||
queued,
|
||||
new Set(),
|
||||
new Set(["local"]),
|
||||
[{ ...baseLog, id: "remote", startTime: 3000 }],
|
||||
disk,
|
||||
);
|
||||
|
||||
assert.deepEqual(rebased.map((log) => log.id), ["remote"]);
|
||||
assert.equal(rebased.find((log) => log.id === "local"), undefined);
|
||||
|
||||
const notYetOnDisk = rebaseQueuedConnectionLogs(
|
||||
queued,
|
||||
new Set(),
|
||||
new Set(["local"]),
|
||||
[{ ...baseLog, id: "remote", startTime: 3000 }],
|
||||
[{ ...baseLog, id: "remote", startTime: 3000 }],
|
||||
);
|
||||
assert.deepEqual(notYetOnDisk.map((log) => log.id), ["remote"]);
|
||||
});
|
||||
|
||||
test("rebaseQueuedConnectionLogs does not restore an unchanged log another window deleted", () => {
|
||||
const unchanged = { ...baseLog, id: "unchanged", startTime: 1000, terminalData: "old" };
|
||||
const added = { ...baseLog, id: "local", startTime: 4000, terminalData: "local-replay" };
|
||||
const queued = [added, unchanged];
|
||||
const remote = [{ ...baseLog, id: "remote", startTime: 3000 }];
|
||||
|
||||
const rebased = rebaseQueuedConnectionLogs(
|
||||
queued,
|
||||
new Set(),
|
||||
new Set(["local"]),
|
||||
[added, ...remote],
|
||||
remote,
|
||||
);
|
||||
|
||||
assert.deepEqual(rebased.map((log) => log.id), ["local", "remote"]);
|
||||
assert.equal(rebased.find((log) => log.id === "unchanged"), undefined);
|
||||
assert.equal(rebased.find((log) => log.id === "local")?.terminalData, "local-replay");
|
||||
});
|
||||
|
||||
test("rebaseQueuedConnectionLogs applies a local metadata edit onto the latest disk row", () => {
|
||||
const queued = [{ ...baseLog, id: "local", startTime: 2000, saved: true, terminalData: "replay" }];
|
||||
const disk = [
|
||||
{ ...baseLog, id: "remote", startTime: 3000 },
|
||||
{ ...baseLog, id: "local", startTime: 2000, saved: false },
|
||||
];
|
||||
|
||||
const rebased = rebaseQueuedConnectionLogs(
|
||||
queued,
|
||||
new Set(),
|
||||
new Set(["local"]),
|
||||
queued,
|
||||
disk,
|
||||
);
|
||||
|
||||
assert.equal(rebased.find((log) => log.id === "local")?.saved, true);
|
||||
assert.equal(rebased.find((log) => log.id === "local")?.terminalData, "replay");
|
||||
assert.ok(rebased.some((log) => log.id === "remote"));
|
||||
});
|
||||
|
||||
test("retainNewestUnsavedReplays drops older unsaved buffers and keeps the newest", () => {
|
||||
const logs: ConnectionLog[] = [
|
||||
{ ...baseLog, id: "old", startTime: 1000, terminalData: "old" },
|
||||
{ ...baseLog, id: "mid", startTime: 2000, terminalData: "mid" },
|
||||
{ ...baseLog, id: "new", startTime: 3000, terminalData: "new" },
|
||||
{ ...baseLog, id: "saved", startTime: 500, saved: true, terminalData: "saved" },
|
||||
];
|
||||
|
||||
const kept = retainNewestUnsavedReplays(logs, 1);
|
||||
|
||||
assert.equal(kept.find((log) => log.id === "new")?.terminalData, "new");
|
||||
assert.equal(kept.find((log) => log.id === "saved")?.terminalData, "saved");
|
||||
assert.equal(kept.find((log) => log.id === "old")?.terminalData, undefined);
|
||||
assert.equal(kept.find((log) => log.id === "mid")?.terminalData, undefined);
|
||||
});
|
||||
|
||||
test("rebaseQueuedConnectionLogs drops a queued delete and keeps a queued add still in memory", () => {
|
||||
const queued = [{ ...baseLog, id: "added", startTime: 4000, terminalData: "new" }];
|
||||
const memory = queued;
|
||||
const disk = [
|
||||
{ ...baseLog, id: "deleted", startTime: 1000 },
|
||||
{ ...baseLog, id: "kept", startTime: 2000 },
|
||||
];
|
||||
|
||||
const rebased = rebaseQueuedConnectionLogs(
|
||||
queued,
|
||||
new Set(["deleted"]),
|
||||
new Set(["added"]),
|
||||
memory,
|
||||
disk,
|
||||
);
|
||||
|
||||
assert.deepEqual(rebased.map((log) => log.id), ["added", "kept"]);
|
||||
assert.equal(rebased.find((log) => log.id === "added")?.terminalData, "new");
|
||||
});
|
||||
|
||||
test("diffTerminalDataMaps puts only changed ids and deletes removed ids", () => {
|
||||
const diff = diffTerminalDataMaps(
|
||||
{ same: "keep", edited: "old", removed: "gone" },
|
||||
{ same: "keep", edited: "new", added: "fresh" },
|
||||
);
|
||||
|
||||
assert.deepEqual(diff.puts, { edited: "new", added: "fresh" });
|
||||
assert.deepEqual(diff.deleteIds, ["removed"]);
|
||||
});
|
||||
|
||||
test("diffTerminalDataMaps treats key order as unchanged", () => {
|
||||
const diff = diffTerminalDataMaps(
|
||||
{ b: "2", a: "1" },
|
||||
{ a: "1", b: "2" },
|
||||
);
|
||||
|
||||
assert.deepEqual(diff.puts, {});
|
||||
assert.deepEqual(diff.deleteIds, []);
|
||||
});
|
||||
|
||||
test("prune cap diffs to deleting only replay buffers outside the newest 50", () => {
|
||||
const logs: ConnectionLog[] = Array.from({ length: 60 }, (_, index) => ({
|
||||
...baseLog,
|
||||
id: `log-${index}`,
|
||||
startTime: index,
|
||||
saved: false,
|
||||
}));
|
||||
logs.push({
|
||||
...baseLog,
|
||||
id: "saved-keep",
|
||||
startTime: 1,
|
||||
saved: true,
|
||||
});
|
||||
const map = Object.fromEntries(logs.map((log) => [log.id, `data-${log.id}`]));
|
||||
const pruned = pruneTerminalDataMapForStorage(logs, map);
|
||||
const diff = diffTerminalDataMaps(map, pruned);
|
||||
|
||||
const quotaWriter = (limitChars: number) => {
|
||||
const calls: number[] = [];
|
||||
const write = (next: Record<string, string>) => {
|
||||
const size = Object.values(next).reduce((sum, value) => sum + value.length, 0);
|
||||
calls.push(Object.keys(next).length);
|
||||
return size <= limitChars;
|
||||
};
|
||||
return { calls, write };
|
||||
};
|
||||
|
||||
test("writeTerminalDataMapWithQuotaRetry writes once when it fits", () => {
|
||||
const logs = quotaLogs(5);
|
||||
const map = Object.fromEntries(logs.map((log) => [log.id, "x".repeat(10)]));
|
||||
const { calls, write } = quotaWriter(1000);
|
||||
const result = writeTerminalDataMapWithQuotaRetry(logs, map, write, null);
|
||||
assert.equal(result.persisted, true);
|
||||
assert.equal(result.written, map);
|
||||
assert.deepEqual(calls, [5]);
|
||||
});
|
||||
|
||||
test("writeTerminalDataMapWithQuotaRetry drops the oldest unsaved entries until it fits", () => {
|
||||
const logs = quotaLogs(50);
|
||||
// Newest first, matching pruneTerminalDataMapForStorage order.
|
||||
const map = Object.fromEntries(
|
||||
[...logs].reverse().map((log) => [log.id, "x".repeat(10)]),
|
||||
);
|
||||
const { calls, write } = quotaWriter(10 * 47);
|
||||
const result = writeTerminalDataMapWithQuotaRetry(logs, map, write, null);
|
||||
assert.equal(result.persisted, true);
|
||||
// 50 fails, drop 1 → 49 fails, drop 2 → 48 fails, drop 4 → 46 fits.
|
||||
assert.deepEqual(calls, [50, 49, 48, 46]);
|
||||
assert.equal(result.written["log-49"], map["log-49"]);
|
||||
assert.equal(result.written["log-4"], map["log-4"]);
|
||||
for (const id of ["log-0", "log-1", "log-2", "log-3"]) {
|
||||
assert.equal(result.written[id], undefined);
|
||||
}
|
||||
// Key order (newest first) is preserved.
|
||||
assert.deepEqual(Object.keys(result.written), Object.keys(map).filter((id) => id in result.written));
|
||||
});
|
||||
|
||||
test("writeTerminalDataMapWithQuotaRetry never drops saved or unknown entries and stops after all unsaved are gone", () => {
|
||||
const logs: ConnectionLog[] = [
|
||||
...quotaLogs(20),
|
||||
{ ...baseLog, id: "saved", startTime: -1, saved: true },
|
||||
];
|
||||
const map: Record<string, string> = Object.fromEntries(
|
||||
logs.map((log) => [log.id, "x".repeat(10)]),
|
||||
);
|
||||
map["other-window"] = "y".repeat(10);
|
||||
const { calls, write } = quotaWriter(5); // nothing fits
|
||||
const result = writeTerminalDataMapWithQuotaRetry(logs, map, write, null);
|
||||
assert.equal(result.persisted, false);
|
||||
assert.equal(result.written, map);
|
||||
// 22, then drop 1, 2, 4, 8, 16, then all 20 unsaved: bounded attempts.
|
||||
assert.deepEqual(calls, [22, 21, 20, 18, 14, 6, 2]);
|
||||
assert.ok(result.attempts <= 2 + Math.ceil(Math.log2(20)));
|
||||
});
|
||||
|
||||
const newestFirstMap = (logs: ConnectionLog[], chars: number) =>
|
||||
Object.fromEntries([...logs].reverse().map((log) => [log.id, "x".repeat(chars)]));
|
||||
|
||||
test("writeTerminalDataMapWithQuotaRetry starts the next write from the newest set that last fit", () => {
|
||||
const logs = quotaLogs(50);
|
||||
const quota = quotaWriter(10 * 47);
|
||||
const first = writeTerminalDataMapWithQuotaRetry(logs, newestFirstMap(logs, 10), quota.write, null);
|
||||
// Next close: oldest drops out of the 50, a newer session arrives.
|
||||
const nextLogs = [...logs.slice(1), { ...baseLog, id: "log-50", startTime: 50 }];
|
||||
const second = quotaWriter(10 * 47);
|
||||
const result = writeTerminalDataMapWithQuotaRetry(
|
||||
nextLogs,
|
||||
newestFirstMap(nextLogs, 10),
|
||||
second.write,
|
||||
first.quotaFailedChars,
|
||||
);
|
||||
assert.equal(result.persisted, true);
|
||||
assert.deepEqual(second.calls, [47]);
|
||||
assert.equal(result.written["log-50"] !== undefined, true);
|
||||
assert.equal(result.written["log-3"], undefined);
|
||||
});
|
||||
|
||||
test("writeTerminalDataMapWithQuotaRetry grows back when entries get smaller", () => {
|
||||
const logs = quotaLogs(50);
|
||||
const first = writeTerminalDataMapWithQuotaRetry(
|
||||
logs,
|
||||
newestFirstMap(logs, 10),
|
||||
quotaWriter(10 * 47).write,
|
||||
null,
|
||||
);
|
||||
const small = quotaWriter(10 * 47);
|
||||
const result = writeTerminalDataMapWithQuotaRetry(
|
||||
logs,
|
||||
newestFirstMap(logs, 5),
|
||||
small.write,
|
||||
first.quotaFailedChars,
|
||||
);
|
||||
assert.deepEqual(small.calls, [50]);
|
||||
assert.equal(Object.keys(result.written).length, 50);
|
||||
});
|
||||
|
||||
test("writeTerminalDataMapWithQuotaRetry falls back to dropping when the hint is too optimistic", () => {
|
||||
const logs = quotaLogs(50);
|
||||
const first = writeTerminalDataMapWithQuotaRetry(
|
||||
logs,
|
||||
newestFirstMap(logs, 10),
|
||||
quotaWriter(10 * 47).write,
|
||||
null,
|
||||
);
|
||||
const tighter = quotaWriter(10 * 30); // quota shrank (other keys grew)
|
||||
const result = writeTerminalDataMapWithQuotaRetry(
|
||||
logs,
|
||||
newestFirstMap(logs, 10),
|
||||
tighter.write,
|
||||
first.quotaFailedChars,
|
||||
);
|
||||
assert.equal(result.persisted, true);
|
||||
assert.equal(tighter.calls[0], 47);
|
||||
assert.ok(Object.keys(result.written).length <= 30);
|
||||
assert.equal(result.written["log-49"] !== undefined, true);
|
||||
assert.ok(result.attempts <= 2 + Math.ceil(Math.log2(50)));
|
||||
assert.equal(Object.keys(pruned).length, MAX_PERSISTED_UNSAVED_TERMINAL_DATA_ENTRIES + 1);
|
||||
assert.equal(Object.keys(diff.puts).length, 0);
|
||||
assert.equal(diff.deleteIds.length, 10);
|
||||
assert.equal(diff.deleteIds.includes("log-0"), true);
|
||||
assert.equal(diff.deleteIds.includes("log-9"), true);
|
||||
assert.equal(diff.deleteIds.includes("log-10"), false);
|
||||
assert.equal(diff.deleteIds.includes("log-59"), false);
|
||||
assert.equal(diff.deleteIds.includes("saved-keep"), false);
|
||||
assert.equal(pruned["saved-keep"], "data-saved-keep");
|
||||
});
|
||||
@@ -26,8 +26,8 @@ export const mergeTerminalDataIntoLogs = (
|
||||
};
|
||||
|
||||
/**
|
||||
* When another window or a pruned localStorage write reloads connection logs,
|
||||
* keep in-memory / side-store terminal replay data instead of wiping it.
|
||||
* When another window reloads connection logs, keep in-memory / side-store
|
||||
* terminal replay data instead of wiping it.
|
||||
*/
|
||||
export const mergeConnectionLogsFromStorage = (
|
||||
prev: ConnectionLog[],
|
||||
@@ -49,6 +49,81 @@ export const mergeConnectionLogsFromStorage = (
|
||||
return changed ? merged : next;
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply a queued connection-log snapshot when it runs, not when it was captured.
|
||||
* Disk may have gained another window's logs during the wait. Keep those logs.
|
||||
* Metadata from this snapshot replaces a disk row only for ids the recipe added
|
||||
* or updated (`changedIds`) that are still in memory. An id deleted locally
|
||||
* after this snapshot was queued is not written back. Deletes from this
|
||||
* snapshot apply only while memory still agrees.
|
||||
*/
|
||||
export const rebaseQueuedConnectionLogs = (
|
||||
queued: readonly ConnectionLog[],
|
||||
removedIds: ReadonlySet<string>,
|
||||
changedIds: ReadonlySet<string>,
|
||||
memory: readonly ConnectionLog[],
|
||||
disk: readonly ConnectionLog[] | null,
|
||||
): ConnectionLog[] => {
|
||||
const memoryById = new Map(memory.map((log) => [log.id, log]));
|
||||
const queuedById = new Map(queued.map((log) => [log.id, log]));
|
||||
const diskKept = (disk ?? []).filter(
|
||||
(log) => !(removedIds.has(log.id) && !memoryById.has(log.id)),
|
||||
);
|
||||
const present = new Set<string>();
|
||||
const withLocalEdits: ConnectionLog[] = [];
|
||||
for (const log of diskKept) {
|
||||
const queuedLog = queuedById.get(log.id);
|
||||
const memoryLog = memoryById.get(log.id);
|
||||
if (changedIds.has(log.id)) {
|
||||
if (!memoryLog) continue;
|
||||
present.add(log.id);
|
||||
withLocalEdits.push(memoryLog);
|
||||
continue;
|
||||
}
|
||||
present.add(log.id);
|
||||
const replay = queuedLog?.terminalData ?? memoryLog?.terminalData ?? log.terminalData;
|
||||
if (!replay || log.terminalData === replay) {
|
||||
withLocalEdits.push(log);
|
||||
continue;
|
||||
}
|
||||
withLocalEdits.push({ ...log, terminalData: replay });
|
||||
}
|
||||
const extras: ConnectionLog[] = [];
|
||||
for (const log of queued) {
|
||||
if (present.has(log.id) || !changedIds.has(log.id)) continue;
|
||||
const memoryLog = memoryById.get(log.id);
|
||||
if (!memoryLog) continue;
|
||||
extras.push(memoryLog);
|
||||
}
|
||||
if (extras.length === 0) return withLocalEdits;
|
||||
return pruneInMemoryUnsavedTerminalData(
|
||||
[...extras, ...withLocalEdits].sort((a, b) => b.startTime - a.startTime),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Drop `terminalData` from unsaved logs outside the newest `keep` entries.
|
||||
* Saved logs and the newest unsaved replays stay intact.
|
||||
*/
|
||||
export const retainNewestUnsavedReplays = (
|
||||
logs: readonly ConnectionLog[],
|
||||
keep: number,
|
||||
): ConnectionLog[] => {
|
||||
const ranked = logs
|
||||
.filter((log) => !log.saved && log.terminalData)
|
||||
.sort((a, b) => b.startTime - a.startTime);
|
||||
if (ranked.length <= keep) return [...logs];
|
||||
const keepIds = new Set(ranked.slice(0, Math.max(0, keep)).map((log) => log.id));
|
||||
let changed = false;
|
||||
const next = logs.map((log) => {
|
||||
if (log.saved || log.terminalData === undefined || keepIds.has(log.id)) return log;
|
||||
changed = true;
|
||||
const { terminalData: _omitted, ...rest } = log;
|
||||
return rest;
|
||||
});
|
||||
return changed ? next : [...logs];
|
||||
};
|
||||
|
||||
export const buildTerminalDataMapFromLogs = (
|
||||
logs: ConnectionLog[],
|
||||
): ConnectionLogTerminalDataMap => {
|
||||
@@ -114,62 +189,25 @@ export const pruneInMemoryUnsavedTerminalData = (
|
||||
return changed ? next : logs;
|
||||
};
|
||||
|
||||
/**
|
||||
* Write the side store; if the write is rejected (localStorage quota), retry
|
||||
* without the oldest unsaved replay buffers so the newest sessions are kept.
|
||||
* Drops 1, 2, 4, … oldest entries per retry, so attempts stay O(log n).
|
||||
* Saved logs and ids not in `logs` are never dropped.
|
||||
*
|
||||
* `quotaFailedChars` is owned by the caller. It is the summed entry length of
|
||||
* the smallest payload that last hit the quota, so the next write can start
|
||||
* from the newest set that fits. A successful write of the full map returns
|
||||
* null. The helper does not keep that hint itself.
|
||||
*/
|
||||
export const writeTerminalDataMapWithQuotaRetry = (
|
||||
logs: ConnectionLog[],
|
||||
map: ConnectionLogTerminalDataMap,
|
||||
write: (next: ConnectionLogTerminalDataMap) => boolean,
|
||||
quotaFailedChars: number | null,
|
||||
): { persisted: boolean; written: ConnectionLogTerminalDataMap; attempts: number; quotaFailedChars: number | null } => {
|
||||
const droppable = logs
|
||||
.filter((log) => !log.saved && map[log.id] !== undefined)
|
||||
.sort((a, b) => a.startTime - b.startTime)
|
||||
.map((log) => log.id);
|
||||
let chars = 0;
|
||||
for (const data of Object.values(map)) chars += data.length;
|
||||
export type ConnectionLogTerminalDataDiff = {
|
||||
puts: ConnectionLogTerminalDataMap;
|
||||
deleteIds: string[];
|
||||
};
|
||||
|
||||
let hint = quotaFailedChars;
|
||||
let dropCount = 0;
|
||||
if (hint !== null) {
|
||||
while (dropCount < droppable.length && chars >= hint) {
|
||||
chars -= map[droppable[dropCount]].length;
|
||||
dropCount += 1;
|
||||
}
|
||||
/** Changed replay blobs to write, and ids no longer in the persisted map. */
|
||||
export const diffTerminalDataMaps = (
|
||||
previous: ConnectionLogTerminalDataMap,
|
||||
next: ConnectionLogTerminalDataMap,
|
||||
): ConnectionLogTerminalDataDiff => {
|
||||
const puts: ConnectionLogTerminalDataMap = {};
|
||||
const deleteIds: string[] = [];
|
||||
for (const id of Object.keys(next)) {
|
||||
if (previous[id] !== next[id]) puts[id] = next[id];
|
||||
}
|
||||
|
||||
let attempts = 0;
|
||||
for (;;) {
|
||||
let next = map;
|
||||
if (dropCount > 0) {
|
||||
const dropped = new Set(droppable.slice(0, dropCount));
|
||||
next = {};
|
||||
for (const [id, data] of Object.entries(map)) {
|
||||
if (!dropped.has(id)) next[id] = data;
|
||||
}
|
||||
}
|
||||
attempts += 1;
|
||||
if (write(next)) {
|
||||
if (dropCount === 0) hint = null;
|
||||
return { persisted: true, written: next, attempts, quotaFailedChars: hint };
|
||||
}
|
||||
hint = hint === null ? chars : Math.min(hint, chars);
|
||||
if (dropCount >= droppable.length) {
|
||||
return { persisted: false, written: map, attempts, quotaFailedChars: hint };
|
||||
}
|
||||
const target = Math.min(Math.max(dropCount * 2, 1), droppable.length);
|
||||
for (let i = dropCount; i < target; i += 1) chars -= map[droppable[i]].length;
|
||||
dropCount = target;
|
||||
for (const id of Object.keys(previous)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(next, id)) deleteIds.push(id);
|
||||
}
|
||||
return { puts, deleteIds };
|
||||
};
|
||||
|
||||
/** Fold side-store maps together, then cap to the allowed unsaved/saved set. */
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { getTerminalPassthroughActions } from '../application/state/useGlobalHotkeys.ts';
|
||||
import en from '../application/i18n/locales/en.ts';
|
||||
import zhCN from '../application/i18n/locales/zh-CN.ts';
|
||||
import zhTW from '../application/i18n/locales/zh-TW.ts';
|
||||
import { findActiveSystemShortcutConflict } from './activeKeyBindings.ts';
|
||||
import { DEFAULT_KEY_BINDINGS, matchesKeyBinding } from './models/keyBindings.ts';
|
||||
|
||||
test('host list toggle defaults to Ctrl/Cmd+Shift+B and leaves Ctrl/Cmd+B on broadcast', () => {
|
||||
const binding = DEFAULT_KEY_BINDINGS.find((entry) => entry.id === 'toggle-host-tree');
|
||||
|
||||
assert.equal(binding?.action, 'toggleHostTree');
|
||||
assert.equal(binding?.label, 'Toggle Host List');
|
||||
assert.equal(binding?.category, 'app');
|
||||
assert.equal(binding?.pc, 'Ctrl + Shift + B');
|
||||
assert.equal(binding?.mac, '⌘ + Shift + B');
|
||||
|
||||
assert.equal(
|
||||
findActiveSystemShortcutConflict('Ctrl + B', 'pc', DEFAULT_KEY_BINDINGS)?.id,
|
||||
'broadcast',
|
||||
);
|
||||
assert.equal(
|
||||
findActiveSystemShortcutConflict('⌘ + B', 'mac', DEFAULT_KEY_BINDINGS)?.id,
|
||||
'broadcast',
|
||||
);
|
||||
assert.equal(
|
||||
findActiveSystemShortcutConflict('Ctrl + Shift + B', 'pc', DEFAULT_KEY_BINDINGS)?.id,
|
||||
'toggle-host-tree',
|
||||
);
|
||||
assert.equal(
|
||||
findActiveSystemShortcutConflict('⌘ + Shift + B', 'mac', DEFAULT_KEY_BINDINGS)?.id,
|
||||
'toggle-host-tree',
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
DEFAULT_KEY_BINDINGS.filter((entry) => entry.pc === 'Ctrl + Shift + B').map((entry) => entry.id),
|
||||
['toggle-host-tree'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
DEFAULT_KEY_BINDINGS.filter((entry) => entry.mac === '⌘ + Shift + B').map((entry) => entry.id),
|
||||
['toggle-host-tree'],
|
||||
);
|
||||
|
||||
assert.equal(getTerminalPassthroughActions().has('toggleHostTree'), false);
|
||||
assert.equal(matchesKeyBinding({
|
||||
key: 'B',
|
||||
code: 'KeyB',
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
shiftKey: true,
|
||||
} as KeyboardEvent, binding?.pc ?? '', false), true);
|
||||
assert.equal(matchesKeyBinding({
|
||||
key: 'b',
|
||||
code: 'KeyB',
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
} as KeyboardEvent, binding?.pc ?? '', false), false);
|
||||
});
|
||||
|
||||
test('host list shortcut label is localized in English and Chinese', () => {
|
||||
assert.equal(en['settings.shortcuts.binding.toggle-host-tree'], 'Toggle Host List');
|
||||
assert.equal(zhCN['settings.shortcuts.binding.toggle-host-tree'], '切换主机列表');
|
||||
assert.equal(zhTW['settings.shortcuts.binding.toggle-host-tree'], '切換主機列表');
|
||||
});
|
||||
@@ -273,6 +273,7 @@ export const DEFAULT_KEY_BINDINGS: KeyBinding[] = [
|
||||
{ id: 'snippets', action: 'snippets', label: 'Open Snippets', mac: '⌘ + Shift + S', pc: 'Ctrl + Shift + S', category: 'app' },
|
||||
{ id: 'broadcast', action: 'broadcast', label: 'Switch the Broadcast Mode', mac: '⌘ + B', pc: 'Ctrl + B', category: 'app' },
|
||||
{ id: 'toggle-side-panel', action: 'toggleSidePanel', label: 'Toggle Side Panel', mac: '⌘ + \\', pc: 'Ctrl + \\', category: 'app' },
|
||||
{ id: 'toggle-host-tree', action: 'toggleHostTree', label: 'Toggle Host List', mac: '⌘ + Shift + B', pc: 'Ctrl + Shift + B', category: 'app' },
|
||||
{ id: 'open-settings', action: 'openSettings', label: 'Open Settings', mac: '⌘ + ,', pc: 'Ctrl + ,', category: 'app' },
|
||||
{ id: 'toggle-opacity', action: 'toggleOpacity', label: 'Toggle Window Transparency', mac: '⌘ + U', pc: 'Ctrl + Shift + U', category: 'app' },
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
composeSendTargetAfterToolbarToggle,
|
||||
flipComposeSendTarget,
|
||||
resolveTerminalBroadcastTargetIds,
|
||||
shouldBroadcastDuringSensitivePrompt,
|
||||
} from './terminalBroadcast.ts';
|
||||
@@ -93,6 +95,49 @@ test('missing or hidden global sources do not fan out', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('compose send target follows toolbar broadcast, then flips on its own', () => {
|
||||
assert.equal(composeSendTargetAfterToolbarToggle(true), 'all');
|
||||
assert.equal(composeSendTargetAfterToolbarToggle(false), 'active');
|
||||
assert.equal(flipComposeSendTarget('all'), 'active');
|
||||
assert.equal(flipComposeSendTarget('active'), 'all');
|
||||
// An independent flip does not rewrite the toolbar-derived default.
|
||||
assert.equal(composeSendTargetAfterToolbarToggle(true), 'all');
|
||||
});
|
||||
|
||||
test('compose peer fan-out can reach orphan peers while toolbar broadcast is off', () => {
|
||||
const sessions = [
|
||||
session('source'),
|
||||
session('peer'),
|
||||
session('hidden-orphan', { hiddenFromTabs: true }),
|
||||
];
|
||||
assert.deepEqual(
|
||||
resolveTerminalBroadcastTargetIds({
|
||||
sessions,
|
||||
sourceSessionId: 'source',
|
||||
globalBroadcastEnabled: false,
|
||||
includeGlobalPeersWithoutToolbar: true,
|
||||
}),
|
||||
['peer'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolveTerminalBroadcastTargetIds({
|
||||
sessions,
|
||||
sourceSessionId: 'source',
|
||||
globalBroadcastEnabled: false,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolveTerminalBroadcastTargetIds({
|
||||
sessions: [session('hidden-source', { hiddenFromTabs: true }), session('peer')],
|
||||
sourceSessionId: 'hidden-source',
|
||||
globalBroadcastEnabled: false,
|
||||
includeGlobalPeersWithoutToolbar: true,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('sensitive prompts pause broadcasting unless the password bypass is on', () => {
|
||||
assert.equal(
|
||||
shouldBroadcastDuringSensitivePrompt({
|
||||
|
||||
@@ -15,16 +15,37 @@ export function shouldBroadcastDuringSensitivePrompt(input: {
|
||||
return !input.sensitivePromptActive || input.broadcastPasswordBypass;
|
||||
}
|
||||
|
||||
export type ComposeSendTarget = 'all' | 'active';
|
||||
|
||||
/**
|
||||
* Toolbar broadcast is the keystroke fan-out switch. Turning it on or off
|
||||
* resets the compose-bar send target to match. A later compose-bar flip does
|
||||
* not move the toolbar switch.
|
||||
*/
|
||||
export function composeSendTargetAfterToolbarToggle(toolbarEnabled: boolean): ComposeSendTarget {
|
||||
return toolbarEnabled ? 'all' : 'active';
|
||||
}
|
||||
|
||||
export function flipComposeSendTarget(target: ComposeSendTarget): ComposeSendTarget {
|
||||
return target === 'all' ? 'active' : 'all';
|
||||
}
|
||||
|
||||
export function resolveTerminalBroadcastTargetIds({
|
||||
sessions,
|
||||
sourceSessionId,
|
||||
globalBroadcastEnabled,
|
||||
directTargetSessionIds,
|
||||
includeGlobalPeersWithoutToolbar = false,
|
||||
}: {
|
||||
sessions: readonly BroadcastSession[];
|
||||
sourceSessionId: string;
|
||||
globalBroadcastEnabled: boolean;
|
||||
directTargetSessionIds?: readonly string[];
|
||||
/**
|
||||
* Compose-bar "send to all" can target orphan peers while the toolbar
|
||||
* (global) broadcast switch stays off. Keystroke fan-out leaves this false.
|
||||
*/
|
||||
includeGlobalPeersWithoutToolbar?: boolean;
|
||||
}): string[] {
|
||||
if (directTargetSessionIds) {
|
||||
const directTargetIds = new Set(directTargetSessionIds);
|
||||
@@ -45,7 +66,8 @@ export function resolveTerminalBroadcastTargetIds({
|
||||
.map((session) => session.id);
|
||||
}
|
||||
|
||||
if (!globalBroadcastEnabled || sourceSession.hiddenFromTabs) return [];
|
||||
if (sourceSession.hiddenFromTabs) return [];
|
||||
if (!globalBroadcastEnabled && !includeGlobalPeersWithoutToolbar) return [];
|
||||
|
||||
return sessions
|
||||
.filter((session) => (
|
||||
|
||||
@@ -57,6 +57,16 @@ test('invalid ranges, queries and changed notes fail explicitly', () => {
|
||||
for (const params of [
|
||||
{ offset: -1 }, { offset: 0.5 }, { offset: 9 }, { offset: 2 }, { offset: '1' },
|
||||
{ maxChars: 0 }, { maxChars: NaN }, { maxChars: Infinity },
|
||||
{ query: '' }, { query: 'a'.repeat(201) }, { query: 1 }, { expectedUpdatedAt: 1 },
|
||||
{ query: 'a'.repeat(201) }, { query: 1 }, { expectedUpdatedAt: 1 },
|
||||
]) assert.equal(readVaultNote(source, params).ok, false, JSON.stringify(params));
|
||||
});
|
||||
|
||||
test('empty query is a range read, same as omitting query', () => {
|
||||
const source = { ...note, content: 'hello note' };
|
||||
const omitted = read(source, { offset: 1, maxChars: 4 });
|
||||
const empty = read(source, { query: '', offset: 1, maxChars: 4 });
|
||||
assert.equal(empty.note?.content, omitted.note?.content);
|
||||
assert.equal(empty.offset, omitted.offset);
|
||||
assert.equal(empty.nextOffset, omitted.nextOffset);
|
||||
assert.equal('matchOffset' in empty, false);
|
||||
});
|
||||
@@ -7,7 +7,8 @@ export const MAX_NOTE_READ_CHARS = 6000;
|
||||
export function readVaultNote(note: VaultNote, params: Record<string, unknown>) {
|
||||
const offset = params.offset ?? 0;
|
||||
const maxChars = params.maxChars ?? MAX_NOTE_READ_CHARS;
|
||||
const query = params.query;
|
||||
// Models often send query: "" with a range read. Empty means omit, not an invalid search.
|
||||
const query = params.query === '' ? undefined : params.query;
|
||||
if (typeof offset !== 'number' || !Number.isSafeInteger(offset) || offset < 0 || offset > note.content.length) {
|
||||
return { ok: false, error: 'offset must be an integer within the note content.' };
|
||||
}
|
||||
|
||||
@@ -1,17 +1,30 @@
|
||||
"use strict";
|
||||
|
||||
// An abandoned OPEN cannot be cancelled in SSH, but its physical connection
|
||||
// may own unrelated terminals. Block only additional opens until its callback
|
||||
// or transport closure settles; retain no unbounded retry queue.
|
||||
// may own unrelated terminals. Hold further opens until the late callback,
|
||||
// transport closure, or a short abandon wait. If the callback never arrives,
|
||||
// force-clear the gate and admit a new open; the late channel is still closed
|
||||
// when its callback finally fires. Do not queue unbounded SSH channel requests
|
||||
// during the wait.
|
||||
const abandonedOpens = new WeakMap();
|
||||
|
||||
function wakeAbandonedWaiters(state) {
|
||||
const waiters = state?.waiters;
|
||||
if (!waiters || waiters.size === 0) return;
|
||||
state.waiters = new Set();
|
||||
for (const wake of waiters) {
|
||||
try { wake(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
function abandonOpen(client, token) {
|
||||
let state = abandonedOpens.get(client);
|
||||
if (!state) {
|
||||
state = { tokens: new Set(), onClose: null };
|
||||
state = { tokens: new Set(), onClose: null, waiters: new Set() };
|
||||
state.onClose = () => {
|
||||
if (abandonedOpens.get(client) === state) abandonedOpens.delete(client);
|
||||
state.tokens.clear();
|
||||
wakeAbandonedWaiters(state);
|
||||
};
|
||||
abandonedOpens.set(client, state);
|
||||
client.once?.("close", state.onClose);
|
||||
@@ -24,9 +37,29 @@ function settleAbandonedOpen(client, token) {
|
||||
if (!state || !state.tokens.delete(token) || state.tokens.size > 0) return;
|
||||
abandonedOpens.delete(client);
|
||||
client.removeListener?.("close", state.onClose);
|
||||
wakeAbandonedWaiters(state);
|
||||
}
|
||||
|
||||
function forceClearAbandonedOpens(client) {
|
||||
const state = abandonedOpens.get(client);
|
||||
if (!state) return;
|
||||
abandonedOpens.delete(client);
|
||||
state.tokens.clear();
|
||||
try { client.removeListener?.("close", state.onClose); } catch { /* ignore */ }
|
||||
wakeAbandonedWaiters(state);
|
||||
}
|
||||
|
||||
const DEFAULT_SFTP_CHANNEL_OPEN_TIMEOUT_MS = 10_000;
|
||||
const DEFAULT_SFTP_CHANNEL_ABANDON_WAIT_MS = 2_000;
|
||||
|
||||
function resolveAbandonWaitMs(value) {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return DEFAULT_SFTP_CHANNEL_ABANDON_WAIT_MS;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return DEFAULT_SFTP_CHANNEL_ABANDON_WAIT_MS;
|
||||
return Math.max(0, parsed);
|
||||
}
|
||||
|
||||
function closeSftpChannel(channel) {
|
||||
if (!channel) return;
|
||||
@@ -43,17 +76,66 @@ function createSftpOpenAbortError(signal) {
|
||||
return error;
|
||||
}
|
||||
|
||||
function openBoundedSftpChannel(sshClient, options = {}) {
|
||||
if (!sshClient || typeof sshClient.sftp !== "function") {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
function createPendingOpenError() {
|
||||
const error = new Error("A previous SFTP channel open is still pending on this connection");
|
||||
error.code = "SFTP_CHANNEL_OPEN_PENDING";
|
||||
return error;
|
||||
}
|
||||
|
||||
function waitForAbandonedOpenAdmission(sshClient, options) {
|
||||
const state = abandonedOpens.get(sshClient);
|
||||
if (!state) return Promise.resolve();
|
||||
const abandonWaitMs = resolveAbandonWaitMs(options.abandonWaitMs);
|
||||
// A zero wait keeps the historical immediate rejection for callers that must
|
||||
// not block, and still lets upload fallback treat PENDING as a poisoned open.
|
||||
if (abandonWaitMs === 0) return Promise.reject(createPendingOpenError());
|
||||
|
||||
const signal = options.signal || null;
|
||||
if (signal?.aborted) return Promise.reject(createSftpOpenAbortError(signal));
|
||||
if (abandonedOpens.has(sshClient)) {
|
||||
const error = new Error("A previous SFTP channel open is still pending on this connection");
|
||||
error.code = "SFTP_CHANNEL_OPEN_PENDING";
|
||||
return Promise.reject(error);
|
||||
}
|
||||
const setTimeoutFn = options.setTimeoutFn || setTimeout;
|
||||
const clearTimeoutFn = options.clearTimeoutFn || clearTimeout;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let timer = null;
|
||||
const cleanup = () => {
|
||||
state.waiters?.delete(onGateCleared);
|
||||
if (timer) clearTimeoutFn(timer);
|
||||
timer = null;
|
||||
signal?.removeEventListener?.("abort", onAbort);
|
||||
};
|
||||
const finish = (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
};
|
||||
const onGateCleared = () => finish();
|
||||
const onAbort = () => finish(createSftpOpenAbortError(signal));
|
||||
|
||||
if (!state.waiters) state.waiters = new Set();
|
||||
state.waiters.add(onGateCleared);
|
||||
if (abandonedOpens.get(sshClient) !== state) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
signal?.addEventListener?.("abort", onAbort, { once: true });
|
||||
if (signal?.aborted) {
|
||||
finish(createSftpOpenAbortError(signal));
|
||||
return;
|
||||
}
|
||||
// Referenced on purpose: an unreferenced timer can let Node exit while
|
||||
// this admission promise is still pending.
|
||||
timer = setTimeoutFn(() => {
|
||||
if (abandonedOpens.get(sshClient) === state) forceClearAbandonedOpens(sshClient);
|
||||
finish();
|
||||
}, abandonWaitMs);
|
||||
});
|
||||
}
|
||||
|
||||
function startBoundedSftpOpen(sshClient, options) {
|
||||
const signal = options.signal || null;
|
||||
const setTimeoutFn = options.setTimeoutFn || setTimeout;
|
||||
const clearTimeoutFn = options.clearTimeoutFn || clearTimeout;
|
||||
const timeoutMs = Math.max(
|
||||
@@ -117,7 +199,22 @@ function openBoundedSftpChannel(sshClient, options = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
function openBoundedSftpChannel(sshClient, options = {}) {
|
||||
if (!sshClient || typeof sshClient.sftp !== "function") {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
const signal = options.signal || null;
|
||||
if (signal?.aborted) return Promise.reject(createSftpOpenAbortError(signal));
|
||||
if (!abandonedOpens.has(sshClient)) {
|
||||
return startBoundedSftpOpen(sshClient, options);
|
||||
}
|
||||
return waitForAbandonedOpenAdmission(sshClient, options).then(() => (
|
||||
openBoundedSftpChannel(sshClient, options)
|
||||
));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_SFTP_CHANNEL_ABANDON_WAIT_MS,
|
||||
DEFAULT_SFTP_CHANNEL_OPEN_TIMEOUT_MS,
|
||||
closeSftpChannel,
|
||||
openBoundedSftpChannel,
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter, getEventListeners } = require("node:events");
|
||||
const { openBoundedSftpChannel } = require("./boundedSftpOpen.cjs");
|
||||
const {
|
||||
DEFAULT_SFTP_CHANNEL_ABANDON_WAIT_MS,
|
||||
openBoundedSftpChannel,
|
||||
} = require("./boundedSftpOpen.cjs");
|
||||
|
||||
function trackedTimerApi() {
|
||||
const active = new Set();
|
||||
@@ -94,9 +97,12 @@ for (const reason of ["timeout", "cancel"]) {
|
||||
if (reason === "cancel") controller.abort(new Error("cancelled"));
|
||||
await assert.rejects(opening);
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
await assert.rejects(openBoundedSftpChannel(sshClient, { timeoutMs: 5 }));
|
||||
await assert.rejects(
|
||||
openBoundedSftpChannel(sshClient, { timeoutMs: 5, abandonWaitMs: 0 }),
|
||||
(error) => error.code === "SFTP_CHANNEL_OPEN_PENDING",
|
||||
);
|
||||
}
|
||||
assert.equal(callbacks.length, 1, "retries must not allocate more abandoned SSH channel requests");
|
||||
assert.equal(callbacks.length, 1, "a zero abandon wait must not allocate more SSH channel requests");
|
||||
assert.equal(physicalCloses, 0, "existing terminal and SFTP channels remain connected");
|
||||
|
||||
const late = createChannel();
|
||||
@@ -174,10 +180,170 @@ test("all abandoned parallel opens must settle before admitting a new one", asyn
|
||||
await Promise.all([assert.rejects(first), assert.rejects(second)]);
|
||||
assert.equal(sshClient.listenerCount("close"), 1);
|
||||
callbacks[0](new Error("first failed"));
|
||||
await assert.rejects(openBoundedSftpChannel(sshClient), error => error.code === "SFTP_CHANNEL_OPEN_PENDING");
|
||||
await assert.rejects(
|
||||
openBoundedSftpChannel(sshClient, { abandonWaitMs: 0 }),
|
||||
(error) => error.code === "SFTP_CHANNEL_OPEN_PENDING",
|
||||
);
|
||||
callbacks[1](new Error("second failed"));
|
||||
assert.equal(sshClient.listenerCount("close"), 0);
|
||||
const next = openBoundedSftpChannel(sshClient);
|
||||
callbacks[2](null, createChannel());
|
||||
await next;
|
||||
});
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function settleOpen(promise) {
|
||||
return promise.then((channel) => ({ channel }), (error) => ({ error }));
|
||||
}
|
||||
|
||||
test("abandon wait defaults to 2s", () => {
|
||||
assert.equal(DEFAULT_SFTP_CHANNEL_ABANDON_WAIT_MS, 2_000);
|
||||
});
|
||||
|
||||
test("omitted abandon wait does not admit a new open immediately", async () => {
|
||||
const callbacks = [];
|
||||
const sshClient = new EventEmitter();
|
||||
sshClient.sftp = (callback) => { callbacks.push(callback); };
|
||||
await assert.rejects(
|
||||
openBoundedSftpChannel(sshClient, { timeoutMs: 5 }),
|
||||
(error) => error.code === "SFTP_CHANNEL_OPEN_TIMEOUT",
|
||||
);
|
||||
const outcome = settleOpen(openBoundedSftpChannel(sshClient, { timeoutMs: 50 }));
|
||||
await delay(40);
|
||||
assert.equal(callbacks.length, 1, "the default abandon wait must still be holding");
|
||||
const late = createChannel();
|
||||
callbacks[0](null, late);
|
||||
assert.ok(late.endCalls > 0 || late.closeCalls > 0);
|
||||
await delay(20);
|
||||
assert.equal(callbacks.length, 2, "a late callback must release the next open");
|
||||
const healthy = createChannel();
|
||||
callbacks[1](null, healthy);
|
||||
const settled = await outcome;
|
||||
assert.equal(settled.channel, healthy);
|
||||
assert.equal(settled.error, undefined);
|
||||
});
|
||||
|
||||
test("abandon wait admits a new open when the late callback never arrives", async () => {
|
||||
const callbacks = [];
|
||||
const sshClient = new EventEmitter();
|
||||
sshClient.sftp = (callback) => {
|
||||
callbacks.push(callback);
|
||||
if (callbacks.length > 1) callback(null, createChannel());
|
||||
};
|
||||
await assert.rejects(
|
||||
openBoundedSftpChannel(sshClient, { timeoutMs: 10, abandonWaitMs: 20 }),
|
||||
(error) => error.code === "SFTP_CHANNEL_OPEN_TIMEOUT",
|
||||
);
|
||||
assert.equal(sshClient.listenerCount("close"), 1);
|
||||
|
||||
let pending = 0;
|
||||
let ok = 0;
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
try {
|
||||
const channel = await openBoundedSftpChannel(sshClient, {
|
||||
timeoutMs: 50,
|
||||
abandonWaitMs: 30,
|
||||
});
|
||||
assert.ok(channel);
|
||||
ok += 1;
|
||||
} catch (error) {
|
||||
if (error?.code === "SFTP_CHANNEL_OPEN_PENDING") pending += 1;
|
||||
else throw error;
|
||||
}
|
||||
}
|
||||
assert.equal(ok, 5);
|
||||
assert.equal(pending, 0);
|
||||
assert.equal(sshClient.listenerCount("close"), 0, "force-clear must drop the close listener");
|
||||
|
||||
const late = createChannel();
|
||||
callbacks[0](null, late);
|
||||
assert.ok(late.endCalls > 0 || late.closeCalls > 0, "a late channel is still closed");
|
||||
assert.equal(sshClient.listenerCount("close"), 0);
|
||||
});
|
||||
|
||||
test("partially settled abandoned opens keep the gate until the rest arrive or the wait expires", async () => {
|
||||
const callbacks = [];
|
||||
const sshClient = new EventEmitter();
|
||||
sshClient.sftp = (callback) => { callbacks.push(callback); };
|
||||
const first = openBoundedSftpChannel(sshClient, { timeoutMs: 5, abandonWaitMs: 20 });
|
||||
const second = openBoundedSftpChannel(sshClient, { timeoutMs: 5, abandonWaitMs: 20 });
|
||||
await Promise.all([assert.rejects(first), assert.rejects(second)]);
|
||||
|
||||
const outcome = settleOpen(openBoundedSftpChannel(sshClient, {
|
||||
timeoutMs: 50,
|
||||
abandonWaitMs: 250,
|
||||
}));
|
||||
await delay(30);
|
||||
callbacks[0](new Error("first failed"));
|
||||
await delay(30);
|
||||
assert.equal(callbacks.length, 2, "one remaining abandoned open must keep the gate");
|
||||
callbacks[1](new Error("second failed"));
|
||||
await delay(20);
|
||||
assert.equal(callbacks.length, 3);
|
||||
const healthy = createChannel();
|
||||
callbacks[2](null, healthy);
|
||||
const settled = await outcome;
|
||||
assert.equal(settled.channel, healthy);
|
||||
});
|
||||
|
||||
test("abandon wait force-clears every outstanding token", async () => {
|
||||
const callbacks = [];
|
||||
let admitted = null;
|
||||
const sshClient = new EventEmitter();
|
||||
sshClient.sftp = (callback) => {
|
||||
callbacks.push(callback);
|
||||
if (callbacks.length > 2) {
|
||||
admitted = createChannel();
|
||||
callback(null, admitted);
|
||||
}
|
||||
};
|
||||
await Promise.all([
|
||||
assert.rejects(openBoundedSftpChannel(sshClient, { timeoutMs: 5 })),
|
||||
assert.rejects(openBoundedSftpChannel(sshClient, { timeoutMs: 5 })),
|
||||
]);
|
||||
const outcome = settleOpen(openBoundedSftpChannel(sshClient, {
|
||||
timeoutMs: 50,
|
||||
abandonWaitMs: 40,
|
||||
}));
|
||||
await delay(15);
|
||||
assert.equal(callbacks.length, 2);
|
||||
const settled = await outcome;
|
||||
assert.equal(settled.error, undefined, settled.error?.message);
|
||||
assert.equal(settled.channel, admitted);
|
||||
assert.equal(admitted.endCalls, 0);
|
||||
assert.equal(admitted.closeCalls, 0);
|
||||
assert.equal(callbacks.length, 3);
|
||||
for (const callback of callbacks.slice(0, 2)) {
|
||||
const late = createChannel();
|
||||
callback(null, late);
|
||||
assert.ok(late.endCalls > 0 || late.closeCalls > 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("cancel during the abandon wait does not admit another open or clear the gate", async () => {
|
||||
const callbacks = [];
|
||||
const sshClient = new EventEmitter();
|
||||
sshClient.sftp = (callback) => { callbacks.push(callback); };
|
||||
const controller = new AbortController();
|
||||
await assert.rejects(openBoundedSftpChannel(sshClient, { timeoutMs: 5 }));
|
||||
const outcome = settleOpen(openBoundedSftpChannel(sshClient, {
|
||||
timeoutMs: 50,
|
||||
abandonWaitMs: 400,
|
||||
signal: controller.signal,
|
||||
}));
|
||||
await delay(20);
|
||||
assert.equal(callbacks.length, 1);
|
||||
controller.abort(new Error("cancelled"));
|
||||
const settled = await outcome;
|
||||
assert.match(settled.error?.message || "", /cancelled/);
|
||||
assert.equal(callbacks.length, 1);
|
||||
assert.equal(getEventListeners(controller.signal, "abort").length, 0);
|
||||
await assert.rejects(
|
||||
openBoundedSftpChannel(sshClient, { abandonWaitMs: 0 }),
|
||||
(error) => error.code === "SFTP_CHANNEL_OPEN_PENDING",
|
||||
);
|
||||
assert.equal(callbacks.length, 1);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
"use strict";
|
||||
|
||||
// Read whole hardware-backed block devices only. Partitions live below these
|
||||
// entries; dm/md/loop/zram layers have no device link and would double-count I/O.
|
||||
// Linux block-stat sectors are always 512 bytes, regardless of device sector size.
|
||||
// No iostat dependency, extra exec, sampling sleep, or privileged access is needed.
|
||||
const DISK_IO_ASSIGNMENT = String.raw`diskio=$(if [ -r /proc/uptime ]; then awk '{printf "%s;",$1}' /proc/uptime; for d in /sys/block/*; do [ -e "$d/device" ] && [ -r "$d/stat" ] || continue; awk -v path="$d" 'NF>=7 {n=split(path,a,"/");printf "%s:%s:%s,",a[n],$3,$7}' "$d/stat"; done; fi)`;
|
||||
|
||||
function parseDiskIo(text) {
|
||||
if (typeof text !== "string") return null;
|
||||
const [time, counters, ...extra] = text.trim().split(";");
|
||||
const timestamp = Number(time);
|
||||
if (!time || extra.length || !Number.isFinite(timestamp) || timestamp < 0 || !counters) return null;
|
||||
const devices = new Map();
|
||||
for (const entry of counters.replace(/,$/, "").split(",")) {
|
||||
const fields = entry.split(":");
|
||||
const [name, read, write] = fields;
|
||||
if (fields.length !== 3 || !/^[a-zA-Z0-9_.!-]+$/.test(name) || devices.has(name) || !/^\d+$/.test(read) || !/^\d+$/.test(write)) return null;
|
||||
const readSectors = Number(read);
|
||||
const writeSectors = Number(write);
|
||||
if (!Number.isSafeInteger(readSectors) || !Number.isSafeInteger(writeSectors)) return null;
|
||||
devices.set(name, { readSectors, writeSectors });
|
||||
}
|
||||
return { timestamp, devices };
|
||||
}
|
||||
|
||||
function diskIoRates(current, previous) {
|
||||
const unavailable = { diskReadSpeed: null, diskWriteSpeed: null };
|
||||
if (!current || !previous || current.devices.size !== previous.devices.size) return unavailable;
|
||||
const seconds = current.timestamp - previous.timestamp;
|
||||
if (!(seconds > 0)) return unavailable;
|
||||
let read = 0;
|
||||
let write = 0;
|
||||
for (const [name, counters] of current.devices) {
|
||||
const before = previous.devices.get(name);
|
||||
if (!before || counters.readSectors < before.readSectors || counters.writeSectors < before.writeSectors) return unavailable;
|
||||
read += (counters.readSectors - before.readSectors) * 512;
|
||||
write += (counters.writeSectors - before.writeSectors) * 512;
|
||||
}
|
||||
return { diskReadSpeed: read / seconds, diskWriteSpeed: write / seconds };
|
||||
}
|
||||
|
||||
module.exports = { DISK_IO_ASSIGNMENT, parseDiskIo, diskIoRates };
|
||||
@@ -0,0 +1,51 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { mkdtempSync, mkdirSync, writeFileSync, rmSync } = require("node:fs");
|
||||
const { tmpdir } = require("node:os");
|
||||
const { join } = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const { DISK_IO_ASSIGNMENT, parseDiskIo, diskIoRates } = require("./monitorDiskIo.cjs");
|
||||
const unknown = { diskReadSpeed: null, diskWriteSpeed: null };
|
||||
|
||||
test("disk rates use remote fractional sample time and fixed 512-byte sectors", () => {
|
||||
const before = parseDiskIo("100.25;sda:100:200,nvme0n1:50:100,");
|
||||
const after = parseDiskIo("101.75;nvme0n1:80:140,sda:130:220,");
|
||||
assert.deepEqual(diskIoRates(after, before), { diskReadSpeed: 20480, diskWriteSpeed: 20480 });
|
||||
assert.deepEqual(diskIoRates(parseDiskIo("102.75;nvme0n1:80:140,sda:130:220,"), after), { diskReadSpeed: 0, diskWriteSpeed: 0 });
|
||||
});
|
||||
|
||||
test("first sample, unavailable counters, reset, and topology changes never look idle", () => {
|
||||
const before = parseDiskIo("100;sda:100:200,");
|
||||
assert.deepEqual(diskIoRates(before, null), unknown);
|
||||
assert.deepEqual(diskIoRates(null, before), unknown);
|
||||
for (const text of ["100;sda:110:210", "99;sda:110:210", "101;sda:99:210", "101;sda:110:199", "101;sdb:110:210", "101;sda:110:210,sdb:0:0"]) {
|
||||
assert.deepEqual(diskIoRates(parseDiskIo(text), before), unknown, text);
|
||||
}
|
||||
for (const text of [undefined, "", "100;", "NaN;sda:1:2", "100;sda:-1:2", "100;sda:1:NaN", "100;sda:1:2,sda:1:2", "100;sda:9007199254740992:1", "100;sda:1:2:3", "100;sda:1:2;extra"]) {
|
||||
assert.equal(parseDiskIo(text), null, String(text));
|
||||
}
|
||||
});
|
||||
|
||||
test("Linux collection includes whole hardware devices, excludes partitions and stacked devices", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "disk-io-"));
|
||||
try {
|
||||
const block = join(dir, "block");
|
||||
mkdirSync(block);
|
||||
writeFileSync(join(dir, "uptime"), "123.45 100.00\n");
|
||||
for (const name of ["sda", "nvme0n1", "dm-0", "md0", "loop0", "zram0"]) {
|
||||
mkdirSync(join(block, name));
|
||||
writeFileSync(join(block, name, "stat"), "1 2 100 4 5 6 200 8 9 10 11\n");
|
||||
if (["sda", "nvme0n1"].includes(name)) mkdirSync(join(block, name, "device"));
|
||||
}
|
||||
mkdirSync(join(block, "sda", "sda1"));
|
||||
writeFileSync(join(block, "sda", "sda1", "stat"), "1 2 100 4 5 6 200\n");
|
||||
const command = DISK_IO_ASSIGNMENT.replaceAll("/proc/uptime", join(dir, "uptime")).replaceAll("/sys/block", block);
|
||||
const output = execFileSync("sh", ["-c", `${command}; printf '%s' "$diskio"`], { encoding: "utf8" });
|
||||
const parsed = parseDiskIo(output);
|
||||
assert.equal(parsed.timestamp, 123.45);
|
||||
assert.deepEqual([...parsed.devices.keys()].sort(), ["nvme0n1", "sda"]);
|
||||
assert.deepEqual(parsed.devices.get("sda"), { readSectors: 100, writeSectors: 200 });
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
/* eslint-disable no-undef */
|
||||
const { DISK_IO_ASSIGNMENT, parseDiskIo, diskIoRates } = require("./monitorDiskIo.cjs");
|
||||
const { sampleSharedStats } = require("./sharedStats.cjs");
|
||||
const { GPU_ASSIGNMENT, parseGpuStats, withCachedMonitorGpu, cacheFullGpuStats, startMonitorGpuProbe } = require("./monitorGpuStats.cjs");
|
||||
const { executeBoundedSshCommand } = require("../boundedSshExec.cjs");
|
||||
@@ -1327,8 +1328,9 @@ function createSessionOpsApi(ctx) {
|
||||
// polling. Hosts without timeout also omit this optional metric.
|
||||
// macOS is skipped: powermetrics needs root, which stats must not.
|
||||
GPU_ASSIGNMENT,
|
||||
DISK_IO_ASSIGNMENT,
|
||||
// Output all stats (using CPURAW and PERCORERAW instead of CPU and PERCORE)
|
||||
`echo "CPURAW:$cpuraw|CORES:$cores|PERCORERAW:$percoreraw|MEMINFO:$meminfo|PROCS:$procs|NET:$net|GPU:$gpustat|HOST:$hostname_value|OS:$osname|KERNEL:$kernel|UPTIME:$uptime|LOAD:$loadavg"`
|
||||
`echo "CPURAW:$cpuraw|CORES:$cores|PERCORERAW:$percoreraw|MEMINFO:$meminfo|PROCS:$procs|NET:$net|GPU:$gpustat|DISKIO:$diskio|HOST:$hostname_value|OS:$osname|KERNEL:$kernel|UPTIME:$uptime|LOAD:$loadavg"`
|
||||
].filter(command => !session._monitorStats || (!command.startsWith('procs=') && !command.startsWith('gpustat='))).join('; ');
|
||||
|
||||
// Client-side run bound shared by the stats and disk probes; the remote
|
||||
@@ -1457,6 +1459,7 @@ function createSessionOpsApi(ctx) {
|
||||
let swapUsed = null;
|
||||
let topProcesses = []; // Array of { pid, memPercent, command }
|
||||
let disks = []; // Array of { mountPoint, used, total, percent }
|
||||
let diskIo = null;
|
||||
let networkInterfaces = []; // Array of { name, rxBytes, txBytes }
|
||||
let hostname = "";
|
||||
let osName = "";
|
||||
@@ -1473,6 +1476,8 @@ function createSessionOpsApi(ctx) {
|
||||
// macOS: command reports normalized CPU% directly (no delta needed)
|
||||
const val = parseFloat(part.substring(4).trim());
|
||||
if (!isNaN(val)) cpuDirect = Math.min(100, Math.max(0, Math.round(val)));
|
||||
} else if (part.startsWith('DISKIO:')) {
|
||||
diskIo = parseDiskIo(part.substring(7));
|
||||
} else if (part.startsWith('CPURAW:')) {
|
||||
const rawParts = part.substring(7).trim().split(/\s+/);
|
||||
if (rawParts.length >= 2) {
|
||||
@@ -1619,6 +1624,9 @@ function createSessionOpsApi(ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
const diskRates = diskIoRates(diskIo, session.prevDiskIo);
|
||||
session.prevDiskIo = diskIo;
|
||||
|
||||
// Calculate network speed based on previous reading
|
||||
const now = Date.now();
|
||||
const prevNet = session.prevNetStats || { interfaces: [], timestamp: 0 };
|
||||
@@ -1761,6 +1769,7 @@ function createSessionOpsApi(ctx) {
|
||||
diskUsed, // Disk used in GB for root partition (backward compat)
|
||||
diskTotal, // Total disk in GB for root partition (backward compat)
|
||||
disks, // Array of all mounted disks
|
||||
...diskRates, // Aggregate physical-device I/O; null until two valid Linux samples
|
||||
networkReady: prevNet.timestamp > 0 && prevNet.interfaces.length > 0,
|
||||
netRxSpeed, // Total network receive speed (bytes/sec)
|
||||
netTxSpeed, // Total network transmit speed (bytes/sec)
|
||||
|
||||
@@ -1530,3 +1530,33 @@ test("monitor CPU samples return while an optional GPU driver is still blocked",
|
||||
await pending;
|
||||
}
|
||||
});
|
||||
|
||||
test("shared server stats parse Linux disk I/O across samples and leave macOS unavailable", async (t) => {
|
||||
let now = 100000;
|
||||
t.mock.method(Date, "now", () => now);
|
||||
let payload = `${LINUX_STATS}|DISKIO:10.5;sda:100:200,`;
|
||||
const conn = { exec(command, cb) { cb(null, fakeStream(selectServerStatsFixtureOutput(command, payload))); } };
|
||||
const session = { conn, type: "ssh", _monitorStats: true, _monitorIntervalMs: 1000 };
|
||||
const api = makeSessionOps(new Map([["diskio", session]]));
|
||||
const get = () => api.getServerStats({ sender: {} }, { sessionId: "diskio" });
|
||||
let result = await get();
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.stats.diskReadSpeed, null);
|
||||
assert.equal(result.stats.diskWriteSpeed, null);
|
||||
now += 1000;
|
||||
payload = `${LINUX_STATS}|DISKIO:11.5;sda:120:240,`;
|
||||
result = await get();
|
||||
assert.equal(result.stats.diskReadSpeed, 10240);
|
||||
assert.equal(result.stats.diskWriteSpeed, 20480);
|
||||
now += 1000;
|
||||
payload = `${LINUX_STATS}|DISKIO:12.5;sda:120:240,`;
|
||||
result = await get();
|
||||
assert.equal(result.stats.diskReadSpeed, 0);
|
||||
assert.equal(result.stats.diskWriteSpeed, 0);
|
||||
now += 1000;
|
||||
payload = MACOS_STATS;
|
||||
result = await get();
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.stats.diskReadSpeed, null);
|
||||
assert.equal(result.stats.diskWriteSpeed, null);
|
||||
});
|
||||
@@ -83,6 +83,7 @@ async function sampleSharedStats(session, collect, now = Date.now()) {
|
||||
const companion = session.moshStatsConn || session.etStatsConn;
|
||||
if (companion && result.success) {
|
||||
const state = stateFor(companion);
|
||||
state.context.prevDiskIo = session.prevDiskIo;
|
||||
state.context.prevNetStats = session.prevNetStats;
|
||||
state.context.prevCpuStats = session.prevCpuStats;
|
||||
state.history.latest = result;
|
||||
|
||||
@@ -93,3 +93,35 @@ test('a snapshot read before sampling still joins the existing endpoint history'
|
||||
assert.equal(readSharedStats(monitor).points, readSharedStats(terminal).points);
|
||||
assert.equal(readSharedStats(monitor).result.stats.cpu, 42);
|
||||
});
|
||||
|
||||
test('one-second requests never overlap and keep disk baselines on the physical transport', async () => {
|
||||
resetSharedStatsForTests();
|
||||
const conn = {};
|
||||
let release;
|
||||
let calls = 0;
|
||||
const contexts = [];
|
||||
const collect = context => {
|
||||
calls++;
|
||||
contexts.push(context);
|
||||
const previous = context.prevDiskIo;
|
||||
context.prevDiskIo = { timestamp: calls };
|
||||
return new Promise(resolve => { release = () => resolve({ success: true, stats: { diskReadSpeed: previous ? 512 : null, diskWriteSpeed: previous ? 1024 : null } }); });
|
||||
};
|
||||
const session = { conn, _monitorStats: true, _monitorIntervalMs: 1000 };
|
||||
const first = sampleSharedStats(session, collect, 1000);
|
||||
const overlapping = sampleSharedStats(session, collect, 2000);
|
||||
assert.equal(calls, 1);
|
||||
release();
|
||||
const [a, b] = await Promise.all([first, overlapping]);
|
||||
assert.equal(a, b);
|
||||
assert.equal(a.stats.diskReadSpeed, null);
|
||||
const next = sampleSharedStats(session, collect, Date.now() + 1001);
|
||||
assert.equal(calls, 2);
|
||||
release();
|
||||
assert.equal((await next).stats.diskReadSpeed, 512);
|
||||
const reconnect = sampleSharedStats({ ...session, conn: {} }, collect, Date.now() + 2002);
|
||||
assert.equal(calls, 3);
|
||||
assert.notEqual(contexts[0], contexts[2]);
|
||||
release();
|
||||
assert.equal((await reconnect).stats.diskReadSpeed, null);
|
||||
});
|
||||
@@ -1596,7 +1596,10 @@ let sftpClients = null;
|
||||
// Retry is same-process — a relaunch with no fingerprint already restarts at 0.
|
||||
// Entries stay only while that task can still resume from the checkpoint.
|
||||
const localUploadAcknowledgedPrefix = new Map();
|
||||
const UPLOAD_PREFIX_SESSION_LOSS = /SFTP (channel closed|session lost)|ECONNRESET|EPIPE|Not connected|No response from server|Keepalive timeout|connection (lost|closed|reset)/i;
|
||||
// Keep identical to SESSION_LOSS_ERROR in application/state/sftp/retryFromCheckpoint.ts.
|
||||
// Both must accept every code or message isSftpChannelLossError treats as channel
|
||||
// loss, or a same-id Retry drops this digest and restarts at byte 0.
|
||||
const UPLOAD_PREFIX_SESSION_LOSS = /SFTP (channel closed|session lost)|ERR_SFTP_CHANNEL_CLOSED|ECONNRESET|ECONNABORTED|EPIPE|Not connected|No response from server|Keepalive timeout|socket hang up|client-socket|connection (lost|closed|reset)/i;
|
||||
|
||||
function forgetLocalUploadAcknowledgedPrefix(transferId) {
|
||||
if (transferId) localUploadAcknowledgedPrefix.delete(String(transferId));
|
||||
@@ -1641,9 +1644,20 @@ function localUploadAcknowledgedOffset(transferId) {
|
||||
return entry.digestOffset;
|
||||
}
|
||||
|
||||
function localUploadPrefixFailureKeepsCheckpoint(transferId, message) {
|
||||
return localUploadAcknowledgedOffset(transferId) > 0
|
||||
&& UPLOAD_PREFIX_SESSION_LOSS.test(String(message || ""));
|
||||
function isTransferChannelLossError(error) {
|
||||
if (!error || typeof error !== "object") return false;
|
||||
if (isSftpChannelLossError(error)) return true;
|
||||
const cause = error.cause;
|
||||
return Boolean(cause && typeof cause === "object" && isSftpChannelLossError(cause));
|
||||
}
|
||||
|
||||
function localUploadPrefixFailureKeepsCheckpoint(transferId, error) {
|
||||
if (!(localUploadAcknowledgedOffset(transferId) > 0)) return false;
|
||||
// Prefer the bridge classifier (code, level, cause). The shared regex still
|
||||
// covers string-only failures such as "Not connected" and "SFTP channel closed".
|
||||
if (isTransferChannelLossError(error)) return true;
|
||||
const message = typeof error === "string" ? error : (error?.message || String(error || ""));
|
||||
return UPLOAD_PREFIX_SESSION_LOSS.test(message);
|
||||
}
|
||||
|
||||
async function localUploadAcknowledgedPrefixMatches(transferId, sourcePath, checkpoint, sourceSize, options) {
|
||||
@@ -2051,6 +2065,23 @@ function isStaleOrClosedSharedChannelError(error, sftp) {
|
||||
return /channel closed/i.test(String(error.message || ""));
|
||||
}
|
||||
|
||||
// Test seam: shorten channel-open timeout / abandon wait. Production leaves this null.
|
||||
let isolatedSftpChannelOpenOptionsForTests = null;
|
||||
|
||||
function isolatedChannelOpenOptions(signal) {
|
||||
const options = { signal };
|
||||
const overrides = isolatedSftpChannelOpenOptionsForTests;
|
||||
if (!overrides || typeof overrides !== "object") return options;
|
||||
if (overrides.timeoutMs !== undefined) options.timeoutMs = overrides.timeoutMs;
|
||||
if (overrides.abandonWaitMs !== undefined) options.abandonWaitMs = overrides.abandonWaitMs;
|
||||
return options;
|
||||
}
|
||||
|
||||
function isPoisonedSftpChannelOpenError(error) {
|
||||
const code = error?.code;
|
||||
return code === "SFTP_CHANNEL_OPEN_TIMEOUT" || code === "SFTP_CHANNEL_OPEN_PENDING";
|
||||
}
|
||||
|
||||
async function openIsolatedSftpChannel(client, signal = null) {
|
||||
// The dedicated one-channel login already holds its only session channel.
|
||||
// Opening another SFTP subsystem drops that login, so stay on the browse channel.
|
||||
@@ -2058,7 +2089,7 @@ async function openIsolatedSftpChannel(client, signal = null) {
|
||||
return null;
|
||||
}
|
||||
const sshClient = client?.client;
|
||||
return openBoundedSftpChannel(sshClient, { signal });
|
||||
return openBoundedSftpChannel(sshClient, isolatedChannelOpenOptions(signal));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2698,6 +2729,7 @@ async function uploadFile(
|
||||
|
||||
// Prefer an isolated SFTP channel so cancellation cannot kill the browse session.
|
||||
let isolated = null;
|
||||
let isolatedOpenError = null;
|
||||
const preferSharedChannel = Number(fileSize) > 0
|
||||
&& Number(fileSize) <= SMALL_UPLOAD_SHARED_MAX_BYTES
|
||||
&& typeof sftp?.open === "function"
|
||||
@@ -2709,6 +2741,7 @@ async function uploadFile(
|
||||
);
|
||||
} catch (err) {
|
||||
rememberPipelineError(err);
|
||||
isolatedOpenError = err;
|
||||
console.warn(
|
||||
"[transferBridge] Failed to open isolated SFTP channel for upload:",
|
||||
err.message || String(err),
|
||||
@@ -2774,7 +2807,12 @@ async function uploadFile(
|
||||
}
|
||||
}
|
||||
|
||||
if (!isolated && !client.__netcattySudoMode && !preferSharedChannel) {
|
||||
// A timed-out or still-pending isolated OPEN has already spent the channel
|
||||
// deadline (or is waiting on an abandoned one). Reopening for fastPut only
|
||||
// pays another timeout or an immediate PENDING on a lossy link. Fall through
|
||||
// to the shared browse channel instead (#3155).
|
||||
const skipIsolatedFastPutReopen = isPoisonedSftpChannelOpenError(isolatedOpenError);
|
||||
if (!isolated && !skipIsolatedFastPutReopen && !client.__netcattySudoMode && !preferSharedChannel) {
|
||||
try {
|
||||
isolated = markDisposableUploadChannel(
|
||||
await openIsolatedSftpChannel(client, transfer?.signal),
|
||||
@@ -6412,7 +6450,7 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
}
|
||||
cleanupTransfer();
|
||||
const cancelled = /cancel/i.test(message);
|
||||
if (cancelled || !localUploadPrefixFailureKeepsCheckpoint(transferId, message)) {
|
||||
if (cancelled || !localUploadPrefixFailureKeepsCheckpoint(transferId, error)) {
|
||||
forgetLocalUploadAcknowledgedPrefix(transferId);
|
||||
}
|
||||
if (cancelled) {
|
||||
@@ -8270,6 +8308,11 @@ module.exports = {
|
||||
_setLocalPrefixHashStreamOverrideForTests: (override) => {
|
||||
localPrefixHashStreamOverrideForTests = override;
|
||||
},
|
||||
_setIsolatedSftpChannelOpenOptionsForTests: (options) => {
|
||||
isolatedSftpChannelOpenOptionsForTests = options && typeof options === "object"
|
||||
? options
|
||||
: null;
|
||||
},
|
||||
_hasLocalUploadAcknowledgedPrefixForTests: (transferId) => (
|
||||
localUploadAcknowledgedPrefix.has(String(transferId))
|
||||
),
|
||||
|
||||
@@ -10,6 +10,7 @@ const { EventEmitter } = require("node:events");
|
||||
const { PassThrough, Readable, Writable } = require("node:stream");
|
||||
|
||||
const transferBridge = require("./transferBridge.cjs");
|
||||
const { openBoundedSftpChannel } = require("./boundedSftpOpen.cjs");
|
||||
// Most download tests here drive the session slot / isolated pool with tiny
|
||||
// fixture files. Keep them on that path; the small-file exemption has its own
|
||||
// test below that restores the production threshold.
|
||||
@@ -14754,3 +14755,247 @@ test("small download does not open a fresh channel for ENOENT or EACCES", async
|
||||
assert.match(denied.error || "", /Permission denied|EACCES/);
|
||||
assert.equal(isolatedOpens, 0);
|
||||
});
|
||||
|
||||
test("channel-open timeout skips the fastPut reopen and uses the shared channel", async (t) => {
|
||||
// Lossy links abandon an isolated OPEN. Reopening for fastPut only waits out
|
||||
// the abandon gate and pays another timeout (or PENDING). The body must move
|
||||
// to the shared browse channel after the first poisoned open.
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-sftp-open-poison-upload-"));
|
||||
t.after(async () => {
|
||||
transferBridge._setIsolatedSftpChannelOpenOptionsForTests(null);
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
transferBridge._setIsolatedSftpChannelOpenOptionsForTests({
|
||||
timeoutMs: 20,
|
||||
abandonWaitMs: 30,
|
||||
});
|
||||
|
||||
const localPath = path.join(tempDir, "fresh.bin");
|
||||
const payload = Buffer.concat([
|
||||
Buffer.from("open-poison"),
|
||||
Buffer.alloc(SMALL_UPLOAD_SHARED_MAX_BYTES),
|
||||
]);
|
||||
await fs.promises.writeFile(localPath, payload);
|
||||
const targetPath = "/tmp/open-poison.bin";
|
||||
const remoteFiles = new Map();
|
||||
const lateChannels = [];
|
||||
let isolatedOpens = 0;
|
||||
let fastPutCalls = 0;
|
||||
let sharedWrites = 0;
|
||||
|
||||
const rememberFile = (sftp) => {
|
||||
sftp.lstat = (remotePath, callback) => {
|
||||
const key = String(remotePath);
|
||||
if (!remoteFiles.has(key)) {
|
||||
const error = new Error("ENOENT");
|
||||
error.code = 2;
|
||||
callback(error);
|
||||
return;
|
||||
}
|
||||
callback(null, {
|
||||
size: remoteFiles.get(key).length,
|
||||
mode: 0o100644,
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => false,
|
||||
});
|
||||
};
|
||||
sftp.open = (remotePath, _flags, callback) => {
|
||||
const key = String(remotePath);
|
||||
if (!remoteFiles.has(key)) remoteFiles.set(key, Buffer.alloc(0));
|
||||
callback(null, Buffer.from(key));
|
||||
};
|
||||
sftp.close = (_handle, callback) => callback(null);
|
||||
};
|
||||
|
||||
const shared = createFastSftp({});
|
||||
rememberFile(shared);
|
||||
shared.write = (handle, buffer, offset, length, position, callback) => {
|
||||
sharedWrites += 1;
|
||||
const key = handle.toString();
|
||||
const next = Buffer.alloc(Math.max(remoteFiles.get(key)?.length || 0, position + length));
|
||||
if (remoteFiles.has(key)) remoteFiles.get(key).copy(next);
|
||||
buffer.copy(next, position, offset, offset + length);
|
||||
remoteFiles.set(key, next);
|
||||
setImmediate(() => callback(null));
|
||||
};
|
||||
shared.fastPut = () => {
|
||||
fastPutCalls += 1;
|
||||
throw new Error("shared fastPut must not run after a poisoned channel open");
|
||||
};
|
||||
|
||||
const client = {
|
||||
sftp: shared,
|
||||
stat: async (remotePath) => {
|
||||
const key = String(remotePath);
|
||||
if (!remoteFiles.has(key)) {
|
||||
const error = new Error("ENOENT");
|
||||
error.code = 2;
|
||||
throw error;
|
||||
}
|
||||
return { size: remoteFiles.get(key).length, isDirectory: false };
|
||||
},
|
||||
delete: async (remotePath) => { remoteFiles.delete(String(remotePath)); },
|
||||
async rename(fromPath, toPath) {
|
||||
remoteFiles.set(String(toPath), remoteFiles.get(String(fromPath)));
|
||||
remoteFiles.delete(String(fromPath));
|
||||
},
|
||||
client: {
|
||||
sftp(callback) {
|
||||
isolatedOpens += 1;
|
||||
const late = createFastSftp({
|
||||
fastPut() {
|
||||
fastPutCalls += 1;
|
||||
throw new Error("fastPut reopen must not run after channel-open timeout");
|
||||
},
|
||||
end() { late.endCalls += 1; },
|
||||
close() { late.closeCalls += 1; },
|
||||
destroy() { late.destroyCalls += 1; },
|
||||
});
|
||||
late.endCalls = 0;
|
||||
late.closeCalls = 0;
|
||||
late.destroyCalls = 0;
|
||||
lateChannels.push({ callback, late });
|
||||
},
|
||||
},
|
||||
};
|
||||
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
||||
|
||||
const result = await transferBridge.startTransfer({ sender: createSender() }, {
|
||||
transferId: "upload-channel-open-timeout-shared",
|
||||
sourcePath: localPath,
|
||||
targetPath,
|
||||
sourceType: "local",
|
||||
targetType: "sftp",
|
||||
targetSftpId: "target",
|
||||
totalBytes: payload.length,
|
||||
resumable: false,
|
||||
skipAdmission: true,
|
||||
});
|
||||
|
||||
assert.equal(result.error, undefined, result.error);
|
||||
assert.equal(isolatedOpens, 1, "fastPut must not reopen an isolated channel after open timeout");
|
||||
assert.equal(fastPutCalls, 0);
|
||||
assert.ok(sharedWrites >= 1);
|
||||
assert.deepEqual(remoteFiles.get(targetPath), payload);
|
||||
|
||||
const abandoned = lateChannels[0];
|
||||
abandoned.callback(null, abandoned.late);
|
||||
assert.ok(
|
||||
abandoned.late.endCalls > 0 || abandoned.late.closeCalls > 0 || abandoned.late.destroyCalls > 0,
|
||||
"the abandoned channel is closed when its callback finally arrives",
|
||||
);
|
||||
});
|
||||
|
||||
test("pending channel-open skips the fastPut reopen and uses the shared channel", async (t) => {
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-sftp-open-pending-upload-"));
|
||||
t.after(async () => {
|
||||
transferBridge._setIsolatedSftpChannelOpenOptionsForTests(null);
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const localPath = path.join(tempDir, "fresh.bin");
|
||||
const payload = Buffer.concat([
|
||||
Buffer.from("open-pending"),
|
||||
Buffer.alloc(SMALL_UPLOAD_SHARED_MAX_BYTES),
|
||||
]);
|
||||
await fs.promises.writeFile(localPath, payload);
|
||||
const targetPath = "/tmp/open-pending.bin";
|
||||
const remoteFiles = new Map();
|
||||
let isolatedOpens = 0;
|
||||
let sharedWrites = 0;
|
||||
const warnings = [];
|
||||
const originalWarn = console.warn;
|
||||
console.warn = (...args) => {
|
||||
warnings.push(args.map((part) => String(part)).join(" "));
|
||||
originalWarn.apply(console, args);
|
||||
};
|
||||
t.after(() => { console.warn = originalWarn; });
|
||||
|
||||
const rememberFile = (sftp) => {
|
||||
sftp.lstat = (remotePath, callback) => {
|
||||
const key = String(remotePath);
|
||||
if (!remoteFiles.has(key)) {
|
||||
const error = new Error("ENOENT");
|
||||
error.code = 2;
|
||||
callback(error);
|
||||
return;
|
||||
}
|
||||
callback(null, {
|
||||
size: remoteFiles.get(key).length,
|
||||
mode: 0o100644,
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => false,
|
||||
});
|
||||
};
|
||||
sftp.open = (remotePath, _flags, callback) => {
|
||||
const key = String(remotePath);
|
||||
if (!remoteFiles.has(key)) remoteFiles.set(key, Buffer.alloc(0));
|
||||
callback(null, Buffer.from(key));
|
||||
};
|
||||
sftp.close = (_handle, callback) => callback(null);
|
||||
};
|
||||
const shared = createFastSftp({});
|
||||
rememberFile(shared);
|
||||
shared.write = (handle, buffer, offset, length, position, callback) => {
|
||||
sharedWrites += 1;
|
||||
const key = handle.toString();
|
||||
const next = Buffer.alloc(Math.max(remoteFiles.get(key)?.length || 0, position + length));
|
||||
if (remoteFiles.has(key)) remoteFiles.get(key).copy(next);
|
||||
buffer.copy(next, position, offset, offset + length);
|
||||
remoteFiles.set(key, next);
|
||||
setImmediate(() => callback(null));
|
||||
};
|
||||
|
||||
const sshClient = new EventEmitter();
|
||||
sshClient.sftp = (callback) => {
|
||||
isolatedOpens += 1;
|
||||
sshClient.pending = callback;
|
||||
};
|
||||
const client = {
|
||||
sftp: shared,
|
||||
stat: async (remotePath) => {
|
||||
const key = String(remotePath);
|
||||
if (!remoteFiles.has(key)) {
|
||||
const error = new Error("ENOENT");
|
||||
error.code = 2;
|
||||
throw error;
|
||||
}
|
||||
return { size: remoteFiles.get(key).length, isDirectory: false };
|
||||
},
|
||||
delete: async (remotePath) => { remoteFiles.delete(String(remotePath)); },
|
||||
async rename(fromPath, toPath) {
|
||||
remoteFiles.set(String(toPath), remoteFiles.get(String(fromPath)));
|
||||
remoteFiles.delete(String(fromPath));
|
||||
},
|
||||
client: sshClient,
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
openBoundedSftpChannel(sshClient, { timeoutMs: 15 }),
|
||||
(error) => error.code === "SFTP_CHANNEL_OPEN_TIMEOUT",
|
||||
);
|
||||
assert.equal(isolatedOpens, 1);
|
||||
transferBridge._setIsolatedSftpChannelOpenOptionsForTests({ abandonWaitMs: 0 });
|
||||
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
||||
|
||||
const result = await transferBridge.startTransfer({ sender: createSender() }, {
|
||||
transferId: "upload-channel-open-pending-shared",
|
||||
sourcePath: localPath,
|
||||
targetPath,
|
||||
sourceType: "local",
|
||||
targetType: "sftp",
|
||||
targetSftpId: "target",
|
||||
totalBytes: payload.length,
|
||||
resumable: false,
|
||||
skipAdmission: true,
|
||||
});
|
||||
|
||||
assert.equal(result.error, undefined, result.error);
|
||||
assert.equal(isolatedOpens, 1, "PENDING must not allocate another isolated OPEN");
|
||||
assert.equal(
|
||||
warnings.some((line) => line.includes("Failed to reopen isolated SFTP channel for fastPut")),
|
||||
false,
|
||||
);
|
||||
assert.ok(sharedWrites >= 1);
|
||||
assert.deepEqual(remoteFiles.get(targetPath), payload);
|
||||
});
|
||||
@@ -0,0 +1,417 @@
|
||||
"use strict";
|
||||
|
||||
const { test } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const net = require("node:net");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { Client } = require("@modelcontextprotocol/sdk/client/index.js");
|
||||
const { StdioClientTransport } = require("@modelcontextprotocol/sdk/client/stdio.js");
|
||||
|
||||
const EXTERNAL_SERVER = path.join(__dirname, "netcatty-external-mcp-server.cjs");
|
||||
const MANAGED_SERVER = path.join(__dirname, "netcatty-mcp-server.cjs");
|
||||
|
||||
function errorText(result) {
|
||||
return (result?.content || []).map((part) => part?.text || "").join("\n");
|
||||
}
|
||||
|
||||
function writeDiscovery(filePath, { port, token }) {
|
||||
fs.writeFileSync(filePath, `${JSON.stringify({
|
||||
version: 1,
|
||||
host: "127.0.0.1",
|
||||
port,
|
||||
token,
|
||||
permissionMode: "confirm",
|
||||
chatSessionId: "__external_mcp__",
|
||||
updatedAt: new Date().toISOString(),
|
||||
})}\n`);
|
||||
}
|
||||
|
||||
function startBridge({ token, authDelayMs = 0, onGetContext = null }) {
|
||||
const calls = [];
|
||||
const sockets = new Set();
|
||||
let connections = 0;
|
||||
const server = net.createServer((socket) => {
|
||||
connections += 1;
|
||||
sockets.add(socket);
|
||||
socket.on("close", () => sockets.delete(socket));
|
||||
let buffer = "";
|
||||
socket.setEncoding("utf8");
|
||||
socket.on("data", (chunk) => {
|
||||
buffer += chunk;
|
||||
let newlineIdx;
|
||||
while ((newlineIdx = buffer.indexOf("\n")) !== -1) {
|
||||
const line = buffer.slice(0, newlineIdx);
|
||||
buffer = buffer.slice(newlineIdx + 1);
|
||||
if (!line.trim()) continue;
|
||||
const msg = JSON.parse(line);
|
||||
calls.push({
|
||||
id: msg.id,
|
||||
method: msg.method,
|
||||
token: msg.params?.token,
|
||||
});
|
||||
const respond = () => {
|
||||
if (msg.method === "auth/verify") {
|
||||
if (msg.params?.token !== token) {
|
||||
socket.write(`${JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: msg.id,
|
||||
error: { code: -32001, message: "Authentication required. Send auth/verify with valid token first." },
|
||||
})}\n`);
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
socket.write(`${JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: { ok: true } })}\n`);
|
||||
return;
|
||||
}
|
||||
if (msg.method === "netcatty/getContext" && onGetContext) {
|
||||
const action = onGetContext(msg, socket);
|
||||
if (action === "hold-and-close") {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
}
|
||||
socket.write(`${JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: msg.id,
|
||||
result: { ok: true, marker: token },
|
||||
})}\n`);
|
||||
};
|
||||
if (msg.method === "auth/verify" && authDelayMs > 0) {
|
||||
setTimeout(respond, authDelayMs);
|
||||
} else {
|
||||
respond();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const ready = new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => resolve(server.address().port));
|
||||
});
|
||||
|
||||
return {
|
||||
calls,
|
||||
ready,
|
||||
get connections() {
|
||||
return connections;
|
||||
},
|
||||
destroySockets() {
|
||||
for (const socket of sockets) socket.destroy();
|
||||
},
|
||||
close() {
|
||||
for (const socket of sockets) socket.destroy();
|
||||
if (!server.listening) return Promise.resolve();
|
||||
return new Promise((resolve) => server.close(() => resolve()));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function connectClient(entry, env) {
|
||||
const client = new Client({ name: "external-mcp-reconnect-test", version: "1" });
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entry],
|
||||
env,
|
||||
stderr: "pipe",
|
||||
});
|
||||
const stderr = [];
|
||||
transport.stderr.on("data", (chunk) => stderr.push(String(chunk)));
|
||||
try {
|
||||
await client.connect(transport);
|
||||
} catch (error) {
|
||||
error.message = `${error.message}\nstderr:\n${stderr.join("")}`;
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
client,
|
||||
stderr,
|
||||
pid: transport.pid,
|
||||
async close() {
|
||||
await client.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function childEnv(overrides) {
|
||||
const env = { ...process.env };
|
||||
delete env.NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE;
|
||||
delete env.NETCATTY_MCP_PORT;
|
||||
delete env.NETCATTY_MCP_TOKEN;
|
||||
delete env.NETCATTY_MCP_PERMISSION_MODE;
|
||||
delete env.NETCATTY_MCP_CHAT_SESSION_ID;
|
||||
delete env.NETCATTY_MCP_SESSION_IDS;
|
||||
return { ...env, ...overrides };
|
||||
}
|
||||
|
||||
async function callEnvironment(client) {
|
||||
return client.callTool({ name: "get_environment", arguments: {} });
|
||||
}
|
||||
|
||||
async function waitForStoppedChild(pid) {
|
||||
const statusPath = `/proc/${pid}/status`;
|
||||
const deadline = Date.now() + 1000;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
if (/^State:\s+T/m.test(fs.readFileSync(statusPath, "utf8"))) return;
|
||||
} catch {
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
throw new Error(`child ${pid} did not stop`);
|
||||
}
|
||||
|
||||
test("external stdio bridge reconnects with refreshed discovery after the app endpoint changes", async (t) => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mcp-reconnect-"));
|
||||
const discoveryPath = path.join(tmp, "discovery.json");
|
||||
const first = startBridge({ token: "token-first" });
|
||||
const firstPort = await first.ready;
|
||||
writeDiscovery(discoveryPath, { port: firstPort, token: "token-first" });
|
||||
const child = await connectClient(EXTERNAL_SERVER, childEnv({
|
||||
NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE: discoveryPath,
|
||||
}));
|
||||
t.after(async () => {
|
||||
await child.close().catch(() => {});
|
||||
await first.close().catch(() => {});
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const live = await callEnvironment(child.client);
|
||||
assert.equal(live.isError, undefined);
|
||||
assert.match(errorText(live), /token-first/);
|
||||
|
||||
await first.close();
|
||||
const second = startBridge({ token: "token-restarted" });
|
||||
t.after(() => second.close().catch(() => {}));
|
||||
const secondPort = await second.ready;
|
||||
writeDiscovery(discoveryPath, { port: secondPort, token: "token-restarted" });
|
||||
|
||||
const recovered = await callEnvironment(child.client);
|
||||
assert.notEqual(recovered.isError, true, errorText(recovered));
|
||||
assert.match(errorText(recovered), /token-restarted/);
|
||||
assert.deepEqual(
|
||||
second.calls.map((call) => call.method),
|
||||
["auth/verify", "netcatty/getContext"],
|
||||
);
|
||||
assert.equal(second.calls[0].token, "token-restarted");
|
||||
assert.equal(second.connections, 1);
|
||||
});
|
||||
|
||||
test("concurrent external calls share one reconnect and one re-auth", async (t) => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mcp-reconnect-"));
|
||||
const discoveryPath = path.join(tmp, "discovery.json");
|
||||
const first = startBridge({ token: "token-first" });
|
||||
const firstPort = await first.ready;
|
||||
writeDiscovery(discoveryPath, { port: firstPort, token: "token-first" });
|
||||
const child = await connectClient(EXTERNAL_SERVER, childEnv({
|
||||
NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE: discoveryPath,
|
||||
}));
|
||||
let second = null;
|
||||
t.after(async () => {
|
||||
await child.close().catch(() => {});
|
||||
await first.close().catch(() => {});
|
||||
if (second) await second.close().catch(() => {});
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
assert.notEqual((await callEnvironment(child.client)).isError, true);
|
||||
await first.close();
|
||||
// Hold auth open so both tool calls enter recovery before the socket exists.
|
||||
second = startBridge({ token: "token-restarted", authDelayMs: 300 });
|
||||
const secondPort = await second.ready;
|
||||
writeDiscovery(discoveryPath, { port: secondPort, token: "token-restarted" });
|
||||
|
||||
const [left, right] = await Promise.all([
|
||||
callEnvironment(child.client),
|
||||
callEnvironment(child.client),
|
||||
]);
|
||||
assert.notEqual(left.isError, true, errorText(left));
|
||||
assert.notEqual(right.isError, true, errorText(right));
|
||||
assert.match(errorText(left), /token-restarted/);
|
||||
assert.match(errorText(right), /token-restarted/);
|
||||
assert.equal(second.connections, 1);
|
||||
assert.equal(second.calls.filter((call) => call.method === "auth/verify").length, 1);
|
||||
assert.equal(second.calls.filter((call) => call.method === "netcatty/getContext").length, 2);
|
||||
assert.equal(second.calls[0].token, "token-restarted");
|
||||
});
|
||||
|
||||
test("in-flight external calls are rejected on disconnect and are not replayed", async (t) => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mcp-reconnect-"));
|
||||
const discoveryPath = path.join(tmp, "discovery.json");
|
||||
let holdNext = false;
|
||||
const first = startBridge({
|
||||
token: "token-first",
|
||||
onGetContext: () => (holdNext ? "hold-and-close" : null),
|
||||
});
|
||||
const firstPort = await first.ready;
|
||||
writeDiscovery(discoveryPath, { port: firstPort, token: "token-first" });
|
||||
const child = await connectClient(EXTERNAL_SERVER, childEnv({
|
||||
NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE: discoveryPath,
|
||||
}));
|
||||
let second = null;
|
||||
t.after(async () => {
|
||||
await child.close().catch(() => {});
|
||||
await first.close().catch(() => {});
|
||||
if (second) await second.close().catch(() => {});
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
assert.notEqual((await callEnvironment(child.client)).isError, true);
|
||||
holdNext = true;
|
||||
const dropped = await callEnvironment(child.client);
|
||||
assert.equal(dropped.isError, true);
|
||||
assert.match(errorText(dropped), /TCP connection (closed|lost)/);
|
||||
const droppedCall = first.calls.filter((call) => call.method === "netcatty/getContext").at(-1);
|
||||
assert.ok(droppedCall);
|
||||
|
||||
await first.close();
|
||||
second = startBridge({ token: "token-restarted" });
|
||||
const secondPort = await second.ready;
|
||||
writeDiscovery(discoveryPath, { port: secondPort, token: "token-restarted" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
assert.equal(second.connections, 0);
|
||||
|
||||
const recovered = await callEnvironment(child.client);
|
||||
assert.notEqual(recovered.isError, true, errorText(recovered));
|
||||
assert.match(errorText(recovered), /token-restarted/);
|
||||
assert.deepEqual(
|
||||
second.calls.map((call) => call.method),
|
||||
["auth/verify", "netcatty/getContext"],
|
||||
);
|
||||
assert.equal(second.calls.some((call) => call.id === droppedCall.id), false);
|
||||
});
|
||||
|
||||
test("missing or rejected discovery stays recoverable without killing the external process", async (t) => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mcp-reconnect-"));
|
||||
const discoveryPath = path.join(tmp, "discovery.json");
|
||||
const first = startBridge({ token: "token-first" });
|
||||
const firstPort = await first.ready;
|
||||
writeDiscovery(discoveryPath, { port: firstPort, token: "token-first" });
|
||||
const child = await connectClient(EXTERNAL_SERVER, childEnv({
|
||||
NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE: discoveryPath,
|
||||
}));
|
||||
let second = null;
|
||||
t.after(async () => {
|
||||
await child.close().catch(() => {});
|
||||
await first.close().catch(() => {});
|
||||
if (second) await second.close().catch(() => {});
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
assert.notEqual((await callEnvironment(child.client)).isError, true);
|
||||
await first.close();
|
||||
fs.rmSync(discoveryPath, { force: true });
|
||||
|
||||
const missing = await callEnvironment(child.client);
|
||||
assert.equal(missing.isError, true);
|
||||
assert.match(errorText(missing), /temporarily unavailable|unavailable or invalid/i);
|
||||
|
||||
second = startBridge({ token: "token-restarted" });
|
||||
const secondPort = await second.ready;
|
||||
writeDiscovery(discoveryPath, { port: secondPort, token: "wrong-token" });
|
||||
const rejected = await callEnvironment(child.client);
|
||||
assert.equal(rejected.isError, true);
|
||||
assert.match(errorText(rejected), /temporarily unavailable/i);
|
||||
|
||||
writeDiscovery(discoveryPath, { port: secondPort, token: "token-restarted" });
|
||||
const recovered = await callEnvironment(child.client);
|
||||
assert.notEqual(recovered.isError, true, `${errorText(recovered)}\n${child.stderr.join("")}`);
|
||||
assert.match(errorText(recovered), /token-restarted/);
|
||||
});
|
||||
|
||||
test("managed stdio children fail fast without rereading discovery", async (t) => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mcp-reconnect-"));
|
||||
const discoveryPath = path.join(tmp, "discovery.json");
|
||||
const first = startBridge({ token: "managed-token" });
|
||||
const firstPort = await first.ready;
|
||||
const child = await connectClient(MANAGED_SERVER, childEnv({
|
||||
NETCATTY_MCP_PORT: String(firstPort),
|
||||
NETCATTY_MCP_TOKEN: "managed-token",
|
||||
}));
|
||||
let second = null;
|
||||
t.after(async () => {
|
||||
await child.close().catch(() => {});
|
||||
await first.close().catch(() => {});
|
||||
if (second) await second.close().catch(() => {});
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const live = await callEnvironment(child.client);
|
||||
assert.notEqual(live.isError, true, errorText(live));
|
||||
await first.close();
|
||||
|
||||
second = startBridge({ token: "token-restarted" });
|
||||
const secondPort = await second.ready;
|
||||
writeDiscovery(discoveryPath, { port: secondPort, token: "token-restarted" });
|
||||
|
||||
const started = Date.now();
|
||||
const failed = await callEnvironment(child.client);
|
||||
assert.ok(Date.now() - started < 1000, "managed disconnect must not wait on discovery refresh");
|
||||
assert.equal(failed.isError, true);
|
||||
assert.match(errorText(failed), /Not connected to Netcatty/);
|
||||
assert.equal(second.connections, 0);
|
||||
|
||||
const stillAlive = await callEnvironment(child.client);
|
||||
assert.equal(stillAlive.isError, true);
|
||||
assert.match(errorText(stillAlive), /Not connected to Netcatty/);
|
||||
assert.equal(second.connections, 0);
|
||||
});
|
||||
|
||||
test("managed stdio calls that race bridge shutdown stay not-connected", {
|
||||
skip: process.platform === "win32" ? "SIGSTOP is POSIX-only" : false,
|
||||
timeout: 10000,
|
||||
}, async (t) => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mcp-reconnect-"));
|
||||
const discoveryPath = path.join(tmp, "discovery.json");
|
||||
const first = startBridge({ token: "managed-token" });
|
||||
const firstPort = await first.ready;
|
||||
const child = await connectClient(MANAGED_SERVER, childEnv({
|
||||
NETCATTY_MCP_PORT: String(firstPort),
|
||||
NETCATTY_MCP_TOKEN: "managed-token",
|
||||
}));
|
||||
let second = null;
|
||||
const resumeChild = () => {
|
||||
if (!child.pid) return;
|
||||
try { process.kill(child.pid, "SIGCONT"); } catch { /* already gone */ }
|
||||
};
|
||||
t.after(async () => {
|
||||
resumeChild();
|
||||
await child.close().catch(() => {});
|
||||
await first.close().catch(() => {});
|
||||
if (second) await second.close().catch(() => {});
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const live = await callEnvironment(child.client);
|
||||
assert.notEqual(live.isError, true, errorText(live));
|
||||
assert.ok(child.pid);
|
||||
// Stop the child before the bridge socket is destroyed so the next tool
|
||||
// call is queued against a connection the process has not observed yet.
|
||||
// Loaded CI hit this ordering and reported "TCP connection lost".
|
||||
process.kill(child.pid, "SIGSTOP");
|
||||
await waitForStoppedChild(child.pid);
|
||||
await first.close();
|
||||
|
||||
second = startBridge({ token: "token-restarted" });
|
||||
const secondPort = await second.ready;
|
||||
writeDiscovery(discoveryPath, { port: secondPort, token: "token-restarted" });
|
||||
|
||||
const pending = callEnvironment(child.client);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
const started = Date.now();
|
||||
resumeChild();
|
||||
const failed = await pending;
|
||||
assert.ok(Date.now() - started < 1000, "managed disconnect must not wait on discovery refresh");
|
||||
assert.equal(failed.isError, true);
|
||||
assert.match(errorText(failed), /Not connected to Netcatty/);
|
||||
assert.equal(second.connections, 0);
|
||||
|
||||
const stillAlive = await callEnvironment(child.client);
|
||||
assert.equal(stillAlive.isError, true);
|
||||
assert.match(errorText(stillAlive), /Not connected to Netcatty/);
|
||||
assert.equal(second.connections, 0);
|
||||
});
|
||||
@@ -38,17 +38,21 @@ function debugLog(...args) {
|
||||
|
||||
// ── TCP Bridge to Netcatty main process ──
|
||||
|
||||
const NETCATTY_MCP_PORT = parseInt(process.env.NETCATTY_MCP_PORT, 10);
|
||||
if (!NETCATTY_MCP_PORT) {
|
||||
process.stderr.write("[netcatty-mcp] NETCATTY_MCP_PORT not set\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Auth token for TCP bridge authentication
|
||||
const NETCATTY_MCP_TOKEN = process.env.NETCATTY_MCP_TOKEN || "";
|
||||
if (!NETCATTY_MCP_TOKEN) {
|
||||
process.stderr.write("[netcatty-mcp] NETCATTY_MCP_TOKEN not set\n");
|
||||
process.exit(1);
|
||||
// External stdio clients outlive Netcatty. Their port and token stay mutable so
|
||||
// a later call can refresh discovery. Managed children receive a fixed env and
|
||||
// keep the original fail-fast exit.
|
||||
const externalDiscoveryPath = process.env.NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE;
|
||||
let NETCATTY_MCP_PORT = parseInt(process.env.NETCATTY_MCP_PORT, 10);
|
||||
let NETCATTY_MCP_TOKEN = process.env.NETCATTY_MCP_TOKEN || "";
|
||||
if (!externalDiscoveryPath) {
|
||||
if (!NETCATTY_MCP_PORT) {
|
||||
process.stderr.write("[netcatty-mcp] NETCATTY_MCP_PORT not set\n");
|
||||
process.exit(1);
|
||||
}
|
||||
if (!NETCATTY_MCP_TOKEN) {
|
||||
process.stderr.write("[netcatty-mcp] NETCATTY_MCP_TOKEN not set\n");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Scoped session IDs (comma-separated). When set (even if empty), only listed
|
||||
@@ -122,6 +126,17 @@ let pendingRequests = new Map(); // id -> { resolve, reject }
|
||||
let nextRpcId = 1;
|
||||
let tcpBuffer = "";
|
||||
|
||||
function rejectPendingOnSocket(sock, externalMessage) {
|
||||
if (tcpSocket && tcpSocket !== sock) return;
|
||||
const managed = !externalDiscoveryPath;
|
||||
if (managed && tcpSocket === sock) tcpSocket = null;
|
||||
const message = managed ? "Not connected to Netcatty" : externalMessage;
|
||||
for (const { reject: rej } of pendingRequests.values()) {
|
||||
rej(new Error(message));
|
||||
}
|
||||
pendingRequests.clear();
|
||||
}
|
||||
|
||||
function connectTcp() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const sock = net.createConnection({ host: "127.0.0.1", port: NETCATTY_MCP_PORT }, () => {
|
||||
@@ -132,6 +147,7 @@ function connectTcp() {
|
||||
sock.setEncoding("utf-8");
|
||||
const MAX_BUFFER_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
sock.on("data", (chunk) => {
|
||||
if (tcpSocket !== sock) return;
|
||||
tcpBuffer += chunk;
|
||||
if (tcpBuffer.length > MAX_BUFFER_SIZE) {
|
||||
process.stderr.write(`[netcatty-mcp] TCP buffer exceeded ${MAX_BUFFER_SIZE} bytes, clearing buffer\n`);
|
||||
@@ -167,28 +183,24 @@ function connectTcp() {
|
||||
sock.on("error", (err) => {
|
||||
debugLog("TCP socket error", { message: err?.message || String(err) });
|
||||
reject(err);
|
||||
// Reject all pending
|
||||
for (const { reject: rej } of pendingRequests.values()) {
|
||||
rej(new Error("TCP connection lost"));
|
||||
}
|
||||
pendingRequests.clear();
|
||||
// External in-flight calls stay rejected and are not replayed.
|
||||
// Managed children do not reread discovery, so a call that races the
|
||||
// dead socket fails as not-connected rather than "TCP connection lost".
|
||||
rejectPendingOnSocket(sock, "TCP connection lost");
|
||||
});
|
||||
sock.on("close", () => {
|
||||
debugLog("TCP socket closed");
|
||||
// Reject all pending requests on clean close
|
||||
for (const { reject: rej } of pendingRequests.values()) {
|
||||
rej(new Error("TCP connection closed"));
|
||||
}
|
||||
pendingRequests.clear();
|
||||
tcpSocket = null;
|
||||
rejectPendingOnSocket(sock, "TCP connection closed");
|
||||
if (tcpSocket === sock) tcpSocket = null;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function rpcCall(method, params) {
|
||||
function sendRpc(method, params) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!tcpSocket || tcpSocket.destroyed) {
|
||||
return reject(new Error("Not connected to Netcatty"));
|
||||
reject(new Error("Not connected to Netcatty"));
|
||||
return;
|
||||
}
|
||||
const id = nextRpcId++;
|
||||
pendingRequests.set(id, { resolve, reject });
|
||||
@@ -198,8 +210,73 @@ function rpcCall(method, params) {
|
||||
});
|
||||
}
|
||||
|
||||
function temporaryUnavailable(error) {
|
||||
const message = error?.message || String(error || "connection failed");
|
||||
if (message.startsWith("Netcatty is temporarily unavailable")) {
|
||||
return error instanceof Error ? error : new Error(message);
|
||||
}
|
||||
return new Error(`Netcatty is temporarily unavailable: ${message}`);
|
||||
}
|
||||
|
||||
function refreshExternalEndpoint() {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(fs.readFileSync(externalDiscoveryPath, "utf8"));
|
||||
} catch {
|
||||
throw new Error("Netcatty is temporarily unavailable: discovery file is missing or unreadable");
|
||||
}
|
||||
const port = parseInt(parsed?.port, 10);
|
||||
const token = typeof parsed?.token === "string" ? parsed.token.trim() : "";
|
||||
if (!port || !token) {
|
||||
throw new Error("Netcatty is temporarily unavailable: discovery endpoint is incomplete");
|
||||
}
|
||||
NETCATTY_MCP_PORT = port;
|
||||
NETCATTY_MCP_TOKEN = token;
|
||||
}
|
||||
|
||||
function dropActiveSocket() {
|
||||
const sock = tcpSocket;
|
||||
tcpSocket = null;
|
||||
tcpBuffer = "";
|
||||
if (sock && !sock.destroyed) sock.destroy();
|
||||
}
|
||||
|
||||
// One reconnect at a time. Auth uses sendRpc so callers waiting on this promise
|
||||
// cannot deadlock, and calls already rejected by close/error stay rejected.
|
||||
let connecting = null;
|
||||
|
||||
async function connectAndAuth() {
|
||||
if (tcpSocket?.destroyed) tcpSocket = null;
|
||||
refreshExternalEndpoint();
|
||||
tcpBuffer = "";
|
||||
try {
|
||||
await connectTcp();
|
||||
const authResult = await sendRpc("auth/verify", { token: NETCATTY_MCP_TOKEN });
|
||||
if (!authResult?.ok) {
|
||||
throw new Error("Netcatty is temporarily unavailable: TCP bridge authentication failed");
|
||||
}
|
||||
} catch (error) {
|
||||
dropActiveSocket();
|
||||
throw temporaryUnavailable(error);
|
||||
}
|
||||
process.stderr.write("[netcatty-mcp] Authenticated with TCP bridge\n");
|
||||
}
|
||||
|
||||
function ensureConnected() {
|
||||
if (tcpSocket && !tcpSocket.destroyed) return Promise.resolve();
|
||||
if (connecting) return connecting;
|
||||
connecting = connectAndAuth().finally(() => {
|
||||
connecting = null;
|
||||
});
|
||||
return connecting;
|
||||
}
|
||||
|
||||
function rpcCall(method, params) {
|
||||
if (!externalDiscoveryPath) return sendRpc(method, params);
|
||||
return ensureConnected().then(() => sendRpc(method, params));
|
||||
}
|
||||
|
||||
// External clients own connection maps here; managed agents use the main-process chat boundary.
|
||||
const externalDiscoveryPath = process.env.NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE;
|
||||
const exposedRpcCall = externalDiscoveryPath
|
||||
? createPrivacyBoundary(rpcCall, () => {
|
||||
const discovery = JSON.parse(fs.readFileSync(externalDiscoveryPath, "utf8"));
|
||||
@@ -258,16 +335,20 @@ async function main() {
|
||||
scopedSessionIds: SCOPED_SESSION_IDS,
|
||||
chatSessionId: CHAT_SESSION_ID,
|
||||
permissionMode: readPermissionMode(),
|
||||
externalDiscovery: Boolean(externalDiscoveryPath),
|
||||
});
|
||||
await connectTcp();
|
||||
|
||||
// Authenticate with the TCP bridge before accepting any tool calls
|
||||
const authResult = await rpcCall("auth/verify", { token: NETCATTY_MCP_TOKEN });
|
||||
debugLog("auth/verify result", authResult);
|
||||
if (!authResult?.ok) {
|
||||
throw new Error("TCP bridge authentication failed");
|
||||
if (externalDiscoveryPath) {
|
||||
await ensureConnected();
|
||||
} else {
|
||||
await connectTcp();
|
||||
// Authenticate with the TCP bridge before accepting any tool calls.
|
||||
const authResult = await rpcCall("auth/verify", { token: NETCATTY_MCP_TOKEN });
|
||||
debugLog("auth/verify result", authResult);
|
||||
if (!authResult?.ok) {
|
||||
throw new Error("TCP bridge authentication failed");
|
||||
}
|
||||
process.stderr.write("[netcatty-mcp] Authenticated with TCP bridge\n");
|
||||
}
|
||||
process.stderr.write("[netcatty-mcp] Authenticated with TCP bridge\n");
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
// Protocol.connect keeps this callback and runs it before request dispatch.
|
||||
|
||||
@@ -45,8 +45,16 @@ export const STORAGE_KEY_COMMAND_HISTORY = 'netcatty:commandHistory';
|
||||
/** Set once the user clears all command history; histfile seeding honors it. */
|
||||
export const STORAGE_KEY_COMMAND_HISTORY_CLEARED = 'netcatty:commandHistoryCleared';
|
||||
export const STORAGE_KEY_CONNECTION_LOGS = 'netcatty_connection_logs_v1';
|
||||
/** Side store for unsaved connection-log terminal replay buffers (main blob omits them for perf). */
|
||||
/**
|
||||
* Legacy side store for unsaved connection-log terminal replay buffers.
|
||||
* Read once to migrate into IndexedDB, then removed. Do not write it.
|
||||
*/
|
||||
export const STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA = 'netcatty_connection_log_terminal_data_v1';
|
||||
/**
|
||||
* Tiny cross-window signal for the IndexedDB replay side store.
|
||||
* Value shape: `{ rev: number, ids?: string[], nonce?: string }`.
|
||||
*/
|
||||
export const STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV = 'netcatty_connection_log_terminal_data_rev_v1';
|
||||
export const STORAGE_KEY_SESSION_RESTORE = 'netcatty_session_restore_v1';
|
||||
export const STORAGE_KEY_RESTORE_PREVIOUS_SESSION = 'netcatty_restore_previous_session_v1';
|
||||
export const STORAGE_KEY_RESTORE_TERMINAL_CWD = 'netcatty_restore_terminal_cwd_v1';
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, beforeEach, test } from "node:test";
|
||||
|
||||
import {
|
||||
loadAll,
|
||||
migrateLegacyConnectionLogTerminalData,
|
||||
putMany,
|
||||
replaceAll,
|
||||
writeTerminalDataMapDiff,
|
||||
type ConnectionLogTerminalDataMap,
|
||||
} from "./connectionLogTerminalDataStore.ts";
|
||||
|
||||
type Row = { id: string; data: string };
|
||||
|
||||
type FakeState = {
|
||||
failWrites: boolean;
|
||||
onGet?: (rows: Map<string, Row>) => void;
|
||||
openConnections: Set<{ close: () => void; onversionchange: (() => void) | null }>;
|
||||
};
|
||||
|
||||
const fakeState: FakeState = {
|
||||
failWrites: false,
|
||||
openConnections: new Set(),
|
||||
};
|
||||
|
||||
const databases = new Map<string, { version: number; stores: Map<string, Map<string, Row>> }>();
|
||||
|
||||
const previousIndexedDB = Object.getOwnPropertyDescriptor(globalThis, "indexedDB");
|
||||
|
||||
class FakeRequest<T = unknown> {
|
||||
result: T | undefined;
|
||||
error: Error | null = null;
|
||||
onsuccess: ((event?: Event) => void) | null = null;
|
||||
onerror: ((event?: Event) => void) | null = null;
|
||||
onupgradeneeded: ((event?: Event) => void) | null = null;
|
||||
}
|
||||
|
||||
function installFakeIndexedDB(): void {
|
||||
const indexedDB = {
|
||||
open(name: string, version = 1) {
|
||||
const request = new FakeRequest<FakeDatabase>();
|
||||
queueMicrotask(() => {
|
||||
let record = databases.get(name);
|
||||
if (!record) {
|
||||
record = { version: 0, stores: new Map() };
|
||||
databases.set(name, record);
|
||||
}
|
||||
const db = new FakeDatabase(record);
|
||||
fakeState.openConnections.add(db);
|
||||
request.result = db;
|
||||
if (version > record.version) {
|
||||
record.version = version;
|
||||
request.onupgradeneeded?.(new Event("upgradeneeded"));
|
||||
}
|
||||
request.onsuccess?.(new Event("success"));
|
||||
});
|
||||
return request;
|
||||
},
|
||||
deleteDatabase(name: string) {
|
||||
const request = new FakeRequest();
|
||||
queueMicrotask(() => {
|
||||
for (const db of [...fakeState.openConnections]) db.onversionchange?.();
|
||||
databases.delete(name);
|
||||
request.onsuccess?.(new Event("success"));
|
||||
});
|
||||
return request;
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, "indexedDB", {
|
||||
value: indexedDB,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
|
||||
class FakeDatabase {
|
||||
onversionchange: (() => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
closed = false;
|
||||
|
||||
constructor(private record: { version: number; stores: Map<string, Map<string, Row>> }) {}
|
||||
|
||||
objectStoreNames = {
|
||||
contains: (name: string) => this.record.stores.has(name),
|
||||
};
|
||||
|
||||
createObjectStore(name: string): void {
|
||||
if (!this.record.stores.has(name)) this.record.stores.set(name, new Map());
|
||||
}
|
||||
|
||||
transaction(storeName: string): FakeTransaction {
|
||||
const rows = this.record.stores.get(storeName);
|
||||
if (!rows) throw new Error(`missing store ${storeName}`);
|
||||
return new FakeTransaction(rows);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
fakeState.openConnections.delete(this);
|
||||
this.onclose?.();
|
||||
}
|
||||
}
|
||||
|
||||
class FakeTransaction {
|
||||
error: Error | null = null;
|
||||
oncomplete: (() => void) | null = null;
|
||||
onabort: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
private pending = 0;
|
||||
private settled = false;
|
||||
|
||||
constructor(private rows: Map<string, Row>) {
|
||||
queueMicrotask(() => this.finish());
|
||||
}
|
||||
|
||||
objectStore(): FakeStore {
|
||||
return new FakeStore(this.rows, this);
|
||||
}
|
||||
|
||||
abort(): void {
|
||||
this.error = this.error ?? new Error("aborted");
|
||||
if (this.settled) return;
|
||||
this.settled = true;
|
||||
this.onabort?.();
|
||||
}
|
||||
|
||||
track<T>(produce: () => T, writes = true): FakeRequest<T> {
|
||||
this.pending += 1;
|
||||
const request = new FakeRequest<T>();
|
||||
queueMicrotask(() => {
|
||||
try {
|
||||
if (writes && fakeState.failWrites) {
|
||||
const error = new Error("indexedDB write failed");
|
||||
this.error = error;
|
||||
request.error = error;
|
||||
request.onerror?.(new Event("error"));
|
||||
this.settled = true;
|
||||
this.onerror?.();
|
||||
this.onabort?.();
|
||||
return;
|
||||
}
|
||||
request.result = produce();
|
||||
request.onsuccess?.(new Event("success"));
|
||||
} catch (error) {
|
||||
request.error = error instanceof Error ? error : new Error(String(error));
|
||||
this.error = request.error;
|
||||
request.onerror?.(new Event("error"));
|
||||
this.settled = true;
|
||||
this.onerror?.();
|
||||
this.onabort?.();
|
||||
return;
|
||||
} finally {
|
||||
this.pending -= 1;
|
||||
this.finish();
|
||||
}
|
||||
});
|
||||
return request;
|
||||
}
|
||||
|
||||
private finish(): void {
|
||||
if (this.settled || this.pending > 0) return;
|
||||
this.settled = true;
|
||||
this.oncomplete?.();
|
||||
}
|
||||
}
|
||||
|
||||
class FakeStore {
|
||||
constructor(
|
||||
private rows: Map<string, Row>,
|
||||
private tx: FakeTransaction,
|
||||
) {}
|
||||
|
||||
getAll(): FakeRequest<Row[]> {
|
||||
return this.tx.track(() => [...this.rows.values()].map((row) => ({ ...row })), false);
|
||||
}
|
||||
|
||||
get(id: string): FakeRequest<Row | undefined> {
|
||||
return this.tx.track(() => {
|
||||
fakeState.onGet?.(this.rows);
|
||||
const row = this.rows.get(id);
|
||||
return row ? { ...row } : undefined;
|
||||
}, false);
|
||||
}
|
||||
|
||||
put(value: Row): FakeRequest<string> {
|
||||
return this.tx.track(() => {
|
||||
this.rows.set(value.id, { ...value });
|
||||
return value.id;
|
||||
});
|
||||
}
|
||||
|
||||
delete(id: string): FakeRequest<undefined> {
|
||||
return this.tx.track(() => {
|
||||
this.rows.delete(id);
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
clear(): FakeRequest<undefined> {
|
||||
return this.tx.track(() => {
|
||||
this.rows.clear();
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
installFakeIndexedDB();
|
||||
|
||||
const resetStore = async () => {
|
||||
fakeState.failWrites = false;
|
||||
fakeState.onGet = undefined;
|
||||
await new Promise<void>((resolve) => {
|
||||
const request = indexedDB.deleteDatabase("netcatty_connection_log_terminal_data");
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => resolve();
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetStore();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
if (previousIndexedDB) Object.defineProperty(globalThis, "indexedDB", previousIndexedDB);
|
||||
else delete (globalThis as { indexedDB?: unknown }).indexedDB;
|
||||
});
|
||||
|
||||
test("putMany, deleteMany, replaceAll, and clear round-trip one row per log id", async () => {
|
||||
assert.equal(await replaceAll({ a: "one", b: "two" }), true);
|
||||
assert.deepEqual(await loadAll(), { a: "one", b: "two" });
|
||||
|
||||
const diff = await writeTerminalDataMapDiff(
|
||||
{ a: "one", b: "two" },
|
||||
{ a: "one", b: "edited", c: "three" },
|
||||
);
|
||||
assert.deepEqual(diff, { persisted: true, changed: true });
|
||||
assert.deepEqual(await loadAll(), { a: "one", b: "edited", c: "three" });
|
||||
|
||||
assert.equal(await replaceAll({ only: "left" }), true);
|
||||
assert.deepEqual(await loadAll(), { only: "left" });
|
||||
|
||||
const { clear } = await import("./connectionLogTerminalDataStore.ts");
|
||||
assert.equal(await clear(), true);
|
||||
assert.deepEqual(await loadAll(), {});
|
||||
});
|
||||
|
||||
test("writeTerminalDataMapDiff skips indexedDB when the map is unchanged", async () => {
|
||||
assert.equal(await putMany({ b: "2", a: "1" }), true);
|
||||
const result = await writeTerminalDataMapDiff(
|
||||
{ b: "2", a: "1" },
|
||||
{ a: "1", b: "2" },
|
||||
);
|
||||
assert.deepEqual(result, { persisted: true, changed: false });
|
||||
assert.deepEqual(await loadAll(), { a: "1", b: "2" });
|
||||
});
|
||||
|
||||
test("migration inserts legacy rows without clearing a replay committed after the empty read", async () => {
|
||||
fakeState.onGet = (rows) => {
|
||||
rows.set("concurrent", { id: "concurrent", data: "new-window" });
|
||||
fakeState.onGet = undefined;
|
||||
};
|
||||
let removed = false;
|
||||
const loaded = await migrateLegacyConnectionLogTerminalData({
|
||||
read: () => ({ "log-old": "legacy" }),
|
||||
remove: () => {
|
||||
removed = true;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(removed, true);
|
||||
assert.equal(loaded.durable, true);
|
||||
assert.equal(loaded.map.concurrent, "new-window");
|
||||
assert.equal(loaded.map["log-old"], "legacy");
|
||||
assert.deepEqual(await loadAll(), { concurrent: "new-window", "log-old": "legacy" });
|
||||
});
|
||||
|
||||
test("migration copies a legacy map into indexedDB and removes it only after commit", async () => {
|
||||
let removed = false;
|
||||
const legacy: ConnectionLogTerminalDataMap = { "log-new": "replay", "log-old": "older" };
|
||||
const loaded = await migrateLegacyConnectionLogTerminalData({
|
||||
read: () => legacy,
|
||||
remove: () => {
|
||||
removed = true;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(removed, true);
|
||||
assert.equal(loaded.migrated, true);
|
||||
assert.deepEqual(loaded.map, legacy);
|
||||
assert.deepEqual(await loadAll(), legacy);
|
||||
});
|
||||
|
||||
test("migration keeps indexedDB rows and copies legacy ids that are not stored yet", async () => {
|
||||
assert.equal(await putMany({ kept: "from-idb" }), true);
|
||||
let removed = false;
|
||||
const loaded = await migrateLegacyConnectionLogTerminalData({
|
||||
read: () => ({ kept: "stale-legacy", missing: "from-legacy" }),
|
||||
remove: () => {
|
||||
removed = true;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(removed, true);
|
||||
assert.equal(loaded.migrated, true);
|
||||
assert.equal(loaded.durable, true);
|
||||
assert.deepEqual(loaded.map, { kept: "from-idb", missing: "from-legacy" });
|
||||
assert.deepEqual(await loadAll(), { kept: "from-idb", missing: "from-legacy" });
|
||||
});
|
||||
|
||||
test("migration keeps the legacy key when merging missing rows fails", async () => {
|
||||
assert.equal(await putMany({ kept: "from-idb" }), true);
|
||||
fakeState.failWrites = true;
|
||||
let removed = false;
|
||||
const loaded = await migrateLegacyConnectionLogTerminalData({
|
||||
read: () => ({ missing: "from-legacy" }),
|
||||
remove: () => {
|
||||
removed = true;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(removed, false);
|
||||
assert.equal(loaded.migrated, false);
|
||||
assert.equal(loaded.durable, false);
|
||||
assert.equal(loaded.map.missing, "from-legacy");
|
||||
assert.equal(loaded.map.kept, "from-idb");
|
||||
fakeState.failWrites = false;
|
||||
assert.deepEqual(await loadAll(), { kept: "from-idb" });
|
||||
});
|
||||
|
||||
test("migration keeps the legacy key when the indexedDB replace fails", async () => {
|
||||
let removed = false;
|
||||
fakeState.failWrites = true;
|
||||
const loaded = await migrateLegacyConnectionLogTerminalData({
|
||||
read: () => ({ keep: "legacy" }),
|
||||
remove: () => {
|
||||
removed = true;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(removed, false);
|
||||
assert.equal(loaded.migrated, false);
|
||||
assert.deepEqual(loaded.map, { keep: "legacy" });
|
||||
fakeState.failWrites = false;
|
||||
assert.deepEqual(await loadAll(), {});
|
||||
});
|
||||
|
||||
test("store methods soft-fail when indexedDB is unavailable", async () => {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(globalThis, "indexedDB");
|
||||
Object.defineProperty(globalThis, "indexedDB", {
|
||||
value: undefined,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
try {
|
||||
assert.deepEqual(await loadAll(), {});
|
||||
assert.equal(await putMany({ a: "x" }), false);
|
||||
assert.equal(await replaceAll({ a: "x" }), false);
|
||||
const { deleteMany, clear } = await import("./connectionLogTerminalDataStore.ts");
|
||||
assert.equal(await deleteMany(["a"]), false);
|
||||
assert.equal(await clear(), false);
|
||||
} finally {
|
||||
if (descriptor) Object.defineProperty(globalThis, "indexedDB", descriptor);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,371 @@
|
||||
import {
|
||||
diffTerminalDataMaps,
|
||||
type ConnectionLogTerminalDataMap,
|
||||
} from "../../domain/connectionLogTerminalData.ts";
|
||||
|
||||
export const CONNECTION_LOG_TERMINAL_DATA_DB_NAME = "netcatty_connection_log_terminal_data";
|
||||
export const CONNECTION_LOG_TERMINAL_DATA_STORE_NAME = "entries";
|
||||
|
||||
const DB_VERSION = 1;
|
||||
|
||||
export type ConnectionLogTerminalDataRev = {
|
||||
rev: number;
|
||||
/** Log ids currently in the side store. */
|
||||
ids?: string[];
|
||||
/**
|
||||
* Distinguishes two writes that would otherwise share `rev` and `ids`
|
||||
* in the same millisecond, so the other window still observes a change.
|
||||
*/
|
||||
nonce?: string;
|
||||
};
|
||||
|
||||
export type LegacyConnectionLogTerminalDataMigration = {
|
||||
read: () => ConnectionLogTerminalDataMap | null;
|
||||
remove: () => void;
|
||||
};
|
||||
|
||||
export type MigratedConnectionLogTerminalData = {
|
||||
map: ConnectionLogTerminalDataMap;
|
||||
migrated: boolean;
|
||||
/**
|
||||
* False when `map` contains replay rows that are not committed in IndexedDB.
|
||||
* Callers must not use that map as a diff baseline until a later write commits.
|
||||
*/
|
||||
durable: boolean;
|
||||
};
|
||||
|
||||
type TerminalDataRow = {
|
||||
id: string;
|
||||
data: string;
|
||||
};
|
||||
|
||||
let cachedDatabase: IDBDatabase | null = null;
|
||||
let openingDatabase: Promise<IDBDatabase | null> | null = null;
|
||||
|
||||
const warnStoreFailure = (message: string, error: unknown): void => {
|
||||
console.warn(`[connectionLogTerminalDataStore] ${message}`, error);
|
||||
};
|
||||
|
||||
const forgetDatabase = (): void => {
|
||||
const db = cachedDatabase;
|
||||
cachedDatabase = null;
|
||||
openingDatabase = null;
|
||||
try {
|
||||
db?.close();
|
||||
} catch {
|
||||
// The connection is already closing.
|
||||
}
|
||||
};
|
||||
|
||||
const transactionComplete = (tx: IDBTransaction): Promise<void> => new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (fn: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
fn();
|
||||
};
|
||||
tx.oncomplete = () => finish(() => resolve());
|
||||
tx.onabort = () => finish(() => reject(tx.error ?? new Error("IndexedDB transaction aborted")));
|
||||
tx.onerror = () => finish(() => reject(tx.error ?? new Error("IndexedDB transaction failed")));
|
||||
});
|
||||
|
||||
const openDatabase = (): Promise<IDBDatabase | null> => new Promise((resolve) => {
|
||||
try {
|
||||
if (typeof indexedDB === "undefined" || indexedDB === null) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const request = indexedDB.open(CONNECTION_LOG_TERMINAL_DATA_DB_NAME, DB_VERSION);
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(CONNECTION_LOG_TERMINAL_DATA_STORE_NAME)) {
|
||||
db.createObjectStore(CONNECTION_LOG_TERMINAL_DATA_STORE_NAME, { keyPath: "id" });
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => {
|
||||
warnStoreFailure("IndexedDB open failed", request.error);
|
||||
resolve(null);
|
||||
};
|
||||
} catch (error) {
|
||||
warnStoreFailure("IndexedDB open failed", error);
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
|
||||
const getDatabase = async (): Promise<IDBDatabase | null> => {
|
||||
if (cachedDatabase) return cachedDatabase;
|
||||
if (!openingDatabase) {
|
||||
openingDatabase = openDatabase().then((db) => {
|
||||
openingDatabase = null;
|
||||
if (!db) return null;
|
||||
cachedDatabase = db;
|
||||
db.onversionchange = () => {
|
||||
if (cachedDatabase === db) cachedDatabase = null;
|
||||
try {
|
||||
db.close();
|
||||
} catch {
|
||||
// Already closed by the deleting context.
|
||||
}
|
||||
};
|
||||
db.onclose = () => {
|
||||
if (cachedDatabase === db) cachedDatabase = null;
|
||||
};
|
||||
return db;
|
||||
}).catch((error: unknown) => {
|
||||
openingDatabase = null;
|
||||
warnStoreFailure("IndexedDB open failed", error);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
return openingDatabase;
|
||||
};
|
||||
|
||||
const rowsToMap = (rows: TerminalDataRow[]): ConnectionLogTerminalDataMap => {
|
||||
const map: ConnectionLogTerminalDataMap = {};
|
||||
for (const row of rows) {
|
||||
if (!row || typeof row.id !== "string" || typeof row.data !== "string") continue;
|
||||
map[row.id] = row.data;
|
||||
}
|
||||
return map;
|
||||
};
|
||||
|
||||
const sanitizeMap = (map: ConnectionLogTerminalDataMap | null): ConnectionLogTerminalDataMap => {
|
||||
if (!map || typeof map !== "object" || Array.isArray(map)) return {};
|
||||
const next: ConnectionLogTerminalDataMap = {};
|
||||
for (const [id, data] of Object.entries(map)) {
|
||||
if (typeof data === "string" && data.length > 0) next[id] = data;
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
const readAll = async (): Promise<ConnectionLogTerminalDataMap | null> => {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
if (!db) return null;
|
||||
const tx = db.transaction(CONNECTION_LOG_TERMINAL_DATA_STORE_NAME, "readonly");
|
||||
const store = tx.objectStore(CONNECTION_LOG_TERMINAL_DATA_STORE_NAME);
|
||||
const request = store.getAll() as IDBRequest<TerminalDataRow[]>;
|
||||
let rows: TerminalDataRow[] = [];
|
||||
request.onsuccess = () => {
|
||||
rows = request.result ?? [];
|
||||
};
|
||||
await transactionComplete(tx);
|
||||
return rowsToMap(rows);
|
||||
} catch (error) {
|
||||
forgetDatabase();
|
||||
warnStoreFailure("IndexedDB read failed", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const loadAll = async (): Promise<ConnectionLogTerminalDataMap> => (await readAll()) ?? {};
|
||||
|
||||
/** `null` when IndexedDB could not be read. `{}` is a successful empty store. */
|
||||
export const tryLoadAll = async (): Promise<ConnectionLogTerminalDataMap | null> => readAll();
|
||||
|
||||
export const replaceAll = async (map: ConnectionLogTerminalDataMap): Promise<boolean> => {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
if (!db) return false;
|
||||
const entries = sanitizeMap(map);
|
||||
const tx = db.transaction(CONNECTION_LOG_TERMINAL_DATA_STORE_NAME, "readwrite");
|
||||
const store = tx.objectStore(CONNECTION_LOG_TERMINAL_DATA_STORE_NAME);
|
||||
const done = transactionComplete(tx);
|
||||
store.clear();
|
||||
for (const [id, data] of Object.entries(entries)) {
|
||||
store.put({ id, data });
|
||||
}
|
||||
await done;
|
||||
return true;
|
||||
} catch (error) {
|
||||
forgetDatabase();
|
||||
warnStoreFailure("IndexedDB replace failed", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/** Insert ids that are still absent. Does not clear or overwrite existing rows. */
|
||||
const putMissing = async (entries: ConnectionLogTerminalDataMap): Promise<boolean> => {
|
||||
const rows = sanitizeMap(entries);
|
||||
const ids = Object.keys(rows);
|
||||
if (ids.length === 0) return true;
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
if (!db) return false;
|
||||
const tx = db.transaction(CONNECTION_LOG_TERMINAL_DATA_STORE_NAME, "readwrite");
|
||||
const store = tx.objectStore(CONNECTION_LOG_TERMINAL_DATA_STORE_NAME);
|
||||
const done = transactionComplete(tx);
|
||||
for (const id of ids) {
|
||||
const request = store.get(id) as IDBRequest<TerminalDataRow | undefined>;
|
||||
request.onsuccess = () => {
|
||||
if (request.result === undefined) store.put({ id, data: rows[id] });
|
||||
};
|
||||
}
|
||||
await done;
|
||||
return true;
|
||||
} catch (error) {
|
||||
forgetDatabase();
|
||||
warnStoreFailure("IndexedDB put failed", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const putMany = async (entries: ConnectionLogTerminalDataMap): Promise<boolean> => {
|
||||
const rows = sanitizeMap(entries);
|
||||
const ids = Object.keys(rows);
|
||||
if (ids.length === 0) return true;
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
if (!db) return false;
|
||||
const tx = db.transaction(CONNECTION_LOG_TERMINAL_DATA_STORE_NAME, "readwrite");
|
||||
const store = tx.objectStore(CONNECTION_LOG_TERMINAL_DATA_STORE_NAME);
|
||||
const done = transactionComplete(tx);
|
||||
for (const id of ids) store.put({ id, data: rows[id] });
|
||||
await done;
|
||||
return true;
|
||||
} catch (error) {
|
||||
forgetDatabase();
|
||||
warnStoreFailure("IndexedDB put failed", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteMany = async (ids: readonly string[]): Promise<boolean> => {
|
||||
if (ids.length === 0) return true;
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
if (!db) return false;
|
||||
const tx = db.transaction(CONNECTION_LOG_TERMINAL_DATA_STORE_NAME, "readwrite");
|
||||
const store = tx.objectStore(CONNECTION_LOG_TERMINAL_DATA_STORE_NAME);
|
||||
const done = transactionComplete(tx);
|
||||
for (const id of ids) store.delete(id);
|
||||
await done;
|
||||
return true;
|
||||
} catch (error) {
|
||||
forgetDatabase();
|
||||
warnStoreFailure("IndexedDB delete failed", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const clear = async (): Promise<boolean> => {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
if (!db) return false;
|
||||
const tx = db.transaction(CONNECTION_LOG_TERMINAL_DATA_STORE_NAME, "readwrite");
|
||||
const store = tx.objectStore(CONNECTION_LOG_TERMINAL_DATA_STORE_NAME);
|
||||
const done = transactionComplete(tx);
|
||||
store.clear();
|
||||
await done;
|
||||
return true;
|
||||
} catch (error) {
|
||||
forgetDatabase();
|
||||
warnStoreFailure("IndexedDB clear failed", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply a persisted-map diff in one transaction. Unchanged maps do not open
|
||||
* a write. A failed commit leaves the previous rows in place for a retry.
|
||||
*/
|
||||
export const writeTerminalDataMapDiff = async (
|
||||
previous: ConnectionLogTerminalDataMap,
|
||||
next: ConnectionLogTerminalDataMap,
|
||||
): Promise<{ persisted: boolean; changed: boolean }> => {
|
||||
const diff = diffTerminalDataMaps(previous, next);
|
||||
const puts = sanitizeMap(diff.puts);
|
||||
const putIds = Object.keys(puts);
|
||||
if (putIds.length === 0 && diff.deleteIds.length === 0) {
|
||||
return { persisted: true, changed: false };
|
||||
}
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
if (!db) return { persisted: false, changed: true };
|
||||
const tx = db.transaction(CONNECTION_LOG_TERMINAL_DATA_STORE_NAME, "readwrite");
|
||||
const store = tx.objectStore(CONNECTION_LOG_TERMINAL_DATA_STORE_NAME);
|
||||
const done = transactionComplete(tx);
|
||||
for (const id of putIds) store.put({ id, data: puts[id] });
|
||||
for (const id of diff.deleteIds) store.delete(id);
|
||||
await done;
|
||||
return { persisted: true, changed: true };
|
||||
} catch (error) {
|
||||
forgetDatabase();
|
||||
warnStoreFailure("IndexedDB diff write failed", error);
|
||||
return { persisted: false, changed: true };
|
||||
}
|
||||
};
|
||||
|
||||
const safeRemove = (remove: () => void): void => {
|
||||
try {
|
||||
remove();
|
||||
} catch (error) {
|
||||
warnStoreFailure("Failed to remove legacy terminal replay blob", error);
|
||||
}
|
||||
};
|
||||
|
||||
const readLegacyMap = (
|
||||
legacy: LegacyConnectionLogTerminalDataMigration,
|
||||
): ConnectionLogTerminalDataMap | null => {
|
||||
try {
|
||||
return sanitizeMap(legacy.read());
|
||||
} catch (error) {
|
||||
warnStoreFailure("Failed to read legacy terminal replay blob", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* One-shot move from the legacy localStorage blob into IndexedDB.
|
||||
* An empty IndexedDB plus a legacy blob is copied, then the legacy key is
|
||||
* removed only after the transaction commits. Rows already in IndexedDB are
|
||||
* kept; legacy ids that are not there yet are inserted before the key is
|
||||
* removed. A failed read does not treat the store as empty, and a failed
|
||||
* write leaves the legacy key in place.
|
||||
*/
|
||||
export const migrateLegacyConnectionLogTerminalData = async (
|
||||
legacy: LegacyConnectionLogTerminalDataMigration,
|
||||
): Promise<MigratedConnectionLogTerminalData> => {
|
||||
const loaded = await readAll();
|
||||
if (loaded === null) {
|
||||
const fallback = readLegacyMap(legacy);
|
||||
return { map: fallback ?? {}, migrated: false, durable: false };
|
||||
}
|
||||
if (Object.keys(loaded).length > 0) {
|
||||
const legacyMap = readLegacyMap(legacy);
|
||||
if (legacyMap === null) return { map: loaded, migrated: false, durable: true };
|
||||
const missing: ConnectionLogTerminalDataMap = {};
|
||||
for (const [id, data] of Object.entries(legacyMap)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(loaded, id)) missing[id] = data;
|
||||
}
|
||||
if (Object.keys(missing).length === 0) {
|
||||
safeRemove(legacy.remove);
|
||||
return { map: loaded, migrated: false, durable: true };
|
||||
}
|
||||
const persisted = await putMissing(missing);
|
||||
if (!persisted) {
|
||||
return { map: { ...missing, ...loaded }, migrated: false, durable: false };
|
||||
}
|
||||
safeRemove(legacy.remove);
|
||||
const committed = await readAll();
|
||||
return {
|
||||
map: committed ?? { ...loaded, ...missing },
|
||||
migrated: true,
|
||||
durable: committed !== null,
|
||||
};
|
||||
}
|
||||
|
||||
const legacyMap = readLegacyMap(legacy);
|
||||
if (legacyMap === null) return { map: {}, migrated: false, durable: false };
|
||||
if (Object.keys(legacyMap).length === 0) {
|
||||
safeRemove(legacy.remove);
|
||||
return { map: {}, migrated: false, durable: true };
|
||||
}
|
||||
// Insert without clearing. A row committed after the empty readAll must stay.
|
||||
const persisted = await putMissing(legacyMap);
|
||||
if (!persisted) return { map: legacyMap, migrated: false, durable: false };
|
||||
safeRemove(legacy.remove);
|
||||
const committed = await readAll();
|
||||
return { map: committed ?? legacyMap, migrated: true, durable: committed !== null };
|
||||
};
|
||||
@@ -119,6 +119,8 @@ function seed(
|
||||
diskPercent: 35,
|
||||
diskUsed: 35,
|
||||
diskTotal: 100,
|
||||
diskReadSpeed: i === 1 ? null : i === 2 ? 0 : 2 * 1024 ** 2,
|
||||
diskWriteSpeed: i === 1 ? null : i === 2 ? 0 : 512 * 1024,
|
||||
disks: Array.from({ length: i === 2 ? 10 : 2 }, (_, j) => ({
|
||||
mountPoint: j ? `/data-${j}` : "/",
|
||||
used: 35,
|
||||
|
||||
@@ -59,6 +59,7 @@ async function main() {
|
||||
() => run('document.querySelectorAll("[data-monitor-host]").length > 0'),
|
||||
"monitoring renders",
|
||||
);
|
||||
assert.match(await run('document.querySelector("[data-disk-io]").textContent'), /2.0 MiB\/s/);
|
||||
const screenshot = async (name) => {
|
||||
owner.webContents.sendInputEvent({ type: "mouseMove", x: 1, y: 1 });
|
||||
await wait(200);
|
||||
|
||||
+2
@@ -321,6 +321,8 @@ declare global {
|
||||
memPercent: number;
|
||||
command: string;
|
||||
}>;
|
||||
diskReadSpeed?: number | null; // Linux hardware-backed block devices, bytes/sec; null while unavailable/warming up
|
||||
diskWriteSpeed?: number | null; // Aggregate without partitions or stacked virtual devices
|
||||
diskPercent: number | null; // Disk usage percentage for root partition
|
||||
diskUsed: number | null; // Disk used in GB
|
||||
diskTotal: number | null; // Total disk in GB
|
||||
|
||||
Reference in new issue
Block a user