Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9e88cd99d | ||
|
|
32afade4f9 | ||
|
|
66de2db912 | ||
|
|
0a38da8867 | ||
|
|
5e739f8293 | ||
|
|
6f64245d10 | ||
|
|
d48ca65a1e | ||
|
|
285fcd55a9 | ||
|
|
05b713ab18 | ||
|
|
293b15f67a | ||
|
|
83aec35f2f | ||
|
|
910ef72205 | ||
|
|
550a37b379 | ||
|
|
2b396c14e3 | ||
|
|
36724a3abd | ||
|
|
4459aa4ef3 | ||
|
|
64a6986d01 | ||
|
|
a301ecb2ca | ||
|
|
f16429e30f | ||
|
|
46b9bf6ccb | ||
|
|
17c8f11194 | ||
|
|
4d1a7ea55a | ||
|
|
babe06a944 | ||
|
|
9e31d53bdd |
2
App.tsx
2
App.tsx
@@ -194,7 +194,6 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const keysRef = useRef(keys);
|
||||
keysRef.current = keys;
|
||||
const knownHostsRef = useRef(knownHosts);
|
||||
knownHostsRef.current = knownHosts;
|
||||
// Bridge the gap while useVaultState hydrates: its async init awaits
|
||||
// hosts/keys/identities/proxyProfiles decryption before reading knownHosts,
|
||||
// so the state is briefly [] at boot even when localStorage has entries.
|
||||
@@ -205,6 +204,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
() => getEffectiveKnownHosts(knownHosts) ?? [],
|
||||
[knownHosts],
|
||||
);
|
||||
knownHostsRef.current = effectiveKnownHosts;
|
||||
|
||||
const {
|
||||
sessions,
|
||||
|
||||
@@ -214,9 +214,11 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
hosts={hosts}
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
knownHosts={effectiveKnownHosts}
|
||||
proxyProfiles={proxyProfiles}
|
||||
groupConfigs={groupConfigs}
|
||||
updateHosts={updateHosts}
|
||||
onAddKnownHost={handleAddKnownHost}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
|
||||
sftpAutoSync={sftpAutoSync}
|
||||
@@ -250,6 +252,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
terminalFontFamilyId={terminalFontFamilyId}
|
||||
fontSize={terminalFontSize}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
disableTerminalFontZoom={settings.disableTerminalFontZoom}
|
||||
keyBindings={keyBindings}
|
||||
onHotkeyAction={handleHotkeyAction}
|
||||
onUpdateTerminalThemeId={setTerminalThemeId}
|
||||
|
||||
@@ -267,6 +267,11 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.slashNoResults': 'No matching commands',
|
||||
'ai.chat.slashEmptyHint': 'Add prompts in Settings → AI → Quick Messages.',
|
||||
|
||||
// AI Chat Shortcuts
|
||||
'ai.chatShortcuts.title': 'Chat Shortcuts',
|
||||
'ai.chatShortcuts.selectionAction': 'Show Add to Conversation when selecting terminal text',
|
||||
'ai.chatShortcuts.selectionAction.description': 'Show a small AI button next to selected terminal text.',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Codex main-process handlers are not loaded yet. Fully restart Netcatty, or restart the Electron dev process, then try again.',
|
||||
|
||||
|
||||
@@ -341,6 +341,11 @@ export const enCoreMessages: Messages = {
|
||||
'settings.terminal.behavior.middleClickPaste': 'Middle-click paste',
|
||||
'settings.terminal.behavior.middleClickPaste.desc':
|
||||
'Paste clipboard content on middle-click',
|
||||
'settings.terminal.behavior.middleClick': 'Middle-click behavior',
|
||||
'settings.terminal.behavior.middleClick.desc': 'Action when middle-clicking in terminal',
|
||||
'settings.terminal.behavior.middleClick.menu': 'Show menu',
|
||||
'settings.terminal.behavior.middleClick.paste': 'Paste',
|
||||
'settings.terminal.behavior.middleClick.disabled': 'Do nothing',
|
||||
'settings.terminal.behavior.bracketedPaste': 'Bracketed paste mode',
|
||||
'settings.terminal.behavior.bracketedPaste.desc':
|
||||
'Wrap pasted text with escape sequences so the shell can distinguish paste from typed input. Disable if you see ^[[200~ artifacts.',
|
||||
@@ -476,6 +481,8 @@ export const enCoreMessages: Messages = {
|
||||
'settings.shortcuts.scheme.disabled': 'Disabled',
|
||||
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
|
||||
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
|
||||
'settings.shortcuts.disableTerminalFontZoom.label': 'Disable terminal zoom',
|
||||
'settings.shortcuts.disableTerminalFontZoom.desc': 'Turn off terminal font zoom shortcuts, including Cmd/Ctrl + mouse wheel.',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': 'Number keys skip pinned tabs',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': 'When enabled, Cmd/Ctrl+[1...9] switches only work tabs (terminals, workspaces, editors), not the pinned Vault or SFTP tabs.',
|
||||
'settings.shortcuts.section.custom': 'Custom Shortcuts',
|
||||
|
||||
@@ -6,6 +6,7 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.toolbar.openSftp': 'Open SFTP',
|
||||
'terminal.toolbar.availableAfterConnect': 'Available after connect',
|
||||
'terminal.toolbar.sendYmodem': 'Send with YMODEM',
|
||||
'terminal.toolbar.receiveYmodem': 'Receive with YMODEM',
|
||||
'terminal.toolbar.sftp': 'SFTP',
|
||||
'terminal.toolbar.more': 'More actions',
|
||||
'terminal.toolbar.scripts': 'Scripts',
|
||||
@@ -88,7 +89,9 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.dragDrop.localTitle': 'Drop to Insert Paths',
|
||||
'terminal.dragDrop.localMessage': 'File paths will be inserted into the terminal',
|
||||
'terminal.dragDrop.remoteTitle': 'Drop to Upload Files',
|
||||
'terminal.dragDrop.remoteMessage': 'Files will be uploaded via SFTP',
|
||||
'terminal.dragDrop.remoteZmodemMessage': 'Files will be uploaded via ZMODEM (PTY)',
|
||||
'terminal.dragDrop.remoteSftpMessage': 'Files will be uploaded via SFTP',
|
||||
'terminal.dragDrop.noFiles': 'No files to upload',
|
||||
'terminal.dragDrop.notConnected': 'Cannot drop files - terminal is not connected',
|
||||
'terminal.dragDrop.errorTitle': 'Drop Error',
|
||||
'terminal.dragDrop.errorMessage': 'Failed to process dropped files',
|
||||
@@ -103,6 +106,7 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.menu.selectAll': 'Select All',
|
||||
'terminal.menu.reconnect': 'Reconnect',
|
||||
'terminal.menu.sendYmodem': 'Send with YMODEM',
|
||||
'terminal.menu.receiveYmodem': 'Receive with YMODEM',
|
||||
'terminal.menu.splitHorizontal': 'Split Horizontal',
|
||||
'terminal.menu.splitVertical': 'Split Vertical',
|
||||
'terminal.menu.clearBuffer': 'Clear Buffer',
|
||||
@@ -112,6 +116,12 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.ymodem.started': 'YMODEM sending {fileName}',
|
||||
'terminal.ymodem.complete': 'YMODEM sent {fileName}',
|
||||
'terminal.ymodem.failed': 'YMODEM send failed',
|
||||
'terminal.ymodem.selectReceiveDirectory': 'Select folder to save received files',
|
||||
'terminal.ymodem.receiveStarted': 'YMODEM receiving...',
|
||||
'terminal.ymodem.receiveComplete': 'YMODEM received {fileName}',
|
||||
'terminal.ymodem.receiveCompleteMultiple': 'YMODEM received {count} files',
|
||||
'terminal.ymodem.receiveEmpty': 'No YMODEM files received',
|
||||
'terminal.ymodem.receiveFailed': 'YMODEM receive failed',
|
||||
'terminal.ymodem.unavailable': 'YMODEM is unavailable',
|
||||
'terminal.selection.addToAI': 'Add to Conversation',
|
||||
'terminal.selection.addToAIDesc': 'Attach selected terminal output to the AI draft',
|
||||
|
||||
@@ -258,6 +258,8 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.tabs.addTab': 'Add new tab',
|
||||
'sftp.tabs.closeTab': 'Close tab',
|
||||
'sftp.tabs.newTab': 'New Tab',
|
||||
'sftp.tabs.copyDefaultPath': 'Copy tab (default path)',
|
||||
'sftp.tabs.copyCurrentPath': 'Copy and go to current path',
|
||||
'sftp.conflict.title': 'File Conflict',
|
||||
'sftp.conflict.desc': 'A file with the same name already exists at the destination',
|
||||
'sftp.conflict.alreadyExistsSuffix': 'already exists',
|
||||
|
||||
@@ -267,6 +267,11 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.slashNoResults': 'Нет подходящих команд',
|
||||
'ai.chat.slashEmptyHint': 'Добавьте подсказки в Настройки → AI → Быстрые сообщения.',
|
||||
|
||||
// AI Chat Shortcuts
|
||||
'ai.chatShortcuts.title': 'Быстрые действия чата',
|
||||
'ai.chatShortcuts.selectionAction': 'Показывать «Добавить в чат» при выделении в терминале',
|
||||
'ai.chatShortcuts.selectionAction.description': 'Показывать небольшую кнопку AI рядом с выделенным текстом терминала.',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Обработчики главного процесса Codex ещё не загружены. Полностью перезапустите Netcatty или dev-процесс Electron и попробуйте снова.',
|
||||
|
||||
|
||||
@@ -341,6 +341,11 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.behavior.middleClickPaste': 'Вставка средней кнопкой мыши',
|
||||
'settings.terminal.behavior.middleClickPaste.desc':
|
||||
'Вставлять содержимое буфера обмена по щелчку средней кнопкой',
|
||||
'settings.terminal.behavior.middleClick': 'Поведение средней кнопки мыши',
|
||||
'settings.terminal.behavior.middleClick.desc': 'Действие при щелчке средней кнопкой в терминале',
|
||||
'settings.terminal.behavior.middleClick.menu': 'Показать меню',
|
||||
'settings.terminal.behavior.middleClick.paste': 'Вставить',
|
||||
'settings.terminal.behavior.middleClick.disabled': 'Ничего не делать',
|
||||
'settings.terminal.behavior.bracketedPaste': 'Режим bracketed paste',
|
||||
'settings.terminal.behavior.bracketedPaste.desc':
|
||||
'Оборачивать вставляемый текст escape-последовательностями, чтобы оболочка отличала вставку от обычного ввода. Отключите, если видите артефакты вида ^[[200~.',
|
||||
@@ -476,6 +481,8 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.shortcuts.scheme.disabled': 'Отключено',
|
||||
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
|
||||
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
|
||||
'settings.shortcuts.disableTerminalFontZoom.label': 'Отключить масштаб терминала',
|
||||
'settings.shortcuts.disableTerminalFontZoom.desc': 'Отключает быстрый масштаб текста в терминале, включая Cmd/Ctrl + колесо мыши.',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': 'Цифры без закреплённых вкладок',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': 'Если включено, Cmd/Ctrl+[1...9] переключает только рабочие вкладки (терминалы, рабочие области, редакторы), а не закреплённые Vault и SFTP.',
|
||||
'settings.shortcuts.section.custom': 'Пользовательские сочетания',
|
||||
|
||||
@@ -27,6 +27,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.toolbar.openSftp': 'Открыть SFTP',
|
||||
'terminal.toolbar.availableAfterConnect': 'Доступно после подключения',
|
||||
'terminal.toolbar.sendYmodem': 'Отправить через YMODEM',
|
||||
'terminal.toolbar.receiveYmodem': 'Получить через YMODEM',
|
||||
'terminal.toolbar.sftp': 'SFTP',
|
||||
'terminal.toolbar.more': 'Другие действия',
|
||||
'terminal.toolbar.scripts': 'Скрипты',
|
||||
@@ -109,7 +110,9 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.dragDrop.localTitle': 'Перетащите для вставки путей',
|
||||
'terminal.dragDrop.localMessage': 'Пути к файлам будут вставлены в терминал',
|
||||
'terminal.dragDrop.remoteTitle': 'Перетащите для загрузки файлов',
|
||||
'terminal.dragDrop.remoteMessage': 'Файлы будут загружены через SFTP',
|
||||
'terminal.dragDrop.remoteZmodemMessage': 'Файлы будут загружены через ZMODEM (PTY)',
|
||||
'terminal.dragDrop.remoteSftpMessage': 'Файлы будут загружены через SFTP',
|
||||
'terminal.dragDrop.noFiles': 'Нет файлов для загрузки',
|
||||
'terminal.dragDrop.notConnected': 'Нельзя перетащить файлы — терминал не подключён',
|
||||
'terminal.dragDrop.errorTitle': 'Ошибка перетаскивания',
|
||||
'terminal.dragDrop.errorMessage': 'Не удалось обработать перетащенные файлы',
|
||||
@@ -124,6 +127,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.menu.selectAll': 'Выбрать всё',
|
||||
'terminal.menu.reconnect': 'Переподключиться',
|
||||
'terminal.menu.sendYmodem': 'Отправить через YMODEM',
|
||||
'terminal.menu.receiveYmodem': 'Получить через YMODEM',
|
||||
'terminal.menu.splitHorizontal': 'Разделить по горизонтали',
|
||||
'terminal.menu.splitVertical': 'Разделить по вертикали',
|
||||
'terminal.menu.clearBuffer': 'Очистить буфер',
|
||||
@@ -133,6 +137,12 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.ymodem.started': 'YMODEM отправляет {fileName}',
|
||||
'terminal.ymodem.complete': 'YMODEM отправил {fileName}',
|
||||
'terminal.ymodem.failed': 'Не удалось отправить через YMODEM',
|
||||
'terminal.ymodem.selectReceiveDirectory': 'Выберите папку для полученных файлов',
|
||||
'terminal.ymodem.receiveStarted': 'YMODEM получает...',
|
||||
'terminal.ymodem.receiveComplete': 'YMODEM получил {fileName}',
|
||||
'terminal.ymodem.receiveCompleteMultiple': 'YMODEM получил файлов: {count}',
|
||||
'terminal.ymodem.receiveEmpty': 'Файлы YMODEM не получены',
|
||||
'terminal.ymodem.receiveFailed': 'Не удалось получить через YMODEM',
|
||||
'terminal.ymodem.unavailable': 'YMODEM недоступен',
|
||||
'terminal.selection.addToAI': 'Добавить в чат',
|
||||
'terminal.selection.addToAIDesc': 'Прикрепить выбранный вывод терминала к черновику AI',
|
||||
|
||||
@@ -293,6 +293,8 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.tabs.addTab': 'Добавить новую вкладку',
|
||||
'sftp.tabs.closeTab': 'Закрыть вкладку',
|
||||
'sftp.tabs.newTab': 'Новая вкладка',
|
||||
'sftp.tabs.copyDefaultPath': 'Копировать вкладку (путь по умолчанию)',
|
||||
'sftp.tabs.copyCurrentPath': 'Копировать и перейти к текущему пути',
|
||||
'sftp.conflict.title': 'Конфликт файлов',
|
||||
'sftp.conflict.desc': 'В месте назначения уже существует файл с таким именем',
|
||||
'sftp.conflict.alreadyExistsSuffix': 'уже существует',
|
||||
|
||||
@@ -267,6 +267,11 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.slashNoResults': '没有匹配的命令',
|
||||
'ai.chat.slashEmptyHint': '可在 设置 → AI → 快捷消息 中添加常用提示词。',
|
||||
|
||||
// AI 聊天快捷入口
|
||||
'ai.chatShortcuts.title': '聊天快捷入口',
|
||||
'ai.chatShortcuts.selectionAction': '选中终端内容时显示“添加到对话”',
|
||||
'ai.chatShortcuts.selectionAction.description': '在终端里选中文本后显示 AI 快捷按钮。',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Codex 主进程处理器尚未加载。请完全重启 Netcatty 或重启 Electron 开发进程,然后重试。',
|
||||
|
||||
|
||||
@@ -212,6 +212,11 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.behavior.copyOnSelect.desc': '自动复制选中的文本。在 tmux/vim 鼠标模式下,macOS 按住 Option,Windows/Linux 按住 Shift 拖选即可选中文本',
|
||||
'settings.terminal.behavior.middleClickPaste': '中键粘贴',
|
||||
'settings.terminal.behavior.middleClickPaste.desc': '中键点击时粘贴剪贴板内容',
|
||||
'settings.terminal.behavior.middleClick': '中键行为',
|
||||
'settings.terminal.behavior.middleClick.desc': '在终端中点击鼠标中键时执行的操作',
|
||||
'settings.terminal.behavior.middleClick.menu': '显示菜单',
|
||||
'settings.terminal.behavior.middleClick.paste': '粘贴',
|
||||
'settings.terminal.behavior.middleClick.disabled': '无动作',
|
||||
'settings.terminal.behavior.bracketedPaste': '括号粘贴模式',
|
||||
'settings.terminal.behavior.bracketedPaste.desc':
|
||||
'粘贴文本时使用转义序列包裹,以便终端区分粘贴和键入。如果出现 ^[[200~ 字样请关闭此选项。',
|
||||
@@ -336,6 +341,8 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.shortcuts.scheme.disabled': '禁用',
|
||||
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
|
||||
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
|
||||
'settings.shortcuts.disableTerminalFontZoom.label': '禁用终端缩放',
|
||||
'settings.shortcuts.disableTerminalFontZoom.desc': '关闭终端文字缩放快捷操作,包括 Cmd/Ctrl 加滚轮。',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': '数字键跳过固定标签',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': '开启后,Cmd/Ctrl+[1...9] 仅在终端、工作区、编辑器等可关闭标签页之间切换,不包括固定的 Vault 和 SFTP 标签页。',
|
||||
'settings.shortcuts.section.custom': '自定义快捷键',
|
||||
|
||||
@@ -216,6 +216,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.toolbar.openSftp': '打开 SFTP',
|
||||
'terminal.toolbar.availableAfterConnect': '连接后可用',
|
||||
'terminal.toolbar.sendYmodem': 'YMODEM 发送',
|
||||
'terminal.toolbar.receiveYmodem': 'YMODEM 接收',
|
||||
'terminal.toolbar.sftp': 'SFTP',
|
||||
'terminal.toolbar.more': '更多操作',
|
||||
'terminal.toolbar.scripts': '脚本',
|
||||
@@ -281,7 +282,9 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.dragDrop.localTitle': '拖放以插入路径',
|
||||
'terminal.dragDrop.localMessage': '文件路径将被插入到终端',
|
||||
'terminal.dragDrop.remoteTitle': '拖放以上传文件',
|
||||
'terminal.dragDrop.remoteMessage': '文件将通过 SFTP 上传',
|
||||
'terminal.dragDrop.remoteZmodemMessage': '文件将通过 ZMODEM(PTY)上传',
|
||||
'terminal.dragDrop.remoteSftpMessage': '文件将通过 SFTP 上传',
|
||||
'terminal.dragDrop.noFiles': '没有可上传的文件',
|
||||
'terminal.dragDrop.notConnected': '无法拖放文件 - 终端未连接',
|
||||
'terminal.dragDrop.errorTitle': '拖放错误',
|
||||
'terminal.dragDrop.errorMessage': '处理拖放文件失败',
|
||||
@@ -296,6 +299,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.menu.selectAll': '全选',
|
||||
'terminal.menu.reconnect': '重新连接',
|
||||
'terminal.menu.sendYmodem': 'YMODEM 发送',
|
||||
'terminal.menu.receiveYmodem': 'YMODEM 接收',
|
||||
'terminal.menu.splitHorizontal': '水平分屏',
|
||||
'terminal.menu.splitVertical': '垂直分屏',
|
||||
'terminal.menu.clearBuffer': '清空缓冲区',
|
||||
@@ -305,6 +309,12 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.ymodem.started': '正在通过 YMODEM 发送 {fileName}',
|
||||
'terminal.ymodem.complete': 'YMODEM 已发送 {fileName}',
|
||||
'terminal.ymodem.failed': 'YMODEM 发送失败',
|
||||
'terminal.ymodem.selectReceiveDirectory': '选择接收文件保存位置',
|
||||
'terminal.ymodem.receiveStarted': '正在通过 YMODEM 接收...',
|
||||
'terminal.ymodem.receiveComplete': 'YMODEM 已接收 {fileName}',
|
||||
'terminal.ymodem.receiveCompleteMultiple': 'YMODEM 已接收 {count} 个文件',
|
||||
'terminal.ymodem.receiveEmpty': '没有接收到 YMODEM 文件',
|
||||
'terminal.ymodem.receiveFailed': 'YMODEM 接收失败',
|
||||
'terminal.ymodem.unavailable': 'YMODEM 当前不可用',
|
||||
'terminal.selection.addToAI': '添加到对话',
|
||||
'terminal.selection.addToAIDesc': '将选中的终端输出作为附件加入 AI 草稿',
|
||||
@@ -676,6 +686,8 @@ export const zhCNVaultMessages: Messages = {
|
||||
'sftp.tabs.addTab': '新建标签页',
|
||||
'sftp.tabs.closeTab': '关闭标签页',
|
||||
'sftp.tabs.newTab': '新标签页',
|
||||
'sftp.tabs.copyDefaultPath': '复制标签页(默认路径)',
|
||||
'sftp.tabs.copyCurrentPath': '复制并跳转到当前路径',
|
||||
'sftp.conflict.title': '文件冲突',
|
||||
'sftp.conflict.desc': '目标位置已存在同名文件',
|
||||
'sftp.conflict.alreadyExistsSuffix': '已存在',
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
STORAGE_KEY_HOTKEY_RECORDING,
|
||||
STORAGE_KEY_HOTKEY_SCHEME,
|
||||
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
|
||||
STORAGE_KEY_SESSION_LOGS_DIR,
|
||||
STORAGE_KEY_SESSION_LOGS_ENABLED,
|
||||
STORAGE_KEY_SESSION_LOGS_FORMAT,
|
||||
@@ -73,6 +74,7 @@ interface UseSettingsIpcSyncParams {
|
||||
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
|
||||
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
|
||||
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
|
||||
setDisableTerminalFontZoomState: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpTransferConcurrencyState: Dispatch<SetStateAction<number>>;
|
||||
}
|
||||
|
||||
@@ -105,6 +107,7 @@ export function useSettingsIpcSync({
|
||||
setSftpDefaultViewMode,
|
||||
setWorkspaceFocusStyleState,
|
||||
setShowHostTreeSidebarState,
|
||||
setDisableTerminalFontZoomState,
|
||||
setSftpTransferConcurrencyState,
|
||||
}: UseSettingsIpcSyncParams) {
|
||||
// Listen for settings changes from other windows via IPC
|
||||
@@ -228,6 +231,9 @@ export function useSettingsIpcSync({
|
||||
if (key === STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR && typeof value === 'boolean') {
|
||||
setShowHostTreeSidebarState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM && typeof value === 'boolean') {
|
||||
setDisableTerminalFontZoomState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY && typeof value === 'number') {
|
||||
setSftpTransferConcurrencyState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
@@ -258,6 +264,7 @@ export function useSettingsIpcSync({
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
setShowHostTreeSidebarState,
|
||||
setDisableTerminalFontZoomState,
|
||||
setSftpTransferConcurrencyState,
|
||||
setTerminalFontFamilyId,
|
||||
setTerminalFontSize,
|
||||
|
||||
@@ -65,6 +65,7 @@ export const DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT = false;
|
||||
export const DEFAULT_SHOW_SFTP_TAB = true;
|
||||
export const DEFAULT_SHOW_HOST_TREE_SIDEBAR = true;
|
||||
export const DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS = false;
|
||||
export const DEFAULT_DISABLE_TERMINAL_FONT_ZOOM = false;
|
||||
|
||||
// Editor defaults
|
||||
export const DEFAULT_EDITOR_WORD_WRAP = false;
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
|
||||
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_FONT_SIZE,
|
||||
@@ -79,6 +80,7 @@ interface UseSettingsStorageSyncParams {
|
||||
showSftpTab: boolean;
|
||||
showHostTreeSidebar: boolean;
|
||||
shellOnlyTabNumberShortcuts: boolean;
|
||||
disableTerminalFontZoom: boolean;
|
||||
editorWordWrap: boolean;
|
||||
sessionLogsEnabled: boolean;
|
||||
sessionLogsDir: string;
|
||||
@@ -115,6 +117,7 @@ interface UseSettingsStorageSyncParams {
|
||||
setShowSftpTabState: Dispatch<SetStateAction<boolean>>;
|
||||
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
|
||||
setShellOnlyTabNumberShortcutsState: Dispatch<SetStateAction<boolean>>;
|
||||
setDisableTerminalFontZoomState: Dispatch<SetStateAction<boolean>>;
|
||||
setEditorWordWrapState: Dispatch<SetStateAction<boolean>>;
|
||||
setSessionLogsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSessionLogsDir: Dispatch<SetStateAction<string>>;
|
||||
@@ -136,7 +139,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
|
||||
@@ -145,7 +148,7 @@ export function useSettingsStorageSync({
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setShellOnlyTabNumberShortcutsState,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
|
||||
@@ -159,7 +162,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
});
|
||||
@@ -169,7 +172,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
};
|
||||
@@ -389,6 +392,12 @@ export function useSettingsStorageSync({
|
||||
setShellOnlyTabNumberShortcutsState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.disableTerminalFontZoom) {
|
||||
setDisableTerminalFontZoomState(newValue);
|
||||
}
|
||||
}
|
||||
// Sync global hotkey enabled setting from other windows
|
||||
if (e.key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
@@ -458,6 +467,7 @@ export function useSettingsStorageSync({
|
||||
setShowRecentHostsState,
|
||||
setShowSftpTabState,
|
||||
setShellOnlyTabNumberShortcutsState,
|
||||
setDisableTerminalFontZoomState,
|
||||
setTerminalFontFamilyId,
|
||||
setTerminalFontSize,
|
||||
setTerminalThemeDarkId,
|
||||
|
||||
56
application/state/sftp/sftpConnectStartPath.test.ts
Normal file
56
application/state/sftp/sftpConnectStartPath.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type { RemoteSftpStartCache } from "./sftpConnectStartPath.ts";
|
||||
import {
|
||||
normalizeSftpInitialPath,
|
||||
resolveRemoteSftpStartState,
|
||||
} from "./sftpConnectStartPath.ts";
|
||||
|
||||
const cached: RemoteSftpStartCache = {
|
||||
path: "/var/cache",
|
||||
homeDir: "/home/deploy",
|
||||
files: [],
|
||||
filenameEncoding: "auto",
|
||||
};
|
||||
|
||||
test("remote SFTP default-path duplication ignores the shared host cache", () => {
|
||||
const state = resolveRemoteSftpStartState({
|
||||
filenameEncoding: "auto",
|
||||
ignoreSharedCache: true,
|
||||
sharedHostCacheCandidate: cached,
|
||||
});
|
||||
|
||||
assert.equal(state.initialPath, undefined);
|
||||
assert.equal(state.sharedHostCache, null);
|
||||
assert.equal(state.cachedStartPath, "/");
|
||||
});
|
||||
|
||||
test("remote SFTP current-path duplication uses the requested path instead of stale cache", () => {
|
||||
const state = resolveRemoteSftpStartState({
|
||||
filenameEncoding: "auto",
|
||||
initialPath: "/var/www/app",
|
||||
sharedHostCacheCandidate: cached,
|
||||
});
|
||||
|
||||
assert.equal(state.initialPath, "/var/www/app");
|
||||
assert.equal(state.sharedHostCache, null);
|
||||
assert.equal(state.cachedStartPath, "/var/www/app");
|
||||
});
|
||||
|
||||
test("remote SFTP initial paths preserve meaningful whitespace", () => {
|
||||
assert.equal(normalizeSftpInitialPath("/var/www/app "), "/var/www/app ");
|
||||
|
||||
const state = resolveRemoteSftpStartState({
|
||||
filenameEncoding: "auto",
|
||||
initialPath: "/var/www/app ",
|
||||
sharedHostCacheCandidate: {
|
||||
...cached,
|
||||
path: "/var/www/app",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(state.initialPath, "/var/www/app ");
|
||||
assert.equal(state.sharedHostCache, null);
|
||||
assert.equal(state.cachedStartPath, "/var/www/app ");
|
||||
});
|
||||
44
application/state/sftp/sftpConnectStartPath.ts
Normal file
44
application/state/sftp/sftpConnectStartPath.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
|
||||
|
||||
export interface RemoteSftpStartCache {
|
||||
path: string;
|
||||
homeDir: string;
|
||||
files: SftpFileEntry[];
|
||||
filenameEncoding: SftpFilenameEncoding;
|
||||
}
|
||||
|
||||
interface ResolveRemoteSftpStartStateParams {
|
||||
filenameEncoding: SftpFilenameEncoding;
|
||||
ignoreSharedCache?: boolean;
|
||||
initialPath?: string;
|
||||
sharedHostCacheCandidate: RemoteSftpStartCache | null;
|
||||
}
|
||||
|
||||
export function normalizeSftpInitialPath(initialPath?: string): string | undefined {
|
||||
return initialPath === undefined || initialPath.length === 0 ? undefined : initialPath;
|
||||
}
|
||||
|
||||
export function resolveRemoteSftpStartState({
|
||||
filenameEncoding,
|
||||
ignoreSharedCache,
|
||||
initialPath,
|
||||
sharedHostCacheCandidate,
|
||||
}: ResolveRemoteSftpStartStateParams): {
|
||||
initialPath: string | undefined;
|
||||
sharedHostCache: RemoteSftpStartCache | null;
|
||||
cachedStartPath: string;
|
||||
} {
|
||||
const requestedInitialPath = normalizeSftpInitialPath(initialPath);
|
||||
const sharedHostCache =
|
||||
!ignoreSharedCache
|
||||
&& sharedHostCacheCandidate?.filenameEncoding === filenameEncoding
|
||||
&& (!requestedInitialPath || sharedHostCacheCandidate.path === requestedInitialPath)
|
||||
? sharedHostCacheCandidate
|
||||
: null;
|
||||
|
||||
return {
|
||||
initialPath: requestedInitialPath,
|
||||
sharedHostCache,
|
||||
cachedStartPath: requestedInitialPath ?? sharedHostCache?.path ?? "/",
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
|
||||
|
||||
interface SharedRemoteHostCacheEntry {
|
||||
export interface SharedRemoteHostCacheEntry {
|
||||
path: string;
|
||||
homeDir: string;
|
||||
files: SftpFileEntry[];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SftpConnection, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
|
||||
import { KnownHost, SftpConnection, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
|
||||
|
||||
export interface SftpPane {
|
||||
id: string;
|
||||
@@ -15,6 +15,22 @@ export interface SftpPane {
|
||||
transferMutationToken: number;
|
||||
}
|
||||
|
||||
export interface SftpHostKeyInfo {
|
||||
hostname: string;
|
||||
port: number;
|
||||
keyType: string;
|
||||
fingerprint: string;
|
||||
publicKey?: string;
|
||||
status?: "unknown" | "changed";
|
||||
knownHostId?: string;
|
||||
knownFingerprint?: string;
|
||||
}
|
||||
|
||||
export interface SftpHostKeyVerificationState {
|
||||
hostKeyInfo: SftpHostKeyInfo;
|
||||
progressLogs: string[];
|
||||
}
|
||||
|
||||
// Multi-tab state for left and right sides
|
||||
export interface SftpSideTabs {
|
||||
tabs: SftpPane[];
|
||||
@@ -70,4 +86,6 @@ export interface SftpStateOptions {
|
||||
* is honored for SFTP browsing too (not just the terminal session).
|
||||
*/
|
||||
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
|
||||
knownHosts?: KnownHost[];
|
||||
onAddKnownHost?: (knownHost: KnownHost) => void;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import React, { useCallback, useEffect, useRef } from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import type { Host, Identity, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";
|
||||
import type { SftpPane } from "./types";
|
||||
import type { Host, Identity, KnownHost, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";
|
||||
import type { SftpHostKeyInfo, SftpHostKeyVerificationState, SftpPane } from "./types";
|
||||
import { useSftpDirectoryListing } from "./useSftpDirectoryListing";
|
||||
import { useSftpHostCredentials } from "./useSftpHostCredentials";
|
||||
import { buildCacheKey, getSharedRemoteHostCache, setSharedRemoteHostCache } from "./sharedRemoteHostCache";
|
||||
import { resolveRemoteSftpStartState } from "./sftpConnectStartPath";
|
||||
|
||||
interface UseSftpConnectionsParams {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
knownHosts?: KnownHost[];
|
||||
onAddKnownHost?: (knownHost: KnownHost) => void;
|
||||
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
|
||||
leftTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
|
||||
rightTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
|
||||
@@ -34,17 +37,61 @@ interface UseSftpConnectionsParams {
|
||||
autoConnectLocalOnMount?: boolean;
|
||||
}
|
||||
|
||||
export interface SftpConnectOptions {
|
||||
forceNewTab?: boolean;
|
||||
ignoreSharedCache?: boolean;
|
||||
initialPath?: string;
|
||||
onTabCreated?: (tabId: string) => void;
|
||||
sourceSessionId?: string;
|
||||
}
|
||||
|
||||
interface UseSftpConnectionsResult {
|
||||
connect: (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean; onTabCreated?: (tabId: string) => void }) => Promise<void>;
|
||||
connect: (side: "left" | "right", host: Host | "local", options?: SftpConnectOptions) => Promise<void>;
|
||||
disconnect: (side: "left" | "right") => Promise<void>;
|
||||
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
|
||||
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
|
||||
hostKeyVerification: SftpHostKeyVerificationState | null;
|
||||
rejectHostKeyVerification: () => void;
|
||||
acceptHostKeyVerification: () => void;
|
||||
acceptAndSaveHostKeyVerification: () => void;
|
||||
}
|
||||
|
||||
type HostKeyVerificationRequest = SftpHostKeyInfo & {
|
||||
requestId: string;
|
||||
sessionId?: string;
|
||||
};
|
||||
|
||||
const toSftpHostKeyInfo = (request: HostKeyVerificationRequest): SftpHostKeyInfo => ({
|
||||
hostname: request.hostname,
|
||||
port: request.port || 22,
|
||||
keyType: request.keyType,
|
||||
fingerprint: request.fingerprint,
|
||||
publicKey: request.publicKey,
|
||||
status: request.status,
|
||||
knownHostId: request.knownHostId,
|
||||
knownFingerprint: request.knownFingerprint,
|
||||
});
|
||||
|
||||
const createKnownHostFromSftpHostKeyInfo = (
|
||||
hostKeyInfo: SftpHostKeyInfo,
|
||||
now = Date.now(),
|
||||
idSuffix = Math.random().toString(36).slice(2, 11),
|
||||
): KnownHost => ({
|
||||
id: hostKeyInfo.knownHostId || `kh-${now}-${idSuffix}`,
|
||||
hostname: hostKeyInfo.hostname,
|
||||
port: hostKeyInfo.port || 22,
|
||||
keyType: hostKeyInfo.keyType,
|
||||
publicKey: hostKeyInfo.publicKey || `SHA256:${hostKeyInfo.fingerprint}`,
|
||||
fingerprint: hostKeyInfo.fingerprint,
|
||||
discoveredAt: now,
|
||||
});
|
||||
|
||||
export const useSftpConnections = ({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
knownHosts,
|
||||
onAddKnownHost,
|
||||
terminalSettings,
|
||||
leftTabsRef,
|
||||
rightTabsRef,
|
||||
@@ -67,11 +114,79 @@ export const useSftpConnections = ({
|
||||
createEmptyPane,
|
||||
autoConnectLocalOnMount = true,
|
||||
}: UseSftpConnectionsParams): UseSftpConnectionsResult => {
|
||||
const getHostCredentials = useSftpHostCredentials({ hosts, keys, identities, terminalSettings });
|
||||
const getHostCredentials = useSftpHostCredentials({ hosts, keys, identities, knownHosts, terminalSettings });
|
||||
const { listLocalFiles, listRemoteFiles } = useSftpDirectoryListing();
|
||||
const [hostKeyVerification, setHostKeyVerification] = useState<SftpHostKeyVerificationState | null>(null);
|
||||
const hostKeyVerificationRef = useRef<(SftpHostKeyVerificationState & { requestId: string; sessionId: string }) | null>(null);
|
||||
const activeHostKeySessionsRef = useRef<Map<string, { side: "left" | "right"; tabId: string }>>(new Map());
|
||||
|
||||
const setPendingHostKeyVerification = useCallback((
|
||||
next: (SftpHostKeyVerificationState & { requestId: string; sessionId: string }) | null,
|
||||
) => {
|
||||
hostKeyVerificationRef.current = next;
|
||||
setHostKeyVerification(next ? {
|
||||
hostKeyInfo: next.hostKeyInfo,
|
||||
progressLogs: next.progressLogs,
|
||||
} : null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const dispose = netcattyBridge.get()?.onHostKeyVerification?.((request: HostKeyVerificationRequest) => {
|
||||
const sessionId = request.sessionId;
|
||||
if (!sessionId) return;
|
||||
const activeSession = activeHostKeySessionsRef.current.get(sessionId);
|
||||
if (!activeSession) return;
|
||||
|
||||
const hostKeyInfo = toSftpHostKeyInfo(request);
|
||||
const logLine = request.status === "changed"
|
||||
? `Host key changed for ${request.hostname}. Waiting for confirmation...`
|
||||
: `Host key verification required for ${request.hostname}.`;
|
||||
|
||||
updateTab(activeSession.side, activeSession.tabId, (prev) => ({
|
||||
...prev,
|
||||
connectionLogs: [...prev.connectionLogs, logLine],
|
||||
}));
|
||||
setPendingHostKeyVerification({
|
||||
requestId: request.requestId,
|
||||
sessionId,
|
||||
hostKeyInfo,
|
||||
progressLogs: [logLine],
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
dispose?.();
|
||||
};
|
||||
}, [setPendingHostKeyVerification, updateTab]);
|
||||
|
||||
const respondToHostKeyVerification = useCallback((accept: boolean, addToKnownHosts = false) => {
|
||||
const pending = hostKeyVerificationRef.current;
|
||||
if (!pending) return;
|
||||
if (accept && addToKnownHosts) {
|
||||
onAddKnownHost?.(createKnownHostFromSftpHostKeyInfo(pending.hostKeyInfo));
|
||||
}
|
||||
void netcattyBridge.get()?.respondHostKeyVerification?.(
|
||||
pending.requestId,
|
||||
accept,
|
||||
addToKnownHosts,
|
||||
);
|
||||
setPendingHostKeyVerification(null);
|
||||
}, [onAddKnownHost, setPendingHostKeyVerification]);
|
||||
|
||||
const rejectHostKeyVerification = useCallback(() => {
|
||||
respondToHostKeyVerification(false);
|
||||
}, [respondToHostKeyVerification]);
|
||||
|
||||
const acceptHostKeyVerification = useCallback(() => {
|
||||
respondToHostKeyVerification(true, false);
|
||||
}, [respondToHostKeyVerification]);
|
||||
|
||||
const acceptAndSaveHostKeyVerification = useCallback(() => {
|
||||
respondToHostKeyVerification(true, true);
|
||||
}, [respondToHostKeyVerification]);
|
||||
|
||||
const connect = useCallback(
|
||||
async (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean; onTabCreated?: (tabId: string) => void; sourceSessionId?: string }) => {
|
||||
async (side: "left" | "right", host: Host | "local", options?: SftpConnectOptions) => {
|
||||
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
|
||||
|
||||
let activeTabId: string | null = null;
|
||||
@@ -101,6 +216,33 @@ export const useSftpConnections = ({
|
||||
|
||||
navSeqRef.current[side] += 1;
|
||||
const connectRequestId = navSeqRef.current[side];
|
||||
const getTargetPane = () => {
|
||||
const tabs = side === "left" ? leftTabsRef.current.tabs : rightTabsRef.current.tabs;
|
||||
return tabs.find((tab) => tab.id === activeTabId) ?? null;
|
||||
};
|
||||
const isTargetConnectionCurrent = () => {
|
||||
const pane = getTargetPane();
|
||||
if (!pane) return false;
|
||||
if (pane.connection?.id === connectionId) return true;
|
||||
return !pane.connection && navSeqRef.current[side] === connectRequestId;
|
||||
};
|
||||
const isTargetConnectionAtPath = (path: string) => {
|
||||
const connection = getTargetPane()?.connection;
|
||||
if (!connection) return navSeqRef.current[side] === connectRequestId;
|
||||
return connection?.id === connectionId && connection.currentPath === path;
|
||||
};
|
||||
const closeSftpSessionForConnection = async () => {
|
||||
const sftpId = sftpSessionsRef.current.get(connectionId);
|
||||
sftpSessionsRef.current.delete(connectionId);
|
||||
connectionCacheKeyMapRef.current.delete(connectionId);
|
||||
clearCacheForConnection(connectionId);
|
||||
if (!sftpId) return;
|
||||
try {
|
||||
await netcattyBridge.get()?.closeSftp(sftpId);
|
||||
} catch {
|
||||
// Ignore errors when closing stale SFTP sessions
|
||||
}
|
||||
};
|
||||
|
||||
lastConnectedHostRef.current[side] = host;
|
||||
// Store the cache key for this connection so pane actions can look it up
|
||||
@@ -147,13 +289,15 @@ export const useSftpConnections = ({
|
||||
homeDir = isWindows ? "C:\\Users\\damao" : "/Users/damao";
|
||||
}
|
||||
|
||||
const startPath = options?.initialPath || homeDir;
|
||||
|
||||
const connection: SftpConnection = {
|
||||
id: connectionId,
|
||||
hostId: "local",
|
||||
hostLabel: "Local",
|
||||
isLocal: true,
|
||||
status: "connected",
|
||||
currentPath: homeDir,
|
||||
currentPath: startPath,
|
||||
homeDir,
|
||||
};
|
||||
|
||||
@@ -168,9 +312,9 @@ export const useSftpConnections = ({
|
||||
}));
|
||||
|
||||
try {
|
||||
const files = await listLocalFiles(homeDir);
|
||||
if (navSeqRef.current[side] !== connectRequestId) return;
|
||||
dirCacheRef.current.set(makeCacheKey(connectionId, homeDir, filenameEncoding), {
|
||||
const files = await listLocalFiles(startPath);
|
||||
if (!isTargetConnectionAtPath(startPath)) return;
|
||||
dirCacheRef.current.set(makeCacheKey(connectionId, startPath, filenameEncoding), {
|
||||
files,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
@@ -182,7 +326,7 @@ export const useSftpConnections = ({
|
||||
reconnecting: false,
|
||||
}));
|
||||
} catch (err) {
|
||||
if (navSeqRef.current[side] !== connectRequestId) return;
|
||||
if (!isTargetConnectionAtPath(startPath)) return;
|
||||
reconnectingRef.current[side] = false;
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
@@ -193,12 +337,15 @@ export const useSftpConnections = ({
|
||||
}
|
||||
} else {
|
||||
const hostCacheKey = buildCacheKey(host.id, host.hostname, host.port, host.protocol, host.sftpSudo, host.username);
|
||||
const sharedHostCacheCandidate = getSharedRemoteHostCache(hostCacheKey);
|
||||
const sharedHostCache =
|
||||
sharedHostCacheCandidate?.filenameEncoding === filenameEncoding
|
||||
? sharedHostCacheCandidate
|
||||
: null;
|
||||
const cachedStartPath = sharedHostCache?.path ?? "/";
|
||||
const sharedHostCacheCandidate = options?.ignoreSharedCache
|
||||
? null
|
||||
: getSharedRemoteHostCache(hostCacheKey);
|
||||
const { initialPath, sharedHostCache, cachedStartPath } = resolveRemoteSftpStartState({
|
||||
filenameEncoding,
|
||||
ignoreSharedCache: options?.ignoreSharedCache,
|
||||
initialPath: options?.initialPath,
|
||||
sharedHostCacheCandidate,
|
||||
});
|
||||
|
||||
const connection: SftpConnection = {
|
||||
id: connectionId,
|
||||
@@ -230,6 +377,7 @@ export const useSftpConnections = ({
|
||||
|
||||
// Subscribe to SFTP connection progress events for auth logging
|
||||
const sftpSessionId = `sftp-${connectionId}`;
|
||||
activeHostKeySessionsRef.current.set(sftpSessionId, { side, tabId: activeTabId });
|
||||
let unsubSftpProgress: (() => void) | undefined;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.onSftpConnectionProgress) {
|
||||
@@ -264,7 +412,7 @@ export const useSftpConnections = ({
|
||||
logLine = `${label} - ${status}${detail ? `: ${detail}` : ''}`;
|
||||
}
|
||||
// Only update if this is still the active request (avoids stale logs leaking)
|
||||
if (navSeqRef.current[side] !== connectRequestId) return;
|
||||
if (!isTargetConnectionCurrent()) return;
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
connectionLogs: [...prev.connectionLogs, logLine],
|
||||
@@ -295,7 +443,7 @@ export const useSftpConnections = ({
|
||||
if (hasKey) {
|
||||
try {
|
||||
const keyFirstCredentials = {
|
||||
sessionId: `sftp-${connectionId}`,
|
||||
sessionId: sftpSessionId,
|
||||
...credentials,
|
||||
sourceSessionId: options?.sourceSessionId,
|
||||
};
|
||||
@@ -306,7 +454,7 @@ export const useSftpConnections = ({
|
||||
} catch (err) {
|
||||
if (hasPassword && isAuthError(err)) {
|
||||
sftpId = await openSftp({
|
||||
sessionId: `sftp-${connectionId}`,
|
||||
sessionId: sftpSessionId,
|
||||
...credentials,
|
||||
sourceSessionId: options?.sourceSessionId,
|
||||
privateKey: undefined,
|
||||
@@ -322,7 +470,7 @@ export const useSftpConnections = ({
|
||||
}
|
||||
} else {
|
||||
sftpId = await openSftp({
|
||||
sessionId: `sftp-${connectionId}`,
|
||||
sessionId: sftpSessionId,
|
||||
...credentials,
|
||||
sourceSessionId: options?.sourceSessionId,
|
||||
});
|
||||
@@ -331,6 +479,10 @@ export const useSftpConnections = ({
|
||||
if (!sftpId) throw new Error("Failed to open SFTP session");
|
||||
|
||||
sftpSessionsRef.current.set(connectionId, sftpId);
|
||||
if (!isTargetConnectionCurrent()) {
|
||||
await closeSftpSessionForConnection();
|
||||
return;
|
||||
}
|
||||
|
||||
let startPath = sharedHostCache?.path ?? "/";
|
||||
let homeDir = sharedHostCache?.homeDir ?? startPath;
|
||||
@@ -395,6 +547,10 @@ export const useSftpConnections = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (initialPath) {
|
||||
startPath = initialPath;
|
||||
}
|
||||
|
||||
const provisionalCacheKey = sharedHostCache
|
||||
? makeCacheKey(connectionId, startPath, filenameEncoding)
|
||||
: null;
|
||||
@@ -438,7 +594,10 @@ export const useSftpConnections = ({
|
||||
throw new Error("Cannot list any remote directory");
|
||||
}
|
||||
}
|
||||
if (navSeqRef.current[side] !== connectRequestId) return;
|
||||
if (!isTargetConnectionCurrent()) {
|
||||
await closeSftpSessionForConnection();
|
||||
return;
|
||||
}
|
||||
dirCacheRef.current.set(makeCacheKey(connectionId, startPath, filenameEncoding), {
|
||||
files,
|
||||
timestamp: Date.now(),
|
||||
@@ -469,7 +628,10 @@ export const useSftpConnections = ({
|
||||
connectionLogs: [], // Clear after successful connect to avoid replay during navigation
|
||||
}));
|
||||
} catch (err) {
|
||||
if (navSeqRef.current[side] !== connectRequestId) return;
|
||||
if (!isTargetConnectionCurrent()) {
|
||||
await closeSftpSessionForConnection();
|
||||
return;
|
||||
}
|
||||
reconnectingRef.current[side] = false;
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
@@ -489,6 +651,10 @@ export const useSftpConnections = ({
|
||||
reconnecting: false,
|
||||
}));
|
||||
} finally {
|
||||
activeHostKeySessionsRef.current.delete(sftpSessionId);
|
||||
if (hostKeyVerificationRef.current?.sessionId === sftpSessionId) {
|
||||
setPendingHostKeyVerification(null);
|
||||
}
|
||||
unsubSftpProgress?.();
|
||||
}
|
||||
}
|
||||
@@ -503,6 +669,7 @@ export const useSftpConnections = ({
|
||||
makeCacheKey,
|
||||
listLocalFiles,
|
||||
listRemoteFiles,
|
||||
setPendingHostKeyVerification,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -588,5 +755,9 @@ export const useSftpConnections = ({
|
||||
disconnect,
|
||||
listLocalFiles,
|
||||
listRemoteFiles,
|
||||
hostKeyVerification,
|
||||
rejectHostKeyVerification,
|
||||
acceptHostKeyVerification,
|
||||
acceptAndSaveHostKeyVerification,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useRef, useMemo, useState } from "react";
|
||||
import { FileConflict, FileConflictAction, TransferStatus, SftpFilenameEncoding } from "../../../domain/models";
|
||||
import { getSftpConflictTypeKey } from "../../../domain/sftpConflict";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { notify } from "../../notification";
|
||||
@@ -501,7 +502,7 @@ export const useSftpExternalOperations = (
|
||||
newModified: number;
|
||||
applyToAllCount: number;
|
||||
}): Promise<FileConflictAction> => {
|
||||
const conflictType = conflict.isDirectory ? "directory" : "file";
|
||||
const conflictType = getSftpConflictTypeKey(conflict.isDirectory, conflict.existingType);
|
||||
const defaultAction = conflictDefaults.get(conflictType);
|
||||
if (defaultAction) return defaultAction;
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { buildSftpHostCredentials } from "./useSftpHostCredentials.ts";
|
||||
import type { Host, SSHKey } from "../../../domain/models.ts";
|
||||
import type { Host, KnownHost, SSHKey } from "../../../domain/models.ts";
|
||||
|
||||
const host = (overrides: Partial<Host> = {}): Host => ({
|
||||
id: "host-1",
|
||||
@@ -102,6 +102,28 @@ test("buildSftpHostCredentials passes reference keys as identity file paths", ()
|
||||
assert.equal(credentials.passphrase, "saved-passphrase");
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials forwards known hosts for SFTP host-key checks", () => {
|
||||
const knownHosts: KnownHost[] = [{
|
||||
id: "kh-1",
|
||||
hostname: "example.com",
|
||||
port: 22,
|
||||
keyType: "ssh-ed25519",
|
||||
publicKey: "SHA256:abc",
|
||||
fingerprint: "abc",
|
||||
discoveredAt: 1,
|
||||
}];
|
||||
|
||||
const credentials = buildSftpHostCredentials({
|
||||
host: host(),
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
knownHosts,
|
||||
});
|
||||
|
||||
assert.equal(credentials.knownHosts, knownHosts);
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials passes jump host reference keys as identity file paths", () => {
|
||||
const key: SSHKey = {
|
||||
id: "jump-key",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback } from "react";
|
||||
import type { Host, Identity, SSHKey, TerminalSettings } from "../../../domain/models";
|
||||
import type { Host, Identity, KnownHost, SSHKey, TerminalSettings } from "../../../domain/models";
|
||||
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "../../../domain/credentials";
|
||||
import { resolveBridgeKeyAuth, resolveHostAuth } from "../../../domain/sshAuth";
|
||||
import { resolveHostKeepalive } from "../../../domain/host";
|
||||
@@ -14,6 +14,7 @@ interface UseSftpHostCredentialsParams {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
knownHosts?: KnownHost[];
|
||||
terminalSettings?: Pick<TerminalSettings, 'keepaliveInterval' | 'keepaliveCountMax'>;
|
||||
}
|
||||
|
||||
@@ -22,6 +23,7 @@ export const buildSftpHostCredentials = ({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
knownHosts,
|
||||
terminalSettings,
|
||||
}: UseSftpHostCredentialsParams & { host: Host }): NetcattySSHOptions => {
|
||||
const globalKeepalive = terminalSettings ?? FALLBACK_KEEPALIVE;
|
||||
@@ -165,6 +167,7 @@ export const buildSftpHostCredentials = ({
|
||||
identityFilePaths: keyAuth.identityFilePaths,
|
||||
keepaliveInterval: targetKeepalive.interval,
|
||||
keepaliveCountMax: targetKeepalive.countMax,
|
||||
knownHosts,
|
||||
// Algorithm settings — must reach the SFTP bridge or hosts that need
|
||||
// legacy mode / the ECDSA skip / advanced overrides would still hit
|
||||
// the original negotiation failure when opening their SFTP pane,
|
||||
@@ -179,9 +182,10 @@ export const useSftpHostCredentials = ({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
knownHosts,
|
||||
terminalSettings,
|
||||
}: UseSftpHostCredentialsParams) =>
|
||||
useCallback(
|
||||
(host: Host): NetcattySSHOptions => buildSftpHostCredentials({ host, hosts, keys, identities, terminalSettings }),
|
||||
[hosts, identities, keys, terminalSettings],
|
||||
(host: Host): NetcattySSHOptions => buildSftpHostCredentials({ host, hosts, keys, identities, knownHosts, terminalSettings }),
|
||||
[hosts, identities, keys, knownHosts, terminalSettings],
|
||||
);
|
||||
|
||||
@@ -7,6 +7,12 @@ import {
|
||||
TransferStatus,
|
||||
TransferTask,
|
||||
} from "../../../domain/models";
|
||||
import {
|
||||
canReplaceSftpConflict,
|
||||
describeSftpExistingKind,
|
||||
describeSftpIncomingKind,
|
||||
getSftpConflictTypeKey,
|
||||
} from "../../../domain/sftpConflict";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { SftpPane } from "./types";
|
||||
@@ -69,8 +75,14 @@ export const useSftpTransfers = ({
|
||||
);
|
||||
|
||||
const conflictDefaultKey = useCallback(
|
||||
(batchId: string | undefined, isDirectory: boolean) =>
|
||||
`${batchId ?? "global"}:${isDirectory ? "directory" : "file"}`,
|
||||
(batchId: string | undefined, isDirectory: boolean, existingType?: "file" | "directory" | "symlink") =>
|
||||
`${batchId ?? "global"}:${getSftpConflictTypeKey(isDirectory, existingType)}`,
|
||||
[],
|
||||
);
|
||||
|
||||
const buildReplaceTypeMismatchError = useCallback(
|
||||
(isDirectory: boolean, existingType: "file" | "directory" | "symlink" | undefined, targetPath: string) =>
|
||||
`Cannot replace existing ${describeSftpExistingKind(existingType)} with ${describeSftpIncomingKind(isDirectory)}: ${targetPath}`,
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -233,6 +245,33 @@ export const useSftpTransfers = ({
|
||||
const existingStat = await statTargetPath(targetPane, targetSftpId, task.targetPath, targetEncoding);
|
||||
|
||||
if (existingStat) {
|
||||
const applyToAllCount = task.batchId
|
||||
? await (async () => {
|
||||
const candidates = transfersRef.current.filter((candidate) =>
|
||||
candidate.batchId === task.batchId &&
|
||||
candidate.isDirectory === task.isDirectory &&
|
||||
!candidate.parentTaskId &&
|
||||
candidate.status !== "completed" &&
|
||||
candidate.status !== "cancelled",
|
||||
);
|
||||
const matches = await Promise.all(candidates.map(async (candidate) => {
|
||||
if (candidate.id === task.id) return true;
|
||||
try {
|
||||
const candidateStat = await statTargetPath(
|
||||
targetPane,
|
||||
targetSftpId,
|
||||
candidate.targetPath,
|
||||
targetEncoding,
|
||||
);
|
||||
return candidateStat?.type === existingStat.type;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}));
|
||||
return Math.max(1, matches.filter(Boolean).length);
|
||||
})()
|
||||
: 1;
|
||||
|
||||
return {
|
||||
transferId: task.id,
|
||||
batchId: task.batchId,
|
||||
@@ -241,15 +280,7 @@ export const useSftpTransfers = ({
|
||||
targetPath: task.targetPath,
|
||||
isDirectory: task.isDirectory,
|
||||
existingType: existingStat.type,
|
||||
applyToAllCount: task.batchId
|
||||
? transfersRef.current.filter((candidate) =>
|
||||
candidate.batchId === task.batchId &&
|
||||
candidate.isDirectory === task.isDirectory &&
|
||||
!candidate.parentTaskId &&
|
||||
candidate.status !== "completed" &&
|
||||
candidate.status !== "cancelled",
|
||||
).length
|
||||
: 1,
|
||||
applyToAllCount,
|
||||
existingSize: existingStat.size,
|
||||
newSize: sourceStat?.size || task.totalBytes || 0,
|
||||
existingModified: existingStat.mtime,
|
||||
@@ -271,7 +302,9 @@ export const useSftpTransfers = ({
|
||||
const conflict = await conflictCheckPromise;
|
||||
|
||||
if (conflict) {
|
||||
const defaultAction = conflictDefaultsRef.current.get(conflictDefaultKey(task.batchId, task.isDirectory));
|
||||
const defaultAction = conflictDefaultsRef.current.get(
|
||||
conflictDefaultKey(task.batchId, task.isDirectory, conflict.existingType),
|
||||
);
|
||||
if (defaultAction) {
|
||||
if (defaultAction === "stop") {
|
||||
await markBatchStopped(task);
|
||||
@@ -285,6 +318,16 @@ export const useSftpTransfers = ({
|
||||
return "cancelled";
|
||||
}
|
||||
|
||||
if (defaultAction === "replace" && !canReplaceSftpConflict(task.isDirectory, conflict.existingType)) {
|
||||
updateTask({
|
||||
status: "failed",
|
||||
endTime: Date.now(),
|
||||
error: buildReplaceTypeMismatchError(task.isDirectory, conflict.existingType, task.targetPath),
|
||||
retryable: false,
|
||||
});
|
||||
return "failed";
|
||||
}
|
||||
|
||||
const duplicateTarget = defaultAction === "duplicate"
|
||||
? await getDuplicateTarget(task, targetPane, targetSftpId, targetEncoding)
|
||||
: null;
|
||||
@@ -728,16 +771,19 @@ export const useSftpTransfers = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedConflictKey = conflictDefaultKey(task.batchId, task.isDirectory);
|
||||
const selectedConflictKey = conflictDefaultKey(conflict.batchId, conflict.isDirectory, conflict.existingType);
|
||||
const affectedConflicts = applyToAll
|
||||
? conflictsRef.current.filter((candidate) =>
|
||||
conflictDefaultKey(candidate.batchId, candidate.isDirectory) === selectedConflictKey,
|
||||
conflictDefaultKey(candidate.batchId, candidate.isDirectory, candidate.existingType) === selectedConflictKey,
|
||||
)
|
||||
: [conflict];
|
||||
const affectedConflictIds = new Set(affectedConflicts.map((candidate) => candidate.transferId));
|
||||
const affectedTasks = affectedConflicts
|
||||
.map((candidate) => transfersRef.current.find((transfer) => transfer.id === candidate.transferId))
|
||||
.filter((candidate): candidate is TransferTask => Boolean(candidate));
|
||||
const affectedConflictById = new Map<string, FileConflict>(
|
||||
affectedConflicts.map((candidate): [string, FileConflict] => [candidate.transferId, candidate]),
|
||||
);
|
||||
|
||||
if (applyToAll) {
|
||||
conflictDefaultsRef.current.set(selectedConflictKey, action);
|
||||
@@ -771,9 +817,11 @@ export const useSftpTransfers = ({
|
||||
}
|
||||
|
||||
const updatedTasks: TransferTask[] = [];
|
||||
const blockedReplaceTasks: Array<{ task: TransferTask; conflict: FileConflict }> = [];
|
||||
|
||||
for (const affectedTask of affectedTasks) {
|
||||
let updatedTask = { ...affectedTask };
|
||||
const affectedConflict = affectedConflictById.get(affectedTask.id);
|
||||
|
||||
if (action === "duplicate") {
|
||||
const endpoints = resolveTaskEndpoints(affectedTask);
|
||||
@@ -792,6 +840,13 @@ export const useSftpTransfers = ({
|
||||
skipConflictCheck: true,
|
||||
};
|
||||
} else if (action === "replace") {
|
||||
if (
|
||||
affectedConflict &&
|
||||
!canReplaceSftpConflict(affectedTask.isDirectory, affectedConflict.existingType)
|
||||
) {
|
||||
blockedReplaceTasks.push({ task: affectedTask, conflict: affectedConflict });
|
||||
continue;
|
||||
}
|
||||
updatedTask = {
|
||||
...affectedTask,
|
||||
skipConflictCheck: true,
|
||||
@@ -808,6 +863,28 @@ export const useSftpTransfers = ({
|
||||
updatedTasks.push(updatedTask);
|
||||
}
|
||||
|
||||
if (blockedReplaceTasks.length > 0) {
|
||||
const blockedTaskIds = new Set(blockedReplaceTasks.map(({ task }) => task.id));
|
||||
const blockedErrors = new Map(
|
||||
blockedReplaceTasks.map(({ task, conflict }) => [
|
||||
task.id,
|
||||
buildReplaceTypeMismatchError(task.isDirectory, conflict.existingType, task.targetPath),
|
||||
]),
|
||||
);
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) => blockedTaskIds.has(t.id)
|
||||
? {
|
||||
...t,
|
||||
status: "failed" as TransferStatus,
|
||||
endTime: Date.now(),
|
||||
error: blockedErrors.get(t.id),
|
||||
retryable: false,
|
||||
}
|
||||
: t,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const updatedTaskMap = new Map(updatedTasks.map((updatedTask) => [updatedTask.id, updatedTask]));
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) => {
|
||||
|
||||
53
application/state/shellHistoryPersistence.test.ts
Normal file
53
application/state/shellHistoryPersistence.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { buildDockerLogsCommand } from '../../domain/systemManager/dockerShell.ts';
|
||||
import { loadSanitizedShellHistory } from './shellHistoryPersistence.ts';
|
||||
import type { ShellHistoryEntry } from '../../domain/models.ts';
|
||||
|
||||
const entry = (id: string, command: string): ShellHistoryEntry => ({
|
||||
id,
|
||||
command,
|
||||
hostId: 'host-1',
|
||||
hostLabel: 'Host',
|
||||
sessionId: 'session-1',
|
||||
timestamp: 1000,
|
||||
});
|
||||
|
||||
test('loadSanitizedShellHistory removes persisted managed startup commands and writes back cleaned history', () => {
|
||||
const stored = [
|
||||
entry('managed', buildDockerLogsCommand('587abcdef123')),
|
||||
entry('user', 'docker ps -a'),
|
||||
];
|
||||
let written: ShellHistoryEntry[] | null = null;
|
||||
|
||||
const loaded = loadSanitizedShellHistory({
|
||||
read: () => stored,
|
||||
write: (_key, value) => {
|
||||
written = value;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
loaded?.map((item) => item.command),
|
||||
['docker ps -a'],
|
||||
);
|
||||
assert.deepEqual(written, loaded);
|
||||
});
|
||||
|
||||
test('loadSanitizedShellHistory does not write when persisted history is already clean', () => {
|
||||
const stored = [entry('user', 'docker ps -a')];
|
||||
let writeCount = 0;
|
||||
|
||||
const loaded = loadSanitizedShellHistory({
|
||||
read: () => stored,
|
||||
write: () => {
|
||||
writeCount += 1;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(loaded, stored);
|
||||
assert.equal(writeCount, 0);
|
||||
});
|
||||
23
application/state/shellHistoryPersistence.ts
Normal file
23
application/state/shellHistoryPersistence.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { ShellHistoryEntry } from '../../domain/models';
|
||||
import { sanitizeGlobalHistoryEntries } from '../../domain/globalHistory';
|
||||
import { STORAGE_KEY_SHELL_HISTORY } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
|
||||
type ShellHistoryStorage = {
|
||||
read<T>(key: string): T | null;
|
||||
write<T>(key: string, value: T): boolean;
|
||||
};
|
||||
|
||||
export function loadSanitizedShellHistory(
|
||||
storage: ShellHistoryStorage = localStorageAdapter,
|
||||
storageKey = STORAGE_KEY_SHELL_HISTORY,
|
||||
): ShellHistoryEntry[] | null {
|
||||
const savedShellHistory = storage.read<ShellHistoryEntry[]>(storageKey);
|
||||
if (!savedShellHistory) return null;
|
||||
|
||||
const cleanedShellHistory = sanitizeGlobalHistoryEntries(savedShellHistory);
|
||||
if (cleanedShellHistory.length !== savedShellHistory.length) {
|
||||
storage.write(storageKey, cleanedShellHistory);
|
||||
}
|
||||
return cleanedShellHistory;
|
||||
}
|
||||
@@ -139,6 +139,86 @@ test("uploads picked folder files with their relative directory structure", asyn
|
||||
]);
|
||||
});
|
||||
|
||||
test("does not replace an existing directory when uploading a same-named file", async () => {
|
||||
const file = new File(["local"], "dddd", { lastModified: 1234 });
|
||||
const deletedPaths: string[] = [];
|
||||
const uploadedPaths: string[] = [];
|
||||
|
||||
const results = await uploadFromFileList(
|
||||
[file],
|
||||
{
|
||||
targetPath: "/target",
|
||||
sftpId: "sftp-1",
|
||||
isLocal: false,
|
||||
bridge: {
|
||||
mkdirSftp: async () => {},
|
||||
statSftp: async (_sftpId, path) =>
|
||||
path === "/target/dddd"
|
||||
? { type: "directory", size: 0, lastModified: 1000 }
|
||||
: null,
|
||||
deleteSftp: async (_sftpId, path) => {
|
||||
deletedPaths.push(path);
|
||||
},
|
||||
writeSftpBinary: async (_sftpId, path) => {
|
||||
uploadedPaths.push(path);
|
||||
},
|
||||
},
|
||||
joinPath: (base, name) => `${base}/${name}`,
|
||||
resolveConflict: async () => "replace",
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(deletedPaths, []);
|
||||
assert.deepEqual(uploadedPaths, []);
|
||||
assert.equal(results.length, 1);
|
||||
assert.equal(results[0].fileName, "dddd");
|
||||
assert.equal(results[0].success, false);
|
||||
assert.match(results[0].error ?? "", /directory/i);
|
||||
});
|
||||
|
||||
test("counts apply-to-all upload conflicts by incoming and existing type", async () => {
|
||||
const files = [
|
||||
new File(["local"], "existing-file", { lastModified: 1234 }),
|
||||
new File(["local"], "existing-directory", { lastModified: 1234 }),
|
||||
];
|
||||
const conflictCounts: number[] = [];
|
||||
|
||||
const results = await uploadFromFileList(
|
||||
files,
|
||||
{
|
||||
targetPath: "/target",
|
||||
sftpId: "sftp-1",
|
||||
isLocal: false,
|
||||
bridge: {
|
||||
mkdirSftp: async () => {},
|
||||
statSftp: async (_sftpId, path) => {
|
||||
if (path === "/target/existing-file") {
|
||||
return { type: "file", size: 2, lastModified: 1000 };
|
||||
}
|
||||
if (path === "/target/existing-directory") {
|
||||
return { type: "directory", size: 0, lastModified: 1000 };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
writeSftpBinary: async () => {
|
||||
throw new Error("skipped conflicts should not upload");
|
||||
},
|
||||
},
|
||||
joinPath: (base, name) => `${base}/${name}`,
|
||||
resolveConflict: async (conflict) => {
|
||||
conflictCounts.push(conflict.applyToAllCount);
|
||||
return "skip";
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(conflictCounts, [1, 1]);
|
||||
assert.deepEqual(results, [
|
||||
{ fileName: "existing-file", success: false, cancelled: true },
|
||||
{ fileName: "existing-directory", success: false, cancelled: true },
|
||||
]);
|
||||
});
|
||||
|
||||
test("uploads path-backed clipboard files through stream transfer", async () => {
|
||||
const transfers: Array<{ sourcePath: string; targetPath: string; totalBytes?: number }> = [];
|
||||
const taskTotals: number[] = [];
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
STORAGE_KEY_AI_QUICK_MESSAGES,
|
||||
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import type { AIQuickMessage } from '../../infrastructure/ai/quickMessages';
|
||||
import { sanitizeQuickMessages } from '../../infrastructure/ai/quickMessages';
|
||||
@@ -29,6 +30,7 @@ import { DEFAULT_COMMAND_BLOCKLIST } from '../../infrastructure/ai/types';
|
||||
import { removeProviderReferences } from './aiProviderCleanup';
|
||||
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
|
||||
import { getAIBridge } from './aiStateSnapshots';
|
||||
import { useStoredBoolean } from './useStoredBoolean';
|
||||
|
||||
function readPermissionMode(): AIPermissionMode {
|
||||
const stored = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
|
||||
@@ -75,6 +77,10 @@ export function useAISettingsState() {
|
||||
const [quickMessages, setQuickMessagesRaw] = useState<AIQuickMessage[]>(() =>
|
||||
sanitizeQuickMessages(localStorageAdapter.read<unknown>(STORAGE_KEY_AI_QUICK_MESSAGES)),
|
||||
);
|
||||
const [showTerminalSelectionAIAction, setShowTerminalSelectionAIAction] = useStoredBoolean(
|
||||
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
|
||||
true,
|
||||
);
|
||||
|
||||
const setProviders = useCallback((value: ProviderConfig[] | ((prev: ProviderConfig[]) => ProviderConfig[])) => {
|
||||
setProvidersRaw((prev) => {
|
||||
@@ -307,6 +313,8 @@ export function useAISettingsState() {
|
||||
setWebSearchConfig,
|
||||
quickMessages,
|
||||
setQuickMessages,
|
||||
showTerminalSelectionAIAction,
|
||||
setShowTerminalSelectionAIAction,
|
||||
}), [
|
||||
providers,
|
||||
setProviders,
|
||||
@@ -336,5 +344,7 @@ export function useAISettingsState() {
|
||||
setWebSearchConfig,
|
||||
quickMessages,
|
||||
setQuickMessages,
|
||||
showTerminalSelectionAIAction,
|
||||
setShowTerminalSelectionAIAction,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||
import {
|
||||
@@ -89,6 +90,7 @@ import {
|
||||
DEFAULT_SHOW_SFTP_TAB,
|
||||
DEFAULT_SHOW_HOST_TREE_SIDEBAR,
|
||||
DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
DEFAULT_DISABLE_TERMINAL_FONT_ZOOM,
|
||||
DEFAULT_SSH_DEBUG_LOGS_ENABLED,
|
||||
DEFAULT_TERMINAL_THEME,
|
||||
DEFAULT_THEME,
|
||||
@@ -244,6 +246,10 @@ export const useSettingsState = () => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
|
||||
return stored ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS;
|
||||
});
|
||||
const [disableTerminalFontZoom, setDisableTerminalFontZoomState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM);
|
||||
return stored ?? DEFAULT_DISABLE_TERMINAL_FONT_ZOOM;
|
||||
});
|
||||
const [sftpTransferConcurrency, setSftpTransferConcurrencyState] = useState<number>(() => {
|
||||
const stored = localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY);
|
||||
return stored != null && stored >= 1 && stored <= 16 ? stored : 4;
|
||||
@@ -343,7 +349,14 @@ export const useSettingsState = () => {
|
||||
|
||||
const mergeIncomingTerminalSettings = useCallback((incoming: Partial<TerminalSettings>) => {
|
||||
setTerminalSettingsState((prev) => {
|
||||
const next = normalizeTerminalSettings({ ...prev, ...incoming });
|
||||
const merged: Partial<TerminalSettings> = { ...prev, ...incoming };
|
||||
if (
|
||||
!Object.prototype.hasOwnProperty.call(incoming, 'middleClickBehavior') &&
|
||||
Object.prototype.hasOwnProperty.call(incoming, 'middleClickPaste')
|
||||
) {
|
||||
delete merged.middleClickBehavior;
|
||||
}
|
||||
const next = normalizeTerminalSettings(merged);
|
||||
if (areTerminalSettingsEqual(prev, next)) {
|
||||
return prev;
|
||||
}
|
||||
@@ -544,6 +557,8 @@ export const useSettingsState = () => {
|
||||
setShowHostTreeSidebarState(storedShowHostTreeSidebar ?? DEFAULT_SHOW_HOST_TREE_SIDEBAR);
|
||||
const storedShellOnlyTabNumberShortcuts = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
|
||||
setShellOnlyTabNumberShortcutsState(storedShellOnlyTabNumberShortcuts ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
|
||||
const storedDisableTerminalFontZoom = localStorageAdapter.readBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM);
|
||||
setDisableTerminalFontZoomState(storedDisableTerminalFontZoom ?? DEFAULT_DISABLE_TERMINAL_FONT_ZOOM);
|
||||
|
||||
// Workspace focus style
|
||||
const storedFocusStyle = readStoredString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
|
||||
@@ -635,6 +650,7 @@ export const useSettingsState = () => {
|
||||
setSftpDefaultViewMode,
|
||||
setWorkspaceFocusStyleState,
|
||||
setShowHostTreeSidebarState,
|
||||
setDisableTerminalFontZoomState,
|
||||
setSftpTransferConcurrencyState,
|
||||
});
|
||||
|
||||
@@ -661,7 +677,7 @@ export const useSettingsState = () => {
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
|
||||
@@ -670,7 +686,7 @@ export const useSettingsState = () => {
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setShellOnlyTabNumberShortcutsState,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
|
||||
@@ -791,6 +807,13 @@ export const useSettingsState = () => {
|
||||
notifySettingsChanged(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setDisableTerminalFontZoom = useCallback((enabled: boolean) => {
|
||||
setDisableTerminalFontZoomState(enabled);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM, enabled);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
// Apply and persist custom CSS
|
||||
useEffect(() => {
|
||||
applyCustomCssToDocument(customCSS);
|
||||
@@ -1031,6 +1054,8 @@ export const useSettingsState = () => {
|
||||
setShowHostTreeSidebar,
|
||||
shellOnlyTabNumberShortcuts,
|
||||
setShellOnlyTabNumberShortcuts,
|
||||
disableTerminalFontZoom,
|
||||
setDisableTerminalFontZoom,
|
||||
sftpTransferConcurrency,
|
||||
setSftpTransferConcurrency,
|
||||
// Editor Settings
|
||||
@@ -1075,7 +1100,7 @@ export const useSettingsState = () => {
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
||||
customKeyBindings, editorWordWrap,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
|
||||
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
]),
|
||||
};
|
||||
|
||||
@@ -170,10 +170,21 @@ export const useSftpState = (
|
||||
useSftpSessionCleanup(sftpSessionsRef);
|
||||
useSftpFileWatch(options);
|
||||
|
||||
const { connect, disconnect, listLocalFiles, listRemoteFiles } = useSftpConnections({
|
||||
const {
|
||||
connect,
|
||||
disconnect,
|
||||
listLocalFiles,
|
||||
listRemoteFiles,
|
||||
hostKeyVerification,
|
||||
rejectHostKeyVerification,
|
||||
acceptHostKeyVerification,
|
||||
acceptAndSaveHostKeyVerification,
|
||||
} = useSftpConnections({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
knownHosts: options?.knownHosts,
|
||||
onAddKnownHost: options?.onAddKnownHost,
|
||||
terminalSettings: options?.terminalSettings,
|
||||
leftTabsRef,
|
||||
rightTabsRef,
|
||||
@@ -402,6 +413,9 @@ export const useSftpState = (
|
||||
resolveConflict: resolveAnyConflict,
|
||||
getSftpIdForConnection,
|
||||
reportSessionError: handleSessionError,
|
||||
rejectHostKeyVerification,
|
||||
acceptHostKeyVerification,
|
||||
acceptAndSaveHostKeyVerification,
|
||||
});
|
||||
methodsRef.current = {
|
||||
getFilteredFiles,
|
||||
@@ -460,6 +474,9 @@ export const useSftpState = (
|
||||
resolveConflict: resolveAnyConflict,
|
||||
getSftpIdForConnection,
|
||||
reportSessionError: handleSessionError,
|
||||
rejectHostKeyVerification,
|
||||
acceptHostKeyVerification,
|
||||
acceptAndSaveHostKeyVerification,
|
||||
};
|
||||
|
||||
// Create stable method wrappers that call through methodsRef
|
||||
@@ -532,6 +549,9 @@ export const useSftpState = (
|
||||
resolveConflict: (...args: Parameters<typeof resolveAnyConflict>) => methodsRef.current.resolveConflict(...args),
|
||||
getSftpIdForConnection: (...args: Parameters<typeof getSftpIdForConnection>) => methodsRef.current.getSftpIdForConnection(...args),
|
||||
reportSessionError: (...args: Parameters<typeof handleSessionError>) => methodsRef.current.reportSessionError(...args),
|
||||
rejectHostKeyVerification: () => methodsRef.current.rejectHostKeyVerification(),
|
||||
acceptHostKeyVerification: () => methodsRef.current.acceptHostKeyVerification(),
|
||||
acceptAndSaveHostKeyVerification: () => methodsRef.current.acceptAndSaveHostKeyVerification(),
|
||||
activeFileWatchCountRef,
|
||||
}), [activeFileWatchCountRef]); // activeFileWatchCountRef is a stable ref
|
||||
|
||||
@@ -546,6 +566,7 @@ export const useSftpState = (
|
||||
transfers,
|
||||
activeTransfersCount,
|
||||
conflicts,
|
||||
hostKeyVerification,
|
||||
|
||||
// Stable methods - never change reference
|
||||
...stableMethods,
|
||||
@@ -566,6 +587,7 @@ export const useSftpState = (
|
||||
transfers,
|
||||
activeTransfersCount,
|
||||
conflicts,
|
||||
hostKeyVerification,
|
||||
stableMethods,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -180,17 +180,33 @@ export const useTerminalBackend = () => {
|
||||
return !!bridge?.sendSerialYmodem;
|
||||
}, []);
|
||||
|
||||
const serialYmodemReceiveAvailable = useCallback(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return !!bridge?.receiveSerialYmodem;
|
||||
}, []);
|
||||
|
||||
const selectFileAvailable = useCallback(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return !!bridge?.selectFile;
|
||||
}, []);
|
||||
|
||||
const selectDirectoryAvailable = useCallback(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return !!bridge?.selectDirectory;
|
||||
}, []);
|
||||
|
||||
const sendSerialYmodem = useCallback(async (sessionId: string, filePath: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.sendSerialYmodem) return { success: false, error: 'sendSerialYmodem unavailable' };
|
||||
return bridge.sendSerialYmodem(sessionId, filePath);
|
||||
}, []);
|
||||
|
||||
const receiveSerialYmodem = useCallback(async (sessionId: string, destinationDir: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.receiveSerialYmodem) return { success: false, error: 'receiveSerialYmodem unavailable' };
|
||||
return bridge.receiveSerialYmodem(sessionId, destinationDir);
|
||||
}, []);
|
||||
|
||||
const selectFile = useCallback(async (
|
||||
title?: string,
|
||||
defaultPath?: string,
|
||||
@@ -201,6 +217,42 @@ export const useTerminalBackend = () => {
|
||||
return bridge.selectFile(title, defaultPath, filters);
|
||||
}, []);
|
||||
|
||||
const selectDirectory = useCallback(async (title?: string, defaultPath?: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.selectDirectory) return null;
|
||||
return bridge.selectDirectory(title, defaultPath);
|
||||
}, []);
|
||||
|
||||
const startZmodemDragDropUpload = useCallback(async (
|
||||
sessionId: string,
|
||||
files: Array<{
|
||||
path?: string;
|
||||
name: string;
|
||||
remoteName: string;
|
||||
data?: ArrayBuffer;
|
||||
}>,
|
||||
uploadCommand?: string,
|
||||
) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.startZmodemDragDropUpload) {
|
||||
return { success: false, error: "startZmodemDragDropUpload unavailable" };
|
||||
}
|
||||
return bridge.startZmodemDragDropUpload(sessionId, files, uploadCommand);
|
||||
}, []);
|
||||
|
||||
const cancelZmodem = useCallback((sessionId: string, options?: { interrupt?: boolean }) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.cancelZmodem?.(sessionId, options);
|
||||
}, []);
|
||||
|
||||
const onZmodemEvent = useCallback((
|
||||
sessionId: string,
|
||||
cb: Parameters<NonNullable<NetcattyBridge["onZmodemEvent"]>>[1],
|
||||
) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onZmodemEvent?.(sessionId, cb) ?? (() => {});
|
||||
}, []);
|
||||
|
||||
const getSessionPwd = useCallback(async (sessionId: string, options?: { allowHomeFallback?: boolean }) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getSessionPwd) return { success: false, error: 'getSessionPwd unavailable' };
|
||||
@@ -256,9 +308,16 @@ export const useTerminalBackend = () => {
|
||||
startSerialSession,
|
||||
listSerialPorts,
|
||||
serialYmodemAvailable,
|
||||
serialYmodemReceiveAvailable,
|
||||
selectFileAvailable,
|
||||
selectDirectoryAvailable,
|
||||
sendSerialYmodem,
|
||||
receiveSerialYmodem,
|
||||
selectFile,
|
||||
selectDirectory,
|
||||
startZmodemDragDropUpload,
|
||||
cancelZmodem,
|
||||
onZmodemEvent,
|
||||
execCommand,
|
||||
getSessionPwd,
|
||||
getSessionRemoteInfo,
|
||||
@@ -297,9 +356,16 @@ export const useTerminalBackend = () => {
|
||||
startSerialSession,
|
||||
listSerialPorts,
|
||||
serialYmodemAvailable,
|
||||
serialYmodemReceiveAvailable,
|
||||
selectFileAvailable,
|
||||
selectDirectoryAvailable,
|
||||
sendSerialYmodem,
|
||||
receiveSerialYmodem,
|
||||
selectFile,
|
||||
selectDirectory,
|
||||
startZmodemDragDropUpload,
|
||||
cancelZmodem,
|
||||
onZmodemEvent,
|
||||
execCommand,
|
||||
getSessionPwd,
|
||||
getSessionRemoteInfo,
|
||||
|
||||
@@ -36,8 +36,9 @@ import {
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
} from "../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import { mergeGlobalHistoryOnAppend } from "../../domain/globalHistory";
|
||||
import { mergeGlobalHistoryOnAppend, sanitizeGlobalHistoryEntries } from "../../domain/globalHistory";
|
||||
import { getNextVaultOrder, normalizeVaultOrder } from "../../domain/vaultOrder";
|
||||
import { loadSanitizedShellHistory } from "./shellHistoryPersistence";
|
||||
import {
|
||||
decryptGroupConfigs,
|
||||
decryptHosts,
|
||||
@@ -598,10 +599,10 @@ export const useVaultState = () => {
|
||||
}
|
||||
|
||||
// Load shell history
|
||||
const savedShellHistory = localStorageAdapter.read<ShellHistoryEntry[]>(
|
||||
STORAGE_KEY_SHELL_HISTORY,
|
||||
);
|
||||
if (savedShellHistory) setShellHistory(savedShellHistory);
|
||||
const savedShellHistory = loadSanitizedShellHistory();
|
||||
if (savedShellHistory) {
|
||||
setShellHistory(savedShellHistory);
|
||||
}
|
||||
|
||||
// Load connection logs
|
||||
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
|
||||
@@ -729,7 +730,9 @@ export const useVaultState = () => {
|
||||
}
|
||||
|
||||
if (key === STORAGE_KEY_SHELL_HISTORY) {
|
||||
const next = safeParse<ShellHistoryEntry[]>(event.newValue) ?? [];
|
||||
const next = sanitizeGlobalHistoryEntries(
|
||||
safeParse<ShellHistoryEntry[]>(event.newValue) ?? [],
|
||||
);
|
||||
setShellHistory(next);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ const {
|
||||
hasCloudSyncEntityData,
|
||||
hasMeaningfulCloudSyncData,
|
||||
shouldPromptCloudVaultRecovery,
|
||||
SYNCABLE_SETTING_STORAGE_KEYS,
|
||||
} = await import("./syncPayload.ts");
|
||||
const storageKeys = await import("../infrastructure/config/storageKeys.ts");
|
||||
|
||||
@@ -124,6 +125,7 @@ test("buildSyncPayload includes AI configuration settings", () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP, JSON.stringify({ codex: "gpt-test" }));
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP, JSON.stringify({ catty: "openai-main" }));
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify(webSearch));
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION, "false");
|
||||
|
||||
const payload = buildSyncPayload(vault([]));
|
||||
|
||||
@@ -140,9 +142,18 @@ test("buildSyncPayload includes AI configuration settings", () => {
|
||||
agentModelMap: { codex: "gpt-test" },
|
||||
agentProviderMap: { catty: "openai-main" },
|
||||
webSearchConfig: webSearch,
|
||||
showTerminalSelectionAction: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("terminal selection AI preference is syncable for auto-sync detection", () => {
|
||||
assert.ok(
|
||||
(SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(
|
||||
storageKeys.STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("buildSyncPayload includes host tree sidebar visibility setting", () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, "false");
|
||||
|
||||
@@ -215,6 +226,7 @@ test("applySyncPayload restores AI configuration settings", async () => {
|
||||
agentModelMap: { claude: "claude-test" },
|
||||
agentProviderMap: { catty: "anthropic-main" },
|
||||
webSearchConfig: webSearch,
|
||||
showTerminalSelectionAction: false,
|
||||
},
|
||||
},
|
||||
syncedAt: 1,
|
||||
@@ -234,6 +246,7 @@ test("applySyncPayload restores AI configuration settings", async () => {
|
||||
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP)!), { claude: "claude-test" });
|
||||
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP)!), { catty: "anthropic-main" });
|
||||
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH)!), webSearch);
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION), "false");
|
||||
});
|
||||
|
||||
test("applySyncPayload restores host tree sidebar visibility setting", async () => {
|
||||
@@ -529,6 +542,7 @@ test("buildSyncPayload includes syncable terminal options from settings", () =>
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_TERM_SETTINGS, JSON.stringify({
|
||||
terminalEmulationType: "vt100",
|
||||
altAsMeta: true,
|
||||
middleClickBehavior: "context-menu",
|
||||
showServerStats: false,
|
||||
serverStatsRefreshInterval: 12,
|
||||
rendererType: "dom",
|
||||
@@ -541,6 +555,7 @@ test("buildSyncPayload includes syncable terminal options from settings", () =>
|
||||
assert.deepEqual(payload.settings?.terminalSettings, {
|
||||
terminalEmulationType: "vt100",
|
||||
altAsMeta: true,
|
||||
middleClickBehavior: "context-menu",
|
||||
showServerStats: false,
|
||||
serverStatsRefreshInterval: 12,
|
||||
rendererType: "dom",
|
||||
@@ -805,6 +820,42 @@ test("applySyncPayload writes incoming fallbackFont into local TERM_SETTINGS", a
|
||||
assert.equal(parsed.fallbackFont, "Sarasa Mono SC");
|
||||
});
|
||||
|
||||
test("applySyncPayload lets legacy middle-click paste update the new middle-click behavior", async () => {
|
||||
localStorage.setItem(
|
||||
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({
|
||||
scrollback: 2000,
|
||||
middleClickBehavior: "paste",
|
||||
middleClickPaste: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const payload: SyncPayload = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
settings: {
|
||||
terminalSettings: {
|
||||
middleClickPaste: false,
|
||||
},
|
||||
},
|
||||
} as SyncPayload;
|
||||
|
||||
await applySyncPayload(payload, {
|
||||
importVaultData: () => {},
|
||||
});
|
||||
|
||||
const raw = localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS);
|
||||
assert.ok(raw, "TERM_SETTINGS should be written");
|
||||
const parsed = JSON.parse(raw!);
|
||||
assert.equal(parsed.scrollback, 2000);
|
||||
assert.equal(parsed.middleClickBehavior, "disabled");
|
||||
assert.equal(parsed.middleClickPaste, false);
|
||||
});
|
||||
|
||||
test("applySyncPayload from legacy client (no fallbackFont) preserves local value", async () => {
|
||||
localStorage.setItem(
|
||||
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
||||
|
||||
@@ -67,6 +67,7 @@ import {
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_AI_PROVIDERS,
|
||||
STORAGE_KEY_AI_ACTIVE_PROVIDER,
|
||||
@@ -82,6 +83,7 @@ import {
|
||||
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
STORAGE_KEY_AI_QUICK_MESSAGES,
|
||||
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
} from '../infrastructure/config/storageKeys';
|
||||
|
||||
@@ -193,7 +195,7 @@ const SYNCABLE_TERMINAL_KEYS = [
|
||||
'linePadding', 'cursorShape', 'cursorBlink', 'minimumContrastRatio',
|
||||
'altAsMeta', 'optionArrowWordJump', 'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
|
||||
'smoothScrolling',
|
||||
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
|
||||
'rightClickBehavior', 'middleClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
|
||||
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
|
||||
'keepaliveInterval', 'keepaliveCountMax', 'disableBracketedPaste', 'clearWipesScrollback',
|
||||
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'showServerStats',
|
||||
@@ -251,6 +253,7 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
|
||||
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
STORAGE_KEY_AI_QUICK_MESSAGES,
|
||||
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
|
||||
] as const;
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
@@ -416,6 +419,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (showSftpTab != null) settings.showSftpTab = showSftpTab;
|
||||
const shellOnlyTabNumberShortcuts = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
|
||||
if (shellOnlyTabNumberShortcuts != null) settings.shellOnlyTabNumberShortcuts = shellOnlyTabNumberShortcuts;
|
||||
const disableTerminalFontZoom = localStorageAdapter.readBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM);
|
||||
if (disableTerminalFontZoom != null) settings.disableTerminalFontZoom = disableTerminalFontZoom;
|
||||
const showHostTreeSidebar = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
|
||||
if (showHostTreeSidebar != null) settings.showHostTreeSidebar = showHostTreeSidebar;
|
||||
const workspaceFocusStyle = localStorageAdapter.readString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
|
||||
@@ -457,6 +462,10 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (webSearchConfig) ai.webSearchConfig = stripDeviceBoundApiKey(webSearchConfig);
|
||||
const quickMessages = readArraySetting(STORAGE_KEY_AI_QUICK_MESSAGES);
|
||||
if (quickMessages) ai.quickMessages = sanitizeQuickMessages(quickMessages);
|
||||
const showTerminalSelectionAction = localStorageAdapter.readBoolean(STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION);
|
||||
if (showTerminalSelectionAction != null) {
|
||||
ai.showTerminalSelectionAction = showTerminalSelectionAction;
|
||||
}
|
||||
if (Object.keys(ai).length > 0) settings.ai = ai;
|
||||
|
||||
return Object.keys(settings).length > 0 ? settings : undefined;
|
||||
@@ -495,11 +504,27 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
|
||||
try { existing = JSON.parse(raw); } catch { /* ignore */ }
|
||||
}
|
||||
const merged = { ...existing };
|
||||
const hasIncomingMiddleClickBehavior = 'middleClickBehavior' in settings.terminalSettings;
|
||||
const hasIncomingMiddleClickPaste = 'middleClickPaste' in settings.terminalSettings;
|
||||
for (const key of SYNCABLE_TERMINAL_KEYS) {
|
||||
if (key in settings.terminalSettings) {
|
||||
merged[key] = settings.terminalSettings[key];
|
||||
}
|
||||
}
|
||||
if (hasIncomingMiddleClickBehavior) {
|
||||
const behavior = settings.terminalSettings.middleClickBehavior;
|
||||
if (
|
||||
behavior === 'context-menu' ||
|
||||
behavior === 'paste' ||
|
||||
behavior === 'disabled'
|
||||
) {
|
||||
merged.middleClickPaste = behavior === 'paste';
|
||||
}
|
||||
} else if (hasIncomingMiddleClickPaste) {
|
||||
merged.middleClickBehavior = settings.terminalSettings.middleClickPaste === false
|
||||
? 'disabled'
|
||||
: 'paste';
|
||||
}
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_SETTINGS, JSON.stringify(merged));
|
||||
}
|
||||
|
||||
@@ -553,6 +578,9 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
|
||||
if (settings.shellOnlyTabNumberShortcuts != null) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, settings.shellOnlyTabNumberShortcuts);
|
||||
}
|
||||
if (settings.disableTerminalFontZoom != null) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM, settings.disableTerminalFontZoom);
|
||||
}
|
||||
if (settings.showHostTreeSidebar != null) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, settings.showHostTreeSidebar);
|
||||
}
|
||||
@@ -594,6 +622,12 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
|
||||
if (ai.quickMessages != null) {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_QUICK_MESSAGES, sanitizeQuickMessages(ai.quickMessages));
|
||||
}
|
||||
if (ai.showTerminalSelectionAction != null) {
|
||||
localStorageAdapter.writeBoolean(
|
||||
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
|
||||
ai.showTerminalSelectionAction,
|
||||
);
|
||||
}
|
||||
// After all AI writes, reconcile per-agent bindings against the final
|
||||
// provider list. Sync payloads can land with a new `providers` set but
|
||||
// no `agentProviderMap`, or with a stale `agentProviderMap` that
|
||||
@@ -635,6 +669,9 @@ function notifyAIStateAfterSync(ai: NonNullable<SyncPayload['settings']>['ai']):
|
||||
}
|
||||
if (ai.webSearchConfig !== undefined) touched.push(STORAGE_KEY_AI_WEB_SEARCH);
|
||||
if (ai.quickMessages != null) touched.push(STORAGE_KEY_AI_QUICK_MESSAGES);
|
||||
if (ai.showTerminalSelectionAction != null) {
|
||||
touched.push(STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION);
|
||||
}
|
||||
for (const key of touched) {
|
||||
emitAIStateChanged(key);
|
||||
}
|
||||
|
||||
@@ -157,6 +157,8 @@ const SettingsAITabContainer: React.FC = () => {
|
||||
setWebSearchConfig={aiState.setWebSearchConfig}
|
||||
quickMessages={aiState.quickMessages}
|
||||
setQuickMessages={aiState.setQuickMessages}
|
||||
showTerminalSelectionAIAction={aiState.showTerminalSelectionAIAction}
|
||||
setShowTerminalSelectionAIAction={aiState.setShowTerminalSelectionAIAction}
|
||||
/>
|
||||
</AITabErrorBoundary>
|
||||
);
|
||||
@@ -401,6 +403,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
setHotkeyScheme={settings.setHotkeyScheme}
|
||||
shellOnlyTabNumberShortcuts={settings.shellOnlyTabNumberShortcuts}
|
||||
setShellOnlyTabNumberShortcuts={settings.setShellOnlyTabNumberShortcuts}
|
||||
disableTerminalFontZoom={settings.disableTerminalFontZoom}
|
||||
setDisableTerminalFontZoom={settings.setDisableTerminalFontZoom}
|
||||
keyBindings={settings.keyBindings}
|
||||
updateKeyBinding={settings.updateKeyBinding}
|
||||
resetKeyBinding={settings.resetKeyBinding}
|
||||
|
||||
@@ -24,7 +24,7 @@ import { getParentPath, isConcreteTransferTargetPath } from "../application/stat
|
||||
import { buildCacheKey } from "../application/state/sftp/sharedRemoteHostCache";
|
||||
import { logger } from "../lib/logger";
|
||||
import type { DropEntry } from "../lib/sftpFileUtils";
|
||||
import { Host, Identity, SSHKey } from "../types";
|
||||
import { Host, Identity, KnownHost, SSHKey } from "../types";
|
||||
import type { TransferTask } from "../types";
|
||||
import { toast } from "./ui/toast";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
@@ -47,7 +47,9 @@ interface SftpSidePanelProps {
|
||||
writableHosts?: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
knownHosts?: KnownHost[];
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
onAddKnownHost?: (knownHost: KnownHost) => void;
|
||||
sftpDefaultViewMode: "list" | "tree";
|
||||
/** The host to connect to (follows focused terminal) */
|
||||
activeHost: Host | null;
|
||||
@@ -87,7 +89,9 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
writableHosts,
|
||||
keys,
|
||||
identities,
|
||||
knownHosts = [],
|
||||
updateHosts,
|
||||
onAddKnownHost,
|
||||
sftpDefaultViewMode,
|
||||
activeHost,
|
||||
activeSessionId,
|
||||
@@ -134,7 +138,9 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
defaultShowHiddenFiles: sftpShowHiddenFiles,
|
||||
autoConnectLocalOnMount: false,
|
||||
terminalSettings,
|
||||
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles, terminalSettings]);
|
||||
knownHosts,
|
||||
onAddKnownHost,
|
||||
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles, terminalSettings, knownHosts, onAddKnownHost]);
|
||||
|
||||
const sftp = useSftpState(hosts, keys, identities, sftpOptions);
|
||||
const {
|
||||
@@ -964,7 +970,9 @@ const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps):
|
||||
prev.writableHosts === next.writableHosts &&
|
||||
prev.keys === next.keys &&
|
||||
prev.identities === next.identities &&
|
||||
prev.knownHosts === next.knownHosts &&
|
||||
prev.updateHosts === next.updateHosts &&
|
||||
prev.onAddKnownHost === next.onAddKnownHost &&
|
||||
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
|
||||
prev.activeHost === next.activeHost &&
|
||||
prev.activeSessionId === next.activeSessionId &&
|
||||
|
||||
@@ -24,7 +24,7 @@ import { HotkeyScheme, KeyBinding } from "../domain/models";
|
||||
import { logger } from "../lib/logger";
|
||||
import { useRenderTracker } from "../lib/useRenderTracker";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Host, Identity, ProxyProfile, SSHKey, TransferTask } from "../types";
|
||||
import { Host, Identity, KnownHost, ProxyProfile, SSHKey, TransferTask } from "../types";
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
|
||||
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
|
||||
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
|
||||
@@ -54,9 +54,11 @@ interface SftpViewProps {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
knownHosts?: KnownHost[];
|
||||
groupConfigs?: import('../domain/models').GroupConfig[];
|
||||
proxyProfiles?: ProxyProfile[];
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
onAddKnownHost?: (knownHost: KnownHost) => void;
|
||||
sftpDefaultViewMode: "list" | "tree";
|
||||
sftpDoubleClickBehavior: "open" | "transfer";
|
||||
sftpAutoSync: boolean;
|
||||
@@ -73,9 +75,11 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
knownHosts = [],
|
||||
groupConfigs = [],
|
||||
proxyProfiles = [],
|
||||
updateHosts,
|
||||
onAddKnownHost,
|
||||
sftpDefaultViewMode,
|
||||
sftpDoubleClickBehavior,
|
||||
sftpAutoSync,
|
||||
@@ -110,7 +114,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
useCompressedUpload: sftpUseCompressedUpload,
|
||||
defaultShowHiddenFiles: sftpShowHiddenFiles,
|
||||
terminalSettings,
|
||||
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles, terminalSettings]);
|
||||
knownHosts,
|
||||
onAddKnownHost,
|
||||
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles, terminalSettings, knownHosts, onAddKnownHost]);
|
||||
|
||||
// Pre-resolve group defaults so SFTP connections inherit group config
|
||||
const effectiveHosts = useMemo(() => {
|
||||
@@ -374,9 +380,11 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
handleReorderTabsRight,
|
||||
handleMoveTabFromLeftToRight,
|
||||
handleMoveTabFromRightToLeft,
|
||||
handleDuplicateTabLeft,
|
||||
handleDuplicateTabRight,
|
||||
handleHostSelectLeft,
|
||||
handleHostSelectRight,
|
||||
} = useSftpViewTabs({ sftp, sftpRef });
|
||||
} = useSftpViewTabs({ sftp, sftpRef, hosts: effectiveHosts });
|
||||
|
||||
const handleAddTabLeftWithFocus = useCallback(() => {
|
||||
const tabId = handleAddTabLeft();
|
||||
@@ -398,6 +406,26 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
handlePaneFocus("right", tabId);
|
||||
}, [handlePaneFocus, handleSelectTabRight]);
|
||||
|
||||
const handleDuplicateTabLeftWithFocus = useCallback(
|
||||
async (...args: Parameters<typeof handleDuplicateTabLeft>) => {
|
||||
const tabId = await handleDuplicateTabLeft(...args);
|
||||
if (tabId) {
|
||||
handlePaneFocus("left", tabId);
|
||||
}
|
||||
},
|
||||
[handleDuplicateTabLeft, handlePaneFocus],
|
||||
);
|
||||
|
||||
const handleDuplicateTabRightWithFocus = useCallback(
|
||||
async (...args: Parameters<typeof handleDuplicateTabRight>) => {
|
||||
const tabId = await handleDuplicateTabRight(...args);
|
||||
if (tabId) {
|
||||
handlePaneFocus("right", tabId);
|
||||
}
|
||||
},
|
||||
[handleDuplicateTabRight, handlePaneFocus],
|
||||
);
|
||||
|
||||
return (
|
||||
<SftpContextProvider
|
||||
hosts={effectiveHosts}
|
||||
@@ -444,6 +472,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
onAddTab={handleAddTabLeftWithFocus}
|
||||
onReorderTabs={handleReorderTabsLeft}
|
||||
onMoveTabToOtherSide={handleMoveTabFromRightToLeft}
|
||||
onDuplicateTab={handleDuplicateTabLeftWithFocus}
|
||||
/>
|
||||
)}
|
||||
<div className="relative flex-1 min-h-0">
|
||||
@@ -504,6 +533,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
onAddTab={handleAddTabRightWithFocus}
|
||||
onReorderTabs={handleReorderTabsRight}
|
||||
onMoveTabToOtherSide={handleMoveTabFromLeftToRight}
|
||||
onDuplicateTab={handleDuplicateTabRightWithFocus}
|
||||
/>
|
||||
)}
|
||||
<div className="relative flex-1 min-h-0">
|
||||
@@ -588,9 +618,11 @@ const sftpViewAreEqual = (prev: SftpViewProps, next: SftpViewProps): boolean =>
|
||||
prev.hosts === next.hosts &&
|
||||
prev.keys === next.keys &&
|
||||
prev.identities === next.identities &&
|
||||
prev.knownHosts === next.knownHosts &&
|
||||
prev.groupConfigs === next.groupConfigs &&
|
||||
prev.proxyProfiles === next.proxyProfiles &&
|
||||
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
|
||||
prev.onAddKnownHost === next.onAddKnownHost &&
|
||||
prev.sftpDoubleClickBehavior === next.sftpDoubleClickBehavior &&
|
||||
prev.sftpAutoSync === next.sftpAutoSync &&
|
||||
prev.sftpShowHiddenFiles === next.sftpShowHiddenFiles &&
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
type TerminalHostUpdate,
|
||||
} from "../domain/terminalAppearance";
|
||||
import { classifyDistroId, shouldProbeSessionCwd } from "../domain/host";
|
||||
import { supportsZmodemTerminalDragDrop } from "../lib/zmodemDragDrop";
|
||||
import { resolveHostAuth } from "../domain/sshAuth";
|
||||
import { useTerminalBackend } from "../application/state/useTerminalBackend";
|
||||
import { useTerminalLayoutSuppressActive } from "../application/state/terminalLayoutSuppressStore";
|
||||
@@ -117,6 +118,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
reuseConnectionFromSessionId,
|
||||
serialConfig,
|
||||
hotkeyScheme = "disabled",
|
||||
disableTerminalFontZoom = false,
|
||||
keyBindings = [],
|
||||
onHotkeyAction,
|
||||
onTerminalFontSizeChange,
|
||||
@@ -147,6 +149,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
sessionLog,
|
||||
sshDebugLogEnabled,
|
||||
sudoAutofillPassword,
|
||||
showSelectionAIAction,
|
||||
onAddSelectionToAI,
|
||||
}) => {
|
||||
const layoutSuppressActive = useTerminalLayoutSuppressActive();
|
||||
@@ -220,9 +223,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}, [captureTerminalLogData]);
|
||||
|
||||
const hotkeySchemeRef = useRef(hotkeyScheme);
|
||||
const disableTerminalFontZoomRef = useRef(disableTerminalFontZoom);
|
||||
const keyBindingsRef = useRef(keyBindings);
|
||||
const onHotkeyActionRef = useRef(onHotkeyAction);
|
||||
hotkeySchemeRef.current = hotkeyScheme;
|
||||
disableTerminalFontZoomRef.current = disableTerminalFontZoom;
|
||||
keyBindingsRef.current = keyBindings;
|
||||
onHotkeyActionRef.current = onHotkeyAction;
|
||||
|
||||
@@ -247,10 +252,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const terminalBackend = useTerminalBackend();
|
||||
const {
|
||||
resizeSession,
|
||||
receiveSerialYmodem,
|
||||
selectDirectory,
|
||||
selectDirectoryAvailable,
|
||||
selectFile,
|
||||
selectFileAvailable,
|
||||
sendSerialYmodem,
|
||||
serialYmodemAvailable,
|
||||
serialYmodemReceiveAvailable,
|
||||
setSessionEncoding,
|
||||
} = terminalBackend;
|
||||
|
||||
@@ -462,6 +471,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const detectedDeviceClass = classifyDistroId(host.distro);
|
||||
const isNetworkDevice =
|
||||
host.deviceType === 'network' || detectedDeviceClass === 'network-device';
|
||||
const remoteDragDropUsesZmodem = supportsZmodemTerminalDragDrop(host, isNetworkDevice);
|
||||
|
||||
// Check if this is a local or serial connection (doesn't need connection dialog during connecting)
|
||||
const isLocalConnection = host.protocol === "local";
|
||||
@@ -961,6 +971,43 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
}, [isSerialConnection, selectFile, selectFileAvailable, sendSerialYmodem, serialYmodemAvailable, sessionId, t]);
|
||||
|
||||
const handleReceiveYmodem = useCallback(async () => {
|
||||
if (!isSerialConnection || statusRef.current !== "connected") return;
|
||||
if (!selectDirectoryAvailable() || !serialYmodemReceiveAvailable()) {
|
||||
toast.error(t("terminal.ymodem.unavailable"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const destinationDir = await selectDirectory(t("terminal.ymodem.selectReceiveDirectory"));
|
||||
if (!destinationDir) return;
|
||||
|
||||
toast.info(t("terminal.ymodem.receiveStarted"));
|
||||
const result = await receiveSerialYmodem(sessionRef.current || sessionId, destinationDir);
|
||||
if (result.success) {
|
||||
if (result.fileCount && result.fileCount > 1) {
|
||||
toast.success(t("terminal.ymodem.receiveCompleteMultiple", { count: result.fileCount }));
|
||||
} else if (result.fileName) {
|
||||
toast.success(t("terminal.ymodem.receiveComplete", { fileName: result.fileName }));
|
||||
} else {
|
||||
toast.success(t("terminal.ymodem.receiveEmpty"));
|
||||
}
|
||||
} else {
|
||||
toast.error(t("terminal.ymodem.receiveFailed"));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("terminal.ymodem.receiveFailed"));
|
||||
}
|
||||
}, [
|
||||
isSerialConnection,
|
||||
receiveSerialYmodem,
|
||||
selectDirectory,
|
||||
selectDirectoryAvailable,
|
||||
serialYmodemReceiveAvailable,
|
||||
sessionId,
|
||||
t,
|
||||
]);
|
||||
|
||||
const handleCancelConnect = () => {
|
||||
if (pendingHostKeyRequestId) {
|
||||
void terminalBackend.respondHostKeyVerification(pendingHostKeyRequestId, false);
|
||||
@@ -1117,6 +1164,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
} = useTerminalDragDrop({
|
||||
host,
|
||||
isLocalConnection,
|
||||
isNetworkDevice,
|
||||
onOpenSftp,
|
||||
resolveSftpInitialPath,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
@@ -1152,6 +1200,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onSnippetClick={(snippet) => { void executeSnippet(snippet); }}
|
||||
onOpenSFTP={handleOpenSFTP}
|
||||
onSendYmodem={isSerialConnection ? handleSendYmodem : undefined}
|
||||
onReceiveYmodem={isSerialConnection ? handleReceiveYmodem : undefined}
|
||||
onOpenScripts={onOpenScripts ?? (() => {})}
|
||||
onOpenHistory={onOpenHistory}
|
||||
onOpenTheme={onOpenTheme ?? (() => {})}
|
||||
@@ -1169,6 +1218,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
compactToolbar,
|
||||
executeSnippet,
|
||||
handleOpenSFTP,
|
||||
handleReceiveYmodem,
|
||||
handleSendYmodem,
|
||||
handleSetTerminalEncoding,
|
||||
handleToggleSearch,
|
||||
@@ -1208,9 +1258,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
const effectiveComposeBarOpen = inWorkspace ? !!isWorkspaceComposeBarOpen : isComposeBarOpen;
|
||||
|
||||
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef });
|
||||
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef });
|
||||
|
||||
return <TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onExpandToFocus, onOpenSystem, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, scrollToBottomAfterProgrammaticInput, searchMatchCount, selectionOverlayPosition, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, snippets, status, statusDotTone, sudoHintRef, sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />;
|
||||
return <TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, remoteDragDropUsesZmodem, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onExpandToFocus, onOpenSystem, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, scrollToBottomAfterProgrammaticInput, searchMatchCount, selectionOverlayPosition, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, showSelectionAIAction, snippets, status, statusDotTone, sudoHintRef, sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />;
|
||||
};
|
||||
|
||||
const Terminal = memo(TerminalComponent, terminalPropsAreEqual);
|
||||
|
||||
@@ -99,6 +99,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
terminalFontFamilyId,
|
||||
fontSize = 14,
|
||||
hotkeyScheme = 'disabled',
|
||||
disableTerminalFontZoom = false,
|
||||
keyBindings = [],
|
||||
onHotkeyAction,
|
||||
onUpdateTerminalThemeId,
|
||||
@@ -1118,6 +1119,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
hosts,
|
||||
hostsRef,
|
||||
hotkeyScheme,
|
||||
disableTerminalFontZoom,
|
||||
identities,
|
||||
isBroadcastEnabled,
|
||||
isComposeBarOpen,
|
||||
|
||||
@@ -7,9 +7,11 @@ import { useTerminalPopupWindow } from '../application/state/useTerminalPopupWin
|
||||
import { useVaultState } from '../application/state/useVaultState';
|
||||
import { useWindowControls } from '../application/state/useWindowControls';
|
||||
import { shouldCloseTerminalPopupOnExit } from '../application/state/resolveTerminalSessionExitIntent';
|
||||
import { upsertKnownHost } from '../domain/knownHosts';
|
||||
import type { TerminalPopupPayload } from '../domain/systemManager/types';
|
||||
import type { TerminalTheme } from '../domain/models';
|
||||
import type { Host } from '../types';
|
||||
import type { Host, KnownHost } from '../types';
|
||||
import { getEffectiveKnownHosts } from '../infrastructure/syncHelpers';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
const Terminal = lazy(() => import('./Terminal'));
|
||||
@@ -195,11 +197,22 @@ function TerminalPopupPageInner() {
|
||||
const { close, setWindowTitle, onPopupConfig } = useTerminalPopupWindow();
|
||||
const { notifyRendererReady, onWindowCommandCloseRequested } = useWindowControls();
|
||||
const settings = useSettingsState();
|
||||
const { isInitialized: vaultInitialized, hosts, keys, identities, knownHosts, snippets, snippetPackages } = useVaultState();
|
||||
const { isInitialized: vaultInitialized, hosts, keys, identities, knownHosts, snippets, snippetPackages, updateKnownHosts } = useVaultState();
|
||||
const [config, setConfig] = useState<TerminalPopupPayload | null>(null);
|
||||
const [terminalReady, setTerminalReady] = useState(false);
|
||||
const [startupError, setStartupError] = useState<string | null>(null);
|
||||
const sessionId = useMemo(() => crypto.randomUUID(), []);
|
||||
const knownHostsRef = React.useRef(knownHosts);
|
||||
const effectiveKnownHosts = useMemo(
|
||||
() => getEffectiveKnownHosts(knownHosts) ?? [],
|
||||
[knownHosts],
|
||||
);
|
||||
knownHostsRef.current = effectiveKnownHosts;
|
||||
const handleAddKnownHost = useCallback((knownHost: KnownHost) => {
|
||||
const nextKnownHosts = upsertKnownHost(knownHostsRef.current, knownHost);
|
||||
knownHostsRef.current = nextKnownHosts;
|
||||
updateKnownHosts(nextKnownHosts);
|
||||
}, [updateKnownHosts]);
|
||||
const popupThemeVars = useMemo(
|
||||
() => buildPopupThemeVars(settings.currentTerminalTheme),
|
||||
[settings.currentTerminalTheme],
|
||||
@@ -307,7 +320,8 @@ function TerminalPopupPageInner() {
|
||||
snippetPackages={snippetPackages}
|
||||
compactToolbar
|
||||
lineTimestampsAvailable={false}
|
||||
knownHosts={knownHosts}
|
||||
knownHosts={effectiveKnownHosts}
|
||||
onAddKnownHost={handleAddKnownHost}
|
||||
isVisible
|
||||
isFocused
|
||||
fontFamilyId={settings.terminalFontFamilyId}
|
||||
@@ -317,6 +331,7 @@ function TerminalPopupPageInner() {
|
||||
accentMode={settings.accentMode}
|
||||
customAccent={settings.customAccent}
|
||||
terminalSettings={settings.terminalSettings}
|
||||
disableTerminalFontZoom={settings.disableTerminalFontZoom}
|
||||
sessionId={sessionId}
|
||||
startupCommand={config.startupCommand}
|
||||
reuseConnectionFromSessionId={reuseId}
|
||||
|
||||
18
components/settings/TerminalBehaviorSettings.test.tsx
Normal file
18
components/settings/TerminalBehaviorSettings.test.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import * as terminalBehaviorSettings from "./tabs/TerminalBehaviorSettings.tsx";
|
||||
|
||||
const middleClickBehaviorOptions = (
|
||||
terminalBehaviorSettings as {
|
||||
MIDDLE_CLICK_BEHAVIOR_OPTIONS?: Array<{ value: string; labelKey: string }>;
|
||||
}
|
||||
).MIDDLE_CLICK_BEHAVIOR_OPTIONS;
|
||||
|
||||
test("middle-click settings expose only supported behaviors", () => {
|
||||
assert.ok(Array.isArray(middleClickBehaviorOptions));
|
||||
assert.deepEqual(
|
||||
middleClickBehaviorOptions.map((option) => option.value),
|
||||
["context-menu", "paste", "disabled"],
|
||||
);
|
||||
});
|
||||
@@ -8,13 +8,15 @@ interface ToggleProps {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export const Toggle: React.FC<ToggleProps> = ({ checked, onChange, disabled }) => (
|
||||
export const Toggle: React.FC<ToggleProps> = ({ checked, onChange, disabled, ariaLabel }) => (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={ariaLabel}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={cn(
|
||||
|
||||
@@ -21,7 +21,7 @@ import type { ManagedAgentKey } from "../../../infrastructure/ai/managedAgents";
|
||||
import { PROVIDER_PRESETS } from "../../../infrastructure/ai/types";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../ui/button";
|
||||
import { Select, SettingCard, SettingsSection, SettingsTabContent, SettingRow } from "../settings-ui";
|
||||
import { Select, SettingCard, SettingsSection, SettingsTabContent, SettingRow, Toggle } from "../settings-ui";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../ui/tabs";
|
||||
import { AgentIconBadge } from "../../ai/AgentIconBadge";
|
||||
import { canSendWithAgent } from "../../ai/agentSendEligibility";
|
||||
@@ -140,6 +140,8 @@ interface SettingsAITabProps {
|
||||
setWebSearchConfig: (config: WebSearchConfig | null) => void;
|
||||
quickMessages: AIQuickMessage[];
|
||||
setQuickMessages: (value: AIQuickMessage[] | ((prev: AIQuickMessage[]) => AIQuickMessage[])) => void;
|
||||
showTerminalSelectionAIAction: boolean;
|
||||
setShowTerminalSelectionAIAction: (value: boolean | ((prev: boolean) => boolean)) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -173,6 +175,8 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
setWebSearchConfig,
|
||||
quickMessages,
|
||||
setQuickMessages,
|
||||
showTerminalSelectionAIAction,
|
||||
setShowTerminalSelectionAIAction,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [editingProviderId, setEditingProviderId] = useState<string | null>(null);
|
||||
@@ -871,6 +875,21 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tools" className="m-0 space-y-6">
|
||||
<SettingsSection title={t('ai.chatShortcuts.title')}>
|
||||
<SettingCard divided>
|
||||
<SettingRow
|
||||
label={t('ai.chatShortcuts.selectionAction')}
|
||||
description={t('ai.chatShortcuts.selectionAction.description')}
|
||||
>
|
||||
<Toggle
|
||||
checked={showTerminalSelectionAIAction}
|
||||
onChange={setShowTerminalSelectionAIAction}
|
||||
ariaLabel={t('ai.chatShortcuts.selectionAction')}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title={t('ai.toolAccess.title')}>
|
||||
<SettingCard>
|
||||
<SettingRow description={t('ai.toolAccess.description')}>
|
||||
|
||||
@@ -12,6 +12,8 @@ export default function SettingsShortcutsTab(props: {
|
||||
setHotkeyScheme: (scheme: HotkeyScheme) => void;
|
||||
shellOnlyTabNumberShortcuts: boolean;
|
||||
setShellOnlyTabNumberShortcuts: (enabled: boolean) => void;
|
||||
disableTerminalFontZoom: boolean;
|
||||
setDisableTerminalFontZoom: (enabled: boolean) => void;
|
||||
keyBindings: KeyBinding[];
|
||||
updateKeyBinding?: (bindingId: string, scheme: "mac" | "pc", newKey: string) => void;
|
||||
resetKeyBinding?: (bindingId: string, scheme?: "mac" | "pc") => void;
|
||||
@@ -23,6 +25,8 @@ export default function SettingsShortcutsTab(props: {
|
||||
setHotkeyScheme,
|
||||
shellOnlyTabNumberShortcuts,
|
||||
setShellOnlyTabNumberShortcuts,
|
||||
disableTerminalFontZoom,
|
||||
setDisableTerminalFontZoom,
|
||||
keyBindings,
|
||||
updateKeyBinding,
|
||||
resetKeyBinding,
|
||||
@@ -140,6 +144,15 @@ export default function SettingsShortcutsTab(props: {
|
||||
className="w-32"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("settings.shortcuts.disableTerminalFontZoom.label")}
|
||||
description={t("settings.shortcuts.disableTerminalFontZoom.desc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={disableTerminalFontZoom}
|
||||
onChange={setDisableTerminalFontZoom}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("settings.shortcuts.shellOnlyTabNumberShortcuts.label")}
|
||||
description={t("settings.shortcuts.shellOnlyTabNumberShortcuts.desc")}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import type { LinkModifier, RightClickBehavior, TerminalSettings } from "../../../domain/models";
|
||||
import type { LinkModifier, MiddleClickBehavior, RightClickBehavior, TerminalSettings } from "../../../domain/models";
|
||||
import { Input } from "../../ui/input";
|
||||
import { Label } from "../../ui/label";
|
||||
import { SectionHeader, Select, SettingRow, Toggle } from "../settings-ui";
|
||||
@@ -12,6 +12,15 @@ interface TerminalBehaviorSettingsProps {
|
||||
updateTerminalSetting: <K extends keyof TerminalSettings>(key: K, value: TerminalSettings[K]) => void;
|
||||
}
|
||||
|
||||
export const MIDDLE_CLICK_BEHAVIOR_OPTIONS: Array<{
|
||||
value: MiddleClickBehavior;
|
||||
labelKey: string;
|
||||
}> = [
|
||||
{ value: "context-menu", labelKey: "settings.terminal.behavior.middleClick.menu" },
|
||||
{ value: "paste", labelKey: "settings.terminal.behavior.middleClick.paste" },
|
||||
{ value: "disabled", labelKey: "settings.terminal.behavior.middleClick.disabled" },
|
||||
];
|
||||
|
||||
export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> = ({
|
||||
t,
|
||||
terminalSettings,
|
||||
@@ -44,10 +53,18 @@ export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> =
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.terminal.behavior.middleClickPaste")}
|
||||
description={t("settings.terminal.behavior.middleClickPaste.desc")}
|
||||
label={t("settings.terminal.behavior.middleClick")}
|
||||
description={t("settings.terminal.behavior.middleClick.desc")}
|
||||
>
|
||||
<Toggle checked={terminalSettings.middleClickPaste} onChange={(v) => updateTerminalSetting("middleClickPaste", v)} />
|
||||
<Select
|
||||
value={terminalSettings.middleClickBehavior}
|
||||
options={MIDDLE_CLICK_BEHAVIOR_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: t(option.labelKey),
|
||||
}))}
|
||||
onChange={(v) => updateTerminalSetting("middleClickBehavior", v as MiddleClickBehavior)}
|
||||
className="w-36"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
|
||||
25
components/sftp/SftpConflictDialog.test.ts
Normal file
25
components/sftp/SftpConflictDialog.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { canReplaceConflict } from "./SftpConflictDialog.tsx";
|
||||
|
||||
test("does not offer replace when a file upload conflicts with an existing directory", () => {
|
||||
assert.equal(canReplaceConflict({
|
||||
isDirectory: false,
|
||||
existingType: "directory",
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("does not offer replace when a directory upload conflicts with an existing file", () => {
|
||||
assert.equal(canReplaceConflict({
|
||||
isDirectory: true,
|
||||
existingType: "file",
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("offers replace when a file upload conflicts with an existing file", () => {
|
||||
assert.equal(canReplaceConflict({
|
||||
isDirectory: false,
|
||||
existingType: "file",
|
||||
}), true);
|
||||
});
|
||||
@@ -5,6 +5,7 @@
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import React, { memo, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { canReplaceSftpConflict, getSftpConflictTypeKey } from '../../domain/sftpConflict';
|
||||
import { Button } from '../ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
import type { FileConflictAction } from '../../domain/models';
|
||||
@@ -23,12 +24,53 @@ interface ConflictItem {
|
||||
newModified: number;
|
||||
}
|
||||
|
||||
export const canReplaceConflict = (conflict: Pick<ConflictItem, 'isDirectory' | 'existingType'>): boolean => {
|
||||
return canReplaceSftpConflict(conflict.isDirectory, conflict.existingType);
|
||||
};
|
||||
|
||||
const getConflictTypeKey = (conflict: Pick<ConflictItem, 'isDirectory' | 'existingType'>): string =>
|
||||
getSftpConflictTypeKey(conflict.isDirectory, conflict.existingType);
|
||||
|
||||
interface SftpConflictDialogProps {
|
||||
conflicts: ConflictItem[];
|
||||
onResolve: (conflictId: string, action: FileConflictAction, applyToAll?: boolean) => void;
|
||||
formatFileSize: (size: number) => string;
|
||||
}
|
||||
|
||||
interface ConflictFileSummaryProps {
|
||||
title: string;
|
||||
sizeLabel: string;
|
||||
modifiedLabel: string;
|
||||
size: string;
|
||||
modified: string;
|
||||
}
|
||||
|
||||
const ConflictFileSummary: React.FC<ConflictFileSummaryProps> = ({
|
||||
title,
|
||||
sizeLabel,
|
||||
modifiedLabel,
|
||||
size,
|
||||
modified,
|
||||
}) => (
|
||||
<div className="rounded-md border border-border/60 bg-secondary/25 px-4 py-3">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
<dl className="space-y-2 text-sm">
|
||||
<div className="grid grid-cols-[5.5rem_minmax(0,1fr)] gap-3">
|
||||
<dt className="text-muted-foreground">{sizeLabel}</dt>
|
||||
<dd className="min-w-0 text-foreground">{size}</dd>
|
||||
</div>
|
||||
<div className="grid grid-cols-[5.5rem_minmax(0,1fr)] gap-3">
|
||||
<dt className="text-muted-foreground">{modifiedLabel}</dt>
|
||||
<dd className="min-w-0 break-words leading-relaxed text-foreground">{modified}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts, onResolve, formatFileSize }) => {
|
||||
const { t } = useI18n();
|
||||
const [applyToAll, setApplyToAll] = useState(false);
|
||||
@@ -42,9 +84,10 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
|
||||
|
||||
const sameTypeConflictCount = Math.max(
|
||||
conflict.applyToAllCount ?? 1,
|
||||
conflicts.filter((item) => item.isDirectory === conflict.isDirectory).length,
|
||||
conflicts.filter((item) => getConflictTypeKey(item) === getConflictTypeKey(conflict)).length,
|
||||
);
|
||||
const canMerge = conflict.isDirectory && conflict.existingType === 'directory';
|
||||
const canReplace = canReplaceConflict(conflict);
|
||||
|
||||
const handleAction = (action: FileConflictAction) => {
|
||||
onResolve(conflict.transferId, action, applyToAll);
|
||||
@@ -53,55 +96,46 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
|
||||
|
||||
return (
|
||||
<Dialog open={!!conflict} onOpenChange={() => handleAction('skip')}>
|
||||
<DialogContent className="sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-yellow-500" />
|
||||
<DialogContent className="gap-5 p-5 sm:max-w-[520px] sm:p-6">
|
||||
<DialogHeader className="space-y-2 pr-8">
|
||||
<DialogTitle className="flex items-center gap-3 text-xl leading-tight">
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-border/70 text-muted-foreground">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
</span>
|
||||
{t('sftp.conflict.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<DialogDescription className="text-[15px] leading-6">
|
||||
{t('sftp.conflict.desc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="text-sm">
|
||||
<span className="font-medium">{conflict.fileName}</span>
|
||||
<span className="text-muted-foreground ml-1">{t('sftp.conflict.alreadyExistsSuffix')}</span>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border border-border/60 bg-muted/25 px-4 py-3 text-sm leading-6">
|
||||
<div className="min-w-0 break-words">
|
||||
<span className="font-medium text-foreground">{conflict.fileName}</span>
|
||||
<span className="ml-1 text-muted-foreground">{t('sftp.conflict.alreadyExistsSuffix')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-xs">
|
||||
<div className="p-3 rounded-lg bg-secondary/50 border border-border/60">
|
||||
<div className="font-medium mb-2 text-muted-foreground">{t('sftp.conflict.existingFile')}</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('sftp.conflict.size')}</span>
|
||||
<span>{formatFileSize(conflict.existingSize)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('sftp.conflict.modified')}</span>
|
||||
<span>{formatDate(conflict.existingModified)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 rounded-lg bg-primary/10 border border-primary/30">
|
||||
<div className="font-medium mb-2 text-primary">{t('sftp.conflict.newFile')}</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('sftp.conflict.size')}</span>
|
||||
<span>{formatFileSize(conflict.newSize)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('sftp.conflict.modified')}</span>
|
||||
<span>{formatDate(conflict.newModified)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<ConflictFileSummary
|
||||
title={t('sftp.conflict.existingFile')}
|
||||
sizeLabel={t('sftp.conflict.size')}
|
||||
modifiedLabel={t('sftp.conflict.modified')}
|
||||
size={formatFileSize(conflict.existingSize)}
|
||||
modified={formatDate(conflict.existingModified)}
|
||||
/>
|
||||
<ConflictFileSummary
|
||||
title={t('sftp.conflict.newFile')}
|
||||
sizeLabel={t('sftp.conflict.size')}
|
||||
modifiedLabel={t('sftp.conflict.modified')}
|
||||
size={formatFileSize(conflict.newSize)}
|
||||
modified={formatDate(conflict.newModified)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{sameTypeConflictCount > 1 && (
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<label className="flex cursor-pointer items-center gap-2 rounded-md border border-border/60 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={applyToAll}
|
||||
@@ -113,25 +147,25 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex flex-wrap gap-2 sm:justify-end sm:space-x-0">
|
||||
<DialogFooter className="flex flex-wrap gap-2 sm:items-center sm:justify-end sm:space-x-0">
|
||||
<Button
|
||||
variant="destructive"
|
||||
variant="outline"
|
||||
onClick={() => handleAction('stop')}
|
||||
className="flex-1"
|
||||
className="min-w-24 border-border/70 text-muted-foreground hover:text-destructive sm:mr-auto"
|
||||
>
|
||||
{t('sftp.conflict.action.stop')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleAction('skip')}
|
||||
className="flex-1"
|
||||
className="min-w-24"
|
||||
>
|
||||
{t('sftp.conflict.action.skip')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleAction('duplicate')}
|
||||
className="flex-1"
|
||||
className="min-w-24"
|
||||
>
|
||||
{t('sftp.conflict.action.duplicate')}
|
||||
</Button>
|
||||
@@ -140,18 +174,20 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
|
||||
variant="outline"
|
||||
onClick={() => handleAction('merge')}
|
||||
disabled={!canMerge}
|
||||
className="flex-1"
|
||||
className="min-w-24"
|
||||
>
|
||||
{t('sftp.conflict.action.merge')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => handleAction('replace')}
|
||||
className="flex-1"
|
||||
>
|
||||
{t('sftp.conflict.action.replace')}
|
||||
</Button>
|
||||
{canReplace && (
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => handleAction('replace')}
|
||||
className="min-w-28"
|
||||
>
|
||||
{t('sftp.conflict.action.replace')}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -7,6 +7,8 @@ import type { TransferTask } from "../../types";
|
||||
import FileOpenerDialog from "../FileOpenerDialog";
|
||||
import TextEditorModal from "../TextEditorModal";
|
||||
import type { TextEditorModalSnapshot } from "../TextEditorModal";
|
||||
import { TerminalHostKeyVerification } from "../terminal/TerminalHostKeyVerification";
|
||||
import { Dialog, DialogContent, DialogTitle } from "../ui/dialog";
|
||||
import { SftpConflictDialog } from "./SftpConflictDialog";
|
||||
import { SftpHostPicker } from "./SftpHostPicker";
|
||||
import { SftpPermissionsDialog } from "./SftpPermissionsDialog";
|
||||
@@ -139,6 +141,27 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
|
||||
formatFileSize={sftp.formatFileSize}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
open={!!sftp.hostKeyVerification}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) sftp.rejectHostKeyVerification();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-lg" hideCloseButton>
|
||||
<DialogTitle className="sr-only">Confirm host key</DialogTitle>
|
||||
{sftp.hostKeyVerification && (
|
||||
<TerminalHostKeyVerification
|
||||
hostKeyInfo={sftp.hostKeyVerification.hostKeyInfo}
|
||||
showLogs={sftp.hostKeyVerification.progressLogs.length > 0}
|
||||
progressLogs={sftp.hostKeyVerification.progressLogs}
|
||||
onClose={sftp.rejectHostKeyVerification}
|
||||
onContinue={sftp.acceptHostKeyVerification}
|
||||
onAddAndContinue={sftp.acceptAndSaveHostKeyVerification}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<SftpPermissionsDialog
|
||||
open={!!permissionsState}
|
||||
onOpenChange={(open) => !open && setPermissionsState(null)}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* - Drag-and-drop reordering of tabs
|
||||
*/
|
||||
|
||||
import { HardDrive, Monitor, Plus, X } from "lucide-react";
|
||||
import { Copy, HardDrive, Monitor, Plus, X } from "lucide-react";
|
||||
import React, {
|
||||
memo,
|
||||
useCallback,
|
||||
@@ -25,12 +25,27 @@ import { useRenderTracker } from "../../lib/useRenderTracker";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { useActiveTabId } from "./SftpContext";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "../ui/context-menu";
|
||||
import {
|
||||
canDuplicateSftpTab,
|
||||
isSftpTabKeyboardContextMenuShortcut,
|
||||
isSftpTabKeyboardSelectShortcut,
|
||||
shouldHandleSftpTabKeyboardEvent,
|
||||
SFTP_TAB_DUPLICATE_MENU_ITEMS,
|
||||
type SftpTabDuplicateMode,
|
||||
} from "./sftpTabDuplication";
|
||||
|
||||
export interface SftpTab {
|
||||
id: string;
|
||||
label: string;
|
||||
isLocal: boolean;
|
||||
hostId: string | null;
|
||||
canDuplicate?: boolean;
|
||||
}
|
||||
|
||||
interface SftpTabBarProps {
|
||||
@@ -46,6 +61,10 @@ interface SftpTabBarProps {
|
||||
) => void;
|
||||
/** Called when a tab is dragged to the other side */
|
||||
onMoveTabToOtherSide?: (tabId: string) => void;
|
||||
onDuplicateTab?: (
|
||||
tabId: string,
|
||||
mode: SftpTabDuplicateMode,
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
|
||||
@@ -56,6 +75,7 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
|
||||
onAddTab,
|
||||
onReorderTabs,
|
||||
onMoveTabToOtherSide,
|
||||
onDuplicateTab,
|
||||
}) => {
|
||||
// Subscribe to activeTabId from store (isolated subscription)
|
||||
const activeTabId = useActiveTabId(side);
|
||||
@@ -232,6 +252,35 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
|
||||
[onAddTab],
|
||||
);
|
||||
|
||||
const handleTabKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLDivElement>, tabId: string) => {
|
||||
if (!shouldHandleSftpTabKeyboardEvent(e.target, e.currentTarget)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSftpTabKeyboardSelectShortcut(e.key)) {
|
||||
e.preventDefault();
|
||||
onSelectTab(tabId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSftpTabKeyboardContextMenuShortcut(e.key, e.shiftKey)) {
|
||||
e.preventDefault();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
e.currentTarget.dispatchEvent(
|
||||
new MouseEvent("contextmenu", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 2,
|
||||
clientX: rect.left + Math.min(rect.width / 2, 24),
|
||||
clientY: rect.bottom,
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
[onSelectTab],
|
||||
);
|
||||
|
||||
// Cross-pane drag handlers
|
||||
const handleCrossPaneDragOver = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
@@ -307,6 +356,7 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = activeTabId === tab.id;
|
||||
const canDuplicateTab = canDuplicateSftpTab(tab, !!onDuplicateTab);
|
||||
const isBeingDragged =
|
||||
isDragging && draggedTabIdRef.current === tab.id;
|
||||
const showDropIndicatorBefore =
|
||||
@@ -317,71 +367,92 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
|
||||
dropIndicator.position === "after";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
data-tab-id={tab.id}
|
||||
data-tab-type="sftp"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
onClick={(e) => handleSelectTabClick(e, tab.id)}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseTab(tab.id))}
|
||||
draggable
|
||||
onDragStart={(e) => handleTabDragStart(e, tab.id)}
|
||||
onDragEnd={handleTabDragEnd}
|
||||
onDragOver={(e) => handleTabDragOver(e, tab.id)}
|
||||
onDrop={(e) => handleTabDrop(e, tab.id)}
|
||||
className={cn(
|
||||
"netcatty-tab relative px-3 min-w-[100px] max-w-[180px] text-xs font-medium cursor-pointer flex items-center justify-between gap-2 flex-shrink-0 border-r border-border/40",
|
||||
"transition-[color,opacity,transform] duration-100 ease-out",
|
||||
isActive
|
||||
? "text-foreground border-b-2"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
isBeingDragged && "opacity-50",
|
||||
)}
|
||||
style={
|
||||
isActive
|
||||
? { borderBottomColor: "hsl(var(--accent))" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{/* Drop indicator line - before */}
|
||||
{showDropIndicatorBefore && isDragging && (
|
||||
<div className="absolute left-0 top-1 bottom-1 w-0.5 bg-primary shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
|
||||
)}
|
||||
{/* Drop indicator line - after */}
|
||||
{showDropIndicatorAfter && isDragging && (
|
||||
<div className="absolute right-0 top-1 bottom-1 w-0.5 bg-primary shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
|
||||
)}
|
||||
<ContextMenu key={tab.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
data-tab-id={tab.id}
|
||||
data-tab-type="sftp"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
tabIndex={0}
|
||||
aria-haspopup="menu"
|
||||
aria-label={tab.label}
|
||||
onClick={(e) => handleSelectTabClick(e, tab.id)}
|
||||
onKeyDown={(e) => handleTabKeyDown(e, tab.id)}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseTab(tab.id))}
|
||||
draggable
|
||||
onDragStart={(e) => handleTabDragStart(e, tab.id)}
|
||||
onDragEnd={handleTabDragEnd}
|
||||
onDragOver={(e) => handleTabDragOver(e, tab.id)}
|
||||
onDrop={(e) => handleTabDrop(e, tab.id)}
|
||||
className={cn(
|
||||
"netcatty-tab relative px-3 min-w-[100px] max-w-[180px] text-xs font-medium cursor-pointer flex items-center justify-between gap-2 flex-shrink-0 border-r border-border/40",
|
||||
"transition-[color,opacity,transform] duration-100 ease-out focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 focus-visible:ring-inset",
|
||||
isActive
|
||||
? "text-foreground border-b-2"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
isBeingDragged && "opacity-50",
|
||||
)}
|
||||
style={
|
||||
isActive
|
||||
? { borderBottomColor: "hsl(var(--accent))" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{/* Drop indicator line - before */}
|
||||
{showDropIndicatorBefore && isDragging && (
|
||||
<div className="absolute left-0 top-1 bottom-1 w-0.5 bg-primary shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
|
||||
)}
|
||||
{/* Drop indicator line - after */}
|
||||
{showDropIndicatorAfter && isDragging && (
|
||||
<div className="absolute right-0 top-1 bottom-1 w-0.5 bg-primary shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
{tab.isLocal ? (
|
||||
<Monitor
|
||||
size={12}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
isActive ? "text-primary" : "text-muted-foreground",
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
{tab.isLocal ? (
|
||||
<Monitor
|
||||
size={12}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
isActive ? "text-primary" : "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<HardDrive
|
||||
size={12}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
isActive ? "text-primary" : "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<HardDrive
|
||||
size={12}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
isActive ? "text-primary" : "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span className="truncate">{tab.label}</span>
|
||||
</div>
|
||||
<span className="truncate">{tab.label}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={(e) => handleCloseTab(e, tab.id)}
|
||||
className="p-0.5 hover:bg-destructive/10 hover:text-destructive transition-colors shrink-0"
|
||||
aria-label={t("common.close")}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => handleCloseTab(e, tab.id)}
|
||||
className="p-0.5 hover:bg-destructive/10 hover:text-destructive transition-colors shrink-0"
|
||||
aria-label={t("common.close")}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
{SFTP_TAB_DUPLICATE_MENU_ITEMS.map((item) => (
|
||||
<ContextMenuItem
|
||||
key={item.mode}
|
||||
disabled={!canDuplicateTab}
|
||||
onClick={() => {
|
||||
void onDuplicateTab?.(tab.id, item.mode);
|
||||
}}
|
||||
>
|
||||
<Copy size={14} className="mr-2" />
|
||||
{t(item.labelKey)}
|
||||
</ContextMenuItem>
|
||||
))}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -432,7 +503,8 @@ const sftpTabBarAreEqual = (
|
||||
prevTab.id !== nextTab.id ||
|
||||
prevTab.label !== nextTab.label ||
|
||||
prevTab.isLocal !== nextTab.isLocal ||
|
||||
prevTab.hostId !== nextTab.hostId
|
||||
prevTab.hostId !== nextTab.hostId ||
|
||||
prevTab.canDuplicate !== nextTab.canDuplicate
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -6,17 +6,22 @@ import { editorTabStore } from "../../../application/state/editorTabStore";
|
||||
import type { EditorTab, EditorTabId } from "../../../application/state/editorTabStore";
|
||||
import { releaseEditorTabSaveCoordinator, saveEditorTab } from "../../../application/state/editorTabSave";
|
||||
import { promptUnsavedChanges } from "../../editor/UnsavedChangesDialog";
|
||||
import {
|
||||
getSftpTabDuplicateRequest,
|
||||
type SftpTabDuplicateMode,
|
||||
} from "../sftpTabDuplication";
|
||||
|
||||
interface UseSftpViewTabsParams {
|
||||
sftp: SftpStateApi;
|
||||
sftpRef: MutableRefObject<SftpStateApi>;
|
||||
hosts?: Host[];
|
||||
}
|
||||
|
||||
interface UseSftpViewTabsResult {
|
||||
leftPanes: SftpStateApi["leftPane"][];
|
||||
rightPanes: SftpStateApi["rightPane"][];
|
||||
leftTabsInfo: { id: string; label: string; isLocal: boolean; hostId: string | null }[];
|
||||
rightTabsInfo: { id: string; label: string; isLocal: boolean; hostId: string | null }[];
|
||||
leftTabsInfo: { id: string; label: string; isLocal: boolean; hostId: string | null; canDuplicate: boolean }[];
|
||||
rightTabsInfo: { id: string; label: string; isLocal: boolean; hostId: string | null; canDuplicate: boolean }[];
|
||||
showHostPickerLeft: boolean;
|
||||
showHostPickerRight: boolean;
|
||||
hostSearchLeft: string;
|
||||
@@ -35,15 +40,19 @@ interface UseSftpViewTabsResult {
|
||||
handleReorderTabsRight: (draggedId: string, targetId: string, position: "before" | "after") => void;
|
||||
handleMoveTabFromLeftToRight: (tabId: string) => void;
|
||||
handleMoveTabFromRightToLeft: (tabId: string) => void;
|
||||
handleDuplicateTabLeft: (tabId: string, mode: SftpTabDuplicateMode) => Promise<string | null>;
|
||||
handleDuplicateTabRight: (tabId: string, mode: SftpTabDuplicateMode) => Promise<string | null>;
|
||||
handleHostSelectLeft: (host: Host | "local") => void;
|
||||
handleHostSelectRight: (host: Host | "local") => void;
|
||||
}
|
||||
|
||||
export const useSftpViewTabs = ({ sftp, sftpRef }: UseSftpViewTabsParams): UseSftpViewTabsResult => {
|
||||
export const useSftpViewTabs = ({ sftp, sftpRef, hosts = [] }: UseSftpViewTabsParams): UseSftpViewTabsResult => {
|
||||
const [showHostPickerLeft, setShowHostPickerLeft] = useState(false);
|
||||
const [showHostPickerRight, setShowHostPickerRight] = useState(false);
|
||||
const [hostSearchLeft, setHostSearchLeft] = useState("");
|
||||
const [hostSearchRight, setHostSearchRight] = useState("");
|
||||
const hostsRef = React.useRef(hosts);
|
||||
hostsRef.current = hosts;
|
||||
|
||||
const handleAddTabLeft = useCallback(() => {
|
||||
const tabId = sftpRef.current.addTab("left");
|
||||
@@ -132,6 +141,43 @@ export const useSftpViewTabs = ({ sftp, sftpRef }: UseSftpViewTabsParams): UseSf
|
||||
sftpRef.current.moveTabToOtherSide("right", tabId);
|
||||
}, [sftpRef]);
|
||||
|
||||
const handleDuplicateTab = useCallback(
|
||||
async (side: "left" | "right", tabId: string, mode: SftpTabDuplicateMode) => {
|
||||
const sideTabs = side === "left" ? sftpRef.current.leftTabs : sftpRef.current.rightTabs;
|
||||
const pane = sideTabs.tabs.find((tab) => tab.id === tabId);
|
||||
const request = getSftpTabDuplicateRequest(pane, mode);
|
||||
if (!request) return null;
|
||||
|
||||
const host = request.kind === "local"
|
||||
? "local"
|
||||
: hostsRef.current.find((item) => item.id === request.hostId);
|
||||
if (!host) return null;
|
||||
|
||||
let duplicatedTabId: string | null = null;
|
||||
await sftpRef.current.connect(side, host, {
|
||||
forceNewTab: true,
|
||||
ignoreSharedCache: mode === "defaultPath",
|
||||
initialPath: request.path,
|
||||
onTabCreated: (createdTabId) => {
|
||||
duplicatedTabId = createdTabId;
|
||||
},
|
||||
});
|
||||
|
||||
return duplicatedTabId;
|
||||
},
|
||||
[sftpRef],
|
||||
);
|
||||
|
||||
const handleDuplicateTabLeft = useCallback(
|
||||
(tabId: string, mode: SftpTabDuplicateMode) => handleDuplicateTab("left", tabId, mode),
|
||||
[handleDuplicateTab],
|
||||
);
|
||||
|
||||
const handleDuplicateTabRight = useCallback(
|
||||
(tabId: string, mode: SftpTabDuplicateMode) => handleDuplicateTab("right", tabId, mode),
|
||||
[handleDuplicateTab],
|
||||
);
|
||||
|
||||
const handleHostSelectLeft = useCallback((host: Host | "local") => {
|
||||
sftpRef.current.connect("left", host);
|
||||
setShowHostPickerLeft(false);
|
||||
@@ -149,6 +195,7 @@ export const useSftpViewTabs = ({ sftp, sftpRef }: UseSftpViewTabsParams): UseSf
|
||||
label: pane.connection?.hostLabel || "New Tab",
|
||||
isLocal: pane.connection?.isLocal || false,
|
||||
hostId: pane.connection?.hostId || null,
|
||||
canDuplicate: pane.connection?.status === "connected",
|
||||
})),
|
||||
[sftp.leftTabs.tabs],
|
||||
);
|
||||
@@ -160,6 +207,7 @@ export const useSftpViewTabs = ({ sftp, sftpRef }: UseSftpViewTabsParams): UseSf
|
||||
label: pane.connection?.hostLabel || "New Tab",
|
||||
isLocal: pane.connection?.isLocal || false,
|
||||
hostId: pane.connection?.hostId || null,
|
||||
canDuplicate: pane.connection?.status === "connected",
|
||||
})),
|
||||
[sftp.rightTabs.tabs],
|
||||
);
|
||||
@@ -187,6 +235,8 @@ export const useSftpViewTabs = ({ sftp, sftpRef }: UseSftpViewTabsParams): UseSf
|
||||
handleReorderTabsRight,
|
||||
handleMoveTabFromLeftToRight,
|
||||
handleMoveTabFromRightToLeft,
|
||||
handleDuplicateTabLeft,
|
||||
handleDuplicateTabRight,
|
||||
handleHostSelectLeft,
|
||||
handleHostSelectRight,
|
||||
};
|
||||
|
||||
114
components/sftp/sftpTabDuplication.test.ts
Normal file
114
components/sftp/sftpTabDuplication.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type { SftpPane } from "../../application/state/sftp/types.ts";
|
||||
import {
|
||||
canDuplicateSftpTab,
|
||||
getSftpTabDuplicateRequest,
|
||||
isSftpTabKeyboardContextMenuShortcut,
|
||||
isSftpTabKeyboardSelectShortcut,
|
||||
shouldHandleSftpTabKeyboardEvent,
|
||||
SFTP_TAB_DUPLICATE_MENU_ITEMS,
|
||||
} from "./sftpTabDuplication.ts";
|
||||
|
||||
const connectedPane = (overrides: Partial<NonNullable<SftpPane["connection"]>> = {}): SftpPane => ({
|
||||
id: "tab-1",
|
||||
connection: {
|
||||
id: "conn-1",
|
||||
hostId: "host-1",
|
||||
hostLabel: "Prod",
|
||||
isLocal: false,
|
||||
status: "connected",
|
||||
currentPath: "/var/www/app",
|
||||
homeDir: "/home/deploy",
|
||||
...overrides,
|
||||
},
|
||||
files: [],
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
error: null,
|
||||
connectionLogs: [],
|
||||
selectedFiles: new Set(),
|
||||
filter: "",
|
||||
filenameEncoding: "auto",
|
||||
showHiddenFiles: false,
|
||||
transferMutationToken: 0,
|
||||
});
|
||||
|
||||
test("default-path SFTP tab duplication keeps only the remote host identity", () => {
|
||||
assert.deepEqual(getSftpTabDuplicateRequest(connectedPane(), "defaultPath"), {
|
||||
kind: "remote",
|
||||
hostId: "host-1",
|
||||
});
|
||||
});
|
||||
|
||||
test("current-path SFTP tab duplication carries the active directory", () => {
|
||||
assert.deepEqual(getSftpTabDuplicateRequest(connectedPane(), "currentPath"), {
|
||||
kind: "remote",
|
||||
hostId: "host-1",
|
||||
path: "/var/www/app",
|
||||
});
|
||||
});
|
||||
|
||||
test("local SFTP tab duplication targets the local filesystem", () => {
|
||||
assert.deepEqual(
|
||||
getSftpTabDuplicateRequest(
|
||||
connectedPane({
|
||||
hostId: "local",
|
||||
hostLabel: "Local",
|
||||
isLocal: true,
|
||||
currentPath: "/Users/damao/projects",
|
||||
homeDir: "/Users/damao",
|
||||
}),
|
||||
"currentPath",
|
||||
),
|
||||
{
|
||||
kind: "local",
|
||||
path: "/Users/damao/projects",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("SFTP tab duplication is unavailable before a tab is connected", () => {
|
||||
assert.equal(getSftpTabDuplicateRequest({ ...connectedPane(), connection: null }, "defaultPath"), null);
|
||||
assert.equal(
|
||||
getSftpTabDuplicateRequest(connectedPane({ status: "connecting" }), "currentPath"),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("SFTP tab duplicate menu exposes separate default and current path actions", () => {
|
||||
assert.deepEqual(
|
||||
SFTP_TAB_DUPLICATE_MENU_ITEMS.map((item) => item.mode),
|
||||
["defaultPath", "currentPath"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
SFTP_TAB_DUPLICATE_MENU_ITEMS.map((item) => item.labelKey),
|
||||
["sftp.tabs.copyDefaultPath", "sftp.tabs.copyCurrentPath"],
|
||||
);
|
||||
});
|
||||
|
||||
test("SFTP tab duplicate menu is disabled without a connected tab and handler", () => {
|
||||
assert.equal(canDuplicateSftpTab({ canDuplicate: true }, true), true);
|
||||
assert.equal(canDuplicateSftpTab({ canDuplicate: true }, false), false);
|
||||
assert.equal(canDuplicateSftpTab({ canDuplicate: false }, true), false);
|
||||
assert.equal(canDuplicateSftpTab(connectedPane(), true), true);
|
||||
assert.equal(canDuplicateSftpTab(connectedPane({ status: "connecting" }), true), false);
|
||||
});
|
||||
|
||||
test("SFTP tab duplicate menu has keyboard shortcuts for selection and menu access", () => {
|
||||
assert.equal(isSftpTabKeyboardSelectShortcut("Enter"), true);
|
||||
assert.equal(isSftpTabKeyboardSelectShortcut(" "), true);
|
||||
assert.equal(isSftpTabKeyboardSelectShortcut("Escape"), false);
|
||||
assert.equal(isSftpTabKeyboardContextMenuShortcut("ContextMenu"), true);
|
||||
assert.equal(isSftpTabKeyboardContextMenuShortcut("F10", true), true);
|
||||
assert.equal(isSftpTabKeyboardContextMenuShortcut("F10", false), false);
|
||||
});
|
||||
|
||||
test("SFTP tab keyboard shortcuts do not intercept nested close button events", () => {
|
||||
const tab = new EventTarget();
|
||||
const closeButton = new EventTarget();
|
||||
|
||||
assert.equal(shouldHandleSftpTabKeyboardEvent(tab, tab), true);
|
||||
assert.equal(shouldHandleSftpTabKeyboardEvent(closeButton, tab), false);
|
||||
});
|
||||
73
components/sftp/sftpTabDuplication.ts
Normal file
73
components/sftp/sftpTabDuplication.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { SftpPane } from "../../application/state/sftp/types";
|
||||
|
||||
export type SftpTabDuplicateMode = "defaultPath" | "currentPath";
|
||||
|
||||
export type SftpTabDuplicateRequest =
|
||||
| { kind: "local"; path?: string }
|
||||
| { kind: "remote"; hostId: string; path?: string };
|
||||
|
||||
export const SFTP_TAB_DUPLICATE_MENU_ITEMS: ReadonlyArray<{
|
||||
mode: SftpTabDuplicateMode;
|
||||
labelKey: "sftp.tabs.copyDefaultPath" | "sftp.tabs.copyCurrentPath";
|
||||
}> = Object.freeze([
|
||||
{ mode: "defaultPath", labelKey: "sftp.tabs.copyDefaultPath" },
|
||||
{ mode: "currentPath", labelKey: "sftp.tabs.copyCurrentPath" },
|
||||
]);
|
||||
|
||||
export function canDuplicateSftpTab(
|
||||
tab: Pick<SftpPane, "connection"> | { canDuplicate?: boolean } | null | undefined,
|
||||
hasDuplicateHandler: boolean,
|
||||
): boolean {
|
||||
if (!hasDuplicateHandler || !tab) return false;
|
||||
if ("connection" in tab) return tab.connection?.status === "connected";
|
||||
return !!tab.canDuplicate;
|
||||
}
|
||||
|
||||
export function isSftpTabKeyboardContextMenuShortcut(
|
||||
key: string,
|
||||
shiftKey = false,
|
||||
): boolean {
|
||||
return key === "ContextMenu" || (shiftKey && key === "F10");
|
||||
}
|
||||
|
||||
export function isSftpTabKeyboardSelectShortcut(key: string): boolean {
|
||||
return key === "Enter" || key === " ";
|
||||
}
|
||||
|
||||
export function shouldHandleSftpTabKeyboardEvent(
|
||||
target: EventTarget | null,
|
||||
currentTarget: EventTarget | null,
|
||||
): boolean {
|
||||
return target === currentTarget;
|
||||
}
|
||||
|
||||
export function getSftpTabDuplicateRequest(
|
||||
pane: Pick<SftpPane, "connection"> | null | undefined,
|
||||
mode: SftpTabDuplicateMode,
|
||||
): SftpTabDuplicateRequest | null {
|
||||
const connection = pane?.connection;
|
||||
if (!connection || connection.status !== "connected") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const path = mode === "currentPath" && connection.currentPath
|
||||
? { path: connection.currentPath }
|
||||
: {};
|
||||
|
||||
if (connection.isLocal) {
|
||||
return {
|
||||
kind: "local",
|
||||
...path,
|
||||
};
|
||||
}
|
||||
|
||||
if (!connection.hostId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "remote",
|
||||
hostId: connection.hostId,
|
||||
...path,
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
|
||||
|
||||
import en from "../../application/i18n/locales/en.ts";
|
||||
import zhCN from "../../application/i18n/locales/zh-CN.ts";
|
||||
import { markMiddleClickContextMenuEvent } from "./runtime/middleClickBehavior.ts";
|
||||
import * as terminalContextMenu from "./TerminalContextMenu.tsx";
|
||||
import { shouldEnableYmodemAction } from "./TerminalView.tsx";
|
||||
|
||||
@@ -22,6 +23,30 @@ const shouldSuppressMouseTrackingContextMenu = (
|
||||
}) => boolean;
|
||||
}
|
||||
).shouldSuppressMouseTrackingContextMenu;
|
||||
const shouldShowAddSelectionToAIContextMenuAction = (
|
||||
terminalContextMenu as {
|
||||
shouldShowAddSelectionToAIContextMenuAction?: (onAddSelectionToAI?: () => void) => boolean;
|
||||
}
|
||||
).shouldShowAddSelectionToAIContextMenuAction;
|
||||
const shouldOpenTerminalContextMenu = (
|
||||
terminalContextMenu as {
|
||||
shouldOpenTerminalContextMenu?: (options: {
|
||||
event: { shiftKey?: boolean; nativeEvent: MouseEvent };
|
||||
rightClickBehavior?: "context-menu" | "paste" | "select-word";
|
||||
isAlternateScreen?: boolean;
|
||||
showReconnectAction?: boolean;
|
||||
}) => boolean;
|
||||
}
|
||||
).shouldOpenTerminalContextMenu;
|
||||
const shouldRenderTerminalContextMenuContent = (
|
||||
terminalContextMenu as {
|
||||
shouldRenderTerminalContextMenuContent?: (options: {
|
||||
isAlternateScreen?: boolean;
|
||||
showReconnectAction?: boolean;
|
||||
allowSuppressedMenuContent?: boolean;
|
||||
}) => boolean;
|
||||
}
|
||||
).shouldRenderTerminalContextMenuContent;
|
||||
|
||||
test("shows reconnect only for reconnectable terminals with a handler", () => {
|
||||
assert.equal(typeof shouldShowReconnectAction, "function");
|
||||
@@ -49,11 +74,23 @@ test("localizes the reconnect context menu label", () => {
|
||||
assert.equal(zhCN["terminal.menu.reconnect"], "重新连接");
|
||||
});
|
||||
|
||||
test("shows add selection to AI context menu action when a handler exists", () => {
|
||||
assert.equal(typeof shouldShowAddSelectionToAIContextMenuAction, "function");
|
||||
if (typeof shouldShowAddSelectionToAIContextMenuAction !== "function") return;
|
||||
|
||||
assert.equal(shouldShowAddSelectionToAIContextMenuAction(() => {}), true);
|
||||
assert.equal(shouldShowAddSelectionToAIContextMenuAction(), false);
|
||||
});
|
||||
|
||||
test("localizes the YMODEM serial send actions", () => {
|
||||
assert.equal(en["terminal.menu.sendYmodem"], "Send with YMODEM");
|
||||
assert.equal(en["terminal.menu.receiveYmodem"], "Receive with YMODEM");
|
||||
assert.equal(en["terminal.toolbar.sendYmodem"], "Send with YMODEM");
|
||||
assert.equal(en["terminal.toolbar.receiveYmodem"], "Receive with YMODEM");
|
||||
assert.equal(zhCN["terminal.menu.sendYmodem"], "YMODEM 发送");
|
||||
assert.equal(zhCN["terminal.menu.receiveYmodem"], "YMODEM 接收");
|
||||
assert.equal(zhCN["terminal.toolbar.sendYmodem"], "YMODEM 发送");
|
||||
assert.equal(zhCN["terminal.toolbar.receiveYmodem"], "YMODEM 接收");
|
||||
});
|
||||
|
||||
test("enables YMODEM action only for connected serial terminals", () => {
|
||||
@@ -64,6 +101,16 @@ test("enables YMODEM action only for connected serial terminals", () => {
|
||||
status: "connected",
|
||||
handleSendYmodem: handler,
|
||||
}), true);
|
||||
assert.equal(shouldEnableYmodemAction({
|
||||
isSerialConnection: true,
|
||||
status: "connected",
|
||||
handleReceiveYmodem: handler,
|
||||
}), true);
|
||||
assert.equal(shouldEnableYmodemAction({
|
||||
isSerialConnection: true,
|
||||
status: "disconnected",
|
||||
handleReceiveYmodem: handler,
|
||||
}), false);
|
||||
assert.equal(shouldEnableYmodemAction({
|
||||
isSerialConnection: true,
|
||||
status: "disconnected",
|
||||
@@ -99,3 +146,83 @@ test("allows reconnect menu while stale mouse tracking is still active", () => {
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("opens a middle-click menu even when right-click is configured to paste", () => {
|
||||
assert.equal(typeof shouldOpenTerminalContextMenu, "function");
|
||||
if (typeof shouldOpenTerminalContextMenu !== "function") return;
|
||||
|
||||
assert.equal(
|
||||
shouldOpenTerminalContextMenu({
|
||||
event: {
|
||||
shiftKey: false,
|
||||
nativeEvent: markMiddleClickContextMenuEvent({} as MouseEvent),
|
||||
},
|
||||
rightClickBehavior: "paste",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
shouldOpenTerminalContextMenu({
|
||||
event: {
|
||||
shiftKey: false,
|
||||
nativeEvent: {} as MouseEvent,
|
||||
},
|
||||
rightClickBehavior: "paste",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("opens and renders middle-click menu while alternate-screen mouse tracking suppresses right-click menus", () => {
|
||||
assert.equal(typeof shouldOpenTerminalContextMenu, "function");
|
||||
assert.equal(typeof shouldRenderTerminalContextMenuContent, "function");
|
||||
if (
|
||||
typeof shouldOpenTerminalContextMenu !== "function" ||
|
||||
typeof shouldRenderTerminalContextMenuContent !== "function"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
shouldOpenTerminalContextMenu({
|
||||
event: {
|
||||
shiftKey: false,
|
||||
nativeEvent: markMiddleClickContextMenuEvent({} as MouseEvent),
|
||||
},
|
||||
rightClickBehavior: "paste",
|
||||
isAlternateScreen: true,
|
||||
showReconnectAction: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldRenderTerminalContextMenuContent({
|
||||
isAlternateScreen: true,
|
||||
showReconnectAction: false,
|
||||
allowSuppressedMenuContent: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
shouldOpenTerminalContextMenu({
|
||||
event: {
|
||||
shiftKey: false,
|
||||
nativeEvent: {} as MouseEvent,
|
||||
},
|
||||
rightClickBehavior: "context-menu",
|
||||
isAlternateScreen: true,
|
||||
showReconnectAction: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldRenderTerminalContextMenuContent({
|
||||
isAlternateScreen: true,
|
||||
showReconnectAction: false,
|
||||
allowSuppressedMenuContent: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import {
|
||||
ClipboardPaste,
|
||||
Copy,
|
||||
Download,
|
||||
RefreshCcw,
|
||||
Sparkles,
|
||||
SplitSquareHorizontal,
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
Trash2,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { KeyBinding, RightClickBehavior } from '../../domain/models';
|
||||
import {
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
ContextMenuShortcut,
|
||||
ContextMenuTrigger,
|
||||
} from '../ui/context-menu';
|
||||
import { isMiddleClickContextMenuEvent } from './runtime/middleClickBehavior';
|
||||
|
||||
export interface TerminalContextMenuProps {
|
||||
children: React.ReactNode;
|
||||
@@ -40,6 +42,7 @@ export interface TerminalContextMenuProps {
|
||||
onSplitHorizontal?: () => void;
|
||||
onSplitVertical?: () => void;
|
||||
onSendYmodem?: () => void;
|
||||
onReceiveYmodem?: () => void;
|
||||
isReconnectable?: boolean;
|
||||
onReconnect?: () => void;
|
||||
onClose?: () => void;
|
||||
@@ -63,6 +66,44 @@ export const shouldSuppressMouseTrackingContextMenu = ({
|
||||
showReconnectAction?: boolean;
|
||||
}): boolean => Boolean(isAlternateScreen && !showReconnectAction);
|
||||
|
||||
export const shouldShowAddSelectionToAIContextMenuAction = (
|
||||
onAddSelectionToAI?: () => void,
|
||||
): boolean => Boolean(onAddSelectionToAI);
|
||||
|
||||
export const shouldRenderTerminalContextMenuContent = ({
|
||||
isAlternateScreen,
|
||||
showReconnectAction,
|
||||
allowSuppressedMenuContent,
|
||||
}: {
|
||||
isAlternateScreen?: boolean;
|
||||
showReconnectAction?: boolean;
|
||||
allowSuppressedMenuContent?: boolean;
|
||||
}): boolean =>
|
||||
allowSuppressedMenuContent ||
|
||||
!shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, showReconnectAction });
|
||||
|
||||
export const shouldOpenTerminalContextMenu = ({
|
||||
event,
|
||||
rightClickBehavior = 'context-menu',
|
||||
isAlternateScreen,
|
||||
showReconnectAction,
|
||||
}: {
|
||||
event: { shiftKey?: boolean; nativeEvent: MouseEvent };
|
||||
rightClickBehavior?: RightClickBehavior;
|
||||
isAlternateScreen?: boolean;
|
||||
showReconnectAction?: boolean;
|
||||
}): boolean => {
|
||||
if (isMiddleClickContextMenuEvent(event.nativeEvent)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, showReconnectAction })) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Boolean(event.shiftKey || rightClickBehavior === 'context-menu');
|
||||
};
|
||||
|
||||
export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
children,
|
||||
hasSelection = false,
|
||||
@@ -78,6 +119,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
onSplitHorizontal,
|
||||
onSplitVertical,
|
||||
onSendYmodem,
|
||||
onReceiveYmodem,
|
||||
isReconnectable,
|
||||
onReconnect,
|
||||
onClose,
|
||||
@@ -90,11 +132,13 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
// keep its `:focus-within`-driven opacity stable while focus is in the
|
||||
// menu portal (otherwise the pane dims for the menu's lifetime).
|
||||
const markedPaneRef = useRef<HTMLElement | null>(null);
|
||||
const [allowSuppressedMenuContent, setAllowSuppressedMenuContent] = useState(false);
|
||||
|
||||
const handleOpenChange = useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
markedPaneRef.current?.removeAttribute('data-menu-open');
|
||||
markedPaneRef.current = null;
|
||||
setAllowSuppressedMenuContent(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -125,19 +169,28 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
// In alternate screen (tmux, vim, etc.), let the terminal application
|
||||
// handle right-click natively to avoid conflicting menus. Reconnect is
|
||||
// still available after disconnect, even if mouse tracking was left on.
|
||||
if (shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, showReconnectAction })) {
|
||||
const shouldOpenMenu = shouldOpenTerminalContextMenu({
|
||||
event: e,
|
||||
rightClickBehavior,
|
||||
isAlternateScreen,
|
||||
showReconnectAction,
|
||||
});
|
||||
const isMiddleClickMenu = isMiddleClickContextMenuEvent(e.nativeEvent);
|
||||
|
||||
if (!shouldOpenMenu && shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, showReconnectAction })) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// Shift+Right-Click or context-menu mode: let Radix open the menu
|
||||
if (e.shiftKey || rightClickBehavior === 'context-menu') {
|
||||
if (shouldOpenMenu) {
|
||||
const pane = (e.target as HTMLElement | null)?.closest<HTMLElement>('.workspace-pane');
|
||||
if (pane) {
|
||||
markedPaneRef.current?.removeAttribute('data-menu-open');
|
||||
pane.setAttribute('data-menu-open', '');
|
||||
markedPaneRef.current = pane;
|
||||
}
|
||||
setAllowSuppressedMenuContent(isMiddleClickMenu);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -162,7 +215,11 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
>
|
||||
{children}
|
||||
</ContextMenuTrigger>
|
||||
{!shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, showReconnectAction }) && (
|
||||
{shouldRenderTerminalContextMenuContent({
|
||||
isAlternateScreen,
|
||||
showReconnectAction,
|
||||
allowSuppressedMenuContent,
|
||||
}) && (
|
||||
<ContextMenuContent className="w-max">
|
||||
<ContextMenuItem onClick={onCopy} disabled={!hasSelection}>
|
||||
<Copy size={14} className="mr-2" />
|
||||
@@ -174,7 +231,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
{t('terminal.menu.paste')}
|
||||
<ContextMenuShortcut>{pasteShortcut}</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
{onAddSelectionToAI && (
|
||||
{shouldShowAddSelectionToAIContextMenuAction(onAddSelectionToAI) && (
|
||||
<ContextMenuItem onClick={onAddSelectionToAI} disabled={!hasSelection}>
|
||||
<Sparkles size={14} className="mr-2" />
|
||||
{t('terminal.menu.addSelectionToAI')}
|
||||
@@ -203,13 +260,21 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{onSendYmodem && (
|
||||
{(onSendYmodem || onReceiveYmodem) && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={onSendYmodem}>
|
||||
<Upload size={14} className="mr-2" />
|
||||
{t('terminal.menu.sendYmodem')}
|
||||
</ContextMenuItem>
|
||||
{onSendYmodem && (
|
||||
<ContextMenuItem onClick={onSendYmodem}>
|
||||
<Upload size={14} className="mr-2" />
|
||||
{t('terminal.menu.sendYmodem')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{onReceiveYmodem && (
|
||||
<ContextMenuItem onClick={onReceiveYmodem}>
|
||||
<Download size={14} className="mr-2" />
|
||||
{t('terminal.menu.receiveYmodem')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -69,12 +69,15 @@ test("hides SFTP for local terminal sessions", () => {
|
||||
test("shows YMODEM send only for connected serial sessions", () => {
|
||||
const connectedSerial = renderToolbar(serialHost, "connected", {
|
||||
onSendYmodem: () => {},
|
||||
onReceiveYmodem: () => {},
|
||||
});
|
||||
const disconnectedSerial = renderToolbar(serialHost, "disconnected", {
|
||||
onSendYmodem: () => {},
|
||||
onReceiveYmodem: () => {},
|
||||
});
|
||||
const ssh = renderToolbar(sshHost, "connected", {
|
||||
onSendYmodem: () => {},
|
||||
onReceiveYmodem: () => {},
|
||||
});
|
||||
const local = renderToolbar({
|
||||
...sshHost,
|
||||
@@ -82,14 +85,20 @@ test("shows YMODEM send only for connected serial sessions", () => {
|
||||
protocol: "local",
|
||||
}, "connected", {
|
||||
onSendYmodem: () => {},
|
||||
onReceiveYmodem: () => {},
|
||||
});
|
||||
|
||||
assert.equal(connectedSerial.includes('aria-label="Send with YMODEM"'), true);
|
||||
assert.equal(connectedSerial.includes('aria-label="Receive with YMODEM"'), true);
|
||||
assert.doesNotMatch(connectedSerial, /aria-label="Send with YMODEM"[^>]*disabled/);
|
||||
assert.equal(disconnectedSerial.includes('aria-label="Available after connect"'), true);
|
||||
assert.match(disconnectedSerial, /aria-label="Available after connect"[^>]*disabled/);
|
||||
assert.equal(disconnectedSerial.includes('aria-label="Send with YMODEM - Available after connect"'), true);
|
||||
assert.equal(disconnectedSerial.includes('aria-label="Receive with YMODEM - Available after connect"'), true);
|
||||
assert.match(disconnectedSerial, /aria-label="Send with YMODEM - Available after connect"[^>]*disabled/);
|
||||
assert.match(disconnectedSerial, /aria-label="Receive with YMODEM - Available after connect"[^>]*disabled/);
|
||||
assert.equal(ssh.includes('aria-label="Send with YMODEM"'), false);
|
||||
assert.equal(ssh.includes('aria-label="Receive with YMODEM"'), false);
|
||||
assert.equal(local.includes('aria-label="Send with YMODEM"'), false);
|
||||
assert.equal(local.includes('aria-label="Receive with YMODEM"'), false);
|
||||
});
|
||||
|
||||
test("uses the terminal active button color for pressed toolbar actions", () => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Terminal Toolbar
|
||||
* Displays high-frequency terminal actions and close button in the terminal status bar.
|
||||
*/
|
||||
import { Check, ChevronRight, FolderInput, History, Languages, MoreVertical, X, Zap, Palette, Search, TextCursorInput, Upload } from 'lucide-react';
|
||||
import { Check, ChevronRight, Download, FolderInput, History, Languages, MoreVertical, X, Zap, Palette, Search, TextCursorInput, Upload } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Host, Snippet } from '../../types';
|
||||
@@ -23,6 +23,7 @@ export interface TerminalToolbarProps {
|
||||
onSnippetClick?: (snippet: Snippet) => void;
|
||||
onOpenSFTP: () => void;
|
||||
onSendYmodem?: () => void;
|
||||
onReceiveYmodem?: () => void;
|
||||
onOpenScripts: () => void;
|
||||
onOpenHistory?: () => void;
|
||||
onOpenTheme: () => void;
|
||||
@@ -49,6 +50,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
onSnippetClick,
|
||||
onOpenSFTP,
|
||||
onSendYmodem,
|
||||
onReceiveYmodem,
|
||||
onOpenScripts,
|
||||
onOpenHistory,
|
||||
onOpenTheme,
|
||||
@@ -86,6 +88,8 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
const encodingSwitchSupported = !isLocalTerminal && !isMoshSession && !isEtSession;
|
||||
const hidesSftp = isLocalTerminal || isSerialTerminal;
|
||||
const historySupported = !!onOpenHistory && !isLocalTerminal && !isSerialTerminal && host?.protocol !== 'telnet';
|
||||
const unavailableYmodemSendLabel = `${t("terminal.toolbar.sendYmodem")} - ${t("terminal.toolbar.availableAfterConnect")}`;
|
||||
const unavailableYmodemReceiveLabel = `${t("terminal.toolbar.receiveYmodem")} - ${t("terminal.toolbar.availableAfterConnect")}`;
|
||||
|
||||
const menuItemClass = "w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors";
|
||||
const activeButtonStyle: React.CSSProperties = {
|
||||
@@ -194,23 +198,43 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
)}
|
||||
|
||||
{isSerialTerminal && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className={cn(buttonBase, status !== 'connected' && "opacity-50")}
|
||||
aria-label={status === 'connected' ? t("terminal.toolbar.sendYmodem") : t("terminal.toolbar.availableAfterConnect")}
|
||||
onClick={onSendYmodem}
|
||||
disabled={status !== 'connected' || !onSendYmodem}
|
||||
>
|
||||
<Upload size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{status === 'connected' ? t("terminal.toolbar.sendYmodem") : t("terminal.toolbar.availableAfterConnect")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className={cn(buttonBase, status !== 'connected' && "opacity-50")}
|
||||
aria-label={status === 'connected' ? t("terminal.toolbar.sendYmodem") : unavailableYmodemSendLabel}
|
||||
onClick={onSendYmodem}
|
||||
disabled={status !== 'connected' || !onSendYmodem}
|
||||
>
|
||||
<Upload size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{status === 'connected' ? t("terminal.toolbar.sendYmodem") : t("terminal.toolbar.availableAfterConnect")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className={cn(buttonBase, status !== 'connected' && "opacity-50")}
|
||||
aria-label={status === 'connected' ? t("terminal.toolbar.receiveYmodem") : unavailableYmodemReceiveLabel}
|
||||
onClick={onReceiveYmodem}
|
||||
disabled={status !== 'connected' || !onReceiveYmodem}
|
||||
>
|
||||
<Download size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{status === 'connected' ? t("terminal.toolbar.receiveYmodem") : t("terminal.toolbar.availableAfterConnect")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { readFileSync } from "node:fs";
|
||||
|
||||
import {
|
||||
getLineTimestampToggleHostUpdate,
|
||||
shouldShowSelectionAIOverlay,
|
||||
shouldShowLineTimestampToolbarToggle,
|
||||
} from "./TerminalView.tsx";
|
||||
|
||||
@@ -32,6 +33,38 @@ test("line timestamp toolbar toggle is hidden when timestamps are unavailable",
|
||||
assert.equal(shouldShowLineTimestampToolbarToggle(true, undefined), false);
|
||||
});
|
||||
|
||||
test("selection AI overlay honors the visibility preference", () => {
|
||||
const overlayPosition = { left: 120, top: 80 };
|
||||
const addSelection = () => {};
|
||||
|
||||
assert.equal(
|
||||
shouldShowSelectionAIOverlay({
|
||||
hasSelection: true,
|
||||
selectionOverlayPosition: overlayPosition,
|
||||
onAddSelectionToAI: addSelection,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldShowSelectionAIOverlay({
|
||||
hasSelection: true,
|
||||
selectionOverlayPosition: overlayPosition,
|
||||
onAddSelectionToAI: addSelection,
|
||||
showSelectionAIAction: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldShowSelectionAIOverlay({
|
||||
hasSelection: true,
|
||||
selectionOverlayPosition: overlayPosition,
|
||||
onAddSelectionToAI: addSelection,
|
||||
showSelectionAIAction: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("popup terminals disable line timestamp controls", () => {
|
||||
const source = readFileSync(new URL("../TerminalPopupPage.tsx", import.meta.url), "utf8");
|
||||
|
||||
|
||||
@@ -34,12 +34,33 @@ export function shouldEnableYmodemAction({
|
||||
isSerialConnection,
|
||||
status,
|
||||
handleSendYmodem,
|
||||
handleReceiveYmodem,
|
||||
}: {
|
||||
isSerialConnection?: boolean;
|
||||
status?: string;
|
||||
handleSendYmodem?: () => void;
|
||||
handleReceiveYmodem?: () => void;
|
||||
}): boolean {
|
||||
return Boolean(isSerialConnection && status === "connected" && handleSendYmodem);
|
||||
return Boolean(isSerialConnection && status === "connected" && (handleSendYmodem || handleReceiveYmodem));
|
||||
}
|
||||
|
||||
export function shouldShowSelectionAIOverlay({
|
||||
hasSelection,
|
||||
selectionOverlayPosition,
|
||||
onAddSelectionToAI,
|
||||
showSelectionAIAction,
|
||||
}: {
|
||||
hasSelection: boolean;
|
||||
selectionOverlayPosition?: { left: number; top: number } | null;
|
||||
onAddSelectionToAI?: unknown;
|
||||
showSelectionAIAction?: boolean;
|
||||
}): boolean {
|
||||
return Boolean(
|
||||
showSelectionAIAction !== false
|
||||
&& hasSelection
|
||||
&& selectionOverlayPosition
|
||||
&& onAddSelectionToAI,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,7 +88,13 @@ function terminalViewCtxEqual(
|
||||
}
|
||||
|
||||
function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
|
||||
const { Activity, Button, Clock3, Copy, Maximize2, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onCloseSession, onExpandToFocus, onOpenSystem, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, searchMatchCount, selectionOverlayPosition, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, snippets, status, statusDotTone, sudoHintRef, sudoHintText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem } = ctx;
|
||||
const { Activity, Button, Clock3, Copy, Maximize2, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, remoteDragDropUsesZmodem, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onCloseSession, onExpandToFocus, onOpenSystem, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, searchMatchCount, selectionOverlayPosition, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, showSelectionAIAction, snippets, status, statusDotTone, sudoHintRef, sudoHintText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem } = ctx;
|
||||
const ymodemActionEnabled = shouldEnableYmodemAction({
|
||||
isSerialConnection,
|
||||
status,
|
||||
handleSendYmodem,
|
||||
handleReceiveYmodem,
|
||||
});
|
||||
const terminalContentTop = isSearchOpen ? "64px" : "30px";
|
||||
const showLineTimestampGutter = lineTimestampsAvailable !== false && host.showLineTimestamps === true;
|
||||
const lineTimestampColor = resolveTerminalTimestampGutterColor(effectiveTheme.colors);
|
||||
@@ -100,7 +127,8 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
|
||||
onSelectWord={terminalContextActions.onSelectWord}
|
||||
onSplitHorizontal={onSplitHorizontal}
|
||||
onSplitVertical={onSplitVertical}
|
||||
onSendYmodem={shouldEnableYmodemAction({ isSerialConnection, status, handleSendYmodem }) ? handleSendYmodem : undefined}
|
||||
onSendYmodem={ymodemActionEnabled ? handleSendYmodem : undefined}
|
||||
onReceiveYmodem={ymodemActionEnabled ? handleReceiveYmodem : undefined}
|
||||
isReconnectable={status === "disconnected"}
|
||||
onReconnect={handleRetry}
|
||||
onClose={inWorkspace ? () => onCloseSession?.(sessionId) : undefined}
|
||||
@@ -131,7 +159,9 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{isLocalConnection
|
||||
? t("terminal.dragDrop.localMessage")
|
||||
: t("terminal.dragDrop.remoteMessage")
|
||||
: remoteDragDropUsesZmodem
|
||||
? t("terminal.dragDrop.remoteZmodemMessage")
|
||||
: t("terminal.dragDrop.remoteSftpMessage")
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -326,7 +356,12 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
|
||||
width={lineTimestampGutterWidth}
|
||||
onWidthChange={handleLineTimestampGutterWidthChange}
|
||||
/>
|
||||
{hasSelection && selectionOverlayPosition && ctx.onAddSelectionToAI && handleAddSelectionToAI && (
|
||||
{shouldShowSelectionAIOverlay({
|
||||
hasSelection,
|
||||
selectionOverlayPosition,
|
||||
onAddSelectionToAI: ctx.onAddSelectionToAI,
|
||||
showSelectionAIAction,
|
||||
}) && handleAddSelectionToAI && (
|
||||
<div
|
||||
className="absolute z-30 pointer-events-none"
|
||||
style={{
|
||||
|
||||
@@ -3,6 +3,15 @@ import type React from "react";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { logger } from "../../../lib/logger";
|
||||
import {
|
||||
buildZmodemDragDropFiles,
|
||||
buildZmodemDragDropUploadCommand,
|
||||
containsZmodemRzMissingMarker,
|
||||
createZmodemRzMissingToken,
|
||||
supportsZmodemDragDropSftpFallback,
|
||||
supportsZmodemTerminalDragDrop,
|
||||
type ZmodemDragDropFile,
|
||||
} from "../../../lib/zmodemDragDrop";
|
||||
import { extractDropEntries, type DropEntry } from "../../../lib/sftpFileUtils";
|
||||
import type { Host, TerminalSession } from "../../../types";
|
||||
import { toast } from "../../ui/toast";
|
||||
@@ -14,6 +23,7 @@ import {
|
||||
interface UseTerminalDragDropOptions {
|
||||
host: Host;
|
||||
isLocalConnection: boolean;
|
||||
isNetworkDevice?: boolean;
|
||||
onOpenSftp?: TerminalProps["onOpenSftp"];
|
||||
resolveSftpInitialPath: (options?: { preferFreshBackend?: boolean }) => Promise<string | undefined>;
|
||||
scrollToBottomAfterProgrammaticInput: (data: string) => void;
|
||||
@@ -23,37 +33,112 @@ interface UseTerminalDragDropOptions {
|
||||
t: (key: string) => string;
|
||||
terminalBackend: {
|
||||
writeToSession: (sessionId: string, data: string, options?: { automated?: boolean }) => void;
|
||||
cancelZmodem?: (sessionId: string, options?: { interrupt?: boolean }) => void;
|
||||
onSessionData?: (sessionId: string, cb: (chunk: string) => void) => () => void;
|
||||
onZmodemEvent?: (
|
||||
sessionId: string,
|
||||
cb: (event: { type: string; transferType?: string }) => void,
|
||||
) => () => void;
|
||||
startZmodemDragDropUpload?: (
|
||||
sessionId: string,
|
||||
files: ZmodemDragDropFile[],
|
||||
uploadCommand?: string,
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
};
|
||||
rzMissingFallbackTimeoutMs?: number;
|
||||
termRef: React.MutableRefObject<XTerm | null>;
|
||||
}
|
||||
|
||||
const RZ_MISSING_FALLBACK_TIMEOUT_MS = 2500;
|
||||
|
||||
export async function resolveTerminalDropUploadInitialPath(
|
||||
resolveSftpInitialPath: UseTerminalDragDropOptions["resolveSftpInitialPath"],
|
||||
): Promise<string | undefined> {
|
||||
return resolveSftpInitialPath({ preferFreshBackend: true });
|
||||
}
|
||||
|
||||
function createRzMissingWatcher({
|
||||
sessionId,
|
||||
terminalBackend,
|
||||
token,
|
||||
timeoutMs = RZ_MISSING_FALLBACK_TIMEOUT_MS,
|
||||
}: {
|
||||
sessionId: string;
|
||||
terminalBackend: Pick<UseTerminalDragDropOptions["terminalBackend"], "onSessionData" | "onZmodemEvent">;
|
||||
token: string;
|
||||
timeoutMs?: number;
|
||||
}): { promise: Promise<"missing" | "detected" | "timeout">; stop: () => void } {
|
||||
let settled = false;
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
let buffer = "";
|
||||
let unsubscribeData: (() => void) | undefined;
|
||||
let unsubscribeZmodem: (() => void) | undefined;
|
||||
let settle: (result: "missing" | "detected" | "timeout") => void = () => {};
|
||||
|
||||
const cleanup = () => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
timeout = undefined;
|
||||
unsubscribeData?.();
|
||||
unsubscribeData = undefined;
|
||||
unsubscribeZmodem?.();
|
||||
unsubscribeZmodem = undefined;
|
||||
};
|
||||
|
||||
const promise = new Promise<"missing" | "detected" | "timeout">((resolve) => {
|
||||
settle = (result) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
unsubscribeData = terminalBackend.onSessionData?.(sessionId, (chunk) => {
|
||||
buffer = `${buffer}${chunk}`.slice(-512);
|
||||
if (containsZmodemRzMissingMarker(buffer, token)) {
|
||||
settle("missing");
|
||||
}
|
||||
});
|
||||
|
||||
unsubscribeZmodem = terminalBackend.onZmodemEvent?.(sessionId, (event) => {
|
||||
if (event.type === "detect" && event.transferType === "upload") {
|
||||
settle("detected");
|
||||
}
|
||||
});
|
||||
|
||||
timeout = setTimeout(() => settle("timeout"), timeoutMs);
|
||||
});
|
||||
|
||||
return {
|
||||
promise,
|
||||
stop: () => settle("detected"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleTerminalDropEntries({
|
||||
dropEntries,
|
||||
host,
|
||||
isLocalConnection,
|
||||
isNetworkDevice = false,
|
||||
onOpenSftp,
|
||||
resolveSftpInitialPath,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
sessionId,
|
||||
sessionRef,
|
||||
terminalBackend,
|
||||
rzMissingFallbackTimeoutMs,
|
||||
termRef,
|
||||
}: Pick<
|
||||
UseTerminalDragDropOptions,
|
||||
| "host"
|
||||
| "isLocalConnection"
|
||||
| "isNetworkDevice"
|
||||
| "onOpenSftp"
|
||||
| "resolveSftpInitialPath"
|
||||
| "scrollToBottomAfterProgrammaticInput"
|
||||
| "sessionId"
|
||||
| "sessionRef"
|
||||
| "terminalBackend"
|
||||
| "rzMissingFallbackTimeoutMs"
|
||||
| "termRef"
|
||||
> & {
|
||||
dropEntries: DropEntry[];
|
||||
@@ -74,7 +159,67 @@ export async function handleTerminalDropEntries({
|
||||
return;
|
||||
}
|
||||
|
||||
if (onOpenSftp) {
|
||||
const requiresSftpForDirectoryDrop = dropEntries.some((entry) => (
|
||||
entry.isDirectory || /[\\/]/.test(entry.relativePath)
|
||||
));
|
||||
|
||||
if (
|
||||
requiresSftpForDirectoryDrop
|
||||
&& onOpenSftp
|
||||
&& supportsZmodemDragDropSftpFallback(host)
|
||||
) {
|
||||
const initialPath = await resolveTerminalDropUploadInitialPath(resolveSftpInitialPath);
|
||||
onOpenSftp(host, initialPath, dropEntries, sessionId);
|
||||
} else if (supportsZmodemTerminalDragDrop(host, isNetworkDevice)) {
|
||||
const files = await buildZmodemDragDropFiles(dropEntries);
|
||||
if (files.length === 0) {
|
||||
throw new Error("No files to upload");
|
||||
}
|
||||
|
||||
if (!terminalBackend.startZmodemDragDropUpload) {
|
||||
throw new Error("ZMODEM drag-drop upload is unavailable");
|
||||
}
|
||||
|
||||
const shouldFallbackToSftpWhenRzMissing = Boolean(
|
||||
onOpenSftp
|
||||
&& supportsZmodemDragDropSftpFallback(host)
|
||||
&& terminalBackend.onSessionData
|
||||
&& terminalBackend.cancelZmodem,
|
||||
);
|
||||
const rzMissingToken = shouldFallbackToSftpWhenRzMissing
|
||||
? createZmodemRzMissingToken()
|
||||
: undefined;
|
||||
const rzMissingWatcher = rzMissingToken
|
||||
? createRzMissingWatcher({
|
||||
sessionId,
|
||||
terminalBackend,
|
||||
token: rzMissingToken,
|
||||
timeoutMs: rzMissingFallbackTimeoutMs,
|
||||
})
|
||||
: undefined;
|
||||
const uploadCommand = rzMissingToken
|
||||
? buildZmodemDragDropUploadCommand(rzMissingToken)
|
||||
: undefined;
|
||||
|
||||
let result: { success: boolean; error?: string };
|
||||
try {
|
||||
result = await terminalBackend.startZmodemDragDropUpload(sessionId, files, uploadCommand);
|
||||
} catch (error) {
|
||||
rzMissingWatcher?.stop();
|
||||
throw error;
|
||||
}
|
||||
if (!result.success) {
|
||||
rzMissingWatcher?.stop();
|
||||
throw new Error(result.error || "ZMODEM upload failed");
|
||||
}
|
||||
|
||||
const fallbackResult = rzMissingWatcher ? await rzMissingWatcher.promise : "detected";
|
||||
if (fallbackResult === "missing" || fallbackResult === "timeout") {
|
||||
terminalBackend.cancelZmodem?.(sessionId, { interrupt: fallbackResult === "timeout" });
|
||||
const initialPath = await resolveTerminalDropUploadInitialPath(resolveSftpInitialPath);
|
||||
onOpenSftp?.(host, initialPath, dropEntries, sessionId);
|
||||
}
|
||||
} else if (onOpenSftp) {
|
||||
const initialPath = await resolveTerminalDropUploadInitialPath(resolveSftpInitialPath);
|
||||
onOpenSftp(host, initialPath, dropEntries, sessionId);
|
||||
}
|
||||
@@ -83,6 +228,7 @@ export async function handleTerminalDropEntries({
|
||||
export function useTerminalDragDrop({
|
||||
host,
|
||||
isLocalConnection,
|
||||
isNetworkDevice = false,
|
||||
onOpenSftp,
|
||||
resolveSftpInitialPath,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
@@ -91,6 +237,7 @@ export function useTerminalDragDrop({
|
||||
status,
|
||||
t,
|
||||
terminalBackend,
|
||||
rzMissingFallbackTimeoutMs,
|
||||
termRef,
|
||||
}: UseTerminalDragDropOptions) {
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||
@@ -143,17 +290,22 @@ export function useTerminalDragDrop({
|
||||
dropEntries,
|
||||
host,
|
||||
isLocalConnection,
|
||||
isNetworkDevice,
|
||||
onOpenSftp,
|
||||
resolveSftpInitialPath,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
sessionId,
|
||||
sessionRef,
|
||||
terminalBackend,
|
||||
rzMissingFallbackTimeoutMs,
|
||||
termRef,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Failed to handle file drop", error);
|
||||
toast.error(t("terminal.dragDrop.errorMessage"), t("terminal.dragDrop.errorTitle"));
|
||||
const message = error instanceof Error && error.message === "No files to upload"
|
||||
? t("terminal.dragDrop.noFiles")
|
||||
: t("terminal.dragDrop.errorMessage");
|
||||
toast.error(message, t("terminal.dragDrop.errorTitle"));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -48,10 +48,17 @@ import { terminalAltKeyOptions } from "./altKeyOptions";
|
||||
import { optionArrowWordJumpSequence } from "./optionArrowWordJump";
|
||||
import { watchDevicePixelRatio } from "./rendererDprWatch";
|
||||
import { shouldDeferWebglUntilVisible } from "./webglRendererPolicy";
|
||||
import {
|
||||
captureMiddleClickTerminalMouseEvent,
|
||||
markMiddleClickContextMenuEvent,
|
||||
resolveMiddleClickBehavior,
|
||||
} from "./middleClickBehavior";
|
||||
import { handleSerialLineModeInput } from "./serialLineInput";
|
||||
import {
|
||||
isTerminalFontSizeAction,
|
||||
nextTerminalFontSizeForAction,
|
||||
nextTerminalFontSizeForWheel,
|
||||
shouldHandleTerminalFontSizeAction,
|
||||
terminalFontSizeWheelListenerOptions,
|
||||
} from "./terminalFontZoom";
|
||||
import {
|
||||
@@ -122,6 +129,7 @@ export type CreateXTermRuntimeContext = {
|
||||
sessionRef: RefObject<string | null>;
|
||||
|
||||
hotkeySchemeRef: RefObject<"disabled" | "mac" | "pc">;
|
||||
disableTerminalFontZoomRef: RefObject<boolean>;
|
||||
keyBindingsRef: RefObject<KeyBinding[]>;
|
||||
onHotkeyActionRef: RefObject<
|
||||
((action: string, event: KeyboardEvent) => void) | undefined
|
||||
@@ -173,6 +181,11 @@ export type CreateXTermRuntimeContext = {
|
||||
// Autocomplete input handler — called on every character input
|
||||
onAutocompleteInput?: (data: string) => void;
|
||||
|
||||
terminalContextActionsRef?: RefObject<{
|
||||
onPaste?: () => void | Promise<void>;
|
||||
onSelectWord?: () => void;
|
||||
} | undefined>;
|
||||
|
||||
// Set to true while we're programmatically restoring a selection so that
|
||||
// copy-on-select listeners can suppress redundant clipboard writes.
|
||||
isRestoringSelectionRef?: RefObject<boolean>;
|
||||
@@ -562,6 +575,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
event,
|
||||
currentTerminalFontSize(),
|
||||
isMac,
|
||||
ctx.disableTerminalFontZoomRef.current,
|
||||
);
|
||||
if (nextFontSize === null) return;
|
||||
event.preventDefault();
|
||||
@@ -684,6 +698,12 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
}
|
||||
|
||||
if (terminalActions.has(action)) {
|
||||
if (
|
||||
isTerminalFontSizeAction(action)
|
||||
&& !shouldHandleTerminalFontSizeAction(action, ctx.disableTerminalFontZoomRef.current)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
switch (action) {
|
||||
@@ -731,7 +751,11 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
case "decreaseTerminalFontSize":
|
||||
case "resetTerminalFontSize": {
|
||||
applyTerminalFontSize(
|
||||
nextTerminalFontSizeForAction(action, currentTerminalFontSize()),
|
||||
nextTerminalFontSizeForAction(
|
||||
action,
|
||||
currentTerminalFontSize(),
|
||||
ctx.disableTerminalFontZoomRef.current,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
@@ -783,29 +807,35 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
return true;
|
||||
});
|
||||
|
||||
let cleanupMiddleClick: (() => void) | null = null;
|
||||
const middleClickPaste = settings?.middleClickPaste ?? true;
|
||||
if (middleClickPaste) {
|
||||
const handleMiddleClick = async (e: MouseEvent) => {
|
||||
if (e.button !== 1) return;
|
||||
e.preventDefault();
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (text && ctx.sessionRef.current) {
|
||||
pasteTextIntoTerminal(term, text, {
|
||||
scrollOnPaste: shouldScrollOnTerminalPaste(ctx.terminalSettingsRef.current),
|
||||
onPasteData: broadcastUserPasteData,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn("[Terminal] Failed to paste from clipboard:", err);
|
||||
}
|
||||
};
|
||||
const handleMiddleClick = (e: MouseEvent) => {
|
||||
if (e.button !== 1) return;
|
||||
e.preventDefault();
|
||||
|
||||
ctx.container.addEventListener("auxclick", handleMiddleClick);
|
||||
cleanupMiddleClick = () =>
|
||||
ctx.container.removeEventListener("auxclick", handleMiddleClick);
|
||||
}
|
||||
const behavior = resolveMiddleClickBehavior(ctx.terminalSettingsRef.current);
|
||||
if (behavior === "disabled") return;
|
||||
|
||||
if (behavior === "paste") {
|
||||
void ctx.terminalContextActionsRef?.current?.onPaste?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const contextMenuEvent = markMiddleClickContextMenuEvent(new MouseEvent("contextmenu", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
screenX: e.screenX,
|
||||
screenY: e.screenY,
|
||||
button: 2,
|
||||
buttons: 0,
|
||||
view: window,
|
||||
}));
|
||||
ctx.container.dispatchEvent(contextMenuEvent);
|
||||
};
|
||||
|
||||
ctx.container.addEventListener("mousedown", captureMiddleClickTerminalMouseEvent, true);
|
||||
ctx.container.addEventListener("mouseup", captureMiddleClickTerminalMouseEvent, true);
|
||||
ctx.container.addEventListener("auxclick", handleMiddleClick);
|
||||
|
||||
fitAddon.fit();
|
||||
term.focus();
|
||||
@@ -1093,7 +1123,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
handleFontSizeWheel,
|
||||
terminalFontSizeWheelListenerOptions,
|
||||
);
|
||||
cleanupMiddleClick?.();
|
||||
ctx.container.removeEventListener("auxclick", handleMiddleClick);
|
||||
ctx.container.removeEventListener("mousedown", captureMiddleClickTerminalMouseEvent, true);
|
||||
ctx.container.removeEventListener("mouseup", captureMiddleClickTerminalMouseEvent, true);
|
||||
stopDprWatch();
|
||||
keywordHighlighter.dispose();
|
||||
eraseScrollbackDisposable.dispose();
|
||||
|
||||
74
components/terminal/runtime/middleClickBehavior.test.ts
Normal file
74
components/terminal/runtime/middleClickBehavior.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
isMiddleClickContextMenuEvent,
|
||||
markMiddleClickContextMenuEvent,
|
||||
captureMiddleClickTerminalMouseEvent,
|
||||
resolveMiddleClickBehavior,
|
||||
shouldInterceptMouseTrackingContextMenu,
|
||||
} from "./middleClickBehavior";
|
||||
|
||||
test("resolveMiddleClickBehavior uses the explicit middle-click behavior", () => {
|
||||
assert.equal(resolveMiddleClickBehavior({ middleClickBehavior: "context-menu" }), "context-menu");
|
||||
assert.equal(resolveMiddleClickBehavior({ middleClickBehavior: "disabled" }), "disabled");
|
||||
});
|
||||
|
||||
test("resolveMiddleClickBehavior ignores unsupported middle-click behavior values", () => {
|
||||
assert.equal(
|
||||
resolveMiddleClickBehavior({ middleClickBehavior: "select-word" as never }),
|
||||
"paste",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveMiddleClickBehavior falls back to the legacy middle-click paste flag", () => {
|
||||
assert.equal(resolveMiddleClickBehavior({ middleClickPaste: true }), "paste");
|
||||
assert.equal(resolveMiddleClickBehavior({ middleClickPaste: false }), "disabled");
|
||||
assert.equal(resolveMiddleClickBehavior(undefined), "paste");
|
||||
});
|
||||
|
||||
test("middle-click context menu events are identifiable", () => {
|
||||
const event = {} as MouseEvent;
|
||||
|
||||
assert.equal(isMiddleClickContextMenuEvent(event), false);
|
||||
assert.equal(isMiddleClickContextMenuEvent(markMiddleClickContextMenuEvent(event)), true);
|
||||
});
|
||||
|
||||
test("mouse-tracking context menu capture lets middle-click menu events pass through", () => {
|
||||
assert.equal(
|
||||
shouldInterceptMouseTrackingContextMenu({
|
||||
event: markMiddleClickContextMenuEvent({} as MouseEvent),
|
||||
mouseTracking: true,
|
||||
status: "connected",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldInterceptMouseTrackingContextMenu({
|
||||
event: {} as MouseEvent,
|
||||
mouseTracking: true,
|
||||
status: "connected",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("middle-click terminal mouse down/up events are captured before xterm sees them", () => {
|
||||
const calls: string[] = [];
|
||||
const middleClickEvent = {
|
||||
button: 1,
|
||||
preventDefault: () => calls.push("preventDefault"),
|
||||
stopImmediatePropagation: () => calls.push("stopImmediatePropagation"),
|
||||
} as unknown as MouseEvent;
|
||||
|
||||
assert.equal(captureMiddleClickTerminalMouseEvent(middleClickEvent), true);
|
||||
assert.deepEqual(calls, ["preventDefault", "stopImmediatePropagation"]);
|
||||
|
||||
calls.length = 0;
|
||||
assert.equal(captureMiddleClickTerminalMouseEvent({
|
||||
button: 0,
|
||||
preventDefault: () => calls.push("preventDefault"),
|
||||
stopImmediatePropagation: () => calls.push("stopImmediatePropagation"),
|
||||
} as unknown as MouseEvent), false);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
54
components/terminal/runtime/middleClickBehavior.ts
Normal file
54
components/terminal/runtime/middleClickBehavior.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { MiddleClickBehavior, TerminalSettings } from "../../../domain/models";
|
||||
|
||||
type MiddleClickSettings = Partial<Pick<TerminalSettings, "middleClickBehavior" | "middleClickPaste">>;
|
||||
const MIDDLE_CONTEXT_MENU_EVENT_KEY = "__netcattyMiddleContextMenu";
|
||||
|
||||
type MiddleClickContextMenuEvent = MouseEvent & {
|
||||
[MIDDLE_CONTEXT_MENU_EVENT_KEY]?: boolean;
|
||||
};
|
||||
|
||||
export interface MouseTrackingContextMenuCaptureState {
|
||||
event: MouseEvent;
|
||||
mouseTracking: boolean;
|
||||
status?: string | null;
|
||||
}
|
||||
|
||||
export const resolveMiddleClickBehavior = (
|
||||
settings?: MiddleClickSettings | null,
|
||||
): MiddleClickBehavior => {
|
||||
const behavior = settings?.middleClickBehavior;
|
||||
if (
|
||||
behavior === "context-menu" ||
|
||||
behavior === "paste" ||
|
||||
behavior === "disabled"
|
||||
) {
|
||||
return behavior;
|
||||
}
|
||||
|
||||
return settings?.middleClickPaste === false ? "disabled" : "paste";
|
||||
};
|
||||
|
||||
export const markMiddleClickContextMenuEvent = (event: MouseEvent): MouseEvent => {
|
||||
Object.defineProperty(event, MIDDLE_CONTEXT_MENU_EVENT_KEY, {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
return event;
|
||||
};
|
||||
|
||||
export const isMiddleClickContextMenuEvent = (event: MouseEvent): boolean =>
|
||||
(event as MiddleClickContextMenuEvent)[MIDDLE_CONTEXT_MENU_EVENT_KEY] === true;
|
||||
|
||||
export const shouldInterceptMouseTrackingContextMenu = ({
|
||||
event,
|
||||
mouseTracking,
|
||||
status,
|
||||
}: MouseTrackingContextMenuCaptureState): boolean =>
|
||||
mouseTracking && status === "connected" && !isMiddleClickContextMenuEvent(event);
|
||||
|
||||
export const captureMiddleClickTerminalMouseEvent = (event: MouseEvent): boolean => {
|
||||
if (event.button !== 1) return false;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
return true;
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import test from 'node:test';
|
||||
import {
|
||||
nextTerminalFontSizeForAction,
|
||||
nextTerminalFontSizeForWheel,
|
||||
shouldHandleTerminalFontSizeAction,
|
||||
terminalFontSizeWheelListenerOptions,
|
||||
} from './terminalFontZoom.ts';
|
||||
|
||||
@@ -16,6 +17,20 @@ test('terminal font size actions step and reset within bounds', () => {
|
||||
assert.equal(nextTerminalFontSizeForAction('copy', 14), null);
|
||||
});
|
||||
|
||||
test('terminal font size actions return null when terminal font zoom is disabled', () => {
|
||||
assert.equal(nextTerminalFontSizeForAction('increaseTerminalFontSize', 14, true), null);
|
||||
assert.equal(nextTerminalFontSizeForAction('decreaseTerminalFontSize', 14, true), null);
|
||||
assert.equal(nextTerminalFontSizeForAction('resetTerminalFontSize', 18, true), null);
|
||||
});
|
||||
|
||||
test('terminal font size actions are not handled when terminal font zoom is disabled', () => {
|
||||
assert.equal(shouldHandleTerminalFontSizeAction('increaseTerminalFontSize', false), true);
|
||||
assert.equal(shouldHandleTerminalFontSizeAction('decreaseTerminalFontSize', false), true);
|
||||
assert.equal(shouldHandleTerminalFontSizeAction('resetTerminalFontSize', false), true);
|
||||
assert.equal(shouldHandleTerminalFontSizeAction('increaseTerminalFontSize', true), false);
|
||||
assert.equal(shouldHandleTerminalFontSizeAction('copy', true), false);
|
||||
});
|
||||
|
||||
test('wheel adjusts terminal font size with the platform modifier only', () => {
|
||||
assert.equal(nextTerminalFontSizeForWheel({ ctrlKey: true, metaKey: false, deltaY: -1 }, 14, false), 15);
|
||||
assert.equal(nextTerminalFontSizeForWheel({ ctrlKey: true, metaKey: false, deltaY: 1 }, 14, false), 13);
|
||||
@@ -27,6 +42,11 @@ test('wheel adjusts terminal font size with the platform modifier only', () => {
|
||||
assert.equal(nextTerminalFontSizeForWheel({ ctrlKey: true, metaKey: false, deltaY: 0 }, 14, false), null);
|
||||
});
|
||||
|
||||
test('wheel zoom returns null when terminal font zoom is disabled', () => {
|
||||
assert.equal(nextTerminalFontSizeForWheel({ ctrlKey: true, metaKey: false, deltaY: -1 }, 14, false, true), null);
|
||||
assert.equal(nextTerminalFontSizeForWheel({ ctrlKey: false, metaKey: true, deltaY: -1 }, 14, true, true), null);
|
||||
});
|
||||
|
||||
test('wheel font-size listener runs before xterm consumes terminal scrolling', () => {
|
||||
assert.equal(terminalFontSizeWheelListenerOptions.capture, true);
|
||||
assert.equal(terminalFontSizeWheelListenerOptions.passive, false);
|
||||
|
||||
@@ -6,6 +6,12 @@ import {
|
||||
|
||||
type WheelLike = Pick<WheelEvent, "ctrlKey" | "metaKey" | "deltaY">;
|
||||
|
||||
const TERMINAL_FONT_SIZE_ACTIONS = new Set([
|
||||
"increaseTerminalFontSize",
|
||||
"decreaseTerminalFontSize",
|
||||
"resetTerminalFontSize",
|
||||
]);
|
||||
|
||||
export const terminalFontSizeWheelListenerOptions = {
|
||||
passive: false,
|
||||
capture: true,
|
||||
@@ -14,10 +20,20 @@ export const terminalFontSizeWheelListenerOptions = {
|
||||
export const clampTerminalFontSize = (fontSize: number): number =>
|
||||
Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, fontSize));
|
||||
|
||||
export const isTerminalFontSizeAction = (action: string): boolean =>
|
||||
TERMINAL_FONT_SIZE_ACTIONS.has(action);
|
||||
|
||||
export const shouldHandleTerminalFontSizeAction = (
|
||||
action: string,
|
||||
disabled = false,
|
||||
): boolean => isTerminalFontSizeAction(action) && !disabled;
|
||||
|
||||
export const nextTerminalFontSizeForAction = (
|
||||
action: string,
|
||||
currentFontSize: number,
|
||||
disabled = false,
|
||||
): number | null => {
|
||||
if (disabled) return null;
|
||||
switch (action) {
|
||||
case "increaseTerminalFontSize":
|
||||
return clampTerminalFontSize(currentFontSize + 1);
|
||||
@@ -34,7 +50,9 @@ export const nextTerminalFontSizeForWheel = (
|
||||
event: WheelLike,
|
||||
currentFontSize: number,
|
||||
isMac: boolean,
|
||||
disabled = false,
|
||||
): number | null => {
|
||||
if (disabled) return null;
|
||||
const hasZoomModifier = isMac
|
||||
? event.metaKey && !event.ctrlKey
|
||||
: event.ctrlKey && !event.metaKey;
|
||||
|
||||
@@ -23,7 +23,166 @@ const dropEntries: DropEntry[] = [
|
||||
},
|
||||
];
|
||||
|
||||
test("remote terminal drop opens SFTP upload with a freshly resolved cwd", async () => {
|
||||
test("remote SSH terminal drop triggers ZMODEM drag-drop upload", async () => {
|
||||
let uploadedFiles: unknown;
|
||||
let uploadedSessionId: string | undefined;
|
||||
|
||||
await handleTerminalDropEntries({
|
||||
dropEntries: [
|
||||
{
|
||||
file: {
|
||||
name: "report.txt",
|
||||
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
|
||||
} as File,
|
||||
relativePath: "report.txt",
|
||||
isDirectory: false,
|
||||
},
|
||||
],
|
||||
host,
|
||||
isLocalConnection: false,
|
||||
resolveSftpInitialPath: async () => "/srv/app/current",
|
||||
scrollToBottomAfterProgrammaticInput: () => {},
|
||||
sessionId: "session-1",
|
||||
sessionRef: { current: "session-1" },
|
||||
terminalBackend: {
|
||||
writeToSession: () => {},
|
||||
startZmodemDragDropUpload: async (sessionId, files) => {
|
||||
uploadedSessionId = sessionId;
|
||||
uploadedFiles = files;
|
||||
return { success: true };
|
||||
},
|
||||
},
|
||||
termRef: { current: null },
|
||||
});
|
||||
|
||||
assert.equal(uploadedSessionId, "session-1");
|
||||
assert.equal(Array.isArray(uploadedFiles), true);
|
||||
const files = uploadedFiles as Array<{ name: string; remoteName: string; data?: ArrayBuffer }>;
|
||||
assert.equal(files.length, 1);
|
||||
assert.equal(files[0].name, "report.txt");
|
||||
assert.equal(files[0].remoteName, "report.txt");
|
||||
assert.ok(files[0].data);
|
||||
});
|
||||
|
||||
test("remote SSH terminal drop stays on ZMODEM when rz starts", async () => {
|
||||
let openedSftp = false;
|
||||
let zmodemCallback: ((event: { type: string; transferType?: string }) => void) | undefined;
|
||||
|
||||
await handleTerminalDropEntries({
|
||||
dropEntries: [
|
||||
{
|
||||
file: {
|
||||
name: "report.txt",
|
||||
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
|
||||
} as File,
|
||||
relativePath: "report.txt",
|
||||
isDirectory: false,
|
||||
},
|
||||
],
|
||||
host,
|
||||
isLocalConnection: false,
|
||||
onOpenSftp: () => {
|
||||
openedSftp = true;
|
||||
},
|
||||
resolveSftpInitialPath: async () => "/srv/app/current",
|
||||
scrollToBottomAfterProgrammaticInput: () => {},
|
||||
sessionId: "session-1",
|
||||
sessionRef: { current: "session-1" },
|
||||
terminalBackend: {
|
||||
writeToSession: () => {},
|
||||
cancelZmodem: () => {},
|
||||
onSessionData: () => () => {},
|
||||
onZmodemEvent: (_sessionId, cb) => {
|
||||
zmodemCallback = cb;
|
||||
return () => {
|
||||
zmodemCallback = undefined;
|
||||
};
|
||||
},
|
||||
startZmodemDragDropUpload: async (_sessionId, _files, uploadCommand) => {
|
||||
assert.match(uploadCommand ?? "", /NetcattyRzMissing=/);
|
||||
zmodemCallback?.({ type: "detect", transferType: "upload" });
|
||||
return { success: true };
|
||||
},
|
||||
},
|
||||
termRef: { current: null },
|
||||
});
|
||||
|
||||
assert.equal(openedSftp, false);
|
||||
});
|
||||
|
||||
test("serial terminal drop does not wrap rz with an SSH shell fallback", async () => {
|
||||
let uploadCommandSeen: string | undefined;
|
||||
|
||||
await handleTerminalDropEntries({
|
||||
dropEntries: [
|
||||
{
|
||||
file: {
|
||||
name: "report.txt",
|
||||
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
|
||||
} as File,
|
||||
relativePath: "report.txt",
|
||||
isDirectory: false,
|
||||
},
|
||||
],
|
||||
host: { ...host, protocol: "serial" } as Host,
|
||||
isLocalConnection: false,
|
||||
onOpenSftp: () => {},
|
||||
resolveSftpInitialPath: async () => "/srv/app/current",
|
||||
scrollToBottomAfterProgrammaticInput: () => {},
|
||||
sessionId: "session-1",
|
||||
sessionRef: { current: "session-1" },
|
||||
terminalBackend: {
|
||||
writeToSession: () => {},
|
||||
cancelZmodem: () => {},
|
||||
onSessionData: () => () => {},
|
||||
startZmodemDragDropUpload: async (_sessionId, _files, uploadCommand) => {
|
||||
uploadCommandSeen = uploadCommand;
|
||||
return { success: true };
|
||||
},
|
||||
},
|
||||
termRef: { current: null },
|
||||
});
|
||||
|
||||
assert.equal(uploadCommandSeen, undefined);
|
||||
});
|
||||
|
||||
test("telnet terminal drop does not wrap rz with an SSH shell fallback", async () => {
|
||||
let uploadCommandSeen: string | undefined;
|
||||
|
||||
await handleTerminalDropEntries({
|
||||
dropEntries: [
|
||||
{
|
||||
file: {
|
||||
name: "report.txt",
|
||||
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
|
||||
} as File,
|
||||
relativePath: "report.txt",
|
||||
isDirectory: false,
|
||||
},
|
||||
],
|
||||
host: { ...host, protocol: "telnet" } as Host,
|
||||
isLocalConnection: false,
|
||||
onOpenSftp: () => {},
|
||||
resolveSftpInitialPath: async () => "/srv/app/current",
|
||||
scrollToBottomAfterProgrammaticInput: () => {},
|
||||
sessionId: "session-1",
|
||||
sessionRef: { current: "session-1" },
|
||||
terminalBackend: {
|
||||
writeToSession: () => {},
|
||||
cancelZmodem: () => {},
|
||||
onSessionData: () => () => {},
|
||||
startZmodemDragDropUpload: async (_sessionId, _files, uploadCommand) => {
|
||||
uploadCommandSeen = uploadCommand;
|
||||
return { success: true };
|
||||
},
|
||||
},
|
||||
termRef: { current: null },
|
||||
});
|
||||
|
||||
assert.equal(uploadCommandSeen, undefined);
|
||||
});
|
||||
|
||||
test("network device drop falls back to SFTP upload with a freshly resolved cwd", async () => {
|
||||
let receivedOptions: { preferFreshBackend?: boolean } | undefined;
|
||||
let openedPath: string | undefined;
|
||||
let openedEntries: DropEntry[] | undefined;
|
||||
@@ -33,6 +192,7 @@ test("remote terminal drop opens SFTP upload with a freshly resolved cwd", async
|
||||
dropEntries,
|
||||
host,
|
||||
isLocalConnection: false,
|
||||
isNetworkDevice: true,
|
||||
onOpenSftp: (_host, initialPath, pendingUploadEntries, sourceSessionId) => {
|
||||
openedPath = initialPath;
|
||||
openedEntries = pendingUploadEntries;
|
||||
@@ -57,6 +217,163 @@ test("remote terminal drop opens SFTP upload with a freshly resolved cwd", async
|
||||
assert.equal(openedSessionId, "session-1");
|
||||
});
|
||||
|
||||
test("remote SSH terminal drop falls back to SFTP when rz is unavailable", async () => {
|
||||
let receivedOptions: { preferFreshBackend?: boolean } | undefined;
|
||||
let openedPath: string | undefined;
|
||||
let openedEntries: DropEntry[] | undefined;
|
||||
let openedSessionId: string | undefined;
|
||||
let dataCallback: ((chunk: string) => void) | undefined;
|
||||
let cancelled: { sessionId: string; interrupt?: boolean } | undefined;
|
||||
|
||||
await handleTerminalDropEntries({
|
||||
dropEntries: [
|
||||
{
|
||||
file: {
|
||||
name: "report.txt",
|
||||
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
|
||||
} as File,
|
||||
relativePath: "report.txt",
|
||||
isDirectory: false,
|
||||
},
|
||||
],
|
||||
host,
|
||||
isLocalConnection: false,
|
||||
onOpenSftp: (_host, initialPath, pendingUploadEntries, sourceSessionId) => {
|
||||
openedPath = initialPath;
|
||||
openedEntries = pendingUploadEntries;
|
||||
openedSessionId = sourceSessionId;
|
||||
},
|
||||
resolveSftpInitialPath: async (options) => {
|
||||
receivedOptions = options;
|
||||
return "/srv/app/current";
|
||||
},
|
||||
scrollToBottomAfterProgrammaticInput: () => {},
|
||||
sessionId: "session-1",
|
||||
sessionRef: { current: "session-1" },
|
||||
terminalBackend: {
|
||||
writeToSession: () => {},
|
||||
onSessionData: (_sessionId: string, cb: (chunk: string) => void) => {
|
||||
dataCallback = cb;
|
||||
return () => {
|
||||
dataCallback = undefined;
|
||||
};
|
||||
},
|
||||
cancelZmodem: (sessionId: string, options?: { interrupt?: boolean }) => {
|
||||
cancelled = { sessionId, interrupt: options?.interrupt };
|
||||
},
|
||||
startZmodemDragDropUpload: async (_sessionId, _files, uploadCommand) => {
|
||||
assert.match(uploadCommand ?? "", /NetcattyRzMissing=/);
|
||||
assert.equal((uploadCommand ?? "").includes("\u001b]1337;NetcattyRzMissing="), false);
|
||||
const token = uploadCommand?.match(/NetcattyRzMissing=([A-Za-z0-9_-]+)/)?.[1];
|
||||
assert.ok(token);
|
||||
dataCallback?.(`\u001b]1337;NetcattyRzMissing=${token}\u0007`);
|
||||
return { success: true };
|
||||
},
|
||||
},
|
||||
termRef: { current: null },
|
||||
});
|
||||
|
||||
assert.deepEqual(receivedOptions, { preferFreshBackend: true });
|
||||
assert.equal(openedPath, "/srv/app/current");
|
||||
assert.equal(openedEntries?.length, 1);
|
||||
assert.equal(openedEntries?.[0].relativePath, "report.txt");
|
||||
assert.equal(openedSessionId, "session-1");
|
||||
assert.deepEqual(cancelled, { sessionId: "session-1", interrupt: false });
|
||||
});
|
||||
|
||||
test("remote SSH terminal drop falls back to SFTP when rz never starts", async () => {
|
||||
let receivedOptions: { preferFreshBackend?: boolean } | undefined;
|
||||
let openedPath: string | undefined;
|
||||
let openedEntries: DropEntry[] | undefined;
|
||||
let cancelled: { sessionId: string; interrupt?: boolean } | undefined;
|
||||
|
||||
await handleTerminalDropEntries({
|
||||
dropEntries: [
|
||||
{
|
||||
file: {
|
||||
name: "report.txt",
|
||||
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
|
||||
} as File,
|
||||
relativePath: "report.txt",
|
||||
isDirectory: false,
|
||||
},
|
||||
],
|
||||
host,
|
||||
isLocalConnection: false,
|
||||
onOpenSftp: (_host, initialPath, pendingUploadEntries) => {
|
||||
openedPath = initialPath;
|
||||
openedEntries = pendingUploadEntries;
|
||||
},
|
||||
resolveSftpInitialPath: async (options) => {
|
||||
receivedOptions = options;
|
||||
return "/srv/app/current";
|
||||
},
|
||||
scrollToBottomAfterProgrammaticInput: () => {},
|
||||
sessionId: "session-1",
|
||||
sessionRef: { current: "session-1" },
|
||||
terminalBackend: {
|
||||
writeToSession: () => {},
|
||||
onSessionData: () => () => {},
|
||||
cancelZmodem: (sessionId: string, options?: { interrupt?: boolean }) => {
|
||||
cancelled = { sessionId, interrupt: options?.interrupt };
|
||||
},
|
||||
startZmodemDragDropUpload: async () => ({ success: true }),
|
||||
},
|
||||
rzMissingFallbackTimeoutMs: 1,
|
||||
termRef: { current: null },
|
||||
});
|
||||
|
||||
assert.deepEqual(receivedOptions, { preferFreshBackend: true });
|
||||
assert.equal(openedPath, "/srv/app/current");
|
||||
assert.equal(openedEntries?.length, 1);
|
||||
assert.deepEqual(cancelled, { sessionId: "session-1", interrupt: true });
|
||||
});
|
||||
|
||||
test("remote SSH folder drop uses SFTP to preserve directory structure", async () => {
|
||||
let openedEntries: DropEntry[] | undefined;
|
||||
let zmodemStarted = false;
|
||||
|
||||
const folderEntries: DropEntry[] = [
|
||||
{
|
||||
file: null,
|
||||
relativePath: "docs",
|
||||
isDirectory: true,
|
||||
},
|
||||
{
|
||||
file: {
|
||||
name: "guide.txt",
|
||||
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
|
||||
} as File,
|
||||
relativePath: "docs/guide.txt",
|
||||
isDirectory: false,
|
||||
},
|
||||
];
|
||||
|
||||
await handleTerminalDropEntries({
|
||||
dropEntries: folderEntries,
|
||||
host,
|
||||
isLocalConnection: false,
|
||||
onOpenSftp: (_host, _initialPath, pendingUploadEntries) => {
|
||||
openedEntries = pendingUploadEntries;
|
||||
},
|
||||
resolveSftpInitialPath: async () => "/srv/app/current",
|
||||
scrollToBottomAfterProgrammaticInput: () => {},
|
||||
sessionId: "session-1",
|
||||
sessionRef: { current: "session-1" },
|
||||
terminalBackend: {
|
||||
writeToSession: () => {},
|
||||
startZmodemDragDropUpload: async () => {
|
||||
zmodemStarted = true;
|
||||
return { success: true };
|
||||
},
|
||||
},
|
||||
termRef: { current: null },
|
||||
});
|
||||
|
||||
assert.equal(zmodemStarted, false);
|
||||
assert.equal(openedEntries, folderEntries);
|
||||
});
|
||||
|
||||
test("fresh cwd resolution falls back to the renderer cwd when backend probe has no real cwd", async () => {
|
||||
const cwd = await resolvePreferredTerminalCwd({
|
||||
rendererCwd: "/srv/app/current",
|
||||
|
||||
@@ -119,6 +119,7 @@ export interface TerminalProps {
|
||||
reuseConnectionFromSessionId?: string;
|
||||
serialConfig?: SerialConfig;
|
||||
hotkeyScheme?: "disabled" | "mac" | "pc";
|
||||
disableTerminalFontZoom?: boolean;
|
||||
keyBindings?: KeyBinding[];
|
||||
onHotkeyAction?: (action: string, event: KeyboardEvent) => void;
|
||||
onTerminalFontSizeChange?: (fontSize: number) => void;
|
||||
@@ -167,6 +168,7 @@ export interface TerminalProps {
|
||||
sessionLog?: { enabled: boolean; directory: string; format: string; timestampsEnabled?: boolean };
|
||||
sshDebugLogEnabled?: boolean;
|
||||
sudoAutofillPassword?: string;
|
||||
showSelectionAIAction?: boolean;
|
||||
onAddSelectionToAI?: (sessionId: string, selection: string) => void;
|
||||
}
|
||||
|
||||
|
||||
25
components/terminal/terminalMemo.test.ts
Normal file
25
components/terminal/terminalMemo.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { terminalPropsAreEqual } from "./terminalMemo.ts";
|
||||
import type { TerminalProps } from "./terminalHelpers.ts";
|
||||
|
||||
const baseProps = {
|
||||
host: {},
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
isVisible: true,
|
||||
fontFamilyId: "default",
|
||||
fontSize: 14,
|
||||
terminalTheme: {},
|
||||
sessionId: "session-1",
|
||||
showSelectionAIAction: true,
|
||||
} as unknown as TerminalProps;
|
||||
|
||||
test("terminal memo refreshes when selection AI action visibility changes", () => {
|
||||
assert.equal(
|
||||
terminalPropsAreEqual(baseProps, { ...baseProps, showSelectionAIAction: false }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -38,12 +38,14 @@ export const terminalPropsAreEqual = (
|
||||
&& prev.reuseConnectionFromSessionId === next.reuseConnectionFromSessionId
|
||||
&& prev.serialConfig === next.serialConfig
|
||||
&& prev.hotkeyScheme === next.hotkeyScheme
|
||||
&& prev.disableTerminalFontZoom === next.disableTerminalFontZoom
|
||||
&& prev.keyBindings === next.keyBindings
|
||||
&& prev.isBroadcastEnabled === next.isBroadcastEnabled
|
||||
&& prev.isWorkspaceComposeBarOpen === next.isWorkspaceComposeBarOpen
|
||||
&& prev.sessionLog === next.sessionLog
|
||||
&& prev.sshDebugLogEnabled === next.sshDebugLogEnabled
|
||||
&& prev.sudoAutofillPassword === next.sudoAutofillPassword
|
||||
&& prev.showSelectionAIAction === next.showSelectionAIAction
|
||||
&& prev.onHotkeyAction === next.onHotkeyAction
|
||||
&& prev.onTerminalFontSizeChange === next.onTerminalFontSizeChange
|
||||
&& prev.onStatusChange === next.onStatusChange
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps */
|
||||
import { useRef } from 'react';
|
||||
import { resolveFontWeightBold } from '../../lib/fontWeightAvailability';
|
||||
import { shouldInterceptMouseTrackingContextMenu } from './runtime/middleClickBehavior';
|
||||
|
||||
type TerminalEffectsContext = Record<string, any>;
|
||||
|
||||
@@ -48,7 +49,7 @@ export function resolveSelectionOverlayPosition(term: any, container: HTMLElemen
|
||||
}
|
||||
|
||||
export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
const { CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isComposeBarOpen, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, onSnippetShortkeyRef, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef } = ctx;
|
||||
const { CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isComposeBarOpen, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, onSnippetShortkeyRef, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef } = ctx;
|
||||
|
||||
// Remember the last layout we successfully refit while visible so revisiting
|
||||
// the same workspace tab does not replay expensive force-fit/WebGL recovery.
|
||||
@@ -239,6 +240,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
terminalBackend,
|
||||
sessionRef,
|
||||
hotkeySchemeRef,
|
||||
disableTerminalFontZoomRef,
|
||||
keyBindingsRef,
|
||||
onHotkeyActionRef,
|
||||
onTerminalFontSizeChange,
|
||||
@@ -268,6 +270,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
// Autocomplete integration
|
||||
onAutocompleteKeyEvent: (e: KeyboardEvent) => autocompleteKeyEventRef.current?.(e) ?? true,
|
||||
onAutocompleteInput: (data: string) => autocompleteInputRef.current?.(data),
|
||||
terminalContextActionsRef,
|
||||
isRestoringSelectionRef,
|
||||
// Defer WebGL context creation for panes that mount hidden (e.g. the
|
||||
// background tabs of a batch connect) until they first become visible.
|
||||
@@ -958,8 +961,13 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
if (!el) return;
|
||||
|
||||
const handleContextMenuCapture = (e: MouseEvent) => {
|
||||
if (!mouseTrackingRef.current) return;
|
||||
if (statusRef.current !== 'connected') return;
|
||||
if (!shouldInterceptMouseTrackingContextMenu({
|
||||
event: e,
|
||||
mouseTracking: mouseTrackingRef.current,
|
||||
status: statusRef.current,
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
|
||||
|
||||
@@ -89,6 +89,7 @@ function TerminalLayerSidePanelTabBody({ ctx }: { ctx: SidePanelContext }) {
|
||||
handleCloseSidePanel,
|
||||
handleHistoryPaste,
|
||||
handleHistoryRun,
|
||||
handleAddKnownHost,
|
||||
handleOpenHistory,
|
||||
handleFontFamilyChangeForFocusedSession,
|
||||
handleFontFamilyResetForFocusedSession,
|
||||
@@ -116,6 +117,7 @@ function TerminalLayerSidePanelTabBody({ ctx }: { ctx: SidePanelContext }) {
|
||||
identities,
|
||||
keyBindings,
|
||||
keys,
|
||||
knownHosts,
|
||||
mountedAiTabIds,
|
||||
mountedSftpTabIds,
|
||||
scriptsMountedTabIds,
|
||||
@@ -460,7 +462,9 @@ function TerminalLayerSidePanelTabBody({ ctx }: { ctx: SidePanelContext }) {
|
||||
writableHosts={hosts}
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
knownHosts={knownHosts}
|
||||
updateHosts={updateHosts}
|
||||
onAddKnownHost={handleAddKnownHost}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
activeHost={panelActiveHost}
|
||||
activeSessionId={isVisibleSftpPanel ? activeTerminalSessionIdForSftp : null}
|
||||
|
||||
@@ -5,8 +5,10 @@ import { useTerminalLayoutSuppressActive } from '../../application/state/termina
|
||||
import type { TerminalSessionExitEvent } from '../../application/state/resolveTerminalSessionExitIntent';
|
||||
import { createTerminalSelectionAttachment } from '../../application/state/terminalSelectionAttachment';
|
||||
import { useAIState } from '../../application/state/useAIState';
|
||||
import { useStoredBoolean } from '../../application/state/useStoredBoolean';
|
||||
import { SplitDirection } from '../../domain/workspace';
|
||||
import { KeyBinding, TerminalSettings } from '../../domain/models';
|
||||
import { STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION } from '../../infrastructure/config/storageKeys';
|
||||
import { cn } from '../../lib/utils';
|
||||
import type { DropEntry } from '../../lib/sftpFileUtils';
|
||||
import type { GroupConfig, Host, Identity, KnownHost, ProxyProfile, SSHKey, Snippet, TerminalSession, TerminalTheme, Workspace } from '../../types';
|
||||
@@ -474,6 +476,7 @@ export interface TerminalLayerProps {
|
||||
terminalFontFamilyId: string;
|
||||
fontSize?: number;
|
||||
hotkeyScheme?: 'disabled' | 'mac' | 'pc';
|
||||
disableTerminalFontZoom?: boolean;
|
||||
keyBindings?: KeyBinding[];
|
||||
onHotkeyAction?: (action: string, event: KeyboardEvent) => void;
|
||||
onUpdateTerminalThemeId?: (themeId: string) => void;
|
||||
@@ -554,6 +557,7 @@ interface TerminalPaneProps {
|
||||
customAccent?: string;
|
||||
terminalSettings?: TerminalSettings;
|
||||
hotkeyScheme?: 'disabled' | 'mac' | 'pc';
|
||||
disableTerminalFontZoom?: boolean;
|
||||
keyBindings?: KeyBinding[];
|
||||
isResizing: boolean;
|
||||
isComposeBarOpen: boolean;
|
||||
@@ -592,6 +596,7 @@ interface TerminalPaneProps {
|
||||
executor: SnippetExecutor | null,
|
||||
) => void;
|
||||
onAddSelectionToAI?: (sessionId: string, selection: string) => void;
|
||||
showSelectionAIAction: boolean;
|
||||
}
|
||||
|
||||
const getPaneThemePreviewId = (props: TerminalPaneProps): string | null => (
|
||||
@@ -649,6 +654,7 @@ const terminalPanePropsAreEqual = (
|
||||
prev.customAccent === next.customAccent &&
|
||||
prev.terminalSettings === next.terminalSettings &&
|
||||
prev.hotkeyScheme === next.hotkeyScheme &&
|
||||
prev.disableTerminalFontZoom === next.disableTerminalFontZoom &&
|
||||
prev.keyBindings === next.keyBindings &&
|
||||
prev.isResizing === next.isResizing &&
|
||||
prev.isComposeBarOpen === next.isComposeBarOpen &&
|
||||
@@ -677,7 +683,8 @@ const terminalPanePropsAreEqual = (
|
||||
prev.onBroadcastInput === next.onBroadcastInput &&
|
||||
prev.onToggleWorkspaceComposeBar === next.onToggleWorkspaceComposeBar &&
|
||||
prev.onSnippetExecutorChange === next.onSnippetExecutorChange &&
|
||||
prev.onAddSelectionToAI === next.onAddSelectionToAI
|
||||
prev.onAddSelectionToAI === next.onAddSelectionToAI &&
|
||||
prev.showSelectionAIAction === next.showSelectionAIAction
|
||||
);
|
||||
|
||||
const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
||||
@@ -705,6 +712,7 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
||||
customAccent,
|
||||
terminalSettings,
|
||||
hotkeyScheme,
|
||||
disableTerminalFontZoom,
|
||||
keyBindings,
|
||||
isResizing,
|
||||
isComposeBarOpen,
|
||||
@@ -734,6 +742,7 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
||||
onToggleWorkspaceComposeBar,
|
||||
onSnippetExecutorChange,
|
||||
onAddSelectionToAI,
|
||||
showSelectionAIAction,
|
||||
}) => {
|
||||
const layoutSuppressActive = useTerminalLayoutSuppressActive();
|
||||
const deferPaneLayoutUpdate = isResizing || layoutSuppressActive;
|
||||
@@ -891,6 +900,7 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
||||
reuseConnectionFromSessionId={session.reuseConnectionFromSessionId}
|
||||
serialConfig={session.serialConfig}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
disableTerminalFontZoom={disableTerminalFontZoom}
|
||||
keyBindings={keyBindings}
|
||||
onHotkeyAction={onHotkeyAction}
|
||||
onTerminalFontSizeChange={handleTerminalFontSizeChange}
|
||||
@@ -921,6 +931,7 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
||||
sessionLog={sessionLog}
|
||||
sshDebugLogEnabled={sshDebugLogEnabled}
|
||||
sudoAutofillPassword={sudoAutofillPassword}
|
||||
showSelectionAIAction={showSelectionAIAction}
|
||||
onAddSelectionToAI={onAddSelectionToAI}
|
||||
/>
|
||||
</div>
|
||||
@@ -953,6 +964,7 @@ interface TerminalPanesHostProps {
|
||||
customAccent?: string;
|
||||
terminalSettings?: TerminalSettings;
|
||||
hotkeyScheme?: 'disabled' | 'mac' | 'pc';
|
||||
disableTerminalFontZoom?: boolean;
|
||||
keyBindings?: KeyBinding[];
|
||||
isResizing: boolean;
|
||||
isComposeBarOpen: boolean;
|
||||
@@ -1015,6 +1027,7 @@ const terminalPanesHostPropsAreEqual = (
|
||||
if (prev.customAccent !== next.customAccent) return false;
|
||||
if (prev.terminalSettings !== next.terminalSettings) return false;
|
||||
if (prev.hotkeyScheme !== next.hotkeyScheme) return false;
|
||||
if (prev.disableTerminalFontZoom !== next.disableTerminalFontZoom) return false;
|
||||
if (prev.keyBindings !== next.keyBindings) return false;
|
||||
if (prev.isResizing !== next.isResizing) return false;
|
||||
if (prev.isComposeBarOpen !== next.isComposeBarOpen) return false;
|
||||
@@ -1066,22 +1079,30 @@ export const TerminalPanesHost: React.FC<TerminalPanesHostProps> = memo(({
|
||||
sessionChainHostsMap,
|
||||
sessionSudoAutofillPasswordsMap,
|
||||
...sharedProps
|
||||
}) => (
|
||||
<>
|
||||
{sessions.map((session) => {
|
||||
const host = sessionHostsMap.get(session.id);
|
||||
if (!host) return null;
|
||||
return (
|
||||
<TerminalPane
|
||||
key={session.id}
|
||||
session={session}
|
||||
host={host}
|
||||
chainHosts={sessionChainHostsMap.get(session.id)}
|
||||
sudoAutofillPassword={sessionSudoAutofillPasswordsMap.get(session.id)}
|
||||
{...sharedProps}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
), terminalPanesHostPropsAreEqual);
|
||||
}) => {
|
||||
const [showSelectionAIAction] = useStoredBoolean(
|
||||
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
|
||||
true,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{sessions.map((session) => {
|
||||
const host = sessionHostsMap.get(session.id);
|
||||
if (!host) return null;
|
||||
return (
|
||||
<TerminalPane
|
||||
key={session.id}
|
||||
session={session}
|
||||
host={host}
|
||||
chainHosts={sessionChainHostsMap.get(session.id)}
|
||||
sudoAutofillPassword={sessionSudoAutofillPasswordsMap.get(session.id)}
|
||||
showSelectionAIAction={showSelectionAIAction}
|
||||
{...sharedProps}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}, terminalPanesHostPropsAreEqual);
|
||||
TerminalPanesHost.displayName = 'TerminalPanesHost';
|
||||
|
||||
@@ -371,6 +371,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
handleWorkspaceDrop,
|
||||
hosts: s.hosts,
|
||||
hotkeyScheme: s.hotkeyScheme,
|
||||
disableTerminalFontZoom: s.disableTerminalFontZoom,
|
||||
identities: s.identities,
|
||||
isBroadcastEnabled: s.isBroadcastEnabled,
|
||||
isComposeBarOpen: s.isComposeBarOpen,
|
||||
|
||||
@@ -40,6 +40,7 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
|
||||
customAccent,
|
||||
terminalSettings,
|
||||
hotkeyScheme,
|
||||
disableTerminalFontZoom,
|
||||
keyBindings,
|
||||
resizing,
|
||||
isComposeBarOpen,
|
||||
@@ -149,6 +150,7 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
|
||||
customAccent={customAccent}
|
||||
terminalSettings={terminalSettings}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
disableTerminalFontZoom={disableTerminalFontZoom}
|
||||
keyBindings={keyBindings}
|
||||
isResizing={!!resizing}
|
||||
isComposeBarOpen={isComposeBarOpen}
|
||||
|
||||
@@ -131,6 +131,7 @@ export type TerminalLayerStableSnapshot = {
|
||||
snippetPackages: string[];
|
||||
knownHosts: KnownHost[];
|
||||
hotkeyScheme: TerminalLayerProps['hotkeyScheme'];
|
||||
disableTerminalFontZoom: TerminalLayerProps['disableTerminalFontZoom'];
|
||||
keyBindings: TerminalLayerProps['keyBindings'];
|
||||
onHotkeyAction: TerminalLayerProps['onHotkeyAction'];
|
||||
onConnectToHost: TerminalLayerProps['onConnectToHost'];
|
||||
|
||||
@@ -241,6 +241,7 @@ const WORKSPACE_CTX_KEYS = [
|
||||
'customAccent',
|
||||
'terminalSettings',
|
||||
'hotkeyScheme',
|
||||
'disableTerminalFontZoom',
|
||||
'keyBindings',
|
||||
'resizing',
|
||||
'isComposeBarOpen',
|
||||
|
||||
@@ -19,6 +19,7 @@ export const terminalLayerAreEqual = (
|
||||
prev.terminalSettings === next.terminalSettings &&
|
||||
prev.fontSize === next.fontSize &&
|
||||
prev.hotkeyScheme === next.hotkeyScheme &&
|
||||
prev.disableTerminalFontZoom === next.disableTerminalFontZoom &&
|
||||
prev.keyBindings === next.keyBindings &&
|
||||
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
|
||||
prev.sftpDoubleClickBehavior === next.sftpDoubleClickBehavior &&
|
||||
|
||||
@@ -3,10 +3,13 @@ import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
mergeGlobalHistoryOnAppend,
|
||||
sanitizeGlobalHistoryEntries,
|
||||
shouldRecordGlobalHistoryCommand,
|
||||
toGlobalHistoryDisplayEntries,
|
||||
} from './globalHistory.ts';
|
||||
import { NETCATTY_AI_HISTORY_MARKER } from './remoteHistory.ts';
|
||||
import { buildDockerExecShellCommand, buildDockerLogsCommand } from './systemManager/dockerShell.ts';
|
||||
import { buildTmuxAttachCommand } from './systemManager/tmuxShell.ts';
|
||||
import type { ShellHistoryEntry } from './models';
|
||||
|
||||
const baseEntry = (
|
||||
@@ -30,6 +33,17 @@ test('shouldRecordGlobalHistoryCommand: rejects empty and AI marker commands', (
|
||||
assert.equal(shouldRecordGlobalHistoryCommand('ls -la'), true);
|
||||
});
|
||||
|
||||
test('shouldRecordGlobalHistoryCommand: rejects Netcatty managed Docker and tmux startup commands', () => {
|
||||
assert.equal(shouldRecordGlobalHistoryCommand(buildDockerExecShellCommand('587abcdef123')), false);
|
||||
assert.equal(shouldRecordGlobalHistoryCommand(buildDockerLogsCommand('587abcdef123')), false);
|
||||
assert.equal(shouldRecordGlobalHistoryCommand(buildTmuxAttachCommand('my-session')), false);
|
||||
assert.equal(shouldRecordGlobalHistoryCommand(buildTmuxAttachCommand('my-session', 2)), false);
|
||||
assert.equal(shouldRecordGlobalHistoryCommand('docker ps -a'), true);
|
||||
assert.equal(shouldRecordGlobalHistoryCommand('docker logs -f 587abcdef123'), true);
|
||||
assert.equal(shouldRecordGlobalHistoryCommand('docker exec -it 587abcdef123 bash'), true);
|
||||
assert.equal(shouldRecordGlobalHistoryCommand('tmux attach -t my-session'), true);
|
||||
});
|
||||
|
||||
test('mergeGlobalHistoryOnAppend: trims and prepends a new command', () => {
|
||||
const next = mergeGlobalHistoryOnAppend([], {
|
||||
command: ' pwd ',
|
||||
@@ -41,6 +55,19 @@ test('mergeGlobalHistoryOnAppend: trims and prepends a new command', () => {
|
||||
assert.equal(next[0].command, 'pwd');
|
||||
});
|
||||
|
||||
test('sanitizeGlobalHistoryEntries: removes persisted Netcatty managed startup commands', () => {
|
||||
const entries = [
|
||||
baseEntry({ id: 'a', command: buildDockerLogsCommand('587abcdef123') }),
|
||||
baseEntry({ id: 'b', command: 'docker ps -a' }),
|
||||
baseEntry({ id: 'c', command: buildTmuxAttachCommand('my-session') }),
|
||||
];
|
||||
const out = sanitizeGlobalHistoryEntries(entries);
|
||||
assert.deepEqual(
|
||||
out.map((entry) => entry.command),
|
||||
['docker ps -a'],
|
||||
);
|
||||
});
|
||||
|
||||
test('mergeGlobalHistoryOnAppend: bumps timestamp for consecutive duplicate', () => {
|
||||
const prev = [baseEntry({ id: 'a', command: 'ls', timestamp: 1000 })];
|
||||
const next = mergeGlobalHistoryOnAppend(prev, {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { ShellHistoryEntry } from './models';
|
||||
import { isNetcattyAiHistoryCommand } from './remoteHistory';
|
||||
import {
|
||||
isNetcattyAiHistoryCommand,
|
||||
isNetcattyManagedStartupHistoryCommand,
|
||||
} from './remoteHistory';
|
||||
|
||||
const makeId = (): string => {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
@@ -13,9 +16,16 @@ export function shouldRecordGlobalHistoryCommand(command: string): boolean {
|
||||
const cmd = command.trim();
|
||||
if (!cmd) return false;
|
||||
if (isNetcattyAiHistoryCommand(cmd)) return false;
|
||||
if (isNetcattyManagedStartupHistoryCommand(cmd)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function sanitizeGlobalHistoryEntries(
|
||||
entries: ShellHistoryEntry[],
|
||||
): ShellHistoryEntry[] {
|
||||
return entries.filter((entry) => shouldRecordGlobalHistoryCommand(entry.command));
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one command to global history: trim, drop noise, and de-dupe the most
|
||||
* recent identical command by bumping its timestamp instead of adding a row.
|
||||
@@ -61,7 +71,7 @@ export interface GlobalHistoryDisplayEntry {
|
||||
export function toGlobalHistoryDisplayEntries(
|
||||
entries: ShellHistoryEntry[],
|
||||
): GlobalHistoryDisplayEntry[] {
|
||||
return entries.map((entry) => ({
|
||||
return sanitizeGlobalHistoryEntries(entries).map((entry) => ({
|
||||
id: entry.id,
|
||||
command: entry.command,
|
||||
timestamp: entry.timestamp,
|
||||
|
||||
@@ -2,7 +2,9 @@ import type { SerialConfig } from './connection';
|
||||
|
||||
// Terminal appearance settings
|
||||
export type CursorShape = 'block' | 'bar' | 'underline';
|
||||
export type RightClickBehavior = 'context-menu' | 'paste' | 'select-word';
|
||||
export type TerminalMouseClickBehavior = 'context-menu' | 'paste' | 'select-word';
|
||||
export type RightClickBehavior = TerminalMouseClickBehavior;
|
||||
export type MiddleClickBehavior = 'context-menu' | 'paste' | 'disabled';
|
||||
export type LinkModifier = 'none' | 'ctrl' | 'alt' | 'meta';
|
||||
export type TerminalEmulationType = 'xterm-256color' | 'xterm-16color' | 'xterm';
|
||||
|
||||
@@ -53,8 +55,9 @@ export interface TerminalSettings {
|
||||
|
||||
// Mouse
|
||||
rightClickBehavior: RightClickBehavior;
|
||||
middleClickBehavior: MiddleClickBehavior;
|
||||
copyOnSelect: boolean; // Automatically copy selected text
|
||||
middleClickPaste: boolean; // Paste on middle-click
|
||||
middleClickPaste: boolean; // Legacy mirror for older settings payloads
|
||||
wordSeparators: string; // Characters for word selection
|
||||
linkModifier: LinkModifier; // Modifier key to click links
|
||||
|
||||
@@ -213,12 +216,39 @@ const normalizeKeywordHighlightRules = (
|
||||
return normalizedRules;
|
||||
};
|
||||
|
||||
const isMiddleClickBehavior = (value: unknown): value is MiddleClickBehavior => (
|
||||
value === 'context-menu' ||
|
||||
value === 'paste' ||
|
||||
value === 'disabled'
|
||||
);
|
||||
|
||||
const resolveMiddleClickBehavior = (
|
||||
settings?: Partial<TerminalSettings> | null,
|
||||
): MiddleClickBehavior => {
|
||||
if (isMiddleClickBehavior(settings?.middleClickBehavior)) {
|
||||
return settings.middleClickBehavior;
|
||||
}
|
||||
|
||||
if (
|
||||
settings &&
|
||||
Object.prototype.hasOwnProperty.call(settings, 'middleClickPaste') &&
|
||||
settings.middleClickPaste === false
|
||||
) {
|
||||
return 'disabled';
|
||||
}
|
||||
|
||||
return DEFAULT_TERMINAL_SETTINGS.middleClickBehavior;
|
||||
};
|
||||
|
||||
export const normalizeTerminalSettings = (
|
||||
settings?: Partial<TerminalSettings> | null,
|
||||
): TerminalSettings => {
|
||||
const middleClickBehavior = resolveMiddleClickBehavior(settings);
|
||||
const mergedSettings = {
|
||||
...DEFAULT_TERMINAL_SETTINGS,
|
||||
...(settings ?? {}),
|
||||
middleClickBehavior,
|
||||
middleClickPaste: middleClickBehavior === 'paste',
|
||||
};
|
||||
|
||||
// Migrate legacy 'canvas' renderer to 'dom' (canvas removed in xterm.js 6.0)
|
||||
@@ -259,6 +289,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
scrollOnPaste: true,
|
||||
smoothScrolling: false,
|
||||
rightClickBehavior: 'context-menu',
|
||||
middleClickBehavior: 'paste',
|
||||
copyOnSelect: false,
|
||||
middleClickPaste: true,
|
||||
wordSeparators: ' ()[]{}\'"',
|
||||
|
||||
@@ -8,7 +8,10 @@ import {
|
||||
parseShellHistory,
|
||||
mergeRemoteHistory,
|
||||
isNetcattyAiHistoryCommand,
|
||||
isNetcattyManagedStartupHistoryCommand,
|
||||
} from './remoteHistory.ts';
|
||||
import { buildDockerExecShellCommand, buildDockerLogsCommand } from './systemManager/dockerShell.ts';
|
||||
import { buildTmuxAttachCommand } from './systemManager/tmuxShell.ts';
|
||||
|
||||
test('parseBashHistory: plain lines', () => {
|
||||
const out = parseBashHistory(['ls -la', 'cd /tmp', 'echo hi'].join('\n'));
|
||||
@@ -193,6 +196,17 @@ test('isNetcattyAiHistoryCommand: detects AI PTY marker lines', () => {
|
||||
assert.equal(isNetcattyAiHistoryCommand('grep NCMCP log.txt'), false);
|
||||
});
|
||||
|
||||
test('isNetcattyManagedStartupHistoryCommand: detects Docker and tmux terminal launch commands', () => {
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand(buildDockerExecShellCommand('587abcdef123')), true);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand(buildDockerLogsCommand('587abcdef123')), true);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand(buildTmuxAttachCommand('my-session')), true);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand(buildTmuxAttachCommand('my-session', 2)), true);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand('docker ps -a'), false);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand('docker logs -f 587abcdef123'), false);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand('docker exec -it 587abcdef123 bash'), false);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand('tmux attach -t my-session'), false);
|
||||
});
|
||||
|
||||
test('mergeRemoteHistory: drops Netcatty AI PTY history lines', () => {
|
||||
const lists = [
|
||||
parseBashHistory(
|
||||
@@ -205,3 +219,44 @@ test('mergeRemoteHistory: drops Netcatty AI PTY history lines', () => {
|
||||
['git status', 'ls -la'],
|
||||
);
|
||||
});
|
||||
|
||||
test('mergeRemoteHistory: drops Netcatty managed Docker and tmux startup lines', () => {
|
||||
const lists = [
|
||||
parseBashHistory(
|
||||
[
|
||||
'docker ps -a',
|
||||
buildDockerLogsCommand('587abcdef123'),
|
||||
buildTmuxAttachCommand('my-session'),
|
||||
'history',
|
||||
].join('\n'),
|
||||
),
|
||||
];
|
||||
const merged = mergeRemoteHistory(lists);
|
||||
assert.deepEqual(
|
||||
merged.map((e) => e.command),
|
||||
['history', 'docker ps -a'],
|
||||
);
|
||||
});
|
||||
|
||||
test('mergeRemoteHistory: drops Netcatty managed startup lines from zsh and fish history', () => {
|
||||
const zsh = parseZshHistory(
|
||||
[
|
||||
': 1700000000:0;git status',
|
||||
`: 1700000100:0;${buildDockerExecShellCommand('587abcdef123')}`,
|
||||
].join('\n'),
|
||||
);
|
||||
const fish = parseFishHistory(
|
||||
[
|
||||
'- cmd: docker ps -a',
|
||||
' when: 1700000200',
|
||||
`- cmd: ${buildTmuxAttachCommand('my-session')}`,
|
||||
' when: 1700000300',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const merged = mergeRemoteHistory([zsh, fish]);
|
||||
assert.deepEqual(
|
||||
merged.map((e) => e.command),
|
||||
['docker ps -a', 'git status'],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,14 @@ export function isNetcattyAiHistoryCommand(command: string): boolean {
|
||||
return command.includes(NETCATTY_AI_HISTORY_MARKER);
|
||||
}
|
||||
|
||||
const NETCATTY_MANAGED_STARTUP_COMMAND =
|
||||
/^printf '\\033\[H\\033\[2J\\033\[3J';\s*exec\s+(?:docker\s+(?:exec|logs)\b|tmux\s+attach\b)/;
|
||||
|
||||
/** True when a shell history line came from a Netcatty-managed terminal launch. */
|
||||
export function isNetcattyManagedStartupHistoryCommand(command: string): boolean {
|
||||
return NETCATTY_MANAGED_STARTUP_COMMAND.test(command.trim());
|
||||
}
|
||||
|
||||
const ZSH_EXTENDED_RECORD = /^: (\d+):\d+;([\s\S]*)$/;
|
||||
// fish_history is a YAML subset: each record starts with `- cmd: <value>`,
|
||||
// optionally followed by ` when: <epoch>` and a ` paths:` block.
|
||||
@@ -215,6 +223,7 @@ export function mergeRemoteHistory(
|
||||
const merged: RemoteHistoryEntry[] = [];
|
||||
for (const { entry } of indexed) {
|
||||
if (isNetcattyAiHistoryCommand(entry.command)) continue;
|
||||
if (isNetcattyManagedStartupHistoryCommand(entry.command)) continue;
|
||||
if (seen.has(entry.command)) continue;
|
||||
seen.add(entry.command);
|
||||
merged.push(entry);
|
||||
|
||||
20
domain/sftpConflict.ts
Normal file
20
domain/sftpConflict.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export type SftpConflictExistingType = "file" | "directory" | "symlink";
|
||||
|
||||
export const getSftpConflictTypeKey = (
|
||||
isDirectory: boolean,
|
||||
existingType?: SftpConflictExistingType,
|
||||
): string => `${isDirectory ? "directory" : "file"}:${existingType ?? "unknown"}`;
|
||||
|
||||
export const canReplaceSftpConflict = (
|
||||
isDirectory: boolean,
|
||||
existingType?: SftpConflictExistingType,
|
||||
): boolean => {
|
||||
if (!existingType) return true;
|
||||
return (existingType === "directory") === isDirectory;
|
||||
};
|
||||
|
||||
export const describeSftpIncomingKind = (isDirectory: boolean): string =>
|
||||
isDirectory ? "directory" : "file";
|
||||
|
||||
export const describeSftpExistingKind = (existingType?: SftpConflictExistingType): string =>
|
||||
existingType === "directory" ? "directory" : "file";
|
||||
@@ -251,6 +251,8 @@ export interface SyncPayload {
|
||||
showSftpTab?: boolean;
|
||||
// Shortcuts: Cmd/Ctrl+[1...9] skip pinned Vault/SFTP tabs
|
||||
shellOnlyTabNumberShortcuts?: boolean;
|
||||
// Shortcuts: disable terminal font zoom shortcuts
|
||||
disableTerminalFontZoom?: boolean;
|
||||
// Terminal/editor tabs: show left host list sidebar
|
||||
showHostTreeSidebar?: boolean;
|
||||
// Workspace focus indicator style
|
||||
@@ -273,6 +275,7 @@ export interface SyncPayload {
|
||||
agentProviderMap?: Record<string, string>;
|
||||
webSearchConfig?: Record<string, unknown> | null;
|
||||
quickMessages?: Array<Record<string, unknown>>;
|
||||
showTerminalSelectionAction?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -29,3 +29,26 @@ test("normalizeTerminalSettings preserves provided localShellArgs", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizeTerminalSettings defaults middle-click behavior to paste", () => {
|
||||
const settings = normalizeTerminalSettings();
|
||||
|
||||
assert.equal(settings.middleClickBehavior, "paste");
|
||||
assert.equal(settings.middleClickPaste, true);
|
||||
});
|
||||
|
||||
test("normalizeTerminalSettings migrates disabled legacy middle-click paste", () => {
|
||||
const settings = normalizeTerminalSettings({ middleClickPaste: false });
|
||||
|
||||
assert.equal(settings.middleClickBehavior, "disabled");
|
||||
assert.equal(settings.middleClickPaste, false);
|
||||
});
|
||||
|
||||
test("normalizeTerminalSettings prefers explicit middle-click behavior over legacy paste flag", () => {
|
||||
const settings = normalizeTerminalSettings({
|
||||
middleClickBehavior: "context-menu",
|
||||
middleClickPaste: true,
|
||||
});
|
||||
|
||||
assert.equal(settings.middleClickBehavior, "context-menu");
|
||||
assert.equal(settings.middleClickPaste, false);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ module.exports = {
|
||||
appId: 'com.netcatty.app',
|
||||
productName: 'Netcatty',
|
||||
artifactName: '${productName}-${version}-${os}-${arch}.${ext}',
|
||||
electronLanguages: ['en', 'en-US', 'zh_CN', 'zh-CN', 'ru'],
|
||||
// Give the macOS build a unique Mach-O LC_UUID before signing, so macOS
|
||||
// Local Network privacy treats Netcatty distinctly from every other
|
||||
// Electron app (which all share Electron's prebuilt LC_UUID) — see #1040
|
||||
@@ -43,8 +44,47 @@ module.exports = {
|
||||
'lib/**/*.json',
|
||||
'!electron/.dev-config.json',
|
||||
'skills/**/*',
|
||||
'public/**/*',
|
||||
'node_modules/**/*',
|
||||
'!public/**/*',
|
||||
'!**/*.map',
|
||||
'!**/*.d.ts',
|
||||
'!**/*.d.mts',
|
||||
'!**/*.d.cts',
|
||||
'!**/*.ts',
|
||||
'!**/*.tsx',
|
||||
'!**/*.test.*',
|
||||
'!**/*.spec.*',
|
||||
'!**/__tests__/**/*',
|
||||
'!**/test/**/*',
|
||||
'!**/tests/**/*',
|
||||
'!**/example/**/*',
|
||||
'!**/examples/**/*',
|
||||
'!node_modules/**/docs/**/*',
|
||||
'!node_modules/**/doc/**/*',
|
||||
'!node_modules/**/benchmark/**/*',
|
||||
'!node_modules/**/benchmarks/**/*',
|
||||
// Renderer-only packages are compiled into dist by Vite. Keep them
|
||||
// installed for npm run dev/build, but do not ship the duplicate source
|
||||
// packages in release artifacts.
|
||||
'!node_modules/@fontsource/**/*',
|
||||
'!node_modules/@monaco-editor/**/*',
|
||||
'!node_modules/@radix-ui/**/*',
|
||||
'!node_modules/@xterm/**/*',
|
||||
'!node_modules/lucide-react/**/*',
|
||||
'!node_modules/monaco-editor/**/*',
|
||||
'!node_modules/react/**/*',
|
||||
'!node_modules/react-dom/**/*',
|
||||
// Heavy cloud completion specs are intentionally not bundled. The main
|
||||
// process filters the same prefixes so dev and packaged builds behave
|
||||
// consistently.
|
||||
'!node_modules/@withfig/autocomplete/build/aws.js',
|
||||
'!node_modules/@withfig/autocomplete/build/aws/**/*',
|
||||
'!node_modules/@withfig/autocomplete/build/gcloud.js',
|
||||
'!node_modules/@withfig/autocomplete/build/gcloud/**/*',
|
||||
'!node_modules/@withfig/autocomplete/build/az/**/*',
|
||||
// Fig specs are already compiled JavaScript; TypeScript is only pulled
|
||||
// in by Fig helper packages as build tooling and is not needed at app
|
||||
// runtime.
|
||||
'!node_modules/typescript/**/*',
|
||||
// ── Exclude per-platform native agent binaries (~100s of MB each). ──
|
||||
// Netcatty is "bring your own CLI": each SDK is pointed at the user's
|
||||
// system-installed CLI via an absolute path override (claude
|
||||
@@ -62,7 +102,20 @@ module.exports = {
|
||||
'!node_modules/@anthropic-ai/claude-code-*/**/*',
|
||||
'!node_modules/@openai/codex-{darwin,linux,linuxmusl,win32}-*/**/*',
|
||||
'!node_modules/@github/copilot-{darwin,linux,linuxmusl,win32}-*/**/*',
|
||||
'!node_modules/@github/copilot/**/*'
|
||||
'!node_modules/@github/copilot/**/*',
|
||||
// CodeBuddy follows the same first-party integration model as the
|
||||
// other coding agents: Netcatty discovers and passes the user's
|
||||
// installed CLI path to the SDK. Keep the small SDK wrapper, but do not
|
||||
// bundle the full CodeBuddy CLI payload (rg vendors + web UI).
|
||||
'!node_modules/@tencent-ai/agent-sdk/cli/**/*',
|
||||
// Netcatty loads Cursor SDK through ESM dynamic import, so the duplicate
|
||||
// CommonJS build and type metadata are not needed at runtime.
|
||||
'!node_modules/@cursor/sdk/dist/cjs/**/*',
|
||||
'!node_modules/@cursor/sdk/dist/**/*.d.ts',
|
||||
'!node_modules/@cursor/sdk/dist/**/*.d.ts.map',
|
||||
// sqlite3 rebuilds a native module for Electron; its upstream source
|
||||
// tarball is build-time payload only.
|
||||
'!node_modules/sqlite3/deps/**/*'
|
||||
],
|
||||
asarUnpack: [
|
||||
'node_modules/node-pty/**/*',
|
||||
@@ -70,7 +123,6 @@ module.exports = {
|
||||
'node_modules/cpu-features/**/*',
|
||||
'node_modules/@vscode/windows-process-tree/**/*',
|
||||
'node_modules/@anthropic-ai/claude-agent-sdk/**/*',
|
||||
'node_modules/@cursor/sdk/**/*',
|
||||
'node_modules/@cursor/sdk-*/**/*',
|
||||
'node_modules/sqlite3/**/*',
|
||||
'node_modules/@modelcontextprotocol/sdk/**/*',
|
||||
|
||||
@@ -445,6 +445,56 @@ function resolveCodexExecutableForSdk(codexExecutablePath, platform = process.pl
|
||||
return ext === ".cmd" || ext === ".bat" || ext === ".ps1" ? null : normalized;
|
||||
}
|
||||
|
||||
function resolveCodebuddyExecutableForSdk(codebuddyExecutablePath, platform = process.platform) {
|
||||
const normalized = String(codebuddyExecutablePath || "").trim();
|
||||
if (!normalized) return null;
|
||||
if (platform !== "win32") return normalized;
|
||||
|
||||
const ext = path.extname(normalized).toLowerCase();
|
||||
// A native exe or an explicit .js entry can be launched by the Agent SDK as-is.
|
||||
if (ext === ".exe" || ext === ".js") return normalized;
|
||||
// Any other concrete, non-shim extension: leave it untouched.
|
||||
if (ext && ext !== ".cmd" && ext !== ".bat" && ext !== ".ps1") return normalized;
|
||||
|
||||
// Windows npm globals expose `codebuddy.cmd` / `codebuddy.ps1` shims (and an
|
||||
// extensionless POSIX shim). The Agent SDK launches the CLI through `node`
|
||||
// (electron-as-node in a packaged app), which cannot parse a batch/POSIX shim
|
||||
// as JavaScript — the spawned process exits immediately and the SDK surfaces
|
||||
// "CLI process stdout closed unexpectedly". Resolve the shim to the package's
|
||||
// real `bin/codebuddy` JS entry so the SDK runs it exactly as on macOS/Linux.
|
||||
const baseDir = path.dirname(normalized);
|
||||
const packageRoots = [
|
||||
path.join(baseDir, "node_modules", "@tencent-ai", "codebuddy-code"),
|
||||
path.join(baseDir, "..", "node_modules", "@tencent-ai", "codebuddy-code"),
|
||||
];
|
||||
for (const root of packageRoots) {
|
||||
const binJs = path.join(root, "bin", "codebuddy");
|
||||
if (existsSync(binJs)) return binJs;
|
||||
}
|
||||
|
||||
// Fall back to parsing the shim for the bin/codebuddy path it references.
|
||||
const shimCandidates = [normalized];
|
||||
if (!ext) shimCandidates.push(`${normalized}.cmd`, `${normalized}.bat`);
|
||||
for (const shimPath of shimCandidates) {
|
||||
try {
|
||||
if (!existsSync(shimPath)) continue;
|
||||
const contents = readFileSync(shimPath, "utf8");
|
||||
const match = contents.match(/([^"\s]*codebuddy-code[\\/]bin[\\/]codebuddy)/i);
|
||||
if (match) {
|
||||
const ref = match[1].replace(/^%~dp0[\\/]?/i, "").replace(/[\\/]+/g, path.sep);
|
||||
const binJs = path.isAbsolute(ref) ? ref : path.resolve(path.dirname(shimPath), ref);
|
||||
if (existsSync(binJs)) return binJs;
|
||||
}
|
||||
} catch {
|
||||
// Try the next shim candidate.
|
||||
}
|
||||
}
|
||||
|
||||
// Could not locate the JS entry — return null so the caller falls back to the
|
||||
// SDK's bundled CLI rather than handing `node` an unrunnable shim.
|
||||
return ext === ".cmd" || ext === ".bat" || ext === ".ps1" ? null : normalized;
|
||||
}
|
||||
|
||||
function resolveSdkBinPath(command, shellEnv, platform = process.platform) {
|
||||
const raw = resolveCliFromPath(command, shellEnv);
|
||||
if (!raw) return null;
|
||||
@@ -604,6 +654,80 @@ function mergeLoginShellPath({
|
||||
return out.join(delimiter);
|
||||
}
|
||||
|
||||
// ── Windows live PATH refresh ──
|
||||
//
|
||||
// A GUI-launched Electron process freezes process.env at launch. When a CLI is
|
||||
// installed *after* Netcatty starts (its installer appends to the user/system
|
||||
// PATH in the registry), a freshly opened cmd/PowerShell sees it but Netcatty
|
||||
// does not — and clicking "Refresh" can't help, because process.env never
|
||||
// changes for the life of the process. So on Windows we re-read the authoritative
|
||||
// PATH from the registry (the value a brand-new shell would inherit) and merge it
|
||||
// with the in-process PATH. This mirrors the login-shell PATH probe used on
|
||||
// macOS/Linux and fixes CLIs (e.g. CodeBuddy) that "work in cmd" but don't scan.
|
||||
|
||||
function parseRegQueryPath(stdout) {
|
||||
// `reg query` prints e.g.: " Path REG_EXPAND_SZ C:\\a;C:\\b"
|
||||
for (const line of String(stdout || "").split(/\r?\n/)) {
|
||||
const match = line.match(/^\s*Path\s+REG_(?:EXPAND_)?SZ\s+(.*\S)\s*$/i);
|
||||
if (match) return match[1];
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function expandWindowsEnvRefs(value, env = process.env) {
|
||||
return String(value || "").replace(/%([^%]+)%/g, (whole, name) => {
|
||||
const key = Object.keys(env).find((k) => k.toLowerCase() === String(name).toLowerCase());
|
||||
return key && typeof env[key] === "string" ? env[key] : whole;
|
||||
});
|
||||
}
|
||||
|
||||
function mergeWindowsPath(...pathStrings) {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const str of pathStrings) {
|
||||
for (const part of String(str || "").split(";")) {
|
||||
const trimmed = part.trim().replace(/^"|"$/g, "");
|
||||
if (!trimmed) continue;
|
||||
const dedupeKey = trimmed.toLowerCase().replace(/[\\/]+$/, "");
|
||||
if (seen.has(dedupeKey)) continue;
|
||||
seen.add(dedupeKey);
|
||||
out.push(trimmed);
|
||||
}
|
||||
}
|
||||
return out.join(";");
|
||||
}
|
||||
|
||||
function getWindowsKnownCliPathDirs(env = process.env) {
|
||||
const dirs = [];
|
||||
if (env.APPDATA) dirs.push(path.join(env.APPDATA, "npm"));
|
||||
if (env.LOCALAPPDATA) {
|
||||
dirs.push(path.join(env.LOCALAPPDATA, "pnpm"));
|
||||
dirs.push(path.join(env.LOCALAPPDATA, "Yarn", "bin"));
|
||||
}
|
||||
return dirs.filter((dir) => existsSync(dir));
|
||||
}
|
||||
|
||||
async function readWindowsRegistryPath({ exec = execFileAsync, env = process.env } = {}) {
|
||||
const hives = [
|
||||
"HKCU\\Environment",
|
||||
"HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment",
|
||||
];
|
||||
const parts = [];
|
||||
for (const hive of hives) {
|
||||
try {
|
||||
const { stdout } = await exec("reg", ["query", hive, "/v", "Path"], {
|
||||
encoding: "utf8",
|
||||
timeout: 3000,
|
||||
});
|
||||
const raw = parseRegQueryPath(stdout);
|
||||
if (raw) parts.push(expandWindowsEnvRefs(raw, env));
|
||||
} catch {
|
||||
// Hive unreadable / value missing — skip and rely on other sources.
|
||||
}
|
||||
}
|
||||
return parts.join(";");
|
||||
}
|
||||
|
||||
async function getShellEnv() {
|
||||
if (_cachedShellEnv) return _cachedShellEnv;
|
||||
if (_shellEnvPromise) return _shellEnvPromise;
|
||||
@@ -619,9 +743,19 @@ async function getShellEnv() {
|
||||
];
|
||||
|
||||
if (process.platform === "win32") {
|
||||
// Re-read the live PATH from the registry so CLIs installed after launch
|
||||
// (e.g. CodeBuddy) are discoverable without restarting Netcatty, then fold
|
||||
// in well-known npm/pnpm/yarn global bin dirs as a belt-and-suspenders.
|
||||
let registryPath = "";
|
||||
try {
|
||||
registryPath = await readWindowsRegistryPath();
|
||||
} catch {
|
||||
registryPath = "";
|
||||
}
|
||||
const knownDirs = getWindowsKnownCliPathDirs().join(path.delimiter);
|
||||
const nextEnv = {
|
||||
...process.env,
|
||||
PATH: [...extraPaths, process.env.PATH || ""].join(path.delimiter),
|
||||
PATH: mergeWindowsPath(registryPath, knownDirs, process.env.PATH || ""),
|
||||
};
|
||||
if (generation === _shellEnvGeneration) {
|
||||
_cachedShellEnv = nextEnv;
|
||||
@@ -721,6 +855,7 @@ module.exports = {
|
||||
normalizeClaudeCodeExecutableEnvForSdk,
|
||||
resolveCodexExecutableForSdk,
|
||||
addCodexExecutableEnvForSdk,
|
||||
resolveCodebuddyExecutableForSdk,
|
||||
resolveSdkBinPath,
|
||||
resolveSdkBinPathAsync,
|
||||
resolveCliFromPath,
|
||||
@@ -728,6 +863,10 @@ module.exports = {
|
||||
toUnpackedAsarPath,
|
||||
isPlausibleCliVersionOutput,
|
||||
mergeLoginShellPath,
|
||||
parseRegQueryPath,
|
||||
expandWindowsEnvRefs,
|
||||
mergeWindowsPath,
|
||||
readWindowsRegistryPath,
|
||||
getShellEnv,
|
||||
invalidateShellEnvCache,
|
||||
};
|
||||
|
||||
@@ -13,6 +13,11 @@ const {
|
||||
resolveWindowsShimToNativeExe,
|
||||
resolveClaudeCodeExecutableForSdk,
|
||||
resolveCodexExecutableForSdk,
|
||||
resolveCodebuddyExecutableForSdk,
|
||||
parseRegQueryPath,
|
||||
expandWindowsEnvRefs,
|
||||
mergeWindowsPath,
|
||||
readWindowsRegistryPath,
|
||||
trackSessionIdlePrompt,
|
||||
} = require("./shellUtils.cjs");
|
||||
const fs = require("node:fs");
|
||||
@@ -342,6 +347,127 @@ test("addCodexExecutableEnvForSdk prepends bundled Codex path dir on Windows", (
|
||||
}
|
||||
});
|
||||
|
||||
function writeCodebuddyWin32BinLayout(dir) {
|
||||
const binJs = path.join(dir, "node_modules", "@tencent-ai", "codebuddy-code", "bin", "codebuddy");
|
||||
fs.mkdirSync(path.dirname(binJs), { recursive: true });
|
||||
fs.writeFileSync(binJs, "#!/usr/bin/env node\n", "utf8");
|
||||
return binJs;
|
||||
}
|
||||
|
||||
test("resolveCodebuddyExecutableForSdk leaves non-Windows CodeBuddy paths unchanged", () => {
|
||||
assert.equal(
|
||||
resolveCodebuddyExecutableForSdk("/usr/local/bin/codebuddy", "darwin"),
|
||||
"/usr/local/bin/codebuddy",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveCodebuddyExecutableForSdk maps Windows npm cmd shim to package bin/codebuddy", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codebuddy-shim-"));
|
||||
try {
|
||||
const shimPath = path.join(tmp, "codebuddy.cmd");
|
||||
const binJs = writeCodebuddyWin32BinLayout(tmp);
|
||||
fs.writeFileSync(
|
||||
shimPath,
|
||||
'@ECHO off\r\nnode "%~dp0\\node_modules\\@tencent-ai\\codebuddy-code\\bin\\codebuddy" %*\r\n',
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.equal(resolveCodebuddyExecutableForSdk(shimPath, "win32"), binJs);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveCodebuddyExecutableForSdk maps extensionless Windows shim to package bin/codebuddy", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codebuddy-noext-"));
|
||||
try {
|
||||
const shimPath = path.join(tmp, "codebuddy");
|
||||
const binJs = writeCodebuddyWin32BinLayout(tmp);
|
||||
fs.writeFileSync(shimPath, "#!/bin/sh\n", "utf8");
|
||||
|
||||
assert.equal(resolveCodebuddyExecutableForSdk(shimPath, "win32"), binJs);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveCodebuddyExecutableForSdk returns null for Windows cmd shim when package JS is missing", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codebuddy-missing-"));
|
||||
try {
|
||||
const shimPath = path.join(tmp, "codebuddy.cmd");
|
||||
fs.writeFileSync(shimPath, "@ECHO off\r\nnode foo %*\r\n", "utf8");
|
||||
|
||||
assert.equal(resolveCodebuddyExecutableForSdk(shimPath, "win32"), null);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveCodebuddyExecutableForSdk passes through a native exe path", () => {
|
||||
assert.equal(
|
||||
resolveCodebuddyExecutableForSdk("C:\\tools\\codebuddy.exe", "win32"),
|
||||
"C:\\tools\\codebuddy.exe",
|
||||
);
|
||||
});
|
||||
|
||||
test("parseRegQueryPath extracts the Path value from reg query output", () => {
|
||||
const out = parseRegQueryPath(
|
||||
"\r\nHKEY_CURRENT_USER\\Environment\r\n Path REG_EXPAND_SZ C:\\Users\\me\\AppData\\Roaming\\npm;C:\\tools\r\n",
|
||||
);
|
||||
assert.equal(out, "C:\\Users\\me\\AppData\\Roaming\\npm;C:\\tools");
|
||||
});
|
||||
|
||||
test("parseRegQueryPath handles REG_SZ and missing value", () => {
|
||||
assert.equal(parseRegQueryPath(" Path REG_SZ C:\\bin"), "C:\\bin");
|
||||
assert.equal(parseRegQueryPath("HKEY_CURRENT_USER\\Environment\r\n Temp REG_SZ C:\\Temp"), "");
|
||||
});
|
||||
|
||||
test("expandWindowsEnvRefs expands %VAR% case-insensitively", () => {
|
||||
assert.equal(
|
||||
expandWindowsEnvRefs("%AppData%\\npm;%Other%", { APPDATA: "C:\\Users\\me\\AppData\\Roaming" }),
|
||||
"C:\\Users\\me\\AppData\\Roaming\\npm;%Other%",
|
||||
);
|
||||
});
|
||||
|
||||
test("mergeWindowsPath dedupes case-insensitively and trims trailing slashes", () => {
|
||||
const out = mergeWindowsPath(
|
||||
"C:\\Windows\\System32;C:\\tools\\",
|
||||
"c:\\windows\\system32;C:\\tools;C:\\new",
|
||||
);
|
||||
assert.equal(out, "C:\\Windows\\System32;C:\\tools\\;C:\\new");
|
||||
});
|
||||
|
||||
test("mergeWindowsPath keeps refreshed Windows PATH entries ahead of stale process entries", () => {
|
||||
const out = mergeWindowsPath(
|
||||
"C:\\new-codebuddy;C:\\Windows\\System32",
|
||||
"C:\\Users\\me\\AppData\\Roaming\\npm",
|
||||
"C:\\old-codebuddy;C:\\Windows\\System32",
|
||||
);
|
||||
assert.equal(out, "C:\\new-codebuddy;C:\\Windows\\System32;C:\\Users\\me\\AppData\\Roaming\\npm;C:\\old-codebuddy");
|
||||
});
|
||||
|
||||
test("readWindowsRegistryPath merges HKCU and HKLM and expands refs", async () => {
|
||||
const exec = async (cmd, args) => {
|
||||
assert.equal(cmd, "reg");
|
||||
const hive = args[1];
|
||||
if (hive === "HKCU\\Environment") {
|
||||
return { stdout: " Path REG_EXPAND_SZ %APPDATA%\\npm\r\n" };
|
||||
}
|
||||
return { stdout: " Path REG_EXPAND_SZ C:\\Windows\\System32\r\n" };
|
||||
};
|
||||
const out = await readWindowsRegistryPath({ exec, env: { APPDATA: "C:\\Roaming" } });
|
||||
assert.equal(out, "C:\\Roaming\\npm;C:\\Windows\\System32");
|
||||
});
|
||||
|
||||
test("readWindowsRegistryPath tolerates a failing hive query", async () => {
|
||||
const exec = async (cmd, args) => {
|
||||
if (args[1] === "HKCU\\Environment") throw new Error("ERROR: cannot read");
|
||||
return { stdout: " Path REG_SZ C:\\tools\r\n" };
|
||||
};
|
||||
const out = await readWindowsRegistryPath({ exec, env: {} });
|
||||
assert.equal(out, "C:\\tools");
|
||||
});
|
||||
|
||||
test("tracks PowerShell idle prompt after SSH output", () => {
|
||||
const session = {};
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ const {
|
||||
prepareCommandForSpawn,
|
||||
normalizeClaudeCodeExecutableEnvForSdk,
|
||||
addCodexExecutableEnvForSdk,
|
||||
resolveCodebuddyExecutableForSdk,
|
||||
resolveSdkBinPath,
|
||||
resolveSdkBinPathAsync,
|
||||
resolveCliFromPath,
|
||||
@@ -641,6 +642,7 @@ function createHandlerContext(ipcMain) {
|
||||
prepareCommandForSpawn,
|
||||
normalizeClaudeCodeExecutableEnvForSdk,
|
||||
addCodexExecutableEnvForSdk,
|
||||
resolveCodebuddyExecutableForSdk,
|
||||
resolveSdkBinPath,
|
||||
resolveSdkBinPathAsync,
|
||||
resolveCliFromPath,
|
||||
|
||||
@@ -63,13 +63,22 @@ function resolveRealCliPath(cliPath, realpath = realpathSync) {
|
||||
}
|
||||
|
||||
function resolveSdkBackendBinPath({
|
||||
backendKey, shellEnv, env, resolveCliFromPath, normalizeCliPathForPlatform, resolveSdkBinPath, realpath = realpathSync,
|
||||
backendKey, shellEnv, env, resolveCliFromPath, normalizeCliPathForPlatform,
|
||||
resolveSdkBinPath, resolveCodebuddyExecutableForSdk, realpath = realpathSync,
|
||||
}) {
|
||||
if (backendKey === "codebuddy") {
|
||||
const configuredPath = normalizeCliPathForPlatform?.(env?.CODEBUDDY_CODE_PATH);
|
||||
if (configuredPath) return resolveRealCliPath(configuredPath, realpath);
|
||||
const resolvedPath = resolveCliFromPath(backendKey, shellEnv) || undefined;
|
||||
return resolveRealCliPath(resolvedPath, realpath);
|
||||
const rawPath = configuredPath || resolveCliFromPath(backendKey, shellEnv) || undefined;
|
||||
if (!rawPath) return undefined;
|
||||
const realPath = resolveRealCliPath(rawPath, realpath);
|
||||
// On Windows the discovered path is an npm shim (codebuddy.cmd/.ps1) that the
|
||||
// Agent SDK can't run through `node`; resolve it to the package's JS entry so
|
||||
// it launches like on macOS/Linux. A null result means the shim is unrunnable
|
||||
// and unresolvable, so fall back to the SDK's bundled CLI.
|
||||
const sdkPath = typeof resolveCodebuddyExecutableForSdk === "function"
|
||||
? resolveCodebuddyExecutableForSdk(realPath)
|
||||
: realPath;
|
||||
return sdkPath || undefined;
|
||||
}
|
||||
return resolveSdkBinPath?.(backendKey, shellEnv) || undefined;
|
||||
}
|
||||
@@ -208,6 +217,7 @@ function registerSdkStreamHandlers(ctx) {
|
||||
resolveCliFromPath,
|
||||
normalizeCliPathForPlatform,
|
||||
resolveSdkBinPath,
|
||||
resolveCodebuddyExecutableForSdk,
|
||||
});
|
||||
if (backendKey === "codex") {
|
||||
env = addCodexExecutableEnvForSdk(env, binPath);
|
||||
@@ -296,6 +306,7 @@ function registerSdkStreamHandlers(ctx) {
|
||||
resolveCliFromPath,
|
||||
normalizeCliPathForPlatform,
|
||||
resolveSdkBinPath,
|
||||
resolveCodebuddyExecutableForSdk,
|
||||
});
|
||||
const raw = await withTimeout(driver.listModels({ binPath, env }), MODEL_LIST_TIMEOUT_MS);
|
||||
const models = Array.isArray(raw) ? raw.filter((m) => m && m.id) : [];
|
||||
|
||||
@@ -97,6 +97,38 @@ test("resolveSdkBackendBinPath realpaths CodeBuddy PATH discovery fallback", ()
|
||||
assert.equal(out, "/opt/codebuddy/bin/codebuddy");
|
||||
});
|
||||
|
||||
test("resolveSdkBackendBinPath resolves Windows CodeBuddy shim to the package JS entry", () => {
|
||||
const out = resolveSdkBackendBinPath({
|
||||
backendKey: "codebuddy",
|
||||
shellEnv: { Path: "C:\\Users\\me\\AppData\\Roaming\\npm" },
|
||||
env: {},
|
||||
resolveCliFromPath: () => "C:\\Users\\me\\AppData\\Roaming\\npm\\codebuddy.cmd",
|
||||
normalizeCliPathForPlatform: () => null,
|
||||
realpath: (p) => p,
|
||||
resolveCodebuddyExecutableForSdk: (p) =>
|
||||
p.endsWith("codebuddy.cmd")
|
||||
? "C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@tencent-ai\\codebuddy-code\\bin\\codebuddy"
|
||||
: p,
|
||||
});
|
||||
assert.equal(
|
||||
out,
|
||||
"C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@tencent-ai\\codebuddy-code\\bin\\codebuddy",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveSdkBackendBinPath falls back to bundled CLI when Windows CodeBuddy shim is unresolvable", () => {
|
||||
const out = resolveSdkBackendBinPath({
|
||||
backendKey: "codebuddy",
|
||||
shellEnv: { Path: "C:\\Users\\me\\AppData\\Roaming\\npm" },
|
||||
env: {},
|
||||
resolveCliFromPath: () => "C:\\Users\\me\\AppData\\Roaming\\npm\\codebuddy.cmd",
|
||||
normalizeCliPathForPlatform: () => null,
|
||||
realpath: (p) => p,
|
||||
resolveCodebuddyExecutableForSdk: () => null,
|
||||
});
|
||||
assert.equal(out, undefined);
|
||||
});
|
||||
|
||||
test("resolveSdkBackendBinPath keeps non-CodeBuddy SDK path normalization", () => {
|
||||
const out = resolveSdkBackendBinPath({
|
||||
backendKey: "codex",
|
||||
|
||||
22
electron/bridges/registerBridgesFigSpec.test.cjs
Normal file
22
electron/bridges/registerBridgesFigSpec.test.cjs
Normal file
@@ -0,0 +1,22 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const {
|
||||
filterExcludedFigSpecs,
|
||||
isExcludedFigSpec,
|
||||
} = require("../main/registerBridges.cjs");
|
||||
|
||||
test("filters cloud fig specs removed from packaged builds", () => {
|
||||
assert.equal(isExcludedFigSpec("aws"), true);
|
||||
assert.equal(isExcludedFigSpec("aws/s3"), true);
|
||||
assert.equal(isExcludedFigSpec("gcloud"), true);
|
||||
assert.equal(isExcludedFigSpec("gcloud/compute"), true);
|
||||
assert.equal(isExcludedFigSpec("az"), true);
|
||||
assert.equal(isExcludedFigSpec("az/2.53.0"), true);
|
||||
assert.equal(isExcludedFigSpec("aws-vault"), false);
|
||||
|
||||
assert.deepEqual(
|
||||
filterExcludedFigSpecs(["git", "aws", "aws/s3", "gcloud", "az/2.53.0", "aws-vault"]),
|
||||
["git", "aws-vault"],
|
||||
);
|
||||
});
|
||||
@@ -24,6 +24,7 @@ const { NetcattyAgent } = require("./netcattyAgent.cjs");
|
||||
const fileWatcherBridge = require("./fileWatcherBridge.cjs");
|
||||
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
const passphraseHandler = require("./passphraseHandler.cjs");
|
||||
const hostKeyVerifier = require("./hostKeyVerifier.cjs");
|
||||
const tempDirBridge = require("./tempDirBridge.cjs");
|
||||
const { createProxySocket } = require("./proxyUtils.cjs");
|
||||
const {
|
||||
@@ -888,6 +889,7 @@ const openConnectionApi = createOpenConnectionApi({
|
||||
get sessions() { return sessions; },
|
||||
get electronModule() { return electronModule; },
|
||||
jumpConnectionsMap, SftpClient, SSHClient, NetcattyAgent, keyboardInteractiveHandler, passphraseHandler,
|
||||
hostKeyVerifier,
|
||||
fs, path, net, Buffer, process, console, setTimeout, clearTimeout,
|
||||
SFTPWrapper, createProxySocket, buildSftpAlgorithms, getAvailableAgentSocket,
|
||||
preparePrivateKeyForAuth, loadFirstIdentityFileForAuth, findAllDefaultPrivateKeysFromHelper,
|
||||
|
||||
313
electron/bridges/sftpBridge.hostKeyVerification.test.cjs
Normal file
313
electron/bridges/sftpBridge.hostKeyVerification.test.cjs
Normal file
@@ -0,0 +1,313 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const crypto = require("node:crypto");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const Module = require("node:module");
|
||||
|
||||
function makeRawPublicKey(keyType, body) {
|
||||
const type = Buffer.from(keyType);
|
||||
const length = Buffer.alloc(4);
|
||||
length.writeUInt32BE(type.length, 0);
|
||||
return Buffer.concat([length, type, Buffer.from(body)]);
|
||||
}
|
||||
|
||||
function makeKnownHost(id, hostname, rawKey) {
|
||||
return {
|
||||
id,
|
||||
hostname,
|
||||
port: 22,
|
||||
keyType: "ssh-ed25519",
|
||||
publicKey: `ssh-ed25519 ${rawKey.toString("base64")}`,
|
||||
fingerprint: crypto.createHash("sha256")
|
||||
.update(rawKey)
|
||||
.digest("base64")
|
||||
.replace(/=+$/g, ""),
|
||||
discoveredAt: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function loadSftpBridgeWithMockedClients(t) {
|
||||
const bridgePath = require.resolve("./sftpBridge.cjs");
|
||||
const originalLoad = Module._load;
|
||||
|
||||
class MockJumpClient extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
MockJumpClient.instances.push(this);
|
||||
this.connectOpts = null;
|
||||
this.ended = false;
|
||||
this.hostVerifierCalls = 0;
|
||||
}
|
||||
|
||||
connect(opts) {
|
||||
this.connectOpts = opts;
|
||||
const rawKey = MockJumpClient.hostKeysByHost.get(opts.host) || MockJumpClient.defaultHostKey;
|
||||
setImmediate(() => {
|
||||
const accept = () => {
|
||||
this.emit("handshake");
|
||||
this.emit("ready");
|
||||
};
|
||||
if (typeof opts.hostVerifier !== "function") {
|
||||
accept();
|
||||
return;
|
||||
}
|
||||
this.hostVerifierCalls += 1;
|
||||
opts.hostVerifier(rawKey, (accepted) => {
|
||||
if (accepted) {
|
||||
accept();
|
||||
return;
|
||||
}
|
||||
const err = new Error(`Host key rejected for ${opts.host || "tunneled host"}`);
|
||||
err.level = "client-socket";
|
||||
this.emit("error", err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
forwardOut(_srcIP, _srcPort, _dstHost, _dstPort, cb) {
|
||||
const stream = new EventEmitter();
|
||||
stream.end = () => {};
|
||||
stream.destroy = () => {};
|
||||
setImmediate(() => cb(null, stream));
|
||||
}
|
||||
|
||||
end() {
|
||||
this.ended = true;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.ended = true;
|
||||
}
|
||||
}
|
||||
MockJumpClient.instances = [];
|
||||
MockJumpClient.hostKeysByHost = new Map();
|
||||
MockJumpClient.defaultHostKey = makeRawPublicKey("ssh-ed25519", "default untrusted jump key");
|
||||
|
||||
class MockSftpClient extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
MockSftpClient.instances.push(this);
|
||||
this.hostVerifierCalls = 0;
|
||||
this.client = new EventEmitter();
|
||||
this.client.setMaxListeners = () => {};
|
||||
this.client.connectOpts = null;
|
||||
this.client.connect = (opts) => {
|
||||
this.client.connectOpts = opts;
|
||||
const rawKey = MockSftpClient.hostKeysByHost.get(opts.host)
|
||||
|| MockSftpClient.tunneledHostKey
|
||||
|| MockSftpClient.defaultHostKey;
|
||||
setImmediate(() => {
|
||||
const accept = () => {
|
||||
this.client.emit("handshake");
|
||||
this.client.emit("ready");
|
||||
};
|
||||
if (typeof opts.hostVerifier !== "function") {
|
||||
accept();
|
||||
return;
|
||||
}
|
||||
this.hostVerifierCalls += 1;
|
||||
opts.hostVerifier(rawKey, (accepted) => {
|
||||
if (accepted) {
|
||||
accept();
|
||||
return;
|
||||
}
|
||||
const err = new Error(`Host key rejected for ${opts.host || "tunneled host"}`);
|
||||
err.level = "client-socket";
|
||||
this.client.emit("error", err);
|
||||
});
|
||||
});
|
||||
};
|
||||
this.client.sftp = (cb) => {
|
||||
setImmediate(() => cb(null, new EventEmitter()));
|
||||
};
|
||||
this.client.end = () => {};
|
||||
this.client.destroy = () => {};
|
||||
}
|
||||
|
||||
end() {}
|
||||
}
|
||||
MockSftpClient.instances = [];
|
||||
MockSftpClient.hostKeysByHost = new Map();
|
||||
MockSftpClient.defaultHostKey = makeRawPublicKey("ssh-ed25519", "default untrusted target key");
|
||||
MockSftpClient.tunneledHostKey = null;
|
||||
|
||||
Module._load = function patchedLoad(request, parent, isMain) {
|
||||
if (request === "ssh2") {
|
||||
return {
|
||||
Client: MockJumpClient,
|
||||
utils: { parseKey: () => new Error("no key") },
|
||||
};
|
||||
}
|
||||
if (request === "ssh2-sftp-client") {
|
||||
return MockSftpClient;
|
||||
}
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
|
||||
delete require.cache[bridgePath];
|
||||
const bridge = require("./sftpBridge.cjs");
|
||||
|
||||
t.after(() => {
|
||||
delete require.cache[bridgePath];
|
||||
Module._load = originalLoad;
|
||||
});
|
||||
|
||||
return { bridge, MockJumpClient, MockSftpClient };
|
||||
}
|
||||
|
||||
function makeSender({ rejectHostKeyPrompts = false } = {}) {
|
||||
return {
|
||||
id: 1,
|
||||
isDestroyed: () => false,
|
||||
sent: [],
|
||||
send(channel, payload) {
|
||||
this.sent.push({ channel, payload });
|
||||
if (rejectHostKeyPrompts && channel === "netcatty:host-key:verify") {
|
||||
const { handleResponse } = require("./hostKeyVerifier.cjs");
|
||||
queueMicrotask(() => {
|
||||
handleResponse(null, {
|
||||
requestId: payload.requestId,
|
||||
accept: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("SFTP direct connections verify target host keys against known hosts", async (t) => {
|
||||
const { bridge, MockSftpClient } = loadSftpBridgeWithMockedClients(t);
|
||||
const sender = makeSender();
|
||||
const rawTargetKey = makeRawPublicKey("ssh-ed25519", "trusted sftp target key");
|
||||
MockSftpClient.hostKeysByHost.set("target.example.com", rawTargetKey);
|
||||
|
||||
bridge.init({ sftpClients: new Map(), sessions: new Map(), electronModule: {} });
|
||||
await bridge.openSftp(
|
||||
{ sender },
|
||||
{
|
||||
sessionId: "sftp-direct-host-key",
|
||||
hostname: "target.example.com",
|
||||
port: 22,
|
||||
username: "alice",
|
||||
knownHosts: [makeKnownHost("kh-target", "target.example.com", rawTargetKey)],
|
||||
},
|
||||
);
|
||||
|
||||
const connectOpts = MockSftpClient.instances[0].client.connectOpts;
|
||||
assert.equal(typeof connectOpts.hostVerifier, "function");
|
||||
assert.equal(MockSftpClient.instances[0].hostVerifierCalls, 1);
|
||||
assert.deepEqual(
|
||||
sender.sent.filter((message) => message.channel === "netcatty:host-key:verify"),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("SFTP jump-host chains verify hop and target host keys against known hosts", async (t) => {
|
||||
const { bridge, MockJumpClient, MockSftpClient } = loadSftpBridgeWithMockedClients(t);
|
||||
const sender = makeSender();
|
||||
const rawJumpKey = makeRawPublicKey("ssh-ed25519", "trusted sftp jump key");
|
||||
const rawTargetKey = makeRawPublicKey("ssh-ed25519", "trusted sftp target key");
|
||||
MockJumpClient.hostKeysByHost.set("bastion.example.com", rawJumpKey);
|
||||
MockSftpClient.tunneledHostKey = rawTargetKey;
|
||||
|
||||
bridge.init({ sftpClients: new Map(), sessions: new Map(), electronModule: {} });
|
||||
await bridge.openSftp(
|
||||
{ sender },
|
||||
{
|
||||
sessionId: "sftp-chain-host-key",
|
||||
hostname: "target.example.com",
|
||||
port: 22,
|
||||
username: "alice",
|
||||
knownHosts: [
|
||||
makeKnownHost("kh-jump", "bastion.example.com", rawJumpKey),
|
||||
makeKnownHost("kh-target", "target.example.com", rawTargetKey),
|
||||
],
|
||||
jumpHosts: [{
|
||||
hostname: "bastion.example.com",
|
||||
port: 22,
|
||||
username: "jump",
|
||||
password: "secret",
|
||||
label: "Bastion",
|
||||
}],
|
||||
},
|
||||
);
|
||||
|
||||
const jumpConnectOpts = MockJumpClient.instances[0].connectOpts;
|
||||
assert.equal(typeof jumpConnectOpts.hostVerifier, "function");
|
||||
assert.equal(MockJumpClient.instances[0].hostVerifierCalls, 1);
|
||||
|
||||
const targetConnectOpts = MockSftpClient.instances[0].client.connectOpts;
|
||||
assert.equal(typeof targetConnectOpts.hostVerifier, "function");
|
||||
assert.equal(MockSftpClient.instances[0].hostVerifierCalls, 1);
|
||||
assert.deepEqual(
|
||||
sender.sent.filter((message) => message.channel === "netcatty:host-key:verify"),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("SFTP direct connections stop when target host keys are rejected", async (t) => {
|
||||
const { bridge, MockSftpClient } = loadSftpBridgeWithMockedClients(t);
|
||||
const sender = makeSender({ rejectHostKeyPrompts: true });
|
||||
MockSftpClient.hostKeysByHost.set(
|
||||
"target.example.com",
|
||||
makeRawPublicKey("ssh-ed25519", "unknown sftp target key"),
|
||||
);
|
||||
|
||||
bridge.init({ sftpClients: new Map(), sessions: new Map(), electronModule: {} });
|
||||
await assert.rejects(
|
||||
bridge.openSftp(
|
||||
{ sender },
|
||||
{
|
||||
sessionId: "sftp-direct-host-key-rejected",
|
||||
hostname: "target.example.com",
|
||||
port: 22,
|
||||
username: "alice",
|
||||
knownHosts: [],
|
||||
},
|
||||
),
|
||||
/Host key rejected/,
|
||||
);
|
||||
|
||||
assert.equal(MockSftpClient.instances[0].hostVerifierCalls, 1);
|
||||
assert.equal(
|
||||
sender.sent.filter((message) => message.channel === "netcatty:host-key:verify").length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test("SFTP jump-host chains stop when hop host keys are rejected", async (t) => {
|
||||
const { bridge, MockJumpClient } = loadSftpBridgeWithMockedClients(t);
|
||||
const sender = makeSender({ rejectHostKeyPrompts: true });
|
||||
MockJumpClient.hostKeysByHost.set(
|
||||
"bastion.example.com",
|
||||
makeRawPublicKey("ssh-ed25519", "unknown sftp jump key"),
|
||||
);
|
||||
|
||||
bridge.init({ sftpClients: new Map(), sessions: new Map(), electronModule: {} });
|
||||
await assert.rejects(
|
||||
bridge.openSftp(
|
||||
{ sender },
|
||||
{
|
||||
sessionId: "sftp-chain-host-key-rejected",
|
||||
hostname: "target.example.com",
|
||||
port: 22,
|
||||
username: "alice",
|
||||
knownHosts: [],
|
||||
jumpHosts: [{
|
||||
hostname: "bastion.example.com",
|
||||
port: 22,
|
||||
username: "jump",
|
||||
password: "secret",
|
||||
label: "Bastion",
|
||||
}],
|
||||
},
|
||||
),
|
||||
/Host key rejected/,
|
||||
);
|
||||
|
||||
assert.equal(MockJumpClient.instances[0].hostVerifierCalls, 1);
|
||||
assert.equal(
|
||||
sender.sent.filter((message) => message.channel === "netcatty:host-key:verify").length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
@@ -81,6 +81,13 @@ function createOpenConnectionApi(ctx) {
|
||||
},
|
||||
),
|
||||
};
|
||||
connOpts.hostVerifier = hostKeyVerifier.createHostVerifier({
|
||||
sender,
|
||||
sessionId: connId,
|
||||
hostname: jump.hostname,
|
||||
port: jump.port || 22,
|
||||
knownHosts: options.knownHosts,
|
||||
});
|
||||
|
||||
// Auth - support agent (certificate), key, and password fallback
|
||||
const hasCertificate =
|
||||
@@ -640,6 +647,13 @@ function createOpenConnectionApi(ctx) {
|
||||
algorithmOverrides: options.algorithmOverrides,
|
||||
}),
|
||||
};
|
||||
connectOpts.hostVerifier = hostKeyVerifier.createHostVerifier({
|
||||
sender: event.sender,
|
||||
sessionId: connId,
|
||||
hostname: options.hostname,
|
||||
port: options.port || 22,
|
||||
knownHosts: options.knownHosts,
|
||||
});
|
||||
|
||||
// Use the tunneled socket if we have one
|
||||
if (connectionSocket) {
|
||||
|
||||
@@ -545,6 +545,13 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
},
|
||||
),
|
||||
};
|
||||
connOpts.hostVerifier = hostKeyVerifier.createHostVerifier({
|
||||
sender,
|
||||
sessionId,
|
||||
hostname: jump.hostname,
|
||||
port: jump.port || 22,
|
||||
knownHosts: options.knownHosts,
|
||||
});
|
||||
attachSshDebugLogger(connOpts, sshDiagnosticLogger);
|
||||
logSshAlgorithms("Jump host", connOpts.algorithms, {
|
||||
hostname: jump.hostname,
|
||||
|
||||
196
electron/bridges/sshBridge.hostKeyChain.test.cjs
Normal file
196
electron/bridges/sshBridge.hostKeyChain.test.cjs
Normal file
@@ -0,0 +1,196 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const crypto = require("node:crypto");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const Module = require("node:module");
|
||||
|
||||
function makeRawPublicKey(keyType, body = "trusted jump host key") {
|
||||
const type = Buffer.from(keyType);
|
||||
const length = Buffer.alloc(4);
|
||||
length.writeUInt32BE(type.length, 0);
|
||||
return Buffer.concat([length, type, Buffer.from(body)]);
|
||||
}
|
||||
|
||||
function loadBridgeWithMockedSsh2(t) {
|
||||
const bridgePath = require.resolve("./sshBridge.cjs");
|
||||
const authHelperPath = require.resolve("./sshAuthHelper.cjs");
|
||||
const originalLoad = Module._load;
|
||||
|
||||
class MockSSHClient extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
MockSSHClient.instances.push(this);
|
||||
this.ended = false;
|
||||
this.connectOpts = null;
|
||||
this.hostVerifierCalls = 0;
|
||||
}
|
||||
|
||||
connect(opts) {
|
||||
this.connectOpts = opts;
|
||||
const rawKey = MockSSHClient.hostKeysByHost.get(opts.host) || MockSSHClient.defaultHostKey;
|
||||
setImmediate(() => {
|
||||
const accept = () => {
|
||||
this.emit("connect");
|
||||
this.emit("handshake");
|
||||
this.emit("ready");
|
||||
};
|
||||
if (typeof opts.hostVerifier !== "function") {
|
||||
accept();
|
||||
return;
|
||||
}
|
||||
this.hostVerifierCalls += 1;
|
||||
opts.hostVerifier(rawKey, (accepted) => {
|
||||
if (accepted) {
|
||||
accept();
|
||||
return;
|
||||
}
|
||||
const err = new Error(`Host key rejected for ${opts.host || "tunneled host"}`);
|
||||
err.level = "client-socket";
|
||||
this.emit("error", err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
forwardOut(_srcIP, _srcPort, _dstHost, _dstPort, cb) {
|
||||
const stream = new EventEmitter();
|
||||
stream.destroy = () => {};
|
||||
setImmediate(() => cb(null, stream));
|
||||
}
|
||||
|
||||
end() {
|
||||
this.ended = true;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.ended = true;
|
||||
}
|
||||
}
|
||||
MockSSHClient.instances = [];
|
||||
MockSSHClient.hostKeysByHost = new Map();
|
||||
MockSSHClient.defaultHostKey = makeRawPublicKey("ssh-ed25519", "default untrusted key");
|
||||
|
||||
Module._load = function patchedLoad(request, parent, isMain) {
|
||||
if (request === "ssh2") {
|
||||
return {
|
||||
Client: MockSSHClient,
|
||||
utils: { parseKey: () => new Error("no key") },
|
||||
};
|
||||
}
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
|
||||
delete require.cache[bridgePath];
|
||||
delete require.cache[authHelperPath];
|
||||
const bridge = require("./sshBridge.cjs");
|
||||
|
||||
t.after(() => {
|
||||
delete require.cache[bridgePath];
|
||||
delete require.cache[authHelperPath];
|
||||
Module._load = originalLoad;
|
||||
});
|
||||
|
||||
return { bridge, MockSSHClient };
|
||||
}
|
||||
|
||||
function makeSender({ rejectHostKeyPrompts = false } = {}) {
|
||||
return {
|
||||
id: 1,
|
||||
isDestroyed: () => false,
|
||||
sent: [],
|
||||
send(channel, payload) {
|
||||
this.sent.push({ channel, payload });
|
||||
if (rejectHostKeyPrompts && channel === "netcatty:host-key:verify") {
|
||||
const { handleResponse } = require("./hostKeyVerifier.cjs");
|
||||
queueMicrotask(() => {
|
||||
handleResponse(null, {
|
||||
requestId: payload.requestId,
|
||||
accept: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("jump-host chain connections verify hop host keys against known hosts", async (t) => {
|
||||
const { bridge, MockSSHClient } = loadBridgeWithMockedSsh2(t);
|
||||
const sender = makeSender();
|
||||
const rawKey = makeRawPublicKey("ssh-ed25519");
|
||||
MockSSHClient.hostKeysByHost.set("bastion.example.com", rawKey);
|
||||
const fingerprint = crypto.createHash("sha256")
|
||||
.update(rawKey)
|
||||
.digest("base64")
|
||||
.replace(/=+$/g, "");
|
||||
|
||||
await bridge.connectThroughChain(
|
||||
{ sender },
|
||||
{
|
||||
knownHosts: [{
|
||||
id: "kh-jump",
|
||||
hostname: "bastion.example.com",
|
||||
port: 22,
|
||||
keyType: "ssh-ed25519",
|
||||
publicKey: `ssh-ed25519 ${rawKey.toString("base64")}`,
|
||||
fingerprint,
|
||||
discoveredAt: 1,
|
||||
}],
|
||||
_defaultKeys: [],
|
||||
},
|
||||
[{
|
||||
hostname: "bastion.example.com",
|
||||
port: 22,
|
||||
username: "alice",
|
||||
password: "secret",
|
||||
label: "Bastion",
|
||||
}],
|
||||
"target.example.com",
|
||||
22,
|
||||
"session-1",
|
||||
);
|
||||
|
||||
assert.equal(MockSSHClient.instances.length, 1);
|
||||
const connectOpts = MockSSHClient.instances[0].connectOpts;
|
||||
assert.equal(typeof connectOpts.hostVerifier, "function");
|
||||
assert.equal(MockSSHClient.instances[0].hostVerifierCalls, 1);
|
||||
assert.deepEqual(
|
||||
sender.sent.filter((message) => message.channel === "netcatty:host-key:verify"),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("jump-host chain connections stop when hop host keys are rejected", async (t) => {
|
||||
const { bridge, MockSSHClient } = loadBridgeWithMockedSsh2(t);
|
||||
const sender = makeSender({ rejectHostKeyPrompts: true });
|
||||
MockSSHClient.hostKeysByHost.set(
|
||||
"bastion.example.com",
|
||||
makeRawPublicKey("ssh-ed25519", "unknown jump host key"),
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
bridge.connectThroughChain(
|
||||
{ sender },
|
||||
{
|
||||
knownHosts: [],
|
||||
_defaultKeys: [],
|
||||
},
|
||||
[{
|
||||
hostname: "bastion.example.com",
|
||||
port: 22,
|
||||
username: "alice",
|
||||
password: "secret",
|
||||
label: "Bastion",
|
||||
}],
|
||||
"target.example.com",
|
||||
22,
|
||||
"session-1",
|
||||
),
|
||||
/Host key rejected/,
|
||||
);
|
||||
|
||||
assert.equal(MockSSHClient.instances.length, 1);
|
||||
assert.equal(MockSSHClient.instances[0].hostVerifierCalls, 1);
|
||||
assert.equal(
|
||||
sender.sent.filter((message) => message.channel === "netcatty:host-key:verify").length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
@@ -16,7 +16,7 @@ const CAPABILITY_SCRIPT_POSIX = [
|
||||
const PROCESS_LIST_SCRIPT_POSIX = [
|
||||
"exec sh -c ",
|
||||
"'",
|
||||
"ps -eo pid=,ppid=,user=,stat=,pcpu=,pmem=,rss=,vsz=,etime=,args= 2>/dev/null | head -n 200",
|
||||
"ps -eo pid= -o ppid= -o user= -o stat= -o pcpu= -o pmem= -o rss= -o vsz= -o etime= -o args= 2>/dev/null | head -n 200",
|
||||
"'",
|
||||
].join("");
|
||||
|
||||
|
||||
48
electron/bridges/systemManagerBridge.processes.test.cjs
Normal file
48
electron/bridges/systemManagerBridge.processes.test.cjs
Normal file
@@ -0,0 +1,48 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const { createSystemManagerBridge } = require("./systemManagerBridge.cjs");
|
||||
|
||||
function createFakeExecStream(stdout) {
|
||||
const stream = new EventEmitter();
|
||||
stream.stderr = new EventEmitter();
|
||||
process.nextTick(() => {
|
||||
stream.emit("data", stdout);
|
||||
stream.emit("close", 0);
|
||||
});
|
||||
return stream;
|
||||
}
|
||||
|
||||
test("listProcesses uses a ps format that works on CentOS 7 procps", async () => {
|
||||
const compatiblePsFormat = "ps -eo pid= -o ppid= -o user= -o stat= -o pcpu= -o pmem= -o rss= -o vsz= -o etime= -o args=";
|
||||
const badCentos7Output = [
|
||||
",ppid=,user=,stat=,pcpu=,pmem=,rss=,vsz=,etime=,args=",
|
||||
" 1",
|
||||
].join("\n");
|
||||
const compatibleOutput = [
|
||||
" 1 0 root Ss 0.0 0.0 4060 191024 2-19:23:42 /usr/lib/systemd/systemd --switched-root --system --deserialize 21",
|
||||
].join("\n");
|
||||
|
||||
const conn = {
|
||||
exec(command, callback) {
|
||||
const stdout = command.includes(compatiblePsFormat)
|
||||
? compatibleOutput
|
||||
: badCentos7Output;
|
||||
callback(null, createFakeExecStream(stdout));
|
||||
},
|
||||
};
|
||||
const sessions = new Map([["s1", { conn, type: "ssh" }]]);
|
||||
const bridge = createSystemManagerBridge({
|
||||
getSessions: () => sessions,
|
||||
process,
|
||||
});
|
||||
|
||||
const result = await bridge.listProcesses(null, { sessionId: "s1" });
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.processes.length, 1);
|
||||
assert.equal(result.processes[0].pid, 1);
|
||||
assert.equal(result.processes[0].command, "/usr/lib/systemd/systemd --switched-root --system --deserialize 21");
|
||||
});
|
||||
@@ -14,6 +14,7 @@ const NETCATTY_TEMP_DIR_NAME = "Netcatty";
|
||||
|
||||
// Cached temp directory path
|
||||
let cachedTempDir = null;
|
||||
let tempFileCounter = 0;
|
||||
|
||||
/**
|
||||
* Get the Netcatty temp directory path
|
||||
@@ -143,8 +144,9 @@ async function clearTempDir() {
|
||||
function getTempFilePath(fileName) {
|
||||
const tempDir = getTempDir();
|
||||
const timestamp = Date.now();
|
||||
tempFileCounter = (tempFileCounter + 1) % 1000000;
|
||||
const safeFileName = fileName.replace(/[<>:"/\\|?*]/g, "_");
|
||||
return path.join(tempDir, `${timestamp}_${safeFileName}`);
|
||||
return path.join(tempDir, `${timestamp}_${tempFileCounter}_${safeFileName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user