mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-10 16:21:22 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67607870a8 | ||
|
|
5311dd3507 | ||
|
|
d6871dbca9 | ||
|
|
850c5309b6 | ||
|
|
e5bfc12a2e | ||
|
|
6ab684bcd5 | ||
|
|
acecd46d57 | ||
|
|
aee852a9ac | ||
|
|
5bef721ac9 | ||
|
|
4c82424adf |
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\(\)/);
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
import { getCredentialProtectionAvailability } from '../../infrastructure/services/credentialProtection';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { terminalHostTreeStore } from '../state/terminalHostTreeStore';
|
||||
import {
|
||||
markExternalMcpStartupReady,
|
||||
readExternalMcpFocusOnHostOpen,
|
||||
@@ -1126,6 +1127,7 @@ export function AppSideEffects() {
|
||||
toggleBroadcast,
|
||||
toggleGlobalBroadcast,
|
||||
canUseGlobalBroadcast: canUseGlobalBroadcastRef.current,
|
||||
toggleHostTree: () => terminalHostTreeStore.toggle(),
|
||||
toggleScriptsSidePanelRef,
|
||||
toggleSidePanelRef,
|
||||
toggleWindowOpacity,
|
||||
|
||||
@@ -272,7 +272,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit,
|
||||
handleOpenHostFromVaultNote, handleOpenQuickSwitcher, handleOpenSettings, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleFidoPromptSubmit, handleFidoPromptCancel, handleProtocolSelect,
|
||||
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleUpdateHostFromTerminal,
|
||||
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isGlobalBroadcastEnabled, onToggleGlobalBroadcast, canUseGlobalBroadcast, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
|
||||
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isComposeSendToAll, onToggleComposeSendTarget, isGlobalBroadcastEnabled, isGlobalComposeSendToAll, onToggleGlobalBroadcast, onToggleGlobalComposeSendTarget, canUseGlobalBroadcast, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
|
||||
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
|
||||
passphraseQueue, fidoPromptQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
|
||||
resolveEmptyVaultConflict, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
|
||||
@@ -784,8 +784,12 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onCreateLocalTerminal={handleCreateLocalTerminal}
|
||||
isBroadcastEnabled={isBroadcastEnabled}
|
||||
onToggleBroadcast={toggleBroadcast}
|
||||
isComposeSendToAll={isComposeSendToAll}
|
||||
onToggleComposeSendTarget={onToggleComposeSendTarget}
|
||||
isGlobalBroadcastEnabled={isGlobalBroadcastEnabled}
|
||||
isGlobalComposeSendToAll={isGlobalComposeSendToAll}
|
||||
onToggleGlobalBroadcast={onToggleGlobalBroadcast}
|
||||
onToggleGlobalComposeSendTarget={onToggleGlobalComposeSendTarget}
|
||||
canUseGlobalBroadcast={canUseGlobalBroadcast}
|
||||
updateHosts={updateTerminalHosts}
|
||||
updateSnippets={updateSnippets}
|
||||
|
||||
@@ -205,7 +205,11 @@ export function TerminalHost() {
|
||||
updateTerminalHosts: handlers.updateTerminalHosts,
|
||||
hotkeyScheme: terminalSettings.hotkeyScheme,
|
||||
isBroadcastEnabled: sessionActions?.isBroadcastEnabled,
|
||||
isComposeSendToAll: sessionActions?.isComposeSendToAll,
|
||||
onToggleComposeSendTarget: sessionActions?.toggleComposeSendTarget,
|
||||
isGlobalBroadcastEnabled: sessionActions?.isGlobalBroadcastEnabled,
|
||||
isGlobalComposeSendToAll: sessionActions?.isGlobalComposeSendToAll,
|
||||
onToggleGlobalComposeSendTarget: sessionActions?.toggleGlobalComposeSendTarget,
|
||||
canUseGlobalBroadcast: sessionActions?.canUseGlobalBroadcast,
|
||||
keyBindings: terminalSettings.keyBindings,
|
||||
openNoteRequest: local.openNoteRequest,
|
||||
|
||||
@@ -70,8 +70,12 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
reorderTabs,
|
||||
toggleBroadcast,
|
||||
isBroadcastEnabled,
|
||||
toggleComposeSendTarget,
|
||||
isComposeSendToAll,
|
||||
toggleGlobalBroadcast,
|
||||
isGlobalBroadcastEnabled,
|
||||
toggleGlobalComposeSendTarget,
|
||||
isGlobalComposeSendToAll,
|
||||
canUseGlobalBroadcast,
|
||||
copySession,
|
||||
copyWorkspace,
|
||||
@@ -150,8 +154,12 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
reorderTabs,
|
||||
toggleBroadcast,
|
||||
isBroadcastEnabled,
|
||||
toggleComposeSendTarget,
|
||||
isComposeSendToAll,
|
||||
toggleGlobalBroadcast,
|
||||
isGlobalBroadcastEnabled,
|
||||
toggleGlobalComposeSendTarget,
|
||||
isGlobalComposeSendToAll,
|
||||
canUseGlobalBroadcast,
|
||||
copySession,
|
||||
copyWorkspace,
|
||||
@@ -181,7 +189,9 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
getOrderedWorkTabs,
|
||||
getSessionRestoreCwd,
|
||||
isBroadcastEnabled,
|
||||
isComposeSendToAll,
|
||||
isGlobalBroadcastEnabled,
|
||||
isGlobalComposeSendToAll,
|
||||
moveFocusInWorkspace,
|
||||
openLogView,
|
||||
canUseGlobalBroadcast,
|
||||
@@ -201,7 +211,9 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
submitSessionRename,
|
||||
submitWorkspaceRename,
|
||||
toggleBroadcast,
|
||||
toggleComposeSendTarget,
|
||||
toggleGlobalBroadcast,
|
||||
toggleGlobalComposeSendTarget,
|
||||
toggleWorkspaceViewMode,
|
||||
updateSessionCodingCliProvider,
|
||||
updateSessionDynamicTitle,
|
||||
|
||||
@@ -964,6 +964,7 @@ Highlight the focused split pane:
|
||||
'settings.shortcuts.category.terminal': 'Terminal',
|
||||
'settings.shortcuts.category.navigation': 'Navigation',
|
||||
'settings.shortcuts.category.app': 'App',
|
||||
'settings.shortcuts.binding.toggle-host-tree': 'Toggle Host List',
|
||||
'settings.shortcuts.category.sftp': 'SFTP',
|
||||
|
||||
// Context menus / common actions
|
||||
|
||||
@@ -1,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',
|
||||
|
||||
@@ -913,6 +913,7 @@ Resalta el panel dividido enfocado:
|
||||
'settings.shortcuts.binding.broadcast': 'Alternar modo de transmisión',
|
||||
'settings.shortcuts.binding.toggle-opacity': 'Alternar la transparencia de la ventana',
|
||||
'settings.shortcuts.binding.toggle-side-panel': 'Alternar panel lateral',
|
||||
'settings.shortcuts.binding.toggle-host-tree': 'Alternar lista de hosts',
|
||||
'settings.shortcuts.binding.sftp-copy': 'Copiar archivos',
|
||||
'settings.shortcuts.binding.sftp-cut': 'Cortar archivos',
|
||||
'settings.shortcuts.binding.sftp-paste': 'Pegar archivos',
|
||||
|
||||
@@ -1,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',
|
||||
|
||||
@@ -938,6 +938,7 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.shortcuts.binding.broadcast': 'Переключить режим трансляции',
|
||||
'settings.shortcuts.binding.toggle-opacity': 'Переключить прозрачность окна',
|
||||
'settings.shortcuts.binding.toggle-side-panel': 'Переключить боковую панель',
|
||||
'settings.shortcuts.binding.toggle-host-tree': 'Переключить список хостов',
|
||||
'settings.shortcuts.binding.sftp-copy': 'Копировать файл',
|
||||
'settings.shortcuts.binding.sftp-cut': 'Вырезать файл',
|
||||
'settings.shortcuts.binding.sftp-paste': 'Вставить файл',
|
||||
|
||||
@@ -1,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': '粘贴文件',
|
||||
|
||||
@@ -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': '貼上檔案',
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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); }}
|
||||
|
||||
@@ -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(); });
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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\)/);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,69 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { getTerminalPassthroughActions } from '../application/state/useGlobalHotkeys.ts';
|
||||
import en from '../application/i18n/locales/en.ts';
|
||||
import zhCN from '../application/i18n/locales/zh-CN.ts';
|
||||
import zhTW from '../application/i18n/locales/zh-TW.ts';
|
||||
import { findActiveSystemShortcutConflict } from './activeKeyBindings.ts';
|
||||
import { DEFAULT_KEY_BINDINGS, matchesKeyBinding } from './models/keyBindings.ts';
|
||||
|
||||
test('host list toggle defaults to Ctrl/Cmd+Shift+B and leaves Ctrl/Cmd+B on broadcast', () => {
|
||||
const binding = DEFAULT_KEY_BINDINGS.find((entry) => entry.id === 'toggle-host-tree');
|
||||
|
||||
assert.equal(binding?.action, 'toggleHostTree');
|
||||
assert.equal(binding?.label, 'Toggle Host List');
|
||||
assert.equal(binding?.category, 'app');
|
||||
assert.equal(binding?.pc, 'Ctrl + Shift + B');
|
||||
assert.equal(binding?.mac, '⌘ + Shift + B');
|
||||
|
||||
assert.equal(
|
||||
findActiveSystemShortcutConflict('Ctrl + B', 'pc', DEFAULT_KEY_BINDINGS)?.id,
|
||||
'broadcast',
|
||||
);
|
||||
assert.equal(
|
||||
findActiveSystemShortcutConflict('⌘ + B', 'mac', DEFAULT_KEY_BINDINGS)?.id,
|
||||
'broadcast',
|
||||
);
|
||||
assert.equal(
|
||||
findActiveSystemShortcutConflict('Ctrl + Shift + B', 'pc', DEFAULT_KEY_BINDINGS)?.id,
|
||||
'toggle-host-tree',
|
||||
);
|
||||
assert.equal(
|
||||
findActiveSystemShortcutConflict('⌘ + Shift + B', 'mac', DEFAULT_KEY_BINDINGS)?.id,
|
||||
'toggle-host-tree',
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
DEFAULT_KEY_BINDINGS.filter((entry) => entry.pc === 'Ctrl + Shift + B').map((entry) => entry.id),
|
||||
['toggle-host-tree'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
DEFAULT_KEY_BINDINGS.filter((entry) => entry.mac === '⌘ + Shift + B').map((entry) => entry.id),
|
||||
['toggle-host-tree'],
|
||||
);
|
||||
|
||||
assert.equal(getTerminalPassthroughActions().has('toggleHostTree'), false);
|
||||
assert.equal(matchesKeyBinding({
|
||||
key: 'B',
|
||||
code: 'KeyB',
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
shiftKey: true,
|
||||
} as KeyboardEvent, binding?.pc ?? '', false), true);
|
||||
assert.equal(matchesKeyBinding({
|
||||
key: 'b',
|
||||
code: 'KeyB',
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
} as KeyboardEvent, binding?.pc ?? '', false), false);
|
||||
});
|
||||
|
||||
test('host list shortcut label is localized in English and Chinese', () => {
|
||||
assert.equal(en['settings.shortcuts.binding.toggle-host-tree'], 'Toggle Host List');
|
||||
assert.equal(zhCN['settings.shortcuts.binding.toggle-host-tree'], '切换主机列表');
|
||||
assert.equal(zhTW['settings.shortcuts.binding.toggle-host-tree'], '切換主機列表');
|
||||
});
|
||||
@@ -273,6 +273,7 @@ export const DEFAULT_KEY_BINDINGS: KeyBinding[] = [
|
||||
{ id: 'snippets', action: 'snippets', label: 'Open Snippets', mac: '⌘ + Shift + S', pc: 'Ctrl + Shift + S', category: 'app' },
|
||||
{ id: 'broadcast', action: 'broadcast', label: 'Switch the Broadcast Mode', mac: '⌘ + B', pc: 'Ctrl + B', category: 'app' },
|
||||
{ id: 'toggle-side-panel', action: 'toggleSidePanel', label: 'Toggle Side Panel', mac: '⌘ + \\', pc: 'Ctrl + \\', category: 'app' },
|
||||
{ id: 'toggle-host-tree', action: 'toggleHostTree', label: 'Toggle Host List', mac: '⌘ + Shift + B', pc: 'Ctrl + Shift + B', category: 'app' },
|
||||
{ id: 'open-settings', action: 'openSettings', label: 'Open Settings', mac: '⌘ + ,', pc: 'Ctrl + ,', category: 'app' },
|
||||
{ id: 'toggle-opacity', action: 'toggleOpacity', label: 'Toggle Window Transparency', mac: '⌘ + U', pc: 'Ctrl + Shift + U', category: 'app' },
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
composeSendTargetAfterToolbarToggle,
|
||||
flipComposeSendTarget,
|
||||
resolveTerminalBroadcastTargetIds,
|
||||
shouldBroadcastDuringSensitivePrompt,
|
||||
} from './terminalBroadcast.ts';
|
||||
@@ -93,6 +95,49 @@ test('missing or hidden global sources do not fan out', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('compose send target follows toolbar broadcast, then flips on its own', () => {
|
||||
assert.equal(composeSendTargetAfterToolbarToggle(true), 'all');
|
||||
assert.equal(composeSendTargetAfterToolbarToggle(false), 'active');
|
||||
assert.equal(flipComposeSendTarget('all'), 'active');
|
||||
assert.equal(flipComposeSendTarget('active'), 'all');
|
||||
// An independent flip does not rewrite the toolbar-derived default.
|
||||
assert.equal(composeSendTargetAfterToolbarToggle(true), 'all');
|
||||
});
|
||||
|
||||
test('compose peer fan-out can reach orphan peers while toolbar broadcast is off', () => {
|
||||
const sessions = [
|
||||
session('source'),
|
||||
session('peer'),
|
||||
session('hidden-orphan', { hiddenFromTabs: true }),
|
||||
];
|
||||
assert.deepEqual(
|
||||
resolveTerminalBroadcastTargetIds({
|
||||
sessions,
|
||||
sourceSessionId: 'source',
|
||||
globalBroadcastEnabled: false,
|
||||
includeGlobalPeersWithoutToolbar: true,
|
||||
}),
|
||||
['peer'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolveTerminalBroadcastTargetIds({
|
||||
sessions,
|
||||
sourceSessionId: 'source',
|
||||
globalBroadcastEnabled: false,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolveTerminalBroadcastTargetIds({
|
||||
sessions: [session('hidden-source', { hiddenFromTabs: true }), session('peer')],
|
||||
sourceSessionId: 'hidden-source',
|
||||
globalBroadcastEnabled: false,
|
||||
includeGlobalPeersWithoutToolbar: true,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('sensitive prompts pause broadcasting unless the password bypass is on', () => {
|
||||
assert.equal(
|
||||
shouldBroadcastDuringSensitivePrompt({
|
||||
|
||||
@@ -15,16 +15,37 @@ export function shouldBroadcastDuringSensitivePrompt(input: {
|
||||
return !input.sensitivePromptActive || input.broadcastPasswordBypass;
|
||||
}
|
||||
|
||||
export type ComposeSendTarget = 'all' | 'active';
|
||||
|
||||
/**
|
||||
* Toolbar broadcast is the keystroke fan-out switch. Turning it on or off
|
||||
* resets the compose-bar send target to match. A later compose-bar flip does
|
||||
* not move the toolbar switch.
|
||||
*/
|
||||
export function composeSendTargetAfterToolbarToggle(toolbarEnabled: boolean): ComposeSendTarget {
|
||||
return toolbarEnabled ? 'all' : 'active';
|
||||
}
|
||||
|
||||
export function flipComposeSendTarget(target: ComposeSendTarget): ComposeSendTarget {
|
||||
return target === 'all' ? 'active' : 'all';
|
||||
}
|
||||
|
||||
export function resolveTerminalBroadcastTargetIds({
|
||||
sessions,
|
||||
sourceSessionId,
|
||||
globalBroadcastEnabled,
|
||||
directTargetSessionIds,
|
||||
includeGlobalPeersWithoutToolbar = false,
|
||||
}: {
|
||||
sessions: readonly BroadcastSession[];
|
||||
sourceSessionId: string;
|
||||
globalBroadcastEnabled: boolean;
|
||||
directTargetSessionIds?: readonly string[];
|
||||
/**
|
||||
* Compose-bar "send to all" can target orphan peers while the toolbar
|
||||
* (global) broadcast switch stays off. Keystroke fan-out leaves this false.
|
||||
*/
|
||||
includeGlobalPeersWithoutToolbar?: boolean;
|
||||
}): string[] {
|
||||
if (directTargetSessionIds) {
|
||||
const directTargetIds = new Set(directTargetSessionIds);
|
||||
@@ -45,7 +66,8 @@ export function resolveTerminalBroadcastTargetIds({
|
||||
.map((session) => session.id);
|
||||
}
|
||||
|
||||
if (!globalBroadcastEnabled || sourceSession.hiddenFromTabs) return [];
|
||||
if (sourceSession.hiddenFromTabs) return [];
|
||||
if (!globalBroadcastEnabled && !includeGlobalPeersWithoutToolbar) return [];
|
||||
|
||||
return sessions
|
||||
.filter((session) => (
|
||||
|
||||
@@ -1,17 +1,30 @@
|
||||
"use strict";
|
||||
|
||||
// An abandoned OPEN cannot be cancelled in SSH, but its physical connection
|
||||
// may own unrelated terminals. Block only additional opens until its callback
|
||||
// or transport closure settles; retain no unbounded retry queue.
|
||||
// may own unrelated terminals. Hold further opens until the late callback,
|
||||
// transport closure, or a short abandon wait. If the callback never arrives,
|
||||
// force-clear the gate and admit a new open; the late channel is still closed
|
||||
// when its callback finally fires. Do not queue unbounded SSH channel requests
|
||||
// during the wait.
|
||||
const abandonedOpens = new WeakMap();
|
||||
|
||||
function wakeAbandonedWaiters(state) {
|
||||
const waiters = state?.waiters;
|
||||
if (!waiters || waiters.size === 0) return;
|
||||
state.waiters = new Set();
|
||||
for (const wake of waiters) {
|
||||
try { wake(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
function abandonOpen(client, token) {
|
||||
let state = abandonedOpens.get(client);
|
||||
if (!state) {
|
||||
state = { tokens: new Set(), onClose: null };
|
||||
state = { tokens: new Set(), onClose: null, waiters: new Set() };
|
||||
state.onClose = () => {
|
||||
if (abandonedOpens.get(client) === state) abandonedOpens.delete(client);
|
||||
state.tokens.clear();
|
||||
wakeAbandonedWaiters(state);
|
||||
};
|
||||
abandonedOpens.set(client, state);
|
||||
client.once?.("close", state.onClose);
|
||||
@@ -24,9 +37,29 @@ function settleAbandonedOpen(client, token) {
|
||||
if (!state || !state.tokens.delete(token) || state.tokens.size > 0) return;
|
||||
abandonedOpens.delete(client);
|
||||
client.removeListener?.("close", state.onClose);
|
||||
wakeAbandonedWaiters(state);
|
||||
}
|
||||
|
||||
function forceClearAbandonedOpens(client) {
|
||||
const state = abandonedOpens.get(client);
|
||||
if (!state) return;
|
||||
abandonedOpens.delete(client);
|
||||
state.tokens.clear();
|
||||
try { client.removeListener?.("close", state.onClose); } catch { /* ignore */ }
|
||||
wakeAbandonedWaiters(state);
|
||||
}
|
||||
|
||||
const DEFAULT_SFTP_CHANNEL_OPEN_TIMEOUT_MS = 10_000;
|
||||
const DEFAULT_SFTP_CHANNEL_ABANDON_WAIT_MS = 2_000;
|
||||
|
||||
function resolveAbandonWaitMs(value) {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return DEFAULT_SFTP_CHANNEL_ABANDON_WAIT_MS;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return DEFAULT_SFTP_CHANNEL_ABANDON_WAIT_MS;
|
||||
return Math.max(0, parsed);
|
||||
}
|
||||
|
||||
function closeSftpChannel(channel) {
|
||||
if (!channel) return;
|
||||
@@ -43,17 +76,66 @@ function createSftpOpenAbortError(signal) {
|
||||
return error;
|
||||
}
|
||||
|
||||
function openBoundedSftpChannel(sshClient, options = {}) {
|
||||
if (!sshClient || typeof sshClient.sftp !== "function") {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
function createPendingOpenError() {
|
||||
const error = new Error("A previous SFTP channel open is still pending on this connection");
|
||||
error.code = "SFTP_CHANNEL_OPEN_PENDING";
|
||||
return error;
|
||||
}
|
||||
|
||||
function waitForAbandonedOpenAdmission(sshClient, options) {
|
||||
const state = abandonedOpens.get(sshClient);
|
||||
if (!state) return Promise.resolve();
|
||||
const abandonWaitMs = resolveAbandonWaitMs(options.abandonWaitMs);
|
||||
// A zero wait keeps the historical immediate rejection for callers that must
|
||||
// not block, and still lets upload fallback treat PENDING as a poisoned open.
|
||||
if (abandonWaitMs === 0) return Promise.reject(createPendingOpenError());
|
||||
|
||||
const signal = options.signal || null;
|
||||
if (signal?.aborted) return Promise.reject(createSftpOpenAbortError(signal));
|
||||
if (abandonedOpens.has(sshClient)) {
|
||||
const error = new Error("A previous SFTP channel open is still pending on this connection");
|
||||
error.code = "SFTP_CHANNEL_OPEN_PENDING";
|
||||
return Promise.reject(error);
|
||||
}
|
||||
const setTimeoutFn = options.setTimeoutFn || setTimeout;
|
||||
const clearTimeoutFn = options.clearTimeoutFn || clearTimeout;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let timer = null;
|
||||
const cleanup = () => {
|
||||
state.waiters?.delete(onGateCleared);
|
||||
if (timer) clearTimeoutFn(timer);
|
||||
timer = null;
|
||||
signal?.removeEventListener?.("abort", onAbort);
|
||||
};
|
||||
const finish = (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
};
|
||||
const onGateCleared = () => finish();
|
||||
const onAbort = () => finish(createSftpOpenAbortError(signal));
|
||||
|
||||
if (!state.waiters) state.waiters = new Set();
|
||||
state.waiters.add(onGateCleared);
|
||||
if (abandonedOpens.get(sshClient) !== state) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
signal?.addEventListener?.("abort", onAbort, { once: true });
|
||||
if (signal?.aborted) {
|
||||
finish(createSftpOpenAbortError(signal));
|
||||
return;
|
||||
}
|
||||
// Referenced on purpose: an unreferenced timer can let Node exit while
|
||||
// this admission promise is still pending.
|
||||
timer = setTimeoutFn(() => {
|
||||
if (abandonedOpens.get(sshClient) === state) forceClearAbandonedOpens(sshClient);
|
||||
finish();
|
||||
}, abandonWaitMs);
|
||||
});
|
||||
}
|
||||
|
||||
function startBoundedSftpOpen(sshClient, options) {
|
||||
const signal = options.signal || null;
|
||||
const setTimeoutFn = options.setTimeoutFn || setTimeout;
|
||||
const clearTimeoutFn = options.clearTimeoutFn || clearTimeout;
|
||||
const timeoutMs = Math.max(
|
||||
@@ -117,7 +199,22 @@ function openBoundedSftpChannel(sshClient, options = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
function openBoundedSftpChannel(sshClient, options = {}) {
|
||||
if (!sshClient || typeof sshClient.sftp !== "function") {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
const signal = options.signal || null;
|
||||
if (signal?.aborted) return Promise.reject(createSftpOpenAbortError(signal));
|
||||
if (!abandonedOpens.has(sshClient)) {
|
||||
return startBoundedSftpOpen(sshClient, options);
|
||||
}
|
||||
return waitForAbandonedOpenAdmission(sshClient, options).then(() => (
|
||||
openBoundedSftpChannel(sshClient, options)
|
||||
));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_SFTP_CHANNEL_ABANDON_WAIT_MS,
|
||||
DEFAULT_SFTP_CHANNEL_OPEN_TIMEOUT_MS,
|
||||
closeSftpChannel,
|
||||
openBoundedSftpChannel,
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter, getEventListeners } = require("node:events");
|
||||
const { openBoundedSftpChannel } = require("./boundedSftpOpen.cjs");
|
||||
const {
|
||||
DEFAULT_SFTP_CHANNEL_ABANDON_WAIT_MS,
|
||||
openBoundedSftpChannel,
|
||||
} = require("./boundedSftpOpen.cjs");
|
||||
|
||||
function trackedTimerApi() {
|
||||
const active = new Set();
|
||||
@@ -94,9 +97,12 @@ for (const reason of ["timeout", "cancel"]) {
|
||||
if (reason === "cancel") controller.abort(new Error("cancelled"));
|
||||
await assert.rejects(opening);
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
await assert.rejects(openBoundedSftpChannel(sshClient, { timeoutMs: 5 }));
|
||||
await assert.rejects(
|
||||
openBoundedSftpChannel(sshClient, { timeoutMs: 5, abandonWaitMs: 0 }),
|
||||
(error) => error.code === "SFTP_CHANNEL_OPEN_PENDING",
|
||||
);
|
||||
}
|
||||
assert.equal(callbacks.length, 1, "retries must not allocate more abandoned SSH channel requests");
|
||||
assert.equal(callbacks.length, 1, "a zero abandon wait must not allocate more SSH channel requests");
|
||||
assert.equal(physicalCloses, 0, "existing terminal and SFTP channels remain connected");
|
||||
|
||||
const late = createChannel();
|
||||
@@ -174,10 +180,170 @@ test("all abandoned parallel opens must settle before admitting a new one", asyn
|
||||
await Promise.all([assert.rejects(first), assert.rejects(second)]);
|
||||
assert.equal(sshClient.listenerCount("close"), 1);
|
||||
callbacks[0](new Error("first failed"));
|
||||
await assert.rejects(openBoundedSftpChannel(sshClient), error => error.code === "SFTP_CHANNEL_OPEN_PENDING");
|
||||
await assert.rejects(
|
||||
openBoundedSftpChannel(sshClient, { abandonWaitMs: 0 }),
|
||||
(error) => error.code === "SFTP_CHANNEL_OPEN_PENDING",
|
||||
);
|
||||
callbacks[1](new Error("second failed"));
|
||||
assert.equal(sshClient.listenerCount("close"), 0);
|
||||
const next = openBoundedSftpChannel(sshClient);
|
||||
callbacks[2](null, createChannel());
|
||||
await next;
|
||||
});
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function settleOpen(promise) {
|
||||
return promise.then((channel) => ({ channel }), (error) => ({ error }));
|
||||
}
|
||||
|
||||
test("abandon wait defaults to 2s", () => {
|
||||
assert.equal(DEFAULT_SFTP_CHANNEL_ABANDON_WAIT_MS, 2_000);
|
||||
});
|
||||
|
||||
test("omitted abandon wait does not admit a new open immediately", async () => {
|
||||
const callbacks = [];
|
||||
const sshClient = new EventEmitter();
|
||||
sshClient.sftp = (callback) => { callbacks.push(callback); };
|
||||
await assert.rejects(
|
||||
openBoundedSftpChannel(sshClient, { timeoutMs: 5 }),
|
||||
(error) => error.code === "SFTP_CHANNEL_OPEN_TIMEOUT",
|
||||
);
|
||||
const outcome = settleOpen(openBoundedSftpChannel(sshClient, { timeoutMs: 50 }));
|
||||
await delay(40);
|
||||
assert.equal(callbacks.length, 1, "the default abandon wait must still be holding");
|
||||
const late = createChannel();
|
||||
callbacks[0](null, late);
|
||||
assert.ok(late.endCalls > 0 || late.closeCalls > 0);
|
||||
await delay(20);
|
||||
assert.equal(callbacks.length, 2, "a late callback must release the next open");
|
||||
const healthy = createChannel();
|
||||
callbacks[1](null, healthy);
|
||||
const settled = await outcome;
|
||||
assert.equal(settled.channel, healthy);
|
||||
assert.equal(settled.error, undefined);
|
||||
});
|
||||
|
||||
test("abandon wait admits a new open when the late callback never arrives", async () => {
|
||||
const callbacks = [];
|
||||
const sshClient = new EventEmitter();
|
||||
sshClient.sftp = (callback) => {
|
||||
callbacks.push(callback);
|
||||
if (callbacks.length > 1) callback(null, createChannel());
|
||||
};
|
||||
await assert.rejects(
|
||||
openBoundedSftpChannel(sshClient, { timeoutMs: 10, abandonWaitMs: 20 }),
|
||||
(error) => error.code === "SFTP_CHANNEL_OPEN_TIMEOUT",
|
||||
);
|
||||
assert.equal(sshClient.listenerCount("close"), 1);
|
||||
|
||||
let pending = 0;
|
||||
let ok = 0;
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
try {
|
||||
const channel = await openBoundedSftpChannel(sshClient, {
|
||||
timeoutMs: 50,
|
||||
abandonWaitMs: 30,
|
||||
});
|
||||
assert.ok(channel);
|
||||
ok += 1;
|
||||
} catch (error) {
|
||||
if (error?.code === "SFTP_CHANNEL_OPEN_PENDING") pending += 1;
|
||||
else throw error;
|
||||
}
|
||||
}
|
||||
assert.equal(ok, 5);
|
||||
assert.equal(pending, 0);
|
||||
assert.equal(sshClient.listenerCount("close"), 0, "force-clear must drop the close listener");
|
||||
|
||||
const late = createChannel();
|
||||
callbacks[0](null, late);
|
||||
assert.ok(late.endCalls > 0 || late.closeCalls > 0, "a late channel is still closed");
|
||||
assert.equal(sshClient.listenerCount("close"), 0);
|
||||
});
|
||||
|
||||
test("partially settled abandoned opens keep the gate until the rest arrive or the wait expires", async () => {
|
||||
const callbacks = [];
|
||||
const sshClient = new EventEmitter();
|
||||
sshClient.sftp = (callback) => { callbacks.push(callback); };
|
||||
const first = openBoundedSftpChannel(sshClient, { timeoutMs: 5, abandonWaitMs: 20 });
|
||||
const second = openBoundedSftpChannel(sshClient, { timeoutMs: 5, abandonWaitMs: 20 });
|
||||
await Promise.all([assert.rejects(first), assert.rejects(second)]);
|
||||
|
||||
const outcome = settleOpen(openBoundedSftpChannel(sshClient, {
|
||||
timeoutMs: 50,
|
||||
abandonWaitMs: 250,
|
||||
}));
|
||||
await delay(30);
|
||||
callbacks[0](new Error("first failed"));
|
||||
await delay(30);
|
||||
assert.equal(callbacks.length, 2, "one remaining abandoned open must keep the gate");
|
||||
callbacks[1](new Error("second failed"));
|
||||
await delay(20);
|
||||
assert.equal(callbacks.length, 3);
|
||||
const healthy = createChannel();
|
||||
callbacks[2](null, healthy);
|
||||
const settled = await outcome;
|
||||
assert.equal(settled.channel, healthy);
|
||||
});
|
||||
|
||||
test("abandon wait force-clears every outstanding token", async () => {
|
||||
const callbacks = [];
|
||||
let admitted = null;
|
||||
const sshClient = new EventEmitter();
|
||||
sshClient.sftp = (callback) => {
|
||||
callbacks.push(callback);
|
||||
if (callbacks.length > 2) {
|
||||
admitted = createChannel();
|
||||
callback(null, admitted);
|
||||
}
|
||||
};
|
||||
await Promise.all([
|
||||
assert.rejects(openBoundedSftpChannel(sshClient, { timeoutMs: 5 })),
|
||||
assert.rejects(openBoundedSftpChannel(sshClient, { timeoutMs: 5 })),
|
||||
]);
|
||||
const outcome = settleOpen(openBoundedSftpChannel(sshClient, {
|
||||
timeoutMs: 50,
|
||||
abandonWaitMs: 40,
|
||||
}));
|
||||
await delay(15);
|
||||
assert.equal(callbacks.length, 2);
|
||||
const settled = await outcome;
|
||||
assert.equal(settled.error, undefined, settled.error?.message);
|
||||
assert.equal(settled.channel, admitted);
|
||||
assert.equal(admitted.endCalls, 0);
|
||||
assert.equal(admitted.closeCalls, 0);
|
||||
assert.equal(callbacks.length, 3);
|
||||
for (const callback of callbacks.slice(0, 2)) {
|
||||
const late = createChannel();
|
||||
callback(null, late);
|
||||
assert.ok(late.endCalls > 0 || late.closeCalls > 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("cancel during the abandon wait does not admit another open or clear the gate", async () => {
|
||||
const callbacks = [];
|
||||
const sshClient = new EventEmitter();
|
||||
sshClient.sftp = (callback) => { callbacks.push(callback); };
|
||||
const controller = new AbortController();
|
||||
await assert.rejects(openBoundedSftpChannel(sshClient, { timeoutMs: 5 }));
|
||||
const outcome = settleOpen(openBoundedSftpChannel(sshClient, {
|
||||
timeoutMs: 50,
|
||||
abandonWaitMs: 400,
|
||||
signal: controller.signal,
|
||||
}));
|
||||
await delay(20);
|
||||
assert.equal(callbacks.length, 1);
|
||||
controller.abort(new Error("cancelled"));
|
||||
const settled = await outcome;
|
||||
assert.match(settled.error?.message || "", /cancelled/);
|
||||
assert.equal(callbacks.length, 1);
|
||||
assert.equal(getEventListeners(controller.signal, "abort").length, 0);
|
||||
await assert.rejects(
|
||||
openBoundedSftpChannel(sshClient, { abandonWaitMs: 0 }),
|
||||
(error) => error.code === "SFTP_CHANNEL_OPEN_PENDING",
|
||||
);
|
||||
assert.equal(callbacks.length, 1);
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -1596,7 +1596,10 @@ let sftpClients = null;
|
||||
// Retry is same-process — a relaunch with no fingerprint already restarts at 0.
|
||||
// Entries stay only while that task can still resume from the checkpoint.
|
||||
const localUploadAcknowledgedPrefix = new Map();
|
||||
const UPLOAD_PREFIX_SESSION_LOSS = /SFTP (channel closed|session lost)|ECONNRESET|EPIPE|Not connected|No response from server|Keepalive timeout|connection (lost|closed|reset)/i;
|
||||
// Keep identical to SESSION_LOSS_ERROR in application/state/sftp/retryFromCheckpoint.ts.
|
||||
// Both must accept every code or message isSftpChannelLossError treats as channel
|
||||
// loss, or a same-id Retry drops this digest and restarts at byte 0.
|
||||
const UPLOAD_PREFIX_SESSION_LOSS = /SFTP (channel closed|session lost)|ERR_SFTP_CHANNEL_CLOSED|ECONNRESET|ECONNABORTED|EPIPE|Not connected|No response from server|Keepalive timeout|socket hang up|client-socket|connection (lost|closed|reset)/i;
|
||||
|
||||
function forgetLocalUploadAcknowledgedPrefix(transferId) {
|
||||
if (transferId) localUploadAcknowledgedPrefix.delete(String(transferId));
|
||||
@@ -1641,9 +1644,20 @@ function localUploadAcknowledgedOffset(transferId) {
|
||||
return entry.digestOffset;
|
||||
}
|
||||
|
||||
function localUploadPrefixFailureKeepsCheckpoint(transferId, message) {
|
||||
return localUploadAcknowledgedOffset(transferId) > 0
|
||||
&& UPLOAD_PREFIX_SESSION_LOSS.test(String(message || ""));
|
||||
function isTransferChannelLossError(error) {
|
||||
if (!error || typeof error !== "object") return false;
|
||||
if (isSftpChannelLossError(error)) return true;
|
||||
const cause = error.cause;
|
||||
return Boolean(cause && typeof cause === "object" && isSftpChannelLossError(cause));
|
||||
}
|
||||
|
||||
function localUploadPrefixFailureKeepsCheckpoint(transferId, error) {
|
||||
if (!(localUploadAcknowledgedOffset(transferId) > 0)) return false;
|
||||
// Prefer the bridge classifier (code, level, cause). The shared regex still
|
||||
// covers string-only failures such as "Not connected" and "SFTP channel closed".
|
||||
if (isTransferChannelLossError(error)) return true;
|
||||
const message = typeof error === "string" ? error : (error?.message || String(error || ""));
|
||||
return UPLOAD_PREFIX_SESSION_LOSS.test(message);
|
||||
}
|
||||
|
||||
async function localUploadAcknowledgedPrefixMatches(transferId, sourcePath, checkpoint, sourceSize, options) {
|
||||
@@ -2051,6 +2065,23 @@ function isStaleOrClosedSharedChannelError(error, sftp) {
|
||||
return /channel closed/i.test(String(error.message || ""));
|
||||
}
|
||||
|
||||
// Test seam: shorten channel-open timeout / abandon wait. Production leaves this null.
|
||||
let isolatedSftpChannelOpenOptionsForTests = null;
|
||||
|
||||
function isolatedChannelOpenOptions(signal) {
|
||||
const options = { signal };
|
||||
const overrides = isolatedSftpChannelOpenOptionsForTests;
|
||||
if (!overrides || typeof overrides !== "object") return options;
|
||||
if (overrides.timeoutMs !== undefined) options.timeoutMs = overrides.timeoutMs;
|
||||
if (overrides.abandonWaitMs !== undefined) options.abandonWaitMs = overrides.abandonWaitMs;
|
||||
return options;
|
||||
}
|
||||
|
||||
function isPoisonedSftpChannelOpenError(error) {
|
||||
const code = error?.code;
|
||||
return code === "SFTP_CHANNEL_OPEN_TIMEOUT" || code === "SFTP_CHANNEL_OPEN_PENDING";
|
||||
}
|
||||
|
||||
async function openIsolatedSftpChannel(client, signal = null) {
|
||||
// The dedicated one-channel login already holds its only session channel.
|
||||
// Opening another SFTP subsystem drops that login, so stay on the browse channel.
|
||||
@@ -2058,7 +2089,7 @@ async function openIsolatedSftpChannel(client, signal = null) {
|
||||
return null;
|
||||
}
|
||||
const sshClient = client?.client;
|
||||
return openBoundedSftpChannel(sshClient, { signal });
|
||||
return openBoundedSftpChannel(sshClient, isolatedChannelOpenOptions(signal));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2698,6 +2729,7 @@ async function uploadFile(
|
||||
|
||||
// Prefer an isolated SFTP channel so cancellation cannot kill the browse session.
|
||||
let isolated = null;
|
||||
let isolatedOpenError = null;
|
||||
const preferSharedChannel = Number(fileSize) > 0
|
||||
&& Number(fileSize) <= SMALL_UPLOAD_SHARED_MAX_BYTES
|
||||
&& typeof sftp?.open === "function"
|
||||
@@ -2709,6 +2741,7 @@ async function uploadFile(
|
||||
);
|
||||
} catch (err) {
|
||||
rememberPipelineError(err);
|
||||
isolatedOpenError = err;
|
||||
console.warn(
|
||||
"[transferBridge] Failed to open isolated SFTP channel for upload:",
|
||||
err.message || String(err),
|
||||
@@ -2774,7 +2807,12 @@ async function uploadFile(
|
||||
}
|
||||
}
|
||||
|
||||
if (!isolated && !client.__netcattySudoMode && !preferSharedChannel) {
|
||||
// A timed-out or still-pending isolated OPEN has already spent the channel
|
||||
// deadline (or is waiting on an abandoned one). Reopening for fastPut only
|
||||
// pays another timeout or an immediate PENDING on a lossy link. Fall through
|
||||
// to the shared browse channel instead (#3155).
|
||||
const skipIsolatedFastPutReopen = isPoisonedSftpChannelOpenError(isolatedOpenError);
|
||||
if (!isolated && !skipIsolatedFastPutReopen && !client.__netcattySudoMode && !preferSharedChannel) {
|
||||
try {
|
||||
isolated = markDisposableUploadChannel(
|
||||
await openIsolatedSftpChannel(client, transfer?.signal),
|
||||
@@ -6412,7 +6450,7 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
}
|
||||
cleanupTransfer();
|
||||
const cancelled = /cancel/i.test(message);
|
||||
if (cancelled || !localUploadPrefixFailureKeepsCheckpoint(transferId, message)) {
|
||||
if (cancelled || !localUploadPrefixFailureKeepsCheckpoint(transferId, error)) {
|
||||
forgetLocalUploadAcknowledgedPrefix(transferId);
|
||||
}
|
||||
if (cancelled) {
|
||||
@@ -8270,6 +8308,11 @@ module.exports = {
|
||||
_setLocalPrefixHashStreamOverrideForTests: (override) => {
|
||||
localPrefixHashStreamOverrideForTests = override;
|
||||
},
|
||||
_setIsolatedSftpChannelOpenOptionsForTests: (options) => {
|
||||
isolatedSftpChannelOpenOptionsForTests = options && typeof options === "object"
|
||||
? options
|
||||
: null;
|
||||
},
|
||||
_hasLocalUploadAcknowledgedPrefixForTests: (transferId) => (
|
||||
localUploadAcknowledgedPrefix.has(String(transferId))
|
||||
),
|
||||
|
||||
@@ -10,6 +10,7 @@ const { EventEmitter } = require("node:events");
|
||||
const { PassThrough, Readable, Writable } = require("node:stream");
|
||||
|
||||
const transferBridge = require("./transferBridge.cjs");
|
||||
const { openBoundedSftpChannel } = require("./boundedSftpOpen.cjs");
|
||||
// Most download tests here drive the session slot / isolated pool with tiny
|
||||
// fixture files. Keep them on that path; the small-file exemption has its own
|
||||
// test below that restores the production threshold.
|
||||
@@ -14754,3 +14755,247 @@ test("small download does not open a fresh channel for ENOENT or EACCES", async
|
||||
assert.match(denied.error || "", /Permission denied|EACCES/);
|
||||
assert.equal(isolatedOpens, 0);
|
||||
});
|
||||
|
||||
test("channel-open timeout skips the fastPut reopen and uses the shared channel", async (t) => {
|
||||
// Lossy links abandon an isolated OPEN. Reopening for fastPut only waits out
|
||||
// the abandon gate and pays another timeout (or PENDING). The body must move
|
||||
// to the shared browse channel after the first poisoned open.
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-sftp-open-poison-upload-"));
|
||||
t.after(async () => {
|
||||
transferBridge._setIsolatedSftpChannelOpenOptionsForTests(null);
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
transferBridge._setIsolatedSftpChannelOpenOptionsForTests({
|
||||
timeoutMs: 20,
|
||||
abandonWaitMs: 30,
|
||||
});
|
||||
|
||||
const localPath = path.join(tempDir, "fresh.bin");
|
||||
const payload = Buffer.concat([
|
||||
Buffer.from("open-poison"),
|
||||
Buffer.alloc(SMALL_UPLOAD_SHARED_MAX_BYTES),
|
||||
]);
|
||||
await fs.promises.writeFile(localPath, payload);
|
||||
const targetPath = "/tmp/open-poison.bin";
|
||||
const remoteFiles = new Map();
|
||||
const lateChannels = [];
|
||||
let isolatedOpens = 0;
|
||||
let fastPutCalls = 0;
|
||||
let sharedWrites = 0;
|
||||
|
||||
const rememberFile = (sftp) => {
|
||||
sftp.lstat = (remotePath, callback) => {
|
||||
const key = String(remotePath);
|
||||
if (!remoteFiles.has(key)) {
|
||||
const error = new Error("ENOENT");
|
||||
error.code = 2;
|
||||
callback(error);
|
||||
return;
|
||||
}
|
||||
callback(null, {
|
||||
size: remoteFiles.get(key).length,
|
||||
mode: 0o100644,
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => false,
|
||||
});
|
||||
};
|
||||
sftp.open = (remotePath, _flags, callback) => {
|
||||
const key = String(remotePath);
|
||||
if (!remoteFiles.has(key)) remoteFiles.set(key, Buffer.alloc(0));
|
||||
callback(null, Buffer.from(key));
|
||||
};
|
||||
sftp.close = (_handle, callback) => callback(null);
|
||||
};
|
||||
|
||||
const shared = createFastSftp({});
|
||||
rememberFile(shared);
|
||||
shared.write = (handle, buffer, offset, length, position, callback) => {
|
||||
sharedWrites += 1;
|
||||
const key = handle.toString();
|
||||
const next = Buffer.alloc(Math.max(remoteFiles.get(key)?.length || 0, position + length));
|
||||
if (remoteFiles.has(key)) remoteFiles.get(key).copy(next);
|
||||
buffer.copy(next, position, offset, offset + length);
|
||||
remoteFiles.set(key, next);
|
||||
setImmediate(() => callback(null));
|
||||
};
|
||||
shared.fastPut = () => {
|
||||
fastPutCalls += 1;
|
||||
throw new Error("shared fastPut must not run after a poisoned channel open");
|
||||
};
|
||||
|
||||
const client = {
|
||||
sftp: shared,
|
||||
stat: async (remotePath) => {
|
||||
const key = String(remotePath);
|
||||
if (!remoteFiles.has(key)) {
|
||||
const error = new Error("ENOENT");
|
||||
error.code = 2;
|
||||
throw error;
|
||||
}
|
||||
return { size: remoteFiles.get(key).length, isDirectory: false };
|
||||
},
|
||||
delete: async (remotePath) => { remoteFiles.delete(String(remotePath)); },
|
||||
async rename(fromPath, toPath) {
|
||||
remoteFiles.set(String(toPath), remoteFiles.get(String(fromPath)));
|
||||
remoteFiles.delete(String(fromPath));
|
||||
},
|
||||
client: {
|
||||
sftp(callback) {
|
||||
isolatedOpens += 1;
|
||||
const late = createFastSftp({
|
||||
fastPut() {
|
||||
fastPutCalls += 1;
|
||||
throw new Error("fastPut reopen must not run after channel-open timeout");
|
||||
},
|
||||
end() { late.endCalls += 1; },
|
||||
close() { late.closeCalls += 1; },
|
||||
destroy() { late.destroyCalls += 1; },
|
||||
});
|
||||
late.endCalls = 0;
|
||||
late.closeCalls = 0;
|
||||
late.destroyCalls = 0;
|
||||
lateChannels.push({ callback, late });
|
||||
},
|
||||
},
|
||||
};
|
||||
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
||||
|
||||
const result = await transferBridge.startTransfer({ sender: createSender() }, {
|
||||
transferId: "upload-channel-open-timeout-shared",
|
||||
sourcePath: localPath,
|
||||
targetPath,
|
||||
sourceType: "local",
|
||||
targetType: "sftp",
|
||||
targetSftpId: "target",
|
||||
totalBytes: payload.length,
|
||||
resumable: false,
|
||||
skipAdmission: true,
|
||||
});
|
||||
|
||||
assert.equal(result.error, undefined, result.error);
|
||||
assert.equal(isolatedOpens, 1, "fastPut must not reopen an isolated channel after open timeout");
|
||||
assert.equal(fastPutCalls, 0);
|
||||
assert.ok(sharedWrites >= 1);
|
||||
assert.deepEqual(remoteFiles.get(targetPath), payload);
|
||||
|
||||
const abandoned = lateChannels[0];
|
||||
abandoned.callback(null, abandoned.late);
|
||||
assert.ok(
|
||||
abandoned.late.endCalls > 0 || abandoned.late.closeCalls > 0 || abandoned.late.destroyCalls > 0,
|
||||
"the abandoned channel is closed when its callback finally arrives",
|
||||
);
|
||||
});
|
||||
|
||||
test("pending channel-open skips the fastPut reopen and uses the shared channel", async (t) => {
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-sftp-open-pending-upload-"));
|
||||
t.after(async () => {
|
||||
transferBridge._setIsolatedSftpChannelOpenOptionsForTests(null);
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const localPath = path.join(tempDir, "fresh.bin");
|
||||
const payload = Buffer.concat([
|
||||
Buffer.from("open-pending"),
|
||||
Buffer.alloc(SMALL_UPLOAD_SHARED_MAX_BYTES),
|
||||
]);
|
||||
await fs.promises.writeFile(localPath, payload);
|
||||
const targetPath = "/tmp/open-pending.bin";
|
||||
const remoteFiles = new Map();
|
||||
let isolatedOpens = 0;
|
||||
let sharedWrites = 0;
|
||||
const warnings = [];
|
||||
const originalWarn = console.warn;
|
||||
console.warn = (...args) => {
|
||||
warnings.push(args.map((part) => String(part)).join(" "));
|
||||
originalWarn.apply(console, args);
|
||||
};
|
||||
t.after(() => { console.warn = originalWarn; });
|
||||
|
||||
const rememberFile = (sftp) => {
|
||||
sftp.lstat = (remotePath, callback) => {
|
||||
const key = String(remotePath);
|
||||
if (!remoteFiles.has(key)) {
|
||||
const error = new Error("ENOENT");
|
||||
error.code = 2;
|
||||
callback(error);
|
||||
return;
|
||||
}
|
||||
callback(null, {
|
||||
size: remoteFiles.get(key).length,
|
||||
mode: 0o100644,
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => false,
|
||||
});
|
||||
};
|
||||
sftp.open = (remotePath, _flags, callback) => {
|
||||
const key = String(remotePath);
|
||||
if (!remoteFiles.has(key)) remoteFiles.set(key, Buffer.alloc(0));
|
||||
callback(null, Buffer.from(key));
|
||||
};
|
||||
sftp.close = (_handle, callback) => callback(null);
|
||||
};
|
||||
const shared = createFastSftp({});
|
||||
rememberFile(shared);
|
||||
shared.write = (handle, buffer, offset, length, position, callback) => {
|
||||
sharedWrites += 1;
|
||||
const key = handle.toString();
|
||||
const next = Buffer.alloc(Math.max(remoteFiles.get(key)?.length || 0, position + length));
|
||||
if (remoteFiles.has(key)) remoteFiles.get(key).copy(next);
|
||||
buffer.copy(next, position, offset, offset + length);
|
||||
remoteFiles.set(key, next);
|
||||
setImmediate(() => callback(null));
|
||||
};
|
||||
|
||||
const sshClient = new EventEmitter();
|
||||
sshClient.sftp = (callback) => {
|
||||
isolatedOpens += 1;
|
||||
sshClient.pending = callback;
|
||||
};
|
||||
const client = {
|
||||
sftp: shared,
|
||||
stat: async (remotePath) => {
|
||||
const key = String(remotePath);
|
||||
if (!remoteFiles.has(key)) {
|
||||
const error = new Error("ENOENT");
|
||||
error.code = 2;
|
||||
throw error;
|
||||
}
|
||||
return { size: remoteFiles.get(key).length, isDirectory: false };
|
||||
},
|
||||
delete: async (remotePath) => { remoteFiles.delete(String(remotePath)); },
|
||||
async rename(fromPath, toPath) {
|
||||
remoteFiles.set(String(toPath), remoteFiles.get(String(fromPath)));
|
||||
remoteFiles.delete(String(fromPath));
|
||||
},
|
||||
client: sshClient,
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
openBoundedSftpChannel(sshClient, { timeoutMs: 15 }),
|
||||
(error) => error.code === "SFTP_CHANNEL_OPEN_TIMEOUT",
|
||||
);
|
||||
assert.equal(isolatedOpens, 1);
|
||||
transferBridge._setIsolatedSftpChannelOpenOptionsForTests({ abandonWaitMs: 0 });
|
||||
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
||||
|
||||
const result = await transferBridge.startTransfer({ sender: createSender() }, {
|
||||
transferId: "upload-channel-open-pending-shared",
|
||||
sourcePath: localPath,
|
||||
targetPath,
|
||||
sourceType: "local",
|
||||
targetType: "sftp",
|
||||
targetSftpId: "target",
|
||||
totalBytes: payload.length,
|
||||
resumable: false,
|
||||
skipAdmission: true,
|
||||
});
|
||||
|
||||
assert.equal(result.error, undefined, result.error);
|
||||
assert.equal(isolatedOpens, 1, "PENDING must not allocate another isolated OPEN");
|
||||
assert.equal(
|
||||
warnings.some((line) => line.includes("Failed to reopen isolated SFTP channel for fastPut")),
|
||||
false,
|
||||
);
|
||||
assert.ok(sharedWrites >= 1);
|
||||
assert.deepEqual(remoteFiles.get(targetPath), payload);
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user