mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-12 01:00:54 +00:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e997d52418 | ||
|
|
02f8c798f7 | ||
|
|
45b0673ce8 | ||
|
|
651d474e01 | ||
|
|
695fd27688 | ||
|
|
238fe45ee4 | ||
|
|
e780d44f97 | ||
|
|
9766f21ecf | ||
|
|
e9cd311936 | ||
|
|
d6871dbca9 | ||
|
|
850c5309b6 | ||
|
|
e5bfc12a2e | ||
|
|
6ab684bcd5 | ||
|
|
acecd46d57 | ||
|
|
aee852a9ac | ||
|
|
5bef721ac9 | ||
|
|
4c82424adf | ||
|
|
439f981c67 | ||
|
|
3d593b206b | ||
|
|
f95e490707 | ||
|
|
bc36f6a085 | ||
|
|
f7a166c3e9 | ||
|
|
7c82ec15d0 | ||
|
|
2849871111 | ||
|
|
36057a97d8 | ||
|
|
41c66f76fa | ||
|
|
54cedb09b2 |
No files matched your search
@@ -181,7 +181,9 @@ Organize and navigate your hosts using the view that best fits the moment: grid
|
||||
|
||||
## Monitoring View
|
||||
|
||||
Inspect CPU, memory, disk, network, and latency readings, with GPU details where available. Choose visible columns, resize them, adjust refresh controls, and open a terminal directly from a monitored host.
|
||||
Inspect CPU, memory, disk, network, and latency readings, with GPU details where available. Choose visible columns, resize them, select refresh intervals down to 1 second, and open a terminal directly from a monitored host.
|
||||
|
||||
Disk I/O shows aggregate read/write throughput for Linux hardware-backed block devices, without counting partitions or stacked virtual devices twice. Rates use two consecutive samples; `--` means unavailable or warming up, not zero activity. Disk I/O is currently unavailable on macOS; existing macOS monitoring is unchanged. The terminal system overview uses the same shared measurements.
|
||||
|
||||
<img width="3364" height="1982" alt="image" src="docs/assets/readme/monitoring.webp" />
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
@@ -8,6 +9,11 @@ import {
|
||||
handleGlobalHotkeyKeyDownImpl,
|
||||
markForwardedNativeShortcutEvent,
|
||||
} from './app/AppHandlers.ts';
|
||||
import {
|
||||
DEFAULT_SETTINGS_CHROME_SNAPSHOT,
|
||||
getSettingsChromeSnapshot,
|
||||
publishSettingsChromeSnapshot,
|
||||
} from './state/settingsChromeStore.ts';
|
||||
import { matchesKeyBinding } from '../domain/models.ts';
|
||||
import { DEFAULT_KEY_BINDINGS } from '../domain/models/keyBindings.ts';
|
||||
|
||||
@@ -829,3 +835,201 @@ for (const scenario of [
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const scheme of ['pc', 'mac'] as const) {
|
||||
test(`focused terminal ${scheme} consumes Shift+B for the host list and leaves plain B to broadcast`, () => {
|
||||
const target = new FakeHTMLElement();
|
||||
const shiftedActions: string[] = [];
|
||||
let shiftedPrevented = false;
|
||||
let shiftedStopped = false;
|
||||
handleGlobalHotkeyKeyDownImpl(() => ({
|
||||
HOTKEY_DEBUG: false,
|
||||
closeTabKeyStr: '',
|
||||
executeHotkeyAction: (action: string) => {
|
||||
shiftedActions.push(action);
|
||||
return action === 'toggleHostTree';
|
||||
},
|
||||
hotkeyScheme: scheme,
|
||||
keyBindings: DEFAULT_KEY_BINDINGS,
|
||||
matchesKeyBinding,
|
||||
}), {
|
||||
key: 'b',
|
||||
code: 'KeyB',
|
||||
ctrlKey: scheme === 'pc',
|
||||
metaKey: scheme === 'mac',
|
||||
altKey: false,
|
||||
shiftKey: true,
|
||||
target,
|
||||
composedPath: () => [target],
|
||||
preventDefault: () => { shiftedPrevented = true; },
|
||||
stopPropagation: () => { shiftedStopped = true; },
|
||||
} as unknown as KeyboardEvent);
|
||||
|
||||
assert.deepEqual(shiftedActions, ['toggleHostTree']);
|
||||
assert.equal(shiftedPrevented, true);
|
||||
assert.equal(shiftedStopped, true);
|
||||
|
||||
const plainActions: string[] = [];
|
||||
let plainPrevented = false;
|
||||
handleGlobalHotkeyKeyDownImpl(() => ({
|
||||
HOTKEY_DEBUG: false,
|
||||
closeTabKeyStr: '',
|
||||
executeHotkeyAction: (action: string) => {
|
||||
plainActions.push(action);
|
||||
return undefined;
|
||||
},
|
||||
hotkeyScheme: scheme,
|
||||
keyBindings: DEFAULT_KEY_BINDINGS,
|
||||
matchesKeyBinding,
|
||||
}), {
|
||||
key: 'b',
|
||||
code: 'KeyB',
|
||||
ctrlKey: scheme === 'pc',
|
||||
metaKey: scheme === 'mac',
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
target,
|
||||
composedPath: () => [target],
|
||||
preventDefault: () => { plainPrevented = true; },
|
||||
stopPropagation: () => {},
|
||||
} as unknown as KeyboardEvent);
|
||||
|
||||
assert.deepEqual(plainActions, ['broadcast']);
|
||||
assert.equal(plainPrevented, false);
|
||||
});
|
||||
}
|
||||
|
||||
test('focused terminal leaves the host-list chord alone when the toggle does not run', () => {
|
||||
const target = new FakeHTMLElement();
|
||||
const actions: string[] = [];
|
||||
let prevented = false;
|
||||
let stopped = false;
|
||||
handleGlobalHotkeyKeyDownImpl(() => ({
|
||||
HOTKEY_DEBUG: false,
|
||||
closeTabKeyStr: '',
|
||||
executeHotkeyAction: (action: string) => {
|
||||
actions.push(action);
|
||||
return false;
|
||||
},
|
||||
hotkeyScheme: 'pc',
|
||||
keyBindings: DEFAULT_KEY_BINDINGS,
|
||||
matchesKeyBinding,
|
||||
}), {
|
||||
key: 'b',
|
||||
code: 'KeyB',
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
shiftKey: true,
|
||||
target,
|
||||
composedPath: () => [target],
|
||||
preventDefault: () => { prevented = true; },
|
||||
stopPropagation: () => { stopped = true; },
|
||||
} as unknown as KeyboardEvent);
|
||||
|
||||
assert.deepEqual(actions, ['toggleHostTree']);
|
||||
assert.equal(prevented, false);
|
||||
assert.equal(stopped, false);
|
||||
});
|
||||
|
||||
test('host list shortcut reaches a focused Monaco editor and stays out of plain text fields', () => {
|
||||
const monaco = new FakeMonacoHTMLElement();
|
||||
const monacoActions: string[] = [];
|
||||
let monacoPrevented = false;
|
||||
handleGlobalHotkeyKeyDownImpl(() => ({
|
||||
HOTKEY_DEBUG: false,
|
||||
closeTabKeyStr: '',
|
||||
executeHotkeyAction: (action: string) => {
|
||||
monacoActions.push(action);
|
||||
return true;
|
||||
},
|
||||
hotkeyScheme: 'pc',
|
||||
keyBindings: DEFAULT_KEY_BINDINGS,
|
||||
matchesKeyBinding,
|
||||
}), {
|
||||
key: 'b',
|
||||
code: 'KeyB',
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
shiftKey: true,
|
||||
target: monaco,
|
||||
composedPath: () => [monaco],
|
||||
preventDefault: () => { monacoPrevented = true; },
|
||||
stopPropagation: () => {},
|
||||
} as unknown as KeyboardEvent);
|
||||
|
||||
assert.deepEqual(monacoActions, ['toggleHostTree']);
|
||||
assert.equal(monacoPrevented, true);
|
||||
|
||||
const input = new FakeInputHTMLElement();
|
||||
const inputActions: string[] = [];
|
||||
let inputPrevented = false;
|
||||
handleGlobalHotkeyKeyDownImpl(() => ({
|
||||
HOTKEY_DEBUG: false,
|
||||
closeTabKeyStr: '',
|
||||
executeHotkeyAction: (action: string) => {
|
||||
inputActions.push(action);
|
||||
return true;
|
||||
},
|
||||
hotkeyScheme: 'pc',
|
||||
keyBindings: DEFAULT_KEY_BINDINGS,
|
||||
matchesKeyBinding,
|
||||
}), {
|
||||
key: 'b',
|
||||
code: 'KeyB',
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
shiftKey: true,
|
||||
target: input,
|
||||
composedPath: () => [input],
|
||||
preventDefault: () => { inputPrevented = true; },
|
||||
stopPropagation: () => {},
|
||||
} as unknown as KeyboardEvent);
|
||||
|
||||
assert.deepEqual(inputActions, []);
|
||||
assert.equal(inputPrevented, false);
|
||||
});
|
||||
|
||||
test('toggleHostTree uses the host list toggle only while that list can show', () => {
|
||||
const previous = getSettingsChromeSnapshot();
|
||||
const calls: string[] = [];
|
||||
const toggleHostTree = () => { calls.push('toggle'); };
|
||||
const event = {} as KeyboardEvent;
|
||||
const contextFor = (activeTabId: string, orderedTabs: string[] = [activeTabId]) => ({
|
||||
activeTabStore: { getActiveTabId: () => activeTabId },
|
||||
editorTabs: [],
|
||||
orderedTabs,
|
||||
sessions: [{ id: 'session-1' }],
|
||||
settings: {},
|
||||
toEditorTabId: (id: string) => id,
|
||||
toggleHostTree,
|
||||
workspaces: [{ id: 'workspace-1' }],
|
||||
});
|
||||
|
||||
try {
|
||||
publishSettingsChromeSnapshot({ ...DEFAULT_SETTINGS_CHROME_SNAPSHOT, showHostTreeSidebar: true });
|
||||
assert.equal(executeHotkeyActionImpl(() => contextFor('session-1'), 'toggleHostTree', event), true);
|
||||
assert.equal(executeHotkeyActionImpl(() => contextFor('editor:note', []), 'toggleHostTree', event), true);
|
||||
assert.equal(executeHotkeyActionImpl(() => contextFor('workspace-1'), 'toggleHostTree', event), true);
|
||||
assert.deepEqual(calls, ['toggle', 'toggle', 'toggle']);
|
||||
|
||||
calls.length = 0;
|
||||
assert.equal(executeHotkeyActionImpl(() => contextFor('vault', []), 'toggleHostTree', event), false);
|
||||
assert.equal(executeHotkeyActionImpl(() => contextFor('sftp', []), 'toggleHostTree', event), false);
|
||||
assert.equal(executeHotkeyActionImpl(() => contextFor('plugin-view:demo:main', ['plugin-view:demo:main']), 'toggleHostTree', event), false);
|
||||
assert.deepEqual(calls, []);
|
||||
|
||||
publishSettingsChromeSnapshot({ ...DEFAULT_SETTINGS_CHROME_SNAPSHOT, showHostTreeSidebar: false });
|
||||
assert.equal(executeHotkeyActionImpl(() => contextFor('session-1'), 'toggleHostTree', event), false);
|
||||
assert.deepEqual(calls, []);
|
||||
} finally {
|
||||
publishSettingsChromeSnapshot(previous);
|
||||
}
|
||||
});
|
||||
|
||||
test('host list shortcut calls the same store toggle as the toolbar button', () => {
|
||||
const source = readFileSync(new URL('./app/AppSideEffects.tsx', import.meta.url), 'utf8');
|
||||
assert.match(source, /toggleHostTree:\s*\(\)\s*=>\s*terminalHostTreeStore\.toggle\(\)/);
|
||||
});
|
||||
@@ -9,6 +9,8 @@ import { getAvailablePaneMagnificationController } from '../../domain/paneMagnif
|
||||
import { sanitizeHostIconFields } from '../../domain/hostIcon';
|
||||
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
|
||||
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
|
||||
import { getSettingsChromeSnapshot } from '../state/settingsChromeStore';
|
||||
import { isHostTreeWorkTabSurface } from './workTabSurface';
|
||||
import { tabShortcutDigitFromEvent } from '../../domain/models/keyBindings';
|
||||
import { isPortForwardingAutoReconnectEnabled } from '../../domain/portForwardingReconnect';
|
||||
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
|
||||
@@ -276,6 +278,9 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
|
||||
const paneZoomBinding = keyBindings.find((binding) => binding.action === 'togglePaneZoom');
|
||||
const paneZoomKeyStr = paneZoomBinding ? (isMac ? paneZoomBinding.mac : paneZoomBinding.pc) : null;
|
||||
const isPaneZoomHotkey = paneZoomKeyStr ? matchesKeyBinding(e, paneZoomKeyStr, isMac) : false;
|
||||
const hostTreeBinding = keyBindings.find((binding) => binding.action === 'toggleHostTree');
|
||||
const hostTreeKeyStr = hostTreeBinding ? (isMac ? hostTreeBinding.mac : hostTreeBinding.pc) : null;
|
||||
const isHostTreeHotkey = hostTreeKeyStr ? matchesKeyBinding(e, hostTreeKeyStr, isMac) : false;
|
||||
|
||||
if (
|
||||
(isFormElement || isMonacoElement)
|
||||
@@ -283,6 +288,7 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
|
||||
&& e.key !== 'Escape'
|
||||
&& !isQuickSwitchHotkey
|
||||
&& !isPaneZoomHotkey
|
||||
&& !(isMonacoElement && isHostTreeHotkey)
|
||||
&& !forwardedNativeShortcutEvents.has(e)
|
||||
) {
|
||||
return;
|
||||
@@ -322,9 +328,10 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
|
||||
continue;
|
||||
}
|
||||
|
||||
// An unavailable broadcast action must leave the chord to the terminal
|
||||
// (notably tmux's Ctrl+B prefix). Dispatch once before consuming it.
|
||||
if (binding.action === 'broadcast') {
|
||||
// An unavailable action must leave the chord to the terminal.
|
||||
// Broadcast keeps Ctrl/Cmd+B for tmux; the host list keeps Ctrl/Cmd+Shift+B
|
||||
// when the sidebar cannot show. Dispatch once before consuming it.
|
||||
if (binding.action === 'broadcast' || binding.action === 'toggleHostTree') {
|
||||
if (executeHotkeyAction(binding.action, e) === true) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -801,7 +808,7 @@ export async function closeTabsBatchImpl(getCtx: AppContextGetter, targetIds: st
|
||||
}
|
||||
|
||||
export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string, e: KeyboardEvent) {
|
||||
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, canUseGlobalBroadcast, closePluginViewTab, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isPluginViewTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, sftpPaneMagnificationRef, splitSessionWithCurrentShell, systemInfoRef, terminalPaneMagnificationRef, toEditorTabId, toggleBroadcast, toggleGlobalBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWindowOpacity, workspaces } = getCtx();
|
||||
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, canUseGlobalBroadcast, closePluginViewTab, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isPluginViewTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, sftpPaneMagnificationRef, splitSessionWithCurrentShell, systemInfoRef, terminalPaneMagnificationRef, toEditorTabId, toggleBroadcast, toggleGlobalBroadcast, toggleHostTree, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWindowOpacity, workspaces } = getCtx();
|
||||
{
|
||||
const shortcutTabs = buildNumberShortcutTabTargets({
|
||||
showSftpTab: settings.showSftpTab ?? true,
|
||||
@@ -997,6 +1004,18 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
case 'toggleSidePanel':
|
||||
toggleSidePanelRef.current?.();
|
||||
break;
|
||||
case 'toggleHostTree': {
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
if (!isHostTreeWorkTabSurface({
|
||||
enabled: getSettingsChromeSnapshot().showHostTreeSidebar,
|
||||
activeTabId: currentId,
|
||||
orderedTabs: orderedTabs ?? [],
|
||||
sessionIds: new Set((sessions ?? []).map((session: TerminalSession) => session.id)),
|
||||
workspaceIds: new Set((workspaces ?? []).map((workspace: { id: string }) => workspace.id)),
|
||||
}) || typeof toggleHostTree !== 'function') return false;
|
||||
toggleHostTree();
|
||||
return true;
|
||||
}
|
||||
case 'broadcast': {
|
||||
// Workspace tabs keep their local broadcast state; orphan tabs share
|
||||
// the global state when at least two visible orphan tabs exist.
|
||||
@@ -1184,7 +1203,7 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host, hi
|
||||
}
|
||||
}
|
||||
|
||||
export function handleTerminalDataCaptureImpl(getCtx: AppContextGetter, sessionId: string, data: string) {
|
||||
export async function handleTerminalDataCaptureImpl(getCtx: AppContextGetter, sessionId: string, data: string) {
|
||||
const { IS_DEV, connectionLogs, selectConnectionLogForTerminalDataCapture, sessions, updateConnectionLog } = getCtx();
|
||||
{
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] Called', { sessionId, dataLength: data.length });
|
||||
@@ -1200,7 +1219,10 @@ export function handleTerminalDataCaptureImpl(getCtx: AppContextGetter, sessionI
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] Matching log', matchingLog);
|
||||
|
||||
if (matchingLog) {
|
||||
updateConnectionLog(matchingLog.id, {
|
||||
// The IndexedDB replay write commits asynchronously. Waiting here keeps
|
||||
// a crash during session teardown from dropping the newest capture any
|
||||
// more often than the old synchronous localStorage write.
|
||||
await updateConnectionLog(matchingLog.id, {
|
||||
endTime: Date.now(),
|
||||
terminalData: data,
|
||||
});
|
||||
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
import { getCredentialProtectionAvailability } from '../../infrastructure/services/credentialProtection';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { terminalHostTreeStore } from '../state/terminalHostTreeStore';
|
||||
import {
|
||||
markExternalMcpStartupReady,
|
||||
readExternalMcpFocusOnHostOpen,
|
||||
@@ -1126,6 +1127,7 @@ export function AppSideEffects() {
|
||||
toggleBroadcast,
|
||||
toggleGlobalBroadcast,
|
||||
canUseGlobalBroadcast: canUseGlobalBroadcastRef.current,
|
||||
toggleHostTree: () => terminalHostTreeStore.toggle(),
|
||||
toggleScriptsSidePanelRef,
|
||||
toggleSidePanelRef,
|
||||
toggleWindowOpacity,
|
||||
|
||||
@@ -272,7 +272,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit,
|
||||
handleOpenHostFromVaultNote, handleOpenQuickSwitcher, handleOpenSettings, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleFidoPromptSubmit, handleFidoPromptCancel, handleProtocolSelect,
|
||||
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleUpdateHostFromTerminal,
|
||||
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isGlobalBroadcastEnabled, onToggleGlobalBroadcast, canUseGlobalBroadcast, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
|
||||
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isComposeSendToAll, onToggleComposeSendTarget, isGlobalBroadcastEnabled, isGlobalComposeSendToAll, onToggleGlobalBroadcast, onToggleGlobalComposeSendTarget, canUseGlobalBroadcast, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
|
||||
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
|
||||
passphraseQueue, fidoPromptQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
|
||||
resolveEmptyVaultConflict, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
|
||||
@@ -784,8 +784,12 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onCreateLocalTerminal={handleCreateLocalTerminal}
|
||||
isBroadcastEnabled={isBroadcastEnabled}
|
||||
onToggleBroadcast={toggleBroadcast}
|
||||
isComposeSendToAll={isComposeSendToAll}
|
||||
onToggleComposeSendTarget={onToggleComposeSendTarget}
|
||||
isGlobalBroadcastEnabled={isGlobalBroadcastEnabled}
|
||||
isGlobalComposeSendToAll={isGlobalComposeSendToAll}
|
||||
onToggleGlobalBroadcast={onToggleGlobalBroadcast}
|
||||
onToggleGlobalComposeSendTarget={onToggleGlobalComposeSendTarget}
|
||||
canUseGlobalBroadcast={canUseGlobalBroadcast}
|
||||
updateHosts={updateTerminalHosts}
|
||||
updateSnippets={updateSnippets}
|
||||
|
||||
@@ -205,7 +205,11 @@ export function TerminalHost() {
|
||||
updateTerminalHosts: handlers.updateTerminalHosts,
|
||||
hotkeyScheme: terminalSettings.hotkeyScheme,
|
||||
isBroadcastEnabled: sessionActions?.isBroadcastEnabled,
|
||||
isComposeSendToAll: sessionActions?.isComposeSendToAll,
|
||||
onToggleComposeSendTarget: sessionActions?.toggleComposeSendTarget,
|
||||
isGlobalBroadcastEnabled: sessionActions?.isGlobalBroadcastEnabled,
|
||||
isGlobalComposeSendToAll: sessionActions?.isGlobalComposeSendToAll,
|
||||
onToggleGlobalComposeSendTarget: sessionActions?.toggleGlobalComposeSendTarget,
|
||||
canUseGlobalBroadcast: sessionActions?.canUseGlobalBroadcast,
|
||||
keyBindings: terminalSettings.keyBindings,
|
||||
openNoteRequest: local.openNoteRequest,
|
||||
|
||||
@@ -70,8 +70,12 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
reorderTabs,
|
||||
toggleBroadcast,
|
||||
isBroadcastEnabled,
|
||||
toggleComposeSendTarget,
|
||||
isComposeSendToAll,
|
||||
toggleGlobalBroadcast,
|
||||
isGlobalBroadcastEnabled,
|
||||
toggleGlobalComposeSendTarget,
|
||||
isGlobalComposeSendToAll,
|
||||
canUseGlobalBroadcast,
|
||||
copySession,
|
||||
copyWorkspace,
|
||||
@@ -150,8 +154,12 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
reorderTabs,
|
||||
toggleBroadcast,
|
||||
isBroadcastEnabled,
|
||||
toggleComposeSendTarget,
|
||||
isComposeSendToAll,
|
||||
toggleGlobalBroadcast,
|
||||
isGlobalBroadcastEnabled,
|
||||
toggleGlobalComposeSendTarget,
|
||||
isGlobalComposeSendToAll,
|
||||
canUseGlobalBroadcast,
|
||||
copySession,
|
||||
copyWorkspace,
|
||||
@@ -181,7 +189,9 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
getOrderedWorkTabs,
|
||||
getSessionRestoreCwd,
|
||||
isBroadcastEnabled,
|
||||
isComposeSendToAll,
|
||||
isGlobalBroadcastEnabled,
|
||||
isGlobalComposeSendToAll,
|
||||
moveFocusInWorkspace,
|
||||
openLogView,
|
||||
canUseGlobalBroadcast,
|
||||
@@ -201,7 +211,9 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
submitSessionRename,
|
||||
submitWorkspaceRename,
|
||||
toggleBroadcast,
|
||||
toggleComposeSendTarget,
|
||||
toggleGlobalBroadcast,
|
||||
toggleGlobalComposeSendTarget,
|
||||
toggleWorkspaceViewMode,
|
||||
updateSessionCodingCliProvider,
|
||||
updateSessionDynamicTitle,
|
||||
|
||||
@@ -165,8 +165,12 @@ test('vault init cancels the superseded StrictMode effect before publishing read
|
||||
);
|
||||
const initAt = vaultSource.indexOf('let cancelled = false;');
|
||||
assert.notEqual(initAt, -1, 'vault init must track cancellation');
|
||||
const initSlice = vaultSource.slice(initAt, initAt + 12000);
|
||||
assert.match(initSlice, /return \(\) => \{\s*\n\s*cancelled = true;\s*\n\s*\};/);
|
||||
const initSlice = vaultSource.slice(initAt, initAt + 20000);
|
||||
assert.match(
|
||||
initSlice,
|
||||
/return \(\) => \{\s*\n\s*cancelled = true;\s*\n\s*resolveTerminalDataReady\(\);\s*\n\s*\};/,
|
||||
'StrictMode cleanup cancels init and unblocks the terminal-data ready promise',
|
||||
);
|
||||
assert.match(
|
||||
initSlice,
|
||||
/if \(!cancelled\) \{\s*\n\s*setIsInitialized\(true\);\s*\n\s*setVaultInitialized\(true\);\s*\n\s*\}/,
|
||||
|
||||
@@ -582,6 +582,7 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.modelCatalogSourceCached': 'Cached · last success {time}',
|
||||
'ai.chat.modelCatalogSourceCachedNoTime': 'Cached',
|
||||
'ai.chat.modelCatalogSourcePresets': 'Built-in default list',
|
||||
'ai.chat.refreshModelList': 'Refresh model list',
|
||||
'ai.chat.noMatchingModels': 'No matching models',
|
||||
'ai.chat.recent': 'Recent',
|
||||
'ai.chat.viewAll': 'View All',
|
||||
|
||||
@@ -964,6 +964,7 @@ Highlight the focused split pane:
|
||||
'settings.shortcuts.category.terminal': 'Terminal',
|
||||
'settings.shortcuts.category.navigation': 'Navigation',
|
||||
'settings.shortcuts.category.app': 'App',
|
||||
'settings.shortcuts.binding.toggle-host-tree': 'Toggle Host List',
|
||||
'settings.shortcuts.category.sftp': 'SFTP',
|
||||
|
||||
// Context menus / common actions
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
export const enMonitorMessages = {
|
||||
"monitor.diskIo": "Disk I/O",
|
||||
"monitor.diskRead": "Read",
|
||||
"monitor.diskWrite": "Write",
|
||||
"monitor.diskIoHint": "Aggregate Linux hardware-backed block-device I/O, excluding partitions and stacked virtual devices. Unavailable until two valid samples; not supported on macOS.",
|
||||
|
||||
"monitor.actions": "Panel actions",
|
||||
"monitor.unavailable": "Monitoring is unavailable. Try starting again when the connection is restored.",
|
||||
"monitor.username": "Username",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -565,6 +565,7 @@ export const esAiMessages: Messages = {
|
||||
'ai.chat.modelCatalogSourceCached': 'En caché · último éxito {time}',
|
||||
'ai.chat.modelCatalogSourceCachedNoTime': 'En caché',
|
||||
'ai.chat.modelCatalogSourcePresets': 'Lista predeterminada integrada',
|
||||
'ai.chat.refreshModelList': 'Actualizar la lista de modelos',
|
||||
'ai.chat.noMatchingModels': 'No hay modelos coincidentes',
|
||||
'ai.chat.recent': 'Recientes',
|
||||
'ai.chat.viewAll': 'Ver todo',
|
||||
|
||||
@@ -913,6 +913,7 @@ Resalta el panel dividido enfocado:
|
||||
'settings.shortcuts.binding.broadcast': 'Alternar modo de transmisión',
|
||||
'settings.shortcuts.binding.toggle-opacity': 'Alternar la transparencia de la ventana',
|
||||
'settings.shortcuts.binding.toggle-side-panel': 'Alternar panel lateral',
|
||||
'settings.shortcuts.binding.toggle-host-tree': 'Alternar lista de hosts',
|
||||
'settings.shortcuts.binding.sftp-copy': 'Copiar archivos',
|
||||
'settings.shortcuts.binding.sftp-cut': 'Cortar archivos',
|
||||
'settings.shortcuts.binding.sftp-paste': 'Pegar archivos',
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
export const esMonitorMessages = {
|
||||
"monitor.diskIo": "E/S de disco",
|
||||
"monitor.diskRead": "Lectura",
|
||||
"monitor.diskWrite": "Escritura",
|
||||
"monitor.diskIoHint": "E/S agregada de dispositivos de bloques físicos de Linux, sin contar particiones ni capas virtuales. No disponible hasta tener dos muestras válidas; no compatible con macOS.",
|
||||
|
||||
"monitor.actions": "Acciones del panel",
|
||||
"monitor.unavailable": "La monitorización no está disponible. Iníciala de nuevo cuando se restablezca la conexión.",
|
||||
"monitor.username": "Usuario",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -521,6 +521,7 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.modelCatalogSourceCached': 'Кэш · обновлён {time}',
|
||||
'ai.chat.modelCatalogSourceCachedNoTime': 'Кэш',
|
||||
'ai.chat.modelCatalogSourcePresets': 'Встроенный список моделей',
|
||||
'ai.chat.refreshModelList': 'Обновить список моделей',
|
||||
'ai.chat.noMatchingModels': 'Нет подходящих моделей',
|
||||
'ai.chat.recent': 'Недавние',
|
||||
'ai.chat.viewAll': 'Показать всё',
|
||||
|
||||
@@ -938,6 +938,7 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.shortcuts.binding.broadcast': 'Переключить режим трансляции',
|
||||
'settings.shortcuts.binding.toggle-opacity': 'Переключить прозрачность окна',
|
||||
'settings.shortcuts.binding.toggle-side-panel': 'Переключить боковую панель',
|
||||
'settings.shortcuts.binding.toggle-host-tree': 'Переключить список хостов',
|
||||
'settings.shortcuts.binding.sftp-copy': 'Копировать файл',
|
||||
'settings.shortcuts.binding.sftp-cut': 'Вырезать файл',
|
||||
'settings.shortcuts.binding.sftp-paste': 'Вставить файл',
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
export const ruMonitorMessages = {
|
||||
"monitor.diskIo": "Дисковый ввод-вывод",
|
||||
"monitor.diskRead": "Чтение",
|
||||
"monitor.diskWrite": "Запись",
|
||||
"monitor.diskIoHint": "Суммарный ввод-вывод физических блочных устройств Linux, без разделов и виртуальных слоёв. Доступен после двух корректных замеров; macOS не поддерживается.",
|
||||
|
||||
"monitor.actions": "Действия панели",
|
||||
"monitor.unavailable": "Мониторинг недоступен. Запустите его снова после восстановления связи.",
|
||||
"monitor.username": "Пользователь",
|
||||
|
||||
@@ -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'], '工作階段日誌');
|
||||
});
|
||||
@@ -582,6 +582,7 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.modelCatalogSourceCached': '缓存 · 上次成功 {time}',
|
||||
'ai.chat.modelCatalogSourceCachedNoTime': '缓存',
|
||||
'ai.chat.modelCatalogSourcePresets': '内置默认列表',
|
||||
'ai.chat.refreshModelList': '刷新模型列表',
|
||||
'ai.chat.noMatchingModels': '没有匹配的模型',
|
||||
'ai.chat.recent': '最近',
|
||||
'ai.chat.viewAll': '查看全部',
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
export const zhCNMonitorMessages = {
|
||||
"monitor.diskIo": "磁盘 I/O",
|
||||
"monitor.diskRead": "读取",
|
||||
"monitor.diskWrite": "写入",
|
||||
"monitor.diskIoHint": "Linux 底层块设备的汇总 I/O,不重复统计分区和叠加的虚拟设备。取得两次有效采样前不可用;暂不支持 macOS。",
|
||||
|
||||
"monitor.actions": "面板操作",
|
||||
"monitor.unavailable": "监控暂不可用,连接恢复后请重新开始。",
|
||||
"monitor.username": "用户名",
|
||||
|
||||
@@ -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': '管理快捷代码片段',
|
||||
|
||||
@@ -572,6 +572,7 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.modelCatalogSourceCached': '快取 · 上次成功 {time}',
|
||||
'ai.chat.modelCatalogSourceCachedNoTime': '快取',
|
||||
'ai.chat.modelCatalogSourcePresets': '內建預設清單',
|
||||
'ai.chat.refreshModelList': '重新整理模型清單',
|
||||
'ai.chat.noMatchingModels': '沒有符合的模型',
|
||||
'ai.chat.recent': '最近',
|
||||
'ai.chat.viewAll': '檢視全部',
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
export const zhTWMonitorMessages = {
|
||||
"monitor.diskIo": "磁碟 I/O",
|
||||
"monitor.diskRead": "讀取",
|
||||
"monitor.diskWrite": "寫入",
|
||||
"monitor.diskIoHint": "Linux 底層區塊裝置的彙總 I/O,不重複統計分割區與疊加的虛擬裝置。取得兩次有效取樣前無法使用;暫不支援 macOS。",
|
||||
|
||||
"monitor.actions": "面板操作",
|
||||
"monitor.unavailable": "監控暫時無法使用,連線恢復後請重新開始。",
|
||||
"monitor.username": "使用者名稱",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -125,6 +125,55 @@ test('overlapping nonmodal menus yield with a picture; modal dialogs yield even
|
||||
await f.tick(); assert.equal(f.layouts.at(-1)?.occluded, true);
|
||||
});
|
||||
|
||||
function panel(f: Awaited<ReturnType<typeof fixture>>, x: number, y: number, width: number, height: number) {
|
||||
const node = f.overlay('<div data-section="app-host-editor-layer"><div data-native-view-occluder></div></div>').firstElementChild!;
|
||||
node.getBoundingClientRect = () => new f.env.window.DOMRect(x, y, width, height);
|
||||
return node;
|
||||
}
|
||||
|
||||
test('an overlay host editor panel narrows the live page beside it, then restores the pane', async (t) => {
|
||||
const f = await fixture(t);
|
||||
panel(f, 900, 0, 400, 900); await f.tick();
|
||||
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 200, y: 100, width: 500, height: 600 }, 'a panel beside the pane changes nothing');
|
||||
const editor = panel(f, 450, 0, 600, 900); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.visible, true);
|
||||
assert.equal(f.layouts.at(-1)?.occluded, false, 'the page stays live instead of becoming a picture');
|
||||
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 200, y: 100, width: 250, height: 600 });
|
||||
assert.deepEqual(f.panel().snapshot, []);
|
||||
editor.getBoundingClientRect = () => new f.env.window.DOMRect(400, 0, 650, 900);
|
||||
f.resize(); await f.tick();
|
||||
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 200, y: 100, width: 200, height: 600 }, 'resizing the panel follows it');
|
||||
editor.parentElement!.remove(); await f.tick();
|
||||
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 200, y: 100, width: 500, height: 600 });
|
||||
assert.equal(f.layouts.at(-1)?.occluded, false);
|
||||
});
|
||||
|
||||
test('a popper inside the panel leaves the narrowed page live; one over the page keeps aligned pictures', async (t) => {
|
||||
const f = await fixture(t);
|
||||
panel(f, 0, 0, 300, 900); await f.tick();
|
||||
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 300, y: 100, width: 400, height: 600 });
|
||||
const select = f.overlay(tooltip, 140, 110); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.occluded, false, 'a select inside the left panel does not reach the page');
|
||||
select.remove();
|
||||
f.overlay(tooltip, 320, 110); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.occluded, true);
|
||||
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 300, y: 100, width: 400, height: 600 });
|
||||
assert.deepEqual(f.panel().snapshot.map((layer) => layer.bounds), [{ x: 100, y: 0, width: 500, height: 600 }],
|
||||
'pictures are placed where the narrowed page sits inside the pane');
|
||||
});
|
||||
|
||||
test('a panel over the middle of the pane or leaving too little room falls back to a picture', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const middle = panel(f, 300, 0, 100, 900); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.occluded, true);
|
||||
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 200, y: 100, width: 500, height: 600 });
|
||||
assert.deepEqual(f.panel().snapshot, layers);
|
||||
middle.parentElement!.remove(); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.occluded, false);
|
||||
panel(f, 250, 0, 600, 900); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.occluded, true, 'a 50px strip is not worth a live page');
|
||||
});
|
||||
|
||||
test('closing a menu while capture is pending cannot restore stale pictures', async (t) => {
|
||||
const f = await fixture(t);
|
||||
let complete: (result: Result) => void = () => assert.fail('capture was not started');
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { currentBrowserThemeTokens, mountHostBrowser } from '../hostBrowser';
|
||||
import { resolveHostBrowserPaneBesidePanels, type HostBrowserPaneRect } from '../../domain/hostBrowser';
|
||||
import type { Host, Identity, KnownHost, SSHKey, TerminalSettings } from '../../domain/models';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import type { HostBrowserPanelSnapshotLayer } from '../../types/global/netcatty-bridge-sync';
|
||||
|
||||
function overlapsBrowser(rect: DOMRect, element: Element): boolean {
|
||||
function overlapsBrowser(rect: HostBrowserPaneRect, element: Element): boolean {
|
||||
const style = getComputedStyle(element);
|
||||
if (style.display === 'none' || style.visibility === 'hidden' || element.closest('[hidden], [inert], [data-state="closed"]')) return false;
|
||||
const overlay = element.getBoundingClientRect();
|
||||
return overlay.width > 0 && overlay.height > 0
|
||||
&& overlay.x < rect.right && overlay.right > rect.x
|
||||
&& overlay.y < rect.bottom && overlay.bottom > rect.y;
|
||||
&& overlay.x < rect.x + rect.width && overlay.right > rect.x
|
||||
&& overlay.y < rect.y + rect.height && overlay.bottom > rect.y;
|
||||
}
|
||||
|
||||
interface BrowserPanelOptions {
|
||||
@@ -70,12 +71,21 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
|
||||
const rect = element?.getBoundingClientRect();
|
||||
// Native views sit above renderer portals. Only overlapping poppers need
|
||||
// to yield; dialogs are modal even when their content is beside the pane.
|
||||
// Overlay panels stay open while the user works, so the live page moves
|
||||
// beside them when it can and only falls back to a picture otherwise.
|
||||
const locked = !!document.querySelector('[data-app-lock-overlay]');
|
||||
const modal = !!document.querySelector('[role="dialog"][aria-modal="true"], [role="alertdialog"]');
|
||||
const overlays = Array.from(document.querySelectorAll(
|
||||
'[role="dialog"], [role="alertdialog"], [role="menu"][data-state="open"], [data-radix-popper-content-wrapper] > *',
|
||||
'[role="dialog"], [role="alertdialog"], [role="menu"][data-state="open"], [data-radix-popper-content-wrapper] > *, '
|
||||
+ '[data-native-view-occluder]',
|
||||
));
|
||||
const covered = modal || (!!rect && overlays.some((overlay) => overlapsBrowser(rect, overlay)));
|
||||
const isPanel = (overlay: Element) => overlay.matches('[data-native-view-occluder]');
|
||||
const panels = rect ? overlays.filter((overlay) => isPanel(overlay) && overlapsBrowser(rect, overlay)) : [];
|
||||
const live = rect && resolveHostBrowserPaneBesidePanels(rect, panels.map((panel) => panel.getBoundingClientRect()));
|
||||
// Poppers over the part of the pane hidden behind a panel (e.g. a select
|
||||
// inside the host editor) no longer touch the live page.
|
||||
const covered = modal || (!!rect && !live)
|
||||
|| (!!live && overlays.some((overlay) => !isPanel(overlay) && overlapsBrowser(live, overlay)));
|
||||
const targets = new Set<Element>(overlays);
|
||||
for (let target: Element | null = element; target; target = target.parentElement) targets.add(target);
|
||||
for (const target of observed) {
|
||||
@@ -90,7 +100,11 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
|
||||
const visible = isVisible && !!element?.isConnected && !!rect
|
||||
&& rect.width > 0 && rect.height > 0 && !element?.closest('[inert]') && !locked;
|
||||
if (!visible) setSnapshot(null);
|
||||
const bounds = rect ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : undefined;
|
||||
const pane = live || rect;
|
||||
const bounds = pane ? { x: pane.x, y: pane.y, width: pane.width, height: pane.height } : undefined;
|
||||
// Pictures are relative to the native page, which may sit beside a panel.
|
||||
const offsetX = rect && bounds ? bounds.x - rect.x : 0;
|
||||
const offsetY = rect && bounds ? bounds.y - rect.y : 0;
|
||||
const theme = element && getComputedStyle(element).colorScheme === 'light' ? 'light' : 'dark';
|
||||
const themeTokens = currentBrowserThemeTokens(element ?? undefined);
|
||||
const layout: Parameters<NonNullable<NetcattyBridge['setHostBrowserPanelLayout']>>[0] = {
|
||||
@@ -102,7 +116,9 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
|
||||
const revision = ++layoutRevision;
|
||||
void bridge?.setHostBrowserPanelLayout?.(layout).then((result) => {
|
||||
if (disposed || layoutRevision !== revision) return;
|
||||
setSnapshot(visible && covered && typeof result === 'object' ? { browserId, layers: result.layers } : null);
|
||||
setSnapshot(visible && covered && typeof result === 'object' ? { browserId, layers: result.layers.map((layer) => ({
|
||||
...layer, bounds: { ...layer.bounds, x: layer.bounds.x + offsetX, y: layer.bounds.y + offsetY },
|
||||
})) } : null);
|
||||
}).catch(() => {});
|
||||
}
|
||||
};
|
||||
@@ -119,14 +135,15 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
|
||||
if (doc) mutationObserver?.observe(doc.documentElement, {
|
||||
subtree: true, childList: true, attributes: true,
|
||||
attributeFilter: ['style', 'class', 'hidden', 'inert', 'role', 'aria-modal', 'data-state',
|
||||
'data-app-lock-overlay', 'data-radix-popper-content-wrapper', 'href', 'media', 'disabled'],
|
||||
'data-app-lock-overlay', 'data-radix-popper-content-wrapper', 'data-native-view-occluder',
|
||||
'href', 'media', 'disabled'],
|
||||
});
|
||||
const onMotion = (event: Event) => {
|
||||
const target = event.target;
|
||||
if (!element || !view || !(target instanceof view.Element)) return;
|
||||
if (!target.contains(element) && !target.matches(
|
||||
'[role="dialog"], [role="alertdialog"], [role="menu"], [data-app-lock-overlay], '
|
||||
+ '[data-radix-popper-content-wrapper], [data-radix-popper-content-wrapper] > *',
|
||||
+ '[data-radix-popper-content-wrapper], [data-radix-popper-content-wrapper] > *, [data-native-view-occluder]',
|
||||
)) return;
|
||||
trackAnimations(target);
|
||||
schedule();
|
||||
|
||||
@@ -54,6 +54,8 @@ export interface ServerStats {
|
||||
swapTotal: number | null; // Total swap in MB
|
||||
swapUsed: number | null; // Used swap in MB
|
||||
topProcesses: ProcessInfo[]; // Top 10 processes by memory
|
||||
diskReadSpeed?: number | null; // Aggregate Linux hardware-backed block I/O, bytes/sec
|
||||
diskWriteSpeed?: number | null;
|
||||
diskPercent: number | null; // Disk usage percentage for root partition
|
||||
diskUsed: number | null; // Disk used in GB
|
||||
diskTotal: number | null; // Total disk in GB
|
||||
@@ -240,6 +242,8 @@ function normalizeServerStats(stats: Partial<ServerStats>): ServerStats {
|
||||
diskPercent: stats.diskPercent ?? null,
|
||||
diskUsed: stats.diskUsed ?? null,
|
||||
diskTotal: stats.diskTotal ?? null,
|
||||
diskReadSpeed: stats.diskReadSpeed ?? null,
|
||||
diskWriteSpeed: stats.diskWriteSpeed ?? null,
|
||||
disks: stats.disks || [],
|
||||
netRxSpeed: stats.netRxSpeed || 0,
|
||||
netTxSpeed: stats.netTxSpeed || 0,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const source = readFileSync(new URL("./useVaultState.ts", import.meta.url), "utf8");
|
||||
const terminalLayerSource = readFileSync(
|
||||
new URL("../../components/TerminalLayer.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
test("connection log replay blobs persist through IndexedDB instead of the legacy localStorage blob", () => {
|
||||
assert.match(source, /await migrateLegacyConnectionLogTerminalData\(/);
|
||||
assert.match(source, /await writeTerminalDataMapDiff\(/);
|
||||
assert.match(source, /STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV/);
|
||||
assert.match(source, /syncConnectionLogTerminalData !== false|syncConnectionLogTerminalData\)/);
|
||||
assert.doesNotMatch(source, /writeTerminalDataMapWithQuotaRetry/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/localStorageAdapter\.write\(\s*STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA\s*,/,
|
||||
);
|
||||
});
|
||||
|
||||
test("close-path connection log updates await the indexedDB persist task", () => {
|
||||
assert.match(
|
||||
source,
|
||||
/const updateConnectionLog = useCallback\([\s\S]*?return commitConnectionLogs\(/,
|
||||
);
|
||||
assert.match(source, /rebaseQueuedConnectionLogs\(\s*next,\s*removedIds,\s*changedIds,/);
|
||||
assert.match(
|
||||
source,
|
||||
/if \(!options\?\.pruneMainBlob\) \{[\s\S]*?rebaseQueuedConnectionLogs\(/,
|
||||
);
|
||||
assert.match(source, /retainNewestUnsavedReplays\(fallbackLogs, keep\)/);
|
||||
assert.match(source, /if \(!map\) return;/);
|
||||
assert.match(
|
||||
source,
|
||||
/if \(key === STORAGE_KEY_CONNECTION_LOGS\) \{\s*\/\/ This window already updated connectionLogsRef before setItem\.[\s\S]*?return;\s*\}/,
|
||||
);
|
||||
assert.match(
|
||||
terminalLayerSource,
|
||||
/const handleTerminalDataCapture = useCallback\(\(sessionId: string, data: string\) => \{\s*return onTerminalDataCapture\?\.\(sessionId, data\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test("boot re-reads connection logs after indexedDB load and falls back to the main blob", () => {
|
||||
const revAt = source.indexOf("let revSeen = readTerminalDataRev()");
|
||||
const bootAt = source.indexOf("const loaded = await migrateLegacyConnectionLogTerminalData");
|
||||
assert.notEqual(revAt, -1);
|
||||
assert.ok(revAt < bootAt);
|
||||
const bootSlice = source.slice(bootAt, bootAt + 2800);
|
||||
assert.match(bootSlice, /revNow !== revSeen[\s\S]*?await loadConnectionLogTerminalData\(\)/);
|
||||
assert.match(
|
||||
bootSlice,
|
||||
/localStorageAdapter\.read<ConnectionLog\[\]>\(\s*STORAGE_KEY_CONNECTION_LOGS,\s*\)[\s\S]*?pruneTerminalDataMapForStorage\(connectionLogsForBoot/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/!sidePersisted && unsavedTerminalDataPending && mainPersisted\) \{\s*\/\/[\s\S]*?localStorageAdapter\.read<ConnectionLog\[\]>\(\s*STORAGE_KEY_CONNECTION_LOGS\s*\)[\s\S]*?mergeTerminalDataIntoLogs\(freshLogs, buildTerminalDataMapFromLogs\(logs\)\)/,
|
||||
);
|
||||
});
|
||||
+338
-106
@@ -37,6 +37,7 @@ import {
|
||||
import {
|
||||
STORAGE_KEY_CONNECTION_LOGS,
|
||||
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA,
|
||||
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV,
|
||||
STORAGE_KEY_GROUP_CONFIGS,
|
||||
STORAGE_KEY_GROUPS,
|
||||
STORAGE_KEY_HOSTS,
|
||||
@@ -64,8 +65,18 @@ import {
|
||||
mergeConnectionLogsFromStorage,
|
||||
mergeTerminalDataIntoLogs,
|
||||
mergeTerminalDataMapsForStorage,
|
||||
pruneInMemoryUnsavedTerminalData,
|
||||
rebaseQueuedConnectionLogs,
|
||||
retainNewestUnsavedReplays,
|
||||
pruneTerminalDataMapForStorage,
|
||||
type ConnectionLogTerminalDataMap,
|
||||
} from "../../domain/connectionLogTerminalData";
|
||||
import {
|
||||
tryLoadAll as tryLoadConnectionLogTerminalData,
|
||||
migrateLegacyConnectionLogTerminalData,
|
||||
writeTerminalDataMapDiff,
|
||||
type ConnectionLogTerminalDataRev,
|
||||
} from "../../infrastructure/persistence/connectionLogTerminalDataStore";
|
||||
import { getNextVaultOrder, normalizeVaultOrder } from "../../domain/vaultOrder";
|
||||
import {
|
||||
deleteSelectedSnippetsFromVault,
|
||||
@@ -227,6 +238,13 @@ const safeParse = <T,>(value: string | null): T | null => {
|
||||
}
|
||||
};
|
||||
|
||||
/** `null` means the read failed. `{}` is a successful empty store. Retries once. */
|
||||
const loadConnectionLogTerminalData = async (): Promise<ConnectionLogTerminalDataMap | null> => {
|
||||
const first = await tryLoadConnectionLogTerminalData();
|
||||
if (first) return first;
|
||||
return tryLoadConnectionLogTerminalData();
|
||||
};
|
||||
|
||||
/**
|
||||
* Strip the bulky `terminalData` replay buffer from transient (unsaved)
|
||||
* connection logs before persisting. `terminalData` is the full terminal
|
||||
@@ -255,9 +273,6 @@ const readLegacyLineTimestampsEnabled = (): boolean => {
|
||||
return stored?.showLineTimestamps === true;
|
||||
};
|
||||
|
||||
const readConnectionLogTerminalDataMap = (): ConnectionLogTerminalDataMap =>
|
||||
localStorageAdapter.read<ConnectionLogTerminalDataMap>(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA) ?? {};
|
||||
|
||||
const readPersistedConnectionLogIds = (): Set<string> => {
|
||||
const stored = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS) ?? [];
|
||||
return new Set(stored.map((log) => log.id));
|
||||
@@ -276,23 +291,18 @@ const buildPersistedLogIds = (
|
||||
return ids;
|
||||
};
|
||||
|
||||
const writeConnectionLogTerminalDataMap = (
|
||||
logs: ConnectionLog[],
|
||||
localMaps: ConnectionLogTerminalDataMap[],
|
||||
usePersistedLogIdsFromDisk: boolean,
|
||||
): boolean => {
|
||||
const existing = readConnectionLogTerminalDataMap();
|
||||
const pruned = mergeTerminalDataMapsForStorage(
|
||||
logs,
|
||||
existing,
|
||||
localMaps,
|
||||
buildPersistedLogIds(logs, usePersistedLogIdsFromDisk),
|
||||
);
|
||||
if (JSON.stringify(existing) === JSON.stringify(pruned)) return true;
|
||||
return localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA, pruned);
|
||||
export type UseVaultStateOptions = {
|
||||
/**
|
||||
* When false, skip loading and cross-window sync of the bulky
|
||||
* connection-log terminal-data side store (~30–50 MB in localStorage).
|
||||
* Pass false from windows that never show connection-log replay
|
||||
* (Settings, tray panel, terminal popup). Default true.
|
||||
*/
|
||||
syncConnectionLogTerminalData?: boolean;
|
||||
};
|
||||
|
||||
export const useVaultState = () => {
|
||||
export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
const syncConnectionLogTerminalData = options?.syncConnectionLogTerminalData !== false;
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [hosts, setHosts] = useState<Host[]>([]);
|
||||
const [keys, setKeys] = useState<SSHKey[]>([]);
|
||||
@@ -418,44 +428,92 @@ export const useVaultState = () => {
|
||||
const identitiesReadSeq = useRef(0);
|
||||
const proxyProfilesReadSeq = useRef(0);
|
||||
const groupConfigsReadSeq = useRef(0);
|
||||
const connectionLogsRef = useRef<ConnectionLog[]>([]);
|
||||
const connectionLogTerminalDataRef = useRef<ConnectionLogTerminalDataMap>({});
|
||||
const connectionLogTerminalDataPersistedRef = useRef<ConnectionLogTerminalDataMap>({});
|
||||
const connectionLogTerminalDataRevRawRef = useRef<string | null>(null);
|
||||
const connectionLogTerminalDataLoadSeq = useRef(0);
|
||||
const terminalDataQueueRef = useRef<Promise<void>>(Promise.resolve());
|
||||
const terminalDataReadyRef = useRef<Promise<void>>(Promise.resolve());
|
||||
const pluginCredentialCatalogVersion = useRef(0);
|
||||
|
||||
const syncConnectionLogTerminalDataMap = useCallback((
|
||||
const enqueueTerminalDataTask = useCallback(<T,>(task: () => Promise<T>): Promise<T> => {
|
||||
const run = terminalDataQueueRef.current.then(task, task);
|
||||
terminalDataQueueRef.current = run.then(() => undefined, () => undefined);
|
||||
return run;
|
||||
}, []);
|
||||
|
||||
const bumpConnectionLogTerminalDataRev = useCallback((ids: readonly string[]) => {
|
||||
const current = localStorageAdapter.read<ConnectionLogTerminalDataRev>(
|
||||
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV,
|
||||
);
|
||||
const prev = typeof current?.rev === "number" && Number.isFinite(current.rev) ? current.rev : 0;
|
||||
const payload: ConnectionLogTerminalDataRev = {
|
||||
rev: Math.max(prev + 1, Date.now()),
|
||||
ids: [...ids].sort(),
|
||||
nonce: crypto.randomUUID(),
|
||||
};
|
||||
// Record the exact JSON before setItem so this window ignores its own echo.
|
||||
connectionLogTerminalDataRevRawRef.current = JSON.stringify(payload);
|
||||
const written = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV, payload);
|
||||
if (!written) {
|
||||
connectionLogTerminalDataRevRawRef.current = localStorageAdapter.readString(
|
||||
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV,
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const absorbRemoteTerminalDataIfNeeded = useCallback(async () => {
|
||||
const raw = localStorageAdapter.readString(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV);
|
||||
if (raw === connectionLogTerminalDataRevRawRef.current) return;
|
||||
const map = await loadConnectionLogTerminalData();
|
||||
if (!map) return;
|
||||
connectionLogTerminalDataRevRawRef.current = raw;
|
||||
connectionLogTerminalDataPersistedRef.current = map;
|
||||
connectionLogTerminalDataRef.current = {
|
||||
...map,
|
||||
...connectionLogTerminalDataRef.current,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const syncConnectionLogTerminalDataMap = useCallback(async (
|
||||
logs: ConnectionLog[],
|
||||
options?: { usePersistedLogIdsFromDisk?: boolean },
|
||||
) => {
|
||||
const usePersistedLogIdsFromDisk = options?.usePersistedLogIdsFromDisk ?? false;
|
||||
const localMaps = [
|
||||
connectionLogTerminalDataRef.current,
|
||||
buildTerminalDataMapFromLogs(logs),
|
||||
];
|
||||
const existing = readConnectionLogTerminalDataMap();
|
||||
if (!syncConnectionLogTerminalData) return true;
|
||||
await absorbRemoteTerminalDataIfNeeded();
|
||||
const baseline = connectionLogTerminalDataPersistedRef.current;
|
||||
const merged = mergeTerminalDataMapsForStorage(
|
||||
logs,
|
||||
existing,
|
||||
localMaps,
|
||||
buildPersistedLogIds(logs, usePersistedLogIdsFromDisk),
|
||||
baseline,
|
||||
[
|
||||
connectionLogTerminalDataRef.current,
|
||||
buildTerminalDataMapFromLogs(logs),
|
||||
],
|
||||
buildPersistedLogIds(logs, options?.usePersistedLogIdsFromDisk ?? false),
|
||||
);
|
||||
const persisted = writeConnectionLogTerminalDataMap(
|
||||
logs,
|
||||
localMaps,
|
||||
usePersistedLogIdsFromDisk,
|
||||
);
|
||||
if (persisted) {
|
||||
connectionLogTerminalDataRef.current = merged;
|
||||
} else {
|
||||
connectionLogTerminalDataRef.current = {
|
||||
...connectionLogTerminalDataRef.current,
|
||||
...buildTerminalDataMapFromLogs(logs),
|
||||
};
|
||||
}
|
||||
return { map: connectionLogTerminalDataRef.current, persisted };
|
||||
}, []);
|
||||
const written = await writeTerminalDataMapDiff(baseline, merged);
|
||||
// Keep the capped map in memory even when IndexedDB rejects the write, so
|
||||
// the next close retries from the last committed baseline.
|
||||
connectionLogTerminalDataRef.current = merged;
|
||||
if (!written.changed) return true;
|
||||
if (!written.persisted) return false;
|
||||
connectionLogTerminalDataPersistedRef.current = merged;
|
||||
bumpConnectionLogTerminalDataRev(Object.keys(merged));
|
||||
return true;
|
||||
}, [
|
||||
absorbRemoteTerminalDataIfNeeded,
|
||||
bumpConnectionLogTerminalDataRev,
|
||||
syncConnectionLogTerminalData,
|
||||
]);
|
||||
|
||||
const persistConnectionLogState = useCallback((
|
||||
const persistConnectionLogState = useCallback(async (
|
||||
logs: ConnectionLog[],
|
||||
options?: { pruneMainBlob?: boolean },
|
||||
options?: {
|
||||
pruneMainBlob?: boolean;
|
||||
removedIds?: ReadonlySet<string>;
|
||||
changedIds?: ReadonlySet<string>;
|
||||
},
|
||||
) => {
|
||||
const unsavedTerminalDataPending = logs.some(
|
||||
(log) => !log.saved && log.terminalData !== undefined,
|
||||
@@ -472,13 +530,43 @@ export const useVaultState = () => {
|
||||
|
||||
let sidePersisted = true;
|
||||
if (mainPersisted || !options?.pruneMainBlob) {
|
||||
({ persisted: sidePersisted } = syncConnectionLogTerminalDataMap(logs, {
|
||||
sidePersisted = await syncConnectionLogTerminalDataMap(logs, {
|
||||
usePersistedLogIdsFromDisk: Boolean(options?.pruneMainBlob && mainPersisted),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
if (!options?.pruneMainBlob) {
|
||||
mainPersisted = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, logs);
|
||||
// The side-store await can overlap another window's metadata write.
|
||||
// Rebase this snapshot onto the blob that exists now, immediately before
|
||||
// replacing it. A failed read keeps the snapshot already rebased at enqueue.
|
||||
const freshLogs = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS);
|
||||
const toWrite = freshLogs
|
||||
? rebaseQueuedConnectionLogs(
|
||||
logs,
|
||||
options?.removedIds ?? new Set<string>(),
|
||||
options?.changedIds ?? new Set<string>(),
|
||||
connectionLogsRef.current,
|
||||
freshLogs,
|
||||
)
|
||||
: logs;
|
||||
mainPersisted = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, toWrite);
|
||||
} else if (!sidePersisted && unsavedTerminalDataPending && mainPersisted) {
|
||||
// IndexedDB rejected the replay after the pruned metadata write. Put the
|
||||
// buffers back on the current main blob so a restart still has them,
|
||||
// without replacing logs another window wrote during the await.
|
||||
// If that blob exceeds quota, drop older unsaved replays and retry so
|
||||
// the newest capture still has a durable copy.
|
||||
const freshLogs = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS);
|
||||
let fallbackLogs = freshLogs
|
||||
? mergeTerminalDataIntoLogs(freshLogs, buildTerminalDataMapFromLogs(logs))
|
||||
: logs;
|
||||
sidePersisted = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, fallbackLogs);
|
||||
let keep = fallbackLogs.filter((log) => !log.saved && log.terminalData).length;
|
||||
while (!sidePersisted && keep > 1) {
|
||||
keep = Math.max(1, Math.floor(keep / 2));
|
||||
fallbackLogs = retainNewestUnsavedReplays(fallbackLogs, keep);
|
||||
sidePersisted = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, fallbackLogs);
|
||||
}
|
||||
}
|
||||
|
||||
if (!mainPersisted && unsavedTerminalDataPending) {
|
||||
@@ -490,8 +578,40 @@ export const useVaultState = () => {
|
||||
"[useVaultState] Failed to persist connection log terminal replay data side store.",
|
||||
);
|
||||
}
|
||||
return mainPersisted && sidePersisted;
|
||||
}, [syncConnectionLogTerminalDataMap]);
|
||||
|
||||
const commitConnectionLogs = useCallback((
|
||||
recipe: (prev: ConnectionLog[]) => ConnectionLog[],
|
||||
options?: { pruneMainBlob?: boolean },
|
||||
) => {
|
||||
const prev = connectionLogsRef.current;
|
||||
const next = recipe(prev);
|
||||
const prevById = new Map(prev.map((log) => [log.id, log]));
|
||||
const nextIds = new Set<string>();
|
||||
const changedIds = new Set<string>();
|
||||
for (const log of next) {
|
||||
nextIds.add(log.id);
|
||||
const before = prevById.get(log.id);
|
||||
if (!before || before !== log) changedIds.add(log.id);
|
||||
}
|
||||
const removedIds = new Set(prev.filter((log) => !nextIds.has(log.id)).map((log) => log.id));
|
||||
connectionLogsRef.current = next;
|
||||
setConnectionLogs(next);
|
||||
// The task may run after another window writes. Rebase onto the logs that
|
||||
// exist then. Restore only ids this recipe added or replaced.
|
||||
return enqueueTerminalDataTask(() => persistConnectionLogState(
|
||||
rebaseQueuedConnectionLogs(
|
||||
next,
|
||||
removedIds,
|
||||
changedIds,
|
||||
connectionLogsRef.current,
|
||||
localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS),
|
||||
),
|
||||
{ ...options, removedIds, changedIds },
|
||||
));
|
||||
}, [enqueueTerminalDataTask, persistConnectionLogState]);
|
||||
|
||||
const applyConnectionLogsFromStorage = useCallback((
|
||||
prev: ConnectionLog[],
|
||||
storedLogs: ConnectionLog[],
|
||||
@@ -1292,63 +1412,56 @@ export const useVaultState = () => {
|
||||
...log,
|
||||
id: crypto.randomUUID(),
|
||||
};
|
||||
setConnectionLogs((prev) => {
|
||||
void commitConnectionLogs((prev) => {
|
||||
// Keep only the last 500 non-saved entries plus all saved entries
|
||||
const savedLogs = prev.filter((l) => l.saved);
|
||||
const unsavedLogs = prev.filter((l) => !l.saved);
|
||||
const updated = [newLog, ...unsavedLogs].slice(0, 500);
|
||||
const final = [...updated, ...savedLogs].sort(
|
||||
(a, b) => b.startTime - a.startTime
|
||||
return pruneInMemoryUnsavedTerminalData(
|
||||
[...updated, ...savedLogs].sort(
|
||||
(a, b) => b.startTime - a.startTime
|
||||
),
|
||||
);
|
||||
persistConnectionLogState(final, { pruneMainBlob: true });
|
||||
return final;
|
||||
});
|
||||
}, { pruneMainBlob: true });
|
||||
return newLog.id;
|
||||
},
|
||||
[persistConnectionLogState],
|
||||
[commitConnectionLogs],
|
||||
);
|
||||
|
||||
const updateConnectionLog = useCallback(
|
||||
(id: string, updates: Partial<ConnectionLog>) => {
|
||||
setConnectionLogs((prev) => {
|
||||
const updated = prev.map((log) =>
|
||||
log.id === id ? { ...log, ...updates } : log
|
||||
);
|
||||
persistConnectionLogState(updated, { pruneMainBlob: true });
|
||||
return updated;
|
||||
});
|
||||
return commitConnectionLogs((prev) => (
|
||||
pruneInMemoryUnsavedTerminalData(
|
||||
prev.map((log) => (log.id === id ? { ...log, ...updates } : log)),
|
||||
)
|
||||
), { pruneMainBlob: true });
|
||||
},
|
||||
[persistConnectionLogState],
|
||||
[commitConnectionLogs],
|
||||
);
|
||||
|
||||
const toggleConnectionLogSaved = useCallback((id: string) => {
|
||||
setConnectionLogs((prev) => {
|
||||
const updated = prev.map((log) =>
|
||||
log.id === id ? { ...log, saved: !log.saved } : log
|
||||
);
|
||||
persistConnectionLogState(updated, { pruneMainBlob: false });
|
||||
return updated;
|
||||
});
|
||||
}, [persistConnectionLogState]);
|
||||
void commitConnectionLogs((prev) => (
|
||||
pruneInMemoryUnsavedTerminalData(
|
||||
prev.map((log) =>
|
||||
log.id === id ? { ...log, saved: !log.saved } : log
|
||||
),
|
||||
)
|
||||
), { pruneMainBlob: false });
|
||||
}, [commitConnectionLogs]);
|
||||
|
||||
const deleteConnectionLog = useCallback((id: string) => {
|
||||
setConnectionLogs((prev) => {
|
||||
const updated = prev.filter((log) => log.id !== id);
|
||||
const map = { ...connectionLogTerminalDataRef.current };
|
||||
delete map[id];
|
||||
connectionLogTerminalDataRef.current = map;
|
||||
persistConnectionLogState(updated, { pruneMainBlob: true });
|
||||
return updated;
|
||||
});
|
||||
}, [persistConnectionLogState]);
|
||||
void commitConnectionLogs(
|
||||
(prev) => prev.filter((log) => log.id !== id),
|
||||
{ pruneMainBlob: true },
|
||||
);
|
||||
}, [commitConnectionLogs]);
|
||||
|
||||
const clearUnsavedConnectionLogs = useCallback(() => {
|
||||
setConnectionLogs((prev) => {
|
||||
const saved = prev.filter((log) => log.saved);
|
||||
persistConnectionLogState(saved, { pruneMainBlob: true });
|
||||
return saved;
|
||||
});
|
||||
}, [persistConnectionLogState]);
|
||||
void commitConnectionLogs(
|
||||
(prev) => prev.filter((log) => log.saved),
|
||||
{ pruneMainBlob: true },
|
||||
);
|
||||
}, [commitConnectionLogs]);
|
||||
|
||||
// Connection logs for Vault logs section — keep App domain bags off session churn.
|
||||
useLayoutEffect(() => {
|
||||
@@ -1415,6 +1528,11 @@ export const useVaultState = () => {
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let resolveTerminalDataReady = () => {};
|
||||
const terminalDataReady = new Promise<void>((resolve) => {
|
||||
resolveTerminalDataReady = resolve;
|
||||
});
|
||||
terminalDataReadyRef.current = terminalDataReady;
|
||||
const init = async () => {
|
||||
try {
|
||||
await withVaultImportLock("vault", async () => {
|
||||
@@ -1624,14 +1742,91 @@ export const useVaultState = () => {
|
||||
publishShellHistorySnapshot(savedShellHistory);
|
||||
}
|
||||
|
||||
// Load connection logs
|
||||
// Load connection logs. Replay blobs live in IndexedDB, one row per
|
||||
// log id. Windows that never render replay skip that read
|
||||
// (Settings / tray / popup).
|
||||
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
|
||||
STORAGE_KEY_CONNECTION_LOGS,
|
||||
);
|
||||
const terminalDataMap = readConnectionLogTerminalDataMap();
|
||||
let connectionLogsForBoot = savedConnectionLogs;
|
||||
let terminalDataMap: ConnectionLogTerminalDataMap = {};
|
||||
let durableTerminalData = true;
|
||||
if (syncConnectionLogTerminalData) {
|
||||
const readTerminalDataRev = () => localStorageAdapter.readString(
|
||||
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV,
|
||||
);
|
||||
// A peer can commit replay and bump the rev while this load is in
|
||||
// flight. Capture the rev first and reload when it moves, so stamping
|
||||
// the key later does not mark an unread snapshot as consumed.
|
||||
let revSeen = readTerminalDataRev();
|
||||
const loaded = await migrateLegacyConnectionLogTerminalData({
|
||||
read: () => localStorageAdapter.read<ConnectionLogTerminalDataMap>(
|
||||
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA,
|
||||
),
|
||||
remove: () => {
|
||||
localStorageAdapter.remove(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA);
|
||||
},
|
||||
});
|
||||
if (cancelled) return;
|
||||
terminalDataMap = loaded.map;
|
||||
let revNow = readTerminalDataRev();
|
||||
if (revNow !== revSeen) {
|
||||
const reloaded = await loadConnectionLogTerminalData();
|
||||
if (cancelled) return;
|
||||
if (reloaded) {
|
||||
if (Object.keys(reloaded).length > 0) {
|
||||
terminalDataMap = { ...terminalDataMap, ...reloaded };
|
||||
}
|
||||
revSeen = revNow;
|
||||
}
|
||||
}
|
||||
// Another window can append a log while IndexedDB is opening. Prune
|
||||
// against that newer metadata so this boot does not delete its replay.
|
||||
connectionLogsForBoot = localStorageAdapter.read<ConnectionLog[]>(
|
||||
STORAGE_KEY_CONNECTION_LOGS,
|
||||
) ?? savedConnectionLogs;
|
||||
const pruned = connectionLogsForBoot
|
||||
? pruneTerminalDataMapForStorage(connectionLogsForBoot, terminalDataMap)
|
||||
: terminalDataMap;
|
||||
// A legacy map that never reached IndexedDB is not a diff baseline.
|
||||
// Comparing against it would persist only the changed ids and then
|
||||
// drop the rest of the legacy blob on the next boot.
|
||||
const baseline = loaded.durable ? terminalDataMap : {};
|
||||
const written = await writeTerminalDataMapDiff(baseline, pruned);
|
||||
if (cancelled) return;
|
||||
if (written.persisted) {
|
||||
terminalDataMap = pruned;
|
||||
if (!loaded.durable) {
|
||||
localStorageAdapter.remove(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA);
|
||||
}
|
||||
}
|
||||
durableTerminalData = written.persisted || loaded.durable;
|
||||
if (loaded.migrated || (written.changed && written.persisted)) {
|
||||
bumpConnectionLogTerminalDataRev(Object.keys(terminalDataMap));
|
||||
} else {
|
||||
revNow = readTerminalDataRev();
|
||||
if (revNow !== revSeen) {
|
||||
const reloaded = await loadConnectionLogTerminalData();
|
||||
if (cancelled) return;
|
||||
if (reloaded) {
|
||||
if (Object.keys(reloaded).length > 0) {
|
||||
terminalDataMap = { ...terminalDataMap, ...reloaded };
|
||||
}
|
||||
revSeen = revNow;
|
||||
connectionLogsForBoot = localStorageAdapter.read<ConnectionLog[]>(
|
||||
STORAGE_KEY_CONNECTION_LOGS,
|
||||
) ?? connectionLogsForBoot;
|
||||
}
|
||||
}
|
||||
connectionLogTerminalDataRevRawRef.current = revSeen;
|
||||
}
|
||||
}
|
||||
connectionLogTerminalDataRef.current = terminalDataMap;
|
||||
if (savedConnectionLogs) {
|
||||
setConnectionLogs(mergeTerminalDataIntoLogs(savedConnectionLogs, terminalDataMap));
|
||||
connectionLogTerminalDataPersistedRef.current = durableTerminalData ? terminalDataMap : {};
|
||||
if (connectionLogsForBoot) {
|
||||
const hydrated = mergeTerminalDataIntoLogs(connectionLogsForBoot, terminalDataMap);
|
||||
connectionLogsRef.current = hydrated;
|
||||
setConnectionLogs(hydrated);
|
||||
}
|
||||
|
||||
// Load managed sources
|
||||
@@ -1660,6 +1855,7 @@ export const useVaultState = () => {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
resolveTerminalDataReady();
|
||||
// StrictMode remount cancels the first init; only the surviving effect
|
||||
// may publish "vault ready" or terminals can boot against empty keys.
|
||||
if (!cancelled) {
|
||||
@@ -1672,8 +1868,15 @@ export const useVaultState = () => {
|
||||
void init();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
resolveTerminalDataReady();
|
||||
};
|
||||
}, [commitEncryptedHostsUnderVaultLock, updateHosts, updateSnippets]);
|
||||
}, [
|
||||
bumpConnectionLogTerminalDataRev,
|
||||
commitEncryptedHostsUnderVaultLock,
|
||||
syncConnectionLogTerminalData,
|
||||
updateHosts,
|
||||
updateSnippets,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInitialized) return;
|
||||
@@ -1875,16 +2078,18 @@ export const useVaultState = () => {
|
||||
|
||||
if (key === STORAGE_KEY_CONNECTION_LOGS) {
|
||||
const next = safeParse<ConnectionLog[]>(event.newValue) ?? [];
|
||||
setConnectionLogs((prev) =>
|
||||
applyConnectionLogsFromStorage(prev, next, connectionLogTerminalDataRef.current),
|
||||
const merged = applyConnectionLogsFromStorage(
|
||||
connectionLogsRef.current,
|
||||
next,
|
||||
connectionLogTerminalDataRef.current,
|
||||
);
|
||||
connectionLogsRef.current = merged;
|
||||
setConnectionLogs(merged);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA) {
|
||||
const next = safeParse<ConnectionLogTerminalDataMap>(event.newValue) ?? {};
|
||||
connectionLogTerminalDataRef.current = next;
|
||||
setConnectionLogs((prev) => mergeConnectionLogsFromStorage(prev, prev, next));
|
||||
if (key === STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV) {
|
||||
applyTerminalDataRev(event.newValue);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1912,19 +2117,46 @@ export const useVaultState = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const applyTerminalDataRev = (raw: string | null) => {
|
||||
// Settings, tray, and popup mounts never replay logs and must not
|
||||
// pull the side store across windows.
|
||||
if (!syncConnectionLogTerminalData) return;
|
||||
if (raw === connectionLogTerminalDataRevRawRef.current) return;
|
||||
const seq = ++connectionLogTerminalDataLoadSeq.current;
|
||||
void terminalDataReadyRef.current.then(() => enqueueTerminalDataTask(async () => {
|
||||
if (seq !== connectionLogTerminalDataLoadSeq.current) return;
|
||||
const map = await loadConnectionLogTerminalData();
|
||||
if (seq !== connectionLogTerminalDataLoadSeq.current) return;
|
||||
if (!map) return;
|
||||
connectionLogTerminalDataRevRawRef.current = localStorageAdapter.readString(
|
||||
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV,
|
||||
);
|
||||
connectionLogTerminalDataPersistedRef.current = map;
|
||||
connectionLogTerminalDataRef.current = map;
|
||||
const merged = mergeConnectionLogsFromStorage(
|
||||
connectionLogsRef.current,
|
||||
connectionLogsRef.current,
|
||||
map,
|
||||
);
|
||||
connectionLogsRef.current = merged;
|
||||
setConnectionLogs(merged);
|
||||
}));
|
||||
};
|
||||
|
||||
const handleLocalStorageAdapterChanged = (event: Event) => {
|
||||
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
|
||||
if (key === STORAGE_KEY_CONNECTION_LOGS) {
|
||||
const next = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS) ?? [];
|
||||
setConnectionLogs((prev) =>
|
||||
applyConnectionLogsFromStorage(prev, next, connectionLogTerminalDataRef.current),
|
||||
);
|
||||
// This window already updated connectionLogsRef before setItem. The
|
||||
// adapter notifies on a timer, so this blob can be older than a log
|
||||
// added or finalized while that write was still queued. Applying it
|
||||
// would drop that log from the ref, and the follow-up recipe would
|
||||
// record no changed id. Other windows still arrive through "storage".
|
||||
return;
|
||||
}
|
||||
if (key === STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA) {
|
||||
const next = readConnectionLogTerminalDataMap();
|
||||
connectionLogTerminalDataRef.current = next;
|
||||
setConnectionLogs((prev) => mergeConnectionLogsFromStorage(prev, prev, next));
|
||||
if (key === STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV) {
|
||||
applyTerminalDataRev(localStorageAdapter.readString(
|
||||
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV,
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1934,7 +2166,7 @@ export const useVaultState = () => {
|
||||
window.removeEventListener("storage", handleStorage);
|
||||
window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handleLocalStorageAdapterChanged);
|
||||
};
|
||||
}, [applyConnectionLogsFromStorage]);
|
||||
}, [applyConnectionLogsFromStorage, enqueueTerminalDataTask, syncConnectionLogTerminalData]);
|
||||
|
||||
const updateHostLastConnected = useCallback((hostId: string) => {
|
||||
setHosts((prev) => {
|
||||
|
||||
@@ -411,6 +411,8 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
modelCatalogLoading={modelCatalogLoading}
|
||||
modelCatalogSource={modelCatalogSource}
|
||||
modelCatalogFetchedAt={modelCatalogFetchedAt}
|
||||
modelCatalogWarning={modelCatalogWarning}
|
||||
onRefreshModelCatalog={handleModelCatalogRetry}
|
||||
allowCustomModelEntry={allowCustomModelEntry}
|
||||
selectedModelId={selectedAgentModel}
|
||||
onModelSelect={handleAgentModelSelect}
|
||||
|
||||
@@ -1090,6 +1090,9 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
target.agentEnv,
|
||||
target.agentCommand,
|
||||
target.codexRuntime,
|
||||
// Manual refresh (retry banner / picker button) must bypass the
|
||||
// main-side LKG-first policy and go live.
|
||||
options.force === true,
|
||||
);
|
||||
if (!result?.ok || !Array.isArray(result.models)) {
|
||||
throw new Error(result?.error || 'Failed to load SDK agent models');
|
||||
|
||||
@@ -620,6 +620,34 @@ test('SDK runtime model cache preserves degraded warnings until a successful ret
|
||||
assert.equal(cache.read('copilot')?.warning, undefined);
|
||||
});
|
||||
|
||||
test('SDK runtime model cache re-queries main past the TTL so a clean LKG serve clears a stale banner', async () => {
|
||||
let now = 1_000;
|
||||
const cache = createSdkRuntimeModelCache({ ttlMs: 100, now: () => now });
|
||||
|
||||
// A degraded serve (warning) is cached and replayed within the TTL.
|
||||
await cache.refresh('claude:/opt/bin/claude', async () => ({
|
||||
currentModelId: 'claude-opus-5-5',
|
||||
models: [{ id: 'claude-opus-5-5', name: 'Opus 5.5' }],
|
||||
warning: 'live fetch failed',
|
||||
}));
|
||||
assert.equal(cache.read('claude:/opt/bin/claude')?.warning, 'live fetch failed');
|
||||
|
||||
// Past the TTL the entry is evicted and the next automatic load re-queries
|
||||
// main; with the LKG-first policy that round-trip returns a clean
|
||||
// last-known-good serve, so the stale banner clears.
|
||||
now = 1_150;
|
||||
const cleared = await cache.refresh('claude:/opt/bin/claude', async () => ({
|
||||
currentModelId: 'claude-opus-5-5',
|
||||
models: [{ id: 'claude-opus-5-5', name: 'Opus 5.5' }],
|
||||
source: 'last-known-good',
|
||||
fetchedAt: '2026-10-23T08:00:00.000Z',
|
||||
}));
|
||||
assert.equal(cleared.source, 'last-known-good');
|
||||
assert.equal(cleared.warning, undefined);
|
||||
assert.equal(cache.read('claude:/opt/bin/claude')?.warning, undefined);
|
||||
assert.equal(cache.read('claude:/opt/bin/claude')?.fetchedAt, '2026-10-23T08:00:00.000Z');
|
||||
});
|
||||
|
||||
// #3584 alignment: OpenCode must render build-time presets while its live
|
||||
// catalog load is pending, exactly like every other backend. The old host
|
||||
// code suppressed presets for opencode while `runtimeModelLoading` was set,
|
||||
|
||||
@@ -247,6 +247,10 @@ export function createSdkRuntimeModelCache(options: SdkRuntimeModelCacheOptions
|
||||
pruneEntries();
|
||||
const cached = entries.get(key);
|
||||
if (!refreshOptions.force && cached && now() - cached.updatedAt < ttlMs) {
|
||||
// The LKG-first policy lives in main: once a persisted catalog exists
|
||||
// there, a TTL-expired non-force refresh is answered from disk with a
|
||||
// clean last-known-good serve (no live attempt, no warning), so the
|
||||
// next automatic load here clears a stale banner.
|
||||
touchEntry(key, cached);
|
||||
return Promise.resolve(cloneCatalog(cached));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { JSDOM } from "jsdom";
|
||||
import type { GroupNode } from "../types";
|
||||
|
||||
test("HostTreeView React updates invalidate order when configs or sort mode change", async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="scroll"><div id="root"></div></div></body></html>', {
|
||||
url: "http://localhost", pretendToBeVisual: true,
|
||||
});
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
const install = (key: string, value: unknown) => {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||
};
|
||||
class ResizeObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
for (const key of ["window", "document", "navigator", "HTMLElement", "Element", "Node", "MutationObserver", "Event", "CustomEvent", "MouseEvent", "KeyboardEvent", "FocusEvent", "localStorage"] as const) {
|
||||
install(key, key === "window" ? dom.window : dom.window[key]);
|
||||
}
|
||||
install("getComputedStyle", dom.window.getComputedStyle.bind(dom.window));
|
||||
install("requestAnimationFrame", dom.window.requestAnimationFrame.bind(dom.window));
|
||||
install("cancelAnimationFrame", dom.window.cancelAnimationFrame.bind(dom.window));
|
||||
install("ResizeObserver", ResizeObserverStub);
|
||||
install("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
const scroller = dom.window.document.getElementById("scroll")!;
|
||||
Object.defineProperties(scroller, { offsetHeight: { value: 800 }, offsetWidth: { value: 1280 } });
|
||||
const { act, createElement } = await import("react");
|
||||
const { createRoot } = await import("react-dom/client");
|
||||
const { HostTreeView } = await import("./HostTreeView.tsx");
|
||||
const noop = () => undefined;
|
||||
const groupTree: GroupNode[] = ["alpha", "beta"].map((path) => ({ name: path, path, hosts: [], children: {} }));
|
||||
const common = {
|
||||
groupTree, hosts: [], expandedPaths: new Set<string>(), scrollRef: { current: scroller },
|
||||
onConnect: noop, onEditHost: noop, onDuplicateHost: noop, onDeleteHost: noop,
|
||||
onCopyCredentials: noop, onNewGroup: noop, onRenameGroup: noop, onEditGroup: noop,
|
||||
onDeleteGroup: noop, moveHostToGroup: noop, moveGroup: noop,
|
||||
};
|
||||
const root = createRoot(dom.window.document.getElementById("root")!);
|
||||
const paths = () => [...dom.window.document.querySelectorAll("[data-group-path]")].map((node) => node.getAttribute("data-group-path"));
|
||||
try {
|
||||
await act(async () => {
|
||||
root.render(createElement(HostTreeView, { ...common, sortMode: "manual", groupConfigs: [{ path: "beta", order: -1 }] }));
|
||||
});
|
||||
assert.deepEqual(paths(), ["beta", "alpha"]);
|
||||
await act(async () => {
|
||||
root.render(createElement(HostTreeView, { ...common, sortMode: "manual", groupConfigs: [{ path: "alpha", order: -1 }] }));
|
||||
});
|
||||
assert.deepEqual(paths(), ["alpha", "beta"]);
|
||||
const groupConfigs = [{ path: "beta", order: -1 }];
|
||||
await act(async () => {
|
||||
root.render(createElement(HostTreeView, { ...common, sortMode: "az", groupConfigs }));
|
||||
});
|
||||
assert.deepEqual(paths(), ["alpha", "beta"]);
|
||||
await act(async () => {
|
||||
root.render(createElement(HostTreeView, { ...common, sortMode: "manual", groupConfigs }));
|
||||
});
|
||||
assert.deepEqual(paths(), ["beta", "alpha"]);
|
||||
} finally {
|
||||
await act(async () => { root.unmount(); });
|
||||
dom.window.close();
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else delete (globalThis as Record<string, unknown>)[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -498,3 +498,75 @@ test("HostTreeView IP sorting stays inside each group and leaves manual order in
|
||||
assert.deepEqual(keys("ip"), ["group:prod", "group:prod/east", "host:east-two", "host:east-ten", "host:prod-two", "host:prod-ten", "host:root-two", "host:root-ten"]);
|
||||
assert.deepEqual(keys("manual"), ["group:prod", "group:prod/east", "host:east-ten", "host:east-two", "host:prod-ten", "host:prod-two", "host:root-ten", "host:root-two"]);
|
||||
});
|
||||
|
||||
// Deterministic work-count assertions, not a wall-clock benchmark.
|
||||
test("HostTreeView scans manual group order only once across expanded leaf groups", () => {
|
||||
const groupTree: GroupNode[] = ["c", "b", "a"].map((path) => ({
|
||||
name: path, path, children: {}, hosts: [],
|
||||
}));
|
||||
const groupConfigs: GroupConfig[] = groupTree.map((node, order) => ({ path: node.path, order }));
|
||||
let configVisits = 0;
|
||||
Object.defineProperty(groupConfigs, "filter", {
|
||||
value: (predicate: (value: GroupConfig, index: number, array: GroupConfig[]) => unknown) =>
|
||||
Array.prototype.filter.call(groupConfigs, (value: GroupConfig, index: number, array: GroupConfig[]) => {
|
||||
configVisits += 1;
|
||||
return predicate(value, index, array);
|
||||
}),
|
||||
});
|
||||
for (const expandedPaths of [new Set<string>(), new Set(["a", "b", "c"])]) {
|
||||
configVisits = 0;
|
||||
const manual = buildVisibleHostTreeItems({ groupTree, groupConfigs, expandedPaths, ungroupedHosts: [], sortMode: "manual" });
|
||||
assert.equal(configVisits, 3);
|
||||
assert.deepEqual(manual.map((item) => item.key), ["group:c", "group:b", "group:a"]);
|
||||
for (const sortMode of ["az", "za", "newest", "oldest", "group", "ip"] as const) {
|
||||
configVisits = 0;
|
||||
const items = buildVisibleHostTreeItems({ groupTree, groupConfigs, expandedPaths, ungroupedHosts: [], sortMode });
|
||||
assert.equal(configVisits, 0, `${sortMode} must not construct a manual order index`);
|
||||
assert.deepEqual(items.map((item) => item.key), sortMode === "za"
|
||||
? ["group:c", "group:b", "group:a"]
|
||||
: ["group:a", "group:b", "group:c"]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("HostTreeView shared order index preserves valid duplicates, ties and nested sibling metadata", () => {
|
||||
const leaf = (path: string): GroupNode => ({ name: path.split("/").at(-1)!, path, children: {}, hosts: [] });
|
||||
const parent: GroupNode = {
|
||||
...leaf("parent"),
|
||||
children: { z: leaf("parent/z"), a: leaf("parent/a"), b: leaf("parent/b") },
|
||||
hosts: [{ ...baseHost, id: "child-host", group: "parent" }],
|
||||
};
|
||||
const groupTree = [leaf("unordered-z"), leaf("tie-b"), parent, leaf("tie-a"), leaf("unordered-a")];
|
||||
const groupConfigs: GroupConfig[] = [
|
||||
{ path: "parent", order: 10 }, { path: "parent", order: -1 },
|
||||
{ path: "parent", order: Number.NaN }, { path: "parent", order: Infinity },
|
||||
{ path: "tie-b", order: 0 }, { path: "tie-a", order: 0 },
|
||||
{ path: "unordered-a", order: Number.NaN },
|
||||
{ path: "parent/a", order: 0 }, { path: "parent/b", order: 0 },
|
||||
{ path: "parent/z", order: -2 },
|
||||
];
|
||||
const ungroupedHosts = [{ ...baseHost, id: "root-host", group: "" }];
|
||||
const expandedPaths = new Set(["parent", "parent/z", "parent/a", "parent/b"]);
|
||||
const inputBefore = structuredClone({ groupTree, groupConfigs, ungroupedHosts, expandedPaths });
|
||||
const items = buildVisibleHostTreeItems({ groupTree, groupConfigs, ungroupedHosts, expandedPaths, sortMode: "manual" });
|
||||
assert.deepEqual(items.map(({ key, depth, posInSet, setSize }) => ({ key, depth, posInSet, setSize })), [
|
||||
{ key: "group:parent", depth: 0, posInSet: 1, setSize: 6 },
|
||||
{ key: "group:parent/z", depth: 1, posInSet: 1, setSize: 4 },
|
||||
{ key: "group:parent/b", depth: 1, posInSet: 2, setSize: 4 },
|
||||
{ key: "group:parent/a", depth: 1, posInSet: 3, setSize: 4 },
|
||||
{ key: "host:child-host", depth: 1, posInSet: 4, setSize: 4 },
|
||||
{ key: "group:tie-a", depth: 0, posInSet: 2, setSize: 6 },
|
||||
{ key: "group:tie-b", depth: 0, posInSet: 3, setSize: 6 },
|
||||
{ key: "group:unordered-z", depth: 0, posInSet: 4, setSize: 6 },
|
||||
{ key: "group:unordered-a", depth: 0, posInSet: 5, setSize: 6 },
|
||||
{ key: "host:root-host", depth: 0, posInSet: 6, setSize: 6 },
|
||||
]);
|
||||
assert.equal(items[0].kind === "group" && items[0].node, parent);
|
||||
assert.equal(items[4].kind === "host" && items[4].host, parent.hosts[0]);
|
||||
assert.deepEqual({ groupTree, groupConfigs, ungroupedHosts, expandedPaths }, inputBefore);
|
||||
const changed = buildVisibleHostTreeItems({
|
||||
groupTree, groupConfigs: [...groupConfigs, { path: "tie-a", order: -10 }],
|
||||
ungroupedHosts, expandedPaths, sortMode: "manual",
|
||||
});
|
||||
assert.equal(changed[0].key, "group:tie-a");
|
||||
});
|
||||
+18
-14
@@ -64,22 +64,25 @@ export type VisibleHostTreeItem =
|
||||
setSize: number;
|
||||
};
|
||||
|
||||
const sortHostTreeGroups = (
|
||||
nodes: GroupNode[],
|
||||
sortMode: HostTreeSortMode,
|
||||
groupConfigs: GroupConfig[],
|
||||
): GroupNode[] => {
|
||||
const originalIndex = new Map(nodes.map((node, index) => [node.path, index]));
|
||||
const orderByPath = new Map(
|
||||
const buildHostTreeGroupOrder = (groupConfigs: GroupConfig[]): Map<string, number> => (
|
||||
new Map(
|
||||
groupConfigs
|
||||
.filter((config) => typeof config.order === 'number' && Number.isFinite(config.order))
|
||||
.map((config) => [config.path, config.order as number]),
|
||||
);
|
||||
)
|
||||
);
|
||||
|
||||
const sortHostTreeGroups = (
|
||||
nodes: GroupNode[],
|
||||
sortMode: HostTreeSortMode,
|
||||
orderByPath?: ReadonlyMap<string, number>,
|
||||
): GroupNode[] => {
|
||||
const originalIndex = new Map(nodes.map((node, index) => [node.path, index]));
|
||||
return [...nodes].sort((a, b) => {
|
||||
if (sortMode === 'za') return b.name.localeCompare(a.name);
|
||||
if (sortMode === 'manual') {
|
||||
const orderA = orderByPath.get(a.path);
|
||||
const orderB = orderByPath.get(b.path);
|
||||
const orderA = orderByPath?.get(a.path);
|
||||
const orderB = orderByPath?.get(b.path);
|
||||
if (orderA !== undefined && orderB !== undefined && orderA !== orderB) return orderA - orderB;
|
||||
if (orderA !== undefined) return -1;
|
||||
if (orderB !== undefined) return 1;
|
||||
@@ -115,8 +118,9 @@ export function buildVisibleHostTreeItems({
|
||||
groupConfigs: GroupConfig[];
|
||||
}): VisibleHostTreeItem[] {
|
||||
const items: VisibleHostTreeItem[] = [];
|
||||
const orderByPath = sortMode === 'manual' ? buildHostTreeGroupOrder(groupConfigs) : undefined;
|
||||
const visitGroups = (nodes: GroupNode[], depth: number) => {
|
||||
const sortedGroups = sortHostTreeGroups(nodes, sortMode, groupConfigs);
|
||||
const sortedGroups = sortHostTreeGroups(nodes, sortMode, orderByPath);
|
||||
// Root-level ungrouped hosts are siblings of top-level groups.
|
||||
const rootUngrouped = depth === 0 ? sortHostTreeHosts(ungroupedHosts, sortMode) : [];
|
||||
const siblingSetSize = sortedGroups.length + rootUngrouped.length;
|
||||
@@ -133,7 +137,7 @@ export function buildVisibleHostTreeItems({
|
||||
const childGroups = sortHostTreeGroups(
|
||||
Object.values(node.children ?? {}) as GroupNode[],
|
||||
sortMode,
|
||||
groupConfigs,
|
||||
orderByPath,
|
||||
);
|
||||
const childHosts = sortHostTreeHosts(node.hosts, sortMode);
|
||||
const childSetSize = childGroups.length + childHosts.length;
|
||||
@@ -182,7 +186,7 @@ export function buildVisibleHostTreeItems({
|
||||
const childGroups = sortHostTreeGroups(
|
||||
Object.values(node.children ?? {}) as GroupNode[],
|
||||
sortMode,
|
||||
groupConfigs,
|
||||
orderByPath,
|
||||
);
|
||||
const childHosts = sortHostTreeHosts(node.hosts, sortMode);
|
||||
const childSetSize = childGroups.length + childHosts.length;
|
||||
@@ -439,7 +443,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
const childNodes = useMemo(() => sortHostTreeGroups(
|
||||
Object.values(node.children ?? {}) as GroupNode[],
|
||||
sortMode,
|
||||
groupConfigs,
|
||||
sortMode === 'manual' ? buildHostTreeGroupOrder(groupConfigs) : undefined,
|
||||
), [groupConfigs, node.children, sortMode]);
|
||||
const sortedHosts = useMemo(
|
||||
() => sortHostTreeHosts(node.hosts, sortMode),
|
||||
|
||||
@@ -270,7 +270,7 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
|
||||
groupConfigs,
|
||||
importDataFromString,
|
||||
clearVaultData,
|
||||
} = useVaultState();
|
||||
} = useVaultState({ syncConnectionLogTerminalData: false });
|
||||
|
||||
const { rules: portForwardingRules, importRules: importPortForwardingRules } = usePortForwardingState();
|
||||
|
||||
|
||||
+49
-7
@@ -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);
|
||||
@@ -1614,7 +1618,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const capturedData = options?.finalized
|
||||
? data
|
||||
: (finalizeTerminalLogData() || data);
|
||||
captureHandler(capturedSessionId, capturedData);
|
||||
return captureHandler(capturedSessionId, capturedData);
|
||||
}, [finalizeTerminalLogData]);
|
||||
|
||||
const attachHomeWebContentsIdRef = useRef<number | null | undefined>(undefined);
|
||||
@@ -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 {
|
||||
@@ -1961,10 +1973,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}, [sessionId]);
|
||||
|
||||
const forceCloseHibernatedSession = useCallback(() => {
|
||||
let pendingCapture: void | Promise<void>;
|
||||
if (!terminalDataCapturedRef.current) {
|
||||
const hibernatedData = hibernateSnapshotRef.current + hibernatePendingBufferRef.current;
|
||||
if (hibernatedData) {
|
||||
handleTerminalDataCaptureOnce(sessionId, hibernatedData);
|
||||
pendingCapture = handleTerminalDataCaptureOnce(sessionId, hibernatedData);
|
||||
}
|
||||
}
|
||||
disposeDataRef.current?.();
|
||||
@@ -2007,6 +2020,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
sessionRef.current = null;
|
||||
clearHibernateRuntimeState();
|
||||
return pendingCapture;
|
||||
}, [attachAuthorization, attachExistingSession, clearHibernateRuntimeState, handleTerminalDataCaptureOnce, sessionId, terminalBackend]);
|
||||
|
||||
const observeTerminalInputPrompt = useCallback((
|
||||
@@ -3433,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, {
|
||||
@@ -3449,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
|
||||
@@ -4127,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) {
|
||||
@@ -4176,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");
|
||||
}
|
||||
@@ -4200,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 () => {
|
||||
@@ -4283,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}
|
||||
@@ -4315,6 +4355,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
handleSendYmodem,
|
||||
handleSetTerminalEncoding,
|
||||
handleToggleSessionLog,
|
||||
handleSessionLogAction,
|
||||
canOpenSessionLog,
|
||||
handleToggleSearch,
|
||||
handleToolbarRecordingToggle,
|
||||
host,
|
||||
@@ -4689,7 +4731,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, RefreshCcw, Sparkles, SquareArrowOutUpRight, Unplug, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost: !!onUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI: onAddSelectionToAI ? handleAddSelectionToAI : undefined, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect: (attachExistingSession || compactToolbar) ? undefined : handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOpenHostBrowser, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isReconnectActive, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, identities, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onTogglePaneMagnification, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
|
||||
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, RefreshCcw, Sparkles, SquareArrowOutUpRight, Unplug, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost: !!onUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI: onAddSelectionToAI ? handleAddSelectionToAI : undefined, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect: (attachExistingSession || compactToolbar) ? undefined : handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOpenHostBrowser, handleOpenSFTP, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, composeSendToAll, onToggleComposeSendTarget, isCancelling, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isReconnectActive, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, identities, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onTogglePaneMagnification, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
|
||||
<ScriptExecutionOverlay
|
||||
run={activeScriptRun}
|
||||
onPause={() => { void pauseScriptRun(activeScriptRun.runId); }}
|
||||
|
||||
@@ -260,8 +260,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onCreateLocalTerminal,
|
||||
isBroadcastEnabled,
|
||||
onToggleBroadcast,
|
||||
isComposeSendToAll,
|
||||
onToggleComposeSendTarget,
|
||||
isGlobalBroadcastEnabled,
|
||||
isGlobalComposeSendToAll,
|
||||
onToggleGlobalBroadcast,
|
||||
onToggleGlobalComposeSendTarget,
|
||||
canUseGlobalBroadcast,
|
||||
updateHosts,
|
||||
updateSnippets,
|
||||
@@ -700,7 +704,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
}, [onAddKnownHost]);
|
||||
|
||||
const handleTerminalDataCapture = useCallback((sessionId: string, data: string) => {
|
||||
onTerminalDataCapture?.(sessionId, data);
|
||||
return onTerminalDataCapture?.(sessionId, data);
|
||||
}, [onTerminalDataCapture]);
|
||||
|
||||
// Terminal backend for broadcast writes
|
||||
@@ -1277,6 +1281,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
sourceSessionId,
|
||||
globalBroadcastEnabled: isGlobalBroadcastEnabled,
|
||||
directTargetSessionIds: options?.kittyKeyboardTargetSessionIds,
|
||||
includeGlobalPeersWithoutToolbar: options?.composePeerFanOut === true,
|
||||
}).filter(id => !paced?.targets || paced.targets.some(target => target.sessionId === id));
|
||||
if (options?.preparePacedBroadcast && paced) {
|
||||
paced.targets = targetSessionIds.filter(id => passwordBypass || !isTerminalSensitiveInputActive(id)).map(id => {
|
||||
@@ -2463,14 +2468,19 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
let anyRecipientSensitive = false;
|
||||
const pendingSends: Promise<boolean>[] = [];
|
||||
const payload = text + '\r';
|
||||
const broadcastEnabled = isBroadcastEnabled?.(activeWorkspace.id);
|
||||
// Compose Enter follows the compose-bar target. Toolbar broadcast stays
|
||||
// the keystroke switch and is only the fallback when no compose target
|
||||
// has been published (older callers).
|
||||
const composeSendToAll = isComposeSendToAll
|
||||
? isComposeSendToAll(activeWorkspace.id) === true
|
||||
: Boolean(isBroadcastEnabled?.(activeWorkspace.id));
|
||||
const focusedSessionId = activeWorkspace.focusedSessionId;
|
||||
const broadcastPasswordBypass = broadcastPasswordBypassRef.current;
|
||||
const focusedSensitive = focusedSessionId
|
||||
? isTerminalSensitiveInputActive(focusedSessionId)
|
||||
: false;
|
||||
|
||||
if (broadcastEnabled && (broadcastPasswordBypass || !focusedSensitive)) {
|
||||
if (composeSendToAll && (broadcastPasswordBypass || !focusedSensitive)) {
|
||||
const allSessionIds = sessionsRef.current
|
||||
.filter((session) => session.workspaceId === activeWorkspace.id)
|
||||
.map((session) => session.id);
|
||||
@@ -2518,7 +2528,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const executor = snippetExecutorsRef.current.get(targetId);
|
||||
if (executor) {
|
||||
const sensitive = isTerminalSensitiveInputActive(targetId);
|
||||
pendingSends.push(Promise.resolve(executor(text, false)).then(
|
||||
// Explicitly local: an omitted broadcast option falls back to the
|
||||
// toolbar switch, which can still be on while compose is active-only.
|
||||
pendingSends.push(Promise.resolve(executor(text, false, { broadcast: false })).then(
|
||||
(sent) => sent && !sensitive && !isTerminalSensitiveInputActive(targetId),
|
||||
));
|
||||
} else {
|
||||
@@ -2539,7 +2551,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
// eligibility for it.
|
||||
if (focusedSensitive || anyRecipientSensitive) return false;
|
||||
return delivered;
|
||||
}, [isBroadcastEnabled, terminalBackend]);
|
||||
}, [isBroadcastEnabled, isComposeSendToAll, terminalBackend]);
|
||||
|
||||
const sessionLogConfig = useMemo(
|
||||
() => ({
|
||||
@@ -2664,7 +2676,11 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
restoreTerminalCwd,
|
||||
identities,
|
||||
isBroadcastEnabled,
|
||||
isComposeSendToAll,
|
||||
onToggleComposeSendTarget,
|
||||
isGlobalBroadcastEnabled,
|
||||
isGlobalComposeSendToAll,
|
||||
onToggleGlobalComposeSendTarget,
|
||||
canUseGlobalBroadcast,
|
||||
isComposeBarOpen,
|
||||
keyBindings,
|
||||
|
||||
@@ -286,7 +286,7 @@ function TerminalPopupPageInner({
|
||||
groupConfigs,
|
||||
updateKnownHosts,
|
||||
deleteSelectedSnippets,
|
||||
} = useVaultState();
|
||||
} = useVaultState({ syncConnectionLogTerminalData: false });
|
||||
const [config, setConfig] = useState<TerminalPopupPayload | null>(null);
|
||||
const [terminalReady, setTerminalReady] = useState(false);
|
||||
const [startupError, setStartupError] = useState<string | null>(null);
|
||||
|
||||
@@ -148,7 +148,7 @@ const TrayPanelContent: React.FC = () => {
|
||||
onTrayPanelMenuData,
|
||||
} = useTrayPanelBackend();
|
||||
|
||||
const { hosts, knownHosts, updateKnownHosts } = useVaultState();
|
||||
const { hosts, knownHosts, updateKnownHosts } = useVaultState({ syncConnectionLogTerminalData: false });
|
||||
// TrayPanel runs in its own BrowserWindow, so this hook's session state is
|
||||
// independent from (and typically empty compared to) the main App's — it's
|
||||
// used here only for its storage-sync side effects, never for closeSession.
|
||||
|
||||
@@ -212,6 +212,10 @@ interface ChatInputProps {
|
||||
modelCatalogSource?: 'live' | 'last-known-good' | 'presets';
|
||||
/** ISO timestamp of the successful fetch backing `modelCatalogSource`. */
|
||||
modelCatalogFetchedAt?: string;
|
||||
/** The catalog warning text behind the host's retry banner, when present. */
|
||||
modelCatalogWarning?: string;
|
||||
/** Manual refresh action for the picker's refresh button (force path). */
|
||||
onRefreshModelCatalog?: () => void;
|
||||
/**
|
||||
* Offer the picker's "use custom model" action. Disable when the current
|
||||
* agent locks the model (e.g. managed Codex `model` config overrides the
|
||||
@@ -286,6 +290,8 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
modelCatalogLoading = false,
|
||||
modelCatalogSource,
|
||||
modelCatalogFetchedAt,
|
||||
modelCatalogWarning,
|
||||
onRefreshModelCatalog,
|
||||
allowCustomModelEntry = true,
|
||||
selectedModelId,
|
||||
onModelSelect,
|
||||
@@ -1608,6 +1614,8 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
loading={!hasProviderSwitcher ? modelCatalogLoading : undefined}
|
||||
catalogSource={!hasProviderSwitcher ? modelCatalogSource : undefined}
|
||||
catalogFetchedAt={!hasProviderSwitcher ? modelCatalogFetchedAt : undefined}
|
||||
catalogWarning={!hasProviderSwitcher ? modelCatalogWarning : undefined}
|
||||
onRefreshCatalog={!hasProviderSwitcher ? onRefreshModelCatalog : undefined}
|
||||
allowCustomEntry={allowCustomModelEntry}
|
||||
prefs={modelPrefs}
|
||||
onSelectProviderModel={(providerId, modelId, contextWindow) => {
|
||||
@@ -1824,6 +1832,8 @@ function chatInputPropsAreEqual(prev: ChatInputProps, next: ChatInputProps): boo
|
||||
&& prev.modelCatalogLoading === next.modelCatalogLoading
|
||||
&& prev.modelCatalogSource === next.modelCatalogSource
|
||||
&& prev.modelCatalogFetchedAt === next.modelCatalogFetchedAt
|
||||
&& prev.modelCatalogWarning === next.modelCatalogWarning
|
||||
&& prev.onRefreshModelCatalog === next.onRefreshModelCatalog
|
||||
&& prev.allowCustomModelEntry === next.allowCustomModelEntry
|
||||
&& prev.selectedModelId === next.selectedModelId
|
||||
&& prev.onModelSelect === next.onModelSelect
|
||||
|
||||
@@ -156,3 +156,36 @@ test('external agent picker shows the loading row while the live catalog is pend
|
||||
// keeps built-in presets visible during loading and passes them through.
|
||||
assert.doesNotMatch(html, /gpt-5\.1/);
|
||||
});
|
||||
|
||||
test('manual refresh button renders for any fetched catalog or warning and stays localized', () => {
|
||||
const base = {
|
||||
modelPresets: [{ id: 'm-1', name: 'M1' }],
|
||||
selectedModelId: 'm-1',
|
||||
prefs: { recent: [], pinned: [] },
|
||||
onSelectModel: () => {},
|
||||
onTogglePinned: () => {},
|
||||
};
|
||||
for (const [locale, refreshLabel] of [
|
||||
['en', 'Refresh model list'],
|
||||
['zh-CN', '刷新模型列表'],
|
||||
['zh-TW', '重新整理模型清單'],
|
||||
['es', 'Actualizar la lista de modelos'],
|
||||
['ru', 'Обновить список моделей'],
|
||||
]) {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider locale={locale}>
|
||||
<ComposerModelPicker {...base} catalogSource="last-known-good" onRefreshCatalog={() => {}} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
assert.ok(html.includes(`aria-label="${refreshLabel}"`));
|
||||
}
|
||||
|
||||
// A plain preset list without a host refresh action stays buttonless.
|
||||
assert.doesNotMatch(renderToStaticMarkup(<ComposerModelPicker {...base} />), /refreshModelList/);
|
||||
|
||||
// A degraded preset serve (warning, no source) still offers the refresh.
|
||||
const warned = renderToStaticMarkup(
|
||||
<ComposerModelPicker {...base} catalogWarning="live fetch failed" onRefreshCatalog={() => {}} />,
|
||||
);
|
||||
assert.match(warned, /aria-label="ai\.chat\.refreshModelList"/);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Check, ChevronLeft, ChevronRight, Loader2, Pin, Search, Star } from 'lucide-react';
|
||||
import { Check, ChevronLeft, ChevronRight, Loader2, Pin, RotateCw, Search, Star } from 'lucide-react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import {
|
||||
@@ -37,6 +37,18 @@ export interface ComposerModelPickerProps {
|
||||
catalogSource?: 'live' | 'last-known-good' | 'presets';
|
||||
/** ISO timestamp of the successful fetch backing `catalogSource`. */
|
||||
catalogFetchedAt?: string;
|
||||
/**
|
||||
* The catalog warning text behind the host's retry banner, when present.
|
||||
* Together with `catalogSource` it decides whether the manual refresh
|
||||
* button renders: any fetched catalog (live or last-known-good) or a
|
||||
* degraded preset fallback offers a refresh; a plain preset list does not.
|
||||
*/
|
||||
catalogWarning?: string | null;
|
||||
/**
|
||||
* Manual refresh action (LKG-first policy: the picker hydrates from disk
|
||||
* and the user refreshes explicitly). Triggers the host's force refresh.
|
||||
*/
|
||||
onRefreshCatalog?: () => void;
|
||||
prefs: ComposerModelPrefs;
|
||||
/**
|
||||
* Offer the "use custom model" action. Disable when the host locks the
|
||||
@@ -107,6 +119,8 @@ export const ComposerModelPicker: React.FC<ComposerModelPickerProps> = ({
|
||||
loading = false,
|
||||
catalogSource,
|
||||
catalogFetchedAt,
|
||||
catalogWarning,
|
||||
onRefreshCatalog,
|
||||
prefs,
|
||||
allowCustomEntry = true,
|
||||
onSelectProviderModel,
|
||||
@@ -174,6 +188,18 @@ export const ComposerModelPicker: React.FC<ComposerModelPickerProps> = ({
|
||||
? t('ai.chat.modelCatalogSourceCached').replace('{time}', time)
|
||||
: t('ai.chat.modelCatalogSourceCachedNoTime');
|
||||
}, [loading, catalogSource, catalogFetchedAt, t]);
|
||||
// Manual refresh (LKG-first): offered whenever a fetched catalog (any
|
||||
// source) or the degraded-preset warning is present. Hidden while loading —
|
||||
// the spinner row is the in-flight feedback — and in provider mode, where
|
||||
// neither a catalog chip nor a host refresh action exists.
|
||||
const showRefreshButton = useMemo(() => (
|
||||
!loading
|
||||
&& Boolean(onRefreshCatalog)
|
||||
&& (catalogSource === 'live'
|
||||
|| catalogSource === 'last-known-good'
|
||||
|| Boolean(catalogWarning))
|
||||
), [loading, onRefreshCatalog, catalogSource, catalogWarning]);
|
||||
const refreshLabel = t('ai.chat.refreshModelList');
|
||||
// Manual model ids are accepted in both modes: provider-bound catalogs and
|
||||
// preset lists from external CLI agents whose catalogs lag new models.
|
||||
const showCustom = Boolean(
|
||||
@@ -384,9 +410,22 @@ export const ComposerModelPicker: React.FC<ComposerModelPickerProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{catalogChipText && (
|
||||
<div className="border-t border-border/40 px-2.5 py-1 text-[10px] text-muted-foreground/45" title={catalogChipText}>
|
||||
{catalogChipText}
|
||||
{(catalogChipText || showRefreshButton) && (
|
||||
<div className="flex items-center gap-1 border-t border-border/40 px-2.5 py-1 text-[10px] text-muted-foreground/45">
|
||||
{catalogChipText && (
|
||||
<span className="min-w-0 flex-1 truncate" title={catalogChipText}>{catalogChipText}</span>
|
||||
)}
|
||||
{showRefreshButton && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={refreshLabel}
|
||||
title={refreshLabel}
|
||||
onClick={onRefreshCatalog}
|
||||
className="shrink-0 rounded p-0.5 text-muted-foreground/55 transition-colors hover:bg-muted/40 hover:text-foreground/75 cursor-pointer"
|
||||
>
|
||||
<RotateCw size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { invokeAsideActionMenuItemClick } from "./ui/aside-panel.tsx";
|
||||
import { AsidePanel, invokeAsideActionMenuItemClick } from "./ui/aside-panel.tsx";
|
||||
|
||||
test("AsideActionMenuItem closes its menu before running the action", () => {
|
||||
const events: string[] = [];
|
||||
@@ -21,3 +23,12 @@ test("AsideActionMenuItem still closes when no action is provided", () => {
|
||||
|
||||
assert.deepEqual(events, ["close"]);
|
||||
});
|
||||
|
||||
test("overlay aside panels ask native views to yield; inline panels do not", () => {
|
||||
const render = (layout: "overlay" | "inline") => renderToStaticMarkup(
|
||||
React.createElement(AsidePanel, { open: true, onClose: () => {}, layout, children: "content" }),
|
||||
);
|
||||
|
||||
assert.match(render("overlay"), /data-native-view-occluder=""/);
|
||||
assert.doesNotMatch(render("inline"), /data-native-view-occluder/);
|
||||
});
|
||||
@@ -158,7 +158,7 @@ export interface NetcattyAiBridge {
|
||||
aiCodexCancelLogin?: (sessionId: string) => Promise<{ ok: boolean; found?: boolean; session?: CodexLoginSession; error?: string }>;
|
||||
aiCodexLogout?: (options?: { codexPath?: string; agentEnv?: Record<string, string> }) => Promise<{ ok: boolean; state?: CodexIntegrationState; isConnected?: boolean; rawOutput?: string; logoutOutput?: string; error?: string }>;
|
||||
aiResolveCli?: (params: { command: string; customPath?: string; refreshShellEnv?: boolean; apiKeyPresent?: boolean }) => Promise<AgentPathInfo>;
|
||||
aiSdkAgentListModels?: (sdkBackend: string, cwd?: string, providerId?: string, chatSessionId?: string, agentEnv?: Record<string, string>, agentCommand?: string, codexRuntime?: 'sdk' | 'app-server') => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string; thinkingLevels?: string[]; defaultThinkingLevel?: string }>; currentModelId?: string | null; warning?: string; source?: 'live' | 'last-known-good'; fetchedAt?: string; error?: string }>;
|
||||
aiSdkAgentListModels?: (sdkBackend: string, cwd?: string, providerId?: string, chatSessionId?: string, agentEnv?: Record<string, string>, agentCommand?: string, codexRuntime?: 'sdk' | 'app-server', force?: boolean) => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string; thinkingLevels?: string[]; defaultThinkingLevel?: string }>; currentModelId?: string | null; warning?: string; source?: 'live' | 'last-known-good'; fetchedAt?: string; error?: string }>;
|
||||
codexAppServerGetStatus?: (agentCommand?: string, agentEnv?: Record<string, string>) => Promise<{ ok: boolean; available: boolean; error?: string }>;
|
||||
aiUserSkillsGetStatus?: () => Promise<UserSkillsStatusResult>;
|
||||
aiUserSkillsOpenFolder?: () => Promise<UserSkillsStatusResult>;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
|
||||
function rate(value: number | null | undefined) {
|
||||
if (value == null || !Number.isFinite(value) || value < 0) return "--";
|
||||
if (value >= 1024 ** 2) return `${(value / 1024 ** 2).toFixed(1)} MiB/s`;
|
||||
if (value >= 1024) return `${(value / 1024).toFixed(1)} KiB/s`;
|
||||
return `${Math.round(value)} B/s`;
|
||||
}
|
||||
|
||||
/** Same shared-sampler rates in monitor cards, tables, and terminal overview. */
|
||||
export function DiskIoSummary({ stats }: {
|
||||
stats?: { diskReadSpeed?: number | null; diskWriteSpeed?: number | null } | null;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div data-disk-io className="flex flex-wrap gap-x-3 gap-y-0.5 text-[10px] tabular-nums text-muted-foreground" title={t("monitor.diskIoHint")}>
|
||||
<span>{t("monitor.diskIo")}</span>
|
||||
<span>{t("monitor.diskRead")} {rate(stats?.diskReadSpeed)}</span>
|
||||
<span>{t("monitor.diskWrite")} {rate(stats?.diskWriteSpeed)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { aggregateMountedDiskUsage } from '../../domain/systemDiskUsage';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { useServerStats } from '../../application/state/useServerStats';
|
||||
import { ResourceBar } from './ResourceBar';
|
||||
import { DiskIoSummary } from './DiskIoSummary';
|
||||
import {
|
||||
SystemPanelEmpty,
|
||||
SystemPanelError,
|
||||
@@ -335,6 +336,8 @@ export const SystemOverviewTab = memo(function SystemOverviewTab({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3"><DiskIoSummary stats={stats} /></div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<InfoPill label={t('systemManager.overview.load')} value={formatLoad(stats.loadAverage)} />
|
||||
<InfoPill label={t('systemManager.overview.uptime')} value={formatDuration(stats.uptimeSeconds, t)} />
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { I18nProvider } from '../../application/i18n/I18nProvider';
|
||||
import { TerminalComposeBar } from './TerminalComposeBar';
|
||||
import { TooltipProvider } from '../ui/tooltip';
|
||||
import { getComposeBarHistory, pruneComposeBarHistory, recordComposeBarHistory } from '../../application/state/composeBarHistoryStore';
|
||||
@@ -195,3 +196,65 @@ test('empty compose draft Enter reconnects; a typed draft still sends', async (t
|
||||
|
||||
await act(async () => { root.unmount(); });
|
||||
});
|
||||
|
||||
test('compose send target toggle shows all vs active without looking like broadcast when active', async (t) => {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
|
||||
const originals = new Map<string, PropertyDescriptor | undefined>();
|
||||
for (const name of ['window', 'document', 'localStorage']) {
|
||||
originals.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { configurable: true, value: dom.window[name as keyof Window] });
|
||||
}
|
||||
t.after(() => {
|
||||
dom.window.close();
|
||||
for (const [name, descriptor] of originals) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
});
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
const toggles: string[] = [];
|
||||
const render = (composeSendToAll: boolean) => (
|
||||
<I18nProvider locale="en">
|
||||
<TooltipProvider>
|
||||
<TerminalComposeBar
|
||||
sessionId="compose-target"
|
||||
onSend={() => true}
|
||||
onClose={() => {}}
|
||||
isBroadcastEnabled
|
||||
composeSendToAll={composeSendToAll}
|
||||
onToggleComposeSendTarget={() => { toggles.push('toggle'); }}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
let root!: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
root = create(render(true), { createNodeMock: (el) => (el.type === 'textarea' ? { focus() {} } : null) });
|
||||
});
|
||||
const button = root.root.findAll(
|
||||
(node) => node.props?.['data-compose-send-target'] === 'all' || node.props?.['data-compose-send-target'] === 'active',
|
||||
)[0];
|
||||
assert.ok(button);
|
||||
assert.equal(button.props['data-compose-send-target'], 'all');
|
||||
assert.equal(button.props['aria-pressed'], true);
|
||||
assert.match(String(button.props['aria-label']), /all sessions/i);
|
||||
const pulsing = root.root.findAll((node) => typeof node.props?.className === 'string' && node.props.className.includes('animate-pulse'));
|
||||
assert.ok(pulsing.length > 0);
|
||||
await act(async () => {
|
||||
button.props.onClick({
|
||||
preventDefault() {},
|
||||
stopPropagation() {},
|
||||
defaultPrevented: false,
|
||||
nativeEvent: { defaultPrevented: false },
|
||||
});
|
||||
});
|
||||
assert.deepEqual(toggles, ['toggle']);
|
||||
await act(async () => { root.update(render(false)); });
|
||||
const active = root.root.findAll((node) => node.props?.['data-compose-send-target'] === 'active')[0];
|
||||
assert.ok(active);
|
||||
assert.equal(active.props['aria-pressed'], false);
|
||||
assert.match(String(active.props['aria-label']), /active session/i);
|
||||
const stillPulsing = root.root.findAll((node) => typeof node.props?.className === 'string' && node.props.className.includes('animate-pulse'));
|
||||
assert.equal(stillPulsing.length, 0);
|
||||
await act(async () => { root.unmount(); });
|
||||
});
|
||||
@@ -280,6 +280,9 @@ export interface TerminalComposeBarProps {
|
||||
onSnippetClick?: (snippet: Snippet) => void;
|
||||
snippets?: Snippet[];
|
||||
isBroadcastEnabled?: boolean;
|
||||
/** Compose-bar send target. Undefined follows toolbar broadcast. */
|
||||
composeSendToAll?: boolean;
|
||||
onToggleComposeSendTarget?: () => void;
|
||||
themeColors?: {
|
||||
background: string;
|
||||
foreground: string;
|
||||
@@ -293,6 +296,8 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
|
||||
onSnippetClick,
|
||||
snippets = [],
|
||||
isBroadcastEnabled,
|
||||
composeSendToAll,
|
||||
onToggleComposeSendTarget,
|
||||
themeColors,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
@@ -350,8 +355,9 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
|
||||
};
|
||||
}, [broadcastMenuAnchor]);
|
||||
|
||||
const sendToAll = composeSendToAll ?? Boolean(isBroadcastEnabled);
|
||||
const handleBroadcastContextMenu = useCallback((event: React.MouseEvent) => {
|
||||
if (!isBroadcastEnabled) return;
|
||||
if (!isBroadcastEnabled && !sendToAll) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setBroadcastMenuAnchor({
|
||||
@@ -359,7 +365,7 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
|
||||
y: event.clientY,
|
||||
inTextarea: event.target instanceof Element && Boolean(event.target.closest('textarea')),
|
||||
});
|
||||
}, [isBroadcastEnabled]);
|
||||
}, [isBroadcastEnabled, sendToAll]);
|
||||
|
||||
const handleComposeMenuEdit = useCallback(async (action: 'cut' | 'copy' | 'paste' | 'selectAll') => {
|
||||
setBroadcastMenuAnchor(null);
|
||||
@@ -607,14 +613,33 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
|
||||
|
||||
<div className="flex-1 min-h-0 px-3 pt-1.5 pb-2 flex flex-col">
|
||||
<div className="flex flex-1 min-h-0 items-start gap-1.5">
|
||||
{isBroadcastEnabled && (
|
||||
{(onToggleComposeSendTarget || sendToAll) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center cursor-default pt-0.5 flex-shrink-0">
|
||||
<Radio size={14} className="text-amber-400 animate-pulse" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-compose-send-target={sendToAll ? 'all' : 'active'}
|
||||
aria-pressed={sendToAll}
|
||||
aria-label={sendToAll
|
||||
? t('terminal.composeBar.sendToAll')
|
||||
: t('terminal.composeBar.sendToActive')}
|
||||
className="flex items-center pt-0.5 flex-shrink-0 rounded-sm"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => onToggleComposeSendTarget?.()}
|
||||
onContextMenu={handleBroadcastContextMenu}
|
||||
>
|
||||
<Radio
|
||||
size={14}
|
||||
className={sendToAll ? 'text-amber-400 animate-pulse' : undefined}
|
||||
style={sendToAll ? undefined : { color: theme.mutedFg }}
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('terminal.composeBar.broadcasting')}</TooltipContent>
|
||||
<TooltipContent>
|
||||
{sendToAll
|
||||
? t('terminal.composeBar.broadcasting')
|
||||
: t('terminal.composeBar.sendToActive')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
test('SFTP menu opens the clicked pane without a selection and is absent without a handler', async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
|
||||
pretendToBeVisual: true,
|
||||
url: 'http://localhost',
|
||||
});
|
||||
const window = dom.window;
|
||||
const previousGlobals = new Map<string, PropertyDescriptor | undefined>();
|
||||
const installGlobal = (key: string, value: unknown) => {
|
||||
previousGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value,
|
||||
});
|
||||
};
|
||||
|
||||
class ResizeObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
installGlobal('window', window);
|
||||
installGlobal('document', window.document);
|
||||
installGlobal('navigator', window.navigator);
|
||||
installGlobal('HTMLElement', window.HTMLElement);
|
||||
installGlobal('HTMLInputElement', window.HTMLInputElement);
|
||||
installGlobal('HTMLTextAreaElement', window.HTMLTextAreaElement);
|
||||
installGlobal('Element', window.Element);
|
||||
installGlobal('SVGElement', window.SVGElement);
|
||||
installGlobal('Node', window.Node);
|
||||
installGlobal('NodeFilter', window.NodeFilter);
|
||||
installGlobal('MutationObserver', window.MutationObserver);
|
||||
installGlobal('CustomEvent', window.CustomEvent);
|
||||
installGlobal('DOMRect', window.DOMRect);
|
||||
installGlobal('Event', window.Event);
|
||||
installGlobal('KeyboardEvent', window.KeyboardEvent);
|
||||
installGlobal('MouseEvent', window.MouseEvent);
|
||||
installGlobal('getComputedStyle', window.getComputedStyle.bind(window));
|
||||
installGlobal('requestAnimationFrame', window.requestAnimationFrame.bind(window));
|
||||
installGlobal('cancelAnimationFrame', window.cancelAnimationFrame.bind(window));
|
||||
installGlobal('ResizeObserver', ResizeObserverStub);
|
||||
installGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
|
||||
const { default: React, act } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { I18nProvider } = await import('../../application/i18n/I18nProvider.tsx');
|
||||
const { TerminalContextMenu } = await import('./TerminalContextMenu.tsx');
|
||||
const rootNode = window.document.getElementById('root');
|
||||
assert.ok(rootNode);
|
||||
const root = createRoot(rootNode);
|
||||
const actions: string[] = [];
|
||||
|
||||
const openMenu = async (hostId: string) => {
|
||||
const surface = window.document.querySelector<HTMLElement>(`[data-testid="${hostId}"]`);
|
||||
assert.ok(surface);
|
||||
await act(async () => {
|
||||
surface.dispatchEvent(new window.MouseEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
button: 2,
|
||||
clientX: 20,
|
||||
clientY: 20,
|
||||
}));
|
||||
});
|
||||
};
|
||||
|
||||
const findMenuItem = (label: string): HTMLElement => {
|
||||
const item = Array.from(window.document.querySelectorAll<HTMLElement>('[role="menuitem"]'))
|
||||
.find((candidate) => candidate.textContent?.includes(label));
|
||||
assert.ok(item, `${label} menu item should be visible`);
|
||||
return item;
|
||||
};
|
||||
|
||||
const renderPanes = async (enabled = true) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<I18nProvider locale="zh-CN">
|
||||
{['host-a', 'host-b'].map((hostId) => (
|
||||
<TerminalContextMenu
|
||||
key={hostId}
|
||||
sessionId={hostId}
|
||||
hostId={hostId}
|
||||
status="connected"
|
||||
hasSelection={false}
|
||||
onOpenSftp={enabled ? () => actions.push(hostId) : undefined}
|
||||
>
|
||||
<div data-testid={hostId}>Terminal</div>
|
||||
</TerminalContextMenu>
|
||||
))}
|
||||
</I18nProvider>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
await renderPanes();
|
||||
for (const hostId of ['host-b', 'host-a', 'host-b']) {
|
||||
await openMenu(hostId);
|
||||
const item = findMenuItem('打开 SFTP');
|
||||
assert.notEqual(item.getAttribute('aria-disabled'), 'true');
|
||||
assert.equal(findMenuItem('复制').getAttribute('aria-disabled'), 'true');
|
||||
await act(async () => item.click());
|
||||
assert.equal(window.document.querySelector('[role="menu"]'), null);
|
||||
}
|
||||
assert.deepEqual(actions, ['host-b', 'host-a', 'host-b']);
|
||||
|
||||
await renderPanes(false);
|
||||
await openMenu('host-a');
|
||||
assert.equal(window.document.body.textContent?.includes('打开 SFTP'), false);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
dom.window.close();
|
||||
for (const [key, descriptor] of previousGlobals) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else delete (globalThis as Record<string, unknown>)[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Copy,
|
||||
Download,
|
||||
Globe2,
|
||||
FolderInput,
|
||||
Pencil,
|
||||
RefreshCcw,
|
||||
Sparkles,
|
||||
@@ -67,6 +68,7 @@ export interface TerminalContextMenuProps {
|
||||
onReconnect?: () => void;
|
||||
onClose?: () => void;
|
||||
onOpenBrowser?: () => void;
|
||||
onOpenSftp?: () => void;
|
||||
onSelectWord?: () => void;
|
||||
onAddSelectionToAI?: () => void;
|
||||
onRename?: () => void;
|
||||
@@ -227,6 +229,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
onReconnect,
|
||||
onClose,
|
||||
onOpenBrowser,
|
||||
onOpenSftp,
|
||||
onSelectWord,
|
||||
onAddSelectionToAI,
|
||||
onRename,
|
||||
@@ -413,6 +416,14 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
{t('terminal.menu.selectAll')}
|
||||
<ContextMenuShortcut>{selectAllShortcut}</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
{onOpenSftp && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={onOpenSftp}>
|
||||
<FolderInput size={14} className="mr-2" /> {t('terminal.toolbar.openSftp')}
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
{onOpenBrowser && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
|
||||
@@ -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,
|
||||
@@ -731,3 +741,11 @@ test("terminal theme updates force xterm renderer to repaint immediately", () =>
|
||||
assert.match(schedulerSource, /term\.options\.theme = \{/);
|
||||
assert.match(schedulerSource, /forceSyncRenderAfterResize\(term\)/);
|
||||
});
|
||||
|
||||
test("SFTP context action reuses the pane toolbar handler and its availability", () => {
|
||||
const source = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
|
||||
const terminalSource = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
|
||||
assert.match(source, /onOpenSftp=\{status === "connected" && !isSerialConnection && !host\?\.id\?\.startsWith\("serial-"\) && !isPluginHostProtocol\(host\?\.protocol\) \? ctx\.handleOpenSFTP : undefined\}/);
|
||||
assert.match(terminalSource, /onOpenSFTP=\{handleOpenSFTP\}/);
|
||||
assert.match(terminalSource, /<TerminalView ctx=\{\{[^\n]*handleOpenSFTP,/);
|
||||
});
|
||||
@@ -390,7 +390,7 @@ function terminalViewCtxEqual(
|
||||
}
|
||||
|
||||
function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalViewContext; isPaneMagnified?: boolean }) {
|
||||
const { Activity, Button, Clock3, Copy, Maximize2, Radio, RefreshCcw, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, Unplug, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTerminalProtocol, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isReconnectActive, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, identities, knownCwdRef, needsHostKeyVerification, onCloseSession, onDetach, onDetachPointerDown, onExpandToFocus, onTogglePaneMagnification, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scriptExecutionOverlay, searchMatchCount, searchFocusToken, sessionDisplayName, sessionId, workspaceId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showDisconnectedTerminalNotice, showConnectionControls, showLogs, showSelectionAIAction, isRestoringSelectionRef, snippets, status, sudoHintRef, sudoHintText, passwordPickerState, onPasswordPickerSelect, passwordPickerTitle, passwordPickerEmptyText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, terminalReconnectAvailable, reconnectNoticeMessage, timeLeft, toast, zmodem } = ctx;
|
||||
const { Activity, Button, Clock3, Copy, Maximize2, Radio, RefreshCcw, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, Unplug, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTerminalProtocol, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, composeSendToAll, onToggleComposeSendTarget, isCancelling, isComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isReconnectActive, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, identities, knownCwdRef, needsHostKeyVerification, onCloseSession, onDetach, onDetachPointerDown, onExpandToFocus, onTogglePaneMagnification, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scriptExecutionOverlay, searchMatchCount, searchFocusToken, sessionDisplayName, sessionId, workspaceId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showDisconnectedTerminalNotice, showConnectionControls, showLogs, showSelectionAIAction, isRestoringSelectionRef, snippets, status, sudoHintRef, sudoHintText, passwordPickerState, onPasswordPickerSelect, passwordPickerTitle, passwordPickerEmptyText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, terminalReconnectAvailable, reconnectNoticeMessage, timeLeft, toast, zmodem } = ctx;
|
||||
// Context menu only needs a snapshot at open; avoid selection state lifting into Terminal.
|
||||
const [contextMenuHasSelection, setContextMenuHasSelection] = useState(false);
|
||||
const isNetworkDevice = host.deviceType === 'network'
|
||||
@@ -591,6 +591,7 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
|
||||
onSaveScreen={terminalContextActions.onSaveScreen}
|
||||
onCopy={terminalContextActions.onCopy}
|
||||
onOpenBrowser={(!host?.protocol || ['ssh', 'mosh', 'local'].includes(host.protocol)) ? () => { void ctx.handleOpenHostBrowser(); } : undefined}
|
||||
onOpenSftp={status === "connected" && !isSerialConnection && !host?.id?.startsWith("serial-") && !isPluginHostProtocol(host?.protocol) ? ctx.handleOpenSFTP : undefined}
|
||||
onPaste={terminalContextActions.onPaste}
|
||||
onUploadClipboardImage={status === "connected" ? terminalContextActions.onUploadClipboardImage : undefined}
|
||||
onPasteSelection={terminalContextActions.onPasteSelection}
|
||||
@@ -1328,6 +1329,10 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
|
||||
let anyRecipientSensitive = false;
|
||||
let broadcastRecipientIds: readonly string[] = [];
|
||||
const sent = await executeSnippetCommand(text, false, {
|
||||
// Explicit target: active-only stays local while toolbar
|
||||
// broadcast (keystrokes) can remain on, and send-to-all
|
||||
// still fans out when the toolbar switch is off.
|
||||
broadcast: composeSendToAll ?? Boolean(isBroadcastEnabled),
|
||||
onBroadcastDelivered: (sessionIds) => {
|
||||
broadcastRecipientIds = sessionIds;
|
||||
if (sessionIds.some((id) => isTerminalSensitiveInputActive(id))) {
|
||||
@@ -1352,6 +1357,8 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
|
||||
termRef.current?.focus();
|
||||
}}
|
||||
isBroadcastEnabled={isBroadcastEnabled}
|
||||
composeSendToAll={composeSendToAll}
|
||||
onToggleComposeSendTarget={onToggleComposeSendTarget}
|
||||
themeColors={effectiveTheme.colors}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1965,7 +1965,14 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
sessionId: ctx.sessionId,
|
||||
dataLength: terminalData.length,
|
||||
});
|
||||
ctx.onTerminalDataCapture(ctx.sessionId, terminalData);
|
||||
const pendingCapture: unknown = ctx.onTerminalDataCapture(ctx.sessionId, terminalData);
|
||||
if (
|
||||
pendingCapture !== undefined
|
||||
&& pendingCapture !== null
|
||||
&& typeof (pendingCapture as Promise<void>).then === "function"
|
||||
) {
|
||||
await (pendingCapture as Promise<void>);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn("Failed to serialize terminal data:", err);
|
||||
}
|
||||
|
||||
@@ -245,7 +245,7 @@ export type TerminalSessionStartersContext = {
|
||||
onSessionAttached?: (sessionId: string) => void;
|
||||
onRestoreCwdIntentConsumed?: (cwd: string) => void;
|
||||
onSessionExit?: (sessionId: string, evt: TerminalSessionExitEvent) => void;
|
||||
onTerminalDataCapture?: (sessionId: string, data: string) => void;
|
||||
onTerminalDataCapture?: (sessionId: string, data: string) => void | Promise<void>;
|
||||
onTerminalLogData?: (data: string) => void;
|
||||
onProgrammaticCommandLogRewrite?: (rewrite: ProgrammaticCommandLogRewrite) => void;
|
||||
onTerminalOutput?: (chunk: string, meta?: TerminalSessionDataMeta) => void;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { shouldBroadcastTerminalUserInput } from "./terminalUserPaste.ts";
|
||||
|
||||
test("toolbar broadcast still fans terminal keystrokes out to peers", () => {
|
||||
const term = {};
|
||||
assert.equal(
|
||||
shouldBroadcastTerminalUserInput(term, "ls", {
|
||||
isBroadcastEnabled: true,
|
||||
hasBroadcastInputHandler: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldBroadcastTerminalUserInput(term, "ls", {
|
||||
isBroadcastEnabled: false,
|
||||
hasBroadcastInputHandler: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("keystroke fan-out stays on the toolbar gate and ignores the compose-bar target", () => {
|
||||
const runtime = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
|
||||
assert.match(runtime, /isBroadcastEnabled: ctx\.isBroadcastEnabledRef\.current/);
|
||||
assert.match(
|
||||
runtime,
|
||||
/onBroadcastInput\?\.\(broadcastData, ctx\.sessionId, sensitive \? \{ sourceSensitive: true \} : undefined\)/,
|
||||
);
|
||||
assert.doesNotMatch(runtime, /composeSendToAll|composePeerFanOut/);
|
||||
|
||||
const layer = readFileSync(new URL("../../TerminalLayer.tsx", import.meta.url), "utf8");
|
||||
assert.match(layer, /includeGlobalPeersWithoutToolbar: options\?\.composePeerFanOut === true/);
|
||||
const keystrokeCall = layer.slice(
|
||||
layer.indexOf("const handleBroadcastInput"),
|
||||
layer.indexOf("const handleCommandSubmitted"),
|
||||
);
|
||||
assert.doesNotMatch(keystrokeCall, /isComposeSendToAll/);
|
||||
});
|
||||
@@ -475,6 +475,10 @@ export const createTerminalOutputHistoryPreview = (options?: {
|
||||
// mark / trailing surrogate in the next) while xterm's grapheme provider
|
||||
// joins them across writes, so the next span must be able to rejoin it.
|
||||
let openGrapheme: string | null = null;
|
||||
// When set, `openGrapheme` still holds the whole non-ASCII piece the last
|
||||
// append wrote; its last grapheme is only segmented out when a following
|
||||
// span actually tries to rejoin it (rare), not on every append.
|
||||
let openGraphemeUnresolved = false;
|
||||
let openGraphemeCell = 0;
|
||||
let cacheDirty = true;
|
||||
let cacheCols = -1;
|
||||
@@ -551,6 +555,10 @@ export const createTerminalOutputHistoryPreview = (options?: {
|
||||
&& cursor === current.length
|
||||
&& cursorCell === openGraphemeCell
|
||||
) {
|
||||
if (openGraphemeUnresolved) {
|
||||
openGrapheme = lastGraphemeUnits(openGrapheme);
|
||||
openGraphemeUnresolved = false;
|
||||
}
|
||||
const probeEnd = Math.min(span.length, WRAP_PROBE_UTF16_UNITS);
|
||||
const head = openGrapheme + span.slice(0, probeEnd);
|
||||
let first = firstGraphemeUnits(head);
|
||||
@@ -703,7 +711,9 @@ export const createTerminalOutputHistoryPreview = (options?: {
|
||||
// clusters and corrupt the transcript, while a boundary-aligned cut
|
||||
// keeps every piece's segmentation identical to the span's.
|
||||
let pieceEnd = offset + (maxChars - current.length);
|
||||
if (pieceEnd - offset > pieceCap) {
|
||||
// A remainder no longer than the cap needs no boundary search: the
|
||||
// segment walk below would consume all of it and cut at its end.
|
||||
if (pieceEnd - offset > pieceCap && span.length - offset > pieceCap) {
|
||||
const window = span.slice(offset, offset + pieceCap + WRAP_PROBE_UTF16_UNITS);
|
||||
let cutLength = 0;
|
||||
let end = window.length;
|
||||
@@ -794,7 +804,8 @@ export const createTerminalOutputHistoryPreview = (options?: {
|
||||
// continuing it (a split cluster) can be rejoined (see writeSpan).
|
||||
// An empty open grapheme would corrupt the join, so track null then.
|
||||
openGrapheme = !fitting ? null
|
||||
: isAsciiOnly(fitting) ? fitting.slice(-1) : lastGraphemeUnits(fitting);
|
||||
: pieceAscii ? fitting.slice(-1) : fitting;
|
||||
openGraphemeUnresolved = !!fitting && !pieceAscii;
|
||||
openGraphemeCell = cursorCell;
|
||||
}
|
||||
cursor = appending ? current.length
|
||||
|
||||
@@ -1000,7 +1000,14 @@ export const attachSessionToTerminal = (
|
||||
try {
|
||||
await ctx.prepareKeywordHighlightSerialization?.();
|
||||
const terminalData = ctx.serializeAddonRef.current.serialize();
|
||||
ctx.onTerminalDataCapture(ctx.sessionId, terminalData);
|
||||
const pendingCapture: unknown = ctx.onTerminalDataCapture(ctx.sessionId, terminalData);
|
||||
if (
|
||||
pendingCapture !== undefined
|
||||
&& pendingCapture !== null
|
||||
&& typeof (pendingCapture as Promise<void>).then === "function"
|
||||
) {
|
||||
await (pendingCapture as Promise<void>);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn("Failed to serialize terminal data:", err);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,11 @@ export interface TerminalBroadcastInputOptions {
|
||||
* the dispatcher tags the payload sourceSensitive.
|
||||
*/
|
||||
sensitive?: boolean;
|
||||
/**
|
||||
* Compose-bar send-to-all for orphan sessions while the toolbar broadcast
|
||||
* switch stays off. Keystroke fan-out never sets this.
|
||||
*/
|
||||
composePeerFanOut?: boolean;
|
||||
/**
|
||||
* Observer for the sessions that actually received the payload (#3491):
|
||||
* the solo compose bar uses it to detect a fan-out delivered into a peer's
|
||||
@@ -198,7 +203,7 @@ export interface TerminalProps {
|
||||
onTerminalFontSizeChange?: (fontSize: number) => void;
|
||||
onStatusChange?: (sessionId: string, status: TerminalSession["status"], sftpHost?: Host) => void;
|
||||
onSessionExit?: (sessionId: string, evt: TerminalSessionExitEvent) => void;
|
||||
onTerminalDataCapture?: (sessionId: string, data: string) => void;
|
||||
onTerminalDataCapture?: (sessionId: string, data: string) => void | Promise<void>;
|
||||
onOsDetected?: (hostId: string, distro: string) => void;
|
||||
onCloseSession?: (sessionId: string) => void;
|
||||
onUpdateHost?: (host: Host) => void;
|
||||
@@ -236,6 +241,9 @@ export interface TerminalProps {
|
||||
onOpenTheme?: () => void;
|
||||
onOpenSystem?: () => void;
|
||||
isBroadcastEnabled?: boolean;
|
||||
/** Compose-bar send target for solo sessions. Workspace compose lives on the layer. */
|
||||
composeSendToAll?: boolean;
|
||||
onToggleComposeSendTarget?: () => void;
|
||||
onToggleBroadcast?: () => void;
|
||||
onToggleComposeBar?: () => void;
|
||||
isWorkspaceComposeBarOpen?: boolean;
|
||||
|
||||
@@ -54,6 +54,8 @@ export const terminalPropsAreEqual = (
|
||||
&& prev.disableTerminalFontZoom === next.disableTerminalFontZoom
|
||||
&& prev.keyBindings === next.keyBindings
|
||||
&& prev.isBroadcastEnabled === next.isBroadcastEnabled
|
||||
&& prev.composeSendToAll === next.composeSendToAll
|
||||
&& prev.onToggleComposeSendTarget === next.onToggleComposeSendTarget
|
||||
&& prev.isWorkspaceComposeBarOpen === next.isWorkspaceComposeBarOpen
|
||||
&& prev.sessionLog === next.sessionLog
|
||||
&& prev.sshDebugLogEnabled === next.sshDebugLogEnabled
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps */
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { useTerminalEffects } from './useTerminalEffects';
|
||||
import { TERMINAL_SESSION_RESTORE_FOCUS_EVENT } from './focusTerminalSession';
|
||||
|
||||
// Exercise the real hook's reveal and settling effects. The injected effect
|
||||
// hooks omit unrelated backend/auth/font setup; no recovery logic is copied.
|
||||
function visibilityEffect(effect: React.EffectCallback) {
|
||||
const body = effect.toString();
|
||||
return body.includes('const becameVisible')
|
||||
|| (/hiddenAtRef\.current\s*=\s*Date\.now\(\)/.test(body))
|
||||
|| ((/hiddenAtRef\.current\s*=\s*null/.test(body)) && body.includes('const timer'))
|
||||
|| (body.includes('const timer1') && body.includes('const timer2'))
|
||||
|| body.includes('const handleRestoreFocus')
|
||||
|| body.includes('const recoverTerminalOnAppResume');
|
||||
}
|
||||
|
||||
function harness(t: any, initiallyHasRuntime = true) {
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let now = 10_000;
|
||||
let nextId = 0;
|
||||
const timers = new Map<number, { at: number; run: () => void }>();
|
||||
const schedule = (run: () => void, delay = 0) => {
|
||||
const id = ++nextId;
|
||||
timers.set(id, { at: now + delay, run });
|
||||
return id;
|
||||
};
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
t.mock.method(globalThis, 'setTimeout', schedule);
|
||||
t.mock.method(globalThis, 'clearTimeout', (id: number) => timers.delete(id));
|
||||
const win = new EventTarget();
|
||||
const doc = Object.assign(new EventTarget(), { visibilityState: 'visible', querySelector: () => null });
|
||||
let fullscreen: ((value: boolean) => void) | undefined;
|
||||
let renderer: ReactTestRenderer;
|
||||
const documentBefore = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: doc });
|
||||
const windowBefore = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: Object.assign(win, { setTimeout: schedule }) });
|
||||
t.after(() => {
|
||||
if (renderer) act(() => renderer.unmount());
|
||||
if (documentBefore) Object.defineProperty(globalThis, 'document', documentBefore);
|
||||
else delete (globalThis as any).document;
|
||||
if (windowBefore) Object.defineProperty(globalThis, 'window', windowBefore);
|
||||
else delete (globalThis as any).window;
|
||||
});
|
||||
const fitTimes: number[] = [];
|
||||
const calls = { ensure: 0, clear: 0, fit: 0, forcedFit: 0, render: 0, focus: 0 };
|
||||
const term = { options: {}, focus() { calls.focus++; }, rows: 24, cols: 80, buffer: { active: { type: 'normal' } }, scrollToBottom() {}, _core: { _renderService: { _renderRows() { calls.render++; } } } };
|
||||
const runtime = { ensureWebglRenderer() { calls.ensure++; }, clearTextureAtlas() { calls.clear++; } };
|
||||
const values: Record<string, any> = {
|
||||
host: { id: 'host', protocol: 'ssh' }, auth: {}, zmodem: {}, effectiveTheme: { id: 'theme', colors: {} },
|
||||
terminalSettings: {}, terminalSettingsRef: { current: {} },
|
||||
terminalBackend: { onWindowFullScreenChanged(callback: (value: boolean) => void) { fullscreen = callback; return () => { fullscreen = undefined; }; } },
|
||||
pluginDecorationRules: [], sessionId: 'session', paneLayoutKey: 'layout',
|
||||
isVisible: false, isVisibleRef: { current: false }, splitResizeActive: false, inWorkspace: false,
|
||||
termRef: { current: initiallyHasRuntime ? term : null }, xtermRuntimeRef: { current: initiallyHasRuntime ? runtime : null },
|
||||
fitAddonRef: { current: {} }, sessionRef: { current: 'pty' },
|
||||
containerRef: { current: { clientWidth: 800, clientHeight: 480 } },
|
||||
lastFittedSizeRef: { current: { width: 800, height: 480 } }, pendingOutputScrollRef: { current: false },
|
||||
safeFit(options: any) {
|
||||
calls.fit++;
|
||||
fitTimes.push(now);
|
||||
if (options?.force) calls.forcedFit++;
|
||||
const container = values.containerRef.current;
|
||||
values.lastFittedSizeRef.current = { width: container.clientWidth, height: container.clientHeight };
|
||||
},
|
||||
forceSyncRenderAfterResize() { calls.render++; }, resizeSession() {},
|
||||
useEffect(effect: React.EffectCallback, deps: React.DependencyList) {
|
||||
React.useEffect(visibilityEffect(effect) ? effect : () => {}, deps);
|
||||
},
|
||||
useLayoutEffect(effect: React.EffectCallback, deps: React.DependencyList) {
|
||||
React.useLayoutEffect(visibilityEffect(effect) ? effect : () => {}, deps);
|
||||
},
|
||||
};
|
||||
const ctx = new Proxy(values, { get(target, key: string) {
|
||||
if (!(key in target)) target[key] = key.endsWith('Ref') ? { current: null } : (() => {});
|
||||
return target[key];
|
||||
} });
|
||||
function Component() { useTerminalEffects(ctx); return null; }
|
||||
act(() => { renderer = create(React.createElement(Component)); });
|
||||
function advance(ms: number) {
|
||||
const end = now + ms;
|
||||
while (true) {
|
||||
const next = [...timers.entries()].filter(([, item]) => item.at <= end).sort((a, b) => a[1].at - b[1].at)[0];
|
||||
if (!next) break;
|
||||
now = next[1].at;
|
||||
timers.delete(next[0]);
|
||||
act(() => next[1].run());
|
||||
}
|
||||
now = end;
|
||||
}
|
||||
return { calls, fitTimes, advance, values,
|
||||
focus() { const event = Object.assign(new Event(TERMINAL_SESSION_RESTORE_FOCUS_EVENT), { detail: { sessionId: 'session' } }); act(() => { win.dispatchEvent(event); }); },
|
||||
resume() { act(() => { doc.dispatchEvent(new Event('visibilitychange')); }); },
|
||||
fullscreen() { act(() => fullscreen?.(true)); },
|
||||
reveal() { values.isVisible = true; values.isVisibleRef.current = true; act(() => renderer.update(React.createElement(Component))); },
|
||||
publishRuntime() { values.termRef.current = term; values.xtermRuntimeRef.current = runtime; },
|
||||
};
|
||||
}
|
||||
|
||||
for (const hiddenMs of [2000, 4000]) {
|
||||
test(`unchanged reveal after ${hiddenMs}ms does not repeat recovery in settling timers`, t => {
|
||||
const h = harness(t);
|
||||
h.advance(hiddenMs);
|
||||
h.reveal();
|
||||
assert.equal(h.calls.clear, hiddenMs >= 3000 ? 1 : 0);
|
||||
h.advance(600);
|
||||
assert.equal(h.calls.clear, hiddenMs >= 3000 ? 1 : 0);
|
||||
assert.equal(h.calls.fit, 0);
|
||||
});
|
||||
}
|
||||
|
||||
test('late changed bounds still receive a settling fit', t => {
|
||||
const h = harness(t);
|
||||
h.advance(4000); h.reveal(); h.advance(200);
|
||||
h.values.containerRef.current.clientHeight = 460;
|
||||
h.advance(400);
|
||||
assert.equal(h.calls.fit, 1);
|
||||
assert.equal(h.calls.forcedFit, 0);
|
||||
assert.deepEqual(h.values.lastFittedSizeRef.current, { width: 800, height: 460 });
|
||||
});
|
||||
|
||||
test('runtime published after reveal still receives delayed recovery', t => {
|
||||
const h = harness(t, false);
|
||||
h.advance(4000); h.reveal();
|
||||
assert.equal(h.calls.clear, 0);
|
||||
h.advance(25); h.publishRuntime(); h.advance(575);
|
||||
assert.equal(h.calls.ensure, 1);
|
||||
assert.equal(h.calls.clear, 1);
|
||||
assert.equal(h.calls.forcedFit, 1);
|
||||
});
|
||||
|
||||
|
||||
test('focus restoration and its 50ms retry do not refit unchanged bounds', t => {
|
||||
const h = harness(t);
|
||||
h.advance(4000); h.reveal(); h.advance(600);
|
||||
h.focus(); h.advance(50); h.focus(); h.advance(500);
|
||||
assert.equal(h.calls.focus, 2);
|
||||
assert.equal(h.calls.fit, 0);
|
||||
assert.equal(h.calls.clear, 1);
|
||||
});
|
||||
|
||||
test('focus restoration still recovers changed bounds', t => {
|
||||
const h = harness(t);
|
||||
h.advance(4000); h.reveal(); h.advance(600);
|
||||
h.values.containerRef.current.clientWidth = 780;
|
||||
h.focus(); h.advance(50); h.focus(); h.advance(100);
|
||||
assert.equal(h.calls.focus, 2);
|
||||
assert.equal(h.calls.forcedFit, 2);
|
||||
assert.deepEqual(h.values.lastFittedSizeRef.current, { width: 780, height: 480 });
|
||||
});
|
||||
|
||||
for (const source of ['resume', 'fullscreen'] as const) {
|
||||
test(`fitted focus retries preserve pending ${source} recovery timers`, t => {
|
||||
const h = harness(t);
|
||||
h.advance(4000); h.reveal(); h.advance(600);
|
||||
h[source](); h.advance(0);
|
||||
assert.equal(h.calls.forcedFit, 1);
|
||||
h.focus(); h.advance(50); h.focus(); h.advance(500);
|
||||
assert.equal(h.calls.focus, 2);
|
||||
assert.equal(h.calls.forcedFit, 3, 'all three explicit recovery passes must survive focus retries');
|
||||
assert.deepEqual(h.fitTimes.map(at => at - h.fitTimes[0]), source === 'resume' ? [0, 100, 300] : [0, 150, 400]);
|
||||
});
|
||||
}
|
||||
|
||||
for (const source of ['resume', 'fullscreen'] as const) {
|
||||
test(`focus during changed bounds preserves pending ${source} settling passes`, t => {
|
||||
const h = harness(t);
|
||||
h.advance(4000); h.reveal(); h.advance(600);
|
||||
h[source](); h.advance(0);
|
||||
h.advance(50);
|
||||
h.values.containerRef.current.clientWidth = 780;
|
||||
h.focus(); h.advance(500);
|
||||
assert.equal(h.calls.focus, 1);
|
||||
assert.equal(h.calls.forcedFit, 3);
|
||||
assert.deepEqual(h.fitTimes.map(at => at - h.fitTimes[0]), source === 'resume' ? [0, 100, 300] : [0, 150, 400]);
|
||||
assert.equal(h.values.lastFittedSizeRef.current.width, 780);
|
||||
});
|
||||
}
|
||||
@@ -115,6 +115,27 @@ test('immediate tab recovery marks webgl recovery to skip the delayed duplicate
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/lastWebglRecoveryLayoutKeyRef\.current === paneLayoutKey\s*&& hiddenMs < CSS_ONLY_TAB_REVEAL_MAX_HIDDEN_MS/,
|
||||
/if \(lastWebglRecoveryLayoutKeyRef\.current === paneLayoutKey\) \{/,
|
||||
);
|
||||
});
|
||||
|
||||
test('reveal does not claim recovery before the terminal runtime exists', () => {
|
||||
const start = source.indexOf('const recoverTerminalAfterBecomeVisible = () => {');
|
||||
const end = source.indexOf('// Refit synchronously', start);
|
||||
const recovery = source.slice(start, end);
|
||||
const runtimeGuard = recovery.indexOf('if (!termRef.current || !xtermRuntimeRef.current) return;');
|
||||
const commit = recovery.indexOf('lastWebglRecoveryLayoutKeyRef.current = paneLayoutKey');
|
||||
assert.ok(runtimeGuard >= 0 && runtimeGuard < commit);
|
||||
});
|
||||
|
||||
test('settling tab-layout probes do not force a fit when bounds are unchanged', () => {
|
||||
const start = source.indexOf('const refitSettledLayout = () => {');
|
||||
const end = source.indexOf('}, [inWorkspace, isVisible]);', start);
|
||||
const settling = source.slice(start, end);
|
||||
assert.ok(start >= 0 && end > start);
|
||||
assert.match(settling, /if \(!isVisibleRef\.current \|\| currentContainerSizeAlreadyFit\(\)\) return;/);
|
||||
assert.match(settling, /safeFit\(\{ requireVisible: true \}\);/);
|
||||
assert.doesNotMatch(settling, /force: true/);
|
||||
assert.match(settling, /setTimeout\(refitSettledLayout, 100\)/);
|
||||
assert.match(settling, /setTimeout\(refitSettledLayout, 350\)/);
|
||||
});
|
||||
@@ -917,8 +917,14 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
bootAbort.abort();
|
||||
isBootActiveRef.current = false;
|
||||
if (hibernatedRef?.current) {
|
||||
forceCloseHibernatedSession?.();
|
||||
disposeOwnedRuntime();
|
||||
const pendingClose = forceCloseHibernatedSession?.() as void | Promise<void>;
|
||||
if (pendingClose && typeof pendingClose.then === "function") {
|
||||
void pendingClose.finally(() => {
|
||||
disposeOwnedRuntime();
|
||||
});
|
||||
} else {
|
||||
disposeOwnedRuntime();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -946,13 +952,20 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
const persistCloseCapture = (data: string, source: string, dataLength: number) => {
|
||||
const persistCloseCapture = async (data: string, source: string, dataLength: number) => {
|
||||
logger.info("[Terminal] Capturing data on unmount", {
|
||||
sessionId,
|
||||
source,
|
||||
dataLength,
|
||||
});
|
||||
handleTerminalDataCaptureOnce(sessionId, data, { finalized: true });
|
||||
const pendingCapture: unknown = handleTerminalDataCaptureOnce(sessionId, data, { finalized: true });
|
||||
if (
|
||||
pendingCapture !== undefined
|
||||
&& pendingCapture !== null
|
||||
&& typeof (pendingCapture as Promise<void>).then === "function"
|
||||
) {
|
||||
await (pendingCapture as Promise<void>);
|
||||
}
|
||||
};
|
||||
|
||||
const term = termRef.current;
|
||||
@@ -991,7 +1004,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
? resolveConnectionLogCapturePayload(finalizeTerminalLogData)
|
||||
: null;
|
||||
if (connectionLogPayload) {
|
||||
persistCloseCapture(
|
||||
await persistCloseCapture(
|
||||
connectionLogPayload.data,
|
||||
connectionLogPayload.source,
|
||||
connectionLogPayload.data.length,
|
||||
@@ -1018,7 +1031,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
return;
|
||||
}
|
||||
if (payload) {
|
||||
persistCloseCapture(payload.data, payload.source, payload.data.length);
|
||||
await persistCloseCapture(payload.data, payload.source, payload.data.length);
|
||||
}
|
||||
ownedRuntime = null;
|
||||
scheduleTerminalCloseTeardown(teardown);
|
||||
@@ -1317,7 +1330,12 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
applyUserCursorBlinkPreference(term, terminalSettingsRef.current);
|
||||
if (hasOpenAppDialog()) return;
|
||||
term.focus();
|
||||
scheduleLayoutRecoveryRefit([0, 100]);
|
||||
// Tab focus retries do not imply a lost renderer or a changed layout.
|
||||
// Leave explicit app-resume/fullscreen recovery timers untouched when
|
||||
// these bounds are already fitted, or recovery is still settling.
|
||||
if (!currentContainerSizeAlreadyFit() && layoutRecoveryTimersRef.current.length === 0) {
|
||||
scheduleLayoutRecoveryRefit([0, 100]);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener(TERMINAL_SESSION_RESTORE_FOCUS_EVENT, handleRestoreFocus);
|
||||
@@ -1387,6 +1405,9 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
|
||||
const recoverTerminalAfterBecomeVisible = () => {
|
||||
flushTerminalWritesAfterBecomeVisible();
|
||||
// A hidden pane can become visible before its asynchronous boot completes.
|
||||
// Do not claim recovery until there is a runtime for the delayed pass to use.
|
||||
if (!termRef.current || !xtermRuntimeRef.current) return;
|
||||
|
||||
if (
|
||||
getHiddenDurationMs() < CSS_ONLY_TAB_REVEAL_MAX_HIDDEN_MS
|
||||
@@ -1463,20 +1484,17 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
|
||||
const hiddenMs = hiddenAtRef.current
|
||||
? Date.now() - hiddenAtRef.current
|
||||
: Number.POSITIVE_INFINITY;
|
||||
hiddenAtRef.current = null;
|
||||
|
||||
if (
|
||||
lastWebglRecoveryLayoutKeyRef.current === paneLayoutKey
|
||||
&& hiddenMs < CSS_ONLY_TAB_REVEAL_MAX_HIDDEN_MS
|
||||
) {
|
||||
// The layout effect already recovers long hides as well as short ones.
|
||||
// Repeating it here discards the freshly populated, potentially shared atlas.
|
||||
if (lastWebglRecoveryLayoutKeyRef.current === paneLayoutKey) {
|
||||
flushPendingOutputScroll();
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (!isVisibleRef.current || !termRef.current || !xtermRuntimeRef.current) return;
|
||||
lastWebglRecoveryLayoutKeyRef.current = paneLayoutKey;
|
||||
// A pane that mounted hidden deferred its WebGL renderer; create it now
|
||||
// that it's visible (no-op if already active or WebGL is disabled).
|
||||
@@ -1687,14 +1705,16 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
// Fit twice: once after initial layout (100ms) and again after layout settles
|
||||
// (350ms) to handle race conditions during split operations where the container
|
||||
// dimensions may not be final on the first pass.
|
||||
const timer1 = setTimeout(() => {
|
||||
const refitSettledLayout = () => {
|
||||
// Keep checking for late split/layout changes, but an unchanged tab does
|
||||
// not need another forced fit (which clears its glyph atlas even when
|
||||
// the row/column count is unchanged).
|
||||
if (!isVisibleRef.current || currentContainerSizeAlreadyFit()) return;
|
||||
safeFit({ requireVisible: true });
|
||||
finishLayoutRecoveryAfterFit();
|
||||
}, 100);
|
||||
const timer2 = setTimeout(() => {
|
||||
safeFit({ force: true, requireVisible: true });
|
||||
finishLayoutRecoveryAfterFit();
|
||||
}, 350);
|
||||
};
|
||||
const timer1 = setTimeout(refitSettledLayout, 100);
|
||||
const timer2 = setTimeout(refitSettledLayout, 350);
|
||||
return () => { clearTimeout(timer1); clearTimeout(timer2); };
|
||||
}, [inWorkspace, isVisible]);
|
||||
|
||||
|
||||
@@ -696,7 +696,7 @@ export interface TerminalLayerProps {
|
||||
onCommandExecuted?: (command: string, hostId: string, hostLabel: string, sessionId: string) => void;
|
||||
onDeleteShellHistoryEntry?: (entryId: string) => void;
|
||||
onClearCommandHistory?: () => void;
|
||||
onTerminalDataCapture?: (sessionId: string, data: string) => void;
|
||||
onTerminalDataCapture?: (sessionId: string, data: string) => void | Promise<void>;
|
||||
onCreateWorkspaceFromSessions: (baseSessionId: string, joiningSessionId: string, hint: Exclude<SplitHint, null>) => void;
|
||||
onAddSessionToWorkspace: (workspaceId: string, sessionId: string, hint: Exclude<SplitHint, null>) => void;
|
||||
onRequestAddToWorkspace?: (workspaceId: string) => void;
|
||||
@@ -719,10 +719,14 @@ export interface TerminalLayerProps {
|
||||
onCreateLocalTerminal?: () => void;
|
||||
// Broadcast mode
|
||||
isBroadcastEnabled?: (workspaceId: string) => boolean;
|
||||
isComposeSendToAll?: (workspaceId: string) => boolean;
|
||||
onToggleComposeSendTarget?: (workspaceId: string) => void;
|
||||
isGlobalBroadcastEnabled?: boolean;
|
||||
isGlobalComposeSendToAll?: boolean;
|
||||
canUseGlobalBroadcast?: boolean;
|
||||
onToggleBroadcast?: (workspaceId: string) => void;
|
||||
onToggleGlobalBroadcast?: () => void;
|
||||
onToggleGlobalComposeSendTarget?: () => void;
|
||||
// SFTP side panel
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
updateSnippets?: (snippets: Snippet[]) => void;
|
||||
@@ -815,7 +819,7 @@ interface TerminalPaneProps {
|
||||
onCloseSession: (sessionId: string) => void;
|
||||
onStatusChange: (sessionId: string, status: TerminalSession['status'], sftpHost?: Host) => void;
|
||||
onSessionExit: (sessionId: string, evt: TerminalSessionExitEvent) => void;
|
||||
onTerminalDataCapture?: (sessionId: string, data: string) => void;
|
||||
onTerminalDataCapture?: (sessionId: string, data: string) => void | Promise<void>;
|
||||
onOsDetected: (hostId: string, distro: string) => void;
|
||||
onUpdateHost: (host: Host) => void;
|
||||
onAddKnownHost?: (knownHost: KnownHost) => void;
|
||||
@@ -824,9 +828,12 @@ interface TerminalPaneProps {
|
||||
onSetWorkspaceFocusedSession?: (workspaceId: string, sessionId: string) => void;
|
||||
onSplitSession?: (sessionId: string, direction: SplitDirection) => void;
|
||||
isBroadcastEnabled?: (workspaceId: string) => boolean;
|
||||
isComposeSendToAll?: (workspaceId: string) => boolean;
|
||||
isGlobalBroadcastEnabled?: boolean;
|
||||
isGlobalComposeSendToAll?: boolean;
|
||||
canUseGlobalBroadcast?: boolean;
|
||||
onToggleGlobalBroadcast?: () => void;
|
||||
onToggleGlobalComposeSendTarget?: () => void;
|
||||
onBroadcastInput: (
|
||||
data: string,
|
||||
sourceSessionId: string,
|
||||
@@ -952,9 +959,12 @@ const terminalPanePropsAreEqual = (
|
||||
prev.onSetWorkspaceFocusedSession === next.onSetWorkspaceFocusedSession &&
|
||||
prev.onSplitSession === next.onSplitSession &&
|
||||
prev.isBroadcastEnabled === next.isBroadcastEnabled &&
|
||||
prev.isComposeSendToAll === next.isComposeSendToAll &&
|
||||
prev.isGlobalBroadcastEnabled === next.isGlobalBroadcastEnabled &&
|
||||
prev.isGlobalComposeSendToAll === next.isGlobalComposeSendToAll &&
|
||||
prev.canUseGlobalBroadcast === next.canUseGlobalBroadcast &&
|
||||
prev.onToggleGlobalBroadcast === next.onToggleGlobalBroadcast &&
|
||||
prev.onToggleGlobalComposeSendTarget === next.onToggleGlobalComposeSendTarget &&
|
||||
prev.onBroadcastInput === next.onBroadcastInput &&
|
||||
prev.onBroadcastInterruptPriorityChange === next.onBroadcastInterruptPriorityChange &&
|
||||
prev.onToggleWorkspaceComposeBar === next.onToggleWorkspaceComposeBar &&
|
||||
@@ -1253,9 +1263,12 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
||||
onSetWorkspaceFocusedSession,
|
||||
onSplitSession,
|
||||
isBroadcastEnabled,
|
||||
isComposeSendToAll,
|
||||
isGlobalBroadcastEnabled,
|
||||
isGlobalComposeSendToAll,
|
||||
canUseGlobalBroadcast,
|
||||
onToggleGlobalBroadcast,
|
||||
onToggleGlobalComposeSendTarget,
|
||||
onBroadcastInput,
|
||||
onBroadcastInterruptPriorityChange,
|
||||
onToggleWorkspaceComposeBar,
|
||||
@@ -1485,6 +1498,12 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
||||
const broadcastEnabled = activeWorkspaceId
|
||||
? !!isBroadcastEnabled?.(activeWorkspaceId)
|
||||
: (isGlobalBroadcastEnabled ?? false);
|
||||
const composeSendToAll = activeWorkspaceId
|
||||
? !!isComposeSendToAll?.(activeWorkspaceId)
|
||||
: !!isGlobalComposeSendToAll;
|
||||
const toggleComposeSendTarget = !activeWorkspaceId && canUseGlobalBroadcast
|
||||
? onToggleGlobalComposeSendTarget
|
||||
: undefined;
|
||||
const isHostEphemeral = !isSavedVaultHost(hostMap.get(host.id));
|
||||
const sessionAppearance = useMemo(
|
||||
() => resolveSessionAppearance({ host, isEphemeral: isHostEphemeral }),
|
||||
@@ -1630,6 +1649,8 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
||||
onSplitHorizontal={onSplitSession ? splitHorizontalHandler : undefined}
|
||||
onSplitVertical={onSplitSession ? splitVerticalHandler : undefined}
|
||||
isBroadcastEnabled={broadcastEnabled}
|
||||
composeSendToAll={inActiveWorkspace ? undefined : composeSendToAll}
|
||||
onToggleComposeSendTarget={toggleComposeSendTarget}
|
||||
onToggleBroadcast={
|
||||
inActiveWorkspace
|
||||
? workspaceBroadcastHandler
|
||||
@@ -1637,7 +1658,7 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
||||
}
|
||||
onToggleComposeBar={inActiveWorkspace ? onToggleWorkspaceComposeBar : undefined}
|
||||
isWorkspaceComposeBarOpen={inActiveWorkspace ? isComposeBarOpen : undefined}
|
||||
onBroadcastInput={broadcastEnabled ? onBroadcastInput : undefined}
|
||||
onBroadcastInput={onBroadcastInput}
|
||||
onBroadcastInterruptPriorityChange={onBroadcastInterruptPriorityChange}
|
||||
onSnippetExecutorChange={onSnippetExecutorChange}
|
||||
onProgrammaticCommandLogRewriteChange={onProgrammaticCommandLogRewriteChange}
|
||||
@@ -1717,7 +1738,7 @@ interface TerminalPanesHostProps {
|
||||
onCloseSession: (sessionId: string) => void;
|
||||
onStatusChange: (sessionId: string, status: TerminalSession['status'], sftpHost?: Host) => void;
|
||||
onSessionExit: (sessionId: string, evt: TerminalSessionExitEvent) => void;
|
||||
onTerminalDataCapture?: (sessionId: string, data: string) => void;
|
||||
onTerminalDataCapture?: (sessionId: string, data: string) => void | Promise<void>;
|
||||
onOsDetected: (hostId: string, distro: string) => void;
|
||||
onUpdateHost: (host: Host) => void;
|
||||
onAddKnownHost?: (knownHost: KnownHost) => void;
|
||||
@@ -1726,9 +1747,12 @@ interface TerminalPanesHostProps {
|
||||
onSetWorkspaceFocusedSession?: (workspaceId: string, sessionId: string) => void;
|
||||
onSplitSession?: (sessionId: string, direction: SplitDirection) => void;
|
||||
isBroadcastEnabled?: (workspaceId: string) => boolean;
|
||||
isComposeSendToAll?: (workspaceId: string) => boolean;
|
||||
isGlobalBroadcastEnabled?: boolean;
|
||||
isGlobalComposeSendToAll?: boolean;
|
||||
canUseGlobalBroadcast?: boolean;
|
||||
onToggleGlobalBroadcast?: () => void;
|
||||
onToggleGlobalComposeSendTarget?: () => void;
|
||||
onBroadcastInput: (
|
||||
data: string,
|
||||
sourceSessionId: string,
|
||||
@@ -1816,9 +1840,12 @@ const terminalPanesHostPropsAreEqual = (
|
||||
if (prev.onSetWorkspaceFocusedSession !== next.onSetWorkspaceFocusedSession) return false;
|
||||
if (prev.onSplitSession !== next.onSplitSession) return false;
|
||||
if (prev.isBroadcastEnabled !== next.isBroadcastEnabled) return false;
|
||||
if (prev.isComposeSendToAll !== next.isComposeSendToAll) return false;
|
||||
if (prev.isGlobalBroadcastEnabled !== next.isGlobalBroadcastEnabled) return false;
|
||||
if (prev.isGlobalComposeSendToAll !== next.isGlobalComposeSendToAll) return false;
|
||||
if (prev.canUseGlobalBroadcast !== next.canUseGlobalBroadcast) return false;
|
||||
if (prev.onToggleGlobalBroadcast !== next.onToggleGlobalBroadcast) return false;
|
||||
if (prev.onToggleGlobalComposeSendTarget !== next.onToggleGlobalComposeSendTarget) return false;
|
||||
if (prev.onBroadcastInput !== next.onBroadcastInput) return false;
|
||||
if (prev.onBroadcastInterruptPriorityChange !== next.onBroadcastInterruptPriorityChange) return false;
|
||||
if (prev.onToggleWorkspaceComposeBar !== next.onToggleWorkspaceComposeBar) return false;
|
||||
|
||||
@@ -547,6 +547,8 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
restoreTerminalCwd: s.restoreTerminalCwd,
|
||||
identities: s.identities,
|
||||
isBroadcastEnabled: s.isBroadcastEnabled,
|
||||
isComposeSendToAll: s.isComposeSendToAll,
|
||||
onToggleComposeSendTarget: s.onToggleComposeSendTarget,
|
||||
isComposeBarOpen: s.isComposeBarOpen,
|
||||
isFocusMode,
|
||||
isSidePanelOpenForCurrentTab,
|
||||
@@ -659,6 +661,8 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
workspaceBroadcastHandlersRef: s.workspaceBroadcastHandlersRef,
|
||||
workspaceById,
|
||||
isGlobalBroadcastEnabled: s.isGlobalBroadcastEnabled,
|
||||
isGlobalComposeSendToAll: s.isGlobalComposeSendToAll,
|
||||
onToggleGlobalComposeSendTarget: s.onToggleGlobalComposeSendTarget,
|
||||
canUseGlobalBroadcast: s.canUseGlobalBroadcast,
|
||||
onToggleGlobalBroadcast: s.onToggleGlobalBroadcastRef.current,
|
||||
// AI scope maintenance (merge/dissolve handoff) needs the full list; do not
|
||||
|
||||
@@ -101,6 +101,8 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
|
||||
onSetWorkspaceFocusedSession,
|
||||
onSplitSession,
|
||||
isBroadcastEnabled,
|
||||
isComposeSendToAll,
|
||||
onToggleComposeSendTarget,
|
||||
handleBroadcastInput,
|
||||
handleBroadcastInterruptPriorityChange,
|
||||
handleToggleWorkspaceComposeBar,
|
||||
@@ -124,8 +126,10 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
|
||||
onStartSessionDrag,
|
||||
onEndSessionDrag,
|
||||
isGlobalBroadcastEnabled,
|
||||
isGlobalComposeSendToAll,
|
||||
canUseGlobalBroadcast,
|
||||
onToggleGlobalBroadcast,
|
||||
onToggleGlobalComposeSendTarget,
|
||||
t,
|
||||
} = ctx;
|
||||
|
||||
@@ -247,6 +251,9 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
|
||||
onSetWorkspaceFocusedSession={onSetWorkspaceFocusedSession}
|
||||
onSplitSession={onSplitSession}
|
||||
isBroadcastEnabled={isBroadcastEnabled}
|
||||
isComposeSendToAll={isComposeSendToAll}
|
||||
isGlobalComposeSendToAll={isGlobalComposeSendToAll}
|
||||
onToggleGlobalComposeSendTarget={onToggleGlobalComposeSendTarget}
|
||||
onBroadcastInput={handleBroadcastInput}
|
||||
onBroadcastInterruptPriorityChange={handleBroadcastInterruptPriorityChange}
|
||||
onToggleWorkspaceComposeBar={handleToggleWorkspaceComposeBar}
|
||||
@@ -349,6 +356,10 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
|
||||
refocusTerminalSession(focusedSessionId);
|
||||
}}
|
||||
isBroadcastEnabled={isBroadcastEnabled?.(activeWorkspace.id)}
|
||||
composeSendToAll={isComposeSendToAll?.(activeWorkspace.id) ?? false}
|
||||
onToggleComposeSendTarget={onToggleComposeSendTarget
|
||||
? () => onToggleComposeSendTarget(activeWorkspace.id)
|
||||
: undefined}
|
||||
themeColors={composeBarThemeColors}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -23,6 +23,7 @@ function setup(
|
||||
sensitive = new Set<string>(),
|
||||
broadcastPasswordBypass = false,
|
||||
restoredDisconnected = new Set<string>(),
|
||||
composeSendToAll?: boolean,
|
||||
) {
|
||||
const writes: string[] = [];
|
||||
const context = {
|
||||
@@ -34,6 +35,7 @@ function setup(
|
||||
restoreState: restoredDisconnected.has(String(index)) ? 'restored-disconnected' : undefined,
|
||||
})) },
|
||||
isBroadcastEnabled: () => broadcast,
|
||||
isComposeSendToAll: composeSendToAll === undefined ? undefined : () => composeSendToAll,
|
||||
isTerminalSensitiveInputActive: (id: string) => sensitive.has(id),
|
||||
broadcastPasswordBypassRef: { current: broadcastPasswordBypass },
|
||||
snippetExecutorsRef: { current: executors },
|
||||
@@ -60,6 +62,53 @@ for (const broadcast of [false, true]) {
|
||||
}
|
||||
}
|
||||
|
||||
test('compose active-only sends to the focused session while toolbar broadcast stays on', async () => {
|
||||
const { send, writes } = setup(
|
||||
['connected', 'connected'], true, new Map(), new Set(), false, new Set(), false,
|
||||
);
|
||||
assert.equal(await send('command'), true);
|
||||
assert.deepEqual(writes, ['0']);
|
||||
});
|
||||
|
||||
test('compose active-only does not run peer executors while toolbar broadcast stays on', async () => {
|
||||
const calls: Array<{ id: string; broadcast: boolean | undefined }> = [];
|
||||
const executors = new Map<string, Executor>([
|
||||
['0', async (_text, _immediate, options) => {
|
||||
calls.push({ id: '0', broadcast: options?.broadcast });
|
||||
return true;
|
||||
}],
|
||||
['1', async () => { calls.push({ id: '1', broadcast: undefined }); return true; }],
|
||||
]);
|
||||
assert.equal(
|
||||
await setup(
|
||||
['connected', 'connected'], true, executors, new Set(), false, new Set(), false,
|
||||
).send('command'),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(calls, [{ id: '0', broadcast: false }]);
|
||||
});
|
||||
|
||||
test('compose send-to-all fans out while toolbar broadcast stays off', async () => {
|
||||
const { send, writes } = setup(
|
||||
['connected', 'connected'], false, new Map(), new Set(), false, new Set(), true,
|
||||
);
|
||||
assert.equal(await send('command'), true);
|
||||
assert.deepEqual(writes, ['0', '1']);
|
||||
});
|
||||
|
||||
test('compose send-to-all keeps a bypassed sensitive peer out of history', async () => {
|
||||
const executors = new Map<string, Executor>([
|
||||
['0', async () => true],
|
||||
['1', async () => true],
|
||||
]);
|
||||
assert.equal(
|
||||
await setup(
|
||||
['connected', 'connected'], false, executors, new Set(['1']), true, new Set(), true,
|
||||
).send('secret'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('a disconnected broadcast peer cannot undo another successful fallback', async () => {
|
||||
assert.equal(await setup(['connected', 'disconnected'], true).send('command'), true);
|
||||
});
|
||||
@@ -169,18 +218,43 @@ for (const result of [true, false, Promise.resolve(true), Promise.resolve(false)
|
||||
sessionId: 'a', sessionRef: { current: 'a' },
|
||||
executeSnippetCommand: () => result,
|
||||
isTerminalSensitiveInputActive: () => false,
|
||||
composeSendToAll: undefined as boolean | undefined,
|
||||
isBroadcastEnabled: false,
|
||||
};
|
||||
runInNewContext(soloCode, context);
|
||||
assert.equal(await context.send('command'), await result);
|
||||
});
|
||||
}
|
||||
|
||||
test('solo compose send uses the compose-bar target while toolbar broadcast stays on', async () => {
|
||||
const calls: Array<boolean | undefined> = [];
|
||||
const context = {
|
||||
send: undefined as unknown as (text: string) => Promise<boolean>,
|
||||
sessionId: 'a', sessionRef: { current: 'a' },
|
||||
executeSnippetCommand: (_text: string, _noAutoRun?: boolean, options?: { broadcast?: boolean }) => {
|
||||
calls.push(options?.broadcast);
|
||||
return true;
|
||||
},
|
||||
isTerminalSensitiveInputActive: () => false,
|
||||
composeSendToAll: false,
|
||||
isBroadcastEnabled: true,
|
||||
};
|
||||
runInNewContext(soloCode, context);
|
||||
assert.equal(await context.send('command'), true);
|
||||
context.composeSendToAll = true;
|
||||
context.isBroadcastEnabled = false;
|
||||
assert.equal(await context.send('command'), true);
|
||||
assert.deepEqual(calls, [false, true]);
|
||||
});
|
||||
|
||||
test('solo missing sessions and sensitive input are excluded; rejection propagates to the compose handler', async () => {
|
||||
const context = {
|
||||
send: undefined as unknown as (text: string) => Promise<boolean>,
|
||||
sessionId: 'a', sessionRef: { current: null as string | null },
|
||||
executeSnippetCommand: async () => true,
|
||||
isTerminalSensitiveInputActive: () => false,
|
||||
composeSendToAll: undefined as boolean | undefined,
|
||||
isBroadcastEnabled: false,
|
||||
};
|
||||
runInNewContext(soloCode, context);
|
||||
assert.equal(await context.send('command'), false);
|
||||
|
||||
@@ -414,6 +414,8 @@ const WORKSPACE_CTX_KEYS = [
|
||||
'onSetWorkspaceFocusedSession',
|
||||
'onSplitSession',
|
||||
'isBroadcastEnabled',
|
||||
'isComposeSendToAll',
|
||||
'onToggleComposeSendTarget',
|
||||
'handleBroadcastInput',
|
||||
'handleBroadcastInterruptPriorityChange',
|
||||
'handleToggleWorkspaceComposeBar',
|
||||
@@ -440,8 +442,10 @@ const WORKSPACE_CTX_KEYS = [
|
||||
'onStartSessionDrag',
|
||||
'onEndSessionDrag',
|
||||
'isGlobalBroadcastEnabled',
|
||||
'isGlobalComposeSendToAll',
|
||||
'canUseGlobalBroadcast',
|
||||
'onToggleGlobalBroadcast',
|
||||
'onToggleGlobalComposeSendTarget',
|
||||
] as const;
|
||||
|
||||
export function terminalLayerSidePanelStableCtxEqual(prev: Ctx, next: Ctx): boolean {
|
||||
@@ -477,6 +481,10 @@ export function terminalLayerViewCtxEqual(prev: Ctx, next: Ctx): boolean {
|
||||
if (prev.refocusTerminalSession !== next.refocusTerminalSession) return false;
|
||||
if (prev.setIsComposeBarOpen !== next.setIsComposeBarOpen) return false;
|
||||
if (prev.isBroadcastEnabled !== next.isBroadcastEnabled) return false;
|
||||
if (prev.isComposeSendToAll !== next.isComposeSendToAll) return false;
|
||||
if (prev.onToggleComposeSendTarget !== next.onToggleComposeSendTarget) return false;
|
||||
if (prev.isGlobalComposeSendToAll !== next.isGlobalComposeSendToAll) return false;
|
||||
if (prev.onToggleGlobalComposeSendTarget !== next.onToggleGlobalComposeSendTarget) return false;
|
||||
if (prev.composeBarThemeColors !== next.composeBarThemeColors) return false;
|
||||
if (prev.workspaceOuterRef !== next.workspaceOuterRef) return false;
|
||||
// Use stable side-panel equal only: LIVE fields (cwd, theme focus, etc.) flow
|
||||
|
||||
@@ -76,9 +76,13 @@ export const terminalLayerAreEqual = (
|
||||
prev.onCreateLocalTerminal === next.onCreateLocalTerminal &&
|
||||
prev.isBroadcastEnabled === next.isBroadcastEnabled &&
|
||||
prev.onToggleBroadcast === next.onToggleBroadcast &&
|
||||
prev.isComposeSendToAll === next.isComposeSendToAll &&
|
||||
prev.onToggleComposeSendTarget === next.onToggleComposeSendTarget &&
|
||||
prev.isGlobalBroadcastEnabled === next.isGlobalBroadcastEnabled &&
|
||||
prev.isGlobalComposeSendToAll === next.isGlobalComposeSendToAll &&
|
||||
prev.canUseGlobalBroadcast === next.canUseGlobalBroadcast &&
|
||||
prev.onToggleGlobalBroadcast === next.onToggleGlobalBroadcast &&
|
||||
prev.onToggleGlobalComposeSendTarget === next.onToggleGlobalComposeSendTarget &&
|
||||
prev.updateSnippets === next.updateSnippets &&
|
||||
prev.updateSnippetPackages === next.updateSnippetPackages &&
|
||||
prev.toggleScriptsSidePanelRef === next.toggleScriptsSidePanelRef &&
|
||||
|
||||
@@ -330,7 +330,8 @@ export const AsidePanelStack: React.FC<AsidePanelStackProps> = ({
|
||||
className
|
||||
)}
|
||||
style={inlineStyle}
|
||||
data-section={dataSection}>
|
||||
data-section={dataSection}
|
||||
data-native-view-occluder={layout === 'overlay' ? '' : undefined}>
|
||||
<AsidePanelHeader
|
||||
title={currentItem.title}
|
||||
subtitle={currentItem.subtitle}
|
||||
@@ -429,7 +430,10 @@ export const AsidePanel: React.FC<AsidePanelProps> = ({
|
||||
className,
|
||||
)}
|
||||
style={panelStyle}
|
||||
data-section={dataSection}>
|
||||
data-section={dataSection}
|
||||
// Overlay panels float above content; native views (host browser)
|
||||
// must yield where they overlap instead of drawing over the panel.
|
||||
data-native-view-occluder={layout === 'overlay' ? '' : undefined}>
|
||||
{resizable ? (
|
||||
<div
|
||||
role="separator"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import { DiskIoSummary } from "../systemManager/DiskIoSummary";
|
||||
import type { MonitorStats } from "../../domain/monitorRuntime";
|
||||
import { ResourceBar } from "../systemManager/ResourceBar";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
@@ -15,7 +16,7 @@ export function MonitorDiskUsage({
|
||||
stats,
|
||||
limit,
|
||||
}: {
|
||||
stats?: (Pick<MonitorStats, "disks" | "diskPercent"> & Partial<Pick<MonitorStats, "diskUsed" | "diskTotal">>) | null;
|
||||
stats?: (Pick<MonitorStats, "disks" | "diskPercent"> & Partial<Pick<MonitorStats, "diskUsed" | "diskTotal" | "diskReadSpeed" | "diskWriteSpeed">>) | null;
|
||||
limit?: number;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
@@ -30,6 +31,7 @@ export function MonitorDiskUsage({
|
||||
return (
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<div className={limit === 2 ? "min-h-[38px] space-y-1.5" : "space-y-1.5"}>
|
||||
<DiskIoSummary stats={stats} />
|
||||
{disks.map((disk, index) => (
|
||||
<Tooltip key={`${disk.mountPoint}-${index}`}>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -186,3 +186,19 @@ for (const view of ["list", "charts"] as const) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("disk I/O distinguishes warming up or unsupported from idle and reports both rates", () => {
|
||||
for (const [rates, expected] of [
|
||||
[{}, ["Read --", "Write --"]],
|
||||
[{ diskReadSpeed: null, diskWriteSpeed: null }, ["Read --", "Write --"]],
|
||||
[{ diskReadSpeed: 0, diskWriteSpeed: 0 }, ["Read 0 B/s", "Write 0 B/s"]],
|
||||
[{ diskReadSpeed: 1024, diskWriteSpeed: 2097152 }, ["Read 1.0 KiB/s", "Write 2.0 MiB/s"]],
|
||||
] as const) {
|
||||
const document = new JSDOM(renderToStaticMarkup(
|
||||
<I18nProvider locale="en"><MonitorDiskUsage stats={{ disks: [], diskPercent: null, ...rates }} /></I18nProvider>,
|
||||
)).window.document;
|
||||
const io = document.querySelector("[data-disk-io]")!;
|
||||
for (const text of expected) assert.ok(io.textContent?.includes(text));
|
||||
assert.match(io.getAttribute("title")!, /Linux.*not supported on macOS/);
|
||||
}
|
||||
});
|
||||
@@ -624,7 +624,7 @@ export function MonitorPanels({
|
||||
<SelectValue>{t("monitor.seconds", { count: panel.interval })}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MONITOR_INTERVALS.filter((v) => v >= 3).map((v) => (
|
||||
{MONITOR_INTERVALS.map((v) => (
|
||||
<SelectItem key={v} value={String(v)}>
|
||||
{t("monitor.seconds", { count: v })}
|
||||
</SelectItem>
|
||||
|
||||
@@ -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.
|
||||
@@ -5,10 +5,14 @@ import type { ConnectionLog } from "./models.ts";
|
||||
import { selectConnectionLogForTerminalDataCapture } from "./connectionLog.ts";
|
||||
import {
|
||||
MAX_PERSISTED_UNSAVED_TERMINAL_DATA_ENTRIES,
|
||||
diffTerminalDataMaps,
|
||||
mergeConnectionLogsFromStorage,
|
||||
mergeTerminalDataIntoLogs,
|
||||
mergeTerminalDataMapsForStorage,
|
||||
pruneInMemoryUnsavedTerminalData,
|
||||
pruneTerminalDataMapForStorage,
|
||||
rebaseQueuedConnectionLogs,
|
||||
retainNewestUnsavedReplays,
|
||||
} from "./connectionLogTerminalData.ts";
|
||||
|
||||
const baseLog: ConnectionLog = {
|
||||
@@ -149,6 +153,44 @@ test("pruneTerminalDataMapForStorage caps unsaved replay buffers", () => {
|
||||
assert.equal(pruned["log-0"], undefined);
|
||||
});
|
||||
|
||||
test("pruneInMemoryUnsavedTerminalData drops oldest unsaved replay buffers beyond the persist cap", () => {
|
||||
const logs: ConnectionLog[] = Array.from({ length: 60 }, (_, index) => ({
|
||||
...baseLog,
|
||||
id: `log-${index}`,
|
||||
startTime: index,
|
||||
saved: false,
|
||||
terminalData: `data-${index}`,
|
||||
}));
|
||||
// One bookmarked entry must keep its replay buffer even when unsaved count is high.
|
||||
logs.push({
|
||||
...baseLog,
|
||||
id: "saved-keep",
|
||||
startTime: 0,
|
||||
saved: true,
|
||||
terminalData: "saved-data",
|
||||
});
|
||||
|
||||
const pruned = pruneInMemoryUnsavedTerminalData(logs);
|
||||
const unsavedWithData = pruned.filter((log) => !log.saved && log.terminalData !== undefined);
|
||||
assert.equal(unsavedWithData.length, MAX_PERSISTED_UNSAVED_TERMINAL_DATA_ENTRIES);
|
||||
assert.equal(pruned.find((log) => log.id === "log-59")?.terminalData, "data-59");
|
||||
assert.equal(pruned.find((log) => log.id === "log-0")?.terminalData, undefined);
|
||||
assert.equal(pruned.find((log) => log.id === "log-0")?.id, "log-0"); // metadata kept
|
||||
assert.equal(pruned.find((log) => log.id === "saved-keep")?.terminalData, "saved-data");
|
||||
assert.equal(pruned.length, logs.length);
|
||||
});
|
||||
|
||||
test("pruneInMemoryUnsavedTerminalData is a no-op at or under the cap", () => {
|
||||
const logs: ConnectionLog[] = Array.from({ length: 10 }, (_, index) => ({
|
||||
...baseLog,
|
||||
id: `log-${index}`,
|
||||
startTime: index,
|
||||
terminalData: `data-${index}`,
|
||||
}));
|
||||
const pruned = pruneInMemoryUnsavedTerminalData(logs);
|
||||
assert.equal(pruned, logs);
|
||||
});
|
||||
|
||||
test("mergeTerminalDataMapsForStorage keeps replay data from other windows", () => {
|
||||
const logs: ConnectionLog[] = [
|
||||
{ ...baseLog, id: "local", startTime: 2000 },
|
||||
@@ -193,3 +235,168 @@ test("mergeTerminalDataMapsForStorage drops deleted log replay buffers", () => {
|
||||
assert.equal(merged.local, "keep");
|
||||
assert.equal(merged.deleted, undefined);
|
||||
});
|
||||
|
||||
test("rebaseQueuedConnectionLogs keeps logs another window wrote while this snapshot waited", () => {
|
||||
const queued = [{ ...baseLog, id: "local", startTime: 2000, terminalData: "local-replay" }];
|
||||
const memory = queued;
|
||||
const disk = [
|
||||
{ ...baseLog, id: "remote", startTime: 3000 },
|
||||
{ ...baseLog, id: "local", startTime: 2000 },
|
||||
];
|
||||
|
||||
const rebased = rebaseQueuedConnectionLogs(queued, new Set(), new Set(["local"]), memory, disk);
|
||||
|
||||
assert.deepEqual(rebased.map((log) => log.id), ["remote", "local"]);
|
||||
assert.equal(rebased.find((log) => log.id === "local")?.terminalData, "local-replay");
|
||||
});
|
||||
|
||||
test("rebaseQueuedConnectionLogs does not restore an id deleted from memory after it was queued", () => {
|
||||
const queued = [{ ...baseLog, id: "local", startTime: 2000, terminalData: "local-replay" }];
|
||||
const disk = [
|
||||
{ ...baseLog, id: "remote", startTime: 3000 },
|
||||
{ ...baseLog, id: "local", startTime: 2000, terminalData: "stale-intermediate" },
|
||||
];
|
||||
|
||||
const rebased = rebaseQueuedConnectionLogs(
|
||||
queued,
|
||||
new Set(),
|
||||
new Set(["local"]),
|
||||
[{ ...baseLog, id: "remote", startTime: 3000 }],
|
||||
disk,
|
||||
);
|
||||
|
||||
assert.deepEqual(rebased.map((log) => log.id), ["remote"]);
|
||||
assert.equal(rebased.find((log) => log.id === "local"), undefined);
|
||||
|
||||
const notYetOnDisk = rebaseQueuedConnectionLogs(
|
||||
queued,
|
||||
new Set(),
|
||||
new Set(["local"]),
|
||||
[{ ...baseLog, id: "remote", startTime: 3000 }],
|
||||
[{ ...baseLog, id: "remote", startTime: 3000 }],
|
||||
);
|
||||
assert.deepEqual(notYetOnDisk.map((log) => log.id), ["remote"]);
|
||||
});
|
||||
|
||||
test("rebaseQueuedConnectionLogs does not restore an unchanged log another window deleted", () => {
|
||||
const unchanged = { ...baseLog, id: "unchanged", startTime: 1000, terminalData: "old" };
|
||||
const added = { ...baseLog, id: "local", startTime: 4000, terminalData: "local-replay" };
|
||||
const queued = [added, unchanged];
|
||||
const remote = [{ ...baseLog, id: "remote", startTime: 3000 }];
|
||||
|
||||
const rebased = rebaseQueuedConnectionLogs(
|
||||
queued,
|
||||
new Set(),
|
||||
new Set(["local"]),
|
||||
[added, ...remote],
|
||||
remote,
|
||||
);
|
||||
|
||||
assert.deepEqual(rebased.map((log) => log.id), ["local", "remote"]);
|
||||
assert.equal(rebased.find((log) => log.id === "unchanged"), undefined);
|
||||
assert.equal(rebased.find((log) => log.id === "local")?.terminalData, "local-replay");
|
||||
});
|
||||
|
||||
test("rebaseQueuedConnectionLogs applies a local metadata edit onto the latest disk row", () => {
|
||||
const queued = [{ ...baseLog, id: "local", startTime: 2000, saved: true, terminalData: "replay" }];
|
||||
const disk = [
|
||||
{ ...baseLog, id: "remote", startTime: 3000 },
|
||||
{ ...baseLog, id: "local", startTime: 2000, saved: false },
|
||||
];
|
||||
|
||||
const rebased = rebaseQueuedConnectionLogs(
|
||||
queued,
|
||||
new Set(),
|
||||
new Set(["local"]),
|
||||
queued,
|
||||
disk,
|
||||
);
|
||||
|
||||
assert.equal(rebased.find((log) => log.id === "local")?.saved, true);
|
||||
assert.equal(rebased.find((log) => log.id === "local")?.terminalData, "replay");
|
||||
assert.ok(rebased.some((log) => log.id === "remote"));
|
||||
});
|
||||
|
||||
test("retainNewestUnsavedReplays drops older unsaved buffers and keeps the newest", () => {
|
||||
const logs: ConnectionLog[] = [
|
||||
{ ...baseLog, id: "old", startTime: 1000, terminalData: "old" },
|
||||
{ ...baseLog, id: "mid", startTime: 2000, terminalData: "mid" },
|
||||
{ ...baseLog, id: "new", startTime: 3000, terminalData: "new" },
|
||||
{ ...baseLog, id: "saved", startTime: 500, saved: true, terminalData: "saved" },
|
||||
];
|
||||
|
||||
const kept = retainNewestUnsavedReplays(logs, 1);
|
||||
|
||||
assert.equal(kept.find((log) => log.id === "new")?.terminalData, "new");
|
||||
assert.equal(kept.find((log) => log.id === "saved")?.terminalData, "saved");
|
||||
assert.equal(kept.find((log) => log.id === "old")?.terminalData, undefined);
|
||||
assert.equal(kept.find((log) => log.id === "mid")?.terminalData, undefined);
|
||||
});
|
||||
|
||||
test("rebaseQueuedConnectionLogs drops a queued delete and keeps a queued add still in memory", () => {
|
||||
const queued = [{ ...baseLog, id: "added", startTime: 4000, terminalData: "new" }];
|
||||
const memory = queued;
|
||||
const disk = [
|
||||
{ ...baseLog, id: "deleted", startTime: 1000 },
|
||||
{ ...baseLog, id: "kept", startTime: 2000 },
|
||||
];
|
||||
|
||||
const rebased = rebaseQueuedConnectionLogs(
|
||||
queued,
|
||||
new Set(["deleted"]),
|
||||
new Set(["added"]),
|
||||
memory,
|
||||
disk,
|
||||
);
|
||||
|
||||
assert.deepEqual(rebased.map((log) => log.id), ["added", "kept"]);
|
||||
assert.equal(rebased.find((log) => log.id === "added")?.terminalData, "new");
|
||||
});
|
||||
|
||||
test("diffTerminalDataMaps puts only changed ids and deletes removed ids", () => {
|
||||
const diff = diffTerminalDataMaps(
|
||||
{ same: "keep", edited: "old", removed: "gone" },
|
||||
{ same: "keep", edited: "new", added: "fresh" },
|
||||
);
|
||||
|
||||
assert.deepEqual(diff.puts, { edited: "new", added: "fresh" });
|
||||
assert.deepEqual(diff.deleteIds, ["removed"]);
|
||||
});
|
||||
|
||||
test("diffTerminalDataMaps treats key order as unchanged", () => {
|
||||
const diff = diffTerminalDataMaps(
|
||||
{ b: "2", a: "1" },
|
||||
{ a: "1", b: "2" },
|
||||
);
|
||||
|
||||
assert.deepEqual(diff.puts, {});
|
||||
assert.deepEqual(diff.deleteIds, []);
|
||||
});
|
||||
|
||||
test("prune cap diffs to deleting only replay buffers outside the newest 50", () => {
|
||||
const logs: ConnectionLog[] = Array.from({ length: 60 }, (_, index) => ({
|
||||
...baseLog,
|
||||
id: `log-${index}`,
|
||||
startTime: index,
|
||||
saved: false,
|
||||
}));
|
||||
logs.push({
|
||||
...baseLog,
|
||||
id: "saved-keep",
|
||||
startTime: 1,
|
||||
saved: true,
|
||||
});
|
||||
const map = Object.fromEntries(logs.map((log) => [log.id, `data-${log.id}`]));
|
||||
const pruned = pruneTerminalDataMapForStorage(logs, map);
|
||||
const diff = diffTerminalDataMaps(map, pruned);
|
||||
|
||||
assert.equal(Object.keys(pruned).length, MAX_PERSISTED_UNSAVED_TERMINAL_DATA_ENTRIES + 1);
|
||||
assert.equal(Object.keys(diff.puts).length, 0);
|
||||
assert.equal(diff.deleteIds.length, 10);
|
||||
assert.equal(diff.deleteIds.includes("log-0"), true);
|
||||
assert.equal(diff.deleteIds.includes("log-9"), true);
|
||||
assert.equal(diff.deleteIds.includes("log-10"), false);
|
||||
assert.equal(diff.deleteIds.includes("log-59"), false);
|
||||
assert.equal(diff.deleteIds.includes("saved-keep"), false);
|
||||
assert.equal(pruned["saved-keep"], "data-saved-keep");
|
||||
});
|
||||
@@ -26,8 +26,8 @@ export const mergeTerminalDataIntoLogs = (
|
||||
};
|
||||
|
||||
/**
|
||||
* When another window or a pruned localStorage write reloads connection logs,
|
||||
* keep in-memory / side-store terminal replay data instead of wiping it.
|
||||
* When another window reloads connection logs, keep in-memory / side-store
|
||||
* terminal replay data instead of wiping it.
|
||||
*/
|
||||
export const mergeConnectionLogsFromStorage = (
|
||||
prev: ConnectionLog[],
|
||||
@@ -49,6 +49,81 @@ export const mergeConnectionLogsFromStorage = (
|
||||
return changed ? merged : next;
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply a queued connection-log snapshot when it runs, not when it was captured.
|
||||
* Disk may have gained another window's logs during the wait. Keep those logs.
|
||||
* Metadata from this snapshot replaces a disk row only for ids the recipe added
|
||||
* or updated (`changedIds`) that are still in memory. An id deleted locally
|
||||
* after this snapshot was queued is not written back. Deletes from this
|
||||
* snapshot apply only while memory still agrees.
|
||||
*/
|
||||
export const rebaseQueuedConnectionLogs = (
|
||||
queued: readonly ConnectionLog[],
|
||||
removedIds: ReadonlySet<string>,
|
||||
changedIds: ReadonlySet<string>,
|
||||
memory: readonly ConnectionLog[],
|
||||
disk: readonly ConnectionLog[] | null,
|
||||
): ConnectionLog[] => {
|
||||
const memoryById = new Map(memory.map((log) => [log.id, log]));
|
||||
const queuedById = new Map(queued.map((log) => [log.id, log]));
|
||||
const diskKept = (disk ?? []).filter(
|
||||
(log) => !(removedIds.has(log.id) && !memoryById.has(log.id)),
|
||||
);
|
||||
const present = new Set<string>();
|
||||
const withLocalEdits: ConnectionLog[] = [];
|
||||
for (const log of diskKept) {
|
||||
const queuedLog = queuedById.get(log.id);
|
||||
const memoryLog = memoryById.get(log.id);
|
||||
if (changedIds.has(log.id)) {
|
||||
if (!memoryLog) continue;
|
||||
present.add(log.id);
|
||||
withLocalEdits.push(memoryLog);
|
||||
continue;
|
||||
}
|
||||
present.add(log.id);
|
||||
const replay = queuedLog?.terminalData ?? memoryLog?.terminalData ?? log.terminalData;
|
||||
if (!replay || log.terminalData === replay) {
|
||||
withLocalEdits.push(log);
|
||||
continue;
|
||||
}
|
||||
withLocalEdits.push({ ...log, terminalData: replay });
|
||||
}
|
||||
const extras: ConnectionLog[] = [];
|
||||
for (const log of queued) {
|
||||
if (present.has(log.id) || !changedIds.has(log.id)) continue;
|
||||
const memoryLog = memoryById.get(log.id);
|
||||
if (!memoryLog) continue;
|
||||
extras.push(memoryLog);
|
||||
}
|
||||
if (extras.length === 0) return withLocalEdits;
|
||||
return pruneInMemoryUnsavedTerminalData(
|
||||
[...extras, ...withLocalEdits].sort((a, b) => b.startTime - a.startTime),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Drop `terminalData` from unsaved logs outside the newest `keep` entries.
|
||||
* Saved logs and the newest unsaved replays stay intact.
|
||||
*/
|
||||
export const retainNewestUnsavedReplays = (
|
||||
logs: readonly ConnectionLog[],
|
||||
keep: number,
|
||||
): ConnectionLog[] => {
|
||||
const ranked = logs
|
||||
.filter((log) => !log.saved && log.terminalData)
|
||||
.sort((a, b) => b.startTime - a.startTime);
|
||||
if (ranked.length <= keep) return [...logs];
|
||||
const keepIds = new Set(ranked.slice(0, Math.max(0, keep)).map((log) => log.id));
|
||||
let changed = false;
|
||||
const next = logs.map((log) => {
|
||||
if (log.saved || log.terminalData === undefined || keepIds.has(log.id)) return log;
|
||||
changed = true;
|
||||
const { terminalData: _omitted, ...rest } = log;
|
||||
return rest;
|
||||
});
|
||||
return changed ? next : [...logs];
|
||||
};
|
||||
|
||||
export const buildTerminalDataMapFromLogs = (
|
||||
logs: ConnectionLog[],
|
||||
): ConnectionLogTerminalDataMap => {
|
||||
@@ -88,6 +163,53 @@ export const pruneTerminalDataMapForStorage = (
|
||||
return next;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Drop in-memory `terminalData` from the oldest unsaved logs beyond the
|
||||
* persisted-entry cap. List metadata stays; only replay buffers are removed.
|
||||
* Matches what survives a restart (`pruneTerminalDataMapForStorage`).
|
||||
*/
|
||||
export const pruneInMemoryUnsavedTerminalData = (
|
||||
logs: ConnectionLog[],
|
||||
maxEntries: number = MAX_PERSISTED_UNSAVED_TERMINAL_DATA_ENTRIES,
|
||||
): ConnectionLog[] => {
|
||||
const unsavedWithData = logs
|
||||
.filter((log) => !log.saved && log.terminalData !== undefined)
|
||||
.sort((a, b) => b.startTime - a.startTime);
|
||||
if (unsavedWithData.length <= maxEntries) return logs;
|
||||
|
||||
const keep = new Set(unsavedWithData.slice(0, maxEntries).map((log) => log.id));
|
||||
let changed = false;
|
||||
const next = logs.map((log) => {
|
||||
if (log.saved || log.terminalData === undefined || keep.has(log.id)) return log;
|
||||
changed = true;
|
||||
const { terminalData: _omitted, ...rest } = log;
|
||||
return rest;
|
||||
});
|
||||
return changed ? next : logs;
|
||||
};
|
||||
|
||||
export type ConnectionLogTerminalDataDiff = {
|
||||
puts: ConnectionLogTerminalDataMap;
|
||||
deleteIds: string[];
|
||||
};
|
||||
|
||||
/** Changed replay blobs to write, and ids no longer in the persisted map. */
|
||||
export const diffTerminalDataMaps = (
|
||||
previous: ConnectionLogTerminalDataMap,
|
||||
next: ConnectionLogTerminalDataMap,
|
||||
): ConnectionLogTerminalDataDiff => {
|
||||
const puts: ConnectionLogTerminalDataMap = {};
|
||||
const deleteIds: string[] = [];
|
||||
for (const id of Object.keys(next)) {
|
||||
if (previous[id] !== next[id]) puts[id] = next[id];
|
||||
}
|
||||
for (const id of Object.keys(previous)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(next, id)) deleteIds.push(id);
|
||||
}
|
||||
return { puts, deleteIds };
|
||||
};
|
||||
|
||||
/** Fold side-store maps together, then cap to the allowed unsaved/saved set. */
|
||||
export const mergeTerminalDataMapsForStorage = (
|
||||
logs: ConnectionLog[],
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { HOST_BROWSER_MIN_LIVE_EXTENT, resolveHostBrowserPaneBesidePanels } from './hostBrowser';
|
||||
|
||||
const pane = { x: 200, y: 100, width: 500, height: 600 };
|
||||
|
||||
test('panels beside the pane leave it unchanged, even when the pane itself is small', () => {
|
||||
assert.equal(resolveHostBrowserPaneBesidePanels(pane, [{ x: 700, y: 0, width: 300, height: 900 }]), pane);
|
||||
const small = { x: 0, y: 0, width: HOST_BROWSER_MIN_LIVE_EXTENT - 1, height: 80 };
|
||||
assert.equal(resolveHostBrowserPaneBesidePanels(small, []), small);
|
||||
});
|
||||
|
||||
test('full-height or full-width edge panels shrink the pane to the remaining rectangle', () => {
|
||||
assert.deepEqual(resolveHostBrowserPaneBesidePanels(pane, [{ x: 450, y: 0, width: 600, height: 900 }]),
|
||||
{ x: 200, y: 100, width: 250, height: 600 });
|
||||
assert.deepEqual(resolveHostBrowserPaneBesidePanels(pane, [{ x: 0, y: 0, width: 300, height: 900 }]),
|
||||
{ x: 300, y: 100, width: 400, height: 600 });
|
||||
assert.deepEqual(resolveHostBrowserPaneBesidePanels(pane, [{ x: 0, y: 500, width: 900, height: 400 }]),
|
||||
{ x: 200, y: 100, width: 500, height: 400 });
|
||||
assert.deepEqual(resolveHostBrowserPaneBesidePanels(pane, [
|
||||
{ x: 0, y: 0, width: 300, height: 900 },
|
||||
{ x: 550, y: 0, width: 400, height: 900 },
|
||||
]), { x: 300, y: 100, width: 250, height: 600 });
|
||||
});
|
||||
|
||||
test('panels over the middle, a corner, or leaving too little room cannot keep a live page', () => {
|
||||
assert.equal(resolveHostBrowserPaneBesidePanels(pane, [{ x: 300, y: 0, width: 100, height: 900 }]), null);
|
||||
assert.equal(resolveHostBrowserPaneBesidePanels(pane, [{ x: 600, y: 0, width: 300, height: 300 }]), null);
|
||||
assert.equal(resolveHostBrowserPaneBesidePanels(pane, [{ x: 0, y: 0, width: 900, height: 900 }]), null);
|
||||
assert.equal(resolveHostBrowserPaneBesidePanels(pane, [{ x: 250, y: 0, width: 600, height: 900 }]), null);
|
||||
});
|
||||
@@ -63,3 +63,39 @@ export type HostBrowserPanelEvent = { browserId: string } & (
|
||||
| { type: 'bookmarks' }
|
||||
| { type: 'shortcut'; shortcut: HostBrowserShortcut }
|
||||
);
|
||||
|
||||
export interface HostBrowserPaneRect { x: number; y: number; width: number; height: number }
|
||||
|
||||
/** Below this extent a page beside an overlay panel is too cramped to keep live. */
|
||||
export const HOST_BROWSER_MIN_LIVE_EXTENT = 160;
|
||||
|
||||
/**
|
||||
* Native browser views draw above the renderer and cannot be clipped, so a
|
||||
* panel covering a full-height or full-width edge strip of the pane is avoided
|
||||
* by shrinking the page to the remaining rectangle. Returns null when a panel
|
||||
* covers the middle or leaves too little room; the pane then shows a picture.
|
||||
*/
|
||||
export function resolveHostBrowserPaneBesidePanels(
|
||||
pane: HostBrowserPaneRect,
|
||||
panels: readonly HostBrowserPaneRect[],
|
||||
): HostBrowserPaneRect | null {
|
||||
let left = pane.x;
|
||||
let top = pane.y;
|
||||
let right = pane.x + pane.width;
|
||||
let bottom = pane.y + pane.height;
|
||||
for (const panel of panels) {
|
||||
const panelRight = panel.x + panel.width;
|
||||
const panelBottom = panel.y + panel.height;
|
||||
if (panel.x >= right || panelRight <= left || panel.y >= bottom || panelBottom <= top) continue;
|
||||
const spansHeight = panel.y <= top && panelBottom >= bottom;
|
||||
const spansWidth = panel.x <= left && panelRight >= right;
|
||||
if (spansHeight && panel.x > left && panelRight >= right) right = panel.x;
|
||||
else if (spansHeight && panel.x <= left && panelRight < right) left = panelRight;
|
||||
else if (spansWidth && panel.y > top && panelBottom >= bottom) bottom = panel.y;
|
||||
else if (spansWidth && panel.y <= top && panelBottom < bottom) top = panelBottom;
|
||||
else return null;
|
||||
}
|
||||
if (left === pane.x && top === pane.y && right === pane.x + pane.width && bottom === pane.y + pane.height) return pane;
|
||||
if (right - left < HOST_BROWSER_MIN_LIVE_EXTENT || bottom - top < HOST_BROWSER_MIN_LIVE_EXTENT) return null;
|
||||
return { x: left, y: top, width: right - left, height: bottom - top };
|
||||
}
|
||||
@@ -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) => (
|
||||
|
||||
Loaded 100 of 152 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user