Compare commits

..
Author SHA1 Message Date
陈大猫 67607870a8 fix: preserve pending recovery passes during focus changes 2026-10-23 12:14:39 +08:00
陈大猫 5311dd3507 fix: avoid repeated atlas rebuilds when switching terminal tabs 2026-10-23 12:01:06 +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
4c82424adf feat(terminal): open current session logs from the toolbar (#3834)
* feat: open saved session logs from terminal toolbar

Add one-click access to the current automatic or manual session log, flush before opening, and reuse application selection with a remembered log preference. Preserve recording controls and current-session ownership across worker routing and reconnects.

Refs #3807

* fix(logging): preserve saved logs on failed reconnect and localize failures

Address both Codex review findings with generation- and identity-checked replacement cleanup and localized user-facing error handling.

* fix(logging): guard successful main-process session replacement

Add shared inactive-log cleanup to fallback start handlers with regression coverage. Registration activation follows separately.

* fix(logging): enable log lifecycle cleanup for main-process fallback

* fix(terminal): keep log cleanup and tuck open-log into More actions

Always end a raw session-log write stream on finalize, including when a
flush already disabled it. Keep immediate snapshot retries, and pause
them only while prepare is waiting so a disk error can be reported.
Show open, reveal, and open-with in the toolbar overflow menu when
logging is active, instead of a default toolbar button.

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

* fix(logging): back off failed snapshot writes and drop retired log actions

Persistent snapshot failures retry on a growing delay instead of a tight
loop, and still flush immediately when new output arrives. Hide open-log
actions after a successful reconnect retires a stopped manual log.

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

* fix(terminal): keep open-log actions after automatic logging is turned off

The current automatic log keeps recording until reconnect, so the menu
stays available for that connection and clears on the next successful
connect.

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

* fix(terminal): derive log menu availability from saved session logs

* fix(terminal): nest session log actions in one More actions submenu

Collapse Open, Show in folder, and the two open-with actions into a Session log submenu that follows the Terminal Encoding menu, and drop the remembered-application hint.

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-22 22:15:21 +08:00
72 changed files with 3348 additions and 135 deletions

No files matched your search

@@ -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\(\)/);
});
+23 -4
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.
+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,
+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
+11
View File
@@ -1,6 +1,15 @@
import type { Messages } from '../types';
export const enTerminalMessages: Messages = {
'terminal.toolbar.openSessionLog': 'Open session log',
'terminal.toolbar.sessionLogActions': 'Session log actions',
'terminal.toolbar.sessionLog.menu': 'Session log',
'terminal.toolbar.sessionLog.open': 'Open',
'terminal.toolbar.sessionLog.reveal': 'Show in folder',
'terminal.toolbar.sessionLog.chooseApplication': 'Open with…',
'terminal.toolbar.sessionLog.systemDefault': 'Open with system default',
'terminal.toolbar.openSessionLogFailed': 'Failed to open session log.',
'terminal.menu.saveScreen': 'Save current screen',
'terminal.saveScreen.failed': 'Failed to save terminal screen.',
// Multi-line paste confirmation dialog (#3398)
@@ -68,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',
+11
View File
@@ -1,6 +1,15 @@
import type { Messages } from '../types';
export const esTerminalMessages: Messages = {
'terminal.toolbar.openSessionLog': 'Abrir registro de sesión',
'terminal.toolbar.sessionLogActions': 'Acciones del registro',
'terminal.toolbar.sessionLog.menu': 'Registro de sesión',
'terminal.toolbar.sessionLog.open': 'Abrir',
'terminal.toolbar.sessionLog.reveal': 'Mostrar en carpeta',
'terminal.toolbar.sessionLog.chooseApplication': 'Abrir con…',
'terminal.toolbar.sessionLog.systemDefault': 'Abrir con la aplicación predeterminada',
'terminal.toolbar.openSessionLogFailed': 'No se pudo abrir el registro.',
'terminal.menu.saveScreen': 'Guardar pantalla actual',
'terminal.saveScreen.failed': 'No se pudo guardar la pantalla del terminal.',
// Diálogo de confirmación de pegado multilínea (#3398)
@@ -68,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': 'Вставить файл',
+11
View File
@@ -1,6 +1,15 @@
import type { Messages } from '../types';
export const ruTerminalMessages: Messages = {
'terminal.toolbar.openSessionLog': 'Открыть журнал сессии',
'terminal.toolbar.sessionLogActions': 'Действия с журналом',
'terminal.toolbar.sessionLog.menu': 'Журнал сессии',
'terminal.toolbar.sessionLog.open': 'Открыть',
'terminal.toolbar.sessionLog.reveal': 'Показать в папке',
'terminal.toolbar.sessionLog.chooseApplication': 'Открыть с помощью…',
'terminal.toolbar.sessionLog.systemDefault': 'Открыть по умолчанию',
'terminal.toolbar.openSessionLogFailed': 'Не удалось открыть журнал.',
'terminal.menu.saveScreen': 'Сохранить текущий экран',
'terminal.saveScreen.failed': 'Не удалось сохранить экран терминала.',
// Диалог подтверждения многострочной вставки (#3398)
@@ -89,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': 'Управление быстрыми сниппетами',
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import en from './en';
import es from './es';
import ru from './ru';
import zhCN from './zh-CN';
import zhTW from './zh-TW';
const keys = [
'terminal.toolbar.openSessionLog',
'terminal.toolbar.sessionLogActions',
'terminal.toolbar.sessionLog.menu',
'terminal.toolbar.sessionLog.open',
'terminal.toolbar.sessionLog.reveal',
'terminal.toolbar.sessionLog.chooseApplication',
'terminal.toolbar.sessionLog.systemDefault',
'terminal.toolbar.openSessionLogFailed',
] as const;
test('saved session log actions are translated in every shipped locale', () => {
for (const [locale, messages] of Object.entries({ en, es, ru, 'zh-CN': zhCN, 'zh-TW': zhTW })) {
for (const key of keys) assert.ok(messages[key], `${locale} is missing ${key}`);
assert.equal(messages['terminal.toolbar.sessionLog.rememberApplication'], undefined);
}
assert.equal(en['terminal.toolbar.sessionLog.menu'], 'Session log');
assert.equal(en['terminal.toolbar.sessionLog.open'], 'Open');
assert.equal(en['terminal.toolbar.sessionLog.reveal'], 'Show in folder');
assert.equal(en['terminal.toolbar.sessionLog.chooseApplication'], 'Open with…');
assert.equal(en['terminal.toolbar.sessionLog.systemDefault'], 'Open with system default');
assert.equal(zhCN['terminal.toolbar.sessionLog.menu'], '会话日志');
assert.equal(zhTW['terminal.toolbar.sessionLog.menu'], '工作階段日誌');
});
@@ -1,6 +1,15 @@
import type { Messages } from '../types';
export const zhCNTerminalMessages: Messages = {
'terminal.toolbar.openSessionLog': '打开会话日志',
'terminal.toolbar.sessionLogActions': '会话日志操作',
'terminal.toolbar.sessionLog.menu': '会话日志',
'terminal.toolbar.sessionLog.open': '打开',
'terminal.toolbar.sessionLog.reveal': '在文件夹中显示',
'terminal.toolbar.sessionLog.chooseApplication': '打开方式…',
'terminal.toolbar.sessionLog.systemDefault': '用系统默认应用打开',
'terminal.toolbar.openSessionLogFailed': '无法打开会话日志。',
'terminal.menu.saveScreen': '保存当前屏幕',
'terminal.saveScreen.failed': '保存终端屏幕失败。',
// 多行粘贴确认对话框(#3398)
@@ -612,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,6 +1,15 @@
import type { Messages } from '../types';
export const zhTWTerminalMessages: Messages = {
'terminal.toolbar.openSessionLog': '開啟工作階段日誌',
'terminal.toolbar.sessionLogActions': '工作階段日誌操作',
'terminal.toolbar.sessionLog.menu': '工作階段日誌',
'terminal.toolbar.sessionLog.open': '開啟',
'terminal.toolbar.sessionLog.reveal': '在資料夾中顯示',
'terminal.toolbar.sessionLog.chooseApplication': '開啟方式…',
'terminal.toolbar.sessionLog.systemDefault': '用系統預設應用程式開啟',
'terminal.toolbar.openSessionLogFailed': '無法開啟工作階段日誌。',
'terminal.menu.saveScreen': '儲存目前畫面',
'terminal.saveScreen.failed': '儲存終端畫面失敗。',
// 多行貼上確認對話框(#3398)
@@ -612,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': '管理快捷程式碼片段',
@@ -0,0 +1,82 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { JSDOM } from 'jsdom';
import { performSessionLogAction } from './sessionLogActions';
import { STORAGE_KEY_SESSION_LOG_APPLICATION } from '../../infrastructure/config/storageKeys';
test('session log actions resolve and flush the selected session, remember apps and reset to default', async () => {
const dom = new JSDOM('', { url: 'http://localhost' });
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const previousStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
Object.defineProperty(globalThis, 'window', { configurable: true, value: dom.window });
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: dom.window.localStorage });
const calls: unknown[][] = [];
let picked: { path: string; name: string } | null = { path: '/Applications/My Editor.app', name: 'My Editor' };
let available = true;
let failOpen = false;
const bridge = {
prepareSessionLog: async (payload: { sessionId: string }) => {
calls.push(['prepare', payload]);
return available ? { success: true, filePath: `/logs/${payload.sessionId}.html` }
: { success: false, error: 'No saved log for this session' };
},
revealSessionLog: async (payload: { sessionId: string }) => {
calls.push(['reveal', payload]);
return { success: true };
},
selectApplication: async () => { calls.push(['choose']); return picked; },
openWithApplication: async (file: string, app: string) => {
calls.push(['application', file, app]);
if (failOpen) throw new Error('Launch failed');
return true;
},
openWithSystemDefault: async (file: string) => { calls.push(['default', file]); return { success: true }; },
};
Object.assign(dom.window, { netcatty: bridge });
try {
await performSessionLogAction('first', 'open');
assert.deepEqual(calls.splice(0), [['prepare', { sessionId: 'first' }], ['default', '/logs/first.html']]);
await performSessionLogAction('second', 'chooseApplication');
assert.deepEqual(calls.splice(0), [
['prepare', { sessionId: 'second' }], ['choose'], ['prepare', { sessionId: 'second' }],
['application', '/logs/second.html', picked!.path],
]);
assert.equal(JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_SESSION_LOG_APPLICATION)!).name, 'My Editor');
await performSessionLogAction('first', 'open');
assert.deepEqual(calls.splice(0), [['prepare', { sessionId: 'first' }], ['application', '/logs/first.html', picked!.path]]);
await performSessionLogAction('first', 'reveal');
assert.deepEqual(calls.splice(0), [['reveal', { sessionId: 'first' }]]);
await performSessionLogAction('first', 'systemDefault');
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_SESSION_LOG_APPLICATION), null);
calls.length = 0;
picked = null;
await performSessionLogAction('first', 'chooseApplication');
assert.deepEqual(calls.splice(0), [['prepare', { sessionId: 'first' }], ['choose']]);
available = false;
await assert.rejects(performSessionLogAction('missing', 'chooseApplication'), /No saved log/);
assert.deepEqual(calls.splice(0), [['prepare', { sessionId: 'missing' }]]);
available = true;
picked = { path: '/missing-editor', name: 'Missing' };
failOpen = true;
await assert.rejects(performSessionLogAction('first', 'chooseApplication'), /Launch failed/);
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_SESSION_LOG_APPLICATION), null);
} finally {
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow);
else Reflect.deleteProperty(globalThis, 'window');
if (previousStorage) Object.defineProperty(globalThis, 'localStorage', previousStorage);
else Reflect.deleteProperty(globalThis, 'localStorage');
dom.window.close();
}
});
// The integration boundary must translate backend errors, including ordinary
// Error instances. Raw filesystem/app-launch details belong only in diagnostics.
test('terminal log action errors use the translated toast instead of raw backend text', () => {
const source = readFileSync(new URL('../../components/Terminal.tsx', import.meta.url), 'utf8');
const handler = source.slice(source.indexOf(' const handleSessionLogAction ='),
source.indexOf(' const handleToggleSessionLog ='));
assert.match(handler, /logger\.error\("\[Terminal\] Failed to open or reveal session log:", err\)/);
assert.match(handler, /toast\.error\(t\('terminal\.toolbar\.openSessionLogFailed'\)\)/);
assert.doesNotMatch(handler, /err\.message/);
});
+48
View File
@@ -0,0 +1,48 @@
import { STORAGE_KEY_SESSION_LOG_APPLICATION } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export type SessionLogAction = 'open' | 'reveal' | 'chooseApplication' | 'systemDefault';
type LogApplication = { path: string; name: string };
/** Resolve the session's actual log in its owning process before opening it. */
export async function performSessionLogAction(sessionId: string, action: SessionLogAction): Promise<void> {
const bridge = netcattyBridge.get();
if (!bridge?.prepareSessionLog) throw new Error('Session log bridge unavailable');
if (action === 'reveal') {
const result = await bridge.revealSessionLog?.({ sessionId });
if (!result?.success) throw new Error(result?.error || 'Failed to reveal session log');
return;
}
// Check availability before presenting an application picker. Resolve again
// afterwards so any output received while the picker was open is flushed.
let result = await bridge.prepareSessionLog({ sessionId });
if (!result.success || !result.filePath) throw new Error(result.error || 'No saved log for this session');
let application = localStorageAdapter.read<LogApplication>(STORAGE_KEY_SESSION_LOG_APPLICATION);
if (action === 'chooseApplication') {
if (!bridge.selectApplication) throw new Error('Application picker unavailable');
application = await bridge.selectApplication();
if (!application) return;
result = await bridge.prepareSessionLog({ sessionId });
if (!result.success || !result.filePath) throw new Error(result.error || 'No saved log for this session');
} else if (action === 'systemDefault') {
application = null;
}
if (application?.path) {
if (!bridge.openWithApplication) throw new Error('Application opening unavailable');
const opened = await bridge.openWithApplication(result.filePath, application.path);
if (!opened) throw new Error('Failed to open session log');
} else {
const opened = await bridge.openWithSystemDefault?.(result.filePath);
if (!opened?.success) throw new Error(opened?.error || 'Failed to open session log');
}
// Only remember a choice after a successful launch; failed launches leave
// the previous preference intact and can be retried or reset from the menu.
if (action === 'chooseApplication') {
localStorageAdapter.write(STORAGE_KEY_SESSION_LOG_APPLICATION, application);
} else if (action === 'systemDefault') {
localStorageAdapter.remove(STORAGE_KEY_SESSION_LOG_APPLICATION);
}
}
@@ -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 () => {
+1 -1
View File
@@ -50,7 +50,7 @@ export const useSessionLogBackend = () => {
const getManualSessionLogStatus = useCallback(async (payload: ManualStatusPayload) => {
const bridge = netcattyBridge.get();
return bridge?.getManualSessionLogStatus?.(payload) ?? { success: false, isLogging: false, error: "Session log bridge unavailable" };
return bridge?.getManualSessionLogStatus?.(payload) ?? { success: false, isLogging: false, hasSessionLog: false, error: "Session log bridge unavailable" };
}, []);
return {
@@ -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,
+45 -5
View File
@@ -73,6 +73,7 @@ import {
} from "../application/state/terminalAutoReconnect";
import { useStoredBoolean } from "../application/state/useStoredBoolean";
import { readOptionalStoredStringValue, useStoredString } from "../application/state/useStoredString";
import { performSessionLogAction, type SessionLogAction } from "../application/state/sessionLogActions";
import { useSessionLogBackend } from "../application/state/useSessionLogBackend";
import { useTerminalLayoutSuppressActive } from "../application/state/terminalLayoutSuppressStore";
import { useAppearanceChromeStore } from "../application/state/appearanceChromeStore";
@@ -363,6 +364,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onOpenTheme,
onOpenSystem,
isBroadcastEnabled,
composeSendToAll,
onToggleComposeSendTarget,
onToggleBroadcast,
onToggleComposeBar,
isWorkspaceComposeBarOpen,
@@ -906,6 +909,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const [isCancelling, setIsCancelling] = useState(false);
const [showSFTP, setShowSFTP] = useState(false);
const [isSessionLogging, setIsSessionLogging] = useState(false);
const [canOpenSessionLog, setCanOpenSessionLog] = useState(false);
const [progressValue, setProgressValue] = useState(CONNECTION_PROGRESS_START);
const [isDisconnectedDialogDismissed, setIsDisconnectedDialogDismissed] = useState(false);
const [connectionReuseFellBack, setConnectionReuseFellBack] = useState(false);
@@ -1792,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 {
@@ -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
@@ -4129,6 +4148,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onClipboardImageUploadResult: handleClipboardImageUploadResult,
});
const handleSessionLogAction = useCallback(async (action: SessionLogAction) => {
try {
await performSessionLogAction(sessionRef.current ?? sessionId, action);
} catch (err) {
logger.error("[Terminal] Failed to open or reveal session log:", err);
toast.error(t('terminal.toolbar.openSessionLogFailed'));
}
}, [sessionId, t]);
const handleToggleSessionLog = useCallback(async () => {
const currentSessionId = sessionRef.current ?? sessionId;
if (!currentSessionId) {
@@ -4178,6 +4206,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (startResult?.success) {
if (!startResult?.started && startResult?.canceled) return;
setIsSessionLogging(!!startResult?.started);
if (startResult?.started) setCanOpenSessionLog(true);
} else {
toast.error(startResult?.error || "Failed to start session log");
}
@@ -4202,14 +4231,21 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const currentSessionId = sessionRef.current ?? sessionId;
if (!currentSessionId) {
setIsSessionLogging(false);
setCanOpenSessionLog(false);
return;
}
let cancelled = false;
void getManualSessionLogStatus({ sessionId: currentSessionId }).then((result) => {
if (!cancelled) setIsSessionLogging(!!result?.isLogging);
if (!cancelled) {
setIsSessionLogging(!!result?.isLogging);
setCanOpenSessionLog(!!result?.hasSessionLog);
}
}).catch(() => {
if (!cancelled) setIsSessionLogging(false);
if (!cancelled) {
setIsSessionLogging(false);
setCanOpenSessionLog(false);
}
});
return () => {
@@ -4285,6 +4321,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
isSearchOpen={isSearchOpen}
onToggleSearch={handleToggleSearch}
showLogButton
onSessionLogAction={handleSessionLogAction}
canOpenSessionLog={canOpenSessionLog}
onToggleSessionLog={handleToggleSessionLog}
isSessionLogging={isSessionLogging}
isSessionLogDisabled={status !== "connected" && !isSessionLogging}
@@ -4317,6 +4355,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
handleSendYmodem,
handleSetTerminalEncoding,
handleToggleSessionLog,
handleSessionLogAction,
canOpenSessionLog,
handleToggleSearch,
handleToolbarRecordingToggle,
host,
@@ -4691,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, 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); }}
+20 -4
View File
@@ -260,8 +260,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onCreateLocalTerminal,
isBroadcastEnabled,
onToggleBroadcast,
isComposeSendToAll,
onToggleComposeSendTarget,
isGlobalBroadcastEnabled,
isGlobalComposeSendToAll,
onToggleGlobalBroadcast,
onToggleGlobalComposeSendTarget,
canUseGlobalBroadcast,
updateHosts,
updateSnippets,
@@ -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,
@@ -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>
)}
+24 -1
View File
@@ -6,7 +6,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
import type { Host } from "../../types.ts";
import { TerminalToolbar } from "./TerminalToolbar.tsx";
import { TERMINAL_TOOLBAR_LAYOUT_DEFAULTS, TerminalToolbar } from "./TerminalToolbar.tsx";
const toolbarSource = readFileSync(new URL("./TerminalToolbar.tsx", import.meta.url), "utf8");
@@ -213,3 +213,26 @@ for (const host of [serialHost, pluginHost]) {
assert.equal(renderToolbar(host).includes('aria-label="Open SFTP"'), false);
});
}
test('open session log stays in the overflow menu until logging is active', () => {
assert.equal(TERMINAL_TOOLBAR_LAYOUT_DEFAULTS.placement.openSessionLog, 'collapse');
const hidden = renderToolbar(sshHost, 'disconnected', { onSessionLogAction: () => {} });
assert.doesNotMatch(hidden, /aria-label="Open session log"/);
assert.doesNotMatch(hidden, /Open session log/);
for (const status of ['connected', 'disconnected'] as const) {
const markup = renderToolbar(sshHost, status, {
onSessionLogAction: () => {},
canOpenSessionLog: true,
});
assert.doesNotMatch(markup, /aria-label="Open session log"/);
assert.doesNotMatch(markup, /aria-label="Session log actions"/);
assert.match(markup, /aria-label="More actions"/);
}
assert.match(toolbarSource, /case 'openSessionLog':/);
assert.match(toolbarSource, /data-session-log-submenu="true"/);
assert.match(toolbarSource, /terminal\.toolbar\.sessionLog\.menu/);
assert.match(toolbarSource, /terminal\.toolbar\.sessionLog\.\$\{action\}/);
assert.equal(toolbarSource.includes('rememberApplication'), false);
assert.equal(toolbarSource.includes('Open session log'), false);
});
+105 -6
View File
@@ -6,6 +6,8 @@
import {
Check,
ChevronRight,
ChevronDown,
FileSearch,
Circle,
Download,
FileText,
@@ -25,6 +27,7 @@ import {
} from 'lucide-react';
import React, { useMemo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { SessionLogAction } from '../../application/state/sessionLogActions';
import { useToolbarItemLayout } from '../../application/state/useToolbarItemLayout';
import type { ToolbarItemLayoutDefaults } from '../../domain/toolbarItemLayout';
import { STORAGE_KEY_TERMINAL_TOOLBAR_LAYOUT } from '../../infrastructure/config/storageKeys';
@@ -51,6 +54,7 @@ export const TERMINAL_TOOLBAR_ITEM_IDS = [
'compose',
'search',
'sessionLog',
'openSessionLog',
'scripts',
'history',
'configureOsc7',
@@ -73,6 +77,7 @@ export const TERMINAL_TOOLBAR_LAYOUT_DEFAULTS: ToolbarItemLayoutDefaults = {
compose: 'show',
search: 'show',
sessionLog: 'show',
openSessionLog: 'collapse',
scripts: 'show',
history: 'collapse',
configureOsc7: 'collapse',
@@ -114,6 +119,9 @@ export interface TerminalToolbarProps {
// Manual session log
showLogButton?: boolean;
onToggleSessionLog?: () => void;
onSessionLogAction?: (action: SessionLogAction) => void;
/** Automatic logging is enabled, or this session has started a manual log. */
canOpenSessionLog?: boolean;
isSessionLogging?: boolean;
isSessionLogDisabled?: boolean;
// Compose bar
@@ -152,6 +160,8 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
onToggleSearch,
showLogButton = false,
onToggleSessionLog,
onSessionLogAction,
canOpenSessionLog = false,
isSessionLogging = false,
isSessionLogDisabled = false,
isComposeBarOpen,
@@ -196,12 +206,13 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
const pendingScriptDeleteCount = (pendingScriptDeleteIds ?? []).filter((id) =>
snippets.some((snippet) => snippet.id === id),
).length;
// Overflow popover + encoding submenu are both controlled so that
// picking an encoding closes the whole chain, and so the parent popover
// can ignore clicks that land in the submenu portal (otherwise the
// submenu click would read as "outside" and dismiss the parent).
// Overflow popover and its submenus are controlled so that picking an
// item closes the whole chain, and so the parent popover can ignore
// clicks that land in a submenu portal (otherwise the submenu click
// would read as "outside" and dismiss the parent).
const [overflowOpen, setOverflowOpen] = useState(false);
const [encodingSubmenuOpen, setEncodingSubmenuOpen] = useState(false);
const [sessionLogSubmenuOpen, setSessionLogSubmenuOpen] = useState(false);
const buttonBase =
'h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)] bg-transparent hover:bg-transparent';
@@ -248,6 +259,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
ids.push('ymodemSend', 'ymodemReceive');
}
if (showLogButton) ids.push('sessionLog');
if (onSessionLogAction && canOpenSessionLog) ids.push('openSessionLog');
if (historySupported) ids.push('history');
if (onConfigureOsc7 && !hidesSftp) ids.push('configureOsc7');
if (onStartRecording) ids.push('recording');
@@ -264,6 +276,8 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
onSetTerminalEncoding,
onStartRecording,
showLogButton,
onSessionLogAction,
canOpenSessionLog,
]);
const itemLabels = useMemo(
@@ -278,6 +292,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
sessionLog: isSessionLogging
? t('terminal.toolbar.stopSessionLog')
: t('terminal.toolbar.startSessionLog'),
openSessionLog: t('terminal.toolbar.sessionLog.menu'),
scripts: t('terminal.toolbar.scripts'),
history: t('terminal.toolbar.history'),
configureOsc7: t('terminal.toolbar.configureOsc7'),
@@ -298,6 +313,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
compose: <TextCursorInput size={14} />,
search: <Search size={14} />,
sessionLog: <FileText size={14} />,
openSessionLog: <FileSearch size={14} />,
scripts: <Zap size={14} />,
history: <History size={14} />,
configureOsc7: <FolderSync size={14} />,
@@ -437,6 +453,52 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
);
}
const renderSessionLogSubmenu = () => {
if (!onSessionLogAction) return null;
return (
<Popover open={sessionLogSubmenuOpen} onOpenChange={setSessionLogSubmenuOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={menuItemClass}
aria-haspopup="menu"
aria-expanded={sessionLogSubmenuOpen}
>
<FileSearch size={12} className="shrink-0" />
<span className="flex-1 text-left truncate">{t('terminal.toolbar.sessionLog.menu')}</span>
<ChevronRight size={12} className="shrink-0 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent
data-session-log-submenu="true"
data-section="terminal-toolbar-menu"
className="w-56 p-1"
side="right"
align="start"
sideOffset={6}
>
{(['open', 'reveal', 'chooseApplication', 'systemDefault'] as const).map((action) => (
<button
key={action}
type="button"
className={menuItemClass}
onClick={() => {
onSessionLogAction(action);
setSessionLogSubmenuOpen(false);
setOverflowOpen(false);
}}
>
<FileSearch size={12} className="shrink-0" />
<span className="flex-1 text-left truncate">
{t(`terminal.toolbar.sessionLog.${action}`)}
</span>
</button>
))}
</PopoverContent>
</Popover>
);
};
const renderEncodingSubmenu = () => {
if (!encodingSwitchSupported || !onSetTerminalEncoding) return null;
return (
@@ -631,6 +693,38 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
<TooltipContent side="bottom">{t('terminal.toolbar.searchTerminal')}</TooltipContent>
</Tooltip>
);
case 'openSessionLog':
return (
<div key={id} className="flex items-center gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button type="button" variant="secondary" size="icon" className={buttonBase}
aria-label={t('terminal.toolbar.openSessionLog')}
onClick={() => onSessionLogAction?.('open')}>
<FileSearch size={12} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{t('terminal.toolbar.openSessionLog')}</TooltipContent>
</Tooltip>
<Popover>
<PopoverTrigger asChild>
<Button type="button" variant="secondary" size="icon" className={cn(buttonBase, 'w-4')}
aria-label={t('terminal.toolbar.sessionLogActions')}>
<ChevronDown size={10} />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-56 p-1">
{(['reveal', 'chooseApplication', 'systemDefault'] as const).map(action => (
<PopoverClose asChild key={action}>
<button type="button" className={menuItemClass} onClick={() => onSessionLogAction?.(action)}>
{t(`terminal.toolbar.sessionLog.${action}`)}
</button>
</PopoverClose>
))}
</PopoverContent>
</Popover>
</div>
);
case 'sessionLog':
return (
<Tooltip key={id}>
@@ -903,6 +997,8 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
</button>
</PopoverClose>
);
case 'openSessionLog':
return <React.Fragment key={id}>{renderSessionLogSubmenu()}</React.Fragment>;
case 'sessionLog':
return (
<PopoverClose asChild key={id}>
@@ -1020,7 +1116,10 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
open={overflowOpen}
onOpenChange={(open) => {
setOverflowOpen(open);
if (!open) setEncodingSubmenuOpen(false);
if (!open) {
setEncodingSubmenuOpen(false);
setSessionLogSubmenuOpen(false);
}
}}
>
<Tooltip>
@@ -1048,7 +1147,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
// "outside" this popover; without this guard a click
// in the submenu would dismiss the parent.
const target = e.target as Element | null;
if (target?.closest('[data-encoding-submenu="true"]')) {
if (target?.closest('[data-encoding-submenu="true"], [data-session-log-submenu="true"]')) {
e.preventDefault();
}
}}
+10
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,
+7 -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'
@@ -1328,6 +1328,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 +1356,8 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
termRef.current?.focus();
}}
isBroadcastEnabled={isBroadcastEnabled}
composeSendToAll={composeSendToAll}
onToggleComposeSendTarget={onToggleComposeSendTarget}
themeColors={effectiveTheme.colors}
/>
)}
@@ -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/);
});
+8
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
@@ -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\)/);
});
+21 -14
View File
@@ -1330,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);
@@ -1400,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
@@ -1476,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).
@@ -1700,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]);
@@ -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;
@@ -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}
@@ -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 &&
+27
View File
@@ -0,0 +1,27 @@
# Saved session logs
Recording and opening a session log stay separate.
- **Start / Stop session log** controls manual recording.
- **Session log** lives in the terminal toolbar's existing **More actions**
menu, as one submenu with **Open**, **Show in folder**, **Open with…**,
and **Open with system default**. It is not a default toolbar button.
Toolbar customization can show the shortcut on the bar or hide it. When it
is shown, the arrow beside it offers the same reveal and open-with actions.
The submenu appears when this session has a saved log, and it stays available
after recording stops. A successful reconnect drops a stopped log from the
old connection, and the submenu goes with it. A failed reconnect keeps it.
Pending terminal output is flushed before opening; recording continues
normally. Automatic logs and manually recorded logs both open this way.
A successfully selected application is remembered locally for subsequent log
opens, independently of SFTP file associations. Choosing the system default
clears this preference. Text, raw, and HTML logs retain their original format.
The last log remains accessible after recording stops or a session disconnects.
After a successful reconnect, a stopped log from the old connection is not reused.
If reconnecting fails, the previous saved log remains available.
Manual recording that is still active remains the current log. If the current session has no saved log,
or the file was moved or deleted, the action reports an error rather than
opening an unrelated file. The action does not enable logging automatically.
+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) => (
+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);
});
@@ -41,6 +41,8 @@ function createBridgeRegistrarForTest({
tempDir,
sftpClients = new Map(),
transferBridge = createNoopBridge(),
sshBridge = null,
terminalBridge = null,
electronModuleOverrides = {},
}) {
const noopBridge = createNoopBridge();
@@ -81,12 +83,12 @@ function createBridgeRegistrarForTest({
sftpClients,
CLOUD_SYNC_PASSWORD_FILE: "cloud-sync-password",
getCliDiscoveryFilePath: () => path.join(tempDir, "cli-discovery.json"),
sshBridge: { ...noopBridge, ensureMoshStatsConnection() {} },
sshBridge: sshBridge || { ...noopBridge, ensureMoshStatsConnection() {} },
sftpBridge,
localFsBridge: noopBridge,
transferBridge,
portForwardingBridge: noopBridge,
terminalBridge: { ...noopBridge, execOnEtSession() {} },
terminalBridge: terminalBridge || { ...noopBridge, execOnEtSession() {} },
crashLogBridge: noopBridge,
ptyProcessTree: { getChildProcesses: () => [] },
getOauthBridge: () => ({ setupOAuthBridge() {} }),
@@ -250,3 +252,33 @@ test("downloadToTempWithProgress proxies transfer work to the terminal worker in
cancelled: false,
});
});
for (const mode of ["disabled", "unavailable", "worker"]) {
test(`registrar enables saved-log restart guards only for main fallback (${mode})`, async (t) => {
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-log-fallback-test-"));
const previousWorkerSetting = process.env.NETCATTY_TERMINAL_WORKER;
process.env.NETCATTY_TERMINAL_WORKER = mode === "disabled" ? "0" : "1";
t.after(async () => {
if (previousWorkerSetting === undefined) delete process.env.NETCATTY_TERMINAL_WORKER;
else process.env.NETCATTY_TERMINAL_WORKER = previousWorkerSetting;
await fs.promises.rm(tempDir, { recursive: true, force: true });
});
const observed = {};
const bridgeSpy = (name) => ({
...createNoopBridge(),
registerHandlers(_ipcMain, options) { observed[name] = options; },
});
const registerBridges = createBridgeRegistrarForTest({
ipcMain: createIpcMainStub(), tempDir,
sshBridge: bridgeSpy("ssh"), terminalBridge: bridgeSpy("terminal"),
electronModuleOverrides: mode === "unavailable" ? {} : {
utilityProcess: { fork() { throw new Error("registration must not cold-start the worker"); } },
},
});
registerBridges({});
for (const name of ["ssh", "terminal"]) {
assert.equal(observed[name].manageSessionLogRestart, mode !== "worker");
assert.equal(Boolean(observed[name].terminalWorkerManager), mode === "worker");
}
});
}
+188 -18
View File
@@ -16,9 +16,16 @@ const { createProgrammaticCommandLogRewriter } = require("./programmaticCommandL
// Active log streams keyed by sessionId
const activeStreams = new Map();
// Keep only the latest server-owned target for each session, including while
// finalization is in flight. Finished records retain no terminal/parser data.
const sessionLogs = new Map();
// Buffer flush interval (ms)
const FLUSH_INTERVAL = 500;
// A failed snapshot retries without waiting for more output, but not in a
// tight loop. The delay grows while the same failure persists.
const SNAPSHOT_RETRY_MIN_MS = 250;
const SNAPSHOT_RETRY_MAX_MS = 5000;
// Max buffer size before immediate flush (bytes)
const MAX_BUFFER_SIZE = 64 * 1024;
const SUDO_AUTOFILL_MARKER_PATTERN = /__NETCATTY_SUDO_[a-z0-9_]+__/gi;
@@ -120,6 +127,7 @@ function startStream(sessionId, opts) {
console.warn(`[SessionLogStream] Stream already active for ${sessionId}, stopping old one`);
stopStream(sessionId, activeStreams.get(sessionId)?.startToken);
}
forgetSessionLog(sessionId);
const { hostLabel, hostname, directory, format, startTime } = opts;
if (!directory) {
@@ -166,16 +174,6 @@ function createStreamEntry(sessionId, opts) {
? fs.createWriteStream(filePath, { flags: "w", encoding: "utf8" })
: null;
if (writeStream) {
writeStream.on("error", (err) => {
console.error(`[SessionLogStream] Write error for ${sessionId}:`, err.message);
const entry = activeStreams.get(sessionId);
if (entry) {
entry.disabled = true;
}
});
}
const startToken = Symbol("session-log-stream");
const entry = {
writeStream,
@@ -202,6 +200,13 @@ function createStreamEntry(sessionId, opts) {
snapshotPromise: null,
snapshotRequested: false,
snapshotDirty: false,
snapshotError: null,
snapshotRetryTimer: null,
snapshotRetryDelay: 0,
// Set only while prepareSessionLog is waiting on a snapshot, so that
// caller can observe the failure before the delayed retry starts.
deferSnapshotRetry: false,
error: null,
closing: false,
disabled: false,
startToken,
@@ -210,11 +215,21 @@ function createStreamEntry(sessionId, opts) {
pendingInitialLineLeadingCarriageReturn: false,
};
if (writeStream) {
writeStream.on("error", (err) => {
console.error(`[SessionLogStream] Write error for ${sessionId}:`, err.message);
// A delayed error from an old connection must not disable a new stream.
entry.disabled = true;
entry.error = err;
});
}
entry.flushTimer = setInterval(() => {
flushBuffer(entry);
}, FLUSH_INTERVAL);
activeStreams.set(sessionId, entry);
sessionLogs.set(sessionId, { entry, filePath, startToken, stopPromise: null, error: null });
console.log(`[SessionLogStream] Started stream for ${sessionId} -> ${filePath}`);
return startToken;
}
@@ -283,6 +298,7 @@ function flushBuffer(entry) {
} catch (err) {
console.error("[SessionLogStream] Flush error:", err.message);
entry.disabled = true;
entry.error = err;
}
}
@@ -318,9 +334,27 @@ function renderSnapshotContent(entry, { finalize = false } = {}) {
: content;
}
function clearSnapshotRetry(entry) {
if (!entry?.snapshotRetryTimer) return;
clearTimeout(entry.snapshotRetryTimer);
entry.snapshotRetryTimer = null;
}
function scheduleSnapshotRetry(entry) {
if (!entry || entry.closing || entry.disabled || entry.isRaw || entry.snapshotRetryTimer) return;
const delay = entry.snapshotRetryDelay || SNAPSHOT_RETRY_MIN_MS;
entry.snapshotRetryTimer = setTimeout(() => {
entry.snapshotRetryTimer = null;
if (entry.closing || entry.disabled) return;
scheduleSnapshot(entry);
}, delay);
entry.snapshotRetryDelay = Math.min(delay * 2, SNAPSHOT_RETRY_MAX_MS);
}
function scheduleSnapshot(entry) {
if (!entry || entry.disabled || entry.isRaw || entry.closing) return;
if (!entry.snapshotDirty) return;
clearSnapshotRetry(entry);
if (entry.snapshotPromise) {
entry.snapshotRequested = true;
@@ -328,18 +362,26 @@ function scheduleSnapshot(entry) {
}
entry.snapshotDirty = false;
entry.snapshotError = null;
entry.snapshotPromise = fs.promises
.writeFile(entry.filePath, renderSnapshotContent(entry), "utf8")
.then(() => {
entry.snapshotRetryDelay = 0;
})
.catch((err) => {
console.error("[SessionLogStream] Snapshot write failed:", err.message);
entry.snapshotDirty = true;
entry.snapshotError = err;
})
.finally(() => {
entry.snapshotPromise = null;
if ((entry.snapshotRequested || entry.snapshotDirty) && !entry.closing) {
entry.snapshotRequested = false;
scheduleSnapshot(entry);
}
// prepareSessionLog must see this failure. Other callers retry on a
// delay, or sooner when new output is flushed.
if (entry.snapshotError && entry.deferSnapshotRetry) return;
if (!(entry.snapshotRequested || entry.snapshotDirty) || entry.closing) return;
entry.snapshotRequested = false;
if (entry.snapshotError) scheduleSnapshotRetry(entry);
else scheduleSnapshot(entry);
});
}
@@ -432,6 +474,21 @@ async function stopStream(sessionId, expectedToken) {
activeStreams.delete(sessionId);
entry.closing = true;
const record = sessionLogs.get(sessionId);
const stopPromise = finalizeStream(sessionId, entry);
if (record?.entry === entry) record.stopPromise = stopPromise;
const finalPath = await stopPromise;
if (record?.entry === entry) {
record.entry = null;
record.stopPromise = null;
record.error = finalPath ? null : (entry.error || new Error("Failed to finalize session log"));
}
return finalPath;
}
async function finalizeStream(sessionId, entry) {
clearSnapshotRetry(entry);
// Stop periodic flush
if (entry.flushTimer) {
clearInterval(entry.flushTimer);
@@ -451,11 +508,21 @@ async function stopStream(sessionId, expectedToken) {
flushBuffer(entry);
await waitForSnapshotIdle(entry);
// Close the raw write stream and wait for it to finish.
// Close the raw write stream and wait for it to finish. A flush that
// already disabled the entry must still end the stream, or the fd leaks.
if (entry.writeStream) {
await new Promise((resolve) => {
entry.writeStream.end(resolve);
});
let closeError = null;
try {
await new Promise((resolve, reject) => {
entry.writeStream.end((err) => err ? reject(err) : resolve());
});
} catch (err) {
closeError = err;
}
if (entry.disabled || closeError) {
entry.disabled = true;
entry.error = entry.error || closeError || new Error("Session log stream failed");
}
} else if (!entry.disabled) {
try {
await fs.promises.writeFile(entry.filePath, renderSnapshotContent(entry, { finalize: true }), "utf8");
@@ -463,6 +530,7 @@ async function stopStream(sessionId, expectedToken) {
} catch (err) {
console.error(`[SessionLogStream] Final snapshot write failed for ${sessionId}:`, err.message);
entry.disabled = true;
entry.error = err;
}
}
@@ -472,6 +540,101 @@ async function stopStream(sessionId, expectedToken) {
return entry.disabled ? null : finalPath;
}
/** Flush the session's current log without ending recording, or return its
* last finalized log. Paths are never inferred from labels or renderer input.
*/
async function prepareSessionLog(sessionId) {
const record = sessionLogs.get(sessionId);
if (!record) return null;
if (record.stopPromise) {
await record.stopPromise;
} else if (record.entry) {
const entry = record.entry;
if (entry.disabled) throw entry.error || new Error("Session log stream failed");
flushBuffer(entry);
if (entry.disabled) throw entry.error || new Error("Failed to flush session log");
if (entry.isRaw) {
// An empty write is a barrier for all earlier writes, not just the
// manager's buffer. Calling write() alone does not make the file ready.
await new Promise((resolve, reject) => {
entry.writeStream.write("", (err) => err ? reject(err) : resolve());
});
} else {
// Also materialize an empty txt/html log before its first output arrives.
// Hold the retry until this wait returns, then resume on the backoff timer.
entry.snapshotDirty = true;
entry.deferSnapshotRetry = true;
try {
scheduleSnapshot(entry);
await waitForSnapshotIdle(entry);
if (entry.snapshotError) throw entry.snapshotError;
} finally {
entry.deferSnapshotRetry = false;
if (!entry.snapshotPromise && !entry.closing
&& (entry.snapshotRequested || entry.snapshotDirty)) {
if (entry.snapshotError) scheduleSnapshotRetry(entry);
else scheduleSnapshot(entry);
}
}
}
// Stop may have begun while the flush was pending; wait for its final file.
if (record.stopPromise) await record.stopPromise;
}
if (sessionLogs.get(sessionId) !== record) {
throw new Error("Session log changed; try again");
}
if (record.error || record.entry?.disabled) {
throw record.error || record.entry.error || new Error("Session log stream failed");
}
const stat = await fs.promises.stat(record.filePath);
if (!stat.isFile()) throw new Error("Session log is not a file");
if (sessionLogs.get(sessionId) !== record) {
throw new Error("Session log changed; try again");
}
return record.filePath;
}
/** Invalidate a previous connection's target without disturbing its writer.
* Late finalization cannot restore it because records are identity-scoped.
*/
function forgetSessionLog(sessionId) {
sessionLogs.delete(sessionId);
}
function getSessionLogToken(sessionId) {
return sessionLogs.get(sessionId)?.startToken ?? null;
}
/** Drop only the inactive target observed before an asynchronous reconnect.
* A continuing manual stream or a newly-created/finalized log must survive.
*/
function forgetInactiveSessionLog(sessionId, expectedToken) {
if (!expectedToken || activeStreams.has(sessionId)
|| getSessionLogToken(sessionId) !== expectedToken) return false;
return sessionLogs.delete(sessionId);
}
/** Main-process fallback starts do not pass through the worker's lifecycle
* guard. Retire their prior inactive log only after a successful same-id start.
*/
function withSessionLogRestart(handler) {
return async (event, payload) => {
const sessionId = payload?.sessionId;
const previousToken = getSessionLogToken(sessionId);
const result = await handler(event, payload);
if (sessionId && result?.sessionId === sessionId && result.success !== false && !result.error) {
forgetInactiveSessionLog(sessionId, previousToken);
}
return result;
};
}
function hasSessionLog(sessionId) {
return sessionLogs.has(sessionId);
}
/**
* Check if a session has an active log stream.
* @param {string} sessionId
@@ -502,6 +665,7 @@ async function cleanupAll() {
console.log(`[SessionLogStream] Cleaning up ${activeStreams.size} active streams`);
const entries = [...activeStreams.entries()];
await Promise.allSettled(entries.map(([id, entry]) => stopStream(id, entry.startToken)));
sessionLogs.clear();
}
module.exports = {
@@ -511,6 +675,12 @@ module.exports = {
registerSudoAutofillInput,
registerProgrammaticCommandLogRewrite,
stopStream,
prepareSessionLog,
forgetSessionLog,
getSessionLogToken,
forgetInactiveSessionLog,
withSessionLogRestart,
hasSessionLog,
hasStream,
getActiveLogPaths,
cleanupAll,
@@ -5,6 +5,12 @@ const path = require("node:path");
const {
appendData,
hasStream,
hasSessionLog,
prepareSessionLog,
forgetSessionLog,
getSessionLogToken,
forgetInactiveSessionLog,
withSessionLogRestart,
registerProgrammaticCommandLogRewrite,
registerSudoAutofillInput,
startStream,
@@ -929,3 +935,293 @@ for (const format of ["txt", "html"]) {
}
});
}
for (const format of ["txt", "html", "raw"]) {
test(`prepare ${format} log flushes current output and leaves recording active`, async (t) => {
const directory = path.join(TEMP_ROOT, `prepare-${format}-${Date.now()}`);
const filePath = path.join(directory, `session.${format}`);
const sessionId = `prepare-${format}`;
t.after(async () => {
await stopStream(sessionId);
forgetSessionLog(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
});
startStreamToFile(sessionId, { filePath, format });
assert.equal(await prepareSessionLog(sessionId), filePath, "empty logs must exist before opening");
assert.equal(fs.statSync(filePath).isFile(), true);
appendData(sessionId, "first\r\n");
assert.equal(await prepareSessionLog(sessionId), filePath);
assert.match(fs.readFileSync(filePath, "utf8"), /first/);
assert.equal(hasStream(sessionId), true);
appendData(sessionId, "second\r\n");
await prepareSessionLog(sessionId);
assert.match(fs.readFileSync(filePath, "utf8"), /first[\s\S]*second/);
const stopping = stopStream(sessionId);
assert.equal(await prepareSessionLog(sessionId), filePath, "prepare waits for finalization");
assert.equal(await stopping, filePath);
assert.equal(hasStream(sessionId), false);
assert.equal(await prepareSessionLog(sessionId), filePath, "stopped logs remain accessible");
assert.equal(await prepareSessionLog(`${sessionId}-wrong`), null);
fs.unlinkSync(filePath);
await assert.rejects(prepareSessionLog(sessionId), /ENOENT/);
});
}
test("prepare raw log waits for the write callback, including already-queued writes", async (t) => {
const { Writable } = require("node:stream");
const directory = path.join(TEMP_ROOT, `prepare-raw-barrier-${Date.now()}`);
const filePath = path.join(directory, "session.log");
const sessionId = "prepare-raw-barrier";
let completedWrites = 0;
t.mock.method(fs, "createWriteStream", () => new Writable({
write(chunk, _encoding, callback) {
setTimeout(() => {
fs.appendFileSync(filePath, chunk);
completedWrites++;
callback();
}, 15);
},
}));
t.after(async () => {
await stopStream(sessionId);
forgetSessionLog(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
});
startStreamToFile(sessionId, { filePath, format: "raw" });
appendData(sessionId, "x".repeat(64 * 1024));
const preparing = prepareSessionLog(sessionId);
assert.equal(completedWrites, 0);
assert.equal(await preparing, filePath);
assert.equal(completedWrites, 2, "data write and barrier callbacks must both complete");
assert.equal(fs.readFileSync(filePath, "utf8").length, 64 * 1024);
});
test("prepare reports snapshot failures promptly and background writes keep retrying", async (t) => {
const directory = path.join(TEMP_ROOT, `prepare-error-${Date.now()}`);
const filePath = path.join(directory, "session.txt");
const sessionId = "prepare-snapshot-error";
const writeFile = fs.promises.writeFile.bind(fs.promises);
let fail = true;
let attempts = 0;
t.mock.method(fs.promises, "writeFile", async (...args) => {
if (args[0] === filePath) {
attempts++;
if (fail) throw new Error("disk full");
}
return writeFile(...args);
});
t.after(async () => {
fail = false;
await stopStream(sessionId);
forgetSessionLog(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
});
startStreamToFile(sessionId, { filePath, format: "txt" });
appendData(sessionId, "not lost\n");
await assert.rejects(prepareSessionLog(sessionId), /disk full/);
const attemptsAtReport = attempts;
assert.ok(attemptsAtReport >= 1);
fail = false;
const started = Date.now();
while (!fs.existsSync(filePath) || !fs.readFileSync(filePath, "utf8").includes("not lost")) {
if (Date.now() - started > 2000) {
assert.fail(`snapshot retry did not persist the log (attempts=${attempts})`);
}
await new Promise((resolve) => setTimeout(resolve, 10));
}
assert.ok(attempts > attemptsAtReport, "a failed snapshot write retries without another prepare");
assert.equal(await prepareSessionLog(sessionId), filePath);
});
test("persistent snapshot failures back off instead of retrying immediately", async (t) => {
const directory = path.join(TEMP_ROOT, `snapshot-backoff-${Date.now()}`);
const filePath = path.join(directory, "session.txt");
const sessionId = "snapshot-backoff";
const writeFile = fs.promises.writeFile.bind(fs.promises);
let attempts = 0;
t.mock.method(fs.promises, "writeFile", async (...args) => {
if (args[0] === filePath) {
attempts++;
throw new Error("disk full");
}
return writeFile(...args);
});
t.after(async () => {
await stopStream(sessionId);
forgetSessionLog(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
});
startStreamToFile(sessionId, { filePath, format: "txt" });
appendData(sessionId, "x".repeat(64 * 1024));
await new Promise((resolve) => setTimeout(resolve, 40));
assert.equal(attempts, 1, "a failed snapshot must not retry in a tight loop");
await new Promise((resolve) => setTimeout(resolve, 400));
assert.equal(attempts, 2, "the first retry waits for the backoff delay");
});
test("finalize ends a raw write stream that a synchronous flush already disabled", async (t) => {
const directory = path.join(TEMP_ROOT, `finalize-disabled-${Date.now()}`);
const filePath = path.join(directory, "session.log");
const sessionId = "finalize-disabled-raw";
const stream = {
ended: false,
on() { return this; },
write() { throw new Error("flush failed"); },
end(callback) {
this.ended = true;
if (typeof callback === "function") callback();
},
};
t.mock.method(fs, "createWriteStream", () => stream);
t.after(async () => {
await stopStream(sessionId);
forgetSessionLog(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
});
const started = startStreamToFile(sessionId, { filePath, format: "raw" });
assert.equal(started.ok, true);
appendData(sessionId, "pending");
assert.equal(await stopStream(sessionId), null);
assert.equal(stream.ended, true);
});
test("prepare reports raw stream errors rather than opening an empty or stale file", async (t) => {
const directory = path.join(TEMP_ROOT, `prepare-raw-error-${Date.now()}`);
const filePath = path.join(directory, "directory.log");
const sessionId = "prepare-raw-error";
fs.mkdirSync(filePath, { recursive: true });
t.after(async () => {
await stopStream(sessionId);
forgetSessionLog(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
});
startStreamToFile(sessionId, { filePath, format: "raw" });
appendData(sessionId, "data");
await assert.rejects(prepareSessionLog(sessionId), /EISDIR/);
assert.equal(await stopStream(sessionId), null);
await assert.rejects(prepareSessionLog(sessionId), /EISDIR/);
});
test("late finalization and stale tokens cannot replace a reconnected session's target", async (t) => {
const directory = path.join(TEMP_ROOT, `prepare-reconnect-${Date.now()}`);
const oldPath = path.join(directory, "old.txt");
const newPath = path.join(directory, "new.txt");
const sessionId = "prepare-reconnect";
const writeFile = fs.promises.writeFile.bind(fs.promises);
let releaseOldWrite;
const oldWriteGate = new Promise((resolve) => { releaseOldWrite = resolve; });
t.mock.method(fs.promises, "writeFile", async (...args) => {
if (args[0] === oldPath) await oldWriteGate;
return writeFile(...args);
});
t.after(async () => {
releaseOldWrite();
await stopStream(sessionId);
forgetSessionLog(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
});
const first = startStreamToFile(sessionId, { filePath: oldPath, format: "txt" });
appendData(sessionId, "old\n");
const stopping = stopStream(sessionId, first.token);
const stalePrepare = prepareSessionLog(sessionId);
const second = startStreamToFile(sessionId, { filePath: newPath, format: "txt" });
assert.equal(second.ok, true);
appendData(sessionId, "new\n");
releaseOldWrite();
await assert.rejects(stalePrepare, /changed/);
assert.equal(await stopping, oldPath);
assert.equal(await stopStream(sessionId, first.token), null);
assert.equal(await prepareSessionLog(sessionId), newPath);
await stopStream(sessionId, second.token);
assert.equal(await prepareSessionLog(sessionId), newPath);
});
test("forgetting a session prevents late finalization from restoring its old path", async (t) => {
const directory = path.join(TEMP_ROOT, `prepare-forget-${Date.now()}`);
const filePath = path.join(directory, "old.txt");
const sessionId = "prepare-forget";
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
startStreamToFile(sessionId, { filePath, format: "txt" });
appendData(sessionId, "old\n");
const stopping = stopStream(sessionId);
forgetSessionLog(sessionId);
await stopping;
assert.equal(hasSessionLog(sessionId), false);
assert.equal(await prepareSessionLog(sessionId), null);
});
test("conditional log invalidation clears only the observed inactive stream token", async (t) => {
const directory = path.join(TEMP_ROOT, `conditional-forget-${Date.now()}`);
const sessionId = "conditional-forget";
const oldPath = path.join(directory, "old.txt");
const newPath = path.join(directory, "new.txt");
t.after(async () => {
await stopStream(sessionId);
forgetSessionLog(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
});
assert.equal(getSessionLogToken(sessionId), null);
assert.equal(forgetInactiveSessionLog(sessionId, null), false);
const first = startStreamToFile(sessionId, { filePath: oldPath, format: "txt" });
assert.equal(getSessionLogToken(sessionId), first.token);
assert.equal(forgetInactiveSessionLog(sessionId, first.token), false, "active recording survives");
await stopStream(sessionId);
assert.equal(getSessionLogToken(sessionId), first.token, "finalization preserves record identity");
assert.equal(forgetInactiveSessionLog(sessionId, Symbol("wrong")), false);
assert.equal(forgetInactiveSessionLog(sessionId, first.token), true);
assert.equal(await prepareSessionLog(sessionId), null);
const second = startStreamToFile(sessionId, { filePath: newPath, format: "txt" });
assert.notEqual(second.token, first.token);
assert.equal(forgetInactiveSessionLog(sessionId, first.token), false);
await stopStream(sessionId);
assert.equal(forgetInactiveSessionLog(sessionId, first.token), false, "a new finalized log survives");
assert.equal(await prepareSessionLog(sessionId), newPath);
});
for (const outcome of ["success", "throw", "failure", "different-session", "new-active", "new-finalized", "manual"]) {
test(`fallback restart wrapper preserves the correct saved log (${outcome})`, async (t) => {
const directory = path.join(TEMP_ROOT, `fallback-${outcome}-${Date.now()}`);
const sessionId = `fallback-${outcome}`;
const oldPath = path.join(directory, "old.txt");
const newPath = path.join(directory, "new.txt");
const event = { sender: { id: 9 } };
const payload = { sessionId, sessionLog: { enabled: false } };
t.after(async () => {
await stopStream(sessionId, getSessionLogToken(sessionId));
forgetSessionLog(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
});
startStreamToFile(sessionId, { filePath: oldPath, format: "txt", stopRequiresToken: outcome === "manual" });
appendData(sessionId, "previous output\n");
if (outcome !== "manual") await stopStream(sessionId);
const previousToken = getSessionLogToken(sessionId);
const start = withSessionLogRestart(async (receivedEvent, receivedPayload) => {
assert.equal(receivedEvent, event);
assert.equal(receivedPayload, payload);
assert.equal(await prepareSessionLog(sessionId), oldPath, "pending starts retain the old target");
if (outcome === "throw") throw new Error("authentication failed");
if (outcome === "failure") return { sessionId, success: false, error: "connection refused" };
if (outcome === "different-session") return { sessionId: "different" };
if (outcome.startsWith("new-")) {
startStreamToFile(sessionId, { filePath: newPath, format: "txt" });
appendData(sessionId, "new output\n");
if (outcome === "new-finalized") await stopStream(sessionId);
}
return { sessionId };
});
if (outcome === "throw") await assert.rejects(start(event, payload), /authentication failed/);
else {
const result = await start(event, payload);
assert.equal(result.sessionId, outcome === "different-session" ? "different" : sessionId);
}
if (outcome === "success") {
assert.equal(await prepareSessionLog(sessionId), null);
} else {
const newLog = outcome.startsWith("new-");
assert.equal(await prepareSessionLog(sessionId), newLog ? newPath : oldPath);
if (newLog) assert.notEqual(getSessionLogToken(sessionId), previousToken);
else assert.equal(getSessionLogToken(sessionId), previousToken);
assert.equal(hasStream(sessionId), outcome === "manual" || outcome === "new-active");
}
});
}
+112 -7
View File
@@ -6,7 +6,7 @@
const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
const { dialog } = require("electron");
const { dialog, shell } = require("electron");
const {
terminalDataToHtmlContent,
terminalDataToPlainText,
@@ -782,13 +782,83 @@ async function stopManualSessionLog(event, payload = {}) {
}
}
async function getManualSessionLogStatus(event, payload = {}) {
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
async function getManualSessionLogStatus(event, payload = {}, terminalWorkerManager = null) {
const manager = require("./sessionLogStreamManager.cjs");
const { sessionId } = payload;
if (!sessionId) {
return { success: false, isLogging: false, error: "Missing sessionId" };
if (!validSessionLogId(sessionId)) {
return { success: false, isLogging: false, hasSessionLog: false, error: "Missing sessionId" };
}
// Keep the recording toggle main-owned. Automatic logs may live in the
// worker, and stopped logs remain available independently of active streams.
const isLogging = manager.hasStream(sessionId);
let hasSessionLog = manager.hasSessionLog(sessionId);
if (!hasSessionLog && isTerminalWorkerRunning(terminalWorkerManager)) {
try {
const result = await terminalWorkerManager.request(
"netcatty:sessionLog:statusLocal", { sessionId }, {},
);
hasSessionLog = result?.success === true && result.hasSessionLog === true;
} catch {
// An unavailable worker must not manufacture an openable log target.
}
}
return { success: true, isLogging, hasSessionLog };
}
function validSessionLogId(sessionId) {
return typeof sessionId === "string" && sessionId.trim().length > 0;
}
async function prepareLocalSessionLog(_event, payload = {}) {
if (!validSessionLogId(payload?.sessionId)) {
return { success: false, error: "Missing sessionId" };
}
try {
const filePath = await require("./sessionLogStreamManager.cjs").prepareSessionLog(payload.sessionId);
return filePath
? { success: true, filePath }
: { success: false, error: "No saved log is available for this session" };
} catch (err) {
return { success: false, error: err?.message || String(err) };
}
}
/** Resolve only a main/worker-owned session target. Renderer paths, host labels
* and configured directories are deliberately not part of this operation.
*/
async function prepareSessionLog(event, payload = {}, terminalWorkerManager = null) {
const sessionId = payload?.sessionId;
if (!validSessionLogId(sessionId)) {
return { success: false, error: "Missing sessionId" };
}
const manager = require("./sessionLogStreamManager.cjs");
if (manager.hasSessionLog(sessionId)) {
return prepareLocalSessionLog(event, { sessionId });
}
if (!isTerminalWorkerRunning(terminalWorkerManager)) {
return { success: false, error: "No saved log is available for this session" };
}
try {
const result = await terminalWorkerManager.request(
"netcatty:sessionLog:prepareLocal", { sessionId }, {},
);
return result?.success && typeof result.filePath === "string" && result.filePath
? { success: true, filePath: result.filePath }
: { success: false, error: result?.error || "No saved log is available for this session" };
} catch (err) {
return { success: false, error: err?.message || String(err) };
}
}
async function revealSessionLog(event, payload = {}, terminalWorkerManager = null) {
const result = await prepareSessionLog(event, payload, terminalWorkerManager);
if (!result.success) return result;
try {
shell.showItemInFolder(result.filePath);
return result;
} catch (err) {
return { success: false, error: err?.message || String(err) };
}
return { success: true, isLogging: sessionLogStreamManager.hasStream(sessionId) };
}
/**
@@ -797,6 +867,11 @@ async function getManualSessionLogStatus(event, payload = {}) {
*/
function registerWorkerHandlers(ipcMain) {
ipcMain.handle("netcatty:sessionLogs:getActivePaths", async () => getLocalActiveLogPaths());
ipcMain.handle("netcatty:sessionLog:prepareLocal", prepareLocalSessionLog);
ipcMain.handle("netcatty:sessionLog:statusLocal", async (_event, payload = {}) => ({
success: true,
hasSessionLog: require("./sessionLogStreamManager.cjs").hasSessionLog(payload.sessionId),
}));
}
/**
@@ -818,7 +893,34 @@ function registerHandlers(ipcMain, options = {}) {
ipcMain.handle("netcatty:sessionLog:manualChoosePath", chooseManualSessionLogPath);
ipcMain.handle("netcatty:sessionLog:manualStart", startManualSessionLog);
ipcMain.handle("netcatty:sessionLog:manualStop", stopManualSessionLog);
ipcMain.handle("netcatty:sessionLog:manualStatus", getManualSessionLogStatus);
ipcMain.handle("netcatty:sessionLog:manualStatus", (event, payload) => (
getManualSessionLogStatus(event, payload, terminalWorkerManager)
));
ipcMain.handle("netcatty:sessionLog:prepare", (event, payload) => (
prepareSessionLog(event, payload, terminalWorkerManager)
));
ipcMain.handle("netcatty:sessionLog:reveal", (event, payload) => (
revealSessionLog(event, payload, terminalWorkerManager)
));
// Disconnected tabs may still open their last manual log. Once that session
// reconnects, it must not mask the new worker-owned automatic log instead.
const disconnectedSessions = new Set();
terminalWorkerManager?.onSessionClosed?.(({ sessionId, explicit }) => {
if (explicit) {
disconnectedSessions.delete(sessionId);
const manager = require("./sessionLogStreamManager.cjs");
if (!manager.hasStream(sessionId)) manager.forgetSessionLog(sessionId);
} else {
disconnectedSessions.add(sessionId);
}
});
terminalWorkerManager?.onSessionOwned?.(({ sessionId }) => {
if (!disconnectedSessions.delete(sessionId)) return;
const manager = require("./sessionLogStreamManager.cjs");
// Continuous manual recording intentionally survives terminal reconnects.
if (!manager.hasStream(sessionId)) manager.forgetSessionLog(sessionId);
});
// In the default terminal-worker runtime, sessions run in a utilityProcess
// and call appendData() on the worker's own sessionLogStreamManager module
@@ -850,6 +952,9 @@ module.exports = {
startManualSessionLog,
stopManualSessionLog,
getManualSessionLogStatus,
prepareLocalSessionLog,
prepareSessionLog,
revealSessionLog,
toLocalISOString,
terminalDataToHtml,
terminalPlainTextToHtml,
+262 -2
View File
@@ -15,11 +15,11 @@ async function waitForPath(targetPath, { timeoutMs = 2000 } = {}) {
throw new Error(`Timed out waiting for path: ${targetPath}`);
}
function loadBridgeWithDialog(dialogMock) {
function loadBridgeWithDialog(dialogMock, shellMock = {}) {
const originalLoad = Module._load;
Module._load = function patchedLoad(request, parent, isMain) {
if (request === "electron") {
return { dialog: dialogMock };
return { dialog: dialogMock, shell: shellMock };
}
return originalLoad.call(this, request, parent, isMain);
};
@@ -1001,3 +1001,263 @@ test("screen export escapes HTML when the user selects an HTML filename", async
fs.rmSync(directory, { recursive: true, force: true });
}
});
function registerSessionLogTestHandlers(bridge, terminalWorkerManager) {
const handlers = new Map();
bridge.registerHandlers({ handle: (channel, handler) => handlers.set(channel, handler) }, { terminalWorkerManager });
return handlers;
}
test("prepare and reveal use the current manual log before querying automatic worker logs", async (t) => {
const directory = path.join(TEMP_ROOT, `shortcut-manual-${Date.now()}`);
const filePath = path.join(directory, "manual.txt");
const sessionId = "shortcut-manual";
const revealed = [];
const bridge = loadBridgeWithDialog(
{ showSaveDialog: async () => ({ canceled: false, filePath }) },
{ showItemInFolder: (target) => revealed.push(target) },
);
const manager = require("./sessionLogStreamManager.cjs");
const workerRequests = [];
const handlers = registerSessionLogTestHandlers(bridge, {
isRunning: () => true,
request: async (...args) => { workerRequests.push(args); return { success: true, filePath: "/worker/log.txt" }; },
});
t.after(async () => {
await manager.cleanupAll();
fs.rmSync(directory, { recursive: true, force: true });
});
await bridge.startManualSessionLog(null, { sessionId, format: "txt" });
manager.appendData(sessionId, "fresh output\n");
const prepare = handlers.get("netcatty:sessionLog:prepare");
const reveal = handlers.get("netcatty:sessionLog:reveal");
assert.deepEqual(await prepare(null, { sessionId, filePath: "/untrusted/path" }), { success: true, filePath });
assert.match(fs.readFileSync(filePath, "utf8"), /fresh output/);
assert.equal(manager.hasStream(sessionId), true);
assert.deepEqual(await reveal(null, { sessionId, filePath: "/untrusted/path" }), { success: true, filePath });
assert.deepEqual(revealed, [filePath]);
await bridge.stopManualSessionLog(null, { sessionId });
assert.deepEqual(await prepare(null, { sessionId }), { success: true, filePath });
assert.deepEqual(workerRequests, []);
});
test("prepare routes automatic logs only to an already running worker with sessionId only", async () => {
const bridge = loadBridgeWithDialog({});
const requests = [];
const handlers = registerSessionLogTestHandlers(bridge, {
isRunning: () => true,
request: async (...args) => {
requests.push(args);
return { success: true, filePath: "/server-owned/automatic.html" };
},
});
const result = await handlers.get("netcatty:sessionLog:prepare")(null, {
sessionId: "shortcut-auto", filePath: "/arbitrary/renderer/path", directory: "/untrusted",
});
assert.deepEqual(result, { success: true, filePath: "/server-owned/automatic.html" });
assert.deepEqual(requests, [["netcatty:sessionLog:prepareLocal", { sessionId: "shortcut-auto" }, {}]]);
assert.equal(handlers.has("netcatty:sessionLog:prepareLocal"), false, "internal lookup is not renderer IPC");
});
test("prepare rejects malformed ids and never cold-starts an idle worker", async () => {
const bridge = loadBridgeWithDialog({});
let requests = 0;
const worker = { isRunning: () => false, request: async () => { requests++; } };
for (const payload of [undefined, null, {}, { sessionId: "" }, { sessionId: " " }, { sessionId: {} }]) {
assert.deepEqual(await bridge.prepareSessionLog(null, payload, worker), { success: false, error: "Missing sessionId" });
}
assert.deepEqual(await bridge.prepareSessionLog(null, { sessionId: "unknown", filePath: __filename }, worker), {
success: false, error: "No saved log is available for this session",
});
assert.equal(requests, 0);
});
test("prepare and reveal surface missing logs, worker errors, and shell failures", async () => {
const bridge = loadBridgeWithDialog({}, { showItemInFolder: () => { throw new Error("file manager unavailable"); } });
const payload = { sessionId: "shortcut-error" };
const worker = {
isRunning: () => true,
request: async () => { throw new Error("worker unavailable"); },
};
assert.deepEqual(await bridge.prepareSessionLog(null, payload, worker), { success: false, error: "worker unavailable" });
worker.request = async () => ({ success: false, error: "No saved log is available for this session" });
assert.deepEqual(await bridge.revealSessionLog(null, payload, worker), { success: false, error: "No saved log is available for this session" });
worker.request = async () => ({ success: true });
assert.equal((await bridge.prepareSessionLog(null, payload, worker)).success, false);
worker.request = async () => ({ success: true, filePath: "/server-owned/log.txt" });
assert.deepEqual(await bridge.revealSessionLog(null, payload, worker), { success: false, error: "file manager unavailable" });
});
test("worker prepare handler flushes and retains only the requested session's log", async (t) => {
const directory = path.join(TEMP_ROOT, `shortcut-worker-${Date.now()}`);
const filePath = path.join(directory, "worker.txt");
const sessionId = "shortcut-worker";
const bridge = loadBridgeWithDialog({});
const manager = require("./sessionLogStreamManager.cjs");
const handlers = new Map();
bridge.registerWorkerHandlers({ handle: (channel, handler) => handlers.set(channel, handler) });
const prepare = handlers.get("netcatty:sessionLog:prepareLocal");
t.after(async () => {
await manager.cleanupAll();
fs.rmSync(directory, { recursive: true, force: true });
});
manager.startStreamToFile(sessionId, { filePath, format: "txt" });
manager.appendData(sessionId, "worker output\n");
assert.deepEqual(await prepare(null, { sessionId }), { success: true, filePath });
assert.match(fs.readFileSync(filePath, "utf8"), /worker output/);
assert.equal(manager.hasStream(sessionId), true);
assert.equal((await prepare(null, { sessionId: "wrong-session", filePath })).success, false);
await manager.stopStream(sessionId);
assert.deepEqual(await prepare(null, { sessionId }), { success: true, filePath });
});
test("a missing manual log reports an error instead of silently opening another worker log", async (t) => {
const directory = path.join(TEMP_ROOT, `shortcut-missing-${Date.now()}`);
const filePath = path.join(directory, "missing.txt");
const sessionId = "shortcut-missing";
const bridge = loadBridgeWithDialog({});
const manager = require("./sessionLogStreamManager.cjs");
t.after(async () => {
await manager.cleanupAll();
fs.rmSync(directory, { recursive: true, force: true });
});
manager.startStreamToFile(sessionId, { filePath, format: "txt" });
await manager.stopStream(sessionId);
fs.unlinkSync(filePath);
const result = await bridge.prepareSessionLog(null, { sessionId }, {
isRunning: () => true,
request: async () => assert.fail("must not fall back to an unrelated automatic log"),
});
assert.equal(result.success, false);
assert.match(result.error, /ENOENT/);
});
test("disconnect retains a manual log but reconnect clears its precedence over a new automatic log", async (t) => {
const directory = path.join(TEMP_ROOT, `shortcut-reconnect-${Date.now()}`);
const filePath = path.join(directory, "old-manual.txt");
const sessionId = "shortcut-reconnect";
const bridge = loadBridgeWithDialog({});
const manager = require("./sessionLogStreamManager.cjs");
let onClosed;
let onOwned;
const handlers = registerSessionLogTestHandlers(bridge, {
isRunning: () => true,
onSessionClosed: (listener) => { onClosed = listener; },
onSessionOwned: (listener) => { onOwned = listener; },
request: async () => ({ success: true, filePath: "/worker/new-connection.txt" }),
});
t.after(async () => {
await manager.cleanupAll();
fs.rmSync(directory, { recursive: true, force: true });
});
manager.startStreamToFile(sessionId, { filePath, format: "txt" });
await manager.stopStream(sessionId);
const prepare = handlers.get("netcatty:sessionLog:prepare");
onClosed({ sessionId, reason: "exited" });
assert.deepEqual(await prepare(null, { sessionId }), { success: true, filePath });
onOwned({ sessionId, webContentsId: 2 });
assert.deepEqual(await prepare(null, { sessionId }), { success: true, filePath: "/worker/new-connection.txt" });
});
test("a live manual recording keeps its target across disconnect and reconnect", async (t) => {
const directory = path.join(TEMP_ROOT, `shortcut-live-reconnect-${Date.now()}`);
const filePath = path.join(directory, "manual.txt");
const sessionId = "shortcut-live-reconnect";
const bridge = loadBridgeWithDialog({});
const manager = require("./sessionLogStreamManager.cjs");
let onClosed;
let onOwned;
const handlers = registerSessionLogTestHandlers(bridge, {
isRunning: () => true,
onSessionClosed: (listener) => { onClosed = listener; },
onSessionOwned: (listener) => { onOwned = listener; },
request: async () => assert.fail("live manual recording must retain precedence"),
});
t.after(async () => {
await manager.cleanupAll();
fs.rmSync(directory, { recursive: true, force: true });
});
manager.startStreamToFile(sessionId, { filePath, format: "txt" });
manager.appendData(sessionId, "before\n");
onClosed({ sessionId, reason: "exited" });
onOwned({ sessionId, webContentsId: 2 });
manager.appendData(sessionId, "after\n");
const prepare = handlers.get("netcatty:sessionLog:prepare");
assert.deepEqual(await prepare(null, { sessionId }), { success: true, filePath });
assert.match(fs.readFileSync(filePath, "utf8"), /before[\s\S]*after/);
onClosed({ sessionId, explicit: true });
assert.deepEqual(await prepare(null, { sessionId }), { success: true, filePath });
});
test("log menu availability follows the owning stream, not automatic logging settings", async () => {
const bridge = loadBridgeWithDialog({});
const workerHandlers = new Map();
bridge.registerWorkerHandlers({ handle: (channel, handler) => workerHandlers.set(channel, handler) });
const manager = require("./sessionLogStreamManager.cjs");
const workerLogs = new Set();
const handlers = registerSessionLogTestHandlers(bridge, {
isRunning: () => true,
request: async (channel, payload) => {
assert.equal(channel, "netcatty:sessionLog:statusLocal");
return { success: true, hasSessionLog: workerLogs.has(payload.sessionId) };
},
});
const status = handlers.get("netcatty:sessionLog:manualStatus");
for (const sessionId of ["enabled-after-connect", "failed-before-shell"]) {
assert.equal((await status(null, { sessionId, enabled: true })).hasSessionLog, false);
}
workerLogs.add("recording-after-disabled");
assert.deepEqual(await status(null, { sessionId: "recording-after-disabled", enabled: false }), {
success: true, isLogging: false, hasSessionLog: true,
});
assert.equal((await status(null, { sessionId: "another-session" })).hasSessionLog, false);
// A successful replacement without logging retires the worker-owned target.
workerLogs.delete("recording-after-disabled");
assert.equal((await status(null, { sessionId: "recording-after-disabled" })).hasSessionLog, false);
assert.equal(handlers.has("netcatty:sessionLog:statusLocal"), false);
assert.deepEqual(await workerHandlers.get("netcatty:sessionLog:statusLocal")(null, { sessionId: "missing" }), {
success: true, hasSessionLog: false,
});
assert.equal(manager.hasSessionLog("missing"), false);
});
test("log availability retains stopped main logs and never starts a worker to check", async (t) => {
const directory = path.join(TEMP_ROOT, `availability-${Date.now()}`);
const filePath = path.join(directory, "manual.txt");
const sessionId = "availability-main";
const bridge = loadBridgeWithDialog({ showSaveDialog: async () => ({ canceled: false, filePath }) });
const manager = require("./sessionLogStreamManager.cjs");
const handlers = registerSessionLogTestHandlers(bridge, {
isRunning: () => false,
request: () => assert.fail("must not cold-start a worker"),
});
t.after(async () => {
await manager.cleanupAll();
manager.forgetSessionLog(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
});
const status = handlers.get("netcatty:sessionLog:manualStatus");
assert.equal((await status(null, { sessionId })).hasSessionLog, false);
assert.equal((await bridge.startManualSessionLog(null, { sessionId, format: "txt" })).started, true);
assert.deepEqual(await status(null, { sessionId }), { success: true, isLogging: true, hasSessionLog: true });
await bridge.stopManualSessionLog(null, { sessionId });
assert.deepEqual(await status(null, { sessionId }), { success: true, isLogging: false, hasSessionLog: true });
const workerHandlers = new Map();
bridge.registerWorkerHandlers({ handle: (channel, handler) => workerHandlers.set(channel, handler) });
assert.deepEqual(await workerHandlers.get("netcatty:sessionLog:statusLocal")(null, { sessionId }), {
success: true, hasSessionLog: true,
}, "worker lookup also retains stopped logs without requiring an active stream");
});
test("an unavailable worker does not offer a nonexistent log or change manual recording status", async () => {
const bridge = loadBridgeWithDialog({});
for (const request of [
async () => { throw new Error("worker stopped"); },
async () => ({ success: false, error: "session missing" }),
]) {
const handlers = registerSessionLogTestHandlers(bridge, { isRunning: () => true, request });
assert.deepEqual(await handlers.get("netcatty:sessionLog:manualStatus")(null, { sessionId: "unavailable" }), {
success: true, isLogging: false, hasSessionLog: false,
});
}
});
+21 -15
View File
@@ -1656,21 +1656,27 @@ async function runRemoteUploadTransaction(client, localPath, remotePath, options
signal,
));
assertCanPromote();
// Apply the old mode to the stage before promotion. A failed chmod must not
// replace an executable final with a non-executable file and report success.
await restoreRemoteMode(client, encodedStagedPath, plan.existingMode, {
bestEffort: false,
});
assertCanPromote();
await runCancelablePreflight(() => assertStagedPromotionTargetSafe(
client,
encodedPath,
remotePath,
plan.destinationExisted,
plan.destinationSnapshot,
signal,
));
assertCanPromote();
// Mode restore is a remote chmod on the stage. Recheck the destination
// only after that round trip. A non-finite existingMode (new files, and
// targets whose mode could not be captured) makes restore a no-op, so a
// second lstat would be a serial RTT on every such upload.
if (Number.isFinite(plan.existingMode)) {
// Apply the old mode to the stage before promotion. A failed chmod must not
// replace an executable final with a non-executable file and report success.
await restoreRemoteMode(client, encodedStagedPath, plan.existingMode, {
bestEffort: false,
});
assertCanPromote();
await runCancelablePreflight(() => assertStagedPromotionTargetSafe(
client,
encodedPath,
remotePath,
plan.destinationExisted,
plan.destinationSnapshot,
signal,
));
assertCanPromote();
}
commitPromotion();
await renameRemotePath(client, encodedStagedPath, encodedPath, encodedBackupPath, {
stagePath: stagedLogical,
@@ -571,6 +571,104 @@ test("existing destinations stage then restore mode after rename", async (t) =>
assert.equal(remoteMeta.get("/usr/local/bin/tool")?.mode & 0o777, 0o755);
});
test("new-file staged upload lstats the destination once before rename", async (t) => {
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-new-file-lstat-"));
t.after(async () => {
await fs.promises.rm(tempRoot, { recursive: true, force: true });
});
tempDirBridge.init?.({ getPath: () => tempRoot });
const localPath = path.join(tempRoot, "payload.bin");
const payload = Buffer.from("new-file");
await fs.promises.writeFile(localPath, payload);
const remotePath = "/home/alice/new-file.bin";
const { channel, remoteFiles, chmodCalls } = createSessionChannel();
const originalLstat = channel.lstat.bind(channel);
let destinationLstats = 0;
channel.lstat = (targetPath, callback) => {
if (String(targetPath) === remotePath) destinationLstats += 1;
originalLstat(targetPath, callback);
};
const sftpClients = new Map();
sftpBridge.init({
electronModule: { webContents: { fromId: () => null } },
sessions: new Map([["session-new-file-lstat", { conn: { sftp: (cb) => cb(null, channel) } }]]),
sftpClients,
});
const opened = await sftpBridge.openSftpForSession(null, {
sessionId: "session-new-file-lstat",
fileProtocol: "sftp",
});
const result = await sftpBridge.uploadLocalToSftp(null, {
sftpId: opened.sftpId,
localPath,
remotePath,
encoding: "utf-8",
});
assert.equal(result.success, true);
assert.deepEqual(remoteFiles.get(remotePath), payload);
assert.deepEqual(chmodCalls, []);
// Plan lstat plus one pre-rename check. existingMode is null, so the
// post-restore lstat would be a no-op round trip.
assert.equal(destinationLstats, 2);
});
test("existing-file staged upload rechecks the destination after mode restore", async (t) => {
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-existing-file-lstat-"));
t.after(async () => {
await fs.promises.rm(tempRoot, { recursive: true, force: true });
});
tempDirBridge.init?.({ getPath: () => tempRoot });
const localPath = path.join(tempRoot, "tool.bin");
const payload = Buffer.from("replaced-tool");
await fs.promises.writeFile(localPath, payload);
const remotePath = "/usr/local/bin/tool";
const { channel, remoteFiles, remoteMeta, chmodCalls } = createSessionChannel();
remoteFiles.set(remotePath, Buffer.from("old-tool"));
remoteMeta.set(remotePath, { mode: 0o100755, mtime: 1, uid: 1000, gid: 1000 });
const originalLstat = channel.lstat.bind(channel);
let destinationLstats = 0;
channel.lstat = (targetPath, callback) => {
if (String(targetPath) === remotePath) destinationLstats += 1;
originalLstat(targetPath, callback);
};
const sftpClients = new Map();
sftpBridge.init({
electronModule: { webContents: { fromId: () => null } },
sessions: new Map([["session-existing-file-lstat", { conn: { sftp: (cb) => cb(null, channel) } }]]),
sftpClients,
});
const opened = await sftpBridge.openSftpForSession(null, {
sessionId: "session-existing-file-lstat",
fileProtocol: "sftp",
});
const result = await sftpBridge.uploadLocalToSftp(null, {
sftpId: opened.sftpId,
localPath,
remotePath,
encoding: "utf-8",
});
assert.equal(result.success, true);
assert.deepEqual(remoteFiles.get(remotePath), payload);
assert.equal(remoteMeta.get(remotePath)?.mode & 0o777, 0o755);
assert.ok(
chmodCalls.some((call) => String(call.targetPath).includes(".netcatty-upload-")),
`expected staged chmod, got ${JSON.stringify(chmodCalls)}`,
);
// Plan lstat, pre-restore check, and the post-chmod recheck that covers the
// mode-restore round trip.
assert.equal(destinationLstats, 3);
});
test("mode restore failure leaves the existing destination untouched", async (t) => {
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-stage-mode-fail-"));
t.after(async () => {
+3 -1
View File
@@ -1625,7 +1625,9 @@ function registerHandlers(ipcMain, options = {}) {
});
});
} else {
ipcMain.handle("netcatty:start", startSSHSessionWrapper);
ipcMain.handle("netcatty:start", options.manageSessionLogRestart === true
? sessionLogStreamManager.withSessionLogRestart(startSSHSessionWrapper)
: startSSHSessionWrapper);
ipcMain.handle("netcatty:ssh:exec", execCommand);
ipcMain.handle("netcatty:ssh:pwd", getSessionPwd);
ipcMain.handle("netcatty:ssh:remoteInfo", getSessionRemoteInfo);
+8 -5
View File
@@ -2639,11 +2639,14 @@ function registerHandlers(ipcMain, options = {}) {
});
return;
}
ipcMain.handle("netcatty:local:start", startLocalSession);
ipcMain.handle("netcatty:telnet:start", startTelnetSession);
ipcMain.handle("netcatty:mosh:start", startMoshSession);
ipcMain.handle("netcatty:et:start", startEtSession);
ipcMain.handle("netcatty:serial:start", startSerialSession);
const wrapSessionStart = options.manageSessionLogRestart === true
? sessionLogStreamManager.withSessionLogRestart
: (handler) => handler;
ipcMain.handle("netcatty:local:start", wrapSessionStart(startLocalSession));
ipcMain.handle("netcatty:telnet:start", wrapSessionStart(startTelnetSession));
ipcMain.handle("netcatty:mosh:start", wrapSessionStart(startMoshSession));
ipcMain.handle("netcatty:et:start", wrapSessionStart(startEtSession));
ipcMain.handle("netcatty:serial:start", wrapSessionStart(startSerialSession));
ipcMain.handle("netcatty:serial:list", listSerialPorts);
ipcMain.handle("netcatty:serial:ymodem-send", sendSerialYmodem);
ipcMain.handle("netcatty:serial:ymodem-receive", receiveSerialYmodem);
@@ -360,3 +360,37 @@ test("terminal worker mode proxies system management requests", async () => {
],
);
});
test("fallback log restart guard wraps all six starts only when main explicitly enables it", (t) => {
const manager = require("./sessionLogStreamManager.cjs");
const wrappers = new Map();
t.mock.method(manager, "withSessionLogRestart", (handler) => {
const wrapped = () => {};
wrappers.set(handler, wrapped);
return wrapped;
});
const workerHandlers = createFakeIpcMain();
sshBridge.registerHandlers(workerHandlers);
terminalBridge.registerHandlers(workerHandlers);
assert.equal(wrappers.size, 0, "utility-process registration keeps the runtime generation guard");
const fallbackHandlers = createFakeIpcMain();
sshBridge.registerHandlers(fallbackHandlers, { manageSessionLogRestart: true });
terminalBridge.registerHandlers(fallbackHandlers, { manageSessionLogRestart: true });
const startChannels = [
"netcatty:start", "netcatty:local:start", "netcatty:telnet:start",
"netcatty:mosh:start", "netcatty:et:start", "netcatty:serial:start",
];
assert.equal(wrappers.size, startChannels.length);
for (const channel of startChannels) {
assert.equal(fallbackHandlers.handlers.get(channel), wrappers.get(workerHandlers.handlers.get(channel)), channel);
}
assert.equal(fallbackHandlers.handlers.get("netcatty:serial:list"), workerHandlers.handlers.get("netcatty:serial:list"));
wrappers.clear();
const proxyHandlers = createFakeIpcMain();
const terminalWorkerManager = createFakeWorkerManager();
sshBridge.registerHandlers(proxyHandlers, { terminalWorkerManager, manageSessionLogRestart: true });
terminalBridge.registerHandlers(proxyHandlers, { terminalWorkerManager, manageSessionLogRestart: true });
assert.equal(wrappers.size, 0, "worker proxies must not also apply the main fallback guard");
});
+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);
});
+8 -2
View File
@@ -460,7 +460,10 @@ function createBridgeRegistrar(context) {
tempDirBridge.ensureTempDir();
// Register all IPC handlers
sshBridge.registerHandlers(ipcMain, { terminalWorkerManager });
sshBridge.registerHandlers(ipcMain, {
terminalWorkerManager,
manageSessionLogRestart: !terminalWorkerManager,
});
const stopMonitoring = () => terminalWorkerManager
? terminalWorkerManager.request("netcatty:monitor:stop-all", {}, {}).catch(() => {})
: sshBridge.stopAllMonitoring();
@@ -494,7 +497,10 @@ function createBridgeRegistrar(context) {
});
app.once?.("will-quit", () => hostBrowserManager.closeAll());
}
terminalBridge.registerHandlers(ipcMain, { terminalWorkerManager });
terminalBridge.registerHandlers(ipcMain, {
terminalWorkerManager,
manageSessionLogRestart: !terminalWorkerManager,
});
const scriptBridge = require("../bridges/scriptBridge.cjs");
scriptBridge.init({
+4
View File
@@ -1680,6 +1680,10 @@ function createPreloadApi(ctx) {
ipcRenderer.invoke("netcatty:sessionLog:manualStart", payload),
stopManualSessionLog: (payload) =>
ipcRenderer.invoke("netcatty:sessionLog:manualStop", payload),
prepareSessionLog: (payload) =>
ipcRenderer.invoke("netcatty:sessionLog:prepare", { sessionId: payload.sessionId }),
revealSessionLog: (payload) =>
ipcRenderer.invoke("netcatty:sessionLog:reveal", { sessionId: payload.sessionId }),
getManualSessionLogStatus: (payload) =>
ipcRenderer.invoke("netcatty:sessionLog:manualStatus", payload),
+11
View File
@@ -525,6 +525,9 @@ function createTerminalWorkerRuntime(options = {}) {
const requestedSessionGeneration = requestedSessionId
? (sessionOutputGenerations.get(requestedSessionId) ?? 0)
: null;
const previousSessionLogToken = requestedSessionId
? require("../bridges/sessionLogStreamManager.cjs").getSessionLogToken(requestedSessionId)
: null;
if (requestedSessionId) {
sessionRequestIds.set(requestedSessionId, message.requestId);
sessionLifecycleTombstoneTimes.delete(requestedSessionId);
@@ -556,6 +559,14 @@ function createTerminalWorkerRuntime(options = {}) {
const requestedGenerationStillCurrent = requestedSessionGeneration === null
|| currentGeneration === requestedSessionGeneration;
if (!exitedDuringRequest && requestedGenerationStillCurrent) {
// Failed reconnects retain the previous saved log. After a successful
// start without a replacement log, retire only the old inactive
// target, never a continuing manual or newly-finalized stream.
if (sessionId === requestedSessionId && result.success !== false && !result.error) {
require("../bridges/sessionLogStreamManager.cjs").forgetInactiveSessionLog(
sessionId, previousSessionLogToken,
);
}
sessionRequestIds.set(sessionId, message.requestId);
sessionStartMarkers.add(sessionId);
sessionLifecycleTombstoneTimes.delete(sessionId);
+117
View File
@@ -1532,3 +1532,120 @@ test("runtime contains throws from fire-and-forget send listeners", () => {
{ sessionId: "s1", data: "pwd\r" },
]);
});
test("a same-id restart with auto-save disabled forgets the prior saved-log target", async (t) => {
const fs = require("node:fs");
const path = require("node:path");
const manager = require("../bridges/sessionLogStreamManager.cjs");
const directory = path.join(__dirname, `.tmp-runtime-session-log-${Date.now()}`);
const filePath = path.join(directory, "old.txt");
const sessionId = "runtime-log-reconnect";
t.after(async () => {
await manager.cleanupAll();
fs.rmSync(directory, { recursive: true, force: true });
});
manager.startStreamToFile(sessionId, { filePath, format: "txt" });
manager.appendData(sessionId, "previous connection\n");
await manager.stopStream(sessionId);
assert.equal(await manager.prepareSessionLog(sessionId), filePath);
const parentPort = createParentPort();
const runtime = createTerminalWorkerRuntime({
parentPort,
registerBridges(ipcMain) {
ipcMain.handle("netcatty:start", async (_event, payload) => {
assert.equal(manager.hasSessionLog(payload.sessionId), true, "keep the old target until the replacement succeeds");
return { sessionId: payload.sessionId };
});
},
});
runtime.start();
parentPort.emitMessage({
kind: "request", requestId: "restart-no-log", channel: "netcatty:start",
payload: { sessionId, sessionLog: { enabled: false } }, webContentsId: 7,
});
await new Promise((resolve) => setImmediate(resolve));
assert.ok(parentPort.messages.some((message) => message.requestId === "restart-no-log" && message.result?.sessionId === sessionId));
assert.equal(await manager.prepareSessionLog(sessionId), null);
});
async function createSavedLogRestartFixture(t, name, handler, { active = false } = {}) {
const fs = require("node:fs");
const path = require("node:path");
const manager = require("../bridges/sessionLogStreamManager.cjs");
const directory = path.join(__dirname, `.tmp-runtime-session-log-${name}-${Date.now()}`);
const filePath = path.join(directory, "old.txt");
const sessionId = `runtime-log-${name}`;
t.after(async () => {
await manager.cleanupAll();
fs.rmSync(directory, { recursive: true, force: true });
});
manager.startStreamToFile(sessionId, { filePath, format: "txt", stopRequiresToken: active });
manager.appendData(sessionId, "previous connection\n");
if (!active) await manager.stopStream(sessionId);
const oldToken = manager.getSessionLogToken(sessionId);
const parentPort = createParentPort();
let resolveResponse;
const response = new Promise((resolve) => { resolveResponse = resolve; });
const postMessage = parentPort.postMessage;
parentPort.postMessage = (message) => {
postMessage(message);
if (message.kind === "response" && message.requestId === name) resolveResponse(message);
};
const fixture = { manager, directory, filePath, sessionId, oldToken, parentPort };
const runtime = createTerminalWorkerRuntime({
parentPort,
registerBridges(ipcMain) {
ipcMain.handle("netcatty:start", (event, payload) => handler(fixture, event, payload));
},
});
runtime.start();
parentPort.emitMessage({
kind: "request", requestId: name, channel: "netcatty:start",
payload: { sessionId, sessionLog: { enabled: false } }, webContentsId: 7,
});
return { ...fixture, response: await response };
}
for (const failure of ["throw", "result"]) {
test(`failed reconnect (${failure}) retains the previous saved log`, async (t) => {
const fixture = await createSavedLogRestartFixture(t, `failed-${failure}`, async ({ manager, sessionId, filePath }) => {
assert.equal(await manager.prepareSessionLog(sessionId), filePath);
if (failure === "throw") throw new Error("authentication failed");
return { sessionId, success: false, error: "connection refused" };
});
const { manager, sessionId, filePath, oldToken, response } = fixture;
if (failure === "throw") assert.equal(response.error, "authentication failed");
else assert.equal(response.result.success, false);
assert.equal(manager.getSessionLogToken(sessionId), oldToken);
assert.equal(await manager.prepareSessionLog(sessionId), filePath);
});
}
for (const finalized of [false, true]) {
test(`successful reconnect preserves its new ${finalized ? "finalized" : "active"} log`, async (t) => {
let newPath;
const { manager, sessionId, oldToken, response } = await createSavedLogRestartFixture(t, `new-${finalized}`, async (fixture) => {
newPath = require("node:path").join(fixture.directory, "new.txt");
fixture.manager.startStreamToFile(fixture.sessionId, { filePath: newPath, format: "txt" });
fixture.manager.appendData(fixture.sessionId, "new connection\n");
if (finalized) await fixture.manager.stopStream(fixture.sessionId);
return { sessionId: fixture.sessionId };
});
assert.equal(response.result.sessionId, sessionId);
assert.notEqual(manager.getSessionLogToken(sessionId), oldToken);
assert.equal(manager.hasStream(sessionId), !finalized);
assert.equal(await manager.prepareSessionLog(sessionId), newPath);
});
}
test("successful reconnect leaves an existing active manual recording untouched", async (t) => {
const { manager, sessionId, filePath, oldToken, response } = await createSavedLogRestartFixture(
t, "live-manual", async (fixture) => ({ sessionId: fixture.sessionId }), { active: true },
);
assert.equal(response.result.sessionId, sessionId);
assert.equal(manager.hasStream(sessionId), true);
assert.equal(manager.getSessionLogToken(sessionId), oldToken);
manager.appendData(sessionId, "after reconnect\n");
assert.equal(await manager.prepareSessionLog(sessionId), filePath);
assert.match(require("node:fs").readFileSync(filePath, "utf8"), /previous connection[\s\S]*after reconnect/);
});
+3
View File
@@ -354,3 +354,6 @@ export const STORAGE_KEY_MONITOR_PANELS = 'netcatty_monitor_panels_v1';
export const STORAGE_KEY_MONITOR_TABLE_COLUMNS = 'netcatty_monitor_table_columns_v1';
export const STORAGE_KEY_MONITOR_HIDDEN_COLUMNS = "netcatty_monitor_hidden_columns_v1";
// Preferred application for saved terminal logs (independent of SFTP associations).
export const STORAGE_KEY_SESSION_LOG_APPLICATION = "netcatty.sessionLogApplication";
+3 -1
View File
@@ -127,9 +127,11 @@ declare global {
stopManualSessionLog?(payload: {
sessionId: string;
}): Promise<{ success: boolean; stopped: boolean; error?: string; filePath?: string }>;
prepareSessionLog?(payload: { sessionId: string }): Promise<{ success: boolean; filePath?: string; error?: string }>;
revealSessionLog?(payload: { sessionId: string }): Promise<{ success: boolean; error?: string }>;
getManualSessionLogStatus?(payload: {
sessionId: string;
}): Promise<{ success: boolean; isLogging: boolean; error?: string }>;
}): Promise<{ success: boolean; isLogging: boolean; hasSessionLog: boolean; error?: string }>;
// Get file path from File object (for drag-and-drop, uses Electron's webUtils)
getPathForFile?(file: File): string | undefined;