Compare commits

..
Author SHA1 Message Date
netcatty-bot 6b143c2f4e fix: address Codex review on PR #3239 2026-09-02 03:43:07 +00:00
196 changed files with 742 additions and 12670 deletions

No files matched your search

-5
View File
@@ -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(() => ({
+2 -7
View File
@@ -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;
}
+1 -4
View File
@@ -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}
-4
View File
@@ -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,
-7
View File
@@ -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',
-1
View File
@@ -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)',
-15
View File
@@ -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.',
-1
View File
@@ -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',
-1
View File
@@ -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)',
-15
View File
@@ -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.',
-6
View File
@@ -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.',
-1
View File
@@ -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)',
-16
View File
@@ -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.',
-17
View File
@@ -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': '文件夹',
-6
View File
@@ -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)',
-17
View File
@@ -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': '資料夾',
-6
View File
@@ -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)',
-464
View File
@@ -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,
});
}
});
+5 -256
View File
@@ -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',
);
});
-11
View File
@@ -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);
});
-26
View File
@@ -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: {
+1 -11
View File
@@ -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 () => {
+12 -8
View File
@@ -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,
);
});
+3 -2
View File
@@ -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;
-45
View File
@@ -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;
}
});
+8 -7
View File
@@ -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;
+15 -11
View File
@@ -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 -148
View File
@@ -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");
}
});
+4 -90
View File
@@ -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();
});
});
-15
View File
@@ -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,
-103
View File
@@ -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;
}
});
+12 -19
View File
@@ -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);
-83
View File
@@ -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: {
+3 -12
View File
@@ -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
-3
View File
@@ -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}
+18 -1
View File
@@ -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;
}
-7
View File
@@ -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");
-54
View File
@@ -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
-10
View File
@@ -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,
-20
View File
@@ -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(
+14 -22
View File
@@ -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 -16
View File
@@ -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", () => {
+1 -11
View File
@@ -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({
-153
View File
@@ -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();
}
});
-128
View File
@@ -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
+3 -14
View File
@@ -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 (
-5
View File
@@ -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();
+1 -85
View File
@@ -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),
+62 -385
View File
@@ -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\)\}/);
});
+5 -1
View File
@@ -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);
+1 -15
View File
@@ -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(), []);
+6 -37
View File
@@ -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 -19
View File
@@ -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 () => {
+1 -1
View File
@@ -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,
+4 -128
View File
@@ -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();
}
});
+1 -53
View File
@@ -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 () => {
+55 -223
View File
@@ -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