mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-07 06:55:03 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5be5902f34 | ||
|
|
f075210f4e | ||
|
|
98224a443c | ||
|
|
4ad424c45c |
No files matched your search
@@ -877,7 +877,6 @@ jobs:
|
||||
--bare -p --permission-mode dontAsk \
|
||||
--settings "$HOME/.claude/settings.json" \
|
||||
--allowedTools "Read" "Bash(web-search *)" "Bash(web-fetch *)" \
|
||||
"Bash($research_dir/web-search *)" "Bash($research_dir/web-fetch *)" \
|
||||
--disallowedTools "WebSearch" "WebFetch" "Edit" "Write" "NotebookEdit" \
|
||||
--output-format stream-json --verbose --model "$AI_MODEL" \
|
||||
"$prompt"
|
||||
@@ -1696,7 +1695,6 @@ jobs:
|
||||
--bare -p --permission-mode dontAsk \
|
||||
--settings "$HOME/.claude/settings.json" \
|
||||
--allowedTools "Read" "Bash(web-search *)" "Bash(web-fetch *)" \
|
||||
"Bash($research_dir/web-search *)" "Bash($research_dir/web-fetch *)" \
|
||||
--disallowedTools "WebSearch" "WebFetch" "Edit" "Write" "NotebookEdit" \
|
||||
--output-format stream-json --verbose --model "$AI_MODEL" \
|
||||
"$prompt"
|
||||
|
||||
@@ -695,11 +695,6 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Security CDN endpoints can lack files advertised in their indexes.
|
||||
# Fall back per file, with the final Bullseye security snapshot for packages only.
|
||||
# Live signed indexes still select versions and verify every download.
|
||||
printf 'http://deb.debian.org/debian-security\tpriority:1\nhttp://security.debian.org/debian-security\tpriority:2\nhttp://snapshot.debian.org/archive/debian-security/20260901T000000Z/\tpriority:3 type:deb\n' > /etc/apt/security-mirrors.txt
|
||||
sed -i 's|http://deb.debian.org/debian-security|mirror+file:/etc/apt/security-mirrors.txt|g' /etc/apt/sources.list
|
||||
apt-get -o Acquire::Retries=4 update
|
||||
apt-get -o Acquire::Retries=4 install -y curl build-essential python3 git libfuse2 file rpm \
|
||||
libarchive-tools \
|
||||
|
||||
@@ -52,9 +52,6 @@ jobs:
|
||||
- name: Test
|
||||
run: npm test
|
||||
|
||||
- name: Test terminal background rendering
|
||||
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox --use-angle=swiftshader --enable-unsafe-swiftshader scripts/terminal-background-rendering.live.test.cjs
|
||||
|
||||
- name: Test terminal keyword highlight performance
|
||||
env:
|
||||
NETCATTY_TERMINAL_PERF_SHOW_WINDOW: "1"
|
||||
|
||||
@@ -315,30 +315,6 @@ test('pane zoom hotkey delegates to the active in-app magnification surface', ()
|
||||
assert.equal(toggles, 1);
|
||||
});
|
||||
|
||||
test('broadcast hotkey toggles global mode for an active orphan tab', () => {
|
||||
let globalToggles = 0;
|
||||
let workspaceToggles = 0;
|
||||
|
||||
executeHotkeyActionImpl(() => ({
|
||||
activeTabStore: { getActiveTabId: () => 'orphan-1' },
|
||||
editorTabs: [],
|
||||
orderedTabs: ['orphan-1', 'orphan-2'],
|
||||
settings: { showSftpTab: true, shellOnlyTabNumberShortcuts: false },
|
||||
toEditorTabId: (id: string) => id,
|
||||
sessions: [
|
||||
{ id: 'orphan-1' },
|
||||
{ id: 'orphan-2' },
|
||||
],
|
||||
workspaces: [],
|
||||
canUseGlobalBroadcast: true,
|
||||
toggleBroadcast: () => { workspaceToggles += 1; },
|
||||
toggleGlobalBroadcast: () => { globalToggles += 1; },
|
||||
}), 'broadcast', {} as KeyboardEvent);
|
||||
|
||||
assert.equal(globalToggles, 1);
|
||||
assert.equal(workspaceToggles, 0);
|
||||
});
|
||||
|
||||
test('move-focus shortcut cannot send input behind a magnified pane', () => {
|
||||
let moveCalls = 0;
|
||||
executeHotkeyActionImpl(() => ({
|
||||
|
||||
@@ -722,7 +722,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, workspaces } = getCtx();
|
||||
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, 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, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces } = getCtx();
|
||||
{
|
||||
const shortcutTabs = buildNumberShortcutTabTargets({
|
||||
showSftpTab: settings.showSftpTab ?? true,
|
||||
@@ -916,16 +916,11 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
toggleSidePanelRef.current?.();
|
||||
break;
|
||||
case 'broadcast': {
|
||||
// Workspace tabs keep their local broadcast state; orphan tabs share
|
||||
// the global state when at least two visible orphan tabs exist.
|
||||
// Toggle broadcast mode for the active workspace
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
const activeWs = workspaces.find(w => w.id === currentId);
|
||||
if (activeWs) {
|
||||
toggleBroadcast(activeWs.id);
|
||||
} else if (sessions.some((session: TerminalSession) => (
|
||||
session.id === currentId && !session.workspaceId
|
||||
)) && canUseGlobalBroadcast) {
|
||||
toggleGlobalBroadcast();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -225,9 +225,3 @@ test('AppShell uses default memo (store-driven), not always-rerender comparator'
|
||||
assert.match(appShellSource, /memo\s*\(\s*AppShellView\s*\)/);
|
||||
assert.doesNotMatch(appShellSource, /memo\s*\(\s*AppShellView\s*,\s*\(\s*\)\s*=>\s*false\s*\)/);
|
||||
});
|
||||
|
||||
test('terminal system detection routes through the owner of temporary hosts', () => {
|
||||
const sideEffects = readFileSync(join(here, 'AppSideEffects.tsx'), 'utf8');
|
||||
assert.match(hostSources['TerminalHost.tsx'], /updateHostDistro: handlers\.updateTerminalHostDistro/);
|
||||
assert.match(sideEffects, /if \(ephemeralHostIds\.has\(hostId\)\) \{\s*setEphemeralHosts\([\s\S]*?applyEphemeralHostDistroUpdate[\s\S]*?return;\s*\}\s*updateHostDistro\(hostId, distro\)/);
|
||||
});
|
||||
@@ -43,7 +43,7 @@ import { materializeHostProxyProfile } from '../../domain/proxyProfiles';
|
||||
import { buildSshDeepLinkConnectionHost, buildSshDeepLinkEphemeralHost, buildSshDeepLinkEphemeralHostFromSaved, buildSshDeepLinkHostDraft, findSshDeepLinkHost, parseSshDeepLink } from '../../domain/sshDeepLink';
|
||||
import { buildTelnetDeepLinkConnectionHost, buildTelnetDeepLinkEphemeralHostFromSaved, buildTelnetDeepLinkOpenHost, findTelnetDeepLinkHost, materializeTelnetDeepLinkMatchHost, parseTelnetDeepLink } from '../../domain/telnetDeepLink';
|
||||
import { buildJmsDeepLinkEphemeralHost, isSupportedJmsProtocol, parseJmsDeepLink } from '../../domain/jmsDeepLink';
|
||||
import { applyEphemeralHostDistroUpdate, applyEphemeralHostsUpdate, splitHostsUpdateByEphemeral } from '../../domain/ephemeralHosts';
|
||||
import { applyEphemeralHostsUpdate, splitHostsUpdateByEphemeral } from '../../domain/ephemeralHosts';
|
||||
import { resolveHostAuth } from '../../domain/sshAuth';
|
||||
import { isEncryptedCredentialPlaceholder, stripSyncPayloadEncryptedCredentials } from '../../domain/credentials';
|
||||
import {
|
||||
@@ -202,7 +202,6 @@ export function AppSideEffects() {
|
||||
addConnectionLog,
|
||||
updateConnectionLog,
|
||||
updateHostLastConnected,
|
||||
updateHostDistro,
|
||||
importDataFromString,
|
||||
readPersistedHosts,
|
||||
groupConfigs,
|
||||
@@ -1752,14 +1751,6 @@ export function AppSideEffects() {
|
||||
updateHosts(vaultHosts);
|
||||
}, [ephemeralHostIds, updateHosts]);
|
||||
|
||||
const updateTerminalHostDistro = useCallback((hostId: string, distro: string) => {
|
||||
if (ephemeralHostIds.has(hostId)) {
|
||||
setEphemeralHosts((previous) => applyEphemeralHostDistroUpdate(previous, hostId, distro));
|
||||
return;
|
||||
}
|
||||
updateHostDistro(hostId, distro);
|
||||
}, [ephemeralHostIds, updateHostDistro]);
|
||||
|
||||
// Wrapper to create serial session with logging
|
||||
const handleConnectSerial = useCallback((config: SerialConfig, options?: { charset?: string }) => {
|
||||
const { username, hostname } = systemInfoRef.current;
|
||||
@@ -1895,7 +1886,6 @@ export function AppSideEffects() {
|
||||
handleTerminalDataCapture,
|
||||
handleUpdateHostFromTerminal,
|
||||
updateTerminalHosts,
|
||||
updateTerminalHostDistro,
|
||||
runSnippet: handleRunSnippet,
|
||||
splitSessionWithCurrentShell,
|
||||
toggleScriptsSidePanelRef,
|
||||
@@ -1972,7 +1962,6 @@ export function AppSideEffects() {
|
||||
handleTerminalDataCapture,
|
||||
handleUpdateHostFromTerminal,
|
||||
updateTerminalHosts,
|
||||
updateTerminalHostDistro,
|
||||
handleRunSnippet,
|
||||
splitSessionWithCurrentShell,
|
||||
handleEndSessionDrag,
|
||||
|
||||
@@ -268,7 +268,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit,
|
||||
handleOpenHostFromVaultNote, handleOpenQuickSwitcher, handleOpenSettings, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, 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, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
|
||||
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
|
||||
passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
|
||||
resolveEmptyVaultConflict, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
|
||||
@@ -737,9 +737,6 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onCreateLocalTerminal={handleCreateLocalTerminal}
|
||||
isBroadcastEnabled={isBroadcastEnabled}
|
||||
onToggleBroadcast={toggleBroadcast}
|
||||
isGlobalBroadcastEnabled={isGlobalBroadcastEnabled}
|
||||
onToggleGlobalBroadcast={onToggleGlobalBroadcast}
|
||||
canUseGlobalBroadcast={canUseGlobalBroadcast}
|
||||
updateHosts={updateTerminalHosts}
|
||||
updateSnippets={updateSnippets}
|
||||
updateSnippetPackages={updateSnippetPackages}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { useThemeRuntime, useTerminalAppearanceInjection } from '../../state/useThemeRuntime';
|
||||
import {
|
||||
useVaultSnapshot,
|
||||
useVaultSnapshotActions,
|
||||
} from '../../state/vaultSnapshotStore';
|
||||
import { getAppHandlers, subscribeAppHandlers } from '../appHandlersBridge';
|
||||
import { publishAppShellDomainSlice } from '../appShellPropsStore';
|
||||
@@ -36,6 +37,7 @@ export function TerminalHost() {
|
||||
const session = useSessionSnapshot();
|
||||
const sessionActions = useSessionSnapshotActions();
|
||||
const vault = useVaultSnapshot();
|
||||
const vaultActions = useVaultSnapshotActions();
|
||||
const terminalSettings = useTerminalSettingsStore();
|
||||
const terminalSettingsActions = useTerminalSettingsActions();
|
||||
const {
|
||||
@@ -205,8 +207,6 @@ export function TerminalHost() {
|
||||
updateTerminalHosts: handlers.updateTerminalHosts,
|
||||
hotkeyScheme: terminalSettings.hotkeyScheme,
|
||||
isBroadcastEnabled: sessionActions?.isBroadcastEnabled,
|
||||
isGlobalBroadcastEnabled: sessionActions?.isGlobalBroadcastEnabled,
|
||||
canUseGlobalBroadcast: sessionActions?.canUseGlobalBroadcast,
|
||||
keyBindings: terminalSettings.keyBindings,
|
||||
openNoteRequest: local.openNoteRequest,
|
||||
portForwardingRules: local.portForwardingRules,
|
||||
@@ -238,14 +238,12 @@ export function TerminalHost() {
|
||||
terminalSettings: terminalSettings.terminalSettings,
|
||||
terminalThemeId: terminalSettings.terminalThemeId,
|
||||
toggleBroadcast: sessionActions?.toggleBroadcast,
|
||||
toggleGlobalBroadcast: sessionActions?.toggleGlobalBroadcast,
|
||||
onToggleGlobalBroadcast: sessionActions?.toggleGlobalBroadcast,
|
||||
toggleScriptsSidePanelRef: handlers.toggleScriptsSidePanelRef,
|
||||
toggleSidePanelRef: handlers.toggleSidePanelRef,
|
||||
terminalPaneMagnificationRef: handlers.terminalPaneMagnificationRef,
|
||||
sftpPaneMagnificationRef: handlers.sftpPaneMagnificationRef,
|
||||
toggleWorkspaceViewMode: sessionActions?.toggleWorkspaceViewMode,
|
||||
updateHostDistro: handlers.updateTerminalHostDistro,
|
||||
updateHostDistro: vaultActions?.updateHostDistro,
|
||||
updateSplitSizes: sessionActions?.updateSplitSizes,
|
||||
updateSessionFontSize: sessionActions?.updateSessionFontSize,
|
||||
updateSessionRestoreCwd: sessionActions?.updateSessionRestoreCwd,
|
||||
@@ -273,6 +271,7 @@ export function TerminalHost() {
|
||||
terminalHosts,
|
||||
terminalSettings,
|
||||
terminalSettingsActions,
|
||||
vaultActions,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
|
||||
@@ -70,9 +70,6 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
reorderTabs,
|
||||
toggleBroadcast,
|
||||
isBroadcastEnabled,
|
||||
toggleGlobalBroadcast,
|
||||
isGlobalBroadcastEnabled,
|
||||
canUseGlobalBroadcast,
|
||||
copySession,
|
||||
copyWorkspace,
|
||||
createSessionFromCloneSource,
|
||||
@@ -150,9 +147,6 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
reorderTabs,
|
||||
toggleBroadcast,
|
||||
isBroadcastEnabled,
|
||||
toggleGlobalBroadcast,
|
||||
isGlobalBroadcastEnabled,
|
||||
canUseGlobalBroadcast,
|
||||
copySession,
|
||||
copyWorkspace,
|
||||
createSessionFromCloneSource,
|
||||
@@ -181,10 +175,8 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
getOrderedWorkTabs,
|
||||
getSessionRestoreCwd,
|
||||
isBroadcastEnabled,
|
||||
isGlobalBroadcastEnabled,
|
||||
moveFocusInWorkspace,
|
||||
openLogView,
|
||||
canUseGlobalBroadcast,
|
||||
removeSessionFromWorkspace,
|
||||
renameSessionInline,
|
||||
reorderTabs,
|
||||
@@ -201,7 +193,6 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
submitSessionRename,
|
||||
submitWorkspaceRename,
|
||||
toggleBroadcast,
|
||||
toggleGlobalBroadcast,
|
||||
toggleWorkspaceViewMode,
|
||||
updateSessionCodingCliProvider,
|
||||
updateSessionDynamicTitle,
|
||||
|
||||
@@ -497,13 +497,6 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.menuFiles': 'Files',
|
||||
'ai.chat.menuImage': 'Image',
|
||||
'ai.chat.menuMentionHost': 'Mention Host',
|
||||
'ai.chat.menuMentionNote': 'Mention Note',
|
||||
'ai.chat.mentionNoteSearch': 'Search notes…',
|
||||
'ai.chat.mentionNoteEmpty': 'No matching notes',
|
||||
'ai.chat.mentionNoteUnavailable': 'This agent cannot read Vault notes in the current connection mode.',
|
||||
'ai.chat.mentionNoteTooMany': 'These notes cannot all be referenced together. Please select fewer notes.',
|
||||
'ai.chat.mentionNoteInvalid': '"{{title}}" could not be attached: the note has an invalid identifier.',
|
||||
'ai.chat.untitledNote': 'Untitled note',
|
||||
'ai.chat.menuUserSkills': 'User Skills',
|
||||
'ai.chat.menuSlashCommands': 'Slash Commands',
|
||||
'ai.chat.slashCommands': 'Slash commands',
|
||||
|
||||
@@ -710,13 +710,8 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.section.address': 'Address',
|
||||
'hostDetails.hostname.placeholder': 'IP or Hostname',
|
||||
'hostDetails.section.general': 'General',
|
||||
'hostDetails.os.title': 'System identification',
|
||||
'hostDetails.os.desc': 'Prefer automatic detection. Override only when the detected system is incorrect. Network Device Mode is configured separately.',
|
||||
'hostDetails.os.auto': 'Automatic',
|
||||
'hostDetails.os.unknown': 'Other / unknown',
|
||||
'hostDetails.os.selection': 'System selection',
|
||||
'hostDetails.os.current': 'Current system',
|
||||
'hostDetails.os.freebsd': 'FreeBSD',
|
||||
'hostDetails.os.title': 'Operating System',
|
||||
'hostDetails.os.desc': 'Used for environment reporting and shell-aware behavior.',
|
||||
'hostDetails.os.linux': 'Linux',
|
||||
'hostDetails.os.windows': 'Windows',
|
||||
'hostDetails.os.macos': 'macOS',
|
||||
|
||||
@@ -497,13 +497,6 @@ export const esAiMessages: Messages = {
|
||||
'ai.chat.menuFiles': 'Archivos',
|
||||
'ai.chat.menuImage': 'Imagen',
|
||||
'ai.chat.menuMentionHost': 'Mencionar host',
|
||||
'ai.chat.menuMentionNote': 'Mencionar nota',
|
||||
'ai.chat.mentionNoteSearch': 'Buscar notas…',
|
||||
'ai.chat.mentionNoteEmpty': 'Sin notas coincidentes',
|
||||
'ai.chat.mentionNoteUnavailable': 'Este agente no puede leer notas de la bóveda en el modo de conexión actual.',
|
||||
'ai.chat.mentionNoteTooMany': 'No se pueden referenciar todas estas notas juntas. Selecciona menos notas.',
|
||||
'ai.chat.mentionNoteInvalid': 'No se pudo adjuntar «{{title}}»: la nota tiene un identificador no válido.',
|
||||
'ai.chat.untitledNote': 'Nota sin título',
|
||||
'ai.chat.menuUserSkills': 'Habilidades de usuario',
|
||||
'ai.chat.menuSlashCommands': 'Comandos de barra',
|
||||
'ai.chat.slashCommands': 'Comandos de barra',
|
||||
|
||||
@@ -710,13 +710,8 @@ export const esVaultMessages: Messages = {
|
||||
'hostDetails.section.address': 'Dirección',
|
||||
'hostDetails.hostname.placeholder': 'IP o nombre de host',
|
||||
'hostDetails.section.general': 'General',
|
||||
'hostDetails.os.title': 'Identificación del sistema',
|
||||
'hostDetails.os.desc': 'Usa la detección automática. Selecciona un sistema solo si la detección es incorrecta. El modo de dispositivo de red se configura por separado.',
|
||||
'hostDetails.os.auto': 'Automático',
|
||||
'hostDetails.os.unknown': 'Otro / desconocido',
|
||||
'hostDetails.os.selection': 'Selección del sistema',
|
||||
'hostDetails.os.current': 'Sistema actual',
|
||||
'hostDetails.os.freebsd': 'FreeBSD',
|
||||
'hostDetails.os.title': 'Sistema operativo',
|
||||
'hostDetails.os.desc': 'Se usa para informar del entorno y adaptar el comportamiento del shell.',
|
||||
'hostDetails.os.linux': 'Linux',
|
||||
'hostDetails.os.windows': 'Windows',
|
||||
'hostDetails.os.macos': 'macOS',
|
||||
|
||||
@@ -449,13 +449,6 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.menuFiles': 'Файлы',
|
||||
'ai.chat.menuImage': 'Изображение',
|
||||
'ai.chat.menuMentionHost': 'Упомянуть хост',
|
||||
'ai.chat.menuMentionNote': 'Упомянуть заметку',
|
||||
'ai.chat.mentionNoteSearch': 'Поиск заметок…',
|
||||
'ai.chat.mentionNoteEmpty': 'Нет подходящих заметок',
|
||||
'ai.chat.mentionNoteUnavailable': 'Этот агент не может читать заметки хранилища в текущем режиме подключения.',
|
||||
'ai.chat.mentionNoteTooMany': 'Невозможно сослаться на все эти заметки одновременно. Выберите меньше заметок.',
|
||||
'ai.chat.mentionNoteInvalid': '«{{title}}» не удалось прикрепить: у заметки недопустимый идентификатор.',
|
||||
'ai.chat.untitledNote': 'Заметка без названия',
|
||||
'ai.chat.menuUserSkills': 'Пользовательские навыки',
|
||||
'ai.chat.menuSlashCommands': 'Команды /',
|
||||
'ai.chat.slashCommands': 'Команды /',
|
||||
|
||||
@@ -747,13 +747,8 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.section.address': 'Адрес',
|
||||
'hostDetails.hostname.placeholder': 'IP или имя хоста',
|
||||
'hostDetails.section.general': 'Общие',
|
||||
'hostDetails.os.title': 'Определение системы',
|
||||
'hostDetails.os.desc': 'Используйте автоматическое определение. Укажите систему вручную только при ошибке. Режим сетевого устройства настраивается отдельно.',
|
||||
'hostDetails.os.auto': 'Автоматически',
|
||||
'hostDetails.os.unknown': 'Другая / неизвестно',
|
||||
'hostDetails.os.selection': 'Выбор системы',
|
||||
'hostDetails.os.current': 'Текущая система',
|
||||
'hostDetails.os.freebsd': 'FreeBSD',
|
||||
'hostDetails.os.title': 'Операционная система',
|
||||
'hostDetails.os.desc': 'Используется для сведений о среде и адаптации командной оболочки.',
|
||||
'hostDetails.os.linux': 'Linux',
|
||||
'hostDetails.os.windows': 'Windows',
|
||||
'hostDetails.os.macos': 'macOS',
|
||||
|
||||
@@ -497,13 +497,6 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.menuFiles': '文件',
|
||||
'ai.chat.menuImage': '图片',
|
||||
'ai.chat.menuMentionHost': '提及主机',
|
||||
'ai.chat.menuMentionNote': '提及笔记',
|
||||
'ai.chat.mentionNoteSearch': '搜索笔记…',
|
||||
'ai.chat.mentionNoteEmpty': '没有匹配的笔记',
|
||||
'ai.chat.mentionNoteUnavailable': '当前连接方式下,此 AI 无法读取保险箱笔记。',
|
||||
'ai.chat.mentionNoteTooMany': '这些笔记无法一起引用,请减少选择。',
|
||||
'ai.chat.mentionNoteInvalid': '无法附加「{{title}}」:笔记的标识符无效。',
|
||||
'ai.chat.untitledNote': '未命名笔记',
|
||||
'ai.chat.menuUserSkills': '用户 Skills',
|
||||
'ai.chat.menuSlashCommands': '快捷命令',
|
||||
'ai.chat.slashCommands': '快捷命令',
|
||||
|
||||
@@ -133,13 +133,8 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.section.address': '地址',
|
||||
'hostDetails.hostname.placeholder': 'IP 或 主机名',
|
||||
'hostDetails.section.general': '通用',
|
||||
'hostDetails.os.title': '系统识别',
|
||||
'hostDetails.os.desc': '默认自动识别,仅在识别错误时手动指定。网络设备模式单独设置。',
|
||||
'hostDetails.os.auto': '自动识别',
|
||||
'hostDetails.os.unknown': '其他 / 未知',
|
||||
'hostDetails.os.selection': '识别方式',
|
||||
'hostDetails.os.current': '当前系统',
|
||||
'hostDetails.os.freebsd': 'FreeBSD',
|
||||
'hostDetails.os.title': '操作系统',
|
||||
'hostDetails.os.desc': '用于环境信息和适配对应的命令行行为。',
|
||||
'hostDetails.os.linux': 'Linux',
|
||||
'hostDetails.os.windows': 'Windows',
|
||||
'hostDetails.os.macos': 'macOS',
|
||||
|
||||
@@ -496,13 +496,6 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.menuFiles': '檔案',
|
||||
'ai.chat.menuImage': '圖片',
|
||||
'ai.chat.menuMentionHost': '提及主機',
|
||||
'ai.chat.menuMentionNote': '提及筆記',
|
||||
'ai.chat.mentionNoteSearch': '搜尋筆記…',
|
||||
'ai.chat.mentionNoteEmpty': '沒有符合的筆記',
|
||||
'ai.chat.mentionNoteUnavailable': '目前連線方式下,此 AI 無法讀取保險箱筆記。',
|
||||
'ai.chat.mentionNoteTooMany': '這些筆記無法一起引用,請減少選擇。',
|
||||
'ai.chat.mentionNoteInvalid': '無法附加「{{title}}」:筆記的識別碼無效。',
|
||||
'ai.chat.untitledNote': '未命名筆記',
|
||||
'ai.chat.menuUserSkills': '使用者 Skills',
|
||||
'ai.chat.menuSlashCommands': '快捷指令',
|
||||
'ai.chat.slashCommands': '快捷指令',
|
||||
|
||||
@@ -132,13 +132,8 @@ export const zhTWVaultMessages: Messages = {
|
||||
'hostDetails.section.address': '位址',
|
||||
'hostDetails.hostname.placeholder': 'IP 或 主機名',
|
||||
'hostDetails.section.general': '通用',
|
||||
'hostDetails.os.title': '系統識別',
|
||||
'hostDetails.os.desc': '預設自動辨識,僅在辨識錯誤時手動指定。網路裝置模式另行設定。',
|
||||
'hostDetails.os.auto': '自動辨識',
|
||||
'hostDetails.os.unknown': '其他 / 未知',
|
||||
'hostDetails.os.selection': '辨識方式',
|
||||
'hostDetails.os.current': '目前系統',
|
||||
'hostDetails.os.freebsd': 'FreeBSD',
|
||||
'hostDetails.os.title': '作業系統',
|
||||
'hostDetails.os.desc': '用於環境資訊和配合對應的命令列行為。',
|
||||
'hostDetails.os.linux': 'Linux',
|
||||
'hostDetails.os.windows': 'Windows',
|
||||
'hostDetails.os.macos': 'macOS',
|
||||
|
||||
@@ -68,9 +68,6 @@ export type SessionSnapshotActions = Pick<
|
||||
| 'reorderTabs'
|
||||
| 'toggleBroadcast'
|
||||
| 'isBroadcastEnabled'
|
||||
| 'toggleGlobalBroadcast'
|
||||
| 'isGlobalBroadcastEnabled'
|
||||
| 'canUseGlobalBroadcast'
|
||||
| 'copySession'
|
||||
| 'copyWorkspace'
|
||||
| 'createSessionFromCloneSource'
|
||||
|
||||
@@ -39,108 +39,6 @@ const host = (id: string, label: string, hostname = label): Host => ({
|
||||
protocol: "ssh",
|
||||
} as Host);
|
||||
|
||||
for (const retainedStatus of [undefined, "interrupted", "failed", "paused"] as const) {
|
||||
for (const newerPause of retainedStatus === "paused" ? [false, true] : [false]) {
|
||||
const batchExistingIdentity = retainedStatus !== undefined;
|
||||
test(`superseded folder child settles when its completion was compacted into the parent: retained=${retainedStatus ?? "none"}, newerPause=${newerPause}`, async (t) => {
|
||||
const { sftpTransferCenterStore } = await import("../sftpTransferCenterStore");
|
||||
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
|
||||
});
|
||||
const originalGet = netcattyBridge.get;
|
||||
let abort = false;
|
||||
let running: Promise<unknown> | undefined;
|
||||
let childId: string | undefined;
|
||||
const parent: TransferTask = {
|
||||
id: "superseded-compacted-folder", fileName: "folder",
|
||||
sourcePath: "/local/folder", targetPath: "/remote/folder",
|
||||
sourceConnectionId: "local", targetConnectionId: "expired-sftp",
|
||||
targetHostId: "h1", direction: "upload", status: "transferring",
|
||||
totalBytes: 1, transferredBytes: 0, speed: 0, startTime: Date.now(),
|
||||
isDirectory: true, progressMode: "files", reconnectRequired: true,
|
||||
};
|
||||
t.after(async () => {
|
||||
abort = true;
|
||||
await running;
|
||||
netcattyBridge.get = originalGet;
|
||||
sftpTransferCenterStore.patchTask(parent.id, { status: "completed" });
|
||||
sftpTransferCenterStore.dismiss(parent.id);
|
||||
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
|
||||
else Reflect.deleteProperty(globalThis, "localStorage");
|
||||
resetDedicatedSessionOpenGateForTests();
|
||||
});
|
||||
const persisted: TransferTask = {
|
||||
...parent, id: "retained-child", parentTaskId: parent.id, isDirectory: false,
|
||||
sourcePath: "/local/folder/a.bin", targetPath: "/remote/folder/a.bin",
|
||||
totalBytes: 10, sourceLastModified: 1, status: retainedStatus ?? "interrupted",
|
||||
directoryEntryIndex: 0, directoryEntryIdentity: "a".repeat(64),
|
||||
};
|
||||
const { createDedicatedResumeChildUpdateBatcher } = await import("../../app/dedicatedResumeProgress");
|
||||
// Exercise the real large-history batching branch with one relevant retained row.
|
||||
const childBatcher = createDedicatedResumeChildUpdateBatcher({
|
||||
getTaskCount: () => 4096,
|
||||
hasTask: (id) => id === persisted.id,
|
||||
upsertTasks: (updates) => sftpTransferCenterStore.upsertTasks(updates),
|
||||
});
|
||||
sftpTransferCenterStore.publishOwner("compacted-owner", [parent, ...(batchExistingIdentity ? [persisted] : [])]);
|
||||
(netcattyBridge as { get: () => unknown }).get = () => ({
|
||||
openSftp: async () => "dedicated-sftp", closeSftp: async () => {},
|
||||
listLocalTree: async () => [{
|
||||
localPath: "/local/folder/a.bin", relativePath: "a.bin", type: "file", size: 10, lastModified: 1,
|
||||
}],
|
||||
mkdirSftp: async () => {},
|
||||
statLocal: async () => {
|
||||
if (newerPause) sftpTransferCenterStore.patchTask(persisted.id, { status: "paused", lifecycleEpoch: 9 });
|
||||
return { size: 10, lastModified: 1 };
|
||||
},
|
||||
startStreamTransfer: async (options: {
|
||||
transferId: string; parentTaskId?: string; directoryEntryIndex?: number; directoryEntryIdentity?: string;
|
||||
}) => {
|
||||
childId = options.transferId;
|
||||
// Main-process lifecycle events carry the stream's metadata, not queued UI updates.
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "started", transferId: childId, lifecycleEpoch: 0,
|
||||
parentTaskId: options.parentTaskId,
|
||||
directoryEntryIndex: options.directoryEntryIndex,
|
||||
directoryEntryIdentity: options.directoryEntryIdentity,
|
||||
});
|
||||
// A newer same-id owner completes before this older invocation rejoins.
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "completed", transferId: childId, transferred: 10, totalBytes: 10, lifecycleEpoch: 0,
|
||||
});
|
||||
return { superseded: true };
|
||||
},
|
||||
});
|
||||
running = resumeTransferWithDedicatedSession(parent, {
|
||||
hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [],
|
||||
}, undefined, {
|
||||
children: batchExistingIdentity ? [persisted] : [], shouldAbort: () => abort,
|
||||
onChildUpdate: (child) => batchExistingIdentity
|
||||
? childBatcher.push(child)
|
||||
: sftpTransferCenterStore.publishOwner("compacted-owner", [parent, child]),
|
||||
});
|
||||
const result = await Promise.race([
|
||||
running,
|
||||
new Promise<"still-waiting">((resolve) => setTimeout(() => resolve("still-waiting"), 450)),
|
||||
]);
|
||||
if (newerPause) {
|
||||
assert.equal(result, "still-waiting", "a later pause must still hold fresh recovery");
|
||||
assert.equal(childId, undefined, "newer paused child must not start");
|
||||
assert.equal(sftpTransferCenterStore.getTask(persisted.id)?.lifecycleEpoch, 9);
|
||||
return;
|
||||
}
|
||||
assert.notEqual(result, "still-waiting", "completed compacted child must not leave its folder waiting forever");
|
||||
assert.ok(childId);
|
||||
assert.equal(sftpTransferCenterStore.getTask(childId), undefined, "completed row was compacted");
|
||||
assert.equal(sftpTransferCenterStore.getTask(parent.id)?.directoryResumeCheckpoint?.completedEntries, 1);
|
||||
assert.notEqual(result, "still-waiting", "completed compacted child must not leave its folder waiting forever");
|
||||
assert.equal((result as { success: boolean }).success, true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test("resolveDirectoryResumeTargetRoot prefers staged replace path", () => {
|
||||
assert.equal(
|
||||
resolveDirectoryResumeTargetRoot({
|
||||
@@ -199,11 +97,10 @@ test("persisted resume child lookup handles 50,000 retained rows in linear time"
|
||||
assert.ok(Date.now() - startedAt < 2_000, "50k retained-child lookup should stay linear");
|
||||
});
|
||||
|
||||
test("resolveHostForTransferEndpoint requires recorded id and supports unique legacy names", () => {
|
||||
test("resolveHostForTransferEndpoint prefers id then label", () => {
|
||||
const hosts = [host("id-1", "CI-Build-01", "ci-01.example")];
|
||||
assert.equal(resolveHostForTransferEndpoint(hosts, "id-1", "other")?.id, "id-1");
|
||||
assert.equal(resolveHostForTransferEndpoint(hosts, "missing", "CI-Build-01"), null);
|
||||
assert.equal(resolveHostForTransferEndpoint(hosts, undefined, "CI-Build-01")?.id, "id-1");
|
||||
assert.equal(resolveHostForTransferEndpoint(hosts, "missing", "CI-Build-01")?.id, "id-1");
|
||||
assert.equal(resolveHostForTransferEndpoint(hosts, undefined, "ci-01.example")?.id, "id-1");
|
||||
assert.equal(resolveHostForTransferEndpoint(hosts, "missing", "gone"), null);
|
||||
});
|
||||
@@ -2101,33 +1998,3 @@ test("corrupted replace-stage history cannot delete an unrelated directory", asy
|
||||
resetDedicatedSessionOpenGateForTests();
|
||||
}
|
||||
});
|
||||
|
||||
for (const scenario of ["deleted-id", "ambiguous-legacy-label"] as const) {
|
||||
test(`restart upload does not select a different server by ${scenario}`, async (t) => {
|
||||
const originalGet = netcattyBridge.get;
|
||||
t.after(() => { netcattyBridge.get = originalGet; });
|
||||
const opened: string[] = [];
|
||||
let uploads = 0;
|
||||
(netcattyBridge as { get: () => unknown }).get = () => ({
|
||||
openSftp: async (options: { hostname: string }) => { opened.push(options.hostname); return "new-channel"; },
|
||||
closeSftp: async () => {},
|
||||
statLocal: async () => ({ size: 100, lastModified: 123 }),
|
||||
startStreamTransfer: async () => { uploads++; return { transferId: "wrong-host-resume" }; },
|
||||
});
|
||||
const hosts = scenario === "deleted-id"
|
||||
? [host("different-id", "production", "different.example")]
|
||||
: [host("first-id", "production", "first.example"), host("second-id", "production", "second.example")];
|
||||
const result = await resumeTransferWithDedicatedSession({
|
||||
id: "wrong-host-resume", fileName: "deployment.zip",
|
||||
sourcePath: "/fixture/deployment.zip", targetPath: "/srv/deployment.zip",
|
||||
sourceConnectionId: "local", targetConnectionId: "right-expired",
|
||||
targetHostId: scenario === "deleted-id" ? "deleted-original-id" : undefined,
|
||||
targetHostLabel: "production", direction: "upload", status: "interrupted",
|
||||
totalBytes: 100, transferredBytes: 20, checkpointBytes: 20,
|
||||
speed: 0, startTime: 1, isDirectory: false, reconnectRequired: true,
|
||||
}, {hosts, keys: [], identities: []});
|
||||
assert.equal(result.success, false, "resume must stop instead of selecting another endpoint by display name");
|
||||
assert.deepEqual(opened, [], "must not authenticate to an unproven replacement host");
|
||||
assert.equal(uploads, 0);
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { runTransferAndWaitForOwner } from "./waitForTransferOwner";
|
||||
import type { Host, Identity, KnownHost, SSHKey, TerminalSettings, TransferTask } from "../../../domain/models";
|
||||
import { validateTransferResumeSource } from "../../../domain/sftpTransferCenter";
|
||||
import { STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY } from "../../../infrastructure/config/storageKeys";
|
||||
@@ -96,8 +95,8 @@ export function resetDedicatedSessionOpenGateForTests(): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the recorded endpoint, never a same-named replacement. Legacy tasks
|
||||
* without an id may use label/hostname only when exactly one host matches.
|
||||
* Resolve a vault host for a transfer endpoint. Prefer stable id; fall back to
|
||||
* label/hostname when the id is stale after vault edits or older task records.
|
||||
*/
|
||||
export function resolveHostForTransferEndpoint(
|
||||
hosts: readonly Host[],
|
||||
@@ -105,16 +104,16 @@ export function resolveHostForTransferEndpoint(
|
||||
hostLabel?: string,
|
||||
): Host | null {
|
||||
if (hostId) {
|
||||
return hosts.find((host) => host.id === hostId) ?? null;
|
||||
const byId = hosts.find((host) => host.id === hostId);
|
||||
if (byId) return byId;
|
||||
}
|
||||
const needle = (hostLabel || "").trim().toLowerCase();
|
||||
if (!needle) return null;
|
||||
const matches = hosts.filter((host) => {
|
||||
return hosts.find((host) => {
|
||||
const label = (host.label || "").trim().toLowerCase();
|
||||
const hostname = (host.hostname || "").trim().toLowerCase();
|
||||
return label === needle || hostname === needle;
|
||||
});
|
||||
return matches.length === 1 ? matches[0] : null;
|
||||
}) ?? null;
|
||||
}
|
||||
|
||||
export type OpenTransferSftpSessionOptions = {
|
||||
@@ -1001,11 +1000,6 @@ async function resumeDirectoryWithDedicatedSession(
|
||||
onProgress?: (progress: DedicatedResumeProgress) => void,
|
||||
options?: DedicatedResumeOptions,
|
||||
): Promise<DedicatedResumeResult> {
|
||||
// Fresh recovery authorizes only pauses already present at its entry. Keep
|
||||
// row references so a newer cross-window pause invalidates that permission.
|
||||
const pausedAtResume = new Map(sftpTransferCenterStore.getSnapshot().tasks
|
||||
.filter((child) => child.parentTaskId === parent.id && child.status === "paused")
|
||||
.map((child) => [child.id, child]));
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.startStreamTransfer) {
|
||||
return { success: false, error: "Transfer bridge unavailable" };
|
||||
@@ -1253,13 +1247,8 @@ async function resumeDirectoryWithDedicatedSession(
|
||||
if (options?.shouldAbort?.()) throw new Error("Transfer cancelled");
|
||||
options?.onChildUpdate?.(childBase);
|
||||
|
||||
const streamResult = await runTransferAndWaitForOwner(childBase, () => bridge.startStreamTransfer!({
|
||||
const streamResult = await bridge.startStreamTransfer!({
|
||||
transferId: childId,
|
||||
// Lifecycle events must carry the current identity even while
|
||||
// large-history renderer child updates remain batched.
|
||||
parentTaskId: childBase.parentTaskId,
|
||||
directoryEntryIndex: childBase.directoryEntryIndex,
|
||||
directoryEntryIdentity: childBase.directoryEntryIdentity,
|
||||
sourcePath: file.sourcePath,
|
||||
targetPath: file.targetPath,
|
||||
sourceType,
|
||||
@@ -1278,9 +1267,19 @@ async function resumeDirectoryWithDedicatedSession(
|
||||
uploadCheckpointBytes: childBase.uploadCheckpointBytes,
|
||||
sourceFingerprint: childBase.sourceFingerprint,
|
||||
skipAdmission: true,
|
||||
}), () => options?.shouldAbort?.() === true, pausedAtResume.get(childId));
|
||||
});
|
||||
|
||||
if (streamResult?.error || streamResult?.cancelled) {
|
||||
if (streamResult?.superseded === true) {
|
||||
for (;;) {
|
||||
if (options?.shouldAbort?.()) throw new Error("Transfer cancelled");
|
||||
const latest = sftpTransferCenterStore.getTask(childBase.id);
|
||||
const status = latest?.status;
|
||||
if (status === "completed") break;
|
||||
if (status === "failed") throw new Error(latest?.error || "Transfer failed");
|
||||
if (status === "cancelled") throw new Error("Transfer cancelled");
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
} else if (streamResult?.error || streamResult?.cancelled) {
|
||||
throw new Error(streamResult.error || "Transfer cancelled");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
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 { useSftpTransfers } from "./useSftpTransfers";
|
||||
import { transferRuntime } from "./transferRuntime";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
import { releaseTransferPauseTree } from "./transferPauseLatch";
|
||||
|
||||
for (const closePanel of [false, true]) {
|
||||
test(`direct folder download resumes discovery with panel ${closePanel ? "closed" : "open"}`, async () => {
|
||||
const previousWindow = globalThis.window;
|
||||
const previousStorage = globalThis.localStorage;
|
||||
const globals = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previousAct = globals.IS_REACT_ACT_ENVIRONMENT;
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let finishListing!: () => void;
|
||||
const listingGate = new Promise<void>((resolve) => { finishListing = resolve; });
|
||||
let listingStarted!: () => void;
|
||||
const started = new Promise<void>((resolve) => { listingStarted = resolve; });
|
||||
let listCalls = 0;
|
||||
let maxCount = 0;
|
||||
const unsubscribe = sftpTransferCenterStore.subscribe(() => {
|
||||
const root = sftpTransferCenterStore.getOwnerTasks("direct-runtime-owner").find((row) => !row.parentTaskId);
|
||||
maxCount = Math.max(maxCount, root?.transferredBytes ?? 0);
|
||||
});
|
||||
(globalThis as { window?: unknown }).window = { netcatty: {
|
||||
mkdirLocal: async () => undefined,
|
||||
statLocal: async () => undefined,
|
||||
startStreamTransfer: async (options: { transferId: string }) => {
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({ type: "completed", transferId: options.transferId, transferred: 1, totalBytes: 1, lifecycleEpoch: 0 });
|
||||
return {};
|
||||
},
|
||||
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
pauseTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
} };
|
||||
(globalThis as { localStorage?: unknown }).localStorage = {
|
||||
getItem: () => null, setItem: () => undefined, removeItem: () => undefined,
|
||||
};
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
let running: Promise<unknown> | undefined;
|
||||
let rootId = "";
|
||||
let reconnects = 0;
|
||||
sftpTransferCenterStore.setDedicatedResumeHandler(async () => {
|
||||
reconnects++;
|
||||
return { success: false, error: "unexpected reconnect" };
|
||||
});
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId: "direct-runtime-owner", getActivePane: () => null,
|
||||
getPaneByConnectionId: () => null, getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined, refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined, handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map() }, connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [], listRemoteFiles: async () => {
|
||||
listCalls++; listingStarted(); await listingGate;
|
||||
return ["one.txt", "two.txt"].map((name) => ({ name, type: "file" as const, size: 1, sizeFormatted: "1 B", lastModified: 0, lastModifiedFormatted: "" }));
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
assert.ok(ops);
|
||||
await act(async () => {
|
||||
running = ops!.downloadToLocal({ fileName: "folder", sourcePath: "/folder", targetPath: "/download/folder-runtime", sftpId: "sftp", connectionId: "ssh", sourceHostId: "host", sourceHostLabel: "Test", isDirectory: true });
|
||||
await started;
|
||||
});
|
||||
const root = sftpTransferCenterStore.getOwnerTasks("direct-runtime-owner")[0];
|
||||
assert.ok(root); rootId = root.id;
|
||||
assert.equal(root.status, "transferring", "live root must be visible in In Progress");
|
||||
assert.equal(transferRuntime.isWalkInFlight(rootId), true, "direct download registers before discovery");
|
||||
await act(async () => { await ops!.pauseTransfer(rootId); });
|
||||
assert.equal(sftpTransferCenterStore.getTask(rootId)?.status, "paused");
|
||||
if (closePanel) await act(async () => { renderer?.unmount(); renderer = undefined; });
|
||||
await act(async () => { await ops!.resumeTransfer(rootId); });
|
||||
assert.equal(sftpTransferCenterStore.getTask(rootId)?.status, "transferring", "resume must rejoin live directory discovery even without an active child stream");
|
||||
assert.equal(transferRuntime.isWalkInFlight(rootId), true);
|
||||
assert.equal(reconnects, 0);
|
||||
finishListing();
|
||||
await act(async () => { assert.equal(await running, "completed"); });
|
||||
assert.equal(sftpTransferCenterStore.getTask(rootId)?.status, "completed");
|
||||
assert.equal(sftpTransferCenterStore.getTask(rootId)?.transferredBytes, 2);
|
||||
assert.ok(maxCount <= 2, `completed children must be counted once, observed ${maxCount}`);
|
||||
assert.equal(listCalls, 1);
|
||||
assert.equal(transferRuntime.isWalkInFlight(rootId), false);
|
||||
} finally {
|
||||
releaseTransferPauseTree(rootId, []);
|
||||
finishListing();
|
||||
await act(async () => { await running; renderer?.unmount(); });
|
||||
if (rootId) sftpTransferCenterStore.dismiss(rootId);
|
||||
unsubscribe();
|
||||
sftpTransferCenterStore.setDedicatedResumeHandler(null);
|
||||
(globalThis as { window?: unknown }).window = previousWindow;
|
||||
(globalThis as { localStorage?: unknown }).localStorage = previousStorage;
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = previousAct;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -51,46 +51,6 @@ function createHost(initial: TransferTask[], bridge?: TransferControlHost["getBr
|
||||
};
|
||||
}
|
||||
|
||||
test("an older pause response must not resume a newer pause", async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
let finishFirstPause!: (value: { success: boolean; lifecycleEpoch: number }) => void;
|
||||
let calls = 0;
|
||||
let backendPaused = true;
|
||||
const { host, getTasks } = createHost([makeTask("overlapping-pause")], () => ({
|
||||
pauseTransfer: async () => {
|
||||
backendPaused = true;
|
||||
if (++calls === 1) return new Promise((resolve) => { finishFirstPause = resolve; });
|
||||
return { success: true, lifecycleEpoch: 2 };
|
||||
},
|
||||
resumeTransfer: async () => {
|
||||
backendPaused = false;
|
||||
return { success: true, lifecycleEpoch: 3 };
|
||||
},
|
||||
}));
|
||||
const first = softPauseTransfer(host, "overlapping-pause");
|
||||
await softPauseTransfer(host, "overlapping-pause");
|
||||
finishFirstPause({ success: true, lifecycleEpoch: 1 });
|
||||
await first;
|
||||
assert.equal(getTasks()[0].status, "paused");
|
||||
assert.equal(backendPaused, true, "late pause acknowledgement must not restart file writes");
|
||||
});
|
||||
|
||||
test("a delayed resume response must not repaint a newer pause", async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
let finishResume!: (value: { success: boolean; lifecycleEpoch: number }) => void;
|
||||
const { host, getTasks } = createHost([makeTask("resume-then-pause", "paused")], () => ({
|
||||
resumeTransfer: () => new Promise((resolve) => { finishResume = resolve; }),
|
||||
pauseTransfer: async () => ({ success: true, lifecycleEpoch: 3 }),
|
||||
}));
|
||||
const resume = softResumeTransfer(host, "resume-then-pause");
|
||||
await softPauseTransfer(host, "resume-then-pause");
|
||||
finishResume({ success: true, lifecycleEpoch: 2 });
|
||||
await resume;
|
||||
assert.equal(isTransferPauseLatched("resume-then-pause"), true);
|
||||
assert.equal(getTasks()[0].status, "paused", "latest user intent must win over an older response");
|
||||
assert.equal(getTasks()[0].lifecycleEpoch, 3);
|
||||
});
|
||||
|
||||
test("softPauseTransfer latches and paints paused for a live directory walk without a panel", async () => {
|
||||
resetTransferPauseLatchesForTests();
|
||||
resetTransferWalkRegistryForTests();
|
||||
@@ -285,238 +245,3 @@ test("directory softResume stamps bridge epoch only on successIds; queued siblin
|
||||
resetTransferPauseLatchesForTests();
|
||||
resetTransferWalkRegistryForTests();
|
||||
});
|
||||
|
||||
for (const isDirectory of [false, true]) {
|
||||
for (const newerLocalPause of [false, true]) {
|
||||
test(`cross-window resume releases ${isDirectory ? "folder" : "file"} latches unless local pause is newer: ${newerLocalPause}`, async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
let finish!: (result: { success: boolean; superseded: true; supersededBy: "resume" }) => void;
|
||||
let pauses = 0;
|
||||
const id = `remote-resume-${isDirectory}-${newerLocalPause}`;
|
||||
const initial: TransferTask[] = [{ ...makeTask(id), isDirectory }];
|
||||
if (isDirectory) initial.push({ ...makeTask(`${id}-child`), parentTaskId: id });
|
||||
const { host, getTasks } = createHost(initial, () => ({
|
||||
pauseTransfer: () => ++pauses === 1
|
||||
? new Promise((resolve) => { finish = resolve; })
|
||||
: Promise.resolve({ success: true, lifecycleEpoch: 9 }),
|
||||
}));
|
||||
const pending = softPauseTransfer(host, id);
|
||||
host.setTasks(getTasks().map(task => isDirectory && task.id === id ? task : ({ ...task, status: "transferring", lifecycleEpoch: 8 })));
|
||||
if (newerLocalPause) await softPauseTransfer(host, id);
|
||||
finish({ success: false, superseded: true, supersededBy: "resume" });
|
||||
await pending;
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(isTransferPauseLatched(id), newerLocalPause, "authoritative resume must release the old root pause barrier");
|
||||
assert.equal(getTasks().find(task => task.id === id)?.status, newerLocalPause ? "paused" : "transferring");
|
||||
if (isDirectory) assert.equal(isTransferPauseLatched(`${id}-child`), newerLocalPause);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const action of ["pause", "resume"] as const) {
|
||||
test(`superseded cross-window ${action} preserves authoritative paused state`, async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
let finish!: (result: { success: boolean; superseded: boolean }) => void;
|
||||
const deferred = () => new Promise<{ success: boolean; superseded: boolean }>((resolve) => { finish = resolve; });
|
||||
const { host, getTasks } = createHost([makeTask(`cross-window-${action}`, action === "pause" ? "transferring" : "paused")], () => ({ pauseTransfer: deferred, resumeTransfer: deferred }));
|
||||
const id = getTasks()[0].id;
|
||||
const operation = action === "pause" ? softPauseTransfer(host, id) : softResumeTransfer(host, id);
|
||||
// A global event from another window changes lifecycle, not this window's local control epoch.
|
||||
host.setTasks(getTasks().map(task => ({ ...task, status: "paused", lifecycleEpoch: 8 })));
|
||||
finish({ success: false, superseded: true });
|
||||
const result = await operation;
|
||||
assert.equal(getTasks()[0].status, "paused");
|
||||
assert.equal(getTasks()[0].lifecycleEpoch, 8);
|
||||
if (action === "pause") assert.equal(isTransferPauseLatched(id), true);
|
||||
else assert.deepEqual(result, { handled: true }, "obsolete response must not trigger dedicated recovery");
|
||||
});
|
||||
}
|
||||
|
||||
for (const isDirectory of [false, true]) {
|
||||
test(`remote pause restores released ${isDirectory ? "folder" : "file"} barriers after stale resume`, async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
const id = `remote-pause-${isDirectory}`;
|
||||
const tasks: TransferTask[] = [{ ...makeTask(id, "paused"), isDirectory }];
|
||||
if (isDirectory) tasks.push({ ...makeTask(`${id}-child`, "paused"), parentTaskId: id });
|
||||
const { host, getTasks } = createHost(tasks, () => ({
|
||||
resumeTransfer: async () => ({ success: false, superseded: true, supersededBy: "pause" }),
|
||||
}));
|
||||
assert.deepEqual(await softResumeTransfer(host, id), { handled: true });
|
||||
assert.equal(isTransferPauseLatched(id), true);
|
||||
if (isDirectory) assert.equal(isTransferPauseLatched(`${id}-child`), true);
|
||||
assert.equal(getTasks()[0].status, "paused");
|
||||
});
|
||||
}
|
||||
|
||||
test("a child-only remote resume does not release the folder pause", async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
const { host } = createHost([
|
||||
{ ...makeTask("mixed-root"), isDirectory: true },
|
||||
{ ...makeTask("mixed-one"), parentTaskId: "mixed-root" },
|
||||
{ ...makeTask("mixed-two"), parentTaskId: "mixed-root" },
|
||||
], () => ({ pauseTransfer: async id => id === "mixed-one"
|
||||
? { success: false, superseded: true, supersededBy: "resume" }
|
||||
: { success: true } }));
|
||||
await softPauseTransfer(host, "mixed-root");
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(isTransferPauseLatched("mixed-root"), true);
|
||||
assert.equal(isTransferPauseLatched("mixed-two"), true);
|
||||
});
|
||||
|
||||
test("directory resume joins successful and remotely resumed children", async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
const id = "mixed-success-resume";
|
||||
const { host, getTasks } = createHost([
|
||||
{ ...makeTask(id, "paused"), isDirectory: true },
|
||||
{ ...makeTask(`${id}-one`, "paused"), parentTaskId: id },
|
||||
{ ...makeTask(`${id}-two`, "paused"), parentTaskId: id },
|
||||
], () => ({ resumeTransfer: async childId => childId === `${id}-one`
|
||||
? { success: true, lifecycleEpoch: 3 }
|
||||
: { success: false, superseded: true, supersededBy: "resume" } }));
|
||||
assert.deepEqual(await softResumeTransfer(host, id), { handled: true });
|
||||
assert.equal(getTasks().find(task => task.id === id)?.status, "transferring");
|
||||
assert.equal(isTransferPauseLatched(id), false);
|
||||
});
|
||||
|
||||
for (const newerPause of [false, true]) {
|
||||
test(`directory live resume rejection is ignored only when superseded: ${newerPause}`, async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
t.after(resetTransferWalkRegistryForTests);
|
||||
const id = `rejected-folder-resume-${newerPause}`;
|
||||
registerTransferWalk(id);
|
||||
let rejectResume!: (error: Error) => void;
|
||||
let backendPaused = true;
|
||||
const { host, getTasks } = createHost([
|
||||
{ ...makeTask(id, "paused"), isDirectory: true },
|
||||
{ ...makeTask(`${id}-child`, "paused"), parentTaskId: id },
|
||||
], () => ({
|
||||
resumeTransfer: () => new Promise((_, reject) => { rejectResume = reject; }),
|
||||
pauseTransfer: async () => { backendPaused = true; return { success: true, lifecycleEpoch: 4 }; },
|
||||
}));
|
||||
const running = softResumeTransfer(host, id);
|
||||
if (newerPause) await softPauseTransfer(host, id);
|
||||
rejectResume(new Error("resume transport disconnected"));
|
||||
const result = await running;
|
||||
assert.equal(result.handled, newerPause);
|
||||
if (!newerPause) assert.match(result.reason || "", /resume transport disconnected/);
|
||||
assert.equal(backendPaused, true);
|
||||
assert.equal(getTasks().find(task => task.id === id)?.status, "paused");
|
||||
if (newerPause) assert.equal(isTransferPauseLatched(id), true);
|
||||
});
|
||||
}
|
||||
|
||||
for (const newerResume of [false, true]) {
|
||||
test(`partial folder resume rejection reports running and paused children unless superseded: ${newerResume}`, async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
const id = `partial-reject-${newerResume}`;
|
||||
const successfulId = `${id}-one`;
|
||||
let rejectResume!: (error: Error) => void;
|
||||
let successfulBackendPaused = true;
|
||||
let round = 0;
|
||||
let rollbackCalls = 0;
|
||||
const { host, getTasks } = createHost([
|
||||
{ ...makeTask(id, "paused"), isDirectory: true },
|
||||
{ ...makeTask(successfulId, "paused"), parentTaskId: id },
|
||||
{ ...makeTask(`${id}-two`, "paused"), parentTaskId: id },
|
||||
], () => ({
|
||||
resumeTransfer: async childId => {
|
||||
if (childId === successfulId) { successfulBackendPaused = false; return { success: true }; }
|
||||
if (round > 0) return { success: true };
|
||||
return new Promise((_, reject) => { rejectResume = reject; });
|
||||
},
|
||||
pauseTransfer: async childId => {
|
||||
rollbackCalls++;
|
||||
if (childId === successfulId) successfulBackendPaused = true;
|
||||
return { success: true };
|
||||
},
|
||||
}));
|
||||
const running = softResumeTransfer(host, id);
|
||||
if (newerResume) { round++; await softResumeTransfer(host, id); }
|
||||
rejectResume(new Error("second child IPC rejected"));
|
||||
const result = await running;
|
||||
assert.equal(result.handled, true);
|
||||
assert.equal(successfulBackendPaused, false, "successful child keeps running after partial resume");
|
||||
assert.equal(rollbackCalls, 0, "partial reporting must not introduce compensating controls");
|
||||
assert.equal(getTasks()[0].status, "transferring", "root must report the successful child still running");
|
||||
assert.equal(isTransferPauseLatched(id), false);
|
||||
const rejected = getTasks().find(task => task.id === `${id}-two`);
|
||||
assert.equal(rejected?.status, newerResume ? "transferring" : "paused");
|
||||
assert.equal(isTransferPauseLatched(`${id}-two`), !newerResume);
|
||||
if (!newerResume) assert.match(rejected?.error || "", /second child IPC rejected/);
|
||||
});
|
||||
}
|
||||
|
||||
for (const resolvedFailure of [false, true]) {
|
||||
test(`remote-resumed child remains visibly running when sibling resume fails: resolved=${resolvedFailure}`, async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
const id = "remote-partial-reject";
|
||||
const runningId = `${id}-one`;
|
||||
const rejectedId = `${id}-two`;
|
||||
const { host, getTasks } = createHost([
|
||||
{ ...makeTask(id, "paused"), isDirectory: true },
|
||||
{ ...makeTask(runningId, "paused"), parentTaskId: id },
|
||||
{ ...makeTask(rejectedId, "paused"), parentTaskId: id },
|
||||
], () => ({ resumeTransfer: async childId => {
|
||||
if (childId === rejectedId) {
|
||||
if (resolvedFailure) return { success: false, reason: "sibling resume rejected" };
|
||||
throw new Error("sibling resume rejected");
|
||||
}
|
||||
host.setTasks(getTasks().map(task => task.id === runningId ? { ...task, status: "transferring", lifecycleEpoch: 8 } : task));
|
||||
return { success: false, superseded: true, supersededBy: "resume" };
|
||||
} }));
|
||||
assert.equal((await softResumeTransfer(host, id)).handled, true);
|
||||
assert.equal(getTasks()[0].status, "transferring");
|
||||
assert.equal(isTransferPauseLatched(id), false);
|
||||
assert.equal(getTasks().find(task => task.id === runningId)?.status, "transferring");
|
||||
assert.equal(getTasks().find(task => task.id === runningId)?.lifecycleEpoch, 8);
|
||||
assert.equal(getTasks().find(task => task.id === rejectedId)?.status, "paused");
|
||||
assert.equal(isTransferPauseLatched(rejectedId), true);
|
||||
assert.match(getTasks().find(task => task.id === rejectedId)?.error || "", /sibling resume rejected/);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
for (const newerResume of [false, true]) {
|
||||
test(`resolved verification failure holds live folder unless newer resume won: ${newerResume}`, async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
t.after(resetTransferWalkRegistryForTests);
|
||||
const id = `resolved-verification-${newerResume}`;
|
||||
registerTransferWalk(id);
|
||||
let finish!: (value: { success: boolean; reason: string }) => void;
|
||||
let calls = 0;
|
||||
const { host, getTasks } = createHost([
|
||||
{ ...makeTask(id, "paused"), isDirectory: true },
|
||||
{ ...makeTask(`${id}-child`, "paused"), parentTaskId: id },
|
||||
], () => ({ resumeTransfer: () => ++calls === 1
|
||||
? new Promise(resolve => { finish = resolve; }) : Promise.resolve({ success: true }) }));
|
||||
const running = softResumeTransfer(host, id);
|
||||
if (newerResume) await softResumeTransfer(host, id);
|
||||
finish({ success: false, reason: "Could not verify the source file for resume" });
|
||||
const result = await running;
|
||||
assert.equal(result.handled, newerResume);
|
||||
if (!newerResume) assert.match(result.reason || "", /verify the source file/);
|
||||
assert.equal(getTasks()[0].status, newerResume ? "transferring" : "paused");
|
||||
assert.equal(isTransferPauseLatched(id), !newerResume);
|
||||
assert.equal(isTransferPauseLatched(`${id}-child`), !newerResume);
|
||||
});
|
||||
}
|
||||
|
||||
test("folder resume releases a completed child compacted while pause was draining", async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
t.after(resetTransferWalkRegistryForTests);
|
||||
const root = { ...makeTask("compacted-pause-root"), isDirectory: true };
|
||||
const child = { ...makeTask("compacted-pause-child"), parentTaskId: root.id };
|
||||
registerTransferWalk(root.id);
|
||||
const { host, getTasks } = createHost([root, child], () => ({
|
||||
pauseTransfer: async () => ({ success: true }),
|
||||
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
}));
|
||||
await softPauseTransfer(host, root.id);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
assert.equal(isTransferPauseLatched(child.id), true);
|
||||
// Completion during soft-drain compacts this row before the user resumes.
|
||||
host.setTasks(getTasks().filter((task) => task.id !== child.id));
|
||||
const resumed = await softResumeTransfer(host, root.id);
|
||||
assert.equal(resumed.handled, true);
|
||||
assert.equal(isTransferPauseLatched(child.id), false, "the worker still waits on this child even though its history row is gone");
|
||||
});
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
isTransferControlEpochCurrent,
|
||||
} from "./transferControlEpoch";
|
||||
import {
|
||||
isTransferOrRootPauseLatched,
|
||||
latchTransferPauseTree,
|
||||
releaseTransferPauseTree,
|
||||
} from "./transferPauseLatch";
|
||||
@@ -29,9 +28,6 @@ import { isTransferWalkInFlight } from "./transferWalkRegistry";
|
||||
export type TransferControlBridge = {
|
||||
pauseTransfer?: (id: string) => Promise<{
|
||||
success: boolean;
|
||||
/** A newer control in another window owns the authoritative state. */
|
||||
superseded?: boolean;
|
||||
supersededBy?: "pause" | "resume" | "cancel";
|
||||
reason?: string;
|
||||
checkpointBytes?: number;
|
||||
resumeStage?: TransferTask["resumeStage"];
|
||||
@@ -43,67 +39,12 @@ export type TransferControlBridge = {
|
||||
}>;
|
||||
resumeTransfer?: (id: string) => Promise<{
|
||||
success: boolean;
|
||||
/** A newer control in another window owns the authoritative state. */
|
||||
superseded?: boolean;
|
||||
supersededBy?: "pause" | "resume" | "cancel";
|
||||
reason?: string;
|
||||
lifecycleEpoch?: number;
|
||||
}>;
|
||||
cancelTransfer?: (id: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
function wasSuperseded(result: { success?: boolean; superseded?: boolean } | undefined | null): boolean {
|
||||
return result?.superseded === true;
|
||||
}
|
||||
|
||||
type SupersededControlResult = {
|
||||
success?: boolean;
|
||||
superseded?: boolean;
|
||||
supersededBy?: "pause" | "resume" | "cancel";
|
||||
};
|
||||
|
||||
export function reconcileSupersededControls(
|
||||
host: TransferControlHost,
|
||||
taskId: string,
|
||||
childIds: string[],
|
||||
backendIds: string[],
|
||||
results: ReadonlyArray<SupersededControlResult | undefined>,
|
||||
epoch: number,
|
||||
requestedAction: "pause" | "resume",
|
||||
controlTaskId = taskId,
|
||||
): void {
|
||||
if (!isTransferControlEpochCurrent(controlTaskId, epoch)) return;
|
||||
const task = host.getTasks().find((candidate) => candidate.id === taskId);
|
||||
if (!task || ["completed", "cancelled", "failed"].includes(task.status)) return;
|
||||
const apply = (id: string, descendants: string[], action: "pause" | "resume" | "cancel") => {
|
||||
if (action === "resume") releaseTransferPauseTree(id, descendants);
|
||||
else latchTransferPauseTree(id, descendants);
|
||||
for (const affectedId of [id, ...descendants]) {
|
||||
try {
|
||||
if (action === "resume") globalSftpTransferScheduler.resume(affectedId);
|
||||
else globalSftpTransferScheduler.pause(affectedId);
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
};
|
||||
const decisions = results.map((result) => result?.superseded
|
||||
? result.supersededBy
|
||||
: result?.success ? requestedAction : undefined);
|
||||
// A child-only resume does not establish a folder-wide decision. Require all
|
||||
// relevant children to agree, or an already-authoritative resumed root row.
|
||||
if (decisions.length > 0 && decisions.every((action) => action === "resume")) {
|
||||
apply(taskId, childIds, "resume");
|
||||
host.setTasks(host.getTasks().map((row) => row.id === taskId ? { ...row, status: "transferring" } : row));
|
||||
} else if (decisions.length > 0 && decisions.every((action) => action === "pause")) {
|
||||
apply(taskId, childIds, "pause");
|
||||
host.setTasks(host.getTasks().map((row) => row.id === taskId ? { ...row, status: "paused" } : row));
|
||||
} else if (task.status === "transferring" && decisions.includes("resume")) {
|
||||
apply(taskId, [], "resume");
|
||||
}
|
||||
decisions.forEach((action, index) => {
|
||||
if (action) apply(backendIds[index], [], action);
|
||||
});
|
||||
}
|
||||
|
||||
/** Prefer the highest bridge lifecycleEpoch from successful pause/resume results. */
|
||||
function maxBridgeLifecycleEpoch(
|
||||
results: ReadonlyArray<{ success?: boolean; lifecycleEpoch?: number } | undefined | null>,
|
||||
@@ -229,23 +170,14 @@ export async function softPauseTransfer(
|
||||
}
|
||||
|
||||
const backendIds = treeIds.length > 0 ? treeIds : [taskId];
|
||||
// An obsolete pause can be superseded by another pause or cancellation,
|
||||
// not only by resume. Compensate only while the tree still wants to run.
|
||||
const undoObsoletePause = async (id: string) => {
|
||||
const live = host.getTasks().find((candidate) => candidate.id === taskId);
|
||||
if (!live || ["completed", "cancelled", "failed", "interrupted"].includes(live.status)) return;
|
||||
if (isTransferOrRootPauseLatched(taskId, id)) return;
|
||||
try { await bridge!.resumeTransfer?.(id); } catch { /* best-effort */ }
|
||||
};
|
||||
const pauseOne = async (id: string) => {
|
||||
let result = await bridge!.pauseTransfer?.(id)
|
||||
?? { success: false, reason: "Pause unavailable" };
|
||||
const maxAttempts = task.isDirectory ? 4 : 16;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||
if (wasSuperseded(result)) return result;
|
||||
if (!isTransferControlEpochCurrent(taskId, pauseEpoch)) {
|
||||
if (result.success) {
|
||||
await undoObsoletePause(id);
|
||||
try { await bridge!.resumeTransfer?.(id); } catch { /* best-effort */ }
|
||||
}
|
||||
return { success: false, reason: "Pause superseded by resume" };
|
||||
}
|
||||
@@ -254,9 +186,6 @@ export async function softPauseTransfer(
|
||||
return result;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
if (!isTransferControlEpochCurrent(taskId, pauseEpoch)) {
|
||||
return { success: false, reason: "Pause superseded by newer control" };
|
||||
}
|
||||
result = await bridge!.pauseTransfer?.(id)
|
||||
?? { success: false, reason: "Pause unavailable" };
|
||||
}
|
||||
@@ -271,22 +200,17 @@ export async function softPauseTransfer(
|
||||
return { id, result: { success: false, reason: "Pause superseded by resume" } };
|
||||
}
|
||||
const result = await pauseOne(id);
|
||||
if (wasSuperseded(result)) return { id, result };
|
||||
const live = host.getTasks().find((candidate) => candidate.id === taskId);
|
||||
const userResumed = !pauseStillCurrent()
|
||||
|| !live
|
||||
|| (live.status !== "paused" && live.status !== "pausing");
|
||||
if (userResumed) {
|
||||
await undoObsoletePause(id);
|
||||
try { await bridge!.resumeTransfer?.(id); } catch { /* best-effort */ }
|
||||
return { id, result: { success: false, reason: "Pause superseded by resume" } };
|
||||
}
|
||||
return { id, result };
|
||||
}));
|
||||
if (!pauseStillCurrent()) return;
|
||||
if (pauseResults.some(({ result }) => wasSuperseded(result))) {
|
||||
reconcileSupersededControls(host, taskId, childIds, backendIds, pauseResults.map(({ result }) => result), pauseEpoch, "pause");
|
||||
return;
|
||||
}
|
||||
const after = host.getTasks().find((candidate) => candidate.id === taskId);
|
||||
if (!after || after.status === "cancelled") return;
|
||||
if (after.status !== "paused" && after.status !== "pausing") return;
|
||||
@@ -335,10 +259,6 @@ export async function softPauseTransfer(
|
||||
id,
|
||||
result: await pauseOne(id),
|
||||
})));
|
||||
if (pauseResults.some(({ result }) => wasSuperseded(result))) {
|
||||
reconcileSupersededControls(host, taskId, childIds, backendIds, pauseResults.map(({ result }) => result), pauseEpoch, "pause");
|
||||
return "noop";
|
||||
}
|
||||
const afterLivePause = host.getTasks().find((candidate) => candidate.id === taskId);
|
||||
if (afterLivePause?.status === "cancelled") {
|
||||
releaseTransferPauseTree(taskId, childIds);
|
||||
@@ -351,7 +271,7 @@ export async function softPauseTransfer(
|
||||
if (userAlreadyResumed) {
|
||||
for (const { id, result } of pauseResults) {
|
||||
if (result?.success) {
|
||||
await undoObsoletePause(id);
|
||||
try { await bridge.resumeTransfer?.(id); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
return "noop";
|
||||
@@ -364,7 +284,7 @@ export async function softPauseTransfer(
|
||||
if (!isTransferControlEpochCurrent(taskId, pauseEpoch)) {
|
||||
for (const { id, result } of pauseResults) {
|
||||
if (result?.success) {
|
||||
await undoObsoletePause(id);
|
||||
try { await bridge.resumeTransfer?.(id); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
return "noop";
|
||||
@@ -447,9 +367,8 @@ export async function softPauseTransfer(
|
||||
bridgeResults: pauseResults.map((row) => row.result),
|
||||
});
|
||||
for (const id of rollback.bridgeIdsToResume) {
|
||||
await undoObsoletePause(id);
|
||||
try { await bridge.resumeTransfer?.(id); } catch { /* best-effort */ }
|
||||
}
|
||||
if (!isTransferControlEpochCurrent(taskId, pauseEpoch)) return "noop";
|
||||
const hard = pauseResults.find(({ result }) =>
|
||||
result && !result.success && !isBenignPauseMiss(result.reason),
|
||||
)?.result;
|
||||
@@ -509,7 +428,7 @@ export async function softResumeTransfer(
|
||||
: [taskId, ...childIds.filter((id) => id !== taskId)];
|
||||
|
||||
// Supersede in-flight soft-drain / pauseWatch only — not a bridge lifecycle stamp.
|
||||
const resumeEpoch = bumpTransferControlEpoch(taskId);
|
||||
bumpTransferControlEpoch(taskId);
|
||||
releaseTransferPauseTree(taskId, releaseIds);
|
||||
for (const id of [taskId, ...releaseIds]) {
|
||||
try { globalSftpTransferScheduler.resume(id); } catch { /* best-effort */ }
|
||||
@@ -519,50 +438,17 @@ export async function softResumeTransfer(
|
||||
try { bridge = host.getBridge(); } catch { bridge = undefined; }
|
||||
|
||||
const resumeIds = treeIds.length > 0 ? treeIds : [taskId];
|
||||
const failedIds = new Set<string>();
|
||||
const results = await Promise.all(resumeIds.map(async (id) => {
|
||||
try {
|
||||
return await bridge?.resumeTransfer?.(id) ?? { success: false, reason: "Resume unavailable" };
|
||||
} catch (error) {
|
||||
// Rejections must pass the same stale-control check as ordinary failures.
|
||||
failedIds.add(id);
|
||||
return { success: false, reason: error instanceof Error && error.message ? error.message : "Resume request failed" };
|
||||
}
|
||||
}));
|
||||
const results = await Promise.all(resumeIds.map(async (id) =>
|
||||
bridge?.resumeTransfer?.(id) ?? { success: false, reason: "Resume unavailable" },
|
||||
));
|
||||
const after = host.getTasks().find((candidate) => candidate.id === taskId);
|
||||
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return { handled: true };
|
||||
if (!after || ["completed", "cancelled", "failed"].includes(after.status)) return { handled: true };
|
||||
if (after?.status === "cancelled") return { handled: true };
|
||||
|
||||
results.forEach((result, index) => {
|
||||
// A missing stream can belong to queued directory work. Verification and
|
||||
// other explicit failures still own a paused stream and must remain held.
|
||||
const benignMiss = /^(?:Transfer is no longer active|not active|Resume unavailable)$/i.test(result.reason || "");
|
||||
if (!result.success && !result.superseded && !benignMiss) failedIds.add(resumeIds[index]);
|
||||
});
|
||||
if (results.some(wasSuperseded)) {
|
||||
reconcileSupersededControls(host, taskId, releaseIds, resumeIds, results, resumeEpoch, "resume");
|
||||
}
|
||||
const successIds = resumeIds.filter((_, index) => results[index]?.success);
|
||||
const effectiveRunning = results.some((result) => result?.success || (result?.superseded && result.supersededBy === "resume"));
|
||||
const failedIndex = resumeIds.findIndex((id) => failedIds.has(id));
|
||||
const failureReason = failedIndex >= 0 ? results[failedIndex]?.reason || "Resume request failed" : undefined;
|
||||
if (failedIds.size > 0) {
|
||||
// A partial resume stays visibly active; hold only failed children. Do not
|
||||
// hide successfully running streams behind a paused root barrier.
|
||||
const heldIds = effectiveRunning ? [...failedIds] : [taskId, ...failedIds];
|
||||
for (const id of heldIds) {
|
||||
latchTransferPauseTree(id, []);
|
||||
try { globalSftpTransferScheduler.pause(id); } catch { /* best-effort */ }
|
||||
}
|
||||
if (!effectiveRunning) return { handled: false, reason: failureReason };
|
||||
}
|
||||
if (results.some(wasSuperseded) && failedIds.size === 0) {
|
||||
return { handled: true };
|
||||
}
|
||||
const walkAlive = isTransferWalkInFlight(taskId);
|
||||
// Directory walk can continue after unlatch without bridge resume on every child.
|
||||
// Single-file must not claim success when every bridge resume fails (stuck bar).
|
||||
if (!effectiveRunning) {
|
||||
if (successIds.length === 0) {
|
||||
if (task.isDirectory && walkAlive) {
|
||||
host.setTasks(paintTreeStatus(
|
||||
host.getTasks(),
|
||||
@@ -605,12 +491,6 @@ export async function softResumeTransfer(
|
||||
return candidate;
|
||||
}
|
||||
|
||||
if (failedIds.has(candidate.id)) {
|
||||
return { ...candidate, status: "paused" as const, speed: 0, error: failureReason };
|
||||
}
|
||||
const candidateResult = results[resumeIds.indexOf(candidate.id)];
|
||||
if (candidate.id !== taskId && candidateResult?.superseded) return candidate;
|
||||
|
||||
// Parent: transferring. Prefer bridge epoch; never wipe to undefined or a
|
||||
// late pause fanout re-applies "paused" (acceptsLifecycle treats missing as any).
|
||||
if (candidate.id === taskId) {
|
||||
@@ -622,7 +502,7 @@ export async function softResumeTransfer(
|
||||
return {
|
||||
...candidate,
|
||||
status: "transferring" as const,
|
||||
error: failureReason,
|
||||
error: undefined,
|
||||
reconnectRequired: false,
|
||||
pauseUnavailableReason: undefined,
|
||||
phase: undefined,
|
||||
|
||||
@@ -41,106 +41,6 @@ const rootTask = (): TransferTask => ({
|
||||
progressMode: "files",
|
||||
});
|
||||
|
||||
for (const newestAction of ["pause", "cancel", "resume", "remote-resume", "remote-child-resume"] as const) {
|
||||
test(`directory pause watcher respects a newer ${newestAction}`, async () => {
|
||||
const { bumpTransferControlEpoch, resetTransferControlEpochsForTests } = await import("./transferControlEpoch");
|
||||
const { latchTransferPauseTree, isTransferPauseLatched, resetTransferPauseLatchesForTests } = await import("./transferPauseLatch");
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
const previousLocalStorage = (globalThis as { localStorage?: unknown }).localStorage;
|
||||
const previousActEnvironment = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const root = { ...rootTask(), id: `watch-root-${newestAction}` };
|
||||
let tasks: TransferTask[] = [root];
|
||||
const transfersRef = { current: tasks };
|
||||
const cancelledTasksRef = { current: new Set<string>() };
|
||||
const pausedTasksRef = { current: new Set<string>() };
|
||||
let pauseCalls = 0;
|
||||
let finishPause!: (value: { success: boolean; superseded?: boolean; supersededBy?: "resume" }) => void;
|
||||
let finishTransfer!: (value: { error?: string }) => void;
|
||||
let pauseStarted!: () => void;
|
||||
const pauseGate = new Promise<void>((resolve) => { pauseStarted = resolve; });
|
||||
const transferGate = new Promise<{ error?: string }>((resolve) => { finishTransfer = resolve; });
|
||||
let resumeCalls = 0;
|
||||
let childId = "";
|
||||
(globalThis as { window?: unknown }).window = { netcatty: {
|
||||
mkdirLocal: async () => undefined,
|
||||
statLocal: async () => ({ type: "directory" }),
|
||||
startStreamTransfer: (options: { transferId: string }) => {
|
||||
childId = options.transferId;
|
||||
// Reproduce a stream arming during the parent's initial pause round.
|
||||
bumpTransferControlEpoch(root.id);
|
||||
latchTransferPauseTree(root.id, [childId]);
|
||||
if (newestAction === "remote-resume" || newestAction === "remote-child-resume") { pausedTasksRef.current.add(root.id); pausedTasksRef.current.add(childId); }
|
||||
return transferGate;
|
||||
},
|
||||
pauseTransfer: () => {
|
||||
pauseCalls++;
|
||||
pauseStarted();
|
||||
return new Promise<{ success: boolean; superseded?: boolean; supersededBy?: "resume" }>((resolve) => { finishPause = resolve; });
|
||||
},
|
||||
resumeTransfer: async () => { resumeCalls++; return { success: true }; },
|
||||
} };
|
||||
(globalThis as { localStorage?: unknown }).localStorage = {
|
||||
getItem: () => null, setItem: () => undefined, removeItem: () => undefined,
|
||||
};
|
||||
let operations: ReturnType<typeof useSftpDirectoryTransferOps> | undefined;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
let running: Promise<unknown> | undefined;
|
||||
const Probe = () => {
|
||||
operations = useSftpDirectoryTransferOps({
|
||||
ownerId: `watch-owner-${newestAction}`, cancelledTasksRef,
|
||||
pausedTasksRef,
|
||||
waitUntilTransferResumed: async () => undefined,
|
||||
activeChildIdsRef: { current: new Map() }, transfersRef,
|
||||
setTransfers: (update) => {
|
||||
tasks = typeof update === "function" ? update(tasks) : update;
|
||||
transfersRef.current = tasks;
|
||||
},
|
||||
listLocalFiles: async () => [], listRemoteFiles: async () => [fileEntry("file.txt")],
|
||||
});
|
||||
return null;
|
||||
};
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
assert.ok(operations);
|
||||
running = operations.transferDirectory(root, "source-sftp", null, false, true, "auto", "auto", root.id);
|
||||
await pauseGate;
|
||||
if (newestAction === "remote-resume" || newestAction === "remote-child-resume") {
|
||||
// Another window's resumed event updates rows without bumping this local epoch.
|
||||
transfersRef.current = tasks = tasks.map(task => ({ ...task, status: task.id === root.id && newestAction === "remote-child-resume" ? "paused" : "transferring", lifecycleEpoch: 8 }));
|
||||
finishPause({ success: false, superseded: true, supersededBy: "resume" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 110));
|
||||
assert.equal(pauseCalls, 1, "superseded watcher must not re-pause the resumed backend");
|
||||
assert.equal(isTransferPauseLatched(root.id), newestAction === "remote-child-resume");
|
||||
assert.equal(isTransferPauseLatched(childId), false);
|
||||
assert.equal(pausedTasksRef.current.has(root.id), newestAction === "remote-child-resume");
|
||||
assert.equal(pausedTasksRef.current.has(childId), false);
|
||||
return;
|
||||
}
|
||||
bumpTransferControlEpoch(root.id);
|
||||
if (newestAction === "pause") latchTransferPauseTree(root.id, [childId]);
|
||||
else {
|
||||
if (newestAction === "cancel") cancelledTasksRef.current.add(root.id);
|
||||
resetTransferPauseLatchesForTests();
|
||||
}
|
||||
finishPause({ success: true });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(resumeCalls, newestAction === "resume" ? 1 : 0, "compensation must follow the latest decision");
|
||||
} finally {
|
||||
pausedTasksRef.current.clear();
|
||||
resetTransferPauseLatchesForTests();
|
||||
finishPause?.({ success: true });
|
||||
finishTransfer({});
|
||||
await running?.catch(() => {});
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
resetTransferControlEpochsForTests();
|
||||
(globalThis as { window?: unknown }).window = previousWindow;
|
||||
(globalThis as { localStorage?: unknown }).localStorage = previousLocalStorage;
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("directory transfer discovers each directory once with bounded listing concurrency", async () => {
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
const previousLocalStorage = (globalThis as { localStorage?: unknown }).localStorage;
|
||||
@@ -333,74 +233,3 @@ test("live directory download rejects a Windows backslash traversal entry", asyn
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
}
|
||||
});
|
||||
|
||||
test("live folder settles a superseded child whose completed row was compacted", async () => {
|
||||
const { sftpTransferCenterStore } = await import("../sftpTransferCenterStore");
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||
const previousActEnvironment = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
|
||||
});
|
||||
const root = { ...rootTask(), id: "live-compacted-root", startTime: Date.now() };
|
||||
let tasks: TransferTask[] = [root];
|
||||
const transfersRef = { current: tasks };
|
||||
const cancelledTasksRef = { current: new Set<string>() };
|
||||
let childId: string | undefined;
|
||||
(globalThis as { window?: unknown }).window = { netcatty: {
|
||||
mkdirLocal: async () => undefined,
|
||||
statLocal: async () => ({ type: "directory" }),
|
||||
startStreamTransfer: async (options: { transferId: string }) => {
|
||||
childId = options.transferId;
|
||||
sftpTransferCenterStore.publishOwner("live-compacted-owner", tasks);
|
||||
// The winning invocation has already completed; history compaction drops its row.
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "completed", transferId: childId, transferred: 1, totalBytes: 1, lifecycleEpoch: 0,
|
||||
});
|
||||
return { superseded: true };
|
||||
},
|
||||
} };
|
||||
let operations: ReturnType<typeof useSftpDirectoryTransferOps> | undefined;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
let running: Promise<number> | undefined;
|
||||
const Probe = () => {
|
||||
operations = useSftpDirectoryTransferOps({
|
||||
ownerId: "live-compacted-owner", cancelledTasksRef,
|
||||
pausedTasksRef: { current: new Set() }, waitUntilTransferResumed: async () => undefined,
|
||||
activeChildIdsRef: { current: new Map() }, transfersRef,
|
||||
setTransfers: (update) => {
|
||||
tasks = typeof update === "function" ? update(tasks) : update;
|
||||
transfersRef.current = tasks;
|
||||
},
|
||||
listLocalFiles: async () => [], listRemoteFiles: async () => [fileEntry("file.txt")],
|
||||
});
|
||||
return null;
|
||||
};
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
assert.ok(operations);
|
||||
running = operations.transferDirectory(root, "source-sftp", null, false, true, "auto", "auto", root.id);
|
||||
const result = await Promise.race([
|
||||
running,
|
||||
new Promise<"still-waiting">((resolve) => setTimeout(() => resolve("still-waiting"), 450)),
|
||||
]);
|
||||
assert.ok(childId);
|
||||
assert.equal(sftpTransferCenterStore.getTask(childId), undefined);
|
||||
assert.equal(sftpTransferCenterStore.getTask(root.id)?.directoryResumeCheckpoint?.completedEntries, 1);
|
||||
assert.notEqual(result, "still-waiting", "compacted completion must settle the live folder");
|
||||
assert.equal(result, 0);
|
||||
} finally {
|
||||
cancelledTasksRef.current.add(root.id);
|
||||
await running?.catch(() => {});
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
sftpTransferCenterStore.patchTask(root.id, { status: "completed" });
|
||||
sftpTransferCenterStore.dismiss(root.id);
|
||||
(globalThis as { window?: unknown }).window = previousWindow;
|
||||
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
|
||||
else Reflect.deleteProperty(globalThis, "localStorage");
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { reconcileSupersededControls } from "./globalSftpTransferControl";
|
||||
import { runTransferAndWaitForOwner } from "./waitForTransferOwner";
|
||||
import { useCallback, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
|
||||
import type { Host, SftpFileEntry, SftpFilenameEncoding, TransferStatus, TransferTask } from "../../../domain/models";
|
||||
import {
|
||||
@@ -414,11 +412,7 @@ export function useSftpDirectoryTransferOps({
|
||||
}
|
||||
// Await the invoke result — cancel resolves with { error } and may
|
||||
// not fire onComplete/onError after preload clears listeners.
|
||||
const transferPromise = runTransferAndWaitForOwner(
|
||||
task,
|
||||
() => netcattyBridge.require().startStreamTransfer!(options),
|
||||
() => isCancelledLocalOrGlobal(cancelledTasksRef, rootTaskId, task.id),
|
||||
);
|
||||
const transferPromise = netcattyBridge.require().startStreamTransfer!(options);
|
||||
// Streams that arm after the parent pause round never receive the
|
||||
// initial pauseTransfer. Keep pausing while the folder is latched.
|
||||
// Capture epoch per attempt so Resume (epoch bump) undoes a late pause.
|
||||
@@ -433,33 +427,12 @@ export function useSftpDirectoryTransferOps({
|
||||
)
|
||||
) {
|
||||
const epochAtAttempt = getTransferControlEpoch(rootTaskId);
|
||||
const childEpochAtAttempt = getTransferControlEpoch(task.id);
|
||||
try {
|
||||
const result = await netcattyBridge.get()?.pauseTransfer?.(task.id);
|
||||
if (
|
||||
result?.superseded && result.supersededBy === "resume"
|
||||
&& isTransferControlEpochCurrent(rootTaskId, epochAtAttempt)
|
||||
&& getTransferControlEpoch(task.id) === childEpochAtAttempt
|
||||
&& !isCancelledLocalOrGlobal(cancelledTasksRef, rootTaskId, task.id)
|
||||
) {
|
||||
const rootAlreadyRunning = transfersRef.current.find((row) => row.id === rootTaskId)?.status === "transferring";
|
||||
reconcileSupersededControls({
|
||||
getTasks: () => transfersRef.current,
|
||||
setTasks: (next) => setTransfers(next),
|
||||
getBridge: () => netcattyBridge.get(),
|
||||
}, rootAlreadyRunning ? rootTaskId : task.id, rootAlreadyRunning ? [task.id] : [], [task.id], [result],
|
||||
epochAtAttempt, "pause", rootTaskId);
|
||||
if (rootAlreadyRunning) pausedTasksRef.current.delete(rootTaskId);
|
||||
pausedTasksRef.current.delete(task.id);
|
||||
break;
|
||||
}
|
||||
// Compensate only if the newest decision still wants this file running.
|
||||
// A new pause or cancellation also changes the epoch.
|
||||
// Resume won while we were awaiting pause — undo.
|
||||
if (
|
||||
result?.success
|
||||
&& !isTransferControlEpochCurrent(rootTaskId, epochAtAttempt)
|
||||
&& !isPauseLatched(rootTaskId, task.id)
|
||||
&& !isCancelledLocalOrGlobal(cancelledTasksRef, rootTaskId, task.id)
|
||||
) {
|
||||
try {
|
||||
await netcattyBridge.get()?.resumeTransfer?.(task.id);
|
||||
@@ -477,6 +450,33 @@ export function useSftpDirectoryTransferOps({
|
||||
watchPaused = false;
|
||||
await pauseWatch.catch(() => {});
|
||||
}
|
||||
// Same-id retry stole ownership while OPEN was pending. The live
|
||||
// owner still drives progress/completion events; this invoke must
|
||||
// not mark the child completed or failed (Codex P2 on b17f64e9).
|
||||
if (result?.superseded === true) {
|
||||
// Wait for the live same-id owner only — no fixed deadline so
|
||||
// long transfers are not failed while still running (Codex P2).
|
||||
for (;;) {
|
||||
if (
|
||||
cancelledTasksRef.current.has(task.id)
|
||||
|| cancelledTasksRef.current.has(rootTaskId)
|
||||
) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
const latest = sftpTransferCenterStore.getTask(task.id)
|
||||
?? transfersRef.current.find((candidate) => candidate.id === task.id);
|
||||
const status = latest?.status;
|
||||
if (status === "completed") break;
|
||||
if (status === "failed") {
|
||||
throw new Error(latest?.error || "Transfer failed");
|
||||
}
|
||||
if (status === "cancelled") {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (result?.error || result?.cancelled) {
|
||||
throw new Error(result.error || "Transfer cancelled");
|
||||
}
|
||||
@@ -840,9 +840,7 @@ export function useSftpDirectoryTransferOps({
|
||||
if (row.status === "paused" || row.status === "pausing" || isPauseLatched(rootTaskId)) {
|
||||
return { ...row, speed: 0 };
|
||||
}
|
||||
const completed = (row.directoryResumeCheckpoint?.completedEntries ?? 0)
|
||||
+ base.filter((child) => child.parentTaskId === rootTaskId && child.status === "completed").length;
|
||||
return { ...row, transferredBytes: completed };
|
||||
return { ...row, transferredBytes: row.transferredBytes + 1 };
|
||||
});
|
||||
});
|
||||
return;
|
||||
@@ -926,12 +924,6 @@ export function useSftpDirectoryTransferOps({
|
||||
|| parentRow.status === "pausing"
|
||||
|| isPauseLatched(rootTaskId)
|
||||
);
|
||||
// Background completion may have already compacted this child and
|
||||
// advanced the checkpoint. Count retained + compacted completions
|
||||
// instead of incrementing the same file again when invoke settles.
|
||||
const completed = (parentRow?.directoryResumeCheckpoint?.completedEntries ?? 0)
|
||||
+ prev.filter((row) => row.parentTaskId === rootTaskId
|
||||
&& (row.status === "completed" || row.id === fileId)).length;
|
||||
const updated = prev.map((t) => {
|
||||
if (t.id === fileId) {
|
||||
return { ...t, status: "completed" as TransferStatus, endTime: Date.now(), transferredBytes: t.totalBytes };
|
||||
@@ -942,7 +934,7 @@ export function useSftpDirectoryTransferOps({
|
||||
}
|
||||
return {
|
||||
...t,
|
||||
transferredBytes: completed,
|
||||
transferredBytes: t.transferredBytes + 1,
|
||||
speed: t.speed,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,9 +8,6 @@
|
||||
|
||||
const pausedIds = new Set<string>();
|
||||
const barriers = new Map<string, { promise: Promise<void>; resolve: () => void }>();
|
||||
// A completed child's history row can disappear while its worker still waits
|
||||
// for the folder to resume. Keep the paused tree independent of UI history.
|
||||
const pausedChildren = new Map<string, Set<string>>();
|
||||
|
||||
function ensureBarrier(taskId: string): { promise: Promise<void>; resolve: () => void } {
|
||||
let barrier = barriers.get(taskId);
|
||||
@@ -48,18 +45,11 @@ export function releaseTransferPause(taskId: string): void {
|
||||
}
|
||||
|
||||
export function releaseTransferPauseTree(rootTaskId: string, childIds: readonly string[] = []): void {
|
||||
const children = new Set([...childIds, ...(pausedChildren.get(rootTaskId) ?? [])]);
|
||||
pausedChildren.delete(rootTaskId);
|
||||
releaseTransferPause(rootTaskId);
|
||||
for (const id of children) releaseTransferPause(id);
|
||||
for (const id of childIds) releaseTransferPause(id);
|
||||
}
|
||||
|
||||
export function latchTransferPauseTree(rootTaskId: string, childIds: readonly string[] = []): void {
|
||||
if (childIds.length > 0) {
|
||||
const children = pausedChildren.get(rootTaskId) ?? new Set<string>();
|
||||
for (const id of childIds) children.add(id);
|
||||
pausedChildren.set(rootTaskId, children);
|
||||
}
|
||||
latchTransferPause(rootTaskId);
|
||||
for (const id of childIds) latchTransferPause(id);
|
||||
}
|
||||
@@ -87,7 +77,6 @@ export function resetTransferPauseLatchesForTests(): void {
|
||||
for (const id of ids) releaseTransferPause(id);
|
||||
pausedIds.clear();
|
||||
barriers.clear();
|
||||
pausedChildren.clear();
|
||||
}
|
||||
|
||||
export function listTransferPauseLatchesForTests(): string[] {
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { TransferTask } from "../../../domain/models";
|
||||
import { createSftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
|
||||
function child(): TransferTask {
|
||||
return {
|
||||
id: "child", parentTaskId: "root", directoryEntryIndex: 0, directoryEntryIdentity: "a".repeat(64),
|
||||
sourcePath: "/source/a", targetPath: "/target/a", fileName: "a",
|
||||
sourceConnectionId: "local", targetConnectionId: "remote", direction: "upload",
|
||||
totalBytes: 1, transferredBytes: 0, speed: 0, startTime: 0, isDirectory: false, status: "transferring",
|
||||
};
|
||||
}
|
||||
|
||||
for (const status of ["completed", "failed", "cancelled"] as const) {
|
||||
test(`settlement observation retains exact ${status} before history pruning`, () => {
|
||||
const store = createSftpTransferCenterStore();
|
||||
const task = child();
|
||||
store.upsertTasks([{ ...task, id: "root", parentTaskId: undefined, isDirectory: true }, task]);
|
||||
const observation = store.observeTaskSettlement(task);
|
||||
store.patchTask(task.id, { status, error: status === "failed" ? "write failed" : undefined });
|
||||
assert.equal(observation.read()?.status, status);
|
||||
if (status === "completed") assert.equal(store.getTask(task.id), undefined);
|
||||
observation.dispose();
|
||||
assert.equal(observation.read(), undefined);
|
||||
});
|
||||
}
|
||||
|
||||
test("another file reusing an id and index is not completion evidence", () => {
|
||||
const store = createSftpTransferCenterStore();
|
||||
const task = child();
|
||||
store.upsertTasks([task]);
|
||||
const observation = store.observeTaskSettlement(task);
|
||||
store.upsertTasks([{ ...task, directoryEntryIdentity: "b".repeat(64), status: "completed" }]);
|
||||
assert.equal(observation.read(), undefined);
|
||||
observation.dispose();
|
||||
});
|
||||
|
||||
test("a disposed observer receives no later completion", () => {
|
||||
const store = createSftpTransferCenterStore();
|
||||
const task = child();
|
||||
store.upsertTasks([task]);
|
||||
const observation = store.observeTaskSettlement(task);
|
||||
observation.dispose();
|
||||
store.patchTask(task.id, { status: "completed" });
|
||||
assert.equal(observation.read(), undefined);
|
||||
});
|
||||
|
||||
test("explicit dispatch refreshes failed identity without overwriting checkpoints", () => {
|
||||
const store = createSftpTransferCenterStore();
|
||||
const task = { ...child(), status: "failed" as const, checkpointBytes: 7 };
|
||||
store.upsertTasks([task]);
|
||||
const next = { ...task, directoryEntryIdentity: "b".repeat(64), checkpointBytes: 0 };
|
||||
assert.equal(store.admitTaskRun(next), "ready");
|
||||
assert.equal(store.getTask(task.id)?.status, "transferring");
|
||||
assert.equal(store.getTask(task.id)?.directoryEntryIdentity, next.directoryEntryIdentity);
|
||||
assert.equal(store.getTask(task.id)?.checkpointBytes, 7);
|
||||
});
|
||||
|
||||
for (const status of ["cancelled", "completed", "paused", "pausing"] as const) {
|
||||
test(`dispatch cannot revive a retained ${status} row`, () => {
|
||||
const store = createSftpTransferCenterStore();
|
||||
const task = child();
|
||||
store.upsertTasks([
|
||||
{ ...task, id: "root", parentTaskId: undefined },
|
||||
{ ...task, status },
|
||||
]);
|
||||
assert.equal(store.admitTaskRun(task), status === "pausing" ? "paused" : status);
|
||||
assert.equal(store.getTask(task.id)?.status, status);
|
||||
});
|
||||
}
|
||||
|
||||
test("dispatch preserves active lifecycle guards and avoids replacing unchanged rows", () => {
|
||||
const store = createSftpTransferCenterStore();
|
||||
const task = { ...child(), lifecycleEpoch: 9 };
|
||||
store.upsertTasks([task]);
|
||||
const before = store.getTask(task.id);
|
||||
assert.equal(store.admitTaskRun(task), "ready");
|
||||
assert.equal(store.getTask(task.id), before);
|
||||
assert.equal(store.admitTaskRun({ ...task, directoryEntryIdentity: "b".repeat(64) }), "ready");
|
||||
assert.equal(store.getTask(task.id)?.lifecycleEpoch, 9);
|
||||
});
|
||||
|
||||
test("dispatch rejects a later pause or cancellation before changing identity", async (t) => {
|
||||
const { latchTransferPause, resetTransferPauseLatchesForTests } = await import("./transferPauseLatch");
|
||||
const { markTransferCancelledTree, settleTransferCancelTree } = await import("./transferCancelLatch");
|
||||
const store = createSftpTransferCenterStore();
|
||||
const task = { ...child(), status: "failed" as const };
|
||||
store.upsertTasks([task]);
|
||||
t.after(() => { resetTransferPauseLatchesForTests(); settleTransferCancelTree("root", [task.id]); });
|
||||
latchTransferPause("root");
|
||||
assert.equal(store.admitTaskRun(task), "paused");
|
||||
resetTransferPauseLatchesForTests();
|
||||
markTransferCancelledTree("root", [task.id]);
|
||||
assert.equal(store.admitTaskRun(task), "cancelled");
|
||||
assert.equal(store.getTask(task.id)?.status, "failed");
|
||||
});
|
||||
|
||||
for (const nextStatus of ["transferring", "completed", "cancelled"] as const) {
|
||||
test(`dispatch waits through a later pause until ${nextStatus}`, async (t) => {
|
||||
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
|
||||
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
|
||||
const task = { ...child(), id: `paused-admission-${nextStatus}`, parentTaskId: undefined };
|
||||
store.upsertTasks([{ ...task, status: "paused" }]);
|
||||
let starts = 0;
|
||||
let abort = false;
|
||||
const running = runTransferAndWaitForOwner(task, async () => { starts += 1; return {}; }, () => abort);
|
||||
// Attach rejection handling before the cancellation event is delivered.
|
||||
const settled = running.then(() => "completed", (error: Error) => error.message);
|
||||
t.after(async () => { abort = true; await settled; store.dismiss(task.id); });
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.equal(starts, 0);
|
||||
assert.equal(store.getTask(task.id)?.status, "paused");
|
||||
if (nextStatus !== "cancelled") store.patchTask(task.id, { status: "transferring", lifecycleEpoch: 1 });
|
||||
if (nextStatus === "cancelled") {
|
||||
const { markTransferCancelledTree, settleTransferCancelTree } = await import("./transferCancelLatch");
|
||||
markTransferCancelledTree(task.id, []);
|
||||
t.after(() => settleTransferCancelTree(task.id, []));
|
||||
} else store.patchTask(task.id, { status: nextStatus });
|
||||
const outcome = await Promise.race([settled, new Promise((resolve) => setTimeout(() => resolve("still-waiting"), 1000))]);
|
||||
assert.equal(outcome, nextStatus === "cancelled" ? "Transfer cancelled" : "completed");
|
||||
assert.equal(starts, nextStatus === "transferring" ? 1 : 0);
|
||||
});
|
||||
}
|
||||
|
||||
test("completed dispatch consumes only exact identity and never restarts", async (t) => {
|
||||
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
|
||||
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
|
||||
const task = { ...child(), id: "completed-admission", parentTaskId: undefined };
|
||||
store.upsertTasks([{ ...task, status: "completed" }]);
|
||||
t.after(() => store.dismiss(task.id));
|
||||
const start = async () => { assert.fail("completed transfer must not restart"); };
|
||||
await runTransferAndWaitForOwner(task, start, () => false);
|
||||
await assert.rejects(runTransferAndWaitForOwner({ ...task, directoryEntryIdentity: "b".repeat(64) }, start, () => false), /identity changed/);
|
||||
assert.equal(store.getTask(task.id)?.status, "completed");
|
||||
});
|
||||
|
||||
test("a resumed retry does not inherit failed settlement captured while admission was paused", async (t) => {
|
||||
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
|
||||
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
|
||||
const { latchTransferPause, resetTransferPauseLatchesForTests } = await import("./transferPauseLatch");
|
||||
const task = { ...child(), id: "failed-paused-admission", parentTaskId: undefined };
|
||||
store.upsertTasks([{ ...task, status: "failed", error: "previous attempt failed" }]);
|
||||
latchTransferPause(task.id);
|
||||
let abort = false;
|
||||
const running = runTransferAndWaitForOwner(task, async () => {
|
||||
store.patchTask(task.id, { status: "completed" });
|
||||
return { superseded: true };
|
||||
}, () => abort);
|
||||
const settled = running.then(() => "completed", (error: Error) => error.message);
|
||||
t.after(async () => { abort = true; resetTransferPauseLatchesForTests(); await settled; store.dismiss(task.id); });
|
||||
// An unrelated lifecycle publication captures the previous failed row while waiting.
|
||||
store.upsertTasks([{ ...child(), id: "unrelated-admission", parentTaskId: undefined }]);
|
||||
t.after(() => store.dismiss("unrelated-admission"));
|
||||
resetTransferPauseLatchesForTests();
|
||||
assert.equal(await Promise.race([settled, new Promise((resolve) => setTimeout(() => resolve("still-waiting"), 1000))]), "completed");
|
||||
});
|
||||
|
||||
test("fresh directory recovery authorizes only an unchanged retained pause under an active parent", () => {
|
||||
const store = createSftpTransferCenterStore();
|
||||
const task = child();
|
||||
store.upsertTasks([{ ...task, id: "root", parentTaskId: undefined, isDirectory: true, status: "pending" }, { ...task, status: "paused", lifecycleEpoch: 4 }]);
|
||||
const paused = store.getTask(task.id)!;
|
||||
assert.equal(store.admitTaskRun(task), "paused", "ordinary live dispatch must respect cross-window pause");
|
||||
assert.equal(store.admitTaskRun(task, paused), "ready");
|
||||
store.patchTask(task.id, { status: "paused", lifecycleEpoch: 5 });
|
||||
assert.equal(store.admitTaskRun(task, paused), "paused", "newer child pause invalidates fresh recovery permission");
|
||||
const currentPause = store.getTask(task.id)!;
|
||||
store.patchTask("root", { status: "paused", lifecycleEpoch: 6 });
|
||||
assert.equal(store.admitTaskRun(task, currentPause), "paused", "parent pause still wins");
|
||||
});
|
||||
@@ -37,7 +37,10 @@ import {
|
||||
releaseTransferPauseTree,
|
||||
waitUntilTransferPauseReleased,
|
||||
} from "./transferPauseLatch";
|
||||
import { bumpTransferControlEpoch } from "./transferControlEpoch";
|
||||
import {
|
||||
bumpTransferControlEpoch,
|
||||
settleTransferControlEpochTree,
|
||||
} from "./transferControlEpoch";
|
||||
import { transferRuntime } from "./transferRuntime";
|
||||
import {
|
||||
clearTransferCancelled,
|
||||
@@ -45,6 +48,7 @@ import {
|
||||
isTransferCancelledFlag,
|
||||
markTransferCancelled,
|
||||
markTransferCancelledTree,
|
||||
settleTransferCancelTree,
|
||||
} from "./transferCancelLatch";
|
||||
import type { TransferResult, UseSftpTransfersParams, UseSftpTransfersResult } from "./useSftpTransfers.types";
|
||||
import type { TransferConnectionLease } from "./transferConnectionPool";
|
||||
@@ -1805,109 +1809,127 @@ export const useSftpTransfers = ({
|
||||
return "attention";
|
||||
}
|
||||
|
||||
const patchDownload = (updates: Partial<TransferTask>) => {
|
||||
transferRuntime.patchTask(task.id, updates);
|
||||
const canonical = transferRuntime.getTask(task.id);
|
||||
if (canonical) setTransfers((prev) => prev.map((row) => row.id === task.id ? canonical : row));
|
||||
};
|
||||
const executeDownload = async (): Promise<TransferStatus> => {
|
||||
const sourceEncoding = params.sourceEncoding ?? "auto";
|
||||
// Mutable counter to track child failures outside React state,
|
||||
// so the final status check doesn't depend on render timing.
|
||||
let childFailureCount = 0;
|
||||
const sourceEncoding = params.sourceEncoding ?? "auto";
|
||||
// Mutable counter to track child failures outside React state,
|
||||
// so the final status check doesn't depend on render timing.
|
||||
let childFailureCount = 0;
|
||||
|
||||
// Dedicated pool only when host id is known — never fall back to browse
|
||||
// (tab close would kill the download and freeze the global center).
|
||||
let sourceWorkLease: TransferConnectionLease | null = null;
|
||||
let workingSourceSftpId = params.sftpId;
|
||||
try {
|
||||
if (acquireTransferSession && params.sourceHostId) {
|
||||
sourceWorkLease = await acquireTransferSession(
|
||||
params.sourceHostId,
|
||||
`${task.id}:work-source`,
|
||||
);
|
||||
workingSourceSftpId = sourceWorkLease.sftpId;
|
||||
}
|
||||
await waitUntilTransferResumed(task.id);
|
||||
if (cancelledTasksRef.current.has(task.id)) throw new Error("Transfer cancelled");
|
||||
if (params.isDirectory) patchDownload({ status: "transferring", error: undefined });
|
||||
// Dedicated pool only when host id is known — never fall back to browse
|
||||
// (tab close would kill the download and freeze the global center).
|
||||
let sourceWorkLease: TransferConnectionLease | null = null;
|
||||
let workingSourceSftpId = params.sftpId;
|
||||
if (acquireTransferSession && params.sourceHostId) {
|
||||
sourceWorkLease = await acquireTransferSession(
|
||||
params.sourceHostId,
|
||||
`${task.id}:work-source`,
|
||||
);
|
||||
workingSourceSftpId = sourceWorkLease.sftpId;
|
||||
}
|
||||
|
||||
if (params.isDirectory) {
|
||||
childFailureCount = await transferDirectory(
|
||||
task,
|
||||
workingSourceSftpId,
|
||||
null, // targetSftpId = null (local)
|
||||
false, // sourceIsLocal = false
|
||||
true, // targetIsLocal = true
|
||||
sourceEncoding,
|
||||
"auto", // targetEncoding
|
||||
task.id,
|
||||
false, // sameHost
|
||||
0, // symlinkDepth
|
||||
true, // followSymlinks — download should expand symlink dirs
|
||||
);
|
||||
} else {
|
||||
await transferFile(
|
||||
task,
|
||||
workingSourceSftpId,
|
||||
null,
|
||||
false,
|
||||
true,
|
||||
sourceEncoding,
|
||||
"auto",
|
||||
task.id,
|
||||
);
|
||||
}
|
||||
|
||||
// Runtime owns lifecycle while the walk is registered. Publish the
|
||||
// final root there before mirroring it to the panel.
|
||||
const rows = sftpTransferCenterStore.getSnapshot().tasks;
|
||||
const liveParent = transferRuntime.getTask(task.id) ?? task;
|
||||
const completedCount = (liveParent.directoryResumeCheckpoint?.completedEntries ?? 0)
|
||||
+ rows.filter((row) => row.parentTaskId === task.id && row.status === "completed").length;
|
||||
const resolved = resolveDirectDirectoryDownloadFinalStatus({
|
||||
parentCancelled: liveParent.status === "cancelled" || cancelledTasksRef.current.has(task.id),
|
||||
childFailureCount,
|
||||
});
|
||||
const finalTotal = liveParent.totalBytes > 0 ? liveParent.totalBytes : completedCount;
|
||||
patchDownload({
|
||||
status: resolved.status,
|
||||
error: resolved.error,
|
||||
endTime: Date.now(),
|
||||
speed: 0,
|
||||
...(resolved.status === "cancelled" ? {} : {
|
||||
totalBytes: finalTotal,
|
||||
transferredBytes: resolved.status === "failed" ? completedCount : finalTotal,
|
||||
}),
|
||||
});
|
||||
const appliedStatus = transferRuntime.getTask(task.id)?.status ?? resolved.status;
|
||||
activeChildIdsRef.current.delete(task.id);
|
||||
return appliedStatus;
|
||||
} catch (err) {
|
||||
activeChildIdsRef.current.delete(task.id);
|
||||
const isCancelled = cancelledTasksRef.current.has(task.id);
|
||||
// Clean up cancelled task tracking to prevent memory leak
|
||||
if (isCancelled) cancelledTasksRef.current.delete(task.id);
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
patchDownload({
|
||||
status: isCancelled ? "cancelled" : "failed",
|
||||
error: isCancelled ? undefined : errMsg,
|
||||
endTime: Date.now(),
|
||||
speed: 0,
|
||||
});
|
||||
return isCancelled ? "cancelled" : "failed";
|
||||
} finally {
|
||||
sourceWorkLease?.release();
|
||||
sourceWorkLease = null;
|
||||
try {
|
||||
if (params.isDirectory) {
|
||||
childFailureCount = await transferDirectory(
|
||||
task,
|
||||
workingSourceSftpId,
|
||||
null, // targetSftpId = null (local)
|
||||
false, // sourceIsLocal = false
|
||||
true, // targetIsLocal = true
|
||||
sourceEncoding,
|
||||
"auto", // targetEncoding
|
||||
task.id,
|
||||
false, // sameHost
|
||||
0, // symlinkDepth
|
||||
true, // followSymlinks — download should expand symlink dirs
|
||||
);
|
||||
} else {
|
||||
await transferFile(
|
||||
task,
|
||||
workingSourceSftpId,
|
||||
null,
|
||||
false,
|
||||
true,
|
||||
sourceEncoding,
|
||||
"auto",
|
||||
task.id,
|
||||
);
|
||||
}
|
||||
};
|
||||
let result: TransferStatus = "failed";
|
||||
await runTrackedTransferAttempt(inFlightTransferIdsRef.current, task.id, () =>
|
||||
transferRuntime.runWalk(task.id, async () => {
|
||||
result = await executeDownload();
|
||||
}),
|
||||
);
|
||||
return result;
|
||||
|
||||
// Use childFailureCount (tracked outside React state) to determine
|
||||
// final status reliably, regardless of render timing.
|
||||
// Cancel must win: transferDirectory counts cancelled children as errors,
|
||||
// but cancelTransfer already marked the parent cancelled — do not demote
|
||||
// it to failed with "Some files failed to transfer".
|
||||
// Re-read cancel inside the state update so a cancel that lands after
|
||||
// transferDirectory returns cannot be overwritten by completed/failed.
|
||||
let appliedStatus: TransferStatus = "completed";
|
||||
setTransfers((prev) => {
|
||||
const liveParent = prev.find((candidate) => candidate.id === task.id);
|
||||
const completedCount = (liveParent?.directoryResumeCheckpoint?.completedEntries ?? 0) + prev.filter(
|
||||
(t) => t.parentTaskId === task.id && t.status === "completed",
|
||||
).length;
|
||||
return prev.map((t) => {
|
||||
if (t.id !== task.id) return t;
|
||||
const parentCancelled = t.status === "cancelled"
|
||||
|| cancelledTasksRef.current.has(task.id);
|
||||
const resolved = resolveDirectDirectoryDownloadFinalStatus({
|
||||
parentCancelled,
|
||||
childFailureCount,
|
||||
});
|
||||
appliedStatus = resolved.status;
|
||||
if (resolved.status === "cancelled") {
|
||||
cancelledTasksRef.current.delete(task.id);
|
||||
return {
|
||||
...t,
|
||||
status: "cancelled" as TransferStatus,
|
||||
error: undefined,
|
||||
endTime: Date.now(),
|
||||
// Keep partial progress — do not look 100% complete when cancelled.
|
||||
speed: 0,
|
||||
};
|
||||
}
|
||||
const finalTotal = t.totalBytes > 0 ? t.totalBytes : completedCount;
|
||||
const hasFailures = resolved.status === "failed";
|
||||
return {
|
||||
...t,
|
||||
status: resolved.status,
|
||||
error: resolved.error,
|
||||
endTime: Date.now(),
|
||||
totalBytes: finalTotal,
|
||||
transferredBytes: hasFailures ? completedCount : finalTotal,
|
||||
speed: 0,
|
||||
};
|
||||
});
|
||||
});
|
||||
activeChildIdsRef.current.delete(task.id);
|
||||
return appliedStatus;
|
||||
} catch (err) {
|
||||
activeChildIdsRef.current.delete(task.id);
|
||||
const isCancelled = cancelledTasksRef.current.has(task.id);
|
||||
// Clean up cancelled task tracking to prevent memory leak
|
||||
if (isCancelled) cancelledTasksRef.current.delete(task.id);
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === task.id
|
||||
? {
|
||||
...t,
|
||||
status: isCancelled ? ("cancelled" as TransferStatus) : ("failed" as TransferStatus),
|
||||
error: isCancelled ? undefined : errMsg,
|
||||
endTime: Date.now(),
|
||||
}
|
||||
: t,
|
||||
),
|
||||
);
|
||||
return isCancelled ? "cancelled" : "failed";
|
||||
} finally {
|
||||
sourceWorkLease?.release();
|
||||
sourceWorkLease = null;
|
||||
const childIds = sftpTransferCenterStore.getSnapshot().tasks
|
||||
.filter((candidate) => candidate.parentTaskId === task.id)
|
||||
.map((candidate) => candidate.id);
|
||||
const relatedChildIds = settleTransferCancelTree(task.id, childIds);
|
||||
settleTransferControlEpochTree(task.id, relatedChildIds);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[sftpSessionsRef, acquireTransferSession],
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { TransferTask } from "../../../domain/models";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
|
||||
type StreamResult = { error?: string; cancelled?: boolean; superseded?: boolean } | undefined;
|
||||
|
||||
/** A live waiter owns bounded settlement evidence; persisted history stays compact. */
|
||||
export async function runTransferAndWaitForOwner(
|
||||
task: TransferTask,
|
||||
start: () => Promise<StreamResult>,
|
||||
shouldAbort: () => boolean,
|
||||
pausedAtResume?: TransferTask,
|
||||
): Promise<StreamResult> {
|
||||
// Register before admission/start: an owner may finish while dispatch waits for resume.
|
||||
let observation = sftpTransferCenterStore.observeTaskSettlement(task);
|
||||
try {
|
||||
for (;;) {
|
||||
if (shouldAbort()) throw new Error("Transfer cancelled");
|
||||
const admission = sftpTransferCenterStore.admitTaskRun(task, pausedAtResume);
|
||||
if (admission === "cancelled") throw new Error("Transfer cancelled");
|
||||
if (admission === "completed" || observation.read()?.status === "completed") return {};
|
||||
if (admission === "conflict") throw new Error("Transfer identity changed before dispatch");
|
||||
if (admission === "ready") break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
// Admission begins a new attempt; discard any previous failed settlement.
|
||||
observation.dispose();
|
||||
observation = sftpTransferCenterStore.observeTaskSettlement(task);
|
||||
const result = await start();
|
||||
if (!result?.superseded) return result;
|
||||
for (;;) {
|
||||
if (shouldAbort()) throw new Error("Transfer cancelled");
|
||||
const latest = observation.read();
|
||||
if (latest?.status === "completed") return { ...result, superseded: false };
|
||||
if (latest?.status === "failed") throw new Error(latest.error || "Transfer failed");
|
||||
if (latest?.status === "cancelled") throw new Error("Transfer cancelled");
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
} finally {
|
||||
observation.dispose();
|
||||
}
|
||||
}
|
||||
@@ -3762,108 +3762,3 @@ test("hard directory resume failure falls back to bounded fresh retry history",
|
||||
assert.equal(retainedParent?.checkpointBytes, 0);
|
||||
assert.ok(snapshot.length <= 200, `failed directory history must stay bounded, got ${snapshot.length}`);
|
||||
});
|
||||
|
||||
|
||||
test("held dedicated file resume cannot overwrite a subsequent pause", async (t) => {
|
||||
const store = createSftpTransferCenterStore();
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
let releaseRun!: () => void;
|
||||
let started!: () => void;
|
||||
let releaseResume!: (result: { success: boolean; lifecycleEpoch: number }) => void;
|
||||
const runGate = new Promise<void>((resolve) => { releaseRun = resolve; });
|
||||
const startGate = new Promise<void>((resolve) => { started = resolve; });
|
||||
const resumeGate = new Promise<{ success: boolean; lifecycleEpoch: number }>((resolve) => { releaseResume = resolve; });
|
||||
t.after(async () => {
|
||||
releaseRun();
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, "window");
|
||||
const { resetTransferPauseLatchesForTests } = await import("./sftp/transferPauseLatch");
|
||||
resetTransferPauseLatchesForTests();
|
||||
});
|
||||
Object.defineProperty(globalThis, "window", { configurable: true, value: { netcatty: {
|
||||
pauseTransfer: async () => ({ success: true, checkpointBytes: 2, lifecycleEpoch: 3 }),
|
||||
resumeTransfer: () => resumeGate,
|
||||
} } });
|
||||
store.publishOwner("dedicated-resume", [{
|
||||
...makeTask("held-file", "interrupted"),
|
||||
ownerId: "dedicated-resume", targetHostId: "host-a", reconnectRequired: true,
|
||||
}]);
|
||||
store.setDedicatedResumeHandler(async () => {
|
||||
store.patchTask("held-file", { status: "transferring", reconnectRequired: false });
|
||||
started();
|
||||
await runGate;
|
||||
return { success: true };
|
||||
});
|
||||
const first = store.resume("held-file");
|
||||
await startGate;
|
||||
await store.pause("held-file");
|
||||
const second = store.resume("held-file");
|
||||
await store.pause("held-file");
|
||||
releaseResume({ success: true, lifecycleEpoch: 2 });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const row = store.getSnapshot().tasks.find((task) => task.id === "held-file");
|
||||
releaseRun();
|
||||
await Promise.all([first, second]);
|
||||
assert.equal(row?.status, "paused", "held transfer must respect the latest pause");
|
||||
assert.equal(row?.lifecycleEpoch, 3);
|
||||
});
|
||||
|
||||
for (const outcome of ["rejected", "stream-gone"] as const) {
|
||||
test(`held file ${outcome} resume cannot restart after a newer pause during wind-down`, async (t) => {
|
||||
const store = createSftpTransferCenterStore();
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
let releaseRun!: () => void;
|
||||
let started!: () => void;
|
||||
let resolveResume!: (result: { success: boolean; reason: string }) => void;
|
||||
let rejectResume!: (error: Error) => void;
|
||||
let resumeCalls = 0;
|
||||
const runGate = new Promise<void>((resolve) => { releaseRun = resolve; });
|
||||
const startGate = new Promise<void>((resolve) => { started = resolve; });
|
||||
const resumeGate = new Promise<{ success: boolean; reason: string }>((resolve, reject) => {
|
||||
resolveResume = resolve;
|
||||
rejectResume = reject;
|
||||
});
|
||||
t.after(async () => {
|
||||
releaseRun();
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, "window");
|
||||
const { resetTransferPauseLatchesForTests } = await import("./sftp/transferPauseLatch");
|
||||
resetTransferPauseLatchesForTests();
|
||||
});
|
||||
Object.defineProperty(globalThis, "window", { configurable: true, value: { netcatty: {
|
||||
pauseTransfer: async () => ({ success: true, checkpointBytes: 2, lifecycleEpoch: 3 }),
|
||||
resumeTransfer: () => ++resumeCalls === 1
|
||||
? resumeGate
|
||||
: Promise.resolve({ success: true, lifecycleEpoch: 4 }),
|
||||
} } });
|
||||
const id = `held-file-${outcome}`;
|
||||
store.publishOwner("dedicated-resume", [{
|
||||
...makeTask(id, "interrupted"),
|
||||
ownerId: "dedicated-resume", targetHostId: "host-a", reconnectRequired: true,
|
||||
}]);
|
||||
store.setDedicatedResumeHandler(async () => {
|
||||
store.patchTask(id, { status: "transferring", reconnectRequired: false });
|
||||
started();
|
||||
await runGate;
|
||||
return { success: false, error: "Transfer cancelled" };
|
||||
});
|
||||
const first = store.resume(id);
|
||||
await startGate;
|
||||
await store.pause(id);
|
||||
const second = store.resume(id);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
if (outcome === "stream-gone") {
|
||||
resolveResume({ success: false, reason: "Transfer is no longer active" });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
await store.pause(id);
|
||||
if (outcome === "rejected") {
|
||||
rejectResume(new Error("worker channel unavailable"));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
releaseRun();
|
||||
await Promise.all([first, second]);
|
||||
assert.equal(store.getSnapshot().tasks.find((task) => task.id === id)?.status, "paused");
|
||||
assert.equal(resumeCalls, 1, "obsolete resume must not issue another resume after the held run ends");
|
||||
});
|
||||
}
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
} from "./sftp/transferControlEpoch";
|
||||
import { isTransferWalkInFlight } from "./sftp/transferWalkRegistry";
|
||||
import {
|
||||
isTransferOrRootCancelled,
|
||||
markTransferCancelledTree,
|
||||
settleTransferCancelTree,
|
||||
} from "./sftp/transferCancelLatch";
|
||||
@@ -97,10 +96,6 @@ export interface SftpTransferCenterStore {
|
||||
getBadgeSnapshot(): { count: number; hasAttention: boolean };
|
||||
/** Single task row — same object identity until that row is patched. */
|
||||
getTask(taskId: string): TransferTask | undefined;
|
||||
/** Observe exact task settlement before completed child rows are compacted. */
|
||||
observeTaskSettlement(task: TransferTask): { read(): TransferTask | undefined; dispose(): void };
|
||||
/** Publish an explicit dispatch identity without flushing large child-history batches. */
|
||||
admitTaskRun(task: TransferTask, pausedAtResume?: TransferTask): "ready" | "paused" | "completed" | "cancelled" | "conflict";
|
||||
getOwnerTasks(ownerId: string): TransferTask[];
|
||||
publishOwner(ownerId: string, tasks: readonly TransferTask[]): void;
|
||||
registerOwner(ownerId: string, controls: SftpTransferOwnerControls): () => void;
|
||||
@@ -109,8 +104,6 @@ export interface SftpTransferCenterStore {
|
||||
/** Insert or merge tasks by id (used by dedicated directory resume for children). */
|
||||
upsertTasks(incoming: readonly TransferTask[]): void;
|
||||
canControl(taskId: string): boolean;
|
||||
subscribeResume(listener: Listener): () => void;
|
||||
isResuming(taskId: string): boolean;
|
||||
pause(taskId: string): Promise<void>;
|
||||
resume(taskId: string): Promise<void>;
|
||||
cancel(taskId: string): Promise<void>;
|
||||
@@ -405,15 +398,6 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
let snapshotDirty = false;
|
||||
const listeners = new Set<Listener>();
|
||||
const progressListeners = new Set<Listener>();
|
||||
const settlementObservers = new Map<string, Set<{ expected: TransferTask; settled?: TransferTask }>>();
|
||||
const matchesObservedTask = (expected: TransferTask, candidate: TransferTask) => (
|
||||
expected.id === candidate.id
|
||||
&& expected.sourcePath === candidate.sourcePath
|
||||
&& expected.targetPath === candidate.targetPath
|
||||
&& expected.parentTaskId === candidate.parentTaskId
|
||||
&& expected.directoryEntryIndex === candidate.directoryEntryIndex
|
||||
&& expected.directoryEntryIdentity === candidate.directoryEntryIdentity
|
||||
);
|
||||
const refreshBadgeSnapshot = () => {
|
||||
const next = buildBadgeSnapshot(tasks);
|
||||
if (
|
||||
@@ -433,13 +417,6 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
let persistenceDirty = false;
|
||||
let persistenceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const resumeInvocations = new Map<string, Promise<void>>();
|
||||
// Ephemeral control intent: never serialize this or change bridge lifecycle epochs.
|
||||
const resumeRequests = new Map<string, symbol>();
|
||||
const resumeListeners = new Set<Listener>();
|
||||
const notifyResume = () => resumeListeners.forEach((listener) => listener());
|
||||
const clearResumeRequest = (taskId: string) => {
|
||||
if (resumeRequests.delete(taskId)) notifyResume();
|
||||
};
|
||||
const resumePreparationFailures = new Map<string, string>();
|
||||
let dedicatedResumeHandler: DedicatedTransferResumeHandler | null = null;
|
||||
const dedicatedResumeWaiters = new Set<{
|
||||
@@ -632,14 +609,6 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
// live renderer walk keeps its controls until TransferRuntime.runWalk
|
||||
// settles; everything else can release its whole tree here.
|
||||
settleFinishedTransferControlState(tasks, tasks);
|
||||
if (settlementObservers.size > 0) {
|
||||
for (const task of tasks) {
|
||||
if (!TERMINAL_OWNER_STATUSES.has(task.status)) continue;
|
||||
for (const observer of settlementObservers.get(task.id) ?? []) {
|
||||
if (!observer.settled && matchesObservedTask(observer.expected, task)) observer.settled = task;
|
||||
}
|
||||
}
|
||||
}
|
||||
const beforePrune = tasks;
|
||||
tasks = pruneSftpTransferHistory(tasks);
|
||||
const retainedIds = new Set(tasks.map((task) => task.id));
|
||||
@@ -1338,70 +1307,6 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
},
|
||||
getSnapshot: () => ensureSnapshot(),
|
||||
getBadgeSnapshot: () => badgeSnapshot,
|
||||
admitTaskRun(incoming, pausedAtResume) {
|
||||
const rootId = incoming.parentTaskId ?? incoming.id;
|
||||
if (isTransferOrRootCancelled(rootId, incoming.id)) return "cancelled";
|
||||
const parent = incoming.parentTaskId ? tasks.find((task) => task.id === rootId) : undefined;
|
||||
if (parent?.status === "cancelled") return "cancelled";
|
||||
const index = tasks.findIndex((task) => task.id === incoming.id);
|
||||
const existing = index < 0 ? undefined : tasks[index];
|
||||
if (existing && (existing.sourcePath !== incoming.sourcePath || existing.targetPath !== incoming.targetPath
|
||||
|| existing.parentTaskId !== incoming.parentTaskId)) return "conflict";
|
||||
if (existing?.status === "cancelled") return "cancelled";
|
||||
if (existing?.status === "completed") {
|
||||
return matchesObservedTask(incoming, existing) ? "completed" : "conflict";
|
||||
}
|
||||
const resumesUnchangedPause = existing === pausedAtResume
|
||||
&& existing?.status === "paused"
|
||||
&& (parent?.status === "pending" || parent?.status === "transferring");
|
||||
if (isTransferOrRootPauseLatched(rootId, incoming.id)
|
||||
|| parent?.status === "paused" || parent?.status === "pausing"
|
||||
|| (existing?.status === "paused" && !resumesUnchangedPause) || existing?.status === "pausing") return "paused";
|
||||
if (parent?.status === "completed") return "conflict";
|
||||
if (!existing) return "ready";
|
||||
if (existing.status === "transferring"
|
||||
&& existing.directoryEntryIndex === incoming.directoryEntryIndex
|
||||
&& existing.directoryEntryIdentity === incoming.directoryEntryIdentity) return "ready";
|
||||
// Explicit retry may leave failed/interrupted and replace its manifest identity.
|
||||
// Do not overwrite byte checkpoints with a delayed renderer snapshot.
|
||||
const next = tasks.slice();
|
||||
next[index] = {
|
||||
...existing,
|
||||
directoryEntryIndex: incoming.directoryEntryIndex,
|
||||
directoryEntryIdentity: incoming.directoryEntryIdentity,
|
||||
...(existing.status === "transferring" ? {} : {
|
||||
status: "transferring" as const,
|
||||
lifecycleEpoch: undefined,
|
||||
error: undefined,
|
||||
endTime: undefined,
|
||||
reconnectRequired: false,
|
||||
}),
|
||||
};
|
||||
tasks = next;
|
||||
emitProgress(false);
|
||||
return "ready";
|
||||
},
|
||||
observeTaskSettlement(expected) {
|
||||
const observer: { expected: TransferTask; settled?: TransferTask } = { expected: { ...expected } };
|
||||
const observers = settlementObservers.get(expected.id) ?? new Set();
|
||||
observers.add(observer);
|
||||
settlementObservers.set(expected.id, observers);
|
||||
let disposed = false;
|
||||
return {
|
||||
read() {
|
||||
if (disposed) return undefined;
|
||||
if (observer.settled) return observer.settled;
|
||||
const current = tasks.find((task) => task.id === expected.id);
|
||||
return current && matchesObservedTask(observer.expected, current) ? current : undefined;
|
||||
},
|
||||
dispose() {
|
||||
disposed = true;
|
||||
observer.settled = undefined;
|
||||
observers.delete(observer);
|
||||
if (observers.size === 0) settlementObservers.delete(expected.id);
|
||||
},
|
||||
};
|
||||
},
|
||||
getTask(taskId) {
|
||||
return tasks.find((task) => task.id === taskId);
|
||||
},
|
||||
@@ -1734,140 +1639,125 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
controls.adopt && controls.canAdopt?.(task)
|
||||
)));
|
||||
},
|
||||
subscribeResume(listener) {
|
||||
resumeListeners.add(listener);
|
||||
return () => { resumeListeners.delete(listener); };
|
||||
},
|
||||
isResuming(taskId) {
|
||||
if (!resumeRequests.has(taskId)) return false;
|
||||
const task = tasks.find((candidate) => candidate.id === taskId);
|
||||
return !!task && !task.error
|
||||
&& ["paused", "interrupted", "attention", "pending", "queued"].includes(task.status);
|
||||
},
|
||||
pause(taskId) {
|
||||
clearResumeRequest(taskId);
|
||||
return invoke(taskId, "pause");
|
||||
},
|
||||
pause: (taskId) => invoke(taskId, "pause"),
|
||||
async resume(taskId) {
|
||||
const request = Symbol("resume");
|
||||
resumeRequests.set(taskId, request);
|
||||
notifyResume();
|
||||
const runResume = async () => {
|
||||
const startFresh = () => {
|
||||
const running = invoke(taskId, "resume").finally(() => {
|
||||
if (resumeInvocations.get(taskId) === running) resumeInvocations.delete(taskId);
|
||||
});
|
||||
resumeInvocations.set(taskId, running);
|
||||
return running;
|
||||
};
|
||||
|
||||
const existing = resumeInvocations.get(taskId);
|
||||
if (existing) {
|
||||
// Dedicated resume holds the invocation for the full stream lifetime.
|
||||
const task = tasks.find((candidate) => candidate.id === taskId);
|
||||
if (task?.status === "cancelled") return existing;
|
||||
|
||||
// Soft-unpause live backend streams when still paused under a held run.
|
||||
// Dedicated *directory* walks treat status===paused as shouldAbort between
|
||||
// files, so soft-rejoin is unsafe (dying promise + false transferring).
|
||||
// Wind down soft-paused children then startFresh from checkpoints.
|
||||
// Single-file dedicated (and non-directory live streams) soft-unpause.
|
||||
if (task && (task.status === "paused" || task.status === "pausing")) {
|
||||
const childIds = tasks
|
||||
.filter((candidate) => candidate.parentTaskId === taskId
|
||||
&& (candidate.status === "paused"
|
||||
|| candidate.status === "pausing"
|
||||
|| candidate.status === "transferring"))
|
||||
.map((candidate) => candidate.id);
|
||||
|
||||
// Always clear process-global latches + control epoch so walks wake and
|
||||
// late soft-drain / pauseWatch cannot re-pause streams. Do not stamp
|
||||
// control epoch as task.lifecycleEpoch (bridge-aligned only).
|
||||
let resumeEpoch = bumpTransferControlEpoch(taskId);
|
||||
releaseTransferPauseTree(taskId, childIds);
|
||||
|
||||
if (task.ownerId === "dedicated-resume" && task.isDirectory) {
|
||||
const bridge = netcattyBridge.get();
|
||||
// Cancel soft-paused child streams so the held walk settles. When a
|
||||
// child is not in activeTransfers, cancelTransfer leaves a sticky
|
||||
// pendingCancel latch — clear it before startFresh reuses the same
|
||||
// child transferIds (otherwise startStreamTransfer aborts immediately).
|
||||
for (const id of childIds) {
|
||||
try { await bridge?.cancelTransfer?.(id); } catch { /* best-effort wind-down */ }
|
||||
try { await bridge?.clearPendingTransferCancel?.(id); } catch { /* best-effort */ }
|
||||
}
|
||||
try { await bridge?.clearPendingTransferCancel?.(taskId); } catch { /* best-effort */ }
|
||||
try {
|
||||
await existing;
|
||||
} catch { /* previous aborted / cancelled */ }
|
||||
// Clear again after wind-down in case cancel raced during await.
|
||||
for (const id of childIds) {
|
||||
try { await bridge?.clearPendingTransferCancel?.(id); } catch { /* best-effort */ }
|
||||
}
|
||||
try { await bridge?.clearPendingTransferCancel?.(taskId); } catch { /* best-effort */ }
|
||||
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return existing;
|
||||
return resumeInvocations.get(taskId) ?? startFresh();
|
||||
}
|
||||
|
||||
try {
|
||||
// Reuse the live control path: held dedicated transfers must obey
|
||||
// the same ordering and per-child lifecycle rules as panel jobs.
|
||||
const softOperation = softResumeTransfer({
|
||||
getTasks: () => tasks,
|
||||
setTasks: (next) => { tasks = next; emit(); },
|
||||
getBridge: defaultTransferControlBridge,
|
||||
}, taskId);
|
||||
resumeEpoch = getTransferControlEpoch(taskId);
|
||||
const soft = await softOperation;
|
||||
if (soft.handled) return existing;
|
||||
const streamGone = /no longer active|not active|not found|session is no longer|Resume unavailable|Transfer not found/i
|
||||
.test(soft.reason ?? "");
|
||||
if (!streamGone) {
|
||||
tasks = tasks.map((candidate) => candidate.id === taskId ? {
|
||||
...candidate,
|
||||
status: "paused" as const,
|
||||
speed: 0,
|
||||
phase: undefined,
|
||||
error: soft.reason,
|
||||
} : candidate);
|
||||
emit();
|
||||
return existing;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to await + restart.
|
||||
}
|
||||
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return existing;
|
||||
try {
|
||||
await existing;
|
||||
} catch { /* previous aborted */ }
|
||||
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return existing;
|
||||
return resumeInvocations.get(taskId) ?? startFresh();
|
||||
}
|
||||
|
||||
// After demotion to interrupted/attention/failed while work unwinds:
|
||||
// wait then re-invoke (do not rejoin a dying canceling promise).
|
||||
if (task && (task.status === "interrupted" || task.status === "attention" || task.status === "failed")) {
|
||||
const resumeEpoch = getTransferControlEpoch(taskId);
|
||||
try {
|
||||
await existing;
|
||||
} catch { /* previous aborted */ }
|
||||
if (getTransferControlEpoch(taskId) !== resumeEpoch) return existing;
|
||||
return resumeInvocations.get(taskId) ?? startFresh();
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
return startFresh();
|
||||
const startFresh = () => {
|
||||
const running = invoke(taskId, "resume").finally(() => {
|
||||
if (resumeInvocations.get(taskId) === running) resumeInvocations.delete(taskId);
|
||||
});
|
||||
resumeInvocations.set(taskId, running);
|
||||
return running;
|
||||
};
|
||||
try {
|
||||
return await runResume();
|
||||
} finally {
|
||||
if (resumeRequests.get(taskId) === request) clearResumeRequest(taskId);
|
||||
|
||||
const existing = resumeInvocations.get(taskId);
|
||||
if (existing) {
|
||||
// Dedicated resume holds the invocation for the full stream lifetime.
|
||||
const task = tasks.find((candidate) => candidate.id === taskId);
|
||||
if (task?.status === "cancelled") return existing;
|
||||
|
||||
// Soft-unpause live backend streams when still paused under a held run.
|
||||
// Dedicated *directory* walks treat status===paused as shouldAbort between
|
||||
// files, so soft-rejoin is unsafe (dying promise + false transferring).
|
||||
// Wind down soft-paused children then startFresh from checkpoints.
|
||||
// Single-file dedicated (and non-directory live streams) soft-unpause.
|
||||
if (task && (task.status === "paused" || task.status === "pausing")) {
|
||||
const childIds = tasks
|
||||
.filter((candidate) => candidate.parentTaskId === taskId
|
||||
&& (candidate.status === "paused"
|
||||
|| candidate.status === "pausing"
|
||||
|| candidate.status === "transferring"))
|
||||
.map((candidate) => candidate.id);
|
||||
|
||||
// Always clear process-global latches + control epoch so walks wake and
|
||||
// late soft-drain / pauseWatch cannot re-pause streams. Do not stamp
|
||||
// control epoch as task.lifecycleEpoch (bridge-aligned only).
|
||||
bumpTransferControlEpoch(taskId);
|
||||
releaseTransferPauseTree(taskId, childIds);
|
||||
|
||||
if (task.ownerId === "dedicated-resume" && task.isDirectory) {
|
||||
const bridge = netcattyBridge.get();
|
||||
// Cancel soft-paused child streams so the held walk settles. When a
|
||||
// child is not in activeTransfers, cancelTransfer leaves a sticky
|
||||
// pendingCancel latch — clear it before startFresh reuses the same
|
||||
// child transferIds (otherwise startStreamTransfer aborts immediately).
|
||||
for (const id of childIds) {
|
||||
try { await bridge?.cancelTransfer?.(id); } catch { /* best-effort wind-down */ }
|
||||
try { await bridge?.clearPendingTransferCancel?.(id); } catch { /* best-effort */ }
|
||||
}
|
||||
try { await bridge?.clearPendingTransferCancel?.(taskId); } catch { /* best-effort */ }
|
||||
try {
|
||||
await existing;
|
||||
} catch { /* previous aborted / cancelled */ }
|
||||
// Clear again after wind-down in case cancel raced during await.
|
||||
for (const id of childIds) {
|
||||
try { await bridge?.clearPendingTransferCancel?.(id); } catch { /* best-effort */ }
|
||||
}
|
||||
try { await bridge?.clearPendingTransferCancel?.(taskId); } catch { /* best-effort */ }
|
||||
return resumeInvocations.get(taskId) ?? startFresh();
|
||||
}
|
||||
|
||||
try {
|
||||
const resumeIds = [taskId, ...childIds.filter((id) => id !== taskId)];
|
||||
const results = await Promise.all(resumeIds.map(async (id) =>
|
||||
netcattyBridge.get()?.resumeTransfer?.(id) ?? { success: false },
|
||||
));
|
||||
const after = tasks.find((candidate) => candidate.id === taskId);
|
||||
if (after?.status === "cancelled") return existing;
|
||||
// Only rejoin when at least one backend stream actually resumed.
|
||||
// Empty/all-fail must not paint transferring over a dead held run.
|
||||
const successIds = resumeIds.filter((_, index) => results[index]?.success);
|
||||
if (successIds.length > 0) {
|
||||
const resumed = new Set(successIds);
|
||||
// Align with softResumeTransfer: prefer bridge lifecycleEpoch; clear
|
||||
// if omitted so main-process progress is not stale-dropped.
|
||||
let bridgeEpoch: number | undefined;
|
||||
for (let index = 0; index < results.length; index += 1) {
|
||||
if (!results[index]?.success) continue;
|
||||
const epoch = (results[index] as { lifecycleEpoch?: number } | undefined)?.lifecycleEpoch;
|
||||
if (!Number.isFinite(epoch)) continue;
|
||||
bridgeEpoch = bridgeEpoch === undefined
|
||||
? (epoch as number)
|
||||
: Math.max(bridgeEpoch, epoch as number);
|
||||
}
|
||||
tasks = tasks.map((candidate) => {
|
||||
if (candidate.id === taskId || resumed.has(candidate.id)) {
|
||||
return {
|
||||
...candidate,
|
||||
status: "transferring" as const,
|
||||
error: undefined,
|
||||
reconnectRequired: false,
|
||||
pauseUnavailableReason: undefined,
|
||||
phase: undefined,
|
||||
lifecycleEpoch: bridgeEpoch,
|
||||
};
|
||||
}
|
||||
return candidate;
|
||||
});
|
||||
emit();
|
||||
return existing;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to await + restart.
|
||||
}
|
||||
try {
|
||||
await existing;
|
||||
} catch { /* previous aborted */ }
|
||||
return resumeInvocations.get(taskId) ?? startFresh();
|
||||
}
|
||||
|
||||
// After demotion to interrupted/attention/failed while work unwinds:
|
||||
// wait then re-invoke (do not rejoin a dying canceling promise).
|
||||
if (task && (task.status === "interrupted" || task.status === "attention" || task.status === "failed")) {
|
||||
try {
|
||||
await existing;
|
||||
} catch { /* previous aborted */ }
|
||||
return resumeInvocations.get(taskId) ?? startFresh();
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
return startFresh();
|
||||
},
|
||||
cancel(taskId) {
|
||||
clearResumeRequest(taskId);
|
||||
return invoke(taskId, "cancel");
|
||||
},
|
||||
cancel: (taskId) => invoke(taskId, "cancel"),
|
||||
async retry(taskId) {
|
||||
const ownerId = findOwner(taskId);
|
||||
const task = tasks.find((candidate) => candidate.id === taskId);
|
||||
@@ -2465,16 +2355,3 @@ export function useSftpTransferTask(taskId: string, fallback: TransferTask): Tra
|
||||
() => fallbackRef.current,
|
||||
);
|
||||
}
|
||||
|
||||
/** Shared control feedback for every transfer surface, including newly mounted rows. */
|
||||
export function useSftpTransferResuming(taskId: string): boolean {
|
||||
return useSyncExternalStore(
|
||||
(listener) => {
|
||||
const unsubscribeTask = sftpTransferCenterStore.subscribe(listener);
|
||||
const unsubscribeResume = sftpTransferCenterStore.subscribeResume(listener);
|
||||
return () => { unsubscribeTask(); unsubscribeResume(); };
|
||||
},
|
||||
() => sftpTransferCenterStore.isResuming(taskId),
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createSftpTransferCenterStore } from "./sftpTransferCenterStore";
|
||||
|
||||
for (const outcome of ["success", "failure", "pause", "cancel"] as const) {
|
||||
test(`resume feedback clears after ${outcome} without changing the paused checkpoint`, async (t) => {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
t.after(() => {
|
||||
if (previous) Object.defineProperty(globalThis, "window", previous);
|
||||
else Reflect.deleteProperty(globalThis, "window");
|
||||
});
|
||||
let settle!: (value: { success: boolean; reason?: string }) => void;
|
||||
let started!: () => void;
|
||||
const entered = new Promise<void>((resolve) => { started = resolve; });
|
||||
const gate = new Promise<{ success: boolean; reason?: string }>((resolve) => { settle = resolve; });
|
||||
Object.defineProperty(globalThis, "window", { configurable: true, value: { netcatty: {
|
||||
resumeTransfer: () => { started(); return gate; },
|
||||
pauseTransfer: async () => ({ success: true, checkpointBytes: 100 }),
|
||||
cancelTransfer: async () => ({ success: true }),
|
||||
} } });
|
||||
const store = createSftpTransferCenterStore();
|
||||
store.publishOwner("feedback", [{
|
||||
id: "feedback", fileName: "file.bin", sourcePath: "/source.bin", targetPath: "/target.bin",
|
||||
sourceConnectionId: "remote", targetConnectionId: "local", direction: "download",
|
||||
status: "paused", totalBytes: 1000, transferredBytes: 100, checkpointBytes: 100,
|
||||
speed: 0, startTime: 1, isDirectory: false, resumable: true,
|
||||
}]);
|
||||
let notifications = 0;
|
||||
const unsubscribe = store.subscribeResume(() => { notifications += 1; });
|
||||
t.after(unsubscribe);
|
||||
const running = store.resume("feedback");
|
||||
assert.equal(store.isResuming("feedback"), true);
|
||||
assert.equal(store.getTask("feedback")?.status, "paused");
|
||||
assert.equal(store.getTask("feedback")?.checkpointBytes, 100);
|
||||
await entered;
|
||||
const followup = outcome === "pause" ? store.pause("feedback")
|
||||
: outcome === "cancel" ? store.cancel("feedback") : Promise.resolve();
|
||||
if (outcome === "pause" || outcome === "cancel") assert.equal(store.isResuming("feedback"), false);
|
||||
settle(outcome === "failure" ? { success: false, reason: "Resume safety check failed" } : { success: true });
|
||||
await Promise.all([running, followup]);
|
||||
assert.equal(store.isResuming("feedback"), false);
|
||||
assert.ok(notifications >= 2);
|
||||
if (outcome === "pause") assert.equal(store.getTask("feedback")?.status, "paused");
|
||||
if (outcome === "cancel") assert.equal(store.getTask("feedback")?.status, "cancelled");
|
||||
});
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import vm from 'node:vm';
|
||||
import React, { useCallback, useRef, useSyncExternalStore } from 'react';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
import ts from 'typescript';
|
||||
import { getSidePanelLiveSnapshot, subscribeSidePanelLiveSnapshot, sidePanelLiveStore, SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, type SidePanelLiveSnapshot } from './sidePanelLiveStore';
|
||||
|
||||
// Exercise the production hook without importing the unrelated panel UI modules.
|
||||
const source = readFileSync(new URL('../../components/terminalLayer/terminalLayerSidePanelSlots.tsx', import.meta.url), 'utf8');
|
||||
const ast = ts.createSourceFile('slots.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
||||
const hook = ast.statements.find((node) => ts.isFunctionDeclaration(node) && node.name?.text === 'useSidePanelLiveSnapshotForTab');
|
||||
assert.ok(hook);
|
||||
const code = ts.transpile(hook.getText(ast), { target: ts.ScriptTarget.ES2022 });
|
||||
const useSnapshot = vm.runInNewContext(`${code}; useSidePanelLiveSnapshotForTab`, {
|
||||
useCallback, useRef, useSyncExternalStore, getSidePanelLiveSnapshot,
|
||||
subscribeSidePanelLiveSnapshot, SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT,
|
||||
}) as (tabId: string, enabled: boolean) => SidePanelLiveSnapshot;
|
||||
|
||||
const env = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
|
||||
test('returning to a tab never exposes the previous tab snapshot before its publisher catches up', async () => {
|
||||
const previous = env.IS_REACT_ACT_ENVIRONMENT;
|
||||
env.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const own = { ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: 'ssh-a', activeTerminalSessionIdForSftp: 'ssh-a', activeTerminalCwd: '/root', activeTerminalCwdTrusted: true };
|
||||
const other = { ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: 'local-b' };
|
||||
let visible = true;
|
||||
let observed: SidePanelLiveSnapshot = SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT;
|
||||
function Probe() { observed = useSnapshot('ssh-a', visible); return null; }
|
||||
sidePanelLiveStore.update(own);
|
||||
let renderer: ReturnType<typeof create> | undefined;
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
assert.equal(observed.focusedSessionId, 'ssh-a');
|
||||
visible = false;
|
||||
await act(async () => { renderer?.update(React.createElement(Probe)); });
|
||||
await act(async () => { sidePanelLiveStore.update(other); });
|
||||
visible = true;
|
||||
await act(async () => { renderer?.update(React.createElement(Probe)); });
|
||||
assert.equal(observed.focusedSessionId, 'ssh-a', 'stale local session must not enter the SSH panel');
|
||||
assert.equal(observed.activeTerminalCwd, '/root', 'unchanged cwd must survive the reveal gap');
|
||||
await act(async () => { sidePanelLiveStore.update({ ...own, activeTerminalCwd: '/srv/changed' }); });
|
||||
assert.equal(observed.activeTerminalCwd, '/srv/changed', 'real cwd changes must still arrive');
|
||||
} finally {
|
||||
await act(async () => renderer?.unmount());
|
||||
sidePanelLiveStore.update(SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT);
|
||||
env.IS_REACT_ACT_ENVIRONMENT = previous;
|
||||
}
|
||||
});
|
||||
|
||||
test('new panels wait for their own snapshot and workspace panes accept focus changes within the workspace', async () => {
|
||||
const previous = env.IS_REACT_ACT_ENVIRONMENT;
|
||||
env.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let observed = SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT;
|
||||
const workspace = { id: 'workspace-a' } as NonNullable<SidePanelLiveSnapshot['activeWorkspace']>;
|
||||
const own = { ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, activeWorkspace: workspace, focusedSessionId: 'pane-a' };
|
||||
let panelTabId = 'workspace-a';
|
||||
function Probe() { observed = useSnapshot(panelTabId, true); return null; }
|
||||
sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: 'unrelated' });
|
||||
let renderer: ReturnType<typeof create> | undefined;
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
assert.equal(observed, SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT);
|
||||
await act(async () => { sidePanelLiveStore.update(own); });
|
||||
assert.equal(observed.focusedSessionId, 'pane-a');
|
||||
await act(async () => { sidePanelLiveStore.update({ ...own, focusedSessionId: 'pane-b' }); });
|
||||
assert.equal(observed.focusedSessionId, 'pane-b');
|
||||
await act(async () => { sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: 'unrelated' }); });
|
||||
assert.equal(observed.focusedSessionId, 'pane-b');
|
||||
panelTabId = 'new-owner';
|
||||
await act(async () => { renderer?.update(React.createElement(Probe)); });
|
||||
assert.equal(observed, SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, 'a reused hook must not retain another owner');
|
||||
} finally {
|
||||
await act(async () => renderer?.unmount());
|
||||
sidePanelLiveStore.update(SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT);
|
||||
env.IS_REACT_ACT_ENVIRONMENT = previous;
|
||||
}
|
||||
});
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
createTerminalSelectionAttachment,
|
||||
decodeTerminalSelectionAttachment,
|
||||
} from "./terminalSelectionAttachment.ts";
|
||||
import { createVaultNoteAttachment, formatVaultNoteReferences } from "./vaultNoteAttachment.ts";
|
||||
|
||||
test("createTerminalSelectionAttachment returns null for blank selections", () => {
|
||||
assert.equal(createTerminalSelectionAttachment(" \n\t"), null);
|
||||
@@ -51,13 +50,3 @@ test("buildPromptWithTerminalSelectionAttachments supports terminal-only prompts
|
||||
`[Terminal selection: ${attachment.filename}]\nsystemctl status nginx`,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildPromptWithTerminalSelectionAttachments keeps empty Vault note blocks", () => {
|
||||
const attachment = createVaultNoteAttachment({ id: "note-1", title: "Runbook" });
|
||||
|
||||
assert.ok(attachment);
|
||||
assert.equal(
|
||||
buildPromptWithTerminalSelectionAttachments("add deployment steps to this note", [attachment]),
|
||||
`add deployment steps to this note\n\n${formatVaultNoteReferences([attachment])}`,
|
||||
);
|
||||
});
|
||||
@@ -1,8 +1,4 @@
|
||||
import type { ChatMessageAttachment, UploadedFile } from "../../infrastructure/ai/types";
|
||||
import {
|
||||
formatVaultNoteReferences,
|
||||
isVaultNoteAttachment,
|
||||
} from "./vaultNoteAttachment";
|
||||
|
||||
export const TERMINAL_SELECTION_ATTACHMENT_MEDIA_TYPE = "text/plain";
|
||||
|
||||
@@ -85,16 +81,6 @@ export function isTerminalSelectionAttachment(
|
||||
return attachment.terminalSelection === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attachments whose text is inlined into the prompt instead of being sent as
|
||||
* file parts: terminal selections and Vault note mentions.
|
||||
*/
|
||||
export function isInlineTextAttachment(
|
||||
attachment: Pick<ChatMessageAttachment | UploadedFile, "terminalSelection" | "vaultNoteId">,
|
||||
): boolean {
|
||||
return isTerminalSelectionAttachment(attachment) || isVaultNoteAttachment(attachment);
|
||||
}
|
||||
|
||||
export function buildPromptWithTerminalSelectionAttachments(
|
||||
prompt: string,
|
||||
attachments: Array<ChatMessageAttachment | UploadedFile>,
|
||||
@@ -109,11 +95,7 @@ export function buildPromptWithTerminalSelectionAttachments(
|
||||
})
|
||||
.filter((block): block is string => block !== null);
|
||||
|
||||
const notes = attachments.filter(isVaultNoteAttachment);
|
||||
const noteBlocks = notes.length ? [`\n\n${formatVaultNoteReferences(notes)}`] : [];
|
||||
|
||||
const blocks = [...terminalBlocks, ...noteBlocks];
|
||||
if (blocks.length === 0) return prompt;
|
||||
if (!prompt.trim()) return blocks.join("").trimStart();
|
||||
return `${prompt}${blocks.join("")}`;
|
||||
if (terminalBlocks.length === 0) return prompt;
|
||||
if (!prompt.trim()) return terminalBlocks.join("").trimStart();
|
||||
return `${prompt}${terminalBlocks.join("")}`;
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import React from 'react';
|
||||
import { createDomRenderer, flushEffects, installDomEnvironment } from '../../components/test-support/renderReactDom';
|
||||
import { EncryptionService } from '../../infrastructure/services/EncryptionService';
|
||||
import type { SyncManagerState } from '../../infrastructure/services/CloudSyncManager';
|
||||
import type { SyncPayload } from '../../domain/sync';
|
||||
|
||||
test('manual sync does not clear a newly shared password when an older unlock is superseded', async (t) => {
|
||||
const env = installDomEnvironment();
|
||||
const previousStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: env.window.localStorage });
|
||||
t.after(() => {
|
||||
if (previousStorage) Object.defineProperty(globalThis, "localStorage", previousStorage);
|
||||
else Reflect.deleteProperty(globalThis, "localStorage");
|
||||
});
|
||||
let sharedPassword: string | undefined;
|
||||
let clears = 0;
|
||||
Object.defineProperty(env.window, 'netcatty', { configurable: true, value: {
|
||||
cloudSyncGetSessionPassword: async () => sharedPassword,
|
||||
cloudSyncClearSessionPassword: async () => { clears += 1; sharedPassword = undefined; },
|
||||
} });
|
||||
const { getCloudSyncManager } = await import('../../infrastructure/services/CloudSyncManager');
|
||||
const manager = getCloudSyncManager();
|
||||
await manager.setupMasterKey('old-fixture-password');
|
||||
manager.lock();
|
||||
const newConfig = await EncryptionService.createMasterKeyConfig('new-fixture-password');
|
||||
const { useCloudSync } = await import('./useCloudSync');
|
||||
let current!: ReturnType<typeof useCloudSync>;
|
||||
const Probe = () => { current = useCloudSync(); return null; };
|
||||
const renderer = await createDomRenderer(env.document);
|
||||
const originalUnlock = EncryptionService.unlockMasterKey;
|
||||
let release!: () => void;
|
||||
let started!: () => void;
|
||||
const gate = new Promise<void>((resolve) => { release = resolve; });
|
||||
const entered = new Promise<void>((resolve) => { started = resolve; });
|
||||
try {
|
||||
await renderer.render(<Probe />);
|
||||
await flushEffects();
|
||||
EncryptionService.unlockMasterKey = async (...args) => {
|
||||
started();
|
||||
await gate;
|
||||
return originalUnlock(...args);
|
||||
};
|
||||
sharedPassword = 'old-fixture-password';
|
||||
const result = current.syncNow({} as SyncPayload).then(() => null, (error: unknown) => error);
|
||||
await entered;
|
||||
const state = Reflect.get(manager, 'state') as SyncManagerState;
|
||||
state.masterKeyConfig = newConfig;
|
||||
Reflect.get(manager, 'bumpSyncSecurityGeneration').call(manager);
|
||||
sharedPassword = 'new-fixture-password';
|
||||
release();
|
||||
const error = await result;
|
||||
assert.equal(clears, 0, 'stale work must not erase the new shared password');
|
||||
assert.equal(sharedPassword, 'new-fixture-password');
|
||||
assert.ok(error instanceof Error);
|
||||
assert.match(error.message, /changed while unlocking/i);
|
||||
} finally {
|
||||
release();
|
||||
EncryptionService.unlockMasterKey = originalUnlock;
|
||||
await renderer.unmount();
|
||||
manager.destroy();
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -6,7 +6,6 @@
|
||||
* Uses useSyncExternalStore for real-time state synchronization across all components.
|
||||
*/
|
||||
|
||||
import { useCloudSyncAutoUnlock } from './useCloudSyncAutoUnlock';
|
||||
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
|
||||
import {
|
||||
type CloudProvider,
|
||||
@@ -267,14 +266,42 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
getConvergentSyncLocalConfigSnapshot,
|
||||
);
|
||||
|
||||
useCloudSyncAutoUnlock({
|
||||
securityState: state.securityState,
|
||||
masterKeyIdentity: state.masterKeyConfig
|
||||
? JSON.stringify(state.masterKeyConfig)
|
||||
: null,
|
||||
manager,
|
||||
bridge: netcattyBridge.get(),
|
||||
});
|
||||
// Auto-unlock: if a master key exists, retrieve the persisted password (Electron safeStorage)
|
||||
// and unlock silently so users don't have to manage a LOCKED state in the UI.
|
||||
// Track the master key config hash to detect when a new master key is set up in another window.
|
||||
const lastMasterKeyHashRef = useRef<string | null>(null);
|
||||
const attemptedAutoUnlockRef = useRef(false);
|
||||
useEffect(() => {
|
||||
// Compute a simple hash of the master key config to detect changes
|
||||
const currentHash = state.masterKeyConfig
|
||||
? JSON.stringify({ salt: state.masterKeyConfig.salt, kdf: state.masterKeyConfig.kdf })
|
||||
: null;
|
||||
|
||||
// If master key config changed (e.g., set up in settings window), reset the attempt flag
|
||||
if (currentHash !== lastMasterKeyHashRef.current) {
|
||||
lastMasterKeyHashRef.current = currentHash;
|
||||
attemptedAutoUnlockRef.current = false;
|
||||
}
|
||||
|
||||
if (attemptedAutoUnlockRef.current) return;
|
||||
if (state.securityState !== 'LOCKED') return;
|
||||
attemptedAutoUnlockRef.current = true;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const bridge = netcattyBridge.get();
|
||||
const password = await bridge?.cloudSyncGetSessionPassword?.();
|
||||
if (!password) return;
|
||||
|
||||
const ok = await manager.unlock(password);
|
||||
if (!ok) {
|
||||
void bridge?.cloudSyncClearSessionPassword?.();
|
||||
}
|
||||
} catch {
|
||||
// Ignore auto-unlock errors; manual actions will surface them.
|
||||
}
|
||||
})();
|
||||
}, [state.securityState, state.masterKeyConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.securityState !== 'UNLOCKED') return;
|
||||
|
||||
@@ -1,253 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { createDomRenderer, flushEffects, installDomEnvironment, runWithAct } from '../../components/test-support/renderReactDom.tsx';
|
||||
import { useCloudSyncAutoUnlock } from './useCloudSyncAutoUnlock.ts';
|
||||
|
||||
for (const strict of [false, true]) {
|
||||
test(`pending password read is handed off when its owner unmounts (StrictMode=${strict})`, async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
const pending: Array<(value: string | null) => void> = [];
|
||||
let unlocks = 0;
|
||||
const manager = { unlock: async () => { unlocks++; return true; } };
|
||||
const bridge = { cloudSyncGetSessionPassword: () => new Promise<string | null>(resolve => { pending.push(resolve); }) };
|
||||
function Consumer() {
|
||||
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'handoff-key', manager, bridge });
|
||||
return null;
|
||||
}
|
||||
function tree(owner: boolean) {
|
||||
return React.createElement(strict ? React.StrictMode : React.Fragment, null,
|
||||
owner ? React.createElement(Consumer, { key: 'owner' }) : null,
|
||||
React.createElement(Consumer, { key: 'peer' }));
|
||||
}
|
||||
try {
|
||||
await renderer.render(tree(true));
|
||||
await flushEffects();
|
||||
assert.ok(pending.length > 0);
|
||||
await renderer.render(tree(false));
|
||||
await flushEffects();
|
||||
await runWithAct(async () => { for (const resolve of pending) resolve('fixture-only'); });
|
||||
await flushEffects();
|
||||
assert.equal(unlocks, 1, 'cancelled owners must not unlock when their old responses arrive');
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
});
|
||||
}
|
||||
|
||||
for (const strict of [false, true]) {
|
||||
test(`peer auto-unlocks when password arrives after the config (StrictMode=${strict})`, async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let password: string | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
const unlocked: string[] = [];
|
||||
const manager = { unlock: async (value: string) => { unlocked.push(value); return true; } };
|
||||
const bridge = {
|
||||
cloudSyncGetSessionPassword: async () => password,
|
||||
onCloudSyncSessionPasswordAvailable: (cb: () => void) => { listeners.add(cb); return () => { listeners.delete(cb); }; },
|
||||
};
|
||||
function Harness() {
|
||||
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'first-key', manager, bridge });
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await renderer.render(strict ? React.createElement(React.StrictMode, null, React.createElement(Harness)) : React.createElement(Harness));
|
||||
await flushEffects();
|
||||
assert.deepEqual(unlocked, [], 'the initial read really happened before the password existed');
|
||||
await runWithAct(async () => { password = 'fixture-only'; for (const cb of listeners) cb(); });
|
||||
await flushEffects();
|
||||
assert.deepEqual(unlocked, ['fixture-only']);
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
assert.equal(listeners.size, 0);
|
||||
});
|
||||
}
|
||||
|
||||
for (const strict of [false, true]) {
|
||||
test(`a failed attempt during key rotation keeps the password for the arriving config (StrictMode=${strict})`, async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let password: string | null = null;
|
||||
let cleared = 0;
|
||||
let currentIdentity = 'old-key';
|
||||
const listeners = new Set<() => void>();
|
||||
const unlocked: Array<[string, string]> = [];
|
||||
const manager = {
|
||||
unlock: async (value: string) => {
|
||||
unlocked.push([currentIdentity, value]);
|
||||
return currentIdentity === 'new-key';
|
||||
},
|
||||
};
|
||||
const bridge = {
|
||||
cloudSyncGetSessionPassword: async () => password,
|
||||
cloudSyncClearSessionPassword: async () => { cleared++; return true; },
|
||||
onCloudSyncSessionPasswordAvailable: (cb: () => void) => { listeners.add(cb); return () => { listeners.delete(cb); }; },
|
||||
};
|
||||
function Harness() {
|
||||
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: currentIdentity, manager, bridge });
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await renderer.render(strict ? React.createElement(React.StrictMode, null, React.createElement(Harness)) : React.createElement(Harness));
|
||||
await flushEffects();
|
||||
// The password notification overtakes the config event: the peer is
|
||||
// still locked on the old config when the new password arrives.
|
||||
await runWithAct(async () => { password = 'rotation-password'; for (const cb of listeners) cb(); });
|
||||
await flushEffects();
|
||||
assert.deepEqual(unlocked, [['old-key', 'rotation-password']], 'the new password was tried against the old config');
|
||||
assert.equal(cleared, 0, 'the failed attempt must not clear the shared password');
|
||||
// The config event arrives afterwards; the retained password must
|
||||
// still be available to auto-unlock the rotated key.
|
||||
currentIdentity = 'new-key';
|
||||
await renderer.render(strict ? React.createElement(React.StrictMode, null, React.createElement(Harness)) : React.createElement(Harness));
|
||||
await flushEffects();
|
||||
assert.deepEqual(unlocked, [
|
||||
['old-key', 'rotation-password'],
|
||||
['new-key', 'rotation-password'],
|
||||
]);
|
||||
assert.equal(cleared, 0);
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
assert.equal(listeners.size, 0);
|
||||
});
|
||||
}
|
||||
|
||||
test('completed auto-unlock does not undo a later deliberate lock', async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let calls = 0;
|
||||
const manager = { unlock: async () => { calls++; return true; } };
|
||||
let onAvailable: (() => void) | undefined;
|
||||
const bridge = {
|
||||
cloudSyncGetSessionPassword: async () => 'fixture-only',
|
||||
onCloudSyncSessionPasswordAvailable: (cb: () => void) => { onAvailable = cb; return () => { onAvailable = undefined; }; },
|
||||
};
|
||||
function Harness({ state, identity = 'key' }: { state: string; identity?: string }) {
|
||||
useCloudSyncAutoUnlock({ securityState: state, masterKeyIdentity: identity, manager, bridge });
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await renderer.render(React.createElement(Harness, { state: 'LOCKED' }));
|
||||
await flushEffects();
|
||||
await renderer.render(React.createElement(Harness, { state: 'UNLOCKED' }));
|
||||
await runWithAct(async () => { onAvailable?.(); });
|
||||
await renderer.render(React.createElement(Harness, { state: 'LOCKED' }));
|
||||
await flushEffects();
|
||||
assert.equal(calls, 1);
|
||||
await runWithAct(async () => { onAvailable?.(); });
|
||||
await flushEffects();
|
||||
assert.equal(calls, 1, 'a peer password event after deliberate lock must not reopen this vault');
|
||||
await renderer.render(null);
|
||||
await renderer.render(React.createElement(Harness, { state: 'LOCKED' }));
|
||||
await runWithAct(async () => { onAvailable?.(); });
|
||||
await flushEffects();
|
||||
assert.equal(calls, 1, 'a remounted consumer must retain the manager deliberate lock');
|
||||
await renderer.render(React.createElement(Harness, { state: 'LOCKED', identity: 'replacement-key' }));
|
||||
await flushEffects();
|
||||
assert.equal(calls, 2, 'a genuinely replaced key can still initialize automatically');
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
});
|
||||
|
||||
for (const strict of [false, true]) {
|
||||
test(`remount reads a password shared while no consumer was mounted (StrictMode=${strict})`, async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let password: string | null = null;
|
||||
let reads = 0;
|
||||
const unlocked: string[] = [];
|
||||
const manager = { unlock: async (value: string) => { unlocked.push(value); return true; } };
|
||||
const bridge = { cloudSyncGetSessionPassword: async () => { reads++; return password; } };
|
||||
function Consumer() {
|
||||
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'remount-key', manager, bridge });
|
||||
return null;
|
||||
}
|
||||
const tree = () => React.createElement(strict ? React.StrictMode : React.Fragment, null, React.createElement(Consumer));
|
||||
try {
|
||||
await renderer.render(tree());
|
||||
await flushEffects();
|
||||
assert.ok(reads > 0);
|
||||
assert.deepEqual(unlocked, []);
|
||||
await renderer.render(null);
|
||||
await flushEffects();
|
||||
password = 'fixture-shared-while-absent';
|
||||
await renderer.render(tree());
|
||||
await flushEffects();
|
||||
assert.deepEqual(unlocked, ['fixture-shared-while-absent']);
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
});
|
||||
}
|
||||
|
||||
test('remount during an active derivation does not start a duplicate unlock', async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let finish!: (value: boolean) => void;
|
||||
let unlocks = 0;
|
||||
const manager = { unlock: () => { unlocks++; return new Promise<boolean>(resolve => { finish = resolve; }); } };
|
||||
const bridge = { cloudSyncGetSessionPassword: async () => 'fixture-only' };
|
||||
function Consumer() {
|
||||
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'active-key', manager, bridge });
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await renderer.render(React.createElement(Consumer));
|
||||
await flushEffects();
|
||||
assert.equal(unlocks, 1);
|
||||
await renderer.render(null);
|
||||
await renderer.render(React.createElement(Consumer));
|
||||
await flushEffects();
|
||||
assert.equal(unlocks, 1);
|
||||
await runWithAct(async () => finish(true));
|
||||
await flushEffects();
|
||||
assert.equal(unlocks, 1);
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
});
|
||||
|
||||
test('two StrictMode consumers share one derivation after initial effect replay', async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
const pending: Array<(value: string | null) => void> = [];
|
||||
let unlocks = 0;
|
||||
const manager = { unlock: async () => { unlocks++; return true; } };
|
||||
const bridge = { cloudSyncGetSessionPassword: () => new Promise<string | null>(resolve => pending.push(resolve)) };
|
||||
function Consumer() {
|
||||
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'strict-shared', manager, bridge });
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await renderer.render(React.createElement(React.StrictMode, null,
|
||||
React.createElement(Consumer, { key: 'one' }), React.createElement(Consumer, { key: 'two' })));
|
||||
await flushEffects();
|
||||
await runWithAct(async () => { for (const resolve of pending) resolve('fixture-only'); });
|
||||
await flushEffects();
|
||||
assert.equal(unlocks, 1);
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
});
|
||||
|
||||
test('password notification and a later peer mount share the same attempt', async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let password: string | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
let unlocks = 0;
|
||||
const manager = { unlock: async () => { unlocks++; return false; } };
|
||||
const bridge = {
|
||||
cloudSyncGetSessionPassword: async () => password,
|
||||
onCloudSyncSessionPasswordAvailable: (cb: () => void) => { listeners.add(cb); return () => { listeners.delete(cb); }; },
|
||||
};
|
||||
function Consumer() {
|
||||
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'notification-key', manager, bridge });
|
||||
return null;
|
||||
}
|
||||
const tree = (late: boolean) => React.createElement(React.Fragment, null,
|
||||
React.createElement(Consumer, { key: 'one' }), React.createElement(Consumer, { key: 'two' }),
|
||||
late ? React.createElement(Consumer, { key: 'late' }) : null);
|
||||
try {
|
||||
await renderer.render(tree(false));
|
||||
await flushEffects();
|
||||
assert.equal(unlocks, 0);
|
||||
await runWithAct(async () => { password = 'fixture-only'; for (const cb of listeners) cb(); });
|
||||
await flushEffects();
|
||||
assert.equal(unlocks, 1);
|
||||
await renderer.render(tree(true));
|
||||
await flushEffects();
|
||||
assert.equal(unlocks, 1, 'new consumers share the notification generation');
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
});
|
||||
@@ -1,115 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface AutoUnlockManager {
|
||||
unlock(password: string): Promise<boolean>;
|
||||
}
|
||||
interface AutoUnlockBridge {
|
||||
cloudSyncGetSessionPassword?(): Promise<string | null>;
|
||||
onCloudSyncSessionPasswordAvailable?(callback: () => void): () => void;
|
||||
}
|
||||
|
||||
// A renderer can mount several consumers of the same manager. Remember the
|
||||
// unlocked key across consumer unmounts so reopening settings cannot undo a lock.
|
||||
const unlockedIdentityByManager = new WeakMap<AutoUnlockManager, string>();
|
||||
// Dedupe attempts per manager (not per hook instance): every consumer of the
|
||||
// same manager observes the same events, and without sharing they would each
|
||||
// run the expensive PBKDF2 derivation inside manager.unlock.
|
||||
const attemptedAttemptByManager = new WeakMap<AutoUnlockManager, string | null>();
|
||||
const passwordRevisionByManager = new WeakMap<AutoUnlockManager, number>();
|
||||
const completedAttemptByManager = new WeakMap<AutoUnlockManager, string>();
|
||||
// Mounted consumers register a retry trigger here so an attempt can be handed
|
||||
// off when its owner unmounts (see the cleanup below).
|
||||
const retryListenersByManager = new WeakMap<AutoUnlockManager, Set<() => void>>();
|
||||
|
||||
/** Retry a locked peer window when the setting window finishes sharing its key. */
|
||||
export function useCloudSyncAutoUnlock(input: {
|
||||
securityState: string;
|
||||
masterKeyIdentity: string | null;
|
||||
manager: AutoUnlockManager;
|
||||
bridge: AutoUnlockBridge | null | undefined;
|
||||
}) {
|
||||
const { securityState, masterKeyIdentity, manager, bridge } = input;
|
||||
const [retryRevision, setRetryRevision] = useState(0);
|
||||
|
||||
useEffect(() => bridge?.onCloudSyncSessionPasswordAvailable?.(() => {
|
||||
passwordRevisionByManager.set(manager, (passwordRevisionByManager.get(manager) ?? 0) + 1);
|
||||
setRetryRevision(value => value + 1);
|
||||
}), [bridge, manager]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!masterKeyIdentity) return;
|
||||
const attempt = JSON.stringify([masterKeyIdentity, passwordRevisionByManager.get(manager) ?? 0]);
|
||||
if (securityState === 'UNLOCKED') {
|
||||
unlockedIdentityByManager.set(manager, masterKeyIdentity);
|
||||
attemptedAttemptByManager.set(manager, attempt);
|
||||
return;
|
||||
}
|
||||
if (securityState !== 'LOCKED') return;
|
||||
// Once this key has been unlocked, a later lock is intentional. A peer
|
||||
// sharing its password must not undo it, regardless of notification order.
|
||||
if (unlockedIdentityByManager.get(manager) === masterKeyIdentity) return;
|
||||
// Register before the dedup check: a consumer that only observes an
|
||||
// in-flight attempt must still be reachable for an ownership handoff.
|
||||
let listeners = retryListenersByManager.get(manager);
|
||||
if (!listeners) {
|
||||
listeners = new Set();
|
||||
retryListenersByManager.set(manager, listeners);
|
||||
}
|
||||
const notifyRetry = () => setRetryRevision(value => value + 1);
|
||||
listeners.add(notifyRetry);
|
||||
const removeListener = () => {
|
||||
listeners.delete(notifyRetry);
|
||||
if (listeners.size === 0 && completedAttemptByManager.get(manager) === attemptedAttemptByManager.get(manager)) {
|
||||
attemptedAttemptByManager.delete(manager);
|
||||
completedAttemptByManager.delete(manager);
|
||||
}
|
||||
};
|
||||
if (attemptedAttemptByManager.get(manager) === attempt) {
|
||||
return removeListener;
|
||||
}
|
||||
attemptedAttemptByManager.set(manager, attempt);
|
||||
completedAttemptByManager.delete(manager);
|
||||
let handedOff = false;
|
||||
let cancelled = false;
|
||||
let awaitingPassword = true;
|
||||
void (async () => {
|
||||
try {
|
||||
const password = await bridge?.cloudSyncGetSessionPassword?.();
|
||||
awaitingPassword = false;
|
||||
if (cancelled || !password) return;
|
||||
// A failed attempt must not clear the shared password: the config and
|
||||
// the password travel over unordered channels (localStorage vs IPC),
|
||||
// so this attempt may have raced a master-key rotation and the
|
||||
// password may match the config that is still in flight. The attempt
|
||||
// is deduped per [identity, revision], so keeping the password lets
|
||||
// the arriving config trigger a retry instead of stranding the vault.
|
||||
await manager.unlock(password);
|
||||
} catch {
|
||||
// Explicit sync actions surface errors; keep auto-unlock silent.
|
||||
} finally {
|
||||
if (!handedOff && attemptedAttemptByManager.get(manager) === attempt) {
|
||||
completedAttemptByManager.set(manager, attempt);
|
||||
// Availability events can arrive while no consumer is mounted.
|
||||
// A future mount must read again after this attempt has settled.
|
||||
if (listeners.size === 0) {
|
||||
attemptedAttemptByManager.delete(manager);
|
||||
completedAttemptByManager.delete(manager);
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
removeListener();
|
||||
// React StrictMode can immediately remount the same effect.
|
||||
if (awaitingPassword && attemptedAttemptByManager.get(manager) === attempt) {
|
||||
handedOff = true;
|
||||
attemptedAttemptByManager.set(manager, null);
|
||||
// Hand the attempt off: this consumer's in-flight password request is
|
||||
// discarded as cancelled, but other mounted consumers already returned
|
||||
// at the dedup check above, so they must be nudged to retry.
|
||||
for (const notify of listeners) notify();
|
||||
}
|
||||
};
|
||||
}, [securityState, masterKeyIdentity, manager, bridge, retryRevision]);
|
||||
}
|
||||
@@ -385,10 +385,7 @@ export const usePortForwardingAutoStart = ({
|
||||
return startPortForward(rule, host, resolveEffectiveHosts(hostsRef.current), keysRef.current, identitiesRef.current, onStatusChange, true, terminalSettingsRef.current, knownHostsRef.current);
|
||||
};
|
||||
|
||||
setReconnectCallback(handleReconnect, (ruleId) => isPortForwardingAutoStartEnabled(
|
||||
localStorageAdapter.read<PortForwardingRule[]>(STORAGE_KEY_PORT_FORWARDING) ?? [],
|
||||
ruleId,
|
||||
));
|
||||
setReconnectCallback(handleReconnect);
|
||||
return () => {
|
||||
setReconnectCallback(null);
|
||||
};
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
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("global broadcast availability follows visible orphan session count", 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));
|
||||
});
|
||||
|
||||
assert.equal(current().orphanSessions.length, 0);
|
||||
assert.equal(current().canUseGlobalBroadcast, false);
|
||||
assert.equal(current().isGlobalBroadcastEnabled, false);
|
||||
|
||||
await act(async () => {
|
||||
current().createLocalTerminal({ shellName: "01" });
|
||||
});
|
||||
assert.equal(current().orphanSessions.length, 1);
|
||||
assert.equal(current().canUseGlobalBroadcast, false);
|
||||
|
||||
await act(async () => {
|
||||
current().createLocalTerminal({ shellName: "02" });
|
||||
});
|
||||
assert.equal(current().orphanSessions.length, 2);
|
||||
assert.equal(current().canUseGlobalBroadcast, true);
|
||||
|
||||
await act(async () => {
|
||||
current().toggleGlobalBroadcast();
|
||||
});
|
||||
assert.equal(current().isGlobalBroadcastEnabled, true);
|
||||
|
||||
await act(async () => {
|
||||
current().toggleGlobalBroadcast();
|
||||
});
|
||||
assert.equal(current().isGlobalBroadcastEnabled, false);
|
||||
|
||||
await act(async () => { current().toggleGlobalBroadcast(); });
|
||||
const closedId = current().orphanSessions[1].id;
|
||||
await act(async () => { current().closeSession(closedId); });
|
||||
assert.equal(current().canUseGlobalBroadcast, false);
|
||||
assert.equal(current().isGlobalBroadcastEnabled, false);
|
||||
await act(async () => { current().createLocalTerminal({ shellName: "03" }); });
|
||||
assert.equal(current().canUseGlobalBroadcast, true);
|
||||
assert.equal(current().isGlobalBroadcastEnabled, false);
|
||||
await act(async () => { current().toggleGlobalBroadcast(); });
|
||||
const [first, second] = current().orphanSessions;
|
||||
await act(async () => {
|
||||
current().createWorkspaceFromSessions(first.id, second.id, "right");
|
||||
});
|
||||
assert.equal(current().canUseGlobalBroadcast, false);
|
||||
assert.equal(current().isGlobalBroadcastEnabled, false);
|
||||
await act(async () => {
|
||||
current().removeSessionFromWorkspace(second.id);
|
||||
});
|
||||
assert.equal(current().canUseGlobalBroadcast, true);
|
||||
assert.equal(current().isGlobalBroadcastEnabled, 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");
|
||||
}
|
||||
});
|
||||
@@ -233,8 +233,6 @@ 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());
|
||||
// Global broadcast mode: enables broadcast for all orphan sessions (not in workspaces)
|
||||
const [globalBroadcastEnabled, setGlobalBroadcastEnabled] = useState<boolean>(false);
|
||||
// Log views: stores open log replay tabs
|
||||
const [logViews, setLogViews] = useState<LogView[]>([]);
|
||||
const [restorePreviousSessionRevision, setRestorePreviousSessionRevision] = useState(0);
|
||||
@@ -1123,12 +1121,6 @@ export const useSessionState = ({
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const orphanSessions = useMemo(() => sessions.filter(s => !s.workspaceId && !s.hiddenFromTabs), [sessions]);
|
||||
const canUseGlobalBroadcast = orphanSessions.length >= 2;
|
||||
|
||||
// Losing the group must disarm broadcast before another tab can join it.
|
||||
if (!canUseGlobalBroadcast && globalBroadcastEnabled) {
|
||||
setGlobalBroadcastEnabled(false);
|
||||
}
|
||||
|
||||
const openLogView = useCallback((log: ConnectionLog) => {
|
||||
const tabId = getLogViewTabId(log);
|
||||
@@ -1280,14 +1272,6 @@ export const useSessionState = ({
|
||||
return broadcastWorkspaceIds.has(workspaceId);
|
||||
}, [broadcastWorkspaceIds]);
|
||||
|
||||
// Toggle global broadcast mode for orphan sessions
|
||||
const toggleGlobalBroadcast = useCallback(() => {
|
||||
setGlobalBroadcastEnabled(prev => !prev);
|
||||
}, []);
|
||||
|
||||
// Global broadcast enabled state - direct boolean value
|
||||
const isGlobalBroadcastEnabled = globalBroadcastEnabled;
|
||||
|
||||
const baseWorkTabIds = useMemo(() => [
|
||||
...orphanSessions.map(s => s.id),
|
||||
...workspaces.map(w => w.id),
|
||||
@@ -1411,12 +1395,9 @@ export const useSessionState = ({
|
||||
moveFocusInWorkspace,
|
||||
runSnippet,
|
||||
orphanSessions,
|
||||
canUseGlobalBroadcast,
|
||||
// Broadcast mode
|
||||
toggleBroadcast,
|
||||
isBroadcastEnabled,
|
||||
toggleGlobalBroadcast,
|
||||
isGlobalBroadcastEnabled,
|
||||
orderedTabs,
|
||||
getOrderedWorkTabs,
|
||||
reorderTabs,
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
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 { useStoredBoolean } from "./useStoredBoolean.ts";
|
||||
|
||||
const STORAGE_KEY = "netcatty:test:stored-boolean";
|
||||
|
||||
test("functional updates stay pure when another consumer shares the storage key", async () => {
|
||||
const values = new Map<string, string>([[STORAGE_KEY, "false"]]);
|
||||
const fakeStorage = {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => values.set(key, value),
|
||||
removeItem: (key: string) => values.delete(key),
|
||||
};
|
||||
const fakeWindow = new EventTarget();
|
||||
const globals = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
localStorage?: typeof fakeStorage;
|
||||
window?: typeof fakeWindow;
|
||||
};
|
||||
const previousActEnvironment = globals.IS_REACT_ACT_ENVIRONMENT;
|
||||
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
const previousDispatchEvent = Object.getOwnPropertyDescriptor(globalThis, "dispatchEvent");
|
||||
const previousCustomEvent = Object.getOwnPropertyDescriptor(globalThis, "CustomEvent");
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
let toggle: (() => void) | null = null;
|
||||
let parentValue = false;
|
||||
let childValue = false;
|
||||
const errors: string[] = [];
|
||||
const originalConsoleError = console.error;
|
||||
|
||||
Object.defineProperty(globalThis, "localStorage", { value: fakeStorage, configurable: true });
|
||||
Object.defineProperty(globalThis, "window", { value: fakeWindow, configurable: true });
|
||||
Object.defineProperty(globalThis, "dispatchEvent", {
|
||||
value: fakeWindow.dispatchEvent.bind(fakeWindow),
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, "CustomEvent", {
|
||||
value: class TestCustomEvent<T> extends Event implements CustomEvent<T> {
|
||||
readonly detail: T;
|
||||
|
||||
constructor(type: string, init?: CustomEventInit<T>) {
|
||||
super(type);
|
||||
this.detail = init?.detail as T;
|
||||
}
|
||||
|
||||
initCustomEvent(): void {
|
||||
// Deprecated browser API required by the CustomEvent interface.
|
||||
}
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
console.error = (...args: unknown[]) => {
|
||||
errors.push(args.map(String).join(" "));
|
||||
};
|
||||
|
||||
const Parent = () => {
|
||||
[parentValue] = useStoredBoolean(STORAGE_KEY);
|
||||
return React.createElement(Child);
|
||||
};
|
||||
const Child = () => {
|
||||
const [value, setValue] = useStoredBoolean(STORAGE_KEY);
|
||||
childValue = value;
|
||||
toggle = () => setValue((previous) => !previous);
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(React.StrictMode, null, React.createElement(Parent)));
|
||||
});
|
||||
await act(async () => {
|
||||
toggle?.();
|
||||
});
|
||||
|
||||
assert.equal(parentValue, true);
|
||||
assert.equal(childValue, true);
|
||||
assert.equal(values.get(STORAGE_KEY), "true");
|
||||
assert.equal(
|
||||
errors.some((message) => message.includes("Cannot update a component")
|
||||
&& message.includes("while rendering a different component")),
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
|
||||
else delete globals.localStorage;
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
|
||||
else delete globals.window;
|
||||
if (previousDispatchEvent) Object.defineProperty(globalThis, "dispatchEvent", previousDispatchEvent);
|
||||
else delete (globalThis as typeof globalThis & { dispatchEvent?: unknown }).dispatchEvent;
|
||||
if (previousCustomEvent) Object.defineProperty(globalThis, "CustomEvent", previousCustomEvent);
|
||||
else delete (globalThis as typeof globalThis & { CustomEvent?: unknown }).CustomEvent;
|
||||
}
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
|
||||
/**
|
||||
@@ -17,37 +17,30 @@ export const useStoredBoolean = (
|
||||
const stored = localStorageAdapter.readBoolean(storageKey);
|
||||
return stored ?? fallback;
|
||||
});
|
||||
const valueRef = useRef(value);
|
||||
|
||||
const setAndPersist = useCallback((next: boolean | ((prev: boolean) => boolean)) => {
|
||||
// Resolve functional updates before scheduling React state. The updater
|
||||
// must stay pure because React may evaluate it while rendering.
|
||||
const resolved = typeof next === "function" ? next(valueRef.current) : next;
|
||||
valueRef.current = resolved;
|
||||
setValue(resolved);
|
||||
localStorageAdapter.writeBoolean(storageKey, resolved);
|
||||
// Notify other same-window consumers outside the state updater.
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("stored-boolean-change", { detail: { key: storageKey, value: resolved } }),
|
||||
);
|
||||
setValue((prev) => {
|
||||
const resolved = typeof next === "function" ? next(prev) : next;
|
||||
localStorageAdapter.writeBoolean(storageKey, resolved);
|
||||
// Notify other same-window consumers
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("stored-boolean-change", { detail: { key: storageKey, value: resolved } }),
|
||||
);
|
||||
return resolved;
|
||||
});
|
||||
}, [storageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
// Sync from other components in the same window
|
||||
const handleCustom = (e: Event) => {
|
||||
const { key, value: newValue } = (e as CustomEvent).detail;
|
||||
if (key === storageKey) {
|
||||
valueRef.current = newValue;
|
||||
setValue(newValue);
|
||||
}
|
||||
if (key === storageKey) setValue(newValue);
|
||||
};
|
||||
// Sync from other windows
|
||||
const handleStorage = (e: StorageEvent) => {
|
||||
if (e.key === storageKey) {
|
||||
const stored = localStorageAdapter.readBoolean(storageKey);
|
||||
const nextValue = stored ?? fallback;
|
||||
valueRef.current = nextValue;
|
||||
setValue(nextValue);
|
||||
setValue(stored ?? fallback);
|
||||
}
|
||||
};
|
||||
window.addEventListener("stored-boolean-change", handleCustom);
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createVaultNoteAttachment, formatVaultNoteReferences, vaultNoteReferencesFit } from "./vaultNoteAttachment.ts";
|
||||
import { buildPromptWithTerminalSelectionAttachments, createTerminalSelectionAttachment, isInlineTextAttachment } from "./terminalSelectionAttachment.ts";
|
||||
|
||||
test("mentions carry identity without copying even large note bodies", () => {
|
||||
const note = { id: "note-1", title: "Runbook", content: "secret body".repeat(200_000) };
|
||||
const attachment = createVaultNoteAttachment(note)!;
|
||||
assert.equal(attachment.vaultNoteId, note.id);
|
||||
assert.equal(attachment.base64Data, "");
|
||||
assert.equal(attachment.dataUrl, "");
|
||||
assert.ok(JSON.stringify(attachment).length < 400);
|
||||
assert.ok(isInlineTextAttachment(attachment));
|
||||
const prompt = buildPromptWithTerminalSelectionAttachments("summarize", [attachment]);
|
||||
assert.match(prompt, /vault_notes_get/);
|
||||
assert.match(prompt, /latest content/);
|
||||
assert.doesNotMatch(prompt, /secret body/);
|
||||
});
|
||||
|
||||
test("same titled notes keep distinct exact IDs including imported special characters", () => {
|
||||
for (const id of ["note-a", "note-b", ' quoted"\\id\r\n ']) {
|
||||
const attachment = createVaultNoteAttachment({ id, title: "Same title" })!;
|
||||
const reference = formatVaultNoteReferences([attachment]).split("\n")[0];
|
||||
const [metadata] = JSON.parse(reference.slice("[Vault note references: ".length, -1));
|
||||
assert.equal(metadata.noteId, id);
|
||||
assert.equal(metadata.title, "Same title");
|
||||
}
|
||||
});
|
||||
|
||||
test("invalid IDs are rejected and displayed titles remain bounded without broken emoji", () => {
|
||||
assert.equal(createVaultNoteAttachment({ id: " ", title: "x" }), null);
|
||||
assert.equal(createVaultNoteAttachment({ id: "a".repeat(201), title: "x" }), null);
|
||||
const attachment = createVaultNoteAttachment({ id: "ok", title: "😀".repeat(200) })!;
|
||||
assert.equal(Array.from(attachment.vaultNoteTitle!).length, 120);
|
||||
});
|
||||
|
||||
test("note-only sends and mixed terminal selections retain both kinds of context", () => {
|
||||
const note = createVaultNoteAttachment({ id: "empty-note", title: "" })!;
|
||||
const terminal = createTerminalSelectionAttachment("systemctl status nginx")!;
|
||||
assert.match(buildPromptWithTerminalSelectionAttachments("", [note]), /empty-note/);
|
||||
const prompt = buildPromptWithTerminalSelectionAttachments("compare", [note, terminal]);
|
||||
assert.match(prompt, /systemctl status nginx/);
|
||||
assert.match(prompt, /empty-note/);
|
||||
assert.match(prompt, /^compare/);
|
||||
});
|
||||
|
||||
test("reference limit reserves replay space and does not count ordinary files", () => {
|
||||
const notes = Array.from({length: 10}, (_, i) => createVaultNoteAttachment({id: String(i).padEnd(200, "x"), title: "t".repeat(120)})!);
|
||||
assert.equal(vaultNoteReferencesFit(notes), false);
|
||||
const accepted = notes.filter((_, index) => vaultNoteReferencesFit(notes.slice(0, index + 1)));
|
||||
assert.ok(accepted.length > 0);
|
||||
assert.ok(formatVaultNoteReferences(accepted).length <= 1000);
|
||||
assert.equal(vaultNoteReferencesFit([...accepted, {id: "file", filename: "file", mediaType: "text/plain", dataUrl: "", base64Data: "x".repeat(2_000_000)}]), true);
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
import type { VaultNote } from "../../domain/models";
|
||||
import type { ChatMessageAttachment, UploadedFile } from "../../infrastructure/ai/types";
|
||||
|
||||
/** A mention stores only identity; the agent reads current content through the Vault tool. */
|
||||
export function createVaultNoteAttachment(note: Pick<VaultNote, "id" | "title">): UploadedFile | null {
|
||||
// Preserve imported IDs exactly. Reject oversized IDs instead of changing their identity.
|
||||
if (!note.id.trim() || note.id.length > 200) return null;
|
||||
const title = Array.from(note.title.trim() || "Untitled note").slice(0, 120).join("");
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
filename: title,
|
||||
mediaType: "text/markdown",
|
||||
base64Data: "",
|
||||
dataUrl: "",
|
||||
vaultNoteId: note.id,
|
||||
vaultNoteTitle: title,
|
||||
};
|
||||
}
|
||||
|
||||
export function isVaultNoteAttachment(
|
||||
attachment: Pick<ChatMessageAttachment | UploadedFile, "vaultNoteId">,
|
||||
): boolean {
|
||||
return typeof attachment.vaultNoteId === "string" && attachment.vaultNoteId.length > 0;
|
||||
}
|
||||
|
||||
/** JSON preserves special characters and whitespace in exact note IDs. */
|
||||
export function formatVaultNoteReferences(
|
||||
attachments: Array<Pick<ChatMessageAttachment | UploadedFile, "vaultNoteId" | "vaultNoteTitle">>,
|
||||
): string {
|
||||
const notes = attachments.map((attachment) => ({ noteId: attachment.vaultNoteId, title: attachment.vaultNoteTitle }));
|
||||
return `[Vault note references: ${JSON.stringify(notes)}]\nUse vault_notes_get with each exact noteId to read the latest content before summarizing or editing. If unavailable, report that instead of choosing another note.`;
|
||||
}
|
||||
|
||||
/** Reserve at least half of external replay's 2,000 characters for the user's request. */
|
||||
export function vaultNoteReferencesFit(attachments: UploadedFile[]): boolean {
|
||||
const notes = attachments.filter(isVaultNoteAttachment);
|
||||
return notes.length === 0 || formatVaultNoteReferences(notes).length <= 1000;
|
||||
}
|
||||
@@ -88,8 +88,6 @@ interface AIChatPanelContentProps {
|
||||
setGlobalPermissionMode?: (mode: AIPermissionMode) => void;
|
||||
notes?: VaultNote[];
|
||||
hosts?: Host[];
|
||||
/** Mention Note: attach a Vault → Notes entry as inline context. */
|
||||
onMentionNote?: (note: VaultNote) => void;
|
||||
onOpenVaultNote?: (noteId: string) => void;
|
||||
onOpenVaultHost?: (hostId: string) => void;
|
||||
onOpenVaultSection?: (section: 'notes' | 'hosts') => void;
|
||||
@@ -159,7 +157,6 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
setGlobalPermissionMode,
|
||||
notes = [],
|
||||
hosts = [],
|
||||
onMentionNote,
|
||||
onOpenVaultNote,
|
||||
onOpenVaultHost,
|
||||
onOpenVaultSection,
|
||||
@@ -364,8 +361,6 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
onAddFiles={addFiles}
|
||||
onRemoveFile={removeFile}
|
||||
hosts={mentionHosts}
|
||||
notes={notes}
|
||||
onMentionNote={onMentionNote}
|
||||
selectedUserSkills={selectedUserSkills}
|
||||
userSkills={userSkillOptions}
|
||||
quickMessages={quickMessages}
|
||||
|
||||
@@ -9,11 +9,9 @@ import type {
|
||||
AIPanelView,
|
||||
AgentModelPreset,
|
||||
AISessionScope,
|
||||
UploadedFile,
|
||||
DiscoveredAgent,
|
||||
ExternalAgentConfig,
|
||||
} from '../infrastructure/ai/types';
|
||||
import type { VaultNote } from '../domain/models';
|
||||
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
|
||||
import {
|
||||
filterAgentModelPresetsForCliVersion,
|
||||
@@ -22,7 +20,6 @@ import {
|
||||
resolveAgentModelSelection,
|
||||
} from '../infrastructure/ai/types';
|
||||
import { getExternalAgentSdkBackend, getManualAgentCommand, matchesManagedAgentConfig } from '../infrastructure/ai/managedAgents';
|
||||
import { toast } from './ui/toast';
|
||||
import { useAgentDiscovery } from '../application/state/useAgentDiscovery';
|
||||
import {
|
||||
getReadyUserSkillOptions,
|
||||
@@ -45,9 +42,8 @@ import { draftsByScopeEqualIgnoringComposerText, selectDraftForAgentSwitch } fro
|
||||
import { sanitizeContextWindow } from '../infrastructure/ai/contextCompaction';
|
||||
import {
|
||||
buildPromptWithTerminalSelectionAttachments,
|
||||
isInlineTextAttachment,
|
||||
isTerminalSelectionAttachment,
|
||||
} from '../application/state/terminalSelectionAttachment';
|
||||
import { createVaultNoteAttachment, isVaultNoteAttachment, vaultNoteReferencesFit } from '../application/state/vaultNoteAttachment';
|
||||
import type { CodexIntegrationStatus } from './settings/tabs/ai/types';
|
||||
import {
|
||||
useAIChatStreaming,
|
||||
@@ -601,42 +597,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
removeDraftFile(scopeKey, currentAgentId, fileId);
|
||||
}, [removeDraftFile, scopeKey, currentAgentId]);
|
||||
|
||||
// External turns keep the tools from launch; changed settings cannot enable note reads mid-turn.
|
||||
const canMentionNotes = currentAgentId === 'catty' || (toolIntegrationMode === 'mcp' && !isStreaming);
|
||||
const validateNoteMentions = useCallback((attachments: UploadedFile[]) => {
|
||||
if (!canMentionNotes && attachments.some(isVaultNoteAttachment)) {
|
||||
toast.warning(t('ai.chat.mentionNoteUnavailable'));
|
||||
return false;
|
||||
}
|
||||
if (!vaultNoteReferencesFit(attachments)) {
|
||||
toast.warning(t('ai.chat.mentionNoteTooMany'));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}, [canMentionNotes, t]);
|
||||
|
||||
/** Mention Note: attach a Vault → Notes entry as inline context for the next send. */
|
||||
const mentionNote = useCallback((note: VaultNote) => {
|
||||
if (!canMentionNotes) return;
|
||||
const upload = createVaultNoteAttachment({ id: note.id, title: note.title.trim() || t('ai.chat.untitledNote') });
|
||||
if (!upload) {
|
||||
toast.error(t('ai.chat.mentionNoteInvalid', {
|
||||
title: String(note.title || '').trim() || t('ai.chat.untitledNote'),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const existing = currentDraftRef.current?.attachments ?? [];
|
||||
if (!validateNoteMentions([...existing.filter((file) => file.vaultNoteId !== upload.vaultNoteId), upload])) return;
|
||||
enterScopeDraftMode(currentAgentId, panelViewRef.current.mode === 'session');
|
||||
updateDraft(scopeKey, currentAgentId, (current) => ({
|
||||
...current,
|
||||
attachments: [
|
||||
...current.attachments.filter((file) => file.vaultNoteId !== upload.vaultNoteId),
|
||||
upload,
|
||||
],
|
||||
}));
|
||||
}, [canMentionNotes, validateNoteMentions, updateDraft, currentAgentId, enterScopeDraftMode, scopeKey, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible) return undefined;
|
||||
flushDraftText();
|
||||
@@ -1181,7 +1141,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
const draft = currentDraftRef.current;
|
||||
const currentPanelView = panelViewRef.current;
|
||||
const currentSessionView = activeSessionRef.current;
|
||||
if (!validateNoteMentions(draft?.attachments ?? [])) return;
|
||||
const trimmed = draft?.text.trim() ?? '';
|
||||
const sendScopeKey = scopeKey;
|
||||
const attachments = (draft?.attachments ?? []).map((file) => ({
|
||||
@@ -1190,27 +1149,18 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
filename: file.filename,
|
||||
filePath: file.filePath,
|
||||
terminalSelection: file.terminalSelection,
|
||||
vaultNoteId: file.vaultNoteId,
|
||||
vaultNoteTitle: file.vaultNoteTitle,
|
||||
previewText: file.previewText,
|
||||
lineCount: file.lineCount,
|
||||
}));
|
||||
const hasInlineTextAttachments = attachments.some(isInlineTextAttachment);
|
||||
if ((!trimmed && !hasInlineTextAttachments) || isStreaming) return;
|
||||
// Note-only sends (empty text + a mentioned note) still need a usable
|
||||
// session title: fall back to the first mentioned note's title so these
|
||||
// conversations don't all show up as "Untitled" in history.
|
||||
const noteOnlyTitle = attachments.find(
|
||||
(attachment) => isVaultNoteAttachment(attachment) && attachment.vaultNoteTitle,
|
||||
)?.vaultNoteTitle ?? '';
|
||||
const titleText = trimmed || noteOnlyTitle.trim();
|
||||
const hasTerminalSelectionAttachments = attachments.some(isTerminalSelectionAttachment);
|
||||
if ((!trimmed && !hasTerminalSelectionAttachments) || isStreaming) return;
|
||||
const sendAgentId = currentSessionView?.agentId ?? draft?.agentId ?? currentAgentId;
|
||||
const agentConfig = sendAgentId !== 'catty' ? findEnabledExternalAgent(externalAgents, sendAgentId) : undefined;
|
||||
if (sendAgentId !== 'catty' && !agentConfig) return;
|
||||
|
||||
const selectedSkillSlugs = draft?.selectedUserSkillSlugs ?? [];
|
||||
const modelPrompt = buildPromptWithTerminalSelectionAttachments(trimmed, attachments);
|
||||
const modelAttachments = attachments.filter((attachment) => !isInlineTextAttachment(attachment));
|
||||
const modelAttachments = attachments.filter((attachment) => !isTerminalSelectionAttachment(attachment));
|
||||
const isDraftMode = currentPanelView.mode === 'draft';
|
||||
|
||||
flushDraftText();
|
||||
@@ -1387,7 +1337,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
updateLastMessage(sessionId, msg => msg.statusText ? { ...msg, statusText: '' } : msg);
|
||||
setStreamingForScope(sessionId, false);
|
||||
abortControllersRef.current.delete(sessionId);
|
||||
autoTitleSession(sessionId, titleText);
|
||||
autoTitleSession(sessionId, trimmed);
|
||||
} else {
|
||||
const toolScope = {
|
||||
type: scopeType,
|
||||
@@ -1410,7 +1360,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
getExecutorContext: () => buildExecutorContextForScope(toolScope),
|
||||
autoTitleSession,
|
||||
selectedUserSkillSlugs: selectedSkillSlugs,
|
||||
titleText,
|
||||
titleText: trimmed,
|
||||
}, modelAttachments.length > 0 ? modelAttachments : undefined);
|
||||
}
|
||||
} finally {
|
||||
@@ -1421,7 +1371,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
}
|
||||
}
|
||||
}, [
|
||||
validateNoteMentions,
|
||||
isStreaming, activeProvider, effectiveActiveProvider, effectiveActiveModelId, selectedCattyThinking, scopeKey, currentAgentId,
|
||||
activeModelId, externalAgents,
|
||||
createSession, addMessageToSession, updateMessageById, updateLastMessage,
|
||||
@@ -1518,7 +1467,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
const draft = currentDraftRef.current;
|
||||
if (!sessionId || !draft || steeringSessionId || !canSteerCurrentTurn) return;
|
||||
|
||||
if (!validateNoteMentions(draft.attachments)) return;
|
||||
const trimmed = draft.text.trim();
|
||||
const attachments = draft.attachments.map((file) => ({
|
||||
base64Data: file.base64Data,
|
||||
@@ -1526,17 +1474,15 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
filename: file.filename,
|
||||
filePath: file.filePath,
|
||||
terminalSelection: file.terminalSelection,
|
||||
vaultNoteId: file.vaultNoteId,
|
||||
vaultNoteTitle: file.vaultNoteTitle,
|
||||
previewText: file.previewText,
|
||||
lineCount: file.lineCount,
|
||||
}));
|
||||
const hasInlineTextAttachments = attachments.some(isInlineTextAttachment);
|
||||
if (!trimmed && !hasInlineTextAttachments) return;
|
||||
const hasTerminalSelectionAttachments = attachments.some(isTerminalSelectionAttachment);
|
||||
if (!trimmed && !hasTerminalSelectionAttachments) return;
|
||||
|
||||
const userMessageId = generateId();
|
||||
const modelPrompt = buildPromptWithTerminalSelectionAttachments(trimmed, attachments);
|
||||
const modelAttachments = attachments.filter((attachment) => !isInlineTextAttachment(attachment));
|
||||
const modelAttachments = attachments.filter((attachment) => !isTerminalSelectionAttachment(attachment));
|
||||
setSteerWarnings(current => {
|
||||
const next = { ...current };
|
||||
delete next[sessionId];
|
||||
@@ -1565,8 +1511,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
} finally {
|
||||
setSteeringSessionId(current => current === sessionId ? null : current);
|
||||
}
|
||||
}, [
|
||||
validateNoteMentions, canSteerCurrentTurn, clearScopeDraft, steerExternalAgent, steeringSessionId]);
|
||||
}, [canSteerCurrentTurn, clearScopeDraft, steerExternalAgent, steeringSessionId]);
|
||||
|
||||
const stopStreamingForSession = useCallback(async (sessionId: string) => {
|
||||
const controller = abortControllersRef.current.get(sessionId);
|
||||
@@ -1762,7 +1707,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
files={files}
|
||||
addFiles={addFiles}
|
||||
removeFile={removeFile}
|
||||
onMentionNote={canMentionNotes ? mentionNote : undefined}
|
||||
terminalSessions={terminalSessions}
|
||||
selectedUserSkills={selectedUserSkills}
|
||||
userSkillOptions={userSkillOptions}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React, { act } from "react";
|
||||
import { createDomRenderer, dispatchDomEvent, flushEffects, installDomEnvironment } from "./test-support/renderReactDom.tsx";
|
||||
|
||||
test("resume from another panel is visible while backend verification is pending", async (t) => {
|
||||
const env = installDomEnvironment();
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
const globals = {
|
||||
MutationObserver: env.window.MutationObserver,
|
||||
NodeFilter: env.window.NodeFilter,
|
||||
HTMLInputElement: env.window.HTMLInputElement,
|
||||
Element: env.window.Element,
|
||||
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0),
|
||||
cancelAnimationFrame: clearTimeout,
|
||||
};
|
||||
for (const [key, value] of Object.entries(globals)) {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||
}
|
||||
Object.defineProperties(env.window.HTMLElement.prototype, {
|
||||
offsetWidth: { configurable: true, get: () => 460 },
|
||||
offsetHeight: { configurable: true, get() { return this.dataset.section === "global-sftp-transfer-list" ? 460 : 112; } },
|
||||
});
|
||||
const { I18nProvider } = await import("../application/i18n/I18nProvider.tsx");
|
||||
const { sftpTransferCenterStore: store } = await import("../application/state/sftpTransferCenterStore.ts");
|
||||
const { GlobalSftpTransferCenter } = await import("./GlobalSftpTransferCenter.tsx");
|
||||
const { TooltipProvider } = await import("./ui/tooltip.tsx");
|
||||
const renderer = await createDomRenderer(env.document);
|
||||
t.after(async () => {
|
||||
await renderer.unmount();
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
env.cleanup();
|
||||
});
|
||||
let settleResume!: (result: { success: boolean }) => void;
|
||||
let resumeCalls = 0;
|
||||
const gate = new Promise<{ success: boolean }>((resolve) => { settleResume = resolve; });
|
||||
Object.defineProperty(env.window, "netcatty", { configurable: true, value: {
|
||||
resumeTransfer: async () => { resumeCalls += 1; return gate; },
|
||||
} });
|
||||
store.publishOwner("resume-display-test", [{
|
||||
id: "resume-display", fileName: "resume-display.bin",
|
||||
sourcePath: "/source.bin", targetPath: "/target.bin",
|
||||
sourceConnectionId: "remote", targetConnectionId: "local", direction: "download",
|
||||
status: "paused", totalBytes: 1000, transferredBytes: 100, speed: 0,
|
||||
startTime: 1, isDirectory: false, resumable: true,
|
||||
}]);
|
||||
await renderer.render(<I18nProvider locale="en"><TooltipProvider><GlobalSftpTransferCenter /></TooltipProvider></I18nProvider>);
|
||||
const toggle = env.document.querySelector("[data-section=global-sftp-transfer-toggle]");
|
||||
assert.ok(toggle);
|
||||
await dispatchDomEvent(toggle, new env.window.MouseEvent("click", { bubbles: true }));
|
||||
let resumed!: Promise<void>;
|
||||
await act(async () => { resumed = store.resume("resume-display"); });
|
||||
try {
|
||||
await flushEffects();
|
||||
assert.equal(resumeCalls, 1);
|
||||
const row = env.document.querySelector('[role=progressbar][aria-label="resume-display.bin"]')?.closest('[data-transfer-status]');
|
||||
assert.ok(row);
|
||||
assert.match(row.textContent ?? "", /Reconnecting and resuming/);
|
||||
assert.equal(row.querySelector('button[aria-label="Resume"]'), null);
|
||||
} finally {
|
||||
await act(async () => { settleResume({ success: true }); await resumed; });
|
||||
await dispatchDomEvent(toggle, new env.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
}
|
||||
});
|
||||
@@ -23,7 +23,6 @@ import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import {
|
||||
sftpTransferCenterStore,
|
||||
useSftpTransferCenterBadge,
|
||||
useSftpTransferResuming,
|
||||
type SftpTransferCenterSnapshot,
|
||||
} from "../application/state/sftpTransferCenterStore";
|
||||
import { transferRuntime } from "../application/state/sftp/transferRuntime";
|
||||
@@ -356,6 +355,7 @@ function TransferRow({
|
||||
const { t } = useI18n();
|
||||
const folderReplaceWarningId = React.useId();
|
||||
// Optimistic spinner from click until store status moves off paused/interrupted.
|
||||
const [resumeClicked, setResumeClicked] = useState(false);
|
||||
const isDirParent = isDirectoryParentTask(task);
|
||||
const showCollapsedChildren = isDirParent && shouldShowCollapsedActiveChildren(task.status);
|
||||
const activeChildren = useMemo(
|
||||
@@ -397,8 +397,22 @@ function TransferRow({
|
||||
const storeResuming = task.reconnectRequired === true
|
||||
&& ["pending", "queued", "transferring"].includes(task.status)
|
||||
&& !task.error;
|
||||
const sharedResuming = useSftpTransferResuming(task.id);
|
||||
const isResuming = sharedResuming || storeResuming;
|
||||
const isResuming = storeResuming
|
||||
|| (resumeClicked && ["paused", "interrupted", "attention", "pending", "queued"].includes(task.status) && !task.error);
|
||||
// Clear optimistic click once the store has left the idle-resume surface or failed.
|
||||
useEffect(() => {
|
||||
if (!resumeClicked) return;
|
||||
if (
|
||||
storeResuming
|
||||
|| task.status === "transferring"
|
||||
|| task.status === "completed"
|
||||
|| task.status === "failed"
|
||||
|| task.status === "cancelled"
|
||||
|| !!task.error
|
||||
) {
|
||||
setResumeClicked(false);
|
||||
}
|
||||
}, [resumeClicked, storeResuming, task.status, task.error]);
|
||||
const canPause = task.resumable !== false && task.status === "transferring" && canControl && !isResuming;
|
||||
// Orphaned tasks after app restart (interrupted / attention / paused without a
|
||||
// live panel owner) must still expose resume/cancel from the global center.
|
||||
@@ -453,6 +467,7 @@ function TransferRow({
|
||||
}));
|
||||
};
|
||||
const resumeTask = () => {
|
||||
setResumeClicked(true);
|
||||
// Dedicated resume opens vault sessions for local↔remote and SFTP↔SFTP.
|
||||
// Only force-open the panel when the row still needs a live owner/adoption
|
||||
// (e.g. conflict) — not on every remote-to-remote resume click.
|
||||
|
||||
@@ -64,6 +64,4 @@ test("large transfer histories mount only visible rows and can scroll to the las
|
||||
const lastRow = env.document.querySelector('[role=progressbar][aria-label="file-0.bin"]')?.closest('[data-transfer-status]');
|
||||
assert.ok(lastRow?.classList.contains("border-b-0"), "only the actual final row omits its divider");
|
||||
assert.equal(store.getSnapshot().tasks.length, 1_000, "offscreen files must remain queued");
|
||||
await dispatchDomEvent(toggle, new env.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
import { getHostOsSelection, HOST_OS_SELECTIONS, resolveHostOs } from '../domain/host';
|
||||
import React from "react";
|
||||
import { AlertTriangle, ChevronDown, ChevronUp, Forward, Globe, HeartPulse, KeyRound, Link2, Palette, Plus, Router, ShieldAlert, TerminalSquare, Timer, Wifi, X, Variable } from "lucide-react";
|
||||
import { customThemeStore } from "../application/state/customThemeStore";
|
||||
@@ -351,59 +350,26 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
</HostDetailsSection>
|
||||
)}
|
||||
|
||||
{(!form.protocol || ['ssh', 'telnet', 'mosh', 'et'].includes(form.protocol)) && (
|
||||
{/* Network Device Mode — only for SSH hosts without Mosh / ET (serial already uses raw mode) */}
|
||||
{(!form.protocol || form.protocol === 'ssh') && !form.moshEnabled && !form.etEnabled && (
|
||||
<HostDetailsSection
|
||||
icon={<Router size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.deviceType")}
|
||||
>
|
||||
{(!form.protocol || form.protocol === 'ssh') && !form.moshEnabled && !form.etEnabled && (<>
|
||||
<ToggleRow
|
||||
label={t("hostDetails.deviceType")}
|
||||
hint={t("hostDetails.deviceType.desc")}
|
||||
enabled={effectiveDeviceType === 'network'}
|
||||
onToggle={() => update("deviceType", effectiveDeviceType === 'network' ? 'general' : 'network')}
|
||||
/>
|
||||
{effectiveDeviceType === 'network' && (
|
||||
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
|
||||
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400 break-words">
|
||||
{t("hostDetails.deviceType.warning")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>)}
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="group w-full justify-between h-8 px-2 hover:bg-accent/50"
|
||||
aria-label={t("hostDetails.os.title")}
|
||||
>
|
||||
<span className="text-xs font-medium text-muted-foreground">{t("hostDetails.os.title")}</span>
|
||||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{t(`hostDetails.os.${getHostOsSelection(form)}`)}
|
||||
<ChevronDown size={14} className="group-data-[state=open]:rotate-180" />
|
||||
</span>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2 space-y-2">
|
||||
<HostDetailsSettingRow label={t("hostDetails.os.selection")} hint={t("hostDetails.os.desc")}>
|
||||
<Select value={getHostOsSelection(form)} onValueChange={(value) => update("osOverride", value)}>
|
||||
<SelectTrigger className="h-8 w-36 text-xs" aria-label={t("hostDetails.os.selection")}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{HOST_OS_SELECTIONS.map((value) => (
|
||||
<SelectItem key={value} value={value}>{t(`hostDetails.os.${value}`)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</HostDetailsSettingRow>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.os.current")}: {t(`hostDetails.os.${resolveHostOs({ ...form, deviceType: effectiveDeviceType })}`)}
|
||||
<ToggleRow
|
||||
label={t("hostDetails.deviceType")}
|
||||
hint={t("hostDetails.deviceType.desc")}
|
||||
enabled={effectiveDeviceType === 'network'}
|
||||
onToggle={() => update("deviceType", effectiveDeviceType === 'network' ? 'general' : 'network')}
|
||||
/>
|
||||
{effectiveDeviceType === 'network' && (
|
||||
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
|
||||
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400 break-words">
|
||||
{t("hostDetails.deviceType.warning")}
|
||||
</p>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)}
|
||||
</HostDetailsSection>
|
||||
)}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
applyEffectiveHostAuthMethodSelection,
|
||||
detachEffectiveHostIdentity,
|
||||
HOST_AUTH_METHOD_CHOICES,
|
||||
HOST_OS_CHOICES,
|
||||
HostDetailsConnectionSections,
|
||||
removeSelectedHostCredential,
|
||||
shouldForceAuthMethodReselect,
|
||||
@@ -111,9 +112,26 @@ test("color and icon settings render for non-Linux hosts", () => {
|
||||
assert.match(markup, /hostDetails\.icon\.manualLabel/);
|
||||
});
|
||||
|
||||
test("basic connection settings do not require operating system selection", () => {
|
||||
test("host operating system can be selected in the ordinary host editor", () => {
|
||||
const markup = renderConnectionSections({ os: "windows" });
|
||||
assert.doesNotMatch(markup, /hostDetails\.os\./);
|
||||
|
||||
assert.deepEqual(
|
||||
HOST_OS_CHOICES.map(([value, labelKey]) => [value, labelKey]),
|
||||
[
|
||||
["linux", "hostDetails.os.linux"],
|
||||
["windows", "hostDetails.os.windows"],
|
||||
["macos", "hostDetails.os.macos"],
|
||||
],
|
||||
);
|
||||
assert.match(markup, /hostDetails\.os\.title/);
|
||||
assert.match(markup, /role="combobox"[^>]*aria-label="hostDetails\.os\.title"/);
|
||||
assert.match(markup, />hostDetails\.os\.windows<\/span>/);
|
||||
|
||||
const source = fs.readFileSync(
|
||||
path.join(path.dirname(fileURLToPath(import.meta.url)), "HostDetailsConnectionSections.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(source, /onValueChange=\{\(value\) => update\("os", value as Host\["os"\]\)\}/);
|
||||
});
|
||||
|
||||
test("host credentials expose automatic and password-only choices", () => {
|
||||
|
||||
@@ -23,6 +23,12 @@ export const HOST_AUTH_METHOD_CHOICES = [
|
||||
["certificate", "hostDetails.auth.certificate"],
|
||||
] as const;
|
||||
|
||||
export const HOST_OS_CHOICES = [
|
||||
["linux", "hostDetails.os.linux"],
|
||||
["windows", "hostDetails.os.windows"],
|
||||
["macos", "hostDetails.os.macos"],
|
||||
] as const;
|
||||
|
||||
export function shouldForceAuthMethodReselect(
|
||||
nextMethod: "auto" | "password" | "key" | "certificate",
|
||||
currentMethod: "auto" | "password" | "key" | "certificate",
|
||||
@@ -125,6 +131,12 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
|
||||
const selectedAuthMethodLabel = t(
|
||||
HOST_AUTH_METHOD_CHOICES.find(([value]) => value === resolvedAuthMethod)?.[1] ?? "hostDetails.auth.auto",
|
||||
);
|
||||
const resolvedHostOs = HOST_OS_CHOICES.some(([value]) => value === form.os)
|
||||
? form.os as Host["os"]
|
||||
: "linux";
|
||||
const selectedHostOsLabel = t(
|
||||
HOST_OS_CHOICES.find(([value]) => value === resolvedHostOs)?.[1] ?? "hostDetails.os.linux",
|
||||
);
|
||||
const effectiveEtEnabled = form.etEnabled ?? groupDefaults?.etEnabled;
|
||||
const effectiveProtocol = form.protocol ?? groupDefaults?.protocol;
|
||||
|
||||
@@ -140,6 +152,31 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
|
||||
onChange={(e) => update("hostname", e.target.value)}
|
||||
className="h-10 w-full"
|
||||
/>
|
||||
<HostDetailsSettingRow
|
||||
label={t("hostDetails.os.title")}
|
||||
hint={t("hostDetails.os.desc")}
|
||||
>
|
||||
<Select
|
||||
value={resolvedHostOs}
|
||||
onValueChange={(value) => update("os", value as Host["os"])}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-8 w-32 gap-2"
|
||||
aria-label={t("hostDetails.os.title")}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-left">
|
||||
{selectedHostOsLabel}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{HOST_OS_CHOICES.map(([value, labelKey]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{t(labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</HostDetailsSettingRow>
|
||||
</HostDetailsSection>
|
||||
|
||||
<HostDetailsSection
|
||||
|
||||
+2
-48
@@ -1,4 +1,3 @@
|
||||
import { resolveHostOs } from '../domain/host';
|
||||
import { Terminal as XTerm } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { SerializeAddon } from "@xterm/addon-serialize";
|
||||
@@ -253,8 +252,6 @@ import {
|
||||
shouldStartTerminalBackend,
|
||||
} from "./terminal/restoredSessionGate";
|
||||
import {
|
||||
alignTerminalViewportScroll,
|
||||
createSynchronizedOutputFitScheduler,
|
||||
AUTO_RUN_SNIPPET_LINE_DELAY_MS,
|
||||
forceSyncRenderAfterResize,
|
||||
MAX_CONNECTION_LOG_DATA_CHARS,
|
||||
@@ -567,11 +564,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const reconnectWakeInvalidateModeRef = useRef<"dispose" | "keep">("dispose");
|
||||
const reconnectWakeTokenRef = useRef<symbol | null>(null);
|
||||
const manualReconnectRequestRef = useRef<() => void>(() => {});
|
||||
// Once a pane reconnects, every later SSH attempt in it must dial a fresh
|
||||
// connection: reusing a live/idle pooled transport multiplexes onto the old
|
||||
// login, so remote supplementary-group changes (e.g. `usermod -aG`) are not
|
||||
// picked up until the app fully quits (#3293). See createTerminalSessionStarters.
|
||||
const requireFreshConnectionOnReconnectRef = useRef(false);
|
||||
const terminalDataCapturedRef = useRef(false);
|
||||
const connectionLogBufferRef = useRef(createConnectionLogBuffer(MAX_CONNECTION_LOG_DATA_CHARS));
|
||||
const terminalOutputHistoryRef = useRef(createTerminalOutputHistoryPreview());
|
||||
@@ -646,17 +638,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const fontWeightFixupDoneRef = useRef(false);
|
||||
|
||||
const captureTerminalLogData = useCallback((data: string) => {
|
||||
// Keep the history tracker's viewport size current so cursor-row and
|
||||
// cursor-column moves are clamped exactly like the terminal clamps them.
|
||||
const captureTerm = termRef.current;
|
||||
if (captureTerm) {
|
||||
terminalOutputHistoryRef.current.setViewportRows(captureTerm.rows);
|
||||
terminalOutputHistoryRef.current.setViewportCols(captureTerm.cols);
|
||||
// The tracker's wrap decisions must measure cell widths the way this
|
||||
// terminal's Unicode provider does, or the preview diverges from what
|
||||
// xterm actually rendered (e.g. `15-graphemes` emoji widths).
|
||||
terminalOutputHistoryRef.current.setWidthTerminal(captureTerm);
|
||||
}
|
||||
const readableCommandData = commandLogRewriterRef.current.append(data);
|
||||
// The alternate-screen preview reads the raw display stream (before the
|
||||
// replay sanitizer, which drops alternate-screen output on purpose) but
|
||||
@@ -1100,15 +1081,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// hosts (TerminalLayer) and saved-host defaults (HostDetailsPanel) both
|
||||
// stamp os: "linux", which mis-routes the autocomplete clear sequence to
|
||||
// Ctrl-U on Windows where cmd/PowerShell render it literally (#1112).
|
||||
const resolvedAutocompleteOs = host.protocol === "local"
|
||||
const autocompleteHostOs: "linux" | "windows" | "macos" = host.protocol === "local"
|
||||
? detectLocalOs(navigator.userAgent || navigator.platform)
|
||||
: resolveHostOs(host);
|
||||
const autocompleteHostOs = resolvedAutocompleteOs === "windows" || resolvedAutocompleteOs === "macos"
|
||||
? resolvedAutocompleteOs : "linux";
|
||||
: (host.os || "linux");
|
||||
const autocompleteSettings = resolveTerminalAutocompleteSettings({
|
||||
protocol: effectiveTerminalProtocol,
|
||||
terminalSettings,
|
||||
systemUnknown: resolvedAutocompleteOs === 'unknown',
|
||||
isNetworkDevice: host.deviceType === 'network'
|
||||
|| classifyDistroId(host.distro) === 'network-device',
|
||||
});
|
||||
@@ -2296,7 +2274,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
clearHibernateRetry();
|
||||
clearAutoReconnect();
|
||||
void cleanupSession();
|
||||
synchronizedFitSchedulerRef.current?.dispose();
|
||||
disposeRuntimeOnly();
|
||||
};
|
||||
|
||||
@@ -2398,7 +2375,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
sessionId,
|
||||
reuseConnectionFromSessionIdRef: reuseConnectionSourceRef,
|
||||
requireFreshConnection,
|
||||
requireFreshConnectionOnReconnectRef,
|
||||
reuseConnectionSourceAttemptedRef,
|
||||
setConnectionReuseAttemptSourceId,
|
||||
shouldUseFreshSshConnection: () => {
|
||||
@@ -2891,12 +2867,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
});
|
||||
}, [reuseConnectionFromSessionId, sessionId, terminalBackend]);
|
||||
|
||||
const synchronizedFitSchedulerRef = useRef<ReturnType<typeof createSynchronizedOutputFitScheduler> | null>(null);
|
||||
if (!synchronizedFitSchedulerRef.current) {
|
||||
synchronizedFitSchedulerRef.current = createSynchronizedOutputFitScheduler();
|
||||
}
|
||||
useEffect(() => () => synchronizedFitSchedulerRef.current?.dispose(), []);
|
||||
|
||||
type SafeFitOptions = { force?: boolean; requireVisible?: boolean; immediate?: boolean; allowHidden?: boolean };
|
||||
const pendingWriteSafeFitRef = useRef<{
|
||||
term: XTerm;
|
||||
@@ -2994,14 +2964,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep the frozen buffer intact until synchronized output finishes.
|
||||
// Re-enter safeFit to use the latest size, buffer and reading position.
|
||||
if (synchronizedFitSchedulerRef.current?.defer(term, () => {
|
||||
if (termRef.current === term) {
|
||||
safeFitRef.current({ ...options, force: true, immediate: true });
|
||||
}
|
||||
})) return;
|
||||
|
||||
const buffer = term.buffer.active;
|
||||
const wasPinnedToBottom = buffer.viewportY >= buffer.baseY;
|
||||
const savedViewportY = buffer.viewportY;
|
||||
@@ -3026,10 +2988,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
forceSyncRenderAfterResize(term);
|
||||
}
|
||||
|
||||
// Reflow changes the buffer row before xterm updates its scroll range.
|
||||
// Align first so the relative restore below does not apply that delta twice.
|
||||
alignTerminalViewportScroll(term);
|
||||
|
||||
// Preserve scroll position across resize (superset/Tabby pattern).
|
||||
if (wasPinnedToBottom) {
|
||||
term.scrollToBottom();
|
||||
@@ -3669,10 +3627,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const retryStillActive = () => retryTokenStillCurrent() && termRef.current === term;
|
||||
|
||||
bootEpochRef.current += 1;
|
||||
// This start is a reconnect: force the bridge to dial a fresh connection
|
||||
// instead of borrowing a parked/live transport, so the server performs a
|
||||
// new login and picks up remote supplementary-group changes (#3293).
|
||||
requireFreshConnectionOnReconnectRef.current = true;
|
||||
publishBootEpoch();
|
||||
isBootActiveRef.current = true;
|
||||
auth.resetForRetry();
|
||||
|
||||
@@ -46,9 +46,6 @@ const baseProps = {
|
||||
onSetWorkspaceFocusedSession: () => {},
|
||||
isBroadcastEnabled: () => false,
|
||||
onToggleBroadcast: () => {},
|
||||
isGlobalBroadcastEnabled: false,
|
||||
canUseGlobalBroadcast: false,
|
||||
onToggleGlobalBroadcast: () => {},
|
||||
updateSnippets: () => {},
|
||||
updateSnippetPackages: () => {},
|
||||
onSplitSession: () => {},
|
||||
@@ -124,23 +121,6 @@ test("TerminalLayer re-renders when broadcast state changes", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("TerminalLayer re-renders when global broadcast state changes", () => {
|
||||
assert.equal(
|
||||
terminalLayerAreEqual(
|
||||
baseProps as never,
|
||||
{ ...baseProps, isGlobalBroadcastEnabled: true } as never,
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
terminalLayerAreEqual(
|
||||
baseProps as never,
|
||||
{ ...baseProps, canUseGlobalBroadcast: true } as never,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("TerminalLayer re-renders when terminal side panel auto-open settings change", () => {
|
||||
assert.equal(
|
||||
terminalLayerAreEqual(
|
||||
|
||||
@@ -67,7 +67,6 @@ import { ThemeSidePanel } from './terminal/ThemeSidePanel';
|
||||
import { focusTerminalSessionInput } from './terminal/focusTerminalSession';
|
||||
import { TerminalComposeBar } from './terminal/TerminalComposeBar';
|
||||
import { resolveTerminalFontSizeUpdateTarget } from './terminalLayer/terminalFontSizeUpdate';
|
||||
import { resolveTerminalBroadcastTargetIds } from '../domain/terminalBroadcast';
|
||||
import {
|
||||
AUTO_RUN_SNIPPET_LINE_DELAY_MS,
|
||||
shouldDelayAutoRunSnippetInput,
|
||||
@@ -247,9 +246,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onCreateLocalTerminal,
|
||||
isBroadcastEnabled,
|
||||
onToggleBroadcast,
|
||||
isGlobalBroadcastEnabled,
|
||||
onToggleGlobalBroadcast,
|
||||
canUseGlobalBroadcast,
|
||||
updateHosts,
|
||||
updateSnippets,
|
||||
updateSnippetPackages,
|
||||
@@ -1053,25 +1049,27 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const sessionHostsMapRef = useRef(sessionHostsMap);
|
||||
sessionHostsMapRef.current = sessionHostsMap;
|
||||
|
||||
// Resolve the active broadcast mode, then write to its target sessions.
|
||||
// Handle broadcast input - write to all other sessions in the source workspace.
|
||||
const handleBroadcastInput = useCallback((
|
||||
data: string,
|
||||
sourceSessionId: string,
|
||||
options?: TerminalBroadcastInputOptions,
|
||||
) => {
|
||||
const targetSessionIds = resolveTerminalBroadcastTargetIds({
|
||||
sessions: sessionsRef.current,
|
||||
sourceSessionId,
|
||||
globalBroadcastEnabled: isGlobalBroadcastEnabled,
|
||||
directTargetSessionIds: options?.kittyKeyboardTargetSessionIds,
|
||||
});
|
||||
if (targetSessionIds.length === 0) return [];
|
||||
|
||||
const targetSessionIdSet = new Set(targetSessionIds);
|
||||
const directTargetIds = options?.kittyKeyboardTargetSessionIds;
|
||||
const sourceSession = directTargetIds
|
||||
? undefined
|
||||
: sessionsRef.current.find((session) => session.id === sourceSessionId);
|
||||
const workspaceId = sourceSession?.workspaceId;
|
||||
if (!directTargetIds && !workspaceId) return [];
|
||||
const directTargetSet = directTargetIds ? new Set(directTargetIds) : null;
|
||||
const deliveredSessionIds: string[] = [];
|
||||
|
||||
for (const session of sessionsRef.current) {
|
||||
if (!targetSessionIdSet.has(session.id)) continue;
|
||||
if (directTargetSet) {
|
||||
if (!directTargetSet.has(session.id)) continue;
|
||||
} else if (session.workspaceId !== workspaceId || session.id === sourceSessionId) {
|
||||
continue;
|
||||
}
|
||||
if (!canUseDirectSessionWriteFallback(session)) continue;
|
||||
|
||||
if (options?.kittyKeyboardInput) {
|
||||
@@ -1109,7 +1107,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
deliveredSessionIds.push(session.id);
|
||||
}
|
||||
return deliveredSessionIds;
|
||||
}, [terminalBackend, isGlobalBroadcastEnabled]);
|
||||
}, [terminalBackend]);
|
||||
|
||||
const handleCommandSubmitted = useCallback((command: string, _hostId: string, _hostLabel: string, sessionId: string) => {
|
||||
codingCliSignalController.handleCommandSubmitted(sessionId, command);
|
||||
@@ -1299,9 +1297,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onToggleBroadcastRef.current = onToggleBroadcast;
|
||||
const workspaceBroadcastHandlersRef = useRef<Map<string, () => void>>(new Map());
|
||||
|
||||
const onToggleGlobalBroadcastRef = useRef(onToggleGlobalBroadcast);
|
||||
onToggleGlobalBroadcastRef.current = onToggleGlobalBroadcast;
|
||||
|
||||
const mountedSftpTabIds = useMemo(
|
||||
() => Array.from(sftpHostForTab.keys()),
|
||||
[sftpHostForTab],
|
||||
@@ -2303,8 +2298,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
restoreTerminalCwd,
|
||||
identities,
|
||||
isBroadcastEnabled,
|
||||
isGlobalBroadcastEnabled,
|
||||
canUseGlobalBroadcast,
|
||||
isComposeBarOpen,
|
||||
keyBindings,
|
||||
keys,
|
||||
@@ -2341,7 +2334,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onSplitSession,
|
||||
onSplitSessionRef,
|
||||
onToggleBroadcastRef,
|
||||
onToggleGlobalBroadcastRef,
|
||||
onToggleWorkspaceViewMode,
|
||||
onToggleWorkspaceViewModeRef,
|
||||
onUpdateHost,
|
||||
|
||||
@@ -332,21 +332,3 @@ test('ChatInput wires /compact through getSystemSlashCommand and canCompact', ()
|
||||
assert.match(source, /onCompact\?\.\(\)/);
|
||||
assert.match(source, /command\.slug !== 'compact' \|\| canCompact/);
|
||||
});
|
||||
|
||||
test('renders a Mention Note picker entry with a searchable note list', () => {
|
||||
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
|
||||
|
||||
assert.match(source, /openInputPanelMenu\('noteMention'\)/);
|
||||
assert.match(source, /ai\.chat\.menuMentionNote/);
|
||||
assert.match(source, /aria-activedescendant=\{noteMentionItems\[activeMenuIndex\] \? `\$\{noteListId\}/);
|
||||
assert.match(source, /createVaultNoteSearchIndex\(notes\)/);
|
||||
assert.match(source, /onClick=\{\(\) => handleSelectNoteMention\(note\)\}/);
|
||||
assert.match(source, /onKeyDown=\{handleNoteMentionKeyDown\}/);
|
||||
});
|
||||
|
||||
test('renders vault note attachment chips with the note title', () => {
|
||||
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
|
||||
|
||||
assert.match(source, /file\.vaultNoteId \? \(/);
|
||||
assert.match(source, /\{file\.vaultNoteTitle \|\| file\.filename\}/);
|
||||
});
|
||||
+4
-166
@@ -6,7 +6,7 @@
|
||||
* and a bottom toolbar with muted controls + subtle send button.
|
||||
*/
|
||||
|
||||
import { ArrowUp, AtSign, BookOpen, Check, ChevronDown, ChevronRight, Cpu, Eye, FileText, ImageIcon, Loader2, MessageSquare, Package, Plus, ShieldCheck, SquareTerminal, X, Zap } from 'lucide-react';
|
||||
import { ArrowUp, AtSign, Check, ChevronDown, ChevronRight, Cpu, Eye, FileText, ImageIcon, Loader2, MessageSquare, Package, Plus, ShieldCheck, SquareTerminal, X, Zap } from 'lucide-react';
|
||||
import {
|
||||
resolveModelSelectionWithThinking,
|
||||
resolveThinkingSelection,
|
||||
@@ -54,8 +54,6 @@ import {
|
||||
} from '../ai-elements/prompt-input';
|
||||
import type { PromptInputStatus } from '../ai-elements/prompt-input';
|
||||
import type { AgentModelPreset, AIPermissionMode, ProviderConfig, UploadedFile } from '../../infrastructure/ai/types';
|
||||
import type { VaultNote } from '../../domain/models';
|
||||
import { createVaultNoteSearchIndex } from '../../domain/notes';
|
||||
import { ProviderIconBadge } from '../settings/tabs/ai/ProviderIconBadge';
|
||||
import { VariableSizeVirtualList, type VariableSizeVirtualListHandle } from '../ui/VariableSizeVirtualList';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
@@ -218,10 +216,6 @@ interface ChatInputProps {
|
||||
onRemoveFile?: (id: string) => void;
|
||||
/** Available hosts for @ mention */
|
||||
hosts?: Array<{ sessionId: string; hostname: string; label: string; connected: boolean }>;
|
||||
/** Available Vault → Notes entries for the Mention Note picker */
|
||||
notes?: VaultNote[];
|
||||
/** Callback when the user picks a note to attach as context */
|
||||
onMentionNote?: (note: VaultNote) => void;
|
||||
/** User skills currently selected for the next send */
|
||||
selectedUserSkills?: Array<{ id: string; slug: string; name: string; description: string }>;
|
||||
/** Available user skills for /skill-slug insertion */
|
||||
@@ -277,8 +271,6 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
onAddFiles,
|
||||
onRemoveFile,
|
||||
hosts = [],
|
||||
notes = [],
|
||||
onMentionNote,
|
||||
selectedUserSkills = [],
|
||||
userSkills = [],
|
||||
quickMessages = [],
|
||||
@@ -293,7 +285,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
parked = false,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const hasTerminalSelectionAttachment = files.some((file) => file.terminalSelection || file.vaultNoteId);
|
||||
const hasTerminalSelectionAttachment = files.some((file) => file.terminalSelection);
|
||||
const composerDisabled = disabled || isSteering;
|
||||
const composerTextRef = useRef(value);
|
||||
const hasTextStoreRef = useRef<ComposerHasTextStore | null>(null);
|
||||
@@ -306,15 +298,13 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
const [composerMaxHeight, setComposerMaxHeight] = useState(CHAT_INPUT_MAX_HEIGHT);
|
||||
const composerDesiredHeightRef = useRef<number | null>(null);
|
||||
// Consolidate menu state into a single discriminated union to prevent multiple menus open simultaneously
|
||||
type ActiveMenu = 'model' | 'thinking' | 'attach' | 'atMention' | 'noteMention' | 'slashCommand' | 'perm' | null;
|
||||
type ActiveMenu = 'model' | 'thinking' | 'attach' | 'atMention' | 'slashCommand' | 'perm' | null;
|
||||
const [activeMenu, setActiveMenu] = useState<ActiveMenu>(null);
|
||||
const [menuPos, setMenuPos] = useState<{ left: number; bottom: number } | null>(null);
|
||||
const [inputPanelPos, setInputPanelPos] = useState<{ left: number; bottom: number; width: number } | null>(null);
|
||||
const [modelPrefs, setModelPrefs] = useState<ComposerModelPrefs>(() => readComposerModelPrefs(pickerScope));
|
||||
const [slashQuery, setSlashQuery] = useState('');
|
||||
const [slashRange, setSlashRange] = useState<{ start: number; end: number } | null>(null);
|
||||
// Search query for the Mention Note picker
|
||||
const [noteQuery, setNoteQuery] = useState('');
|
||||
// Active highlight index for @ mention / slash skill keyboard navigation
|
||||
const [activeMenuIndex, setActiveMenuIndex] = useState(0);
|
||||
|
||||
@@ -323,7 +313,6 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
const showThinkingPicker = activeMenu === 'thinking';
|
||||
const showAttachMenu = activeMenu === 'attach';
|
||||
const showAtMention = activeMenu === 'atMention';
|
||||
const showNoteMention = activeMenu === 'noteMention';
|
||||
const showSlashCommandPicker = activeMenu === 'slashCommand';
|
||||
const showPermPicker = activeMenu === 'perm';
|
||||
|
||||
@@ -333,7 +322,6 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
setInputPanelPos(null);
|
||||
setSlashQuery('');
|
||||
setSlashRange(null);
|
||||
setNoteQuery('');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -594,12 +582,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
closeAllMenus();
|
||||
}, [readComposerText, commitComposerText, closeAllMenus]);
|
||||
|
||||
const handleSelectNoteMention = useCallback((note: VaultNote) => {
|
||||
onMentionNote?.(note);
|
||||
closeAllMenus();
|
||||
}, [onMentionNote, closeAllMenus]);
|
||||
|
||||
const openInputPanelMenu = useCallback((menu: 'atMention' | 'noteMention' | 'slashCommand') => {
|
||||
const openInputPanelMenu = useCallback((menu: 'atMention' | 'slashCommand') => {
|
||||
const pos = getInputPanelMenuPos();
|
||||
if (!pos) return;
|
||||
setMenuPos(null);
|
||||
@@ -763,58 +746,6 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
if (showSlashCommandPicker) setActiveMenuIndex(0);
|
||||
}, [showSlashCommandPicker, slashCommandKey]);
|
||||
|
||||
// Mention Note picker: filtered notes, highlight reset, search-input focus
|
||||
const searchMentionNotes = useMemo(
|
||||
() => showNoteMention ? createVaultNoteSearchIndex(notes) : null,
|
||||
[notes, showNoteMention],
|
||||
);
|
||||
const noteMentionItems = useMemo(
|
||||
() => searchMentionNotes?.(noteQuery) ?? [],
|
||||
[searchMentionNotes, noteQuery],
|
||||
);
|
||||
const noteMentionKey = useMemo(
|
||||
() => JSON.stringify(noteMentionItems.map((note) => note.id)),
|
||||
[noteMentionItems],
|
||||
);
|
||||
const noteSearchInputRef = useRef<HTMLInputElement>(null);
|
||||
const noteListId = React.useId();
|
||||
useEffect(() => {
|
||||
if (showNoteMention) setActiveMenuIndex(0);
|
||||
}, [showNoteMention, noteMentionKey]);
|
||||
useEffect(() => {
|
||||
if (!showNoteMention || noteMentionItems.length === 0) return;
|
||||
atMentionListRef.current?.scrollToIndex(activeMenuIndex);
|
||||
}, [activeMenuIndex, noteMentionKey, noteMentionItems.length, showNoteMention]);
|
||||
useEffect(() => {
|
||||
if (!showNoteMention) return;
|
||||
noteSearchInputRef.current?.focus();
|
||||
}, [showNoteMention]);
|
||||
|
||||
const handleNoteMentionKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.nativeEvent.isComposing) return;
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeAllMenus();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const note = noteMentionItems[Math.min(activeMenuIndex, noteMentionItems.length - 1)];
|
||||
if (note) handleSelectNoteMention(note);
|
||||
return;
|
||||
}
|
||||
if (noteMentionItems.length === 0) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActiveMenuIndex((i) => (i + 1) % noteMentionItems.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActiveMenuIndex((i) => (i - 1 + noteMentionItems.length) % noteMentionItems.length);
|
||||
}
|
||||
}, [activeMenuIndex, closeAllMenus, handleSelectNoteMention, noteMentionItems]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSlashCommandPicker || !menuPos || slashCommandItems.length === 0) return;
|
||||
slashPickerListRef.current?.focus();
|
||||
@@ -1110,8 +1041,6 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
>
|
||||
{file.terminalSelection ? (
|
||||
<SquareTerminal size={12} className="text-muted-foreground/70 shrink-0" />
|
||||
) : file.vaultNoteId ? (
|
||||
<BookOpen size={11} className="text-muted-foreground/60 shrink-0" />
|
||||
) : file.mediaType.startsWith('image/') ? (
|
||||
<ImageIcon size={11} className="text-muted-foreground/60 shrink-0" />
|
||||
) : (
|
||||
@@ -1124,12 +1053,6 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
{file.lineCount ? ` · ${t('ai.chat.terminalSelectionLines').replace('{count}', String(file.lineCount))}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
) : file.vaultNoteId ? (
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate max-w-[210px] text-foreground/82">
|
||||
{file.vaultNoteTitle || file.filename}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="truncate max-w-[80px]">{file.filename}</span>
|
||||
)}
|
||||
@@ -1286,76 +1209,6 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
document.body,
|
||||
)}
|
||||
|
||||
{/* Mention Note popover */}
|
||||
{showNoteMention && inputPanelPos && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
|
||||
<div
|
||||
className="fixed z-[1000] overflow-hidden rounded-lg border border-border/50 bg-popover shadow-lg"
|
||||
style={{ left: inputPanelPos.left, bottom: inputPanelPos.bottom, width: 'auto', minWidth: Math.min(240, inputPanelPos.width), maxWidth: inputPanelPos.width }}
|
||||
>
|
||||
<div className="p-1.5 border-b border-border/40">
|
||||
<input
|
||||
ref={noteSearchInputRef}
|
||||
role="combobox"
|
||||
aria-label={t('ai.chat.menuMentionNote')}
|
||||
aria-expanded={true}
|
||||
aria-controls={noteListId}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={noteMentionItems[activeMenuIndex] ? `${noteListId}-${activeMenuIndex}` : undefined}
|
||||
type="text"
|
||||
value={noteQuery}
|
||||
onChange={(e) => setNoteQuery(e.target.value)}
|
||||
onKeyDown={handleNoteMentionKeyDown}
|
||||
placeholder={t('ai.chat.mentionNoteSearch')}
|
||||
className="w-full h-6 rounded-md bg-muted/40 px-2 text-[12px] text-foreground outline-none placeholder:text-muted-foreground/50"
|
||||
/>
|
||||
</div>
|
||||
<div id={noteListId} role="listbox" aria-label={t('ai.chat.menuMentionNote')}>
|
||||
{noteMentionItems.length === 0 ? (
|
||||
<div className="px-3 py-2 text-[11px] text-muted-foreground/60">{t('ai.chat.mentionNoteEmpty')}</div>
|
||||
) : (
|
||||
<div className="max-h-[280px]" style={{ height: Math.min(280, 8 + noteMentionItems.reduce((total, note) => total + (note.group ? 52 : 36), 0)) }}>
|
||||
<VariableSizeVirtualList
|
||||
ref={atMentionListRef}
|
||||
items={noteMentionItems}
|
||||
getItemHeight={(note) => note.group ? 52 : 36}
|
||||
getItemKey={(note) => note.id}
|
||||
className="h-full"
|
||||
contentClassName="p-1"
|
||||
renderItem={(note, idx) => {
|
||||
const isActive = idx === activeMenuIndex;
|
||||
return (
|
||||
<button
|
||||
id={`${noteListId}-${idx}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
onMouseEnter={() => setActiveMenuIndex(idx)}
|
||||
onClick={() => handleSelectNoteMention(note)}
|
||||
className={`h-full w-full rounded-md px-2 py-1 text-left transition-colors cursor-pointer ${isActive ? 'bg-muted/40' : 'hover:bg-muted/30'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-[12px] text-foreground/90">
|
||||
<BookOpen size={11} className="text-muted-foreground/50 shrink-0" />
|
||||
<span className="truncate">{note.title || t('ai.chat.untitledNote')}</span>
|
||||
</div>
|
||||
{note.group ? (
|
||||
<div className="pl-5 text-[10px] text-muted-foreground/60 truncate">
|
||||
{note.group}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
{/* / command popover */}
|
||||
{showSlashPickerUI && createPortal(
|
||||
<>
|
||||
@@ -1458,19 +1311,6 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
<ImageIcon size={13} className="text-muted-foreground/60" />
|
||||
<span className="text-foreground/85">{t('ai.chat.menuImage')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-label={t('ai.chat.menuMentionNote')}
|
||||
disabled={!onMentionNote}
|
||||
title={!onMentionNote ? t('ai.chat.mentionNoteUnavailable') : undefined}
|
||||
onClick={() => openInputPanelMenu('noteMention')}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<BookOpen size={13} className="text-muted-foreground/60" />
|
||||
<span className="flex-1 text-foreground/85">{t('ai.chat.menuMentionNote')}</span>
|
||||
{notes.length > 0 && <ChevronRight size={10} className="text-muted-foreground/50" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@@ -1800,8 +1640,6 @@ function chatInputPropsAreEqual(prev: ChatInputProps, next: ChatInputProps): boo
|
||||
&& prev.onAddFiles === next.onAddFiles
|
||||
&& prev.onRemoveFile === next.onRemoveFile
|
||||
&& prev.hosts === next.hosts
|
||||
&& prev.notes === next.notes
|
||||
&& prev.onMentionNote === next.onMentionNote
|
||||
&& prev.selectedUserSkills === next.selectedUserSkills
|
||||
&& prev.userSkills === next.userSkills
|
||||
&& prev.quickMessages === next.quickMessages
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* No avatars. Thinking blocks are collapsible.
|
||||
*/
|
||||
|
||||
import { AlertCircle, BookOpen, FileText, RotateCcw, SquareTerminal, X, ZoomIn, ZoomOut } from 'lucide-react';
|
||||
import { AlertCircle, FileText, RotateCcw, SquareTerminal, X, ZoomIn, ZoomOut } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { ChatMessage, ToolCall as AgentToolCall } from '../../infrastructure/ai/types';
|
||||
@@ -701,14 +701,6 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
<SquareTerminal size={12} className="text-muted-foreground/60 shrink-0" />
|
||||
<span className="truncate max-w-[150px]">{att.filename || 'terminal selection'}</span>
|
||||
</div>
|
||||
) : att.vaultNoteId ? (
|
||||
<div
|
||||
key={att.filename ? `${att.filename}-${i}` : `att-${message.id}-${i}`}
|
||||
className="inline-flex items-center gap-1.5 h-7 px-2 rounded-md bg-muted/20 border border-border/20 text-[11px] text-foreground/70"
|
||||
>
|
||||
<BookOpen size={12} className="text-muted-foreground/60 shrink-0" />
|
||||
<span className="truncate max-w-[150px]">{att.vaultNoteTitle || att.filename || 'note'}</span>
|
||||
</div>
|
||||
) : att.mediaType.startsWith('image/') ? (
|
||||
<img
|
||||
key={att.filename ? `${att.filename}-${i}` : `att-${message.id}-${i}`}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { buildExternalBridgeContextMessages } from "../../infrastructure/ai/harness/externalBridgeContext.ts";
|
||||
|
||||
import type { ChatMessageAttachment, ToolCall, ToolResult } from "../../infrastructure/ai/types.ts";
|
||||
import {
|
||||
@@ -211,68 +210,3 @@ test("buildHistoricalToolReplayMaps pairs reused tool ids with the nearest prece
|
||||
assert.equal(maps.resolvedToolCallsByAssistant.get(messages[1]), undefined);
|
||||
assert.equal(maps.resolvedToolCallsByAssistant.get(messages[2])?.has(messages[2].toolCalls![0]), true);
|
||||
});
|
||||
|
||||
test("buildHistoricalUserReplayContent replaces historical vault note mentions with id metadata", () => {
|
||||
const attachment: ChatMessageAttachment = {
|
||||
base64Data: "A".repeat(5_000),
|
||||
mediaType: "text/markdown",
|
||||
filename: "runbook.md",
|
||||
vaultNoteId: "note-123",
|
||||
vaultNoteTitle: "Runbook",
|
||||
previewText: "restart nginx",
|
||||
};
|
||||
|
||||
const result = buildHistoricalUserReplayContent("check this note", [attachment]);
|
||||
|
||||
assert.match(result, /check this note/);
|
||||
assert.match(result, /Vault note reference/);
|
||||
assert.match(result, /"noteId":"note-123"/);
|
||||
assert.match(result, /"title":"Runbook"/);
|
||||
assert.doesNotMatch(result, /AAAAAA/);
|
||||
});
|
||||
|
||||
test("external recovery retains note identity before truncating a long user request", () => {
|
||||
const history = buildExternalBridgeContextMessages([{
|
||||
id: "long-note-request", role: "user", timestamp: 1, content: "x".repeat(2500),
|
||||
attachments: [{mediaType: "text/markdown", base64Data: "", vaultNoteId: "note-123", vaultNoteTitle: "Runbook"}],
|
||||
}]);
|
||||
const replay = history.find((message) => message.role === "user")!;
|
||||
assert.match(replay.content, /"noteId":"note-123"/);
|
||||
assert.match(replay.content, /vault_notes_get/);
|
||||
assert.match(replay.content, /truncated/);
|
||||
assert.ok(replay.content.length <= 2000);
|
||||
});
|
||||
|
||||
test("external recovery keeps multiple note IDs and a short user constraint", () => {
|
||||
const attachments = Array.from({length: 10}, (_, index) => ({
|
||||
mediaType: "text/markdown", base64Data: "", vaultNoteId: `note-${index}`, vaultNoteTitle: `Runbook ${index}`,
|
||||
}));
|
||||
const history = buildExternalBridgeContextMessages([{
|
||||
id: "many-notes", role: "user", timestamp: 1, content: "Only compare; do not edit.", attachments,
|
||||
}]);
|
||||
const replay = history.find((message) => message.role === "user")!;
|
||||
for (const attachment of attachments) assert.ok(replay.content.includes(JSON.stringify(attachment.vaultNoteId)));
|
||||
assert.match(replay.content, /Only compare; do not edit/);
|
||||
assert.equal(replay.content.match(/Use vault_notes_get/g)?.length, 1);
|
||||
});
|
||||
|
||||
for (const terminalSelection of [false, true]) {
|
||||
test(`compact external recovery retains the request and attachment (terminal=${terminalSelection}) alongside note references`, () => {
|
||||
const noteId = '550e8400-e29b-41d4-a716-446655440000';
|
||||
const request = "Only summarize yesterday's deployments; do not edit.";
|
||||
const messages: ChatMessage[] = [{
|
||||
id: 'old-note', role: 'user', timestamp: 1, content: request,
|
||||
attachments: [
|
||||
{ mediaType: 'text/markdown', base64Data: '', vaultNoteId: noteId, vaultNoteTitle: 'Deployment Runbook' },
|
||||
{ mediaType: 'text/plain', base64Data: 'YWJj', filename: 'other.txt', terminalSelection },
|
||||
],
|
||||
}, ...Array.from({ length: 8 }, (_, i): ChatMessage => ({
|
||||
id: `later-${i}`, role: i % 2 ? 'assistant' : 'user', timestamp: i + 2, content: i % 2 ? 'Done.' : 'ok',
|
||||
}))];
|
||||
const compact = buildExternalBridgeContextMessages(messages)[0].content;
|
||||
assert.ok(compact.includes(noteId));
|
||||
assert.ok(compact.includes(request));
|
||||
assert.ok(compact.includes('other.txt'));
|
||||
assert.ok(compact.includes(terminalSelection ? 'terminal selection omitted' : 'attachment omitted'));
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ChatMessage, ChatMessageAttachment, ToolCall, ToolResult } from "../../infrastructure/ai/types";
|
||||
import { isTerminalSelectionAttachment } from "../../application/state/terminalSelectionAttachment";
|
||||
import { formatVaultNoteReferences, isVaultNoteAttachment } from "../../application/state/vaultNoteAttachment";
|
||||
import { redactSecretsForModel } from "../../infrastructure/ai/harness/modelSecretRedaction";
|
||||
|
||||
const MAX_ATTACHMENT_PLACEHOLDER_DETAIL_CHARS = 120;
|
||||
@@ -51,19 +50,15 @@ export function buildHistoricalUserReplayContent(
|
||||
content: string,
|
||||
attachments: ChatMessageAttachment[] = [],
|
||||
): string {
|
||||
if (!attachments.length) return content;
|
||||
// Keep note identities ahead of prose so bounded external history retains them.
|
||||
const notes = attachments.filter(isVaultNoteAttachment);
|
||||
const placeholders = attachments.filter((attachment) => !isVaultNoteAttachment(attachment))
|
||||
.map((attachment, index) => (
|
||||
isTerminalSelectionAttachment(attachment)
|
||||
? formatTerminalSelectionPlaceholder(attachment, index)
|
||||
: formatAttachmentPlaceholder(attachment, index)
|
||||
));
|
||||
const placeholders = attachments.map((attachment, index) => (
|
||||
isTerminalSelectionAttachment(attachment)
|
||||
? formatTerminalSelectionPlaceholder(attachment, index)
|
||||
: formatAttachmentPlaceholder(attachment, index)
|
||||
));
|
||||
|
||||
if (!placeholders.length) return content;
|
||||
const attachmentBlock = placeholders.map((line) => `\n\n${line}`).join("");
|
||||
const body = content.trim() ? `${content}${attachmentBlock}` : placeholders.join("\n\n");
|
||||
return notes.length ? [formatVaultNoteReferences(notes), body].filter(Boolean).join("\n\n") : body;
|
||||
return content.trim() ? `${content}${attachmentBlock}` : placeholders.join("\n\n");
|
||||
}
|
||||
|
||||
function getToolCommand(toolCall?: ToolCall): string | undefined {
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import vm from "node:vm";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import TestRenderer, { act } from "react-test-renderer";
|
||||
import ts from "typescript";
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
// Exercise the production lifecycle and asynchronous resolver together, without
|
||||
// loading the unrelated provider forms or making real CLI/authentication calls.
|
||||
const source = readFileSync(new URL("./SettingsAITab.tsx", import.meta.url), "utf8");
|
||||
const lifecycleStart = source.indexOf(" const mountedRef = useRef(true);");
|
||||
const lifecycleEnd = source.indexOf("\n const applyResolvedAgentPath", lifecycleStart);
|
||||
const resolverStart = source.indexOf(" const resolveAgentPath = useCallback");
|
||||
const resolverEnd = source.indexOf("\n useEffect(() => {", resolverStart);
|
||||
assert.ok(lifecycleStart >= 0 && lifecycleEnd > lifecycleStart);
|
||||
assert.ok(resolverStart >= 0 && resolverEnd > resolverStart);
|
||||
const declarations = source.slice(lifecycleStart, lifecycleEnd) + source.slice(resolverStart, resolverEnd);
|
||||
const setters = ["Codex", "Claude", "Copilot", "Cursor", "Codebuddy", "Opencode", "Grok"];
|
||||
const bindings = ["useRef", "useEffect", "useCallback", "getBridge", "applyResolvedAgentPath", "cursorApiKeyEncrypted",
|
||||
...setters.map(name => `setIsResolving${name}`)];
|
||||
const runHooks = vm.runInNewContext(ts.transpile(`(function(ctx) {
|
||||
const { ${bindings.join(",")} } = ctx;
|
||||
${declarations}
|
||||
return resolveAgentPath;
|
||||
})`, { target: ts.ScriptTarget.ES2022 }), { console }) as
|
||||
(ctx: Record<string, unknown>) => (key: string) => Promise<unknown>;
|
||||
|
||||
function deferred() {
|
||||
let resolve!: (value: unknown) => void;
|
||||
const promise = new Promise<unknown>(done => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
async function mountResolver() {
|
||||
const calls: ReturnType<typeof deferred>[] = [];
|
||||
const applied: unknown[] = [];
|
||||
const state: { busy: boolean; resolve?: (key: string) => Promise<unknown> } = { busy: false };
|
||||
const bridge = { aiResolveCli: () => { const pending = deferred(); calls.push(pending); return pending.promise; } };
|
||||
function Harness() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
state.busy = busy;
|
||||
state.resolve = runHooks({
|
||||
useRef, useEffect, useCallback,
|
||||
getBridge: () => bridge,
|
||||
applyResolvedAgentPath: (_key: string, result: unknown) => applied.push(result),
|
||||
cursorApiKeyEncrypted: "",
|
||||
...Object.fromEntries(setters.map(name => [`setIsResolving${name}`, setBusy])),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
let root!: TestRenderer.ReactTestRenderer;
|
||||
await act(async () => { root = TestRenderer.create(React.createElement(React.StrictMode, null, React.createElement(Harness))); });
|
||||
return { calls, applied, state, root };
|
||||
}
|
||||
|
||||
test("AI detection accepts completed requests after StrictMode effect replay", async () => {
|
||||
const h = await mountResolver();
|
||||
try {
|
||||
let request!: Promise<unknown>;
|
||||
await act(async () => { request = h.state.resolve!("cursor"); });
|
||||
assert.equal(h.state.busy, true);
|
||||
const result = { installed: true, available: true, cliBinPath: "/fixture/cursor-agent" };
|
||||
await act(async () => { h.calls[0].resolve(result); await request; });
|
||||
assert.deepEqual(h.applied, [result]);
|
||||
assert.equal(h.state.busy, false);
|
||||
} finally { await act(async () => h.root.unmount()); }
|
||||
});
|
||||
|
||||
test("AI detection still ignores completion after real unmount", async () => {
|
||||
const h = await mountResolver();
|
||||
let request!: Promise<unknown>;
|
||||
await act(async () => { request = h.state.resolve!("cursor"); });
|
||||
await act(async () => h.root.unmount());
|
||||
h.calls[0].resolve({ installed: true });
|
||||
await request;
|
||||
assert.deepEqual(h.applied, []);
|
||||
});
|
||||
|
||||
test("an older AI detection result cannot replace or finish the newer request", async () => {
|
||||
const h = await mountResolver();
|
||||
try {
|
||||
let old!: Promise<unknown>;
|
||||
let current!: Promise<unknown>;
|
||||
await act(async () => { old = h.state.resolve!("cursor"); current = h.state.resolve!("cursor"); });
|
||||
await act(async () => { h.calls[0].resolve({ installed: false }); await old; });
|
||||
assert.deepEqual(h.applied, []);
|
||||
assert.equal(h.state.busy, true);
|
||||
const result = { installed: true };
|
||||
await act(async () => { h.calls[1].resolve(result); await current; });
|
||||
assert.deepEqual(h.applied, [result]);
|
||||
assert.equal(h.state.busy, false);
|
||||
} finally { await act(async () => h.root.unmount()); }
|
||||
});
|
||||
@@ -342,16 +342,12 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
const mountedRef = useRef(true);
|
||||
const agentPathRequestIdRef = useRef<Partial<Record<ManagedAgentKey, number>>>({});
|
||||
const codexRequestIdRef = useRef(0);
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
const agentPathRequestIds = agentPathRequestIdRef.current;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
codexRequestIdRef.current += 1;
|
||||
for (const key of ["codex", "claude", "copilot", "cursor", "codebuddy", "opencode", "grok"] as ManagedAgentKey[]) {
|
||||
agentPathRequestIds[key] = (agentPathRequestIds[key] ?? 0) + 1;
|
||||
}
|
||||
};
|
||||
useEffect(() => () => {
|
||||
mountedRef.current = false;
|
||||
codexRequestIdRef.current += 1;
|
||||
for (const key of ["codex", "claude", "copilot", "cursor", "codebuddy", "opencode", "grok"] as ManagedAgentKey[]) {
|
||||
agentPathRequestIdRef.current[key] = (agentPathRequestIdRef.current[key] ?? 0) + 1;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const applyResolvedAgentPath = useCallback((
|
||||
|
||||
@@ -20,10 +20,10 @@ import {
|
||||
X,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import React, { memo } from 'react';
|
||||
import React, { memo, useEffect, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { getParentPath } from '../../application/state/sftp/utils';
|
||||
import { useSftpTransferTask, useSftpTransferResuming } from '../../application/state/sftpTransferCenterStore';
|
||||
import { useSftpTransferTask } from '../../application/state/sftpTransferCenterStore';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { TransferTask } from '../../types';
|
||||
import {
|
||||
@@ -136,6 +136,8 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
|
||||
// panel setTransfersState for every tick — that re-rendered the whole SFTP
|
||||
// tree and pegged the renderer during large copies.
|
||||
const task = useSftpTransferTask(propsTask.id, propsTask);
|
||||
// Optimistic spinner from click until store status moves off paused/interrupted.
|
||||
const [resumeClicked, setResumeClicked] = useState(false);
|
||||
|
||||
// Same progress model as the global transfer center (done · found for folders).
|
||||
const isDirParent = isDirectoryParentTask(task);
|
||||
@@ -157,8 +159,21 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
|
||||
const storeResuming = task.reconnectRequired === true
|
||||
&& ['pending', 'queued', 'transferring'].includes(task.status)
|
||||
&& !task.error;
|
||||
const sharedResuming = useSftpTransferResuming(task.id);
|
||||
const isResuming = sharedResuming || storeResuming;
|
||||
const isResuming = storeResuming
|
||||
|| (resumeClicked && ['paused', 'interrupted', 'attention', 'pending', 'queued'].includes(task.status) && !task.error);
|
||||
useEffect(() => {
|
||||
if (!resumeClicked) return;
|
||||
if (
|
||||
storeResuming
|
||||
|| task.status === 'transferring'
|
||||
|| task.status === 'completed'
|
||||
|| task.status === 'failed'
|
||||
|| task.status === 'cancelled'
|
||||
|| !!task.error
|
||||
) {
|
||||
setResumeClicked(false);
|
||||
}
|
||||
}, [resumeClicked, storeResuming, task.status, task.error]);
|
||||
const effectiveSpeed = task.status === 'transferring'
|
||||
? (Number.isFinite(task.speed) && task.speed > 0 ? task.speed : 0)
|
||||
: 0;
|
||||
@@ -402,6 +417,7 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
|
||||
className={actionButtonClass}
|
||||
data-action="resume-transfer"
|
||||
{...oncePerActivationHandlers(() => {
|
||||
setResumeClicked(true);
|
||||
onResume();
|
||||
})}
|
||||
aria-label={actionAriaLabel(resumeActionLabel)}
|
||||
|
||||
@@ -832,7 +832,7 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
|
||||
)}
|
||||
{showHostInfoBar && <div className="flex-1 min-w-0" />}
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||
{onToggleBroadcast && (
|
||||
{inWorkspace && onToggleBroadcast && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import { alignTerminalViewportScroll } from "./terminalHelpers";
|
||||
|
||||
type ViewportSpy = {
|
||||
scrollToLine: (line: number, disableSmoothScroll?: boolean) => void;
|
||||
_sync?: () => void;
|
||||
calls: Array<{ line: number; disableSmoothScroll?: boolean }>;
|
||||
};
|
||||
|
||||
const createTerm = (
|
||||
viewportY: number,
|
||||
viewport?: ViewportSpy | null,
|
||||
): XTerm => ({
|
||||
buffer: { active: { viewportY } },
|
||||
_core: viewport === null ? undefined : { _viewport: viewport },
|
||||
}) as unknown as XTerm;
|
||||
|
||||
test("alignTerminalViewportScroll snaps the viewport back to the buffer row without smooth scrolling", () => {
|
||||
const calls: Array<{ line: number; disableSmoothScroll?: boolean }> = [];
|
||||
const viewport: ViewportSpy = {
|
||||
scrollToLine: (line, disableSmoothScroll) => {
|
||||
calls.push({ line, disableSmoothScroll });
|
||||
},
|
||||
calls,
|
||||
};
|
||||
const term = createTerm(247, viewport);
|
||||
|
||||
alignTerminalViewportScroll(term);
|
||||
|
||||
assert.deepEqual(calls, [{ line: 247, disableSmoothScroll: true }]);
|
||||
});
|
||||
|
||||
test("alignTerminalViewportScroll syncs viewport dimensions before setting the position", () => {
|
||||
const calls: Array<{ line: number; disableSmoothScroll?: boolean }> = [];
|
||||
const order: string[] = [];
|
||||
const viewport: ViewportSpy = {
|
||||
_sync: () => {
|
||||
order.push("sync");
|
||||
},
|
||||
scrollToLine: (line, disableSmoothScroll) => {
|
||||
order.push("scrollToLine");
|
||||
calls.push({ line, disableSmoothScroll });
|
||||
},
|
||||
calls,
|
||||
};
|
||||
const term = createTerm(247, viewport);
|
||||
|
||||
alignTerminalViewportScroll(term);
|
||||
|
||||
assert.deepEqual(order, ["sync", "scrollToLine"]);
|
||||
assert.deepEqual(calls, [{ line: 247, disableSmoothScroll: true }]);
|
||||
});
|
||||
|
||||
test("alignTerminalViewportScroll survives a failing dimension sync", () => {
|
||||
const calls: Array<{ line: number; disableSmoothScroll?: boolean }> = [];
|
||||
const viewport: ViewportSpy = {
|
||||
_sync: () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
scrollToLine: (line, disableSmoothScroll) => {
|
||||
calls.push({ line, disableSmoothScroll });
|
||||
},
|
||||
calls,
|
||||
};
|
||||
|
||||
assert.doesNotThrow(() => alignTerminalViewportScroll(createTerm(5, viewport)));
|
||||
assert.deepEqual(calls, [{ line: 5, disableSmoothScroll: true }]);
|
||||
});
|
||||
|
||||
test("alignTerminalViewportScroll is a no-op when the private viewport is unavailable", () => {
|
||||
const term = createTerm(10, null);
|
||||
|
||||
assert.doesNotThrow(() => alignTerminalViewportScroll(term));
|
||||
});
|
||||
|
||||
test("alignTerminalViewportScroll survives a viewport without scrollToLine", () => {
|
||||
const term = {
|
||||
buffer: { active: { viewportY: 5 } },
|
||||
_core: { _viewport: {} },
|
||||
} as unknown as XTerm;
|
||||
|
||||
assert.doesNotThrow(() => alignTerminalViewportScroll(term));
|
||||
});
|
||||
|
||||
test("alignTerminalViewportScroll swallows viewport errors instead of breaking the fit", () => {
|
||||
const term = {
|
||||
buffer: { active: { viewportY: 5 } },
|
||||
_core: {
|
||||
_viewport: {
|
||||
scrollToLine: () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as XTerm;
|
||||
|
||||
assert.doesNotThrow(() => alignTerminalViewportScroll(term));
|
||||
});
|
||||
@@ -50,7 +50,7 @@ test("app resume handlers flush backlog and recover the terminal renderer before
|
||||
assertRecoverTerminalOnAppResumeOrder(source);
|
||||
assert.match(
|
||||
resumeEffectSource,
|
||||
/const handleVisibilityChange = \(\) => \{\s*if \(document\.visibilityState !== 'visible'\) \{\s*syncOutputPressureVisibility\(\);\s*return;\s*\}\s*recoverTerminalOnAppResume\(\);\s*\};/,
|
||||
/const handleVisibilityChange = \(\) => \{\s*if \(document\.visibilityState !== 'visible'\) return;\s*recoverTerminalOnAppResume\(\);\s*\};/,
|
||||
);
|
||||
assert.match(
|
||||
resumeEffectSource,
|
||||
|
||||
@@ -18,11 +18,10 @@ export function resolveTerminalAutocompleteSettings(input: {
|
||||
terminalSettings?: TerminalAutocompleteSettingFields;
|
||||
/** Vendor CLI / network-device session: skip live-preview PTY rewrites (#1193). */
|
||||
isNetworkDevice?: boolean;
|
||||
systemUnknown?: boolean;
|
||||
}): Partial<AutocompleteSettings> | undefined {
|
||||
const { protocol, terminalSettings, isNetworkDevice, systemUnknown } = input;
|
||||
const { protocol, terminalSettings, isNetworkDevice } = input;
|
||||
|
||||
if (protocol === "serial" || systemUnknown) {
|
||||
if (protocol === "serial") {
|
||||
return {
|
||||
enabled: terminalSettings?.autocompleteEnabled ?? true,
|
||||
showGhostText: terminalSettings?.autocompleteGhostText ?? true,
|
||||
|
||||
@@ -6,11 +6,7 @@ import type { SerializeAddon as SerializeAddonType } from "@xterm/addon-serializ
|
||||
import type { Terminal as XTermType } from "@xterm/xterm";
|
||||
|
||||
import { writeTerminalDataWithLineTimestamps } from "./runtime/terminalLineTimestamps.ts";
|
||||
import {
|
||||
noteTerminalOutputPressureData,
|
||||
setTerminalOutputPressureVisibility,
|
||||
} from "./runtime/terminalOutputPressure.ts";
|
||||
import { writeTerminalPayloadChunked } from "./terminalReplay.ts";
|
||||
import { noteTerminalOutputPressureData } from "./runtime/terminalOutputPressure.ts";
|
||||
import { KeywordHighlighter } from "./keywordHighlight.ts";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
@@ -247,27 +243,6 @@ test("bulk output skipped on the hot path is highlighted after output becomes qu
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("streaming bulk output colors the viewport in the same write", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 50 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
// More than BULK_WRITE_LINE_BREAKS newlines per chunk, but far below the
|
||||
// large-output rate, like a coalesced `tail -f` tick (#3271).
|
||||
const chunk = Array.from({ length: 12 }, (_, index) => `line-${index} ERROR`).join("\r\n");
|
||||
await write(term, chunk);
|
||||
assert.equal(highlighter.rebuildCount, 0);
|
||||
const viewportY = term.buffer.active.viewportY;
|
||||
assert.equal(cellRgb(term, viewportY + 4, "ERROR"), RED, "visible rows must color in-frame");
|
||||
assert.notEqual(cellRgb(term, 0, "ERROR"), RED, "history above the viewport stays with catch-up");
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
await highlighter.whenSettled();
|
||||
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), []);
|
||||
assert.equal(highlighter.rebuildCount, 1);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("ordinary Enter does not rebuild a saturated scrollback", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 40, rows: 3, scrollback: 10 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
@@ -1064,9 +1039,7 @@ test("quiet catch-up refreshes a tall viewport once", async () => {
|
||||
await write(term, flood);
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
await highlighter.whenSettled();
|
||||
// One refresh paints the viewport in-frame inside the bulk write callback;
|
||||
// the second is the single catch-up viewport pass after output quiets.
|
||||
assert.equal(refreshCount, 2);
|
||||
assert.equal(refreshCount, 1);
|
||||
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), []);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
@@ -1091,7 +1064,7 @@ test("identical flood lines are all colored after quiet catch-up", async () => {
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("saturated streaming keeps the pinned viewport colored in-frame", async () => {
|
||||
test("saturated flood does not rematch the pinned viewport on each write", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 6, scrollback: 8 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
@@ -1103,56 +1076,8 @@ test("saturated streaming keeps the pinned viewport colored in-frame", async ()
|
||||
}
|
||||
assert.equal(highlighter.rebuildCount, 0);
|
||||
const viewportY = term.buffer.active.viewportY;
|
||||
// Bulk bypass still colors the visible rows inside the write callback, so
|
||||
// streaming logs never show an uncolored frame (#3271).
|
||||
assert.equal(cellRgb(term, viewportY, "ERROR"), RED);
|
||||
assert.equal(cellRgb(term, viewportY + 2, "ERROR"), RED);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("hidden panes keep bulk streaming on the deferred catch-up path", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 6, scrollback: 40 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
const line = "2026-08-13 INFO worker=1 WARN ERROR failed from 10.2.0.1";
|
||||
const chunk = Array.from({ length: 16 }, () => line).join("\r\n");
|
||||
// Below the 16KB/100ms flood gate, so without the background check the bulk
|
||||
// bypass would recolor and refresh the viewport for every hidden batch.
|
||||
setTerminalOutputPressureVisibility(term, false);
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
noteTerminalOutputPressureData(term, chunk);
|
||||
await write(term, chunk);
|
||||
}
|
||||
const viewportY = term.buffer.active.viewportY;
|
||||
assert.notEqual(cellRgb(term, viewportY, "ERROR"), RED);
|
||||
// Reveal the pane and let catch-up paint the deferred range.
|
||||
setTerminalOutputPressureVisibility(term, true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
await highlighter.whenSettled();
|
||||
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), []);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("high-rate flood keeps the viewport on the deferred catch-up path", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 6, scrollback: 8 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
const line = "2026-08-13 INFO worker=1 WARN ERROR failed from 10.2.0.1";
|
||||
// ~20KB inside the 100ms rate window trips the large-output degradation, so
|
||||
// the in-frame viewport repaint stays off and only catch-up colors.
|
||||
const chunk = Array.from({ length: 300 }, () => line).join("\r\n");
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
noteTerminalOutputPressureData(term, chunk);
|
||||
await write(term, chunk);
|
||||
}
|
||||
assert.equal(highlighter.rebuildCount, 0);
|
||||
const viewportY = term.buffer.active.viewportY;
|
||||
assert.notEqual(cellRgb(term, viewportY, "ERROR"), RED);
|
||||
await new Promise((resolve) => setTimeout(resolve, 900));
|
||||
await highlighter.whenSettled();
|
||||
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), []);
|
||||
assert.notEqual(cellRgb(term, viewportY + 2, "ERROR"), RED);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
@@ -1173,52 +1098,3 @@ test("recycled identical flood rows are recolored on catch-up", async () => {
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
|
||||
test("hibernate replay defers bulk viewport coloring but subsequent live output colors immediately", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 6, scrollback: 100 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
const chunk = Array.from({ length: 20 }, () => "history ERROR").join("\r\n");
|
||||
try {
|
||||
await writeTerminalPayloadChunked(term, chunk);
|
||||
assert.notEqual(cellRgb(term, term.buffer.active.viewportY, "ERROR"), RED);
|
||||
await highlighter.whenSettled();
|
||||
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), []);
|
||||
await write(term, "\r\n" + chunk);
|
||||
assert.equal(cellRgb(term, term.buffer.active.viewportY, "ERROR"), RED);
|
||||
} finally {
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test("bulk streaming does not recolor a scrolled-back history viewport", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 6, scrollback: 100 });
|
||||
let bypass = false;
|
||||
const highlighter = new KeywordHighlighter(term, { shouldBypassHighlight: () => bypass });
|
||||
highlighter.setRules(rule(), true);
|
||||
const chunk = Array.from({ length: 20 }, () => "log ERROR").join("\r\n") + "\r\n";
|
||||
try {
|
||||
await write(term, chunk);
|
||||
await highlighter.whenSettled();
|
||||
term.scrollToTop();
|
||||
let refreshes = 0;
|
||||
const refresh = term.refresh.bind(term);
|
||||
term.refresh = (start, end) => { refreshes += 1; refresh(start, end); };
|
||||
bypass = true;
|
||||
await write(term, chunk);
|
||||
const scrollRefreshes = refreshes;
|
||||
refreshes = 0;
|
||||
bypass = false;
|
||||
await write(term, chunk);
|
||||
assert.equal(refreshes, scrollRefreshes, "bulk callback must not add another history repaint");
|
||||
term.scrollToBottom();
|
||||
await write(term, chunk);
|
||||
assert.equal(cellRgb(term, term.buffer.active.viewportY, "ERROR"), RED);
|
||||
} finally {
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
}
|
||||
});
|
||||
@@ -5,13 +5,9 @@ import { isSafePluginDecorationPattern } from "../../domain/pluginTerminalProvid
|
||||
import { checkRegexSafetyPattern } from "../../lib/regexSafety";
|
||||
import type { KeywordHighlightRule } from "../../types";
|
||||
import { XTERM_PERFORMANCE_CONFIG } from "../../infrastructure/config/xtermPerformance";
|
||||
import { isTerminalReplayWrite } from "./terminalReplay";
|
||||
import { readPluginTerminalBufferText } from "./pluginTerminalBufferText";
|
||||
import { compileRe2RangeMatcher, forEachNonEmptyRegexMatch } from "./keywordHighlightRegex";
|
||||
import {
|
||||
isTerminalOutputInBackground,
|
||||
shouldDegradeTerminalKeywordHighlight,
|
||||
} from "./runtime/terminalOutputPressure";
|
||||
import { shouldDegradeTerminalKeywordHighlight } from "./runtime/terminalOutputPressure";
|
||||
|
||||
type RuntimeKeywordHighlightRule = KeywordHighlightRule & { readonly providerId?: string };
|
||||
|
||||
@@ -400,43 +396,12 @@ export class KeywordHighlighter implements IDisposable {
|
||||
? this.resolveAbsoluteRepaintRange(absoluteControls, originModeNeedsSafety)
|
||||
: null;
|
||||
const bypass = !startedOnNormal || this.shouldBypassWrite(data);
|
||||
// Freeze in-frame eligibility at entry: the pressure window is a rolling
|
||||
// sample, and by the time this write's callback runs (xterm parses in a
|
||||
// macrotask while backlogged) later chunks may have aged it below the
|
||||
// threshold. Re-checking inside the callback would let a write that took
|
||||
// the flood path repaint the viewport anyway, defeating the deferred path.
|
||||
// Pane visibility is the exception: it can flip while this write is still
|
||||
// in flight (pane/page shown between entry and callback), so it is
|
||||
// re-evaluated live in the callback below instead of frozen here.
|
||||
const colorDuringBypass = bypass && this.mayColorViewportDuringBypass(data);
|
||||
if (bypass) {
|
||||
if (startedOnNormal && (this.enabled || this.compiledPatterns.length > 0 || this.hasPendingCatchUp())) {
|
||||
this.markCatchUp(absoluteRepaintRange === null ? startY : Math.min(startY, startBaseY));
|
||||
this.scheduleCatchUp();
|
||||
}
|
||||
return this.originalWrite(data, () => {
|
||||
// Bulk writes skip per-write coloring so xterm can keep painting the
|
||||
// flood, but the viewport itself must not sit uncolored until the
|
||||
// catch-up timer fires (#3271). Recolor the visible rows here, inside
|
||||
// the write callback: xterm parses in a macrotask and renders in the
|
||||
// next rAF, so these colors land in the same frame as the text. The
|
||||
// cost is O(viewport rows × rules), independent of chunk size, and
|
||||
// true rate/long-line floods keep the fully deferred path. Rows that
|
||||
// scrolled past the viewport stay with the deferred catch-up.
|
||||
if (
|
||||
this.term.buffer.active.type === "normal"
|
||||
&& this.compiledPatterns.length > 0
|
||||
&& colorDuringBypass
|
||||
// Visibility may have changed since this write started (hidden pane
|
||||
// shown before the async xterm parse finished): the entry snapshot
|
||||
// only froze the flood/rate decision, so check the live state here.
|
||||
&& !isTerminalOutputInBackground(this.term)
|
||||
// Appended logs should not add another repaint while reading history.
|
||||
// Scroll/reveal handlers retain their existing recoloring behavior.
|
||||
&& this.term.buffer.active.viewportY === this.term.buffer.active.baseY
|
||||
) {
|
||||
this.recolorVisible();
|
||||
}
|
||||
if (this.term.buffer.active.type === "normal" && !startedOnNormal) {
|
||||
if (this.enabled || this.compiledPatterns.length > 0) {
|
||||
this.markCatchUp(0);
|
||||
@@ -727,23 +692,6 @@ export class KeywordHighlighter implements IDisposable {
|
||||
return this.countNewlines(data) >= BULK_WRITE_LINE_BREAKS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming logs usually trip only the bulk line-break heuristic (coalesced
|
||||
* PTY ticks), which is cheap to color in-frame: rematching the visible
|
||||
* viewport costs O(viewport rows × rules) per write, independent of chunk
|
||||
* size. Rate/long-line floods and explicit full-bypass requests keep the
|
||||
* deferred catch-up so xterm keeps painting the dump smoothly.
|
||||
*
|
||||
* This is the frozen flood/rate decision only: pane visibility is evaluated
|
||||
* live in the write callback (`isTerminalOutputInBackground`) because it can
|
||||
* flip between write entry and the asynchronous callback.
|
||||
*/
|
||||
private mayColorViewportDuringBypass(data: string | Uint8Array): boolean {
|
||||
if (this.options.shouldBypassHighlight?.()) return false;
|
||||
if (typeof data !== "string" || isTerminalReplayWrite(this.term)) return false;
|
||||
return !shouldDegradeTerminalKeywordHighlight(this.term, data);
|
||||
}
|
||||
|
||||
private countNewlines(data: string): number {
|
||||
let count = 0;
|
||||
for (let index = 0; index < data.length; index += 1) {
|
||||
|
||||
@@ -16,11 +16,6 @@ const armSudoPrompt = (
|
||||
};
|
||||
|
||||
test("startMosh enables sudo autofill with the host saved password", async () => {
|
||||
let detected = "";
|
||||
let probes = 0;
|
||||
let ready: (() => void) | undefined;
|
||||
let resolveDetected!: () => void;
|
||||
const detectionDone = new Promise<void>((resolve) => { resolveDetected = resolve; });
|
||||
let onData: ((data: string) => void) | null = null;
|
||||
const sent: string[] = [];
|
||||
const terminalBackend = {
|
||||
@@ -33,8 +28,6 @@ test("startMosh enables sudo autofill with the host saved password", async () =>
|
||||
startSSHSession: async () => "ssh-session",
|
||||
startTelnetSession: async () => "telnet-session",
|
||||
startMoshSession: async () => "mosh-session",
|
||||
onMoshSessionReady: (_id: string, callback: () => void) => { ready = callback; return noop; },
|
||||
getSessionDistroInfo: async () => { probes++; return { success: true, stdout: "ID=ubuntu" }; },
|
||||
startLocalSession: async () => "local-session",
|
||||
startSerialSession: async () => "serial-session",
|
||||
execCommand: async () => ({}),
|
||||
@@ -56,7 +49,6 @@ test("startMosh enables sudo autofill with the host saved password", async () =>
|
||||
username: "alice",
|
||||
password: "saved-secret",
|
||||
},
|
||||
onOsDetected: (_id: string, distro: string) => { detected = distro; resolveDetected(); },
|
||||
keys: [],
|
||||
identities: [],
|
||||
resolvedChainHosts: [],
|
||||
@@ -93,11 +85,6 @@ test("startMosh enables sudo autofill with the host saved password", async () =>
|
||||
};
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startMosh(term as never);
|
||||
assert.equal(probes, 0, "wait for the Mosh handshake before probing");
|
||||
ready?.();
|
||||
await detectionDone;
|
||||
assert.equal(detected, "ubuntu");
|
||||
assert.equal(probes, 1);
|
||||
onData?.(armSudoPrompt(sudoAutofillRef.current));
|
||||
sudoAutofillRef.current?.confirmFill();
|
||||
|
||||
|
||||
@@ -481,9 +481,9 @@ test("startSSH requests a fresh transport for ordinary opens with connection aut
|
||||
assert.equal(captured[0].sourceSessionId, "source-session");
|
||||
assert.equal(captured[1].reuseTransport, false);
|
||||
assert.equal(captured[1].sourceSessionId, undefined);
|
||||
assert.equal(captured[2].reuseTransport, false);
|
||||
assert.equal(captured[2].reuseTransport, undefined);
|
||||
assert.equal(captured[2].sourceSessionId, undefined);
|
||||
assert.equal(captured[3].reuseTransport, false);
|
||||
assert.equal(captured[3].reuseTransport, undefined);
|
||||
assert.deepEqual(reuseAttempts, ["source-session", undefined, undefined]);
|
||||
});
|
||||
|
||||
@@ -502,7 +502,9 @@ test("startSSH requests a fresh transport for Duplicate Session clones", async (
|
||||
resizeSession: noop,
|
||||
};
|
||||
|
||||
// Ordinary opens and Duplicate Session both require a new login.
|
||||
// Without the marker an ordinary open stays eligible for general endpoint
|
||||
// reuse; with it, every attempt (including reconnects) dials fresh instead
|
||||
// of borrowing the source's live authenticated transport.
|
||||
await createTerminalSessionStarters(createStarterContext({
|
||||
shouldUseFreshSshConnection: () => false,
|
||||
terminalBackend,
|
||||
@@ -520,65 +522,12 @@ test("startSSH requests a fresh transport for Duplicate Session clones", async (
|
||||
await duplicateStarters.startSSH(createTermStub() as never);
|
||||
await duplicateStarters.startSSH(createTermStub() as never);
|
||||
|
||||
assert.equal(captured[0].reuseTransport, false);
|
||||
assert.equal(captured[0].reuseTransport, undefined);
|
||||
assert.equal(captured[0].sourceSessionId, undefined);
|
||||
assert.equal(captured[1].reuseTransport, false);
|
||||
assert.equal(captured[2].reuseTransport, false);
|
||||
});
|
||||
|
||||
test("startSSH dials a fresh transport once a pane has reconnected (#3293)", async () => {
|
||||
const captured: Record<string, unknown>[] = [];
|
||||
const terminalBackend = {
|
||||
backendAvailable: () => true,
|
||||
startSSHSession: async (options: Record<string, unknown>) => {
|
||||
captured.push(options);
|
||||
return `ssh-session-${captured.length}`;
|
||||
},
|
||||
onSessionData: () => noop,
|
||||
onSessionExit: () => noop,
|
||||
onChainProgress: () => noop,
|
||||
writeToSession: noop,
|
||||
resizeSession: noop,
|
||||
};
|
||||
|
||||
// An ordinary new tab must refresh groups even before its first reconnect.
|
||||
await createTerminalSessionStarters(createStarterContext({
|
||||
shouldUseFreshSshConnection: () => false,
|
||||
requireFreshConnectionOnReconnectRef: { current: false },
|
||||
terminalBackend,
|
||||
}) as never).startSSH(createTermStub() as never);
|
||||
assert.equal(captured[0].reuseTransport, false);
|
||||
|
||||
// After a reconnect (manual retry / auto-reconnect) every attempt must dial
|
||||
// a brand-new connection so the server performs a fresh login and picks up
|
||||
// remote supplementary-group changes (e.g. `usermod -aG`).
|
||||
const reconnectedStarters = createTerminalSessionStarters(createStarterContext({
|
||||
shouldUseFreshSshConnection: () => false,
|
||||
requireFreshConnectionOnReconnectRef: { current: true },
|
||||
terminalBackend,
|
||||
}) as never);
|
||||
await reconnectedStarters.startSSH(createTermStub() as never);
|
||||
await reconnectedStarters.startSSH(createTermStub() as never);
|
||||
assert.equal(captured[1].reuseTransport, false);
|
||||
assert.equal(captured[2].reuseTransport, false);
|
||||
|
||||
// Copy/Split may still have an unconsumed source while credentials load.
|
||||
// A reconnect must discard that intent before the next backend attempt.
|
||||
const pendingSource = { current: "source-session" as string | undefined };
|
||||
const reuseAttempts: (string | undefined)[] = [];
|
||||
await createTerminalSessionStarters(createStarterContext({
|
||||
shouldUseFreshSshConnection: () => false,
|
||||
requireFreshConnectionOnReconnectRef: { current: true },
|
||||
reuseConnectionFromSessionIdRef: pendingSource,
|
||||
setConnectionReuseAttemptSourceId: (id: string | undefined) => reuseAttempts.push(id),
|
||||
terminalBackend,
|
||||
}) as never).startSSH(createTermStub() as never);
|
||||
assert.equal(captured[3].reuseTransport, false);
|
||||
assert.equal(captured[3].sourceSessionId, undefined);
|
||||
assert.equal(pendingSource.current, undefined);
|
||||
assert.deepEqual(reuseAttempts, [undefined]);
|
||||
});
|
||||
|
||||
test("startSSH commits an empty automation snapshot only after the backend session succeeds", async () => {
|
||||
let resolveStart: ((sessionId: string) => void) | undefined;
|
||||
let commitCount = 0;
|
||||
@@ -656,7 +605,7 @@ test("startSSH rechecks the live automation policy before password fallback", as
|
||||
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
||||
|
||||
assert.equal(captured.length, 2);
|
||||
assert.equal(captured[0].reuseTransport, false);
|
||||
assert.equal(captured[0].reuseTransport, undefined);
|
||||
assert.equal(captured[1].reuseTransport, false);
|
||||
assert.equal(commitCount, 0);
|
||||
});
|
||||
|
||||
@@ -616,20 +616,22 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
? ctx.host.identityFilePaths
|
||||
: undefined;
|
||||
|
||||
let sourceReuseAttemptedWithinStart = false;
|
||||
const startAttempt = async (attempt: {
|
||||
password?: string;
|
||||
key?: SSHKey;
|
||||
useIdentityFiles?: boolean;
|
||||
useSshAgent?: boolean;
|
||||
}): Promise<string> => {
|
||||
// Reconnect supersedes a Copy/Split intent that was still waiting for credentials.
|
||||
const sourceSessionId = ctx.requireFreshConnectionOnReconnectRef?.current
|
||||
? undefined
|
||||
: ctx.reuseConnectionFromSessionIdRef?.current;
|
||||
const sourceSessionId = ctx.reuseConnectionFromSessionIdRef?.current;
|
||||
const sourceReuseAttempted = ctx.reuseConnectionSourceAttemptedRef?.current
|
||||
?? sourceReuseAttemptedWithinStart;
|
||||
const isFallbackAfterSourceReuse = sourceReuseAttempted && !sourceSessionId;
|
||||
if (ctx.reuseConnectionFromSessionIdRef) {
|
||||
ctx.reuseConnectionFromSessionIdRef.current = undefined;
|
||||
}
|
||||
if (sourceSessionId) {
|
||||
sourceReuseAttemptedWithinStart = true;
|
||||
if (ctx.reuseConnectionSourceAttemptedRef) {
|
||||
ctx.reuseConnectionSourceAttemptedRef.current = true;
|
||||
}
|
||||
@@ -702,14 +704,21 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
// bridge silently falls back to a fresh connection if the source is
|
||||
// gone, so reconnect/retry after the source closed still works.
|
||||
sourceSessionId,
|
||||
// Only an explicit Copy/Split may share an existing login. Ordinary
|
||||
// opens and reconnects must authenticate again to refresh remote groups.
|
||||
reuseTransport: sourceSessionId ? undefined : false,
|
||||
// Connect-time automation must see the complete login sequence. An
|
||||
// explicit Copy/Split keeps its source-session reuse contract, while
|
||||
// an ordinary open bypasses endpoint/idle transport reuse. Duplicate
|
||||
// Session clones must never borrow the source's live transport
|
||||
// either, so they send an explicit `reuseTransport: false`.
|
||||
reuseTransport: !sourceSessionId
|
||||
&& (requiresFreshSshConnection || isFallbackAfterSourceReuse || ctx.requireFreshConnection === true)
|
||||
? false
|
||||
: undefined,
|
||||
skipShellPidDiscovery: ctx.isNetworkDevice === true,
|
||||
});
|
||||
if (!requiresFreshSshConnection) {
|
||||
ctx.onConnectAutomationSnapshotCommitted?.();
|
||||
}
|
||||
sourceReuseAttemptedWithinStart = false;
|
||||
if (ctx.reuseConnectionSourceAttemptedRef) {
|
||||
ctx.reuseConnectionSourceAttemptedRef.current = false;
|
||||
}
|
||||
@@ -1208,13 +1217,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
cancelPendingStartupCommand?.();
|
||||
cancelPendingStartupCommand = undefined;
|
||||
};
|
||||
const detectMoshSystem = () => {
|
||||
if (!isCurrentAttempt()) return;
|
||||
const token = registerConnectionToken(attachedSessionId);
|
||||
void runDistroDetection(ctx, attachedSessionId, token);
|
||||
};
|
||||
const runMoshStartup = () => {
|
||||
detectMoshSystem();
|
||||
disposeMoshReady?.();
|
||||
disposeMoshReady = undefined;
|
||||
ctx.setIsConnectionAwaitingUserInput?.(false);
|
||||
@@ -1304,8 +1307,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
runMoshStartup();
|
||||
}
|
||||
} else {
|
||||
// Older bridges without the ready event: the start call completed the handshake.
|
||||
detectMoshSystem();
|
||||
// Older bridges without the ready event: keep previous behavior.
|
||||
scheduleStartupCommand(ctx, term, id);
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -154,13 +154,6 @@ export type TerminalSessionStartersContext = {
|
||||
// SSH attempt must send `reuseTransport: false` so the bridge never borrows
|
||||
// the source's live or any other pooled transport.
|
||||
requireFreshConnection?: boolean;
|
||||
// Set by the reconnect path (manual retry / auto-reconnect) for the rest of
|
||||
// the pane's lifetime: every SSH attempt must dial a brand-new connection
|
||||
// instead of borrowing a live or idle pooled transport. Reusing an
|
||||
// already-authenticated connection skips the server-side login, so remote
|
||||
// supplementary-group changes (e.g. `usermod -aG`) stay invisible until the
|
||||
// whole app quits (#3293).
|
||||
requireFreshConnectionOnReconnectRef?: MutableRefObject<boolean>;
|
||||
// Persists across renderer auth retries after the one-shot source intent is
|
||||
// consumed. Cleared only after a backend session starts successfully.
|
||||
reuseConnectionSourceAttemptedRef?: MutableRefObject<boolean>;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { stringCellWidth } from "../autocomplete/terminalStringCellWidth";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { ImageAddon } from "@xterm/addon-image";
|
||||
import { SearchAddon } from "@xterm/addon-search";
|
||||
@@ -1066,43 +1065,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
overlay.style.fontSize = `${currentTerminalFontSize()}px`;
|
||||
overlay.style.fontFamily = String(term.options.fontFamily ?? fontFamily);
|
||||
overlay.style.lineHeight = String(term.options.lineHeight ?? lineHeight);
|
||||
// Native font advances differ from xterm's device-pixel-rounded cells.
|
||||
// Fit each row to its terminal cell width, including double-width glyphs,
|
||||
// rather than applying a single-cell correction once per Unicode glyph.
|
||||
overlay.style.fontWeight = String(term.options.fontWeight ?? "normal");
|
||||
overlay.style.fontKerning = "none";
|
||||
overlay.style.fontVariantLigatures = "none";
|
||||
const screenRect = term.element?.querySelector(".xterm-screen")?.getBoundingClientRect();
|
||||
if (screenRect && screenRect.width > 0 && screenRect.height > 0
|
||||
&& term.cols > 0 && term.rows > 0) {
|
||||
overlay.style.lineHeight = `${screenRect.height / term.rows}px`;
|
||||
const rowElements = previewRows.map((row) => {
|
||||
const span = document.createElement("span");
|
||||
span.style.display = "inline-block";
|
||||
span.style.verticalAlign = "top";
|
||||
span.style.transformOrigin = "left top";
|
||||
span.textContent = row.text;
|
||||
return span;
|
||||
});
|
||||
const fragment = document.createDocumentFragment();
|
||||
rowElements.forEach((span, index) => {
|
||||
if (index > 0) fragment.appendChild(document.createTextNode("\n"));
|
||||
fragment.appendChild(span);
|
||||
});
|
||||
overlay.replaceChildren(fragment);
|
||||
// Batch all measurements before writing transforms to avoid one forced
|
||||
// layout per row. Text nodes and explicit newlines stay unchanged for copy.
|
||||
const nativeWidths = rowElements.map((span) => span.getBoundingClientRect().width);
|
||||
rowElements.forEach((span, index) => {
|
||||
const nativeWidth = nativeWidths[index];
|
||||
const cells = stringCellWidth(previewRows[index].text, term);
|
||||
if (nativeWidth > 0 && cells > 0) {
|
||||
span.style.transform = `scaleX(${cells * screenRect.width / term.cols / nativeWidth})`;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
overlay.textContent = previewRows.map((row) => row.text).join("\n");
|
||||
}
|
||||
overlay.textContent = previewRows.map((row) => row.text).join("\n");
|
||||
overlay.setAttribute(HISTORY_PREVIEW_WRAP_ATTR, encodeHistoryPreviewWrapFlags(previewRows));
|
||||
return true;
|
||||
};
|
||||
@@ -1699,13 +1662,6 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
return false;
|
||||
}
|
||||
|
||||
if (e.type === "keypress" && broadcastForwardedKeys.has(kittyKeyIdentity(e))) {
|
||||
// Space and uppercase letters can emit xterm data from keypress, after
|
||||
// keydown's zero-delay cleanup. Rejoin that physical broadcast instead
|
||||
// of sending the same character a second time as unpaired raw text.
|
||||
markBroadcastLegacyDataPending(kittyKeyIdentity(e));
|
||||
}
|
||||
|
||||
if (e.type !== "keydown") {
|
||||
return true;
|
||||
}
|
||||
@@ -2467,10 +2423,6 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
});
|
||||
return;
|
||||
}
|
||||
// insertText can follow an already-delivered keypress. Its fallback marker
|
||||
// must not classify the next physical key as a second composition commit.
|
||||
// Actual composition/input handlers clear the physical-key pairing first.
|
||||
if (broadcastLegacyDataPending) kittyCompositionPending = false;
|
||||
if (kittyCompositionPending && !data.startsWith("\u001b")) {
|
||||
kittyCompositionPending = false;
|
||||
if (kittyCompositionClearTimer !== undefined) {
|
||||
@@ -2781,17 +2733,6 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
},
|
||||
);
|
||||
term.onResize(({ cols, rows }) => {
|
||||
// An idle alternate-screen application may emit nothing after resizing.
|
||||
// Update history now, using xterm's cursor after any normal-buffer reflow.
|
||||
const buffer = term.buffer.active;
|
||||
const cursorLine = buffer.getLine(buffer.baseY + buffer.cursorY);
|
||||
ctx.terminalOutputHistory?.syncViewportAfterResize({
|
||||
cols, rows,
|
||||
cursorX: buffer.cursorX,
|
||||
cursorY: buffer.cursorY,
|
||||
cursorLine: cursorLine?.translateToString(true, 0, cols) ?? "",
|
||||
isWrapped: cursorLine?.isWrapped ?? false,
|
||||
});
|
||||
// A reflow can leave stale glyphs in the WebGL atlas; clear it so the new
|
||||
// dimensions re-rasterize cleanly (issue #1049).
|
||||
clearWebglTextureAtlas();
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import ts from "typescript";
|
||||
import { resolveKittyKeyboardBroadcastInput, type KittyKeyboardBroadcastInput } from "./kittyKeyboardBroadcast";
|
||||
import { createKittyKeyboardModeState, setKittyKeyboardModeFlags, shouldEncodeKittyCompositionText, shouldMarkKittyTextInputEvent, encodeKittyCompositionText } from "./kittyKeyboardProtocol";
|
||||
import { shouldBlockKeyPressForImeTextInput, shouldCommitDeferredImeTextInput } from "./terminalImeTextInput";
|
||||
import { sanitizeTerminalInput } from "./terminalInputSanitize";
|
||||
|
||||
// Execute the actual registered key/data callbacks, without constructing the
|
||||
// renderer and its WebGL/addon stack. In particular, retain their real timer
|
||||
// cleanup and raw-broadcast suppression rather than reproducing that logic here.
|
||||
const source = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
|
||||
function section(start: string, end: string): string {
|
||||
const from = source.indexOf(start);
|
||||
const to = source.indexOf(end, from);
|
||||
assert.ok(from >= 0 && to > from, `runtime section missing: ${start}`);
|
||||
return source.slice(from, to);
|
||||
}
|
||||
const declarations = section(" let suppressNextTerminalDataBroadcast =", " const broadcastKittyInput =");
|
||||
const keyboardCallback = section(" term.attachCustomKeyEventHandler((e: KeyboardEvent) => {", " const handleMiddleClick =");
|
||||
const dataCallback = section(" term.onData((data) => {", " const handleKittyKeyboardBroadcast =");
|
||||
const compositionMarker = section(" const markKittyCompositionPending =", " const finishKittyComposition =");
|
||||
const textInputMarker = section(" const markKittyTextInput =", ' textarea?.addEventListener("compositionstart"');
|
||||
const suppression = section(" const suppressTerminalBroadcast =", " // skipBroadcast");
|
||||
const callbackCode = ts.transpileModule(`
|
||||
${declarations}
|
||||
let win32InputModePendingEvent = null;
|
||||
let kittyCompositionPending = false;
|
||||
let kittyCompositionClearTimer;
|
||||
const handleTerminalInputData = (data) => {
|
||||
const inputSource = "terminal";
|
||||
${suppression}
|
||||
if (!suppressTerminalBroadcast) raw(data);
|
||||
};
|
||||
const textarea = null;
|
||||
${compositionMarker}
|
||||
${textInputMarker}
|
||||
${keyboardCallback}
|
||||
${dataCallback}
|
||||
globalThis.controls = { mark: markBroadcastLegacyDataPending, clear: clearBroadcastLegacyDataPending, input: markKittyTextInput };
|
||||
`, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText;
|
||||
|
||||
function setup(flags = 0) {
|
||||
const writes: string[] = [];
|
||||
const timers = new Map<number, () => void>();
|
||||
let timerId = 0;
|
||||
let receive!: (data: string) => void;
|
||||
let keyboard!: (event: Partial<KeyboardEvent>) => boolean;
|
||||
const forwarded = new Map<string, { targetSessionIds: string[] }>();
|
||||
const mode = createKittyKeyboardModeState();
|
||||
setKittyKeyboardModeFlags(mode, flags);
|
||||
const options = {
|
||||
kittyProtocolEnabled: flags !== 0, kittyMode: mode, applicationCursorMode: false,
|
||||
encodedKeys: new Set<string>(), legacySuppressedKeys: new Set<string>(),
|
||||
};
|
||||
const normalized = (input: KittyKeyboardBroadcastInput) => {
|
||||
const result = resolveKittyKeyboardBroadcastInput(input, options);
|
||||
if (result) writes.push(result.data);
|
||||
};
|
||||
const context = {
|
||||
controls: undefined as unknown as { mark: (identity: string) => void; clear: () => void; input: (event: { data: string; inputType: string }) => void },
|
||||
window: {
|
||||
setTimeout(fn: () => void) { const id = ++timerId; timers.set(id, fn); return id; },
|
||||
clearTimeout(id: number) { timers.delete(id); },
|
||||
},
|
||||
term: {
|
||||
modes: { win32InputMode: false },
|
||||
onData(fn: typeof receive) { receive = fn; },
|
||||
attachCustomKeyEventHandler(fn: typeof keyboard) { keyboard = fn; },
|
||||
},
|
||||
ctx: { terminalSettingsRef: { current: {} }, isBroadcastEnabledRef: { current: true }, onBroadcastInputRef: { current: () => undefined } },
|
||||
imeTextInputDeferredKey: null, imeTextInputDeferredKittyEvent: null,
|
||||
shouldBlockKeyPressForImeTextInput, shouldCommitDeferredImeTextInput, shouldMarkKittyTextInputEvent, shouldEncodeKittyCompositionText, encodeKittyCompositionText,
|
||||
kittyKeyboardMode: createKittyKeyboardModeState(), shouldSplitImeTextInputForWire: () => false,
|
||||
kittyKeyIdentity: (event: Partial<KeyboardEvent>) => event.code || event.key,
|
||||
broadcastForwardedKeys: forwarded,
|
||||
broadcastKittyInput: normalized,
|
||||
sanitizeTerminalInput,
|
||||
shouldSplitRawPasteInputForWire: () => false,
|
||||
raw: (data: string) => writes.push(data),
|
||||
};
|
||||
runInNewContext(callbackCode, context);
|
||||
return {
|
||||
writes, receive: (data: string) => receive(data), controls: context.controls,
|
||||
keypress: (key: string, code: string) => keyboard({ type: "keypress", key, code }),
|
||||
press(key: string, code: string) {
|
||||
normalized({ kind: "key", event: { type: "keydown", key, code }, fallbackToLegacy: true });
|
||||
forwarded.set(code, { targetSessionIds: ["target"] });
|
||||
context.controls.mark(code);
|
||||
},
|
||||
flushTimers() { for (const [id, fn] of timers) { timers.delete(id); fn(); } },
|
||||
release(key: string, code: string) {
|
||||
normalized({ kind: "key", event: { type: "keyup", key, code }, fallbackToLegacy: true });
|
||||
forwarded.delete(code);
|
||||
context.controls.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
for (const flags of [0, 8]) {
|
||||
for (const [key, code] of [["A", "KeyA"], [" ", "Space"]]) {
|
||||
test(`broadcast pairs delayed ${code} keypress once with target flags ${flags}`, () => {
|
||||
const runtime = setup(flags);
|
||||
runtime.press(key, code);
|
||||
const firstWrite = runtime.writes.join("");
|
||||
assert.ok(firstWrite);
|
||||
runtime.flushTimers();
|
||||
assert.equal(runtime.keypress(key, code), true);
|
||||
runtime.receive(key);
|
||||
assert.equal(runtime.writes.join(""), firstWrite, "the source's later text must not duplicate its physical key broadcast");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test("repeated physical presses each reach the broadcast target once", () => {
|
||||
const runtime = setup();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
runtime.press("A", "KeyA");
|
||||
runtime.flushTimers();
|
||||
runtime.keypress("A", "KeyA");
|
||||
runtime.receive("A");
|
||||
}
|
||||
assert.equal(runtime.writes.join(""), "AAA");
|
||||
});
|
||||
|
||||
test("keypress without an earlier broadcast and text after release are not swallowed", () => {
|
||||
const runtime = setup();
|
||||
runtime.keypress("B", "KeyB");
|
||||
runtime.receive("B");
|
||||
runtime.press("A", "KeyA");
|
||||
runtime.flushTimers();
|
||||
runtime.keypress("A", "KeyA");
|
||||
runtime.receive("A");
|
||||
runtime.release("A", "KeyA");
|
||||
runtime.receive("paste");
|
||||
assert.equal(runtime.writes.join(""), "BApaste");
|
||||
});
|
||||
|
||||
test("unmatched keypress cleanup still permits a later paste", () => {
|
||||
const runtime = setup();
|
||||
runtime.press("A", "KeyA");
|
||||
runtime.flushTimers();
|
||||
runtime.keypress("A", "KeyA");
|
||||
runtime.flushTimers();
|
||||
runtime.receive("paste");
|
||||
assert.equal(runtime.writes.join(""), "Apaste");
|
||||
});
|
||||
|
||||
test("trailing insertText for Space does not turn the next physical key into duplicate composition text", () => {
|
||||
const runtime = setup();
|
||||
for (const [key, code] of [[" ", "Space"], ["A", "KeyA"], ["B", "KeyB"], ["C", "KeyC"]]) {
|
||||
runtime.press(key, code);
|
||||
runtime.keypress(key, code);
|
||||
runtime.receive(key);
|
||||
runtime.controls.input({ data: key, inputType: "insertText" });
|
||||
runtime.release(key, code);
|
||||
// Real trace: the next key can arrive before insertText's zero-delay timer.
|
||||
}
|
||||
assert.equal(runtime.writes.join(""), " ABC");
|
||||
});
|
||||
|
||||
test("keyless insertText is still broadcast as actual text", () => {
|
||||
const runtime = setup();
|
||||
runtime.controls.input({ data: "中文", inputType: "insertText" });
|
||||
runtime.receive("中文");
|
||||
assert.equal(runtime.writes.join(""), "中文");
|
||||
});
|
||||
@@ -106,43 +106,3 @@ test("runDistroDetection normalizes FreeBSD uname output", async () => {
|
||||
|
||||
assert.deepEqual(detected, ["freebsd"]);
|
||||
});
|
||||
|
||||
test("Windows OpenSSH is identified without sending POSIX probes", async () => {
|
||||
const detected: string[] = [];
|
||||
await runDistroDetection({
|
||||
host: { id: 'windows', os: 'linux' },
|
||||
terminalBackend: {
|
||||
getSessionRemoteInfo: async () => ({ success: true, remoteSshVersion: 'SSH-2.0-OpenSSH_for_Windows_9.5' }),
|
||||
getSessionDistroInfo: async () => { throw new Error('must not probe Windows with POSIX commands'); },
|
||||
},
|
||||
onOsDetected: (_id: string, distro: string) => detected.push(distro),
|
||||
} as never, 'windows', registerConnectionToken('windows'));
|
||||
assert.deepEqual(detected, ['windows']);
|
||||
});
|
||||
|
||||
test("failed or unrecognized probes do not invent an operating system", async () => {
|
||||
const detected: string[] = [];
|
||||
await runDistroDetection({
|
||||
host: { id: 'unknown', os: 'linux' },
|
||||
terminalBackend: {
|
||||
getSessionDistroInfo: async () => ({ success: true, stdout: '', stderr: 'Linux command not found' }),
|
||||
},
|
||||
onOsDetected: (_id: string, distro: string) => detected.push(distro),
|
||||
} as never, 'unknown', registerConnectionToken('unknown'));
|
||||
assert.deepEqual(detected, []);
|
||||
});
|
||||
|
||||
test("a superseded Windows detection cannot update the newer connection", async () => {
|
||||
const detected: string[] = [];
|
||||
await runDistroDetection({
|
||||
host: { id: 'windows', os: 'linux' },
|
||||
terminalBackend: {
|
||||
getSessionRemoteInfo: async () => {
|
||||
registerConnectionToken('reconnected');
|
||||
return { success: true, remoteSshVersion: 'OpenSSH_for_Windows_9.5' };
|
||||
},
|
||||
},
|
||||
onOsDetected: (_id: string, distro: string) => detected.push(distro),
|
||||
} as never, 'reconnected', registerConnectionToken('reconnected'));
|
||||
assert.deepEqual(detected, []);
|
||||
});
|
||||
@@ -63,10 +63,6 @@ export const runDistroDetection = async (
|
||||
if (ctx.terminalBackend.getSessionRemoteInfo && sessionId) {
|
||||
const info = await ctx.terminalBackend.getSessionRemoteInfo(sessionId);
|
||||
if (!isStillCurrent()) return;
|
||||
if (!isKnownNetworkDevice && /^(?:SSH-(?:2\.0|1\.99)-)?OpenSSH_for_Windows(?:_|$)/i.test(info?.remoteSshVersion || '')) {
|
||||
ctx.onOsDetected?.(ctx.host.id, 'windows');
|
||||
return;
|
||||
}
|
||||
const vendor = detectVendorFromSshVersion(info?.remoteSshVersion);
|
||||
if (vendor) {
|
||||
ctx.onOsDetected?.(ctx.host.id, vendor);
|
||||
@@ -93,13 +89,12 @@ export const runDistroDetection = async (
|
||||
const res = await ctx.terminalBackend.getSessionDistroInfo(sessionId);
|
||||
if (!isStillCurrent()) return;
|
||||
if (!res?.success) return;
|
||||
const data = (res.stdout || "").trim();
|
||||
const data = `${res.stdout || ""}\n${res.stderr || ""}`;
|
||||
const idMatch = data.match(/^ID="?([\w-]+)"?$/im);
|
||||
const rawDistro = idMatch
|
||||
? idMatch[1]
|
||||
: (data.match(/^(Linux|Darwin|FreeBSD)\b/i)?.[1] || "").toLowerCase();
|
||||
// An os-release ID confirms Linux even for distributions without a dedicated icon.
|
||||
const distro = normalizeDistroId(rawDistro) || (idMatch ? 'linux' : '');
|
||||
: (data.split(/\s+/)[0] || "").toLowerCase();
|
||||
const distro = normalizeDistroId(rawDistro) || rawDistro;
|
||||
if (distro) ctx.onOsDetected?.(ctx.host.id, distro);
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -17,30 +17,10 @@ import {
|
||||
isHistoryPreviewDismissClick,
|
||||
joinHistoryPreviewSelectionText,
|
||||
nextHistoryPreviewTop,
|
||||
selectHistoryPreviewAll,
|
||||
shouldHideHistoryPreviewOnMouseDown,
|
||||
shouldKeepHistoryPreviewOnKey,
|
||||
} from "./terminalHistoryScrollOverride.ts";
|
||||
|
||||
test("select all includes every preview row and preserves soft-wrap copying", async () => {
|
||||
const { JSDOM } = await import("jsdom");
|
||||
const dom = new JSDOM("<pre><span>中文abc</span>\n<span>def</span>\n<span>last</span></pre>");
|
||||
try {
|
||||
const overlay = dom.window.document.querySelector("pre")!;
|
||||
overlay.setAttribute(HISTORY_PREVIEW_WRAP_ATTR, "010");
|
||||
assert.equal(selectHistoryPreviewAll(overlay), true);
|
||||
assert.equal(dom.window.getSelection()!.toString(), "中文abc\ndef\nlast");
|
||||
assert.equal(getHistoryPreviewSelectionText(overlay, dom.window.getSelection()), "中文abcdef\nlast");
|
||||
overlay.textContent = "plain\ntext";
|
||||
assert.equal(selectHistoryPreviewAll(overlay), true);
|
||||
assert.equal(dom.window.getSelection()!.toString(), "plain\ntext");
|
||||
overlay.textContent = "";
|
||||
assert.equal(selectHistoryPreviewAll(overlay), false);
|
||||
} finally {
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
|
||||
const wheel = (
|
||||
over: Partial<Parameters<typeof forcedHistoryScrollLinesForWheel>[0]> = {},
|
||||
) => ({
|
||||
@@ -372,23 +352,3 @@ test("history preview right-click is recognized as an app-menu target", () => {
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
test("nested preview rows retain soft-wrap copy and partial selections", async () => {
|
||||
const { JSDOM } = await import("jsdom");
|
||||
const dom = new JSDOM("<pre><span>中文abc</span>\n<span>def</span></pre>");
|
||||
const overlay = dom.window.document.querySelector("pre")!;
|
||||
overlay.setAttribute(HISTORY_PREVIEW_WRAP_ATTR, "01");
|
||||
const first = overlay.firstChild!.firstChild!;
|
||||
const last = overlay.lastChild!.firstChild!;
|
||||
const selection = {
|
||||
rangeCount: 1, anchorNode: first, focusNode: last,
|
||||
anchorOffset: 1, focusOffset: 2, toString: () => "文abc\ndef",
|
||||
};
|
||||
assert.equal(getHistoryPreviewSelectionText(overlay, selection), "文abcde");
|
||||
assert.equal(getHistoryPreviewSelectionText(overlay, {
|
||||
...selection, anchorNode: overlay, focusNode: overlay,
|
||||
anchorOffset: 0, focusOffset: overlay.childNodes.length,
|
||||
}), "中文abcdef");
|
||||
dom.window.close();
|
||||
});
|
||||
@@ -147,7 +147,6 @@ export type HistoryPreviewSelectionLike = {
|
||||
};
|
||||
|
||||
export type HistoryPreviewNodeLike = {
|
||||
ownerDocument?: Pick<Document, "createRange">;
|
||||
contains(node: { nodeType?: number } | null): boolean;
|
||||
firstChild?: { nodeType?: number } | null;
|
||||
getAttribute?(name: string): string | null;
|
||||
@@ -272,16 +271,6 @@ const resolveOverlayTextOffset = (
|
||||
node: { nodeType?: number } | null,
|
||||
offset: number,
|
||||
): number | null => {
|
||||
if (overlay.ownerDocument && node) {
|
||||
try {
|
||||
const prefix = overlay.ownerDocument.createRange();
|
||||
prefix.selectNodeContents(overlay as unknown as Node);
|
||||
prefix.setEnd(node as Node, offset);
|
||||
return prefix.toString().length;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (node === textNode) return offset;
|
||||
if (node === overlay) return offset <= 0 ? 0 : overlay.textContent?.length ?? 0;
|
||||
return null;
|
||||
@@ -358,7 +347,9 @@ export const selectHistoryPreviewAll = (overlay: HTMLElement | null | undefined)
|
||||
const selection = overlay.ownerDocument.getSelection();
|
||||
if (!selection) return false;
|
||||
const range = overlay.ownerDocument.createRange();
|
||||
range.selectNodeContents(overlay);
|
||||
const textNode = overlay.firstChild;
|
||||
if (textNode) range.selectNodeContents(textNode);
|
||||
else range.selectNodeContents(overlay);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
return !selection.isCollapsed;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
import {
|
||||
DEFAULT_OUTPUT_HISTORY_MAX_LINES,
|
||||
@@ -160,58 +159,6 @@ test("preview rows wrap long lines and flag the continuation rows", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("preview rows flag lines committed by automatic wraps as soft-wrapped", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(5);
|
||||
// "abcde" fills the viewport and "f" wraps; the transcript commits both
|
||||
// lines, but the preview must keep the soft-wrapped join between them.
|
||||
history.append("abcdef\n");
|
||||
assert.deepEqual([...history.getLines()], ["abcde", "f"]);
|
||||
assert.deepEqual(
|
||||
history.getPreviewRows({ cols: 5, rows: 2, top: 0 }).rows.map((row) => row.isWrapped),
|
||||
[false, true],
|
||||
);
|
||||
});
|
||||
|
||||
test("preview reflows adjacent soft-wrapped lines together when widened", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(5);
|
||||
// Captured at five columns, the transcript holds two wrap segments; a
|
||||
// ten-column preview must rejoin them into the single row xterm shows
|
||||
// after its resize reflow.
|
||||
history.append("abcdef\n");
|
||||
assert.deepEqual(
|
||||
history.getPreviewRows({ cols: 10, rows: 1, top: 0 }).rows.map((row) => row.text),
|
||||
["abcdef"],
|
||||
);
|
||||
// A narrower preview keeps splitting the rejoined run, flagging the
|
||||
// continuation rows.
|
||||
assert.deepEqual(
|
||||
history.getPreviewRows({ cols: 3, rows: 2, top: 0 }).rows.map((row) => row.text),
|
||||
["abc", "def"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
history.getPreviewRows({ cols: 3, rows: 2, top: 0 }).rows.map((row) => row.isWrapped),
|
||||
[false, true],
|
||||
);
|
||||
});
|
||||
|
||||
test("CUB moves from the displayed last column while a wrap is deferred", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(5);
|
||||
// "abcde" fills the row and defers the wrap; xterm displays the cursor on
|
||||
// the last column, so CUB 1 targets it minus one and X overwrites "e".
|
||||
history.append("abcde\x1b[DX");
|
||||
assert.deepEqual([...history.getLines()], ["abcXe"]);
|
||||
history.clear();
|
||||
// CUF from the same deferred state still clamps to the last column.
|
||||
history.append("abcde\x1b[CX");
|
||||
assert.deepEqual([...history.getLines()], ["abcdX"]);
|
||||
});
|
||||
|
||||
test("preview windows clamp to the retained rows and pad short output", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.append("one\ntwo\n");
|
||||
@@ -237,32 +184,6 @@ test("preview rows keep wide characters intact at a column boundary", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("wrap decisions measure with the live terminal's Unicode width provider", () => {
|
||||
// The configured `15-graphemes` runtime counts `🖥` as one xterm cell while
|
||||
// the local fallback counts two, so without the injected provider the
|
||||
// tracker commits `abcd` and puts `🖥X` on the continuation row while xterm
|
||||
// renders `abcd🖥` with `X` wrapped. The preview must match the terminal.
|
||||
const widthTerm = {
|
||||
_core: {
|
||||
unicodeService: { getStringCellWidth: (s: string) => [...s].length },
|
||||
},
|
||||
} as never;
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(5);
|
||||
history.setWidthTerminal(widthTerm);
|
||||
history.append("abcd🖥X");
|
||||
assert.deepEqual(history.getLines(), ["abcd🖥", "X"]);
|
||||
|
||||
const preview = createTerminalOutputHistoryPreview();
|
||||
preview.setWidthTerminal(widthTerm);
|
||||
preview.append("abcd🖥X\n");
|
||||
assert.deepEqual(
|
||||
preview.getPreviewRows({ cols: 5, rows: 2, top: 0 }).rows.map((row) => row.text),
|
||||
["abcd🖥", "X"],
|
||||
);
|
||||
});
|
||||
|
||||
test("wheel steps walk the preview from the newest row upwards", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
for (let index = 0; index < 6; index += 1) history.append(`row ${index}\n`);
|
||||
@@ -312,571 +233,4 @@ test("default retention bounds the preview history", () => {
|
||||
const transcript = [...history.getLines()].join("");
|
||||
assert.ok(transcript.length <= 2 * 6, transcript);
|
||||
assert.ok(transcript.includes("cc"), transcript);
|
||||
});
|
||||
test("Vim absolute row moves separate printed rows across arbitrary chunks", () => {
|
||||
const input = '\x1b[2;1HSMOKE-VIM-002\x1b[2;14H\x1b[K\x1b[3;1HSMOKE-VIM-003\x1b[3;14H\x1b[K\x1b[4;1HSMOKE-VIM-004';
|
||||
for (let split = 0; split <= input.length; split++) {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.append(input.slice(0, split));
|
||||
history.append(input.slice(split));
|
||||
assert.deepEqual(history.getLines(), ['SMOKE-VIM-002', 'SMOKE-VIM-003', 'SMOKE-VIM-004']);
|
||||
}
|
||||
});
|
||||
|
||||
test("same-row cursor-home redraw stays one progress line", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
for (let i = 0; i < 100; i++) history.append(`\x1b[1;1Hprogress ${i}\x1b[K`);
|
||||
assert.deepEqual(history.getLines(), ['progress 99']);
|
||||
});
|
||||
|
||||
test("vertical moves do not insert blank transcript rows or expose control strings", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.append('one\r\n\x1b[2;1Htwo\x1b[1Bthree\x9b4;1Hfour');
|
||||
history.append('\x1b]titleH\x07\x1b[999999999Bfive');
|
||||
assert.deepEqual(history.getLines(), ['one', 'two', ' three', 'four', ' five']);
|
||||
history.clear();
|
||||
history.append('\x1b[Hnew\x1b[HNEW\x1b[K');
|
||||
assert.deepEqual(history.getLines(), ['NEW']);
|
||||
});
|
||||
|
||||
test("row controls retain requested and inherited terminal columns", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.append('\x1b[2;10Htext\x1b[1Bnext\x1b[6dlast\x1b[1Eleft');
|
||||
assert.deepEqual(history.getLines(), [' text', ' next', ' last', 'left']);
|
||||
history.clear();
|
||||
history.append('\x9b2;3fAB\x1b[2;3HXY');
|
||||
assert.deepEqual(history.getLines(), [' XY']);
|
||||
});
|
||||
|
||||
test("positioned output respects wide-cell boundaries and retained size limits", () => {
|
||||
const history = createTerminalOutputHistoryPreview({ maxChars: 64 });
|
||||
history.append('\x1b[2;3H中文\x1b[2;5HX\x1b[K');
|
||||
assert.deepEqual(history.getLines(), [' 中X']);
|
||||
history.clear();
|
||||
history.append('\x1b[2;999999999Hend');
|
||||
assert.ok(history.getLines().join('').length <= 128);
|
||||
assert.ok(history.getLines().join('').endsWith('end'));
|
||||
});
|
||||
|
||||
test("CHA and relative horizontal moves stay on the tracked row", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.append('abcde\x1b[1GX');
|
||||
assert.deepEqual(history.getLines(), ['Xbcde']);
|
||||
history.clear();
|
||||
history.append('ab\x1b[3CX');
|
||||
assert.deepEqual(history.getLines(), ['ab X']);
|
||||
history.clear();
|
||||
history.append('abcde\x1b[2DX');
|
||||
assert.deepEqual(history.getLines(), ['abcXe']);
|
||||
});
|
||||
|
||||
test("NEL and IND advance the row; RI moves back up", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.append('foo\x1bEbar');
|
||||
assert.deepEqual(history.getLines(), ['foo', 'bar']);
|
||||
history.clear();
|
||||
history.append('foo\x1bDbar');
|
||||
assert.deepEqual(history.getLines(), ['foo', ' bar']);
|
||||
history.clear();
|
||||
history.append('\x1b[r\x1b[3;1Hfoo\x1bMbar');
|
||||
assert.deepEqual(history.getLines(), ['foo', ' bar']);
|
||||
history.clear();
|
||||
history.append('\x1b[2;3r\x1b[2;1Hfoo\x1bMbar');
|
||||
assert.deepEqual(history.getLines(), ['foo', ' bar']);
|
||||
});
|
||||
|
||||
|
||||
test("cursor placement alone does not append blank history rows", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.append('abc\x1b[2;4H\x1b[1B\x1b[1B');
|
||||
assert.deepEqual(history.getLines(), ['abc']);
|
||||
history.append('X');
|
||||
assert.deepEqual(history.getLines(), ['abc', ' X']);
|
||||
});
|
||||
|
||||
test("positioned overwrites replace whole graphemes and preserve cell spacing", () => {
|
||||
for (const glyph of ['😀', '👩💻', '中']) {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.append(`A${glyph}B\x1b[1;2HX`);
|
||||
assert.deepEqual(history.getLines(), ['AX B']);
|
||||
history.clear();
|
||||
history.append(`A${glyph}B\x1b[1;3HX`);
|
||||
assert.deepEqual(history.getLines(), ['A XB']);
|
||||
}
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.append('AéB\x1b[1;2HX');
|
||||
assert.deepEqual(history.getLines(), ['AXB']);
|
||||
});
|
||||
|
||||
test("EL 1 erases a wide glyph intersected by the erase boundary", () => {
|
||||
// The cursor lands on the wide glyph's first cell: xterm blanks both cells
|
||||
// and keeps the suffix at its columns.
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.append('中A\x1b[1G\x1b[1K');
|
||||
assert.deepEqual(history.getLines(), [' A']);
|
||||
history.clear();
|
||||
// The cursor lands on the wide glyph's second cell: the glyph is erased too.
|
||||
history.append('A中B\x1b[1;3H\x1b[1K');
|
||||
assert.deepEqual(history.getLines(), [' B']);
|
||||
});
|
||||
|
||||
test("cursor-row moves clamp to the reported terminal viewport", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
// Bottom-row address + a down move past the viewport: the terminal keeps
|
||||
// the cursor on row 24, so the status redraw must not split history lines.
|
||||
history.append("\x1b[24;1Hstatus\x1b[1BNEW");
|
||||
assert.deepEqual(history.getLines(), ["statusNEW"]);
|
||||
history.clear();
|
||||
// Absolute rows beyond the viewport clamp to the bottom row too: the redraw
|
||||
// overwrites the bottom row in place instead of adding a history line.
|
||||
history.append("\x1b[24;1Hrow24\x1b[999;1Hredraw");
|
||||
assert.deepEqual(history.getLines(), ["redraw"]);
|
||||
history.clear();
|
||||
// Without a reported viewport the legacy behavior applies (columns are
|
||||
// retained across row moves).
|
||||
const unclamped = createTerminalOutputHistoryPreview();
|
||||
unclamped.append("\x1b[24;1Hstatus\x1b[1BNEW");
|
||||
assert.deepEqual(unclamped.getLines(), ["status", " NEW"]);
|
||||
});
|
||||
|
||||
test("absolute cursor columns clamp to the reported viewport width", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(80);
|
||||
// CUP past the last column clamps to it on screen; without the clamp the
|
||||
// preview would pad 998 spaces and fabricate wrapped rows.
|
||||
history.append("\x1b[1;999HX");
|
||||
assert.deepEqual(history.getLines(), [" ".repeat(79) + "X"]);
|
||||
});
|
||||
|
||||
test("line feeds on the bottom row stay clamped to it", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.append("\x1b[24;1Hstatus\r\nnew\x1b[24;1HNEW");
|
||||
assert.deepEqual(history.getLines(), ["status", "NEW"]);
|
||||
});
|
||||
|
||||
test("shrinking the viewport clamps the tracked cursor row", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.append("\x1b[24;1Hbottom");
|
||||
history.setViewportRows(10);
|
||||
// The terminal moved the cursor to the new bottom row, so a relative move
|
||||
// from there must stay a same-row redraw instead of committing a line.
|
||||
history.append("\x1b[1BX");
|
||||
assert.deepEqual(history.getLines(), ["bottomX"]);
|
||||
});
|
||||
|
||||
test("relative row moves stop at the scroll region's bottom margin", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.append("\x1b[1;20r\x1b[20;1Hstatus\x1b[1BNEW");
|
||||
assert.deepEqual(history.getLines(), ["statusNEW"]);
|
||||
});
|
||||
|
||||
test("invalid DECSTBM ranges are ignored", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
// Bottom not greater than top: xterm ignores the sequence, so the existing
|
||||
// text must stay on its row and later text must not home the cursor.
|
||||
history.append("\x1b[5;1Hbefore\x1b[20;10rafter");
|
||||
assert.deepEqual(history.getLines(), ["beforeafter"]);
|
||||
history.clear();
|
||||
// A top past the viewport clamps to it, leaving no valid region either.
|
||||
history.append("\x1b[5;1Hbefore\x1b[30;40rafter");
|
||||
assert.deepEqual(history.getLines(), ["beforeafter"]);
|
||||
history.clear();
|
||||
// A bottom past the viewport clamps to it; the valid region still applies.
|
||||
history.append("\x1b[1;999r\x1b[24;1Hstatus\x1b[999BNEW");
|
||||
assert.deepEqual(history.getLines(), ["statusNEW"]);
|
||||
});
|
||||
|
||||
test("growing the viewport resets the scroll margins", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.append("\x1b[1;24r");
|
||||
history.setViewportRows(40);
|
||||
// xterm resets the scroll region on resize, so a relative move past row 24
|
||||
// reaches row 25 instead of stopping at the stale bottom margin.
|
||||
history.append("\x1b[24;1Hrow24\x1b[1Brow25");
|
||||
assert.deepEqual(history.getLines(), ["row24", " row25"]);
|
||||
});
|
||||
|
||||
test("clear resets retained scroll margins between terminal boots", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.append("\x1b[1;20robsolete");
|
||||
history.clear();
|
||||
// The fresh xterm instance has a full-viewport region; the new session must
|
||||
// not inherit the previous boot's bottom margin.
|
||||
history.setViewportRows(24);
|
||||
history.append("\x1b[20;1Hrow20\x1b[1Brow21");
|
||||
assert.deepEqual(history.getLines(), ["row20", " row21"]);
|
||||
});
|
||||
|
||||
test("narrowing the viewport clamps the tracked cursor column", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(80);
|
||||
history.append("\x1b[1;80H");
|
||||
history.setViewportCols(10);
|
||||
// xterm clamps the cursor to the new last column; the next printable span
|
||||
// must be written there, not padded out to the old column 80.
|
||||
history.append("X");
|
||||
assert.deepEqual(history.getLines(), [" ".repeat(9) + "X"]);
|
||||
});
|
||||
|
||||
test("narrowing the viewport trims the tracked cursor row like xterm", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(10);
|
||||
history.append("abcdefgh");
|
||||
history.setViewportCols(5);
|
||||
// xterm trims the cursor row to the new width on a narrowing resize
|
||||
// (`reflowCursorLine` defaults to false, so `fgh` is discarded instead of
|
||||
// re-wrapped), so X overwrites the last retained cell rather than leaving
|
||||
// a phantom `fgh` tail plus an extra wrapped preview row.
|
||||
history.append("X");
|
||||
assert.deepEqual(history.getLines(), ["abcdX"]);
|
||||
assert.deepEqual(
|
||||
history.getPreviewRows({ cols: 5, rows: 1, top: 0 }).rows.map((row) => row.text),
|
||||
["abcdX"],
|
||||
);
|
||||
});
|
||||
|
||||
test("narrowing the viewport drops the trimmed wide-glyph tail", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(6);
|
||||
history.append("中文");
|
||||
history.setViewportCols(3);
|
||||
// The second glyph spans cells 2-3 past the new width; xterm discards it
|
||||
// whole, so the open row keeps only the glyph that still fits and the next
|
||||
// printable span lands on the new last column.
|
||||
assert.deepEqual(history.getLines(), ["中"]);
|
||||
history.append("X");
|
||||
assert.deepEqual(history.getLines(), ["中X"]);
|
||||
});
|
||||
|
||||
test("printable output past the viewport width wraps the tracked cursor", () => {
|
||||
// xterm wraps `f` onto the second row, so `CSI 2;1H` overwrites it there
|
||||
// instead of adding a separate line below a stale wrapped row.
|
||||
const input = "abcdef\x1b[2;1HXY";
|
||||
for (let split = 0; split <= input.length; split++) {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(5);
|
||||
history.append(input.slice(0, split));
|
||||
history.append(input.slice(split));
|
||||
assert.deepEqual(history.getLines(), ["abcde", "XY"]);
|
||||
}
|
||||
});
|
||||
|
||||
test("a carriage return cancels the deferred wrap instead of splitting the row", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(5);
|
||||
history.append("abcde\rXYZ");
|
||||
assert.deepEqual(history.getLines(), ["XYZde"]);
|
||||
});
|
||||
|
||||
test("wide glyphs wrap whole at the viewport width", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(4);
|
||||
history.append("中文中文中文\n");
|
||||
assert.deepEqual(history.getLines(), ["中文", "中文", "中文"]);
|
||||
});
|
||||
|
||||
test("origin mode resolves absolute rows against the scroll region", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
// In origin mode both addresses target row 20 (top margin + 15, then the
|
||||
// bottom-margin clamp), so the redraw overwrites `status` in place instead
|
||||
// of retaining it as a stale separate line.
|
||||
history.append("\x1b[5;20r\x1b[?6h\x1b[16;1Hstatus\x1b[99;1HNEW");
|
||||
assert.deepEqual(history.getLines(), ["NEWtus"]);
|
||||
history.clear();
|
||||
// clear() forgets origin mode: rows are absolute again.
|
||||
history.setViewportRows(24);
|
||||
history.append("\x1b[5;20r\x1b[?6h\x1b[?6l\x1b[5;1Habsolute");
|
||||
assert.deepEqual(history.getLines(), ["absolute"]);
|
||||
});
|
||||
|
||||
test("non-wrapping wide glyph at the right edge cannot block terminal capture", () => {
|
||||
const moduleUrl = new URL("./terminalOutputHistory.ts", import.meta.url).href;
|
||||
const script = `
|
||||
import { createTerminalOutputHistoryPreview } from ${JSON.stringify(moduleUrl)};
|
||||
const results = [];
|
||||
for (const glyph of ["中", "👩💻", "界界"]) {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(5);
|
||||
history.append(String.fromCharCode(27) + "[?7labcd" + glyph + "Z");
|
||||
results.push(history.getLines());
|
||||
}
|
||||
console.log(JSON.stringify(results));
|
||||
`;
|
||||
const result = spawnSync(process.execPath, ["--import", "tsx", "--input-type=module", "-e", script], {
|
||||
encoding: "utf8",
|
||||
timeout: 5000,
|
||||
});
|
||||
assert.equal(result.error, undefined, String(result.error));
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.deepEqual(JSON.parse(result.stdout), [["abcdZ"], ["abcdZ"], ["abcdZ"]]);
|
||||
});
|
||||
|
||||
test("unchanged viewport reports preserve pending wrap across display chunks", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(5);
|
||||
history.append("abcde");
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(5);
|
||||
history.append("f");
|
||||
assert.deepEqual(history.getLines(), ["abcde", "f"]);
|
||||
});
|
||||
|
||||
test("a zero-width mark arriving after the wrap column joins the final cell", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(5);
|
||||
// The base character fills the last column; its combining mark arrives in
|
||||
// the next chunk. xterm attaches the mark to that cell without wrapping.
|
||||
history.append("abcde");
|
||||
history.append("́Z\n");
|
||||
assert.deepEqual([...history.getLines()], ["abcdé", "Z"]);
|
||||
});
|
||||
|
||||
test("a standalone zero-width mark after a control starts the wrapped row", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(5);
|
||||
// The SGR control xterm parses resets its preceding-join state, so the
|
||||
// combining mark that follows is a standalone grapheme: it must not attach
|
||||
// to the previous row's final cell; the pending wrap happens and the mark
|
||||
// begins the next row (like xterm's `15-graphemes` runtime).
|
||||
history.append("abcde\x1b[31m");
|
||||
history.append("́Z\n");
|
||||
assert.deepEqual([...history.getLines()], ["abcde", "́Z"]);
|
||||
// The same holds when the control rides inside one display chunk.
|
||||
history.clear();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(5);
|
||||
history.append("abcde\x1b[31ḿZ\n");
|
||||
assert.deepEqual([...history.getLines()], ["abcde", "́Z"]);
|
||||
});
|
||||
|
||||
test("combined private-mode controls apply every parameter", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(5);
|
||||
// `CSI ?6;7l` resets both origin mode and DECAWM in one control; dropping
|
||||
// the control whole would leave autowrap on and wrap the overflow into a
|
||||
// second row (xterm keeps `abcdef` on one row as `abcdf`).
|
||||
history.append("\x1b[5;20r\x1b[?6;7l\x1b[5;1Habcdef");
|
||||
assert.deepEqual(history.getLines(), ["abcdf"]);
|
||||
// `CSI ?6;7h` re-enables both: absolute rows clamp to the margins again and
|
||||
// printable output wraps once more.
|
||||
history.append("\x1b[?6;7h\x1b[99;1HNEW");
|
||||
assert.deepEqual(history.getLines(), ["abcdf", "NEW"]);
|
||||
// Unrelated parameters ride along: xterm applies both `CSI ?6;25h` params
|
||||
// (25 is DECTCEM, untracked here), so dropping the control whole would
|
||||
// resolve the later CUP row absolutely instead of against the top margin.
|
||||
const combined = createTerminalOutputHistoryPreview();
|
||||
combined.setViewportRows(24);
|
||||
combined.append("\x1b[5;20r\x1b[?6;25h\x1b[16;1Hstatus\x1b[99;1HOLD");
|
||||
assert.deepEqual(combined.getLines(), ["OLDtus"]);
|
||||
});
|
||||
|
||||
test("a grapheme longer than the per-row piece cap is not dropped", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(5);
|
||||
// One base character trailed by more combining marks than the piece cap
|
||||
// holds: cutting at a grapheme boundary yields an empty slice for it, and
|
||||
// returning early would silently drop this grapheme and everything after.
|
||||
const mark = "́";
|
||||
history.append("abcd" + "e" + mark.repeat(80) + "Z");
|
||||
assert.deepEqual(history.getLines(), ["abcde" + mark.repeat(80), "Z"]);
|
||||
});
|
||||
|
||||
test("discarding a non-fitting wide glyph does not re-pad the deferred gap", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(5);
|
||||
// Cursor on the last column, DECAWM off: the wide glyph is discarded and the
|
||||
// next narrow character overwrites the last cell without padding it again.
|
||||
history.append("\x1b[?7l\x1b[1;5H中Z");
|
||||
assert.deepEqual(history.getLines(), [" Z"]);
|
||||
});
|
||||
|
||||
test("restoring a cursor saved above a shrunken viewport clamps to the bottom row", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.append("\x1b[24;1Hsaved\x1b7");
|
||||
history.setViewportRows(10);
|
||||
history.append("\x1b8X");
|
||||
// The saved row 24 no longer exists; the restore lands on the bottom row so
|
||||
// the later bottom-row address writes `Y` on the same line as `X` instead of
|
||||
// keeping an obsolete extra line (the saved column is preserved).
|
||||
history.append("\x1b[10;1HY");
|
||||
assert.deepEqual(history.getLines(), ["saved", "Y X"]);
|
||||
});
|
||||
|
||||
test("RIS resets the tracked modes, margins, and cursor", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(5);
|
||||
// DECAWM off, then RIS: xterm re-enables autowrap and homes the cursor, so
|
||||
// `abcdef` wraps to `abcde`/`f` exactly like a fresh terminal.
|
||||
history.append("\x1b[?7l\x1bcabcdef");
|
||||
assert.deepEqual(history.getLines(), ["abcde", "f"]);
|
||||
|
||||
// RIS also clears the scroll region, DECOM, and the saved cursor.
|
||||
const reset = createTerminalOutputHistoryPreview();
|
||||
reset.setViewportRows(24);
|
||||
reset.append("\x1b[5;20r\x1b[?6h\x1b7\x1bc\x1b[99;1HOLD");
|
||||
// Without the reset, the CUP row would be relative to the top margin and
|
||||
// clamp to its bottom (row 20), rewriting the same tracked line as before.
|
||||
assert.deepEqual(reset.getLines(), ["OLD"]);
|
||||
});
|
||||
|
||||
test("RIS commits the open row even when the cursor is already homed", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(20);
|
||||
// xterm clears the display before homing the cursor, so output after RIS
|
||||
// must not overwrite a stale suffix of the cleared screen row.
|
||||
history.append("LONG\x1bcX");
|
||||
assert.deepEqual(history.getLines(), ["LONG", "X"]);
|
||||
});
|
||||
|
||||
test("a grapheme split across display chunks is rejoined", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(5);
|
||||
history.setViewportCols(4);
|
||||
// The backend delivered the ZWJ sequence's base in one chunk and its
|
||||
// continuation in the next; xterm's grapheme provider joins them across
|
||||
// writes, so the preview must measure the joined cluster, not the chunks.
|
||||
history.append("👩");
|
||||
history.append("💻Z");
|
||||
assert.deepEqual(history.getLines(), ["👩💻Z"]);
|
||||
|
||||
// The unsplit input is the reference behavior.
|
||||
const joined = createTerminalOutputHistoryPreview();
|
||||
joined.setViewportRows(5);
|
||||
joined.setViewportCols(4);
|
||||
joined.append("👩💻Z");
|
||||
assert.deepEqual(joined.getLines(), ["👩💻Z"]);
|
||||
});
|
||||
|
||||
test("a control between chunks resets the split-grapheme join", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(5);
|
||||
history.setViewportCols(5);
|
||||
// xterm resets its preceding-join state at every control it parses, so an
|
||||
// SGR between the base chunk and the continuation leaves two graphemes:
|
||||
// 👩 (2 cells) + 💻 (2 cells) + Z fill the row before Q wraps.
|
||||
history.append("👩");
|
||||
history.append("\x1b[31m");
|
||||
history.append("💻ZQ");
|
||||
assert.deepEqual(history.getLines(), ["👩💻Z", "Q"]);
|
||||
|
||||
// The unsplit input with the same control is the reference behavior.
|
||||
const joined = createTerminalOutputHistoryPreview();
|
||||
joined.setViewportRows(5);
|
||||
joined.setViewportCols(5);
|
||||
joined.append("👩\x1b[31m💻ZQ");
|
||||
assert.deepEqual(joined.getLines(), ["👩💻Z", "Q"]);
|
||||
});
|
||||
|
||||
test("a width-only resize resets the tracked scroll margins", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(80);
|
||||
history.append("\x1b[1;20r");
|
||||
history.append("row20content");
|
||||
// xterm resets its scroll region on any buffer resize; a width-only resize
|
||||
// must clear the old DECSTBM margins so relative moves clamp to the live
|
||||
// bottom row instead of the region's stale one.
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(40);
|
||||
history.append("\x1b[20;1HX\x1b[BY");
|
||||
assert.deepEqual(history.getLines(), ["row20content", "X", " Y"]);
|
||||
});
|
||||
|
||||
|
||||
test("preview reflow retains leading, consecutive and trailing blank hard lines", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportCols(5);
|
||||
history.append("\nabcdef\n\n\nend\n\n");
|
||||
assert.deepEqual([...history.getLines()], ["", "abcde", "f", "", "", "end", ""]);
|
||||
const preview = history.getPreviewRows({ cols: 10, rows: 6, top: 0 });
|
||||
assert.equal(preview.totalRows, 6);
|
||||
assert.deepEqual(preview.rows.map((row) => row.text), ["", "abcdef", "", "", "end", ""]);
|
||||
});
|
||||
|
||||
test("blank-only history remains distinct from empty history", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
assert.equal(history.getPreviewRowCount(10), 0);
|
||||
history.append("\n\n");
|
||||
assert.equal(history.getPreviewRowCount(10), 2);
|
||||
});
|
||||
|
||||
test("reflow keeps an erase-blanked wrapped predecessor as its own row", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(5);
|
||||
// At five columns `abcde` fills row one and defers the wrap; `CSI 2K`
|
||||
// blanks it and `X` wraps to row two, so xterm shows a blank first row and
|
||||
// a wrapped `X` on the second. The preview must keep both rows instead of
|
||||
// joining the erased predecessor into its continuation.
|
||||
history.append("abcde\x1b[2KX");
|
||||
assert.deepEqual([...history.getLines()], ["", "X"]);
|
||||
const preview = history.getPreviewRows({ cols: 5, rows: 2, top: 0 });
|
||||
assert.equal(preview.totalRows, 2);
|
||||
assert.deepEqual(preview.rows.map((row) => row.text), ["", "X"]);
|
||||
assert.deepEqual(preview.rows.map((row) => row.isWrapped), [false, true]);
|
||||
});
|
||||
|
||||
test("a grapheme wider than the viewport wraps the following characters", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(1);
|
||||
// xterm renders the wide glyph on the first row and wraps `X` to the
|
||||
// second; retaining the whole tail as one row would shift every later
|
||||
// cursor-row transition one row behind the terminal.
|
||||
history.append("中X");
|
||||
assert.deepEqual([...history.getLines()], ["中", "X"]);
|
||||
});
|
||||
|
||||
|
||||
test("resizing invalidates cached preview text even when returning to its old width", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(10);
|
||||
history.append("abcdefgh");
|
||||
assert.deepEqual(history.getPreviewRows({ cols: 10, rows: 1, top: 0 }).rows,
|
||||
[{ text: "abcdefgh", isWrapped: false }]);
|
||||
// A hidden preview is not read between these resizes. Returning to the
|
||||
// cached width must not resurrect characters trimmed from the live row.
|
||||
history.setViewportCols(5);
|
||||
history.setViewportCols(10);
|
||||
assert.deepEqual(history.getLines(), ["abcde"]);
|
||||
assert.deepEqual(history.getPreviewRows({ cols: 10, rows: 1, top: 0 }).rows,
|
||||
[{ text: "abcde", isWrapped: false }]);
|
||||
});
|
||||
|
||||
test("resizing invalidates cached preview row counts after trimming a wide line", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(10);
|
||||
history.append("abcdefgh");
|
||||
assert.equal(history.getPreviewRowCount(4), 2);
|
||||
history.setViewportCols(3);
|
||||
assert.equal(history.getPreviewRowCount(4), 1);
|
||||
assert.deepEqual(history.getPreviewRows({ cols: 4, rows: 1, top: 0 }).rows,
|
||||
[{ text: "abc", isWrapped: false }]);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,84 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import type { Terminal } from "@xterm/xterm";
|
||||
import { createTerminalOutputHistoryPreview } from "./terminalOutputHistory.ts";
|
||||
|
||||
const { Terminal: XTerm } = createRequire(import.meta.url)("@xterm/xterm") as { Terminal: typeof Terminal };
|
||||
// Exercise the production resize listener without mounting the WebGL/UI runtime.
|
||||
// The terminal buffer and history collector are real; only unrelated effects
|
||||
// (atlas clearing and backend resize scheduling) are inert in this harness.
|
||||
const runtime = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
|
||||
const start = runtime.indexOf(" term.onResize(({ cols, rows }) => {");
|
||||
assert.notEqual(start, -1);
|
||||
const listenerSource = runtime.slice(start, runtime.indexOf("\n });", start) + "\n });".length);
|
||||
function createHarness(cols: number) {
|
||||
const term = new XTerm({ cols, rows: 24, allowProposedApi: true, convertEol: true });
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
new Function("term", "ctx", "clearWebglTextureAtlas", "resizeScheduler", listenerSource)(
|
||||
term, { terminalOutputHistory: history, sessionRef: { current: null } }, () => {}, { schedule() {} },
|
||||
);
|
||||
const write = async (data: string) => {
|
||||
history.setViewportRows(term.rows);
|
||||
history.setViewportCols(term.cols);
|
||||
history.append(data);
|
||||
await new Promise<void>((resolve) => term.write(data, resolve));
|
||||
};
|
||||
return { term, history, write };
|
||||
}
|
||||
|
||||
for (const alternate of [false, true]) test(`idle ${alternate ? "alternate" : "normal"}-screen resize updates history before any new output`, async () => {
|
||||
const { term, history, write } = createHarness(10);
|
||||
try {
|
||||
await write(`${alternate ? "\x1b[?1049h" : ""}abcdefgh`);
|
||||
history.getPreviewRows({ cols: 10, rows: 1, top: 0 });
|
||||
term.resize(5, 24);
|
||||
assert.equal(history.getPreviewRowCount(5), 1);
|
||||
assert.equal(history.getPreviewRows({ cols: 5, rows: 1, top: 0 }).rows[0].text,
|
||||
term.buffer.active.getLine(0)?.translateToString(true, 0, term.cols));
|
||||
term.resize(10, 24);
|
||||
assert.equal(history.getPreviewRows({ cols: 10, rows: 1, top: 0 }).rows[0].text,
|
||||
term.buffer.active.getLine(0)?.translateToString(true, 0, term.cols));
|
||||
} finally { term.dispose(); }
|
||||
});
|
||||
|
||||
test("resize uses xterm's reflowed cursor for later same-row redraws", async () => {
|
||||
const { term, history, write } = createHarness(5);
|
||||
try {
|
||||
await write("abcdef\n");
|
||||
term.resize(10, 24);
|
||||
assert.equal(term.buffer.active.cursorY, 1);
|
||||
await write("X\x1b[2;2HY");
|
||||
assert.deepEqual(history.getPreviewRows({ cols: 10, rows: 2, top: 0 }).rows.map(row => row.text),
|
||||
[term.buffer.active.getLine(0)?.translateToString(true), term.buffer.active.getLine(1)?.translateToString(true)]);
|
||||
} finally { term.dispose(); }
|
||||
});
|
||||
|
||||
for (const chunks of [["abc中X"], ["abc中XYZ"], ["abc中文X"], ["abc中文", "X"], ["abc中\x1b[5GZ"]]) {
|
||||
test(`no-autowrap final wide cell matches xterm: ${JSON.stringify(chunks)}`, async () => {
|
||||
const { term, history, write } = createHarness(5);
|
||||
try {
|
||||
await write("\x1b[?7l");
|
||||
for (const chunk of chunks) await write(chunk);
|
||||
assert.equal(history.getLines().at(-1), term.buffer.active.getLine(0)?.translateToString(true));
|
||||
} finally { term.dispose(); }
|
||||
});
|
||||
}
|
||||
|
||||
for (const [save, restore] of [["\x1b7", "\x1b8"], ["\x1b[s", "\x1b[u"]]) {
|
||||
test(`cursor restore also restores wrapping mode: ${JSON.stringify(save)}`, async () => {
|
||||
const { term, history, write } = createHarness(5);
|
||||
try {
|
||||
await write(`\x1b[?7l${save}\x1b[?7h${restore}abcdef`);
|
||||
assert.deepEqual(history.getLines(), [term.buffer.active.getLine(0)?.translateToString(true)]);
|
||||
} finally { term.dispose(); }
|
||||
});
|
||||
test(`cursor restore also restores origin mode: ${JSON.stringify(save)}`, async () => {
|
||||
const { term, history, write } = createHarness(5);
|
||||
try {
|
||||
await write(`\x1b[2;5r\x1b[?6h${save}\x1b[?6lA${restore}X\x1b[1;2HY`);
|
||||
assert.deepEqual(history.getLines(), [term.buffer.active.getLine(0)?.translateToString(true), term.buffer.active.getLine(1)?.translateToString(true)]);
|
||||
} finally { term.dispose(); }
|
||||
});
|
||||
}
|
||||
@@ -246,11 +246,6 @@ export const setTerminalOutputPressureVisibility = (
|
||||
getOrCreateState(term).background = !visible;
|
||||
};
|
||||
|
||||
/** True while the pane is hidden (recorded via {@link setTerminalOutputPressureVisibility}). */
|
||||
export const isTerminalOutputInBackground = (term: XTerm): boolean => (
|
||||
pressureStates.get(term)?.background ?? false
|
||||
);
|
||||
|
||||
export const setTerminalOutputPressureLargeOutput = (
|
||||
term: XTerm,
|
||||
largeOutput: boolean,
|
||||
|
||||
@@ -20,10 +20,7 @@ import {
|
||||
writeSessionData,
|
||||
} from "./terminalSessionAttachment.ts";
|
||||
import { getVisibleTerminalLineTimestampRows } from "./terminalLineTimestamps.ts";
|
||||
import {
|
||||
getTerminalOutputPressure,
|
||||
noteTerminalOutputPressureData,
|
||||
} from "./terminalOutputPressure.ts";
|
||||
import { noteTerminalOutputPressureData } from "./terminalOutputPressure.ts";
|
||||
|
||||
import {
|
||||
clearTerminalSessionFlowAck,
|
||||
@@ -1542,50 +1539,6 @@ test("writeSessionData keeps the hidden flush gate after coalescer reset and flu
|
||||
clearTerminalSessionFlowAck("session-1");
|
||||
});
|
||||
|
||||
test("writeSessionData refreshes pressure visibility when hidden output flushes after reveal", () => {
|
||||
clearTerminalSessionFlowAck("session-1");
|
||||
const payload = "x".repeat(XTERM_WRITE_CALLBACK_FAST_PATH_MAX_BYTES + 1);
|
||||
const writes: string[] = [];
|
||||
const term = {
|
||||
buffer: { active: { type: "normal" } },
|
||||
write(data: string, callback?: () => void) {
|
||||
writes.push(data);
|
||||
callback?.();
|
||||
},
|
||||
scrollToBottom() {},
|
||||
} as unknown as XTerm;
|
||||
const ctx = {
|
||||
...createContext(false),
|
||||
isVisibleRef: { current: false },
|
||||
sessionRef: { current: "session-1" },
|
||||
terminalBackend: {
|
||||
ackSessionFlow: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
getFlowController(ctx as never, term);
|
||||
resetTerminalWriteCoalescer(term);
|
||||
withAnimationFrameQueue(() => {
|
||||
withDocumentVisibility("visible", () => {
|
||||
writeSessionData(ctx as never, term, payload);
|
||||
});
|
||||
// The ingress snapshot records the pane as hidden.
|
||||
assert.equal(getTerminalOutputPressure(term).background, true);
|
||||
assert.deepEqual(writes, []);
|
||||
|
||||
ctx.isVisibleRef.current = true;
|
||||
withDocumentVisibility("visible", () => {
|
||||
flushPendingTerminalWritesOnResume(term);
|
||||
});
|
||||
// The batch is actually parsed after reveal, so the flush must clear the
|
||||
// stale hidden flag: bulk write callbacks (e.g. keyword highlight) gate
|
||||
// their in-frame viewport recolor on this state (#3272).
|
||||
assert.deepEqual(writes, [payload]);
|
||||
assert.equal(getTerminalOutputPressure(term).background, false);
|
||||
});
|
||||
clearTerminalSessionFlowAck("session-1");
|
||||
});
|
||||
|
||||
test("hidden tab output marks pending scroll without scrolling immediately", async () => {
|
||||
const writes: string[] = [];
|
||||
let scrollCalls = 0;
|
||||
|
||||
@@ -89,7 +89,6 @@ import {
|
||||
} from "./terminalOutputPipeline";
|
||||
import {
|
||||
hasPendingTerminalWrites,
|
||||
isTerminalPageHidden,
|
||||
maybeFlushTerminalWriteCoalescerWhenUnfocused,
|
||||
scheduleTerminalRepaintWhenUnfocused,
|
||||
shouldFlushTerminalWritesForBackgroundOutput,
|
||||
@@ -115,16 +114,6 @@ const isTerminalPaneVisible = (ctx: TerminalSessionStartersContext): boolean =>
|
||||
(ctx.isPaneVisibleRef?.current ?? ctx.isVisibleRef?.current) !== false
|
||||
);
|
||||
|
||||
/**
|
||||
* Visibility recorded for the output-pressure gates. A pane whose ref says
|
||||
* "visible" still cannot paint while the document is page-hidden, so record it
|
||||
* as background there too; otherwise bulk batches take the background flush
|
||||
* path but keyword-highlight still repainted the viewport per batch.
|
||||
*/
|
||||
const isTerminalOutputVisibleForPressure = (ctx: TerminalSessionStartersContext): boolean => (
|
||||
isTerminalPaneVisible(ctx) && !isTerminalPageHidden()
|
||||
);
|
||||
|
||||
const handleTerminalOutputAutoScroll = (
|
||||
ctx: TerminalSessionStartersContext,
|
||||
term: XTerm,
|
||||
@@ -482,7 +471,7 @@ export const writeSessionData = (
|
||||
visible: isPaneVisible,
|
||||
});
|
||||
flow.received(ingressBytes);
|
||||
setTerminalOutputPressureVisibility(term, isTerminalOutputVisibleForPressure(ctx));
|
||||
setTerminalOutputPressureVisibility(term, isPaneVisible);
|
||||
noteTerminalOutputPressureData(term, data);
|
||||
const settings = ctx.terminalSettingsRef?.current ?? ctx.terminalSettings;
|
||||
const preservePromptSourceChunks = Boolean(
|
||||
@@ -496,12 +485,6 @@ export const writeSessionData = (
|
||||
batchIngress: number,
|
||||
writeOptions?: CoalescedTerminalWriteOptions,
|
||||
): void => {
|
||||
// Output queued while hidden can be flushed after the pane is revealed.
|
||||
// Refresh the pressure visibility at flush time so the write callback
|
||||
// sees the live pane state instead of the ingress snapshot; otherwise a
|
||||
// bulk batch written after reveal stays on the fully deferred path and
|
||||
// the viewport sits uncolored until the quiet catch-up (#3272).
|
||||
setTerminalOutputPressureVisibility(term, isTerminalOutputVisibleForPressure(ctx));
|
||||
writeSessionDataImmediate(ctx, term, batch, batchIngress, {
|
||||
...writeOptions,
|
||||
deferStart: writeOptions?.deferStart ?? !isPaneCurrentlyVisible(),
|
||||
|
||||
@@ -126,12 +126,3 @@ test("falls back to raised maxSuggestions default when unset", () => {
|
||||
50,
|
||||
);
|
||||
});
|
||||
|
||||
test('unknown systems do not rewrite the command line while retaining suggestions', () => {
|
||||
for (const terminalSettings of [undefined, { autocompleteEnabled: true }]) {
|
||||
const settings = resolveTerminalAutocompleteSettings({protocol:'ssh', systemUnknown:true, terminalSettings});
|
||||
assert.equal(settings?.enabled, true);
|
||||
assert.equal(settings?.livePreview, false);
|
||||
assert.equal(settings?.allowLineReplacement, false);
|
||||
}
|
||||
});
|
||||
@@ -24,7 +24,7 @@ test("renderer activity follows the hibernate setting instead of active-tab visi
|
||||
);
|
||||
});
|
||||
|
||||
test("inactive terminal surfaces remain mounted and non-interactive without hibernate", () => {
|
||||
test("inactive terminal surfaces remain painted and non-interactive without hibernate", () => {
|
||||
assert.match(supportSource, /resolveTerminalHibernateEnabledForProtocol\(terminalSettings, host\.protocol\)/);
|
||||
assert.match(
|
||||
supportSource,
|
||||
|
||||
@@ -539,84 +539,3 @@ export function forceSyncRenderAfterResize(term: XTerm): void {
|
||||
logger.warn("Sync render after resize failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
type XTermWithPrivateViewport = XTerm & {
|
||||
_core?: {
|
||||
_viewport?: {
|
||||
scrollToLine?: (line: number, disableSmoothScroll?: boolean) => void;
|
||||
_sync?: () => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Re-align the DOM scroll position with the buffer's viewport row.
|
||||
*
|
||||
* xterm's reflow adjusts the buffer's viewport row (ydisp) during resize, but
|
||||
* the scrollable viewport keeps its stale pixel offset. Any subsequent
|
||||
* relative scroll (wheel, scrollToLine) then applies its delta twice — once
|
||||
* against the buffer and once against the stale DOM offset — drifting the
|
||||
* reading position (all the way to the top while shrinking, #3299). Snapping
|
||||
* the viewport back to the buffer row before a relative restore removes the
|
||||
* desync.
|
||||
*/
|
||||
export function alignTerminalViewportScroll(term: XTerm): void {
|
||||
const viewport = (term as XTermWithPrivateViewport)._core?._viewport;
|
||||
const scrollToLine = viewport?.scrollToLine;
|
||||
if (typeof scrollToLine !== "function") return;
|
||||
|
||||
// After a resize, xterm only refreshes the viewport's scroll dimensions on
|
||||
// its queued render callback. Setting a scroll position against the stale
|
||||
// dimensions gets clamped to the old maximum while xterm records the
|
||||
// requested row, so the queued sync then assumes the position was already
|
||||
// applied and the DOM offset stays stale — the next wheel scroll jumps
|
||||
// upward by the resize delta. Sync the dimensions now, before positioning.
|
||||
// If synchronized output (DECSET 2026) is active, _sync() above is a no-op
|
||||
// that merely defers DOM scroll updates until the mode ends; positioning
|
||||
// here would still record the requested row as _latestYDisp against the
|
||||
// stale dimensions, and the deferred sync would then see
|
||||
// ydisp === _latestYDisp and skip repositioning, leaving a stale DOM
|
||||
// offset. Leave positioning to that deferred sync instead: after reflow the
|
||||
// buffer's ydisp differs from the recorded _latestYDisp, so it repositions
|
||||
// with fresh dimensions on its own.
|
||||
if (typeof viewport._sync === "function") {
|
||||
try {
|
||||
viewport._sync.call(viewport);
|
||||
} catch (err) {
|
||||
logger.warn("Sync viewport dimensions after resize failed", err);
|
||||
}
|
||||
}
|
||||
if (term.modes?.synchronizedOutputMode) return;
|
||||
|
||||
try {
|
||||
scrollToLine.call(viewport, term.buffer.active.viewportY, true);
|
||||
} catch (err) {
|
||||
logger.warn("Align viewport scroll after resize failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Defer the whole fit while xterm keeps the visible frame frozen (DECSET 2026). */
|
||||
export function createSynchronizedOutputFitScheduler() {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const dispose = () => {
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
timer = undefined;
|
||||
};
|
||||
return {
|
||||
dispose,
|
||||
defer(term: XTerm, fit: () => void): boolean {
|
||||
dispose();
|
||||
if (!term.modes.synchronizedOutputMode) return false;
|
||||
// Arms xterm's own synchronized-output timeout even if the program
|
||||
// only enabled the mode without writing visible content afterward.
|
||||
term.refresh(0, Math.max(0, term.rows - 1));
|
||||
timer = setTimeout(() => {
|
||||
timer = undefined;
|
||||
// The caller re-enters safeFit and checks the mode again, including
|
||||
// when another synchronized frame started before this retry.
|
||||
fit();
|
||||
}, 32);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -12,20 +12,10 @@ const scheduleFrame = (): Promise<void> => new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
|
||||
// The highlighter snapshots this flag synchronously when term.write enters.
|
||||
// Replay has no PTY pressure samples, so it must not look like live log output.
|
||||
const replayWrites = new WeakSet<XTerm>();
|
||||
export const isTerminalReplayWrite = (term: XTerm): boolean => replayWrites.has(term);
|
||||
|
||||
const writeChunk = (term: XTerm, data: string): Promise<void> => {
|
||||
if (!data) return Promise.resolve();
|
||||
return new Promise((resolve) => {
|
||||
replayWrites.add(term);
|
||||
try {
|
||||
term.write(data, () => resolve());
|
||||
} finally {
|
||||
replayWrites.delete(term);
|
||||
}
|
||||
term.write(data, () => resolve());
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ test('tab-switch suppression does not consume the visible recovery pass', () =>
|
||||
assert.match(source, /const becameVisible = isVisible && !wasVisibleRef\.current/);
|
||||
assert.match(
|
||||
source,
|
||||
/if \(!isVisible\) \{\s*wasVisibleRef\.current = false;\s*syncOutputPressureVisibility\(\);\s*return;\s*\}[\s\S]*if \(splitResizeActive\) return;[\s\S]*wasVisibleRef\.current = true;[\s\S]*recoverTerminalAfterBecomeVisible\(\)/,
|
||||
/if \(!isVisible\) \{\s*wasVisibleRef\.current = false;\s*return;\s*\}[\s\S]*if \(splitResizeActive\) return;[\s\S]*wasVisibleRef\.current = true;[\s\S]*recoverTerminalAfterBecomeVisible\(\)/,
|
||||
);
|
||||
assert.doesNotMatch(source, /wasVisibleRef\.current = isVisible;\s*if \(!isVisible \|\| isResizing\) return/);
|
||||
});
|
||||
|
||||
@@ -41,10 +41,8 @@ import {
|
||||
flushPendingTerminalWritesBeforeHibernate,
|
||||
flushPendingTerminalWritesOnResume,
|
||||
forceTerminalRepaintBypassingAnimationFrame,
|
||||
isTerminalPageHidden,
|
||||
repaintTerminalAfterReveal,
|
||||
} from './runtime/terminalUnfocusedRepaint';
|
||||
import { setTerminalOutputPressureVisibility } from './runtime/terminalOutputPressure';
|
||||
import {
|
||||
forceXTermFontRemeasure,
|
||||
type XTermFontRemeasureTarget,
|
||||
@@ -1276,27 +1274,10 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
pendingOutputScrollRef.current = false;
|
||||
};
|
||||
|
||||
// The output-pressure visibility flag is read live by the keyword
|
||||
// highlighter's write callback, but the write path only refreshes it at
|
||||
// ingress and coalescer flush. A batch dispatched while hidden can still be
|
||||
// parsing when the pane/page is revealed, and a stale `background: true`
|
||||
// would then skip the in-frame viewport recolor (#3272). Re-sync the flag
|
||||
// on every visibility transition so in-flight writes always see the
|
||||
// current pane/document state.
|
||||
const syncOutputPressureVisibility = () => {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
setTerminalOutputPressureVisibility(
|
||||
term,
|
||||
isVisibleRef.current && !isTerminalPageHidden(),
|
||||
);
|
||||
};
|
||||
|
||||
const flushTerminalWritesAfterBecomeVisible = () => {
|
||||
lastCommittedVisibleLayoutKeyRef.current = null;
|
||||
const term = termRef.current;
|
||||
if (term) {
|
||||
syncOutputPressureVisibility();
|
||||
cancelScheduledUnfocusedRepaint(term);
|
||||
flushPendingTerminalWritesOnResume(term);
|
||||
repaintTerminalAfterReveal(term, () => isVisibleRef.current);
|
||||
@@ -1346,7 +1327,6 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
|
||||
if (!isVisible) {
|
||||
wasVisibleRef.current = false;
|
||||
syncOutputPressureVisibility();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1864,7 +1844,6 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
const recoverTerminalOnAppResume = () => {
|
||||
const term = termRef.current;
|
||||
if (term) {
|
||||
syncOutputPressureVisibility();
|
||||
cancelScheduledUnfocusedRepaint(term);
|
||||
flushPendingTerminalWritesOnResume(term);
|
||||
forceTerminalRepaintBypassingAnimationFrame(term);
|
||||
@@ -1875,10 +1854,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState !== 'visible') {
|
||||
syncOutputPressureVisibility();
|
||||
return;
|
||||
}
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
recoverTerminalOnAppResume();
|
||||
};
|
||||
|
||||
|
||||
@@ -710,10 +710,7 @@ export interface TerminalLayerProps {
|
||||
onCreateLocalTerminal?: () => void;
|
||||
// Broadcast mode
|
||||
isBroadcastEnabled?: (workspaceId: string) => boolean;
|
||||
isGlobalBroadcastEnabled?: boolean;
|
||||
canUseGlobalBroadcast?: boolean;
|
||||
onToggleBroadcast?: (workspaceId: string) => void;
|
||||
onToggleGlobalBroadcast?: () => void;
|
||||
// SFTP side panel
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
updateSnippets?: (snippets: Snippet[]) => void;
|
||||
@@ -813,9 +810,6 @@ interface TerminalPaneProps {
|
||||
onSetWorkspaceFocusedSession?: (workspaceId: string, sessionId: string) => void;
|
||||
onSplitSession?: (sessionId: string, direction: SplitDirection) => void;
|
||||
isBroadcastEnabled?: (workspaceId: string) => boolean;
|
||||
isGlobalBroadcastEnabled?: boolean;
|
||||
canUseGlobalBroadcast?: boolean;
|
||||
onToggleGlobalBroadcast?: () => void;
|
||||
onBroadcastInput: (
|
||||
data: string,
|
||||
sourceSessionId: string,
|
||||
@@ -941,9 +935,6 @@ const terminalPanePropsAreEqual = (
|
||||
prev.onSetWorkspaceFocusedSession === next.onSetWorkspaceFocusedSession &&
|
||||
prev.onSplitSession === next.onSplitSession &&
|
||||
prev.isBroadcastEnabled === next.isBroadcastEnabled &&
|
||||
prev.isGlobalBroadcastEnabled === next.isGlobalBroadcastEnabled &&
|
||||
prev.canUseGlobalBroadcast === next.canUseGlobalBroadcast &&
|
||||
prev.onToggleGlobalBroadcast === next.onToggleGlobalBroadcast &&
|
||||
prev.onBroadcastInput === next.onBroadcastInput &&
|
||||
prev.onBroadcastInterruptPriorityChange === next.onBroadcastInterruptPriorityChange &&
|
||||
prev.onToggleWorkspaceComposeBar === next.onToggleWorkspaceComposeBar &&
|
||||
@@ -1242,9 +1233,6 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
||||
onSetWorkspaceFocusedSession,
|
||||
onSplitSession,
|
||||
isBroadcastEnabled,
|
||||
isGlobalBroadcastEnabled,
|
||||
canUseGlobalBroadcast,
|
||||
onToggleGlobalBroadcast,
|
||||
onBroadcastInput,
|
||||
onBroadcastInterruptPriorityChange,
|
||||
onToggleWorkspaceComposeBar,
|
||||
@@ -1471,9 +1459,7 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
||||
: undefined;
|
||||
const splitHorizontalHandler = splitHorizontalHandlersRef.current.get(session.id);
|
||||
const splitVerticalHandler = splitVerticalHandlersRef.current.get(session.id);
|
||||
const broadcastEnabled = activeWorkspaceId
|
||||
? !!isBroadcastEnabled?.(activeWorkspaceId)
|
||||
: (isGlobalBroadcastEnabled ?? false);
|
||||
const broadcastEnabled = activeWorkspaceId ? !!isBroadcastEnabled?.(activeWorkspaceId) : false;
|
||||
const isHostEphemeral = !isSavedVaultHost(hostMap.get(host.id));
|
||||
const sessionAppearance = useMemo(
|
||||
() => resolveSessionAppearance({ host, isEphemeral: isHostEphemeral }),
|
||||
@@ -1619,11 +1605,7 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
||||
onSplitHorizontal={onSplitSession ? splitHorizontalHandler : undefined}
|
||||
onSplitVertical={onSplitSession ? splitVerticalHandler : undefined}
|
||||
isBroadcastEnabled={broadcastEnabled}
|
||||
onToggleBroadcast={
|
||||
inActiveWorkspace
|
||||
? workspaceBroadcastHandler
|
||||
: (canUseGlobalBroadcast ? onToggleGlobalBroadcast : undefined)
|
||||
}
|
||||
onToggleBroadcast={inActiveWorkspace ? workspaceBroadcastHandler : undefined}
|
||||
onToggleComposeBar={inActiveWorkspace ? onToggleWorkspaceComposeBar : undefined}
|
||||
isWorkspaceComposeBarOpen={inActiveWorkspace ? isComposeBarOpen : undefined}
|
||||
onBroadcastInput={broadcastEnabled ? onBroadcastInput : undefined}
|
||||
@@ -1715,9 +1697,6 @@ interface TerminalPanesHostProps {
|
||||
onSetWorkspaceFocusedSession?: (workspaceId: string, sessionId: string) => void;
|
||||
onSplitSession?: (sessionId: string, direction: SplitDirection) => void;
|
||||
isBroadcastEnabled?: (workspaceId: string) => boolean;
|
||||
isGlobalBroadcastEnabled?: boolean;
|
||||
canUseGlobalBroadcast?: boolean;
|
||||
onToggleGlobalBroadcast?: () => void;
|
||||
onBroadcastInput: (
|
||||
data: string,
|
||||
sourceSessionId: string,
|
||||
@@ -1805,9 +1784,6 @@ 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.isGlobalBroadcastEnabled !== next.isGlobalBroadcastEnabled) return false;
|
||||
if (prev.canUseGlobalBroadcast !== next.canUseGlobalBroadcast) return false;
|
||||
if (prev.onToggleGlobalBroadcast !== next.onToggleGlobalBroadcast) return false;
|
||||
if (prev.onBroadcastInput !== next.onBroadcastInput) return false;
|
||||
if (prev.onBroadcastInterruptPriorityChange !== next.onBroadcastInterruptPriorityChange) return false;
|
||||
if (prev.onToggleWorkspaceComposeBar !== next.onToggleWorkspaceComposeBar) return false;
|
||||
|
||||
@@ -635,9 +635,6 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
validAIScopeTargetIds: s.validAIScopeTargetIds,
|
||||
workspaceBroadcastHandlersRef: s.workspaceBroadcastHandlersRef,
|
||||
workspaceById,
|
||||
isGlobalBroadcastEnabled: s.isGlobalBroadcastEnabled,
|
||||
canUseGlobalBroadcast: s.canUseGlobalBroadcast,
|
||||
onToggleGlobalBroadcast: s.onToggleGlobalBroadcastRef.current,
|
||||
// AI scope maintenance (merge/dissolve handoff) needs the full list; do not
|
||||
// rely on workspaceById alone — SidePanelStateRoot reads ctx.workspaces.
|
||||
workspaces: s.workspaces,
|
||||
@@ -694,9 +691,6 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
s.terminalTheme,
|
||||
s.resolveSessionAppearance,
|
||||
s.hostMap,
|
||||
s.isGlobalBroadcastEnabled,
|
||||
s.canUseGlobalBroadcast,
|
||||
s.onToggleGlobalBroadcastRef,
|
||||
]);
|
||||
|
||||
return <TerminalLayerView ctx={ctx} />;
|
||||
|
||||
@@ -123,9 +123,6 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
|
||||
onReorderTabs,
|
||||
onStartSessionDrag,
|
||||
onEndSessionDrag,
|
||||
isGlobalBroadcastEnabled,
|
||||
canUseGlobalBroadcast,
|
||||
onToggleGlobalBroadcast,
|
||||
t,
|
||||
} = ctx;
|
||||
|
||||
@@ -258,9 +255,6 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
|
||||
onReorderTabs={onReorderTabs}
|
||||
onStartSessionDrag={onStartSessionDrag}
|
||||
onEndSessionDrag={onEndSessionDrag}
|
||||
isGlobalBroadcastEnabled={isGlobalBroadcastEnabled}
|
||||
canUseGlobalBroadcast={canUseGlobalBroadcast}
|
||||
onToggleGlobalBroadcast={onToggleGlobalBroadcast}
|
||||
/>
|
||||
{!isFocusMode && activeResizers.map((handle: any) => {
|
||||
const isVertical = handle.direction === 'vertical';
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React, { memo, useCallback, useRef, useSyncExternalStore } from 'react';
|
||||
import React, { memo, useCallback, useSyncExternalStore } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { activeTabStore } from '../../application/state/activeTabStore';
|
||||
import { getSftpCurrentPathMemoryKey } from '../../application/state/sftp/sftpReopenLocation';
|
||||
import {
|
||||
getSidePanelLiveSnapshot,
|
||||
SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT,
|
||||
subscribeSidePanelLiveSnapshot,
|
||||
} from '../../application/state/sidePanelLiveStore';
|
||||
import {
|
||||
@@ -50,18 +49,10 @@ const subscribeShellHistoryNoop = () => () => {};
|
||||
const getEmptyShellHistorySnapshot = () => EMPTY_SHELL_HISTORY;
|
||||
|
||||
function useSidePanelLiveSnapshotForTab(tabId: string, subscribe: boolean) {
|
||||
const retainedSnapshot = useRef({ tabId, snapshot: SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT });
|
||||
const getSnapshot = useCallback(() => {
|
||||
if (!subscribe) return SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT;
|
||||
const snapshot = getSidePanelLiveSnapshot(true);
|
||||
const snapshotTabId = snapshot.activeWorkspace?.id ?? snapshot.focusedSessionId;
|
||||
// Tab visibility changes before the live publisher commits its next snapshot.
|
||||
// Keep this panel's last context until its own terminal/workspace catches up.
|
||||
if (snapshotTabId === tabId) retainedSnapshot.current = { tabId, snapshot };
|
||||
return retainedSnapshot.current.tabId === tabId
|
||||
? retainedSnapshot.current.snapshot
|
||||
: SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT;
|
||||
}, [subscribe, tabId]);
|
||||
const getSnapshot = useCallback(
|
||||
() => getSidePanelLiveSnapshot(subscribe),
|
||||
[subscribe],
|
||||
);
|
||||
return useSyncExternalStore(
|
||||
(listener) => subscribeSidePanelLiveSnapshot(subscribe, listener),
|
||||
getSnapshot,
|
||||
|
||||
Loaded 100 of 189 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user