diff --git a/application/app/AppView.tsx b/application/app/AppView.tsx index 89708229..41ba96e6 100644 --- a/application/app/AppView.tsx +++ b/application/app/AppView.tsx @@ -250,6 +250,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) { terminalFontFamilyId={terminalFontFamilyId} fontSize={terminalFontSize} hotkeyScheme={hotkeyScheme} + disableTerminalFontZoom={settings.disableTerminalFontZoom} keyBindings={keyBindings} onHotkeyAction={handleHotkeyAction} onUpdateTerminalThemeId={setTerminalThemeId} diff --git a/application/i18n/locales/en/ai.ts b/application/i18n/locales/en/ai.ts index d05eb99a..d5584dc6 100644 --- a/application/i18n/locales/en/ai.ts +++ b/application/i18n/locales/en/ai.ts @@ -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.', diff --git a/application/i18n/locales/en/core.ts b/application/i18n/locales/en/core.ts index 133d79ae..7f8b97d5 100644 --- a/application/i18n/locales/en/core.ts +++ b/application/i18n/locales/en/core.ts @@ -476,6 +476,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', diff --git a/application/i18n/locales/en/terminal.ts b/application/i18n/locales/en/terminal.ts index 0d5202a5..53ca4e03 100644 --- a/application/i18n/locales/en/terminal.ts +++ b/application/i18n/locales/en/terminal.ts @@ -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', @@ -105,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', @@ -114,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', diff --git a/application/i18n/locales/en/vault.ts b/application/i18n/locales/en/vault.ts index c7b0a599..57976778 100644 --- a/application/i18n/locales/en/vault.ts +++ b/application/i18n/locales/en/vault.ts @@ -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', diff --git a/application/i18n/locales/ru/ai.ts b/application/i18n/locales/ru/ai.ts index 07c0cf16..f6436c94 100644 --- a/application/i18n/locales/ru/ai.ts +++ b/application/i18n/locales/ru/ai.ts @@ -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 и попробуйте снова.', diff --git a/application/i18n/locales/ru/core.ts b/application/i18n/locales/ru/core.ts index 4d3118cc..e4bdbb1f 100644 --- a/application/i18n/locales/ru/core.ts +++ b/application/i18n/locales/ru/core.ts @@ -476,6 +476,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': 'Пользовательские сочетания', diff --git a/application/i18n/locales/ru/terminal.ts b/application/i18n/locales/ru/terminal.ts index 13260842..8bf2fcd1 100644 --- a/application/i18n/locales/ru/terminal.ts +++ b/application/i18n/locales/ru/terminal.ts @@ -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': 'Скрипты', @@ -126,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': 'Очистить буфер', @@ -135,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', diff --git a/application/i18n/locales/ru/vault.ts b/application/i18n/locales/ru/vault.ts index d4757466..cb11eab3 100644 --- a/application/i18n/locales/ru/vault.ts +++ b/application/i18n/locales/ru/vault.ts @@ -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': 'уже существует', diff --git a/application/i18n/locales/zh-CN/ai.ts b/application/i18n/locales/zh-CN/ai.ts index cf0ca93b..884912d4 100644 --- a/application/i18n/locales/zh-CN/ai.ts +++ b/application/i18n/locales/zh-CN/ai.ts @@ -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 开发进程,然后重试。', diff --git a/application/i18n/locales/zh-CN/terminal.ts b/application/i18n/locales/zh-CN/terminal.ts index 2d1ffd7d..9c0302fd 100644 --- a/application/i18n/locales/zh-CN/terminal.ts +++ b/application/i18n/locales/zh-CN/terminal.ts @@ -336,6 +336,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': '自定义快捷键', diff --git a/application/i18n/locales/zh-CN/vault.ts b/application/i18n/locales/zh-CN/vault.ts index 2a376c89..03772573 100644 --- a/application/i18n/locales/zh-CN/vault.ts +++ b/application/i18n/locales/zh-CN/vault.ts @@ -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': '脚本', @@ -298,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': '清空缓冲区', @@ -307,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 草稿', @@ -678,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': '已存在', diff --git a/application/state/settingsIpcSync.ts b/application/state/settingsIpcSync.ts index f5b05893..a94ba7a2 100644 --- a/application/state/settingsIpcSync.ts +++ b/application/state/settingsIpcSync.ts @@ -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>; setWorkspaceFocusStyleState: Dispatch>; setShowHostTreeSidebarState: Dispatch>; + setDisableTerminalFontZoomState: Dispatch>; setSftpTransferConcurrencyState: Dispatch>; } @@ -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, diff --git a/application/state/settingsStateDefaults.ts b/application/state/settingsStateDefaults.ts index 0dda6d87..3931fd9a 100644 --- a/application/state/settingsStateDefaults.ts +++ b/application/state/settingsStateDefaults.ts @@ -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; diff --git a/application/state/settingsStorageSync.ts b/application/state/settingsStorageSync.ts index 6cc9ced4..c1935f45 100644 --- a/application/state/settingsStorageSync.ts +++ b/application/state/settingsStorageSync.ts @@ -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>; setShowHostTreeSidebarState: Dispatch>; setShellOnlyTabNumberShortcutsState: Dispatch>; + setDisableTerminalFontZoomState: Dispatch>; setEditorWordWrapState: Dispatch>; setSessionLogsEnabled: Dispatch>; setSessionLogsDir: Dispatch>; @@ -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, diff --git a/application/state/sftp/sftpConnectStartPath.test.ts b/application/state/sftp/sftpConnectStartPath.test.ts new file mode 100644 index 00000000..f3014a79 --- /dev/null +++ b/application/state/sftp/sftpConnectStartPath.test.ts @@ -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 "); +}); diff --git a/application/state/sftp/sftpConnectStartPath.ts b/application/state/sftp/sftpConnectStartPath.ts new file mode 100644 index 00000000..cb4feae6 --- /dev/null +++ b/application/state/sftp/sftpConnectStartPath.ts @@ -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 ?? "/", + }; +} diff --git a/application/state/sftp/sharedRemoteHostCache.ts b/application/state/sftp/sharedRemoteHostCache.ts index 34ecaf3e..6c4cdbbc 100644 --- a/application/state/sftp/sharedRemoteHostCache.ts +++ b/application/state/sftp/sharedRemoteHostCache.ts @@ -1,6 +1,6 @@ import type { SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models"; -interface SharedRemoteHostCacheEntry { +export interface SharedRemoteHostCacheEntry { path: string; homeDir: string; files: SftpFileEntry[]; diff --git a/application/state/sftp/useSftpConnections.ts b/application/state/sftp/useSftpConnections.ts index 6e882cd1..0455455c 100644 --- a/application/state/sftp/useSftpConnections.ts +++ b/application/state/sftp/useSftpConnections.ts @@ -6,6 +6,7 @@ import type { 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[]; @@ -34,8 +35,16 @@ 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; + connect: (side: "left" | "right", host: Host | "local", options?: SftpConnectOptions) => Promise; disconnect: (side: "left" | "right") => Promise; listLocalFiles: (path: string) => Promise; listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise; @@ -71,7 +80,7 @@ export const useSftpConnections = ({ const { listLocalFiles, listRemoteFiles } = useSftpDirectoryListing(); 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 +110,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 +183,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 +206,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 +220,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 +231,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, @@ -264,7 +305,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], @@ -331,6 +372,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 +440,10 @@ export const useSftpConnections = ({ } } + if (initialPath) { + startPath = initialPath; + } + const provisionalCacheKey = sharedHostCache ? makeCacheKey(connectionId, startPath, filenameEncoding) : null; @@ -438,7 +487,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 +521,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, diff --git a/application/state/shellHistoryPersistence.test.ts b/application/state/shellHistoryPersistence.test.ts new file mode 100644 index 00000000..e554b156 --- /dev/null +++ b/application/state/shellHistoryPersistence.test.ts @@ -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); +}); diff --git a/application/state/shellHistoryPersistence.ts b/application/state/shellHistoryPersistence.ts new file mode 100644 index 00000000..f9d7fe91 --- /dev/null +++ b/application/state/shellHistoryPersistence.ts @@ -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(key: string): T | null; + write(key: string, value: T): boolean; +}; + +export function loadSanitizedShellHistory( + storage: ShellHistoryStorage = localStorageAdapter, + storageKey = STORAGE_KEY_SHELL_HISTORY, +): ShellHistoryEntry[] | null { + const savedShellHistory = storage.read(storageKey); + if (!savedShellHistory) return null; + + const cleanedShellHistory = sanitizeGlobalHistoryEntries(savedShellHistory); + if (cleanedShellHistory.length !== savedShellHistory.length) { + storage.write(storageKey, cleanedShellHistory); + } + return cleanedShellHistory; +} diff --git a/application/state/useAISettingsState.ts b/application/state/useAISettingsState.ts index 87aa0165..601977c2 100644 --- a/application/state/useAISettingsState.ts +++ b/application/state/useAISettingsState.ts @@ -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(() => sanitizeQuickMessages(localStorageAdapter.read(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, ]); } diff --git a/application/state/useSettingsState.ts b/application/state/useSettingsState.ts index 43d958d3..72a446c1 100644 --- a/application/state/useSettingsState.ts +++ b/application/state/useSettingsState.ts @@ -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(() => { + const stored = localStorageAdapter.readBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM); + return stored ?? DEFAULT_DISABLE_TERMINAL_FONT_ZOOM; + }); const [sftpTransferConcurrency, setSftpTransferConcurrencyState] = useState(() => { const stored = localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY); return stored != null && stored >= 1 && stored <= 16 ? stored : 4; @@ -544,6 +550,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 +643,7 @@ export const useSettingsState = () => { setSftpDefaultViewMode, setWorkspaceFocusStyleState, setShowHostTreeSidebarState, + setDisableTerminalFontZoomState, setSftpTransferConcurrencyState, }); @@ -661,7 +670,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 +679,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 +800,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 +1047,8 @@ export const useSettingsState = () => { setShowHostTreeSidebar, shellOnlyTabNumberShortcuts, setShellOnlyTabNumberShortcuts, + disableTerminalFontZoom, + setDisableTerminalFontZoom, sftpTransferConcurrency, setSftpTransferConcurrency, // Editor Settings @@ -1075,7 +1093,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, ]), }; diff --git a/application/state/useTerminalBackend.ts b/application/state/useTerminalBackend.ts index e7dff1cb..7b5c6329 100644 --- a/application/state/useTerminalBackend.ts +++ b/application/state/useTerminalBackend.ts @@ -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,12 @@ 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<{ @@ -218,6 +240,19 @@ export const useTerminalBackend = () => { return bridge.startZmodemDragDropUpload(sessionId, files, uploadCommand); }, []); + const cancelZmodem = useCallback((sessionId: string) => { + const bridge = netcattyBridge.get(); + bridge?.cancelZmodem?.(sessionId); + }, []); + + const onZmodemEvent = useCallback(( + sessionId: string, + cb: Parameters>[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' }; @@ -273,10 +308,16 @@ export const useTerminalBackend = () => { startSerialSession, listSerialPorts, serialYmodemAvailable, + serialYmodemReceiveAvailable, selectFileAvailable, + selectDirectoryAvailable, sendSerialYmodem, + receiveSerialYmodem, selectFile, + selectDirectory, startZmodemDragDropUpload, + cancelZmodem, + onZmodemEvent, execCommand, getSessionPwd, getSessionRemoteInfo, @@ -315,10 +356,16 @@ export const useTerminalBackend = () => { startSerialSession, listSerialPorts, serialYmodemAvailable, + serialYmodemReceiveAvailable, selectFileAvailable, + selectDirectoryAvailable, sendSerialYmodem, + receiveSerialYmodem, selectFile, + selectDirectory, startZmodemDragDropUpload, + cancelZmodem, + onZmodemEvent, execCommand, getSessionPwd, getSessionRemoteInfo, diff --git a/application/state/useVaultState.ts b/application/state/useVaultState.ts index fed11ae9..a8fcfcc2 100644 --- a/application/state/useVaultState.ts +++ b/application/state/useVaultState.ts @@ -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( - STORAGE_KEY_SHELL_HISTORY, - ); - if (savedShellHistory) setShellHistory(savedShellHistory); + const savedShellHistory = loadSanitizedShellHistory(); + if (savedShellHistory) { + setShellHistory(savedShellHistory); + } // Load connection logs const savedConnectionLogs = localStorageAdapter.read( @@ -729,7 +730,9 @@ export const useVaultState = () => { } if (key === STORAGE_KEY_SHELL_HISTORY) { - const next = safeParse(event.newValue) ?? []; + const next = sanitizeGlobalHistoryEntries( + safeParse(event.newValue) ?? [], + ); setShellHistory(next); return; } diff --git a/application/syncPayload.test.ts b/application/syncPayload.test.ts index 9adc7e05..874d67bd 100644 --- a/application/syncPayload.test.ts +++ b/application/syncPayload.test.ts @@ -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 () => { diff --git a/application/syncPayload.ts b/application/syncPayload.ts index 4e85808a..259f1d6e 100644 --- a/application/syncPayload.ts +++ b/application/syncPayload.ts @@ -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'; @@ -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 => @@ -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; @@ -553,6 +562,9 @@ function applySyncableSettings(settings: NonNullable): 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 +606,12 @@ function applySyncableSettings(settings: NonNullable): 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 +653,9 @@ function notifyAIStateAfterSync(ai: NonNullable['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); } diff --git a/components/SettingsPage.tsx b/components/SettingsPage.tsx index 927ca293..9dd9327a 100644 --- a/components/SettingsPage.tsx +++ b/components/SettingsPage.tsx @@ -157,6 +157,8 @@ const SettingsAITabContainer: React.FC = () => { setWebSearchConfig={aiState.setWebSearchConfig} quickMessages={aiState.quickMessages} setQuickMessages={aiState.setQuickMessages} + showTerminalSelectionAIAction={aiState.showTerminalSelectionAIAction} + setShowTerminalSelectionAIAction={aiState.setShowTerminalSelectionAIAction} /> ); @@ -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} diff --git a/components/SftpView.tsx b/components/SftpView.tsx index dd3e58c3..7627c428 100644 --- a/components/SftpView.tsx +++ b/components/SftpView.tsx @@ -374,9 +374,11 @@ const SftpViewInner: React.FC = ({ handleReorderTabsRight, handleMoveTabFromLeftToRight, handleMoveTabFromRightToLeft, + handleDuplicateTabLeft, + handleDuplicateTabRight, handleHostSelectLeft, handleHostSelectRight, - } = useSftpViewTabs({ sftp, sftpRef }); + } = useSftpViewTabs({ sftp, sftpRef, hosts: effectiveHosts }); const handleAddTabLeftWithFocus = useCallback(() => { const tabId = handleAddTabLeft(); @@ -398,6 +400,26 @@ const SftpViewInner: React.FC = ({ handlePaneFocus("right", tabId); }, [handlePaneFocus, handleSelectTabRight]); + const handleDuplicateTabLeftWithFocus = useCallback( + async (...args: Parameters) => { + const tabId = await handleDuplicateTabLeft(...args); + if (tabId) { + handlePaneFocus("left", tabId); + } + }, + [handleDuplicateTabLeft, handlePaneFocus], + ); + + const handleDuplicateTabRightWithFocus = useCallback( + async (...args: Parameters) => { + const tabId = await handleDuplicateTabRight(...args); + if (tabId) { + handlePaneFocus("right", tabId); + } + }, + [handleDuplicateTabRight, handlePaneFocus], + ); + return ( = ({ onAddTab={handleAddTabLeftWithFocus} onReorderTabs={handleReorderTabsLeft} onMoveTabToOtherSide={handleMoveTabFromRightToLeft} + onDuplicateTab={handleDuplicateTabLeftWithFocus} /> )}
@@ -504,6 +527,7 @@ const SftpViewInner: React.FC = ({ onAddTab={handleAddTabRightWithFocus} onReorderTabs={handleReorderTabsRight} onMoveTabToOtherSide={handleMoveTabFromLeftToRight} + onDuplicateTab={handleDuplicateTabRightWithFocus} /> )}
diff --git a/components/Terminal.tsx b/components/Terminal.tsx index 5137b464..c457ed78 100644 --- a/components/Terminal.tsx +++ b/components/Terminal.tsx @@ -118,6 +118,7 @@ const TerminalComponent: React.FC = ({ reuseConnectionFromSessionId, serialConfig, hotkeyScheme = "disabled", + disableTerminalFontZoom = false, keyBindings = [], onHotkeyAction, onTerminalFontSizeChange, @@ -148,6 +149,7 @@ const TerminalComponent: React.FC = ({ sessionLog, sshDebugLogEnabled, sudoAutofillPassword, + showSelectionAIAction, onAddSelectionToAI, }) => { const layoutSuppressActive = useTerminalLayoutSuppressActive(); @@ -221,9 +223,11 @@ const TerminalComponent: React.FC = ({ }, [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; @@ -248,10 +252,14 @@ const TerminalComponent: React.FC = ({ const terminalBackend = useTerminalBackend(); const { resizeSession, + receiveSerialYmodem, + selectDirectory, + selectDirectoryAvailable, selectFile, selectFileAvailable, sendSerialYmodem, serialYmodemAvailable, + serialYmodemReceiveAvailable, setSessionEncoding, } = terminalBackend; @@ -963,6 +971,43 @@ const TerminalComponent: React.FC = ({ } }, [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); @@ -1155,6 +1200,7 @@ const TerminalComponent: React.FC = ({ onSnippetClick={(snippet) => { void executeSnippet(snippet); }} onOpenSFTP={handleOpenSFTP} onSendYmodem={isSerialConnection ? handleSendYmodem : undefined} + onReceiveYmodem={isSerialConnection ? handleReceiveYmodem : undefined} onOpenScripts={onOpenScripts ?? (() => {})} onOpenHistory={onOpenHistory} onOpenTheme={onOpenTheme ?? (() => {})} @@ -1172,6 +1218,7 @@ const TerminalComponent: React.FC = ({ compactToolbar, executeSnippet, handleOpenSFTP, + handleReceiveYmodem, handleSendYmodem, handleSetTerminalEncoding, handleToggleSearch, @@ -1211,9 +1258,9 @@ const TerminalComponent: React.FC = ({ 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 ; + return ; }; const Terminal = memo(TerminalComponent, terminalPropsAreEqual); diff --git a/components/TerminalLayer.tsx b/components/TerminalLayer.tsx index 68cb67dd..dcafead3 100644 --- a/components/TerminalLayer.tsx +++ b/components/TerminalLayer.tsx @@ -99,6 +99,7 @@ const TerminalLayerInner: React.FC = ({ terminalFontFamilyId, fontSize = 14, hotkeyScheme = 'disabled', + disableTerminalFontZoom = false, keyBindings = [], onHotkeyAction, onUpdateTerminalThemeId, @@ -1118,6 +1119,7 @@ const TerminalLayerInner: React.FC = ({ hosts, hostsRef, hotkeyScheme, + disableTerminalFontZoom, identities, isBroadcastEnabled, isComposeBarOpen, diff --git a/components/TerminalPopupPage.tsx b/components/TerminalPopupPage.tsx index 594dadd7..9bbb0c59 100644 --- a/components/TerminalPopupPage.tsx +++ b/components/TerminalPopupPage.tsx @@ -317,6 +317,7 @@ function TerminalPopupPageInner() { accentMode={settings.accentMode} customAccent={settings.customAccent} terminalSettings={settings.terminalSettings} + disableTerminalFontZoom={settings.disableTerminalFontZoom} sessionId={sessionId} startupCommand={config.startupCommand} reuseConnectionFromSessionId={reuseId} diff --git a/components/settings/settings-ui.tsx b/components/settings/settings-ui.tsx index 2e101990..62daa6ce 100644 --- a/components/settings/settings-ui.tsx +++ b/components/settings/settings-ui.tsx @@ -8,13 +8,15 @@ interface ToggleProps { checked: boolean; onChange: (checked: boolean) => void; disabled?: boolean; + ariaLabel?: string; } -export const Toggle: React.FC = ({ checked, onChange, disabled }) => ( +export const Toggle: React.FC = ({ checked, onChange, disabled, ariaLabel }) => ( -
+ +
+ + + {SFTP_TAB_DUPLICATE_MENU_ITEMS.map((item) => ( + { + void onDuplicateTab?.(tab.id, item.mode); + }} + > + + {t(item.labelKey)} + + ))} + + ); })} @@ -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; } diff --git a/components/sftp/hooks/useSftpViewTabs.ts b/components/sftp/hooks/useSftpViewTabs.ts index 9665300f..58192eac 100644 --- a/components/sftp/hooks/useSftpViewTabs.ts +++ b/components/sftp/hooks/useSftpViewTabs.ts @@ -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; + 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; + handleDuplicateTabRight: (tabId: string, mode: SftpTabDuplicateMode) => Promise; 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, }; diff --git a/components/sftp/sftpTabDuplication.test.ts b/components/sftp/sftpTabDuplication.test.ts new file mode 100644 index 00000000..3aa6b358 --- /dev/null +++ b/components/sftp/sftpTabDuplication.test.ts @@ -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> = {}): 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); +}); diff --git a/components/sftp/sftpTabDuplication.ts b/components/sftp/sftpTabDuplication.ts new file mode 100644 index 00000000..d3938609 --- /dev/null +++ b/components/sftp/sftpTabDuplication.ts @@ -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 | { 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 | 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, + }; +} diff --git a/components/terminal/TerminalContextMenu.test.ts b/components/terminal/TerminalContextMenu.test.ts index 4beaed0d..9767fa7f 100644 --- a/components/terminal/TerminalContextMenu.test.ts +++ b/components/terminal/TerminalContextMenu.test.ts @@ -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, + ); +}); diff --git a/components/terminal/TerminalContextMenu.tsx b/components/terminal/TerminalContextMenu.tsx index 7a14f99c..e6bb56c8 100644 --- a/components/terminal/TerminalContextMenu.tsx +++ b/components/terminal/TerminalContextMenu.tsx @@ -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 = ({ children, hasSelection = false, @@ -78,6 +119,7 @@ export const TerminalContextMenu: React.FC = ({ onSplitHorizontal, onSplitVertical, onSendYmodem, + onReceiveYmodem, isReconnectable, onReconnect, onClose, @@ -90,11 +132,13 @@ export const TerminalContextMenu: React.FC = ({ // 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(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 = ({ // 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('.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 = ({ > {children} - {!shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, showReconnectAction }) && ( + {shouldRenderTerminalContextMenuContent({ + isAlternateScreen, + showReconnectAction, + allowSuppressedMenuContent, + }) && ( @@ -174,7 +231,7 @@ export const TerminalContextMenu: React.FC = ({ {t('terminal.menu.paste')} {pasteShortcut} - {onAddSelectionToAI && ( + {shouldShowAddSelectionToAIContextMenuAction(onAddSelectionToAI) && ( {t('terminal.menu.addSelectionToAI')} @@ -203,13 +260,21 @@ export const TerminalContextMenu: React.FC = ({ )} - {onSendYmodem && ( + {(onSendYmodem || onReceiveYmodem) && ( <> - - - {t('terminal.menu.sendYmodem')} - + {onSendYmodem && ( + + + {t('terminal.menu.sendYmodem')} + + )} + {onReceiveYmodem && ( + + + {t('terminal.menu.receiveYmodem')} + + )} )} diff --git a/components/terminal/TerminalToolbar.test.ts b/components/terminal/TerminalToolbar.test.ts index 0238e397..0ca27068 100644 --- a/components/terminal/TerminalToolbar.test.ts +++ b/components/terminal/TerminalToolbar.test.ts @@ -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", () => { diff --git a/components/terminal/TerminalToolbar.tsx b/components/terminal/TerminalToolbar.tsx index d7035c69..897c9e74 100644 --- a/components/terminal/TerminalToolbar.tsx +++ b/components/terminal/TerminalToolbar.tsx @@ -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 = ({ onSnippetClick, onOpenSFTP, onSendYmodem, + onReceiveYmodem, onOpenScripts, onOpenHistory, onOpenTheme, @@ -86,6 +88,8 @@ export const TerminalToolbar: React.FC = ({ 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 = ({ )} {isSerialTerminal && ( - - - - - - {status === 'connected' ? t("terminal.toolbar.sendYmodem") : t("terminal.toolbar.availableAfterConnect")} - - + <> + + + + + + {status === 'connected' ? t("terminal.toolbar.sendYmodem") : t("terminal.toolbar.availableAfterConnect")} + + + + + + + + + {status === 'connected' ? t("terminal.toolbar.receiveYmodem") : t("terminal.toolbar.availableAfterConnect")} + + + )} diff --git a/components/terminal/TerminalView.test.tsx b/components/terminal/TerminalView.test.tsx index 1e851cdd..58816532 100644 --- a/components/terminal/TerminalView.test.tsx +++ b/components/terminal/TerminalView.test.tsx @@ -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"); diff --git a/components/terminal/TerminalView.tsx b/components/terminal/TerminalView.tsx index 3657ac4e..bba46170 100644 --- a/components/terminal/TerminalView.tsx +++ b/components/terminal/TerminalView.tsx @@ -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, 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, 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} @@ -328,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 && (
string; terminalBackend: { writeToSession: (sessionId: string, data: string, options?: { automated?: boolean }) => void; + cancelZmodem?: (sessionId: string) => 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[], @@ -38,12 +48,69 @@ interface UseTerminalDragDropOptions { termRef: React.MutableRefObject; } +const RZ_MISSING_FALLBACK_TIMEOUT_MS = 2500; + export async function resolveTerminalDropUploadInitialPath( resolveSftpInitialPath: UseTerminalDragDropOptions["resolveSftpInitialPath"], ): Promise { return resolveSftpInitialPath({ preferFreshBackend: true }); } +function createRzMissingWatcher({ + sessionId, + terminalBackend, + token, +}: { + sessionId: string; + terminalBackend: Pick; + token: string; +}): { promise: Promise; stop: () => void } { + let settled = false; + let timeout: ReturnType | undefined; + let buffer = ""; + let unsubscribeData: (() => void) | undefined; + let unsubscribeZmodem: (() => void) | undefined; + let settle: (rzMissing: boolean) => void = () => {}; + + const cleanup = () => { + if (timeout) clearTimeout(timeout); + timeout = undefined; + unsubscribeData?.(); + unsubscribeData = undefined; + unsubscribeZmodem?.(); + unsubscribeZmodem = undefined; + }; + + const promise = new Promise((resolve) => { + settle = (rzMissing) => { + if (settled) return; + settled = true; + cleanup(); + resolve(rzMissing); + }; + + unsubscribeData = terminalBackend.onSessionData?.(sessionId, (chunk) => { + buffer = `${buffer}${chunk}`.slice(-512); + if (containsZmodemRzMissingMarker(buffer, token)) { + settle(true); + } + }); + + unsubscribeZmodem = terminalBackend.onZmodemEvent?.(sessionId, (event) => { + if (event.type === "detect" && event.transferType === "upload") { + settle(false); + } + }); + + timeout = setTimeout(() => settle(false), RZ_MISSING_FALLBACK_TIMEOUT_MS); + }); + + return { + promise, + stop: () => settle(false), + }; +} + export async function handleTerminalDropEntries({ dropEntries, host, @@ -97,10 +164,39 @@ export async function handleTerminalDropEntries({ throw new Error("ZMODEM drag-drop upload is unavailable"); } - const result = await terminalBackend.startZmodemDragDropUpload(sessionId, files); + const shouldFallbackToSftpWhenRzMissing = Boolean( + onOpenSftp + && supportsZmodemDragDropSftpFallback(host) + && terminalBackend.onSessionData + && terminalBackend.cancelZmodem, + ); + const rzMissingToken = shouldFallbackToSftpWhenRzMissing + ? createZmodemRzMissingToken() + : undefined; + const rzMissingWatcher = rzMissingToken + ? createRzMissingWatcher({ sessionId, terminalBackend, token: rzMissingToken }) + : 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"); } + + if (rzMissingWatcher && await rzMissingWatcher.promise) { + terminalBackend.cancelZmodem?.(sessionId); + const initialPath = await resolveTerminalDropUploadInitialPath(resolveSftpInitialPath); + onOpenSftp?.(host, initialPath, dropEntries, sessionId); + } } else if (onOpenSftp) { const initialPath = await resolveTerminalDropUploadInitialPath(resolveSftpInitialPath); onOpenSftp(host, initialPath, dropEntries, sessionId); diff --git a/components/terminal/runtime/createXTermRuntime.ts b/components/terminal/runtime/createXTermRuntime.ts index 7cf6e3e8..aa970f85 100644 --- a/components/terminal/runtime/createXTermRuntime.ts +++ b/components/terminal/runtime/createXTermRuntime.ts @@ -50,8 +50,10 @@ import { watchDevicePixelRatio } from "./rendererDprWatch"; import { shouldDeferWebglUntilVisible } from "./webglRendererPolicy"; import { handleSerialLineModeInput } from "./serialLineInput"; import { + isTerminalFontSizeAction, nextTerminalFontSizeForAction, nextTerminalFontSizeForWheel, + shouldHandleTerminalFontSizeAction, terminalFontSizeWheelListenerOptions, } from "./terminalFontZoom"; import { @@ -122,6 +124,7 @@ export type CreateXTermRuntimeContext = { sessionRef: RefObject; hotkeySchemeRef: RefObject<"disabled" | "mac" | "pc">; + disableTerminalFontZoomRef: RefObject; keyBindingsRef: RefObject; onHotkeyActionRef: RefObject< ((action: string, event: KeyboardEvent) => void) | undefined @@ -562,6 +565,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime event, currentTerminalFontSize(), isMac, + ctx.disableTerminalFontZoomRef.current, ); if (nextFontSize === null) return; event.preventDefault(); @@ -684,6 +688,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 +741,11 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime case "decreaseTerminalFontSize": case "resetTerminalFontSize": { applyTerminalFontSize( - nextTerminalFontSizeForAction(action, currentTerminalFontSize()), + nextTerminalFontSizeForAction( + action, + currentTerminalFontSize(), + ctx.disableTerminalFontZoomRef.current, + ), ); break; } diff --git a/components/terminal/runtime/terminalFontZoom.test.ts b/components/terminal/runtime/terminalFontZoom.test.ts index ac95b7c3..783fc0ed 100644 --- a/components/terminal/runtime/terminalFontZoom.test.ts +++ b/components/terminal/runtime/terminalFontZoom.test.ts @@ -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); diff --git a/components/terminal/runtime/terminalFontZoom.ts b/components/terminal/runtime/terminalFontZoom.ts index bc1e8d87..79ca28b7 100644 --- a/components/terminal/runtime/terminalFontZoom.ts +++ b/components/terminal/runtime/terminalFontZoom.ts @@ -6,6 +6,12 @@ import { type WheelLike = Pick; +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; diff --git a/components/terminal/terminalDragDropCwd.test.ts b/components/terminal/terminalDragDropCwd.test.ts index 897d5a45..6a918e8b 100644 --- a/components/terminal/terminalDragDropCwd.test.ts +++ b/components/terminal/terminalDragDropCwd.test.ts @@ -64,6 +64,88 @@ test("remote SSH terminal drop triggers ZMODEM drag-drop upload", async () => { 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("network device drop falls back to SFTP upload with a freshly resolved cwd", async () => { let receivedOptions: { preferFreshBackend?: boolean } | undefined; let openedPath: string | undefined; @@ -99,6 +181,70 @@ test("network device drop falls back to SFTP upload with a freshly resolved cwd" 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 cancelledSessionId: 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, + 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) => { + cancelledSessionId = sessionId; + }, + 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.equal(cancelledSessionId, "session-1"); +}); + 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", diff --git a/components/terminal/terminalHelpers.ts b/components/terminal/terminalHelpers.ts index c7b555a4..5cf5953f 100644 --- a/components/terminal/terminalHelpers.ts +++ b/components/terminal/terminalHelpers.ts @@ -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; } diff --git a/components/terminal/terminalMemo.test.ts b/components/terminal/terminalMemo.test.ts new file mode 100644 index 00000000..a9cc7f8e --- /dev/null +++ b/components/terminal/terminalMemo.test.ts @@ -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, + ); +}); diff --git a/components/terminal/terminalMemo.ts b/components/terminal/terminalMemo.ts index 820d1ed9..efc26c5b 100644 --- a/components/terminal/terminalMemo.ts +++ b/components/terminal/terminalMemo.ts @@ -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 diff --git a/components/terminal/useTerminalEffects.ts b/components/terminal/useTerminalEffects.ts index 3db9737b..2f1fcef2 100644 --- a/components/terminal/useTerminalEffects.ts +++ b/components/terminal/useTerminalEffects.ts @@ -48,7 +48,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 +239,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) { terminalBackend, sessionRef, hotkeySchemeRef, + disableTerminalFontZoomRef, keyBindingsRef, onHotkeyActionRef, onTerminalFontSizeChange, diff --git a/components/terminalLayer/TerminalLayerSupport.tsx b/components/terminalLayer/TerminalLayerSupport.tsx index cfdf24cd..7e215d15 100644 --- a/components/terminalLayer/TerminalLayerSupport.tsx +++ b/components/terminalLayer/TerminalLayerSupport.tsx @@ -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 = memo(({ @@ -705,6 +712,7 @@ const TerminalPane: React.FC = memo(({ customAccent, terminalSettings, hotkeyScheme, + disableTerminalFontZoom, keyBindings, isResizing, isComposeBarOpen, @@ -734,6 +742,7 @@ const TerminalPane: React.FC = memo(({ onToggleWorkspaceComposeBar, onSnippetExecutorChange, onAddSelectionToAI, + showSelectionAIAction, }) => { const layoutSuppressActive = useTerminalLayoutSuppressActive(); const deferPaneLayoutUpdate = isResizing || layoutSuppressActive; @@ -891,6 +900,7 @@ const TerminalPane: React.FC = 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 = memo(({ sessionLog={sessionLog} sshDebugLogEnabled={sshDebugLogEnabled} sudoAutofillPassword={sudoAutofillPassword} + showSelectionAIAction={showSelectionAIAction} onAddSelectionToAI={onAddSelectionToAI} />
@@ -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 = memo(({ sessionChainHostsMap, sessionSudoAutofillPasswordsMap, ...sharedProps -}) => ( - <> - {sessions.map((session) => { - const host = sessionHostsMap.get(session.id); - if (!host) return null; - return ( - - ); - })} - -), 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 ( + + ); + })} + + ); +}, terminalPanesHostPropsAreEqual); TerminalPanesHost.displayName = 'TerminalPanesHost'; diff --git a/components/terminalLayer/TerminalLayerTabBridge.tsx b/components/terminalLayer/TerminalLayerTabBridge.tsx index b3b84699..7e51bc3d 100644 --- a/components/terminalLayer/TerminalLayerTabBridge.tsx +++ b/components/terminalLayer/TerminalLayerTabBridge.tsx @@ -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, diff --git a/components/terminalLayer/TerminalLayerWorkspaceSection.tsx b/components/terminalLayer/TerminalLayerWorkspaceSection.tsx index e47a8aaa..3af72124 100644 --- a/components/terminalLayer/TerminalLayerWorkspaceSection.tsx +++ b/components/terminalLayer/TerminalLayerWorkspaceSection.tsx @@ -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} diff --git a/components/terminalLayer/terminalLayerStableSnapshot.ts b/components/terminalLayer/terminalLayerStableSnapshot.ts index d10ac6cb..86478298 100644 --- a/components/terminalLayer/terminalLayerStableSnapshot.ts +++ b/components/terminalLayer/terminalLayerStableSnapshot.ts @@ -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']; diff --git a/components/terminalLayer/terminalLayerViewMemo.ts b/components/terminalLayer/terminalLayerViewMemo.ts index ef89a37e..3fd0ba8f 100644 --- a/components/terminalLayer/terminalLayerViewMemo.ts +++ b/components/terminalLayer/terminalLayerViewMemo.ts @@ -241,6 +241,7 @@ const WORKSPACE_CTX_KEYS = [ 'customAccent', 'terminalSettings', 'hotkeyScheme', + 'disableTerminalFontZoom', 'keyBindings', 'resizing', 'isComposeBarOpen', diff --git a/components/terminalLayerMemo.ts b/components/terminalLayerMemo.ts index 99adfda2..4bd64a62 100644 --- a/components/terminalLayerMemo.ts +++ b/components/terminalLayerMemo.ts @@ -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 && diff --git a/domain/globalHistory.test.ts b/domain/globalHistory.test.ts index 9c54302f..77f85d0e 100644 --- a/domain/globalHistory.test.ts +++ b/domain/globalHistory.test.ts @@ -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, { diff --git a/domain/globalHistory.ts b/domain/globalHistory.ts index c9562786..e083ce50 100644 --- a/domain/globalHistory.ts +++ b/domain/globalHistory.ts @@ -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, diff --git a/domain/remoteHistory.test.ts b/domain/remoteHistory.test.ts index 88f662b9..8e66a3b5 100644 --- a/domain/remoteHistory.test.ts +++ b/domain/remoteHistory.test.ts @@ -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'], + ); +}); diff --git a/domain/remoteHistory.ts b/domain/remoteHistory.ts index 01836675..d3129459 100644 --- a/domain/remoteHistory.ts +++ b/domain/remoteHistory.ts @@ -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: `, // optionally followed by ` when: ` 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); diff --git a/domain/sync.ts b/domain/sync.ts index a02bd6de..c7a79064 100644 --- a/domain/sync.ts +++ b/domain/sync.ts @@ -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; webSearchConfig?: Record | null; quickMessages?: Array>; + showTerminalSelectionAction?: boolean; }; }; diff --git a/electron-builder.config.cjs b/electron-builder.config.cjs index 2ffca59d..0d286371 100644 --- a/electron-builder.config.cjs +++ b/electron-builder.config.cjs @@ -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,43 @@ 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/**/*', + // 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 +98,12 @@ 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/**/*' ], asarUnpack: [ 'node_modules/node-pty/**/*', diff --git a/electron/bridges/registerBridgesFigSpec.test.cjs b/electron/bridges/registerBridgesFigSpec.test.cjs new file mode 100644 index 00000000..879ade88 --- /dev/null +++ b/electron/bridges/registerBridgesFigSpec.test.cjs @@ -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"], + ); +}); diff --git a/electron/bridges/systemManagerBridge.cjs b/electron/bridges/systemManagerBridge.cjs index 8c2fea2a..43b96740 100644 --- a/electron/bridges/systemManagerBridge.cjs +++ b/electron/bridges/systemManagerBridge.cjs @@ -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(""); diff --git a/electron/bridges/systemManagerBridge.processes.test.cjs b/electron/bridges/systemManagerBridge.processes.test.cjs new file mode 100644 index 00000000..09c0e357 --- /dev/null +++ b/electron/bridges/systemManagerBridge.processes.test.cjs @@ -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"); +}); diff --git a/electron/bridges/terminalBridge.cjs b/electron/bridges/terminalBridge.cjs index 96ab065f..3aae248b 100644 --- a/electron/bridges/terminalBridge.cjs +++ b/electron/bridges/terminalBridge.cjs @@ -29,7 +29,7 @@ const { createPtyOutputBuffer } = require("./ptyOutputBuffer.cjs"); const { enableTcpNoDelay } = require("./tcpNoDelay.cjs"); const { releaseConnectionRef } = require("./sshConnectionPool.cjs"); const { normalizeTerminalEncoding, encodeTerminalInput } = require("./terminalEncoding.cjs"); -const { sendYmodemCancel, sendYmodemFile } = require("./ymodemTransfer.cjs"); +const { receiveYmodemFiles, sendYmodemCancel, sendYmodemFile } = require("./ymodemTransfer.cjs"); const execFileAsync = promisify(execFile); @@ -784,6 +784,47 @@ async function sendSerialYmodem(_event, payload) { } } +async function receiveSerialYmodem(_event, payload) { + const session = sessions.get(payload?.sessionId); + if (!session || !session.serialPort || session.type !== 'serial') { + return { success: false, error: "YMODEM receive requires an active serial session" }; + } + if (session.ymodemActive) { + return { success: false, error: "A YMODEM transfer is already in progress" }; + } + if (session.zmodemSentry?.isActive()) { + return { success: false, error: "Another serial file transfer is already in progress" }; + } + if (!payload?.destinationDir || typeof payload.destinationDir !== "string") { + return { success: false, error: "No destination directory selected" }; + } + + const abortController = new AbortController(); + session.ymodemActive = true; + session.ymodemAbortController = abortController; + + try { + const result = await receiveYmodemFiles(session.serialPort, { + destinationDir: payload.destinationDir, + abortSignal: abortController.signal, + timeoutMs: Number.isFinite(payload.timeoutMs) ? payload.timeoutMs : undefined, + }); + return { success: true, ...result }; + } catch (error) { + if (error?.code !== "YMODEM_CANCELLED" && error?.code !== "YMODEM_REMOTE_CANCELLED") { + await sendYmodemCancel(session.serialPort); + } + return { + success: false, + error: error instanceof Error ? error.message : String(error), + code: error?.code, + }; + } finally { + session.ymodemActive = false; + session.ymodemAbortController = null; + } +} + /** * Pause or resume a session's source stream for output back-pressure. * The renderer asks for this when its write backlog crosses a watermark, so a @@ -950,6 +991,7 @@ function registerHandlers(ipcMain) { ipcMain.handle("netcatty:serial:start", startSerialSession); ipcMain.handle("netcatty:serial:list", listSerialPorts); ipcMain.handle("netcatty:serial:ymodem-send", sendSerialYmodem); + ipcMain.handle("netcatty:serial:ymodem-receive", receiveSerialYmodem); ipcMain.handle("netcatty:local:defaultShell", getDefaultShell); ipcMain.handle("netcatty:local:validatePath", validatePath); ipcMain.handle("netcatty:shells:discover", () => discoverShells()); @@ -1116,6 +1158,7 @@ module.exports = { bundledEtClient, startSerialSession, sendSerialYmodem, + receiveSerialYmodem, listSerialPorts, writeToSession, setSessionEncoding, diff --git a/electron/bridges/terminalBridge.ymodem.test.cjs b/electron/bridges/terminalBridge.ymodem.test.cjs index e0419a71..be558528 100644 --- a/electron/bridges/terminalBridge.ymodem.test.cjs +++ b/electron/bridges/terminalBridge.ymodem.test.cjs @@ -1,5 +1,8 @@ const test = require("node:test"); const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); const terminalBridge = require("./terminalBridge.cjs"); const { YMODEM } = require("./ymodemTransfer.cjs"); @@ -75,3 +78,67 @@ test("YMODEM send is refused while ZMODEM owns the same serial session", async ( assert.equal(result.success, false); assert.match(result.error, /file transfer is already in progress/); }); + +test("YMODEM receive is refused while ZMODEM owns the same serial session", async () => { + const sessions = new Map(); + sessions.set("serial-1", { + type: "serial", + protocol: "serial", + serialPort: makeSerialPort(), + zmodemSentry: { + isActive() { + return true; + }, + }, + }); + terminalBridge.init({ sessions, electronModule: {} }); + + const result = await terminalBridge.receiveSerialYmodem({ sender: {} }, { + sessionId: "serial-1", + destinationDir: "/tmp", + }); + + assert.equal(result.success, false); + assert.match(result.error, /file transfer is already in progress/); +}); + +test("YMODEM receive sends cancel bytes when the receive setup fails", async () => { + const targetDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-ymodem-bridge-")); + try { + const destinationFile = path.join(targetDir, "not-a-directory"); + fs.writeFileSync(destinationFile, "not a directory"); + + const sessions = new Map(); + const serialPort = makeSerialPort(); + sessions.set("serial-1", { + type: "serial", + protocol: "serial", + serialPort, + }); + terminalBridge.init({ sessions, electronModule: {} }); + + const result = await terminalBridge.receiveSerialYmodem({ sender: {} }, { + sessionId: "serial-1", + destinationDir: destinationFile, + }); + + assert.equal(result.success, false); + assert.deepEqual( + [...serialPort.writes[0]], + [ + YMODEM.CAN, + YMODEM.CAN, + YMODEM.CAN, + YMODEM.CAN, + YMODEM.CAN, + YMODEM.BACKSPACE, + YMODEM.BACKSPACE, + YMODEM.BACKSPACE, + YMODEM.BACKSPACE, + YMODEM.BACKSPACE, + ], + ); + } finally { + fs.rmSync(targetDir, { recursive: true, force: true }); + } +}); diff --git a/electron/bridges/windowManager.cjs b/electron/bridges/windowManager.cjs index 0f99bd3a..b4cc03a2 100644 --- a/electron/bridges/windowManager.cjs +++ b/electron/bridges/windowManager.cjs @@ -1132,7 +1132,6 @@ function buildAppMenu(Menu, app, isMac, language = currentLanguage) { label: tMenu(language, "view"), submenu: [ { label: tMenu(language, "reload"), click: (_, win) => { if (win) win.reload(); } }, - { role: "forceReload" }, { role: "toggleDevTools" }, { type: "separator" }, { role: "resetZoom" }, diff --git a/electron/bridges/windowManagerReadiness.test.cjs b/electron/bridges/windowManagerReadiness.test.cjs index 8b1d8507..955269a0 100644 --- a/electron/bridges/windowManagerReadiness.test.cjs +++ b/electron/bridges/windowManagerReadiness.test.cjs @@ -297,6 +297,39 @@ test("buildAppMenu sends Cmd+W to any registered main window renderer", () => { } }); +test("buildAppMenu keeps app reload click-only so custom reload-like shortcuts reach the renderer", () => { + let capturedTemplate = null; + const Menu = { + buildFromTemplate(template) { + capturedTemplate = template; + return { template }; + }, + }; + + buildAppMenu(Menu, { name: "Netcatty" }, false); + + const viewMenu = capturedTemplate.find((item) => item.label === "View"); + assert.ok(viewMenu); + assert.equal(viewMenu.submenu.some((item) => item.role === "reload"), false); + assert.equal(viewMenu.submenu.some((item) => item.role === "forceReload"), false); + assert.equal(viewMenu.submenu.some((item) => item.accelerator === "CommandOrControl+R"), false); + assert.equal(viewMenu.submenu.some((item) => item.accelerator === "CommandOrControl+Shift+R"), false); + + const reloadItem = viewMenu.submenu.find((item) => item.label === "Reload"); + assert.ok(reloadItem); + assert.equal(reloadItem.role, undefined); + assert.equal(reloadItem.accelerator, undefined); + + const calls = []; + reloadItem.click(null, { + reload() { + calls.push("reload"); + }, + }); + + assert.deepEqual(calls, ["reload"]); +}); + test("requestWindowCommandClose sends command-close to renderer-capable windows", () => { const sentChannels = []; const win = { @@ -434,6 +467,121 @@ test("main window asks renderer to close tabs from macOS Command+W before-input- assert.equal(commandCloseRequests.length, 1); }); +test("main window leaves primary-modifier reload-like shortcuts available to renderer handlers", async () => { + let beforeInputHandler = null; + const ignoreMenuShortcutValues = []; + + class BrowserWindowStub { + constructor() { + this.webContents = { + id: 1, + on(channel, handler) { + if (channel === "before-input-event") beforeInputHandler = handler; + }, + once() {}, + isDestroyed() { + return false; + }, + isCrashed() { + return false; + }, + setIgnoreMenuShortcuts(value) { + ignoreMenuShortcutValues.push(value); + }, + setWindowOpenHandler() {}, + openDevTools() {}, + }; + } + + on() {} + once() {} + isDestroyed() { return false; } + isMaximized() { return false; } + isFullScreen() { return false; } + getBounds() { return { x: 0, y: 0, width: 1400, height: 900 }; } + setBackgroundColor() {} + setOpacity() {} + async loadURL() {} + close() {} + } + + const api = createMainWindowApi({ + mainWindow: null, + electronApp: null, + currentTheme: "light", + isQuitting: false, + pendingWindowStateWrite: null, + queuedWindowState: null, + windowStateCloseRequested: false, + DEFAULT_WINDOW_WIDTH: 1400, + DEFAULT_WINDOW_HEIGHT: 900, + MIN_WINDOW_WIDTH: 1100, + MIN_WINDOW_HEIGHT: 640, + V8_CACHE_OPTIONS: "bypassHeatCheck", + THEME_COLORS: { light: { background: "#fff" } }, + unhealthyWebContentsIds: new Set(), + rendererReadySeenByWebContentsId: new Set(), + __dirname, + URL, + require, + console, + setTimeout, + clearTimeout, + getGlobalShortcutBridge() { + return { handleWindowClose: () => false }; + }, + debugLog() {}, + resolveFrontendBackgroundColor() { return null; }, + loadWindowState() { return null; }, + getDevRendererBaseUrl(url) { return url; }, + getWindowBoundsState() { return null; }, + queueWindowStateSave() {}, + saveWindowStateSync() {}, + setupDeferredShow() {}, + createExternalOnlyWindowOpenHandler() { return {}; }, + createAppWindowOpenHandler() { return {}; }, + attachOAuthLoadingOverlay() {}, + registerWindowHandlers() {}, + requestWindowCommandClose() { + return true; + }, + shouldCloseWindowFromInput, + applyWindowOpacityToWindow() {}, + closeSettingsWindow() {}, + hideSettingsWindow() {}, + }); + + await api.createWindow( + { + BrowserWindow: BrowserWindowStub, + nativeTheme: {}, + app: {}, + screen: {}, + shell: {}, + ipcMain: {}, + }, + { + preload: "/tmp/preload.cjs", + devServerUrl: "http://localhost:5173", + isDev: true, + appIcon: null, + isMac: false, + electronDir: __dirname, + }, + ); + + let prevented = false; + beforeInputHandler({ preventDefault: () => { prevented = true; } }, { + type: "keyDown", + control: true, + shift: true, + key: "R", + }); + + assert.equal(prevented, false); + assert.deepEqual(ignoreMenuShortcutValues, [false]); +}); + test("createWindow registers each main window as an independent app window", async () => { const registered = []; const unregistered = []; diff --git a/electron/bridges/ymodemTransfer.cjs b/electron/bridges/ymodemTransfer.cjs index 3793de7e..a89cc13a 100644 --- a/electron/bridges/ymodemTransfer.cjs +++ b/electron/bridges/ymodemTransfer.cjs @@ -93,6 +93,52 @@ function createYmodemEndSessionPacket() { return createPacket(YMODEM.SOH, 0, Buffer.alloc(YMODEM.PACKET_SIZE_128, 0x00)); } +function sanitizeYmodemFilename(filename) { + const normalized = String(filename || "").replace(/\\/g, "/"); + const baseName = path.basename(normalized).replace(/[<>:"/\\|?*\x00-\x1f]/g, "_").trim(); + if (!baseName || baseName === "." || baseName === "..") { + return "ymodem-received.bin"; + } + return baseName; +} + +function parseYmodemFileInfoPayload(payload) { + const separatorIndex = payload.indexOf(0x00); + const rawName = (separatorIndex >= 0 ? payload.subarray(0, separatorIndex) : payload).toString("utf8"); + if (!rawName) return null; + + const metadata = separatorIndex >= 0 + ? payload.subarray(separatorIndex + 1).toString("ascii").replace(/\0.*$/u, "").trim() + : ""; + const [sizeText] = metadata.split(/\s+/u); + if (!/^\d+$/u.test(sizeText || "")) { + throw new YmodemTransferError("YMODEM file header has an invalid size", "YMODEM_INVALID_SIZE"); + } + const parsedSize = Number.parseInt(sizeText, 10); + + return { + fileName: sanitizeYmodemFilename(rawName), + totalBytes: Number.isFinite(parsedSize) && parsedSize >= 0 ? parsedSize : 0, + }; +} + +async function resolveUniqueDestinationPath(destinationDir, fileName) { + const parsed = path.parse(fileName); + for (let attempt = 0; attempt < 10_000; attempt += 1) { + const candidateName = attempt === 0 + ? fileName + : `${parsed.name} (${attempt})${parsed.ext}`; + const candidatePath = path.join(destinationDir, candidateName); + try { + await fs.promises.access(candidatePath); + } catch (error) { + if (error?.code === "ENOENT") return candidatePath; + throw error; + } + } + throw new YmodemTransferError("Could not choose a destination file name", "YMODEM_DESTINATION_EXISTS"); +} + function writeAndDrain(serialPort, buffer) { return new Promise((resolve, reject) => { let settled = false; @@ -229,6 +275,268 @@ async function sendPacketWithRetry({ serialPort, reader, packet, timeoutMs, retr throw new YmodemTransferError("YMODEM receiver rejected the packet too many times", "YMODEM_RETRY_LIMIT"); } +async function readYmodemFrame(reader, timeoutMs) { + const header = await reader.readByte(timeoutMs); + if (header === YMODEM.CAN) { + const next = await reader.readByte(1_000).catch(() => null); + if (next === YMODEM.CAN) { + throw new YmodemTransferError("YMODEM transfer cancelled by sender", "YMODEM_REMOTE_CANCELLED"); + } + if (next !== null) { + reader.unreadByte?.(next); + } + return readYmodemFrame(reader, timeoutMs); + } + if (header === YMODEM.EOT) { + return { type: "eot" }; + } + if (header !== YMODEM.SOH && header !== YMODEM.STX) { + throw new YmodemTransferError(`Unexpected YMODEM packet header: 0x${header.toString(16)}`); + } + + const payloadSize = header === YMODEM.SOH ? YMODEM.PACKET_SIZE_128 : YMODEM.PACKET_SIZE_1024; + const blockNumber = await reader.readByte(timeoutMs); + const blockComplement = await reader.readByte(timeoutMs); + const payload = Buffer.alloc(payloadSize); + for (let i = 0; i < payloadSize; i += 1) { + payload[i] = await reader.readByte(timeoutMs); + } + const sentCrc = ((await reader.readByte(timeoutMs)) << 8) | await reader.readByte(timeoutMs); + const expectedCrc = crc16Xmodem(payload); + const valid = blockComplement === (0xff - blockNumber) && sentCrc === expectedCrc; + + return { + type: "packet", + blockNumber, + payload, + valid, + }; +} + +async function readYmodemPacketWithRetry({ + serialPort, + reader, + expectedBlockNumber, + requestByte, + timeoutMs, + retryLimit, + label, +}) { + for (let attempt = 0; attempt < retryLimit; attempt += 1) { + if (requestByte !== undefined) { + await writeAndDrain(serialPort, Buffer.from([requestByte])); + } + + let frame; + try { + frame = await readYmodemFrame(reader, timeoutMs); + } catch (error) { + if (error?.code === "YMODEM_TIMEOUT" && attempt < retryLimit - 1) { + continue; + } + throw error; + } + + if (frame.type === "eot") { + return frame; + } + if (frame.valid && frame.blockNumber === expectedBlockNumber) { + return frame; + } + if (frame.valid && frame.blockNumber === ((expectedBlockNumber - 1) & 0xff)) { + await writeAndDrain(serialPort, Buffer.from([YMODEM.ACK])); + continue; + } + await writeAndDrain(serialPort, Buffer.from([YMODEM.NAK])); + } + + throw new YmodemTransferError(`YMODEM sender did not provide a valid ${label}`, "YMODEM_RETRY_LIMIT"); +} + +async function receiveYmodemFileData({ + serialPort, + reader, + fileHandle, + totalBytes, + timeoutMs, + retryLimit, + onProgress, +}) { + let expectedBlockNumber = 1; + let writtenBytes = 0; + let rejectedPackets = 0; + + for (;;) { + let frame; + try { + frame = await readYmodemFrame(reader, timeoutMs); + } catch (error) { + if (error?.code === "YMODEM_TIMEOUT" && rejectedPackets < retryLimit) { + rejectedPackets += 1; + await writeAndDrain(serialPort, Buffer.from([YMODEM.NAK])); + continue; + } + throw error; + } + + if (frame.type === "eot") { + await writeAndDrain(serialPort, Buffer.from([YMODEM.NAK])); + const secondEot = await readYmodemFrame(reader, timeoutMs); + if (secondEot.type !== "eot") { + throw new YmodemTransferError("YMODEM sender did not confirm end of file", "YMODEM_EOT_EXPECTED"); + } + await writeAndDrain(serialPort, Buffer.from([YMODEM.ACK])); + if (writtenBytes !== totalBytes) { + throw new YmodemTransferError( + `YMODEM received incomplete file (${writtenBytes}/${totalBytes} bytes)`, + "YMODEM_INCOMPLETE_FILE", + ); + } + return writtenBytes; + } + + if (!frame.valid) { + rejectedPackets += 1; + if (rejectedPackets > retryLimit) { + throw new YmodemTransferError("YMODEM sender sent too many invalid packets", "YMODEM_RETRY_LIMIT"); + } + await writeAndDrain(serialPort, Buffer.from([YMODEM.NAK])); + continue; + } + + if (frame.blockNumber === ((expectedBlockNumber - 1) & 0xff)) { + await writeAndDrain(serialPort, Buffer.from([YMODEM.ACK])); + continue; + } + if (frame.blockNumber !== expectedBlockNumber) { + rejectedPackets += 1; + if (rejectedPackets > retryLimit) { + throw new YmodemTransferError("YMODEM sender sent packets out of order", "YMODEM_RETRY_LIMIT"); + } + await writeAndDrain(serialPort, Buffer.from([YMODEM.NAK])); + continue; + } + + const remainingBytes = Math.max(0, totalBytes - writtenBytes); + const bytesToWrite = Math.min(frame.payload.length, remainingBytes); + if (bytesToWrite > 0) { + await fileHandle.write(frame.payload, 0, bytesToWrite); + writtenBytes += bytesToWrite; + } + + rejectedPackets = 0; + expectedBlockNumber = (expectedBlockNumber + 1) & 0xff; + await writeAndDrain(serialPort, Buffer.from([YMODEM.ACK])); + onProgress?.({ transferredBytes: writtenBytes, totalBytes, stage: "data" }); + } +} + +async function receiveYmodemFiles(serialPort, { + destinationDir, + timeoutMs = DEFAULT_TIMEOUT_MS, + retryLimit = DEFAULT_RETRY_LIMIT, + abortSignal, + onProgress, +} = {}) { + if (!serialPort) { + throw new YmodemTransferError("Serial session is not available", "YMODEM_NO_SERIAL"); + } + if (!destinationDir || typeof destinationDir !== "string") { + throw new YmodemTransferError("No destination directory selected", "YMODEM_NO_DESTINATION"); + } + + const resolvedDestinationDir = path.resolve(destinationDir); + let destinationStat; + try { + destinationStat = await fs.promises.stat(resolvedDestinationDir); + } catch (error) { + throw new YmodemTransferError("Selected destination is not a directory", "YMODEM_DESTINATION_NOT_DIRECTORY"); + } + if (!destinationStat.isDirectory()) { + throw new YmodemTransferError("Selected destination is not a directory", "YMODEM_DESTINATION_NOT_DIRECTORY"); + } + + const reader = createSerialByteReader(serialPort, abortSignal); + const files = []; + + try { + for (;;) { + const headerFrame = await readYmodemPacketWithRetry({ + serialPort, + reader, + expectedBlockNumber: 0, + requestByte: YMODEM.CRC16, + timeoutMs, + retryLimit, + label: "file header", + }); + + if (headerFrame.type === "eot") { + await writeAndDrain(serialPort, Buffer.from([YMODEM.ACK])); + continue; + } + + const fileInfo = parseYmodemFileInfoPayload(headerFrame.payload); + await writeAndDrain(serialPort, Buffer.from([YMODEM.ACK])); + if (!fileInfo) { + break; + } + + await writeAndDrain(serialPort, Buffer.from([YMODEM.CRC16])); + onProgress?.({ transferredBytes: 0, totalBytes: fileInfo.totalBytes, stage: "header" }); + + const filePath = await resolveUniqueDestinationPath(resolvedDestinationDir, fileInfo.fileName); + let fileHandle; + let closeNeeded = false; + let createdFile = false; + try { + fileHandle = await fs.promises.open(filePath, "wx"); + closeNeeded = true; + createdFile = true; + const writtenBytes = await receiveYmodemFileData({ + serialPort, + reader, + fileHandle, + totalBytes: fileInfo.totalBytes, + timeoutMs, + retryLimit, + onProgress, + }); + await fileHandle.close(); + closeNeeded = false; + + files.push({ + fileName: path.basename(filePath), + filePath, + totalBytes: fileInfo.totalBytes, + writtenBytes, + }); + } catch (error) { + if (closeNeeded) { + await fileHandle.close().catch(() => {}); + } + if (createdFile) { + await fs.promises.rm(filePath, { force: true }).catch(() => {}); + } + throw error; + } + } + + const totalBytes = files.reduce((sum, file) => sum + file.totalBytes, 0); + const writtenBytes = files.reduce((sum, file) => sum + file.writtenBytes, 0); + return { + files, + fileCount: files.length, + totalBytes, + writtenBytes, + fileName: files[0]?.fileName, + filePath: files[0]?.filePath, + }; + } finally { + reader.cleanup(); + } +} + async function sendYmodemBuffer(serialPort, { filename, buffer, @@ -355,6 +663,7 @@ module.exports = { createYmodemFileInfoPacket, createYmodemDataPackets, createYmodemEndSessionPacket, + receiveYmodemFiles, sendYmodemCancel, sendYmodemBuffer, sendYmodemFile, diff --git a/electron/bridges/ymodemTransfer.test.cjs b/electron/bridges/ymodemTransfer.test.cjs index 2d75503c..89045dad 100644 --- a/electron/bridges/ymodemTransfer.test.cjs +++ b/electron/bridges/ymodemTransfer.test.cjs @@ -1,12 +1,16 @@ const test = require("node:test"); const assert = require("node:assert/strict"); const { EventEmitter } = require("node:events"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); const { YMODEM, createYmodemFileInfoPacket, createYmodemDataPackets, createYmodemEndSessionPacket, + receiveYmodemFiles, sendYmodemCancel, sendYmodemBuffer, } = require("./ymodemTransfer.cjs"); @@ -200,6 +204,137 @@ test("sends the Tera Term style cancel sequence", async () => { ); }); +test("receives a YMODEM file into the selected directory", async () => { + const targetDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-ymodem-receive-")); + try { + const serial = new FakeSerialPort(); + const transfer = receiveYmodemFiles(serial, { + destinationDir: targetDir, + timeoutMs: 200, + }); + + await waitForWrites(serial, 1); + assert.deepEqual([...serial.writes[0]], [YMODEM.CRC16]); + + serial.emit("data", createYmodemFileInfoPacket({ + filename: "device.log", + size: 3, + mtime: 0, + })); + await waitForWrites(serial, 3); + assert.deepEqual([...serial.writes[1]], [YMODEM.ACK]); + assert.deepEqual([...serial.writes[2]], [YMODEM.CRC16]); + + serial.emit("data", createYmodemDataPackets(Buffer.from("abc"))[0]); + await waitForWrites(serial, 4); + assert.deepEqual([...serial.writes[3]], [YMODEM.ACK]); + + serial.emit("data", Buffer.from([YMODEM.EOT])); + await waitForWrites(serial, 5); + assert.deepEqual([...serial.writes[4]], [YMODEM.NAK]); + + serial.emit("data", Buffer.from([YMODEM.EOT])); + await waitForWrites(serial, 7); + assert.deepEqual([...serial.writes[5]], [YMODEM.ACK]); + assert.deepEqual([...serial.writes[6]], [YMODEM.CRC16]); + + serial.emit("data", createYmodemEndSessionPacket()); + await waitForWrites(serial, 8); + assert.deepEqual([...serial.writes[7]], [YMODEM.ACK]); + + const result = await transfer; + assert.deepEqual(result, { + files: [{ + fileName: "device.log", + filePath: path.join(targetDir, "device.log"), + totalBytes: 3, + writtenBytes: 3, + }], + fileCount: 1, + totalBytes: 3, + writtenBytes: 3, + fileName: "device.log", + filePath: path.join(targetDir, "device.log"), + }); + assert.equal(fs.readFileSync(path.join(targetDir, "device.log"), "utf8"), "abc"); + assert.equal(serial.listenerCount("data"), 0); + } finally { + fs.rmSync(targetDir, { recursive: true, force: true }); + } +}); + +test("rejects an incomplete received file and removes the partial file", async () => { + const targetDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-ymodem-short-")); + try { + const serial = new FakeSerialPort(); + const transfer = receiveYmodemFiles(serial, { + destinationDir: targetDir, + timeoutMs: 200, + }); + const rejectedTransfer = assert.rejects(transfer, /incomplete/i); + + await waitForWrites(serial, 1); + serial.emit("data", createYmodemFileInfoPacket({ + filename: "short.log", + size: 1500, + mtime: 0, + })); + await waitForWrites(serial, 3); + + serial.emit("data", createYmodemDataPackets(Buffer.alloc(1024, 0x61))[0]); + await waitForWrites(serial, 4); + + serial.emit("data", Buffer.from([YMODEM.EOT])); + await waitForWrites(serial, 5); + serial.emit("data", Buffer.from([YMODEM.EOT])); + await waitForWrites(serial, 6); + + await rejectedTransfer; + assert.equal(fs.existsSync(path.join(targetDir, "short.log")), false); + assert.equal(serial.listenerCount("data"), 0); + } finally { + fs.rmSync(targetDir, { recursive: true, force: true }); + } +}); + +test("does not delete an existing file if creating the receive target fails", async () => { + const targetDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-ymodem-race-")); + const targetPath = path.join(targetDir, "race.log"); + const originalOpen = fs.promises.open; + try { + fs.promises.open = async (filePath, flags, ...args) => { + if (filePath === targetPath && flags === "wx") { + fs.writeFileSync(targetPath, "existing"); + const error = new Error("file exists"); + error.code = "EEXIST"; + throw error; + } + return originalOpen.call(fs.promises, filePath, flags, ...args); + }; + + const serial = new FakeSerialPort(); + const transfer = receiveYmodemFiles(serial, { + destinationDir: targetDir, + timeoutMs: 200, + }); + const rejectedTransfer = assert.rejects(transfer, /file exists/i); + + await waitForWrites(serial, 1); + serial.emit("data", createYmodemFileInfoPacket({ + filename: "race.log", + size: 3, + mtime: 0, + })); + await waitForWrites(serial, 3); + + await rejectedTransfer; + assert.equal(fs.readFileSync(targetPath, "utf8"), "existing"); + } finally { + fs.promises.open = originalOpen; + fs.rmSync(targetDir, { recursive: true, force: true }); + } +}); + function waitForWrites(serial, count) { return new Promise((resolve, reject) => { const startedAt = Date.now(); diff --git a/electron/bridges/zmodemHelper.cjs b/electron/bridges/zmodemHelper.cjs index f4c99b22..8db34564 100644 --- a/electron/bridges/zmodemHelper.cjs +++ b/electron/bridges/zmodemHelper.cjs @@ -277,6 +277,12 @@ function createZmodemSentry(opts) { } } + function takeDragDropUpload() { + const upload = dragDropUpload; + dragDropUpload = null; + return upload; + } + function scheduleRemoteInterruptAfterCancel(transferRole) { if (cancelInterruptTimer) { clearTimeout(cancelInterruptTimer); @@ -372,6 +378,7 @@ function createZmodemSentry(opts) { const transferOpts = { ...opts, getDragDropUpload: () => dragDropUpload, + takeDragDropUpload, clearDragDropUpload, waitForDrain: () => { if (!_needsDrain) return Promise.resolve(); @@ -542,8 +549,10 @@ function createZmodemSentry(opts) { if (!Array.isArray(filePaths) || filePaths.length === 0) { throw new Error("No files to upload"); } + if (dragDropUpload) { + throw new Error("ZMODEM drag-drop upload already pending"); + } - clearDragDropUpload(); const uploadCommand = payload.uploadCommand || "rz\r"; dragDropUpload = { filePaths, @@ -612,7 +621,7 @@ async function handleUpload(zsession, opts) { const { BrowserWindow, dialog } = getElectron(); const yieldToIO = () => new Promise((resolve) => setImmediate(resolve)); - const dragDrop = opts.getDragDropUpload?.(); + const dragDrop = opts.takeDragDropUpload?.() ?? opts.getDragDropUpload?.(); let filePaths; let allNames; let dragDropTempPaths = []; @@ -623,7 +632,6 @@ async function handleUpload(zsession, opts) { ? dragDrop.remoteNames : filePaths.map((fp) => path.basename(fp)); dragDropTempPaths = dragDrop.tempPaths || []; - opts.clearDragDropUpload?.(); } else { const win = contents ? BrowserWindow.fromWebContents(contents) : null; const result = await dialog.showOpenDialog(win || undefined, { diff --git a/electron/bridges/zmodemHelper.test.cjs b/electron/bridges/zmodemHelper.test.cjs index 0c5ffb49..071ac937 100644 --- a/electron/bridges/zmodemHelper.test.cjs +++ b/electron/bridges/zmodemHelper.test.cjs @@ -1,6 +1,9 @@ const test = require("node:test"); const assert = require("node:assert/strict"); -const { buildUploadPlan, buildModeRestores } = require("./zmodemHelper.cjs"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { createZmodemSentry, buildUploadPlan, buildModeRestores } = require("./zmodemHelper.cjs"); const never = () => { throw new Error("resolver should not be called"); }; @@ -72,3 +75,49 @@ test("buildModeRestores strips trailing slashes and dedupes duplicate basenames" [{ path: "/srv/x", mode: "600" }], ); }); + +test("queued drag-drop upload keeps temp files until cancel", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-")); + const tempPath = path.join(tempDir, "upload.txt"); + fs.writeFileSync(tempPath, "payload"); + + const sentry = createZmodemSentry({ + sessionId: "session-1", + onData: () => {}, + writeToRemote: () => true, + getWebContents: () => null, + }); + + sentry.queueDragDropUpload({ + filePaths: [tempPath], + remoteNames: ["upload.txt"], + tempPaths: [tempPath], + }); + + assert.equal(fs.existsSync(tempPath), true); + sentry.cancel(); + assert.equal(fs.existsSync(tempPath), false); + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +test("queued drag-drop upload rejects a second pending upload", () => { + const sentry = createZmodemSentry({ + sessionId: "session-1", + onData: () => {}, + writeToRemote: () => true, + getWebContents: () => null, + }); + + sentry.queueDragDropUpload({ + filePaths: ["/tmp/first.txt"], + remoteNames: ["first.txt"], + }); + + assert.throws( + () => sentry.queueDragDropUpload({ + filePaths: ["/tmp/second.txt"], + remoteNames: ["second.txt"], + }), + /already pending/, + ); +}); diff --git a/electron/main.cjs b/electron/main.cjs index 9bb48aab..06567f78 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -172,9 +172,20 @@ const devServerUrl = process.env.VITE_DEV_SERVER_URL; // Never treat a packaged app as "dev" even if the user has VITE_DEV_SERVER_URL set globally. const isDev = !app.isPackaged && !!devServerUrl; const effectiveDevServerUrl = isDev ? devServerUrl : undefined; +if (isDev) { + app.setName("Netcatty Dev"); + app.setPath("userData", path.join(app.getPath("userData"), "dev")); +} const preload = path.join(__dirname, "preload.cjs"); const isMac = process.platform === "darwin"; -const appIcon = path.join(__dirname, "../public/icon.png"); +function resolveAppIconPath() { + const candidates = [ + path.join(__dirname, "../dist/icon.png"), + path.join(__dirname, "../public/icon.png"), + ]; + return candidates.find((candidate) => fs.existsSync(candidate)) || candidates[0]; +} +const appIcon = resolveAppIconPath(); const electronDir = __dirname; const APP_PROTOCOL_HEADERS = { diff --git a/electron/main/registerBridges.cjs b/electron/main/registerBridges.cjs index 4a159700..adf690c2 100644 --- a/electron/main/registerBridges.cjs +++ b/electron/main/registerBridges.cjs @@ -4,6 +4,16 @@ let bridgesRegistered = false; let cloudSyncSessionPassword = null; const { readClipboardFiles, readClipboardImage } = require("../bridges/clipboardFiles.cjs"); +const excludedFigSpecPrefixes = ["aws", "gcloud", "az"]; + +function isExcludedFigSpec(commandName) { + return excludedFigSpecPrefixes.some((prefix) => commandName === prefix || commandName.startsWith(`${prefix}/`)); +} + +function filterExcludedFigSpecs(specNames) { + return specNames.filter((name) => !isExcludedFigSpec(name)); +} + function createBridgeRegistrar(context) { const { electronModule, @@ -262,7 +272,7 @@ function createBridgeRegistrar(context) { .filter(f => f.endsWith(".js")) .map(f => f.slice(0, -3)); } catch { /* no local specs dir */ } - const merged = [...new Set([...figSpecs, ...localNames])]; + const merged = filterExcludedFigSpecs([...new Set([...figSpecs, ...localNames])]); return merged; } catch (err) { console.warn("[Main] Failed to load fig spec list:", err?.message || err); @@ -274,6 +284,7 @@ function createBridgeRegistrar(context) { // Sanitize: reject absolute paths, path traversal, and non-spec characters if (!commandName || commandName.startsWith("/") || commandName.startsWith("\\") || commandName.includes("..") || !/^[@a-zA-Z0-9._/+-]+$/.test(commandName)) return null; + if (isExcludedFigSpec(commandName)) return null; const { pathToFileURL } = require("url"); const fs = require("fs"); @@ -858,4 +869,4 @@ function createBridgeRegistrar(context) { return registerBridges; } -module.exports = { createBridgeRegistrar }; +module.exports = { createBridgeRegistrar, filterExcludedFigSpecs, isExcludedFigSpec }; diff --git a/electron/preload/api.cjs b/electron/preload/api.cjs index 562f3fb0..b0c1c636 100644 --- a/electron/preload/api.cjs +++ b/electron/preload/api.cjs @@ -45,6 +45,9 @@ function createPreloadApi(ctx) { sendSerialYmodem: async (sessionId, filePath) => { return ipcRenderer.invoke("netcatty:serial:ymodem-send", { sessionId, filePath }); }, + receiveSerialYmodem: async (sessionId, destinationDir) => { + return ipcRenderer.invoke("netcatty:serial:ymodem-receive", { sessionId, destinationDir }); + }, getDefaultShell: async () => { return ipcRenderer.invoke("netcatty:local:defaultShell"); }, diff --git a/infrastructure/config/storageKeys.ts b/infrastructure/config/storageKeys.ts index 2f918b83..2bfe1cc8 100644 --- a/infrastructure/config/storageKeys.ts +++ b/infrastructure/config/storageKeys.ts @@ -146,6 +146,7 @@ export const STORAGE_KEY_AI_AGENT_MODEL_MAP = 'netcatty_ai_agent_model_map_v1'; export const STORAGE_KEY_AI_AGENT_PROVIDER_MAP = 'netcatty_ai_agent_provider_map_v1'; export const STORAGE_KEY_AI_WEB_SEARCH = 'netcatty_ai_web_search_v1'; export const STORAGE_KEY_AI_QUICK_MESSAGES = 'netcatty_ai_quick_messages_v1'; +export const STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION = 'netcatty_ai_show_terminal_selection_action_v1'; // SFTP Transfer Concurrency export const STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY = 'netcatty_sftp_transfer_concurrency_v1'; @@ -164,6 +165,9 @@ export const STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR = 'netcatty_show_host_tree_sideb // Shortcuts: Cmd/Ctrl+[1...9] skip pinned Vault/SFTP tabs export const STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS = 'netcatty_shell_only_tab_number_shortcuts_v1'; +// Shortcuts: disable terminal font zoom shortcuts +export const STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM = 'netcatty_disable_terminal_font_zoom_v1'; + // Group Configurations (default settings inherited by hosts) export const STORAGE_KEY_GROUP_CONFIGS = 'netcatty_group_configs_v1'; diff --git a/lib/zmodemDragDrop.ts b/lib/zmodemDragDrop.ts index 3966fba6..1e2cf672 100644 --- a/lib/zmodemDragDrop.ts +++ b/lib/zmodemDragDrop.ts @@ -2,6 +2,9 @@ import type { DropEntry } from "./sftpFileUtils"; import { getPathForFile } from "./sftpFileUtils"; import type { Host } from "../types"; +const ZMODEM_RZ_MISSING_MARKER_PREFIX = "\x1b]1337;NetcattyRzMissing="; +const ZMODEM_RZ_MISSING_MARKER_SUFFIX = "\x07"; + export type ZmodemDragDropFile = { path?: string; name: string; @@ -18,12 +21,14 @@ export function supportsZmodemTerminalDragDrop( return ( host.protocol === "ssh" || host.protocol === "telnet" || - host.protocol === "mosh" || - host.protocol === "et" || host.protocol === "serial" ); } +export function supportsZmodemDragDropSftpFallback(host: Host): boolean { + return host.protocol === "ssh" || Boolean(host.moshEnabled || host.etEnabled); +} + export function getZmodemRemoteName(relativePath: string, fallbackName: string): string { const normalized = relativePath.replace(/\\/g, "/").replace(/^\/+/, ""); if (!normalized) return fallbackName; @@ -31,6 +36,24 @@ export function getZmodemRemoteName(relativePath: string, fallbackName: string): return segments[segments.length - 1] || fallbackName; } +function quotePosixShellArg(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + +export function createZmodemRzMissingToken(): string { + return `rz-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; +} + +export function buildZmodemDragDropUploadCommand(rzMissingToken: string): string { + const markerFormat = `\\033]1337;NetcattyRzMissing=${rzMissingToken}\\007`; + const script = `if command -v rz >/dev/null 2>&1; then exec rz; else printf ${quotePosixShellArg(markerFormat)}; fi`; + return `sh -lc ${quotePosixShellArg(script)}\r`; +} + +export function containsZmodemRzMissingMarker(chunk: string, rzMissingToken: string): boolean { + return chunk.includes(`${ZMODEM_RZ_MISSING_MARKER_PREFIX}${rzMissingToken}${ZMODEM_RZ_MISSING_MARKER_SUFFIX}`); +} + export async function buildZmodemDragDropFiles( dropEntries: DropEntry[], ): Promise { diff --git a/package-lock.json b/package-lock.json index 1486ede4..217af6fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,6 @@ "@aws-sdk/client-s3": "^3.956.0", "@fontsource/jetbrains-mono": "^5.2.8", "@fontsource/space-grotesk": "^5.2.10", - "@google/genai": "1.33.0", "@modelcontextprotocol/sdk": "^1.29.0", "@monaco-editor/react": "^4.7.0", "@openai/codex-sdk": "^0.136.0", @@ -27,7 +26,6 @@ "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", - "@radix-ui/react-slot": "1.2.4", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@streamdown/cjk": "^1.0.2", @@ -70,7 +68,6 @@ "@typescript-eslint/eslint-plugin": "^8.49.0", "@typescript-eslint/parser": "^8.49.0", "@vitejs/plugin-react": "^5.1.2", - "@withfig/autocomplete-types": "^1.31.0", "concurrently": "^9.2.1", "cross-env": "^10.1.0", "electron": "^42.3.3", @@ -2977,27 +2974,6 @@ "copilot-win32-x64": "copilot.exe" } }, - "node_modules/@google/genai": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.33.0.tgz", - "integrity": "sha512-ThUjFZ1N0DU88peFjnQkb8K198EWaW2RmmnDShFQ+O+xkIH9itjpRe358x3L/b4X/A7dimkvq63oz49Vbh7Cog==", - "license": "Apache-2.0", - "dependencies": { - "google-auth-library": "^10.3.0", - "ws": "^8.18.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.24.0" - }, - "peerDependenciesMeta": { - "@modelcontextprotocol/sdk": { - "optional": true - } - } - }, "node_modules/@hapi/address": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", @@ -3116,102 +3092,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "license": "MIT" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -3679,16 +3559,6 @@ "node": ">=14.18.0" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -4398,24 +4268,6 @@ } } }, - "node_modules/@radix-ui/react-slot": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", - "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-tabs": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", @@ -7011,13 +6863,6 @@ "pnpm": ">=9" } }, - "node_modules/@withfig/autocomplete-types": { - "version": "1.31.0", - "resolved": "https://registry.npmjs.org/@withfig/autocomplete-types/-/autocomplete-types-1.31.0.tgz", - "integrity": "sha512-TSZDo5jvEaeIHqmHY6Wkd3gBqVbxcHQVdkF6N1J8CXRBuQZpjUVci15/HPNYe0nKLvsomBWIRsTP3m1zr9pv3A==", - "dev": true, - "license": "MIT" - }, "node_modules/@withfig/autocomplete/node_modules/strip-json-comments": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", @@ -7167,6 +7012,7 @@ "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 14" @@ -7277,6 +7123,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -7286,6 +7133,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -7683,6 +7531,7 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "devOptional": true, "funding": [ { "type": "github", @@ -7718,15 +7567,6 @@ "tweetnacl": "^0.14.3" } }, - "node_modules/bignumber.js": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", - "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -7902,12 +7742,6 @@ "node": "*" } }, - "node_modules/buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "license": "BSD-3-Clause" - }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -8419,6 +8253,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -8431,6 +8266,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, "license": "MIT" }, "node_modules/color-support": { @@ -9035,21 +8871,6 @@ "safe-buffer": "~5.1.0" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "license": "MIT" - }, - "node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -9273,6 +9094,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "devOptional": true, "license": "MIT" }, "node_modules/encodeurl": { @@ -10150,34 +9972,6 @@ } } }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/foreground-child/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -10331,35 +10125,6 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, - "node_modules/gaxios": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", - "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", - "license": "Apache-2.0", - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^7.0.1", - "node-fetch": "^3.3.2", - "rimraf": "^5.0.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/gcp-metadata": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", - "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", - "license": "Apache-2.0", - "dependencies": { - "gaxios": "^7.0.0", - "google-logging-utils": "^1.0.0", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -10583,33 +10348,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/google-auth-library": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", - "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", - "license": "Apache-2.0", - "dependencies": { - "base64-js": "^1.3.0", - "ecdsa-sig-formatter": "^1.0.11", - "gaxios": "^7.0.0", - "gcp-metadata": "^8.0.0", - "google-logging-utils": "^1.0.0", - "gtoken": "^8.0.0", - "jws": "^4.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/google-logging-utils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", - "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -10654,19 +10392,6 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, - "node_modules/gtoken": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", - "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", - "license": "MIT", - "dependencies": { - "gaxios": "^7.0.0", - "jws": "^4.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -11053,6 +10778,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -11278,6 +11004,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -11380,21 +11107,6 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/jake": { "version": "10.9.4", "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", @@ -11483,15 +11195,6 @@ "node": ">=6" } }, - "node_modules/json-bigint": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", - "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", - "license": "MIT", - "dependencies": { - "bignumber.js": "^9.0.0" - } - }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -11603,27 +11306,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jws": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", - "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", - "license": "MIT", - "dependencies": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -13218,6 +12900,7 @@ "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" @@ -13864,12 +13547,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "license": "BlueOak-1.0.0" - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -14025,28 +13702,6 @@ "integrity": "sha512-1gJ0WpNIiYcQydgg3Ed8KzvIqTsDpNwq+cjBCssvBtuTWjEqY1AW+i+OepiEMqDCzyro9B2sLAe4RBPajMYFiA==", "license": "ISC" }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "license": "ISC" - }, "node_modules/path-to-regexp": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", @@ -14897,41 +14552,6 @@ "node": ">= 4" } }, - "node_modules/rimraf": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", - "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", - "license": "ISC", - "dependencies": { - "glob": "^10.3.7" - }, - "bin": { - "rimraf": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/roarr": { "version": "2.15.4", "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", @@ -15920,21 +15540,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "devOptional": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -15963,19 +15569,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "devOptional": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -17108,51 +16702,12 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, - "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", diff --git a/package.json b/package.json index adc38d0e..020db682 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,6 @@ "@aws-sdk/client-s3": "^3.956.0", "@fontsource/jetbrains-mono": "^5.2.8", "@fontsource/space-grotesk": "^5.2.10", - "@google/genai": "1.33.0", "@modelcontextprotocol/sdk": "^1.29.0", "@monaco-editor/react": "^4.7.0", "@openai/codex-sdk": "^0.136.0", @@ -55,7 +54,6 @@ "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", - "@radix-ui/react-slot": "1.2.4", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@streamdown/cjk": "^1.0.2", @@ -95,7 +93,6 @@ "@typescript-eslint/eslint-plugin": "^8.49.0", "@typescript-eslint/parser": "^8.49.0", "@vitejs/plugin-react": "^5.1.2", - "@withfig/autocomplete-types": "^1.31.0", "concurrently": "^9.2.1", "cross-env": "^10.1.0", "electron": "^42.3.3", diff --git a/scripts/afterPackMacUuid.cjs b/scripts/afterPackMacUuid.cjs index d862e04e..c825c079 100644 --- a/scripts/afterPackMacUuid.cjs +++ b/scripts/afterPackMacUuid.cjs @@ -19,6 +19,7 @@ const fs = require("node:fs"); const path = require("node:path"); const crypto = require("node:crypto"); +const { execFileSync } = require("node:child_process"); const LC_UUID = 0x1b; const MH_MAGIC_64 = 0xfeedfacf; // thin 64-bit, little-endian on disk @@ -105,15 +106,32 @@ function patchMachOFile(file, uuid) { return result; } +function adHocSignAppBundle(appPath, options = {}) { + const hostPlatform = options.hostPlatform || process.platform; + const execFile = options.execFileSync || execFileSync; + + if (hostPlatform !== "darwin") { + console.warn( + `[afterPack] Skipping ad-hoc codesign for ${appPath}; host platform is ${hostPlatform}`, + ); + return false; + } + + execFile("codesign", ["--force", "--deep", "--sign", "-", "--timestamp=none", appPath], { + stdio: ["ignore", "pipe", "pipe"], + }); + return true; +} + /** @param {import('electron-builder').AfterPackContext} context */ async function afterPack(context) { if (context.electronPlatformName !== "darwin") return; const appId = context.packager.appInfo.id || "com.netcatty.app"; const productFilename = context.packager.appInfo.productFilename; + const appPath = path.join(context.appOutDir, `${productFilename}.app`); const exePath = path.join( - context.appOutDir, - `${productFilename}.app`, + appPath, "Contents", "MacOS", productFilename, @@ -137,6 +155,15 @@ async function afterPack(context) { `${oldUuids.map((h) => formatUuid(Buffer.from(h, "hex"))).join(", ")} -> ${formatUuid(uuid)} ` + `(${patched} slice(s), appId=${appId})`, ); + + // The official Developer ID signing step runs after afterPack and replaces + // this temporary signature. Local unsigned builds skip that step, so the + // patched app bundle still needs a valid ad-hoc signature or macOS kills it + // before Electron can start. Signing the whole bundle also covers Electron's + // nested frameworks, which codesign validates as subcomponents. + if (adHocSignAppBundle(appPath)) { + console.log("[afterPack] Ad-hoc signed patched macOS app for local unsigned builds"); + } } module.exports = afterPack; @@ -145,3 +172,4 @@ module.exports.deriveUuid = deriveUuid; module.exports.formatUuid = formatUuid; module.exports.patchMachOBuffer = patchMachOBuffer; module.exports.patchMachOFile = patchMachOFile; +module.exports.adHocSignAppBundle = adHocSignAppBundle; diff --git a/scripts/afterPackMacUuid.test.cjs b/scripts/afterPackMacUuid.test.cjs index 5eebba94..a75665d0 100644 --- a/scripts/afterPackMacUuid.test.cjs +++ b/scripts/afterPackMacUuid.test.cjs @@ -2,6 +2,7 @@ const test = require("node:test"); const assert = require("node:assert/strict"); const { + adHocSignAppBundle, deriveUuid, patchMachOBuffer, } = require("./afterPackMacUuid.cjs"); @@ -115,3 +116,44 @@ test("patchMachOBuffer reports zero when there is no LC_UUID", () => { const { patched } = patchMachOBuffer(buf, deriveUuid("com.netcatty.app")); assert.equal(patched, 0); }); + +test("adHocSignAppBundle signs the full app bundle on macOS hosts", () => { + const calls = []; + + const didSign = adHocSignAppBundle("/tmp/Netcatty.app", { + hostPlatform: "darwin", + execFileSync: (bin, args, options) => { + calls.push({ bin, args, options }); + }, + }); + + assert.equal(didSign, true); + assert.deepEqual(calls, [ + { + bin: "codesign", + args: [ + "--force", + "--deep", + "--sign", + "-", + "--timestamp=none", + "/tmp/Netcatty.app", + ], + options: { stdio: ["ignore", "pipe", "pipe"] }, + }, + ]); +}); + +test("adHocSignAppBundle skips non-macOS hosts", () => { + let called = false; + + const didSign = adHocSignAppBundle("/tmp/Netcatty.app", { + hostPlatform: "linux", + execFileSync: () => { + called = true; + }, + }); + + assert.equal(didSign, false); + assert.equal(called, false); +}); diff --git a/types/global/netcatty-bridge-session.d.ts b/types/global/netcatty-bridge-session.d.ts index f8df257d..ec132414 100644 --- a/types/global/netcatty-bridge-session.d.ts +++ b/types/global/netcatty-bridge-session.d.ts @@ -97,6 +97,20 @@ declare global { error?: string; code?: string; }>; + receiveSerialYmodem?(sessionId: string, destinationDir: string): Promise<{ + success: boolean; + files?: Array<{ + fileName: string; + filePath: string; + totalBytes: number; + writtenBytes: number; + }>; + fileName?: string; + fileCount?: number; + totalBytes?: number; + error?: string; + code?: string; + }>; getDefaultShell?(): Promise; discoverShells?(): Promise; validatePath?(path: string, type?: 'file' | 'directory' | 'any'): Promise<{ exists: boolean; isFile: boolean; isDirectory: boolean; isExecutable: boolean }>; diff --git a/vite.config.ts b/vite.config.ts index 05702fe7..d702340b 100755 --- a/vite.config.ts +++ b/vite.config.ts @@ -51,7 +51,6 @@ export default defineConfig(() => { '@radix-ui/react-popover', '@radix-ui/react-scroll-area', '@radix-ui/react-select', - '@radix-ui/react-slot', '@radix-ui/react-tabs', ], 'vendor-xterm': [