mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-10 16:21:22 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b143c2f4e |
No files matched your search
@@ -695,11 +695,6 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Security CDN endpoints can lack files advertised in their indexes.
|
||||
# Fall back per file, with the final Bullseye security snapshot for packages only.
|
||||
# Live signed indexes still select versions and verify every download.
|
||||
printf 'http://deb.debian.org/debian-security\tpriority:1\nhttp://security.debian.org/debian-security\tpriority:2\nhttp://snapshot.debian.org/archive/debian-security/20260901T000000Z/\tpriority:3 type:deb\n' > /etc/apt/security-mirrors.txt
|
||||
sed -i 's|http://deb.debian.org/debian-security|mirror+file:/etc/apt/security-mirrors.txt|g' /etc/apt/sources.list
|
||||
apt-get -o Acquire::Retries=4 update
|
||||
apt-get -o Acquire::Retries=4 install -y curl build-essential python3 git libfuse2 file rpm \
|
||||
libarchive-tools \
|
||||
|
||||
@@ -315,30 +315,6 @@ test('pane zoom hotkey delegates to the active in-app magnification surface', ()
|
||||
assert.equal(toggles, 1);
|
||||
});
|
||||
|
||||
test('broadcast hotkey toggles global mode for an active orphan tab', () => {
|
||||
let globalToggles = 0;
|
||||
let workspaceToggles = 0;
|
||||
|
||||
executeHotkeyActionImpl(() => ({
|
||||
activeTabStore: { getActiveTabId: () => 'orphan-1' },
|
||||
editorTabs: [],
|
||||
orderedTabs: ['orphan-1', 'orphan-2'],
|
||||
settings: { showSftpTab: true, shellOnlyTabNumberShortcuts: false },
|
||||
toEditorTabId: (id: string) => id,
|
||||
sessions: [
|
||||
{ id: 'orphan-1' },
|
||||
{ id: 'orphan-2' },
|
||||
],
|
||||
workspaces: [],
|
||||
canUseGlobalBroadcast: true,
|
||||
toggleBroadcast: () => { workspaceToggles += 1; },
|
||||
toggleGlobalBroadcast: () => { globalToggles += 1; },
|
||||
}), 'broadcast', {} as KeyboardEvent);
|
||||
|
||||
assert.equal(globalToggles, 1);
|
||||
assert.equal(workspaceToggles, 0);
|
||||
});
|
||||
|
||||
test('move-focus shortcut cannot send input behind a magnified pane', () => {
|
||||
let moveCalls = 0;
|
||||
executeHotkeyActionImpl(() => ({
|
||||
|
||||
@@ -722,7 +722,7 @@ export async function closeTabsBatchImpl(getCtx: AppContextGetter, targetIds: st
|
||||
}
|
||||
|
||||
export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string, e: KeyboardEvent) {
|
||||
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, canUseGlobalBroadcast, closePluginViewTab, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isPluginViewTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, sftpPaneMagnificationRef, splitSessionWithCurrentShell, systemInfoRef, terminalPaneMagnificationRef, toEditorTabId, toggleBroadcast, toggleGlobalBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces } = getCtx();
|
||||
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, closePluginViewTab, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isPluginViewTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, sftpPaneMagnificationRef, splitSessionWithCurrentShell, systemInfoRef, terminalPaneMagnificationRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces } = getCtx();
|
||||
{
|
||||
const shortcutTabs = buildNumberShortcutTabTargets({
|
||||
showSftpTab: settings.showSftpTab ?? true,
|
||||
@@ -916,16 +916,11 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
toggleSidePanelRef.current?.();
|
||||
break;
|
||||
case 'broadcast': {
|
||||
// Workspace tabs keep their local broadcast state; orphan tabs share
|
||||
// the global state when at least two visible orphan tabs exist.
|
||||
// Toggle broadcast mode for the active workspace
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
const activeWs = workspaces.find(w => w.id === currentId);
|
||||
if (activeWs) {
|
||||
toggleBroadcast(activeWs.id);
|
||||
} else if (sessions.some((session: TerminalSession) => (
|
||||
session.id === currentId && !session.workspaceId
|
||||
)) && canUseGlobalBroadcast) {
|
||||
toggleGlobalBroadcast();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -268,7 +268,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit,
|
||||
handleOpenHostFromVaultNote, handleOpenQuickSwitcher, handleOpenSettings, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect,
|
||||
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleUpdateHostFromTerminal,
|
||||
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isGlobalBroadcastEnabled, onToggleGlobalBroadcast, canUseGlobalBroadcast, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
|
||||
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
|
||||
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
|
||||
passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
|
||||
resolveEmptyVaultConflict, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
|
||||
@@ -737,9 +737,6 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onCreateLocalTerminal={handleCreateLocalTerminal}
|
||||
isBroadcastEnabled={isBroadcastEnabled}
|
||||
onToggleBroadcast={toggleBroadcast}
|
||||
isGlobalBroadcastEnabled={isGlobalBroadcastEnabled}
|
||||
onToggleGlobalBroadcast={onToggleGlobalBroadcast}
|
||||
canUseGlobalBroadcast={canUseGlobalBroadcast}
|
||||
updateHosts={updateTerminalHosts}
|
||||
updateSnippets={updateSnippets}
|
||||
updateSnippetPackages={updateSnippetPackages}
|
||||
|
||||
@@ -207,8 +207,6 @@ export function TerminalHost() {
|
||||
updateTerminalHosts: handlers.updateTerminalHosts,
|
||||
hotkeyScheme: terminalSettings.hotkeyScheme,
|
||||
isBroadcastEnabled: sessionActions?.isBroadcastEnabled,
|
||||
isGlobalBroadcastEnabled: sessionActions?.isGlobalBroadcastEnabled,
|
||||
canUseGlobalBroadcast: sessionActions?.canUseGlobalBroadcast,
|
||||
keyBindings: terminalSettings.keyBindings,
|
||||
openNoteRequest: local.openNoteRequest,
|
||||
portForwardingRules: local.portForwardingRules,
|
||||
@@ -240,8 +238,6 @@ 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,9 +70,6 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
reorderTabs,
|
||||
toggleBroadcast,
|
||||
isBroadcastEnabled,
|
||||
toggleGlobalBroadcast,
|
||||
isGlobalBroadcastEnabled,
|
||||
canUseGlobalBroadcast,
|
||||
copySession,
|
||||
copyWorkspace,
|
||||
createSessionFromCloneSource,
|
||||
@@ -150,9 +147,6 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
reorderTabs,
|
||||
toggleBroadcast,
|
||||
isBroadcastEnabled,
|
||||
toggleGlobalBroadcast,
|
||||
isGlobalBroadcastEnabled,
|
||||
canUseGlobalBroadcast,
|
||||
copySession,
|
||||
copyWorkspace,
|
||||
createSessionFromCloneSource,
|
||||
@@ -181,10 +175,8 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
getOrderedWorkTabs,
|
||||
getSessionRestoreCwd,
|
||||
isBroadcastEnabled,
|
||||
isGlobalBroadcastEnabled,
|
||||
moveFocusInWorkspace,
|
||||
openLogView,
|
||||
canUseGlobalBroadcast,
|
||||
removeSessionFromWorkspace,
|
||||
renameSessionInline,
|
||||
reorderTabs,
|
||||
@@ -201,7 +193,6 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
submitSessionRename,
|
||||
submitWorkspaceRename,
|
||||
toggleBroadcast,
|
||||
toggleGlobalBroadcast,
|
||||
toggleWorkspaceViewMode,
|
||||
updateSessionCodingCliProvider,
|
||||
updateSessionDynamicTitle,
|
||||
|
||||
@@ -197,12 +197,6 @@ export const enCoreMessages: Messages = {
|
||||
'settings.appLock.validation.mismatch': 'The new passwords do not match.',
|
||||
'settings.appLock.validation.incorrect': 'The current lock password is incorrect.',
|
||||
|
||||
// Settings > System > Auto Launch
|
||||
'settings.autoLaunch.title': 'Launch at Login',
|
||||
'settings.autoLaunch.enabled': 'Launch at system startup',
|
||||
'settings.autoLaunch.enabledDesc': 'Automatically start Netcatty when you log in, hidden in the system tray.',
|
||||
'settings.autoLaunch.unsupportedDesc': 'Not available on this platform or in a development build.',
|
||||
|
||||
// Settings > System > Network Proxy
|
||||
'settings.system.networkProxy.title': 'Network Proxy',
|
||||
'settings.system.networkProxy.description': 'HTTP(S) proxy for cloud sync and AI providers. This is separate from SSH ProxyJump / ProxyCommand profiles.',
|
||||
@@ -1082,7 +1076,6 @@ 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',
|
||||
|
||||
@@ -119,7 +119,6 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.dragDrop.errorMessage': 'Failed to process dropped files',
|
||||
'terminal.dragDrop.destinationUnknown': 'Could not determine the current terminal folder. Enable directory tracking, or open SFTP and choose an upload folder first.',
|
||||
'terminal.dragDrop.uploadCancelled': 'The upload was cancelled because the terminal connection changed or could not be reused. Drop the files again after reconnecting.',
|
||||
'terminal.dragDrop.needsSudoElevation': 'This folder is not writable as the login user. Enable Sudo elevation in host settings, or go back to your user directory and drop again.',
|
||||
'terminal.search.placeholder': 'Search...',
|
||||
'terminal.search.noResults': 'No results',
|
||||
'terminal.search.prevMatch': 'Previous match (Shift+Enter)',
|
||||
|
||||
@@ -273,12 +273,6 @@ 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',
|
||||
@@ -319,10 +313,6 @@ 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',
|
||||
@@ -710,11 +700,6 @@ 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,7 +1040,6 @@ 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',
|
||||
|
||||
@@ -119,7 +119,6 @@ export const esTerminalMessages: Messages = {
|
||||
'terminal.dragDrop.errorMessage': 'No se pudieron procesar los archivos soltados',
|
||||
'terminal.dragDrop.destinationUnknown': 'No se pudo determinar la carpeta actual del terminal. Active el seguimiento de directorios o abra SFTP y elija primero una carpeta de carga.',
|
||||
'terminal.dragDrop.uploadCancelled': 'La carga se canceló porque la conexión del terminal cambió o no se pudo reutilizar. Vuelva a soltar los archivos después de reconectar.',
|
||||
'terminal.dragDrop.needsSudoElevation': 'Esta carpeta no es escribible con el usuario de inicio de sesión. Active la elevación Sudo en la configuración del host, o vuelva al directorio de usuario y suelte de nuevo.',
|
||||
'terminal.search.placeholder': 'Buscar...',
|
||||
'terminal.search.noResults': 'Sin resultados',
|
||||
'terminal.search.prevMatch': 'Coincidencia anterior (Shift+Enter)',
|
||||
|
||||
@@ -273,12 +273,6 @@ 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',
|
||||
@@ -319,10 +313,6 @@ 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',
|
||||
@@ -710,11 +700,6 @@ 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.',
|
||||
|
||||
@@ -196,12 +196,6 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.appLock.validation.mismatch': 'Новые пароли не совпадают.',
|
||||
'settings.appLock.validation.incorrect': 'Текущий пароль блокировки неверен.',
|
||||
|
||||
// Settings > System > Auto Launch
|
||||
'settings.autoLaunch.title': 'Запуск при входе в систему',
|
||||
'settings.autoLaunch.enabled': 'Запускать при старте системы',
|
||||
'settings.autoLaunch.enabledDesc': 'Автоматически запускать Netcatty при входе в систему, свернутым в системный трей.',
|
||||
'settings.autoLaunch.unsupportedDesc': 'Недоступно на этой платформе или в сборке для разработки.',
|
||||
|
||||
// Settings > System > Network Proxy
|
||||
'settings.system.networkProxy.title': 'Сетевой прокси',
|
||||
'settings.system.networkProxy.description': 'HTTP(S)-прокси для облачной синхронизации и AI-провайдеров. Не связан с SSH ProxyJump / ProxyCommand.',
|
||||
|
||||
@@ -140,7 +140,6 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.dragDrop.errorMessage': 'Не удалось обработать перетащенные файлы',
|
||||
'terminal.dragDrop.destinationUnknown': 'Не удалось определить текущую папку терминала. Включите отслеживание каталогов или сначала откройте SFTP и выберите папку загрузки.',
|
||||
'terminal.dragDrop.uploadCancelled': 'Загрузка отменена: соединение терминала изменилось или его не удалось повторно использовать. После переподключения перетащите файлы снова.',
|
||||
'terminal.dragDrop.needsSudoElevation': 'Эта папка недоступна для записи пользователю входа. Включите повышение прав Sudo в настройках хоста или вернитесь в домашний каталог и перетащите файлы снова.',
|
||||
'terminal.search.placeholder': 'Поиск...',
|
||||
'terminal.search.noResults': 'Ничего не найдено',
|
||||
'terminal.search.prevMatch': 'Предыдущее совпадение (Shift+Enter)',
|
||||
|
||||
@@ -95,7 +95,6 @@ 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': 'Дерево',
|
||||
@@ -311,12 +310,6 @@ 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': 'Размер',
|
||||
@@ -357,10 +350,6 @@ 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': 'Папка',
|
||||
@@ -747,11 +736,6 @@ 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.',
|
||||
|
||||
@@ -194,12 +194,6 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.appLock.validation.mismatch': '两次输入的新密码不一致。',
|
||||
'settings.appLock.validation.incorrect': '当前锁定密码不正确。',
|
||||
|
||||
// Settings > System > Auto Launch
|
||||
'settings.autoLaunch.title': '开机自启动',
|
||||
'settings.autoLaunch.enabled': '开机自动启动',
|
||||
'settings.autoLaunch.enabledDesc': '系统启动时自动运行 Netcatty,并隐藏到系统托盘。',
|
||||
'settings.autoLaunch.unsupportedDesc': '当前平台不支持此功能,或处于开发环境。',
|
||||
|
||||
// Settings > System > Network Proxy
|
||||
'settings.system.networkProxy.title': '网络代理',
|
||||
'settings.system.networkProxy.description': '用于云同步与 AI 服务商的 HTTP(S) 代理。与 SSH 的 ProxyJump / ProxyCommand 配置无关。',
|
||||
@@ -718,7 +712,6 @@ 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': '树形',
|
||||
@@ -904,12 +897,6 @@ 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': '修改时间',
|
||||
@@ -950,10 +937,6 @@ 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,11 +133,6 @@ 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。',
|
||||
@@ -526,7 +521,6 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.dragDrop.errorMessage': '处理拖放文件失败',
|
||||
'terminal.dragDrop.destinationUnknown': '无法确定当前终端目录。请启用目录跟踪,或先打开 SFTP 选择上传目录。',
|
||||
'terminal.dragDrop.uploadCancelled': '终端连接已变化或无法继续复用,本次上传已取消。请重新连接后再次拖放文件。',
|
||||
'terminal.dragDrop.needsSudoElevation': '当前目录登录用户无法写入。请在主机设置中开启 Sudo 提权,或回到用户目录后再拖放。',
|
||||
'terminal.search.placeholder': '搜索…',
|
||||
'terminal.search.noResults': '无结果',
|
||||
'terminal.search.prevMatch': '上一个匹配 (Shift+Enter)',
|
||||
|
||||
@@ -197,12 +197,6 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.appLock.validation.mismatch': '兩次輸入的新密碼不一致。',
|
||||
'settings.appLock.validation.incorrect': '目前的鎖定密碼不正確。',
|
||||
|
||||
// Settings > System > Auto Launch
|
||||
'settings.autoLaunch.title': '開機自動啟動',
|
||||
'settings.autoLaunch.enabled': '開機時自動啟動',
|
||||
'settings.autoLaunch.enabledDesc': '系統啟動時自動執行 Netcatty,並隱藏到系統匣。',
|
||||
'settings.autoLaunch.unsupportedDesc': '目前平台不支援此功能,或處於開發環境。',
|
||||
|
||||
// Settings > System > Network Proxy
|
||||
'settings.system.networkProxy.title': '網路代理',
|
||||
'settings.system.networkProxy.description': '用於雲端同步與 AI 服務商的 HTTP(S) 代理。與 SSH 的 ProxyJump / ProxyCommand 設定無關。',
|
||||
@@ -721,7 +715,6 @@ 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': '樹形',
|
||||
@@ -907,12 +900,6 @@ 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': '修改時間',
|
||||
@@ -953,10 +940,6 @@ 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,11 +132,6 @@ 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。',
|
||||
@@ -526,7 +521,6 @@ export const zhTWVaultMessages: Messages = {
|
||||
'terminal.dragDrop.errorMessage': '處理拖放檔案失敗',
|
||||
'terminal.dragDrop.destinationUnknown': '無法確定目前終端目錄。請啟用目錄追蹤,或先開啟 SFTP 選擇上傳目錄。',
|
||||
'terminal.dragDrop.uploadCancelled': '終端連線已變更或無法繼續重用,本次上傳已取消。請重新連線後再次拖放檔案。',
|
||||
'terminal.dragDrop.needsSudoElevation': '目前目錄登入使用者無法寫入。請在主機設定中開啟 Sudo 提權,或回到使用者目錄後再拖放。',
|
||||
'terminal.search.placeholder': '搜尋…',
|
||||
'terminal.search.noResults': '無結果',
|
||||
'terminal.search.prevMatch': '上一個相符 (Shift+Enter)',
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { AISession } from '../../infrastructure/ai/types';
|
||||
import {
|
||||
cleanupClosedTerminalSessions,
|
||||
cleanupDeletedAIChatSessions,
|
||||
@@ -48,466 +47,3 @@ 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,8 +10,6 @@ import type {
|
||||
AIPermissionMode,
|
||||
AIToolIntegrationMode,
|
||||
} from '../../infrastructure/ai/types';
|
||||
import type { ProviderContinuationOptions } from '../../infrastructure/ai/providerContinuation';
|
||||
import { findSafeChatMessageCompactionSplitIndex } from '../../infrastructure/ai/contextCompaction';
|
||||
import {
|
||||
bumpDraftMutationVersionState,
|
||||
bumpDraftUploadGenerationState,
|
||||
@@ -114,7 +112,10 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
|
||||
if (nextSessionCleanup.sessions !== currentSessions) {
|
||||
setLatestAISessionsSnapshot(nextSessionCleanup.sessions);
|
||||
writeSessionsForStorage(nextSessionCleanup.sessions);
|
||||
localStorageAdapter.write(
|
||||
STORAGE_KEY_AI_SESSIONS,
|
||||
pruneSessionsForStorage(nextSessionCleanup.sessions),
|
||||
);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
|
||||
}
|
||||
|
||||
@@ -180,91 +181,6 @@ 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
|
||||
@@ -280,179 +196,12 @@ 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) {
|
||||
// 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, messages: s.messages.slice(-MAX_SESSION_MESSAGES) };
|
||||
}
|
||||
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;
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
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,9 +68,6 @@ export type SessionSnapshotActions = Pick<
|
||||
| 'reorderTabs'
|
||||
| 'toggleBroadcast'
|
||||
| 'isBroadcastEnabled'
|
||||
| 'toggleGlobalBroadcast'
|
||||
| 'isGlobalBroadcastEnabled'
|
||||
| 'canUseGlobalBroadcast'
|
||||
| 'copySession'
|
||||
| 'copyWorkspace'
|
||||
| 'createSessionFromCloneSource'
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
/**
|
||||
* useSettingsIpcSync is a React hook (useEffect-driven); without
|
||||
* react-test-renderer available in this environment we can't mount it and
|
||||
* assert on live cross-window broadcast behavior. These structural checks
|
||||
* instead verify the source wiring end-to-end: every other window's
|
||||
* useSettingsState instance (mounted via AppLockGate, even though the
|
||||
* auto-launch toggle only ever renders in the Settings window) must consume
|
||||
* the STORAGE_KEY_AUTO_LAUNCH_ENABLED broadcast, matching the established
|
||||
* pattern used by every other synchronized boolean setting in this file.
|
||||
*/
|
||||
|
||||
test('settingsIpcSync handles the auto-launch broadcast key like other synced booleans', () => {
|
||||
const source = fs.readFileSync(new URL('./settingsIpcSync.ts', import.meta.url), 'utf8');
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
/STORAGE_KEY_AUTO_LAUNCH_ENABLED/,
|
||||
'must import the storage key',
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/if \(key === STORAGE_KEY_AUTO_LAUNCH_ENABLED && typeof value === 'boolean'\) \{\s*[\s\S]*?setAutoLaunchEnabled\(/,
|
||||
'must apply the broadcast value to local state, same as e.g. STORAGE_KEY_GLOBAL_HOTKEY_ENABLED',
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/setAutoLaunchEnabled: Dispatch<SetStateAction<boolean>>/,
|
||||
'the setter prop must be declared on the params interface',
|
||||
);
|
||||
|
||||
// The dependency array of the effect must list the setter, or the
|
||||
// listener could close over a stale setter reference after a re-render.
|
||||
const effectStart = source.indexOf('useEffect(() => {');
|
||||
const depsStart = source.indexOf('}, [', effectStart);
|
||||
assert.ok(effectStart > -1 && depsStart > effectStart, 'expected to find the sync effect and its deps array');
|
||||
const depsSlice = source.slice(depsStart, source.indexOf(']);', depsStart));
|
||||
assert.match(depsSlice, /setAutoLaunchEnabled/, 'setAutoLaunchEnabled must be listed in the effect deps array');
|
||||
});
|
||||
|
||||
test('useSettingsState wires the real autoLaunchEnabled setter into useSettingsIpcSync (not a no-op)', () => {
|
||||
const source = fs.readFileSync(new URL('./useSettingsState.ts', import.meta.url), 'utf8');
|
||||
|
||||
const hookCallStart = source.indexOf('useSettingsIpcSync({');
|
||||
assert.notEqual(hookCallStart, -1, 'expected to find the useSettingsIpcSync(...) call');
|
||||
const hookCallEnd = source.indexOf('});', hookCallStart);
|
||||
const callSlice = source.slice(hookCallStart, hookCallEnd);
|
||||
|
||||
assert.match(
|
||||
callSlice,
|
||||
/setAutoLaunchEnabled,/,
|
||||
'useSettingsIpcSync must receive the real setAutoLaunchEnabled state setter, ' +
|
||||
'or a broadcast from another window silently does nothing in this one',
|
||||
);
|
||||
});
|
||||
@@ -4,7 +4,6 @@ import { parseCustomKeyBindingsStorageRecord } from '../../domain/customKeyBindi
|
||||
import { resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||
import {
|
||||
STORAGE_KEY_ACCENT_MODE,
|
||||
STORAGE_KEY_AUTO_LAUNCH_ENABLED,
|
||||
STORAGE_KEY_AUTO_UPDATE_ENABLED,
|
||||
STORAGE_KEY_COLOR,
|
||||
STORAGE_KEY_CUSTOM_CSS,
|
||||
@@ -94,7 +93,6 @@ interface UseSettingsIpcSyncParams {
|
||||
setWindowOpacity: (raw: unknown) => void;
|
||||
setAppIconVariant: Dispatch<SetStateAction<AppIconVariant>>;
|
||||
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setAutoLaunchEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setHttpNetworkProxy: Dispatch<SetStateAction<HttpNetworkProxySettings>>;
|
||||
setSftpAutoOpenSidebar: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpFollowTerminalCwd: Dispatch<SetStateAction<boolean>>;
|
||||
@@ -140,7 +138,6 @@ export function useSettingsIpcSync({
|
||||
setWindowOpacity,
|
||||
setAppIconVariant,
|
||||
setAutoUpdateEnabled,
|
||||
setAutoLaunchEnabled,
|
||||
setHttpNetworkProxy,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
@@ -273,13 +270,6 @@ export function useSettingsIpcSync({
|
||||
if (key === STORAGE_KEY_AUTO_UPDATE_ENABLED && typeof value === 'boolean') {
|
||||
setAutoUpdateEnabled((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_AUTO_LAUNCH_ENABLED && typeof value === 'boolean') {
|
||||
// Peer windows (main, tray panel, terminal popups) all mount
|
||||
// useSettingsState via AppLockGate even though the toggle is only
|
||||
// ever rendered in the Settings window — keep their in-memory copy
|
||||
// in sync so it's not stale if anything reads it later.
|
||||
setAutoLaunchEnabled((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_HTTP_NETWORK_PROXY) {
|
||||
const next = normalizeHttpNetworkProxySettings(value);
|
||||
setHttpNetworkProxy((prev) => (
|
||||
@@ -340,7 +330,6 @@ export function useSettingsIpcSync({
|
||||
applyIncomingCustomKeyBindings,
|
||||
mergeIncomingTerminalSettings,
|
||||
setAutoUpdateEnabled,
|
||||
setAutoLaunchEnabled,
|
||||
setHttpNetworkProxy,
|
||||
setEditorWordWrapState,
|
||||
setFollowAppTerminalThemeState,
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
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,29 +21,3 @@ 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
|
||||
));
|
||||
}
|
||||
@@ -4,11 +4,6 @@ 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();
|
||||
@@ -33,40 +28,6 @@ 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,5 +1,3 @@
|
||||
import { resolveSftpTransferConcurrency } from "../../../domain/sftpTransferConcurrency";
|
||||
|
||||
type LimitReader = () => number | null | undefined;
|
||||
|
||||
interface ScheduledJob<T> {
|
||||
@@ -22,7 +20,9 @@ export interface GlobalSftpTransferScheduler {
|
||||
}
|
||||
|
||||
function normalizeLimit(value: number | null | undefined): number {
|
||||
return resolveSftpTransferConcurrency(() => value);
|
||||
return Number.isInteger(value) && value !== undefined && value !== null && value >= 1 && value <= 16
|
||||
? value
|
||||
: 2;
|
||||
}
|
||||
|
||||
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, moveSftpBookmark, renameSftpBookmark } from "./bookmarkHelpers";
|
||||
import { createSftpBookmark } from "./bookmarkHelpers";
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
@@ -74,20 +74,10 @@ 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,
|
||||
};
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
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();
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
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 six concurrent files", () => {
|
||||
test("defaults folder file transfers to two concurrent files", () => {
|
||||
assert.equal(resolveSftpTransferConcurrency(() => null), DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY);
|
||||
assert.equal(DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY, 6);
|
||||
assert.equal(DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY, 2);
|
||||
});
|
||||
|
||||
test("defaults directory listing fanout to four concurrent readdirs", () => {
|
||||
@@ -34,7 +34,6 @@ 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);
|
||||
});
|
||||
@@ -52,18 +51,18 @@ test("keeps directory listing concurrency within the supported range", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("limits default multi-file transfer scheduling to six concurrent workers", async () => {
|
||||
test("limits default multi-file transfer scheduling to two concurrent workers", async () => {
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
|
||||
await runSftpTransferWorkers([1, 2, 3, 4, 5, 6, 7], () => null, async () => {
|
||||
await runSftpTransferWorkers([1, 2, 3, 4], () => null, async () => {
|
||||
active += 1;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
active -= 1;
|
||||
});
|
||||
|
||||
assert.equal(maxActive, 6);
|
||||
assert.equal(maxActive, 2);
|
||||
});
|
||||
|
||||
test("runBoundedConcurrency respects an explicit limit", async () => {
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
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";
|
||||
export const DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY = 2;
|
||||
export const MIN_SFTP_FILE_TRANSFER_CONCURRENCY = 1;
|
||||
export const MAX_SFTP_FILE_TRANSFER_CONCURRENCY = 16;
|
||||
|
||||
/**
|
||||
* Bounded parallel directory listings while walking a folder tree.
|
||||
@@ -21,6 +16,15 @@ 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 {
|
||||
|
||||
@@ -747,7 +747,7 @@ test("a reconnecting tab moved to the other pane still schedules recovery", () =
|
||||
}), { side: "right", tabId: "tab-a" });
|
||||
});
|
||||
|
||||
test("automatic reconnect retries the source session then allows a fresh open", () => {
|
||||
test("automatic reconnect preserves strict source reuse", () => {
|
||||
const pane = {
|
||||
id: "tab-a",
|
||||
connection: {
|
||||
@@ -764,9 +764,6 @@ test("automatic reconnect retries the source session then allows a fresh open",
|
||||
assert.deepEqual(resolveSftpReconnectOptions(pane), {
|
||||
tabId: "tab-a",
|
||||
sourceSessionId: "ssh-a",
|
||||
requireSourceSessionReuse: true,
|
||||
});
|
||||
assert.equal(
|
||||
"requireSourceSessionReuse" in resolveSftpReconnectOptions(pane),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -276,14 +276,15 @@ export function resolveSftpReconnectSchedule(params: {
|
||||
}
|
||||
}
|
||||
|
||||
/** Retry the last terminal transport, then fall through to a fresh/sudo open. */
|
||||
export function resolveSftpReconnectOptions(
|
||||
pane: SftpPane,
|
||||
): SftpConnectOptions {
|
||||
const sourceSessionId = pane.connection?.sourceSessionId;
|
||||
return {
|
||||
tabId: pane.id,
|
||||
...(sourceSessionId ? { sourceSessionId } : undefined),
|
||||
...(sourceSessionId
|
||||
? { sourceSessionId, requireSourceSessionReuse: true }
|
||||
: undefined),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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";
|
||||
@@ -106,11 +105,6 @@ 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;
|
||||
@@ -839,10 +833,7 @@ 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,
|
||||
readTransferConcurrency = () => localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY),
|
||||
): UploadBridge => {
|
||||
const createUploadBridge = useCallback((connectHost?: Host): UploadBridge => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return {
|
||||
managesTransferLifecycle: Boolean(
|
||||
@@ -905,7 +896,7 @@ export const useSftpExternalOperations = (
|
||||
ownerId,
|
||||
options.transferId,
|
||||
getSftpTransferResourceKeys(options),
|
||||
readTransferConcurrency,
|
||||
() => localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY),
|
||||
async () => {
|
||||
let lease: { sftpId: string; release: () => void; discard: () => void } | null = null;
|
||||
try {
|
||||
@@ -1040,10 +1031,7 @@ export const useSftpExternalOperations = (
|
||||
const uploadPaneId = livePane.id;
|
||||
const liveTargetPath = targetPath || livePane.connection.currentPath;
|
||||
const connectHost = resolveUploadConnectHost(uploadPaneId, livePane.connection.isLocal);
|
||||
const uploadBridge = createUploadBridge(
|
||||
connectHost,
|
||||
readSftpFileTransferConcurrency,
|
||||
);
|
||||
const uploadBridge = createUploadBridge(connectHost);
|
||||
const liveCallbacks = livePane.connection.id === pane.connection.id
|
||||
&& liveTargetPath === uploadTargetPath
|
||||
? callbacks
|
||||
@@ -1080,7 +1068,6 @@ export const useSftpExternalOperations = (
|
||||
targetPath: liveTargetPath,
|
||||
sftpId,
|
||||
targetHostId: livePane.connection.isLocal ? undefined : livePane.connection.hostId,
|
||||
fileTransferConcurrency: readSftpFileTransferConcurrency(),
|
||||
isLocal: livePane.connection.isLocal,
|
||||
bridge: uploadBridge,
|
||||
joinPath,
|
||||
@@ -1259,7 +1246,6 @@ export const useSftpExternalOperations = (
|
||||
targetPath: liveTargetPath,
|
||||
sftpId: uploadSftpId,
|
||||
targetHostId: livePane.connection!.isLocal ? undefined : livePane.connection!.hostId,
|
||||
fileTransferConcurrency: readSftpFileTransferConcurrency(),
|
||||
isLocal: livePane.connection!.isLocal,
|
||||
bridge: uploadBridge,
|
||||
joinPath,
|
||||
@@ -1389,7 +1375,6 @@ export const useSftpExternalOperations = (
|
||||
targetPath: uploadTargetPath,
|
||||
sftpId: uploadSftpId,
|
||||
targetHostId: livePane.connection!.isLocal ? undefined : livePane.connection!.hostId,
|
||||
fileTransferConcurrency: readSftpFileTransferConcurrency(),
|
||||
isLocal: livePane.connection!.isLocal,
|
||||
bridge: uploadBridge,
|
||||
joinPath,
|
||||
@@ -1610,7 +1595,6 @@ export const useSftpExternalOperations = (
|
||||
targetPath: uploadTargetPath,
|
||||
sftpId: uploadSftpId,
|
||||
targetHostId: livePane.connection!.isLocal ? undefined : livePane.connection!.hostId,
|
||||
fileTransferConcurrency: readSftpFileTransferConcurrency(),
|
||||
isLocal: livePane.connection!.isLocal,
|
||||
bridge: uploadBridge,
|
||||
joinPath,
|
||||
@@ -1796,7 +1780,6 @@ export const useSftpExternalOperations = (
|
||||
targetPath: uploadTargetPath,
|
||||
sftpId: uploadSftpId,
|
||||
targetHostId: livePane.connection!.isLocal ? undefined : livePane.connection!.hostId,
|
||||
fileTransferConcurrency: readSftpFileTransferConcurrency(),
|
||||
isLocal: livePane.connection!.isLocal,
|
||||
bridge: directUploadBridge,
|
||||
joinPath,
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { isAutoLaunchResultTrustworthy } from './systemSettingsEffects.ts';
|
||||
|
||||
test('isAutoLaunchResultTrustworthy trusts a successful read/write result', () => {
|
||||
assert.equal(isAutoLaunchResultTrustworthy({ success: true }), true);
|
||||
});
|
||||
|
||||
test('isAutoLaunchResultTrustworthy rejects a transient failure result', () => {
|
||||
assert.equal(
|
||||
isAutoLaunchResultTrustworthy({ success: false }),
|
||||
false,
|
||||
'success:false means the OS state is unknown, not confirmed disabled',
|
||||
);
|
||||
});
|
||||
|
||||
test('isAutoLaunchResultTrustworthy defaults to trusting a result with no success field', () => {
|
||||
// Defensive default for any future/alternate caller that omits the field
|
||||
// entirely — only an explicit false should withhold trust.
|
||||
assert.equal(isAutoLaunchResultTrustworthy({}), true);
|
||||
});
|
||||
|
||||
/**
|
||||
* Minimal model of the hydration effect's decision: given a getAutoLaunch()
|
||||
* result, should it overwrite the cached autoLaunchEnabled value? This is
|
||||
* the exact regression scenario from the review — a transient read failure
|
||||
* must not overwrite a cached `true` with a "successful-looking" `false`,
|
||||
* which would otherwise cascade into an unwanted disable write via the
|
||||
* adjacent push effect.
|
||||
*/
|
||||
function simulateHydration(cachedEnabled: boolean, result: { success: boolean; enabled: boolean }): boolean {
|
||||
if (!isAutoLaunchResultTrustworthy(result)) return cachedEnabled;
|
||||
return result.enabled;
|
||||
}
|
||||
|
||||
test('a transient read failure during hydration keeps the cached enabled value', () => {
|
||||
const next = simulateHydration(true, { success: false, enabled: false });
|
||||
assert.equal(next, true, 'must not silently flip a cached true to false on an unrelated read failure');
|
||||
});
|
||||
|
||||
test('a successful read during hydration applies the real OS state, even when it flips the cache', () => {
|
||||
const next = simulateHydration(true, { success: true, enabled: false });
|
||||
assert.equal(next, false, 'a confirmed read must still win over a stale cache');
|
||||
});
|
||||
|
||||
/**
|
||||
* Model of the actual hook: a mount-time hydration request and a
|
||||
* user-triggered write can race. This mirrors the real ordering the review
|
||||
* flagged — hydration starts, the user toggles before it resolves (which
|
||||
* starts a real write), then the stale hydration response arrives.
|
||||
*/
|
||||
function simulateMountToggleRace(
|
||||
initialCachedEnabled: boolean,
|
||||
userToggleTo: boolean,
|
||||
staleHydrationResult: { success: boolean; enabled: boolean },
|
||||
): boolean {
|
||||
let state = initialCachedEnabled;
|
||||
let writeStarted = false;
|
||||
|
||||
// Hydration request is issued here (pending) — resolution happens later.
|
||||
|
||||
// The user toggles before hydration resolves; this is what the real push
|
||||
// effect does: apply optimistically and flag that a real write started.
|
||||
state = userToggleTo;
|
||||
writeStarted = true;
|
||||
|
||||
// The stale hydration response now arrives.
|
||||
if (!writeStarted && isAutoLaunchResultTrustworthy(staleHydrationResult)) {
|
||||
state = staleHydrationResult.enabled;
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
test('a user toggle during in-flight hydration is not clobbered by the stale hydration response', () => {
|
||||
const result = simulateMountToggleRace(false, true, { success: true, enabled: false });
|
||||
|
||||
assert.equal(
|
||||
result,
|
||||
true,
|
||||
'the stale response (enabled:false, read before the user toggled) must not overwrite the user\'s fresh true — ' +
|
||||
'otherwise the adjacent push effect reacts to the overwrite and disables an item the user just enabled',
|
||||
);
|
||||
});
|
||||
|
||||
test('a user toggle to false during in-flight hydration is not clobbered by a stale enabled:true response', () => {
|
||||
const result = simulateMountToggleRace(true, false, { success: true, enabled: true });
|
||||
|
||||
assert.equal(result, false, 'symmetric case: disabling must also win over a stale hydration response');
|
||||
});
|
||||
|
||||
test('hydration still applies normally when it resolves before any write starts', () => {
|
||||
// No race: writeStarted stays false throughout, so trustworthy hydration
|
||||
// results must still be applied — the guard must not disable hydration
|
||||
// unconditionally, only once a real write has actually begun.
|
||||
let state = false;
|
||||
let writeStarted = false;
|
||||
const result = { success: true, enabled: true };
|
||||
|
||||
if (!writeStarted && isAutoLaunchResultTrustworthy(result)) {
|
||||
state = result.enabled;
|
||||
}
|
||||
|
||||
assert.equal(state, true);
|
||||
void writeStarted;
|
||||
});
|
||||
|
||||
/**
|
||||
* Model of the push effect's own overlap guard: rapid double (or triple)
|
||||
* toggles start multiple bridge.setAutoLaunch(...) calls that can resolve
|
||||
* out of order. Each response is only reconciled into state if no newer
|
||||
* request has started since — this is the exact scenario the review
|
||||
* flagged: "each callback closes over its render's autoLaunchEnabled value
|
||||
* ... without checking whether a newer request exists".
|
||||
*/
|
||||
class AutoLaunchWriteSimulator {
|
||||
state: boolean;
|
||||
private generation = 0;
|
||||
|
||||
constructor(initial: boolean) {
|
||||
this.state = initial;
|
||||
}
|
||||
|
||||
/** Simulates the user toggling the switch, starting a new write. */
|
||||
startWrite(requestedValue: boolean): { requestGeneration: number; requestedValue: boolean } {
|
||||
this.state = requestedValue;
|
||||
this.generation += 1;
|
||||
return { requestGeneration: this.generation, requestedValue };
|
||||
}
|
||||
|
||||
/** Simulates that write's bridge.setAutoLaunch(...) response arriving. */
|
||||
resolveWrite(request: { requestGeneration: number; requestedValue: boolean }, result: { success: boolean; enabled: boolean }) {
|
||||
if (this.generation !== request.requestGeneration) return; // superseded — ignore
|
||||
if (!isAutoLaunchResultTrustworthy(result)) return;
|
||||
if (result.enabled !== request.requestedValue) this.state = result.enabled;
|
||||
}
|
||||
}
|
||||
|
||||
test('a stale write response is ignored once a newer request has started', () => {
|
||||
const sim = new AutoLaunchWriteSimulator(false);
|
||||
|
||||
const first = sim.startWrite(true); // user enables
|
||||
const second = sim.startWrite(false); // user immediately disables again, before `first` resolves
|
||||
assert.equal(sim.state, false, 'the latest toggle is reflected optimistically right away');
|
||||
|
||||
// `first`'s response arrives late, reporting a mismatch against ITS OWN
|
||||
// request (e.g. macOS pending approval reported enabled:false for a
|
||||
// requested true) — without the generation guard this would incorrectly
|
||||
// "correct" state to false right as `second` is still in flight, and
|
||||
// with a mismatch reported the OTHER way it could just as easily stomp
|
||||
// a subsequent true.
|
||||
sim.resolveWrite(first, { success: true, enabled: false });
|
||||
assert.equal(sim.state, false, 'the stale response for the superseded first request must not touch state');
|
||||
|
||||
// `second`'s own (current) response arrives and is applied normally.
|
||||
sim.resolveWrite(second, { success: true, enabled: false });
|
||||
assert.equal(sim.state, false);
|
||||
});
|
||||
|
||||
test('a stale write response would have incorrectly overridden a later successful write, without the guard', () => {
|
||||
const sim = new AutoLaunchWriteSimulator(false);
|
||||
|
||||
const first = sim.startWrite(true); // request enable — this one will report a mismatch
|
||||
const second = sim.startWrite(true); // user toggles off and back on again quickly; also requests true
|
||||
assert.equal(sim.state, true);
|
||||
|
||||
// second's response arrives first (network/OS timing is not ordered) and
|
||||
// confirms the real, current state.
|
||||
sim.resolveWrite(second, { success: true, enabled: true });
|
||||
assert.equal(sim.state, true);
|
||||
|
||||
// first's stale response finally arrives, reporting a mismatch (its own
|
||||
// request was true but the OS said false at the time) — must not undo
|
||||
// the now-confirmed true state from the newer request.
|
||||
sim.resolveWrite(first, { success: true, enabled: false });
|
||||
assert.equal(
|
||||
sim.state,
|
||||
true,
|
||||
'a delayed response from a superseded request must not overwrite the newer, already-confirmed state',
|
||||
);
|
||||
});
|
||||
|
||||
test('the only in-flight write still reconciles a genuine mismatch normally', () => {
|
||||
const sim = new AutoLaunchWriteSimulator(false);
|
||||
|
||||
const only = sim.startWrite(true);
|
||||
sim.resolveWrite(only, { success: true, enabled: false }); // e.g. blocked by Windows Startup Apps
|
||||
|
||||
assert.equal(sim.state, false, 'with no overlap, a real mismatch must still correct the optimistic toggle');
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import { useEffect, useRef, type MutableRefObject } from 'react';
|
||||
import {
|
||||
STORAGE_KEY_AUTO_UPDATE_ENABLED,
|
||||
STORAGE_KEY_CLOSE_TO_TRAY,
|
||||
STORAGE_KEY_AUTO_LAUNCH_ENABLED,
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
@@ -25,24 +24,11 @@ import {
|
||||
type WindowOpacityRecord,
|
||||
} from './windowOpacitySync';
|
||||
|
||||
/**
|
||||
* A getAutoLaunch/setAutoLaunch IPC result is only safe to apply to state
|
||||
* when the main process could actually determine the OS's login-item state.
|
||||
* success:false means "unknown" (a transient read/write failure), not a
|
||||
* confirmed disabled state — applying enabled:false in that case would
|
||||
* overwrite a valid cached value and, via the push effect, cascade into an
|
||||
* unwanted disable write that could remove a working login item.
|
||||
*/
|
||||
export function isAutoLaunchResultTrustworthy(result: { success?: boolean }): boolean {
|
||||
return result.success !== false;
|
||||
}
|
||||
|
||||
interface UseSystemSettingsEffectsParams {
|
||||
enabled?: boolean;
|
||||
toggleWindowHotkey: string;
|
||||
globalHotkeyEnabled: boolean;
|
||||
closeToTray: boolean;
|
||||
autoLaunchEnabled: boolean;
|
||||
windowOpacityRecord: WindowOpacityRecord;
|
||||
windowOpacityMutationSourceRef: MutableRefObject<WindowOpacityMutationSource>;
|
||||
appIconVariant: AppIconVariant;
|
||||
@@ -52,8 +38,6 @@ interface UseSystemSettingsEffectsParams {
|
||||
setHotkeyRegistrationError: (error: string | null) => void;
|
||||
setAutoUpdateEnabled: (enabled: boolean | ((prev: boolean) => boolean)) => void;
|
||||
setAppIconVariant: (variant: AppIconVariant | ((prev: AppIconVariant) => AppIconVariant)) => void;
|
||||
setAutoLaunchEnabled: (enabled: boolean | ((prev: boolean) => boolean)) => void;
|
||||
setAutoLaunchSupported: (supported: boolean) => void;
|
||||
notifySettingsChanged: (key: string, value: unknown) => void;
|
||||
}
|
||||
|
||||
@@ -62,7 +46,6 @@ export function useSystemSettingsEffects({
|
||||
toggleWindowHotkey,
|
||||
globalHotkeyEnabled,
|
||||
closeToTray,
|
||||
autoLaunchEnabled,
|
||||
windowOpacityRecord,
|
||||
windowOpacityMutationSourceRef,
|
||||
appIconVariant,
|
||||
@@ -72,26 +55,9 @@ export function useSystemSettingsEffects({
|
||||
setHotkeyRegistrationError,
|
||||
setAutoUpdateEnabled,
|
||||
setAppIconVariant,
|
||||
setAutoLaunchEnabled,
|
||||
setAutoLaunchSupported,
|
||||
notifySettingsChanged,
|
||||
}: UseSystemSettingsEffectsParams) {
|
||||
const appIconApplyRequestIdRef = useRef(0);
|
||||
// True once the push effect has issued a real OS write (i.e. after mount,
|
||||
// when persistMountedRef is set — see below). If the user toggles
|
||||
// auto-launch while the mount-time hydration read is still in flight, the
|
||||
// hydration effect must not let its now-stale response overwrite that
|
||||
// choice, or the adjacent push effect reacts to the overwrite and issues
|
||||
// the opposite OS write right after the user's own request lands.
|
||||
const autoLaunchWriteStartedRef = useRef(false);
|
||||
// Incremented on every push-effect invocation. If the user toggles again
|
||||
// before an in-flight bridge.setAutoLaunch(...) resolves, that older
|
||||
// call's response is stale by the time it arrives — comparing its own
|
||||
// request against its own result is self-consistent but says nothing
|
||||
// about whether a newer request has since superseded it, so a delayed
|
||||
// response could otherwise "correct" state the user has already moved
|
||||
// past (undoing a toggle they made after this specific request started).
|
||||
const autoLaunchWriteGenerationRef = useRef(0);
|
||||
|
||||
// Persist and sync toggle window hotkey setting
|
||||
useEffect(() => {
|
||||
@@ -173,61 +139,6 @@ export function useSystemSettingsEffects({
|
||||
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
|
||||
}, [enabled, closeToTray, notifySettingsChanged, persistMountedRef]);
|
||||
|
||||
// Hydrate auto-launch from the main process on mount — the OS login item
|
||||
// is the real source of truth (the user may have toggled it outside the
|
||||
// app), localStorage is only an optimistic cache for first paint.
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getAutoLaunch) return;
|
||||
let cancelled = false;
|
||||
bridge.getAutoLaunch().then((result) => {
|
||||
if (cancelled) return;
|
||||
setAutoLaunchSupported(result.supported);
|
||||
// The user (or an already-issued write) may have moved state on while
|
||||
// this request was in flight — a stale hydration response must not
|
||||
// clobber it, or the push effect below reacts to the clobber and
|
||||
// fires an unwanted OS write right after the user's own request.
|
||||
if (autoLaunchWriteStartedRef.current) return;
|
||||
if (!isAutoLaunchResultTrustworthy(result)) return;
|
||||
setAutoLaunchEnabled(result.enabled);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AUTO_LAUNCH_ENABLED, result.enabled ? 'true' : 'false');
|
||||
}).catch((err) => {
|
||||
console.warn('[AutoLaunch] Failed to read login item state:', err);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// Runs once per mount — this is a one-shot hydration, not a sync loop.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled]);
|
||||
|
||||
// Push auto-launch changes to the main process and cache the result.
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (!persistMountedRef.current) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.setAutoLaunch) return;
|
||||
// A real write is now in flight — permanently disqualify the mount-time
|
||||
// hydration effect above from applying a (now possibly stale) response.
|
||||
autoLaunchWriteStartedRef.current = true;
|
||||
const requestGeneration = ++autoLaunchWriteGenerationRef.current;
|
||||
bridge.setAutoLaunch(autoLaunchEnabled).then((result) => {
|
||||
setAutoLaunchSupported(result.supported);
|
||||
// The user toggled again before this request resolved — a newer
|
||||
// request (and its own response, once it arrives) is authoritative;
|
||||
// reconciling this stale one could undo a choice made after it fired.
|
||||
if (autoLaunchWriteGenerationRef.current !== requestGeneration) return;
|
||||
if (!isAutoLaunchResultTrustworthy(result)) return;
|
||||
if (result.enabled !== autoLaunchEnabled) setAutoLaunchEnabled(result.enabled);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AUTO_LAUNCH_ENABLED, result.enabled ? 'true' : 'false');
|
||||
}).catch((err) => {
|
||||
console.warn('[AutoLaunch] Failed to update login item state:', err);
|
||||
});
|
||||
notifySettingsChanged(STORAGE_KEY_AUTO_LAUNCH_ENABLED, autoLaunchEnabled);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled, autoLaunchEnabled]);
|
||||
|
||||
// Persist and apply app-level HTTP(S) network proxy (cloud sync / AI)
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
uploadFromDataTransfer,
|
||||
uploadFromFileList,
|
||||
} from "../../lib/uploadService.ts";
|
||||
import { DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY } from "../../domain/sftpTransferConcurrency.ts";
|
||||
|
||||
function createDataTransfer(files: File[]): DataTransfer {
|
||||
return {
|
||||
@@ -198,50 +197,6 @@ 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, {
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
|
||||
import { 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,6 +28,7 @@ import type {
|
||||
WebSearchConfig,
|
||||
} from '../../infrastructure/ai/types';
|
||||
import {
|
||||
DEFAULT_COMMAND_BLOCKLIST,
|
||||
DEFAULT_COMMAND_TIMEOUT_SECONDS,
|
||||
DEFAULT_RESPONSE_IDLE_TIMEOUT_SECONDS,
|
||||
normalizeCommandTimeoutSeconds,
|
||||
@@ -37,10 +38,6 @@ 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);
|
||||
@@ -72,7 +69,9 @@ export function useAISettingsState() {
|
||||
const [defaultAgentId, setDefaultAgentIdRaw] = useState<string>(() =>
|
||||
localStorageAdapter.readString(STORAGE_KEY_AI_DEFAULT_AGENT) ?? 'catty'
|
||||
);
|
||||
const [commandBlocklist, setCommandBlocklistRaw] = useState<string[]>(readCommandBlocklistSetting);
|
||||
const [commandBlocklist, setCommandBlocklistRaw] = useState<string[]>(() =>
|
||||
localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST) ?? [...DEFAULT_COMMAND_BLOCKLIST]
|
||||
);
|
||||
const [commandTimeout, setCommandTimeoutRaw] = useState<number>(() =>
|
||||
normalizeCommandTimeoutSeconds(
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? DEFAULT_COMMAND_TIMEOUT_SECONDS,
|
||||
@@ -174,7 +173,7 @@ export function useAISettingsState() {
|
||||
|
||||
const setCommandBlocklist = useCallback((value: string[]) => {
|
||||
setCommandBlocklistRaw(value);
|
||||
persistCommandBlocklistSetting(value);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_COMMAND_BLOCKLIST, value);
|
||||
getAIBridge()?.aiMcpSetCommandBlocklist?.(value);
|
||||
}, []);
|
||||
|
||||
@@ -250,7 +249,9 @@ export function useAISettingsState() {
|
||||
setDefaultAgentIdRaw(localStorageAdapter.readString(STORAGE_KEY_AI_DEFAULT_AGENT) ?? 'catty');
|
||||
break;
|
||||
case STORAGE_KEY_AI_COMMAND_BLOCKLIST: {
|
||||
const blocklist = readCommandBlocklistSetting();
|
||||
const list = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
|
||||
if (list != null && !Array.isArray(list)) break;
|
||||
const blocklist = list ?? [...DEFAULT_COMMAND_BLOCKLIST];
|
||||
setCommandBlocklistRaw(blocklist);
|
||||
getAIBridge()?.aiMcpSetCommandBlocklist?.(blocklist);
|
||||
break;
|
||||
|
||||
@@ -37,6 +37,7 @@ import type {
|
||||
WebSearchConfig,
|
||||
} from '../../infrastructure/ai/types';
|
||||
import {
|
||||
DEFAULT_COMMAND_BLOCKLIST,
|
||||
DEFAULT_COMMAND_TIMEOUT_SECONDS,
|
||||
DEFAULT_RESPONSE_IDLE_TIMEOUT_SECONDS,
|
||||
normalizeCommandTimeoutSeconds,
|
||||
@@ -68,7 +69,7 @@ import {
|
||||
latestAIDraftsByScopeSnapshot,
|
||||
latestAIPanelViewByScopeSnapshot,
|
||||
latestAISessionsSnapshot,
|
||||
writeSessionsForStorage,
|
||||
pruneSessionsForStorage,
|
||||
setLatestAIActiveSessionMapSnapshot,
|
||||
setLatestAIDraftsByScopeSnapshot,
|
||||
setLatestAIPanelViewByScopeSnapshot,
|
||||
@@ -77,10 +78,6 @@ import {
|
||||
type PanelViewByScope,
|
||||
} from './aiStateSnapshots';
|
||||
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
|
||||
import {
|
||||
persistCommandBlocklistSetting,
|
||||
readCommandBlocklistSetting,
|
||||
} from './commandBlocklistSettings';
|
||||
import {
|
||||
handoffDissolvedWorkspaceAIScope,
|
||||
retargetWorkspaceActiveChatAfterMemberLoss,
|
||||
@@ -143,7 +140,9 @@ export function useAIState() {
|
||||
);
|
||||
|
||||
// ── Safety Settings ──
|
||||
const [commandBlocklist, setCommandBlocklistRaw] = useState<string[]>(readCommandBlocklistSetting);
|
||||
const [commandBlocklist, setCommandBlocklistRaw] = useState<string[]>(() =>
|
||||
localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST) ?? [...DEFAULT_COMMAND_BLOCKLIST]
|
||||
);
|
||||
const [commandTimeout, setCommandTimeoutRaw] = useState<number>(() =>
|
||||
normalizeCommandTimeoutSeconds(
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? DEFAULT_COMMAND_TIMEOUT_SECONDS,
|
||||
@@ -424,7 +423,7 @@ export function useAIState() {
|
||||
|
||||
const setCommandBlocklist = useCallback((value: string[]) => {
|
||||
setCommandBlocklistRaw(value);
|
||||
persistCommandBlocklistSetting(value);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_COMMAND_BLOCKLIST, value);
|
||||
// Sync to MCP Server bridge so SDK agents also respect the blocklist
|
||||
const bridge = getAIBridge();
|
||||
bridge?.aiMcpSetCommandBlocklist?.(value);
|
||||
@@ -504,7 +503,12 @@ export function useAIState() {
|
||||
setDefaultAgentIdRaw(localStorageAdapter.readString(STORAGE_KEY_AI_DEFAULT_AGENT) ?? 'catty');
|
||||
break;
|
||||
case STORAGE_KEY_AI_COMMAND_BLOCKLIST: {
|
||||
const blocklist = readCommandBlocklistSetting();
|
||||
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];
|
||||
setCommandBlocklistRaw(blocklist);
|
||||
getAIBridge()?.aiMcpSetCommandBlocklist?.(blocklist);
|
||||
break;
|
||||
@@ -623,7 +627,7 @@ export function useAIState() {
|
||||
// ── Sync initial safety settings to MCP Server on mount ──
|
||||
useEffect(() => {
|
||||
const bridge = getAIBridge();
|
||||
const initialBlocklist = readCommandBlocklistSetting();
|
||||
const initialBlocklist = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST) ?? [...DEFAULT_COMMAND_BLOCKLIST];
|
||||
bridge?.aiMcpSetCommandBlocklist?.(initialBlocklist);
|
||||
const initialTimeout = normalizeCommandTimeoutSeconds(
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? DEFAULT_COMMAND_TIMEOUT_SECONDS,
|
||||
@@ -646,7 +650,7 @@ export function useAIState() {
|
||||
|
||||
// ── Session CRUD ──
|
||||
const persistSessions = useCallback((next: AISession[]) => {
|
||||
writeSessionsForStorage(next);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, pruneSessionsForStorage(next));
|
||||
}, []);
|
||||
|
||||
// Debounced version of persistSessions for high-frequency updates (e.g. streaming)
|
||||
@@ -657,7 +661,7 @@ export function useAIState() {
|
||||
if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
|
||||
persistTimerRef.current = setTimeout(() => {
|
||||
if (!mountedRef.current) return; // Skip writes after unmount
|
||||
writeSessionsForStorage(sessionsRef.current);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, pruneSessionsForStorage(sessionsRef.current));
|
||||
persistTimerRef.current = null;
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
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 { STORAGE_KEY_MANAGED_SOURCES } from "../../infrastructure/config/storageKeys.ts";
|
||||
import { isVaultImportLockHeld } from "./vaultManagedImportLock.ts";
|
||||
import { haveSameManagedSshAgentFields, useManagedSourceSync } from "./useManagedSourceSync.ts";
|
||||
import { haveSameManagedSshAgentFields } from "./useManagedSourceSync.ts";
|
||||
|
||||
const host: Host = {
|
||||
id: "host-1",
|
||||
@@ -59,145 +54,3 @@ 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,14 +156,6 @@ 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,
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
|
||||
import { useSessionState } from "./useSessionState.ts";
|
||||
|
||||
type SessionState = ReturnType<typeof useSessionState>;
|
||||
|
||||
test("global broadcast availability follows visible orphan session count", async () => {
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
const eventTarget = new EventTarget() as EventTarget & Record<string, unknown>;
|
||||
Object.assign(eventTarget, {
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
});
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: eventTarget,
|
||||
});
|
||||
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
};
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let state: SessionState | null = null;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
const current = (): SessionState => {
|
||||
assert.ok(state);
|
||||
return state;
|
||||
};
|
||||
|
||||
function Probe() {
|
||||
state = useSessionState({ persistSessionRestore: false });
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
});
|
||||
|
||||
assert.equal(current().orphanSessions.length, 0);
|
||||
assert.equal(current().canUseGlobalBroadcast, false);
|
||||
assert.equal(current().isGlobalBroadcastEnabled, false);
|
||||
|
||||
await act(async () => {
|
||||
current().createLocalTerminal({ shellName: "01" });
|
||||
});
|
||||
assert.equal(current().orphanSessions.length, 1);
|
||||
assert.equal(current().canUseGlobalBroadcast, false);
|
||||
|
||||
await act(async () => {
|
||||
current().createLocalTerminal({ shellName: "02" });
|
||||
});
|
||||
assert.equal(current().orphanSessions.length, 2);
|
||||
assert.equal(current().canUseGlobalBroadcast, true);
|
||||
|
||||
await act(async () => {
|
||||
current().toggleGlobalBroadcast();
|
||||
});
|
||||
assert.equal(current().isGlobalBroadcastEnabled, true);
|
||||
|
||||
await act(async () => {
|
||||
current().toggleGlobalBroadcast();
|
||||
});
|
||||
assert.equal(current().isGlobalBroadcastEnabled, false);
|
||||
|
||||
await act(async () => { current().toggleGlobalBroadcast(); });
|
||||
const closedId = current().orphanSessions[1].id;
|
||||
await act(async () => { current().closeSession(closedId); });
|
||||
assert.equal(current().canUseGlobalBroadcast, false);
|
||||
assert.equal(current().isGlobalBroadcastEnabled, false);
|
||||
await act(async () => { current().createLocalTerminal({ shellName: "03" }); });
|
||||
assert.equal(current().canUseGlobalBroadcast, true);
|
||||
assert.equal(current().isGlobalBroadcastEnabled, false);
|
||||
await act(async () => { current().toggleGlobalBroadcast(); });
|
||||
const [first, second] = current().orphanSessions;
|
||||
await act(async () => {
|
||||
current().createWorkspaceFromSessions(first.id, second.id, "right");
|
||||
});
|
||||
assert.equal(current().canUseGlobalBroadcast, false);
|
||||
assert.equal(current().isGlobalBroadcastEnabled, false);
|
||||
await act(async () => {
|
||||
current().removeSessionFromWorkspace(second.id);
|
||||
});
|
||||
assert.equal(current().canUseGlobalBroadcast, true);
|
||||
assert.equal(current().isGlobalBroadcastEnabled, false);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, "window");
|
||||
}
|
||||
});
|
||||
@@ -59,12 +59,7 @@ 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,
|
||||
buildMergedWorkspaceTitle,
|
||||
buildMergedWorkspaceTitleFromSessions,
|
||||
getSessionConnectionLabel,
|
||||
} from '../../domain/sessionTabTitle';
|
||||
import { DEFAULT_WORKSPACE_TITLE } from '../../domain/sessionTabTitle';
|
||||
import { cleanupClosedTerminalSessions } from './aiStateSnapshots';
|
||||
|
||||
export function addWorkspaceIfMissing(
|
||||
@@ -233,8 +228,6 @@ export const useSessionState = ({
|
||||
const [tabOrder, setTabOrder] = useState<string[]>(initialRestoreState.tabOrder);
|
||||
// Broadcast mode: stores workspace IDs that have broadcast enabled
|
||||
const [broadcastWorkspaceIds, setBroadcastWorkspaceIds] = useState<Set<string>>(new Set());
|
||||
// Global broadcast mode: enables broadcast for all orphan sessions (not in workspaces)
|
||||
const [globalBroadcastEnabled, setGlobalBroadcastEnabled] = useState<boolean>(false);
|
||||
// Log views: stores open log replay tabs
|
||||
const [logViews, setLogViews] = useState<LogView[]>([]);
|
||||
const [restorePreviousSessionRevision, setRestorePreviousSessionRevision] = useState(0);
|
||||
@@ -267,41 +260,6 @@ 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;
|
||||
@@ -664,16 +622,11 @@ 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 ? applyRename(w, trimmed) : w
|
||||
w.id === workspaceId ? { ...w, title: trimmed, autoTitle: false } : w
|
||||
)));
|
||||
return;
|
||||
}
|
||||
@@ -684,7 +637,7 @@ export const useSessionState = ({
|
||||
|
||||
setWorkspaceRenameTarget(prevTarget => {
|
||||
if (!prevTarget) return prevTarget;
|
||||
setWorkspaces(prev => prev.map(w => w.id === prevTarget.id ? applyRename(w, trimmed) : w));
|
||||
setWorkspaces(prev => prev.map(w => w.id === prevTarget.id ? { ...w, title: trimmed, autoTitle: false } : w));
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -776,29 +729,7 @@ export const useSessionState = ({
|
||||
hint: SplitHint
|
||||
) => {
|
||||
if (!hint || baseSessionId === joiningSessionId) return;
|
||||
// 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;
|
||||
const newWorkspace = createWorkspaceEntity(baseSessionId, joiningSessionId, hint);
|
||||
|
||||
setSessions((prevSessions) => {
|
||||
const base = prevSessions.find((s) => s.id === baseSessionId);
|
||||
@@ -1123,12 +1054,6 @@ export const useSessionState = ({
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const orphanSessions = useMemo(() => sessions.filter(s => !s.workspaceId && !s.hiddenFromTabs), [sessions]);
|
||||
const canUseGlobalBroadcast = orphanSessions.length >= 2;
|
||||
|
||||
// Losing the group must disarm broadcast before another tab can join it.
|
||||
if (!canUseGlobalBroadcast && globalBroadcastEnabled) {
|
||||
setGlobalBroadcastEnabled(false);
|
||||
}
|
||||
|
||||
const openLogView = useCallback((log: ConnectionLog) => {
|
||||
const tabId = getLogViewTabId(log);
|
||||
@@ -1280,14 +1205,6 @@ export const useSessionState = ({
|
||||
return broadcastWorkspaceIds.has(workspaceId);
|
||||
}, [broadcastWorkspaceIds]);
|
||||
|
||||
// Toggle global broadcast mode for orphan sessions
|
||||
const toggleGlobalBroadcast = useCallback(() => {
|
||||
setGlobalBroadcastEnabled(prev => !prev);
|
||||
}, []);
|
||||
|
||||
// Global broadcast enabled state - direct boolean value
|
||||
const isGlobalBroadcastEnabled = globalBroadcastEnabled;
|
||||
|
||||
const baseWorkTabIds = useMemo(() => [
|
||||
...orphanSessions.map(s => s.id),
|
||||
...workspaces.map(w => w.id),
|
||||
@@ -1411,12 +1328,9 @@ export const useSessionState = ({
|
||||
moveFocusInWorkspace,
|
||||
runSnippet,
|
||||
orphanSessions,
|
||||
canUseGlobalBroadcast,
|
||||
// Broadcast mode
|
||||
toggleBroadcast,
|
||||
isBroadcastEnabled,
|
||||
toggleGlobalBroadcast,
|
||||
isGlobalBroadcastEnabled,
|
||||
orderedTabs,
|
||||
getOrderedWorkTabs,
|
||||
reorderTabs,
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
|
||||
import { useSessionState } from "./useSessionState.ts";
|
||||
|
||||
type SessionState = ReturnType<typeof useSessionState>;
|
||||
|
||||
test("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();
|
||||
});
|
||||
});
|
||||
@@ -67,7 +67,6 @@ import {
|
||||
STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED,
|
||||
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
|
||||
STORAGE_KEY_CLOSE_TO_TRAY,
|
||||
STORAGE_KEY_AUTO_LAUNCH_ENABLED,
|
||||
STORAGE_KEY_HTTP_NETWORK_PROXY,
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
@@ -540,13 +539,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
if (stored === null) return true;
|
||||
return stored === 'true';
|
||||
});
|
||||
// Optimistic cache for first paint; useSystemSettingsEffects hydrates this
|
||||
// from app.getLoginItemSettings() (the real source of truth) on mount.
|
||||
const [autoLaunchEnabled, setAutoLaunchEnabled] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_AUTO_LAUNCH_ENABLED);
|
||||
return stored === 'true';
|
||||
});
|
||||
const [autoLaunchSupported, setAutoLaunchSupported] = useState<boolean>(true);
|
||||
const [httpNetworkProxy, setHttpNetworkProxyState] = useState<HttpNetworkProxySettings>(() => {
|
||||
const stored = localStorageAdapter.read<unknown>(STORAGE_KEY_HTTP_NETWORK_PROXY);
|
||||
return normalizeHttpNetworkProxySettings(stored ?? DEFAULT_HTTP_NETWORK_PROXY);
|
||||
@@ -1192,7 +1184,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setWindowOpacity: applyIncomingWindowOpacity,
|
||||
setAppIconVariant,
|
||||
setAutoUpdateEnabled,
|
||||
setAutoLaunchEnabled,
|
||||
setHttpNetworkProxy,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
@@ -1756,7 +1747,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
toggleWindowHotkey,
|
||||
globalHotkeyEnabled,
|
||||
closeToTray,
|
||||
autoLaunchEnabled,
|
||||
windowOpacityRecord,
|
||||
windowOpacityMutationSourceRef,
|
||||
appIconVariant,
|
||||
@@ -1766,8 +1756,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setHotkeyRegistrationError,
|
||||
setAutoUpdateEnabled,
|
||||
setAppIconVariant,
|
||||
setAutoLaunchEnabled,
|
||||
setAutoLaunchSupported,
|
||||
notifySettingsChanged,
|
||||
});
|
||||
|
||||
@@ -2113,9 +2101,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setToggleWindowHotkey,
|
||||
closeToTray,
|
||||
setCloseToTray,
|
||||
autoLaunchEnabled,
|
||||
setAutoLaunchEnabled,
|
||||
autoLaunchSupported,
|
||||
httpNetworkProxy,
|
||||
setHttpNetworkProxy,
|
||||
autoUpdateEnabled,
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
|
||||
import { useStoredBoolean } from "./useStoredBoolean.ts";
|
||||
|
||||
const STORAGE_KEY = "netcatty:test:stored-boolean";
|
||||
|
||||
test("functional updates stay pure when another consumer shares the storage key", async () => {
|
||||
const values = new Map<string, string>([[STORAGE_KEY, "false"]]);
|
||||
const fakeStorage = {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => values.set(key, value),
|
||||
removeItem: (key: string) => values.delete(key),
|
||||
};
|
||||
const fakeWindow = new EventTarget();
|
||||
const globals = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
localStorage?: typeof fakeStorage;
|
||||
window?: typeof fakeWindow;
|
||||
};
|
||||
const previousActEnvironment = globals.IS_REACT_ACT_ENVIRONMENT;
|
||||
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
const previousDispatchEvent = Object.getOwnPropertyDescriptor(globalThis, "dispatchEvent");
|
||||
const previousCustomEvent = Object.getOwnPropertyDescriptor(globalThis, "CustomEvent");
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
let toggle: (() => void) | null = null;
|
||||
let parentValue = false;
|
||||
let childValue = false;
|
||||
const errors: string[] = [];
|
||||
const originalConsoleError = console.error;
|
||||
|
||||
Object.defineProperty(globalThis, "localStorage", { value: fakeStorage, configurable: true });
|
||||
Object.defineProperty(globalThis, "window", { value: fakeWindow, configurable: true });
|
||||
Object.defineProperty(globalThis, "dispatchEvent", {
|
||||
value: fakeWindow.dispatchEvent.bind(fakeWindow),
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, "CustomEvent", {
|
||||
value: class TestCustomEvent<T> extends Event implements CustomEvent<T> {
|
||||
readonly detail: T;
|
||||
|
||||
constructor(type: string, init?: CustomEventInit<T>) {
|
||||
super(type);
|
||||
this.detail = init?.detail as T;
|
||||
}
|
||||
|
||||
initCustomEvent(): void {
|
||||
// Deprecated browser API required by the CustomEvent interface.
|
||||
}
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
console.error = (...args: unknown[]) => {
|
||||
errors.push(args.map(String).join(" "));
|
||||
};
|
||||
|
||||
const Parent = () => {
|
||||
[parentValue] = useStoredBoolean(STORAGE_KEY);
|
||||
return React.createElement(Child);
|
||||
};
|
||||
const Child = () => {
|
||||
const [value, setValue] = useStoredBoolean(STORAGE_KEY);
|
||||
childValue = value;
|
||||
toggle = () => setValue((previous) => !previous);
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(React.StrictMode, null, React.createElement(Parent)));
|
||||
});
|
||||
await act(async () => {
|
||||
toggle?.();
|
||||
});
|
||||
|
||||
assert.equal(parentValue, true);
|
||||
assert.equal(childValue, true);
|
||||
assert.equal(values.get(STORAGE_KEY), "true");
|
||||
assert.equal(
|
||||
errors.some((message) => message.includes("Cannot update a component")
|
||||
&& message.includes("while rendering a different component")),
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
|
||||
else delete globals.localStorage;
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
|
||||
else delete globals.window;
|
||||
if (previousDispatchEvent) Object.defineProperty(globalThis, "dispatchEvent", previousDispatchEvent);
|
||||
else delete (globalThis as typeof globalThis & { dispatchEvent?: unknown }).dispatchEvent;
|
||||
if (previousCustomEvent) Object.defineProperty(globalThis, "CustomEvent", previousCustomEvent);
|
||||
else delete (globalThis as typeof globalThis & { CustomEvent?: unknown }).CustomEvent;
|
||||
}
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
|
||||
/**
|
||||
@@ -17,37 +17,30 @@ export const useStoredBoolean = (
|
||||
const stored = localStorageAdapter.readBoolean(storageKey);
|
||||
return stored ?? fallback;
|
||||
});
|
||||
const valueRef = useRef(value);
|
||||
|
||||
const setAndPersist = useCallback((next: boolean | ((prev: boolean) => boolean)) => {
|
||||
// Resolve functional updates before scheduling React state. The updater
|
||||
// must stay pure because React may evaluate it while rendering.
|
||||
const resolved = typeof next === "function" ? next(valueRef.current) : next;
|
||||
valueRef.current = resolved;
|
||||
setValue(resolved);
|
||||
localStorageAdapter.writeBoolean(storageKey, resolved);
|
||||
// Notify other same-window consumers outside the state updater.
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("stored-boolean-change", { detail: { key: storageKey, value: resolved } }),
|
||||
);
|
||||
setValue((prev) => {
|
||||
const resolved = typeof next === "function" ? next(prev) : next;
|
||||
localStorageAdapter.writeBoolean(storageKey, resolved);
|
||||
// Notify other same-window consumers
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("stored-boolean-change", { detail: { key: storageKey, value: resolved } }),
|
||||
);
|
||||
return resolved;
|
||||
});
|
||||
}, [storageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
// Sync from other components in the same window
|
||||
const handleCustom = (e: Event) => {
|
||||
const { key, value: newValue } = (e as CustomEvent).detail;
|
||||
if (key === storageKey) {
|
||||
valueRef.current = newValue;
|
||||
setValue(newValue);
|
||||
}
|
||||
if (key === storageKey) setValue(newValue);
|
||||
};
|
||||
// Sync from other windows
|
||||
const handleStorage = (e: StorageEvent) => {
|
||||
if (e.key === storageKey) {
|
||||
const stored = localStorageAdapter.readBoolean(storageKey);
|
||||
const nextValue = stored ?? fallback;
|
||||
valueRef.current = nextValue;
|
||||
setValue(nextValue);
|
||||
setValue(stored ?? fallback);
|
||||
}
|
||||
};
|
||||
window.addEventListener("stored-boolean-change", handleCustom);
|
||||
|
||||
@@ -5,7 +5,6 @@ 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;
|
||||
@@ -57,7 +56,6 @@ 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,
|
||||
@@ -644,87 +642,6 @@ 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,7 +28,6 @@ import {
|
||||
sanitizeHostsForSync,
|
||||
type SyncPayload,
|
||||
} from '../domain/sync';
|
||||
import { migrateLegacyCommandBlocklist } from '../domain/commandBlocklist';
|
||||
import { migrateHostsFromLegacyLineTimestamps } from '../domain/host';
|
||||
import { toPersistedPortForwardingRules } from '../domain/portForwardingPersistence';
|
||||
import {
|
||||
@@ -44,10 +43,6 @@ 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,
|
||||
@@ -577,10 +572,8 @@ 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 storedCommandBlocklist = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
|
||||
if (Array.isArray(storedCommandBlocklist)) {
|
||||
ai.commandBlocklist = readCommandBlocklistSetting();
|
||||
}
|
||||
const commandBlocklist = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
|
||||
if (Array.isArray(commandBlocklist)) ai.commandBlocklist = commandBlocklist;
|
||||
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);
|
||||
@@ -831,9 +824,7 @@ 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) {
|
||||
persistCommandBlocklistSetting(migrateLegacyCommandBlocklist(ai.commandBlocklist));
|
||||
}
|
||||
if (ai.commandBlocklist != null) localStorageAdapter.write(STORAGE_KEY_AI_COMMAND_BLOCKLIST, 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,7 +11,6 @@ import {
|
||||
applyEffectiveHostAuthMethodSelection,
|
||||
detachEffectiveHostIdentity,
|
||||
HOST_AUTH_METHOD_CHOICES,
|
||||
HOST_OS_CHOICES,
|
||||
HostDetailsConnectionSections,
|
||||
removeSelectedHostCredential,
|
||||
shouldForceAuthMethodReselect,
|
||||
@@ -112,28 +111,6 @@ 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,12 +23,6 @@ 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",
|
||||
@@ -131,12 +125,6 @@ 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;
|
||||
|
||||
@@ -152,31 +140,6 @@ 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
|
||||
|
||||
@@ -661,9 +661,6 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
|
||||
setToggleWindowHotkey={settings.setToggleWindowHotkey}
|
||||
closeToTray={settings.closeToTray}
|
||||
setCloseToTray={settings.setCloseToTray}
|
||||
autoLaunchEnabled={settings.autoLaunchEnabled}
|
||||
setAutoLaunchEnabled={settings.setAutoLaunchEnabled}
|
||||
autoLaunchSupported={settings.autoLaunchSupported}
|
||||
httpNetworkProxy={settings.httpNetworkProxy}
|
||||
setHttpNetworkProxy={settings.setHttpNetworkProxy}
|
||||
hotkeyRegistrationError={settings.hotkeyRegistrationError}
|
||||
|
||||
@@ -524,6 +524,14 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
originFocused: boolean;
|
||||
sourceActive: boolean;
|
||||
} | null>(null);
|
||||
// Origin sessions observed focused while uploads are pending. Only the
|
||||
// queue head is surfaced to the panel, so a drop queued behind an earlier
|
||||
// request has its drop-time focus switch (issued by handleOpenSftp) land
|
||||
// while the panel is still tracking the head. Remember those observations
|
||||
// so the queued request is not treated as "focus never landed" once it
|
||||
// reaches the head, which would defer cancellation and auto-connect
|
||||
// indefinitely until the user manually revisits the origin pane.
|
||||
const pendingUploadFocusedOriginSessionsRef = useRef<Set<string>>(new Set());
|
||||
const {
|
||||
barrierRef: pendingUploadRebindBarrierRef,
|
||||
bindTarget: bindPendingUploadRebindTarget,
|
||||
@@ -1040,6 +1048,9 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingUpload) return;
|
||||
if (focusedSessionId) {
|
||||
pendingUploadFocusedOriginSessionsRef.current.add(focusedSessionId);
|
||||
}
|
||||
if (handledPendingUploadIdRef.current === pendingUpload.requestId) return;
|
||||
|
||||
const rebindBarrier = pendingUploadRebindBarrierRef.current;
|
||||
@@ -1065,7 +1076,12 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
if (pendingUploadFocusSeenRef.current?.requestId !== pendingUpload.requestId) {
|
||||
pendingUploadFocusSeenRef.current = {
|
||||
requestId: pendingUpload.requestId,
|
||||
originFocused: false,
|
||||
// A queued drop's origin focus already landed (and was recorded) while
|
||||
// an earlier request owned the queue head; only a fresh head must wait
|
||||
// for its own focus switch to propagate.
|
||||
originFocused: pendingUpload.originSessionId
|
||||
? pendingUploadFocusedOriginSessionsRef.current.has(pendingUpload.originSessionId)
|
||||
: false,
|
||||
sourceActive: false,
|
||||
};
|
||||
}
|
||||
@@ -1243,6 +1259,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
useEffect(() => {
|
||||
if (!pendingUpload) {
|
||||
pendingUploadFocusSeenRef.current = null;
|
||||
pendingUploadFocusedOriginSessionsRef.current.clear();
|
||||
resetPendingUploadRebind();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -10,13 +10,6 @@ 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,7 +20,6 @@ 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,
|
||||
@@ -28,11 +27,6 @@ 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";
|
||||
@@ -199,54 +193,6 @@ 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,7 +212,6 @@ import {
|
||||
import {
|
||||
releaseTerminalFlowBeforeHibernate,
|
||||
} from "./terminal/runtime/terminalSessionAttachment";
|
||||
import { resetTerminalLineTimestamps } from "./terminal/runtime/terminalLineTimestamps";
|
||||
import {
|
||||
flushPendingTerminalWritesBeforeHibernate,
|
||||
hasPendingTerminalWrites,
|
||||
@@ -879,10 +878,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
);
|
||||
const sudoAutofillPasswordRef = useRef(resolvedSudoAutofillPassword);
|
||||
sudoAutofillPasswordRef.current = resolvedSudoAutofillPassword;
|
||||
const resolvedLoginUsername = useMemo(
|
||||
() => resolveHostAuth({ host, keys, identities }).username,
|
||||
[host, keys, identities],
|
||||
);
|
||||
const sudoAutofillCandidatesRef = useRef(resolvedSudoAutofillCandidates);
|
||||
sudoAutofillCandidatesRef.current = resolvedSudoAutofillCandidates;
|
||||
const [passwordPickerState, setPasswordPickerState] = useState<PasswordPromptPickerState | null>(null);
|
||||
@@ -1632,9 +1627,6 @@ 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));
|
||||
}
|
||||
@@ -3752,8 +3744,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
isDraggingOver,
|
||||
} = useTerminalDragDrop({
|
||||
host,
|
||||
resolvedLoginUsername,
|
||||
resolvedSudoPassword: resolvedSudoAutofillPassword,
|
||||
isLocalConnection,
|
||||
isNetworkDevice,
|
||||
onOpenSftp,
|
||||
|
||||
@@ -46,9 +46,6 @@ const baseProps = {
|
||||
onSetWorkspaceFocusedSession: () => {},
|
||||
isBroadcastEnabled: () => false,
|
||||
onToggleBroadcast: () => {},
|
||||
isGlobalBroadcastEnabled: false,
|
||||
canUseGlobalBroadcast: false,
|
||||
onToggleGlobalBroadcast: () => {},
|
||||
updateSnippets: () => {},
|
||||
updateSnippetPackages: () => {},
|
||||
onSplitSession: () => {},
|
||||
@@ -124,23 +121,6 @@ test("TerminalLayer re-renders when broadcast state changes", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("TerminalLayer re-renders when global broadcast state changes", () => {
|
||||
assert.equal(
|
||||
terminalLayerAreEqual(
|
||||
baseProps as never,
|
||||
{ ...baseProps, isGlobalBroadcastEnabled: true } as never,
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
terminalLayerAreEqual(
|
||||
baseProps as never,
|
||||
{ ...baseProps, canUseGlobalBroadcast: true } as never,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("TerminalLayer re-renders when terminal side panel auto-open settings change", () => {
|
||||
assert.equal(
|
||||
terminalLayerAreEqual(
|
||||
|
||||
@@ -67,7 +67,6 @@ import { ThemeSidePanel } from './terminal/ThemeSidePanel';
|
||||
import { focusTerminalSessionInput } from './terminal/focusTerminalSession';
|
||||
import { TerminalComposeBar } from './terminal/TerminalComposeBar';
|
||||
import { resolveTerminalFontSizeUpdateTarget } from './terminalLayer/terminalFontSizeUpdate';
|
||||
import { resolveTerminalBroadcastTargetIds } from '../domain/terminalBroadcast';
|
||||
import {
|
||||
AUTO_RUN_SNIPPET_LINE_DELAY_MS,
|
||||
shouldDelayAutoRunSnippetInput,
|
||||
@@ -247,9 +246,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onCreateLocalTerminal,
|
||||
isBroadcastEnabled,
|
||||
onToggleBroadcast,
|
||||
isGlobalBroadcastEnabled,
|
||||
onToggleGlobalBroadcast,
|
||||
canUseGlobalBroadcast,
|
||||
updateHosts,
|
||||
updateSnippets,
|
||||
updateSnippetPackages,
|
||||
@@ -1053,25 +1049,27 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const sessionHostsMapRef = useRef(sessionHostsMap);
|
||||
sessionHostsMapRef.current = sessionHostsMap;
|
||||
|
||||
// Resolve the active broadcast mode, then write to its target sessions.
|
||||
// Handle broadcast input - write to all other sessions in the source workspace.
|
||||
const handleBroadcastInput = useCallback((
|
||||
data: string,
|
||||
sourceSessionId: string,
|
||||
options?: TerminalBroadcastInputOptions,
|
||||
) => {
|
||||
const targetSessionIds = resolveTerminalBroadcastTargetIds({
|
||||
sessions: sessionsRef.current,
|
||||
sourceSessionId,
|
||||
globalBroadcastEnabled: isGlobalBroadcastEnabled,
|
||||
directTargetSessionIds: options?.kittyKeyboardTargetSessionIds,
|
||||
});
|
||||
if (targetSessionIds.length === 0) return [];
|
||||
|
||||
const targetSessionIdSet = new Set(targetSessionIds);
|
||||
const directTargetIds = options?.kittyKeyboardTargetSessionIds;
|
||||
const sourceSession = directTargetIds
|
||||
? undefined
|
||||
: sessionsRef.current.find((session) => session.id === sourceSessionId);
|
||||
const workspaceId = sourceSession?.workspaceId;
|
||||
if (!directTargetIds && !workspaceId) return [];
|
||||
const directTargetSet = directTargetIds ? new Set(directTargetIds) : null;
|
||||
const deliveredSessionIds: string[] = [];
|
||||
|
||||
for (const session of sessionsRef.current) {
|
||||
if (!targetSessionIdSet.has(session.id)) continue;
|
||||
if (directTargetSet) {
|
||||
if (!directTargetSet.has(session.id)) continue;
|
||||
} else if (session.workspaceId !== workspaceId || session.id === sourceSessionId) {
|
||||
continue;
|
||||
}
|
||||
if (!canUseDirectSessionWriteFallback(session)) continue;
|
||||
|
||||
if (options?.kittyKeyboardInput) {
|
||||
@@ -1109,7 +1107,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
deliveredSessionIds.push(session.id);
|
||||
}
|
||||
return deliveredSessionIds;
|
||||
}, [terminalBackend, isGlobalBroadcastEnabled]);
|
||||
}, [terminalBackend]);
|
||||
|
||||
const handleCommandSubmitted = useCallback((command: string, _hostId: string, _hostLabel: string, sessionId: string) => {
|
||||
codingCliSignalController.handleCommandSubmitted(sessionId, command);
|
||||
@@ -1299,9 +1297,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onToggleBroadcastRef.current = onToggleBroadcast;
|
||||
const workspaceBroadcastHandlersRef = useRef<Map<string, () => void>>(new Map());
|
||||
|
||||
const onToggleGlobalBroadcastRef = useRef(onToggleGlobalBroadcast);
|
||||
onToggleGlobalBroadcastRef.current = onToggleGlobalBroadcast;
|
||||
|
||||
const mountedSftpTabIds = useMemo(
|
||||
() => Array.from(sftpHostForTab.keys()),
|
||||
[sftpHostForTab],
|
||||
@@ -2303,8 +2298,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
restoreTerminalCwd,
|
||||
identities,
|
||||
isBroadcastEnabled,
|
||||
isGlobalBroadcastEnabled,
|
||||
canUseGlobalBroadcast,
|
||||
isComposeBarOpen,
|
||||
keyBindings,
|
||||
keys,
|
||||
@@ -2341,7 +2334,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onSplitSession,
|
||||
onSplitSessionRef,
|
||||
onToggleBroadcastRef,
|
||||
onToggleGlobalBroadcastRef,
|
||||
onToggleWorkspaceViewMode,
|
||||
onToggleWorkspaceViewModeRef,
|
||||
onUpdateHost,
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
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,12 +1,9 @@
|
||||
import { Copy, FileSymlink, Files, Folder, FolderOpen, Monitor, Pencil, Plus, Server, Settings2 } from 'lucide-react';
|
||||
import { Copy, FileSymlink, 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';
|
||||
@@ -22,17 +19,6 @@ 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 }
|
||||
> = ({
|
||||
@@ -64,7 +50,6 @@ 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,15 +367,8 @@ test("note editor registers a code block editor for pasted fenced code", () => {
|
||||
source,
|
||||
/codeBlockPlugin\([^)]*\),\s*codeMirrorPlugin\(\{\s*codeBlockLanguages:/s,
|
||||
);
|
||||
assert.match(source, /codeMirrorExtensions:\s*\[\s*\.\.\.NOTE_CODE_MIRROR_EXTENSIONS,/);
|
||||
assert.match(source, /codeMirrorExtensions:\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,7 +63,6 @@ import {
|
||||
} from "./noteClipboardPaste";
|
||||
import { annotateNoteImageSizes } from "./noteImageLayout";
|
||||
import { renderNoteMathFormula } from "./noteMathRenderer";
|
||||
import { createNoteCodeTooltipExtensions } from "./noteCodeTooltips";
|
||||
import {
|
||||
EMPTY_ACTIVE_FORMATS,
|
||||
type ActiveTextFormats,
|
||||
@@ -1204,16 +1203,7 @@ export const InlineMarkdownEditor = React.memo(
|
||||
codeBlockPlugin({ defaultCodeBlockLanguage: "" }),
|
||||
codeMirrorPlugin({
|
||||
codeBlockLanguages: NOTE_CODE_BLOCK_LANGUAGES,
|
||||
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,
|
||||
),
|
||||
],
|
||||
codeMirrorExtensions: NOTE_CODE_MIRROR_EXTENSIONS,
|
||||
}),
|
||||
markdownShortcutPlugin(),
|
||||
noteEditorDialogBridgePlugin({
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -1,128 +0,0 @@
|
||||
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))",
|
||||
},
|
||||
})),
|
||||
];
|
||||
@@ -121,9 +121,6 @@ interface SettingsSystemTabProps {
|
||||
setToggleWindowHotkey: (hotkey: string) => void;
|
||||
closeToTray: boolean;
|
||||
setCloseToTray: (enabled: boolean) => void;
|
||||
autoLaunchEnabled: boolean;
|
||||
setAutoLaunchEnabled: (enabled: boolean) => void;
|
||||
autoLaunchSupported: boolean;
|
||||
httpNetworkProxy: HttpNetworkProxySettings;
|
||||
setHttpNetworkProxy: (settings: HttpNetworkProxySettings | ((prev: HttpNetworkProxySettings) => HttpNetworkProxySettings)) => void;
|
||||
hotkeyRegistrationError: string | null;
|
||||
@@ -173,9 +170,6 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
setToggleWindowHotkey,
|
||||
closeToTray,
|
||||
setCloseToTray,
|
||||
autoLaunchEnabled,
|
||||
setAutoLaunchEnabled,
|
||||
autoLaunchSupported,
|
||||
httpNetworkProxy,
|
||||
setHttpNetworkProxy,
|
||||
hotkeyRegistrationError,
|
||||
@@ -609,25 +603,6 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
{t('settings.update.hint')}
|
||||
</SettingHint>
|
||||
|
||||
<SectionHeader title={t("settings.autoLaunch.title")} />
|
||||
<SettingCard className="py-4">
|
||||
<SettingRow
|
||||
anchorId="system-auto-launch"
|
||||
label={t("settings.autoLaunch.enabled")}
|
||||
description={
|
||||
autoLaunchSupported
|
||||
? t("settings.autoLaunch.enabledDesc")
|
||||
: t("settings.autoLaunch.unsupportedDesc")
|
||||
}
|
||||
>
|
||||
<Toggle
|
||||
checked={autoLaunchEnabled}
|
||||
onChange={setAutoLaunchEnabled}
|
||||
disabled={!autoLaunchSupported}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
|
||||
<SectionHeader title={t("settings.system.networkProxy.title")} />
|
||||
<SettingCard className="space-y-4 py-4">
|
||||
<SettingRow
|
||||
|
||||
@@ -7,11 +7,6 @@ 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 {
|
||||
@@ -29,7 +24,6 @@ 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> = ({
|
||||
@@ -47,7 +41,6 @@ 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)
|
||||
@@ -88,10 +81,8 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
|
||||
onDrop={handleDrop}
|
||||
onClick={handleSelect}
|
||||
onDoubleClick={handleOpen}
|
||||
data-sftp-list-density={density}
|
||||
className={cn(
|
||||
"items-center cursor-pointer",
|
||||
sftpFileRowDensityClass(density),
|
||||
"px-4 py-2 items-center cursor-pointer text-sm",
|
||||
isSelectionVisible
|
||||
? "bg-accent text-accent-foreground hover:bg-accent"
|
||||
: "hover:bg-accent/50",
|
||||
@@ -99,10 +90,9 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
|
||||
)}
|
||||
style={{ display: 'grid', gridTemplateColumns: buildSftpColumnTemplate(columnWidths, visibleColumns) }}
|
||||
>
|
||||
<div className={cn("flex items-center min-w-0", density === "compact" ? "gap-2" : "gap-3")}>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className={cn(
|
||||
"rounded flex items-center justify-center shrink-0 relative",
|
||||
sftpFileRowIconDensityClass(density),
|
||||
"h-7 w-7 rounded flex items-center justify-center shrink-0 relative",
|
||||
isSelectionVisible
|
||||
? "bg-accent-foreground/10 text-accent-foreground"
|
||||
: isNavDir
|
||||
@@ -178,7 +168,6 @@ 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,7 +19,6 @@ 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 {
|
||||
@@ -78,7 +77,6 @@ interface SftpPaneFileListProps {
|
||||
openDeleteConfirm: (targets: string[]) => void;
|
||||
rowHeight: number;
|
||||
visibleRows: { entry: SftpFileEntry; index: number; top: number }[];
|
||||
listDensity?: SftpListDensity;
|
||||
}
|
||||
|
||||
const SftpErrorWithLogs: React.FC<{
|
||||
@@ -166,7 +164,6 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
||||
openDeleteConfirm,
|
||||
rowHeight,
|
||||
visibleRows,
|
||||
listDensity = "comfortable",
|
||||
}) => {
|
||||
const {
|
||||
columnWidths,
|
||||
@@ -291,7 +288,6 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
||||
onDragOver={handleEntryDragOver}
|
||||
onDragLeave={handleRowDragLeave}
|
||||
onDrop={handleEntryDrop}
|
||||
density={listDensity}
|
||||
/>
|
||||
</ContextMenuTrigger>
|
||||
{entry.name !== ".." && (
|
||||
@@ -501,7 +497,6 @@ 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("inline bookmark actions navigate and confirm current-path removal", async () => {
|
||||
test("selecting an inline SFTP bookmark closes its popover", async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost",
|
||||
@@ -48,116 +48,85 @@ test("inline bookmark actions navigate and confirm current-path removal", 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 renderToolbar("/home/app", true);
|
||||
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: () => {},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
let trigger = window.document.querySelector<HTMLButtonElement>(
|
||||
const trigger = window.document.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Bookmarked paths"]',
|
||||
);
|
||||
assert.ok(trigger);
|
||||
@@ -177,134 +146,6 @@ test("inline bookmark actions navigate and confirm current-path removal", 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,11 +8,8 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import {
|
||||
getSftpBookmarkButtonLabelKey,
|
||||
getSftpBookmarkIdentity,
|
||||
getNextSftpViewMode,
|
||||
copySftpCurrentPathToClipboard,
|
||||
canReorderSftpBookmark,
|
||||
getSftpBookmarkMoveTargets,
|
||||
getNextSftpToolbarDisplayPath,
|
||||
getSftpViewModeToggleTarget,
|
||||
getSftpViewModeToggleLabelKey,
|
||||
@@ -35,11 +32,6 @@ 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"];
|
||||
@@ -499,82 +491,6 @@ 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(
|
||||
@@ -586,7 +502,7 @@ test("bookmark list renders saved paths as selectable rows", () => {
|
||||
React.createElement(SftpBookmarkList, {
|
||||
bookmarks: [{ id: "bm-1", path: "/srv/www", label: "Web root" }],
|
||||
onNavigateToBookmark: () => {},
|
||||
onRequestDeleteBookmark: () => {},
|
||||
onDeleteBookmark: () => {},
|
||||
t: (key: string) => ({
|
||||
"sftp.bookmark.remove": "Remove bookmark",
|
||||
}[key] ?? key),
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "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 { Bookmark, Check, ClipboardCopy, Eye, EyeOff, FilePlus, Folder, FolderPlus, FolderSync, Globe, Home, Languages, List, ListTree, RefreshCw, Search, Terminal, TerminalSquare, Trash2, X } from "lucide-react";
|
||||
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";
|
||||
@@ -39,7 +33,6 @@ export const SFTP_TOOLBAR_ITEM_IDS = [
|
||||
"followTerminalCwd",
|
||||
"copyPath",
|
||||
"viewMode",
|
||||
"listDensity",
|
||||
"filter",
|
||||
"encoding",
|
||||
"newFolder",
|
||||
@@ -59,7 +52,6 @@ export const SFTP_TOOLBAR_LAYOUT_DEFAULTS: ToolbarItemLayoutDefaults = {
|
||||
followTerminalCwd: "show",
|
||||
copyPath: "show",
|
||||
viewMode: "show",
|
||||
listDensity: "show",
|
||||
filter: "show",
|
||||
encoding: "collapse",
|
||||
newFolder: "show",
|
||||
@@ -82,7 +74,6 @@ export const SFTP_TOOLBAR_NARROW_INLINE_IDS = new Set<SftpToolbarItemId>([
|
||||
"followTerminalCwd",
|
||||
"copyPath",
|
||||
"viewMode",
|
||||
"listDensity",
|
||||
"filter",
|
||||
]);
|
||||
|
||||
@@ -224,11 +215,7 @@ interface SftpPaneToolbarProps {
|
||||
onAddGlobalBookmark: (path: string) => void;
|
||||
isCurrentPathGlobalBookmarked: boolean;
|
||||
onNavigateToBookmark: (path: string) => void;
|
||||
onDeleteBookmark: (bookmark: SftpBookmark) => void;
|
||||
onReorderBookmark?: (from: SftpBookmark, to: SftpBookmark) => void;
|
||||
onRenameBookmark?: (bookmark: SftpBookmark, label: string) => void;
|
||||
listDensity?: SftpListDensity;
|
||||
onSetListDensity?: (density: SftpListDensity) => void;
|
||||
onDeleteBookmark: (id: string) => void;
|
||||
showHiddenFiles: boolean;
|
||||
onToggleShowHiddenFiles?: () => void;
|
||||
onGoToTerminalCwd?: () => void;
|
||||
@@ -242,249 +229,63 @@ interface SftpPaneToolbarProps {
|
||||
|
||||
interface SftpBookmarkListProps {
|
||||
bookmarks: SftpBookmark[];
|
||||
managing?: boolean;
|
||||
onNavigateToBookmark: (path: string) => void;
|
||||
onRequestDeleteBookmark: (bookmark: SftpBookmark) => void;
|
||||
onReorderBookmark?: (from: SftpBookmark, to: SftpBookmark) => void;
|
||||
onRenameBookmark?: (bookmark: SftpBookmark, label: string) => void;
|
||||
onDeleteBookmark: (id: 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,
|
||||
onRequestDeleteBookmark,
|
||||
onReorderBookmark,
|
||||
onRenameBookmark,
|
||||
onDeleteBookmark,
|
||||
t,
|
||||
}) => {
|
||||
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)}
|
||||
>
|
||||
{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);
|
||||
}}
|
||||
}) => (
|
||||
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)}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
{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);
|
||||
}}
|
||||
>
|
||||
<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";
|
||||
@@ -526,10 +327,6 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
|
||||
isCurrentPathGlobalBookmarked,
|
||||
onNavigateToBookmark,
|
||||
onDeleteBookmark,
|
||||
onReorderBookmark,
|
||||
onRenameBookmark,
|
||||
listDensity = "comfortable",
|
||||
onSetListDensity,
|
||||
showHiddenFiles,
|
||||
onToggleShowHiddenFiles,
|
||||
onGoToTerminalCwd,
|
||||
@@ -542,11 +339,6 @@ 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 ""
|
||||
@@ -667,8 +459,6 @@ 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,
|
||||
@@ -685,7 +475,6 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
|
||||
"bookmark",
|
||||
"copyPath",
|
||||
"viewMode",
|
||||
"listDensity",
|
||||
"filter",
|
||||
"newFolder",
|
||||
"newFile",
|
||||
@@ -707,7 +496,6 @@ 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"),
|
||||
@@ -715,7 +503,7 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
|
||||
showHidden: t("settings.sftp.showHiddenFiles"),
|
||||
refresh: t("common.refresh"),
|
||||
}),
|
||||
[bookmarkButtonLabel, listDensityToggleLabel, t, viewModeToggleLabel],
|
||||
[bookmarkButtonLabel, t, viewModeToggleLabel],
|
||||
);
|
||||
|
||||
const itemIcons = useMemo(
|
||||
@@ -726,7 +514,6 @@ 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} />,
|
||||
@@ -786,29 +573,7 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
|
||||
[shown, collapsed, narrow],
|
||||
);
|
||||
|
||||
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",
|
||||
) => (
|
||||
const renderBookmarkPopoverBody = (onAfterLeafAction?: () => void) => (
|
||||
<SftpBookmarkPopoverBody
|
||||
t={t}
|
||||
bookmarks={bookmarks}
|
||||
@@ -818,22 +583,17 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
|
||||
onToggleBookmark={onToggleBookmark}
|
||||
onAddGlobalBookmark={onAddGlobalBookmark}
|
||||
onNavigateToBookmark={onNavigateToBookmark}
|
||||
onRequestDeleteBookmark={(bookmark) => (
|
||||
requestBookmarkRemoval(bookmark, focusOrigin)
|
||||
)}
|
||||
onReorderBookmark={onReorderBookmark}
|
||||
onRenameBookmark={onRenameBookmark}
|
||||
onDeleteBookmark={onDeleteBookmark}
|
||||
onAfterLeafAction={onAfterLeafAction}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderBookmarkButton = (size: "sm" | "md" = "sm") => (
|
||||
<Popover open={bookmarkPopoverOpen} onOpenChange={setBookmarkPopoverOpen}>
|
||||
<Popover>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
ref={bookmarkButtonRef}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
@@ -855,8 +615,8 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{bookmarkButtonLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent className="w-80 p-0" align="start">
|
||||
{renderBookmarkPopoverBody(() => setBookmarkPopoverOpen(false))}
|
||||
<PopoverContent className="w-64 p-0" align="start">
|
||||
{renderBookmarkPopoverBody()}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
@@ -870,9 +630,7 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
|
||||
bookmarksCount={bookmarks.length}
|
||||
shouldToggleBookmarkFromButton={shouldToggleBookmarkFromButton}
|
||||
onToggleBookmark={onToggleBookmark}
|
||||
renderBody={(closeOverflow) => (
|
||||
renderBookmarkPopoverBody(closeOverflow, "overflow")
|
||||
)}
|
||||
renderBody={(closeOverflow) => renderBookmarkPopoverBody(closeOverflow)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1015,23 +773,6 @@ 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}>
|
||||
@@ -1175,18 +916,6 @@ 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}>
|
||||
@@ -1357,7 +1086,6 @@ 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>
|
||||
@@ -1487,28 +1215,6 @@ 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>
|
||||
);
|
||||
});
|
||||
@@ -1522,9 +1228,7 @@ function SftpBookmarkPopoverBody({
|
||||
onToggleBookmark,
|
||||
onAddGlobalBookmark,
|
||||
onNavigateToBookmark,
|
||||
onRequestDeleteBookmark,
|
||||
onReorderBookmark,
|
||||
onRenameBookmark,
|
||||
onDeleteBookmark,
|
||||
onAfterLeafAction,
|
||||
}: {
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
@@ -1535,12 +1239,9 @@ function SftpBookmarkPopoverBody({
|
||||
onToggleBookmark: () => void;
|
||||
onAddGlobalBookmark: (path: string) => void;
|
||||
onNavigateToBookmark: (path: string) => void;
|
||||
onRequestDeleteBookmark: (bookmark: SftpBookmark) => void;
|
||||
onReorderBookmark?: (from: SftpBookmark, to: SftpBookmark) => void;
|
||||
onRenameBookmark?: (bookmark: SftpBookmark, label: string) => void;
|
||||
onDeleteBookmark: (id: string) => void;
|
||||
onAfterLeafAction?: () => void;
|
||||
}) {
|
||||
const [managing, setManaging] = useState(false);
|
||||
const runThenClose = (action: () => void) => {
|
||||
action();
|
||||
onAfterLeafAction?.();
|
||||
@@ -1548,34 +1249,15 @@ function SftpBookmarkPopoverBody({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="px-3 py-2 border-b border-border/40 flex items-center justify-between gap-2">
|
||||
<div className="px-3 py-2 border-b border-border/40">
|
||||
<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={() => {
|
||||
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);
|
||||
}}
|
||||
onClick={() => runThenClose(onToggleBookmark)}
|
||||
>
|
||||
<Bookmark
|
||||
size={12}
|
||||
@@ -1602,13 +1284,8 @@ function SftpBookmarkPopoverBody({
|
||||
</div>
|
||||
<SftpBookmarkList
|
||||
bookmarks={bookmarks}
|
||||
managing={managing}
|
||||
onNavigateToBookmark={(path) => runThenClose(() => onNavigateToBookmark(path))}
|
||||
onRequestDeleteBookmark={(bookmark) => (
|
||||
runThenClose(() => onRequestDeleteBookmark(bookmark))
|
||||
)}
|
||||
onReorderBookmark={onReorderBookmark}
|
||||
onRenameBookmark={onRenameBookmark}
|
||||
onDeleteBookmark={(id) => runThenClose(() => onDeleteBookmark(id))}
|
||||
t={t}
|
||||
/>
|
||||
</>
|
||||
@@ -1664,7 +1341,7 @@ function SftpOverflowNestedBookmark({
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-80 p-0"
|
||||
className="w-64 p-0"
|
||||
align="start"
|
||||
side="left"
|
||||
sideOffset={6}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
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,7 +67,11 @@ export const TreeNode = React.memo<TreeNodeProps>(({
|
||||
)}
|
||||
style={{ gridTemplateColumns: columnTemplate, height: TREE_ROW_HEIGHT }}
|
||||
onClick={e => onNodeClick(entry, entryPath, e)}
|
||||
onDoubleClick={() => onOpenEntry(entry, entryPath)}
|
||||
onDoubleClick={() => {
|
||||
if (isParentEntry) { onOpenEntry(entry, entryPath); return; }
|
||||
if (isDir) void onToggleExpand(entry, entryPath);
|
||||
else onOpenEntry(entry, entryPath);
|
||||
}}
|
||||
onContextMenu={e => {
|
||||
if (!isParentEntry) {
|
||||
onContextMenu(entry, entryPath, e);
|
||||
|
||||
@@ -24,15 +24,6 @@ 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,
|
||||
@@ -409,15 +400,10 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
||||
lastClickedPathRef.current = entryPath;
|
||||
}, [focusTreeContainer, pane.id]);
|
||||
const openTreeEntry = useCallback((entry: SftpFileEntry, entryPath: string) => {
|
||||
const action = getSftpTreeEntryOpenAction(entry);
|
||||
if (action === 'up') {
|
||||
if (entry.name === '..') {
|
||||
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, SftpBookmark } from "../../domain/models";
|
||||
import type { Host } from "../../domain/models";
|
||||
import { useSftpPaneDialogs } from "./hooks/useSftpPaneDialogs";
|
||||
import { useSftpPaneDragAndSelect } from "./hooks/useSftpPaneDragAndSelect";
|
||||
import { useSftpPaneFiles } from "./hooks/useSftpPaneFiles";
|
||||
@@ -33,7 +33,6 @@ 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";
|
||||
@@ -118,7 +117,6 @@ 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');
|
||||
@@ -197,10 +195,7 @@ 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.map((b) => ({ ...b, global: false as const })),
|
||||
],
|
||||
() => [...globalBookmarks.bookmarks.map((b) => ({ ...b, global: true as const })), ...hostBookmarks.bookmarks],
|
||||
[hostBookmarks.bookmarks, globalBookmarks.bookmarks],
|
||||
);
|
||||
const isCurrentPathBookmarked = hostBookmarks.isCurrentPathBookmarked || globalBookmarks.isCurrentPathBookmarked;
|
||||
@@ -216,31 +211,11 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
}
|
||||
}, [hostBookmarks, globalBookmarks, pane.connection?.currentPath]);
|
||||
const deleteBookmark = useCallback(
|
||||
(bookmark: SftpBookmark) => {
|
||||
if (bookmark.global) {
|
||||
globalBookmarks.deleteBookmark(bookmark.id);
|
||||
(id: string) => {
|
||||
if (id.startsWith("gbm-")) {
|
||||
globalBookmarks.deleteBookmark(id);
|
||||
} else {
|
||||
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.deleteBookmark(id);
|
||||
}
|
||||
},
|
||||
[hostBookmarks, globalBookmarks],
|
||||
@@ -401,7 +376,6 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
isActive,
|
||||
enabled: viewMode === 'list',
|
||||
sortedDisplayFiles,
|
||||
layoutKey: listDensity,
|
||||
});
|
||||
|
||||
const toFullPath = useCallback(
|
||||
@@ -600,8 +574,6 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
isCurrentPathGlobalBookmarked={globalBookmarks.isCurrentPathBookmarked}
|
||||
onNavigateToBookmark={callbacks.onNavigateTo}
|
||||
onDeleteBookmark={deleteBookmark}
|
||||
onReorderBookmark={reorderBookmark}
|
||||
onRenameBookmark={renameBookmark}
|
||||
showHiddenFiles={pane.showHiddenFiles}
|
||||
onToggleShowHiddenFiles={onToggleShowHiddenFiles}
|
||||
onGoToTerminalCwd={onGoToTerminalCwd}
|
||||
@@ -610,8 +582,6 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
onToggleFollowTerminalCwd={onToggleFollowTerminalCwd}
|
||||
viewMode={viewMode}
|
||||
onSetViewMode={handleSetViewMode}
|
||||
listDensity={listDensity}
|
||||
onSetListDensity={setListDensity}
|
||||
onListDrives={callbacks.onListDrives}
|
||||
/>
|
||||
|
||||
@@ -701,7 +671,6 @@ 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, moveSftpBookmark, renameSftpBookmark } from "../../../application/state/sftp/bookmarkHelpers";
|
||||
import { createSftpBookmark } from "../../../application/state/sftp/bookmarkHelpers";
|
||||
|
||||
interface UseGlobalSftpBookmarksParams {
|
||||
currentPath: string | undefined;
|
||||
@@ -34,20 +34,10 @@ 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, moveSftpBookmark, renameSftpBookmark } from "../../../application/state/sftp/bookmarkHelpers";
|
||||
import { createSftpBookmark } from "../../../application/state/sftp/bookmarkHelpers";
|
||||
|
||||
interface UseSftpBookmarksParams {
|
||||
host: Host | undefined;
|
||||
@@ -13,8 +13,6 @@ 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 = ({
|
||||
@@ -54,26 +52,10 @@ 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,7 +5,6 @@ interface UseSftpPaneVirtualListParams {
|
||||
isActive: boolean;
|
||||
enabled?: boolean;
|
||||
sortedDisplayFiles: SftpFileEntry[];
|
||||
layoutKey?: string;
|
||||
}
|
||||
|
||||
interface UseSftpPaneVirtualListResult {
|
||||
@@ -21,7 +20,6 @@ export const useSftpPaneVirtualList = ({
|
||||
isActive,
|
||||
enabled = true,
|
||||
sortedDisplayFiles,
|
||||
layoutKey,
|
||||
}: UseSftpPaneVirtualListParams): UseSftpPaneVirtualListResult => {
|
||||
const fileListRef = useRef<HTMLDivElement>(null);
|
||||
const [rowHeight, setRowHeight] = useState(0);
|
||||
@@ -41,7 +39,7 @@ export const useSftpPaneVirtualList = ({
|
||||
resizeObserver.disconnect();
|
||||
window.cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [enabled, isActive, layoutKey, sortedDisplayFiles.length]);
|
||||
}, [enabled, isActive, sortedDisplayFiles.length]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = fileListRef.current;
|
||||
@@ -57,7 +55,7 @@ export const useSftpPaneVirtualList = ({
|
||||
}
|
||||
});
|
||||
return () => window.cancelAnimationFrame(raf);
|
||||
}, [enabled, isActive, layoutKey, rowHeight, sortedDisplayFiles.length]);
|
||||
}, [enabled, isActive, 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">
|
||||
{onToggleBroadcast && (
|
||||
{inWorkspace && 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'\) \{\s*syncOutputPressureVisibility\(\);\s*return;\s*\}\s*recoverTerminalOnAppResume\(\);\s*\};/,
|
||||
/const handleVisibilityChange = \(\) => \{\s*if \(document\.visibilityState !== 'visible'\) return;\s*recoverTerminalOnAppResume\(\);\s*\};/,
|
||||
);
|
||||
assert.match(
|
||||
resumeEffectSource,
|
||||
|
||||
@@ -15,10 +15,6 @@ import {
|
||||
import { extractDropEntries, type DropEntry } from "../../../lib/sftpFileUtils";
|
||||
import type { Host, TerminalSession } from "../../../types";
|
||||
import { resolveSftpReuseSourceSessionId } from "../../../application/state/terminalConnectionReuse";
|
||||
import {
|
||||
resolveTerminalDropSftpHost,
|
||||
TerminalDropNeedsSudoError,
|
||||
} from "../../../domain/sftpDropElevation";
|
||||
import { toast } from "../../ui/toast";
|
||||
import {
|
||||
extractRootPathsFromDropEntries,
|
||||
@@ -27,10 +23,6 @@ import {
|
||||
|
||||
interface UseTerminalDragDropOptions {
|
||||
host: Host;
|
||||
/** Password already resolved through host auth (host or Keychain identity). */
|
||||
resolvedSudoPassword?: string;
|
||||
/** Login username already resolved through host auth (host or Keychain identity). */
|
||||
resolvedLoginUsername?: string;
|
||||
isLocalConnection: boolean;
|
||||
isNetworkDevice?: boolean;
|
||||
onOpenSftp?: TerminalProps["onOpenSftp"];
|
||||
@@ -80,46 +72,12 @@ export function resolveTerminalDropErrorMessage(
|
||||
if (error instanceof ActiveTerminalCwdUnavailableError) {
|
||||
return t("terminal.dragDrop.destinationUnknown");
|
||||
}
|
||||
if (error instanceof TerminalDropNeedsSudoError) {
|
||||
return t("terminal.dragDrop.needsSudoElevation");
|
||||
}
|
||||
if (error instanceof Error && error.message === "No files to upload") {
|
||||
return t("terminal.dragDrop.noFiles");
|
||||
}
|
||||
return t("terminal.dragDrop.errorMessage");
|
||||
}
|
||||
|
||||
async function openSftpForTerminalDrop({
|
||||
dropEntries,
|
||||
host,
|
||||
onOpenSftp,
|
||||
resolveSftpInitialPath,
|
||||
resolvedLoginUsername,
|
||||
resolvedSudoPassword,
|
||||
sessionId,
|
||||
}: {
|
||||
dropEntries: DropEntry[];
|
||||
host: Host;
|
||||
onOpenSftp: NonNullable<UseTerminalDragDropOptions["onOpenSftp"]>;
|
||||
resolveSftpInitialPath: UseTerminalDragDropOptions["resolveSftpInitialPath"];
|
||||
resolvedLoginUsername?: string;
|
||||
resolvedSudoPassword?: string;
|
||||
sessionId: string;
|
||||
}): Promise<void> {
|
||||
const initialPath = await resolveTerminalDropUploadInitialPath(resolveSftpInitialPath);
|
||||
const uploadHost = resolveTerminalDropSftpHost(host, initialPath, {
|
||||
password: resolvedSudoPassword ?? host.password,
|
||||
username: resolvedLoginUsername ?? host.username,
|
||||
});
|
||||
onOpenSftp(
|
||||
uploadHost,
|
||||
initialPath,
|
||||
dropEntries,
|
||||
sessionId,
|
||||
resolveSftpReuseSourceSessionId(host, sessionId),
|
||||
);
|
||||
}
|
||||
|
||||
export async function resolveTerminalDropUploadInitialPath(
|
||||
resolveSftpInitialPath: UseTerminalDragDropOptions["resolveSftpInitialPath"],
|
||||
): Promise<string | undefined> {
|
||||
@@ -197,8 +155,6 @@ export async function handleTerminalDropEntries({
|
||||
isNetworkDevice = false,
|
||||
onOpenSftp,
|
||||
resolveSftpInitialPath,
|
||||
resolvedLoginUsername,
|
||||
resolvedSudoPassword,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
sessionId,
|
||||
sessionRef,
|
||||
@@ -209,8 +165,6 @@ export async function handleTerminalDropEntries({
|
||||
}: Pick<
|
||||
UseTerminalDragDropOptions,
|
||||
| "host"
|
||||
| "resolvedLoginUsername"
|
||||
| "resolvedSudoPassword"
|
||||
| "isLocalConnection"
|
||||
| "isNetworkDevice"
|
||||
| "onOpenSftp"
|
||||
@@ -252,15 +206,14 @@ export async function handleTerminalDropEntries({
|
||||
&& onOpenSftp
|
||||
&& supportsZmodemDragDropSftpFallback(host)
|
||||
) {
|
||||
await openSftpForTerminalDrop({
|
||||
dropEntries,
|
||||
const initialPath = await resolveTerminalDropUploadInitialPath(resolveSftpInitialPath);
|
||||
onOpenSftp(
|
||||
host,
|
||||
onOpenSftp,
|
||||
resolveSftpInitialPath,
|
||||
resolvedLoginUsername,
|
||||
resolvedSudoPassword,
|
||||
initialPath,
|
||||
dropEntries,
|
||||
sessionId,
|
||||
});
|
||||
resolveSftpReuseSourceSessionId(host, sessionId),
|
||||
);
|
||||
} else if (supportsZmodemTerminalDragDrop(host, isNetworkDevice)) {
|
||||
const files = await buildZmodemDragDropFiles(dropEntries);
|
||||
if (files.length === 0) {
|
||||
@@ -307,35 +260,29 @@ export async function handleTerminalDropEntries({
|
||||
const fallbackResult = rzMissingWatcher ? await rzMissingWatcher.promise : "detected";
|
||||
if (fallbackResult === "missing" || fallbackResult === "timeout") {
|
||||
terminalBackend.cancelZmodem?.(sessionId, { interrupt: fallbackResult === "timeout" });
|
||||
if (onOpenSftp) {
|
||||
await openSftpForTerminalDrop({
|
||||
dropEntries,
|
||||
host,
|
||||
onOpenSftp,
|
||||
resolveSftpInitialPath,
|
||||
resolvedLoginUsername,
|
||||
resolvedSudoPassword,
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
const initialPath = await resolveTerminalDropUploadInitialPath(resolveSftpInitialPath);
|
||||
onOpenSftp?.(
|
||||
host,
|
||||
initialPath,
|
||||
dropEntries,
|
||||
sessionId,
|
||||
resolveSftpReuseSourceSessionId(host, sessionId),
|
||||
);
|
||||
}
|
||||
} else if (onOpenSftp) {
|
||||
await openSftpForTerminalDrop({
|
||||
dropEntries,
|
||||
const initialPath = await resolveTerminalDropUploadInitialPath(resolveSftpInitialPath);
|
||||
onOpenSftp(
|
||||
host,
|
||||
onOpenSftp,
|
||||
resolveSftpInitialPath,
|
||||
resolvedLoginUsername,
|
||||
resolvedSudoPassword,
|
||||
initialPath,
|
||||
dropEntries,
|
||||
sessionId,
|
||||
});
|
||||
resolveSftpReuseSourceSessionId(host, sessionId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function useTerminalDragDrop({
|
||||
host,
|
||||
resolvedLoginUsername,
|
||||
resolvedSudoPassword,
|
||||
isLocalConnection,
|
||||
isNetworkDevice = false,
|
||||
onOpenSftp,
|
||||
@@ -399,8 +346,6 @@ export function useTerminalDragDrop({
|
||||
await handleTerminalDropEntries({
|
||||
dropEntries,
|
||||
host,
|
||||
resolvedLoginUsername,
|
||||
resolvedSudoPassword,
|
||||
isLocalConnection,
|
||||
isNetworkDevice,
|
||||
onOpenSftp,
|
||||
|
||||
@@ -6,11 +6,7 @@ import type { SerializeAddon as SerializeAddonType } from "@xterm/addon-serializ
|
||||
import type { Terminal as XTermType } from "@xterm/xterm";
|
||||
|
||||
import { writeTerminalDataWithLineTimestamps } from "./runtime/terminalLineTimestamps.ts";
|
||||
import {
|
||||
noteTerminalOutputPressureData,
|
||||
setTerminalOutputPressureVisibility,
|
||||
} from "./runtime/terminalOutputPressure.ts";
|
||||
import { writeTerminalPayloadChunked } from "./terminalReplay.ts";
|
||||
import { noteTerminalOutputPressureData } from "./runtime/terminalOutputPressure.ts";
|
||||
import { KeywordHighlighter } from "./keywordHighlight.ts";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
@@ -247,27 +243,6 @@ test("bulk output skipped on the hot path is highlighted after output becomes qu
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("streaming bulk output colors the viewport in the same write", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 50 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
// More than BULK_WRITE_LINE_BREAKS newlines per chunk, but far below the
|
||||
// large-output rate, like a coalesced `tail -f` tick (#3271).
|
||||
const chunk = Array.from({ length: 12 }, (_, index) => `line-${index} ERROR`).join("\r\n");
|
||||
await write(term, chunk);
|
||||
assert.equal(highlighter.rebuildCount, 0);
|
||||
const viewportY = term.buffer.active.viewportY;
|
||||
assert.equal(cellRgb(term, viewportY + 4, "ERROR"), RED, "visible rows must color in-frame");
|
||||
assert.notEqual(cellRgb(term, 0, "ERROR"), RED, "history above the viewport stays with catch-up");
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
await highlighter.whenSettled();
|
||||
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), []);
|
||||
assert.equal(highlighter.rebuildCount, 1);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("ordinary Enter does not rebuild a saturated scrollback", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 40, rows: 3, scrollback: 10 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
@@ -1064,9 +1039,7 @@ test("quiet catch-up refreshes a tall viewport once", async () => {
|
||||
await write(term, flood);
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
await highlighter.whenSettled();
|
||||
// One refresh paints the viewport in-frame inside the bulk write callback;
|
||||
// the second is the single catch-up viewport pass after output quiets.
|
||||
assert.equal(refreshCount, 2);
|
||||
assert.equal(refreshCount, 1);
|
||||
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), []);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
@@ -1091,7 +1064,7 @@ test("identical flood lines are all colored after quiet catch-up", async () => {
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("saturated streaming keeps the pinned viewport colored in-frame", async () => {
|
||||
test("saturated flood does not rematch the pinned viewport on each write", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 6, scrollback: 8 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
@@ -1103,56 +1076,8 @@ test("saturated streaming keeps the pinned viewport colored in-frame", async ()
|
||||
}
|
||||
assert.equal(highlighter.rebuildCount, 0);
|
||||
const viewportY = term.buffer.active.viewportY;
|
||||
// Bulk bypass still colors the visible rows inside the write callback, so
|
||||
// streaming logs never show an uncolored frame (#3271).
|
||||
assert.equal(cellRgb(term, viewportY, "ERROR"), RED);
|
||||
assert.equal(cellRgb(term, viewportY + 2, "ERROR"), RED);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("hidden panes keep bulk streaming on the deferred catch-up path", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 6, scrollback: 40 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
const line = "2026-08-13 INFO worker=1 WARN ERROR failed from 10.2.0.1";
|
||||
const chunk = Array.from({ length: 16 }, () => line).join("\r\n");
|
||||
// Below the 16KB/100ms flood gate, so without the background check the bulk
|
||||
// bypass would recolor and refresh the viewport for every hidden batch.
|
||||
setTerminalOutputPressureVisibility(term, false);
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
noteTerminalOutputPressureData(term, chunk);
|
||||
await write(term, chunk);
|
||||
}
|
||||
const viewportY = term.buffer.active.viewportY;
|
||||
assert.notEqual(cellRgb(term, viewportY, "ERROR"), RED);
|
||||
// Reveal the pane and let catch-up paint the deferred range.
|
||||
setTerminalOutputPressureVisibility(term, true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
await highlighter.whenSettled();
|
||||
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), []);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("high-rate flood keeps the viewport on the deferred catch-up path", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 6, scrollback: 8 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
const line = "2026-08-13 INFO worker=1 WARN ERROR failed from 10.2.0.1";
|
||||
// ~20KB inside the 100ms rate window trips the large-output degradation, so
|
||||
// the in-frame viewport repaint stays off and only catch-up colors.
|
||||
const chunk = Array.from({ length: 300 }, () => line).join("\r\n");
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
noteTerminalOutputPressureData(term, chunk);
|
||||
await write(term, chunk);
|
||||
}
|
||||
assert.equal(highlighter.rebuildCount, 0);
|
||||
const viewportY = term.buffer.active.viewportY;
|
||||
assert.notEqual(cellRgb(term, viewportY, "ERROR"), RED);
|
||||
await new Promise((resolve) => setTimeout(resolve, 900));
|
||||
await highlighter.whenSettled();
|
||||
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), []);
|
||||
assert.notEqual(cellRgb(term, viewportY + 2, "ERROR"), RED);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
@@ -1173,52 +1098,3 @@ test("recycled identical flood rows are recolored on catch-up", async () => {
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
|
||||
test("hibernate replay defers bulk viewport coloring but subsequent live output colors immediately", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 6, scrollback: 100 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
const chunk = Array.from({ length: 20 }, () => "history ERROR").join("\r\n");
|
||||
try {
|
||||
await writeTerminalPayloadChunked(term, chunk);
|
||||
assert.notEqual(cellRgb(term, term.buffer.active.viewportY, "ERROR"), RED);
|
||||
await highlighter.whenSettled();
|
||||
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), []);
|
||||
await write(term, "\r\n" + chunk);
|
||||
assert.equal(cellRgb(term, term.buffer.active.viewportY, "ERROR"), RED);
|
||||
} finally {
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test("bulk streaming does not recolor a scrolled-back history viewport", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 6, scrollback: 100 });
|
||||
let bypass = false;
|
||||
const highlighter = new KeywordHighlighter(term, { shouldBypassHighlight: () => bypass });
|
||||
highlighter.setRules(rule(), true);
|
||||
const chunk = Array.from({ length: 20 }, () => "log ERROR").join("\r\n") + "\r\n";
|
||||
try {
|
||||
await write(term, chunk);
|
||||
await highlighter.whenSettled();
|
||||
term.scrollToTop();
|
||||
let refreshes = 0;
|
||||
const refresh = term.refresh.bind(term);
|
||||
term.refresh = (start, end) => { refreshes += 1; refresh(start, end); };
|
||||
bypass = true;
|
||||
await write(term, chunk);
|
||||
const scrollRefreshes = refreshes;
|
||||
refreshes = 0;
|
||||
bypass = false;
|
||||
await write(term, chunk);
|
||||
assert.equal(refreshes, scrollRefreshes, "bulk callback must not add another history repaint");
|
||||
term.scrollToBottom();
|
||||
await write(term, chunk);
|
||||
assert.equal(cellRgb(term, term.buffer.active.viewportY, "ERROR"), RED);
|
||||
} finally {
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
}
|
||||
});
|
||||
@@ -5,13 +5,9 @@ import { isSafePluginDecorationPattern } from "../../domain/pluginTerminalProvid
|
||||
import { checkRegexSafetyPattern } from "../../lib/regexSafety";
|
||||
import type { KeywordHighlightRule } from "../../types";
|
||||
import { XTERM_PERFORMANCE_CONFIG } from "../../infrastructure/config/xtermPerformance";
|
||||
import { isTerminalReplayWrite } from "./terminalReplay";
|
||||
import { readPluginTerminalBufferText } from "./pluginTerminalBufferText";
|
||||
import { compileRe2RangeMatcher, forEachNonEmptyRegexMatch } from "./keywordHighlightRegex";
|
||||
import {
|
||||
isTerminalOutputInBackground,
|
||||
shouldDegradeTerminalKeywordHighlight,
|
||||
} from "./runtime/terminalOutputPressure";
|
||||
import { shouldDegradeTerminalKeywordHighlight } from "./runtime/terminalOutputPressure";
|
||||
|
||||
type RuntimeKeywordHighlightRule = KeywordHighlightRule & { readonly providerId?: string };
|
||||
|
||||
@@ -400,43 +396,12 @@ export class KeywordHighlighter implements IDisposable {
|
||||
? this.resolveAbsoluteRepaintRange(absoluteControls, originModeNeedsSafety)
|
||||
: null;
|
||||
const bypass = !startedOnNormal || this.shouldBypassWrite(data);
|
||||
// Freeze in-frame eligibility at entry: the pressure window is a rolling
|
||||
// sample, and by the time this write's callback runs (xterm parses in a
|
||||
// macrotask while backlogged) later chunks may have aged it below the
|
||||
// threshold. Re-checking inside the callback would let a write that took
|
||||
// the flood path repaint the viewport anyway, defeating the deferred path.
|
||||
// Pane visibility is the exception: it can flip while this write is still
|
||||
// in flight (pane/page shown between entry and callback), so it is
|
||||
// re-evaluated live in the callback below instead of frozen here.
|
||||
const colorDuringBypass = bypass && this.mayColorViewportDuringBypass(data);
|
||||
if (bypass) {
|
||||
if (startedOnNormal && (this.enabled || this.compiledPatterns.length > 0 || this.hasPendingCatchUp())) {
|
||||
this.markCatchUp(absoluteRepaintRange === null ? startY : Math.min(startY, startBaseY));
|
||||
this.scheduleCatchUp();
|
||||
}
|
||||
return this.originalWrite(data, () => {
|
||||
// Bulk writes skip per-write coloring so xterm can keep painting the
|
||||
// flood, but the viewport itself must not sit uncolored until the
|
||||
// catch-up timer fires (#3271). Recolor the visible rows here, inside
|
||||
// the write callback: xterm parses in a macrotask and renders in the
|
||||
// next rAF, so these colors land in the same frame as the text. The
|
||||
// cost is O(viewport rows × rules), independent of chunk size, and
|
||||
// true rate/long-line floods keep the fully deferred path. Rows that
|
||||
// scrolled past the viewport stay with the deferred catch-up.
|
||||
if (
|
||||
this.term.buffer.active.type === "normal"
|
||||
&& this.compiledPatterns.length > 0
|
||||
&& colorDuringBypass
|
||||
// Visibility may have changed since this write started (hidden pane
|
||||
// shown before the async xterm parse finished): the entry snapshot
|
||||
// only froze the flood/rate decision, so check the live state here.
|
||||
&& !isTerminalOutputInBackground(this.term)
|
||||
// Appended logs should not add another repaint while reading history.
|
||||
// Scroll/reveal handlers retain their existing recoloring behavior.
|
||||
&& this.term.buffer.active.viewportY === this.term.buffer.active.baseY
|
||||
) {
|
||||
this.recolorVisible();
|
||||
}
|
||||
if (this.term.buffer.active.type === "normal" && !startedOnNormal) {
|
||||
if (this.enabled || this.compiledPatterns.length > 0) {
|
||||
this.markCatchUp(0);
|
||||
@@ -727,23 +692,6 @@ export class KeywordHighlighter implements IDisposable {
|
||||
return this.countNewlines(data) >= BULK_WRITE_LINE_BREAKS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming logs usually trip only the bulk line-break heuristic (coalesced
|
||||
* PTY ticks), which is cheap to color in-frame: rematching the visible
|
||||
* viewport costs O(viewport rows × rules) per write, independent of chunk
|
||||
* size. Rate/long-line floods and explicit full-bypass requests keep the
|
||||
* deferred catch-up so xterm keeps painting the dump smoothly.
|
||||
*
|
||||
* This is the frozen flood/rate decision only: pane visibility is evaluated
|
||||
* live in the write callback (`isTerminalOutputInBackground`) because it can
|
||||
* flip between write entry and the asynchronous callback.
|
||||
*/
|
||||
private mayColorViewportDuringBypass(data: string | Uint8Array): boolean {
|
||||
if (this.options.shouldBypassHighlight?.()) return false;
|
||||
if (typeof data !== "string" || isTerminalReplayWrite(this.term)) return false;
|
||||
return !shouldDegradeTerminalKeywordHighlight(this.term, data);
|
||||
}
|
||||
|
||||
private countNewlines(data: string): number {
|
||||
let count = 0;
|
||||
for (let index = 0; index < data.length; index += 1) {
|
||||
|
||||
@@ -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 keeps timestamp anchors for preserved scrollback when reusing a terminal", async () => {
|
||||
test("local session resets terminal timestamp state when reusing a terminal", async () => {
|
||||
const writes: string[] = [];
|
||||
const markerLines: number[] = [];
|
||||
const disposedMarkerLines: number[] = [];
|
||||
@@ -3814,10 +3814,8 @@ test("local session keeps timestamp anchors for preserved scrollback when reusin
|
||||
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, []);
|
||||
assert.deepEqual(disposedMarkerLines, [0]);
|
||||
});
|
||||
|
||||
test("session data waits for prior terminal writes before evaluating prompt line breaks", async () => {
|
||||
|
||||
@@ -82,7 +82,6 @@ import {
|
||||
createKittyKeyboardBroadcastHandler,
|
||||
flushKittyKeyboardBroadcastReleases,
|
||||
registerKittyKeyboardBroadcastHandler,
|
||||
resolveWin32InputLogicalData,
|
||||
upsertKittyKeyboardForwardedPress,
|
||||
type KittyKeyboardBroadcastInput,
|
||||
type KittyKeyboardForwardedPress,
|
||||
@@ -92,7 +91,6 @@ import { terminalAltKeyOptions } from "./altKeyOptions";
|
||||
import { optionArrowWordJumpSequence } from "./optionArrowWordJump";
|
||||
import { optionYankLastArgSequence } from "./optionYankLastArg";
|
||||
import { watchDevicePixelRatio } from "./rendererDprWatch";
|
||||
import { dispatchWin32InputModeEvent } from "./win32InputMode";
|
||||
import { shouldDeferWebglUntilVisible } from "./webglRendererPolicy";
|
||||
import { createWebglRendererController } from "./webglRendererController";
|
||||
import {
|
||||
@@ -104,7 +102,7 @@ import { handleSerialLineModeInput } from "./serialLineInput";
|
||||
import {
|
||||
doesKittyEncodingPreserveShiftEnter,
|
||||
getShiftEnterSubmittedInput,
|
||||
resolveShiftEnterText,
|
||||
resolveShiftEnterPayload,
|
||||
shouldSendShiftEnterText,
|
||||
} from "./shiftEnterText";
|
||||
import {
|
||||
@@ -550,12 +548,6 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
const term = new XTerm({
|
||||
...performanceConfig.options,
|
||||
...(windowsPty ? { windowsPty } : {}),
|
||||
// ConPTY asks the frontend for Win32 INPUT_RECORD encoding with DECSET
|
||||
// 9001. Preserve browser key modifiers for native Windows applications
|
||||
// instead of collapsing them into legacy VT bytes.
|
||||
vtExtensions: {
|
||||
win32InputMode: windowsPty?.backend === "conpty",
|
||||
},
|
||||
// Override ignoreBracketedPasteMode if user explicitly disables bracketed paste
|
||||
ignoreBracketedPasteMode: settings?.disableBracketedPaste ?? performanceConfig.options.ignoreBracketedPasteMode,
|
||||
// Rescale glyphs that would visually overlap into the next cell (CJK compliance)
|
||||
@@ -1107,12 +1099,6 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
data: string,
|
||||
options?: {
|
||||
source?: "terminal" | "shift-enter" | "kitty";
|
||||
/**
|
||||
* User-facing input represented by a transport-specific wire payload.
|
||||
* `null` means the payload has no text/editing semantics (for example a
|
||||
* Win32 key-up record). The wire `data` is still written unchanged.
|
||||
*/
|
||||
logicalData?: string | null;
|
||||
/** Skip string broadcast when peers will re-resolve from a key chord. */
|
||||
skipBroadcast?: boolean;
|
||||
/**
|
||||
@@ -1131,16 +1117,11 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
// cause the executed command to fail (#3138).
|
||||
data = sanitizeTerminalInput(data);
|
||||
if (!data) return;
|
||||
const logicalData = options?.logicalData === null
|
||||
? null
|
||||
: sanitizeTerminalInput(options?.logicalData ?? data);
|
||||
|
||||
// Clipboard paste / typed password while assist is open must dismiss the
|
||||
// hint first. Otherwise Enter is still hijacked for confirmFill and can
|
||||
// append the host session password after the user's pasted secret (#2198).
|
||||
if (logicalData) {
|
||||
ctx.sudoAutofillRef?.current?.dismissOnUserContentInput(logicalData);
|
||||
}
|
||||
ctx.sudoAutofillRef?.current?.dismissOnUserContentInput(data);
|
||||
|
||||
const inputSource = options?.source ?? "terminal";
|
||||
const id = ctx.sessionRef.current;
|
||||
@@ -1148,18 +1129,13 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
const sensitive = ctx.passwordPromptActiveRef?.current === true;
|
||||
let handledSubmittedInput = false;
|
||||
const submittedInput: { text: string; lineEnding: "\r\n" | "\r" | "\n" } | null =
|
||||
logicalData === null
|
||||
? null
|
||||
: inputSource === "shift-enter"
|
||||
? getShiftEnterSubmittedInput(logicalData)
|
||||
: logicalData === "\r" || logicalData === "\n"
|
||||
? { text: "", lineEnding: logicalData as "\r" | "\n" }
|
||||
inputSource === "shift-enter"
|
||||
? getShiftEnterSubmittedInput(data)
|
||||
: data === "\r" || data === "\n"
|
||||
? { text: "", lineEnding: data as "\r" | "\n" }
|
||||
: null;
|
||||
const onBroadcastInput = ctx.onBroadcastInputRef.current;
|
||||
const broadcastDataBeforeSudo = mapTerminalBackspaceInput(
|
||||
logicalData ?? "",
|
||||
ctx.host.backspaceBehavior,
|
||||
);
|
||||
const broadcastDataBeforeSudo = mapTerminalBackspaceInput(data, ctx.host.backspaceBehavior);
|
||||
const suppressTerminalBroadcast = inputSource === "terminal" && suppressNextTerminalDataBroadcast;
|
||||
if (suppressTerminalBroadcast) suppressNextTerminalDataBroadcast = false;
|
||||
// skipBroadcast only suppresses the raw-string fan-out. Peers still receive
|
||||
@@ -1206,7 +1182,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
!handlingKittyBroadcast &&
|
||||
inputSource !== "shift-enter"
|
||||
) {
|
||||
const pastedCommand = logicalData === null ? null : getSinglePastedCommand(logicalData);
|
||||
const pastedCommand = getSinglePastedCommand(data);
|
||||
if (pastedCommand) {
|
||||
if (ctx.passwordPromptActiveRef) ctx.passwordPromptActiveRef.current = false;
|
||||
const recordedCommand = recordTerminalCommandExecution(
|
||||
@@ -1270,50 +1246,43 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
}
|
||||
}
|
||||
|
||||
// Use logical data for raw-string broadcast. Transport-specific wire
|
||||
// records are dispatched separately from their normalized key event.
|
||||
const broadcastData = mapTerminalBackspaceInput(
|
||||
logicalData ?? "",
|
||||
ctx.host.backspaceBehavior,
|
||||
);
|
||||
// Use remapped data so broadcast peers also receive the correct byte
|
||||
const broadcastData = mapTerminalBackspaceInput(dataToWrite, ctx.host.backspaceBehavior);
|
||||
if (willBroadcastInput) {
|
||||
onBroadcastInput?.(broadcastData, ctx.sessionId);
|
||||
}
|
||||
|
||||
if (
|
||||
logicalData !== null &&
|
||||
!shouldSuppressTerminalInputScrollForUserPaste(term, logicalData)
|
||||
) {
|
||||
scrollToBottomAfterInput(logicalData);
|
||||
if (!shouldSuppressTerminalInputScrollForUserPaste(term, data)) {
|
||||
scrollToBottomAfterInput(data);
|
||||
}
|
||||
|
||||
// Notify autocomplete of input
|
||||
if (logicalData !== null) ctx.onAutocompleteInput?.(logicalData);
|
||||
ctx.onAutocompleteInput?.(data);
|
||||
|
||||
if (ctx.statusRef.current === "connected" && logicalData !== null) {
|
||||
if (ctx.statusRef.current === "connected") {
|
||||
if (handledSubmittedInput || submittedInput) {
|
||||
// Command recording and sudo command preparation happen before the
|
||||
// input is written so sudo can receive a one-time prompt marker.
|
||||
} else if (logicalData === "\x7f" || logicalData === "\b") {
|
||||
} else if (data === "\x7f" || data === "\b") {
|
||||
ctx.commandBufferRef.current = ctx.commandBufferRef.current.slice(0, -1);
|
||||
ctx.scriptRecorderRef?.current?.recordBackspace();
|
||||
} else if (logicalData === "\x03") {
|
||||
} else if (data === "\x03") {
|
||||
ctx.commandBufferRef.current = "";
|
||||
ctx.scriptRecorderRef?.current?.recordClearLine();
|
||||
// Hard-abort password assist when Ctrl+C reaches the input path
|
||||
// (e.g. broadcast peers) so a later su re-arms cleanly (#2191).
|
||||
ctx.sudoAutofillRef?.current?.abort();
|
||||
} else if (logicalData === "\x15") {
|
||||
} else if (data === "\x15") {
|
||||
ctx.commandBufferRef.current = "";
|
||||
ctx.scriptRecorderRef?.current?.recordClearLine();
|
||||
} else if (logicalData.length === 1 && logicalData.charCodeAt(0) >= 32) {
|
||||
ctx.commandBufferRef.current += logicalData;
|
||||
ctx.scriptRecorderRef?.current?.recordInput(logicalData);
|
||||
} else if (logicalData.length > 1 && !logicalData.startsWith("\x1b")) {
|
||||
ctx.commandBufferRef.current += logicalData;
|
||||
ctx.scriptRecorderRef?.current?.recordInput(logicalData);
|
||||
} else if (data.length === 1 && data.charCodeAt(0) >= 32) {
|
||||
ctx.commandBufferRef.current += data;
|
||||
ctx.scriptRecorderRef?.current?.recordInput(data);
|
||||
} else if (data.length > 1 && !data.startsWith("\x1b")) {
|
||||
ctx.commandBufferRef.current += data;
|
||||
ctx.scriptRecorderRef?.current?.recordInput(data);
|
||||
} else {
|
||||
const pastedLine = getSingleBracketedPasteLine(logicalData);
|
||||
const pastedLine = getSingleBracketedPasteLine(data);
|
||||
if (pastedLine) {
|
||||
ctx.commandBufferRef.current += pastedLine;
|
||||
ctx.scriptRecorderRef?.current?.recordInput(pastedLine);
|
||||
@@ -1327,14 +1296,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
let kittyCompositionClearTimer: number | undefined;
|
||||
let imeTextInputDeferredKey: string | null = null;
|
||||
let imeTextInputDeferredKittyEvent: KittyKeyboardEvent | null = null;
|
||||
let win32InputModePendingEvent: {
|
||||
event: KittyKeyboardEvent;
|
||||
logicalData: string | null;
|
||||
} | null = null;
|
||||
const win32InputModeForwardedKeys = new Map<string, KittyKeyboardForwardedPress>();
|
||||
const kittyForwardedKeys = new Map<string, KittyKeyboardForwardedPress>();
|
||||
const broadcastForwardedKeys = new Map<string, KittyKeyboardForwardedPress>();
|
||||
const win32BroadcastForwardedKeys = new Map<string, KittyKeyboardForwardedPress>();
|
||||
const broadcastEncodedKeys = new Set<string>();
|
||||
const broadcastLegacySuppressedKeys = new Set<string>();
|
||||
const kittyKeyIdentity = (event: KeyboardEvent): string => event.code || event.key;
|
||||
@@ -1394,7 +1357,6 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
if (
|
||||
isUnchangedDeferredImeTextInput(deferredKey, text) &&
|
||||
deferredKittyEvent &&
|
||||
!term.modes.win32InputMode &&
|
||||
kittyKeyboardProtocolEnabled
|
||||
) {
|
||||
const pressEvent: KittyKeyboardEvent = {
|
||||
@@ -1446,7 +1408,6 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
// on the text path, but still emits a paired :3 release on keyup.
|
||||
// Mirror the ordinary keydown handler so that release is not dropped.
|
||||
if (
|
||||
!term.modes.win32InputMode &&
|
||||
kittyKeyboardProtocolEnabled &&
|
||||
shouldTrackKittyKeyRelease(kittyKeyboardMode, pressEvent)
|
||||
) {
|
||||
@@ -1482,9 +1443,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
// Kitty escape sequences that bypass the handleTerminalInputData guard (#3138).
|
||||
const sanitizedText = sanitizeTerminalInput(text);
|
||||
if (!sanitizedText) return;
|
||||
const encoded = term.modes.win32InputMode
|
||||
? null
|
||||
: encodeKittyCompositionText(kittyKeyboardMode, sanitizedText);
|
||||
const encoded = encodeKittyCompositionText(kittyKeyboardMode, sanitizedText);
|
||||
if (encoded) {
|
||||
handleTerminalInputData(encoded, { source: "kitty" });
|
||||
} else {
|
||||
@@ -1532,7 +1491,6 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
);
|
||||
}
|
||||
if (!kittyForwardedKeys.delete(identity)) return false;
|
||||
if (term.modes.win32InputMode) return false;
|
||||
const sequence = kittyKeyboardProtocolEnabled
|
||||
? encodeKittyKeyEvent(kittyKeyboardMode, event)
|
||||
: null;
|
||||
@@ -1620,27 +1578,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
}
|
||||
}
|
||||
const identity = kittyKeyIdentity(releaseEvent);
|
||||
const hasForwardedWin32KeyDown = win32InputModeForwardedKeys.delete(identity);
|
||||
if (broadcastLegacyDataPending === identity) clearBroadcastLegacyDataPending();
|
||||
if (term.modes.win32InputMode) {
|
||||
// Broadcast peers may still need a paired Kitty release for a keydown
|
||||
// consumed by a Netcatty action (notably the urgent Ctrl+C path).
|
||||
releaseForwardedKittyPress(toKittyKeyboardEvent(releaseEvent));
|
||||
kittyForwardedKeys.delete(identity);
|
||||
// Only let xterm emit a Win32 key-up when its matching keydown was
|
||||
// previously handed to xterm. Netcatty shortcuts, sudo controls and
|
||||
// autocomplete consume their keydown and must not leak an orphaned
|
||||
// native release into the PTY.
|
||||
if (!hasForwardedWin32KeyDown) {
|
||||
win32InputModePendingEvent = null;
|
||||
return false;
|
||||
}
|
||||
win32InputModePendingEvent = {
|
||||
event: toKittyKeyboardEvent(releaseEvent),
|
||||
logicalData: null,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
if (releaseForwardedKittyPress(toKittyKeyboardEvent(releaseEvent))) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -1808,7 +1746,6 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
}
|
||||
|
||||
const kittySequenceForKeyDown =
|
||||
!term.modes.win32InputMode &&
|
||||
kittyKeyboardProtocolEnabled
|
||||
? encodeKittyKeyEvent(kittyKeyboardMode, toKittyKeyboardEvent(e))
|
||||
: null;
|
||||
@@ -1868,7 +1805,6 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
const kittyEvent = toKittyKeyboardEvent(e);
|
||||
const identity = kittyKeyIdentity(e);
|
||||
if (
|
||||
!term.modes.win32InputMode &&
|
||||
kittyKeyboardProtocolEnabled &&
|
||||
shouldTrackKittyKeyRelease(kittyKeyboardMode, kittyEvent)
|
||||
) {
|
||||
@@ -2035,15 +1971,11 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
return false;
|
||||
}
|
||||
|
||||
// ConPTY's negotiated Win32 input mode keeps the native modifier and owns
|
||||
// this event. Otherwise, before Kitty encoding that collapses Shift+Enter
|
||||
// to bare CR/LF (flags=0, or non-preserving sets like alternate-key /
|
||||
// associated-text alone), keep the configured send-text fallback. Only
|
||||
// negotiated preserving modes may emit CSI-u; alternate-screen activation
|
||||
// is not a capability signal.
|
||||
// Before Kitty encoding that collapses Shift+Enter to bare CR/LF (flags=0,
|
||||
// or non-preserving sets like alternate-key / associated-text alone). Remap
|
||||
// first so alternate-screen TUIs still receive CSI-u Shift+Enter.
|
||||
if (
|
||||
shouldSendShiftEnterText(e, ctx.terminalSettingsRef.current) &&
|
||||
!term.modes.win32InputMode &&
|
||||
!doesKittyEncodingPreserveShiftEnter(kittySequenceForKeyDown)
|
||||
) {
|
||||
const id = ctx.sessionRef.current;
|
||||
@@ -2051,16 +1983,24 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const kittyEvent = toKittyKeyboardEvent(e);
|
||||
const shiftEnterText = resolveShiftEnterText(
|
||||
// Local payload uses this session's buffer. Broadcast targets re-resolve
|
||||
// from the key chord via resolveKittyKeyboardBroadcastInput.
|
||||
const shiftEnterPayload = resolveShiftEnterPayload(
|
||||
ctx.terminalSettingsRef.current,
|
||||
{ alternateScreen: term.buffer.active.type === "alternate" },
|
||||
);
|
||||
if (shiftEnterText) {
|
||||
// Skip string broadcast: peers resolve Shift+Enter from their own
|
||||
// negotiated keyboard mode via the key chord below.
|
||||
handleTerminalInputData(shiftEnterText, {
|
||||
source: "shift-enter",
|
||||
skipBroadcast: true,
|
||||
});
|
||||
if (shiftEnterPayload.data) {
|
||||
if (shiftEnterPayload.kind === "key") {
|
||||
// Local CSI-u as kitty-sourced input so raw bytes are not broadcast.
|
||||
handleTerminalInputData(shiftEnterPayload.data, { source: "kitty" });
|
||||
} else {
|
||||
// Skip string broadcast: peers resolve Shift+Enter from their own
|
||||
// buffer + keyboard mode via the key chord below.
|
||||
handleTerminalInputData(shiftEnterPayload.data, {
|
||||
source: "shift-enter",
|
||||
skipBroadcast: true,
|
||||
});
|
||||
}
|
||||
const forwarded = broadcastKittyInput({
|
||||
kind: "key",
|
||||
event: kittyEvent,
|
||||
@@ -2147,19 +2087,6 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
}
|
||||
|
||||
const normalizedKittyEvent = toKittyKeyboardEvent(e);
|
||||
if (term.modes.win32InputMode) {
|
||||
// The following xterm onData callback carries the lossless record.
|
||||
// Keep its normalized event alongside it for non-Win32 broadcast peers
|
||||
// and for local command/autocomplete bookkeeping.
|
||||
win32InputModePendingEvent = {
|
||||
event: normalizedKittyEvent,
|
||||
logicalData: resolveWin32InputLogicalData(
|
||||
normalizedKittyEvent,
|
||||
term.modes.applicationCursorKeysMode,
|
||||
),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
if (!shouldDeferKittyKeyEvent(normalizedKittyEvent)) {
|
||||
const identity = kittyKeyIdentity(e);
|
||||
if (
|
||||
@@ -2259,7 +2186,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
kittyCompositionClearTimer = undefined;
|
||||
}
|
||||
if (
|
||||
(!term.modes.win32InputMode && shouldEncodeKittyCompositionText(kittyKeyboardMode)) ||
|
||||
shouldEncodeKittyCompositionText(kittyKeyboardMode) ||
|
||||
(ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current)
|
||||
) {
|
||||
kittyCompositionPending = true;
|
||||
@@ -2278,30 +2205,16 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
kittyCompositionClearTimer = undefined;
|
||||
}, 0);
|
||||
};
|
||||
const writeWin32InputModeEvent = (
|
||||
event: KittyKeyboardEvent,
|
||||
logicalData: string | null,
|
||||
) => {
|
||||
const pending = { event, logicalData };
|
||||
win32InputModePendingEvent = pending;
|
||||
dispatchWin32InputModeEvent(term, event);
|
||||
if (win32InputModePendingEvent === pending) {
|
||||
win32InputModePendingEvent = null;
|
||||
}
|
||||
};
|
||||
const clearKittyConnectionInputState = () => {
|
||||
// Drop deferred IME punctuation on session reset; do not write into the
|
||||
// next connection. Focus loss uses clearKittyTransientInputState instead.
|
||||
clearImeTextInputDeferral();
|
||||
kittyCompositionPending = false;
|
||||
win32InputModePendingEvent = null;
|
||||
win32InputModeForwardedKeys.clear();
|
||||
kittyForwardedKeys.clear();
|
||||
clearKittyKeyboardBroadcastPairingState(
|
||||
broadcastEncodedKeys,
|
||||
broadcastLegacySuppressedKeys,
|
||||
);
|
||||
win32BroadcastForwardedKeys.clear();
|
||||
clearBroadcastLegacyDataPending();
|
||||
if (kittyCompositionClearTimer !== undefined) {
|
||||
window.clearTimeout(kittyCompositionClearTimer);
|
||||
@@ -2310,40 +2223,17 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
};
|
||||
const clearKittyTransientInputState = () => {
|
||||
flushImeTextInputDeferral();
|
||||
if (term.modes.win32InputMode) {
|
||||
// Browsers do not deliver key-up after focus leaves the terminal. Release
|
||||
// every Win32 key that xterm actually handed to ConPTY so native TUI key
|
||||
// state cannot remain stuck after switching tabs or windows.
|
||||
flushKittyKeyboardBroadcastReleases(
|
||||
win32InputModeForwardedKeys,
|
||||
(input) => {
|
||||
if (input.kind === "key") {
|
||||
writeWin32InputModeEvent(input.event, null);
|
||||
}
|
||||
},
|
||||
kittyKeyboardLockState,
|
||||
);
|
||||
kittyForwardedKeys.clear();
|
||||
win32InputModePendingEvent = null;
|
||||
} else {
|
||||
win32InputModeForwardedKeys.clear();
|
||||
flushKittyKeyboardBroadcastReleases(
|
||||
kittyForwardedKeys,
|
||||
(input) => {
|
||||
if (input.kind !== "key" || !kittyKeyboardProtocolEnabled) return;
|
||||
const sequence = encodeKittyKeyEvent(kittyKeyboardMode, input.event);
|
||||
if (sequence) handleTerminalInputData(sequence, { source: "kitty" });
|
||||
},
|
||||
kittyKeyboardLockState,
|
||||
);
|
||||
}
|
||||
flushKittyKeyboardBroadcastReleases(
|
||||
broadcastForwardedKeys,
|
||||
broadcastKittyInput,
|
||||
kittyForwardedKeys,
|
||||
(input) => {
|
||||
if (input.kind !== "key" || !kittyKeyboardProtocolEnabled) return;
|
||||
const sequence = encodeKittyKeyEvent(kittyKeyboardMode, input.event);
|
||||
if (sequence) handleTerminalInputData(sequence, { source: "kitty" });
|
||||
},
|
||||
kittyKeyboardLockState,
|
||||
);
|
||||
flushKittyKeyboardBroadcastReleases(
|
||||
win32BroadcastForwardedKeys,
|
||||
broadcastForwardedKeys,
|
||||
broadcastKittyInput,
|
||||
kittyKeyboardLockState,
|
||||
);
|
||||
@@ -2373,56 +2263,6 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
textarea?.addEventListener("blur", clearKittyTransientInputState);
|
||||
|
||||
term.onData((data) => {
|
||||
const win32Input = win32InputModePendingEvent;
|
||||
if (
|
||||
win32Input &&
|
||||
term.modes.win32InputMode &&
|
||||
data.startsWith("\u001b[") &&
|
||||
/^\[\d+;\d+;\d+;[01];\d+;\d+_$/.test(data.slice(1))
|
||||
) {
|
||||
win32InputModePendingEvent = null;
|
||||
if (win32Input.event.type === "keydown") {
|
||||
upsertKittyKeyboardForwardedPress(
|
||||
win32InputModeForwardedKeys,
|
||||
win32Input.event.code || win32Input.event.key,
|
||||
win32Input.event,
|
||||
[],
|
||||
);
|
||||
}
|
||||
const identity = win32Input.event.code || win32Input.event.key;
|
||||
const broadcastInput: KittyKeyboardBroadcastInput = {
|
||||
kind: "win32",
|
||||
data,
|
||||
event: win32Input.event,
|
||||
fallbackToLegacy: true,
|
||||
};
|
||||
if (win32Input.event.type === "keyup") {
|
||||
const forwardedPress = win32BroadcastForwardedKeys.get(identity);
|
||||
win32BroadcastForwardedKeys.delete(identity);
|
||||
if (forwardedPress) {
|
||||
broadcastKittyInput(
|
||||
broadcastInput,
|
||||
true,
|
||||
forwardedPress.targetSessionIds,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const forwarded = broadcastKittyInput(broadcastInput);
|
||||
if (forwarded) {
|
||||
upsertKittyKeyboardForwardedPress(
|
||||
win32BroadcastForwardedKeys,
|
||||
identity,
|
||||
win32Input.event,
|
||||
forwarded.targetSessionIds,
|
||||
);
|
||||
}
|
||||
}
|
||||
handleTerminalInputData(data, {
|
||||
logicalData: win32Input.logicalData,
|
||||
skipBroadcast: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (kittyCompositionPending && !data.startsWith("\u001b")) {
|
||||
kittyCompositionPending = false;
|
||||
if (kittyCompositionClearTimer !== undefined) {
|
||||
@@ -2433,9 +2273,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
// Kitty escape sequences that bypass the handleTerminalInputData guard (#3138).
|
||||
const sanitizedData = sanitizeTerminalInput(data);
|
||||
if (!sanitizedData) return;
|
||||
const encoded = term.modes.win32InputMode
|
||||
? null
|
||||
: encodeKittyCompositionText(kittyKeyboardMode, sanitizedData);
|
||||
const encoded = encodeKittyCompositionText(kittyKeyboardMode, sanitizedData);
|
||||
if (encoded) {
|
||||
handleTerminalInputData(encoded, { source: "kitty" });
|
||||
} else {
|
||||
@@ -2480,7 +2318,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
applicationCursorMode: term.modes.applicationCursorKeysMode,
|
||||
encodedKeys: broadcastEncodedKeys,
|
||||
legacySuppressedKeys: broadcastLegacySuppressedKeys,
|
||||
win32InputMode: term.modes.win32InputMode,
|
||||
alternateScreen: term.buffer.active.type === "alternate",
|
||||
shiftEnterSettings: ctx.terminalSettingsRef.current,
|
||||
}),
|
||||
getSessionId: () => ctx.sessionRef.current,
|
||||
@@ -2495,13 +2333,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
mapTerminalBackspaceInput(data, ctx.host.backspaceBehavior),
|
||||
{ sensitive: ctx.passwordPromptActiveRef?.current === true },
|
||||
),
|
||||
writeActive: (data, logicalData) => handleTerminalInputData(data, {
|
||||
source: "kitty",
|
||||
logicalData,
|
||||
}),
|
||||
writeWin32Event: (event, logicalData) => {
|
||||
writeWin32InputModeEvent(event, logicalData);
|
||||
},
|
||||
writeActive: (data) => handleTerminalInputData(data, { source: "kitty" }),
|
||||
});
|
||||
registerKittyKeyboardBroadcastHandler(
|
||||
ctx.sessionId,
|
||||
|
||||
@@ -521,7 +521,7 @@ test("enhanced sources can fall back to reliable legacy data for plain targets",
|
||||
}
|
||||
});
|
||||
|
||||
test("Shift+Enter broadcast uses CSI-u only after the target negotiated a preserving mode", () => {
|
||||
test("Shift+Enter broadcast remaps from each target buffer when Kitty collapses the chord", () => {
|
||||
const alternateOnly = createKittyKeyboardModeState();
|
||||
setKittyKeyboardModeFlags(alternateOnly, 0b00100);
|
||||
const shiftEnter = {
|
||||
@@ -540,9 +540,10 @@ test("Shift+Enter broadcast uses CSI-u only after the target negotiated a preser
|
||||
kittyMode: alternateOnly,
|
||||
applicationCursorMode: false,
|
||||
encodedKeys: new Set<string>(),
|
||||
alternateScreen: true,
|
||||
}), {
|
||||
data: "\n",
|
||||
kittyEncoded: false,
|
||||
data: SHIFT_ENTER_CSI_U_SEQUENCE,
|
||||
kittyEncoded: true,
|
||||
urgentInterrupt: false,
|
||||
});
|
||||
|
||||
@@ -551,6 +552,7 @@ test("Shift+Enter broadcast uses CSI-u only after the target negotiated a preser
|
||||
kittyMode: createKittyKeyboardModeState(),
|
||||
applicationCursorMode: false,
|
||||
encodedKeys: new Set<string>(),
|
||||
alternateScreen: false,
|
||||
}), {
|
||||
data: "\n",
|
||||
kittyEncoded: false,
|
||||
@@ -564,6 +566,7 @@ test("Shift+Enter broadcast uses CSI-u only after the target negotiated a preser
|
||||
kittyMode: disambiguate,
|
||||
applicationCursorMode: false,
|
||||
encodedKeys: new Set<string>(),
|
||||
alternateScreen: true,
|
||||
}), {
|
||||
data: SHIFT_ENTER_CSI_U_SEQUENCE,
|
||||
kittyEncoded: true,
|
||||
@@ -571,178 +574,6 @@ test("Shift+Enter broadcast uses CSI-u only after the target negotiated a preser
|
||||
});
|
||||
});
|
||||
|
||||
test("Win32 input broadcast preserves native records for Win32 targets", () => {
|
||||
const shiftEnterRecord = "\u001b[13;28;13;1;16;1_";
|
||||
const shiftEnter = {
|
||||
kind: "win32" as const,
|
||||
data: shiftEnterRecord,
|
||||
fallbackToLegacy: true,
|
||||
event: {
|
||||
type: "keydown" as const,
|
||||
key: "Enter",
|
||||
code: "Enter",
|
||||
shiftKey: true,
|
||||
},
|
||||
};
|
||||
|
||||
const win32TargetOptions = {
|
||||
kittyProtocolEnabled: false,
|
||||
kittyMode: createKittyKeyboardModeState(),
|
||||
applicationCursorMode: false,
|
||||
encodedKeys: new Set<string>(),
|
||||
win32InputMode: true,
|
||||
};
|
||||
assert.deepEqual(resolveKittyKeyboardBroadcastInput(shiftEnter, win32TargetOptions), {
|
||||
data: shiftEnterRecord,
|
||||
kittyEncoded: false,
|
||||
urgentInterrupt: false,
|
||||
logicalData: null,
|
||||
});
|
||||
|
||||
const normalizedShiftEnter = {
|
||||
kind: "key" as const,
|
||||
fallbackToLegacy: true,
|
||||
event: shiftEnter.event,
|
||||
};
|
||||
const normalizedTargetOptions = {
|
||||
...win32TargetOptions,
|
||||
encodedKeys: new Set<string>(),
|
||||
};
|
||||
assert.deepEqual(
|
||||
resolveKittyKeyboardBroadcastInput(normalizedShiftEnter, normalizedTargetOptions),
|
||||
{
|
||||
data: "",
|
||||
kittyEncoded: false,
|
||||
urgentInterrupt: false,
|
||||
logicalData: null,
|
||||
win32Event: shiftEnter.event,
|
||||
},
|
||||
);
|
||||
|
||||
const disambiguate = createKittyKeyboardModeState();
|
||||
setKittyKeyboardModeFlags(disambiguate, 0b00001);
|
||||
assert.deepEqual(resolveKittyKeyboardBroadcastInput(shiftEnter, {
|
||||
kittyProtocolEnabled: true,
|
||||
kittyMode: disambiguate,
|
||||
applicationCursorMode: false,
|
||||
encodedKeys: new Set<string>(),
|
||||
win32InputMode: false,
|
||||
}), {
|
||||
data: SHIFT_ENTER_CSI_U_SEQUENCE,
|
||||
kittyEncoded: true,
|
||||
urgentInterrupt: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("Win32 input keeps plain Enter bookkeeping without misclassifying modified Enter", () => {
|
||||
const options = () => ({
|
||||
kittyProtocolEnabled: false,
|
||||
kittyMode: createKittyKeyboardModeState(),
|
||||
applicationCursorMode: false,
|
||||
encodedKeys: new Set<string>(),
|
||||
win32InputMode: true,
|
||||
});
|
||||
const resolveEnter = (modifiers: Partial<{
|
||||
shiftKey: boolean;
|
||||
altKey: boolean;
|
||||
ctrlKey: boolean;
|
||||
metaKey: boolean;
|
||||
}>) => resolveKittyKeyboardBroadcastInput({
|
||||
kind: "win32",
|
||||
data: "\u001b[13;28;13;1;0;1_",
|
||||
event: {
|
||||
type: "keydown",
|
||||
key: "Enter",
|
||||
code: "Enter",
|
||||
...modifiers,
|
||||
},
|
||||
}, options())?.logicalData;
|
||||
|
||||
assert.equal(resolveEnter({}), "\r");
|
||||
assert.equal(resolveEnter({ shiftKey: true }), null);
|
||||
assert.equal(resolveEnter({ ctrlKey: true }), null);
|
||||
assert.equal(resolveEnter({ altKey: true }), null);
|
||||
});
|
||||
|
||||
test("Win32 key-up broadcast remains a native release with no editing semantics", () => {
|
||||
const options = {
|
||||
kittyProtocolEnabled: true,
|
||||
kittyMode: createKittyKeyboardModeState(),
|
||||
applicationCursorMode: false,
|
||||
encodedKeys: new Set<string>(),
|
||||
win32InputMode: true,
|
||||
};
|
||||
assert.ok(resolveKittyKeyboardBroadcastInput({
|
||||
kind: "win32",
|
||||
data: "\u001b[13;28;13;1;16;1_",
|
||||
event: {
|
||||
type: "keydown",
|
||||
key: "Enter",
|
||||
code: "Enter",
|
||||
shiftKey: true,
|
||||
},
|
||||
}, options));
|
||||
const releaseRecord = "\u001b[13;28;13;0;16;1_";
|
||||
assert.deepEqual(resolveKittyKeyboardBroadcastInput({
|
||||
kind: "win32",
|
||||
data: releaseRecord,
|
||||
fallbackToLegacy: true,
|
||||
event: {
|
||||
type: "keyup",
|
||||
key: "Enter",
|
||||
code: "Enter",
|
||||
shiftKey: true,
|
||||
},
|
||||
}, options), {
|
||||
data: releaseRecord,
|
||||
kittyEncoded: false,
|
||||
urgentInterrupt: false,
|
||||
logicalData: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("Win32 broadcast targets encode normalized keys and release them during sensitive input", () => {
|
||||
const encodedKeys = new Set<string>();
|
||||
const delivered: Array<{ type?: string; logicalData: string | null }> = [];
|
||||
const writes: string[] = [];
|
||||
let sensitive = false;
|
||||
const handler = createKittyKeyboardBroadcastHandler({
|
||||
resolveOptions: () => ({
|
||||
kittyProtocolEnabled: false,
|
||||
kittyMode: createKittyKeyboardModeState(),
|
||||
applicationCursorMode: false,
|
||||
encodedKeys,
|
||||
win32InputMode: true,
|
||||
}),
|
||||
getSessionId: () => "win32-target",
|
||||
isSensitiveInput: () => sensitive,
|
||||
isConnected: () => true,
|
||||
isRuntimeDisposed: () => false,
|
||||
writeDisposed: (_sessionId, data) => writes.push(data),
|
||||
writeActive: (data) => writes.push(data),
|
||||
writeWin32Event: (event, logicalData) => {
|
||||
delivered.push({ type: event.type, logicalData });
|
||||
},
|
||||
});
|
||||
|
||||
handler({
|
||||
kind: "key",
|
||||
event: { type: "keydown", key: "Enter", code: "Enter", shiftKey: true },
|
||||
});
|
||||
sensitive = true;
|
||||
handler({
|
||||
kind: "key",
|
||||
event: { type: "keyup", key: "Enter", code: "Enter", shiftKey: true },
|
||||
});
|
||||
|
||||
assert.deepEqual(delivered, [
|
||||
{ type: "keydown", logicalData: null },
|
||||
{ type: "keyup", logicalData: null },
|
||||
]);
|
||||
assert.deepEqual(writes, []);
|
||||
assert.equal(encodedKeys.size, 0);
|
||||
});
|
||||
|
||||
test("plain Kitty targets preserve modified non-ASCII keys without a legacy fallback", () => {
|
||||
const plain = createKittyKeyboardModeState();
|
||||
assert.deepEqual(resolveKittyKeyboardBroadcastInput({
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import type { TerminalSettings } from "../../../domain/models";
|
||||
import {
|
||||
isBareShiftEnterLineEnding,
|
||||
resolveShiftEnterText,
|
||||
resolveShiftEnterPayload,
|
||||
shouldSendShiftEnterText,
|
||||
} from "./shiftEnterText";
|
||||
|
||||
@@ -20,13 +20,6 @@ export type KittyKeyboardBroadcastInput =
|
||||
fallbackToLegacy?: boolean;
|
||||
urgentInterrupt?: boolean;
|
||||
}
|
||||
| {
|
||||
kind: "win32";
|
||||
data: string;
|
||||
event: KittyKeyboardEvent;
|
||||
fallbackToLegacy?: boolean;
|
||||
urgentInterrupt?: boolean;
|
||||
}
|
||||
| { kind: "legacy"; data: string; keyIdentity: string; urgentInterrupt?: boolean }
|
||||
| { kind: "text"; text: string };
|
||||
|
||||
@@ -43,9 +36,6 @@ export type ResolvedKittyKeyboardBroadcastInput = {
|
||||
data: string;
|
||||
kittyEncoded: boolean;
|
||||
urgentInterrupt: boolean;
|
||||
logicalData?: string | null;
|
||||
/** Let the target xterm encode this event with its active Win32 mode. */
|
||||
win32Event?: KittyKeyboardEvent;
|
||||
};
|
||||
|
||||
export const createKittyKeyboardBroadcastForwarder = (options: {
|
||||
@@ -212,30 +202,14 @@ type ResolveKittyKeyboardBroadcastOptions = {
|
||||
applicationCursorMode: boolean;
|
||||
encodedKeys: Set<string>;
|
||||
legacySuppressedKeys?: Set<string>;
|
||||
win32InputMode?: boolean;
|
||||
/** Target buffer: remap Shift+Enter per peer, not from the broadcast source. */
|
||||
alternateScreen?: boolean;
|
||||
shiftEnterSettings?: Pick<
|
||||
TerminalSettings,
|
||||
"shiftEnterNewlineEnabled" | "shiftEnterNewlineText"
|
||||
>;
|
||||
};
|
||||
|
||||
export const resolveWin32InputLogicalData = (
|
||||
event: KittyKeyboardEvent,
|
||||
applicationCursorMode: boolean,
|
||||
): string | null => {
|
||||
if (event.type === "keyup") return null;
|
||||
if (
|
||||
event.key === "Enter" &&
|
||||
(event.shiftKey || event.altKey || event.ctrlKey || event.metaKey)
|
||||
) {
|
||||
// The native record carries semantics that a legacy CR cannot express.
|
||||
// Treating a modified Enter as CR would make Netcatty record a command
|
||||
// submission even when the TUI only inserted a line break.
|
||||
return null;
|
||||
}
|
||||
return encodeLegacyKeyboardEvent(event, applicationCursorMode);
|
||||
};
|
||||
|
||||
const resolveShiftEnterBroadcastPayload = (
|
||||
input: Extract<KittyKeyboardBroadcastInput, { kind: "key" }>,
|
||||
options: ResolveKittyKeyboardBroadcastOptions,
|
||||
@@ -248,11 +222,13 @@ const resolveShiftEnterBroadcastPayload = (
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const data = resolveShiftEnterText(options.shiftEnterSettings);
|
||||
if (!data) return null;
|
||||
const payload = resolveShiftEnterPayload(options.shiftEnterSettings, {
|
||||
alternateScreen: options.alternateScreen === true,
|
||||
});
|
||||
if (!payload.data) return null;
|
||||
return {
|
||||
data,
|
||||
kittyEncoded: false,
|
||||
data: payload.data,
|
||||
kittyEncoded: payload.kind === "key",
|
||||
urgentInterrupt: false,
|
||||
};
|
||||
};
|
||||
@@ -279,36 +255,6 @@ export const resolveKittyKeyboardBroadcastInput = (
|
||||
};
|
||||
}
|
||||
|
||||
if (input.kind === "win32") {
|
||||
if (options.win32InputMode) {
|
||||
const identity = input.event.code || input.event.key;
|
||||
const legacySuppressedKeys = options.legacySuppressedKeys ?? options.encodedKeys;
|
||||
if (input.event.type === "keyup") {
|
||||
const hasPairedKeyDown = options.encodedKeys.delete(identity);
|
||||
legacySuppressedKeys.delete(identity);
|
||||
if (!hasPairedKeyDown) return null;
|
||||
} else {
|
||||
options.encodedKeys.add(identity);
|
||||
legacySuppressedKeys.add(identity);
|
||||
}
|
||||
return {
|
||||
data: input.data,
|
||||
kittyEncoded: false,
|
||||
urgentInterrupt: input.urgentInterrupt === true,
|
||||
logicalData: resolveWin32InputLogicalData(
|
||||
input.event,
|
||||
options.applicationCursorMode,
|
||||
),
|
||||
};
|
||||
}
|
||||
return resolveKittyKeyboardBroadcastInput({
|
||||
kind: "key",
|
||||
event: input.event,
|
||||
fallbackToLegacy: input.fallbackToLegacy,
|
||||
urgentInterrupt: input.urgentInterrupt,
|
||||
}, options);
|
||||
}
|
||||
|
||||
const identity = input.event.code || input.event.key;
|
||||
const legacySuppressedKeys = options.legacySuppressedKeys ?? options.encodedKeys;
|
||||
const hasPairedKeyDown = input.event.type === "keyup"
|
||||
@@ -316,22 +262,6 @@ export const resolveKittyKeyboardBroadcastInput = (
|
||||
: false;
|
||||
if (input.event.type === "keyup") legacySuppressedKeys.delete(identity);
|
||||
if (input.event.type === "keyup" && !hasPairedKeyDown) return null;
|
||||
if (options.win32InputMode) {
|
||||
if (input.event.type !== "keyup") {
|
||||
options.encodedKeys.add(identity);
|
||||
legacySuppressedKeys.add(identity);
|
||||
}
|
||||
return {
|
||||
data: "",
|
||||
kittyEncoded: false,
|
||||
urgentInterrupt: false,
|
||||
logicalData: resolveWin32InputLogicalData(
|
||||
input.event,
|
||||
options.applicationCursorMode,
|
||||
),
|
||||
win32Event: input.event,
|
||||
};
|
||||
}
|
||||
const encoded = options.kittyProtocolEnabled
|
||||
? encodeKittyKeyEvent(options.kittyMode, {
|
||||
...input.event,
|
||||
@@ -370,14 +300,11 @@ export const createKittyKeyboardBroadcastHandler = (options: {
|
||||
isRuntimeDisposed: () => boolean;
|
||||
interruptSession?: (sessionId: string) => void;
|
||||
writeDisposed: (sessionId: string, data: string) => void;
|
||||
writeActive: (data: string, logicalData?: string | null) => void;
|
||||
writeWin32Event?: (event: KittyKeyboardEvent, logicalData: string | null) => void;
|
||||
writeActive: (data: string) => void;
|
||||
}): KittyKeyboardBroadcastHandler => (input, dispatchOptions) => {
|
||||
const sessionId = options.getSessionId();
|
||||
if (!sessionId || !options.isConnected()) return;
|
||||
const isPairedRelease =
|
||||
(input.kind === "key" || input.kind === "win32") &&
|
||||
input.event.type === "keyup";
|
||||
const isPairedRelease = input.kind === "key" && input.event.type === "keyup";
|
||||
if (!isPairedRelease && options.isSensitiveInput?.() === true) return;
|
||||
const resolved = resolveKittyKeyboardBroadcastInput(input, options.resolveOptions());
|
||||
if (!resolved) return;
|
||||
@@ -386,20 +313,11 @@ export const createKittyKeyboardBroadcastHandler = (options: {
|
||||
options.interruptSession(sessionId);
|
||||
return;
|
||||
}
|
||||
if (resolved.win32Event) {
|
||||
if (!options.isRuntimeDisposed()) {
|
||||
options.writeWin32Event?.(
|
||||
resolved.win32Event,
|
||||
resolved.logicalData ?? null,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (options.isRuntimeDisposed()) {
|
||||
options.writeDisposed(sessionId, resolved.data);
|
||||
return;
|
||||
}
|
||||
options.writeActive(resolved.data, resolved.logicalData);
|
||||
options.writeActive(resolved.data);
|
||||
};
|
||||
|
||||
const handlers = new Map<string, KittyKeyboardBroadcastHandler>();
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
getShiftEnterSubmittedInput,
|
||||
isBareShiftEnterLineEnding,
|
||||
isShiftEnterLineContinuationText,
|
||||
resolveShiftEnterPayload,
|
||||
resolveShiftEnterText,
|
||||
SHIFT_ENTER_CSI_U_SEQUENCE,
|
||||
shouldSendShiftEnterText,
|
||||
@@ -99,6 +100,44 @@ test("Kitty encodings that collapse Shift+Enter to CR/LF do not preserve the cho
|
||||
assert.equal(doesKittyEncodingPreserveShiftEnter(SHIFT_ENTER_CSI_U_SEQUENCE), true);
|
||||
});
|
||||
|
||||
test("main-buffer Shift+Enter keeps configured send text", () => {
|
||||
assert.deepEqual(resolveShiftEnterPayload(), {
|
||||
kind: "text",
|
||||
data: "\n",
|
||||
});
|
||||
assert.deepEqual(
|
||||
resolveShiftEnterPayload(
|
||||
{ shiftEnterNewlineText: " \\\\\\n" },
|
||||
{ alternateScreen: false },
|
||||
),
|
||||
{ kind: "text", data: " \\\n" },
|
||||
);
|
||||
});
|
||||
|
||||
test("alternate-screen Shift+Enter sends CSI-u when configured text is a bare line ending", () => {
|
||||
assert.deepEqual(
|
||||
resolveShiftEnterPayload(undefined, { alternateScreen: true }),
|
||||
{ kind: "key", data: SHIFT_ENTER_CSI_U_SEQUENCE },
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolveShiftEnterPayload(
|
||||
{ shiftEnterNewlineText: "\\r\\n" },
|
||||
{ alternateScreen: true },
|
||||
),
|
||||
{ kind: "key", data: SHIFT_ENTER_CSI_U_SEQUENCE },
|
||||
);
|
||||
});
|
||||
|
||||
test("alternate-screen Shift+Enter keeps custom non-line-ending send text", () => {
|
||||
assert.deepEqual(
|
||||
resolveShiftEnterPayload(
|
||||
{ shiftEnterNewlineText: " \\\\\\n" },
|
||||
{ alternateScreen: true },
|
||||
),
|
||||
{ kind: "text", data: " \\\n" },
|
||||
);
|
||||
});
|
||||
|
||||
test("runtime routes Shift+Enter text through the shared input handler", () => {
|
||||
const source = readFileSync(
|
||||
new URL("./createXTermRuntime.ts", import.meta.url),
|
||||
@@ -112,19 +151,11 @@ test("runtime routes Shift+Enter text through the shared input handler", () => {
|
||||
// Remap when Kitty encoding does not preserve Shift+Enter (not merely flags===0).
|
||||
assert.match(
|
||||
source,
|
||||
/if \(\s*shouldSendShiftEnterText\([\s\S]*?\) &&\s*!term\.modes\.win32InputMode &&\s*!doesKittyEncodingPreserveShiftEnter\(kittySequenceForKeyDown\)\s*\) \{[\s\S]*?const shiftEnterText = resolveShiftEnterText\([\s\S]*?\)[\s\S]*?\}\s*if \(kittySequenceForKeyDown\)/s,
|
||||
/if \(\s*shouldSendShiftEnterText\([\s\S]*?\) &&\s*!doesKittyEncodingPreserveShiftEnter\(kittySequenceForKeyDown\)\s*\) \{[\s\S]*?const shiftEnterPayload = resolveShiftEnterPayload\([\s\S]*?\)[\s\S]*?\}\s*if \(kittySequenceForKeyDown\)/s,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/vtExtensions: \{\s*win32InputMode: windowsPty\?\.backend === "conpty",\s*\},/s,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const kittySequenceForKeyDown =\s*!term\.modes\.win32InputMode &&\s*kittyKeyboardProtocolEnabled/s,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const shiftEnterText = resolveShiftEnterText\([\s\S]*?if \(shiftEnterText\) \{[\s\S]*?handleTerminalInputData\(shiftEnterText, \{\s*source: "shift-enter",\s*skipBroadcast: true,\s*\}\);\s*const forwarded = broadcastKittyInput\(\{\s*kind: "key",\s*event: kittyEvent,\s*fallbackToLegacy: true,\s*\}\);/s,
|
||||
/if \(shiftEnterPayload\.kind === "key"\) \{[\s\S]*?handleTerminalInputData\(shiftEnterPayload\.data, \{ source: "kitty" \}\);[\s\S]*?\} else \{[\s\S]*?handleTerminalInputData\(shiftEnterPayload\.data, \{\s*source: "shift-enter",\s*skipBroadcast: true,\s*\}\);[\s\S]*?\}\s*const forwarded = broadcastKittyInput\(\{\s*kind: "key",\s*event: kittyEvent,\s*fallbackToLegacy: true,\s*\}\);/s,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
@@ -132,10 +163,9 @@ test("runtime routes Shift+Enter text through the shared input handler", () => {
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/resolveOptions: \(\) => \(\{[\s\S]*?shiftEnterSettings: ctx\.terminalSettingsRef\.current,[\s\S]*?\}\),/s,
|
||||
/alternateScreen: term\.buffer\.active\.type === "alternate",\s*shiftEnterSettings: ctx\.terminalSettingsRef\.current,/s,
|
||||
);
|
||||
assert.doesNotMatch(source, /resolveShiftEnterText\([\s\S]*?alternateScreen:/s);
|
||||
assert.match(source, /getShiftEnterSubmittedInput\(logicalData\)/);
|
||||
assert.match(source, /getShiftEnterSubmittedInput\(data\)/);
|
||||
assert.match(source, /inputSource !== "shift-enter"/);
|
||||
assert.match(
|
||||
source,
|
||||
@@ -147,40 +177,7 @@ test("runtime routes Shift+Enter text through the shared input handler", () => {
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const sanitizedData = sanitizeTerminalInput\(data\);[\s\S]*const encoded = term\.modes\.win32InputMode\s*\? null\s*:\s*encodeKittyCompositionText\(kittyKeyboardMode, sanitizedData\);[\s\S]*if \(encoded\) \{[\s\S]*handleTerminalInputData\(encoded, \{ source: "kitty" \}\);[\s\S]*\} else \{[\s\S]*handleTerminalInputData\(sanitizedData, \{\s*perCharacterWrites: shouldSplitImeTextInputForWire\(sanitizedData\),?\s*\}\);[\s\S]*broadcastKittyInput\(\{ kind: "text", text: sanitizedData \}\);/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/if \(term\.modes\.win32InputMode\) \{[\s\S]*win32InputModePendingEvent = \{\s*event: normalizedKittyEvent,\s*logicalData: resolveWin32InputLogicalData\(/s,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const broadcastInput: KittyKeyboardBroadcastInput = \{\s*kind: "win32",\s*data,\s*event: win32Input\.event,[\s\S]*handleTerminalInputData\(data, \{\s*logicalData: win32Input\.logicalData,\s*skipBroadcast: true,/s,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const hasForwardedWin32KeyDown = win32InputModeForwardedKeys\.delete\(identity\);[\s\S]*if \(term\.modes\.win32InputMode\) \{[\s\S]*releaseForwardedKittyPress\([\s\S]*if \(!hasForwardedWin32KeyDown\) \{[\s\S]*win32InputModePendingEvent = null;[\s\S]*return false;[\s\S]*logicalData: null,[\s\S]*return true;[\s\S]*releaseForwardedKittyPress/s,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/if \(win32Input\.event\.type === "keydown"\) \{\s*upsertKittyKeyboardForwardedPress\(\s*win32InputModeForwardedKeys,\s*win32Input\.event\.code \|\| win32Input\.event\.key,\s*win32Input\.event,\s*\[\],/s,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/if \(term\.modes\.win32InputMode\) \{[\s\S]*flushKittyKeyboardBroadcastReleases\(\s*win32InputModeForwardedKeys,[\s\S]*writeWin32InputModeEvent\(input\.event, null\);/s,
|
||||
);
|
||||
assert.match(source, /win32InputMode: term\.modes\.win32InputMode,/);
|
||||
assert.match(
|
||||
source,
|
||||
/const win32BroadcastForwardedKeys = new Map<string, KittyKeyboardForwardedPress>\(\);/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const forwardedPress = win32BroadcastForwardedKeys\.get\(identity\);[\s\S]*broadcastKittyInput\(\s*broadcastInput,\s*true,\s*forwardedPress\.targetSessionIds,/s,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/upsertKittyKeyboardForwardedPress\(\s*win32BroadcastForwardedKeys,[\s\S]*forwarded\.targetSessionIds,/s,
|
||||
/const sanitizedData = sanitizeTerminalInput\(data\);[\s\S]*const encoded = encodeKittyCompositionText\(kittyKeyboardMode, sanitizedData\);[\s\S]*if \(encoded\) \{[\s\S]*handleTerminalInputData\(encoded, \{ source: "kitty" \}\);[\s\S]*\} else \{[\s\S]*handleTerminalInputData\(sanitizedData, \{\s*perCharacterWrites: shouldSplitImeTextInputForWire\(sanitizedData\),?\s*\}\);[\s\S]*broadcastKittyInput\(\{ kind: "text", text: sanitizedData \}\);/,
|
||||
);
|
||||
assert.match(source, /ctx\.container\.addEventListener\("input", markKittyTextInput, true\);/);
|
||||
assert.match(
|
||||
|
||||
@@ -91,6 +91,30 @@ export function doesKittyEncodingPreserveShiftEnter(
|
||||
&& !isBareShiftEnterLineEnding(encoded);
|
||||
}
|
||||
|
||||
export type ShiftEnterPayload =
|
||||
| { kind: "text"; data: string }
|
||||
| { kind: "key"; data: string };
|
||||
|
||||
/**
|
||||
* Resolve what Shift+Enter should write.
|
||||
*
|
||||
* On the main buffer, keep the configured send-text (default LF) so shell /
|
||||
* Claude-style multiline prompts keep working. On the alternate screen (full-
|
||||
* screen TUIs), bare line-ending remaps collapse Shift+Enter into Ctrl+Enter /
|
||||
* LF for apps like Codex; send CSI-u Shift+Enter instead so the TUI can see
|
||||
* the real chord. Custom send-text (e.g. shell continuation) is unchanged.
|
||||
*/
|
||||
export function resolveShiftEnterPayload(
|
||||
settings?: Pick<TerminalSettings, "shiftEnterNewlineText">,
|
||||
options?: { alternateScreen?: boolean },
|
||||
): ShiftEnterPayload {
|
||||
const text = resolveShiftEnterText(settings);
|
||||
if (options?.alternateScreen && isBareShiftEnterLineEnding(text)) {
|
||||
return { kind: "key", data: SHIFT_ENTER_CSI_U_SEQUENCE };
|
||||
}
|
||||
return { kind: "text", data: text };
|
||||
}
|
||||
|
||||
export function isShiftEnterLineContinuationText(text: string): boolean {
|
||||
return /\\(?:\r\n|\r|\n)$/.test(text);
|
||||
}
|
||||
|
||||
@@ -472,7 +472,7 @@ test("createXTermRuntime recovers a stuck IME punctuation deferral (#3103)", ()
|
||||
// Any real key release ends the deferral, not just an exact key match.
|
||||
const keyupIdx = runtimeSource.indexOf('if (e.type === "keyup")');
|
||||
assert.ok(keyupIdx >= 0);
|
||||
const keyupSlice = runtimeSource.slice(keyupIdx, keyupIdx + 2800);
|
||||
const keyupSlice = runtimeSource.slice(keyupIdx, keyupIdx + 2000);
|
||||
assert.match(
|
||||
keyupSlice,
|
||||
/shouldFlushDeferredImeTextInputOnKeyUp\(imeTextInputDeferredKey, e\)/,
|
||||
|
||||
@@ -1501,23 +1501,6 @@ 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,11 +246,6 @@ export const setTerminalOutputPressureVisibility = (
|
||||
getOrCreateState(term).background = !visible;
|
||||
};
|
||||
|
||||
/** True while the pane is hidden (recorded via {@link setTerminalOutputPressureVisibility}). */
|
||||
export const isTerminalOutputInBackground = (term: XTerm): boolean => (
|
||||
pressureStates.get(term)?.background ?? false
|
||||
);
|
||||
|
||||
export const setTerminalOutputPressureLargeOutput = (
|
||||
term: XTerm,
|
||||
largeOutput: boolean,
|
||||
|
||||
@@ -20,10 +20,7 @@ import {
|
||||
writeSessionData,
|
||||
} from "./terminalSessionAttachment.ts";
|
||||
import { getVisibleTerminalLineTimestampRows } from "./terminalLineTimestamps.ts";
|
||||
import {
|
||||
getTerminalOutputPressure,
|
||||
noteTerminalOutputPressureData,
|
||||
} from "./terminalOutputPressure.ts";
|
||||
import { noteTerminalOutputPressureData } from "./terminalOutputPressure.ts";
|
||||
|
||||
import {
|
||||
clearTerminalSessionFlowAck,
|
||||
@@ -1542,50 +1539,6 @@ test("writeSessionData keeps the hidden flush gate after coalescer reset and flu
|
||||
clearTerminalSessionFlowAck("session-1");
|
||||
});
|
||||
|
||||
test("writeSessionData refreshes pressure visibility when hidden output flushes after reveal", () => {
|
||||
clearTerminalSessionFlowAck("session-1");
|
||||
const payload = "x".repeat(XTERM_WRITE_CALLBACK_FAST_PATH_MAX_BYTES + 1);
|
||||
const writes: string[] = [];
|
||||
const term = {
|
||||
buffer: { active: { type: "normal" } },
|
||||
write(data: string, callback?: () => void) {
|
||||
writes.push(data);
|
||||
callback?.();
|
||||
},
|
||||
scrollToBottom() {},
|
||||
} as unknown as XTerm;
|
||||
const ctx = {
|
||||
...createContext(false),
|
||||
isVisibleRef: { current: false },
|
||||
sessionRef: { current: "session-1" },
|
||||
terminalBackend: {
|
||||
ackSessionFlow: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
getFlowController(ctx as never, term);
|
||||
resetTerminalWriteCoalescer(term);
|
||||
withAnimationFrameQueue(() => {
|
||||
withDocumentVisibility("visible", () => {
|
||||
writeSessionData(ctx as never, term, payload);
|
||||
});
|
||||
// The ingress snapshot records the pane as hidden.
|
||||
assert.equal(getTerminalOutputPressure(term).background, true);
|
||||
assert.deepEqual(writes, []);
|
||||
|
||||
ctx.isVisibleRef.current = true;
|
||||
withDocumentVisibility("visible", () => {
|
||||
flushPendingTerminalWritesOnResume(term);
|
||||
});
|
||||
// The batch is actually parsed after reveal, so the flush must clear the
|
||||
// stale hidden flag: bulk write callbacks (e.g. keyword highlight) gate
|
||||
// their in-frame viewport recolor on this state (#3272).
|
||||
assert.deepEqual(writes, [payload]);
|
||||
assert.equal(getTerminalOutputPressure(term).background, false);
|
||||
});
|
||||
clearTerminalSessionFlowAck("session-1");
|
||||
});
|
||||
|
||||
test("hidden tab output marks pending scroll without scrolling immediately", async () => {
|
||||
const writes: string[] = [];
|
||||
let scrollCalls = 0;
|
||||
@@ -2098,49 +2051,6 @@ 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 {
|
||||
resetTerminalLineTimestampRecordingState,
|
||||
resetTerminalLineTimestamps,
|
||||
type TerminalLineTimestampPerfStep,
|
||||
writeTerminalDataWithLineTimestamps,
|
||||
} from "./terminalLineTimestamps";
|
||||
@@ -89,7 +89,6 @@ import {
|
||||
} from "./terminalOutputPipeline";
|
||||
import {
|
||||
hasPendingTerminalWrites,
|
||||
isTerminalPageHidden,
|
||||
maybeFlushTerminalWriteCoalescerWhenUnfocused,
|
||||
scheduleTerminalRepaintWhenUnfocused,
|
||||
shouldFlushTerminalWritesForBackgroundOutput,
|
||||
@@ -115,16 +114,6 @@ const isTerminalPaneVisible = (ctx: TerminalSessionStartersContext): boolean =>
|
||||
(ctx.isPaneVisibleRef?.current ?? ctx.isVisibleRef?.current) !== false
|
||||
);
|
||||
|
||||
/**
|
||||
* Visibility recorded for the output-pressure gates. A pane whose ref says
|
||||
* "visible" still cannot paint while the document is page-hidden, so record it
|
||||
* as background there too; otherwise bulk batches take the background flush
|
||||
* path but keyword-highlight still repainted the viewport per batch.
|
||||
*/
|
||||
const isTerminalOutputVisibleForPressure = (ctx: TerminalSessionStartersContext): boolean => (
|
||||
isTerminalPaneVisible(ctx) && !isTerminalPageHidden()
|
||||
);
|
||||
|
||||
const handleTerminalOutputAutoScroll = (
|
||||
ctx: TerminalSessionStartersContext,
|
||||
term: XTerm,
|
||||
@@ -420,7 +409,7 @@ export const getFlowController = (
|
||||
return controller;
|
||||
};
|
||||
|
||||
export const resetTerminalLineTimestampState = resetTerminalLineTimestampRecordingState;
|
||||
export const resetTerminalLineTimestampState = resetTerminalLineTimestamps;
|
||||
|
||||
export const acknowledgeDroppedTerminalDisplayBytes = (
|
||||
ctx: TerminalSessionStartersContext,
|
||||
@@ -482,7 +471,7 @@ export const writeSessionData = (
|
||||
visible: isPaneVisible,
|
||||
});
|
||||
flow.received(ingressBytes);
|
||||
setTerminalOutputPressureVisibility(term, isTerminalOutputVisibleForPressure(ctx));
|
||||
setTerminalOutputPressureVisibility(term, isPaneVisible);
|
||||
noteTerminalOutputPressureData(term, data);
|
||||
const settings = ctx.terminalSettingsRef?.current ?? ctx.terminalSettings;
|
||||
const preservePromptSourceChunks = Boolean(
|
||||
@@ -496,12 +485,6 @@ export const writeSessionData = (
|
||||
batchIngress: number,
|
||||
writeOptions?: CoalescedTerminalWriteOptions,
|
||||
): void => {
|
||||
// Output queued while hidden can be flushed after the pane is revealed.
|
||||
// Refresh the pressure visibility at flush time so the write callback
|
||||
// sees the live pane state instead of the ingress snapshot; otherwise a
|
||||
// bulk batch written after reveal stays on the fully deferred path and
|
||||
// the viewport sits uncolored until the quiet catch-up (#3272).
|
||||
setTerminalOutputPressureVisibility(term, isTerminalOutputVisibleForPressure(ctx));
|
||||
writeSessionDataImmediate(ctx, term, batch, batchIngress, {
|
||||
...writeOptions,
|
||||
deferStart: writeOptions?.deferStart ?? !isPaneCurrentlyVisible(),
|
||||
@@ -873,10 +856,7 @@ export const attachSessionToTerminal = (
|
||||
teardownTerminalOutputPipeline(ctx, term, id, flow);
|
||||
flushTerminalWriteCoalescer(term);
|
||||
resetTerminalSyncBlockFilter(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);
|
||||
resetTerminalLineTimestamps(term);
|
||||
resetTerminalOutputPressure(term);
|
||||
ctx.onSessionAttached?.(id);
|
||||
const assistMode =
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
import test from "node:test";
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import type { Terminal as XTermType } from "@xterm/xterm";
|
||||
|
||||
import { dispatchWin32InputModeEvent } from "./win32InputMode";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { Terminal: XTerm } = require("@xterm/xterm") as {
|
||||
Terminal: typeof XTermType;
|
||||
};
|
||||
|
||||
const write = (term: XTermType, data: string): Promise<void> => new Promise((resolve) => {
|
||||
term.write(data, resolve);
|
||||
});
|
||||
|
||||
test("xterm ignores ConPTY's Win32 input request unless the extension is enabled", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true });
|
||||
|
||||
await write(term, "\u001b[?9001h");
|
||||
|
||||
assert.equal(term.modes.win32InputMode, false);
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("xterm preserves modified Enter keys as Win32 input records for ConPTY", async () => {
|
||||
const dom = new JSDOM(
|
||||
"<!doctype html><html><body><div id=terminal></div><div id=source></div></body></html>",
|
||||
{
|
||||
pretendToBeVisual: true,
|
||||
},
|
||||
);
|
||||
const window = dom.window;
|
||||
window.matchMedia = () => ({
|
||||
matches: true,
|
||||
media: "",
|
||||
onchange: null,
|
||||
addListener() {},
|
||||
removeListener() {},
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
dispatchEvent: () => true,
|
||||
});
|
||||
window.HTMLCanvasElement.prototype.getContext = (() => ({
|
||||
createLinearGradient: () => ({}),
|
||||
measureText: () => ({
|
||||
width: 10,
|
||||
actualBoundingBoxDescent: 2,
|
||||
actualBoundingBoxAscent: 8,
|
||||
}),
|
||||
fillRect() {},
|
||||
clearRect() {},
|
||||
getImageData: () => ({ data: new Uint8ClampedArray([0, 0, 0, 255]) }),
|
||||
})) as typeof window.HTMLCanvasElement.prototype.getContext;
|
||||
|
||||
const globals = [
|
||||
"window",
|
||||
"document",
|
||||
"navigator",
|
||||
"HTMLElement",
|
||||
"HTMLCanvasElement",
|
||||
"KeyboardEvent",
|
||||
"Event",
|
||||
"MouseEvent",
|
||||
"CompositionEvent",
|
||||
"InputEvent",
|
||||
] as const;
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
for (const name of globals) {
|
||||
previous.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, {
|
||||
value: window[name],
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
previous.set("devicePixelRatio", Object.getOwnPropertyDescriptor(globalThis, "devicePixelRatio"));
|
||||
Object.defineProperty(globalThis, "devicePixelRatio", { value: 1, configurable: true });
|
||||
previous.set("ResizeObserver", Object.getOwnPropertyDescriptor(globalThis, "ResizeObserver"));
|
||||
Object.defineProperty(globalThis, "ResizeObserver", {
|
||||
value: class {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const term = new XTerm({
|
||||
allowProposedApi: true,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
vtExtensions: { kittyKeyboard: true, win32InputMode: true },
|
||||
});
|
||||
try {
|
||||
term.open(window.document.getElementById("terminal") as HTMLElement);
|
||||
const sent: string[] = [];
|
||||
term.onData((data) => sent.push(data));
|
||||
|
||||
await write(term, "\u001b[?9001h");
|
||||
await write(term, "\u001b[>2u");
|
||||
assert.equal(term.modes.win32InputMode, true);
|
||||
|
||||
for (const modifiers of [
|
||||
{},
|
||||
{ shiftKey: true },
|
||||
{ ctrlKey: true },
|
||||
{ altKey: true },
|
||||
]) {
|
||||
term.textarea?.dispatchEvent(new window.KeyboardEvent("keydown", {
|
||||
key: "Enter",
|
||||
code: "Enter",
|
||||
keyCode: 13,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
...modifiers,
|
||||
}));
|
||||
}
|
||||
term.textarea?.dispatchEvent(new window.KeyboardEvent("keyup", {
|
||||
key: "Enter",
|
||||
code: "Enter",
|
||||
keyCode: 13,
|
||||
shiftKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}));
|
||||
|
||||
assert.deepEqual(sent, [
|
||||
"\u001b[13;28;13;1;0;1_",
|
||||
"\u001b[13;28;13;1;16;1_",
|
||||
"\u001b[13;28;10;1;8;1_",
|
||||
"\u001b[13;28;13;1;2;1_",
|
||||
"\u001b[13;28;13;0;16;1_",
|
||||
]);
|
||||
|
||||
const sourceTerm = new XTerm({ allowProposedApi: true, cols: 80, rows: 24 });
|
||||
try {
|
||||
sourceTerm.open(window.document.getElementById("source") as HTMLElement);
|
||||
sourceTerm.focus();
|
||||
assert.equal(window.document.activeElement, sourceTerm.textarea);
|
||||
sent.length = 0;
|
||||
const shiftEnter = {
|
||||
key: "Enter",
|
||||
code: "Enter",
|
||||
keyCode: 13,
|
||||
shiftKey: true,
|
||||
};
|
||||
dispatchWin32InputModeEvent(term, { type: "keydown", ...shiftEnter });
|
||||
dispatchWin32InputModeEvent(term, { type: "keyup", ...shiftEnter });
|
||||
assert.equal(window.document.activeElement, sourceTerm.textarea);
|
||||
assert.deepEqual(sent, [
|
||||
"\u001b[13;28;13;1;16;1_",
|
||||
"\u001b[13;28;13;0;16;1_",
|
||||
]);
|
||||
} finally {
|
||||
sourceTerm.dispose();
|
||||
}
|
||||
|
||||
// Netcatty consumes shortcuts and local controls before xterm. Their
|
||||
// releases must be consumed too, otherwise ConPTY receives an orphaned
|
||||
// native key-up after a Ctrl+C interrupt or sudo/autocomplete Enter.
|
||||
sent.length = 0;
|
||||
const forwardedKeys = new Set<string>();
|
||||
let pendingKeyDownIdentity: string | null = null;
|
||||
term.onData((data) => {
|
||||
if (
|
||||
pendingKeyDownIdentity &&
|
||||
data.startsWith("\u001b[") &&
|
||||
data.endsWith("_")
|
||||
) {
|
||||
forwardedKeys.add(pendingKeyDownIdentity);
|
||||
pendingKeyDownIdentity = null;
|
||||
}
|
||||
});
|
||||
term.attachCustomKeyEventHandler((event) => {
|
||||
const identity = event.code || event.key;
|
||||
if (event.type === "keyup") {
|
||||
pendingKeyDownIdentity = null;
|
||||
return forwardedKeys.delete(identity);
|
||||
}
|
||||
if (event.type !== "keydown") return true;
|
||||
const consumed =
|
||||
(event.ctrlKey && event.key.toLowerCase() === "c") ||
|
||||
(event.key === "Enter" && !event.shiftKey);
|
||||
if (consumed) return false;
|
||||
pendingKeyDownIdentity = identity;
|
||||
return true;
|
||||
});
|
||||
|
||||
for (const event of [
|
||||
new window.KeyboardEvent("keydown", {
|
||||
key: "c", code: "KeyC", keyCode: 67, ctrlKey: true, bubbles: true, cancelable: true,
|
||||
}),
|
||||
new window.KeyboardEvent("keyup", {
|
||||
key: "c", code: "KeyC", keyCode: 67, ctrlKey: true, bubbles: true, cancelable: true,
|
||||
}),
|
||||
new window.KeyboardEvent("keydown", {
|
||||
key: "Enter", code: "Enter", keyCode: 13, bubbles: true, cancelable: true,
|
||||
}),
|
||||
new window.KeyboardEvent("keyup", {
|
||||
key: "Enter", code: "Enter", keyCode: 13, bubbles: true, cancelable: true,
|
||||
}),
|
||||
new window.KeyboardEvent("keydown", {
|
||||
key: "Process", code: "KeyA", keyCode: 229, bubbles: true, cancelable: true,
|
||||
}),
|
||||
new window.KeyboardEvent("keyup", {
|
||||
key: "Process", code: "KeyA", keyCode: 229, bubbles: true, cancelable: true,
|
||||
}),
|
||||
]) {
|
||||
term.textarea?.dispatchEvent(event);
|
||||
}
|
||||
assert.deepEqual(sent, []);
|
||||
} finally {
|
||||
term.dispose();
|
||||
dom.window.close();
|
||||
for (const [name, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
}
|
||||
});
|
||||
Loaded 100 of 196 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user