Merge main into terminal drag-drop zmodem

This commit is contained in:
bincxz
2026-06-12 16:37:01 +08:00
88 changed files with 2774 additions and 638 deletions

View File

@@ -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}

View File

@@ -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.',

View File

@@ -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',

View File

@@ -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',

View File

@@ -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',

View File

@@ -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 и попробуйте снова.',

View File

@@ -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': 'Пользовательские сочетания',

View File

@@ -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',

View File

@@ -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': 'уже существует',

View File

@@ -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 开发进程,然后重试。',

View File

@@ -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': '自定义快捷键',

View File

@@ -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': '已存在',

View File

@@ -12,6 +12,7 @@ import {
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
STORAGE_KEY_HOTKEY_RECORDING,
STORAGE_KEY_HOTKEY_SCHEME,
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
STORAGE_KEY_SESSION_LOGS_DIR,
STORAGE_KEY_SESSION_LOGS_ENABLED,
STORAGE_KEY_SESSION_LOGS_FORMAT,
@@ -73,6 +74,7 @@ interface UseSettingsIpcSyncParams {
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
setDisableTerminalFontZoomState: Dispatch<SetStateAction<boolean>>;
setSftpTransferConcurrencyState: Dispatch<SetStateAction<number>>;
}
@@ -105,6 +107,7 @@ export function useSettingsIpcSync({
setSftpDefaultViewMode,
setWorkspaceFocusStyleState,
setShowHostTreeSidebarState,
setDisableTerminalFontZoomState,
setSftpTransferConcurrencyState,
}: UseSettingsIpcSyncParams) {
// Listen for settings changes from other windows via IPC
@@ -228,6 +231,9 @@ export function useSettingsIpcSync({
if (key === STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR && typeof value === 'boolean') {
setShowHostTreeSidebarState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM && typeof value === 'boolean') {
setDisableTerminalFontZoomState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY && typeof value === 'number') {
setSftpTransferConcurrencyState((prev) => (prev === value ? prev : value));
}
@@ -258,6 +264,7 @@ export function useSettingsIpcSync({
setSftpFollowTerminalCwd,
setSftpDefaultViewMode,
setShowHostTreeSidebarState,
setDisableTerminalFontZoomState,
setSftpTransferConcurrencyState,
setTerminalFontFamilyId,
setTerminalFontSize,

View File

@@ -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;

View File

@@ -29,6 +29,7 @@ import {
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_FONT_SIZE,
@@ -79,6 +80,7 @@ interface UseSettingsStorageSyncParams {
showSftpTab: boolean;
showHostTreeSidebar: boolean;
shellOnlyTabNumberShortcuts: boolean;
disableTerminalFontZoom: boolean;
editorWordWrap: boolean;
sessionLogsEnabled: boolean;
sessionLogsDir: string;
@@ -115,6 +117,7 @@ interface UseSettingsStorageSyncParams {
setShowSftpTabState: Dispatch<SetStateAction<boolean>>;
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
setShellOnlyTabNumberShortcutsState: Dispatch<SetStateAction<boolean>>;
setDisableTerminalFontZoomState: Dispatch<SetStateAction<boolean>>;
setEditorWordWrapState: Dispatch<SetStateAction<boolean>>;
setSessionLogsEnabled: Dispatch<SetStateAction<boolean>>;
setSessionLogsDir: Dispatch<SetStateAction<string>>;
@@ -136,7 +139,7 @@ export function useSettingsStorageSync({
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
@@ -145,7 +148,7 @@ export function useSettingsStorageSync({
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setShellOnlyTabNumberShortcutsState,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled,
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
@@ -159,7 +162,7 @@ export function useSettingsStorageSync({
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
});
@@ -169,7 +172,7 @@ export function useSettingsStorageSync({
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
};
@@ -389,6 +392,12 @@ export function useSettingsStorageSync({
setShellOnlyTabNumberShortcutsState(newValue);
}
}
if (e.key === STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.disableTerminalFontZoom) {
setDisableTerminalFontZoomState(newValue);
}
}
// Sync global hotkey enabled setting from other windows
if (e.key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && e.newValue !== null) {
const newValue = e.newValue === 'true';
@@ -458,6 +467,7 @@ export function useSettingsStorageSync({
setShowRecentHostsState,
setShowSftpTabState,
setShellOnlyTabNumberShortcutsState,
setDisableTerminalFontZoomState,
setTerminalFontFamilyId,
setTerminalFontSize,
setTerminalThemeDarkId,

View File

@@ -0,0 +1,56 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { RemoteSftpStartCache } from "./sftpConnectStartPath.ts";
import {
normalizeSftpInitialPath,
resolveRemoteSftpStartState,
} from "./sftpConnectStartPath.ts";
const cached: RemoteSftpStartCache = {
path: "/var/cache",
homeDir: "/home/deploy",
files: [],
filenameEncoding: "auto",
};
test("remote SFTP default-path duplication ignores the shared host cache", () => {
const state = resolveRemoteSftpStartState({
filenameEncoding: "auto",
ignoreSharedCache: true,
sharedHostCacheCandidate: cached,
});
assert.equal(state.initialPath, undefined);
assert.equal(state.sharedHostCache, null);
assert.equal(state.cachedStartPath, "/");
});
test("remote SFTP current-path duplication uses the requested path instead of stale cache", () => {
const state = resolveRemoteSftpStartState({
filenameEncoding: "auto",
initialPath: "/var/www/app",
sharedHostCacheCandidate: cached,
});
assert.equal(state.initialPath, "/var/www/app");
assert.equal(state.sharedHostCache, null);
assert.equal(state.cachedStartPath, "/var/www/app");
});
test("remote SFTP initial paths preserve meaningful whitespace", () => {
assert.equal(normalizeSftpInitialPath("/var/www/app "), "/var/www/app ");
const state = resolveRemoteSftpStartState({
filenameEncoding: "auto",
initialPath: "/var/www/app ",
sharedHostCacheCandidate: {
...cached,
path: "/var/www/app",
},
});
assert.equal(state.initialPath, "/var/www/app ");
assert.equal(state.sharedHostCache, null);
assert.equal(state.cachedStartPath, "/var/www/app ");
});

View File

@@ -0,0 +1,44 @@
import type { SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
export interface RemoteSftpStartCache {
path: string;
homeDir: string;
files: SftpFileEntry[];
filenameEncoding: SftpFilenameEncoding;
}
interface ResolveRemoteSftpStartStateParams {
filenameEncoding: SftpFilenameEncoding;
ignoreSharedCache?: boolean;
initialPath?: string;
sharedHostCacheCandidate: RemoteSftpStartCache | null;
}
export function normalizeSftpInitialPath(initialPath?: string): string | undefined {
return initialPath === undefined || initialPath.length === 0 ? undefined : initialPath;
}
export function resolveRemoteSftpStartState({
filenameEncoding,
ignoreSharedCache,
initialPath,
sharedHostCacheCandidate,
}: ResolveRemoteSftpStartStateParams): {
initialPath: string | undefined;
sharedHostCache: RemoteSftpStartCache | null;
cachedStartPath: string;
} {
const requestedInitialPath = normalizeSftpInitialPath(initialPath);
const sharedHostCache =
!ignoreSharedCache
&& sharedHostCacheCandidate?.filenameEncoding === filenameEncoding
&& (!requestedInitialPath || sharedHostCacheCandidate.path === requestedInitialPath)
? sharedHostCacheCandidate
: null;
return {
initialPath: requestedInitialPath,
sharedHostCache,
cachedStartPath: requestedInitialPath ?? sharedHostCache?.path ?? "/",
};
}

View File

@@ -1,6 +1,6 @@
import type { SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
interface SharedRemoteHostCacheEntry {
export interface SharedRemoteHostCacheEntry {
path: string;
homeDir: string;
files: SftpFileEntry[];

View File

@@ -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<void>;
connect: (side: "left" | "right", host: Host | "local", options?: SftpConnectOptions) => Promise<void>;
disconnect: (side: "left" | "right") => Promise<void>;
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
@@ -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,

View File

@@ -0,0 +1,53 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildDockerLogsCommand } from '../../domain/systemManager/dockerShell.ts';
import { loadSanitizedShellHistory } from './shellHistoryPersistence.ts';
import type { ShellHistoryEntry } from '../../domain/models.ts';
const entry = (id: string, command: string): ShellHistoryEntry => ({
id,
command,
hostId: 'host-1',
hostLabel: 'Host',
sessionId: 'session-1',
timestamp: 1000,
});
test('loadSanitizedShellHistory removes persisted managed startup commands and writes back cleaned history', () => {
const stored = [
entry('managed', buildDockerLogsCommand('587abcdef123')),
entry('user', 'docker ps -a'),
];
let written: ShellHistoryEntry[] | null = null;
const loaded = loadSanitizedShellHistory({
read: () => stored,
write: (_key, value) => {
written = value;
return true;
},
});
assert.deepEqual(
loaded?.map((item) => item.command),
['docker ps -a'],
);
assert.deepEqual(written, loaded);
});
test('loadSanitizedShellHistory does not write when persisted history is already clean', () => {
const stored = [entry('user', 'docker ps -a')];
let writeCount = 0;
const loaded = loadSanitizedShellHistory({
read: () => stored,
write: () => {
writeCount += 1;
return true;
},
});
assert.deepEqual(loaded, stored);
assert.equal(writeCount, 0);
});

View File

@@ -0,0 +1,23 @@
import type { ShellHistoryEntry } from '../../domain/models';
import { sanitizeGlobalHistoryEntries } from '../../domain/globalHistory';
import { STORAGE_KEY_SHELL_HISTORY } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
type ShellHistoryStorage = {
read<T>(key: string): T | null;
write<T>(key: string, value: T): boolean;
};
export function loadSanitizedShellHistory(
storage: ShellHistoryStorage = localStorageAdapter,
storageKey = STORAGE_KEY_SHELL_HISTORY,
): ShellHistoryEntry[] | null {
const savedShellHistory = storage.read<ShellHistoryEntry[]>(storageKey);
if (!savedShellHistory) return null;
const cleanedShellHistory = sanitizeGlobalHistoryEntries(savedShellHistory);
if (cleanedShellHistory.length !== savedShellHistory.length) {
storage.write(storageKey, cleanedShellHistory);
}
return cleanedShellHistory;
}

View File

@@ -15,6 +15,7 @@ import {
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
STORAGE_KEY_AI_WEB_SEARCH,
STORAGE_KEY_AI_QUICK_MESSAGES,
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
} from '../../infrastructure/config/storageKeys';
import type { AIQuickMessage } from '../../infrastructure/ai/quickMessages';
import { sanitizeQuickMessages } from '../../infrastructure/ai/quickMessages';
@@ -29,6 +30,7 @@ import { DEFAULT_COMMAND_BLOCKLIST } from '../../infrastructure/ai/types';
import { removeProviderReferences } from './aiProviderCleanup';
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
import { getAIBridge } from './aiStateSnapshots';
import { useStoredBoolean } from './useStoredBoolean';
function readPermissionMode(): AIPermissionMode {
const stored = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
@@ -75,6 +77,10 @@ export function useAISettingsState() {
const [quickMessages, setQuickMessagesRaw] = useState<AIQuickMessage[]>(() =>
sanitizeQuickMessages(localStorageAdapter.read<unknown>(STORAGE_KEY_AI_QUICK_MESSAGES)),
);
const [showTerminalSelectionAIAction, setShowTerminalSelectionAIAction] = useStoredBoolean(
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
true,
);
const setProviders = useCallback((value: ProviderConfig[] | ((prev: ProviderConfig[]) => ProviderConfig[])) => {
setProvidersRaw((prev) => {
@@ -307,6 +313,8 @@ export function useAISettingsState() {
setWebSearchConfig,
quickMessages,
setQuickMessages,
showTerminalSelectionAIAction,
setShowTerminalSelectionAIAction,
}), [
providers,
setProviders,
@@ -336,5 +344,7 @@ export function useAISettingsState() {
setWebSearchConfig,
quickMessages,
setQuickMessages,
showTerminalSelectionAIAction,
setShowTerminalSelectionAIAction,
]);
}

View File

@@ -47,6 +47,7 @@ import {
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
} from '../../infrastructure/config/storageKeys';
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
import {
@@ -89,6 +90,7 @@ import {
DEFAULT_SHOW_SFTP_TAB,
DEFAULT_SHOW_HOST_TREE_SIDEBAR,
DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
DEFAULT_DISABLE_TERMINAL_FONT_ZOOM,
DEFAULT_SSH_DEBUG_LOGS_ENABLED,
DEFAULT_TERMINAL_THEME,
DEFAULT_THEME,
@@ -244,6 +246,10 @@ export const useSettingsState = () => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
return stored ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS;
});
const [disableTerminalFontZoom, setDisableTerminalFontZoomState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM);
return stored ?? DEFAULT_DISABLE_TERMINAL_FONT_ZOOM;
});
const [sftpTransferConcurrency, setSftpTransferConcurrencyState] = useState<number>(() => {
const stored = localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY);
return stored != null && stored >= 1 && stored <= 16 ? stored : 4;
@@ -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,
]),
};

View File

@@ -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<NonNullable<NetcattyBridge["onZmodemEvent"]>>[1],
) => {
const bridge = netcattyBridge.get();
return bridge?.onZmodemEvent?.(sessionId, cb) ?? (() => {});
}, []);
const getSessionPwd = useCallback(async (sessionId: string, options?: { allowHomeFallback?: boolean }) => {
const bridge = netcattyBridge.get();
if (!bridge?.getSessionPwd) return { success: false, error: 'getSessionPwd unavailable' };
@@ -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,

View File

@@ -36,8 +36,9 @@ import {
STORAGE_KEY_TERM_SETTINGS,
} from "../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
import { mergeGlobalHistoryOnAppend } from "../../domain/globalHistory";
import { mergeGlobalHistoryOnAppend, sanitizeGlobalHistoryEntries } from "../../domain/globalHistory";
import { getNextVaultOrder, normalizeVaultOrder } from "../../domain/vaultOrder";
import { loadSanitizedShellHistory } from "./shellHistoryPersistence";
import {
decryptGroupConfigs,
decryptHosts,
@@ -598,10 +599,10 @@ export const useVaultState = () => {
}
// Load shell history
const savedShellHistory = localStorageAdapter.read<ShellHistoryEntry[]>(
STORAGE_KEY_SHELL_HISTORY,
);
if (savedShellHistory) setShellHistory(savedShellHistory);
const savedShellHistory = loadSanitizedShellHistory();
if (savedShellHistory) {
setShellHistory(savedShellHistory);
}
// Load connection logs
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
@@ -729,7 +730,9 @@ export const useVaultState = () => {
}
if (key === STORAGE_KEY_SHELL_HISTORY) {
const next = safeParse<ShellHistoryEntry[]>(event.newValue) ?? [];
const next = sanitizeGlobalHistoryEntries(
safeParse<ShellHistoryEntry[]>(event.newValue) ?? [],
);
setShellHistory(next);
return;
}

View File

@@ -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 () => {

View File

@@ -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<string, unknown> =>
@@ -416,6 +419,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
if (showSftpTab != null) settings.showSftpTab = showSftpTab;
const shellOnlyTabNumberShortcuts = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
if (shellOnlyTabNumberShortcuts != null) settings.shellOnlyTabNumberShortcuts = shellOnlyTabNumberShortcuts;
const disableTerminalFontZoom = localStorageAdapter.readBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM);
if (disableTerminalFontZoom != null) settings.disableTerminalFontZoom = disableTerminalFontZoom;
const showHostTreeSidebar = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
if (showHostTreeSidebar != null) settings.showHostTreeSidebar = showHostTreeSidebar;
const workspaceFocusStyle = localStorageAdapter.readString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
@@ -457,6 +462,10 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
if (webSearchConfig) ai.webSearchConfig = stripDeviceBoundApiKey(webSearchConfig);
const quickMessages = readArraySetting(STORAGE_KEY_AI_QUICK_MESSAGES);
if (quickMessages) ai.quickMessages = sanitizeQuickMessages(quickMessages);
const showTerminalSelectionAction = localStorageAdapter.readBoolean(STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION);
if (showTerminalSelectionAction != null) {
ai.showTerminalSelectionAction = showTerminalSelectionAction;
}
if (Object.keys(ai).length > 0) settings.ai = ai;
return Object.keys(settings).length > 0 ? settings : undefined;
@@ -553,6 +562,9 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
if (settings.shellOnlyTabNumberShortcuts != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, settings.shellOnlyTabNumberShortcuts);
}
if (settings.disableTerminalFontZoom != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM, settings.disableTerminalFontZoom);
}
if (settings.showHostTreeSidebar != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, settings.showHostTreeSidebar);
}
@@ -594,6 +606,12 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
if (ai.quickMessages != null) {
localStorageAdapter.write(STORAGE_KEY_AI_QUICK_MESSAGES, sanitizeQuickMessages(ai.quickMessages));
}
if (ai.showTerminalSelectionAction != null) {
localStorageAdapter.writeBoolean(
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
ai.showTerminalSelectionAction,
);
}
// After all AI writes, reconcile per-agent bindings against the final
// provider list. Sync payloads can land with a new `providers` set but
// no `agentProviderMap`, or with a stale `agentProviderMap` that
@@ -635,6 +653,9 @@ function notifyAIStateAfterSync(ai: NonNullable<SyncPayload['settings']>['ai']):
}
if (ai.webSearchConfig !== undefined) touched.push(STORAGE_KEY_AI_WEB_SEARCH);
if (ai.quickMessages != null) touched.push(STORAGE_KEY_AI_QUICK_MESSAGES);
if (ai.showTerminalSelectionAction != null) {
touched.push(STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION);
}
for (const key of touched) {
emitAIStateChanged(key);
}

View File

@@ -157,6 +157,8 @@ const SettingsAITabContainer: React.FC = () => {
setWebSearchConfig={aiState.setWebSearchConfig}
quickMessages={aiState.quickMessages}
setQuickMessages={aiState.setQuickMessages}
showTerminalSelectionAIAction={aiState.showTerminalSelectionAIAction}
setShowTerminalSelectionAIAction={aiState.setShowTerminalSelectionAIAction}
/>
</AITabErrorBoundary>
);
@@ -401,6 +403,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
setHotkeyScheme={settings.setHotkeyScheme}
shellOnlyTabNumberShortcuts={settings.shellOnlyTabNumberShortcuts}
setShellOnlyTabNumberShortcuts={settings.setShellOnlyTabNumberShortcuts}
disableTerminalFontZoom={settings.disableTerminalFontZoom}
setDisableTerminalFontZoom={settings.setDisableTerminalFontZoom}
keyBindings={settings.keyBindings}
updateKeyBinding={settings.updateKeyBinding}
resetKeyBinding={settings.resetKeyBinding}

View File

@@ -374,9 +374,11 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
handleReorderTabsRight,
handleMoveTabFromLeftToRight,
handleMoveTabFromRightToLeft,
handleDuplicateTabLeft,
handleDuplicateTabRight,
handleHostSelectLeft,
handleHostSelectRight,
} = useSftpViewTabs({ sftp, sftpRef });
} = useSftpViewTabs({ sftp, sftpRef, hosts: effectiveHosts });
const handleAddTabLeftWithFocus = useCallback(() => {
const tabId = handleAddTabLeft();
@@ -398,6 +400,26 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
handlePaneFocus("right", tabId);
}, [handlePaneFocus, handleSelectTabRight]);
const handleDuplicateTabLeftWithFocus = useCallback(
async (...args: Parameters<typeof handleDuplicateTabLeft>) => {
const tabId = await handleDuplicateTabLeft(...args);
if (tabId) {
handlePaneFocus("left", tabId);
}
},
[handleDuplicateTabLeft, handlePaneFocus],
);
const handleDuplicateTabRightWithFocus = useCallback(
async (...args: Parameters<typeof handleDuplicateTabRight>) => {
const tabId = await handleDuplicateTabRight(...args);
if (tabId) {
handlePaneFocus("right", tabId);
}
},
[handleDuplicateTabRight, handlePaneFocus],
);
return (
<SftpContextProvider
hosts={effectiveHosts}
@@ -444,6 +466,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
onAddTab={handleAddTabLeftWithFocus}
onReorderTabs={handleReorderTabsLeft}
onMoveTabToOtherSide={handleMoveTabFromRightToLeft}
onDuplicateTab={handleDuplicateTabLeftWithFocus}
/>
)}
<div className="relative flex-1 min-h-0">
@@ -504,6 +527,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
onAddTab={handleAddTabRightWithFocus}
onReorderTabs={handleReorderTabsRight}
onMoveTabToOtherSide={handleMoveTabFromLeftToRight}
onDuplicateTab={handleDuplicateTabRightWithFocus}
/>
)}
<div className="relative flex-1 min-h-0">

View File

@@ -118,6 +118,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
reuseConnectionFromSessionId,
serialConfig,
hotkeyScheme = "disabled",
disableTerminalFontZoom = false,
keyBindings = [],
onHotkeyAction,
onTerminalFontSizeChange,
@@ -148,6 +149,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
sessionLog,
sshDebugLogEnabled,
sudoAutofillPassword,
showSelectionAIAction,
onAddSelectionToAI,
}) => {
const layoutSuppressActive = useTerminalLayoutSuppressActive();
@@ -221,9 +223,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}, [captureTerminalLogData]);
const hotkeySchemeRef = useRef(hotkeyScheme);
const disableTerminalFontZoomRef = useRef(disableTerminalFontZoom);
const keyBindingsRef = useRef(keyBindings);
const onHotkeyActionRef = useRef(onHotkeyAction);
hotkeySchemeRef.current = hotkeyScheme;
disableTerminalFontZoomRef.current = disableTerminalFontZoom;
keyBindingsRef.current = keyBindings;
onHotkeyActionRef.current = onHotkeyAction;
@@ -248,10 +252,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const terminalBackend = useTerminalBackend();
const {
resizeSession,
receiveSerialYmodem,
selectDirectory,
selectDirectoryAvailable,
selectFile,
selectFileAvailable,
sendSerialYmodem,
serialYmodemAvailable,
serialYmodemReceiveAvailable,
setSessionEncoding,
} = terminalBackend;
@@ -963,6 +971,43 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
}, [isSerialConnection, selectFile, selectFileAvailable, sendSerialYmodem, serialYmodemAvailable, sessionId, t]);
const handleReceiveYmodem = useCallback(async () => {
if (!isSerialConnection || statusRef.current !== "connected") return;
if (!selectDirectoryAvailable() || !serialYmodemReceiveAvailable()) {
toast.error(t("terminal.ymodem.unavailable"));
return;
}
try {
const destinationDir = await selectDirectory(t("terminal.ymodem.selectReceiveDirectory"));
if (!destinationDir) return;
toast.info(t("terminal.ymodem.receiveStarted"));
const result = await receiveSerialYmodem(sessionRef.current || sessionId, destinationDir);
if (result.success) {
if (result.fileCount && result.fileCount > 1) {
toast.success(t("terminal.ymodem.receiveCompleteMultiple", { count: result.fileCount }));
} else if (result.fileName) {
toast.success(t("terminal.ymodem.receiveComplete", { fileName: result.fileName }));
} else {
toast.success(t("terminal.ymodem.receiveEmpty"));
}
} else {
toast.error(t("terminal.ymodem.receiveFailed"));
}
} catch {
toast.error(t("terminal.ymodem.receiveFailed"));
}
}, [
isSerialConnection,
receiveSerialYmodem,
selectDirectory,
selectDirectoryAvailable,
serialYmodemReceiveAvailable,
sessionId,
t,
]);
const handleCancelConnect = () => {
if (pendingHostKeyRequestId) {
void terminalBackend.respondHostKeyVerification(pendingHostKeyRequestId, false);
@@ -1155,6 +1200,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onSnippetClick={(snippet) => { void executeSnippet(snippet); }}
onOpenSFTP={handleOpenSFTP}
onSendYmodem={isSerialConnection ? handleSendYmodem : undefined}
onReceiveYmodem={isSerialConnection ? handleReceiveYmodem : undefined}
onOpenScripts={onOpenScripts ?? (() => {})}
onOpenHistory={onOpenHistory}
onOpenTheme={onOpenTheme ?? (() => {})}
@@ -1172,6 +1218,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
compactToolbar,
executeSnippet,
handleOpenSFTP,
handleReceiveYmodem,
handleSendYmodem,
handleSetTerminalEncoding,
handleToggleSearch,
@@ -1211,9 +1258,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const effectiveComposeBarOpen = inWorkspace ? !!isWorkspaceComposeBarOpen : isComposeBarOpen;
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef });
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef });
return <TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, remoteDragDropUsesZmodem, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onExpandToFocus, onOpenSystem, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, scrollToBottomAfterProgrammaticInput, searchMatchCount, selectionOverlayPosition, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, snippets, status, statusDotTone, sudoHintRef, sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />;
return <TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, remoteDragDropUsesZmodem, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onExpandToFocus, onOpenSystem, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, scrollToBottomAfterProgrammaticInput, searchMatchCount, selectionOverlayPosition, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, showSelectionAIAction, snippets, status, statusDotTone, sudoHintRef, sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />;
};
const Terminal = memo(TerminalComponent, terminalPropsAreEqual);

View File

@@ -99,6 +99,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
terminalFontFamilyId,
fontSize = 14,
hotkeyScheme = 'disabled',
disableTerminalFontZoom = false,
keyBindings = [],
onHotkeyAction,
onUpdateTerminalThemeId,
@@ -1118,6 +1119,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
hosts,
hostsRef,
hotkeyScheme,
disableTerminalFontZoom,
identities,
isBroadcastEnabled,
isComposeBarOpen,

View File

@@ -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}

View File

@@ -8,13 +8,15 @@ interface ToggleProps {
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
ariaLabel?: string;
}
export const Toggle: React.FC<ToggleProps> = ({ checked, onChange, disabled }) => (
export const Toggle: React.FC<ToggleProps> = ({ checked, onChange, disabled, ariaLabel }) => (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={ariaLabel}
disabled={disabled}
onClick={() => onChange(!checked)}
className={cn(

View File

@@ -21,7 +21,7 @@ import type { ManagedAgentKey } from "../../../infrastructure/ai/managedAgents";
import { PROVIDER_PRESETS } from "../../../infrastructure/ai/types";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { Button } from "../../ui/button";
import { Select, SettingCard, SettingsSection, SettingsTabContent, SettingRow } from "../settings-ui";
import { Select, SettingCard, SettingsSection, SettingsTabContent, SettingRow, Toggle } from "../settings-ui";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../ui/tabs";
import { AgentIconBadge } from "../../ai/AgentIconBadge";
import { canSendWithAgent } from "../../ai/agentSendEligibility";
@@ -140,6 +140,8 @@ interface SettingsAITabProps {
setWebSearchConfig: (config: WebSearchConfig | null) => void;
quickMessages: AIQuickMessage[];
setQuickMessages: (value: AIQuickMessage[] | ((prev: AIQuickMessage[]) => AIQuickMessage[])) => void;
showTerminalSelectionAIAction: boolean;
setShowTerminalSelectionAIAction: (value: boolean | ((prev: boolean) => boolean)) => void;
}
// ---------------------------------------------------------------------------
@@ -173,6 +175,8 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
setWebSearchConfig,
quickMessages,
setQuickMessages,
showTerminalSelectionAIAction,
setShowTerminalSelectionAIAction,
}) => {
const { t } = useI18n();
const [editingProviderId, setEditingProviderId] = useState<string | null>(null);
@@ -871,6 +875,21 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
</TabsContent>
<TabsContent value="tools" className="m-0 space-y-6">
<SettingsSection title={t('ai.chatShortcuts.title')}>
<SettingCard divided>
<SettingRow
label={t('ai.chatShortcuts.selectionAction')}
description={t('ai.chatShortcuts.selectionAction.description')}
>
<Toggle
checked={showTerminalSelectionAIAction}
onChange={setShowTerminalSelectionAIAction}
ariaLabel={t('ai.chatShortcuts.selectionAction')}
/>
</SettingRow>
</SettingCard>
</SettingsSection>
<SettingsSection title={t('ai.toolAccess.title')}>
<SettingCard>
<SettingRow description={t('ai.toolAccess.description')}>

View File

@@ -12,6 +12,8 @@ export default function SettingsShortcutsTab(props: {
setHotkeyScheme: (scheme: HotkeyScheme) => void;
shellOnlyTabNumberShortcuts: boolean;
setShellOnlyTabNumberShortcuts: (enabled: boolean) => void;
disableTerminalFontZoom: boolean;
setDisableTerminalFontZoom: (enabled: boolean) => void;
keyBindings: KeyBinding[];
updateKeyBinding?: (bindingId: string, scheme: "mac" | "pc", newKey: string) => void;
resetKeyBinding?: (bindingId: string, scheme?: "mac" | "pc") => void;
@@ -23,6 +25,8 @@ export default function SettingsShortcutsTab(props: {
setHotkeyScheme,
shellOnlyTabNumberShortcuts,
setShellOnlyTabNumberShortcuts,
disableTerminalFontZoom,
setDisableTerminalFontZoom,
keyBindings,
updateKeyBinding,
resetKeyBinding,
@@ -140,6 +144,15 @@ export default function SettingsShortcutsTab(props: {
className="w-32"
/>
</SettingRow>
<SettingRow
label={t("settings.shortcuts.disableTerminalFontZoom.label")}
description={t("settings.shortcuts.disableTerminalFontZoom.desc")}
>
<Toggle
checked={disableTerminalFontZoom}
onChange={setDisableTerminalFontZoom}
/>
</SettingRow>
<SettingRow
label={t("settings.shortcuts.shellOnlyTabNumberShortcuts.label")}
description={t("settings.shortcuts.shellOnlyTabNumberShortcuts.desc")}

View File

@@ -9,7 +9,7 @@
* - Drag-and-drop reordering of tabs
*/
import { HardDrive, Monitor, Plus, X } from "lucide-react";
import { Copy, HardDrive, Monitor, Plus, X } from "lucide-react";
import React, {
memo,
useCallback,
@@ -25,12 +25,27 @@ import { useRenderTracker } from "../../lib/useRenderTracker";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { cn } from "../../lib/utils";
import { useActiveTabId } from "./SftpContext";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "../ui/context-menu";
import {
canDuplicateSftpTab,
isSftpTabKeyboardContextMenuShortcut,
isSftpTabKeyboardSelectShortcut,
shouldHandleSftpTabKeyboardEvent,
SFTP_TAB_DUPLICATE_MENU_ITEMS,
type SftpTabDuplicateMode,
} from "./sftpTabDuplication";
export interface SftpTab {
id: string;
label: string;
isLocal: boolean;
hostId: string | null;
canDuplicate?: boolean;
}
interface SftpTabBarProps {
@@ -46,6 +61,10 @@ interface SftpTabBarProps {
) => void;
/** Called when a tab is dragged to the other side */
onMoveTabToOtherSide?: (tabId: string) => void;
onDuplicateTab?: (
tabId: string,
mode: SftpTabDuplicateMode,
) => void | Promise<void>;
}
const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
@@ -56,6 +75,7 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
onAddTab,
onReorderTabs,
onMoveTabToOtherSide,
onDuplicateTab,
}) => {
// Subscribe to activeTabId from store (isolated subscription)
const activeTabId = useActiveTabId(side);
@@ -232,6 +252,35 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
[onAddTab],
);
const handleTabKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>, tabId: string) => {
if (!shouldHandleSftpTabKeyboardEvent(e.target, e.currentTarget)) {
return;
}
if (isSftpTabKeyboardSelectShortcut(e.key)) {
e.preventDefault();
onSelectTab(tabId);
return;
}
if (isSftpTabKeyboardContextMenuShortcut(e.key, e.shiftKey)) {
e.preventDefault();
const rect = e.currentTarget.getBoundingClientRect();
e.currentTarget.dispatchEvent(
new MouseEvent("contextmenu", {
bubbles: true,
cancelable: true,
button: 2,
clientX: rect.left + Math.min(rect.width / 2, 24),
clientY: rect.bottom,
}),
);
}
},
[onSelectTab],
);
// Cross-pane drag handlers
const handleCrossPaneDragOver = useCallback(
(e: React.DragEvent) => {
@@ -307,6 +356,7 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
>
{tabs.map((tab) => {
const isActive = activeTabId === tab.id;
const canDuplicateTab = canDuplicateSftpTab(tab, !!onDuplicateTab);
const isBeingDragged =
isDragging && draggedTabIdRef.current === tab.id;
const showDropIndicatorBefore =
@@ -317,71 +367,92 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
dropIndicator.position === "after";
return (
<div
key={tab.id}
data-tab-id={tab.id}
data-tab-type="sftp"
data-state={isActive ? 'active' : 'inactive'}
onClick={(e) => handleSelectTabClick(e, tab.id)}
onMouseDown={handleTabMiddleMouseDown}
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseTab(tab.id))}
draggable
onDragStart={(e) => handleTabDragStart(e, tab.id)}
onDragEnd={handleTabDragEnd}
onDragOver={(e) => handleTabDragOver(e, tab.id)}
onDrop={(e) => handleTabDrop(e, tab.id)}
className={cn(
"netcatty-tab relative px-3 min-w-[100px] max-w-[180px] text-xs font-medium cursor-pointer flex items-center justify-between gap-2 flex-shrink-0 border-r border-border/40",
"transition-[color,opacity,transform] duration-100 ease-out",
isActive
? "text-foreground border-b-2"
: "text-muted-foreground hover:text-foreground",
isBeingDragged && "opacity-50",
)}
style={
isActive
? { borderBottomColor: "hsl(var(--accent))" }
: undefined
}
>
{/* Drop indicator line - before */}
{showDropIndicatorBefore && isDragging && (
<div className="absolute left-0 top-1 bottom-1 w-0.5 bg-primary shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
)}
{/* Drop indicator line - after */}
{showDropIndicatorAfter && isDragging && (
<div className="absolute right-0 top-1 bottom-1 w-0.5 bg-primary shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
)}
<ContextMenu key={tab.id}>
<ContextMenuTrigger asChild>
<div
data-tab-id={tab.id}
data-tab-type="sftp"
data-state={isActive ? 'active' : 'inactive'}
tabIndex={0}
aria-haspopup="menu"
aria-label={tab.label}
onClick={(e) => handleSelectTabClick(e, tab.id)}
onKeyDown={(e) => handleTabKeyDown(e, tab.id)}
onMouseDown={handleTabMiddleMouseDown}
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseTab(tab.id))}
draggable
onDragStart={(e) => handleTabDragStart(e, tab.id)}
onDragEnd={handleTabDragEnd}
onDragOver={(e) => handleTabDragOver(e, tab.id)}
onDrop={(e) => handleTabDrop(e, tab.id)}
className={cn(
"netcatty-tab relative px-3 min-w-[100px] max-w-[180px] text-xs font-medium cursor-pointer flex items-center justify-between gap-2 flex-shrink-0 border-r border-border/40",
"transition-[color,opacity,transform] duration-100 ease-out focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 focus-visible:ring-inset",
isActive
? "text-foreground border-b-2"
: "text-muted-foreground hover:text-foreground",
isBeingDragged && "opacity-50",
)}
style={
isActive
? { borderBottomColor: "hsl(var(--accent))" }
: undefined
}
>
{/* Drop indicator line - before */}
{showDropIndicatorBefore && isDragging && (
<div className="absolute left-0 top-1 bottom-1 w-0.5 bg-primary shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
)}
{/* Drop indicator line - after */}
{showDropIndicatorAfter && isDragging && (
<div className="absolute right-0 top-1 bottom-1 w-0.5 bg-primary shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
)}
<div className="flex items-center gap-1.5 min-w-0 flex-1">
{tab.isLocal ? (
<Monitor
size={12}
className={cn(
"shrink-0",
isActive ? "text-primary" : "text-muted-foreground",
<div className="flex items-center gap-1.5 min-w-0 flex-1">
{tab.isLocal ? (
<Monitor
size={12}
className={cn(
"shrink-0",
isActive ? "text-primary" : "text-muted-foreground",
)}
/>
) : (
<HardDrive
size={12}
className={cn(
"shrink-0",
isActive ? "text-primary" : "text-muted-foreground",
)}
/>
)}
/>
) : (
<HardDrive
size={12}
className={cn(
"shrink-0",
isActive ? "text-primary" : "text-muted-foreground",
)}
/>
)}
<span className="truncate">{tab.label}</span>
</div>
<span className="truncate">{tab.label}</span>
</div>
<button
onClick={(e) => handleCloseTab(e, tab.id)}
className="p-0.5 hover:bg-destructive/10 hover:text-destructive transition-colors shrink-0"
aria-label={t("common.close")}
>
<X size={12} />
</button>
</div>
<button
onClick={(e) => handleCloseTab(e, tab.id)}
className="p-0.5 hover:bg-destructive/10 hover:text-destructive transition-colors shrink-0"
aria-label={t("common.close")}
>
<X size={12} />
</button>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
{SFTP_TAB_DUPLICATE_MENU_ITEMS.map((item) => (
<ContextMenuItem
key={item.mode}
disabled={!canDuplicateTab}
onClick={() => {
void onDuplicateTab?.(tab.id, item.mode);
}}
>
<Copy size={14} className="mr-2" />
{t(item.labelKey)}
</ContextMenuItem>
))}
</ContextMenuContent>
</ContextMenu>
);
})}
</div>
@@ -432,7 +503,8 @@ const sftpTabBarAreEqual = (
prevTab.id !== nextTab.id ||
prevTab.label !== nextTab.label ||
prevTab.isLocal !== nextTab.isLocal ||
prevTab.hostId !== nextTab.hostId
prevTab.hostId !== nextTab.hostId ||
prevTab.canDuplicate !== nextTab.canDuplicate
) {
return false;
}

View File

@@ -6,17 +6,22 @@ import { editorTabStore } from "../../../application/state/editorTabStore";
import type { EditorTab, EditorTabId } from "../../../application/state/editorTabStore";
import { releaseEditorTabSaveCoordinator, saveEditorTab } from "../../../application/state/editorTabSave";
import { promptUnsavedChanges } from "../../editor/UnsavedChangesDialog";
import {
getSftpTabDuplicateRequest,
type SftpTabDuplicateMode,
} from "../sftpTabDuplication";
interface UseSftpViewTabsParams {
sftp: SftpStateApi;
sftpRef: MutableRefObject<SftpStateApi>;
hosts?: Host[];
}
interface UseSftpViewTabsResult {
leftPanes: SftpStateApi["leftPane"][];
rightPanes: SftpStateApi["rightPane"][];
leftTabsInfo: { id: string; label: string; isLocal: boolean; hostId: string | null }[];
rightTabsInfo: { id: string; label: string; isLocal: boolean; hostId: string | null }[];
leftTabsInfo: { id: string; label: string; isLocal: boolean; hostId: string | null; canDuplicate: boolean }[];
rightTabsInfo: { id: string; label: string; isLocal: boolean; hostId: string | null; canDuplicate: boolean }[];
showHostPickerLeft: boolean;
showHostPickerRight: boolean;
hostSearchLeft: string;
@@ -35,15 +40,19 @@ interface UseSftpViewTabsResult {
handleReorderTabsRight: (draggedId: string, targetId: string, position: "before" | "after") => void;
handleMoveTabFromLeftToRight: (tabId: string) => void;
handleMoveTabFromRightToLeft: (tabId: string) => void;
handleDuplicateTabLeft: (tabId: string, mode: SftpTabDuplicateMode) => Promise<string | null>;
handleDuplicateTabRight: (tabId: string, mode: SftpTabDuplicateMode) => Promise<string | null>;
handleHostSelectLeft: (host: Host | "local") => void;
handleHostSelectRight: (host: Host | "local") => void;
}
export const useSftpViewTabs = ({ sftp, sftpRef }: UseSftpViewTabsParams): UseSftpViewTabsResult => {
export const useSftpViewTabs = ({ sftp, sftpRef, hosts = [] }: UseSftpViewTabsParams): UseSftpViewTabsResult => {
const [showHostPickerLeft, setShowHostPickerLeft] = useState(false);
const [showHostPickerRight, setShowHostPickerRight] = useState(false);
const [hostSearchLeft, setHostSearchLeft] = useState("");
const [hostSearchRight, setHostSearchRight] = useState("");
const hostsRef = React.useRef(hosts);
hostsRef.current = hosts;
const handleAddTabLeft = useCallback(() => {
const tabId = sftpRef.current.addTab("left");
@@ -132,6 +141,43 @@ export const useSftpViewTabs = ({ sftp, sftpRef }: UseSftpViewTabsParams): UseSf
sftpRef.current.moveTabToOtherSide("right", tabId);
}, [sftpRef]);
const handleDuplicateTab = useCallback(
async (side: "left" | "right", tabId: string, mode: SftpTabDuplicateMode) => {
const sideTabs = side === "left" ? sftpRef.current.leftTabs : sftpRef.current.rightTabs;
const pane = sideTabs.tabs.find((tab) => tab.id === tabId);
const request = getSftpTabDuplicateRequest(pane, mode);
if (!request) return null;
const host = request.kind === "local"
? "local"
: hostsRef.current.find((item) => item.id === request.hostId);
if (!host) return null;
let duplicatedTabId: string | null = null;
await sftpRef.current.connect(side, host, {
forceNewTab: true,
ignoreSharedCache: mode === "defaultPath",
initialPath: request.path,
onTabCreated: (createdTabId) => {
duplicatedTabId = createdTabId;
},
});
return duplicatedTabId;
},
[sftpRef],
);
const handleDuplicateTabLeft = useCallback(
(tabId: string, mode: SftpTabDuplicateMode) => handleDuplicateTab("left", tabId, mode),
[handleDuplicateTab],
);
const handleDuplicateTabRight = useCallback(
(tabId: string, mode: SftpTabDuplicateMode) => handleDuplicateTab("right", tabId, mode),
[handleDuplicateTab],
);
const handleHostSelectLeft = useCallback((host: Host | "local") => {
sftpRef.current.connect("left", host);
setShowHostPickerLeft(false);
@@ -149,6 +195,7 @@ export const useSftpViewTabs = ({ sftp, sftpRef }: UseSftpViewTabsParams): UseSf
label: pane.connection?.hostLabel || "New Tab",
isLocal: pane.connection?.isLocal || false,
hostId: pane.connection?.hostId || null,
canDuplicate: pane.connection?.status === "connected",
})),
[sftp.leftTabs.tabs],
);
@@ -160,6 +207,7 @@ export const useSftpViewTabs = ({ sftp, sftpRef }: UseSftpViewTabsParams): UseSf
label: pane.connection?.hostLabel || "New Tab",
isLocal: pane.connection?.isLocal || false,
hostId: pane.connection?.hostId || null,
canDuplicate: pane.connection?.status === "connected",
})),
[sftp.rightTabs.tabs],
);
@@ -187,6 +235,8 @@ export const useSftpViewTabs = ({ sftp, sftpRef }: UseSftpViewTabsParams): UseSf
handleReorderTabsRight,
handleMoveTabFromLeftToRight,
handleMoveTabFromRightToLeft,
handleDuplicateTabLeft,
handleDuplicateTabRight,
handleHostSelectLeft,
handleHostSelectRight,
};

View File

@@ -0,0 +1,114 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { SftpPane } from "../../application/state/sftp/types.ts";
import {
canDuplicateSftpTab,
getSftpTabDuplicateRequest,
isSftpTabKeyboardContextMenuShortcut,
isSftpTabKeyboardSelectShortcut,
shouldHandleSftpTabKeyboardEvent,
SFTP_TAB_DUPLICATE_MENU_ITEMS,
} from "./sftpTabDuplication.ts";
const connectedPane = (overrides: Partial<NonNullable<SftpPane["connection"]>> = {}): SftpPane => ({
id: "tab-1",
connection: {
id: "conn-1",
hostId: "host-1",
hostLabel: "Prod",
isLocal: false,
status: "connected",
currentPath: "/var/www/app",
homeDir: "/home/deploy",
...overrides,
},
files: [],
loading: false,
reconnecting: false,
error: null,
connectionLogs: [],
selectedFiles: new Set(),
filter: "",
filenameEncoding: "auto",
showHiddenFiles: false,
transferMutationToken: 0,
});
test("default-path SFTP tab duplication keeps only the remote host identity", () => {
assert.deepEqual(getSftpTabDuplicateRequest(connectedPane(), "defaultPath"), {
kind: "remote",
hostId: "host-1",
});
});
test("current-path SFTP tab duplication carries the active directory", () => {
assert.deepEqual(getSftpTabDuplicateRequest(connectedPane(), "currentPath"), {
kind: "remote",
hostId: "host-1",
path: "/var/www/app",
});
});
test("local SFTP tab duplication targets the local filesystem", () => {
assert.deepEqual(
getSftpTabDuplicateRequest(
connectedPane({
hostId: "local",
hostLabel: "Local",
isLocal: true,
currentPath: "/Users/damao/projects",
homeDir: "/Users/damao",
}),
"currentPath",
),
{
kind: "local",
path: "/Users/damao/projects",
},
);
});
test("SFTP tab duplication is unavailable before a tab is connected", () => {
assert.equal(getSftpTabDuplicateRequest({ ...connectedPane(), connection: null }, "defaultPath"), null);
assert.equal(
getSftpTabDuplicateRequest(connectedPane({ status: "connecting" }), "currentPath"),
null,
);
});
test("SFTP tab duplicate menu exposes separate default and current path actions", () => {
assert.deepEqual(
SFTP_TAB_DUPLICATE_MENU_ITEMS.map((item) => item.mode),
["defaultPath", "currentPath"],
);
assert.deepEqual(
SFTP_TAB_DUPLICATE_MENU_ITEMS.map((item) => item.labelKey),
["sftp.tabs.copyDefaultPath", "sftp.tabs.copyCurrentPath"],
);
});
test("SFTP tab duplicate menu is disabled without a connected tab and handler", () => {
assert.equal(canDuplicateSftpTab({ canDuplicate: true }, true), true);
assert.equal(canDuplicateSftpTab({ canDuplicate: true }, false), false);
assert.equal(canDuplicateSftpTab({ canDuplicate: false }, true), false);
assert.equal(canDuplicateSftpTab(connectedPane(), true), true);
assert.equal(canDuplicateSftpTab(connectedPane({ status: "connecting" }), true), false);
});
test("SFTP tab duplicate menu has keyboard shortcuts for selection and menu access", () => {
assert.equal(isSftpTabKeyboardSelectShortcut("Enter"), true);
assert.equal(isSftpTabKeyboardSelectShortcut(" "), true);
assert.equal(isSftpTabKeyboardSelectShortcut("Escape"), false);
assert.equal(isSftpTabKeyboardContextMenuShortcut("ContextMenu"), true);
assert.equal(isSftpTabKeyboardContextMenuShortcut("F10", true), true);
assert.equal(isSftpTabKeyboardContextMenuShortcut("F10", false), false);
});
test("SFTP tab keyboard shortcuts do not intercept nested close button events", () => {
const tab = new EventTarget();
const closeButton = new EventTarget();
assert.equal(shouldHandleSftpTabKeyboardEvent(tab, tab), true);
assert.equal(shouldHandleSftpTabKeyboardEvent(closeButton, tab), false);
});

View File

@@ -0,0 +1,73 @@
import type { SftpPane } from "../../application/state/sftp/types";
export type SftpTabDuplicateMode = "defaultPath" | "currentPath";
export type SftpTabDuplicateRequest =
| { kind: "local"; path?: string }
| { kind: "remote"; hostId: string; path?: string };
export const SFTP_TAB_DUPLICATE_MENU_ITEMS: ReadonlyArray<{
mode: SftpTabDuplicateMode;
labelKey: "sftp.tabs.copyDefaultPath" | "sftp.tabs.copyCurrentPath";
}> = Object.freeze([
{ mode: "defaultPath", labelKey: "sftp.tabs.copyDefaultPath" },
{ mode: "currentPath", labelKey: "sftp.tabs.copyCurrentPath" },
]);
export function canDuplicateSftpTab(
tab: Pick<SftpPane, "connection"> | { canDuplicate?: boolean } | null | undefined,
hasDuplicateHandler: boolean,
): boolean {
if (!hasDuplicateHandler || !tab) return false;
if ("connection" in tab) return tab.connection?.status === "connected";
return !!tab.canDuplicate;
}
export function isSftpTabKeyboardContextMenuShortcut(
key: string,
shiftKey = false,
): boolean {
return key === "ContextMenu" || (shiftKey && key === "F10");
}
export function isSftpTabKeyboardSelectShortcut(key: string): boolean {
return key === "Enter" || key === " ";
}
export function shouldHandleSftpTabKeyboardEvent(
target: EventTarget | null,
currentTarget: EventTarget | null,
): boolean {
return target === currentTarget;
}
export function getSftpTabDuplicateRequest(
pane: Pick<SftpPane, "connection"> | null | undefined,
mode: SftpTabDuplicateMode,
): SftpTabDuplicateRequest | null {
const connection = pane?.connection;
if (!connection || connection.status !== "connected") {
return null;
}
const path = mode === "currentPath" && connection.currentPath
? { path: connection.currentPath }
: {};
if (connection.isLocal) {
return {
kind: "local",
...path,
};
}
if (!connection.hostId) {
return null;
}
return {
kind: "remote",
hostId: connection.hostId,
...path,
};
}

View File

@@ -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,
);
});

View File

@@ -5,6 +5,7 @@
import {
ClipboardPaste,
Copy,
Download,
RefreshCcw,
Sparkles,
SplitSquareHorizontal,
@@ -13,7 +14,7 @@ import {
Trash2,
Upload,
} from 'lucide-react';
import React, { useCallback, useRef } from 'react';
import React, { useCallback, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { KeyBinding, RightClickBehavior } from '../../domain/models';
import {
@@ -24,6 +25,7 @@ import {
ContextMenuShortcut,
ContextMenuTrigger,
} from '../ui/context-menu';
import { isMiddleClickContextMenuEvent } from './runtime/middleClickBehavior';
export interface TerminalContextMenuProps {
children: React.ReactNode;
@@ -40,6 +42,7 @@ export interface TerminalContextMenuProps {
onSplitHorizontal?: () => void;
onSplitVertical?: () => void;
onSendYmodem?: () => void;
onReceiveYmodem?: () => void;
isReconnectable?: boolean;
onReconnect?: () => void;
onClose?: () => void;
@@ -63,6 +66,44 @@ export const shouldSuppressMouseTrackingContextMenu = ({
showReconnectAction?: boolean;
}): boolean => Boolean(isAlternateScreen && !showReconnectAction);
export const shouldShowAddSelectionToAIContextMenuAction = (
onAddSelectionToAI?: () => void,
): boolean => Boolean(onAddSelectionToAI);
export const shouldRenderTerminalContextMenuContent = ({
isAlternateScreen,
showReconnectAction,
allowSuppressedMenuContent,
}: {
isAlternateScreen?: boolean;
showReconnectAction?: boolean;
allowSuppressedMenuContent?: boolean;
}): boolean =>
allowSuppressedMenuContent ||
!shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, showReconnectAction });
export const shouldOpenTerminalContextMenu = ({
event,
rightClickBehavior = 'context-menu',
isAlternateScreen,
showReconnectAction,
}: {
event: { shiftKey?: boolean; nativeEvent: MouseEvent };
rightClickBehavior?: RightClickBehavior;
isAlternateScreen?: boolean;
showReconnectAction?: boolean;
}): boolean => {
if (isMiddleClickContextMenuEvent(event.nativeEvent)) {
return true;
}
if (shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, showReconnectAction })) {
return false;
}
return Boolean(event.shiftKey || rightClickBehavior === 'context-menu');
};
export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
children,
hasSelection = false,
@@ -78,6 +119,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
onSplitHorizontal,
onSplitVertical,
onSendYmodem,
onReceiveYmodem,
isReconnectable,
onReconnect,
onClose,
@@ -90,11 +132,13 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
// keep its `:focus-within`-driven opacity stable while focus is in the
// menu portal (otherwise the pane dims for the menu's lifetime).
const markedPaneRef = useRef<HTMLElement | null>(null);
const [allowSuppressedMenuContent, setAllowSuppressedMenuContent] = useState(false);
const handleOpenChange = useCallback((open: boolean) => {
if (!open) {
markedPaneRef.current?.removeAttribute('data-menu-open');
markedPaneRef.current = null;
setAllowSuppressedMenuContent(false);
}
}, []);
@@ -125,19 +169,28 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
// In alternate screen (tmux, vim, etc.), let the terminal application
// handle right-click natively to avoid conflicting menus. Reconnect is
// still available after disconnect, even if mouse tracking was left on.
if (shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, showReconnectAction })) {
const shouldOpenMenu = shouldOpenTerminalContextMenu({
event: e,
rightClickBehavior,
isAlternateScreen,
showReconnectAction,
});
const isMiddleClickMenu = isMiddleClickContextMenuEvent(e.nativeEvent);
if (!shouldOpenMenu && shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, showReconnectAction })) {
e.preventDefault();
return;
}
// Shift+Right-Click or context-menu mode: let Radix open the menu
if (e.shiftKey || rightClickBehavior === 'context-menu') {
if (shouldOpenMenu) {
const pane = (e.target as HTMLElement | null)?.closest<HTMLElement>('.workspace-pane');
if (pane) {
markedPaneRef.current?.removeAttribute('data-menu-open');
pane.setAttribute('data-menu-open', '');
markedPaneRef.current = pane;
}
setAllowSuppressedMenuContent(isMiddleClickMenu);
return;
}
@@ -162,7 +215,11 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
>
{children}
</ContextMenuTrigger>
{!shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, showReconnectAction }) && (
{shouldRenderTerminalContextMenuContent({
isAlternateScreen,
showReconnectAction,
allowSuppressedMenuContent,
}) && (
<ContextMenuContent className="w-max">
<ContextMenuItem onClick={onCopy} disabled={!hasSelection}>
<Copy size={14} className="mr-2" />
@@ -174,7 +231,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
{t('terminal.menu.paste')}
<ContextMenuShortcut>{pasteShortcut}</ContextMenuShortcut>
</ContextMenuItem>
{onAddSelectionToAI && (
{shouldShowAddSelectionToAIContextMenuAction(onAddSelectionToAI) && (
<ContextMenuItem onClick={onAddSelectionToAI} disabled={!hasSelection}>
<Sparkles size={14} className="mr-2" />
{t('terminal.menu.addSelectionToAI')}
@@ -203,13 +260,21 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
</>
)}
{onSendYmodem && (
{(onSendYmodem || onReceiveYmodem) && (
<>
<ContextMenuSeparator />
<ContextMenuItem onClick={onSendYmodem}>
<Upload size={14} className="mr-2" />
{t('terminal.menu.sendYmodem')}
</ContextMenuItem>
{onSendYmodem && (
<ContextMenuItem onClick={onSendYmodem}>
<Upload size={14} className="mr-2" />
{t('terminal.menu.sendYmodem')}
</ContextMenuItem>
)}
{onReceiveYmodem && (
<ContextMenuItem onClick={onReceiveYmodem}>
<Download size={14} className="mr-2" />
{t('terminal.menu.receiveYmodem')}
</ContextMenuItem>
)}
</>
)}

View File

@@ -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", () => {

View File

@@ -2,7 +2,7 @@
* Terminal Toolbar
* Displays high-frequency terminal actions and close button in the terminal status bar.
*/
import { Check, ChevronRight, FolderInput, History, Languages, MoreVertical, X, Zap, Palette, Search, TextCursorInput, Upload } from 'lucide-react';
import { Check, ChevronRight, Download, FolderInput, History, Languages, MoreVertical, X, Zap, Palette, Search, TextCursorInput, Upload } from 'lucide-react';
import React, { useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Host, Snippet } from '../../types';
@@ -23,6 +23,7 @@ export interface TerminalToolbarProps {
onSnippetClick?: (snippet: Snippet) => void;
onOpenSFTP: () => void;
onSendYmodem?: () => void;
onReceiveYmodem?: () => void;
onOpenScripts: () => void;
onOpenHistory?: () => void;
onOpenTheme: () => void;
@@ -49,6 +50,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
onSnippetClick,
onOpenSFTP,
onSendYmodem,
onReceiveYmodem,
onOpenScripts,
onOpenHistory,
onOpenTheme,
@@ -86,6 +88,8 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
const encodingSwitchSupported = !isLocalTerminal && !isMoshSession && !isEtSession;
const hidesSftp = isLocalTerminal || isSerialTerminal;
const historySupported = !!onOpenHistory && !isLocalTerminal && !isSerialTerminal && host?.protocol !== 'telnet';
const unavailableYmodemSendLabel = `${t("terminal.toolbar.sendYmodem")} - ${t("terminal.toolbar.availableAfterConnect")}`;
const unavailableYmodemReceiveLabel = `${t("terminal.toolbar.receiveYmodem")} - ${t("terminal.toolbar.availableAfterConnect")}`;
const menuItemClass = "w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors";
const activeButtonStyle: React.CSSProperties = {
@@ -194,23 +198,43 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
)}
{isSerialTerminal && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon"
className={cn(buttonBase, status !== 'connected' && "opacity-50")}
aria-label={status === 'connected' ? t("terminal.toolbar.sendYmodem") : t("terminal.toolbar.availableAfterConnect")}
onClick={onSendYmodem}
disabled={status !== 'connected' || !onSendYmodem}
>
<Upload size={12} />
</Button>
</TooltipTrigger>
<TooltipContent>
{status === 'connected' ? t("terminal.toolbar.sendYmodem") : t("terminal.toolbar.availableAfterConnect")}
</TooltipContent>
</Tooltip>
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon"
className={cn(buttonBase, status !== 'connected' && "opacity-50")}
aria-label={status === 'connected' ? t("terminal.toolbar.sendYmodem") : unavailableYmodemSendLabel}
onClick={onSendYmodem}
disabled={status !== 'connected' || !onSendYmodem}
>
<Upload size={12} />
</Button>
</TooltipTrigger>
<TooltipContent>
{status === 'connected' ? t("terminal.toolbar.sendYmodem") : t("terminal.toolbar.availableAfterConnect")}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon"
className={cn(buttonBase, status !== 'connected' && "opacity-50")}
aria-label={status === 'connected' ? t("terminal.toolbar.receiveYmodem") : unavailableYmodemReceiveLabel}
onClick={onReceiveYmodem}
disabled={status !== 'connected' || !onReceiveYmodem}
>
<Download size={12} />
</Button>
</TooltipTrigger>
<TooltipContent>
{status === 'connected' ? t("terminal.toolbar.receiveYmodem") : t("terminal.toolbar.availableAfterConnect")}
</TooltipContent>
</Tooltip>
</>
)}
<Tooltip>

View File

@@ -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");

View File

@@ -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 && (
<div
className="absolute z-30 pointer-events-none"
style={{

View File

@@ -5,6 +5,10 @@ import { useRef, useState } from "react";
import { logger } from "../../../lib/logger";
import {
buildZmodemDragDropFiles,
buildZmodemDragDropUploadCommand,
containsZmodemRzMissingMarker,
createZmodemRzMissingToken,
supportsZmodemDragDropSftpFallback,
supportsZmodemTerminalDragDrop,
type ZmodemDragDropFile,
} from "../../../lib/zmodemDragDrop";
@@ -29,6 +33,12 @@ interface UseTerminalDragDropOptions {
t: (key: string) => 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<XTerm | null>;
}
const RZ_MISSING_FALLBACK_TIMEOUT_MS = 2500;
export async function resolveTerminalDropUploadInitialPath(
resolveSftpInitialPath: UseTerminalDragDropOptions["resolveSftpInitialPath"],
): Promise<string | undefined> {
return resolveSftpInitialPath({ preferFreshBackend: true });
}
function createRzMissingWatcher({
sessionId,
terminalBackend,
token,
}: {
sessionId: string;
terminalBackend: Pick<UseTerminalDragDropOptions["terminalBackend"], "onSessionData" | "onZmodemEvent">;
token: string;
}): { promise: Promise<boolean>; stop: () => void } {
let settled = false;
let timeout: ReturnType<typeof setTimeout> | 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<boolean>((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);

View File

@@ -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<string | null>;
hotkeySchemeRef: RefObject<"disabled" | "mac" | "pc">;
disableTerminalFontZoomRef: RefObject<boolean>;
keyBindingsRef: RefObject<KeyBinding[]>;
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;
}

View File

@@ -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);

View File

@@ -6,6 +6,12 @@ import {
type WheelLike = Pick<WheelEvent, "ctrlKey" | "metaKey" | "deltaY">;
const TERMINAL_FONT_SIZE_ACTIONS = new Set([
"increaseTerminalFontSize",
"decreaseTerminalFontSize",
"resetTerminalFontSize",
]);
export const terminalFontSizeWheelListenerOptions = {
passive: false,
capture: true,
@@ -14,10 +20,20 @@ export const terminalFontSizeWheelListenerOptions = {
export const clampTerminalFontSize = (fontSize: number): number =>
Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, fontSize));
export const isTerminalFontSizeAction = (action: string): boolean =>
TERMINAL_FONT_SIZE_ACTIONS.has(action);
export const shouldHandleTerminalFontSizeAction = (
action: string,
disabled = false,
): boolean => isTerminalFontSizeAction(action) && !disabled;
export const nextTerminalFontSizeForAction = (
action: string,
currentFontSize: number,
disabled = false,
): number | null => {
if (disabled) return null;
switch (action) {
case "increaseTerminalFontSize":
return clampTerminalFontSize(currentFontSize + 1);
@@ -34,7 +50,9 @@ export const nextTerminalFontSizeForWheel = (
event: WheelLike,
currentFontSize: number,
isMac: boolean,
disabled = false,
): number | null => {
if (disabled) return null;
const hasZoomModifier = isMac
? event.metaKey && !event.ctrlKey
: event.ctrlKey && !event.metaKey;

View File

@@ -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",

View File

@@ -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;
}

View File

@@ -0,0 +1,25 @@
import assert from "node:assert/strict";
import test from "node:test";
import { terminalPropsAreEqual } from "./terminalMemo.ts";
import type { TerminalProps } from "./terminalHelpers.ts";
const baseProps = {
host: {},
keys: [],
identities: [],
snippets: [],
isVisible: true,
fontFamilyId: "default",
fontSize: 14,
terminalTheme: {},
sessionId: "session-1",
showSelectionAIAction: true,
} as unknown as TerminalProps;
test("terminal memo refreshes when selection AI action visibility changes", () => {
assert.equal(
terminalPropsAreEqual(baseProps, { ...baseProps, showSelectionAIAction: false }),
false,
);
});

View File

@@ -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

View File

@@ -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,

View File

@@ -5,8 +5,10 @@ import { useTerminalLayoutSuppressActive } from '../../application/state/termina
import type { TerminalSessionExitEvent } from '../../application/state/resolveTerminalSessionExitIntent';
import { createTerminalSelectionAttachment } from '../../application/state/terminalSelectionAttachment';
import { useAIState } from '../../application/state/useAIState';
import { useStoredBoolean } from '../../application/state/useStoredBoolean';
import { SplitDirection } from '../../domain/workspace';
import { KeyBinding, TerminalSettings } from '../../domain/models';
import { STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION } from '../../infrastructure/config/storageKeys';
import { cn } from '../../lib/utils';
import type { DropEntry } from '../../lib/sftpFileUtils';
import type { GroupConfig, Host, Identity, KnownHost, ProxyProfile, SSHKey, Snippet, TerminalSession, TerminalTheme, Workspace } from '../../types';
@@ -474,6 +476,7 @@ export interface TerminalLayerProps {
terminalFontFamilyId: string;
fontSize?: number;
hotkeyScheme?: 'disabled' | 'mac' | 'pc';
disableTerminalFontZoom?: boolean;
keyBindings?: KeyBinding[];
onHotkeyAction?: (action: string, event: KeyboardEvent) => void;
onUpdateTerminalThemeId?: (themeId: string) => void;
@@ -554,6 +557,7 @@ interface TerminalPaneProps {
customAccent?: string;
terminalSettings?: TerminalSettings;
hotkeyScheme?: 'disabled' | 'mac' | 'pc';
disableTerminalFontZoom?: boolean;
keyBindings?: KeyBinding[];
isResizing: boolean;
isComposeBarOpen: boolean;
@@ -592,6 +596,7 @@ interface TerminalPaneProps {
executor: SnippetExecutor | null,
) => void;
onAddSelectionToAI?: (sessionId: string, selection: string) => void;
showSelectionAIAction: boolean;
}
const getPaneThemePreviewId = (props: TerminalPaneProps): string | null => (
@@ -649,6 +654,7 @@ const terminalPanePropsAreEqual = (
prev.customAccent === next.customAccent &&
prev.terminalSettings === next.terminalSettings &&
prev.hotkeyScheme === next.hotkeyScheme &&
prev.disableTerminalFontZoom === next.disableTerminalFontZoom &&
prev.keyBindings === next.keyBindings &&
prev.isResizing === next.isResizing &&
prev.isComposeBarOpen === next.isComposeBarOpen &&
@@ -677,7 +683,8 @@ const terminalPanePropsAreEqual = (
prev.onBroadcastInput === next.onBroadcastInput &&
prev.onToggleWorkspaceComposeBar === next.onToggleWorkspaceComposeBar &&
prev.onSnippetExecutorChange === next.onSnippetExecutorChange &&
prev.onAddSelectionToAI === next.onAddSelectionToAI
prev.onAddSelectionToAI === next.onAddSelectionToAI &&
prev.showSelectionAIAction === next.showSelectionAIAction
);
const TerminalPane: React.FC<TerminalPaneProps> = memo(({
@@ -705,6 +712,7 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
customAccent,
terminalSettings,
hotkeyScheme,
disableTerminalFontZoom,
keyBindings,
isResizing,
isComposeBarOpen,
@@ -734,6 +742,7 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
onToggleWorkspaceComposeBar,
onSnippetExecutorChange,
onAddSelectionToAI,
showSelectionAIAction,
}) => {
const layoutSuppressActive = useTerminalLayoutSuppressActive();
const deferPaneLayoutUpdate = isResizing || layoutSuppressActive;
@@ -891,6 +900,7 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
reuseConnectionFromSessionId={session.reuseConnectionFromSessionId}
serialConfig={session.serialConfig}
hotkeyScheme={hotkeyScheme}
disableTerminalFontZoom={disableTerminalFontZoom}
keyBindings={keyBindings}
onHotkeyAction={onHotkeyAction}
onTerminalFontSizeChange={handleTerminalFontSizeChange}
@@ -921,6 +931,7 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
sessionLog={sessionLog}
sshDebugLogEnabled={sshDebugLogEnabled}
sudoAutofillPassword={sudoAutofillPassword}
showSelectionAIAction={showSelectionAIAction}
onAddSelectionToAI={onAddSelectionToAI}
/>
</div>
@@ -953,6 +964,7 @@ interface TerminalPanesHostProps {
customAccent?: string;
terminalSettings?: TerminalSettings;
hotkeyScheme?: 'disabled' | 'mac' | 'pc';
disableTerminalFontZoom?: boolean;
keyBindings?: KeyBinding[];
isResizing: boolean;
isComposeBarOpen: boolean;
@@ -1015,6 +1027,7 @@ const terminalPanesHostPropsAreEqual = (
if (prev.customAccent !== next.customAccent) return false;
if (prev.terminalSettings !== next.terminalSettings) return false;
if (prev.hotkeyScheme !== next.hotkeyScheme) return false;
if (prev.disableTerminalFontZoom !== next.disableTerminalFontZoom) return false;
if (prev.keyBindings !== next.keyBindings) return false;
if (prev.isResizing !== next.isResizing) return false;
if (prev.isComposeBarOpen !== next.isComposeBarOpen) return false;
@@ -1066,22 +1079,30 @@ export const TerminalPanesHost: React.FC<TerminalPanesHostProps> = memo(({
sessionChainHostsMap,
sessionSudoAutofillPasswordsMap,
...sharedProps
}) => (
<>
{sessions.map((session) => {
const host = sessionHostsMap.get(session.id);
if (!host) return null;
return (
<TerminalPane
key={session.id}
session={session}
host={host}
chainHosts={sessionChainHostsMap.get(session.id)}
sudoAutofillPassword={sessionSudoAutofillPasswordsMap.get(session.id)}
{...sharedProps}
/>
);
})}
</>
), terminalPanesHostPropsAreEqual);
}) => {
const [showSelectionAIAction] = useStoredBoolean(
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
true,
);
return (
<>
{sessions.map((session) => {
const host = sessionHostsMap.get(session.id);
if (!host) return null;
return (
<TerminalPane
key={session.id}
session={session}
host={host}
chainHosts={sessionChainHostsMap.get(session.id)}
sudoAutofillPassword={sessionSudoAutofillPasswordsMap.get(session.id)}
showSelectionAIAction={showSelectionAIAction}
{...sharedProps}
/>
);
})}
</>
);
}, terminalPanesHostPropsAreEqual);
TerminalPanesHost.displayName = 'TerminalPanesHost';

View File

@@ -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,

View File

@@ -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}

View File

@@ -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'];

View File

@@ -241,6 +241,7 @@ const WORKSPACE_CTX_KEYS = [
'customAccent',
'terminalSettings',
'hotkeyScheme',
'disableTerminalFontZoom',
'keyBindings',
'resizing',
'isComposeBarOpen',

View File

@@ -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 &&

View File

@@ -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, {

View File

@@ -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,

View File

@@ -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'],
);
});

View File

@@ -8,6 +8,14 @@ export function isNetcattyAiHistoryCommand(command: string): boolean {
return command.includes(NETCATTY_AI_HISTORY_MARKER);
}
const NETCATTY_MANAGED_STARTUP_COMMAND =
/^printf '\\033\[H\\033\[2J\\033\[3J';\s*exec\s+(?:docker\s+(?:exec|logs)\b|tmux\s+attach\b)/;
/** True when a shell history line came from a Netcatty-managed terminal launch. */
export function isNetcattyManagedStartupHistoryCommand(command: string): boolean {
return NETCATTY_MANAGED_STARTUP_COMMAND.test(command.trim());
}
const ZSH_EXTENDED_RECORD = /^: (\d+):\d+;([\s\S]*)$/;
// fish_history is a YAML subset: each record starts with `- cmd: <value>`,
// optionally followed by ` when: <epoch>` and a ` paths:` block.
@@ -215,6 +223,7 @@ export function mergeRemoteHistory(
const merged: RemoteHistoryEntry[] = [];
for (const { entry } of indexed) {
if (isNetcattyAiHistoryCommand(entry.command)) continue;
if (isNetcattyManagedStartupHistoryCommand(entry.command)) continue;
if (seen.has(entry.command)) continue;
seen.add(entry.command);
merged.push(entry);

View File

@@ -251,6 +251,8 @@ export interface SyncPayload {
showSftpTab?: boolean;
// Shortcuts: Cmd/Ctrl+[1...9] skip pinned Vault/SFTP tabs
shellOnlyTabNumberShortcuts?: boolean;
// Shortcuts: disable terminal font zoom shortcuts
disableTerminalFontZoom?: boolean;
// Terminal/editor tabs: show left host list sidebar
showHostTreeSidebar?: boolean;
// Workspace focus indicator style
@@ -273,6 +275,7 @@ export interface SyncPayload {
agentProviderMap?: Record<string, string>;
webSearchConfig?: Record<string, unknown> | null;
quickMessages?: Array<Record<string, unknown>>;
showTerminalSelectionAction?: boolean;
};
};

View File

@@ -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/**/*',

View File

@@ -0,0 +1,22 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
filterExcludedFigSpecs,
isExcludedFigSpec,
} = require("../main/registerBridges.cjs");
test("filters cloud fig specs removed from packaged builds", () => {
assert.equal(isExcludedFigSpec("aws"), true);
assert.equal(isExcludedFigSpec("aws/s3"), true);
assert.equal(isExcludedFigSpec("gcloud"), true);
assert.equal(isExcludedFigSpec("gcloud/compute"), true);
assert.equal(isExcludedFigSpec("az"), true);
assert.equal(isExcludedFigSpec("az/2.53.0"), true);
assert.equal(isExcludedFigSpec("aws-vault"), false);
assert.deepEqual(
filterExcludedFigSpecs(["git", "aws", "aws/s3", "gcloud", "az/2.53.0", "aws-vault"]),
["git", "aws-vault"],
);
});

View File

@@ -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("");

View File

@@ -0,0 +1,48 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const { createSystemManagerBridge } = require("./systemManagerBridge.cjs");
function createFakeExecStream(stdout) {
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
process.nextTick(() => {
stream.emit("data", stdout);
stream.emit("close", 0);
});
return stream;
}
test("listProcesses uses a ps format that works on CentOS 7 procps", async () => {
const compatiblePsFormat = "ps -eo pid= -o ppid= -o user= -o stat= -o pcpu= -o pmem= -o rss= -o vsz= -o etime= -o args=";
const badCentos7Output = [
",ppid=,user=,stat=,pcpu=,pmem=,rss=,vsz=,etime=,args=",
" 1",
].join("\n");
const compatibleOutput = [
" 1 0 root Ss 0.0 0.0 4060 191024 2-19:23:42 /usr/lib/systemd/systemd --switched-root --system --deserialize 21",
].join("\n");
const conn = {
exec(command, callback) {
const stdout = command.includes(compatiblePsFormat)
? compatibleOutput
: badCentos7Output;
callback(null, createFakeExecStream(stdout));
},
};
const sessions = new Map([["s1", { conn, type: "ssh" }]]);
const bridge = createSystemManagerBridge({
getSessions: () => sessions,
process,
});
const result = await bridge.listProcesses(null, { sessionId: "s1" });
assert.equal(result.success, true);
assert.equal(result.processes.length, 1);
assert.equal(result.processes[0].pid, 1);
assert.equal(result.processes[0].command, "/usr/lib/systemd/systemd --switched-root --system --deserialize 21");
});

View File

@@ -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,

View File

@@ -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 });
}
});

View File

@@ -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" },

View File

@@ -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 = [];

View File

@@ -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,

View File

@@ -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();

View File

@@ -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, {

View File

@@ -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/,
);
});

View File

@@ -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 = {

View File

@@ -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 };

View File

@@ -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");
},

View File

@@ -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';

View File

@@ -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<ZmodemDragDropFile[]> {

469
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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;

View File

@@ -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);
});

View File

@@ -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<string>;
discoverShells?(): Promise<DiscoveredShell[]>;
validatePath?(path: string, type?: 'file' | 'directory' | 'any'): Promise<{ exists: boolean; isFile: boolean; isDirectory: boolean; isExecutable: boolean }>;

View File

@@ -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': [