Compare commits

..
Author SHA1 Message Date
Cursor Agentand陈大猫 80fe4c19c7 fix(vault): treat empty vault_notes_get query as omitted
Models pass query: "" on range reads. Accept that as no search so
vault_notes_get does not fail and retry.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-23 08:49:34 +00:00
陈大猫 695fd27688 feat: add terminal context menu shortcut to open SFTP (#3878) 2026-10-23 14:41:06 +08:00
陈大猫 238fe45ee4 fix: avoid repeated atlas rebuilds when switching terminal tabs (#3873)
Deduplicate terminal reveal and focus recovery while preserving changed-layout and explicit resume/fullscreen recovery. Add behavioral regressions.

Related to #3825; this rendering optimization does not establish that the original Windows/macOS issue is resolved.
2026-10-23 13:53:28 +08:00
e780d44f97 fix(mcp): keep managed stdio fail-fast when the bridge drops (#3874)
A managed tool call that races bridge shutdown was still in flight, so
the socket error rejected it with "TCP connection lost". Managed
children do not reread discovery; report that race as not connected
and leave external in-flight wording unchanged.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-23 12:21:20 +08:00
陈大猫 9766f21ecf feat(monitor): shared Linux disk I/O and one-second refresh (#3870) 2026-10-23 12:19:10 +08:00
陈大猫 e9cd311936 fix(mcp): reconnect external stdio bridge after Netcatty restart (#3872)
When NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE is set, rpcCall recovers on demand via ensureConnected(): re-read discovery, reset TCP buffer, connect to current port, auth/verify with refreshed token. Concurrent callers coalesce. Close/error reject in-flight RPCs without replaying. Managed SDK children keep fail-fast.

Fixes #3871
2026-10-23 11:43:15 +08:00
d6871dbca9 feat: toggle the host list with Ctrl/Cmd+Shift+B (#3868)
* feat(shortcuts): toggle the host list with Ctrl/Cmd+Shift+B

Ctrl/Cmd+B already switches broadcast mode and stays available to a focused terminal. The new shortcut reuses the existing host-tree toggle.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(shortcuts): let the host list chord through Monaco and idle terminals

Editor focus inside Monaco can toggle the host list. The chord is consumed only when that toggle runs, matching broadcast.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-23 10:18:20 +08:00
850c5309b6 feat(terminal): split compose-bar send target from toolbar broadcast (#3860)
* feat(terminal): split compose-bar send target from toolbar broadcast

Toolbar broadcast still fans out keystrokes, paste, and terminal-path
snippets. The compose-bar indicator is now a toggle for whether Enter
sends to broadcast peers or only the active session. Turning the toolbar
switch on or off resets that target to follow it; later flips stay
independent.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): keep active-only compose sends off the toolbar fan-out

The focused workspace executor omitted broadcast, so executeSnippetCommand
fell back to the toolbar switch and still wrote every peer. Pass
broadcast: false on that path.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-23 09:48:54 +08:00
e5bfc12a2e perf(vault): store connection-log replay blobs in IndexedDB (#3861)
* perf(vault): store connection-log replay blobs in IndexedDB

Move unsaved connection-log terminal replay out of the multi-megabyte
localStorage blob into one IndexedDB row per log id. Persist only the
changed ids, keep the newest 50, and signal other windows with a tiny
revision key.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(vault): keep connection-log replay across boot races and IndexedDB failure

Re-read connection-log metadata after the IndexedDB load before pruning,
return the capture promise through TerminalLayer, and write the unpruned
log blob when the side store rejects the commit.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(vault): keep unmigrated connection-log replays until IndexedDB commits

Copy legacy replay ids that are missing from IndexedDB before deleting
the old blob, and do not treat an uncommitted legacy map as the diff
baseline.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(vault): migrate legacy replays without clearing newer IndexedDB rows

Insert missing legacy ids in one transaction instead of replaceAll, so a
replay committed after the empty read is not wiped.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(vault): rebase the IndexedDB fallback onto the latest connection logs

When the side store rejects a close, attach this window's replay to the
connection-log blob currently on disk instead of writing the stale snapshot.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(vault): rebase queued connection-log writes onto the latest logs

A persist task can run after another window changes the log list. Write
the rebased list instead of the snapshot captured when the task was queued.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(vault): keep queued connection logs after a remote snapshot

A storage event can replace memory with another window's logs before the
queued write runs. Keep ids from the queued snapshot unless this snapshot
deleted them.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(vault): restore only locally changed queued logs

A queued persist was putting back every id from the pre-queue snapshot,
which undid another window's delete of a log this recipe never touched.
Track the ids the recipe added or replaced, and reload IndexedDB during
boot when the revision key moves across the migrate await so that peer
replay is hydrated before this window marks the revision seen.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(vault): rebase connection logs after the side-store await

A non-pruned write stored the pre-await snapshot and could replace logs
another window saved during the IndexedDB round trip. Rebase immediately
before that write, shrink the quota fallback until the newest replay fits,
and leave the revision unconsumed when IndexedDB cannot be read.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(vault): ignore same-window connection-log echoes

The deferred localStorage notification replays this window's own metadata
write. Applying it replaced connectionLogsRef with an older blob and dropped
logs that were added or finalized while that write was still queued.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(vault): do not restore a queued log deleted from memory

A queued add or update was written back when the id was already gone from
memory, which let another window keep that row after a later local delete.
Rebase now applies a changed id only while memory still has it.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-23 07:55:24 +08:00
6ab684bcd5 fix(terminal): restore home input after an attached popup closes (#3866)
Attach close preparation clears the popup session ref, then unmount
cleanup called closeSession. That emits netcatty:exit to the home
renderer and can kill the shared PTY. Skip that second close.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-23 03:57:36 +08:00
acecd46d57 fix(sftp): recover from abandoned SFTP channel-open poison on lossy links (#3865)
A timed-out SFTP OPEN used to pin the connection forever when its callback
never arrived. Wait up to 2s, then force-clear the gate and admit a new open.
Late channels are still closed. After TIMEOUT or PENDING, skip the fastPut
reopen and upload on the shared channel.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-23 03:57:29 +08:00
aee852a9ac fix(sftp): resume Retry for every channel-loss error form the bridge already knows (#3864)
canRetryFromCheckpoint and the upload prefix digest missed ECONNABORTED,
socket hang up, ERR_SFTP_CHANNEL_CLOSED, and client-socket. Those Retries
restarted at byte 0 and stranded the staged .part.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-23 03:57:25 +08:00
5bef721ac9 perf(sftp): skip the second promotion lstat when there is no mode to restore (#3862)
New-file uploads have a null existingMode, so restoreRemoteMode is a no-op.
Recheck the destination only after a real mode restore and keep one
pre-rename safety lstat otherwise.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-23 03:57:18 +08:00
87 changed files with 4127 additions and 442 deletions

No files matched your search

+3 -1
View File
@@ -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\(\)/);
});
+28 -6
View File
@@ -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,
});
+2
View File
@@ -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,
+5 -1
View File
@@ -272,7 +272,7 @@ function AppViewInner({ domains }: AppViewProps) {
handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit,
handleOpenHostFromVaultNote, handleOpenQuickSwitcher, handleOpenSettings, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleFidoPromptSubmit, handleFidoPromptCancel, handleProtocolSelect,
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleUpdateHostFromTerminal,
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, 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}
+4
View File
@@ -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*\}/,
+1
View File
@@ -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
+5
View File
@@ -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",
+2
View File
@@ -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',
+1
View File
@@ -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',
+5
View File
@@ -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",
+2
View File
@@ -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',
+1
View File
@@ -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': 'Вставить файл',
+5
View File
@@ -1,4 +1,9 @@
export const ruMonitorMessages = {
"monitor.diskIo": "Дисковый ввод-вывод",
"monitor.diskRead": "Чтение",
"monitor.diskWrite": "Запись",
"monitor.diskIoHint": "Суммарный ввод-вывод физических блочных устройств Linux, без разделов и виртуальных слоёв. Доступен после двух корректных замеров; macOS не поддерживается.",
"monitor.actions": "Действия панели",
"monitor.unavailable": "Мониторинг недоступен. Запустите его снова после восстановления связи.",
"monitor.username": "Пользователь",
+2
View File
@@ -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': '粘贴文件',
+2
View File
@@ -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': '貼上檔案',
+2
View File
@@ -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 () => {
+4
View File
@@ -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");
}
});
+55 -11
View File
@@ -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
View File
@@ -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
View File
@@ -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); }}
+21 -5
View File
@@ -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(); });
});
+32 -7
View File
@@ -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 />
+18
View File
@@ -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,/);
});
+8 -1
View File
@@ -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);
}
+9 -1
View File
@@ -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;
+2
View File
@@ -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\)/);
});
+40 -20
View File
@@ -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
+4
View File
@@ -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 &&
+3 -1
View File
@@ -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>
+16
View File
@@ -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/);
}
});
+1 -1
View File
@@ -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
View File
@@ -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");
});
+93 -55
View File
@@ -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. */
+69
View File
@@ -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'], '切換主機列表');
});
+1
View File
@@ -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' },
+45
View File
@@ -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({
+23 -1
View File
@@ -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) => (
+11 -1
View File
@@ -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);
});
+2 -1
View File
@@ -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.' };
}
+109 -12
View File
@@ -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,
+170 -4
View File
@@ -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 });
}
});
+10 -1
View File
@@ -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);
});
+50 -7
View File
@@ -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))
),
+245
View File
@@ -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);
});
+417
View File
@@ -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);
});
+114 -33
View File
@@ -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.
+9 -1
View File
@@ -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 };
};
+2
View File
@@ -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,
+1
View File
@@ -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
View File
@@ -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