mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-09 15:52:14 +00:00
Compare commits
136
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39d4fcfee6 | ||
|
|
fd0792d83d | ||
|
|
fdb948267d | ||
|
|
cabdcdf31c | ||
|
|
e2a9e54a90 | ||
|
|
c9d22b3b4d | ||
|
|
2dea078c6d | ||
|
|
446e82180b | ||
|
|
2eaddf0baf | ||
|
|
7b964ead21 | ||
|
|
0ea8fa4943 | ||
|
|
7228af73bf | ||
|
|
3626601eef | ||
|
|
81ec019892 | ||
|
|
1151b31a42 | ||
|
|
20b12a861c | ||
|
|
f70f6e4a5f | ||
|
|
a789ec1b9d | ||
|
|
2d9890a92e | ||
|
|
1ef7b026a6 | ||
|
|
f9eded887f | ||
|
|
701e35dac0 | ||
|
|
09cb03c88c | ||
|
|
5c5fa4f27e | ||
|
|
c91a57f315 | ||
|
|
81c3feba0b | ||
|
|
1be02e5c01 | ||
|
|
41a35824aa | ||
|
|
438a5f0652 | ||
|
|
16b2a20220 | ||
|
|
2f4726c8ce | ||
|
|
c9b40d98c2 | ||
|
|
9fca48a927 | ||
|
|
3411d7f08b | ||
|
|
82e62c8c1d | ||
|
|
48780fcd09 | ||
|
|
116e70a206 | ||
|
|
56e593898c | ||
|
|
636f6c29ab | ||
|
|
d2e584f00d | ||
|
|
0b8df00e31 | ||
|
|
dd9577ce75 | ||
|
|
8becc81e7a | ||
|
|
6b6b37c7a1 | ||
|
|
d62dfac977 | ||
|
|
cfbda2a23f | ||
|
|
d2ed328907 | ||
|
|
52ec1edab8 | ||
|
|
f3f89edad5 | ||
|
|
3e03f439ec | ||
|
|
5a05da0b4a | ||
|
|
efaaaebd29 | ||
|
|
969da6aa88 | ||
|
|
8bf63ed89e | ||
|
|
2308d133c2 | ||
|
|
d607c81b5b | ||
|
|
4fc97ce96e | ||
|
|
f828e0d479 | ||
|
|
3162c2c375 | ||
|
|
e7b59ad768 | ||
|
|
1de1221707 | ||
|
|
b285200798 | ||
|
|
02f5729ab7 | ||
|
|
74c1cd063b | ||
|
|
044a95b831 | ||
|
|
8e05923ca2 | ||
|
|
73ed3e02ed | ||
|
|
c0a46cc926 | ||
|
|
b2c72d607b | ||
|
|
b83f3ee17a | ||
|
|
689fcfd4e4 | ||
|
|
0fb15b99c5 | ||
|
|
c6c769acc3 | ||
|
|
0b8ba35da7 | ||
|
|
47bef3c9d9 | ||
|
|
2faa30d79b | ||
|
|
d19ddce4ff | ||
|
|
ae26219fdd | ||
|
|
35076fea5d | ||
|
|
192c7043c1 | ||
|
|
2ff47eafce | ||
|
|
2538da9891 | ||
|
|
7440e66ea8 | ||
|
|
e84ef25720 | ||
|
|
56deb86151 | ||
|
|
2189f6c820 | ||
|
|
7e3960f561 | ||
|
|
acb29e0791 | ||
|
|
a6683e23ab | ||
|
|
d350f2746a | ||
|
|
89dab1394d | ||
|
|
0326881d85 | ||
|
|
309fe73492 | ||
|
|
cc0fc9ee19 | ||
|
|
c628cb233c | ||
|
|
5f3a1eaed6 | ||
|
|
5d10f09a0a | ||
|
|
4abbdd9e48 | ||
|
|
6ff65334d4 | ||
|
|
2c23f445ac | ||
|
|
e3f56204b2 | ||
|
|
dcc3e0718f | ||
|
|
3936d8b7f3 | ||
|
|
b0481e7da0 | ||
|
|
e911792287 | ||
|
|
1025d93ae3 | ||
|
|
3b6f185866 | ||
|
|
8d4fde0340 | ||
|
|
9c61e46635 | ||
|
|
f4987550f1 | ||
|
|
6ad422b1ea | ||
|
|
24b8d8e05d | ||
|
|
2487e954ee | ||
|
|
f66c098c9b | ||
|
|
8332aa2dbd | ||
|
|
908cc687cb | ||
|
|
47667e80f5 | ||
|
|
da8b13471f | ||
|
|
2d6183c440 | ||
|
|
a352ca3836 | ||
|
|
4fad9b8dcf | ||
|
|
2569c6c3c4 | ||
|
|
0ecd06b2f6 | ||
|
|
1f52f6f399 | ||
|
|
606c53d7fa | ||
|
|
0556e493f8 | ||
|
|
4435f763bd | ||
|
|
5ae1df350d | ||
|
|
74b77ddec1 | ||
|
|
2084ede4db | ||
|
|
ae59c32888 | ||
|
|
1f75c20446 | ||
|
|
7062dcb4a4 | ||
|
|
0a3d001f47 | ||
|
|
26b9027d41 | ||
|
|
25b79a6947 |
No files matched your search
@@ -695,6 +695,11 @@ 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,6 +52,9 @@ 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,6 +315,30 @@ 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, 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 { 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 shortcutTabs = buildNumberShortcutTabTargets({
|
||||
showSftpTab: settings.showSftpTab ?? true,
|
||||
@@ -916,11 +916,16 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
toggleSidePanelRef.current?.();
|
||||
break;
|
||||
case 'broadcast': {
|
||||
// Toggle broadcast mode for the active workspace
|
||||
// Workspace tabs keep their local broadcast state; orphan tabs share
|
||||
// the global state when at least two visible orphan tabs exist.
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
|
||||
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isGlobalBroadcastEnabled, onToggleGlobalBroadcast, canUseGlobalBroadcast, 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,6 +737,9 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onCreateLocalTerminal={handleCreateLocalTerminal}
|
||||
isBroadcastEnabled={isBroadcastEnabled}
|
||||
onToggleBroadcast={toggleBroadcast}
|
||||
isGlobalBroadcastEnabled={isGlobalBroadcastEnabled}
|
||||
onToggleGlobalBroadcast={onToggleGlobalBroadcast}
|
||||
canUseGlobalBroadcast={canUseGlobalBroadcast}
|
||||
updateHosts={updateTerminalHosts}
|
||||
updateSnippets={updateSnippets}
|
||||
updateSnippetPackages={updateSnippetPackages}
|
||||
|
||||
@@ -207,6 +207,8 @@ 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,6 +240,8 @@ 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,
|
||||
|
||||
@@ -70,6 +70,9 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
reorderTabs,
|
||||
toggleBroadcast,
|
||||
isBroadcastEnabled,
|
||||
toggleGlobalBroadcast,
|
||||
isGlobalBroadcastEnabled,
|
||||
canUseGlobalBroadcast,
|
||||
copySession,
|
||||
copyWorkspace,
|
||||
createSessionFromCloneSource,
|
||||
@@ -147,6 +150,9 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
reorderTabs,
|
||||
toggleBroadcast,
|
||||
isBroadcastEnabled,
|
||||
toggleGlobalBroadcast,
|
||||
isGlobalBroadcastEnabled,
|
||||
canUseGlobalBroadcast,
|
||||
copySession,
|
||||
copyWorkspace,
|
||||
createSessionFromCloneSource,
|
||||
@@ -175,8 +181,10 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
getOrderedWorkTabs,
|
||||
getSessionRestoreCwd,
|
||||
isBroadcastEnabled,
|
||||
isGlobalBroadcastEnabled,
|
||||
moveFocusInWorkspace,
|
||||
openLogView,
|
||||
canUseGlobalBroadcast,
|
||||
removeSessionFromWorkspace,
|
||||
renameSessionInline,
|
||||
reorderTabs,
|
||||
@@ -193,6 +201,7 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
submitSessionRename,
|
||||
submitWorkspaceRename,
|
||||
toggleBroadcast,
|
||||
toggleGlobalBroadcast,
|
||||
toggleWorkspaceViewMode,
|
||||
updateSessionCodingCliProvider,
|
||||
updateSessionDynamicTitle,
|
||||
|
||||
@@ -1082,6 +1082,7 @@ Highlight the focused split pane:
|
||||
// Vault hosts header/actions
|
||||
'vault.hosts.search.placeholder': 'Find a host or ssh user@hostname / ssh -p 2222 user@hostname...',
|
||||
'vault.hosts.connect': 'Connect',
|
||||
'vault.hosts.openSftp': 'Open SFTP',
|
||||
'vault.view.grid': 'Grid',
|
||||
'vault.view.list': 'List',
|
||||
'vault.view.tree': 'Tree',
|
||||
|
||||
@@ -273,6 +273,12 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.bookmark.addGlobal': '+Global',
|
||||
'sftp.bookmark.addGlobalTooltip': 'Save as global bookmark (shared across all hosts)',
|
||||
'sftp.bookmark.empty': 'No bookmarks yet',
|
||||
'sftp.bookmark.removeConfirm': 'Remove bookmark {path}?',
|
||||
'sftp.bookmark.manage': 'Manage',
|
||||
'sftp.bookmark.rename': 'Rename bookmark',
|
||||
'sftp.bookmark.done': 'Done',
|
||||
'sftp.bookmark.moveUp': 'Move {label} up',
|
||||
'sftp.bookmark.moveDown': 'Move {label} down',
|
||||
'sftp.columns.name': 'Name',
|
||||
'sftp.columns.modified': 'Modified',
|
||||
'sftp.columns.size': 'Size',
|
||||
@@ -313,6 +319,10 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.viewMode.tree': 'Tree view',
|
||||
'sftp.viewMode.switchToList': 'Switch to list view',
|
||||
'sftp.viewMode.switchToTree': 'Switch to tree view',
|
||||
'sftp.listDensity.compact': 'Compact list',
|
||||
'sftp.listDensity.comfortable': 'Comfortable list',
|
||||
'sftp.listDensity.switchToCompact': 'Switch to compact list',
|
||||
'sftp.listDensity.switchToComfortable': 'Switch to comfortable list',
|
||||
'sftp.tree.loadError': 'Failed to load directory',
|
||||
'sftp.tree.loading': 'Loading...',
|
||||
'sftp.kind.folder': 'Folder',
|
||||
@@ -700,6 +710,11 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.section.address': 'Address',
|
||||
'hostDetails.hostname.placeholder': 'IP or Hostname',
|
||||
'hostDetails.section.general': 'General',
|
||||
'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',
|
||||
'hostDetails.section.sftp': 'SFTP Settings',
|
||||
'hostDetails.sftp.fileProtocol': 'File Protocol',
|
||||
'hostDetails.sftp.fileProtocol.desc': 'Auto tries SFTP, then falls back to SCP mode when the SFTP subsystem is unavailable (e.g. some NAS). Force SCP for hosts that only support SCP.',
|
||||
|
||||
@@ -1040,6 +1040,7 @@ Resalta el panel dividido enfocado:
|
||||
// Vault hosts header/actions
|
||||
'vault.hosts.search.placeholder': 'Busca un host o escribe ssh usuario@hostname / ssh -p 2222 usuario@hostname...',
|
||||
'vault.hosts.connect': 'Conectar',
|
||||
'vault.hosts.openSftp': 'Abrir SFTP',
|
||||
'vault.view.grid': 'Cuadrícula',
|
||||
'vault.view.list': 'Lista',
|
||||
'vault.view.tree': 'Árbol',
|
||||
|
||||
@@ -273,6 +273,12 @@ export const esVaultMessages: Messages = {
|
||||
'sftp.bookmark.addGlobal': '+Global',
|
||||
'sftp.bookmark.addGlobalTooltip': 'Guardar como marcador global (compartido entre todos los hosts)',
|
||||
'sftp.bookmark.empty': 'Aún no hay marcadores',
|
||||
'sftp.bookmark.removeConfirm': '¿Quitar el marcador {path}?',
|
||||
'sftp.bookmark.manage': 'Administrar',
|
||||
'sftp.bookmark.rename': 'Renombrar marcador',
|
||||
'sftp.bookmark.done': 'Listo',
|
||||
'sftp.bookmark.moveUp': 'Mover {label} hacia arriba',
|
||||
'sftp.bookmark.moveDown': 'Mover {label} hacia abajo',
|
||||
'sftp.columns.name': 'Nombre',
|
||||
'sftp.columns.modified': 'Modificado',
|
||||
'sftp.columns.size': 'Tamaño',
|
||||
@@ -313,6 +319,10 @@ export const esVaultMessages: Messages = {
|
||||
'sftp.viewMode.tree': 'Vista de árbol',
|
||||
'sftp.viewMode.switchToList': 'Cambiar a vista de lista',
|
||||
'sftp.viewMode.switchToTree': 'Cambiar a vista de árbol',
|
||||
'sftp.listDensity.compact': 'Lista compacta',
|
||||
'sftp.listDensity.comfortable': 'Lista cómoda',
|
||||
'sftp.listDensity.switchToCompact': 'Cambiar a lista compacta',
|
||||
'sftp.listDensity.switchToComfortable': 'Cambiar a lista cómoda',
|
||||
'sftp.tree.loadError': 'No se pudo cargar el directorio',
|
||||
'sftp.tree.loading': 'Cargando...',
|
||||
'sftp.kind.folder': 'Carpeta',
|
||||
@@ -700,6 +710,11 @@ export const esVaultMessages: Messages = {
|
||||
'hostDetails.section.address': 'Dirección',
|
||||
'hostDetails.hostname.placeholder': 'IP o nombre de host',
|
||||
'hostDetails.section.general': 'General',
|
||||
'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',
|
||||
'hostDetails.section.sftp': 'Configuración de SFTP',
|
||||
'hostDetails.sftp.fileProtocol': 'Protocolo de archivos',
|
||||
'hostDetails.sftp.fileProtocol.desc': 'Auto intenta SFTP y luego recurre al modo SCP cuando el subsistema SFTP no está disponible (p. ej., algunos NAS). Fuerza SCP para hosts que solo admiten SCP.',
|
||||
|
||||
@@ -95,6 +95,7 @@ export const ruVaultMessages: Messages = {
|
||||
// Vault hosts header/actions
|
||||
'vault.hosts.search.placeholder': 'Найти хост или ssh user@hostname / ssh -p 2222 user@hostname...',
|
||||
'vault.hosts.connect': 'Подключиться',
|
||||
'vault.hosts.openSftp': 'Открыть SFTP',
|
||||
'vault.view.grid': 'Сетка',
|
||||
'vault.view.list': 'Список',
|
||||
'vault.view.tree': 'Дерево',
|
||||
@@ -310,6 +311,12 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.bookmark.addGlobal': '+Глобальная',
|
||||
'sftp.bookmark.addGlobalTooltip': 'Сохранить как глобальную закладку (общую для всех хостов)',
|
||||
'sftp.bookmark.empty': 'Пока нет закладок',
|
||||
'sftp.bookmark.removeConfirm': 'Удалить закладку {path}?',
|
||||
'sftp.bookmark.manage': 'Управление',
|
||||
'sftp.bookmark.rename': 'Переименовать закладку',
|
||||
'sftp.bookmark.done': 'Готово',
|
||||
'sftp.bookmark.moveUp': 'Переместить {label} вверх',
|
||||
'sftp.bookmark.moveDown': 'Переместить {label} вниз',
|
||||
'sftp.columns.name': 'Имя',
|
||||
'sftp.columns.modified': 'Изменён',
|
||||
'sftp.columns.size': 'Размер',
|
||||
@@ -350,6 +357,10 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.viewMode.tree': 'Дерево',
|
||||
'sftp.viewMode.switchToList': 'Переключиться на список',
|
||||
'sftp.viewMode.switchToTree': 'Переключиться на дерево',
|
||||
'sftp.listDensity.compact': 'Компактный список',
|
||||
'sftp.listDensity.comfortable': 'Обычный список',
|
||||
'sftp.listDensity.switchToCompact': 'Переключиться на компактный список',
|
||||
'sftp.listDensity.switchToComfortable': 'Переключиться на обычный список',
|
||||
'sftp.tree.loadError': 'Не удалось загрузить каталог',
|
||||
'sftp.tree.loading': 'Загрузка...',
|
||||
'sftp.kind.folder': 'Папка',
|
||||
@@ -736,6 +747,11 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.section.address': 'Адрес',
|
||||
'hostDetails.hostname.placeholder': 'IP или имя хоста',
|
||||
'hostDetails.section.general': 'Общие',
|
||||
'hostDetails.os.title': 'Операционная система',
|
||||
'hostDetails.os.desc': 'Используется для сведений о среде и адаптации командной оболочки.',
|
||||
'hostDetails.os.linux': 'Linux',
|
||||
'hostDetails.os.windows': 'Windows',
|
||||
'hostDetails.os.macos': 'macOS',
|
||||
'hostDetails.section.sftp': 'Настройки SFTP',
|
||||
'hostDetails.sftp.fileProtocol': 'Протокол файлов',
|
||||
'hostDetails.sftp.fileProtocol.desc': 'Авто сначала пробует SFTP, затем SCP-режим, если подсистема SFTP недоступна. Принудительный SCP — для хостов только с SCP.',
|
||||
|
||||
@@ -718,6 +718,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
// Vault hosts header/actions
|
||||
'vault.hosts.search.placeholder': '查找主机或 ssh user@hostname / ssh -p 2222 user@hostname…',
|
||||
'vault.hosts.connect': '连接',
|
||||
'vault.hosts.openSftp': '打开 SFTP',
|
||||
'vault.view.grid': '网格',
|
||||
'vault.view.list': '列表',
|
||||
'vault.view.tree': '树形',
|
||||
@@ -903,6 +904,12 @@ export const zhCNCoreMessages: Messages = {
|
||||
'sftp.bookmark.addGlobal': '+全局',
|
||||
'sftp.bookmark.addGlobalTooltip': '保存为全局收藏(所有主机共享)',
|
||||
'sftp.bookmark.empty': '暂无收藏路径',
|
||||
'sftp.bookmark.removeConfirm': '删除收藏 {path}?',
|
||||
'sftp.bookmark.manage': '管理',
|
||||
'sftp.bookmark.rename': '重命名收藏',
|
||||
'sftp.bookmark.done': '完成',
|
||||
'sftp.bookmark.moveUp': '上移 {label}',
|
||||
'sftp.bookmark.moveDown': '下移 {label}',
|
||||
'sftp.columns.name': '名称',
|
||||
'sftp.columns.configure': '选择显示的列',
|
||||
'sftp.columns.modified': '修改时间',
|
||||
@@ -943,6 +950,10 @@ export const zhCNCoreMessages: Messages = {
|
||||
'sftp.viewMode.tree': '树形视图',
|
||||
'sftp.viewMode.switchToList': '切换到列表视图',
|
||||
'sftp.viewMode.switchToTree': '切换到树形视图',
|
||||
'sftp.listDensity.compact': '紧凑列表',
|
||||
'sftp.listDensity.comfortable': '宽松列表',
|
||||
'sftp.listDensity.switchToCompact': '切换到紧凑列表',
|
||||
'sftp.listDensity.switchToComfortable': '切换到宽松列表',
|
||||
'sftp.tree.loadError': '加载目录失败',
|
||||
'sftp.tree.loading': '加载中...',
|
||||
'sftp.kind.folder': '文件夹',
|
||||
|
||||
@@ -133,6 +133,11 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.section.address': '地址',
|
||||
'hostDetails.hostname.placeholder': 'IP 或 主机名',
|
||||
'hostDetails.section.general': '通用',
|
||||
'hostDetails.os.title': '操作系统',
|
||||
'hostDetails.os.desc': '用于环境信息和适配对应的命令行行为。',
|
||||
'hostDetails.os.linux': 'Linux',
|
||||
'hostDetails.os.windows': 'Windows',
|
||||
'hostDetails.os.macos': 'macOS',
|
||||
'hostDetails.section.sftp': 'SFTP 设置',
|
||||
'hostDetails.sftp.fileProtocol': '文件协议',
|
||||
'hostDetails.sftp.fileProtocol.desc': '自动优先 SFTP;当服务器无 SFTP 子系统时回退到 SCP 模式(如部分 NAS)。仅支持 SCP 的主机可强制 SCP。',
|
||||
|
||||
@@ -721,6 +721,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
// Vault hosts header/actions
|
||||
'vault.hosts.search.placeholder': '查詢主機或 ssh user@hostname / ssh -p 2222 user@hostname…',
|
||||
'vault.hosts.connect': '連線',
|
||||
'vault.hosts.openSftp': '開啟 SFTP',
|
||||
'vault.view.grid': '網格',
|
||||
'vault.view.list': '列表',
|
||||
'vault.view.tree': '樹形',
|
||||
@@ -906,6 +907,12 @@ export const zhTWCoreMessages: Messages = {
|
||||
'sftp.bookmark.addGlobal': '+全域',
|
||||
'sftp.bookmark.addGlobalTooltip': '儲存為全域收藏(所有主機共享)',
|
||||
'sftp.bookmark.empty': '暫無收藏路徑',
|
||||
'sftp.bookmark.removeConfirm': '刪除收藏 {path}?',
|
||||
'sftp.bookmark.manage': '管理',
|
||||
'sftp.bookmark.rename': '重新命名收藏',
|
||||
'sftp.bookmark.done': '完成',
|
||||
'sftp.bookmark.moveUp': '上移 {label}',
|
||||
'sftp.bookmark.moveDown': '下移 {label}',
|
||||
'sftp.columns.name': '名稱',
|
||||
'sftp.columns.configure': '選擇顯示的欄位',
|
||||
'sftp.columns.modified': '修改時間',
|
||||
@@ -946,6 +953,10 @@ export const zhTWCoreMessages: Messages = {
|
||||
'sftp.viewMode.tree': '樹形檢視',
|
||||
'sftp.viewMode.switchToList': '切換到列表檢視',
|
||||
'sftp.viewMode.switchToTree': '切換到樹形檢視',
|
||||
'sftp.listDensity.compact': '緊湊列表',
|
||||
'sftp.listDensity.comfortable': '寬鬆列表',
|
||||
'sftp.listDensity.switchToCompact': '切換到緊湊列表',
|
||||
'sftp.listDensity.switchToComfortable': '切換到寬鬆列表',
|
||||
'sftp.tree.loadError': '載入目錄失敗',
|
||||
'sftp.tree.loading': '載入中...',
|
||||
'sftp.kind.folder': '資料夾',
|
||||
|
||||
@@ -132,6 +132,11 @@ export const zhTWVaultMessages: Messages = {
|
||||
'hostDetails.section.address': '位址',
|
||||
'hostDetails.hostname.placeholder': 'IP 或 主機名',
|
||||
'hostDetails.section.general': '通用',
|
||||
'hostDetails.os.title': '作業系統',
|
||||
'hostDetails.os.desc': '用於環境資訊和配合對應的命令列行為。',
|
||||
'hostDetails.os.linux': 'Linux',
|
||||
'hostDetails.os.windows': 'Windows',
|
||||
'hostDetails.os.macos': 'macOS',
|
||||
'hostDetails.section.sftp': 'SFTP 設定',
|
||||
'hostDetails.sftp.fileProtocol': '檔案協定',
|
||||
'hostDetails.sftp.fileProtocol.desc': '自動優先 SFTP;當伺服器無 SFTP 子系統時改用 SCP 模式(如部分 NAS)。僅支援 SCP 的主機可強制 SCP。',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { AISession } from '../../infrastructure/ai/types';
|
||||
import {
|
||||
cleanupClosedTerminalSessions,
|
||||
cleanupDeletedAIChatSessions,
|
||||
@@ -47,3 +48,466 @@ test('orphan cleanup keeps durable Catty output while explicit deletion removes
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function makeSession(id: string, updatedAt: number, messages: unknown[]): AISession {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
agentId: 'agent',
|
||||
scope: { type: 'terminal', targetId: 't' },
|
||||
messages: messages as never,
|
||||
createdAt: updatedAt,
|
||||
updatedAt,
|
||||
} as never;
|
||||
}
|
||||
|
||||
test('serializeSessionsForStorage strips oldest ciphertext before dropping visible sessions', async () => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const ciphertext = 'gAAAA'.repeat(50000); // ~250 KB of ciphertext per message
|
||||
const messages = () => [{
|
||||
id: 'm',
|
||||
role: 'assistant' as const,
|
||||
content: 'hello',
|
||||
timestamp: 0,
|
||||
providerContinuation: {
|
||||
reasoningParts: [{ text: '', providerOptions: { openai: { reasoningEncryptedContent: ciphertext } } }],
|
||||
},
|
||||
}];
|
||||
const sessions = [
|
||||
makeSession('newest', 3, messages()),
|
||||
makeSession('older', 2, messages()),
|
||||
makeSession('oldest', 1, messages()),
|
||||
];
|
||||
|
||||
const hasCiphertext = (result: { sessions: AISession[] }) =>
|
||||
result.sessions.some(s => s.messages.some(m =>
|
||||
m.providerContinuation?.reasoningParts?.some(p => typeof p.providerOptions?.openai?.reasoningEncryptedContent === 'string')));
|
||||
|
||||
// Removing replay-only ciphertext from the oldest session fits the budget,
|
||||
// so every visible chat remains available after restart.
|
||||
const withOldestCiphertextStripped = serializeSessionsForStorage(sessions, 600 * 1024);
|
||||
assert.deepEqual(
|
||||
withOldestCiphertextStripped.sessions.map(s => s.id),
|
||||
['newest', 'older', 'oldest'],
|
||||
);
|
||||
assert.equal(hasCiphertext(withOldestCiphertextStripped), true);
|
||||
assert.ok(withOldestCiphertextStripped.json.length <= 600 * 1024);
|
||||
|
||||
// Tight budget that even a single session exceeds: ciphertext stripped but
|
||||
// the visible conversation content survives.
|
||||
const withCiphertextStripped = serializeSessionsForStorage(sessions, 220 * 1024);
|
||||
assert.ok(withCiphertextStripped.json.length <= 220 * 1024);
|
||||
assert.equal(hasCiphertext(withCiphertextStripped), false);
|
||||
assert.deepEqual(withCiphertextStripped.sessions.map(s => s.id), ['newest', 'older', 'oldest']);
|
||||
assert.equal(withCiphertextStripped.sessions[0].messages[0].content, 'hello');
|
||||
});
|
||||
|
||||
test('serializeSessionsForStorage keeps new ciphertext when an oversized old chat must be dropped', async () => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const ciphertext = 'gAAAA'.repeat(20 * 1024); // ~100 KB
|
||||
const newest = makeSession('newest', 2, [{
|
||||
id: 'new-message',
|
||||
role: 'assistant',
|
||||
content: 'new visible chat',
|
||||
timestamp: 2,
|
||||
providerContinuation: {
|
||||
source: { providerConfigId: 'p', providerType: 'openai', modelId: 'm' },
|
||||
reasoningParts: [{ text: '', providerOptions: { openai: { reasoningEncryptedContent: ciphertext } } }],
|
||||
},
|
||||
}]);
|
||||
const oversizedOld = makeSession('oldest', 1, [{
|
||||
id: 'old-message',
|
||||
role: 'user',
|
||||
content: 'x'.repeat(300 * 1024),
|
||||
timestamp: 1,
|
||||
}]);
|
||||
|
||||
const result = serializeSessionsForStorage([oversizedOld, newest], 200 * 1024);
|
||||
|
||||
assert.deepEqual(result.sessions.map(session => session.id), ['newest']);
|
||||
assert.equal(
|
||||
result.sessions[0].messages[0].providerContinuation
|
||||
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
|
||||
ciphertext,
|
||||
);
|
||||
assert.ok(result.json.length <= 200 * 1024);
|
||||
});
|
||||
|
||||
test('serializeSessionsForStorage prioritizes a usable newest chat over older visible history', async () => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const ciphertext = 'gAAAA'.repeat(20 * 1024); // ~100 KB
|
||||
const newest = makeSession('newest', 2, [{
|
||||
id: 'new-message',
|
||||
role: 'assistant',
|
||||
content: 'new visible chat',
|
||||
timestamp: 2,
|
||||
providerContinuation: {
|
||||
source: { providerConfigId: 'p', providerType: 'openai', modelId: 'm' },
|
||||
reasoningParts: [{ text: '', providerOptions: { openai: { reasoningEncryptedContent: ciphertext } } }],
|
||||
},
|
||||
}]);
|
||||
const olderVisible = makeSession('oldest', 1, [{
|
||||
id: 'old-message',
|
||||
role: 'user',
|
||||
content: 'x'.repeat(100 * 1024),
|
||||
timestamp: 1,
|
||||
}]);
|
||||
|
||||
const result = serializeSessionsForStorage([olderVisible, newest], 150 * 1024);
|
||||
|
||||
assert.deepEqual(result.sessions.map(session => session.id), ['newest']);
|
||||
assert.equal(
|
||||
result.sessions[0].messages[0].providerContinuation
|
||||
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
|
||||
ciphertext,
|
||||
);
|
||||
assert.ok(result.json.length <= 150 * 1024);
|
||||
});
|
||||
|
||||
test('serializeSessionsForStorage keeps the newest replayable turn in one oversized chat', async () => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const ciphertext = 'gAAAA'.repeat(20 * 1024); // ~100 KB per reasoning turn
|
||||
const reasoning = (itemId: string) => ({
|
||||
source: { providerConfigId: 'p', providerType: 'openai', modelId: 'm' },
|
||||
reasoningParts: [{
|
||||
text: '',
|
||||
providerOptions: { openai: { itemId, reasoningEncryptedContent: ciphertext } },
|
||||
}],
|
||||
});
|
||||
const session = makeSession('active', 1, [
|
||||
{
|
||||
id: 'assistant-old',
|
||||
role: 'assistant',
|
||||
content: 'old turn',
|
||||
timestamp: 1,
|
||||
providerContinuation: reasoning('rs_old'),
|
||||
toolCalls: [{ id: 'call-old', name: 'terminal_execute', arguments: { command: 'pwd' } }],
|
||||
},
|
||||
{
|
||||
id: 'tool-old',
|
||||
role: 'tool',
|
||||
content: '',
|
||||
timestamp: 2,
|
||||
toolResults: [{ toolCallId: 'call-old', content: '/tmp' }],
|
||||
},
|
||||
{
|
||||
id: 'assistant-new',
|
||||
role: 'assistant',
|
||||
content: 'new turn',
|
||||
timestamp: 3,
|
||||
providerContinuation: reasoning('rs_new'),
|
||||
toolCalls: [{ id: 'call-new', name: 'terminal_execute', arguments: { command: 'ls' } }],
|
||||
},
|
||||
{
|
||||
id: 'tool-new',
|
||||
role: 'tool',
|
||||
content: '',
|
||||
timestamp: 4,
|
||||
toolResults: [{ toolCallId: 'call-new', content: 'file.txt' }],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = serializeSessionsForStorage([session], 150 * 1024);
|
||||
|
||||
assert.ok(result.json.length <= 150 * 1024);
|
||||
assert.deepEqual(result.sessions[0].messages.map(message => message.id), [
|
||||
'assistant-old',
|
||||
'tool-old',
|
||||
'assistant-new',
|
||||
'tool-new',
|
||||
]);
|
||||
assert.equal(
|
||||
result.sessions[0].messages[0].providerContinuation
|
||||
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
result.sessions[0].messages[2].providerContinuation
|
||||
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
|
||||
ciphertext,
|
||||
);
|
||||
});
|
||||
|
||||
test('serializeSessionsForStorage drops compacted ciphertext before protecting the newest chat', async () => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const ciphertext = 'gAAAA'.repeat(20 * 1024); // ~100 KB per reasoning turn
|
||||
const compactedMessages = Array.from({ length: 17 }, (_, index) => ({
|
||||
id: `compacted-${index}`,
|
||||
role: 'assistant' as const,
|
||||
content: `compacted turn ${index}`,
|
||||
timestamp: index,
|
||||
providerContinuation: {
|
||||
reasoningParts: [{
|
||||
text: '',
|
||||
providerOptions: {
|
||||
openai: { itemId: `rs_${index}`, reasoningEncryptedContent: ciphertext },
|
||||
},
|
||||
}],
|
||||
},
|
||||
}));
|
||||
const newest = {
|
||||
...makeSession('newest', 2, [
|
||||
...compactedMessages,
|
||||
{
|
||||
id: 'recent',
|
||||
role: 'assistant',
|
||||
content: 'recent turn',
|
||||
timestamp: 18,
|
||||
providerContinuation: {
|
||||
reasoningParts: [{
|
||||
text: '',
|
||||
providerOptions: {
|
||||
openai: { itemId: 'rs_recent', reasoningEncryptedContent: ciphertext },
|
||||
},
|
||||
}],
|
||||
},
|
||||
},
|
||||
]),
|
||||
contextCompaction: {
|
||||
summary: 'The first 17 turns were summarized.',
|
||||
compactedMessageCount: 17,
|
||||
},
|
||||
};
|
||||
const olderVisible = makeSession('older', 1, [{
|
||||
id: 'older-visible',
|
||||
role: 'user',
|
||||
content: 'x'.repeat(600 * 1024),
|
||||
timestamp: 1,
|
||||
}]);
|
||||
|
||||
const result = serializeSessionsForStorage([olderVisible, newest]);
|
||||
|
||||
assert.deepEqual(result.sessions.map(session => session.id), ['newest', 'older']);
|
||||
assert.ok(result.json.length <= 2 * 1024 * 1024);
|
||||
const persistedNewest = result.sessions[0];
|
||||
for (const message of persistedNewest.messages.slice(0, 17)) {
|
||||
assert.equal(
|
||||
message.providerContinuation
|
||||
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
persistedNewest.messages[17].providerContinuation
|
||||
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
|
||||
ciphertext,
|
||||
);
|
||||
});
|
||||
|
||||
test('serializeSessionsForStorage shifts the compaction boundary when trimming old messages', async () => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const ciphertext = 'enc'.repeat(100);
|
||||
const messages = Array.from({ length: 250 }, (_, index) => ({
|
||||
id: `message-${index}`,
|
||||
role: 'assistant' as const,
|
||||
content: `turn ${index}`,
|
||||
timestamp: index,
|
||||
providerContinuation: {
|
||||
reasoningParts: [{
|
||||
text: '',
|
||||
providerOptions: {
|
||||
openai: { itemId: `rs_${index}`, reasoningEncryptedContent: ciphertext },
|
||||
},
|
||||
}],
|
||||
},
|
||||
}));
|
||||
const session = {
|
||||
...makeSession('trimmed', 1, messages),
|
||||
contextCompaction: {
|
||||
summary: 'The first 100 messages were summarized.',
|
||||
compactedMessageCount: 100,
|
||||
},
|
||||
};
|
||||
|
||||
const result = serializeSessionsForStorage([session]);
|
||||
const persisted = result.sessions[0];
|
||||
|
||||
assert.equal(persisted.messages.length, 200);
|
||||
assert.equal(persisted.messages[0].id, 'message-50');
|
||||
assert.equal(persisted.contextCompaction?.compactedMessageCount, 50);
|
||||
assert.equal(
|
||||
persisted.messages[49].providerContinuation
|
||||
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
persisted.messages[50].providerContinuation
|
||||
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
|
||||
ciphertext,
|
||||
);
|
||||
});
|
||||
|
||||
test('serializeSessionsForStorage does not trim between a tool call and its result', async () => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const messages = Array.from({ length: 250 }, (_, index) => ({
|
||||
id: `message-${index}`,
|
||||
role: 'user' as const,
|
||||
content: `turn ${index}`,
|
||||
timestamp: index,
|
||||
}));
|
||||
messages[49] = {
|
||||
id: 'assistant-call',
|
||||
role: 'assistant',
|
||||
content: 'Running it.',
|
||||
timestamp: 49,
|
||||
toolCalls: [{ id: 'call-1', name: 'terminal_execute', arguments: { command: 'pwd' } }],
|
||||
} as never;
|
||||
messages[50] = {
|
||||
id: 'tool-result',
|
||||
role: 'tool',
|
||||
content: '',
|
||||
timestamp: 50,
|
||||
toolResults: [{ toolCallId: 'call-1', content: '/tmp' }],
|
||||
} as never;
|
||||
const session = {
|
||||
...makeSession('tool-boundary', 1, messages),
|
||||
contextCompaction: {
|
||||
summary: 'The first 10 messages were summarized.',
|
||||
compactedMessageCount: 10,
|
||||
},
|
||||
};
|
||||
|
||||
const result = serializeSessionsForStorage([session]);
|
||||
const persisted = result.sessions[0];
|
||||
|
||||
assert.equal(persisted.messages.length, 201);
|
||||
assert.equal(persisted.messages[0].id, 'assistant-call');
|
||||
assert.equal(persisted.messages[1].id, 'tool-result');
|
||||
assert.equal(persisted.contextCompaction?.compactedMessageCount, 0);
|
||||
});
|
||||
|
||||
test('writeSessionsForStorage retries below nominal budgets after a quota failure', async () => {
|
||||
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const writes: string[] = [];
|
||||
let stored: string | undefined;
|
||||
const previousLocalStorage = globalThis.localStorage;
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: () => null,
|
||||
setItem: (_key: string, value: string) => {
|
||||
writes.push(value);
|
||||
if (value.length > 350 * 1024) {
|
||||
throw new DOMException('quota exceeded', 'QuotaExceededError');
|
||||
}
|
||||
stored = value;
|
||||
},
|
||||
removeItem: () => {},
|
||||
},
|
||||
});
|
||||
try {
|
||||
// The retry loop must attempt progressively smaller payloads (here the
|
||||
// ciphertext stripping at the tighter budget) before reporting failure.
|
||||
const huge = 'x'.repeat(260 * 1024);
|
||||
const ciphertext = 'gAAAA'.repeat(30 * 1024); // ~150 KB
|
||||
const sessions = [makeSession('s', 1, [{
|
||||
id: 'm',
|
||||
role: 'user' as const,
|
||||
content: huge,
|
||||
timestamp: 0,
|
||||
providerContinuation: {
|
||||
reasoningParts: [{ text: '', providerOptions: { openai: { reasoningEncryptedContent: ciphertext } } }],
|
||||
},
|
||||
}])];
|
||||
assert.equal(writeSessionsForStorage(sessions), true);
|
||||
assert.equal(writes.length, 2);
|
||||
assert.equal(stored, writes[1]);
|
||||
assert.ok(writes[writes.length - 1].length < writes[0].length);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: previousLocalStorage,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('writeSessionsForStorage reduces several small sessions to fit a sub-512 KB quota', async () => {
|
||||
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const writes: string[] = [];
|
||||
let stored: string | undefined;
|
||||
const previousLocalStorage = globalThis.localStorage;
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: () => null,
|
||||
setItem: (_key: string, value: string) => {
|
||||
writes.push(value);
|
||||
if (value.length > 300 * 1024) {
|
||||
throw new DOMException('quota exceeded', 'QuotaExceededError');
|
||||
}
|
||||
stored = value;
|
||||
},
|
||||
removeItem: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const sessions = Array.from({ length: 10 }, (_, index) => makeSession(
|
||||
`session-${index}`,
|
||||
10 - index,
|
||||
[{
|
||||
id: `message-${index}`,
|
||||
role: 'user' as const,
|
||||
content: 'x'.repeat(40 * 1024),
|
||||
timestamp: index,
|
||||
}],
|
||||
));
|
||||
|
||||
assert.equal(writeSessionsForStorage(sessions), true);
|
||||
assert.ok(writes[0].length > 300 * 1024);
|
||||
assert.ok(stored);
|
||||
assert.ok(stored.length <= 300 * 1024);
|
||||
assert.ok((JSON.parse(stored) as AISession[]).length < sessions.length);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: previousLocalStorage,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('writeSessionsForStorage makes a final attempt with only the newest session', async () => {
|
||||
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const writes: string[] = [];
|
||||
let stored: string | undefined;
|
||||
const previousLocalStorage = globalThis.localStorage;
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: () => null,
|
||||
setItem: (_key: string, value: string) => {
|
||||
writes.push(value);
|
||||
if (value.length > 50 * 1024) {
|
||||
throw new DOMException('quota exceeded', 'QuotaExceededError');
|
||||
}
|
||||
stored = value;
|
||||
},
|
||||
removeItem: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const sessions = Array.from({ length: 10 }, (_, index) => makeSession(
|
||||
`session-${index}`,
|
||||
10 - index,
|
||||
[{
|
||||
id: `message-${index}`,
|
||||
role: 'user' as const,
|
||||
content: 'x'.repeat(40 * 1024),
|
||||
timestamp: index,
|
||||
}],
|
||||
));
|
||||
|
||||
assert.equal(writeSessionsForStorage(sessions), true);
|
||||
assert.equal(writes.length, 6);
|
||||
assert.ok(stored);
|
||||
const persisted = JSON.parse(stored) as AISession[];
|
||||
assert.deepEqual(persisted.map(session => session.id), ['session-0']);
|
||||
assert.ok(stored.length <= 50 * 1024);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: previousLocalStorage,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -10,6 +10,8 @@ import type {
|
||||
AIPermissionMode,
|
||||
AIToolIntegrationMode,
|
||||
} from '../../infrastructure/ai/types';
|
||||
import type { ProviderContinuationOptions } from '../../infrastructure/ai/providerContinuation';
|
||||
import { findSafeChatMessageCompactionSplitIndex } from '../../infrastructure/ai/contextCompaction';
|
||||
import {
|
||||
bumpDraftMutationVersionState,
|
||||
bumpDraftUploadGenerationState,
|
||||
@@ -112,10 +114,7 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
|
||||
if (nextSessionCleanup.sessions !== currentSessions) {
|
||||
setLatestAISessionsSnapshot(nextSessionCleanup.sessions);
|
||||
localStorageAdapter.write(
|
||||
STORAGE_KEY_AI_SESSIONS,
|
||||
pruneSessionsForStorage(nextSessionCleanup.sessions),
|
||||
);
|
||||
writeSessionsForStorage(nextSessionCleanup.sessions);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
|
||||
}
|
||||
|
||||
@@ -181,6 +180,91 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
const MAX_STORED_SESSIONS = 50;
|
||||
/** Maximum number of messages per session when persisting to localStorage. */
|
||||
const MAX_SESSION_MESSAGES = 200;
|
||||
/**
|
||||
* Byte budget for the serialized sessions JSON. The localStorage quota is
|
||||
* ~5-10 MB across all keys, and Responses reasoning ciphertext can add tens
|
||||
* of KB per turn, so keep the sessions blob well under the quota with
|
||||
* headroom for the rest of the app's storage keys.
|
||||
*/
|
||||
const MAX_SESSIONS_JSON_BYTES = 2 * 1024 * 1024;
|
||||
/** Retry budgets used when the primary budget still fails to persist. */
|
||||
const RETRY_SESSIONS_JSON_BYTES = [
|
||||
1024 * 1024,
|
||||
512 * 1024,
|
||||
256 * 1024,
|
||||
128 * 1024,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Remove `reasoningEncryptedContent` ciphertext from a message's persisted
|
||||
* continuation. The ciphertext exists so stateless Responses turns can replay
|
||||
* prior reasoning items; it is also by far the largest per-message payload.
|
||||
* When storage pressure forces it, dropping the ciphertext keeps the visible
|
||||
* conversation intact at the cost of reasoning replay for affected messages.
|
||||
*/
|
||||
function stripReasoningEncryptedContent(
|
||||
options: ProviderContinuationOptions,
|
||||
): ProviderContinuationOptions | undefined {
|
||||
const hasCiphertext = Object.values(options).some(
|
||||
providerOptions => typeof providerOptions?.reasoningEncryptedContent === 'string',
|
||||
);
|
||||
if (!hasCiphertext) return options;
|
||||
const stripped: ProviderContinuationOptions = {};
|
||||
for (const [provider, providerOptions] of Object.entries(options)) {
|
||||
const rest = { ...providerOptions };
|
||||
delete rest.reasoningEncryptedContent;
|
||||
if (Object.keys(rest).length) stripped[provider] = rest;
|
||||
}
|
||||
return Object.keys(stripped).length ? stripped : undefined;
|
||||
}
|
||||
|
||||
function stripEncryptedReasoningFromMessage(message: AISession['messages'][number]) {
|
||||
const continuation = message.providerContinuation;
|
||||
if (!continuation?.reasoningParts) return message;
|
||||
let changed = false;
|
||||
const parts = continuation.reasoningParts.map(part => {
|
||||
if (!part.providerOptions) return part;
|
||||
const providerOptions = stripReasoningEncryptedContent(part.providerOptions);
|
||||
if (providerOptions === part.providerOptions) return part;
|
||||
changed = true;
|
||||
return providerOptions ? { text: part.text, providerOptions } : { text: part.text };
|
||||
});
|
||||
if (!changed) return message;
|
||||
return {
|
||||
...message,
|
||||
providerContinuation: {
|
||||
...continuation,
|
||||
reasoningParts: parts,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function stripEncryptedReasoningFromSession(session: AISession): AISession {
|
||||
let changed = false;
|
||||
const messages = session.messages.map(message => {
|
||||
const stripped = stripEncryptedReasoningFromMessage(message);
|
||||
if (stripped !== message) changed = true;
|
||||
return stripped;
|
||||
});
|
||||
return changed ? { ...session, messages } : session;
|
||||
}
|
||||
|
||||
function stripCompactedEncryptedReasoningFromSession(session: AISession): AISession {
|
||||
const compactedMessageCount = Math.min(
|
||||
session.messages.length,
|
||||
Math.max(0, session.contextCompaction?.compactedMessageCount ?? 0),
|
||||
);
|
||||
if (compactedMessageCount === 0) return session;
|
||||
|
||||
let changed = false;
|
||||
const messages = session.messages.map((message, index) => {
|
||||
if (index >= compactedMessageCount) return message;
|
||||
const stripped = stripEncryptedReasoningFromMessage(message);
|
||||
if (stripped !== message) changed = true;
|
||||
return stripped;
|
||||
});
|
||||
return changed ? { ...session, messages } : session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune sessions before writing to localStorage to prevent hitting the
|
||||
@@ -196,12 +280,179 @@ export function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
|
||||
const limited = sorted.slice(0, MAX_STORED_SESSIONS);
|
||||
return limited.map(s => {
|
||||
if (s.messages.length > MAX_SESSION_MESSAGES) {
|
||||
return { ...s, messages: s.messages.slice(-MAX_SESSION_MESSAGES) };
|
||||
// Do not start the retained tail with a tool result whose assistant call
|
||||
// was just trimmed. Reuse the same tool-safe boundary logic as context
|
||||
// compaction, even if that retains a few more than the nominal cap.
|
||||
const removedMessageCount = findSafeChatMessageCompactionSplitIndex(
|
||||
s.messages,
|
||||
MAX_SESSION_MESSAGES,
|
||||
);
|
||||
const contextCompaction = s.contextCompaction
|
||||
? {
|
||||
...s.contextCompaction,
|
||||
compactedMessageCount: Math.max(
|
||||
0,
|
||||
s.contextCompaction.compactedMessageCount - removedMessageCount,
|
||||
),
|
||||
}
|
||||
: undefined;
|
||||
return {
|
||||
...s,
|
||||
messages: s.messages.slice(removedMessageCount),
|
||||
...(contextCompaction ? { contextCompaction } : {}),
|
||||
};
|
||||
}
|
||||
return s;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize sessions for localStorage under a byte budget, escalating pruning
|
||||
* as needed. Returns the JSON to persist plus the (possibly) further-pruned
|
||||
* sessions that JSON represents.
|
||||
*/
|
||||
export function serializeSessionsForStorage(
|
||||
sessions: AISession[],
|
||||
budgetBytes: number = MAX_SESSIONS_JSON_BYTES,
|
||||
): { json: string; sessions: AISession[] } {
|
||||
const serialized = pruneSessionsForStorage(sessions).map(rawSession => {
|
||||
// These messages are replaced by the durable summary before every later
|
||||
// request, so their replay ciphertext can never be used again. Remove it
|
||||
// before deciding whether the newest session deserves full protection;
|
||||
// otherwise dead metadata can evict other visible conversations.
|
||||
const session = stripCompactedEncryptedReasoningFromSession(rawSession);
|
||||
const ciphertextMessages = session.messages.flatMap((message, index) => {
|
||||
const strippedMessage = stripEncryptedReasoningFromMessage(message);
|
||||
if (strippedMessage === message) return [];
|
||||
return [{
|
||||
index,
|
||||
strippedMessage,
|
||||
jsonLengthDelta: JSON.stringify(strippedMessage).length - JSON.stringify(message).length,
|
||||
}];
|
||||
});
|
||||
const strippedSession = stripEncryptedReasoningFromSession(session);
|
||||
const json = JSON.stringify(session);
|
||||
return {
|
||||
session,
|
||||
json,
|
||||
strippedSession,
|
||||
strippedJson: strippedSession === session
|
||||
? json
|
||||
: JSON.stringify(strippedSession),
|
||||
ciphertextMessages,
|
||||
};
|
||||
});
|
||||
|
||||
// Preserve the newest session's full continuation whenever it can fit by
|
||||
// itself. That session is the one the user is most likely continuing now;
|
||||
// older visible history must not make its next tool turn unreplayable.
|
||||
const protectNewestContinuation = serialized.length > 0
|
||||
&& serialized[0].json.length + 2 <= budgetBytes;
|
||||
|
||||
// Determine how many sessions can fit using the smallest representation for
|
||||
// older sessions while reserving the newest session's full representation
|
||||
// when possible. This also prevents an old, oversized visible chat that must
|
||||
// be dropped anyway from causing newer replay ciphertext to be stripped.
|
||||
let minimalLength = 2
|
||||
+ serialized.reduce((total, entry, index) => (
|
||||
total + (protectNewestContinuation && index === 0 ? entry.json.length : entry.strippedJson.length)
|
||||
), 0)
|
||||
+ Math.max(0, serialized.length - 1);
|
||||
while (minimalLength > budgetBytes && serialized.length > 1) {
|
||||
const removed = serialized.pop();
|
||||
if (!removed) break;
|
||||
minimalLength -= removed.strippedJson.length + 1;
|
||||
}
|
||||
|
||||
// Then keep full continuation data for the retained sessions and remove
|
||||
// replay-only ciphertext from the oldest retained sessions only as needed,
|
||||
// never touching the protected newest session. Within one session, remove
|
||||
// it one message at a time from oldest to newest so a long active chat can
|
||||
// retain its most recent replayable tool exchange when that still fits.
|
||||
let jsonLength = 2 + serialized.reduce((total, entry) => total + entry.json.length, 0)
|
||||
+ Math.max(0, serialized.length - 1);
|
||||
const firstStrippableIndex = protectNewestContinuation ? 1 : 0;
|
||||
for (
|
||||
let index = serialized.length - 1;
|
||||
index >= firstStrippableIndex && jsonLength > budgetBytes;
|
||||
index -= 1
|
||||
) {
|
||||
const current = serialized[index];
|
||||
let strippedMessageCount = 0;
|
||||
while (jsonLength > budgetBytes && strippedMessageCount < current.ciphertextMessages.length) {
|
||||
jsonLength += current.ciphertextMessages[strippedMessageCount].jsonLengthDelta;
|
||||
strippedMessageCount += 1;
|
||||
}
|
||||
if (strippedMessageCount > 0) {
|
||||
const strippedMessagesByIndex = new Map(
|
||||
current.ciphertextMessages
|
||||
.slice(0, strippedMessageCount)
|
||||
.map(entry => [entry.index, entry.strippedMessage] as const),
|
||||
);
|
||||
const nextSession = {
|
||||
...current.session,
|
||||
messages: current.session.messages.map((message, messageIndex) => (
|
||||
strippedMessagesByIndex.get(messageIndex) ?? message
|
||||
)),
|
||||
};
|
||||
const nextJson = JSON.stringify(nextSession);
|
||||
const projectedJsonLength = current.json.length
|
||||
+ current.ciphertextMessages
|
||||
.slice(0, strippedMessageCount)
|
||||
.reduce((total, entry) => total + entry.jsonLengthDelta, 0);
|
||||
jsonLength += nextJson.length - projectedJsonLength;
|
||||
serialized[index] = {
|
||||
...current,
|
||||
session: nextSession,
|
||||
json: nextJson,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
json: `[${serialized.map(entry => entry.json).join(',')}]`,
|
||||
sessions: serialized.map(entry => entry.session),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist sessions to localStorage with byte-budgeted pruning and retries.
|
||||
* Returns true when the write succeeded; a false result means the payload
|
||||
* could not be persisted even after escalation (it stays memory-only).
|
||||
*/
|
||||
export function writeSessionsForStorage(sessions: AISession[]): boolean {
|
||||
let previousLength = Number.POSITIVE_INFINITY;
|
||||
for (const configuredBudget of [MAX_SESSIONS_JSON_BYTES, ...RETRY_SESSIONS_JSON_BYTES]) {
|
||||
// A real quota failure means the next attempt must be smaller even when
|
||||
// the failed payload was already below the nominal retry budget. Reduce
|
||||
// materially on each bounded retry so several small sessions can be
|
||||
// removed before the retry sequence is exhausted.
|
||||
const reducedBudget = Number.isFinite(previousLength)
|
||||
? Math.floor(previousLength * 0.75)
|
||||
: configuredBudget;
|
||||
const budget = Math.min(configuredBudget, reducedBudget);
|
||||
if (budget < 2) break;
|
||||
const candidate = serializeSessionsForStorage(sessions, budget);
|
||||
if (candidate.json.length >= previousLength) continue;
|
||||
if (localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, candidate.json)) return true;
|
||||
previousLength = candidate.json.length;
|
||||
}
|
||||
// Last resort: attempt the smallest representation this serializer can
|
||||
// produce (the newest session with replay-only ciphertext removed). A very
|
||||
// small amount of shared quota may still be enough to preserve that chat.
|
||||
const minimalCandidate = serializeSessionsForStorage(sessions, 0);
|
||||
if (
|
||||
minimalCandidate.json.length < previousLength
|
||||
&& localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, minimalCandidate.json)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
console.warn(
|
||||
'[AIState] Failed to persist AI sessions within the storage quota; recent chat history may not survive a restart.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
export let latestAISessionsSnapshot: AISession[] | null = null;
|
||||
export let latestAIActiveSessionMapSnapshot: Record<string, string | null> | null = null;
|
||||
export let latestAIDraftsByScopeSnapshot: DraftsByScope | null = null;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { migrateLegacyCommandBlocklist } from '../../domain/commandBlocklist';
|
||||
import commandBlocklistTable from '../../lib/commandBlocklist.json';
|
||||
|
||||
const legacyDefaults = [
|
||||
...commandBlocklistTable.common,
|
||||
...commandBlocklistTable.posixNative,
|
||||
...commandBlocklistTable.posix,
|
||||
];
|
||||
|
||||
test('untouched legacy defaults gain the PowerShell group once', () => {
|
||||
assert.deepEqual(
|
||||
migrateLegacyCommandBlocklist(legacyDefaults),
|
||||
[...legacyDefaults, ...commandBlocklistTable.powershell],
|
||||
);
|
||||
});
|
||||
|
||||
test('customized legacy settings preserve removed defaults', () => {
|
||||
const customized = legacyDefaults.slice(1);
|
||||
assert.deepEqual(migrateLegacyCommandBlocklist(customized), customized);
|
||||
assert.deepEqual(migrateLegacyCommandBlocklist([]), []);
|
||||
});
|
||||
|
||||
test('legacy settings keep user additions while gaining new defaults', () => {
|
||||
const customized = [...legacyDefaults, 'company-forbidden-command'];
|
||||
assert.deepEqual(
|
||||
migrateLegacyCommandBlocklist(customized),
|
||||
[...customized, ...commandBlocklistTable.powershell],
|
||||
);
|
||||
});
|
||||
|
||||
test('a list that already contains a PowerShell default is left unchanged', () => {
|
||||
const alreadyUpgraded = [...legacyDefaults, commandBlocklistTable.powershell[0]];
|
||||
assert.deepEqual(migrateLegacyCommandBlocklist(alreadyUpgraded), alreadyUpgraded);
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { migrateLegacyCommandBlocklist } from '../../domain/commandBlocklist';
|
||||
import { DEFAULT_COMMAND_BLOCKLIST } from '../../infrastructure/ai/types';
|
||||
import { STORAGE_KEY_AI_COMMAND_BLOCKLIST } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
|
||||
export function persistCommandBlocklistSetting(blocklist: string[]): boolean {
|
||||
return localStorageAdapter.write(STORAGE_KEY_AI_COMMAND_BLOCKLIST, blocklist);
|
||||
}
|
||||
|
||||
export function readCommandBlocklistSetting(): string[] {
|
||||
const stored = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
|
||||
if (stored != null && !Array.isArray(stored)) {
|
||||
return [...DEFAULT_COMMAND_BLOCKLIST];
|
||||
}
|
||||
|
||||
const current = stored ?? [...DEFAULT_COMMAND_BLOCKLIST];
|
||||
const migrated = stored == null ? current : migrateLegacyCommandBlocklist(current);
|
||||
if (
|
||||
stored == null
|
||||
|| migrated.length !== current.length
|
||||
|| migrated.some((pattern, index) => pattern !== current[index])
|
||||
) {
|
||||
persistCommandBlocklistSetting(migrated);
|
||||
}
|
||||
return migrated;
|
||||
}
|
||||
@@ -68,6 +68,9 @@ export type SessionSnapshotActions = Pick<
|
||||
| 'reorderTabs'
|
||||
| 'toggleBroadcast'
|
||||
| 'isBroadcastEnabled'
|
||||
| 'toggleGlobalBroadcast'
|
||||
| 'isGlobalBroadcastEnabled'
|
||||
| 'canUseGlobalBroadcast'
|
||||
| 'copySession'
|
||||
| 'copyWorkspace'
|
||||
| 'createSessionFromCloneSource'
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
createSftpBookmark,
|
||||
moveSftpBookmark,
|
||||
renameSftpBookmark,
|
||||
} from "./bookmarkHelpers.ts";
|
||||
|
||||
test("moveSftpBookmark reorders by id and ignores unknown ids", () => {
|
||||
const bookmarks = [
|
||||
{ id: "a", path: "/a" },
|
||||
{ id: "b", path: "/b" },
|
||||
{ id: "c", path: "/c" },
|
||||
];
|
||||
assert.deepEqual(
|
||||
moveSftpBookmark(bookmarks, "c", "a").map((item) => item.id),
|
||||
["c", "a", "b"],
|
||||
);
|
||||
assert.equal(moveSftpBookmark(bookmarks, "missing", "a"), bookmarks);
|
||||
assert.equal(moveSftpBookmark(bookmarks, "a", "a"), bookmarks);
|
||||
});
|
||||
|
||||
test("renameSftpBookmark updates a trimmed label", () => {
|
||||
const bookmarks = [
|
||||
createSftpBookmark("/var/www"),
|
||||
createSftpBookmark("/etc"),
|
||||
];
|
||||
const renamed = renameSftpBookmark(bookmarks, bookmarks[0].id, " Web ");
|
||||
assert.equal(renamed[0].label, "Web");
|
||||
assert.equal(renamed[1].label, bookmarks[1].label);
|
||||
assert.equal(renameSftpBookmark(bookmarks, bookmarks[0].id, " "), bookmarks);
|
||||
});
|
||||
@@ -21,3 +21,29 @@ export function createSftpBookmark(
|
||||
...(global ? { global: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function moveSftpBookmark<T extends { id: string }>(
|
||||
bookmarks: T[],
|
||||
fromId: string,
|
||||
toId: string,
|
||||
): T[] {
|
||||
const from = bookmarks.findIndex((bookmark) => bookmark.id === fromId);
|
||||
const to = bookmarks.findIndex((bookmark) => bookmark.id === toId);
|
||||
if (from < 0 || to < 0 || from === to) return bookmarks;
|
||||
const next = bookmarks.slice();
|
||||
const [item] = next.splice(from, 1);
|
||||
next.splice(to, 0, item);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function renameSftpBookmark<T extends { id: string; label: string }>(
|
||||
bookmarks: T[],
|
||||
id: string,
|
||||
label: string,
|
||||
): T[] {
|
||||
const nextLabel = label.trim();
|
||||
if (!nextLabel) return bookmarks;
|
||||
return bookmarks.map((bookmark) => (
|
||||
bookmark.id === id ? { ...bookmark, label: nextLabel } : bookmark
|
||||
));
|
||||
}
|
||||
@@ -39,6 +39,108 @@ 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({
|
||||
@@ -97,10 +199,11 @@ 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 prefers id then label", () => {
|
||||
test("resolveHostForTransferEndpoint requires recorded id and supports unique legacy names", () => {
|
||||
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")?.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, undefined, "ci-01.example")?.id, "id-1");
|
||||
assert.equal(resolveHostForTransferEndpoint(hosts, "missing", "gone"), null);
|
||||
});
|
||||
@@ -1998,3 +2101,33 @@ 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,3 +1,4 @@
|
||||
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";
|
||||
@@ -95,8 +96,8 @@ export function resetDedicatedSessionOpenGateForTests(): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Resolve the recorded endpoint, never a same-named replacement. Legacy tasks
|
||||
* without an id may use label/hostname only when exactly one host matches.
|
||||
*/
|
||||
export function resolveHostForTransferEndpoint(
|
||||
hosts: readonly Host[],
|
||||
@@ -104,16 +105,16 @@ export function resolveHostForTransferEndpoint(
|
||||
hostLabel?: string,
|
||||
): Host | null {
|
||||
if (hostId) {
|
||||
const byId = hosts.find((host) => host.id === hostId);
|
||||
if (byId) return byId;
|
||||
return hosts.find((host) => host.id === hostId) ?? null;
|
||||
}
|
||||
const needle = (hostLabel || "").trim().toLowerCase();
|
||||
if (!needle) return null;
|
||||
return hosts.find((host) => {
|
||||
const matches = hosts.filter((host) => {
|
||||
const label = (host.label || "").trim().toLowerCase();
|
||||
const hostname = (host.hostname || "").trim().toLowerCase();
|
||||
return label === needle || hostname === needle;
|
||||
}) ?? null;
|
||||
});
|
||||
return matches.length === 1 ? matches[0] : null;
|
||||
}
|
||||
|
||||
export type OpenTransferSftpSessionOptions = {
|
||||
@@ -1000,6 +1001,11 @@ 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" };
|
||||
@@ -1247,8 +1253,13 @@ async function resumeDirectoryWithDedicatedSession(
|
||||
if (options?.shouldAbort?.()) throw new Error("Transfer cancelled");
|
||||
options?.onChildUpdate?.(childBase);
|
||||
|
||||
const streamResult = await bridge.startStreamTransfer!({
|
||||
const streamResult = await runTransferAndWaitForOwner(childBase, () => 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,
|
||||
@@ -1267,19 +1278,9 @@ async function resumeDirectoryWithDedicatedSession(
|
||||
uploadCheckpointBytes: childBase.uploadCheckpointBytes,
|
||||
sourceFingerprint: childBase.sourceFingerprint,
|
||||
skipAdmission: true,
|
||||
});
|
||||
}), () => options?.shouldAbort?.() === true, pausedAtResume.get(childId));
|
||||
|
||||
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) {
|
||||
if (streamResult?.error || streamResult?.cancelled) {
|
||||
throw new Error(streamResult.error || "Transfer cancelled");
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,46 @@ 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();
|
||||
@@ -245,3 +285,218 @@ 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);
|
||||
});
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
isTransferControlEpochCurrent,
|
||||
} from "./transferControlEpoch";
|
||||
import {
|
||||
isTransferOrRootPauseLatched,
|
||||
latchTransferPauseTree,
|
||||
releaseTransferPauseTree,
|
||||
} from "./transferPauseLatch";
|
||||
@@ -28,6 +29,9 @@ 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"];
|
||||
@@ -39,12 +43,67 @@ 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>,
|
||||
@@ -170,14 +229,23 @@ 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) {
|
||||
try { await bridge!.resumeTransfer?.(id); } catch { /* best-effort */ }
|
||||
await undoObsoletePause(id);
|
||||
}
|
||||
return { success: false, reason: "Pause superseded by resume" };
|
||||
}
|
||||
@@ -186,6 +254,9 @@ 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" };
|
||||
}
|
||||
@@ -200,17 +271,22 @@ 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) {
|
||||
try { await bridge!.resumeTransfer?.(id); } catch { /* best-effort */ }
|
||||
await undoObsoletePause(id);
|
||||
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;
|
||||
@@ -259,6 +335,10 @@ 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);
|
||||
@@ -271,7 +351,7 @@ export async function softPauseTransfer(
|
||||
if (userAlreadyResumed) {
|
||||
for (const { id, result } of pauseResults) {
|
||||
if (result?.success) {
|
||||
try { await bridge.resumeTransfer?.(id); } catch { /* best-effort */ }
|
||||
await undoObsoletePause(id);
|
||||
}
|
||||
}
|
||||
return "noop";
|
||||
@@ -284,7 +364,7 @@ export async function softPauseTransfer(
|
||||
if (!isTransferControlEpochCurrent(taskId, pauseEpoch)) {
|
||||
for (const { id, result } of pauseResults) {
|
||||
if (result?.success) {
|
||||
try { await bridge.resumeTransfer?.(id); } catch { /* best-effort */ }
|
||||
await undoObsoletePause(id);
|
||||
}
|
||||
}
|
||||
return "noop";
|
||||
@@ -367,8 +447,9 @@ export async function softPauseTransfer(
|
||||
bridgeResults: pauseResults.map((row) => row.result),
|
||||
});
|
||||
for (const id of rollback.bridgeIdsToResume) {
|
||||
try { await bridge.resumeTransfer?.(id); } catch { /* best-effort */ }
|
||||
await undoObsoletePause(id);
|
||||
}
|
||||
if (!isTransferControlEpochCurrent(taskId, pauseEpoch)) return "noop";
|
||||
const hard = pauseResults.find(({ result }) =>
|
||||
result && !result.success && !isBenignPauseMiss(result.reason),
|
||||
)?.result;
|
||||
@@ -428,7 +509,7 @@ export async function softResumeTransfer(
|
||||
: [taskId, ...childIds.filter((id) => id !== taskId)];
|
||||
|
||||
// Supersede in-flight soft-drain / pauseWatch only — not a bridge lifecycle stamp.
|
||||
bumpTransferControlEpoch(taskId);
|
||||
const resumeEpoch = bumpTransferControlEpoch(taskId);
|
||||
releaseTransferPauseTree(taskId, releaseIds);
|
||||
for (const id of [taskId, ...releaseIds]) {
|
||||
try { globalSftpTransferScheduler.resume(id); } catch { /* best-effort */ }
|
||||
@@ -438,17 +519,50 @@ export async function softResumeTransfer(
|
||||
try { bridge = host.getBridge(); } catch { bridge = undefined; }
|
||||
|
||||
const resumeIds = treeIds.length > 0 ? treeIds : [taskId];
|
||||
const results = await Promise.all(resumeIds.map(async (id) =>
|
||||
bridge?.resumeTransfer?.(id) ?? { success: false, reason: "Resume unavailable" },
|
||||
));
|
||||
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 after = host.getTasks().find((candidate) => candidate.id === taskId);
|
||||
if (after?.status === "cancelled") return { handled: true };
|
||||
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return { handled: true };
|
||||
if (!after || ["completed", "cancelled", "failed"].includes(after.status)) 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 (successIds.length === 0) {
|
||||
if (!effectiveRunning) {
|
||||
if (task.isDirectory && walkAlive) {
|
||||
host.setTasks(paintTreeStatus(
|
||||
host.getTasks(),
|
||||
@@ -491,6 +605,12 @@ 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) {
|
||||
@@ -502,7 +622,7 @@ export async function softResumeTransfer(
|
||||
return {
|
||||
...candidate,
|
||||
status: "transferring" as const,
|
||||
error: undefined,
|
||||
error: failureReason,
|
||||
reconnectRequired: false,
|
||||
pauseUnavailableReason: undefined,
|
||||
phase: undefined,
|
||||
|
||||
@@ -4,6 +4,11 @@ import test from "node:test";
|
||||
import {
|
||||
createGlobalSftpTransferScheduler,
|
||||
} from "./globalTransferScheduler";
|
||||
import { resolveProgressiveFolderUploadConcurrency } from "../../../lib/progressiveFolderUpload";
|
||||
import {
|
||||
DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY,
|
||||
resolveSftpTransferConcurrency,
|
||||
} from "./transferConcurrency";
|
||||
|
||||
test("scheduler limits each remote host independently", async () => {
|
||||
const scheduler = createGlobalSftpTransferScheduler();
|
||||
@@ -28,6 +33,40 @@ test("scheduler limits each remote host independently", async () => {
|
||||
assert.equal(maxActive, 2);
|
||||
});
|
||||
|
||||
test("progressive folder uploads use six slots by default and preserve a saved limit", async () => {
|
||||
const displayedDefault = resolveSftpTransferConcurrency(() => null);
|
||||
const effectiveDefault = resolveProgressiveFolderUploadConcurrency(null);
|
||||
assert.equal(displayedDefault, DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY);
|
||||
assert.equal(effectiveDefault, displayedDefault);
|
||||
assert.equal(effectiveDefault, 6);
|
||||
assert.equal(resolveProgressiveFolderUploadConcurrency(3), 3);
|
||||
assert.equal(resolveProgressiveFolderUploadConcurrency(99), 6);
|
||||
|
||||
const scheduler = createGlobalSftpTransferScheduler();
|
||||
const releases: Array<() => void> = [];
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
const jobs = Array.from({ length: 8 }, (_, index) => scheduler.run(
|
||||
"progressive",
|
||||
`file-${index}`,
|
||||
["host:one"],
|
||||
() => null,
|
||||
async () => {
|
||||
active += 1;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
await new Promise<void>((resolve) => releases.push(resolve));
|
||||
active -= 1;
|
||||
},
|
||||
));
|
||||
|
||||
while (releases.length < 6) await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(maxActive, 6);
|
||||
releases.splice(0).forEach((release) => release());
|
||||
while (releases.length < 2) await new Promise((resolve) => setImmediate(resolve));
|
||||
releases.splice(0).forEach((release) => release());
|
||||
await Promise.all(jobs);
|
||||
});
|
||||
|
||||
test("scheduler alternates owners when both have queued work", async () => {
|
||||
const scheduler = createGlobalSftpTransferScheduler();
|
||||
const order: string[] = [];
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { resolveSftpTransferConcurrency } from "../../../domain/sftpTransferConcurrency";
|
||||
|
||||
type LimitReader = () => number | null | undefined;
|
||||
|
||||
interface ScheduledJob<T> {
|
||||
@@ -20,9 +22,7 @@ export interface GlobalSftpTransferScheduler {
|
||||
}
|
||||
|
||||
function normalizeLimit(value: number | null | undefined): number {
|
||||
return Number.isInteger(value) && value !== undefined && value !== null && value >= 1 && value <= 16
|
||||
? value
|
||||
: 2;
|
||||
return resolveSftpTransferConcurrency(() => value);
|
||||
}
|
||||
|
||||
export function getSftpTransferResourceKeys(input: {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useMemo, useSyncExternalStore } from "react";
|
||||
import type { SftpBookmark } from "../../../domain/models";
|
||||
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
import { STORAGE_KEY_SFTP_LOCAL_BOOKMARKS } from "../../../infrastructure/config/storageKeys";
|
||||
import { createSftpBookmark } from "./bookmarkHelpers";
|
||||
import { createSftpBookmark, moveSftpBookmark, renameSftpBookmark } from "./bookmarkHelpers";
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
@@ -74,10 +74,20 @@ export const useLocalSftpBookmarks = ({
|
||||
setLocalSftpBookmarks((prev) => prev.filter((b) => b.id !== id));
|
||||
}, []);
|
||||
|
||||
const reorderBookmark = useCallback((fromId: string, toId: string) => {
|
||||
setLocalSftpBookmarks((prev) => moveSftpBookmark(prev, fromId, toId));
|
||||
}, []);
|
||||
|
||||
const renameBookmark = useCallback((id: string, label: string) => {
|
||||
setLocalSftpBookmarks((prev) => renameSftpBookmark(prev, id, label));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
bookmarks,
|
||||
isCurrentPathBookmarked,
|
||||
toggleBookmark,
|
||||
deleteBookmark,
|
||||
reorderBookmark,
|
||||
renameBookmark,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
consumePendingDualPaneSftpRequest,
|
||||
requestOpenDualPaneSftp,
|
||||
resetDualPaneSftpOpenStore,
|
||||
subscribeDualPaneSftpOpen,
|
||||
} from "./sftpDualPaneOpenStore.ts";
|
||||
|
||||
test("requestOpenDualPaneSftp stores a pending request when nothing is listening", () => {
|
||||
resetDualPaneSftpOpenStore();
|
||||
requestOpenDualPaneSftp("host-1");
|
||||
assert.deepEqual(consumePendingDualPaneSftpRequest(), { hostId: "host-1", seq: 1 });
|
||||
assert.equal(consumePendingDualPaneSftpRequest(), null);
|
||||
});
|
||||
|
||||
test("requestOpenDualPaneSftp delivers live to subscribers instead of queueing", () => {
|
||||
resetDualPaneSftpOpenStore();
|
||||
const received: string[] = [];
|
||||
const unsubscribe = subscribeDualPaneSftpOpen((request) => {
|
||||
received.push(request.hostId);
|
||||
});
|
||||
requestOpenDualPaneSftp("host-2");
|
||||
assert.deepEqual(received, ["host-2"]);
|
||||
assert.equal(consumePendingDualPaneSftpRequest(), null);
|
||||
unsubscribe();
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { activeTabStore } from "../activeTabStore";
|
||||
|
||||
export type DualPaneSftpRequest = {
|
||||
hostId: string;
|
||||
seq: number;
|
||||
};
|
||||
|
||||
type Listener = (request: DualPaneSftpRequest) => void;
|
||||
|
||||
const listeners = new Set<Listener>();
|
||||
let pending: DualPaneSftpRequest | null = null;
|
||||
let seq = 0;
|
||||
|
||||
export function requestOpenDualPaneSftp(hostId: string): DualPaneSftpRequest {
|
||||
const request: DualPaneSftpRequest = { hostId, seq: ++seq };
|
||||
if (listeners.size > 0) {
|
||||
pending = null;
|
||||
for (const listener of listeners) listener(request);
|
||||
} else {
|
||||
pending = request;
|
||||
}
|
||||
if (typeof globalThis.window !== "undefined") {
|
||||
activeTabStore.setActiveTabId("sftp");
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
export function consumePendingDualPaneSftpRequest(): DualPaneSftpRequest | null {
|
||||
const current = pending;
|
||||
pending = null;
|
||||
return current;
|
||||
}
|
||||
|
||||
export function subscribeDualPaneSftpOpen(listener: Listener) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function resetDualPaneSftpOpenStore() {
|
||||
pending = null;
|
||||
listeners.clear();
|
||||
seq = 0;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
import {
|
||||
DEFAULT_SFTP_LIST_DENSITY,
|
||||
getNextSftpListDensity,
|
||||
parseSftpListDensity,
|
||||
type SftpListDensity,
|
||||
} from "../../../domain/sftpListDensity";
|
||||
import { STORAGE_KEY_SFTP_LIST_DENSITY } from "../../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
let snapshot: SftpListDensity = parseSftpListDensity(
|
||||
localStorageAdapter.readString(STORAGE_KEY_SFTP_LIST_DENSITY),
|
||||
);
|
||||
|
||||
function emit() {
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
export function subscribeSftpListDensity(listener: Listener) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function getSftpListDensitySnapshot(): SftpListDensity {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function setSftpListDensity(next: SftpListDensity) {
|
||||
const density = parseSftpListDensity(next);
|
||||
if (density === snapshot) return;
|
||||
snapshot = density;
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_LIST_DENSITY, density);
|
||||
emit();
|
||||
}
|
||||
|
||||
export function toggleSftpListDensity() {
|
||||
setSftpListDensity(getNextSftpListDensity(snapshot));
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("storage", (event) => {
|
||||
if (event.key !== STORAGE_KEY_SFTP_LIST_DENSITY) return;
|
||||
snapshot = parseSftpListDensity(event.newValue);
|
||||
emit();
|
||||
});
|
||||
}
|
||||
|
||||
export function useSftpListDensity() {
|
||||
const density = useSyncExternalStore(
|
||||
subscribeSftpListDensity,
|
||||
getSftpListDensitySnapshot,
|
||||
() => DEFAULT_SFTP_LIST_DENSITY,
|
||||
);
|
||||
const setDensity = useCallback((next: SftpListDensity) => {
|
||||
setSftpListDensity(next);
|
||||
}, []);
|
||||
const toggleDensity = useCallback(() => {
|
||||
toggleSftpListDensity();
|
||||
}, []);
|
||||
return { density, setDensity, toggleDensity };
|
||||
}
|
||||
@@ -12,9 +12,9 @@ import {
|
||||
runSftpTransferWorkers,
|
||||
} from "./transferConcurrency";
|
||||
|
||||
test("defaults folder file transfers to two concurrent files", () => {
|
||||
test("defaults folder file transfers to six concurrent files", () => {
|
||||
assert.equal(resolveSftpTransferConcurrency(() => null), DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY);
|
||||
assert.equal(DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY, 2);
|
||||
assert.equal(DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY, 6);
|
||||
});
|
||||
|
||||
test("defaults directory listing fanout to four concurrent readdirs", () => {
|
||||
@@ -34,6 +34,7 @@ test("defaults skip-unchanged to enabled", () => {
|
||||
test("keeps explicit folder transfer concurrency within the supported range", () => {
|
||||
assert.equal(resolveSftpTransferConcurrency(() => 1), 1);
|
||||
assert.equal(resolveSftpTransferConcurrency(() => 16), 16);
|
||||
assert.equal(resolveSftpTransferConcurrency(() => 1.5), DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY);
|
||||
assert.equal(resolveSftpTransferConcurrency(() => 0), DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY);
|
||||
assert.equal(resolveSftpTransferConcurrency(() => 17), DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY);
|
||||
});
|
||||
@@ -51,18 +52,18 @@ test("keeps directory listing concurrency within the supported range", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("limits default multi-file transfer scheduling to two concurrent workers", async () => {
|
||||
test("limits default multi-file transfer scheduling to six concurrent workers", async () => {
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
|
||||
await runSftpTransferWorkers([1, 2, 3, 4], () => null, async () => {
|
||||
await runSftpTransferWorkers([1, 2, 3, 4, 5, 6, 7], () => null, async () => {
|
||||
active += 1;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
active -= 1;
|
||||
});
|
||||
|
||||
assert.equal(maxActive, 2);
|
||||
assert.equal(maxActive, 6);
|
||||
});
|
||||
|
||||
test("runBoundedConcurrency respects an explicit limit", async () => {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
export const DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY = 2;
|
||||
export const MIN_SFTP_FILE_TRANSFER_CONCURRENCY = 1;
|
||||
export const MAX_SFTP_FILE_TRANSFER_CONCURRENCY = 16;
|
||||
import { resolveSftpTransferConcurrency } from "../../../domain/sftpTransferConcurrency";
|
||||
|
||||
export {
|
||||
DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY,
|
||||
MAX_SFTP_FILE_TRANSFER_CONCURRENCY,
|
||||
MIN_SFTP_FILE_TRANSFER_CONCURRENCY,
|
||||
resolveSftpTransferConcurrency,
|
||||
} from "../../../domain/sftpTransferConcurrency";
|
||||
|
||||
/**
|
||||
* Bounded parallel directory listings while walking a folder tree.
|
||||
@@ -16,15 +21,6 @@ export const MAX_SFTP_DIRECTORY_LISTING_CONCURRENCY = 8;
|
||||
/** Default on: skip size+mtime matches like rsync's generator. */
|
||||
export const DEFAULT_SFTP_SKIP_UNCHANGED = true;
|
||||
|
||||
export function resolveSftpTransferConcurrency(readStoredValue: () => number | null | undefined): number {
|
||||
const stored = readStoredValue();
|
||||
return stored != null &&
|
||||
stored >= MIN_SFTP_FILE_TRANSFER_CONCURRENCY &&
|
||||
stored <= MAX_SFTP_FILE_TRANSFER_CONCURRENCY
|
||||
? stored
|
||||
: DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY;
|
||||
}
|
||||
|
||||
export function resolveSftpDirectoryListingConcurrency(
|
||||
readStoredValue?: () => number | null | undefined,
|
||||
): number {
|
||||
|
||||
@@ -41,6 +41,106 @@ 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;
|
||||
@@ -233,3 +333,74 @@ 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,3 +1,5 @@
|
||||
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 {
|
||||
@@ -412,7 +414,11 @@ export function useSftpDirectoryTransferOps({
|
||||
}
|
||||
// Await the invoke result — cancel resolves with { error } and may
|
||||
// not fire onComplete/onError after preload clears listeners.
|
||||
const transferPromise = netcattyBridge.require().startStreamTransfer!(options);
|
||||
const transferPromise = runTransferAndWaitForOwner(
|
||||
task,
|
||||
() => netcattyBridge.require().startStreamTransfer!(options),
|
||||
() => isCancelledLocalOrGlobal(cancelledTasksRef, rootTaskId, task.id),
|
||||
);
|
||||
// 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.
|
||||
@@ -427,12 +433,33 @@ export function useSftpDirectoryTransferOps({
|
||||
)
|
||||
) {
|
||||
const epochAtAttempt = getTransferControlEpoch(rootTaskId);
|
||||
const childEpochAtAttempt = getTransferControlEpoch(task.id);
|
||||
try {
|
||||
const result = await netcattyBridge.get()?.pauseTransfer?.(task.id);
|
||||
// Resume won while we were awaiting pause — undo.
|
||||
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.
|
||||
if (
|
||||
result?.success
|
||||
&& !isTransferControlEpochCurrent(rootTaskId, epochAtAttempt)
|
||||
&& !isPauseLatched(rootTaskId, task.id)
|
||||
&& !isCancelledLocalOrGlobal(cancelledTasksRef, rootTaskId, task.id)
|
||||
) {
|
||||
try {
|
||||
await netcattyBridge.get()?.resumeTransfer?.(task.id);
|
||||
@@ -450,33 +477,6 @@ 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");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
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");
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { FileConflict, FileConflictAction, Host, TransferStatus, SftpFilenameEncoding } from "../../../domain/models";
|
||||
import { getSftpConflictTypeKey } from "../../../domain/sftpConflict";
|
||||
import { resolveSftpTransferConcurrency } from "../../../domain/sftpTransferConcurrency";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { notify } from "../../notification";
|
||||
@@ -105,6 +106,11 @@ import {
|
||||
waitWhileTransferOrRootPaused,
|
||||
} from "./transferPauseLatch";
|
||||
|
||||
const readSftpFileTransferConcurrency = (): number =>
|
||||
resolveSftpTransferConcurrency(
|
||||
() => localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY),
|
||||
);
|
||||
|
||||
type UploadConflictResolver = {
|
||||
resolve: (action: FileConflictAction) => void;
|
||||
setDefault: (action: FileConflictAction) => void;
|
||||
@@ -833,7 +839,10 @@ export const useSftpExternalOperations = (
|
||||
// Create upload bridge that wraps netcattyBridge.
|
||||
// Pass connect-time Host so pooled stream uploads open the pinned endpoint
|
||||
// (session hostname/port/user overrides), not the vault entry by hostId alone.
|
||||
const createUploadBridge = useCallback((connectHost?: Host): UploadBridge => {
|
||||
const createUploadBridge = useCallback((
|
||||
connectHost?: Host,
|
||||
readTransferConcurrency = () => localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY),
|
||||
): UploadBridge => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return {
|
||||
managesTransferLifecycle: Boolean(
|
||||
@@ -896,7 +905,7 @@ export const useSftpExternalOperations = (
|
||||
ownerId,
|
||||
options.transferId,
|
||||
getSftpTransferResourceKeys(options),
|
||||
() => localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY),
|
||||
readTransferConcurrency,
|
||||
async () => {
|
||||
let lease: { sftpId: string; release: () => void; discard: () => void } | null = null;
|
||||
try {
|
||||
@@ -1031,7 +1040,10 @@ export const useSftpExternalOperations = (
|
||||
const uploadPaneId = livePane.id;
|
||||
const liveTargetPath = targetPath || livePane.connection.currentPath;
|
||||
const connectHost = resolveUploadConnectHost(uploadPaneId, livePane.connection.isLocal);
|
||||
const uploadBridge = createUploadBridge(connectHost);
|
||||
const uploadBridge = createUploadBridge(
|
||||
connectHost,
|
||||
readSftpFileTransferConcurrency,
|
||||
);
|
||||
const liveCallbacks = livePane.connection.id === pane.connection.id
|
||||
&& liveTargetPath === uploadTargetPath
|
||||
? callbacks
|
||||
@@ -1068,6 +1080,7 @@ export const useSftpExternalOperations = (
|
||||
targetPath: liveTargetPath,
|
||||
sftpId,
|
||||
targetHostId: livePane.connection.isLocal ? undefined : livePane.connection.hostId,
|
||||
fileTransferConcurrency: readSftpFileTransferConcurrency(),
|
||||
isLocal: livePane.connection.isLocal,
|
||||
bridge: uploadBridge,
|
||||
joinPath,
|
||||
@@ -1246,6 +1259,7 @@ export const useSftpExternalOperations = (
|
||||
targetPath: liveTargetPath,
|
||||
sftpId: uploadSftpId,
|
||||
targetHostId: livePane.connection!.isLocal ? undefined : livePane.connection!.hostId,
|
||||
fileTransferConcurrency: readSftpFileTransferConcurrency(),
|
||||
isLocal: livePane.connection!.isLocal,
|
||||
bridge: uploadBridge,
|
||||
joinPath,
|
||||
@@ -1375,6 +1389,7 @@ export const useSftpExternalOperations = (
|
||||
targetPath: uploadTargetPath,
|
||||
sftpId: uploadSftpId,
|
||||
targetHostId: livePane.connection!.isLocal ? undefined : livePane.connection!.hostId,
|
||||
fileTransferConcurrency: readSftpFileTransferConcurrency(),
|
||||
isLocal: livePane.connection!.isLocal,
|
||||
bridge: uploadBridge,
|
||||
joinPath,
|
||||
@@ -1595,6 +1610,7 @@ export const useSftpExternalOperations = (
|
||||
targetPath: uploadTargetPath,
|
||||
sftpId: uploadSftpId,
|
||||
targetHostId: livePane.connection!.isLocal ? undefined : livePane.connection!.hostId,
|
||||
fileTransferConcurrency: readSftpFileTransferConcurrency(),
|
||||
isLocal: livePane.connection!.isLocal,
|
||||
bridge: uploadBridge,
|
||||
joinPath,
|
||||
@@ -1780,6 +1796,7 @@ export const useSftpExternalOperations = (
|
||||
targetPath: uploadTargetPath,
|
||||
sftpId: uploadSftpId,
|
||||
targetHostId: livePane.connection!.isLocal ? undefined : livePane.connection!.hostId,
|
||||
fileTransferConcurrency: readSftpFileTransferConcurrency(),
|
||||
isLocal: livePane.connection!.isLocal,
|
||||
bridge: directUploadBridge,
|
||||
joinPath,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
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,3 +3762,108 @@ 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,6 +28,7 @@ import {
|
||||
} from "./sftp/transferControlEpoch";
|
||||
import { isTransferWalkInFlight } from "./sftp/transferWalkRegistry";
|
||||
import {
|
||||
isTransferOrRootCancelled,
|
||||
markTransferCancelledTree,
|
||||
settleTransferCancelTree,
|
||||
} from "./sftp/transferCancelLatch";
|
||||
@@ -96,6 +97,10 @@ 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;
|
||||
@@ -398,6 +403,15 @@ 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 (
|
||||
@@ -609,6 +623,14 @@ 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));
|
||||
@@ -1307,6 +1329,70 @@ 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);
|
||||
},
|
||||
@@ -1671,7 +1757,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
// 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);
|
||||
let resumeEpoch = bumpTransferControlEpoch(taskId);
|
||||
releaseTransferPauseTree(taskId, childIds);
|
||||
|
||||
if (task.ownerId === "dedicated-resume" && task.isDirectory) {
|
||||
@@ -1693,64 +1779,53 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
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 {
|
||||
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;
|
||||
});
|
||||
// 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;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
uploadFromDataTransfer,
|
||||
uploadFromFileList,
|
||||
} from "../../lib/uploadService.ts";
|
||||
import { DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY } from "../../domain/sftpTransferConcurrency.ts";
|
||||
|
||||
function createDataTransfer(files: File[]): DataTransfer {
|
||||
return {
|
||||
@@ -197,6 +198,50 @@ test("uploads picked folder files with their relative directory structure", asyn
|
||||
]);
|
||||
});
|
||||
|
||||
test("ordinary multi-file uploads use the shared transfer concurrency", async () => {
|
||||
const countInitiallyStarted = async (fileTransferConcurrency?: number) => {
|
||||
const started: string[] = [];
|
||||
let releaseAll!: () => void;
|
||||
const gate = new Promise<void>((resolve) => { releaseAll = resolve; });
|
||||
const files = Array.from({ length: DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY + 2 }, (_, index) => {
|
||||
const file = new File([`file-${index}`], `file-${index}.txt`);
|
||||
Object.defineProperty(file, "path", { value: `/local/file-${index}.txt` });
|
||||
return file;
|
||||
});
|
||||
|
||||
const uploading = uploadFromFileList(
|
||||
files,
|
||||
{
|
||||
targetPath: "/target",
|
||||
sftpId: "sftp-1",
|
||||
fileTransferConcurrency,
|
||||
isLocal: false,
|
||||
bridge: {
|
||||
mkdirSftp: async () => {},
|
||||
startStreamTransfer: async ({ targetPath }) => {
|
||||
started.push(targetPath);
|
||||
await gate;
|
||||
return { transferId: targetPath };
|
||||
},
|
||||
},
|
||||
joinPath: (base, name) => `${base}/${name}`,
|
||||
},
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const initiallyStarted = started.length;
|
||||
releaseAll();
|
||||
await uploading;
|
||||
return initiallyStarted;
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
await countInitiallyStarted(),
|
||||
DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY,
|
||||
);
|
||||
assert.equal(await countInitiallyStarted(3), 3);
|
||||
});
|
||||
|
||||
test("compression remains enabled for DataTransfer folder uploads that have a conflict resolver", async (t) => {
|
||||
const starts: string[] = [];
|
||||
installCompressedUploadBridge(t, {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
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 { STORAGE_KEY_AI_COMMAND_BLOCKLIST } from '../../infrastructure/config/storageKeys.ts';
|
||||
import { useAISettingsState } from './useAISettingsState.ts';
|
||||
|
||||
test('command blocklist storage events refresh the mounted AI settings state', async () => {
|
||||
const store = new Map<string, string>();
|
||||
const fakeStorage = {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => store.set(key, String(value)),
|
||||
removeItem: (key: string) => store.delete(key),
|
||||
};
|
||||
const fakeWindow = new EventTarget() as EventTarget & { netcatty?: unknown };
|
||||
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');
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
Object.defineProperty(globalThis, 'localStorage', { value: fakeStorage, configurable: true });
|
||||
Object.defineProperty(globalThis, 'window', { value: fakeWindow, configurable: true });
|
||||
|
||||
let currentBlocklist: string[] = [];
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
const Probe = () => {
|
||||
currentBlocklist = useAISettingsState().commandBlocklist;
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
});
|
||||
fakeStorage.setItem(STORAGE_KEY_AI_COMMAND_BLOCKLIST, JSON.stringify(['company-blocked-command']));
|
||||
const event = new Event('storage');
|
||||
Object.defineProperty(event, 'key', { value: STORAGE_KEY_AI_COMMAND_BLOCKLIST });
|
||||
await act(async () => {
|
||||
fakeWindow.dispatchEvent(event);
|
||||
});
|
||||
assert.deepEqual(currentBlocklist, ['company-blocked-command']);
|
||||
} finally {
|
||||
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;
|
||||
}
|
||||
});
|
||||
@@ -28,7 +28,6 @@ import type {
|
||||
WebSearchConfig,
|
||||
} from '../../infrastructure/ai/types';
|
||||
import {
|
||||
DEFAULT_COMMAND_BLOCKLIST,
|
||||
DEFAULT_COMMAND_TIMEOUT_SECONDS,
|
||||
DEFAULT_RESPONSE_IDLE_TIMEOUT_SECONDS,
|
||||
normalizeCommandTimeoutSeconds,
|
||||
@@ -38,6 +37,10 @@ import { removeProviderReferences } from './aiProviderCleanup';
|
||||
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
|
||||
import { getAIBridge } from './aiStateSnapshots';
|
||||
import { useStoredBoolean } from './useStoredBoolean';
|
||||
import {
|
||||
persistCommandBlocklistSetting,
|
||||
readCommandBlocklistSetting,
|
||||
} from './commandBlocklistSettings';
|
||||
|
||||
function readPermissionMode(): AIPermissionMode {
|
||||
const stored = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
|
||||
@@ -69,9 +72,7 @@ export function useAISettingsState() {
|
||||
const [defaultAgentId, setDefaultAgentIdRaw] = useState<string>(() =>
|
||||
localStorageAdapter.readString(STORAGE_KEY_AI_DEFAULT_AGENT) ?? 'catty'
|
||||
);
|
||||
const [commandBlocklist, setCommandBlocklistRaw] = useState<string[]>(() =>
|
||||
localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST) ?? [...DEFAULT_COMMAND_BLOCKLIST]
|
||||
);
|
||||
const [commandBlocklist, setCommandBlocklistRaw] = useState<string[]>(readCommandBlocklistSetting);
|
||||
const [commandTimeout, setCommandTimeoutRaw] = useState<number>(() =>
|
||||
normalizeCommandTimeoutSeconds(
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? DEFAULT_COMMAND_TIMEOUT_SECONDS,
|
||||
@@ -173,7 +174,7 @@ export function useAISettingsState() {
|
||||
|
||||
const setCommandBlocklist = useCallback((value: string[]) => {
|
||||
setCommandBlocklistRaw(value);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_COMMAND_BLOCKLIST, value);
|
||||
persistCommandBlocklistSetting(value);
|
||||
getAIBridge()?.aiMcpSetCommandBlocklist?.(value);
|
||||
}, []);
|
||||
|
||||
@@ -249,9 +250,7 @@ export function useAISettingsState() {
|
||||
setDefaultAgentIdRaw(localStorageAdapter.readString(STORAGE_KEY_AI_DEFAULT_AGENT) ?? 'catty');
|
||||
break;
|
||||
case STORAGE_KEY_AI_COMMAND_BLOCKLIST: {
|
||||
const list = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
|
||||
if (list != null && !Array.isArray(list)) break;
|
||||
const blocklist = list ?? [...DEFAULT_COMMAND_BLOCKLIST];
|
||||
const blocklist = readCommandBlocklistSetting();
|
||||
setCommandBlocklistRaw(blocklist);
|
||||
getAIBridge()?.aiMcpSetCommandBlocklist?.(blocklist);
|
||||
break;
|
||||
|
||||
@@ -37,7 +37,6 @@ import type {
|
||||
WebSearchConfig,
|
||||
} from '../../infrastructure/ai/types';
|
||||
import {
|
||||
DEFAULT_COMMAND_BLOCKLIST,
|
||||
DEFAULT_COMMAND_TIMEOUT_SECONDS,
|
||||
DEFAULT_RESPONSE_IDLE_TIMEOUT_SECONDS,
|
||||
normalizeCommandTimeoutSeconds,
|
||||
@@ -69,7 +68,7 @@ import {
|
||||
latestAIDraftsByScopeSnapshot,
|
||||
latestAIPanelViewByScopeSnapshot,
|
||||
latestAISessionsSnapshot,
|
||||
pruneSessionsForStorage,
|
||||
writeSessionsForStorage,
|
||||
setLatestAIActiveSessionMapSnapshot,
|
||||
setLatestAIDraftsByScopeSnapshot,
|
||||
setLatestAIPanelViewByScopeSnapshot,
|
||||
@@ -78,6 +77,10 @@ import {
|
||||
type PanelViewByScope,
|
||||
} from './aiStateSnapshots';
|
||||
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
|
||||
import {
|
||||
persistCommandBlocklistSetting,
|
||||
readCommandBlocklistSetting,
|
||||
} from './commandBlocklistSettings';
|
||||
import {
|
||||
handoffDissolvedWorkspaceAIScope,
|
||||
retargetWorkspaceActiveChatAfterMemberLoss,
|
||||
@@ -140,9 +143,7 @@ export function useAIState() {
|
||||
);
|
||||
|
||||
// ── Safety Settings ──
|
||||
const [commandBlocklist, setCommandBlocklistRaw] = useState<string[]>(() =>
|
||||
localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST) ?? [...DEFAULT_COMMAND_BLOCKLIST]
|
||||
);
|
||||
const [commandBlocklist, setCommandBlocklistRaw] = useState<string[]>(readCommandBlocklistSetting);
|
||||
const [commandTimeout, setCommandTimeoutRaw] = useState<number>(() =>
|
||||
normalizeCommandTimeoutSeconds(
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? DEFAULT_COMMAND_TIMEOUT_SECONDS,
|
||||
@@ -423,7 +424,7 @@ export function useAIState() {
|
||||
|
||||
const setCommandBlocklist = useCallback((value: string[]) => {
|
||||
setCommandBlocklistRaw(value);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_COMMAND_BLOCKLIST, value);
|
||||
persistCommandBlocklistSetting(value);
|
||||
// Sync to MCP Server bridge so SDK agents also respect the blocklist
|
||||
const bridge = getAIBridge();
|
||||
bridge?.aiMcpSetCommandBlocklist?.(value);
|
||||
@@ -503,12 +504,7 @@ export function useAIState() {
|
||||
setDefaultAgentIdRaw(localStorageAdapter.readString(STORAGE_KEY_AI_DEFAULT_AGENT) ?? 'catty');
|
||||
break;
|
||||
case STORAGE_KEY_AI_COMMAND_BLOCKLIST: {
|
||||
const list = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
|
||||
if (list != null && !Array.isArray(list)) {
|
||||
console.warn('[useAIState] Cross-window sync: AI_COMMAND_BLOCKLIST is not an array, skipping');
|
||||
break;
|
||||
}
|
||||
const blocklist = list ?? [...DEFAULT_COMMAND_BLOCKLIST];
|
||||
const blocklist = readCommandBlocklistSetting();
|
||||
setCommandBlocklistRaw(blocklist);
|
||||
getAIBridge()?.aiMcpSetCommandBlocklist?.(blocklist);
|
||||
break;
|
||||
@@ -627,7 +623,7 @@ export function useAIState() {
|
||||
// ── Sync initial safety settings to MCP Server on mount ──
|
||||
useEffect(() => {
|
||||
const bridge = getAIBridge();
|
||||
const initialBlocklist = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST) ?? [...DEFAULT_COMMAND_BLOCKLIST];
|
||||
const initialBlocklist = readCommandBlocklistSetting();
|
||||
bridge?.aiMcpSetCommandBlocklist?.(initialBlocklist);
|
||||
const initialTimeout = normalizeCommandTimeoutSeconds(
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? DEFAULT_COMMAND_TIMEOUT_SECONDS,
|
||||
@@ -650,7 +646,7 @@ export function useAIState() {
|
||||
|
||||
// ── Session CRUD ──
|
||||
const persistSessions = useCallback((next: AISession[]) => {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, pruneSessionsForStorage(next));
|
||||
writeSessionsForStorage(next);
|
||||
}, []);
|
||||
|
||||
// Debounced version of persistSessions for high-frequency updates (e.g. streaming)
|
||||
@@ -661,7 +657,7 @@ export function useAIState() {
|
||||
if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
|
||||
persistTimerRef.current = setTimeout(() => {
|
||||
if (!mountedRef.current) return; // Skip writes after unmount
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, pruneSessionsForStorage(sessionsRef.current));
|
||||
writeSessionsForStorage(sessionsRef.current);
|
||||
persistTimerRef.current = null;
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
import type { Host } from "../../domain/models.ts";
|
||||
import { haveSameManagedSshAgentFields } from "./useManagedSourceSync.ts";
|
||||
import { STORAGE_KEY_MANAGED_SOURCES } from "../../infrastructure/config/storageKeys.ts";
|
||||
import { isVaultImportLockHeld } from "./vaultManagedImportLock.ts";
|
||||
import { haveSameManagedSshAgentFields, useManagedSourceSync } from "./useManagedSourceSync.ts";
|
||||
|
||||
const host: Host = {
|
||||
id: "host-1",
|
||||
@@ -54,3 +59,145 @@ test("managed SSH source comparison tracks identity file paths", () => {
|
||||
identityFilePaths: [...withPaths.identityFilePaths].reverse(),
|
||||
}), false);
|
||||
});
|
||||
|
||||
type TestHost = Host & { managedSourceId?: string };
|
||||
|
||||
const managedHost = (label: string): TestHost => ({
|
||||
...host,
|
||||
label,
|
||||
managedSourceId: "source-1",
|
||||
});
|
||||
|
||||
const managedSource = {
|
||||
id: "source-1",
|
||||
type: "ssh_config" as const,
|
||||
filePath: "/home/tester/.ssh/config",
|
||||
groupName: "Managed",
|
||||
lastSyncedAt: 0,
|
||||
};
|
||||
|
||||
function createFakeLockManager() {
|
||||
const tails = new Map<string, Promise<unknown>>();
|
||||
return {
|
||||
request<T>(name: string, callback: () => Promise<T>): Promise<T> {
|
||||
const previous = (tails.get(name) ?? Promise.resolve()) as Promise<unknown>;
|
||||
const run = previous.then(callback, callback) as Promise<T>;
|
||||
tails.set(name, run.then(() => undefined, () => undefined));
|
||||
return run;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("managed sync writes the latest persisted hosts, not the pre-edit snapshot", async () => {
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||
url: "http://localhost",
|
||||
});
|
||||
const globalAny = globalThis as Record<string, unknown>;
|
||||
const saved = {
|
||||
window: globalAny.window,
|
||||
document: globalAny.document,
|
||||
localStorage: globalAny.localStorage,
|
||||
navigator: Object.getOwnPropertyDescriptor(globalThis, "navigator"),
|
||||
actEnv: (globalAny as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT,
|
||||
};
|
||||
Object.defineProperty(globalThis, "window", { configurable: true, value: dom.window });
|
||||
Object.defineProperty(globalThis, "document", { configurable: true, value: dom.window.document });
|
||||
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: dom.window.localStorage });
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
configurable: true,
|
||||
value: { locks: createFakeLockManager() },
|
||||
});
|
||||
Object.defineProperty(globalAny, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
|
||||
|
||||
const container = dom.window.document.createElement("div");
|
||||
dom.window.document.body.appendChild(container);
|
||||
|
||||
try {
|
||||
dom.window.localStorage.setItem(
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
JSON.stringify([managedSource]),
|
||||
);
|
||||
|
||||
// Simulate updateHosts: the React state already holds the edited alias,
|
||||
// but the encrypted disk write is still queued and only lands later.
|
||||
const hostBeforeEdit = managedHost("aaaaaaaa");
|
||||
const hostAfterEdit = managedHost("bbbbbbbb");
|
||||
let persistedHosts: TestHost[] = [hostBeforeEdit];
|
||||
let resolveCommit!: () => void;
|
||||
const commit = new Promise<void>((resolve) => { resolveCommit = resolve; });
|
||||
setTimeout(() => {
|
||||
persistedHosts = [hostAfterEdit];
|
||||
resolveCommit();
|
||||
}, 20);
|
||||
|
||||
const onReadPersistedHosts = async (): Promise<Host[]> => {
|
||||
// Mirrors the real readPersistedHosts semantics: while the vault lock is
|
||||
// held, only the encrypt phase is drained (the disk write queued behind
|
||||
// that lock is not visible yet); outside the lock it also waits for the
|
||||
// queued disk write before reading.
|
||||
if (isVaultImportLockHeld("vault")) return persistedHosts;
|
||||
await commit;
|
||||
return persistedHosts;
|
||||
};
|
||||
|
||||
let written = "";
|
||||
(dom.window as unknown as Record<string, unknown>).netcatty = {
|
||||
readLocalFile: async () => new TextEncoder().encode(
|
||||
"# BEGIN NETCATTY MANAGED - DO NOT EDIT THIS BLOCK\n"
|
||||
+ "Host aaaaaaaa\n HostName managed.example.com\n"
|
||||
+ "# END NETCATTY MANAGED\n",
|
||||
).buffer,
|
||||
writeLocalFile: async (_filePath: string, buffer: ArrayBuffer) => {
|
||||
written = new TextDecoder().decode(buffer);
|
||||
},
|
||||
};
|
||||
|
||||
function Harness() {
|
||||
useManagedSourceSync({
|
||||
hosts: [hostAfterEdit],
|
||||
managedSources: [managedSource],
|
||||
onUpdateManagedSources: () => {},
|
||||
onReadPersistedHosts,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Harness));
|
||||
});
|
||||
|
||||
for (let i = 0; i < 250 && !written; i += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
|
||||
await act(async () => root.unmount());
|
||||
assert.ok(written, "managed sync never wrote the ssh_config file");
|
||||
assert.ok(written.includes("Host bbbbbbbb"), "sync wrote a stale alias");
|
||||
assert.ok(!written.includes("Host aaaaaaaa"), "sync wrote the pre-edit alias");
|
||||
} finally {
|
||||
container.remove();
|
||||
dom.window.close();
|
||||
for (const key of ["window", "document", "localStorage"] as const) {
|
||||
if (key in globalAny) {
|
||||
Object.defineProperty(globalThis, key, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
writable: true,
|
||||
value: saved[key],
|
||||
});
|
||||
} else {
|
||||
delete globalAny[key];
|
||||
}
|
||||
}
|
||||
if (saved.navigator) {
|
||||
Object.defineProperty(globalThis, "navigator", saved.navigator);
|
||||
} else {
|
||||
delete (globalThis as { navigator?: unknown }).navigator;
|
||||
}
|
||||
Object.defineProperty(globalAny, "IS_REACT_ACT_ENVIRONMENT", {
|
||||
configurable: true,
|
||||
value: saved.actEnv,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -156,6 +156,14 @@ export const useManagedSourceSync = ({
|
||||
|
||||
const syncManagedSource = useCallback(
|
||||
async (source: ManagedSource): Promise<{ sourceId: string; success: boolean }> => {
|
||||
// Drain any in-flight Vault host write BEFORE taking the vault lock.
|
||||
// updateHosts commits its encrypted write under the vault lock, so if this
|
||||
// sync acquires the lock first, readPersistedHosts runs while the lock is
|
||||
// held and only waits for the encrypt phase — returning the previous host
|
||||
// snapshot and writing the stale alias back to the file (lagging one edit
|
||||
// behind, see issue #3259). Waiting outside the lock also waits for the
|
||||
// queued disk write, so the read below sees the just-edited hosts.
|
||||
await onReadPersistedHosts();
|
||||
return withVaultImportLock("vault", async () => {
|
||||
const persistedSources = localStorageAdapter.read<ManagedSource[]>(
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
|
||||
@@ -385,7 +385,10 @@ export const usePortForwardingAutoStart = ({
|
||||
return startPortForward(rule, host, resolveEffectiveHosts(hostsRef.current), keysRef.current, identitiesRef.current, onStatusChange, true, terminalSettingsRef.current, knownHostsRef.current);
|
||||
};
|
||||
|
||||
setReconnectCallback(handleReconnect);
|
||||
setReconnectCallback(handleReconnect, (ruleId) => isPortForwardingAutoStartEnabled(
|
||||
localStorageAdapter.read<PortForwardingRule[]>(STORAGE_KEY_PORT_FORWARDING) ?? [],
|
||||
ruleId,
|
||||
));
|
||||
return () => {
|
||||
setReconnectCallback(null);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
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");
|
||||
}
|
||||
});
|
||||
@@ -59,7 +59,12 @@ import type { SessionRestorePayload } from '../../domain/sessionRestore';
|
||||
import { resolveRestorePreviousSessionSetting } from './sessionRestoreSettings';
|
||||
import type { CodingCliProviderId } from '../../domain/codingCliProviders';
|
||||
import { normalizeCodingCliDynamicTitleForStorage } from '../../domain/codingCliTitleParse';
|
||||
import { DEFAULT_WORKSPACE_TITLE } from '../../domain/sessionTabTitle';
|
||||
import {
|
||||
DEFAULT_WORKSPACE_TITLE,
|
||||
buildMergedWorkspaceTitle,
|
||||
buildMergedWorkspaceTitleFromSessions,
|
||||
getSessionConnectionLabel,
|
||||
} from '../../domain/sessionTabTitle';
|
||||
import { cleanupClosedTerminalSessions } from './aiStateSnapshots';
|
||||
|
||||
export function addWorkspaceIfMissing(
|
||||
@@ -228,6 +233,8 @@ 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);
|
||||
@@ -260,6 +267,41 @@ export const useSessionState = ({
|
||||
}
|
||||
}, [initialRestoreState.activeTabId]);
|
||||
|
||||
// Keep generated merged-workspace titles (e.g. "01/02" after dragging tab
|
||||
// "02" onto "01") synchronized with the workspace's current membership and
|
||||
// each pane's connection label. `autoTitle: false` makes
|
||||
// resolveWorkspaceTabLabel treat the composed title as a user-chosen name,
|
||||
// so without this pass a pane rename or a pane being added/removed would
|
||||
// leave the label stale. Runs off session changes and only touches
|
||||
// workspaces still flagged `generatedTitle` (a user rename clears it).
|
||||
useEffect(() => {
|
||||
setWorkspaces(prev => {
|
||||
let changed = false;
|
||||
const next = prev.map(ws => {
|
||||
if (!ws.generatedTitle) return ws;
|
||||
// Order members by the workspace's pane tree (base/join or split
|
||||
// layout), not by global session creation order — merging a newer tab
|
||||
// onto an older one must keep the merged title as created (e.g.
|
||||
// "02/01"), while `sessions.filter` would rewrite it to "01/02".
|
||||
const rootIds = collectSessionIds(ws.root);
|
||||
const members = [
|
||||
...rootIds
|
||||
.map(id => sessions.find(s => s.id === id))
|
||||
.filter((s): s is TerminalSession => Boolean(s)),
|
||||
// Safety net for transient membership: any session assigned to this
|
||||
// workspace whose pane has not landed in `root` yet keeps its
|
||||
// previous (creation-order) position after the pane-tree members.
|
||||
...sessions.filter(s => s.workspaceId === ws.id && !rootIds.includes(s.id)),
|
||||
];
|
||||
const recomputed = buildMergedWorkspaceTitleFromSessions(members) ?? DEFAULT_WORKSPACE_TITLE;
|
||||
if (recomputed === ws.title) return ws;
|
||||
changed = true;
|
||||
return { ...ws, title: recomputed };
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [sessions]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleRestorePreviousSessionChanged = (key?: string) => {
|
||||
if (key !== STORAGE_KEY_RESTORE_PREVIOUS_SESSION) return;
|
||||
@@ -622,11 +664,16 @@ export const useSessionState = ({
|
||||
}, []);
|
||||
|
||||
const submitWorkspaceRename = useCallback((workspaceId?: string, name?: string) => {
|
||||
// A user rename takes ownership of the title: clear `generatedTitle` so a
|
||||
// previously synthesized merged title ("01/02") stops being auto-updated.
|
||||
const applyRename = (w: Workspace, trimmed: string): Workspace => (
|
||||
{ ...w, title: trimmed, autoTitle: false, generatedTitle: false }
|
||||
);
|
||||
if (workspaceId !== undefined && name !== undefined) {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
setWorkspaces(prev => prev.map(w => (
|
||||
w.id === workspaceId ? { ...w, title: trimmed, autoTitle: false } : w
|
||||
w.id === workspaceId ? applyRename(w, trimmed) : w
|
||||
)));
|
||||
return;
|
||||
}
|
||||
@@ -637,7 +684,7 @@ export const useSessionState = ({
|
||||
|
||||
setWorkspaceRenameTarget(prevTarget => {
|
||||
if (!prevTarget) return prevTarget;
|
||||
setWorkspaces(prev => prev.map(w => w.id === prevTarget.id ? { ...w, title: trimmed, autoTitle: false } : w));
|
||||
setWorkspaces(prev => prev.map(w => w.id === prevTarget.id ? applyRename(w, trimmed) : w));
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -729,7 +776,29 @@ export const useSessionState = ({
|
||||
hint: SplitHint
|
||||
) => {
|
||||
if (!hint || baseSessionId === joiningSessionId) return;
|
||||
const newWorkspace = createWorkspaceEntity(baseSessionId, joiningSessionId, hint);
|
||||
// Name the merged workspace after both tabs' connection labels (e.g.
|
||||
// "01/02") so the window title and the rename dialog show the merged
|
||||
// identity instead of the generic "Workspace". Falls back to the default
|
||||
// auto title when neither tab has a label. The explicit `autoTitle: false`
|
||||
// makes tab labels use this composed name too; `generatedTitle: true`
|
||||
// marks it as synthesized so it keeps tracking membership/renames (see the
|
||||
// generated-title sync effect below) instead of going stale.
|
||||
// Compose the title from the created pane-tree order (not base/join
|
||||
// order): for left/top merges `createWorkspaceEntity` places the joining
|
||||
// pane before the base pane in `root`, and the generated-title sync
|
||||
// effect above recomputes from `root` order — building the initial title
|
||||
// in tree order keeps it stable instead of renaming itself right after
|
||||
// creation.
|
||||
const created = createWorkspaceEntity(baseSessionId, joiningSessionId, hint);
|
||||
const treeLabels = collectSessionIds(created.root).map((id) =>
|
||||
getSessionConnectionLabel(
|
||||
sessionsRef.current.find(s => s.id === id) ?? { customName: '', hostLabel: '' },
|
||||
),
|
||||
);
|
||||
const mergedTitle = buildMergedWorkspaceTitle(treeLabels[0] ?? '', treeLabels[1] ?? '');
|
||||
const newWorkspace = mergedTitle
|
||||
? { ...created, title: mergedTitle, autoTitle: false, generatedTitle: true }
|
||||
: created;
|
||||
|
||||
setSessions((prevSessions) => {
|
||||
const base = prevSessions.find((s) => s.id === baseSessionId);
|
||||
@@ -1054,6 +1123,12 @@ 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);
|
||||
@@ -1205,6 +1280,14 @@ 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),
|
||||
@@ -1328,9 +1411,12 @@ export const useSessionState = ({
|
||||
moveFocusInWorkspace,
|
||||
runSnippet,
|
||||
orphanSessions,
|
||||
canUseGlobalBroadcast,
|
||||
// Broadcast mode
|
||||
toggleBroadcast,
|
||||
isBroadcastEnabled,
|
||||
toggleGlobalBroadcast,
|
||||
isGlobalBroadcastEnabled,
|
||||
orderedTabs,
|
||||
getOrderedWorkTabs,
|
||||
reorderTabs,
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
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("merged tab titles follow the workspace lifecycle until the user renames it", async (t) => {
|
||||
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,
|
||||
});
|
||||
t.after(() => {
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, "window");
|
||||
});
|
||||
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
};
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
t.after(() => {
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
});
|
||||
|
||||
let state: SessionState | null = null;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
const current = (): SessionState => {
|
||||
assert.ok(state);
|
||||
return state;
|
||||
};
|
||||
const workspace = (workspaceId: string) => {
|
||||
const found = current().workspaces.find((candidate) => candidate.id === workspaceId);
|
||||
assert.ok(found);
|
||||
return found;
|
||||
};
|
||||
function Probe() {
|
||||
state = useSessionState({ persistSessionRestore: false });
|
||||
return null;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
});
|
||||
|
||||
let firstSessionId = "";
|
||||
let secondSessionId = "";
|
||||
await act(async () => {
|
||||
firstSessionId = current().createLocalTerminal({ shellName: "01" });
|
||||
secondSessionId = current().createLocalTerminal({ shellName: "02" });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
current().createWorkspaceFromSessions(firstSessionId, secondSessionId, {
|
||||
direction: "vertical",
|
||||
position: "right",
|
||||
targetSessionId: firstSessionId,
|
||||
});
|
||||
});
|
||||
|
||||
const workspaceId = current().workspaces[0]?.id;
|
||||
assert.ok(workspaceId);
|
||||
assert.equal(workspace(workspaceId).title, "01/02");
|
||||
assert.equal(workspace(workspaceId).autoTitle, false);
|
||||
assert.equal(workspace(workspaceId).generatedTitle, true);
|
||||
|
||||
await act(async () => {
|
||||
current().startWorkspaceRename(workspaceId);
|
||||
});
|
||||
assert.equal(current().workspaceRenameValue, "01/02");
|
||||
|
||||
await act(async () => {
|
||||
current().resetWorkspaceRename();
|
||||
current().renameSessionInline(secondSessionId, "prod");
|
||||
});
|
||||
assert.equal(workspace(workspaceId).title, "01/prod");
|
||||
|
||||
let thirdSessionId = "";
|
||||
await act(async () => {
|
||||
thirdSessionId = current().createLocalTerminal({ shellName: "03" });
|
||||
});
|
||||
await act(async () => {
|
||||
current().addSessionToWorkspace(workspaceId, thirdSessionId, {
|
||||
direction: "vertical",
|
||||
position: "right",
|
||||
targetSessionId: secondSessionId,
|
||||
});
|
||||
});
|
||||
assert.equal(workspace(workspaceId).title, "01/prod/03");
|
||||
|
||||
await act(async () => {
|
||||
current().removeSessionFromWorkspace(thirdSessionId);
|
||||
});
|
||||
assert.equal(workspace(workspaceId).title, "01/prod");
|
||||
|
||||
const workspaceIdsBeforeCopy = new Set(current().workspaces.map((candidate) => candidate.id));
|
||||
await act(async () => {
|
||||
current().copyWorkspace(workspaceId);
|
||||
});
|
||||
const copiedWorkspace = current().workspaces.find(
|
||||
(candidate) => !workspaceIdsBeforeCopy.has(candidate.id),
|
||||
);
|
||||
assert.ok(copiedWorkspace);
|
||||
assert.equal(copiedWorkspace.title, "01/prod");
|
||||
assert.equal(copiedWorkspace.autoTitle, false);
|
||||
assert.equal(copiedWorkspace.generatedTitle, true);
|
||||
|
||||
await act(async () => {
|
||||
current().submitWorkspaceRename(workspaceId, "Pinned");
|
||||
current().renameSessionInline(firstSessionId, "ops");
|
||||
});
|
||||
assert.equal(workspace(workspaceId).title, "Pinned");
|
||||
assert.equal(workspace(workspaceId).generatedTitle, false);
|
||||
|
||||
let leftBaseSessionId = "";
|
||||
let leftJoiningSessionId = "";
|
||||
await act(async () => {
|
||||
leftBaseSessionId = current().createLocalTerminal({ shellName: "04" });
|
||||
leftJoiningSessionId = current().createLocalTerminal({ shellName: "05" });
|
||||
});
|
||||
await act(async () => {
|
||||
current().createWorkspaceFromSessions(leftBaseSessionId, leftJoiningSessionId, {
|
||||
direction: "vertical",
|
||||
position: "left",
|
||||
targetSessionId: leftBaseSessionId,
|
||||
});
|
||||
});
|
||||
const leftWorkspace = current().workspaces.find((candidate) => candidate.title === "05/04");
|
||||
assert.ok(leftWorkspace);
|
||||
assert.equal(leftWorkspace.generatedTitle, true);
|
||||
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
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, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
|
||||
/**
|
||||
@@ -17,30 +17,37 @@ export const useStoredBoolean = (
|
||||
const stored = localStorageAdapter.readBoolean(storageKey);
|
||||
return stored ?? fallback;
|
||||
});
|
||||
const valueRef = useRef(value);
|
||||
|
||||
const setAndPersist = useCallback((next: boolean | ((prev: boolean) => boolean)) => {
|
||||
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;
|
||||
});
|
||||
// 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 } }),
|
||||
);
|
||||
}, [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) setValue(newValue);
|
||||
if (key === storageKey) {
|
||||
valueRef.current = newValue;
|
||||
setValue(newValue);
|
||||
}
|
||||
};
|
||||
// Sync from other windows
|
||||
const handleStorage = (e: StorageEvent) => {
|
||||
if (e.key === storageKey) {
|
||||
const stored = localStorageAdapter.readBoolean(storageKey);
|
||||
setValue(stored ?? fallback);
|
||||
const nextValue = stored ?? fallback;
|
||||
valueRef.current = nextValue;
|
||||
setValue(nextValue);
|
||||
}
|
||||
};
|
||||
window.addEventListener("stored-boolean-change", handleCustom);
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { SyncPayload } from "../domain/sync.ts";
|
||||
import type { KnownHost } from "../domain/models.ts";
|
||||
import type { SyncableVaultData } from "./syncPayload.ts";
|
||||
import { parseTerminalFontSizeRecord } from "./state/terminalFontSizeSync.ts";
|
||||
import commandBlocklistTable from "../lib/commandBlocklist.json";
|
||||
|
||||
type LocalStorageMock = {
|
||||
clear(): void;
|
||||
@@ -56,6 +57,7 @@ const {
|
||||
const storageKeys = await import("../infrastructure/config/storageKeys.ts");
|
||||
const { SYNC_STORAGE_KEYS } = await import("../domain/sync.ts");
|
||||
const { localStorageAdapter } = await import("../infrastructure/persistence/localStorageAdapter.ts");
|
||||
const { readCommandBlocklistSetting } = await import("./state/commandBlocklistSettings.ts");
|
||||
|
||||
const knownHost = (id = "kh-1"): KnownHost => ({
|
||||
id,
|
||||
@@ -642,6 +644,87 @@ test("applySyncPayload restores AI configuration settings", async () => {
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION), "false");
|
||||
});
|
||||
|
||||
test("applySyncPayload migrates an untouched legacy command blocklist", async () => {
|
||||
const legacyDefaults = [
|
||||
...commandBlocklistTable.common,
|
||||
...commandBlocklistTable.posixNative,
|
||||
...commandBlocklistTable.posix,
|
||||
];
|
||||
|
||||
await applySyncPayload({
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
settings: {
|
||||
ai: {
|
||||
commandBlocklist: legacyDefaults,
|
||||
},
|
||||
},
|
||||
syncedAt: 1,
|
||||
} as SyncPayload, { importVaultData: () => {} });
|
||||
|
||||
assert.deepEqual(
|
||||
JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_COMMAND_BLOCKLIST)!),
|
||||
[...legacyDefaults, ...commandBlocklistTable.powershell],
|
||||
);
|
||||
assert.deepEqual(
|
||||
readCommandBlocklistSetting(),
|
||||
[...legacyDefaults, ...commandBlocklistTable.powershell],
|
||||
);
|
||||
});
|
||||
|
||||
test("command blocklist read migrates an untouched local legacy list", () => {
|
||||
const legacyDefaults = [
|
||||
...commandBlocklistTable.common,
|
||||
...commandBlocklistTable.posixNative,
|
||||
...commandBlocklistTable.posix,
|
||||
];
|
||||
const currentDefaults = [...legacyDefaults, ...commandBlocklistTable.powershell];
|
||||
|
||||
assert.deepEqual(readCommandBlocklistSetting(), currentDefaults);
|
||||
assert.deepEqual(
|
||||
JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_COMMAND_BLOCKLIST)!),
|
||||
currentDefaults,
|
||||
);
|
||||
|
||||
localStorage.setItem(
|
||||
storageKeys.STORAGE_KEY_AI_COMMAND_BLOCKLIST,
|
||||
JSON.stringify([...legacyDefaults, "old-client-user-rule"]),
|
||||
);
|
||||
assert.deepEqual(readCommandBlocklistSetting(), [
|
||||
...legacyDefaults,
|
||||
"old-client-user-rule",
|
||||
...commandBlocklistTable.powershell,
|
||||
]);
|
||||
});
|
||||
|
||||
test("applySyncPayload preserves a customized PowerShell blocklist", async () => {
|
||||
const currentCustomized = [
|
||||
...commandBlocklistTable.common,
|
||||
...commandBlocklistTable.posixNative,
|
||||
...commandBlocklistTable.posix,
|
||||
...commandBlocklistTable.powershell.slice(1),
|
||||
];
|
||||
|
||||
await applySyncPayload({
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
settings: {
|
||||
ai: {
|
||||
commandBlocklist: currentCustomized,
|
||||
},
|
||||
},
|
||||
syncedAt: 1,
|
||||
} as SyncPayload, { importVaultData: () => {} });
|
||||
|
||||
assert.deepEqual(readCommandBlocklistSetting(), currentCustomized);
|
||||
});
|
||||
|
||||
test("applySyncPayload encrypts synced plaintext AI API keys before saving locally", async () => {
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
value: {
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
sanitizeHostsForSync,
|
||||
type SyncPayload,
|
||||
} from '../domain/sync';
|
||||
import { migrateLegacyCommandBlocklist } from '../domain/commandBlocklist';
|
||||
import { migrateHostsFromLegacyLineTimestamps } from '../domain/host';
|
||||
import { toPersistedPortForwardingRules } from '../domain/portForwardingPersistence';
|
||||
import {
|
||||
@@ -43,6 +44,10 @@ import { localStorageAdapter } from '../infrastructure/persistence/localStorageA
|
||||
import { decryptField, encryptField } from '../infrastructure/persistence/secureFieldAdapter';
|
||||
import { sanitizeQuickMessages } from '../infrastructure/ai/quickMessages';
|
||||
import { emitAIStateChanged } from './state/aiStateEvents';
|
||||
import {
|
||||
persistCommandBlocklistSetting,
|
||||
readCommandBlocklistSetting,
|
||||
} from './state/commandBlocklistSettings';
|
||||
import { rehydrateGlobalSftpBookmarks } from './state/sftp/globalSftpBookmarks';
|
||||
import {
|
||||
nextTerminalFontSizeSyncVersion,
|
||||
@@ -572,8 +577,10 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
// externalAgents intentionally not collected: command/args/env are device-local.
|
||||
const defaultAgentId = localStorageAdapter.readString(STORAGE_KEY_AI_DEFAULT_AGENT);
|
||||
if (defaultAgentId != null) ai.defaultAgentId = defaultAgentId;
|
||||
const commandBlocklist = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
|
||||
if (Array.isArray(commandBlocklist)) ai.commandBlocklist = commandBlocklist;
|
||||
const storedCommandBlocklist = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
|
||||
if (Array.isArray(storedCommandBlocklist)) {
|
||||
ai.commandBlocklist = readCommandBlocklistSetting();
|
||||
}
|
||||
const commandTimeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT);
|
||||
if (commandTimeout != null && Number.isFinite(commandTimeout)) ai.commandTimeout = commandTimeout;
|
||||
const responseIdleTimeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT);
|
||||
@@ -824,7 +831,9 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
|
||||
// externalAgents intentionally not applied: device-local. Legacy snapshots
|
||||
// that still carry an `externalAgents` field are silently ignored.
|
||||
if (ai.defaultAgentId != null) localStorageAdapter.writeString(STORAGE_KEY_AI_DEFAULT_AGENT, ai.defaultAgentId);
|
||||
if (ai.commandBlocklist != null) localStorageAdapter.write(STORAGE_KEY_AI_COMMAND_BLOCKLIST, ai.commandBlocklist);
|
||||
if (ai.commandBlocklist != null) {
|
||||
persistCommandBlocklistSetting(migrateLegacyCommandBlocklist(ai.commandBlocklist));
|
||||
}
|
||||
if (ai.commandTimeout != null) localStorageAdapter.writeNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT, ai.commandTimeout);
|
||||
if (ai.responseIdleTimeout != null) {
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT, ai.responseIdleTimeout);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
applyEffectiveHostAuthMethodSelection,
|
||||
detachEffectiveHostIdentity,
|
||||
HOST_AUTH_METHOD_CHOICES,
|
||||
HOST_OS_CHOICES,
|
||||
HostDetailsConnectionSections,
|
||||
removeSelectedHostCredential,
|
||||
shouldForceAuthMethodReselect,
|
||||
@@ -111,6 +112,28 @@ test("color and icon settings render for non-Linux hosts", () => {
|
||||
assert.match(markup, /hostDetails\.icon\.manualLabel/);
|
||||
});
|
||||
|
||||
test("host operating system can be selected in the ordinary host editor", () => {
|
||||
const markup = renderConnectionSections({ os: "windows" });
|
||||
|
||||
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", () => {
|
||||
const markup = renderConnectionSections({
|
||||
authMethod: "auto",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,6 +10,13 @@ test("main SftpView keeps browse sessions across top-tab switches", () => {
|
||||
assert.doesNotMatch(source, /interactive:\s*isActive/);
|
||||
});
|
||||
|
||||
test("Open SFTP always restores dual-pane layout through the planner", () => {
|
||||
const source = readFileSync(new URL("./SftpView.tsx", import.meta.url), "utf8");
|
||||
assert.match(source, /setMagnifiedSide\(null\);\s*applyDualPaneSftpOpen/);
|
||||
assert.match(source, /applyDualPaneSftpOpen/);
|
||||
assert.doesNotMatch(source, /firstVisitBothEmpty/);
|
||||
});
|
||||
|
||||
test("SFTP magnification overlays one side while preserving the original two-pane geometry", async () => {
|
||||
const { resolveTwoPaneMagnificationStyle } = await import("../domain/paneMagnification.ts");
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import { activeTabStore as globalActiveTabStore, useIsSftpActive } from "../appl
|
||||
import { useSftpState } from "../application/state/useSftpState";
|
||||
import { useSftpBackend } from "../application/state/useSftpBackend";
|
||||
import { getParentPath, isConcreteTransferTargetPath } from "../application/state/sftp/utils";
|
||||
import { buildCacheKey } from "../application/state/sftp/sharedRemoteHostCache";
|
||||
import { HotkeyScheme, KeyBinding, TerminalSession } from "../domain/models";
|
||||
import {
|
||||
getPaneMagnificationShortcutLabel,
|
||||
@@ -27,6 +28,11 @@ import {
|
||||
type PaneMagnificationController,
|
||||
} from "../domain/paneMagnification";
|
||||
import { listSftpConnectedHosts, resolveSftpTransferSourceSessionId, sftpPickerSessionsEqual } from "../domain/sftpConnectedHosts";
|
||||
import { applyDualPaneSftpOpen, dualPaneTabFromPane } from "../domain/sftpDualPaneOpen";
|
||||
import {
|
||||
consumePendingDualPaneSftpRequest,
|
||||
subscribeDualPaneSftpOpen,
|
||||
} from "../application/state/sftp/sftpDualPaneOpenStore";
|
||||
import { logger } from "../lib/logger";
|
||||
import { useRenderTracker } from "../lib/useRenderTracker";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -193,6 +199,54 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
// without needing to re-create when sftp changes
|
||||
const sftpRef = useRef(sftp);
|
||||
sftpRef.current = sftp;
|
||||
const effectiveHostsRef = useRef(effectiveHosts);
|
||||
effectiveHostsRef.current = effectiveHosts;
|
||||
|
||||
useEffect(() => {
|
||||
const toTabs = (panes: Array<{
|
||||
id: string;
|
||||
connection: { id: string; isLocal?: boolean; hostId?: string | null; status?: string | null } | null;
|
||||
}>, getEndpointKey: (connectionId: string) => string | null) => panes.map(
|
||||
(pane) => dualPaneTabFromPane(
|
||||
pane,
|
||||
pane.connection ? getEndpointKey(pane.connection.id) : null,
|
||||
),
|
||||
);
|
||||
|
||||
const applyRequest = (request: { hostId: string } | null) => {
|
||||
if (!request) return;
|
||||
const host = effectiveHostsRef.current.find((candidate) => candidate.id === request.hostId);
|
||||
if (!host) return;
|
||||
const current = sftpRef.current;
|
||||
const hostEndpointKey = buildCacheKey(
|
||||
host.id,
|
||||
host.hostname,
|
||||
host.port,
|
||||
host.protocol,
|
||||
host.sftpSudo,
|
||||
host.username,
|
||||
host.sftpFileProtocol,
|
||||
);
|
||||
// External "Open SFTP" promises a visible local-left / host-right pair.
|
||||
// Clear a stale single-pane magnification before selecting or connecting.
|
||||
setMagnifiedSide(null);
|
||||
applyDualPaneSftpOpen(
|
||||
{
|
||||
leftTabs: toTabs(current.leftTabs.tabs, current.getConnectionCacheKey),
|
||||
rightTabs: toTabs(current.rightTabs.tabs, current.getConnectionCacheKey),
|
||||
selectTab: current.selectTab,
|
||||
connect: (side, nextHost, options) => {
|
||||
void current.connect(side, nextHost === "local" ? "local" : host, options);
|
||||
},
|
||||
},
|
||||
host,
|
||||
hostEndpointKey,
|
||||
);
|
||||
};
|
||||
|
||||
applyRequest(consumePendingDualPaneSftpRequest());
|
||||
return subscribeDualPaneSftpOpen(applyRequest);
|
||||
}, []);
|
||||
|
||||
// Register this useSftpState's writeTextFileByConnection with the bridge so
|
||||
// the editor tab's save path can reach the active SFTP session. The bridge
|
||||
|
||||
@@ -212,6 +212,7 @@ import {
|
||||
import {
|
||||
releaseTerminalFlowBeforeHibernate,
|
||||
} from "./terminal/runtime/terminalSessionAttachment";
|
||||
import { resetTerminalLineTimestamps } from "./terminal/runtime/terminalLineTimestamps";
|
||||
import {
|
||||
flushPendingTerminalWritesBeforeHibernate,
|
||||
hasPendingTerminalWrites,
|
||||
@@ -1631,6 +1632,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
if (term) {
|
||||
term.reset();
|
||||
// The buffer was just wiped; drop ledger stamps anchored to the old
|
||||
// rows so the reconnect gutter cannot paint stale timestamps.
|
||||
resetTerminalLineTimestamps(term);
|
||||
if (payload.snapshot) {
|
||||
await new Promise<void>((resolve) => term.write(payload.snapshot, resolve));
|
||||
}
|
||||
|
||||
@@ -46,6 +46,9 @@ const baseProps = {
|
||||
onSetWorkspaceFocusedSession: () => {},
|
||||
isBroadcastEnabled: () => false,
|
||||
onToggleBroadcast: () => {},
|
||||
isGlobalBroadcastEnabled: false,
|
||||
canUseGlobalBroadcast: false,
|
||||
onToggleGlobalBroadcast: () => {},
|
||||
updateSnippets: () => {},
|
||||
updateSnippetPackages: () => {},
|
||||
onSplitSession: () => {},
|
||||
@@ -121,6 +124,23 @@ 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,6 +67,7 @@ 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,
|
||||
@@ -246,6 +247,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onCreateLocalTerminal,
|
||||
isBroadcastEnabled,
|
||||
onToggleBroadcast,
|
||||
isGlobalBroadcastEnabled,
|
||||
onToggleGlobalBroadcast,
|
||||
canUseGlobalBroadcast,
|
||||
updateHosts,
|
||||
updateSnippets,
|
||||
updateSnippetPackages,
|
||||
@@ -1049,27 +1053,25 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const sessionHostsMapRef = useRef(sessionHostsMap);
|
||||
sessionHostsMapRef.current = sessionHostsMap;
|
||||
|
||||
// Handle broadcast input - write to all other sessions in the source workspace.
|
||||
// Resolve the active broadcast mode, then write to its target sessions.
|
||||
const handleBroadcastInput = useCallback((
|
||||
data: string,
|
||||
sourceSessionId: string,
|
||||
options?: TerminalBroadcastInputOptions,
|
||||
) => {
|
||||
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 targetSessionIds = resolveTerminalBroadcastTargetIds({
|
||||
sessions: sessionsRef.current,
|
||||
sourceSessionId,
|
||||
globalBroadcastEnabled: isGlobalBroadcastEnabled,
|
||||
directTargetSessionIds: options?.kittyKeyboardTargetSessionIds,
|
||||
});
|
||||
if (targetSessionIds.length === 0) return [];
|
||||
|
||||
const targetSessionIdSet = new Set(targetSessionIds);
|
||||
const deliveredSessionIds: string[] = [];
|
||||
|
||||
for (const session of sessionsRef.current) {
|
||||
if (directTargetSet) {
|
||||
if (!directTargetSet.has(session.id)) continue;
|
||||
} else if (session.workspaceId !== workspaceId || session.id === sourceSessionId) {
|
||||
continue;
|
||||
}
|
||||
if (!targetSessionIdSet.has(session.id)) continue;
|
||||
if (!canUseDirectSessionWriteFallback(session)) continue;
|
||||
|
||||
if (options?.kittyKeyboardInput) {
|
||||
@@ -1107,7 +1109,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
deliveredSessionIds.push(session.id);
|
||||
}
|
||||
return deliveredSessionIds;
|
||||
}, [terminalBackend]);
|
||||
}, [terminalBackend, isGlobalBroadcastEnabled]);
|
||||
|
||||
const handleCommandSubmitted = useCallback((command: string, _hostId: string, _hostLabel: string, sessionId: string) => {
|
||||
codingCliSignalController.handleCommandSubmitted(sessionId, command);
|
||||
@@ -1297,6 +1299,9 @@ 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],
|
||||
@@ -2298,6 +2303,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
restoreTerminalCwd,
|
||||
identities,
|
||||
isBroadcastEnabled,
|
||||
isGlobalBroadcastEnabled,
|
||||
canUseGlobalBroadcast,
|
||||
isComposeBarOpen,
|
||||
keyBindings,
|
||||
keys,
|
||||
@@ -2334,6 +2341,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onSplitSession,
|
||||
onSplitSessionRef,
|
||||
onToggleBroadcastRef,
|
||||
onToggleGlobalBroadcastRef,
|
||||
onToggleWorkspaceViewMode,
|
||||
onToggleWorkspaceViewModeRef,
|
||||
onUpdateHost,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const source = fs.readFileSync(
|
||||
path.join(path.dirname(fileURLToPath(import.meta.url)), "HostTreeContextMenus.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
test("host context menu offers dual-pane SFTP without extra prop drilling", () => {
|
||||
assert.match(source, /OpenDualPaneSftpMenuItem/);
|
||||
assert.match(source, /requestOpenDualPaneSftp/);
|
||||
assert.match(source, /vault\.hosts\.openSftp/);
|
||||
assert.match(source, /useSettingsChromeStore/);
|
||||
assert.match(source, /!showSftpTab\s*\|\|\s*!canOpenDualPaneSftp/);
|
||||
});
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Copy, FileSymlink, Folder, FolderOpen, Monitor, Pencil, Plus, Server, Settings2 } from 'lucide-react';
|
||||
import { Copy, FileSymlink, Files, Folder, FolderOpen, Monitor, Pencil, Plus, Server, Settings2 } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { requestOpenDualPaneSftp } from '../../application/state/sftp/sftpDualPaneOpenStore';
|
||||
import { useSettingsChromeStore } from '../../application/state/settingsChromeStore';
|
||||
import { sanitizeHost } from '../../domain/host';
|
||||
import { isPluginHostProtocol } from '../../domain/pluginConnection';
|
||||
import { canOpenDualPaneSftp } from '../../domain/sftpDualPaneOpen';
|
||||
import type { Host } from '../../types';
|
||||
import { ContextMenuContent, ContextMenuItem, ContextMenuShortcut } from '../ui/context-menu';
|
||||
import { collectOwnedPluginMenus, comparePluginMenus, usePluginContributions } from '../../application/state/usePluginContributions';
|
||||
@@ -19,6 +22,17 @@ export interface HostTreeHostContextMenuHandlers {
|
||||
onDeleteHost: (host: Host) => void;
|
||||
}
|
||||
|
||||
export const OpenDualPaneSftpMenuItem: React.FC<{ host: Host }> = ({ host }) => {
|
||||
const { t } = useI18n();
|
||||
const { showSftpTab } = useSettingsChromeStore();
|
||||
if (!showSftpTab || !canOpenDualPaneSftp(host)) return null;
|
||||
return (
|
||||
<ContextMenuItem onClick={() => requestOpenDualPaneSftp(host.id)}>
|
||||
<Files className="mr-2 h-4 w-4" /> {t('vault.hosts.openSftp')}
|
||||
</ContextMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
export const HostTreeHostContextMenuContent: React.FC<
|
||||
HostTreeHostContextMenuHandlers & { host: Host }
|
||||
> = ({
|
||||
@@ -50,6 +64,7 @@ export const HostTreeHostContextMenuContent: React.FC<
|
||||
<ContextMenuItem onClick={() => onConnect(safeHost)}>
|
||||
<Monitor className="mr-2 h-4 w-4" /> {t('vault.hosts.connect')}
|
||||
</ContextMenuItem>
|
||||
<OpenDualPaneSftpMenuItem host={safeHost} />
|
||||
{onEditHost && (
|
||||
<ContextMenuItem onClick={() => onEditHost(host)}>
|
||||
<Settings2 className="mr-2 h-4 w-4" /> {t('terminal.layer.hostTree.editHost')}
|
||||
|
||||
@@ -367,8 +367,15 @@ test("note editor registers a code block editor for pasted fenced code", () => {
|
||||
source,
|
||||
/codeBlockPlugin\([^)]*\),\s*codeMirrorPlugin\(\{\s*codeBlockLanguages:/s,
|
||||
);
|
||||
assert.match(source, /codeMirrorExtensions:\s*NOTE_CODE_MIRROR_EXTENSIONS/);
|
||||
assert.match(source, /codeMirrorExtensions:\s*\[\s*\.\.\.NOTE_CODE_MIRROR_EXTENSIONS,/);
|
||||
assert.match(source, /syntaxHighlighting\(noteCodeHighlightStyle\)/);
|
||||
// Tooltips mount on body to escape clipped code blocks, so their placement
|
||||
// must be bounded to this editor instead of the whole window.
|
||||
assert.match(
|
||||
source,
|
||||
/createNoteCodeTooltipExtensions\(\s*typeof document === "undefined" \? undefined : document\.body,/,
|
||||
);
|
||||
assert.match(source, /\(\) => containerRef\.current,/);
|
||||
});
|
||||
|
||||
test("note editor enables image plugin for remote markdown images", () => {
|
||||
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
} from "./noteClipboardPaste";
|
||||
import { annotateNoteImageSizes } from "./noteImageLayout";
|
||||
import { renderNoteMathFormula } from "./noteMathRenderer";
|
||||
import { createNoteCodeTooltipExtensions } from "./noteCodeTooltips";
|
||||
import {
|
||||
EMPTY_ACTIVE_FORMATS,
|
||||
type ActiveTextFormats,
|
||||
@@ -1203,7 +1204,16 @@ export const InlineMarkdownEditor = React.memo(
|
||||
codeBlockPlugin({ defaultCodeBlockLanguage: "" }),
|
||||
codeMirrorPlugin({
|
||||
codeBlockLanguages: NOTE_CODE_BLOCK_LANGUAGES,
|
||||
codeMirrorExtensions: NOTE_CODE_MIRROR_EXTENSIONS,
|
||||
codeMirrorExtensions: [
|
||||
...NOTE_CODE_MIRROR_EXTENSIONS,
|
||||
...createNoteCodeTooltipExtensions(
|
||||
typeof document === "undefined" ? undefined : document.body,
|
||||
// Tooltips escape clipped code blocks via the body parent; bound
|
||||
// their placement to this notes editor so a completion near a pane
|
||||
// edge flips or shrinks instead of rendering over an adjacent pane.
|
||||
() => containerRef.current,
|
||||
),
|
||||
],
|
||||
}),
|
||||
markdownShortcutPlugin(),
|
||||
noteEditorDialogBridgePlugin({
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView, showTooltip } from "@codemirror/view";
|
||||
import { syncNoteCodeTooltipStyle, createNoteCodeTooltipExtensions, getNoteTooltipSpace } from "./noteCodeTooltips";
|
||||
|
||||
const stubRect = (element: Element, top: number, left: number, right: number, bottom: number) => {
|
||||
element.getBoundingClientRect = () => ({
|
||||
top,
|
||||
left,
|
||||
right,
|
||||
bottom,
|
||||
width: right - left,
|
||||
height: bottom - top,
|
||||
x: left,
|
||||
y: top,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect);
|
||||
};
|
||||
|
||||
test("note tooltips escape clipped code blocks and disappear with their editor", () => {
|
||||
const dom = new JSDOM('<div id="note" style="overflow:hidden;height:20px"></div>', {
|
||||
pretendToBeVisual: true,
|
||||
});
|
||||
const keys = ["window", "document", "MutationObserver", "requestAnimationFrame", "cancelAnimationFrame"] as const;
|
||||
const previous = keys.map((key) => Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
for (const key of keys) {
|
||||
const value = dom.window[key];
|
||||
Object.defineProperty(globalThis, key, {
|
||||
configurable: true,
|
||||
value: typeof value === "function" && key.includes("AnimationFrame") ? value.bind(dom.window) : value,
|
||||
});
|
||||
}
|
||||
let view: EditorView | undefined;
|
||||
try {
|
||||
const parent = dom.window.document.querySelector("#note") as HTMLElement;
|
||||
view = new EditorView({
|
||||
parent,
|
||||
state: EditorState.create({
|
||||
doc: "con",
|
||||
extensions: [
|
||||
...createNoteCodeTooltipExtensions(dom.window.document.body),
|
||||
showTooltip.of({
|
||||
pos: 0,
|
||||
create() {
|
||||
const tooltip = dom.window.document.createElement("div");
|
||||
tooltip.textContent = "const";
|
||||
return { dom: tooltip };
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
const tooltip = dom.window.document.querySelector(".cm-tooltip")!;
|
||||
assert.ok(tooltip);
|
||||
assert.equal(parent.contains(tooltip), false);
|
||||
assert.equal(tooltip.parentElement?.parentElement, dom.window.document.body);
|
||||
for (const themeClass of view.themeClasses.split(" ")) {
|
||||
assert.ok(tooltip.parentElement?.classList.contains(themeClass));
|
||||
}
|
||||
view.dom.style.setProperty("--popover", "120 50% 20%");
|
||||
view.dom.style.setProperty("--accent", "120 70% 40%");
|
||||
syncNoteCodeTooltipStyle(view, { top: 0, left: 100, right: 300, bottom: 400 });
|
||||
assert.equal((tooltip as HTMLElement).style.getPropertyValue("--popover"), "120 50% 20%");
|
||||
assert.equal((tooltip as HTMLElement).style.getPropertyValue("--accent"), "120 70% 40%");
|
||||
assert.equal((tooltip as HTMLElement).style.getPropertyValue("--note-tooltip-width"), "200px");
|
||||
view.dom.style.setProperty("--popover", "240 50% 20%");
|
||||
view.dom.style.removeProperty("--accent");
|
||||
syncNoteCodeTooltipStyle(view, { top: 0, left: 100, right: 250, bottom: 400 });
|
||||
assert.equal((tooltip as HTMLElement).style.getPropertyValue("--popover"), "240 50% 20%");
|
||||
assert.equal((tooltip as HTMLElement).style.getPropertyValue("--accent"), "");
|
||||
assert.equal((tooltip as HTMLElement).style.getPropertyValue("--note-tooltip-width"), "150px");
|
||||
view.destroy();
|
||||
view = undefined;
|
||||
assert.equal(dom.window.document.querySelector(".cm-tooltip"), null);
|
||||
} finally {
|
||||
view?.destroy();
|
||||
keys.forEach((key, index) => {
|
||||
const descriptor = previous[index];
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("note tooltip space is bounded by clipping pane ancestors", () => {
|
||||
const dom = new JSDOM('<div id="pane" style="overflow:hidden"><div id="note"></div></div>');
|
||||
const pane = dom.window.document.querySelector("#pane") as HTMLElement;
|
||||
const note = dom.window.document.querySelector("#note") as HTMLElement;
|
||||
stubRect(pane, 100, 200, 600, 500);
|
||||
// The notes editor extends below its pane; a tooltip must not follow it out.
|
||||
stubRect(note, 150, 250, 550, 700);
|
||||
const realGetComputedStyle = dom.window.getComputedStyle.bind(dom.window);
|
||||
dom.window.getComputedStyle = ((element: Element) => ({
|
||||
...realGetComputedStyle(element),
|
||||
overflowX: element === pane ? "hidden" : "visible",
|
||||
overflowY: element === pane ? "hidden" : "visible",
|
||||
})) as typeof dom.window.getComputedStyle;
|
||||
try {
|
||||
assert.deepEqual(getNoteTooltipSpace(note, dom.window.document), {
|
||||
top: 150,
|
||||
left: 250,
|
||||
right: 550,
|
||||
bottom: 500,
|
||||
});
|
||||
} finally {
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("note tooltip space honors paint containment without overflow clipping", () => {
|
||||
const dom = new JSDOM(
|
||||
'<style>#pane{contain:strict}</style><div id="pane"><div id="note"></div></div>',
|
||||
);
|
||||
const pane = dom.window.document.querySelector("#pane") as HTMLElement;
|
||||
const note = dom.window.document.querySelector("#note") as HTMLElement;
|
||||
stubRect(pane, 0, 0, 800, 600);
|
||||
stubRect(note, 50, -50, 850, 900);
|
||||
const realGetComputedStyle = dom.window.getComputedStyle.bind(dom.window);
|
||||
dom.window.getComputedStyle = ((element: Element) => ({
|
||||
...realGetComputedStyle(element),
|
||||
overflowX: "visible",
|
||||
overflowY: "visible",
|
||||
contain: element === pane ? "strict" : "none",
|
||||
})) as typeof dom.window.getComputedStyle;
|
||||
try {
|
||||
assert.deepEqual(getNoteTooltipSpace(note, dom.window.document), {
|
||||
top: 50,
|
||||
left: 0,
|
||||
right: 800,
|
||||
bottom: 600,
|
||||
});
|
||||
} finally {
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("note tooltip space falls back to window bounds when the editor is unmounted", () => {
|
||||
const dom = new JSDOM("<div></div>");
|
||||
const detached = dom.window.document.createElement("div");
|
||||
try {
|
||||
assert.deepEqual(getNoteTooltipSpace(detached, dom.window.document), {
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: dom.window.document.documentElement.clientWidth,
|
||||
bottom: dom.window.document.documentElement.clientHeight,
|
||||
});
|
||||
} finally {
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Prec } from "@codemirror/state";
|
||||
import { EditorView, getTooltip, showTooltip, tooltips } from "@codemirror/view";
|
||||
|
||||
export interface NoteTooltipSpace {
|
||||
top: number;
|
||||
left: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
/** True when the element clips horizontally overflowing descendants. */
|
||||
const clipsHorizontally = (style: CSSStyleDeclaration): boolean =>
|
||||
style.overflowX !== "visible" ||
|
||||
/(^|\s)(strict|content|paint)(\s|$)/.test(style.contain || "");
|
||||
|
||||
/** True when the element clips vertically overflowing descendants. Paint
|
||||
* containment (strict/content/paint) also clips and captures fixed
|
||||
* descendants even when overflow is visible. */
|
||||
const clipsVertically = (style: CSSStyleDeclaration): boolean =>
|
||||
style.overflowY !== "visible" ||
|
||||
/(^|\s)(strict|content|paint)(\s|$)/.test(style.contain || "");
|
||||
|
||||
/**
|
||||
* Viewport-space bounds CodeMirror may place note tooltips in: the owning
|
||||
* notes region (`boundsRoot`) shrunk by every clipping ancestor (terminal
|
||||
* side-panel pane hosts, ScrollArea viewports). Tooltips are mounted on
|
||||
* `document.body` so they escape clipped code blocks; bounding their space
|
||||
* to the notes pane keeps a completion from rendering over an adjacent
|
||||
* split pane. Without a mounted `boundsRoot`, falls back to the window
|
||||
* bounds CodeMirror would use by default.
|
||||
*/
|
||||
export const getNoteTooltipSpace = (
|
||||
boundsRoot: HTMLElement | null | undefined,
|
||||
doc: Document,
|
||||
): NoteTooltipSpace => {
|
||||
if (!boundsRoot || !boundsRoot.isConnected) {
|
||||
const docElt = doc.documentElement;
|
||||
return { top: 0, left: 0, right: docElt.clientWidth, bottom: docElt.clientHeight };
|
||||
}
|
||||
const rootRect = boundsRoot.getBoundingClientRect();
|
||||
let space: NoteTooltipSpace = {
|
||||
top: rootRect.top,
|
||||
left: rootRect.left,
|
||||
right: rootRect.right,
|
||||
bottom: rootRect.bottom,
|
||||
};
|
||||
const defaultView = boundsRoot.ownerDocument.defaultView;
|
||||
if (!defaultView) return space;
|
||||
for (let node: Element | null = boundsRoot; node; node = node.parentElement) {
|
||||
const style = defaultView.getComputedStyle(node);
|
||||
const rect = node.getBoundingClientRect();
|
||||
if (clipsHorizontally(style)) {
|
||||
space = {
|
||||
...space,
|
||||
left: Math.max(space.left, rect.left),
|
||||
right: Math.min(space.right, rect.right),
|
||||
};
|
||||
}
|
||||
if (clipsVertically(style)) {
|
||||
space = {
|
||||
...space,
|
||||
top: Math.max(space.top, rect.top),
|
||||
bottom: Math.min(space.bottom, rect.bottom),
|
||||
};
|
||||
}
|
||||
}
|
||||
return space;
|
||||
};
|
||||
|
||||
export const syncNoteCodeTooltipStyle = (view: EditorView, space: NoteTooltipSpace): void => {
|
||||
const width = `${Math.max(0, space.right - space.left)}px`;
|
||||
const paneStyle = view.dom.ownerDocument.defaultView?.getComputedStyle(view.dom);
|
||||
const tokens = ["--popover", "--popover-foreground", "--border", "--accent", "--accent-foreground"];
|
||||
for (const tooltip of view.state.facet(showTooltip)) {
|
||||
if (!tooltip) continue;
|
||||
const dom = getTooltip(view, tooltip)?.dom;
|
||||
if (!dom) continue;
|
||||
dom.style.setProperty("--note-tooltip-width", width);
|
||||
// Detached tooltips must retain terminal-side-panel theme overrides.
|
||||
for (const token of tokens) {
|
||||
const value = paneStyle?.getPropertyValue(token).trim();
|
||||
if (value) dom.style.setProperty(token, value);
|
||||
else dom.style.removeProperty(token);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const createNoteCodeTooltipExtensions = (
|
||||
parent: HTMLElement | undefined,
|
||||
getBoundsRoot?: () => HTMLElement | null,
|
||||
) => [
|
||||
// Code blocks and their enclosing note panes can clip editor descendants.
|
||||
Prec.highest(
|
||||
tooltips({
|
||||
parent,
|
||||
// Tooltips escape those clips via the global parent, so constrain their
|
||||
// placement to the owning notes pane instead of the whole window.
|
||||
tooltipSpace: (view: EditorView) => {
|
||||
const space = getNoteTooltipSpace(getBoundsRoot?.(), view.dom.ownerDocument);
|
||||
// CodeMirror constrains coordinates and height, but not width. Set
|
||||
// the available width; its ResizeObserver remeasures after resizing.
|
||||
syncNoteCodeTooltipStyle(view, space);
|
||||
return space;
|
||||
},
|
||||
}),
|
||||
),
|
||||
// CodeMirror carries this theme's scope onto its detached tooltip container.
|
||||
// Keep these rules local to Notes and use the application's existing colors.
|
||||
Prec.highest(EditorView.theme({
|
||||
".cm-tooltip": {
|
||||
backgroundColor: "hsl(var(--popover))",
|
||||
color: "hsl(var(--popover-foreground))",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
},
|
||||
".cm-tooltip-autocomplete": {
|
||||
maxWidth: "var(--note-tooltip-width, 95vw)",
|
||||
boxSizing: "border-box",
|
||||
},
|
||||
".cm-tooltip.cm-tooltip-autocomplete > ul": {
|
||||
minWidth: "min(250px, max(0px, calc(var(--note-tooltip-width, 95vw) - 2px)))",
|
||||
maxWidth: "min(700px, max(0px, calc(var(--note-tooltip-width, 95vw) - 2px)))",
|
||||
},
|
||||
".cm-tooltip-autocomplete > ul > li[aria-selected]": {
|
||||
backgroundColor: "hsl(var(--accent))",
|
||||
color: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
})),
|
||||
];
|
||||
@@ -7,6 +7,11 @@ import React, { memo, useCallback } from 'react';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { SftpFileEntry } from '../../types';
|
||||
import {
|
||||
sftpFileRowDensityClass,
|
||||
sftpFileRowIconDensityClass,
|
||||
type SftpListDensity,
|
||||
} from '../../domain/sftpListDensity';
|
||||
import { buildSftpColumnTemplate, formatBytes, formatDate, getFileIcon, isNavigableDirectory, type ColumnWidths, type SftpColumnVisibility } from './utils';
|
||||
|
||||
interface SftpFileRowProps {
|
||||
@@ -24,6 +29,7 @@ interface SftpFileRowProps {
|
||||
onDragOver: (entry: SftpFileEntry, e: React.DragEvent) => void;
|
||||
onDragLeave: () => void;
|
||||
onDrop: (entry: SftpFileEntry, e: React.DragEvent) => void;
|
||||
density?: SftpListDensity;
|
||||
}
|
||||
|
||||
const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
|
||||
@@ -41,6 +47,7 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
density = "comfortable",
|
||||
}) => {
|
||||
const isParentDir = entry.name === '..';
|
||||
// A symlink pointing to a directory behaves like a directory (navigable, accepts drops)
|
||||
@@ -81,8 +88,10 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
|
||||
onDrop={handleDrop}
|
||||
onClick={handleSelect}
|
||||
onDoubleClick={handleOpen}
|
||||
data-sftp-list-density={density}
|
||||
className={cn(
|
||||
"px-4 py-2 items-center cursor-pointer text-sm",
|
||||
"items-center cursor-pointer",
|
||||
sftpFileRowDensityClass(density),
|
||||
isSelectionVisible
|
||||
? "bg-accent text-accent-foreground hover:bg-accent"
|
||||
: "hover:bg-accent/50",
|
||||
@@ -90,9 +99,10 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
|
||||
)}
|
||||
style={{ display: 'grid', gridTemplateColumns: buildSftpColumnTemplate(columnWidths, visibleColumns) }}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className={cn("flex items-center min-w-0", density === "compact" ? "gap-2" : "gap-3")}>
|
||||
<div className={cn(
|
||||
"h-7 w-7 rounded flex items-center justify-center shrink-0 relative",
|
||||
"rounded flex items-center justify-center shrink-0 relative",
|
||||
sftpFileRowIconDensityClass(density),
|
||||
isSelectionVisible
|
||||
? "bg-accent-foreground/10 text-accent-foreground"
|
||||
: isNavDir
|
||||
@@ -168,6 +178,7 @@ const areEqual = (prev: SftpFileRowProps, next: SftpFileRowProps): boolean => {
|
||||
// Compare callbacks - important for ".." entry which has static properties
|
||||
if (prev.onOpen !== next.onOpen) return false;
|
||||
if (prev.onSelect !== next.onSelect) return false;
|
||||
if (prev.density !== next.density) return false;
|
||||
const prevEntry = prev.entry;
|
||||
const nextEntry = next.entry;
|
||||
return (
|
||||
|
||||
@@ -19,6 +19,7 @@ import { buildSftpColumnTemplate, isNavigableDirectory, isSftpColumnMenuKey } fr
|
||||
import { isKnownBinaryFile } from "../../lib/sftpFileUtils";
|
||||
import { isExtractableArchive } from "../../domain/sftpArchive";
|
||||
import { SftpFileRow } from "./SftpFileRow";
|
||||
import type { SftpListDensity } from "../../domain/sftpListDensity";
|
||||
import { SftpColumnMenuItems } from "./SftpColumnMenuItems";
|
||||
import { getSftpVirtualListScrollTop } from "../../domain/sftpVirtualList";
|
||||
import {
|
||||
@@ -77,6 +78,7 @@ interface SftpPaneFileListProps {
|
||||
openDeleteConfirm: (targets: string[]) => void;
|
||||
rowHeight: number;
|
||||
visibleRows: { entry: SftpFileEntry; index: number; top: number }[];
|
||||
listDensity?: SftpListDensity;
|
||||
}
|
||||
|
||||
const SftpErrorWithLogs: React.FC<{
|
||||
@@ -164,6 +166,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
||||
openDeleteConfirm,
|
||||
rowHeight,
|
||||
visibleRows,
|
||||
listDensity = "comfortable",
|
||||
}) => {
|
||||
const {
|
||||
columnWidths,
|
||||
@@ -288,6 +291,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
||||
onDragOver={handleEntryDragOver}
|
||||
onDragLeave={handleRowDragLeave}
|
||||
onDrop={handleEntryDrop}
|
||||
density={listDensity}
|
||||
/>
|
||||
</ContextMenuTrigger>
|
||||
{entry.name !== ".." && (
|
||||
@@ -497,6 +501,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
||||
openRenameDialog,
|
||||
pane.connection,
|
||||
pane.selectedFiles,
|
||||
listDensity,
|
||||
setShowNewFolderDialog,
|
||||
setShowNewFileDialog,
|
||||
t,
|
||||
|
||||
@@ -2,7 +2,7 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
test("selecting an inline SFTP bookmark closes its popover", async () => {
|
||||
test("inline bookmark actions navigate and confirm current-path removal", async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost",
|
||||
@@ -48,85 +48,116 @@ test("selecting an inline SFTP bookmark closes its popover", async () => {
|
||||
|
||||
const { default: React, act } = await import("react");
|
||||
const { createRoot } = await import("react-dom/client");
|
||||
const { I18nProvider } = await import("../../application/i18n/I18nProvider.tsx");
|
||||
const { SftpPaneToolbar } = await import("./SftpPaneToolbar.tsx");
|
||||
const rootNode = window.document.getElementById("root");
|
||||
assert.ok(rootNode);
|
||||
const root = createRoot(rootNode);
|
||||
const navigatedPaths: string[] = [];
|
||||
const deletedBookmarkIds: string[] = [];
|
||||
let toggleBookmarkCalls = 0;
|
||||
const waitForFocusRestore = async () => {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 20));
|
||||
});
|
||||
};
|
||||
const setBookmarkPlacement = async (placement: "show" | "collapse") => {
|
||||
window.localStorage.setItem("netcatty_sftp_toolbar_layout_v1", JSON.stringify({
|
||||
order: ["bookmark"],
|
||||
placement: { bookmark: placement },
|
||||
}));
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new window.StorageEvent("storage", {
|
||||
key: "netcatty_sftp_toolbar_layout_v1",
|
||||
}));
|
||||
});
|
||||
};
|
||||
const findRemovalDialog = () => Array.from(window.document.querySelectorAll("h2"))
|
||||
.find((heading) => heading.textContent?.trim() === "Remove bookmark")
|
||||
?.closest<HTMLElement>('[role="dialog"]') ?? null;
|
||||
|
||||
const renderToolbar = async (currentPath: string, currentPathBookmarked: boolean) => {
|
||||
await act(async () => {
|
||||
const toolbar = React.createElement(SftpPaneToolbar, {
|
||||
t: (key, params) => ({
|
||||
"sftp.bookmark.list": "Bookmarked paths",
|
||||
"sftp.bookmark.remove": "Remove bookmark",
|
||||
"sftp.bookmark.removeConfirm": `Remove bookmark ${params?.path ?? ""}?`,
|
||||
"sftp.viewMode.switchToTree": "Switch to tree view",
|
||||
}[key] ?? key),
|
||||
pane: {
|
||||
id: "pane-1",
|
||||
connection: {
|
||||
id: "conn-1",
|
||||
hostId: "host-1",
|
||||
name: "Example",
|
||||
currentPath,
|
||||
homeDir: "/home/app",
|
||||
isLocal: false,
|
||||
},
|
||||
files: [],
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
error: null,
|
||||
connectionLogs: [],
|
||||
selectedFiles: new Set(),
|
||||
filter: "",
|
||||
filenameEncoding: "auto",
|
||||
showHiddenFiles: false,
|
||||
transferMutationToken: 0,
|
||||
},
|
||||
onNavigateTo: () => {},
|
||||
onSetFilter: () => {},
|
||||
onSetFilenameEncoding: () => {},
|
||||
onRefresh: () => {},
|
||||
showFilterBar: false,
|
||||
setShowFilterBar: () => {},
|
||||
filterInputRef: { current: null },
|
||||
isEditingPath: false,
|
||||
editingPathValue: "",
|
||||
setEditingPathValue: () => {},
|
||||
setShowPathSuggestions: () => {},
|
||||
showPathSuggestions: false,
|
||||
setPathSuggestionIndex: () => {},
|
||||
pathSuggestions: [],
|
||||
pathSuggestionIndex: -1,
|
||||
pathInputRef: { current: null },
|
||||
pathDropdownRef: { current: null },
|
||||
handlePathBlur: () => {},
|
||||
handlePathKeyDown: () => {},
|
||||
handlePathDoubleClick: () => {},
|
||||
handlePathSubmit: () => {},
|
||||
getNextUntitledName: () => "untitled",
|
||||
setNewFileName: () => {},
|
||||
setFileNameError: () => {},
|
||||
setShowNewFileDialog: () => {},
|
||||
setShowNewFolderDialog: () => {},
|
||||
setNewFolderName: () => {},
|
||||
bookmarks: [
|
||||
{ id: "bm-current", path: "/home/app", label: "App root" },
|
||||
{ id: "bm-1", path: "/srv/www", label: "Web root" },
|
||||
],
|
||||
isCurrentPathBookmarked: currentPathBookmarked,
|
||||
onToggleBookmark: () => {
|
||||
toggleBookmarkCalls += 1;
|
||||
},
|
||||
onAddGlobalBookmark: () => {},
|
||||
isCurrentPathGlobalBookmarked: false,
|
||||
onNavigateToBookmark: (path) => navigatedPaths.push(path),
|
||||
onDeleteBookmark: (bookmark) => deletedBookmarkIds.push(bookmark.id),
|
||||
showHiddenFiles: false,
|
||||
onToggleShowHiddenFiles: () => {},
|
||||
viewMode: "list",
|
||||
onSetViewMode: () => {},
|
||||
});
|
||||
root.render(React.createElement(I18nProvider, { locale: "en" }, toolbar));
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(SftpPaneToolbar, {
|
||||
t: (key) => ({
|
||||
"sftp.bookmark.list": "Bookmarked paths",
|
||||
"sftp.bookmark.remove": "Remove bookmark",
|
||||
"sftp.viewMode.switchToTree": "Switch to tree view",
|
||||
}[key] ?? key),
|
||||
pane: {
|
||||
id: "pane-1",
|
||||
connection: {
|
||||
id: "conn-1",
|
||||
hostId: "host-1",
|
||||
name: "Example",
|
||||
currentPath: "/home/app",
|
||||
homeDir: "/home/app",
|
||||
isLocal: false,
|
||||
},
|
||||
files: [],
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
error: null,
|
||||
connectionLogs: [],
|
||||
selectedFiles: new Set(),
|
||||
filter: "",
|
||||
filenameEncoding: "auto",
|
||||
showHiddenFiles: false,
|
||||
transferMutationToken: 0,
|
||||
},
|
||||
onNavigateTo: () => {},
|
||||
onSetFilter: () => {},
|
||||
onSetFilenameEncoding: () => {},
|
||||
onRefresh: () => {},
|
||||
showFilterBar: false,
|
||||
setShowFilterBar: () => {},
|
||||
filterInputRef: { current: null },
|
||||
isEditingPath: false,
|
||||
editingPathValue: "",
|
||||
setEditingPathValue: () => {},
|
||||
setShowPathSuggestions: () => {},
|
||||
showPathSuggestions: false,
|
||||
setPathSuggestionIndex: () => {},
|
||||
pathSuggestions: [],
|
||||
pathSuggestionIndex: -1,
|
||||
pathInputRef: { current: null },
|
||||
pathDropdownRef: { current: null },
|
||||
handlePathBlur: () => {},
|
||||
handlePathKeyDown: () => {},
|
||||
handlePathDoubleClick: () => {},
|
||||
handlePathSubmit: () => {},
|
||||
getNextUntitledName: () => "untitled",
|
||||
setNewFileName: () => {},
|
||||
setFileNameError: () => {},
|
||||
setShowNewFileDialog: () => {},
|
||||
setShowNewFolderDialog: () => {},
|
||||
setNewFolderName: () => {},
|
||||
bookmarks: [{ id: "bm-1", path: "/srv/www", label: "Web root" }],
|
||||
isCurrentPathBookmarked: false,
|
||||
onToggleBookmark: () => {},
|
||||
onAddGlobalBookmark: () => {},
|
||||
isCurrentPathGlobalBookmarked: false,
|
||||
onNavigateToBookmark: (path) => navigatedPaths.push(path),
|
||||
onDeleteBookmark: () => {},
|
||||
showHiddenFiles: false,
|
||||
onToggleShowHiddenFiles: () => {},
|
||||
viewMode: "list",
|
||||
onSetViewMode: () => {},
|
||||
}),
|
||||
);
|
||||
});
|
||||
await renderToolbar("/home/app", true);
|
||||
|
||||
const trigger = window.document.querySelector<HTMLButtonElement>(
|
||||
let trigger = window.document.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Bookmarked paths"]',
|
||||
);
|
||||
assert.ok(trigger);
|
||||
@@ -146,6 +177,134 @@ test("selecting an inline SFTP bookmark closes its popover", async () => {
|
||||
false,
|
||||
"bookmark popover should close after selecting a path",
|
||||
);
|
||||
|
||||
await act(async () => trigger.click());
|
||||
const removeCurrentPath = Array.from(window.document.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.trim() === "Remove bookmark",
|
||||
);
|
||||
assert.ok(removeCurrentPath, "current-path bookmark removal should be visible");
|
||||
removeCurrentPath.focus();
|
||||
await act(async () => removeCurrentPath.click());
|
||||
|
||||
let dialog = findRemovalDialog();
|
||||
assert.ok(dialog, "current-path removal should open an in-app dialog");
|
||||
assert.match(dialog.textContent ?? "", /Remove bookmark \/home\/app\?/);
|
||||
assert.equal(
|
||||
Array.from(window.document.querySelectorAll("button")).some((button) =>
|
||||
button.textContent?.includes("Web root"),
|
||||
),
|
||||
false,
|
||||
"the bookmark popover must close before the dialog opens",
|
||||
);
|
||||
assert.equal(toggleBookmarkCalls, 0, "opening the dialog must not remove the bookmark");
|
||||
|
||||
const cancel = Array.from(dialog.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.trim() === "Cancel",
|
||||
);
|
||||
assert.ok(cancel);
|
||||
await setBookmarkPlacement("collapse");
|
||||
const overflowTriggerAfterLayoutChange = window.document.querySelector<HTMLButtonElement>(
|
||||
'button[data-toolbar-overflow-trigger="true"]',
|
||||
);
|
||||
assert.ok(overflowTriggerAfterLayoutChange);
|
||||
await act(async () => cancel.click());
|
||||
await waitForFocusRestore();
|
||||
assert.equal(findRemovalDialog(), null);
|
||||
assert.equal(toggleBookmarkCalls, 0, "cancelling must preserve the bookmark");
|
||||
assert.equal(
|
||||
window.document.activeElement,
|
||||
overflowTriggerAfterLayoutChange,
|
||||
"cancel should fall back to the newly mounted overflow trigger",
|
||||
);
|
||||
|
||||
await setBookmarkPlacement("show");
|
||||
trigger = window.document.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Bookmarked paths"]',
|
||||
);
|
||||
assert.ok(trigger);
|
||||
|
||||
await act(async () => trigger.click());
|
||||
const removeCurrentPathAfterCancel = Array.from(
|
||||
window.document.querySelectorAll("button"),
|
||||
).find((button) => button.textContent?.trim() === "Remove bookmark");
|
||||
assert.ok(removeCurrentPathAfterCancel);
|
||||
await act(async () => removeCurrentPathAfterCancel.click());
|
||||
dialog = findRemovalDialog();
|
||||
assert.ok(dialog, "the removal dialog should reopen after cancellation");
|
||||
await renderToolbar("/home/other", false);
|
||||
dialog = findRemovalDialog();
|
||||
assert.ok(dialog, "changing directory must not replace the pending removal target");
|
||||
assert.match(dialog.textContent ?? "", /Remove bookmark \/home\/app\?/);
|
||||
assert.doesNotMatch(dialog.textContent ?? "", /\/home\/other/);
|
||||
const confirm = Array.from(dialog.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.trim() === "Remove bookmark",
|
||||
);
|
||||
assert.ok(confirm);
|
||||
await act(async () => confirm.click());
|
||||
assert.deepEqual(deletedBookmarkIds, ["bm-current"]);
|
||||
assert.equal(toggleBookmarkCalls, 0, "confirmation must not toggle the new current path");
|
||||
assert.equal(findRemovalDialog(), null);
|
||||
await waitForFocusRestore();
|
||||
assert.equal(window.document.activeElement, trigger, "confirm should restore the bookmark trigger");
|
||||
|
||||
await act(async () => trigger.click());
|
||||
const webRootRow = Array.from(
|
||||
window.document.querySelectorAll<HTMLElement>("[data-bookmark-scope]"),
|
||||
).find((element) => element.textContent?.includes("Web root"));
|
||||
const removeWebRoot = webRootRow?.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Remove bookmark"]',
|
||||
);
|
||||
assert.ok(removeWebRoot);
|
||||
await act(async () => removeWebRoot.click());
|
||||
dialog = findRemovalDialog();
|
||||
assert.ok(dialog, "row removal should use the shared in-app dialog");
|
||||
assert.match(dialog.textContent ?? "", /Remove bookmark \/srv\/www\?/);
|
||||
assert.equal(webRootRow.isConnected, false, "row removal must close the bookmark popover");
|
||||
const confirmWebRoot = Array.from(dialog.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.trim() === "Remove bookmark",
|
||||
);
|
||||
assert.ok(confirmWebRoot);
|
||||
await act(async () => confirmWebRoot.click());
|
||||
assert.deepEqual(deletedBookmarkIds, ["bm-current", "bm-1"]);
|
||||
await waitForFocusRestore();
|
||||
assert.equal(window.document.activeElement, trigger, "row removal should restore the bookmark trigger");
|
||||
|
||||
await setBookmarkPlacement("collapse");
|
||||
const overflowTrigger = window.document.querySelector<HTMLButtonElement>(
|
||||
'button[data-toolbar-overflow-trigger="true"]',
|
||||
);
|
||||
assert.ok(overflowTrigger, "collapsed bookmark should expose the overflow trigger");
|
||||
await act(async () => overflowTrigger.click());
|
||||
const nestedBookmarkTrigger = window.document.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Bookmarked paths"]',
|
||||
);
|
||||
assert.ok(nestedBookmarkTrigger, "overflow should expose the nested bookmark trigger");
|
||||
await act(async () => nestedBookmarkTrigger.click());
|
||||
const overflowWebRootRow = Array.from(
|
||||
window.document.querySelectorAll<HTMLElement>("[data-bookmark-scope]"),
|
||||
).find((element) => element.textContent?.includes("Web root"));
|
||||
const removeOverflowWebRoot = overflowWebRootRow?.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Remove bookmark"]',
|
||||
);
|
||||
assert.ok(removeOverflowWebRoot);
|
||||
removeOverflowWebRoot.focus();
|
||||
await act(async () => removeOverflowWebRoot.click());
|
||||
dialog = findRemovalDialog();
|
||||
assert.ok(dialog, "overflow row removal should open the shared dialog");
|
||||
const cancelOverflowRemoval = Array.from(dialog.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.trim() === "Cancel",
|
||||
);
|
||||
assert.ok(cancelOverflowRemoval);
|
||||
await act(async () => cancelOverflowRemoval.click());
|
||||
await waitForFocusRestore();
|
||||
assert.equal(
|
||||
window.document.activeElement,
|
||||
overflowTrigger,
|
||||
"overflow removal should restore the persistent overflow trigger",
|
||||
);
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 300));
|
||||
});
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
dom.window.close();
|
||||
|
||||
@@ -8,8 +8,11 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import {
|
||||
getSftpBookmarkButtonLabelKey,
|
||||
getSftpBookmarkIdentity,
|
||||
getNextSftpViewMode,
|
||||
copySftpCurrentPathToClipboard,
|
||||
canReorderSftpBookmark,
|
||||
getSftpBookmarkMoveTargets,
|
||||
getNextSftpToolbarDisplayPath,
|
||||
getSftpViewModeToggleTarget,
|
||||
getSftpViewModeToggleLabelKey,
|
||||
@@ -32,6 +35,11 @@ test("single SFTP view-mode button toggles to the other mode", () => {
|
||||
assert.equal(getNextSftpViewMode("tree"), "list");
|
||||
});
|
||||
|
||||
test("SFTP toolbar includes compact list density next to view mode", () => {
|
||||
assert.match(toolbarSource, /"listDensity"/);
|
||||
assert.match(toolbarSource, /getSftpListDensityToggleLabelKey/);
|
||||
});
|
||||
|
||||
test("narrow SFTP toolbar spills non-pinned show items into overflow without changing hide/collapse", () => {
|
||||
const shown = ["bookmark", "copyPath", "viewMode", "filter", "newFolder", "newFile", "refresh"];
|
||||
const collapsed = ["encoding"];
|
||||
@@ -491,6 +499,82 @@ test("toolbar display path keeps the previous confirmed path while loading the s
|
||||
);
|
||||
});
|
||||
|
||||
test("bookmark delete asks for confirmation inside the app", () => {
|
||||
assert.doesNotMatch(toolbarSource, /window\.confirm/);
|
||||
assert.doesNotMatch(toolbarSource, /confirmRemoveSftpBookmark/);
|
||||
assert.match(toolbarSource, /import \{ ConfirmDialog \} from "\.\.\/ui\/confirm-dialog"/);
|
||||
assert.match(toolbarSource, /<ConfirmDialog/);
|
||||
assert.match(toolbarSource, /sftp\.bookmark\.removeConfirm/);
|
||||
assert.match(toolbarSource, /path: pendingBookmarkRemoval\.path/);
|
||||
});
|
||||
|
||||
test("bookmark rename does not use window.prompt", () => {
|
||||
assert.doesNotMatch(toolbarSource, /window\.prompt/);
|
||||
assert.match(toolbarSource, /event\.nativeEvent\.isComposing/);
|
||||
});
|
||||
|
||||
test("bookmark drag ordering stays within global or location scope", () => {
|
||||
const bookmarks = [
|
||||
{ id: "gbm-1", path: "/global-a", label: "Global A", global: true },
|
||||
{ id: "gbm-2", path: "/global-b", label: "Global B", global: true },
|
||||
{ id: "bm-1", path: "/host-a", label: "Host A" },
|
||||
{ id: "bm-2", path: "/host-b", label: "Host B" },
|
||||
];
|
||||
assert.equal(canReorderSftpBookmark(bookmarks[0], bookmarks[1]), true);
|
||||
assert.equal(canReorderSftpBookmark(bookmarks[2], bookmarks[3]), true);
|
||||
assert.equal(canReorderSftpBookmark(bookmarks[0], bookmarks[2]), false);
|
||||
assert.equal(canReorderSftpBookmark(undefined, bookmarks[2]), false);
|
||||
assert.deepEqual(getSftpBookmarkMoveTargets(bookmarks, bookmarks[0]), {
|
||||
previous: null,
|
||||
next: bookmarks[1],
|
||||
});
|
||||
assert.deepEqual(getSftpBookmarkMoveTargets(bookmarks, bookmarks[2]), {
|
||||
previous: null,
|
||||
next: bookmarks[3],
|
||||
});
|
||||
});
|
||||
|
||||
test("bookmark operations keep global and location entries distinct when ids collide", () => {
|
||||
const global = { id: "shared-id", path: "/global", label: "Global", global: true };
|
||||
const location = { id: "shared-id", path: "/location", label: "Location" };
|
||||
const nextLocation = { id: "next", path: "/next", label: "Next" };
|
||||
const bookmarks = [global, location, nextLocation];
|
||||
|
||||
assert.notEqual(getSftpBookmarkIdentity(global), getSftpBookmarkIdentity(location));
|
||||
assert.deepEqual(getSftpBookmarkMoveTargets(bookmarks, location), {
|
||||
previous: null,
|
||||
next: nextLocation,
|
||||
});
|
||||
assert.equal(canReorderSftpBookmark(location, nextLocation), true);
|
||||
assert.equal(canReorderSftpBookmark(global, location), false);
|
||||
});
|
||||
|
||||
test("bookmark manage mode exposes keyboard reorder controls without a dead path button", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
React.createElement(SftpBookmarkList, {
|
||||
bookmarks: [
|
||||
{ id: "bm-1", path: "/srv/a", label: "First" },
|
||||
{ id: "bm-2", path: "/srv/b", label: "Second" },
|
||||
],
|
||||
managing: true,
|
||||
onNavigateToBookmark: () => {},
|
||||
onRequestDeleteBookmark: () => {},
|
||||
onReorderBookmark: () => {},
|
||||
t: (key: string, params?: Record<string, unknown>) => ({
|
||||
"sftp.bookmark.moveUp": `Move ${params?.label ?? ""} up`,
|
||||
"sftp.bookmark.moveDown": `Move ${params?.label ?? ""} down`,
|
||||
"sftp.bookmark.remove": "Remove bookmark",
|
||||
}[key] ?? key),
|
||||
}),
|
||||
);
|
||||
|
||||
assert.match(markup, /aria-label="Move First up"[^>]*disabled/);
|
||||
assert.match(markup, /aria-label="Move First down"/);
|
||||
assert.match(markup, /aria-label="Move Second up"/);
|
||||
assert.match(markup, /aria-label="Move Second down"[^>]*disabled/);
|
||||
assert.doesNotMatch(markup, /<button[^>]*>\s*<div class="text-xs font-medium truncate">First/);
|
||||
});
|
||||
|
||||
test("bookmark list renders saved paths as selectable rows", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
React.createElement(
|
||||
@@ -502,7 +586,7 @@ test("bookmark list renders saved paths as selectable rows", () => {
|
||||
React.createElement(SftpBookmarkList, {
|
||||
bookmarks: [{ id: "bm-1", path: "/srv/www", label: "Web root" }],
|
||||
onNavigateToBookmark: () => {},
|
||||
onDeleteBookmark: () => {},
|
||||
onRequestDeleteBookmark: () => {},
|
||||
t: (key: string) => ({
|
||||
"sftp.bookmark.remove": "Remove bookmark",
|
||||
}[key] ?? key),
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Bookmark, Check, ClipboardCopy, Eye, EyeOff, FilePlus, Folder, FolderPlus, FolderSync, Globe, Home, Languages, List, ListTree, RefreshCw, Search, Terminal, TerminalSquare, Trash2, X } from "lucide-react";
|
||||
import { ArrowDown, ArrowUp, Bookmark, Check, ClipboardCopy, Eye, EyeOff, FilePlus, Folder, FolderPlus, FolderSync, Globe, GripVertical, Home, Languages, List, ListTree, Pencil, RefreshCw, Rows3, Search, Terminal, TerminalSquare, Trash2, X } from "lucide-react";
|
||||
import {
|
||||
getNextSftpListDensity,
|
||||
getSftpListDensityToggleLabelKey,
|
||||
type SftpListDensity,
|
||||
} from "../../domain/sftpListDensity";
|
||||
import { useToolbarItemLayout } from "../../application/state/useToolbarItemLayout";
|
||||
import type { ToolbarItemLayoutDefaults } from "../../domain/toolbarItemLayout";
|
||||
import { STORAGE_KEY_SFTP_TOOLBAR_LAYOUT } from "../../infrastructure/config/storageKeys";
|
||||
import { Button } from "../ui/button";
|
||||
import { ConfirmDialog } from "../ui/confirm-dialog";
|
||||
import { Input } from "../ui/input";
|
||||
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
import { cn } from "../../lib/utils";
|
||||
@@ -33,6 +39,7 @@ export const SFTP_TOOLBAR_ITEM_IDS = [
|
||||
"followTerminalCwd",
|
||||
"copyPath",
|
||||
"viewMode",
|
||||
"listDensity",
|
||||
"filter",
|
||||
"encoding",
|
||||
"newFolder",
|
||||
@@ -52,6 +59,7 @@ export const SFTP_TOOLBAR_LAYOUT_DEFAULTS: ToolbarItemLayoutDefaults = {
|
||||
followTerminalCwd: "show",
|
||||
copyPath: "show",
|
||||
viewMode: "show",
|
||||
listDensity: "show",
|
||||
filter: "show",
|
||||
encoding: "collapse",
|
||||
newFolder: "show",
|
||||
@@ -74,6 +82,7 @@ export const SFTP_TOOLBAR_NARROW_INLINE_IDS = new Set<SftpToolbarItemId>([
|
||||
"followTerminalCwd",
|
||||
"copyPath",
|
||||
"viewMode",
|
||||
"listDensity",
|
||||
"filter",
|
||||
]);
|
||||
|
||||
@@ -215,7 +224,11 @@ interface SftpPaneToolbarProps {
|
||||
onAddGlobalBookmark: (path: string) => void;
|
||||
isCurrentPathGlobalBookmarked: boolean;
|
||||
onNavigateToBookmark: (path: string) => void;
|
||||
onDeleteBookmark: (id: string) => void;
|
||||
onDeleteBookmark: (bookmark: SftpBookmark) => void;
|
||||
onReorderBookmark?: (from: SftpBookmark, to: SftpBookmark) => void;
|
||||
onRenameBookmark?: (bookmark: SftpBookmark, label: string) => void;
|
||||
listDensity?: SftpListDensity;
|
||||
onSetListDensity?: (density: SftpListDensity) => void;
|
||||
showHiddenFiles: boolean;
|
||||
onToggleShowHiddenFiles?: () => void;
|
||||
onGoToTerminalCwd?: () => void;
|
||||
@@ -229,63 +242,249 @@ interface SftpPaneToolbarProps {
|
||||
|
||||
interface SftpBookmarkListProps {
|
||||
bookmarks: SftpBookmark[];
|
||||
managing?: boolean;
|
||||
onNavigateToBookmark: (path: string) => void;
|
||||
onDeleteBookmark: (id: string) => void;
|
||||
onRequestDeleteBookmark: (bookmark: SftpBookmark) => void;
|
||||
onReorderBookmark?: (from: SftpBookmark, to: SftpBookmark) => void;
|
||||
onRenameBookmark?: (bookmark: SftpBookmark, label: string) => void;
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
}
|
||||
|
||||
const SFTP_BOOKMARK_DRAG_TYPE = "text/netcatty-sftp-bookmark";
|
||||
|
||||
export const getSftpBookmarkIdentity = (bookmark: SftpBookmark): string => (
|
||||
`${bookmark.global ? "global" : "location"}:${bookmark.id}`
|
||||
);
|
||||
|
||||
export const canReorderSftpBookmark = (
|
||||
source: SftpBookmark | undefined,
|
||||
target: SftpBookmark | undefined,
|
||||
): boolean => {
|
||||
return !!source && !!target && !!source.global === !!target.global;
|
||||
};
|
||||
|
||||
export const getSftpBookmarkMoveTargets = (
|
||||
bookmarks: SftpBookmark[],
|
||||
bookmark: SftpBookmark,
|
||||
): { previous: SftpBookmark | null; next: SftpBookmark | null } => {
|
||||
const index = bookmarks.findIndex((candidate) => (
|
||||
candidate.id === bookmark.id && !!candidate.global === !!bookmark.global
|
||||
));
|
||||
if (index < 0) return { previous: null, next: null };
|
||||
const isGlobal = !!bookmarks[index]?.global;
|
||||
let previous: SftpBookmark | null = null;
|
||||
let next: SftpBookmark | null = null;
|
||||
for (let candidate = index - 1; candidate >= 0; candidate -= 1) {
|
||||
if (!!bookmarks[candidate]?.global === isGlobal) {
|
||||
previous = bookmarks[candidate]!;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (let candidate = index + 1; candidate < bookmarks.length; candidate += 1) {
|
||||
if (!!bookmarks[candidate]?.global === isGlobal) {
|
||||
next = bookmarks[candidate]!;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { previous, next };
|
||||
};
|
||||
|
||||
export const SftpBookmarkList: React.FC<SftpBookmarkListProps> = ({
|
||||
bookmarks,
|
||||
managing = false,
|
||||
onNavigateToBookmark,
|
||||
onDeleteBookmark,
|
||||
onRequestDeleteBookmark,
|
||||
onReorderBookmark,
|
||||
onRenameBookmark,
|
||||
t,
|
||||
}) => (
|
||||
bookmarks.length > 0 ? (
|
||||
<div className="max-h-48 overflow-auto py-1">
|
||||
{bookmarks.map((bm) => (
|
||||
<div
|
||||
key={bm.id}
|
||||
className="flex items-center gap-1 px-2 py-1 hover:bg-secondary/60 group"
|
||||
>
|
||||
{bm.global && (
|
||||
<Globe size={10} className="shrink-0 text-primary" />
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 text-left text-xs truncate font-mono"
|
||||
onClick={() => onNavigateToBookmark(bm.path)}
|
||||
>
|
||||
{bm.label}
|
||||
<span className="ml-1.5 text-muted-foreground text-[10px]">{bm.path}</span>
|
||||
</button>
|
||||
</PopoverClose>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{bm.path}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 opacity-0 group-hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-1 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
aria-label={t("sftp.bookmark.remove")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteBookmark(bm.id);
|
||||
}}
|
||||
}) => {
|
||||
const [draggingBookmark, setDraggingBookmark] = useState<SftpBookmark | null>(null);
|
||||
const [renamingBookmark, setRenamingBookmark] = useState<SftpBookmark | null>(null);
|
||||
const [renameDraft, setRenameDraft] = useState("");
|
||||
|
||||
if (bookmarks.length === 0) {
|
||||
return (
|
||||
<div className="p-3 text-xs text-muted-foreground text-center">
|
||||
{t("sftp.bookmark.empty")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-h-64 overflow-auto py-1 min-w-[18rem]">
|
||||
{bookmarks.map((bm, index) => {
|
||||
const bookmarkIdentity = getSftpBookmarkIdentity(bm);
|
||||
const startsHostGroup = index > 0 && !!bookmarks[index - 1]?.global && !bm.global;
|
||||
const moveTargets = getSftpBookmarkMoveTargets(bookmarks, bm);
|
||||
const isDragging = draggingBookmark?.id === bm.id
|
||||
&& !!draggingBookmark.global === !!bm.global;
|
||||
const isRenaming = renamingBookmark?.id === bm.id
|
||||
&& !!renamingBookmark.global === !!bm.global;
|
||||
const pathContent = (
|
||||
<>
|
||||
<div className="text-xs font-medium truncate">{bm.label}</div>
|
||||
<div className="text-[10px] text-muted-foreground font-mono break-all leading-tight">
|
||||
{bm.path}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
const pathDisplay = managing ? (
|
||||
<div className="flex-1 min-w-0 text-left">{pathContent}</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 min-w-0 text-left"
|
||||
onClick={() => onNavigateToBookmark(bm.path)}
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{pathContent}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={bookmarkIdentity}
|
||||
className={cn(
|
||||
"flex items-start gap-1 px-2 py-1.5 hover:bg-secondary/60 group",
|
||||
startsHostGroup && "mt-1 border-t border-border/60 pt-2",
|
||||
isDragging && "opacity-60",
|
||||
)}
|
||||
data-bookmark-scope={bm.global ? "global" : "location"}
|
||||
draggable={managing && !isRenaming}
|
||||
onDragStart={(event) => {
|
||||
if (!managing) return;
|
||||
setDraggingBookmark(bm);
|
||||
event.dataTransfer.setData(SFTP_BOOKMARK_DRAG_TYPE, bookmarkIdentity);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
if (
|
||||
!managing
|
||||
|| !draggingBookmark
|
||||
|| (draggingBookmark.id === bm.id && !!draggingBookmark.global === !!bm.global)
|
||||
|| !canReorderSftpBookmark(draggingBookmark, bm)
|
||||
) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
const transferredIdentity = event.dataTransfer.getData(SFTP_BOOKMARK_DRAG_TYPE);
|
||||
const source = bookmarks.find((candidate) => (
|
||||
getSftpBookmarkIdentity(candidate) === transferredIdentity
|
||||
)) ?? draggingBookmark;
|
||||
setDraggingBookmark(null);
|
||||
if (
|
||||
source
|
||||
&& (source.id !== bm.id || !!source.global !== !!bm.global)
|
||||
&& canReorderSftpBookmark(source, bm)
|
||||
) onReorderBookmark?.(source, bm);
|
||||
}}
|
||||
onDragEnd={() => setDraggingBookmark(null)}
|
||||
>
|
||||
{managing && (
|
||||
<GripVertical
|
||||
size={12}
|
||||
className="mt-1 shrink-0 text-muted-foreground cursor-grab"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{bm.global && (
|
||||
<Globe size={10} className="mt-1 shrink-0 text-primary" />
|
||||
)}
|
||||
{managing && onRenameBookmark && isRenaming ? (
|
||||
<Input
|
||||
autoFocus
|
||||
value={renameDraft}
|
||||
aria-label={t("sftp.bookmark.rename")}
|
||||
className="h-6 flex-1 min-w-0 text-xs"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onChange={(event) => setRenameDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.nativeEvent.isComposing) return;
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
onRenameBookmark(bm, renameDraft);
|
||||
setRenamingBookmark(null);
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setRenamingBookmark(null);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
onRenameBookmark(bm, renameDraft);
|
||||
setRenamingBookmark(null);
|
||||
}}
|
||||
/>
|
||||
) : managing ? pathDisplay : (
|
||||
<PopoverClose asChild>{pathDisplay}</PopoverClose>
|
||||
)}
|
||||
{managing && onReorderBookmark && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 shrink-0 text-muted-foreground"
|
||||
aria-label={t("sftp.bookmark.moveUp", { label: bm.label })}
|
||||
disabled={!moveTargets.previous}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (moveTargets.previous) onReorderBookmark(bm, moveTargets.previous);
|
||||
}}
|
||||
>
|
||||
<ArrowUp size={10} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 shrink-0 text-muted-foreground"
|
||||
aria-label={t("sftp.bookmark.moveDown", { label: bm.label })}
|
||||
disabled={!moveTargets.next}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (moveTargets.next) onReorderBookmark(bm, moveTargets.next);
|
||||
}}
|
||||
>
|
||||
<ArrowDown size={10} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{managing && onRenameBookmark && !isRenaming && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 shrink-0 text-muted-foreground"
|
||||
aria-label={t("sftp.bookmark.rename")}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setRenamingBookmark(bm);
|
||||
setRenameDraft(bm.label);
|
||||
}}
|
||||
>
|
||||
<Pencil size={10} />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-5 w-5 shrink-0 text-muted-foreground hover:text-destructive",
|
||||
managing ? "opacity-100" : "opacity-0 group-hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-1",
|
||||
)}
|
||||
aria-label={t("sftp.bookmark.remove")}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onRequestDeleteBookmark(bm);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-3 text-xs text-muted-foreground text-center">
|
||||
{t("sftp.bookmark.empty")}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
const menuItemClass =
|
||||
"flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors w-full text-left";
|
||||
@@ -327,6 +526,10 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
|
||||
isCurrentPathGlobalBookmarked,
|
||||
onNavigateToBookmark,
|
||||
onDeleteBookmark,
|
||||
onReorderBookmark,
|
||||
onRenameBookmark,
|
||||
listDensity = "comfortable",
|
||||
onSetListDensity,
|
||||
showHiddenFiles,
|
||||
onToggleShowHiddenFiles,
|
||||
onGoToTerminalCwd,
|
||||
@@ -339,6 +542,11 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
|
||||
}) => {
|
||||
const [displayPath, setDisplayPath] = useState(pane.connection?.currentPath ?? "");
|
||||
const [filterDraft, setFilterDraft] = useState(pane.filter);
|
||||
const [bookmarkPopoverOpen, setBookmarkPopoverOpen] = useState(false);
|
||||
const [pendingBookmarkRemoval, setPendingBookmarkRemoval] = useState<SftpBookmark | null>(null);
|
||||
const bookmarkButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const overflowButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const bookmarkRemovalFocusOriginRef = useRef<"inline" | "overflow" | null>(null);
|
||||
const filterComposingRef = useRef(false);
|
||||
const filterAtComposeStartRef = useRef(pane.filter);
|
||||
// Directory at compositionstart. Navigation that leaves the filter already ""
|
||||
@@ -459,6 +667,8 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
|
||||
const isRemote = !pane.connection?.isLocal;
|
||||
const viewModeToggleTarget = getSftpViewModeToggleTarget(viewMode);
|
||||
const viewModeToggleLabel = t(viewModeToggleTarget.labelKey);
|
||||
const listDensityToggleTarget = getNextSftpListDensity(listDensity);
|
||||
const listDensityToggleLabel = t(getSftpListDensityToggleLabelKey(listDensity));
|
||||
const shouldToggleBookmarkFromButton = shouldToggleSftpBookmarkFromButton({
|
||||
bookmarkCount: bookmarks.length,
|
||||
isCurrentPathBookmarked,
|
||||
@@ -475,6 +685,7 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
|
||||
"bookmark",
|
||||
"copyPath",
|
||||
"viewMode",
|
||||
"listDensity",
|
||||
"filter",
|
||||
"newFolder",
|
||||
"newFile",
|
||||
@@ -496,6 +707,7 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
|
||||
followTerminalCwd: t("sftp.followTerminalCwd"),
|
||||
copyPath: t("sftp.copyCurrentPath"),
|
||||
viewMode: viewModeToggleLabel,
|
||||
listDensity: listDensityToggleLabel,
|
||||
filter: t("sftp.filter"),
|
||||
encoding: t("sftp.encoding.label"),
|
||||
newFolder: t("sftp.newFolder"),
|
||||
@@ -503,7 +715,7 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
|
||||
showHidden: t("settings.sftp.showHiddenFiles"),
|
||||
refresh: t("common.refresh"),
|
||||
}),
|
||||
[bookmarkButtonLabel, t, viewModeToggleLabel],
|
||||
[bookmarkButtonLabel, listDensityToggleLabel, t, viewModeToggleLabel],
|
||||
);
|
||||
|
||||
const itemIcons = useMemo(
|
||||
@@ -514,6 +726,7 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
|
||||
followTerminalCwd: <FolderSync size={14} />,
|
||||
copyPath: <ClipboardCopy size={14} />,
|
||||
viewMode: viewMode === "list" ? <List size={14} /> : <ListTree size={14} />,
|
||||
listDensity: <Rows3 size={14} />,
|
||||
filter: <Search size={14} />,
|
||||
encoding: <Languages size={14} />,
|
||||
newFolder: <FolderPlus size={14} />,
|
||||
@@ -573,7 +786,29 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
|
||||
[shown, collapsed, narrow],
|
||||
);
|
||||
|
||||
const renderBookmarkPopoverBody = (onAfterLeafAction?: () => void) => (
|
||||
const requestBookmarkRemoval = (
|
||||
bookmark: SftpBookmark,
|
||||
focusOrigin: "inline" | "overflow",
|
||||
) => {
|
||||
bookmarkRemovalFocusOriginRef.current = focusOrigin;
|
||||
setPendingBookmarkRemoval(bookmark);
|
||||
};
|
||||
|
||||
const restoreBookmarkRemovalFocus = () => {
|
||||
const origin = bookmarkRemovalFocusOriginRef.current;
|
||||
bookmarkRemovalFocusOriginRef.current = null;
|
||||
window.setTimeout(() => {
|
||||
const preferred = origin === "overflow" ? overflowButtonRef.current : bookmarkButtonRef.current;
|
||||
const fallback = origin === "overflow" ? bookmarkButtonRef.current : overflowButtonRef.current;
|
||||
const target = preferred?.isConnected ? preferred : fallback?.isConnected ? fallback : null;
|
||||
target?.focus();
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const renderBookmarkPopoverBody = (
|
||||
onAfterLeafAction?: () => void,
|
||||
focusOrigin: "inline" | "overflow" = "inline",
|
||||
) => (
|
||||
<SftpBookmarkPopoverBody
|
||||
t={t}
|
||||
bookmarks={bookmarks}
|
||||
@@ -583,17 +818,22 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
|
||||
onToggleBookmark={onToggleBookmark}
|
||||
onAddGlobalBookmark={onAddGlobalBookmark}
|
||||
onNavigateToBookmark={onNavigateToBookmark}
|
||||
onDeleteBookmark={onDeleteBookmark}
|
||||
onRequestDeleteBookmark={(bookmark) => (
|
||||
requestBookmarkRemoval(bookmark, focusOrigin)
|
||||
)}
|
||||
onReorderBookmark={onReorderBookmark}
|
||||
onRenameBookmark={onRenameBookmark}
|
||||
onAfterLeafAction={onAfterLeafAction}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderBookmarkButton = (size: "sm" | "md" = "sm") => (
|
||||
<Popover>
|
||||
<Popover open={bookmarkPopoverOpen} onOpenChange={setBookmarkPopoverOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
ref={bookmarkButtonRef}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
@@ -615,8 +855,8 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{bookmarkButtonLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent className="w-64 p-0" align="start">
|
||||
{renderBookmarkPopoverBody()}
|
||||
<PopoverContent className="w-80 p-0" align="start">
|
||||
{renderBookmarkPopoverBody(() => setBookmarkPopoverOpen(false))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
@@ -630,7 +870,9 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
|
||||
bookmarksCount={bookmarks.length}
|
||||
shouldToggleBookmarkFromButton={shouldToggleBookmarkFromButton}
|
||||
onToggleBookmark={onToggleBookmark}
|
||||
renderBody={(closeOverflow) => renderBookmarkPopoverBody(closeOverflow)}
|
||||
renderBody={(closeOverflow) => (
|
||||
renderBookmarkPopoverBody(closeOverflow, "overflow")
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -773,6 +1015,23 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
|
||||
<TooltipContent>{viewModeToggleLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
case "listDensity":
|
||||
return (
|
||||
<Tooltip key={id}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-6 w-6", listDensity === "compact" && "bg-secondary text-foreground")}
|
||||
aria-label={listDensityToggleLabel}
|
||||
onClick={() => onSetListDensity?.(listDensityToggleTarget)}
|
||||
>
|
||||
<Rows3 size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{listDensityToggleLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
case "filter":
|
||||
return (
|
||||
<Tooltip key={id}>
|
||||
@@ -916,6 +1175,18 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
|
||||
{viewModeToggleLabel}
|
||||
</button>
|
||||
);
|
||||
case "listDensity":
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={cn(menuItemClass, listDensity === "compact" && "text-primary")}
|
||||
onClick={() => onSetListDensity?.(listDensityToggleTarget)}
|
||||
>
|
||||
<Rows3 size={14} className="shrink-0" />
|
||||
{listDensityToggleLabel}
|
||||
</button>
|
||||
);
|
||||
case "filter":
|
||||
return (
|
||||
<button key={id} type="button" className={menuItemClass} onClick={handleToggleFilter}>
|
||||
@@ -1086,6 +1357,7 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
|
||||
orientation="horizontal"
|
||||
buttonClassName="h-6 w-6"
|
||||
contentClassName="min-w-[140px]"
|
||||
triggerRef={overflowButtonRef}
|
||||
>
|
||||
<div className="flex flex-col min-w-[140px]">{overflowNodes}</div>
|
||||
</ToolbarOverflowMenu>
|
||||
@@ -1215,6 +1487,28 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={pendingBookmarkRemoval !== null}
|
||||
title={t("sftp.bookmark.remove")}
|
||||
message={pendingBookmarkRemoval
|
||||
? t("sftp.bookmark.removeConfirm", { path: pendingBookmarkRemoval.path })
|
||||
: undefined}
|
||||
confirmLabel={t("sftp.bookmark.remove")}
|
||||
destructive
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setPendingBookmarkRemoval(null);
|
||||
restoreBookmarkRemovalFocus();
|
||||
}
|
||||
}}
|
||||
onConfirm={() => {
|
||||
if (!pendingBookmarkRemoval) return;
|
||||
const bookmark = pendingBookmarkRemoval;
|
||||
setPendingBookmarkRemoval(null);
|
||||
onDeleteBookmark(bookmark);
|
||||
restoreBookmarkRemovalFocus();
|
||||
}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
);
|
||||
});
|
||||
@@ -1228,7 +1522,9 @@ function SftpBookmarkPopoverBody({
|
||||
onToggleBookmark,
|
||||
onAddGlobalBookmark,
|
||||
onNavigateToBookmark,
|
||||
onDeleteBookmark,
|
||||
onRequestDeleteBookmark,
|
||||
onReorderBookmark,
|
||||
onRenameBookmark,
|
||||
onAfterLeafAction,
|
||||
}: {
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
@@ -1239,9 +1535,12 @@ function SftpBookmarkPopoverBody({
|
||||
onToggleBookmark: () => void;
|
||||
onAddGlobalBookmark: (path: string) => void;
|
||||
onNavigateToBookmark: (path: string) => void;
|
||||
onDeleteBookmark: (id: string) => void;
|
||||
onRequestDeleteBookmark: (bookmark: SftpBookmark) => void;
|
||||
onReorderBookmark?: (from: SftpBookmark, to: SftpBookmark) => void;
|
||||
onRenameBookmark?: (bookmark: SftpBookmark, label: string) => void;
|
||||
onAfterLeafAction?: () => void;
|
||||
}) {
|
||||
const [managing, setManaging] = useState(false);
|
||||
const runThenClose = (action: () => void) => {
|
||||
action();
|
||||
onAfterLeafAction?.();
|
||||
@@ -1249,15 +1548,34 @@ function SftpBookmarkPopoverBody({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="px-3 py-2 border-b border-border/40">
|
||||
<div className="px-3 py-2 border-b border-border/40 flex items-center justify-between gap-2">
|
||||
<div className="text-xs font-medium">{t("sftp.bookmark.list")}</div>
|
||||
{bookmarks.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-[11px]"
|
||||
onClick={() => setManaging((value) => !value)}
|
||||
>
|
||||
{managing ? t("sftp.bookmark.done") : t("sftp.bookmark.manage")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-2 border-b border-border/40 flex gap-1">
|
||||
<Button
|
||||
variant={isCurrentPathBookmarked ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="flex-1 justify-start text-xs h-7"
|
||||
onClick={() => runThenClose(onToggleBookmark)}
|
||||
onClick={() => {
|
||||
if (isCurrentPathBookmarked && currentPath) {
|
||||
const bookmark = bookmarks.find((candidate) => (
|
||||
candidate.path === currentPath && !candidate.global
|
||||
)) ?? bookmarks.find((candidate) => candidate.path === currentPath);
|
||||
if (bookmark) runThenClose(() => onRequestDeleteBookmark(bookmark));
|
||||
return;
|
||||
}
|
||||
runThenClose(onToggleBookmark);
|
||||
}}
|
||||
>
|
||||
<Bookmark
|
||||
size={12}
|
||||
@@ -1284,8 +1602,13 @@ function SftpBookmarkPopoverBody({
|
||||
</div>
|
||||
<SftpBookmarkList
|
||||
bookmarks={bookmarks}
|
||||
managing={managing}
|
||||
onNavigateToBookmark={(path) => runThenClose(() => onNavigateToBookmark(path))}
|
||||
onDeleteBookmark={(id) => runThenClose(() => onDeleteBookmark(id))}
|
||||
onRequestDeleteBookmark={(bookmark) => (
|
||||
runThenClose(() => onRequestDeleteBookmark(bookmark))
|
||||
)}
|
||||
onReorderBookmark={onReorderBookmark}
|
||||
onRenameBookmark={onRenameBookmark}
|
||||
t={t}
|
||||
/>
|
||||
</>
|
||||
@@ -1341,7 +1664,7 @@ function SftpOverflowNestedBookmark({
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-64 p-0"
|
||||
className="w-80 p-0"
|
||||
align="start"
|
||||
side="left"
|
||||
sideOffset={6}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
|
||||
import { getSftpTreeEntryOpenAction } from "./SftpPaneTreeView.tsx";
|
||||
import type { SftpFileEntry } from "../../types";
|
||||
|
||||
const entry = (name: string, type: SftpFileEntry["type"]): SftpFileEntry => ({
|
||||
name,
|
||||
type,
|
||||
size: 0,
|
||||
lastModified: 0,
|
||||
});
|
||||
|
||||
test("tree activation enters directories and keeps file opening separate", () => {
|
||||
assert.equal(getSftpTreeEntryOpenAction(entry("..", "directory")), "up");
|
||||
assert.equal(getSftpTreeEntryOpenAction(entry("docs", "directory")), "navigate");
|
||||
assert.equal(getSftpTreeEntryOpenAction(entry("notes.txt", "file")), "open");
|
||||
});
|
||||
|
||||
test("tree row double click routes through the open action", () => {
|
||||
const source = fs.readFileSync(new URL("./SftpPaneTreeNode.tsx", import.meta.url), "utf8");
|
||||
assert.match(source, /onDoubleClick=\{\(\) => onOpenEntry\(entry, entryPath\)\}/);
|
||||
});
|
||||
@@ -67,11 +67,7 @@ export const TreeNode = React.memo<TreeNodeProps>(({
|
||||
)}
|
||||
style={{ gridTemplateColumns: columnTemplate, height: TREE_ROW_HEIGHT }}
|
||||
onClick={e => onNodeClick(entry, entryPath, e)}
|
||||
onDoubleClick={() => {
|
||||
if (isParentEntry) { onOpenEntry(entry, entryPath); return; }
|
||||
if (isDir) void onToggleExpand(entry, entryPath);
|
||||
else onOpenEntry(entry, entryPath);
|
||||
}}
|
||||
onDoubleClick={() => onOpenEntry(entry, entryPath)}
|
||||
onContextMenu={e => {
|
||||
if (!isParentEntry) {
|
||||
onContextMenu(entry, entryPath, e);
|
||||
|
||||
@@ -24,6 +24,15 @@ interface ContextTarget {
|
||||
entry: SftpFileEntry;
|
||||
entryPath: string;
|
||||
}
|
||||
|
||||
export const getSftpTreeEntryOpenAction = (
|
||||
entry: SftpFileEntry,
|
||||
): 'up' | 'navigate' | 'open' => {
|
||||
if (entry.name === '..') return 'up';
|
||||
if (isNavigableDirectory(entry)) return 'navigate';
|
||||
return 'open';
|
||||
};
|
||||
|
||||
export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
||||
pane,
|
||||
side,
|
||||
@@ -400,10 +409,15 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
||||
lastClickedPathRef.current = entryPath;
|
||||
}, [focusTreeContainer, pane.id]);
|
||||
const openTreeEntry = useCallback((entry: SftpFileEntry, entryPath: string) => {
|
||||
if (entry.name === '..') {
|
||||
const action = getSftpTreeEntryOpenAction(entry);
|
||||
if (action === 'up') {
|
||||
onNavigateUpRef.current();
|
||||
return;
|
||||
}
|
||||
if (action === 'navigate') {
|
||||
onNavigateToRef.current(entryPath);
|
||||
return;
|
||||
}
|
||||
onOpenEntryRef.current(entry, entryPath);
|
||||
}, []);
|
||||
const stableOnRefresh = useCallback(() => onRefreshRef.current(), []);
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
} from "./SftpContext";
|
||||
import type { SftpPane } from "../../application/state/sftp/types";
|
||||
import { joinPath, getParentPath } from "../../application/state/sftp/utils";
|
||||
import type { Host } from "../../domain/models";
|
||||
import type { Host, SftpBookmark } from "../../domain/models";
|
||||
import { useSftpPaneDialogs } from "./hooks/useSftpPaneDialogs";
|
||||
import { useSftpPaneDragAndSelect } from "./hooks/useSftpPaneDragAndSelect";
|
||||
import { useSftpPaneFiles } from "./hooks/useSftpPaneFiles";
|
||||
@@ -33,6 +33,7 @@ import { useSftpBookmarks } from "./hooks/useSftpBookmarks";
|
||||
import { useLocalSftpBookmarks } from "../../application/state/sftp/localSftpBookmarks";
|
||||
import { useGlobalSftpBookmarks } from "./hooks/useGlobalSftpBookmarks";
|
||||
import { useSftpHostViewMode } from "../../application/state/sftp/sftpHostViewModeStore";
|
||||
import { useSftpListDensity } from "../../application/state/sftp/sftpListDensityStore";
|
||||
import { sftpListOrderStore } from "./hooks/useSftpListOrderStore";
|
||||
import { sftpTreeSelectionStore } from "../../application/state/sftp/sftpTreeSelectionStore";
|
||||
import { sftpClipboardUploadStore } from "./clipboardUpload";
|
||||
@@ -117,6 +118,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
const [showFilterBar, setShowFilterBar] = useState(false);
|
||||
const initialViewMode = hostViewMode ?? sftpDefaultViewMode ?? 'list';
|
||||
const [viewMode, setViewMode] = useState<'list' | 'tree'>(initialViewMode);
|
||||
const { density: listDensity, setDensity: setListDensity } = useSftpListDensity();
|
||||
const [treeReloadRequest, setTreeReloadRequest] = useState<TreeReloadRequest>({ token: 0, full: true });
|
||||
// Lazy-mount: only render the tree component once tree mode has been activated
|
||||
const [treeEverMounted, setTreeEverMounted] = useState(initialViewMode === 'tree');
|
||||
@@ -195,7 +197,10 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
});
|
||||
const hostBookmarks = pane.connection?.isLocal ? localBookmarks : remoteBookmarks;
|
||||
const mergedBookmarks = useMemo(
|
||||
() => [...globalBookmarks.bookmarks.map((b) => ({ ...b, global: true as const })), ...hostBookmarks.bookmarks],
|
||||
() => [
|
||||
...globalBookmarks.bookmarks.map((b) => ({ ...b, global: true as const })),
|
||||
...hostBookmarks.bookmarks.map((b) => ({ ...b, global: false as const })),
|
||||
],
|
||||
[hostBookmarks.bookmarks, globalBookmarks.bookmarks],
|
||||
);
|
||||
const isCurrentPathBookmarked = hostBookmarks.isCurrentPathBookmarked || globalBookmarks.isCurrentPathBookmarked;
|
||||
@@ -211,11 +216,31 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
}
|
||||
}, [hostBookmarks, globalBookmarks, pane.connection?.currentPath]);
|
||||
const deleteBookmark = useCallback(
|
||||
(id: string) => {
|
||||
if (id.startsWith("gbm-")) {
|
||||
globalBookmarks.deleteBookmark(id);
|
||||
(bookmark: SftpBookmark) => {
|
||||
if (bookmark.global) {
|
||||
globalBookmarks.deleteBookmark(bookmark.id);
|
||||
} else {
|
||||
hostBookmarks.deleteBookmark(id);
|
||||
hostBookmarks.deleteBookmark(bookmark.id);
|
||||
}
|
||||
},
|
||||
[hostBookmarks, globalBookmarks],
|
||||
);
|
||||
const reorderBookmark = useCallback(
|
||||
(from: SftpBookmark, to: SftpBookmark) => {
|
||||
if (from.global && to.global) {
|
||||
globalBookmarks.reorderBookmark(from.id, to.id);
|
||||
} else if (!from.global && !to.global) {
|
||||
hostBookmarks.reorderBookmark(from.id, to.id);
|
||||
}
|
||||
},
|
||||
[hostBookmarks, globalBookmarks],
|
||||
);
|
||||
const renameBookmark = useCallback(
|
||||
(bookmark: SftpBookmark, label: string) => {
|
||||
if (bookmark.global) {
|
||||
globalBookmarks.renameBookmark(bookmark.id, label);
|
||||
} else {
|
||||
hostBookmarks.renameBookmark(bookmark.id, label);
|
||||
}
|
||||
},
|
||||
[hostBookmarks, globalBookmarks],
|
||||
@@ -376,6 +401,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
isActive,
|
||||
enabled: viewMode === 'list',
|
||||
sortedDisplayFiles,
|
||||
layoutKey: listDensity,
|
||||
});
|
||||
|
||||
const toFullPath = useCallback(
|
||||
@@ -574,6 +600,8 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
isCurrentPathGlobalBookmarked={globalBookmarks.isCurrentPathBookmarked}
|
||||
onNavigateToBookmark={callbacks.onNavigateTo}
|
||||
onDeleteBookmark={deleteBookmark}
|
||||
onReorderBookmark={reorderBookmark}
|
||||
onRenameBookmark={renameBookmark}
|
||||
showHiddenFiles={pane.showHiddenFiles}
|
||||
onToggleShowHiddenFiles={onToggleShowHiddenFiles}
|
||||
onGoToTerminalCwd={onGoToTerminalCwd}
|
||||
@@ -582,6 +610,8 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
onToggleFollowTerminalCwd={onToggleFollowTerminalCwd}
|
||||
viewMode={viewMode}
|
||||
onSetViewMode={handleSetViewMode}
|
||||
listDensity={listDensity}
|
||||
onSetListDensity={setListDensity}
|
||||
onListDrives={callbacks.onListDrives}
|
||||
/>
|
||||
|
||||
@@ -671,6 +701,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
openDeleteConfirm={openDeleteConfirm}
|
||||
rowHeight={rowHeight}
|
||||
visibleRows={visibleRows}
|
||||
listDensity={listDensity}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
setGlobalSftpBookmarks,
|
||||
subscribeGlobalSftpBookmarks,
|
||||
} from "../../../application/state/sftp/globalSftpBookmarks";
|
||||
import { createSftpBookmark } from "../../../application/state/sftp/bookmarkHelpers";
|
||||
import { createSftpBookmark, moveSftpBookmark, renameSftpBookmark } from "../../../application/state/sftp/bookmarkHelpers";
|
||||
|
||||
interface UseGlobalSftpBookmarksParams {
|
||||
currentPath: string | undefined;
|
||||
@@ -34,10 +34,20 @@ export const useGlobalSftpBookmarks = ({
|
||||
setGlobalSftpBookmarks((prev) => prev.filter((b) => b.id !== id));
|
||||
}, []);
|
||||
|
||||
const reorderBookmark = useCallback((fromId: string, toId: string) => {
|
||||
setGlobalSftpBookmarks((prev) => moveSftpBookmark(prev, fromId, toId));
|
||||
}, []);
|
||||
|
||||
const renameBookmark = useCallback((id: string, label: string) => {
|
||||
setGlobalSftpBookmarks((prev) => renameSftpBookmark(prev, id, label));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
bookmarks,
|
||||
isCurrentPathBookmarked,
|
||||
addBookmark,
|
||||
deleteBookmark,
|
||||
reorderBookmark,
|
||||
renameBookmark,
|
||||
};
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type { Host, SftpBookmark } from "../../../domain/models";
|
||||
import { createSftpBookmark } from "../../../application/state/sftp/bookmarkHelpers";
|
||||
import { createSftpBookmark, moveSftpBookmark, renameSftpBookmark } from "../../../application/state/sftp/bookmarkHelpers";
|
||||
|
||||
interface UseSftpBookmarksParams {
|
||||
host: Host | undefined;
|
||||
@@ -13,6 +13,8 @@ interface UseSftpBookmarksResult {
|
||||
isCurrentPathBookmarked: boolean;
|
||||
toggleBookmark: () => void;
|
||||
deleteBookmark: (id: string) => void;
|
||||
reorderBookmark: (fromId: string, toId: string) => void;
|
||||
renameBookmark: (id: string, label: string) => void;
|
||||
}
|
||||
|
||||
export const useSftpBookmarks = ({
|
||||
@@ -52,10 +54,26 @@ export const useSftpBookmarks = ({
|
||||
[bookmarks, updateHostBookmarks],
|
||||
);
|
||||
|
||||
const reorderBookmark = useCallback(
|
||||
(fromId: string, toId: string) => {
|
||||
updateHostBookmarks(moveSftpBookmark(bookmarks, fromId, toId));
|
||||
},
|
||||
[bookmarks, updateHostBookmarks],
|
||||
);
|
||||
|
||||
const renameBookmark = useCallback(
|
||||
(id: string, label: string) => {
|
||||
updateHostBookmarks(renameSftpBookmark(bookmarks, id, label));
|
||||
},
|
||||
[bookmarks, updateHostBookmarks],
|
||||
);
|
||||
|
||||
return {
|
||||
bookmarks,
|
||||
isCurrentPathBookmarked,
|
||||
toggleBookmark,
|
||||
deleteBookmark,
|
||||
reorderBookmark,
|
||||
renameBookmark,
|
||||
};
|
||||
};
|
||||
@@ -5,6 +5,7 @@ interface UseSftpPaneVirtualListParams {
|
||||
isActive: boolean;
|
||||
enabled?: boolean;
|
||||
sortedDisplayFiles: SftpFileEntry[];
|
||||
layoutKey?: string;
|
||||
}
|
||||
|
||||
interface UseSftpPaneVirtualListResult {
|
||||
@@ -20,6 +21,7 @@ export const useSftpPaneVirtualList = ({
|
||||
isActive,
|
||||
enabled = true,
|
||||
sortedDisplayFiles,
|
||||
layoutKey,
|
||||
}: UseSftpPaneVirtualListParams): UseSftpPaneVirtualListResult => {
|
||||
const fileListRef = useRef<HTMLDivElement>(null);
|
||||
const [rowHeight, setRowHeight] = useState(0);
|
||||
@@ -39,7 +41,7 @@ export const useSftpPaneVirtualList = ({
|
||||
resizeObserver.disconnect();
|
||||
window.cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [enabled, isActive, sortedDisplayFiles.length]);
|
||||
}, [enabled, isActive, layoutKey, sortedDisplayFiles.length]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = fileListRef.current;
|
||||
@@ -55,7 +57,7 @@ export const useSftpPaneVirtualList = ({
|
||||
}
|
||||
});
|
||||
return () => window.cancelAnimationFrame(raf);
|
||||
}, [enabled, isActive, rowHeight, sortedDisplayFiles.length]);
|
||||
}, [enabled, isActive, layoutKey, rowHeight, sortedDisplayFiles.length]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
||||
@@ -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">
|
||||
{inWorkspace && onToggleBroadcast && (
|
||||
{onToggleBroadcast && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -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'\) return;\s*recoverTerminalOnAppResume\(\);\s*\};/,
|
||||
/const handleVisibilityChange = \(\) => \{\s*if \(document\.visibilityState !== 'visible'\) \{\s*syncOutputPressureVisibility\(\);\s*return;\s*\}\s*recoverTerminalOnAppResume\(\);\s*\};/,
|
||||
);
|
||||
assert.match(
|
||||
resumeEffectSource,
|
||||
|
||||
@@ -6,7 +6,11 @@ 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 } from "./runtime/terminalOutputPressure.ts";
|
||||
import {
|
||||
noteTerminalOutputPressureData,
|
||||
setTerminalOutputPressureVisibility,
|
||||
} from "./runtime/terminalOutputPressure.ts";
|
||||
import { writeTerminalPayloadChunked } from "./terminalReplay.ts";
|
||||
import { KeywordHighlighter } from "./keywordHighlight.ts";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
@@ -243,6 +247,27 @@ 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);
|
||||
@@ -1039,7 +1064,9 @@ test("quiet catch-up refreshes a tall viewport once", async () => {
|
||||
await write(term, flood);
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
await highlighter.whenSettled();
|
||||
assert.equal(refreshCount, 1);
|
||||
// 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.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), []);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
@@ -1064,7 +1091,7 @@ test("identical flood lines are all colored after quiet catch-up", async () => {
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("saturated flood does not rematch the pinned viewport on each write", async () => {
|
||||
test("saturated streaming keeps the pinned viewport colored in-frame", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 6, scrollback: 8 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
@@ -1076,8 +1103,56 @@ test("saturated flood does not rematch the pinned viewport on each write", 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);
|
||||
assert.notEqual(cellRgb(term, viewportY + 2, "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), []);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
@@ -1098,3 +1173,52 @@ 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,9 +5,13 @@ 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 { shouldDegradeTerminalKeywordHighlight } from "./runtime/terminalOutputPressure";
|
||||
import {
|
||||
isTerminalOutputInBackground,
|
||||
shouldDegradeTerminalKeywordHighlight,
|
||||
} from "./runtime/terminalOutputPressure";
|
||||
|
||||
type RuntimeKeywordHighlightRule = KeywordHighlightRule & { readonly providerId?: string };
|
||||
|
||||
@@ -396,12 +400,43 @@ 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);
|
||||
@@ -692,6 +727,23 @@ 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) {
|
||||
|
||||
@@ -3708,7 +3708,7 @@ test("ssh session restores cwd before startup command after attaching", async ()
|
||||
assert.deepEqual(progressLogs, ["Restoring working directory: /srv/app dir"]);
|
||||
});
|
||||
|
||||
test("local session resets terminal timestamp state when reusing a terminal", async () => {
|
||||
test("local session keeps timestamp anchors for preserved scrollback when reusing a terminal", async () => {
|
||||
const writes: string[] = [];
|
||||
const markerLines: number[] = [];
|
||||
const disposedMarkerLines: number[] = [];
|
||||
@@ -3814,8 +3814,10 @@ test("local session resets terminal timestamp state when reusing a terminal", as
|
||||
assert.equal(writes.length, 2);
|
||||
assert.equal(writes[0], "unfinished");
|
||||
assert.equal(writes[1], "fresh");
|
||||
// Reconnect preserves the buffer, so the pre-restart stamp stays anchored
|
||||
// (not disposed) and the fresh output records its own stamp.
|
||||
assert.deepEqual(markerLines, [0, 0]);
|
||||
assert.deepEqual(disposedMarkerLines, [0]);
|
||||
assert.deepEqual(disposedMarkerLines, []);
|
||||
});
|
||||
|
||||
test("session data waits for prior terminal writes before evaluating prompt line breaks", async () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { stringCellWidth } from "../autocomplete/terminalStringCellWidth";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { ImageAddon } from "@xterm/addon-image";
|
||||
import { SearchAddon } from "@xterm/addon-search";
|
||||
@@ -1065,7 +1066,43 @@ 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);
|
||||
overlay.textContent = previewRows.map((row) => row.text).join("\n");
|
||||
// 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.setAttribute(HISTORY_PREVIEW_WRAP_ATTR, encodeHistoryPreviewWrapFlags(previewRows));
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -17,10 +17,30 @@ 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]> = {},
|
||||
) => ({
|
||||
@@ -352,3 +372,23 @@ 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,6 +147,7 @@ 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;
|
||||
@@ -271,6 +272,16 @@ 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;
|
||||
@@ -347,9 +358,7 @@ export const selectHistoryPreviewAll = (overlay: HTMLElement | null | undefined)
|
||||
const selection = overlay.ownerDocument.getSelection();
|
||||
if (!selection) return false;
|
||||
const range = overlay.ownerDocument.createRange();
|
||||
const textNode = overlay.firstChild;
|
||||
if (textNode) range.selectNodeContents(textNode);
|
||||
else range.selectNodeContents(overlay);
|
||||
range.selectNodeContents(overlay);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
return !selection.isCollapsed;
|
||||
|
||||
@@ -1501,6 +1501,23 @@ export const resetTerminalLineTimestamps = (term: XTerm) => {
|
||||
resetTimestampStore(getTimestampStore(term));
|
||||
};
|
||||
|
||||
/**
|
||||
* Reconnect-style reattach on a reused terminal: the xterm buffer (including
|
||||
* scrollback preserved by the reconnect flow) survives the previous session,
|
||||
* so keep ledger stamps anchored to that history and only reset per-session
|
||||
* recording state (segmenter line tracking, same-second gate, pending prefix).
|
||||
* A full reset here would leave preserved scrollback rows without timestamps.
|
||||
*/
|
||||
export const resetTerminalLineTimestampRecordingState = (term: XTerm): void => {
|
||||
const store = getTimestampStore(term);
|
||||
store.segmenter.reset();
|
||||
store.timestampOnlyPrefix = "";
|
||||
store.lastStampSecondKey = null;
|
||||
store.recordsSincePrune = 0;
|
||||
store.disposedPendingCompact = 0;
|
||||
notifyTimestampStore(store);
|
||||
};
|
||||
|
||||
export const onTerminalLineTimestampsChange = (
|
||||
term: XTerm,
|
||||
listener: () => void,
|
||||
|
||||
@@ -246,6 +246,11 @@ 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,7 +20,10 @@ import {
|
||||
writeSessionData,
|
||||
} from "./terminalSessionAttachment.ts";
|
||||
import { getVisibleTerminalLineTimestampRows } from "./terminalLineTimestamps.ts";
|
||||
import { noteTerminalOutputPressureData } from "./terminalOutputPressure.ts";
|
||||
import {
|
||||
getTerminalOutputPressure,
|
||||
noteTerminalOutputPressureData,
|
||||
} from "./terminalOutputPressure.ts";
|
||||
|
||||
import {
|
||||
clearTerminalSessionFlowAck,
|
||||
@@ -1539,6 +1542,50 @@ 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;
|
||||
@@ -2051,6 +2098,49 @@ test("attachSessionToTerminal resets timestamp state for a reused terminal", ()
|
||||
assert.equal(writes[1], "fresh");
|
||||
});
|
||||
|
||||
test("attachSessionToTerminal keeps timestamps for scrollback preserved by reconnect", () => {
|
||||
const { term } = createFakeTerm();
|
||||
const ctx = {
|
||||
...createContext(false, { showLineTimestamps: true }),
|
||||
sessionId: "session-1",
|
||||
sessionRef: { current: null },
|
||||
hasConnectedRef: { current: true },
|
||||
hasRunStartupCommandRef: { current: false },
|
||||
disposeDataRef: { current: null },
|
||||
disposeExitRef: { current: null },
|
||||
fitAddonRef: { current: null },
|
||||
serializeAddonRef: { current: null },
|
||||
pendingAuthRef: { current: null },
|
||||
terminalBackend: {
|
||||
onSessionData: () => () => {},
|
||||
onSessionExit: () => () => {},
|
||||
},
|
||||
updateStatus: () => {},
|
||||
setError: () => {},
|
||||
onSessionExit: () => {},
|
||||
};
|
||||
const originalDateNow = Date.now;
|
||||
|
||||
try {
|
||||
Date.now = () => new Date(2026, 0, 1, 12, 0, 59, 800).getTime();
|
||||
writeSessionData(ctx as never, term, "before reconnect\r\n");
|
||||
|
||||
// Reconnect reuses the same xterm (scrollback preserved by the caller).
|
||||
attachSessionToTerminal(ctx as never, term, "session-2");
|
||||
|
||||
Date.now = () => new Date(2026, 0, 1, 12, 1, 30, 0).getTime();
|
||||
writeSessionData(ctx as never, term, "after reconnect\r\n");
|
||||
|
||||
assert.deepEqual(getVisibleTerminalLineTimestampRows(term), [
|
||||
{ row: 0, label: "12:00:59" },
|
||||
{ row: 1, label: "12:01:30" },
|
||||
]);
|
||||
} finally {
|
||||
Date.now = originalDateNow;
|
||||
resetTerminalWriteCoalescer(term);
|
||||
}
|
||||
});
|
||||
|
||||
test("attachSessionToTerminal clears the backend id before reporting exit", () => {
|
||||
const { term } = createFakeTerm();
|
||||
let onExit: ((evt: { reason?: string }) => void) | null = null;
|
||||
|
||||
@@ -23,7 +23,7 @@ import type {
|
||||
} from "./createTerminalSessionStarters.types";
|
||||
import { clearConnectionToken } from "./terminalDistroDetection";
|
||||
import {
|
||||
resetTerminalLineTimestamps,
|
||||
resetTerminalLineTimestampRecordingState,
|
||||
type TerminalLineTimestampPerfStep,
|
||||
writeTerminalDataWithLineTimestamps,
|
||||
} from "./terminalLineTimestamps";
|
||||
@@ -89,6 +89,7 @@ import {
|
||||
} from "./terminalOutputPipeline";
|
||||
import {
|
||||
hasPendingTerminalWrites,
|
||||
isTerminalPageHidden,
|
||||
maybeFlushTerminalWriteCoalescerWhenUnfocused,
|
||||
scheduleTerminalRepaintWhenUnfocused,
|
||||
shouldFlushTerminalWritesForBackgroundOutput,
|
||||
@@ -114,6 +115,16 @@ 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,
|
||||
@@ -409,7 +420,7 @@ export const getFlowController = (
|
||||
return controller;
|
||||
};
|
||||
|
||||
export const resetTerminalLineTimestampState = resetTerminalLineTimestamps;
|
||||
export const resetTerminalLineTimestampState = resetTerminalLineTimestampRecordingState;
|
||||
|
||||
export const acknowledgeDroppedTerminalDisplayBytes = (
|
||||
ctx: TerminalSessionStartersContext,
|
||||
@@ -471,7 +482,7 @@ export const writeSessionData = (
|
||||
visible: isPaneVisible,
|
||||
});
|
||||
flow.received(ingressBytes);
|
||||
setTerminalOutputPressureVisibility(term, isPaneVisible);
|
||||
setTerminalOutputPressureVisibility(term, isTerminalOutputVisibleForPressure(ctx));
|
||||
noteTerminalOutputPressureData(term, data);
|
||||
const settings = ctx.terminalSettingsRef?.current ?? ctx.terminalSettings;
|
||||
const preservePromptSourceChunks = Boolean(
|
||||
@@ -485,6 +496,12 @@ 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(),
|
||||
@@ -856,7 +873,10 @@ export const attachSessionToTerminal = (
|
||||
teardownTerminalOutputPipeline(ctx, term, id, flow);
|
||||
flushTerminalWriteCoalescer(term);
|
||||
resetTerminalSyncBlockFilter(term);
|
||||
resetTerminalLineTimestamps(term);
|
||||
// Reattach keeps the xterm buffer (reconnect preserves scrollback), so only
|
||||
// reset per-session recording state — a full reset would strip timestamps
|
||||
// from preserved history.
|
||||
resetTerminalLineTimestampRecordingState(term);
|
||||
resetTerminalOutputPressure(term);
|
||||
ctx.onSessionAttached?.(id);
|
||||
const assistMode =
|
||||
|
||||
@@ -24,7 +24,7 @@ test("renderer activity follows the hibernate setting instead of active-tab visi
|
||||
);
|
||||
});
|
||||
|
||||
test("inactive terminal surfaces remain painted and non-interactive without hibernate", () => {
|
||||
test("inactive terminal surfaces remain mounted and non-interactive without hibernate", () => {
|
||||
assert.match(supportSource, /resolveTerminalHibernateEnabledForProtocol\(terminalSettings, host\.protocol\)/);
|
||||
assert.match(
|
||||
supportSource,
|
||||
|
||||
@@ -12,10 +12,20 @@ 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) => {
|
||||
term.write(data, () => resolve());
|
||||
replayWrites.add(term);
|
||||
try {
|
||||
term.write(data, () => resolve());
|
||||
} finally {
|
||||
replayWrites.delete(term);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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*return;\s*\}[\s\S]*if \(splitResizeActive\) return;[\s\S]*wasVisibleRef\.current = true;[\s\S]*recoverTerminalAfterBecomeVisible\(\)/,
|
||||
/if \(!isVisible\) \{\s*wasVisibleRef\.current = false;\s*syncOutputPressureVisibility\(\);\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,8 +41,10 @@ import {
|
||||
flushPendingTerminalWritesBeforeHibernate,
|
||||
flushPendingTerminalWritesOnResume,
|
||||
forceTerminalRepaintBypassingAnimationFrame,
|
||||
isTerminalPageHidden,
|
||||
repaintTerminalAfterReveal,
|
||||
} from './runtime/terminalUnfocusedRepaint';
|
||||
import { setTerminalOutputPressureVisibility } from './runtime/terminalOutputPressure';
|
||||
import {
|
||||
forceXTermFontRemeasure,
|
||||
type XTermFontRemeasureTarget,
|
||||
@@ -1274,10 +1276,27 @@ 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);
|
||||
@@ -1327,6 +1346,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
|
||||
if (!isVisible) {
|
||||
wasVisibleRef.current = false;
|
||||
syncOutputPressureVisibility();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1844,6 +1864,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
const recoverTerminalOnAppResume = () => {
|
||||
const term = termRef.current;
|
||||
if (term) {
|
||||
syncOutputPressureVisibility();
|
||||
cancelScheduledUnfocusedRepaint(term);
|
||||
flushPendingTerminalWritesOnResume(term);
|
||||
forceTerminalRepaintBypassingAnimationFrame(term);
|
||||
@@ -1854,7 +1875,10 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
if (document.visibilityState !== 'visible') {
|
||||
syncOutputPressureVisibility();
|
||||
return;
|
||||
}
|
||||
recoverTerminalOnAppResume();
|
||||
};
|
||||
|
||||
|
||||
@@ -710,7 +710,10 @@ 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;
|
||||
@@ -810,6 +813,9 @@ 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,
|
||||
@@ -935,6 +941,9 @@ 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 &&
|
||||
@@ -1233,6 +1242,9 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
||||
onSetWorkspaceFocusedSession,
|
||||
onSplitSession,
|
||||
isBroadcastEnabled,
|
||||
isGlobalBroadcastEnabled,
|
||||
canUseGlobalBroadcast,
|
||||
onToggleGlobalBroadcast,
|
||||
onBroadcastInput,
|
||||
onBroadcastInterruptPriorityChange,
|
||||
onToggleWorkspaceComposeBar,
|
||||
@@ -1459,7 +1471,9 @@ 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) : false;
|
||||
const broadcastEnabled = activeWorkspaceId
|
||||
? !!isBroadcastEnabled?.(activeWorkspaceId)
|
||||
: (isGlobalBroadcastEnabled ?? false);
|
||||
const isHostEphemeral = !isSavedVaultHost(hostMap.get(host.id));
|
||||
const sessionAppearance = useMemo(
|
||||
() => resolveSessionAppearance({ host, isEphemeral: isHostEphemeral }),
|
||||
@@ -1605,7 +1619,11 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
||||
onSplitHorizontal={onSplitSession ? splitHorizontalHandler : undefined}
|
||||
onSplitVertical={onSplitSession ? splitVerticalHandler : undefined}
|
||||
isBroadcastEnabled={broadcastEnabled}
|
||||
onToggleBroadcast={inActiveWorkspace ? workspaceBroadcastHandler : undefined}
|
||||
onToggleBroadcast={
|
||||
inActiveWorkspace
|
||||
? workspaceBroadcastHandler
|
||||
: (canUseGlobalBroadcast ? onToggleGlobalBroadcast : undefined)
|
||||
}
|
||||
onToggleComposeBar={inActiveWorkspace ? onToggleWorkspaceComposeBar : undefined}
|
||||
isWorkspaceComposeBarOpen={inActiveWorkspace ? isComposeBarOpen : undefined}
|
||||
onBroadcastInput={broadcastEnabled ? onBroadcastInput : undefined}
|
||||
@@ -1697,6 +1715,9 @@ 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,
|
||||
@@ -1784,6 +1805,9 @@ 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,6 +635,9 @@ 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,
|
||||
@@ -691,6 +694,9 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
s.terminalTheme,
|
||||
s.resolveSessionAppearance,
|
||||
s.hostMap,
|
||||
s.isGlobalBroadcastEnabled,
|
||||
s.canUseGlobalBroadcast,
|
||||
s.onToggleGlobalBroadcastRef,
|
||||
]);
|
||||
|
||||
return <TerminalLayerView ctx={ctx} />;
|
||||
|
||||
@@ -123,6 +123,9 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
|
||||
onReorderTabs,
|
||||
onStartSessionDrag,
|
||||
onEndSessionDrag,
|
||||
isGlobalBroadcastEnabled,
|
||||
canUseGlobalBroadcast,
|
||||
onToggleGlobalBroadcast,
|
||||
t,
|
||||
} = ctx;
|
||||
|
||||
@@ -255,6 +258,9 @@ 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';
|
||||
|
||||
Loaded 100 of 201 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user