Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7771592cf2 | ||
|
|
6e9e8fc40d | ||
|
|
67448cea65 | ||
|
|
770b06a9ee | ||
|
|
1d50b2c4a1 | ||
|
|
453202df8f | ||
|
|
a78c052d86 | ||
|
|
e6b0a551e8 | ||
|
|
38775245d2 | ||
|
|
fcb699ffb9 | ||
|
|
e889d8fc20 | ||
|
|
bf1c95500a | ||
|
|
f9d00c9d23 | ||
|
|
8fd7ff6475 | ||
|
|
02c80ae7d2 | ||
|
|
e5d3d02b17 | ||
|
|
78186d8d46 | ||
|
|
c899653621 | ||
|
|
a91fbcdd68 | ||
|
|
74b315e285 | ||
|
|
60eeafe7a9 | ||
|
|
ee2c21e712 | ||
|
|
e678ad3546 | ||
|
|
c47c780b48 | ||
|
|
88074ac9b3 | ||
|
|
59cb0c4b65 | ||
|
|
bf0bd193eb | ||
|
|
7661375925 | ||
|
|
308fb45985 |
15
App.tsx
15
App.tsx
@@ -1197,12 +1197,11 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const addConnectionLogRef = useRef(addConnectionLog);
|
||||
addConnectionLogRef.current = addConnectionLog;
|
||||
|
||||
const closeSidePanelRef = useRef<(() => void) | null>(null);
|
||||
const toggleScriptsSidePanelRef = useRef<(() => void) | null>(null);
|
||||
const toggleSidePanelRef = useRef<(() => void) | null>(null);
|
||||
// Populated below so the hotkey dispatcher can open the Settings window
|
||||
// even though `handleOpenSettings` is declared further down in the file.
|
||||
const handleOpenSettingsRef = useRef<() => void>(() => {});
|
||||
const activeSidePanelTabRef = useRef<string | null>(null);
|
||||
const closeTabInFlightRef = useRef(false);
|
||||
// Populated by UnsavedChangesProvider render-prop below so that the hotkey
|
||||
// dispatcher (defined outside that scope) can still reach the dirty-confirm
|
||||
@@ -1382,13 +1381,11 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const workspace = workspaces.find((w) => w.id === currentId) ?? null;
|
||||
|
||||
const focusIsInsideTerminal = !!document.activeElement?.closest('[data-session-id]');
|
||||
const activeSidePanel = activeSidePanelTabRef.current;
|
||||
|
||||
const intent = resolveCloseIntent({
|
||||
activeTabId: currentId,
|
||||
workspace: workspace ? { id: workspace.id, focusedSessionId: workspace.focusedSessionId } : null,
|
||||
sessionForTab: session,
|
||||
activeSidePanelTab: activeSidePanel,
|
||||
focusIsInsideTerminal,
|
||||
});
|
||||
|
||||
@@ -1402,10 +1399,6 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
if (ok) closeSession(intent.sessionId);
|
||||
return;
|
||||
}
|
||||
case 'closeSidePanel': {
|
||||
closeSidePanelRef.current?.();
|
||||
return;
|
||||
}
|
||||
case 'closeWorkspace': {
|
||||
const ids = sessions.filter((s) => s.workspaceId === intent.workspaceId).map((s) => s.id);
|
||||
const ok = await confirmIfBusyLocalTerminal(ids);
|
||||
@@ -1481,6 +1474,9 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
setNavigateToSection('snippets');
|
||||
}
|
||||
break;
|
||||
case 'toggleSidePanel':
|
||||
toggleSidePanelRef.current?.();
|
||||
break;
|
||||
case 'broadcast': {
|
||||
// Toggle broadcast mode for the active workspace
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
@@ -2147,9 +2143,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
sessionLogsEnabled={sessionLogsEnabled}
|
||||
sessionLogsDir={sessionLogsDir}
|
||||
sessionLogsFormat={sessionLogsFormat}
|
||||
closeSidePanelRef={closeSidePanelRef}
|
||||
toggleScriptsSidePanelRef={toggleScriptsSidePanelRef}
|
||||
activeSidePanelTabRef={activeSidePanelTabRef}
|
||||
toggleSidePanelRef={toggleSidePanelRef}
|
||||
/>
|
||||
|
||||
{/* Log Views - readonly terminal replays */}
|
||||
|
||||
@@ -264,6 +264,10 @@ const en: Messages = {
|
||||
'settings.terminal.theme.selectButton': 'Select Theme',
|
||||
'settings.terminal.theme.followApp': 'Follow Application Theme',
|
||||
'settings.terminal.theme.followApp.desc': 'Automatically match the terminal background to the current app theme for a seamless look.',
|
||||
'settings.terminal.theme.darkTheme': 'Dark mode terminal theme',
|
||||
'settings.terminal.theme.lightTheme': 'Light mode terminal theme',
|
||||
'settings.terminal.theme.auto': 'Auto (match app theme)',
|
||||
'settings.terminal.theme.autoDesc': 'Follows the active UI theme preset',
|
||||
'settings.terminal.section.font': 'Font',
|
||||
'settings.terminal.section.cursor': 'Cursor',
|
||||
'settings.terminal.section.keyboard': 'Keyboard',
|
||||
@@ -301,6 +305,9 @@ const en: Messages = {
|
||||
'settings.terminal.keyboard.altAsMeta': 'Use Option as Meta key',
|
||||
'settings.terminal.keyboard.altAsMeta.desc':
|
||||
'Use Option (Alt) as the Meta key instead of for special characters',
|
||||
'settings.terminal.keyboard.optionArrowWordJump': 'Option+←/→ jumps by word',
|
||||
'settings.terminal.keyboard.optionArrowWordJump.desc':
|
||||
'Send Meta-b / Meta-f on Option+Left/Right so the shell moves by word, instead of the default ^[[1;3D / ^[[1;3C',
|
||||
'settings.terminal.accessibility.minimumContrastRatio': 'Minimum contrast ratio',
|
||||
'settings.terminal.accessibility.minimumContrastRatio.desc':
|
||||
'Adjust colors to meet contrast requirements (1 = disabled, 21 = max)',
|
||||
@@ -359,6 +366,9 @@ const en: Messages = {
|
||||
'settings.terminal.behavior.linkModifier.meta': 'Cmd / Win',
|
||||
'settings.terminal.scrollback.desc': 'Limit number of terminal rows. Set to 0 for no limit.',
|
||||
'settings.terminal.scrollback.rows': 'Number of rows *',
|
||||
'settings.terminal.section.startupCommand': 'Startup command',
|
||||
'settings.terminal.startupCommandDelay.label': 'Startup command delay (ms)',
|
||||
'settings.terminal.startupCommandDelay.desc': 'How long to wait after connecting before sending the startup command. Also used between lines when the startup command has multiple lines. Increase for slow connections.',
|
||||
'settings.terminal.keywordHighlight.title': 'Keyword highlighting',
|
||||
'settings.terminal.keywordHighlight.resetColors': 'Reset to default colors',
|
||||
'settings.terminal.keywordHighlight.resetDefaults': 'Reset built-ins to defaults',
|
||||
@@ -1942,6 +1952,13 @@ const en: Messages = {
|
||||
'ai.claude.path': 'Path:',
|
||||
'ai.claude.notFoundHint': 'Could not find claude in PATH. Install it or specify the executable path below.',
|
||||
'ai.claude.customPathPlaceholder': 'e.g. /usr/local/bin/claude',
|
||||
'ai.claude.configSection': 'Authentication & config (optional)',
|
||||
'ai.claude.configDir': 'Config directory',
|
||||
'ai.claude.configDir.placeholder': '~/.claude (leave blank for default)',
|
||||
'ai.claude.configDir.hint': 'Sets CLAUDE_CONFIG_DIR — point at a folder where you have run `claude` login (contains settings.json + credentials).',
|
||||
'ai.claude.envVars': 'Environment variables',
|
||||
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
|
||||
'ai.claude.envVars.hint': 'One KEY=VALUE per line, passed to the Claude agent. Stored locally in plaintext — for API keys / credentials, prefer the config directory above (a `claude` login).',
|
||||
'ai.claude.check': 'Check',
|
||||
|
||||
// AI GitHub Copilot CLI
|
||||
@@ -2092,6 +2109,11 @@ const en: Messages = {
|
||||
'zmodem.uploading': 'Uploading',
|
||||
'zmodem.downloading': 'Downloading',
|
||||
'zmodem.cancelTransfer': 'Cancel transfer (Ctrl+C)',
|
||||
'zmodem.overwrite.title': 'Remote file already exists',
|
||||
'zmodem.overwrite.applyToRest': 'Apply to remaining conflicts',
|
||||
'zmodem.overwrite.overwrite': 'Overwrite',
|
||||
'zmodem.overwrite.skip': 'Skip',
|
||||
'zmodem.overwrite.cancel': 'Cancel',
|
||||
'settings.shortcuts.resetToDefault': 'Reset to default',
|
||||
};
|
||||
|
||||
|
||||
@@ -264,6 +264,10 @@ const ru: Messages = {
|
||||
'settings.terminal.theme.selectButton': 'Выбрать тему',
|
||||
'settings.terminal.theme.followApp': 'Следовать теме приложения',
|
||||
'settings.terminal.theme.followApp.desc': 'Автоматически подбирать фон терминала под текущую тему приложения для более цельного вида.',
|
||||
'settings.terminal.theme.darkTheme': 'Тема терминала для тёмного режима',
|
||||
'settings.terminal.theme.lightTheme': 'Тема терминала для светлого режима',
|
||||
'settings.terminal.theme.auto': 'Авто (как тема приложения)',
|
||||
'settings.terminal.theme.autoDesc': 'Следует активному пресету темы интерфейса',
|
||||
'settings.terminal.section.font': 'Шрифт',
|
||||
'settings.terminal.section.cursor': 'Курсор',
|
||||
'settings.terminal.section.keyboard': 'Клавиатура',
|
||||
@@ -301,6 +305,9 @@ const ru: Messages = {
|
||||
'settings.terminal.keyboard.altAsMeta': 'Использовать Option как клавишу Meta',
|
||||
'settings.terminal.keyboard.altAsMeta.desc':
|
||||
'Использовать Option (Alt) как клавишу Meta вместо ввода специальных символов',
|
||||
'settings.terminal.keyboard.optionArrowWordJump': 'Option+←/→ переход по словам',
|
||||
'settings.terminal.keyboard.optionArrowWordJump.desc':
|
||||
'Отправлять Meta-b / Meta-f при Option+Влево/Вправо, чтобы оболочка перемещалась по словам, вместо стандартного ^[[1;3D / ^[[1;3C',
|
||||
'settings.terminal.accessibility.minimumContrastRatio': 'Минимальный коэффициент контрастности',
|
||||
'settings.terminal.accessibility.minimumContrastRatio.desc':
|
||||
'Подстраивать цвета под требования контрастности (1 = отключено, 21 = максимум)',
|
||||
@@ -359,6 +366,9 @@ const ru: Messages = {
|
||||
'settings.terminal.behavior.linkModifier.meta': 'Cmd / Win',
|
||||
'settings.terminal.scrollback.desc': 'Ограничение количества строк терминала. Установите 0, чтобы снять ограничение.',
|
||||
'settings.terminal.scrollback.rows': 'Количество строк *',
|
||||
'settings.terminal.section.startupCommand': 'Команда запуска',
|
||||
'settings.terminal.startupCommandDelay.label': 'Задержка команды запуска (мс)',
|
||||
'settings.terminal.startupCommandDelay.desc': 'Сколько ждать после подключения перед отправкой команды запуска. Также используется между строками, если команда запуска многострочная. Увеличьте для медленных соединений.',
|
||||
'settings.terminal.keywordHighlight.title': 'Подсветка ключевых слов',
|
||||
'settings.terminal.keywordHighlight.resetColors': 'Сбросить цвета по умолчанию',
|
||||
'settings.terminal.keywordHighlight.resetDefaults': 'Сбросить встроенные правила по умолчанию',
|
||||
@@ -469,6 +479,7 @@ const ru: Messages = {
|
||||
'settings.shortcuts.binding.new-workspace': 'Новая рабочая область',
|
||||
'settings.shortcuts.binding.snippets': 'Открыть сниппеты',
|
||||
'settings.shortcuts.binding.broadcast': 'Переключить режим трансляции',
|
||||
'settings.shortcuts.binding.toggle-side-panel': 'Переключить боковую панель',
|
||||
'settings.shortcuts.binding.sftp-copy': 'Копировать файл',
|
||||
'settings.shortcuts.binding.sftp-cut': 'Вырезать файл',
|
||||
'settings.shortcuts.binding.sftp-paste': 'Вставить файл',
|
||||
@@ -1974,6 +1985,13 @@ const ru: Messages = {
|
||||
'ai.claude.path': 'Путь:',
|
||||
'ai.claude.notFoundHint': 'Не удалось найти claude в PATH. Установите его или укажите путь к исполняемому файлу ниже.',
|
||||
'ai.claude.customPathPlaceholder': 'например, /usr/local/bin/claude',
|
||||
'ai.claude.configSection': 'Аутентификация и конфигурация (опционально)',
|
||||
'ai.claude.configDir': 'Каталог конфигурации',
|
||||
'ai.claude.configDir.placeholder': '~/.claude (пусто — по умолчанию)',
|
||||
'ai.claude.configDir.hint': 'Задаёт CLAUDE_CONFIG_DIR — укажите папку, где выполнен вход `claude` (содержит settings.json и учётные данные).',
|
||||
'ai.claude.envVars': 'Переменные окружения',
|
||||
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
|
||||
'ai.claude.envVars.hint': 'По одному KEY=VALUE в строке, передаётся агенту Claude. Хранится локально в открытом виде — для API-ключей и учётных данных используйте «Каталог конфигурации» выше (вход `claude`).',
|
||||
'ai.claude.check': 'Проверить',
|
||||
|
||||
// AI GitHub Copilot CLI
|
||||
@@ -2124,6 +2142,11 @@ const ru: Messages = {
|
||||
'zmodem.uploading': 'Загрузка',
|
||||
'zmodem.downloading': 'Скачивание',
|
||||
'zmodem.cancelTransfer': 'Отменить передачу (Ctrl+C)',
|
||||
'zmodem.overwrite.title': 'Remote file already exists',
|
||||
'zmodem.overwrite.applyToRest': 'Apply to remaining conflicts',
|
||||
'zmodem.overwrite.overwrite': 'Overwrite',
|
||||
'zmodem.overwrite.skip': 'Skip',
|
||||
'zmodem.overwrite.cancel': 'Cancel',
|
||||
'settings.shortcuts.resetToDefault': 'Сбросить по умолчанию',
|
||||
};
|
||||
|
||||
|
||||
@@ -1409,6 +1409,10 @@ const zhCN: Messages = {
|
||||
'settings.terminal.theme.selectButton': '选择主题',
|
||||
'settings.terminal.theme.followApp': '跟随应用主题',
|
||||
'settings.terminal.theme.followApp.desc': '终端背景色自动匹配当前应用主题,保持视觉一致性。',
|
||||
'settings.terminal.theme.darkTheme': '深色模式终端主题',
|
||||
'settings.terminal.theme.lightTheme': '浅色模式终端主题',
|
||||
'settings.terminal.theme.auto': '自动(跟随界面主题)',
|
||||
'settings.terminal.theme.autoDesc': '跟随当前界面主题预设',
|
||||
'settings.terminal.section.font': '字体',
|
||||
'settings.terminal.section.cursor': '光标',
|
||||
'settings.terminal.section.keyboard': '键盘',
|
||||
@@ -1445,6 +1449,8 @@ const zhCN: Messages = {
|
||||
'settings.terminal.cursor.blink': '光标闪烁',
|
||||
'settings.terminal.keyboard.altAsMeta': '将 Option 作为 Meta 键',
|
||||
'settings.terminal.keyboard.altAsMeta.desc': '使用 Option (Alt) 作为 Meta 键,而不是用于输入特殊字符',
|
||||
'settings.terminal.keyboard.optionArrowWordJump': 'Option+←/→ 按单词跳转',
|
||||
'settings.terminal.keyboard.optionArrowWordJump.desc': '按 Option+左/右 时发送 Meta-b / Meta-f,让 Shell 按单词移动光标(而非默认的 ^[[1;3D / ^[[1;3C)',
|
||||
'settings.terminal.accessibility.minimumContrastRatio': '最小对比度',
|
||||
'settings.terminal.accessibility.minimumContrastRatio.desc': '调整颜色以满足对比度要求 (1 = 禁用, 21 = 最大)',
|
||||
'settings.terminal.behavior.rightClick': '右键行为',
|
||||
@@ -1497,6 +1503,9 @@ const zhCN: Messages = {
|
||||
'settings.terminal.behavior.linkModifier.meta': 'Cmd / Win',
|
||||
'settings.terminal.scrollback.desc': '限制终端行数。设为 0 表示不限制。',
|
||||
'settings.terminal.scrollback.rows': '行数 *',
|
||||
'settings.terminal.section.startupCommand': '启动命令',
|
||||
'settings.terminal.startupCommandDelay.label': '启动命令延迟(毫秒)',
|
||||
'settings.terminal.startupCommandDelay.desc': '连接建立后等待多久再发送启动命令;启动命令为多行时,行与行之间也使用该间隔。慢连接可调大。',
|
||||
'settings.terminal.keywordHighlight.title': '关键字高亮',
|
||||
'settings.terminal.keywordHighlight.resetColors': '重置为默认颜色',
|
||||
'settings.terminal.keywordHighlight.resetDefaults': '把内置规则恢复为默认',
|
||||
@@ -1597,6 +1606,7 @@ const zhCN: Messages = {
|
||||
'settings.shortcuts.binding.new-workspace': '新建工作区',
|
||||
'settings.shortcuts.binding.snippets': '打开代码片段',
|
||||
'settings.shortcuts.binding.broadcast': '切换广播模式',
|
||||
'settings.shortcuts.binding.toggle-side-panel': '切换侧边栏',
|
||||
'settings.shortcuts.binding.sftp-copy': '复制文件',
|
||||
'settings.shortcuts.binding.sftp-cut': '剪切文件',
|
||||
'settings.shortcuts.binding.sftp-paste': '粘贴文件',
|
||||
@@ -1951,6 +1961,13 @@ const zhCN: Messages = {
|
||||
'ai.claude.path': '路径:',
|
||||
'ai.claude.notFoundHint': '在 PATH 中未找到 claude。请安装或在下方指定可执行文件路径。',
|
||||
'ai.claude.customPathPlaceholder': '例如 /usr/local/bin/claude',
|
||||
'ai.claude.configSection': '认证与配置(可选)',
|
||||
'ai.claude.configDir': '配置目录',
|
||||
'ai.claude.configDir.placeholder': '~/.claude(留空用默认)',
|
||||
'ai.claude.configDir.hint': '设置 CLAUDE_CONFIG_DIR —— 指向你已运行 `claude` 登录的目录(含 settings.json 和凭据)。',
|
||||
'ai.claude.envVars': '环境变量',
|
||||
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
|
||||
'ai.claude.envVars.hint': '每行一个 KEY=VALUE,传给 Claude agent。明文存在本地——API key/凭据建议用上面的「配置目录」(claude 登录),不要放这里。',
|
||||
'ai.claude.check': '检查',
|
||||
|
||||
// AI GitHub Copilot CLI
|
||||
@@ -2101,6 +2118,11 @@ const zhCN: Messages = {
|
||||
'zmodem.uploading': '上传中',
|
||||
'zmodem.downloading': '下载中',
|
||||
'zmodem.cancelTransfer': '取消传输 (Ctrl+C)',
|
||||
'zmodem.overwrite.title': '远端已存在同名文件',
|
||||
'zmodem.overwrite.applyToRest': '应用到其余冲突文件',
|
||||
'zmodem.overwrite.overwrite': '覆盖',
|
||||
'zmodem.overwrite.skip': '跳过',
|
||||
'zmodem.overwrite.cancel': '取消',
|
||||
'settings.shortcuts.resetToDefault': '重置为默认',
|
||||
};
|
||||
|
||||
|
||||
111
application/state/autoSyncRemoteSchedule.test.ts
Normal file
111
application/state/autoSyncRemoteSchedule.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
getRuntimeRemoteCheckIntervalMs,
|
||||
shouldRunRuntimeRemoteCheck,
|
||||
} from './autoSyncRemoteSchedule';
|
||||
|
||||
test("runtime remote checks wait for the startup check to finish", () => {
|
||||
assert.equal(
|
||||
shouldRunRuntimeRemoteCheck({
|
||||
hasAnyConnectedProvider: true,
|
||||
autoSyncEnabled: true,
|
||||
isUnlocked: true,
|
||||
startupRemoteCheckDone: false,
|
||||
isSyncing: false,
|
||||
isSyncRunning: false,
|
||||
remoteCheckInFlight: false,
|
||||
now: 10_000,
|
||||
lastRemoteCheckAt: null,
|
||||
minIntervalMs: 30_000,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("runtime remote checks run immediately after startup gate opens", () => {
|
||||
assert.equal(
|
||||
shouldRunRuntimeRemoteCheck({
|
||||
hasAnyConnectedProvider: true,
|
||||
autoSyncEnabled: true,
|
||||
isUnlocked: true,
|
||||
startupRemoteCheckDone: true,
|
||||
isSyncing: false,
|
||||
isSyncRunning: false,
|
||||
remoteCheckInFlight: false,
|
||||
now: 10_000,
|
||||
lastRemoteCheckAt: null,
|
||||
minIntervalMs: 30_000,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("runtime remote checks respect the minimum interval", () => {
|
||||
const common = {
|
||||
hasAnyConnectedProvider: true,
|
||||
autoSyncEnabled: true,
|
||||
isUnlocked: true,
|
||||
startupRemoteCheckDone: true,
|
||||
isSyncing: false,
|
||||
isSyncRunning: false,
|
||||
remoteCheckInFlight: false,
|
||||
minIntervalMs: 30_000,
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
shouldRunRuntimeRemoteCheck({
|
||||
...common,
|
||||
now: 35_000,
|
||||
lastRemoteCheckAt: 10_000,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldRunRuntimeRemoteCheck({
|
||||
...common,
|
||||
now: 40_000,
|
||||
lastRemoteCheckAt: 10_000,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("forced runtime remote checks bypass only the interval gate", () => {
|
||||
const common = {
|
||||
hasAnyConnectedProvider: true,
|
||||
autoSyncEnabled: true,
|
||||
isUnlocked: true,
|
||||
startupRemoteCheckDone: true,
|
||||
isSyncing: false,
|
||||
isSyncRunning: false,
|
||||
remoteCheckInFlight: false,
|
||||
minIntervalMs: 30_000,
|
||||
force: true,
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
shouldRunRuntimeRemoteCheck({
|
||||
...common,
|
||||
now: 35_000,
|
||||
lastRemoteCheckAt: 10_000,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldRunRuntimeRemoteCheck({
|
||||
...common,
|
||||
isSyncing: true,
|
||||
now: 35_000,
|
||||
lastRemoteCheckAt: 10_000,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("configured auto-sync intervals map to bounded remote recheck intervals", () => {
|
||||
assert.equal(getRuntimeRemoteCheckIntervalMs(1), 30_000);
|
||||
assert.equal(getRuntimeRemoteCheckIntervalMs(10), 300_000);
|
||||
assert.equal(getRuntimeRemoteCheckIntervalMs(120), 300_000);
|
||||
});
|
||||
35
application/state/autoSyncRemoteSchedule.ts
Normal file
35
application/state/autoSyncRemoteSchedule.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
const MIN_RUNTIME_REMOTE_CHECK_MS = 30_000;
|
||||
const MAX_RUNTIME_REMOTE_CHECK_MS = 5 * 60_000;
|
||||
|
||||
export function getRuntimeRemoteCheckIntervalMs(autoSyncIntervalMinutes: number): number {
|
||||
const configuredMs = Math.max(1, Number(autoSyncIntervalMinutes) || 1) * 60_000;
|
||||
return Math.max(
|
||||
MIN_RUNTIME_REMOTE_CHECK_MS,
|
||||
Math.min(MAX_RUNTIME_REMOTE_CHECK_MS, Math.floor(configuredMs / 2)),
|
||||
);
|
||||
}
|
||||
|
||||
export interface RuntimeRemoteCheckInput {
|
||||
hasAnyConnectedProvider: boolean;
|
||||
autoSyncEnabled: boolean;
|
||||
isUnlocked: boolean;
|
||||
startupRemoteCheckDone: boolean;
|
||||
isSyncing: boolean;
|
||||
isSyncRunning: boolean;
|
||||
remoteCheckInFlight: boolean;
|
||||
force?: boolean;
|
||||
now: number;
|
||||
lastRemoteCheckAt: number | null;
|
||||
minIntervalMs: number;
|
||||
}
|
||||
|
||||
export function shouldRunRuntimeRemoteCheck(input: RuntimeRemoteCheckInput): boolean {
|
||||
if (!input.hasAnyConnectedProvider) return false;
|
||||
if (!input.autoSyncEnabled) return false;
|
||||
if (!input.isUnlocked) return false;
|
||||
if (!input.startupRemoteCheckDone) return false;
|
||||
if (input.isSyncing || input.isSyncRunning || input.remoteCheckInFlight) return false;
|
||||
if (input.force === true) return true;
|
||||
if (input.lastRemoteCheckAt == null) return true;
|
||||
return input.now - input.lastRemoteCheckAt >= input.minIntervalMs;
|
||||
}
|
||||
@@ -3,33 +3,27 @@ import assert from "node:assert/strict";
|
||||
|
||||
import { resolveCloseIntent } from "./resolveCloseIntent.ts";
|
||||
|
||||
const baseWorkspace = {
|
||||
id: "w1",
|
||||
focusedSessionId: "s1",
|
||||
};
|
||||
|
||||
const baseWorkspace = { id: "w1", focusedSessionId: "s1" };
|
||||
const baseSession = { id: "s1" };
|
||||
|
||||
test("non-workspace tab → closeSingleTab with session id", () => {
|
||||
const result = resolveCloseIntent({
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "s1",
|
||||
workspace: null,
|
||||
sessionForTab: baseSession,
|
||||
activeSidePanelTab: null,
|
||||
focusIsInsideTerminal: true,
|
||||
});
|
||||
assert.deepEqual(result, { kind: "closeSingleTab", sessionId: "s1" });
|
||||
assert.deepEqual(r, { kind: "closeSingleTab", sessionId: "s1" });
|
||||
});
|
||||
|
||||
test("non-workspace session tab + sidebar open → closeSidePanel (sidebar beats session close)", () => {
|
||||
test("non-workspace session tab → closeSingleTab even when focus is outside the terminal", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "s1",
|
||||
workspace: null,
|
||||
sessionForTab: { id: "s1" },
|
||||
activeSidePanelTab: "ai",
|
||||
focusIsInsideTerminal: true, // focus IS in terminal, but sidebar wins
|
||||
focusIsInsideTerminal: false,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeSidePanel" });
|
||||
assert.deepEqual(r, { kind: "closeSingleTab", sessionId: "s1" });
|
||||
});
|
||||
|
||||
test("vault/sftp tab → noop", () => {
|
||||
@@ -37,74 +31,37 @@ test("vault/sftp tab → noop", () => {
|
||||
activeTabId: "vault",
|
||||
workspace: null,
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: null,
|
||||
focusIsInsideTerminal: false,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "noop" });
|
||||
});
|
||||
|
||||
test("workspace + focus in terminal + sidebar open → closeSidePanel wins (sidebar beats focus)", () => {
|
||||
test("workspace + focus in terminal → closeTerminal (side panel no longer intercepts)", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: baseWorkspace,
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: "ai",
|
||||
focusIsInsideTerminal: true,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeSidePanel" });
|
||||
});
|
||||
|
||||
test("workspace + focus NOT in terminal + sidebar open → closeSidePanel", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: baseWorkspace,
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: "sftp",
|
||||
focusIsInsideTerminal: false,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeSidePanel" });
|
||||
});
|
||||
|
||||
test("workspace + sidebar closed + focus in terminal → closeTerminal", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: baseWorkspace,
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: null,
|
||||
focusIsInsideTerminal: true,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeTerminal", sessionId: "s1" });
|
||||
});
|
||||
|
||||
test("workspace + sidebar closed + focus NOT in terminal → closeWorkspace", () => {
|
||||
test("workspace + focus NOT in terminal → closeWorkspace", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: baseWorkspace,
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: null,
|
||||
focusIsInsideTerminal: false,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeWorkspace", workspaceId: "w1" });
|
||||
});
|
||||
|
||||
test("workspace with no focused session + sidebar closed → closeWorkspace", () => {
|
||||
test("workspace with no focused session → closeWorkspace", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: { id: "w1", focusedSessionId: undefined },
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: null,
|
||||
focusIsInsideTerminal: true, // even if flag true, no focused id → cannot closeTerminal
|
||||
focusIsInsideTerminal: true,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeWorkspace", workspaceId: "w1" });
|
||||
});
|
||||
|
||||
test("workspace with no focused session + sidebar open → closeSidePanel", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: { id: "w1", focusedSessionId: undefined },
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: "ai",
|
||||
focusIsInsideTerminal: false,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeSidePanel" });
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export type CloseIntent =
|
||||
| { kind: 'closeTerminal'; sessionId: string }
|
||||
| { kind: 'closeSidePanel' }
|
||||
| { kind: 'closeWorkspace'; workspaceId: string }
|
||||
| { kind: 'closeSingleTab'; sessionId: string }
|
||||
| { kind: 'noop' };
|
||||
@@ -9,22 +8,14 @@ export interface ResolveCloseInput {
|
||||
activeTabId: string | null;
|
||||
workspace: { id: string; focusedSessionId?: string } | null;
|
||||
sessionForTab: { id: string } | null;
|
||||
activeSidePanelTab: string | null;
|
||||
focusIsInsideTerminal: boolean;
|
||||
}
|
||||
|
||||
export function resolveCloseIntent(input: ResolveCloseInput): CloseIntent {
|
||||
const { activeTabId, workspace, sessionForTab, activeSidePanelTab, focusIsInsideTerminal } = input;
|
||||
const { activeTabId, workspace, sessionForTab, focusIsInsideTerminal } = input;
|
||||
|
||||
if (!activeTabId) return { kind: 'noop' };
|
||||
|
||||
// Sidebar always wins — applies to any tab type (workspace, single-session, etc.).
|
||||
// Modals take priority over this but are intercepted upstream in App.tsx before the
|
||||
// hotkey reaches resolveCloseIntent.
|
||||
if (activeSidePanelTab !== null) {
|
||||
return { kind: 'closeSidePanel' };
|
||||
}
|
||||
|
||||
if (sessionForTab && !workspace) {
|
||||
return { kind: 'closeSingleTab', sessionId: sessionForTab.id };
|
||||
}
|
||||
|
||||
19
application/state/resolveSidePanelToggleIntent.test.ts
Normal file
19
application/state/resolveSidePanelToggleIntent.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { resolveSidePanelToggleIntent } from "./resolveSidePanelToggleIntent.ts";
|
||||
|
||||
test("open: closed with a remembered tab → open that tab", () => {
|
||||
const r = resolveSidePanelToggleIntent({ isOpen: false, lastTab: "sftp", fallbackTab: "scripts" });
|
||||
assert.deepEqual(r, { kind: "open", tab: "sftp" });
|
||||
});
|
||||
|
||||
test("open: closed with no memory → open the fallback tab", () => {
|
||||
const r = resolveSidePanelToggleIntent({ isOpen: false, lastTab: null, fallbackTab: "scripts" });
|
||||
assert.deepEqual(r, { kind: "open", tab: "scripts" });
|
||||
});
|
||||
|
||||
test("close: already open → close", () => {
|
||||
const r = resolveSidePanelToggleIntent({ isOpen: true, lastTab: "theme", fallbackTab: "sftp" });
|
||||
assert.deepEqual(r, { kind: "close" });
|
||||
});
|
||||
18
application/state/resolveSidePanelToggleIntent.ts
Normal file
18
application/state/resolveSidePanelToggleIntent.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export type SidePanelToggleIntent<T extends string> =
|
||||
| { kind: 'close' }
|
||||
| { kind: 'open'; tab: T };
|
||||
|
||||
/**
|
||||
* Decide what the "toggle side panel" shortcut should do.
|
||||
* - If a panel is open → close it.
|
||||
* - If closed → reopen the last-shown sub-panel for the tab, falling back to
|
||||
* `fallbackTab` when the tab has no remembered panel.
|
||||
*/
|
||||
export function resolveSidePanelToggleIntent<T extends string>(input: {
|
||||
isOpen: boolean;
|
||||
lastTab: T | null;
|
||||
fallbackTab: T;
|
||||
}): SidePanelToggleIntent<T> {
|
||||
if (input.isOpen) return { kind: 'close' };
|
||||
return { kind: 'open', tab: input.lastTab ?? input.fallbackTab };
|
||||
}
|
||||
@@ -3,10 +3,10 @@ import assert from "node:assert/strict";
|
||||
|
||||
import { resolveTerminalSessionExitIntent } from "./resolveTerminalSessionExitIntent.ts";
|
||||
|
||||
test("backend exited events keep the tab and mark it disconnected", () => {
|
||||
test("normal backend exited events close the session tab", () => {
|
||||
assert.deepEqual(
|
||||
resolveTerminalSessionExitIntent({ reason: "exited", exitCode: 0 }),
|
||||
{ kind: "markDisconnected" },
|
||||
{ kind: "closeSession" },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -16,3 +16,17 @@ test("backend timeout events keep the tab and mark it disconnected", () => {
|
||||
{ kind: "markDisconnected" },
|
||||
);
|
||||
});
|
||||
|
||||
test("backend error events keep the tab and mark it disconnected", () => {
|
||||
assert.deepEqual(
|
||||
resolveTerminalSessionExitIntent({ reason: "error", error: "connection reset" }),
|
||||
{ kind: "markDisconnected" },
|
||||
);
|
||||
});
|
||||
|
||||
test("backend closed events keep the tab and mark it disconnected", () => {
|
||||
assert.deepEqual(
|
||||
resolveTerminalSessionExitIntent({ reason: "closed", exitCode: 0 }),
|
||||
{ kind: "markDisconnected" },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -6,12 +6,17 @@ export type TerminalSessionExitEvent = {
|
||||
};
|
||||
|
||||
export type TerminalSessionExitIntent =
|
||||
| { kind: "closeSession" }
|
||||
| { kind: "markDisconnected" };
|
||||
|
||||
export function resolveTerminalSessionExitIntent(
|
||||
_evt: TerminalSessionExitEvent,
|
||||
evt: TerminalSessionExitEvent,
|
||||
): TerminalSessionExitIntent {
|
||||
// Backend exits can be remote idle timeouts, shell termination, or transport closes.
|
||||
// Explicit user closes bypass this policy and call the close-session path directly.
|
||||
if (evt.reason === "exited") {
|
||||
return { kind: "closeSession" };
|
||||
}
|
||||
|
||||
// Timeouts, transport errors, and channel closes should keep the tab visible
|
||||
// so the user can inspect output and reconnect.
|
||||
return { kind: "markDisconnected" };
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
findSyncPayloadEncryptedCredentialPaths,
|
||||
} from '../../domain/credentials';
|
||||
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
|
||||
import { mergeSyncPayloads } from '../../domain/syncMerge';
|
||||
import {
|
||||
SYNCABLE_SETTING_STORAGE_KEYS,
|
||||
collectSyncableSettings,
|
||||
@@ -31,6 +32,10 @@ import {
|
||||
localStorageAdapter,
|
||||
} from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { notify } from '../notification';
|
||||
import {
|
||||
getRuntimeRemoteCheckIntervalMs,
|
||||
shouldRunRuntimeRemoteCheck,
|
||||
} from './autoSyncRemoteSchedule';
|
||||
|
||||
interface AutoSyncConfig {
|
||||
// Data to sync
|
||||
@@ -95,6 +100,11 @@ interface SyncNowOptions {
|
||||
trigger?: SyncTrigger;
|
||||
}
|
||||
|
||||
interface RemoteVersionCheckOptions {
|
||||
force?: boolean;
|
||||
notifyOnFailure?: boolean;
|
||||
}
|
||||
|
||||
export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
const { t } = useI18n();
|
||||
const sync = useCloudSync();
|
||||
@@ -402,17 +412,20 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// windows but does NOT serialize same-window re-entry, so this
|
||||
// in-flight guard closes that gap at the top of the call.
|
||||
const checkRemoteInFlightRef = useRef(false);
|
||||
const lastRuntimeRemoteCheckAtRef = useRef<number | null>(null);
|
||||
|
||||
// Check remote version and pull if newer (on startup)
|
||||
const checkRemoteVersion = useCallback(async () => {
|
||||
const checkRemoteVersion = useCallback(async (options?: RemoteVersionCheckOptions) => {
|
||||
if (checkRemoteInFlightRef.current) {
|
||||
return;
|
||||
}
|
||||
const force = options?.force === true;
|
||||
const notifyOnFailure = options?.notifyOnFailure !== false;
|
||||
const state = manager.getState();
|
||||
const hasProvider = Object.values(state.providers).some((provider) => isProviderReadyForSync(provider));
|
||||
const unlocked = state.securityState === 'UNLOCKED';
|
||||
|
||||
if (!hasProvider || !unlocked || hasCheckedRemoteRef.current || startupReadyRef.current === false) {
|
||||
if (!hasProvider || !unlocked || (!force && hasCheckedRemoteRef.current) || startupReadyRef.current === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -494,7 +507,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const { mergeSyncPayloads } = await import('../../domain/syncMerge');
|
||||
const mergeResult = mergeSyncPayloads(base, localPayload, remotePayload);
|
||||
|
||||
// Apply merged payload to local state BEFORE committing. If the apply
|
||||
@@ -548,14 +560,16 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AutoSync] Failed to check remote version:', error);
|
||||
// Surface a degraded-sync hint to the user rather than silently
|
||||
// opening the auto-sync gate. Auto-sync will still retry on next
|
||||
// data change (see finally block), but without this toast the user
|
||||
// has no visible signal that startup reconciliation failed.
|
||||
notify.error(
|
||||
t('sync.autoSync.inspectFailedMessage'),
|
||||
t('sync.autoSync.inspectFailedTitle'),
|
||||
);
|
||||
if (notifyOnFailure) {
|
||||
// Surface a degraded-sync hint to the user rather than silently
|
||||
// opening the auto-sync gate. Auto-sync will still retry on next
|
||||
// data change (see finally block), but without this toast the user
|
||||
// has no visible signal that startup reconciliation failed.
|
||||
notify.error(
|
||||
t('sync.autoSync.inspectFailedMessage'),
|
||||
t('sync.autoSync.inspectFailedTitle'),
|
||||
);
|
||||
}
|
||||
// Leave hasCheckedRemoteRef=false so the next startup (or the next
|
||||
// provider/unlock transition) can retry.
|
||||
} finally {
|
||||
@@ -726,12 +740,86 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
if (timerId) clearTimeout(timerId);
|
||||
};
|
||||
}, [sync.hasAnyConnectedProvider, sync.isUnlocked, config.startupReady, checkRemoteVersion]);
|
||||
|
||||
const runRuntimeRemoteCheck = useCallback(async (options?: { force?: boolean }) => {
|
||||
const now = Date.now();
|
||||
const minIntervalMs = getRuntimeRemoteCheckIntervalMs(sync.autoSyncInterval);
|
||||
if (!shouldRunRuntimeRemoteCheck({
|
||||
hasAnyConnectedProvider: sync.hasAnyConnectedProvider,
|
||||
autoSyncEnabled: sync.autoSyncEnabled,
|
||||
isUnlocked: sync.isUnlocked,
|
||||
startupRemoteCheckDone: remoteCheckDoneRef.current,
|
||||
isSyncing: sync.isSyncing,
|
||||
isSyncRunning: isSyncRunningRef.current,
|
||||
remoteCheckInFlight: checkRemoteInFlightRef.current,
|
||||
force: options?.force === true,
|
||||
now,
|
||||
lastRemoteCheckAt: lastRuntimeRemoteCheckAtRef.current,
|
||||
minIntervalMs,
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastRuntimeRemoteCheckAtRef.current = now;
|
||||
await checkRemoteVersion({ force: true, notifyOnFailure: false });
|
||||
}, [
|
||||
checkRemoteVersion,
|
||||
sync.autoSyncEnabled,
|
||||
sync.autoSyncInterval,
|
||||
sync.hasAnyConnectedProvider,
|
||||
sync.isSyncing,
|
||||
sync.isUnlocked,
|
||||
]);
|
||||
|
||||
// Keep checking the cloud while the app is open. This closes the gap where
|
||||
// another device uploads changes after our startup inspection but before
|
||||
// this device edits anything locally.
|
||||
useEffect(() => {
|
||||
if (!sync.hasAnyConnectedProvider || !sync.autoSyncEnabled || !sync.isUnlocked) {
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalMs = getRuntimeRemoteCheckIntervalMs(sync.autoSyncInterval);
|
||||
const timerId = window.setInterval(() => {
|
||||
void runRuntimeRemoteCheck();
|
||||
}, intervalMs);
|
||||
|
||||
return () => window.clearInterval(timerId);
|
||||
}, [
|
||||
runRuntimeRemoteCheck,
|
||||
sync.autoSyncEnabled,
|
||||
sync.autoSyncInterval,
|
||||
sync.hasAnyConnectedProvider,
|
||||
sync.isUnlocked,
|
||||
]);
|
||||
|
||||
// Also re-check when the user returns to the app or the network comes back.
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') return;
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
void runRuntimeRemoteCheck({ force: true });
|
||||
}
|
||||
};
|
||||
const handleOnline = () => {
|
||||
void runRuntimeRemoteCheck({ force: true });
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
window.addEventListener('online', handleOnline);
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
window.removeEventListener('online', handleOnline);
|
||||
};
|
||||
}, [runRuntimeRemoteCheck]);
|
||||
|
||||
// Reset check flags when provider disconnects
|
||||
useEffect(() => {
|
||||
if (!sync.hasAnyConnectedProvider) {
|
||||
hasCheckedRemoteRef.current = false;
|
||||
remoteCheckDoneRef.current = false;
|
||||
lastRuntimeRemoteCheckAtRef.current = null;
|
||||
}
|
||||
}, [sync.hasAnyConnectedProvider]);
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
|
||||
import { SyncConfig, TerminalTheme, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } from '../../domain/models';
|
||||
import { SyncConfig, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } from '../../domain/models';
|
||||
import {
|
||||
STORAGE_KEY_COLOR,
|
||||
STORAGE_KEY_SYNC,
|
||||
STORAGE_KEY_TERM_THEME,
|
||||
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
|
||||
STORAGE_KEY_TERM_THEME_DARK,
|
||||
STORAGE_KEY_TERM_THEME_LIGHT,
|
||||
STORAGE_KEY_THEME,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_FONT_SIZE,
|
||||
@@ -49,7 +51,7 @@ import {
|
||||
shouldApplyIncomingCustomKeyBindingsRecord,
|
||||
updateCustomKeyBinding as updateCustomKeyBindingRecord,
|
||||
} from '../../domain/customKeyBindings';
|
||||
import { applyCustomAccentToTerminalTheme, getTerminalThemeForUiTheme } from '../../domain/terminalAppearance';
|
||||
import { applyCustomAccentToTerminalTheme, resolveFollowedTerminalThemeId, TERMINAL_THEME_AUTO } from '../../domain/terminalAppearance';
|
||||
import { customThemeStore, useCustomThemes } from '../state/customThemeStore';
|
||||
import { DEFAULT_FONT_SIZE, isDeprecatedPrimaryFontId } from '../../infrastructure/config/fonts';
|
||||
import { DARK_UI_THEMES, LIGHT_UI_THEMES, UiThemeTokens, getUiThemeById } from '../../infrastructure/config/uiThemes';
|
||||
@@ -254,6 +256,12 @@ export const useSettingsState = () => {
|
||||
const isUpgrade = !!localStorageAdapter.readString(STORAGE_KEY_TERM_THEME);
|
||||
return !isUpgrade;
|
||||
});
|
||||
const [terminalThemeDarkId, setTerminalThemeDarkId] = useState<string>(
|
||||
() => localStorageAdapter.readString(STORAGE_KEY_TERM_THEME_DARK) || TERMINAL_THEME_AUTO,
|
||||
);
|
||||
const [terminalThemeLightId, setTerminalThemeLightId] = useState<string>(
|
||||
() => localStorageAdapter.readString(STORAGE_KEY_TERM_THEME_LIGHT) || TERMINAL_THEME_AUTO,
|
||||
);
|
||||
const [terminalFontFamilyId, setTerminalFontFamilyId] = useState<string>(() => {
|
||||
const stored = localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY);
|
||||
return migrateIncomingTerminalFontId(stored) ?? DEFAULT_FONT_FAMILY;
|
||||
@@ -536,6 +544,10 @@ export const useSettingsState = () => {
|
||||
// Terminal
|
||||
const storedTermTheme = readStoredString(STORAGE_KEY_TERM_THEME);
|
||||
if (storedTermTheme) setTerminalThemeId(storedTermTheme);
|
||||
const storedTermThemeDark = readStoredString(STORAGE_KEY_TERM_THEME_DARK);
|
||||
if (storedTermThemeDark) setTerminalThemeDarkId(storedTermThemeDark);
|
||||
const storedTermThemeLight = readStoredString(STORAGE_KEY_TERM_THEME_LIGHT);
|
||||
if (storedTermThemeLight) setTerminalThemeLightId(storedTermThemeLight);
|
||||
const storedTermFont = readStoredString(STORAGE_KEY_TERM_FONT_FAMILY);
|
||||
const migratedTermFont = migrateIncomingTerminalFontId(storedTermFont);
|
||||
if (migratedTermFont) setTerminalFontFamilyId(migratedTermFont);
|
||||
@@ -669,6 +681,12 @@ export const useSettingsState = () => {
|
||||
if (key === STORAGE_KEY_TERM_THEME && typeof value === 'string') {
|
||||
setTerminalThemeId(value);
|
||||
}
|
||||
if (key === STORAGE_KEY_TERM_THEME_DARK && typeof value === 'string') {
|
||||
setTerminalThemeDarkId(value);
|
||||
}
|
||||
if (key === STORAGE_KEY_TERM_THEME_LIGHT && typeof value === 'string') {
|
||||
setTerminalThemeLightId(value);
|
||||
}
|
||||
if (key === STORAGE_KEY_TERM_FOLLOW_APP_THEME) {
|
||||
const next = value === true || value === 'true';
|
||||
setFollowAppTerminalThemeState((prev) => (prev === next ? prev : next));
|
||||
@@ -862,6 +880,15 @@ export const useSettingsState = () => {
|
||||
setTerminalThemeId(e.newValue);
|
||||
}
|
||||
}
|
||||
// Sync per-mode follow terminal themes from other windows
|
||||
if (e.key === STORAGE_KEY_TERM_THEME_DARK && e.newValue) {
|
||||
const next = e.newValue;
|
||||
setTerminalThemeDarkId((prev) => (prev === next ? prev : next));
|
||||
}
|
||||
if (e.key === STORAGE_KEY_TERM_THEME_LIGHT && e.newValue) {
|
||||
const next = e.newValue;
|
||||
setTerminalThemeLightId((prev) => (prev === next ? prev : next));
|
||||
}
|
||||
// Sync follow-app-theme toggle from other windows
|
||||
if (e.key === STORAGE_KEY_TERM_FOLLOW_APP_THEME && e.newValue) {
|
||||
const next = e.newValue === 'true';
|
||||
@@ -1011,6 +1038,18 @@ export const useSettingsState = () => {
|
||||
notifySettingsChanged(STORAGE_KEY_TERM_FOLLOW_APP_THEME, String(followAppTerminalTheme));
|
||||
}, [followAppTerminalTheme, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME_DARK, terminalThemeDarkId);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_TERM_THEME_DARK, terminalThemeDarkId);
|
||||
}, [terminalThemeDarkId, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME_LIGHT, terminalThemeLightId);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_TERM_THEME_LIGHT, terminalThemeLightId);
|
||||
}, [terminalThemeLightId, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, terminalFontFamilyId);
|
||||
if (!persistMountedRef.current) return;
|
||||
@@ -1293,25 +1332,32 @@ export const useSettingsState = () => {
|
||||
const customThemes = useCustomThemes();
|
||||
|
||||
const currentTerminalTheme = useMemo(() => {
|
||||
let baseTheme: TerminalTheme;
|
||||
// When "Follow Application Theme" is enabled, pick the terminal theme
|
||||
// whose background matches the active UI theme preset.
|
||||
// When "Follow Application Theme" is enabled, honor the per-mode override
|
||||
// (or auto-match the active UI theme preset when set to auto).
|
||||
if (followAppTerminalTheme) {
|
||||
const activeUiThemeId = resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId;
|
||||
const mapped = getTerminalThemeForUiTheme(activeUiThemeId);
|
||||
if (mapped) {
|
||||
const found = TERMINAL_THEMES.find(t => t.id === mapped);
|
||||
if (found) {
|
||||
baseTheme = found;
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
}
|
||||
const followedId = resolveFollowedTerminalThemeId({
|
||||
resolvedTheme,
|
||||
terminalThemeDarkId,
|
||||
terminalThemeLightId,
|
||||
lightUiThemeId,
|
||||
darkUiThemeId,
|
||||
fallbackThemeId: terminalThemeId,
|
||||
});
|
||||
const followed = TERMINAL_THEMES.find(t => t.id === followedId)
|
||||
|| customThemes.find(t => t.id === followedId);
|
||||
if (followed) {
|
||||
return applyCustomAccentToTerminalTheme(followed, accentMode, customAccent);
|
||||
}
|
||||
// Explicit override pointing at a deleted theme: fall through to the
|
||||
// manual theme below.
|
||||
}
|
||||
baseTheme = TERMINAL_THEMES.find(t => t.id === terminalThemeId)
|
||||
const baseTheme = TERMINAL_THEMES.find(t => t.id === terminalThemeId)
|
||||
|| customThemes.find(t => t.id === terminalThemeId)
|
||||
|| TERMINAL_THEMES[0];
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
}, [terminalThemeId, customThemes, followAppTerminalTheme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
|
||||
}, [terminalThemeId, terminalThemeDarkId, terminalThemeLightId, customThemes,
|
||||
followAppTerminalTheme, resolvedTheme, lightUiThemeId, darkUiThemeId,
|
||||
accentMode, customAccent]);
|
||||
|
||||
const updateTerminalSetting = useCallback(<K extends keyof TerminalSettings>(
|
||||
key: K,
|
||||
@@ -1348,6 +1394,10 @@ export const useSettingsState = () => {
|
||||
setTerminalThemeId,
|
||||
followAppTerminalTheme,
|
||||
setFollowAppTerminalTheme: setFollowAppTerminalThemeState,
|
||||
terminalThemeDarkId,
|
||||
setTerminalThemeDarkId,
|
||||
terminalThemeLightId,
|
||||
setTerminalThemeLightId,
|
||||
currentTerminalTheme,
|
||||
terminalFontFamilyId,
|
||||
setTerminalFontFamilyId,
|
||||
|
||||
@@ -73,6 +73,11 @@ export const useTerminalBackend = () => {
|
||||
bridge?.resizeSession?.(sessionId, cols, rows);
|
||||
}, []);
|
||||
|
||||
const setSessionFlowPaused = useCallback((sessionId: string, paused: boolean) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.setSessionFlowPaused?.(sessionId, paused);
|
||||
}, []);
|
||||
|
||||
const closeSession = useCallback((sessionId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.closeSession?.(sessionId);
|
||||
@@ -208,6 +213,7 @@ export const useTerminalBackend = () => {
|
||||
getServerStats,
|
||||
writeToSession,
|
||||
resizeSession,
|
||||
setSessionFlowPaused,
|
||||
closeSession,
|
||||
setSessionEncoding,
|
||||
onSessionData,
|
||||
@@ -240,6 +246,7 @@ export const useTerminalBackend = () => {
|
||||
getServerStats,
|
||||
writeToSession,
|
||||
resizeSession,
|
||||
setSessionFlowPaused,
|
||||
closeSession,
|
||||
setSessionEncoding,
|
||||
onSessionData,
|
||||
|
||||
@@ -43,6 +43,8 @@ import {
|
||||
STORAGE_KEY_CUSTOM_CSS,
|
||||
STORAGE_KEY_TERM_THEME,
|
||||
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
|
||||
STORAGE_KEY_TERM_THEME_DARK,
|
||||
STORAGE_KEY_TERM_THEME_LIGHT,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_FONT_SIZE,
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
@@ -161,10 +163,11 @@ interface SyncPayloadImporters {
|
||||
|
||||
/** Terminal settings keys that are safe to sync (platform-agnostic). */
|
||||
const SYNCABLE_TERMINAL_KEYS = [
|
||||
'startupCommandDelayMs',
|
||||
'scrollback', 'drawBoldInBrightColors', 'terminalEmulationType',
|
||||
'fontLigatures', 'fontWeight', 'fontWeightBold', 'fallbackFont',
|
||||
'linePadding', 'cursorShape', 'cursorBlink', 'minimumContrastRatio',
|
||||
'altAsMeta', 'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
|
||||
'altAsMeta', 'optionArrowWordJump', 'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
|
||||
'smoothScrolling',
|
||||
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
|
||||
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
|
||||
@@ -186,6 +189,8 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
|
||||
STORAGE_KEY_CUSTOM_CSS,
|
||||
STORAGE_KEY_TERM_THEME,
|
||||
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
|
||||
STORAGE_KEY_TERM_THEME_DARK,
|
||||
STORAGE_KEY_TERM_THEME_LIGHT,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_FONT_SIZE,
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
@@ -309,6 +314,10 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (followAppTermTheme === 'true' || followAppTermTheme === 'false') {
|
||||
settings.followAppTerminalTheme = followAppTermTheme === 'true';
|
||||
}
|
||||
const termThemeDark = localStorageAdapter.readString(STORAGE_KEY_TERM_THEME_DARK);
|
||||
if (termThemeDark) settings.terminalThemeDark = termThemeDark;
|
||||
const termThemeLight = localStorageAdapter.readString(STORAGE_KEY_TERM_THEME_LIGHT);
|
||||
if (termThemeLight) settings.terminalThemeLight = termThemeLight;
|
||||
const termFont = localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY);
|
||||
if (termFont) settings.terminalFontFamily = termFont;
|
||||
const termSize = localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE);
|
||||
@@ -432,6 +441,8 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
|
||||
if (settings.followAppTerminalTheme != null) {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_FOLLOW_APP_THEME, String(settings.followAppTerminalTheme));
|
||||
}
|
||||
if (settings.terminalThemeDark != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME_DARK, settings.terminalThemeDark);
|
||||
if (settings.terminalThemeLight != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME_LIGHT, settings.terminalThemeLight);
|
||||
if (settings.terminalFontFamily != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, settings.terminalFontFamily);
|
||||
if (settings.terminalFontSize != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_SIZE, String(settings.terminalFontSize));
|
||||
|
||||
|
||||
@@ -65,6 +65,12 @@ const SettingsTerminalTabContainer: React.FC<{ settings: SettingsState }> = ({ s
|
||||
setTerminalThemeId={settings.setTerminalThemeId}
|
||||
followAppTerminalTheme={settings.followAppTerminalTheme}
|
||||
setFollowAppTerminalTheme={settings.setFollowAppTerminalTheme}
|
||||
terminalThemeDarkId={settings.terminalThemeDarkId}
|
||||
setTerminalThemeDarkId={settings.setTerminalThemeDarkId}
|
||||
terminalThemeLightId={settings.terminalThemeLightId}
|
||||
setTerminalThemeLightId={settings.setTerminalThemeLightId}
|
||||
lightUiThemeId={settings.lightUiThemeId}
|
||||
darkUiThemeId={settings.darkUiThemeId}
|
||||
terminalFontFamilyId={settings.terminalFontFamilyId}
|
||||
setTerminalFontFamilyId={settings.setTerminalFontFamilyId}
|
||||
terminalFontSize={settings.terminalFontSize}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { SearchAddon } from "@xterm/addon-search";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { Cpu, Copy, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine } from "lucide-react";
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { logger } from "../lib/logger";
|
||||
import { cn, normalizeLineEndings, wrapBracketedPaste } from "../lib/utils";
|
||||
@@ -29,7 +28,7 @@ import {
|
||||
applyCustomAccentToTerminalTheme,
|
||||
resolveHostTerminalThemeId,
|
||||
} from "../domain/terminalAppearance";
|
||||
import { classifyDistroId } from "../domain/host";
|
||||
import { classifyDistroId, shouldProbeSessionCwd } from "../domain/host";
|
||||
import { resolveHostAuth } from "../domain/sshAuth";
|
||||
import { useTerminalBackend } from "../application/state/useTerminalBackend";
|
||||
// SFTPModal removed - SFTP is now handled by SftpSidePanel in TerminalLayer
|
||||
@@ -49,12 +48,15 @@ import { TerminalToolbar } from "./terminal/TerminalToolbar";
|
||||
import { TerminalComposeBar } from "./terminal/TerminalComposeBar";
|
||||
import { TerminalContextMenu } from "./terminal/TerminalContextMenu";
|
||||
import { TerminalSearchBar } from "./terminal/TerminalSearchBar";
|
||||
import { ZmodemOverwriteDialog } from "./terminal/ZmodemOverwriteDialog";
|
||||
import { ZmodemProgressIndicator } from "./terminal/ZmodemProgressIndicator";
|
||||
import { createReplaySafeTerminalLogSanitizer } from "./terminal/replaySafeTerminalLog";
|
||||
import { createConnectionLogBuffer } from "./terminal/connectionLogBuffer";
|
||||
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
|
||||
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
|
||||
import { createXTermRuntime, primaryFontFamily, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
|
||||
import { applyUserCursorPreference } from "./terminal/runtime/cursorPreference";
|
||||
import { terminalAltKeyOptions } from "./terminal/runtime/altKeyOptions";
|
||||
import {
|
||||
createPromptLineBreakState,
|
||||
type PromptLineBreakState,
|
||||
@@ -68,7 +70,7 @@ import { useTerminalContextActions } from "./terminal/hooks/useTerminalContextAc
|
||||
import { useTerminalAuthState } from "./terminal/hooks/useTerminalAuthState";
|
||||
import { useServerStats } from "./terminal/hooks/useServerStats";
|
||||
import { extractDropEntries, getPathForFile, DropEntry } from "../lib/sftpFileUtils";
|
||||
import { useTerminalAutocomplete, AutocompletePopup } from "./terminal/autocomplete";
|
||||
import { TerminalAutocomplete } from "./terminal/TerminalAutocomplete";
|
||||
import { createTerminalCwdTracker, resolvePreferredTerminalCwd } from "./terminal/sftpCwd";
|
||||
|
||||
const MAX_CONNECTION_LOG_DATA_CHARS = 1_000_000;
|
||||
@@ -297,7 +299,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// cancelled retry can't fire a startNewSession after the fact.
|
||||
const retryTokenRef = useRef<symbol | null>(null);
|
||||
const terminalDataCapturedRef = useRef(false);
|
||||
const terminalLogDataRef = useRef("");
|
||||
const connectionLogBufferRef = useRef(createConnectionLogBuffer(MAX_CONNECTION_LOG_DATA_CHARS));
|
||||
const terminalLogSanitizerRef = useRef(createReplaySafeTerminalLogSanitizer());
|
||||
const onTerminalDataCaptureRef = useRef(onTerminalDataCapture);
|
||||
const commandBufferRef = useRef<string>("");
|
||||
@@ -318,21 +320,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const captureTerminalLogData = useCallback((data: string) => {
|
||||
const replaySafeData = terminalLogSanitizerRef.current.append(data);
|
||||
if (!replaySafeData) return;
|
||||
terminalLogDataRef.current += replaySafeData;
|
||||
if (terminalLogDataRef.current.length > MAX_CONNECTION_LOG_DATA_CHARS) {
|
||||
terminalLogDataRef.current = terminalLogDataRef.current.slice(-MAX_CONNECTION_LOG_DATA_CHARS);
|
||||
}
|
||||
connectionLogBufferRef.current.append(replaySafeData);
|
||||
}, []);
|
||||
|
||||
const finalizeTerminalLogData = useCallback(() => {
|
||||
const replaySafeData = terminalLogSanitizerRef.current.finish();
|
||||
if (replaySafeData) {
|
||||
terminalLogDataRef.current += replaySafeData;
|
||||
if (terminalLogDataRef.current.length > MAX_CONNECTION_LOG_DATA_CHARS) {
|
||||
terminalLogDataRef.current = terminalLogDataRef.current.slice(-MAX_CONNECTION_LOG_DATA_CHARS);
|
||||
}
|
||||
connectionLogBufferRef.current.append(replaySafeData);
|
||||
}
|
||||
return terminalLogDataRef.current;
|
||||
return connectionLogBufferRef.current.toString();
|
||||
}, []);
|
||||
|
||||
const writeLocalTerminalData = useCallback((data: string) => {
|
||||
@@ -387,10 +383,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const snippetsRef = useRef(snippets);
|
||||
snippetsRef.current = snippets;
|
||||
|
||||
// Autocomplete handler refs (set after hook initialization)
|
||||
// Autocomplete handler refs — populated by <TerminalAutocomplete> so the
|
||||
// xterm runtime (and a few effects here) can drive the hook without making
|
||||
// Terminal re-render on every suggestion update.
|
||||
const autocompleteKeyEventRef = useRef<((e: KeyboardEvent) => boolean) | undefined>(undefined);
|
||||
const autocompleteInputRef = useRef<((data: string) => void) | undefined>(undefined);
|
||||
const autocompleteRepositionRef = useRef<(() => void) | undefined>(undefined);
|
||||
const autocompleteCloseRef = useRef<(() => void) | undefined>(undefined);
|
||||
|
||||
const terminalBackend = useTerminalBackend();
|
||||
const { resizeSession, setSessionEncoding } = terminalBackend;
|
||||
@@ -539,31 +538,19 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const autocomplete = useTerminalAutocomplete({
|
||||
termRef,
|
||||
sessionId,
|
||||
hostId: host.id,
|
||||
hostOs: host.os || (host.protocol === "local"
|
||||
? (navigator.platform?.startsWith("Win") ? "windows" : navigator.platform?.startsWith("Mac") ? "macos" : "linux")
|
||||
: "linux"),
|
||||
settings: terminalSettings ? {
|
||||
enabled: terminalSettings.autocompleteEnabled ?? true,
|
||||
showGhostText: terminalSettings.autocompleteGhostText ?? true,
|
||||
showPopupMenu: terminalSettings.autocompletePopupMenu ?? true,
|
||||
debounceMs: terminalSettings.autocompleteDebounceMs ?? 100,
|
||||
minChars: terminalSettings.autocompleteMinChars ?? 1,
|
||||
maxSuggestions: terminalSettings.autocompleteMaxSuggestions ?? 8,
|
||||
} : undefined,
|
||||
onAcceptText: (text) => autocompleteAcceptTextRef.current?.(text),
|
||||
protocol: host.protocol,
|
||||
getCwd: () => terminalCwdTracker.getRendererCwd() ?? knownCwdRef.current,
|
||||
});
|
||||
|
||||
// Wire up autocomplete handler refs so createXTermRuntime can use them
|
||||
autocompleteKeyEventRef.current = autocomplete.handleKeyEvent;
|
||||
autocompleteInputRef.current = autocomplete.handleInput;
|
||||
autocompleteRepositionRef.current = autocomplete.repositionPopup;
|
||||
const autocompleteClosePopup = autocomplete.closePopup;
|
||||
// Autocomplete config — the hook itself lives in <TerminalAutocomplete> so
|
||||
// its state updates don't re-render this component (see render below).
|
||||
const autocompleteHostOs: "linux" | "windows" | "macos" = host.os || (host.protocol === "local"
|
||||
? (navigator.platform?.startsWith("Win") ? "windows" : navigator.platform?.startsWith("Mac") ? "macos" : "linux")
|
||||
: "linux");
|
||||
const autocompleteSettings = terminalSettings ? {
|
||||
enabled: terminalSettings.autocompleteEnabled ?? true,
|
||||
showGhostText: terminalSettings.autocompleteGhostText ?? true,
|
||||
showPopupMenu: terminalSettings.autocompletePopupMenu ?? true,
|
||||
debounceMs: terminalSettings.autocompleteDebounceMs ?? 100,
|
||||
minChars: terminalSettings.autocompleteMinChars ?? 1,
|
||||
maxSuggestions: terminalSettings.autocompleteMaxSuggestions ?? 8,
|
||||
} : undefined;
|
||||
|
||||
const resolveSftpInitialPath = useCallback(async (): Promise<string | undefined> => {
|
||||
const cwd = await resolvePreferredTerminalCwd({
|
||||
@@ -585,6 +572,21 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return clearTerminalCwd;
|
||||
}, [clearTerminalCwd, host.id]);
|
||||
|
||||
// Classify the host's device family from the *detected* distro and the
|
||||
// explicit deviceType only. This intentionally bypasses
|
||||
// getEffectiveHostDistro(): the manual distro override (`distroMode:
|
||||
// 'manual'` + `manualDistro`) is a purely cosmetic icon choice, and a
|
||||
// user who pinned e.g. an "ubuntu" icon on what is actually a Cisco /
|
||||
// Huawei host must not silently re-enable POSIX-shell probes against it.
|
||||
// Several features gate on this — the working-directory probe below, the
|
||||
// /etc/os-release probe, and the periodic server-stats poll (#674) —
|
||||
// because each opens an extra exec channel that strict network-device
|
||||
// CLIs reject or log as a new AAA session, and on Huawei VRP closes the
|
||||
// whole session (#1043).
|
||||
const detectedDeviceClass = classifyDistroId(host.distro);
|
||||
const isNetworkDevice =
|
||||
host.deviceType === 'network' || detectedDeviceClass === 'network-device';
|
||||
|
||||
useEffect(() => {
|
||||
if (host.protocol === "local" || host.protocol === "serial" || host.protocol === "telnet") {
|
||||
return;
|
||||
@@ -593,9 +595,20 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(async () => {
|
||||
if (!sessionRef.current) return;
|
||||
const id = sessionRef.current;
|
||||
if (!id) return;
|
||||
try {
|
||||
const result = await terminalBackend.getSessionPwd(sessionRef.current);
|
||||
// The pwd probe opens an extra POSIX-shell exec channel, which strict
|
||||
// network-device CLIs like Huawei VRP answer by closing the whole
|
||||
// session (#1043). Skip it for known network devices; for a brand-new
|
||||
// host (distro not classified yet on the first connect) consult the
|
||||
// SSH banner, which is captured for free at handshake time.
|
||||
const info = await terminalBackend.getSessionRemoteInfo?.(id);
|
||||
if (cancelled || id !== sessionRef.current) return;
|
||||
if (!shouldProbeSessionCwd({ isNetworkDevice, remoteSshVersion: info?.remoteSshVersion })) {
|
||||
return;
|
||||
}
|
||||
const result = await terminalBackend.getSessionPwd(id);
|
||||
if (!cancelled && !terminalCwdTracker.getRendererCwd() && result.success && result.cwd) {
|
||||
knownCwdRef.current = result.cwd;
|
||||
}
|
||||
@@ -608,37 +621,22 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [host.protocol, status, terminalBackend, terminalCwdTracker]);
|
||||
}, [host.protocol, status, terminalBackend, terminalCwdTracker, isNetworkDevice]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
autocompleteClosePopup();
|
||||
autocompleteCloseRef.current?.();
|
||||
}
|
||||
}, [isVisible, autocompleteClosePopup]);
|
||||
}, [isVisible]);
|
||||
|
||||
// Check if this is a local or serial connection (doesn't need connection dialog during connecting)
|
||||
const isLocalConnection = host.protocol === "local";
|
||||
const isSerialConnection = host.protocol === "serial";
|
||||
|
||||
// Server stats (CPU, Memory, Disk) — only for Linux/macOS, and never
|
||||
// for hosts classified as network devices (either via explicit
|
||||
// deviceType='network' or via SSH banner detection that populated
|
||||
// host.distro with a network-vendor ID). See #674: polling the stats
|
||||
// command on Cisco / Huawei / Juniper etc. generates one AAA session
|
||||
// log entry per poll because each exec channel is counted as a new
|
||||
// session on those devices.
|
||||
//
|
||||
// IMPORTANT: this gating must NOT go through getEffectiveHostDistro()
|
||||
// because that honors the manual distro override (`distroMode: 'manual'`
|
||||
// + `manualDistro`) which is purely a cosmetic icon choice. A user who
|
||||
// pinned an "ubuntu" icon on what is actually a Cisco host would
|
||||
// otherwise silently re-enable the polling loop and re-introduce the
|
||||
// AAA log flood this patch is meant to eliminate. The display icon can
|
||||
// still be overridden (see DistroAvatar) — gating uses the raw detected
|
||||
// `host.distro` and the explicit `host.deviceType` only.
|
||||
const detectedDeviceClass = classifyDistroId(host.distro);
|
||||
const isNetworkDevice =
|
||||
host.deviceType === 'network' || detectedDeviceClass === 'network-device';
|
||||
// Server stats (CPU, Memory, Disk) — only for Linux/macOS, never for
|
||||
// network devices. See isNetworkDevice above for why the gating uses the
|
||||
// raw detected distro / explicit deviceType (not getEffectiveHostDistro);
|
||||
// #674 covers the AAA-log-flood motivation for stats specifically.
|
||||
const isSupportedOs =
|
||||
!isNetworkDevice &&
|
||||
(host.os === 'linux' || host.os === 'macos' || detectedDeviceClass === 'linux-like');
|
||||
@@ -927,7 +925,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
terminalDataCapturedRef.current = false;
|
||||
terminalLogDataRef.current = "";
|
||||
connectionLogBufferRef.current.reset();
|
||||
terminalLogSanitizerRef.current = createReplaySafeTerminalLogSanitizer();
|
||||
setError(null);
|
||||
hasConnectedRef.current = false;
|
||||
@@ -1236,11 +1234,18 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
: 0;
|
||||
termRef.current.options.scrollOnUserInput =
|
||||
shouldEnableNativeUserInputAutoScroll(terminalSettings);
|
||||
termRef.current.options.altClickMovesCursor = !terminalSettings.altAsMeta;
|
||||
const altKeyOpts = terminalAltKeyOptions(terminalSettings.altAsMeta);
|
||||
termRef.current.options.macOptionIsMeta = altKeyOpts.macOptionIsMeta;
|
||||
termRef.current.options.altClickMovesCursor = altKeyOpts.altClickMovesCursor;
|
||||
termRef.current.options.wordSeparator = terminalSettings.wordSeparators;
|
||||
termRef.current.options.ignoreBracketedPasteMode = terminalSettings.disableBracketedPaste ?? false;
|
||||
}
|
||||
|
||||
// Changing the font can leave the WebGL renderer drawing stale glyphs from
|
||||
// the old metrics (xterm.js #3280), surfacing as garbled text (issue #1049).
|
||||
// Clear the texture atlas so glyphs re-rasterize with the new font.
|
||||
xtermRuntimeRef.current?.clearTextureAtlas();
|
||||
|
||||
if (isVisibleRef.current) {
|
||||
setTimeout(() => safeFit({ force: true, requireVisible: true }), 50);
|
||||
} else {
|
||||
@@ -1253,6 +1258,18 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
if (!isVisible) return;
|
||||
const timer = setTimeout(() => {
|
||||
safeFit({ requireVisible: true });
|
||||
// Recover the WebGL renderer now that this tab is visible again. Hidden
|
||||
// panes stay mounted off-screen (visibility:hidden) so each keeps a live
|
||||
// WebGL context; creating another terminal's context — or the GPU dropping
|
||||
// a non-composited off-screen canvas — can leave this terminal's drawing
|
||||
// buffer corrupted ("花屏", issue #1063). Because a hidden pane keeps its
|
||||
// dimensions, becoming visible triggers no resize and therefore no redraw,
|
||||
// so the corruption persists until the user resizes the window. Force the
|
||||
// same recovery a resize performs: clear the texture atlas (no-op on the
|
||||
// DOM renderer) and synchronously repaint every row.
|
||||
xtermRuntimeRef.current?.clearTextureAtlas();
|
||||
const visibleTerm = termRef.current;
|
||||
if (visibleTerm) forceSyncRenderAfterResize(visibleTerm);
|
||||
if (pendingOutputScrollRef.current) {
|
||||
termRef.current?.scrollToBottom();
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
@@ -1566,10 +1583,21 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
if (!noAutoRun) data = `${data}\r`;
|
||||
|
||||
// Broadcast the exact bytes the active session receives so peers mirror it,
|
||||
// including the bracketed-paste wrapping and the auto-run \r. Broadcasting
|
||||
// the raw (un-wrapped) form would let a multi-line noAutoRun snippet run
|
||||
// line-by-line on peers, since handleBroadcastInput writes bytes directly
|
||||
// without re-wrapping. Without broadcasting at all, accepting a snippet in
|
||||
// broadcast mode would clear peer input (the clear keystrokes already go
|
||||
// through the broadcast-aware path) but never send the command.
|
||||
if (isBroadcastEnabledRef.current && onBroadcastInputRef.current) {
|
||||
onBroadcastInputRef.current(data, sessionId);
|
||||
}
|
||||
|
||||
terminalBackend.writeToSession(id, data);
|
||||
scrollToBottomAfterProgrammaticInput(data);
|
||||
term.focus();
|
||||
}, [scrollToBottomAfterProgrammaticInput, terminalBackend]);
|
||||
}, [scrollToBottomAfterProgrammaticInput, terminalBackend, sessionId]);
|
||||
|
||||
// Only register the snippet executor once the terminal session is ready.
|
||||
// Before that, TerminalLayer falls back to raw writeToSession which is the
|
||||
@@ -2352,29 +2380,29 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Autocomplete popup — rendered via Portal to escape overflow:hidden */}
|
||||
{isVisible && autocomplete.state.popupVisible && autocomplete.state.suggestions.length > 0 &&
|
||||
ReactDOM.createPortal(
|
||||
<AutocompletePopup
|
||||
suggestions={autocomplete.state.suggestions}
|
||||
selectedIndex={autocomplete.state.selectedIndex}
|
||||
position={autocomplete.state.popupPosition}
|
||||
cursorLineTop={autocomplete.state.popupCursorLineTop}
|
||||
cursorLineBottom={autocomplete.state.popupCursorLineBottom}
|
||||
visible={autocomplete.state.popupVisible}
|
||||
expandUpward={autocomplete.state.expandUpward}
|
||||
themeColors={effectiveTheme.colors}
|
||||
onSelect={autocomplete.selectSuggestion}
|
||||
subDirPanels={autocomplete.state.subDirPanels}
|
||||
subDirFocusLevel={autocomplete.state.subDirFocusLevel}
|
||||
containerRef={containerRef}
|
||||
onRequestReposition={autocomplete.repositionPopup}
|
||||
searchBarOffset={isSearchOpen ? 64 : 30}
|
||||
onDismiss={autocompleteClosePopup}
|
||||
/>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
{/* Autocomplete — owns the hook + popup in its own component so
|
||||
suggestion/selection updates don't re-render Terminal. Mounted
|
||||
unconditionally; it gates the popup on `visible` internally. */}
|
||||
<TerminalAutocomplete
|
||||
termRef={termRef}
|
||||
sessionId={sessionId}
|
||||
hostId={host.id}
|
||||
hostOs={autocompleteHostOs}
|
||||
settings={autocompleteSettings}
|
||||
protocol={host.protocol}
|
||||
getCwd={() => terminalCwdTracker.getRendererCwd() ?? knownCwdRef.current}
|
||||
onAcceptText={(text) => autocompleteAcceptTextRef.current?.(text)}
|
||||
snippets={snippets}
|
||||
onAcceptSnippet={(snippet) => executeSnippetCommand(snippet.command, snippet.noAutoRun)}
|
||||
visible={isVisible}
|
||||
themeColors={effectiveTheme.colors}
|
||||
containerRef={containerRef}
|
||||
searchBarOffset={isSearchOpen ? 64 : 30}
|
||||
keyEventRef={autocompleteKeyEventRef}
|
||||
inputRef={autocompleteInputRef}
|
||||
repositionRef={autocompleteRepositionRef}
|
||||
closeRef={autocompleteCloseRef}
|
||||
/>
|
||||
|
||||
{/* OSC-52 clipboard read prompt */}
|
||||
{osc52ReadPromptVisible && (
|
||||
@@ -2465,6 +2493,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* ZMODEM overwrite conflict dialog */}
|
||||
{zmodem.overwriteRequest && (
|
||||
<ZmodemOverwriteDialog
|
||||
filename={zmodem.overwriteRequest.filename}
|
||||
onRespond={zmodem.respondOverwrite}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Compose Bar (solo sessions only; workspace uses TerminalLayer's global bar) */}
|
||||
|
||||
@@ -57,6 +57,7 @@ import { Input } from './ui/input';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { setupMcpApprovalBridge } from '../infrastructure/ai/shared/approvalGate';
|
||||
import { resolveScriptsSidePanelShortcutIntent } from '../application/state/resolveSnippetsShortcutIntent';
|
||||
import { resolveSidePanelToggleIntent } from '../application/state/resolveSidePanelToggleIntent';
|
||||
import { terminalLayerAreEqual } from './terminalLayerMemo';
|
||||
import { getTerminalPaneSnapshot, parseTerminalPaneSnapshot } from './terminalPaneVisibility';
|
||||
import { getScopedTopTabsThemeId } from './terminalTopTabsTheme';
|
||||
@@ -465,9 +466,8 @@ interface TerminalLayerProps {
|
||||
sessionLogsEnabled?: boolean;
|
||||
sessionLogsDir?: string;
|
||||
sessionLogsFormat?: string;
|
||||
closeSidePanelRef?: React.MutableRefObject<(() => void) | null>;
|
||||
toggleScriptsSidePanelRef?: React.MutableRefObject<(() => void) | null>;
|
||||
activeSidePanelTabRef?: React.MutableRefObject<string | null>;
|
||||
toggleSidePanelRef?: React.MutableRefObject<(() => void) | null>;
|
||||
}
|
||||
|
||||
interface TerminalPaneProps {
|
||||
@@ -890,9 +890,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
sessionLogsEnabled,
|
||||
sessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
closeSidePanelRef,
|
||||
toggleScriptsSidePanelRef,
|
||||
activeSidePanelTabRef,
|
||||
toggleSidePanelRef,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
// Subscribe to activeTabId from external store
|
||||
@@ -978,10 +977,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
|
||||
const handleSessionExit = useCallback((sessionId: string, evt: TerminalSessionExitEvent) => {
|
||||
const intent = resolveTerminalSessionExitIntent(evt);
|
||||
if (intent.kind === "markDisconnected") {
|
||||
if (intent.kind === "closeSession") {
|
||||
onCloseSession(sessionId);
|
||||
} else {
|
||||
onUpdateSessionStatus(sessionId, 'disconnected');
|
||||
}
|
||||
}, [onUpdateSessionStatus]);
|
||||
}, [onCloseSession, onUpdateSessionStatus]);
|
||||
|
||||
const handleOsDetected = useCallback((hostId: string, distro: string) => {
|
||||
onUpdateHostDistro(hostId, distro);
|
||||
@@ -1099,13 +1100,18 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const sidePanelOpenTabsRef = useRef(sidePanelOpenTabs);
|
||||
sidePanelOpenTabsRef.current = sidePanelOpenTabs;
|
||||
|
||||
// Remember the last sub-panel shown per tab so the toggle shortcut can
|
||||
// restore it after a close. Overwritten on open, never cleared on close.
|
||||
const lastSidePanelTabRef = useRef<Map<string, SidePanelTab>>(new Map());
|
||||
useEffect(() => {
|
||||
sidePanelOpenTabs.forEach((tab, tabId) => {
|
||||
lastSidePanelTabRef.current.set(tabId, tab);
|
||||
});
|
||||
}, [sidePanelOpenTabs]);
|
||||
|
||||
// Whether side panel is open for the currently active tab and which sub-panel
|
||||
const isSidePanelOpenForCurrentTab = activeTabId ? sidePanelOpenTabs.has(activeTabId) : false;
|
||||
const activeSidePanelTab = activeTabId ? sidePanelOpenTabs.get(activeTabId) ?? null : null;
|
||||
if (activeSidePanelTabRef) {
|
||||
activeSidePanelTabRef.current = activeSidePanelTab;
|
||||
}
|
||||
|
||||
// Legacy compatibility helpers for SFTP-specific logic
|
||||
const isSftpOpenForCurrentTab = activeSidePanelTab === 'sftp';
|
||||
|
||||
@@ -1843,13 +1849,23 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
refocusTerminalSession(sessionIdToRefocus);
|
||||
}, [activeTabId, getActiveTerminalSessionId, refocusTerminalSession, syncWorkspaceFocusIfNeeded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!closeSidePanelRef) return;
|
||||
closeSidePanelRef.current = handleCloseSidePanel;
|
||||
return () => {
|
||||
closeSidePanelRef.current = null;
|
||||
};
|
||||
}, [closeSidePanelRef, handleCloseSidePanel]);
|
||||
// Resolve the SFTP host for a tab: a previously-stored host, otherwise the
|
||||
// host of the workspace's focused session or the active session. null = none.
|
||||
const resolveSftpHostForTab = useCallback((tabId: string): Host | null => {
|
||||
const stored = sftpHostForTabRef.current.get(tabId);
|
||||
if (stored) return stored;
|
||||
const currentWorkspace = activeWorkspaceRef.current;
|
||||
const currentFocusedSessionId = focusedSessionIdRef.current;
|
||||
const currentActiveSession = activeSessionRef.current;
|
||||
const currentSessionHosts = sessionHostsMapRef.current;
|
||||
if (currentWorkspace && currentFocusedSessionId) {
|
||||
return currentSessionHosts.get(currentFocusedSessionId) ?? null;
|
||||
}
|
||||
if (currentActiveSession) {
|
||||
return currentSessionHosts.get(currentActiveSession.id) ?? null;
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
// Switch side panel to a specific tab (or toggle if already on that tab)
|
||||
const handleSwitchSidePanelTab = useCallback((tab: SidePanelTab) => {
|
||||
@@ -1862,16 +1878,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
|
||||
// If switching to SFTP and no host is stored yet, resolve it
|
||||
if (tab === 'sftp' && !sftpHostForTabRef.current.has(tabId)) {
|
||||
let host: Host | null = null;
|
||||
const currentWorkspace = activeWorkspaceRef.current;
|
||||
const currentFocusedSessionId = focusedSessionIdRef.current;
|
||||
const currentActiveSession = activeSessionRef.current;
|
||||
const currentSessionHosts = sessionHostsMapRef.current;
|
||||
if (currentWorkspace && currentFocusedSessionId) {
|
||||
host = currentSessionHosts.get(currentFocusedSessionId) ?? null;
|
||||
} else if (currentActiveSession) {
|
||||
host = currentSessionHosts.get(currentActiveSession.id) ?? null;
|
||||
}
|
||||
const host = resolveSftpHostForTab(tabId);
|
||||
if (!host) return;
|
||||
setSftpHostForTab(prev => {
|
||||
const next = new Map(prev);
|
||||
@@ -1889,7 +1896,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
next.set(tabId, tab);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
}, [resolveSftpHostForTab]);
|
||||
|
||||
// Toggle SFTP from activity bar header
|
||||
const handleToggleSftpFromBar = useCallback(() => {
|
||||
@@ -1929,6 +1936,34 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
};
|
||||
}, [toggleScriptsSidePanelRef, handleToggleScriptsSidePanel]);
|
||||
|
||||
// Toggle the whole side panel (new ⌘/Ctrl+\ shortcut). Close if open; if
|
||||
// closed, reopen the tab's last sub-panel, defaulting to SFTP (when a host is
|
||||
// available) or scripts.
|
||||
const handleToggleSidePanel = useCallback(() => {
|
||||
const tabId = activeTabIdRef.current;
|
||||
if (!tabId) return;
|
||||
const isOpen = sidePanelOpenTabsRef.current.has(tabId);
|
||||
const sftpAvailable = !!resolveSftpHostForTab(tabId);
|
||||
const fallbackTab: SidePanelTab = sftpAvailable ? 'sftp' : 'scripts';
|
||||
const lastTab = lastSidePanelTabRef.current.get(tabId) ?? null;
|
||||
const intent = resolveSidePanelToggleIntent<SidePanelTab>({ isOpen, lastTab, fallbackTab });
|
||||
if (intent.kind === 'close') {
|
||||
handleCloseSidePanel();
|
||||
return;
|
||||
}
|
||||
// If the remembered panel is SFTP but no host is resolvable, use scripts.
|
||||
const target: SidePanelTab = intent.tab === 'sftp' && !sftpAvailable ? 'scripts' : intent.tab;
|
||||
handleSwitchSidePanelTab(target);
|
||||
}, [handleCloseSidePanel, handleSwitchSidePanelTab, resolveSftpHostForTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!toggleSidePanelRef) return;
|
||||
toggleSidePanelRef.current = handleToggleSidePanel;
|
||||
return () => {
|
||||
toggleSidePanelRef.current = null;
|
||||
};
|
||||
}, [toggleSidePanelRef, handleToggleSidePanel]);
|
||||
|
||||
// Open theme side panel (called from Terminal toolbar)
|
||||
const handleOpenTheme = useCallback(() => {
|
||||
handleSwitchSidePanelTab('theme');
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
* Shared theme list component used by both ThemeSelectPanel and ThemeSelectModal
|
||||
*/
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { Check, Wand2 } from 'lucide-react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { TERMINAL_THEMES, USER_VISIBLE_TERMINAL_THEMES, isUiMatchTerminalThemeId } from '../infrastructure/config/terminalThemes';
|
||||
import { TERMINAL_THEME_AUTO } from '../domain/terminalAppearance';
|
||||
import { useCustomThemes } from '../application/state/customThemeStore';
|
||||
import { cn } from '../lib/utils';
|
||||
import { TerminalTheme } from '../types';
|
||||
@@ -53,13 +54,18 @@ ThemeItem.displayName = 'ThemeItem';
|
||||
interface ThemeListProps {
|
||||
selectedThemeId: string;
|
||||
onSelect: (themeId: string) => void;
|
||||
/** Restrict the list to a single type; omit to show both sections. */
|
||||
filterType?: 'dark' | 'light';
|
||||
/** Render an "Auto (match app theme)" entry at the top. */
|
||||
showAutoOption?: boolean;
|
||||
}
|
||||
|
||||
export const ThemeList: React.FC<ThemeListProps> = ({ selectedThemeId, onSelect }) => {
|
||||
export const ThemeList: React.FC<ThemeListProps> = ({ selectedThemeId, onSelect, filterType, showAutoOption }) => {
|
||||
const { t } = useI18n();
|
||||
const customThemes = useCustomThemes();
|
||||
const deletedSelectedTheme = useMemo(
|
||||
() => (selectedThemeId
|
||||
&& selectedThemeId !== TERMINAL_THEME_AUTO
|
||||
&& !isUiMatchTerminalThemeId(selectedThemeId)
|
||||
&& !TERMINAL_THEMES.some((theme) => theme.id === selectedThemeId)
|
||||
&& !customThemes.some((theme) => theme.id === selectedThemeId)
|
||||
@@ -80,8 +86,33 @@ export const ThemeList: React.FC<ThemeListProps> = ({ selectedThemeId, onSelect
|
||||
return { darkThemes: dark, lightThemes: light };
|
||||
}, []);
|
||||
|
||||
const visibleCustomThemes = filterType
|
||||
? customThemes.filter(theme => theme.type === filterType)
|
||||
: customThemes;
|
||||
const isAutoSelected = selectedThemeId === TERMINAL_THEME_AUTO;
|
||||
|
||||
return (
|
||||
<>
|
||||
{showAutoOption && (
|
||||
<button
|
||||
onClick={() => onSelect(TERMINAL_THEME_AUTO)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 mb-3 rounded-md text-left transition-all',
|
||||
isAutoSelected ? 'bg-primary/10' : 'hover:bg-muted',
|
||||
)}
|
||||
>
|
||||
<div className="w-12 h-8 rounded-[4px] flex-shrink-0 flex items-center justify-center border border-border/50 bg-gradient-to-br from-muted to-background">
|
||||
<Wand2 size={14} className="text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={cn('text-sm font-medium truncate', isAutoSelected ? 'text-primary' : 'text-foreground')}>
|
||||
{t('settings.terminal.theme.auto')}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">{t('settings.terminal.theme.autoDesc')}</div>
|
||||
</div>
|
||||
{isAutoSelected && <Check size={16} className="text-primary flex-shrink-0" />}
|
||||
</button>
|
||||
)}
|
||||
{hiddenSelectedTheme && (
|
||||
<div className="mb-4 rounded-lg border border-border/60 bg-muted/30 px-3 py-2.5">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1 font-semibold">
|
||||
@@ -105,6 +136,7 @@ export const ThemeList: React.FC<ThemeListProps> = ({ selectedThemeId, onSelect
|
||||
</div>
|
||||
)}
|
||||
{/* Dark Themes Section */}
|
||||
{(!filterType || filterType === 'dark') && (
|
||||
<div className="mb-4">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-3">
|
||||
{t('settings.terminal.themeModal.darkThemes')}
|
||||
@@ -120,8 +152,10 @@ export const ThemeList: React.FC<ThemeListProps> = ({ selectedThemeId, onSelect
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Light Themes Section */}
|
||||
{(!filterType || filterType === 'light') && (
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-3">
|
||||
{t('settings.terminal.themeModal.lightThemes')}
|
||||
@@ -137,15 +171,16 @@ export const ThemeList: React.FC<ThemeListProps> = ({ selectedThemeId, onSelect
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom Themes Section */}
|
||||
{customThemes.length > 0 && (
|
||||
{visibleCustomThemes.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-3">
|
||||
{t('terminal.customTheme.section')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{customThemes.map(theme => (
|
||||
{visibleCustomThemes.map(theme => (
|
||||
<ThemeItem
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { cn } from '../lib/utils';
|
||||
import { Host, TerminalSession, Workspace } from '../types';
|
||||
import { DISTRO_LOGOS, DISTRO_COLORS } from './DistroAvatar';
|
||||
import { getShellIconPath, isMonochromeShellIcon } from '../lib/useDiscoveredShells';
|
||||
import { handleTabMiddleClickClose, handleTabMiddleMouseDown } from '../lib/tabInteractions';
|
||||
import { Button } from './ui/button';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from './ui/context-menu';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
@@ -355,6 +356,8 @@ const EditorTopTab: React.FC<EditorTopTabProps> = memo(({
|
||||
data-tab-type="editor"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onRequestCloseEditorTab(editorTab.id))}
|
||||
className="netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: isActive
|
||||
@@ -458,6 +461,8 @@ const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
|
||||
data-tab-type="session"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseSession(session.id))}
|
||||
draggable
|
||||
onDragStart={(e) => onTabDragStart(e, session.id)}
|
||||
onDragEnd={onTabDragEnd}
|
||||
@@ -586,6 +591,8 @@ const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
|
||||
data-tab-type="workspace"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseWorkspace(workspace.id))}
|
||||
draggable
|
||||
onDragStart={(e) => onTabDragStart(e, workspace.id)}
|
||||
onDragEnd={onTabDragEnd}
|
||||
@@ -694,6 +701,8 @@ const LogViewTopTab: React.FC<LogViewTopTabProps> = memo(({
|
||||
data-tab-type="logView"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseLogView(logView.id))}
|
||||
className="netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: isActive
|
||||
|
||||
61
components/ai/claudeConfigEnv.test.ts
Normal file
61
components/ai/claudeConfigEnv.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
splitClaudeEnv,
|
||||
buildClaudeEnv,
|
||||
parseEnvLines,
|
||||
serializeEnvLines,
|
||||
} from "../settings/tabs/ai/claudeConfigEnv";
|
||||
|
||||
test("splitClaudeEnv pulls out config dir and hides CLAUDE_CODE_EXECUTABLE", () => {
|
||||
const result = splitClaudeEnv({
|
||||
CLAUDE_CONFIG_DIR: "/cfg",
|
||||
CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude",
|
||||
ANTHROPIC_API_KEY: "sk-x",
|
||||
});
|
||||
assert.equal(result.configDir, "/cfg");
|
||||
assert.equal(result.envText, "ANTHROPIC_API_KEY=sk-x");
|
||||
});
|
||||
|
||||
test("splitClaudeEnv handles undefined env", () => {
|
||||
assert.deepEqual(splitClaudeEnv(undefined), { configDir: "", envText: "" });
|
||||
});
|
||||
|
||||
test("parseEnvLines parses KEY=VALUE, trims keys, keeps value as-is, skips blanks/comments", () => {
|
||||
assert.deepEqual(
|
||||
parseEnvLines("ANTHROPIC_API_KEY = sk-x\n# comment\n\nANTHROPIC_BASE_URL=https://h/?a=b"),
|
||||
{ ANTHROPIC_API_KEY: "sk-x", ANTHROPIC_BASE_URL: "https://h/?a=b" },
|
||||
);
|
||||
});
|
||||
|
||||
test("serializeEnvLines is the inverse for simple entries", () => {
|
||||
assert.equal(serializeEnvLines({ A: "1", B: "2" }), "A=1\nB=2");
|
||||
});
|
||||
|
||||
test("buildClaudeEnv merges config dir + parsed env, preserves CLAUDE_CODE_EXECUTABLE, drops empties", () => {
|
||||
const prev = { CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude", OLD: "x" };
|
||||
const next = buildClaudeEnv(prev, "/cfg", "ANTHROPIC_API_KEY=sk-x");
|
||||
assert.deepEqual(next, {
|
||||
CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude",
|
||||
CLAUDE_CONFIG_DIR: "/cfg",
|
||||
ANTHROPIC_API_KEY: "sk-x",
|
||||
});
|
||||
});
|
||||
|
||||
test("buildClaudeEnv omits config dir when blank and returns undefined when empty", () => {
|
||||
assert.equal(buildClaudeEnv(undefined, " ", ""), undefined);
|
||||
});
|
||||
|
||||
test("buildClaudeEnv ignores managed keys typed into the env editor", () => {
|
||||
const next = buildClaudeEnv(
|
||||
{ CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude" },
|
||||
"/cfg",
|
||||
"CLAUDE_CODE_EXECUTABLE=/evil/claude\nCLAUDE_CONFIG_DIR=/evil/dir\nANTHROPIC_API_KEY=sk-x",
|
||||
);
|
||||
assert.deepEqual(next, {
|
||||
CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude",
|
||||
CLAUDE_CONFIG_DIR: "/cfg",
|
||||
ANTHROPIC_API_KEY: "sk-x",
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,8 @@ interface ThemeSelectModalProps {
|
||||
onClose: () => void;
|
||||
selectedThemeId: string;
|
||||
onSelect: (themeId: string) => void;
|
||||
filterType?: 'dark' | 'light';
|
||||
showAutoOption?: boolean;
|
||||
}
|
||||
|
||||
export const ThemeSelectModal: React.FC<ThemeSelectModalProps> = ({
|
||||
@@ -22,6 +24,8 @@ export const ThemeSelectModal: React.FC<ThemeSelectModalProps> = ({
|
||||
onClose,
|
||||
selectedThemeId,
|
||||
onSelect,
|
||||
filterType,
|
||||
showAutoOption,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -85,6 +89,8 @@ export const ThemeSelectModal: React.FC<ThemeSelectModalProps> = ({
|
||||
<ThemeList
|
||||
selectedThemeId={selectedThemeId}
|
||||
onSelect={handleThemeSelect}
|
||||
filterType={filterType}
|
||||
showAutoOption={showAutoOption}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
buildManagedAgentState,
|
||||
getInitialManagedAgentPaths,
|
||||
} from "./ai/managedAgentState";
|
||||
import { splitClaudeEnv, buildClaudeEnv } from "./ai/claudeConfigEnv";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Props
|
||||
@@ -125,6 +126,29 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
const [claudePathInfo, setClaudePathInfo] = useState<AgentPathInfo | null>(null);
|
||||
const [claudeCustomPath, setClaudeCustomPath] = useState("");
|
||||
const [isResolvingClaude, setIsResolvingClaude] = useState(false);
|
||||
|
||||
const claudeManagedEnv = useMemo(
|
||||
() => externalAgents.find((a) => a.id === "discovered_claude")?.env,
|
||||
[externalAgents],
|
||||
);
|
||||
const { configDir: claudeConfigDir, envText: claudeEnvText } = useMemo(
|
||||
() => splitClaudeEnv(claudeManagedEnv),
|
||||
[claudeManagedEnv],
|
||||
);
|
||||
|
||||
const updateClaudeEnv = useCallback(
|
||||
(nextConfigDir: string, nextEnvText: string) => {
|
||||
setExternalAgents((prev) =>
|
||||
prev.map((a) =>
|
||||
a.id === "discovered_claude"
|
||||
? { ...a, env: buildClaudeEnv(a.env, nextConfigDir, nextEnvText) }
|
||||
: a,
|
||||
),
|
||||
);
|
||||
},
|
||||
[setExternalAgents],
|
||||
);
|
||||
|
||||
const initialManagedPathsRef = useRef<{
|
||||
codex: string;
|
||||
claude: string;
|
||||
@@ -542,6 +566,10 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
customPath={claudeCustomPath}
|
||||
onCustomPathChange={setClaudeCustomPath}
|
||||
onRecheckPath={() => void handleCheckCustomPath("claude")}
|
||||
configDir={claudeConfigDir}
|
||||
onConfigDirChange={(v) => updateClaudeEnv(v, claudeEnvText)}
|
||||
envText={claudeEnvText}
|
||||
onEnvTextChange={(v) => updateClaudeEnv(claudeConfigDir, v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import { TerminalFontSelect } from "../TerminalFontSelect";
|
||||
import { TerminalCjkFontSelect } from "../TerminalCjkFontSelect";
|
||||
import { CustomThemeModal } from "../../terminal/CustomThemeModal";
|
||||
import type { TerminalTheme } from "../../../domain/models";
|
||||
import { resolveFollowedTerminalThemeId, TERMINAL_THEME_AUTO } from "../../../domain/terminalAppearance";
|
||||
|
||||
// Keyword highlight rules editor for global settings
|
||||
const DEFAULT_NEW_RULE_COLOR = '#F87171';
|
||||
@@ -315,6 +316,12 @@ export default function SettingsTerminalTab(props: {
|
||||
setTerminalThemeId: (id: string) => void;
|
||||
followAppTerminalTheme: boolean;
|
||||
setFollowAppTerminalTheme: (value: boolean) => void;
|
||||
terminalThemeDarkId: string;
|
||||
setTerminalThemeDarkId: (id: string) => void;
|
||||
terminalThemeLightId: string;
|
||||
setTerminalThemeLightId: (id: string) => void;
|
||||
lightUiThemeId: string;
|
||||
darkUiThemeId: string;
|
||||
terminalFontFamilyId: string;
|
||||
setTerminalFontFamilyId: (id: string) => void;
|
||||
terminalFontSize: number;
|
||||
@@ -333,6 +340,12 @@ export default function SettingsTerminalTab(props: {
|
||||
setTerminalThemeId,
|
||||
followAppTerminalTheme,
|
||||
setFollowAppTerminalTheme,
|
||||
terminalThemeDarkId,
|
||||
setTerminalThemeDarkId,
|
||||
terminalThemeLightId,
|
||||
setTerminalThemeLightId,
|
||||
lightUiThemeId,
|
||||
darkUiThemeId,
|
||||
terminalFontFamilyId,
|
||||
setTerminalFontFamilyId,
|
||||
terminalFontSize,
|
||||
@@ -364,6 +377,7 @@ export default function SettingsTerminalTab(props: {
|
||||
setShowCustomShellInput(!discoveredShells.some(s => s.id === terminalSettings.localShell));
|
||||
}, [discoveredShells, terminalSettings.localShell]);
|
||||
const [themeModalOpen, setThemeModalOpen] = useState(false);
|
||||
const [themeModalSlot, setThemeModalSlot] = useState<'dark' | 'light' | null>(null);
|
||||
|
||||
// Subscribe to custom theme changes so editing in-place triggers re-render
|
||||
const customThemes = useCustomThemes();
|
||||
@@ -375,6 +389,38 @@ export default function SettingsTerminalTab(props: {
|
||||
|| TERMINAL_THEMES[0];
|
||||
}, [terminalThemeId, customThemes]);
|
||||
|
||||
// Preview themes for the follow-app per-mode pickers. resolvedTheme is
|
||||
// forced per slot so each preview reflects exactly that mode's selection.
|
||||
const darkPreviewTheme = useMemo(() => {
|
||||
const id = resolveFollowedTerminalThemeId({
|
||||
resolvedTheme: 'dark',
|
||||
terminalThemeDarkId, terminalThemeLightId,
|
||||
lightUiThemeId, darkUiThemeId, fallbackThemeId: terminalThemeId,
|
||||
});
|
||||
return TERMINAL_THEMES.find(t => t.id === id)
|
||||
|| customThemes.find(t => t.id === id)
|
||||
// Mirror the runtime fallback in useSettingsState.currentTerminalTheme:
|
||||
// a deleted per-mode override falls back to the manual theme, not [0].
|
||||
|| TERMINAL_THEMES.find(t => t.id === terminalThemeId)
|
||||
|| customThemes.find(t => t.id === terminalThemeId)
|
||||
|| TERMINAL_THEMES[0];
|
||||
}, [terminalThemeDarkId, terminalThemeLightId, lightUiThemeId, darkUiThemeId, terminalThemeId, customThemes]);
|
||||
|
||||
const lightPreviewTheme = useMemo(() => {
|
||||
const id = resolveFollowedTerminalThemeId({
|
||||
resolvedTheme: 'light',
|
||||
terminalThemeDarkId, terminalThemeLightId,
|
||||
lightUiThemeId, darkUiThemeId, fallbackThemeId: terminalThemeId,
|
||||
});
|
||||
return TERMINAL_THEMES.find(t => t.id === id)
|
||||
|| customThemes.find(t => t.id === id)
|
||||
// Mirror the runtime fallback in useSettingsState.currentTerminalTheme:
|
||||
// a deleted per-mode override falls back to the manual theme, not [0].
|
||||
|| TERMINAL_THEMES.find(t => t.id === terminalThemeId)
|
||||
|| customThemes.find(t => t.id === terminalThemeId)
|
||||
|| TERMINAL_THEMES[0];
|
||||
}, [terminalThemeDarkId, terminalThemeLightId, lightUiThemeId, darkUiThemeId, terminalThemeId, customThemes]);
|
||||
|
||||
const handleAutocompleteGhostTextChange = useCallback((enabled: boolean) => {
|
||||
updateTerminalSetting("autocompleteGhostText", enabled);
|
||||
if (enabled) {
|
||||
@@ -556,7 +602,34 @@ export default function SettingsTerminalTab(props: {
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
{!followAppTerminalTheme && (
|
||||
{followAppTerminalTheme ? (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1.5 px-1">
|
||||
{t("settings.terminal.theme.darkTheme")}
|
||||
</div>
|
||||
<ThemePreviewButton
|
||||
theme={darkPreviewTheme}
|
||||
onClick={() => setThemeModalSlot('dark')}
|
||||
buttonLabel={terminalThemeDarkId === TERMINAL_THEME_AUTO
|
||||
? t("settings.terminal.theme.auto")
|
||||
: t("settings.terminal.theme.selectButton")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1.5 px-1">
|
||||
{t("settings.terminal.theme.lightTheme")}
|
||||
</div>
|
||||
<ThemePreviewButton
|
||||
theme={lightPreviewTheme}
|
||||
onClick={() => setThemeModalSlot('light')}
|
||||
buttonLabel={terminalThemeLightId === TERMINAL_THEME_AUTO
|
||||
? t("settings.terminal.theme.auto")
|
||||
: t("settings.terminal.theme.selectButton")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ThemePreviewButton
|
||||
theme={currentTheme}
|
||||
onClick={() => setThemeModalOpen(true)}
|
||||
@@ -570,6 +643,17 @@ export default function SettingsTerminalTab(props: {
|
||||
selectedThemeId={terminalThemeId}
|
||||
onSelect={setTerminalThemeId}
|
||||
/>
|
||||
<ThemeSelectModal
|
||||
open={themeModalSlot !== null}
|
||||
onClose={() => setThemeModalSlot(null)}
|
||||
selectedThemeId={themeModalSlot === 'dark' ? terminalThemeDarkId : terminalThemeLightId}
|
||||
onSelect={(id) => {
|
||||
if (themeModalSlot === 'dark') setTerminalThemeDarkId(id);
|
||||
else if (themeModalSlot === 'light') setTerminalThemeLightId(id);
|
||||
}}
|
||||
filterType={themeModalSlot === 'light' ? 'light' : 'dark'}
|
||||
showAutoOption
|
||||
/>
|
||||
|
||||
{/* Theme action buttons */}
|
||||
<div className="flex items-center gap-2 -mt-1">
|
||||
@@ -810,6 +894,12 @@ export default function SettingsTerminalTab(props: {
|
||||
>
|
||||
<Toggle checked={terminalSettings.altAsMeta} onChange={(v) => updateTerminalSetting("altAsMeta", v)} />
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("settings.terminal.keyboard.optionArrowWordJump")}
|
||||
description={t("settings.terminal.keyboard.optionArrowWordJump.desc")}
|
||||
>
|
||||
<Toggle checked={terminalSettings.optionArrowWordJump} onChange={(v) => updateTerminalSetting("optionArrowWordJump", v)} />
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<SectionHeader title={t("settings.terminal.section.accessibility")} />
|
||||
@@ -990,6 +1080,29 @@ export default function SettingsTerminalTab(props: {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SectionHeader title={t("settings.terminal.section.startupCommand")} />
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
{t("settings.terminal.startupCommandDelay.desc")}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t("settings.terminal.startupCommandDelay.label")}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={10000}
|
||||
value={terminalSettings.startupCommandDelayMs}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
if (!isNaN(val) && val >= 0 && val <= 10000) {
|
||||
updateTerminalSetting("startupCommandDelayMs", val);
|
||||
}
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SectionHeader title={t("settings.terminal.section.keywordHighlight")} />
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React from "react";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { ChevronDown, RefreshCw } from "lucide-react";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import type { AgentPathInfo } from "./types";
|
||||
import { ProviderIconBadge } from "./ProviderIconBadge";
|
||||
import { parseEnvLines, serializeEnvLines } from "./claudeConfigEnv";
|
||||
|
||||
export const ClaudeCodeCard: React.FC<{
|
||||
pathInfo: AgentPathInfo | null;
|
||||
@@ -12,15 +13,40 @@ export const ClaudeCodeCard: React.FC<{
|
||||
customPath: string;
|
||||
onCustomPathChange: (path: string) => void;
|
||||
onRecheckPath: () => void;
|
||||
configDir: string;
|
||||
onConfigDirChange: (value: string) => void;
|
||||
envText: string;
|
||||
onEnvTextChange: (value: string) => void;
|
||||
}> = ({
|
||||
pathInfo,
|
||||
isResolvingPath,
|
||||
customPath,
|
||||
onCustomPathChange,
|
||||
onRecheckPath,
|
||||
configDir,
|
||||
onConfigDirChange,
|
||||
envText,
|
||||
onEnvTextChange,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const found = pathInfo?.available;
|
||||
// Collapsed by default; auto-expand when the user already has config so it
|
||||
// isn't hidden. Local UI state — not persisted.
|
||||
const [configOpen, setConfigOpen] = useState(
|
||||
() => Boolean(configDir.trim() || envText.trim()),
|
||||
);
|
||||
|
||||
// The env editor keeps the raw text the user types. Persisting parses it into
|
||||
// a record (dropping incomplete lines), so binding the textarea directly to
|
||||
// the persisted value would erase a key the moment it's typed before its "=".
|
||||
// Only resync from the persisted value when it changes for some reason other
|
||||
// than our own parse→serialize round-trip.
|
||||
const [envDraft, setEnvDraft] = useState(envText);
|
||||
useEffect(() => {
|
||||
setEnvDraft((prev) =>
|
||||
serializeEnvLines(parseEnvLines(prev)) === envText ? prev : envText,
|
||||
);
|
||||
}, [envText]);
|
||||
|
||||
const statusText = isResolvingPath
|
||||
? t('ai.claude.detecting')
|
||||
@@ -83,6 +109,53 @@ export const ClaudeCodeCard: React.FC<{
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Authentication & config (optional, collapsible) */}
|
||||
<div className="border-t border-border/60 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfigOpen((v) => !v)}
|
||||
aria-expanded={configOpen}
|
||||
className="flex w-full items-center justify-between gap-2 text-left"
|
||||
>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t('ai.claude.configSection')}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={cn("text-muted-foreground transition-transform", configOpen && "rotate-180")}
|
||||
/>
|
||||
</button>
|
||||
{configOpen && (
|
||||
<div className="space-y-3 mt-3">
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="claude-config-dir" className="text-xs text-muted-foreground">{t('ai.claude.configDir')}</label>
|
||||
<input
|
||||
id="claude-config-dir"
|
||||
type="text"
|
||||
value={configDir}
|
||||
onChange={(e) => onConfigDirChange(e.target.value)}
|
||||
placeholder={t('ai.claude.configDir.placeholder')}
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.claude.configDir.hint')}</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="claude-env-vars" className="text-xs text-muted-foreground">{t('ai.claude.envVars')}</label>
|
||||
<textarea
|
||||
id="claude-env-vars"
|
||||
value={envDraft}
|
||||
onChange={(e) => { setEnvDraft(e.target.value); onEnvTextChange(e.target.value); }}
|
||||
placeholder={t('ai.claude.envVars.placeholder')}
|
||||
rows={3}
|
||||
spellCheck={false}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-y"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.claude.envVars.hint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
65
components/settings/tabs/ai/claudeConfigEnv.ts
Normal file
65
components/settings/tabs/ai/claudeConfigEnv.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Pure helpers for the Claude Code card's "config directory + environment
|
||||
* variables" editor. The managed Claude agent stores everything in its
|
||||
* ExternalAgentConfig.env; this splits that into the editable pieces and
|
||||
* recombines them. CLAUDE_CODE_EXECUTABLE is owned by path discovery, so it
|
||||
* is preserved across edits but never shown in the env editor.
|
||||
*/
|
||||
|
||||
const CONFIG_DIR_KEY = "CLAUDE_CONFIG_DIR";
|
||||
const MANAGED_KEYS = new Set(["CLAUDE_CODE_EXECUTABLE", CONFIG_DIR_KEY]);
|
||||
|
||||
export function parseEnvLines(text: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const rawLine of String(text || "").split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
const eq = line.indexOf("=");
|
||||
if (eq <= 0) continue;
|
||||
const key = line.slice(0, eq).trim();
|
||||
const value = line.slice(eq + 1).trim();
|
||||
if (key) out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function serializeEnvLines(env: Record<string, string>): string {
|
||||
return Object.entries(env)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function splitClaudeEnv(
|
||||
env: Record<string, string> | undefined,
|
||||
): { configDir: string; envText: string } {
|
||||
if (!env) return { configDir: "", envText: "" };
|
||||
const configDir = env[CONFIG_DIR_KEY] ?? "";
|
||||
const rest: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(env)) {
|
||||
if (MANAGED_KEYS.has(k)) continue;
|
||||
rest[k] = v;
|
||||
}
|
||||
return { configDir, envText: serializeEnvLines(rest) };
|
||||
}
|
||||
|
||||
export function buildClaudeEnv(
|
||||
prevEnv: Record<string, string> | undefined,
|
||||
configDir: string,
|
||||
envText: string,
|
||||
): Record<string, string> | undefined {
|
||||
const next: Record<string, string> = {};
|
||||
// Preserve discovery-owned key if present.
|
||||
const exe = prevEnv?.CLAUDE_CODE_EXECUTABLE;
|
||||
if (exe) next.CLAUDE_CODE_EXECUTABLE = exe;
|
||||
|
||||
const trimmedDir = String(configDir || "").trim();
|
||||
if (trimmedDir) next[CONFIG_DIR_KEY] = trimmedDir;
|
||||
|
||||
// Drop managed keys if a user typed them into the free-text editor — the
|
||||
// config-dir field and path discovery own CLAUDE_CONFIG_DIR / CLAUDE_CODE_EXECUTABLE.
|
||||
const parsed = parseEnvLines(envText);
|
||||
for (const key of MANAGED_KEYS) delete parsed[key];
|
||||
Object.assign(next, parsed);
|
||||
|
||||
return Object.keys(next).length > 0 ? next : undefined;
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import React, {
|
||||
} from "react";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import { logger } from "../../lib/logger";
|
||||
import { handleTabMiddleClickClose, handleTabMiddleMouseDown } from "../../lib/tabInteractions";
|
||||
import { useRenderTracker } from "../../lib/useRenderTracker";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
import { cn } from "../../lib/utils";
|
||||
@@ -322,6 +323,8 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
|
||||
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}
|
||||
|
||||
118
components/terminal/TerminalAutocomplete.tsx
Normal file
118
components/terminal/TerminalAutocomplete.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import ReactDOM from "react-dom";
|
||||
import type { ComponentProps, RefObject } from "react";
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import {
|
||||
useTerminalAutocomplete,
|
||||
AutocompletePopup,
|
||||
type AutocompleteSettings,
|
||||
} from "./autocomplete";
|
||||
import type { Snippet } from "../../domain/models";
|
||||
|
||||
type PopupProps = ComponentProps<typeof AutocompletePopup>;
|
||||
|
||||
/** A mutable handler ref Terminal hands down for the xterm runtime to call. */
|
||||
type HandlerRef<T> = { current: T | undefined };
|
||||
|
||||
interface TerminalAutocompleteProps {
|
||||
termRef: RefObject<XTerm | null>;
|
||||
sessionId: string;
|
||||
hostId: string;
|
||||
hostOs: "linux" | "windows" | "macos";
|
||||
settings?: Partial<AutocompleteSettings>;
|
||||
protocol?: string;
|
||||
getCwd?: () => string | undefined;
|
||||
onAcceptText: (text: string) => void;
|
||||
snippets?: Snippet[];
|
||||
onAcceptSnippet?: (snippet: Snippet) => void;
|
||||
/** Whether this terminal tab is the visible one. */
|
||||
visible: boolean;
|
||||
themeColors: PopupProps["themeColors"];
|
||||
containerRef: PopupProps["containerRef"];
|
||||
searchBarOffset: number;
|
||||
// Handlers exposed back to Terminal so createXTermRuntime can drive them.
|
||||
keyEventRef: HandlerRef<(e: KeyboardEvent) => boolean>;
|
||||
inputRef: HandlerRef<(data: string) => void>;
|
||||
repositionRef: HandlerRef<() => void>;
|
||||
closeRef: HandlerRef<() => void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the terminal autocomplete hook and renders its popup.
|
||||
*
|
||||
* Kept as its own component so the frequent autocomplete state updates
|
||||
* (suggestions, selection, live-preview navigation) re-render only this small
|
||||
* subtree rather than the whole Terminal component. The hook's handlers are
|
||||
* surfaced back to Terminal through refs so the xterm runtime can call them.
|
||||
*
|
||||
* Must be mounted unconditionally for the terminal session's lifetime: the hook
|
||||
* records command history on Enter and intercepts completion keys even while no
|
||||
* popup is visible. Visibility only gates the rendered popup, not the hook.
|
||||
*/
|
||||
export function TerminalAutocomplete({
|
||||
termRef,
|
||||
sessionId,
|
||||
hostId,
|
||||
hostOs,
|
||||
settings,
|
||||
protocol,
|
||||
getCwd,
|
||||
onAcceptText,
|
||||
snippets,
|
||||
onAcceptSnippet,
|
||||
visible,
|
||||
themeColors,
|
||||
containerRef,
|
||||
searchBarOffset,
|
||||
keyEventRef,
|
||||
inputRef,
|
||||
repositionRef,
|
||||
closeRef,
|
||||
}: TerminalAutocompleteProps) {
|
||||
const autocomplete = useTerminalAutocomplete({
|
||||
termRef,
|
||||
sessionId,
|
||||
hostId,
|
||||
hostOs,
|
||||
settings,
|
||||
onAcceptText,
|
||||
snippets,
|
||||
onAcceptSnippet,
|
||||
protocol,
|
||||
getCwd,
|
||||
});
|
||||
|
||||
// Surface the handlers for runtime wiring. They have stable identities
|
||||
// (useCallback over refs), so assigning during render is cheap and mirrors
|
||||
// the wiring Terminal did inline before this was extracted.
|
||||
keyEventRef.current = autocomplete.handleKeyEvent;
|
||||
inputRef.current = autocomplete.handleInput;
|
||||
repositionRef.current = autocomplete.repositionPopup;
|
||||
closeRef.current = autocomplete.closePopup;
|
||||
|
||||
const { state } = autocomplete;
|
||||
if (!visible || !state.popupVisible || state.suggestions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Portal to body so the popup escapes the terminal container's overflow.
|
||||
return ReactDOM.createPortal(
|
||||
<AutocompletePopup
|
||||
suggestions={state.suggestions}
|
||||
selectedIndex={state.selectedIndex}
|
||||
position={state.popupPosition}
|
||||
cursorLineTop={state.popupCursorLineTop}
|
||||
cursorLineBottom={state.popupCursorLineBottom}
|
||||
visible={state.popupVisible}
|
||||
expandUpward={state.expandUpward}
|
||||
themeColors={themeColors}
|
||||
onSelect={autocomplete.selectSuggestion}
|
||||
subDirPanels={state.subDirPanels}
|
||||
subDirFocusLevel={state.subDirFocusLevel}
|
||||
containerRef={containerRef}
|
||||
onRequestReposition={autocomplete.repositionPopup}
|
||||
searchBarOffset={searchBarOffset}
|
||||
onDismiss={autocomplete.closePopup}
|
||||
/>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
33
components/terminal/ZmodemOverwriteDialog.tsx
Normal file
33
components/terminal/ZmodemOverwriteDialog.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React, { useState } from "react";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "../ui/dialog";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
interface Props {
|
||||
filename: string;
|
||||
onRespond: (action: "overwrite" | "skip" | "cancel", applyToRest: boolean) => void;
|
||||
}
|
||||
|
||||
export const ZmodemOverwriteDialog: React.FC<Props> = ({ filename, onRespond }) => {
|
||||
const { t } = useI18n();
|
||||
const [applyToRest, setApplyToRest] = useState(false);
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => { if (!o) onRespond("cancel", false); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("zmodem.overwrite.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground break-all">{filename}</p>
|
||||
<label className="flex items-center gap-2 text-sm mt-2">
|
||||
<input type="checkbox" checked={applyToRest} onChange={(e) => setApplyToRest(e.target.checked)} />
|
||||
{t("zmodem.overwrite.applyToRest")}
|
||||
</label>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onRespond("cancel", applyToRest)}>{t("zmodem.overwrite.cancel")}</Button>
|
||||
<Button variant="outline" onClick={() => onRespond("skip", applyToRest)}>{t("zmodem.overwrite.skip")}</Button>
|
||||
<Button onClick={() => onRespond("overwrite", applyToRest)}>{t("zmodem.overwrite.overwrite")}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -59,6 +59,7 @@ const SOURCE_LABELS: Record<SuggestionSource, { label: string; fullLabel: string
|
||||
option: { label: "o", fullLabel: "Option", fallbackColor: "#A78BFA" },
|
||||
arg: { label: "a", fullLabel: "Argument", fallbackColor: "#F87171" },
|
||||
path: { label: "p", fullLabel: "Path", fallbackColor: "#38BDF8" },
|
||||
snippet: { label: "{}", fullLabel: "Snippet", fallbackColor: "#C084FC" },
|
||||
};
|
||||
|
||||
/** Lucide icon components for file types in path suggestions */
|
||||
@@ -91,6 +92,32 @@ const DirExpandIndicator: React.FC<{ visible: boolean; color: string }> = ({ vis
|
||||
<span style={{ fontSize: "10px", color, opacity: visible ? 0.6 : 0, flexShrink: 0, marginLeft: "2px" }}>›</span>
|
||||
);
|
||||
|
||||
/** Small key-cap badge shown on the selected row to hint the actionable key. */
|
||||
const KeyCap: React.FC<{ label: string; color: string; bg: string }> = ({ label, color, bg }) => (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
boxSizing: "border-box",
|
||||
height: "16px",
|
||||
minWidth: "16px",
|
||||
padding: "0 4px",
|
||||
fontSize: "11px",
|
||||
lineHeight: 1,
|
||||
borderRadius: "4px",
|
||||
border: `1px solid color-mix(in srgb, ${color} 35%, transparent)`,
|
||||
color: `color-mix(in srgb, ${color} 80%, ${bg})`,
|
||||
backgroundColor: `color-mix(in srgb, ${color} 12%, ${bg})`,
|
||||
flexShrink: 0,
|
||||
fontFamily:
|
||||
'ui-sans-serif, -apple-system, "Segoe UI", system-ui, sans-serif',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
|
||||
const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
|
||||
suggestions,
|
||||
selectedIndex,
|
||||
@@ -327,8 +354,9 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
|
||||
{suggestion.displayText}
|
||||
</span>
|
||||
|
||||
{/* Inline description (truncated) */}
|
||||
{suggestion.description && (
|
||||
{/* Inline description (truncated). Snippets show only their label
|
||||
in the row — the full command lives in the detail preview. */}
|
||||
{suggestion.source !== "snippet" && suggestion.description && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
@@ -361,6 +389,16 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
|
||||
{suggestion.source === "path" && suggestion.fileType === "directory" && (
|
||||
<DirExpandIndicator visible={isSelected || isHovered} color={dimTextColor} />
|
||||
)}
|
||||
|
||||
{/* Key hint on the selected row: → expands directories, ↵ runs. */}
|
||||
{isSelected && (
|
||||
<span style={{ display: "flex", gap: "3px", marginLeft: "4px", flexShrink: 0 }}>
|
||||
{suggestion.source === "path" && suggestion.fileType === "directory" && (
|
||||
<KeyCap label="→" color={dimTextColor} bg={popupBg} />
|
||||
)}
|
||||
<KeyCap label="⏎" color={dimTextColor} bg={popupBg} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -445,7 +483,22 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: "12px", color: dimTextColor, lineHeight: "1.5", wordBreak: "break-word" }}>
|
||||
{detailItem.description}
|
||||
{detailItem.source === "snippet" ? (
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
fontFamily: "var(--terminal-font, monospace)",
|
||||
fontSize: "11px",
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{detailItem.description}
|
||||
</pre>
|
||||
) : (
|
||||
detailItem.description
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -30,9 +30,11 @@ import {
|
||||
getPathSuggestions,
|
||||
resolvePathComponents,
|
||||
} from "./remotePathCompleter";
|
||||
import { getSnippetSuggestions } from "./snippetCompleter";
|
||||
import type { Snippet } from "../../../domain/models";
|
||||
|
||||
/** Source indicator for where a suggestion came from */
|
||||
export type SuggestionSource = "history" | "command" | "subcommand" | "option" | "arg" | "path";
|
||||
export type SuggestionSource = "history" | "command" | "subcommand" | "option" | "arg" | "path" | "snippet";
|
||||
|
||||
export interface CompletionSuggestion {
|
||||
/** The text to insert */
|
||||
@@ -49,6 +51,8 @@ export interface CompletionSuggestion {
|
||||
frequency?: number;
|
||||
/** For path suggestions: file type */
|
||||
fileType?: "file" | "directory" | "symlink";
|
||||
/** For snippet suggestions: the source snippet (used by the accept path). */
|
||||
snippet?: Snippet;
|
||||
}
|
||||
|
||||
export interface CompletionContext {
|
||||
@@ -168,6 +172,8 @@ export async function getCompletions(
|
||||
protocol?: string;
|
||||
/** Current working directory (from OSC 7) */
|
||||
cwd?: string;
|
||||
/** Custom snippets to surface at the command position */
|
||||
snippets?: Snippet[];
|
||||
} = {},
|
||||
): Promise<CompletionSuggestion[]> {
|
||||
const { hostId, maxResults = 15 } = options;
|
||||
@@ -290,6 +296,16 @@ export async function getCompletions(
|
||||
}
|
||||
}
|
||||
|
||||
// Snippets: only at the command position (typing the command name).
|
||||
// Push without the early seen-text skip: snippets score above history, so if
|
||||
// a snippet's label collides with an existing history entry's text, the
|
||||
// score-sort + final dedup below keeps the snippet (the higher-scored one).
|
||||
if (options.snippets && options.snippets.length > 0 && ctx.wordIndex === 0) {
|
||||
for (const snippetSuggestion of getSnippetSuggestions(input, options.snippets, { hostId })) {
|
||||
suggestions.push(snippetSuggestion);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score descending
|
||||
suggestions.sort((a, b) => b.score - a.score);
|
||||
|
||||
|
||||
24
components/terminal/autocomplete/livePreviewSequence.ts
Normal file
24
components/terminal/autocomplete/livePreviewSequence.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Compute the keystrokes to send so the terminal input line becomes exactly
|
||||
* `candidate`, given what is currently on the line. Drives the popup
|
||||
* autocomplete live-preview (#1005): moving the selection renders the chosen
|
||||
* suggestion into the command line, and switching / reverting rewrites it.
|
||||
*
|
||||
* - Forward prefix (candidate continues the line): append only the new tail.
|
||||
* - Otherwise: clear the current input, then write the full candidate. POSIX
|
||||
* shells use Ctrl-U (kill-line); Windows (cmd/PowerShell) uses backspaces
|
||||
* sized to the current line length.
|
||||
*/
|
||||
export function computeLivePreviewWrite(input: {
|
||||
currentLine: string;
|
||||
candidate: string;
|
||||
os: string;
|
||||
}): string {
|
||||
const { currentLine, candidate, os } = input;
|
||||
if (candidate === currentLine) return "";
|
||||
if (candidate.startsWith(currentLine)) {
|
||||
return candidate.slice(currentLine.length);
|
||||
}
|
||||
const clear = os === "windows" ? "\b".repeat(currentLine.length) : "\x15";
|
||||
return clear + candidate;
|
||||
}
|
||||
49
components/terminal/autocomplete/snippetCompleter.ts
Normal file
49
components/terminal/autocomplete/snippetCompleter.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Snippet completion source. Surfaces custom snippets in terminal autocomplete
|
||||
* when the user is typing the command name. Matches against the snippet label
|
||||
* and the first line of its command (case-insensitive; prefix matches rank
|
||||
* above substring matches). Each suggestion carries the full Snippet so the
|
||||
* accept path can run it through the canonical executeSnippetCommand.
|
||||
*/
|
||||
import type { Snippet } from "../../../domain/models";
|
||||
import type { CompletionSuggestion } from "./completionEngine";
|
||||
|
||||
const SNIPPET_BASE_SCORE = 2000; // Above history (1000+freq) per "snippet > history".
|
||||
const SNIPPET_PREFIX_BONUS = 100;
|
||||
|
||||
function appliesToHost(snippet: Snippet, hostId?: string): boolean {
|
||||
if (!snippet.targets || snippet.targets.length === 0) return true;
|
||||
return hostId !== undefined && snippet.targets.includes(hostId);
|
||||
}
|
||||
|
||||
export function getSnippetSuggestions(
|
||||
input: string,
|
||||
snippets: Snippet[],
|
||||
options: { hostId?: string } = {},
|
||||
): CompletionSuggestion[] {
|
||||
const needle = input.trim().toLowerCase();
|
||||
if (!needle || !Array.isArray(snippets)) return [];
|
||||
|
||||
const out: CompletionSuggestion[] = [];
|
||||
for (const snippet of snippets) {
|
||||
if (!appliesToHost(snippet, options.hostId)) continue;
|
||||
const label = (snippet.label || "").toLowerCase();
|
||||
const firstLine = (snippet.command || "").split("\n")[0].trim().toLowerCase();
|
||||
|
||||
const labelPrefix = label.startsWith(needle);
|
||||
const matches = labelPrefix || label.includes(needle) || firstLine.startsWith(needle);
|
||||
if (!matches) continue;
|
||||
|
||||
out.push({
|
||||
text: snippet.label,
|
||||
displayText: snippet.label,
|
||||
description: snippet.command,
|
||||
source: "snippet",
|
||||
score: SNIPPET_BASE_SCORE + (labelPrefix ? SNIPPET_PREFIX_BONUS : 0),
|
||||
snippet,
|
||||
});
|
||||
}
|
||||
|
||||
out.sort((a, b) => b.score - a.score);
|
||||
return out;
|
||||
}
|
||||
@@ -18,12 +18,14 @@ import {
|
||||
type PromptDetectionResult,
|
||||
} from "./promptDetector";
|
||||
import { getCompletions, parseCommandLine, type CompletionSuggestion } from "./completionEngine";
|
||||
import type { Snippet } from "../../../domain/models";
|
||||
import { recordCommand } from "./commandHistoryStore";
|
||||
import { shellEscape } from "./completionEngine";
|
||||
import { preloadCommonSpecs } from "./figSpecLoader";
|
||||
import { getXTermCellDimensions } from "./xtermUtils";
|
||||
import { listDirectoryEntries, normalizePathTokenForLookup } from "./remotePathCompleter";
|
||||
import { decideGhostSuggestion } from "./ghostSuggestionPolicy";
|
||||
import { computeLivePreviewWrite } from "./livePreviewSequence";
|
||||
|
||||
export interface AutocompleteSettings {
|
||||
enabled: boolean;
|
||||
@@ -46,6 +48,18 @@ export const DEFAULT_AUTOCOMPLETE_SETTINGS: AutocompleteSettings = {
|
||||
fastTypingThresholdMs: 40,
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether completion work is worth doing — i.e. whether anything would
|
||||
* actually be rendered. With both the popup and ghost text disabled, querying
|
||||
* completions only to discard the result is pure main-thread waste, so callers
|
||||
* skip it entirely.
|
||||
*/
|
||||
export function shouldQueryCompletions(
|
||||
settings: Pick<AutocompleteSettings, "showPopupMenu" | "showGhostText">,
|
||||
): boolean {
|
||||
return settings.showPopupMenu || settings.showGhostText;
|
||||
}
|
||||
|
||||
/** Shared empty state to avoid creating new objects on every reset */
|
||||
const EMPTY_STATE: AutocompleteState = Object.freeze({
|
||||
suggestions: [],
|
||||
@@ -99,6 +113,10 @@ interface UseTerminalAutocompleteOptions {
|
||||
protocol?: string;
|
||||
/** Get current working directory (from OSC 7 or other source) */
|
||||
getCwd?: () => string | undefined;
|
||||
/** Custom snippets to surface at the command position */
|
||||
snippets?: Snippet[];
|
||||
/** Accept a snippet — clears typed input then runs it (host-canonical send) */
|
||||
onAcceptSnippet?: (snippet: Snippet) => void;
|
||||
}
|
||||
|
||||
export interface TerminalAutocompleteHandle {
|
||||
@@ -205,7 +223,7 @@ export function getCommandToRecordOnEnter(
|
||||
export function useTerminalAutocomplete(
|
||||
options: UseTerminalAutocompleteOptions,
|
||||
): TerminalAutocompleteHandle {
|
||||
const { termRef, sessionId, hostId, hostOs, settings: userSettings, onAcceptText, protocol, getCwd } = options;
|
||||
const { termRef, sessionId, hostId, hostOs, settings: userSettings, onAcceptText, protocol, getCwd, snippets, onAcceptSnippet } = options;
|
||||
const rawSettings: AutocompleteSettings = {
|
||||
...DEFAULT_AUTOCOMPLETE_SETTINGS,
|
||||
...userSettings,
|
||||
@@ -227,6 +245,10 @@ export function useTerminalAutocomplete(
|
||||
settingsRef.current = settings;
|
||||
const onAcceptTextRef = useRef(onAcceptText);
|
||||
onAcceptTextRef.current = onAcceptText;
|
||||
const snippetsRef = useRef(snippets);
|
||||
snippetsRef.current = snippets;
|
||||
const onAcceptSnippetRef = useRef(onAcceptSnippet);
|
||||
onAcceptSnippetRef.current = onAcceptSnippet;
|
||||
const hostIdRef = useRef(hostId);
|
||||
hostIdRef.current = hostId;
|
||||
const hostOsRef = useRef(hostOs);
|
||||
@@ -253,6 +275,10 @@ export function useTerminalAutocomplete(
|
||||
const fetchVersionRef = useRef(0);
|
||||
/** Last accepted suggestion text — for accurate history recording on fast Enter after accept */
|
||||
const lastAcceptedCommandRef = useRef<string | null>(null);
|
||||
/** The user's typed input that produced the current popup suggestions (live-preview baseline). */
|
||||
const previewBaselineRef = useRef<string>("");
|
||||
/** Whether a popup candidate is currently rendered into the command line (#1005). */
|
||||
const previewActiveRef = useRef(false);
|
||||
/** Monotonic counter to invalidate stale async sub-dir fetches */
|
||||
const subDirFetchVersionRef = useRef(0);
|
||||
/**
|
||||
@@ -536,6 +562,41 @@ export function useTerminalAutocomplete(
|
||||
});
|
||||
}, [termRef]);
|
||||
|
||||
/**
|
||||
* Render the full path for a sub-dir entry into the line WITHOUT finalizing
|
||||
* (no clearState). Used for live-preview while navigating sub-dir panels (#1005).
|
||||
*/
|
||||
const renderSubDirPath = useCallback((level: number, entry: SubDirEntry) => {
|
||||
const s = stateRef.current;
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
const panel = s.subDirPanels[level];
|
||||
if (!panel) return;
|
||||
const { prompt } = getAlignedPrompt(
|
||||
term, typedInputBufferRef.current, typedBufferReliableRef.current,
|
||||
);
|
||||
if (!prompt.isAtPrompt) return;
|
||||
const parsed = parseCommandLine(prompt.userInput);
|
||||
const cmdPrefix = parsed.tokens.slice(0, parsed.wordIndex).join(" ")
|
||||
+ (parsed.wordIndex > 0 ? " " : "");
|
||||
const currentToken = parsed.currentWord;
|
||||
const quotePrefix = currentToken.startsWith('"') || currentToken.startsWith("'")
|
||||
? currentToken[0] : "";
|
||||
const quoteSuffix = quotePrefix && currentToken.endsWith(quotePrefix) ? quotePrefix : "";
|
||||
const suffix = entry.type === "directory" ? "/" : "";
|
||||
const entryName = quotePrefix || !/[\\$'"|!<>;#~` ]/.test(entry.name)
|
||||
? entry.name : shellEscape(entry.name);
|
||||
const newCommand = cmdPrefix + `${quotePrefix}${panel.dirPath}${entryName}${suffix}${quoteSuffix}`;
|
||||
const seq = computeLivePreviewWrite({
|
||||
currentLine: prompt.userInput, candidate: newCommand, os: hostOsRef.current,
|
||||
});
|
||||
if (seq) writeToTerminal(seq);
|
||||
typedInputBufferRef.current = newCommand;
|
||||
typedBufferReliableRef.current = true;
|
||||
previewActiveRef.current = true;
|
||||
lastAcceptedCommandRef.current = newCommand;
|
||||
}, [termRef, writeToTerminal]);
|
||||
|
||||
/** Handle selecting a file/directory from any sub-dir panel.
|
||||
* Builds the full path from the panel stack and replaces the current input. */
|
||||
const handleSubDirSelect = useCallback((level: number, entry: SubDirEntry) => {
|
||||
@@ -600,6 +661,15 @@ export function useTerminalAutocomplete(
|
||||
return;
|
||||
}
|
||||
|
||||
// Nothing will be rendered when both the popup and ghost text are off, so
|
||||
// don't run the (potentially expensive) completion query just to throw the
|
||||
// result away. Clear any stale state and bail before touching history,
|
||||
// fig specs, or remote path lookups.
|
||||
if (!shouldQueryCompletions(settingsRef.current)) {
|
||||
clearState();
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture version at start — if it changes during async work, discard results
|
||||
const version = ++fetchVersionRef.current;
|
||||
|
||||
@@ -638,6 +708,7 @@ export function useTerminalAutocomplete(
|
||||
sessionId: sessionIdRef.current,
|
||||
protocol: protocolRef.current,
|
||||
cwd,
|
||||
snippets: snippetsRef.current,
|
||||
});
|
||||
|
||||
if (disposedRef.current || version !== fetchVersionRef.current) return;
|
||||
@@ -655,7 +726,8 @@ export function useTerminalAutocomplete(
|
||||
if (settingsRef.current.showGhostText) {
|
||||
const ghost = ghostAddonRef.current;
|
||||
const activeSuggestion = ghost?.isActive() ? ghost.getSuggestion() : null;
|
||||
const nextSuggestion = completions.length > 0 ? completions[0].text : null;
|
||||
// Snippets are popup-only — never used as inline ghost text.
|
||||
const nextSuggestion = completions.find((c) => c.source !== "snippet")?.text ?? null;
|
||||
const ghostDecision = decideGhostSuggestion(activeSuggestion, input, nextSuggestion);
|
||||
if (ghostDecision.type === "show") {
|
||||
ghost?.show(ghostDecision.suggestion, input);
|
||||
@@ -666,6 +738,9 @@ export function useTerminalAutocomplete(
|
||||
|
||||
// Popup
|
||||
if (settingsRef.current.showPopupMenu && completions.length > 0) {
|
||||
// Live-preview baseline: the typed input these suggestions completed.
|
||||
previewBaselineRef.current = input;
|
||||
previewActiveRef.current = false;
|
||||
const { position, cursorLineTop, cursorLineBottom, expandUpward } = calculatePopupPosition(term, completions.length);
|
||||
startTransition(() => {
|
||||
setState((prev) => {
|
||||
@@ -876,6 +951,10 @@ export function useTerminalAutocomplete(
|
||||
// User is typing more — invalidate accepted command fallback since the
|
||||
// command is being edited further (e.g., accepted "git status" then added " --short")
|
||||
lastAcceptedCommandRef.current = null;
|
||||
// The previewed candidate is now edited, so the line is the user's own
|
||||
// text. Drop preview-active so Escape dismisses the popup without
|
||||
// reverting these edits back to the stale baseline (#1005).
|
||||
previewActiveRef.current = false;
|
||||
|
||||
// Re-align any visible ghost text to the freshly-updated buffer
|
||||
// immediately. Without this the ghost keeps the tail it captured at
|
||||
@@ -1055,10 +1134,11 @@ export function useTerminalAutocomplete(
|
||||
// which is otherwise shadowed by our single-Tab ghost accept.
|
||||
if (e.key === "Tab" && !e.ctrlKey && !e.metaKey && !e.altKey && s.subDirFocusLevel < 0) {
|
||||
if (s.popupVisible && s.suggestions.length > 0) {
|
||||
e.preventDefault();
|
||||
const selected = s.suggestions[Math.max(0, s.selectedIndex)];
|
||||
if (selected) insertSuggestion(selected, false);
|
||||
return false;
|
||||
// #1005: don't intercept Tab. Keep whatever is currently rendered on
|
||||
// the line and let Tab reach the shell for native completion.
|
||||
clearState();
|
||||
previewActiveRef.current = false;
|
||||
return true;
|
||||
}
|
||||
// Hide stale ghost text before Tab reaches the shell — the shell's
|
||||
// completion will rewrite the line and the old ghost would mislead.
|
||||
@@ -1087,8 +1167,10 @@ export function useTerminalAutocomplete(
|
||||
panels[focusLevel] = { ...p, selectedIndex: newIdx };
|
||||
return { ...prev, subDirPanels: panels.slice(0, focusLevel + 1) };
|
||||
});
|
||||
// Auto-expand next level if the newly selected item is a directory
|
||||
// Live-render the highlighted entry's full path into the line (#1005).
|
||||
const newEntry = focusedPanel.entries[newIdx];
|
||||
if (newEntry) renderSubDirPath(focusLevel, newEntry);
|
||||
// Auto-expand next level if the newly selected item is a directory
|
||||
if (newEntry?.type === "directory") {
|
||||
expandSubDir(focusLevel, newEntry);
|
||||
}
|
||||
@@ -1144,39 +1226,44 @@ export function useTerminalAutocomplete(
|
||||
return true;
|
||||
}
|
||||
|
||||
// Main panel navigation
|
||||
if (e.key === "ArrowUp") {
|
||||
// Main panel navigation. The cycle includes a -1 "no selection" slot so
|
||||
// ↑ off the top / ↓ off the bottom reverts to the typed baseline. Moving
|
||||
// the selection live-renders the candidate into the command line (#1005).
|
||||
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
const n = s.suggestions.length;
|
||||
const cur = s.selectedIndex;
|
||||
const next =
|
||||
e.key === "ArrowDown"
|
||||
? (cur >= n - 1 ? -1 : cur + 1)
|
||||
: (cur <= -1 ? n - 1 : cur - 1);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
selectedIndex: prev.selectedIndex <= 0 ? prev.suggestions.length - 1 : prev.selectedIndex - 1,
|
||||
selectedIndex: next,
|
||||
subDirPanels: [], subDirFocusLevel: -1,
|
||||
}));
|
||||
fetchSubDirForIndex(s.selectedIndex <= 0 ? s.suggestions.length - 1 : s.selectedIndex - 1);
|
||||
return false;
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
selectedIndex: prev.selectedIndex >= prev.suggestions.length - 1 ? 0 : prev.selectedIndex + 1,
|
||||
subDirPanels: [], subDirFocusLevel: -1,
|
||||
}));
|
||||
fetchSubDirForIndex(s.selectedIndex >= s.suggestions.length - 1 ? 0 : s.selectedIndex + 1);
|
||||
renderPreviewSelection(next);
|
||||
if (next >= 0) fetchSubDirForIndex(next);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Enter on popup
|
||||
// Enter on popup. The selected candidate is already rendered into the
|
||||
// line by live-preview, so let Enter reach the shell. Don't record here:
|
||||
// handleInput's Enter path records the *actual* line — it uses
|
||||
// lastAcceptedCommandRef (set on select) but falls back to the live
|
||||
// buffer when the user edited the previewed command (typing nulls that
|
||||
// ref), so recording stays accurate in both cases.
|
||||
if (e.key === "Enter") {
|
||||
if (s.selectedIndex >= 0) {
|
||||
const selected = s.suggestions[s.selectedIndex];
|
||||
if (selected) {
|
||||
e.preventDefault();
|
||||
insertSuggestion(selected, true);
|
||||
return false;
|
||||
}
|
||||
const selected = s.selectedIndex >= 0 ? s.suggestions[s.selectedIndex] : null;
|
||||
if (selected?.source === "snippet" && selected.snippet) {
|
||||
e.preventDefault();
|
||||
previewActiveRef.current = false;
|
||||
acceptSnippet(selected.snippet);
|
||||
return false; // consume — run the snippet, not the typed text
|
||||
}
|
||||
clearState();
|
||||
previewActiveRef.current = false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1185,8 +1272,12 @@ export function useTerminalAutocomplete(
|
||||
// when only ghost text is showing (ghost text is passive/non-intrusive)
|
||||
if (e.key === "Escape" && s.popupVisible) {
|
||||
e.preventDefault();
|
||||
if (previewActiveRef.current) {
|
||||
renderPreviewSelection(-1); // restore the typed baseline
|
||||
}
|
||||
ghost?.hide();
|
||||
clearState();
|
||||
previewActiveRef.current = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1196,6 +1287,59 @@ export function useTerminalAutocomplete(
|
||||
[writeToTerminal],
|
||||
);
|
||||
|
||||
/**
|
||||
* Render the suggestion at `index` straight into the command line (Termius
|
||||
* live-preview, #1005). `index < 0` restores the user's typed baseline.
|
||||
*/
|
||||
const renderPreviewSelection = useCallback((index: number) => {
|
||||
const s = stateRef.current;
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
const baseline = previewBaselineRef.current;
|
||||
const selected = index >= 0 ? s.suggestions[index] : null;
|
||||
// Snippets aren't literal completions — keep the user's typed text in the
|
||||
// line (the popup detail panel shows the full command instead).
|
||||
const candidate =
|
||||
selected && selected.source !== "snippet" ? selected.text : baseline;
|
||||
const { prompt } = getAlignedPrompt(
|
||||
term,
|
||||
typedInputBufferRef.current,
|
||||
typedBufferReliableRef.current,
|
||||
);
|
||||
if (!prompt.isAtPrompt) return;
|
||||
const seq = computeLivePreviewWrite({
|
||||
currentLine: prompt.userInput,
|
||||
candidate,
|
||||
os: hostOsRef.current,
|
||||
});
|
||||
if (seq) writeToTerminal(seq);
|
||||
typedInputBufferRef.current = candidate;
|
||||
typedBufferReliableRef.current = true;
|
||||
const isPreview = index >= 0 && candidate !== baseline;
|
||||
previewActiveRef.current = isPreview;
|
||||
lastAcceptedCommandRef.current = isPreview ? candidate : null;
|
||||
}, [termRef, writeToTerminal]);
|
||||
|
||||
/** Accept a snippet: clear the user's typed input, then run it via the
|
||||
* host-canonical send path (onAcceptSnippet). */
|
||||
const acceptSnippet = useCallback((snippet: Snippet) => {
|
||||
const term = termRef.current;
|
||||
if (term) {
|
||||
const { prompt } = getAlignedPrompt(term, typedInputBufferRef.current, typedBufferReliableRef.current);
|
||||
if (prompt.isAtPrompt && prompt.userInput.length > 0) {
|
||||
const clearSequence = hostOsRef.current === "windows"
|
||||
? "\b".repeat(prompt.userInput.length)
|
||||
: "\x15"; // Ctrl+U (readline kill-line)
|
||||
writeToTerminal(clearSequence);
|
||||
}
|
||||
}
|
||||
typedInputBufferRef.current = "";
|
||||
typedBufferReliableRef.current = true;
|
||||
onAcceptSnippetRef.current?.(snippet);
|
||||
clearState();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- clearState is stable
|
||||
}, [termRef, writeToTerminal]);
|
||||
|
||||
/**
|
||||
* Insert a suggestion into the terminal.
|
||||
* @param execute If true, also sends \r to execute the command.
|
||||
@@ -1268,9 +1412,13 @@ export function useTerminalAutocomplete(
|
||||
*/
|
||||
const selectSuggestion = useCallback(
|
||||
(suggestion: CompletionSuggestion) => {
|
||||
if (suggestion.source === "snippet" && suggestion.snippet) {
|
||||
acceptSnippet(suggestion.snippet);
|
||||
return;
|
||||
}
|
||||
insertSuggestion(suggestion, false);
|
||||
},
|
||||
[insertSuggestion],
|
||||
[insertSuggestion, acceptSnippet],
|
||||
);
|
||||
|
||||
const closePopup = useCallback(() => {
|
||||
|
||||
19
components/terminal/completionEngineSnippets.test.ts
Normal file
19
components/terminal/completionEngineSnippets.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { getCompletions } from "./autocomplete/completionEngine";
|
||||
import type { Snippet } from "../../domain/models";
|
||||
|
||||
const deploySnippet: Snippet = { id: "d", label: "deploy", command: "kubectl apply -f ." };
|
||||
|
||||
test("getCompletions includes snippet suggestions at the command position", async () => {
|
||||
const out = await getCompletions("dep", { snippets: [deploySnippet] });
|
||||
const snip = out.find((s) => s.source === "snippet");
|
||||
assert.ok(snip, "expected a snippet suggestion");
|
||||
assert.equal(snip?.displayText, "deploy");
|
||||
});
|
||||
|
||||
test("getCompletions does not surface snippets past the command position", async () => {
|
||||
const out = await getCompletions("git dep", { snippets: [deploySnippet] });
|
||||
assert.equal(out.find((s) => s.source === "snippet"), undefined);
|
||||
});
|
||||
25
components/terminal/completionGate.test.ts
Normal file
25
components/terminal/completionGate.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { shouldQueryCompletions } from "./autocomplete/useTerminalAutocomplete.ts";
|
||||
|
||||
test("queries completions when the popup menu is enabled", () => {
|
||||
assert.equal(
|
||||
shouldQueryCompletions({ showPopupMenu: true, showGhostText: false }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("queries completions when ghost text is enabled", () => {
|
||||
assert.equal(
|
||||
shouldQueryCompletions({ showPopupMenu: false, showGhostText: true }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("skips completion work when both popup and ghost text are off", () => {
|
||||
assert.equal(
|
||||
shouldQueryCompletions({ showPopupMenu: false, showGhostText: false }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
98
components/terminal/connectionLogBuffer.test.ts
Normal file
98
components/terminal/connectionLogBuffer.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { createConnectionLogBuffer } from "./connectionLogBuffer.ts";
|
||||
|
||||
test("concatenates appended chunks while under the cap", () => {
|
||||
const buf = createConnectionLogBuffer(100);
|
||||
buf.append("foo");
|
||||
buf.append("bar");
|
||||
buf.append("baz");
|
||||
assert.equal(buf.toString(), "foobarbaz");
|
||||
});
|
||||
|
||||
test("keeps only the last maxChars, matching slice(-max) semantics", () => {
|
||||
const max = 10;
|
||||
const buf = createConnectionLogBuffer(max);
|
||||
const chunks = ["abcd", "efgh", "ijkl", "mnop"]; // 16 chars total
|
||||
let naive = "";
|
||||
for (const c of chunks) {
|
||||
buf.append(c);
|
||||
naive += c;
|
||||
}
|
||||
assert.equal(buf.toString(), naive.slice(-max));
|
||||
assert.equal(buf.toString().length, max);
|
||||
});
|
||||
|
||||
test("trims a single chunk larger than the cap to its last maxChars", () => {
|
||||
const buf = createConnectionLogBuffer(5);
|
||||
buf.append("0123456789");
|
||||
assert.equal(buf.toString(), "56789");
|
||||
});
|
||||
|
||||
test("partial-trims the boundary chunk to keep exactly maxChars", () => {
|
||||
const buf = createConnectionLogBuffer(6);
|
||||
buf.append("abcde"); // 5
|
||||
buf.append("fghij"); // total 10 -> keep last 6 => "efghij"
|
||||
assert.equal(buf.toString(), "efghij");
|
||||
});
|
||||
|
||||
test("stays correct across many small appends (ring semantics)", () => {
|
||||
const max = 50;
|
||||
const buf = createConnectionLogBuffer(max);
|
||||
let naive = "";
|
||||
for (let i = 0; i < 500; i++) {
|
||||
const chunk = `x${i}-`;
|
||||
buf.append(chunk);
|
||||
naive += chunk;
|
||||
}
|
||||
assert.equal(buf.toString(), naive.slice(-max));
|
||||
});
|
||||
|
||||
test("reset clears the buffer", () => {
|
||||
const buf = createConnectionLogBuffer(100);
|
||||
buf.append("hello");
|
||||
buf.reset();
|
||||
assert.equal(buf.toString(), "");
|
||||
buf.append("world");
|
||||
assert.equal(buf.toString(), "world");
|
||||
});
|
||||
|
||||
test("ignores empty appends", () => {
|
||||
const buf = createConnectionLogBuffer(100);
|
||||
buf.append("a");
|
||||
buf.append("");
|
||||
buf.append("b");
|
||||
assert.equal(buf.toString(), "ab");
|
||||
});
|
||||
|
||||
test("keeps the segment count bounded across many tiny appends", () => {
|
||||
// The whole point of the rewrite: trimming must not walk one array entry
|
||||
// per append. With a blockSize of 10 and a 100-char cap, the buffer should
|
||||
// never hold more than ~ceil(cap/blockSize)+1 segments no matter how many
|
||||
// single-char appends arrive once it's at capacity.
|
||||
const maxChars = 100;
|
||||
const blockSize = 10;
|
||||
const buf = createConnectionLogBuffer(maxChars, blockSize);
|
||||
let naive = "";
|
||||
for (let i = 0; i < 10000; i++) {
|
||||
buf.append("x");
|
||||
naive += "x";
|
||||
}
|
||||
assert.ok(
|
||||
buf.segmentCount() <= Math.ceil(maxChars / blockSize) + 1,
|
||||
`segmentCount ${buf.segmentCount()} exceeded the bound`,
|
||||
);
|
||||
assert.equal(buf.toString(), naive.slice(-maxChars));
|
||||
});
|
||||
|
||||
test("seals and trims whole blocks with a small blockSize", () => {
|
||||
const buf = createConnectionLogBuffer(10, 4);
|
||||
const chunks = ["abcd", "efgh", "ijkl"]; // 12 chars total
|
||||
let naive = "";
|
||||
for (const c of chunks) {
|
||||
buf.append(c);
|
||||
naive += c;
|
||||
}
|
||||
assert.equal(buf.toString(), naive.slice(-10)); // "cdefghijkl"
|
||||
});
|
||||
94
components/terminal/connectionLogBuffer.ts
Normal file
94
components/terminal/connectionLogBuffer.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* A bounded, append-only text buffer that retains only the last `maxChars`
|
||||
* characters — the connection log used for diagnostics/replay.
|
||||
*
|
||||
* The naive implementation (`log += chunk; if (log.length > max) log =
|
||||
* log.slice(-max)`) flattens a ~max-length string on *every* append once the
|
||||
* cap is reached — on the render thread, for every output chunk including each
|
||||
* echoed keystroke.
|
||||
*
|
||||
* Instead, data is coalesced into a small, bounded number of fixed-size blocks
|
||||
* (~`maxChars / blockSize`, e.g. ~16 for the 1 MB cap). New data accumulates in
|
||||
* an open `tail`; once it reaches `blockSize` it is sealed into a block. Trimming
|
||||
* the oldest data therefore only ever drops/slices a handful of blocks — never
|
||||
* one array element per append, which would make trim O(number of appends) and
|
||||
* defeat the purpose. Append is amortized O(chunk); the full string is
|
||||
* materialized only on `toString()` (called rarely, on finalize).
|
||||
*/
|
||||
export interface ConnectionLogBuffer {
|
||||
append(chunk: string): void;
|
||||
toString(): string;
|
||||
reset(): void;
|
||||
/**
|
||||
* Number of internal string segments currently retained. Exposed for tests
|
||||
* to assert the bounded-memory / bounded-trim property.
|
||||
*/
|
||||
segmentCount(): number;
|
||||
}
|
||||
|
||||
const DEFAULT_BLOCK_SIZE = 64 * 1024;
|
||||
|
||||
export function createConnectionLogBuffer(
|
||||
maxChars: number,
|
||||
blockSize: number = DEFAULT_BLOCK_SIZE,
|
||||
): ConnectionLogBuffer {
|
||||
let blocks: string[] = []; // sealed blocks, oldest first, each up to ~blockSize
|
||||
let tail = ""; // open block currently being filled (newest data)
|
||||
let total = 0; // total retained length across blocks + tail
|
||||
|
||||
const trim = () => {
|
||||
let overflow = total - maxChars;
|
||||
if (overflow <= 0) return;
|
||||
// Drop/slice whole blocks from the front. `blocks.length` is bounded by
|
||||
// ~maxChars/blockSize, so this shift is O(small constant), not O(appends).
|
||||
while (overflow > 0 && blocks.length > 0) {
|
||||
const head = blocks[0];
|
||||
if (head.length <= overflow) {
|
||||
blocks.shift();
|
||||
total -= head.length;
|
||||
overflow -= head.length;
|
||||
} else {
|
||||
blocks[0] = head.slice(overflow);
|
||||
total -= overflow;
|
||||
overflow = 0;
|
||||
}
|
||||
}
|
||||
// Only reachable when the tail alone exceeds the cap (e.g. blockSize >=
|
||||
// maxChars); keep its last `maxChars` characters.
|
||||
if (overflow > 0) {
|
||||
tail = tail.slice(overflow);
|
||||
total -= overflow;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
append(chunk: string): void {
|
||||
if (!chunk) return;
|
||||
// A single chunk at/over the cap can only contribute its own tail.
|
||||
if (chunk.length >= maxChars) {
|
||||
blocks = [];
|
||||
tail = chunk.slice(chunk.length - maxChars);
|
||||
total = tail.length;
|
||||
return;
|
||||
}
|
||||
tail += chunk;
|
||||
total += chunk.length;
|
||||
if (tail.length >= blockSize) {
|
||||
blocks.push(tail);
|
||||
tail = "";
|
||||
}
|
||||
if (total > maxChars) trim();
|
||||
},
|
||||
toString(): string {
|
||||
return blocks.length > 0 ? blocks.join("") + tail : tail;
|
||||
},
|
||||
reset(): void {
|
||||
blocks = [];
|
||||
tail = "";
|
||||
total = 0;
|
||||
},
|
||||
segmentCount(): number {
|
||||
return blocks.length + (tail.length > 0 ? 1 : 0);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -27,6 +27,7 @@ const initialState: ZmodemTransferState = {
|
||||
|
||||
export function useZmodemTransfer(sessionId: string | null) {
|
||||
const [state, setState] = useState<ZmodemTransferState>(initialState);
|
||||
const [overwriteRequest, setOverwriteRequest] = useState<{ requestId: string; filename: string } | null>(null);
|
||||
const disposeRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const disposeExitRef = useRef<(() => void) | null>(null);
|
||||
@@ -77,6 +78,10 @@ export function useZmodemTransfer(sessionId: string | null) {
|
||||
}
|
||||
});
|
||||
|
||||
const disposeOverwrite = bridge.onZmodemOverwriteRequest?.(sessionId, (payload) => {
|
||||
setOverwriteRequest({ requestId: payload.requestId, filename: payload.filename });
|
||||
});
|
||||
|
||||
// If the session exits mid-transfer (disconnect, shell exit, etc.),
|
||||
// reset state so the progress indicator doesn't stay stuck.
|
||||
disposeExitRef.current = bridge.onSessionExit(sessionId, () => {
|
||||
@@ -86,9 +91,11 @@ export function useZmodemTransfer(sessionId: string | null) {
|
||||
return () => {
|
||||
disposeRef.current?.();
|
||||
disposeRef.current = null;
|
||||
disposeOverwrite?.();
|
||||
disposeExitRef.current?.();
|
||||
disposeExitRef.current = null;
|
||||
setState(initialState);
|
||||
setOverwriteRequest(null);
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
@@ -98,5 +105,12 @@ export function useZmodemTransfer(sessionId: string | null) {
|
||||
bridge?.cancelZmodem?.(sessionId);
|
||||
}, [sessionId]);
|
||||
|
||||
return { ...state, cancel };
|
||||
const respondOverwrite = useCallback((action: "overwrite" | "skip" | "cancel", applyToRest: boolean) => {
|
||||
setOverwriteRequest((req) => {
|
||||
if (req) netcattyBridge.get()?.respondZmodemOverwrite?.({ requestId: req.requestId, action, applyToRest });
|
||||
return null;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { ...state, cancel, overwriteRequest, respondOverwrite };
|
||||
}
|
||||
|
||||
45
components/terminal/livePreviewSequence.test.ts
Normal file
45
components/terminal/livePreviewSequence.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { computeLivePreviewWrite } from "./autocomplete/livePreviewSequence.ts";
|
||||
|
||||
test("appends only the tail when the candidate continues the current line", () => {
|
||||
assert.equal(
|
||||
computeLivePreviewWrite({ currentLine: "do", candidate: "docker", os: "linux" }),
|
||||
"cker",
|
||||
);
|
||||
});
|
||||
|
||||
test("returns empty when the line already equals the candidate", () => {
|
||||
assert.equal(
|
||||
computeLivePreviewWrite({ currentLine: "docker", candidate: "docker", os: "linux" }),
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
test("clears with Ctrl-U then writes the full candidate on a non-prefix change", () => {
|
||||
assert.equal(
|
||||
computeLivePreviewWrite({ currentLine: "docker", candidate: "df", os: "linux" }),
|
||||
"\x15df",
|
||||
);
|
||||
});
|
||||
|
||||
test("clears when switching to a shorter prefix candidate", () => {
|
||||
assert.equal(
|
||||
computeLivePreviewWrite({ currentLine: "docker-compose", candidate: "docker", os: "linux" }),
|
||||
"\x15docker",
|
||||
);
|
||||
});
|
||||
|
||||
test("reverting to the typed baseline clears then rewrites the baseline", () => {
|
||||
assert.equal(
|
||||
computeLivePreviewWrite({ currentLine: "docker", candidate: "do", os: "linux" }),
|
||||
"\x15do",
|
||||
);
|
||||
});
|
||||
|
||||
test("Windows uses backspaces sized to the current line, not Ctrl-U", () => {
|
||||
assert.equal(
|
||||
computeLivePreviewWrite({ currentLine: "abc", candidate: "xy", os: "windows" }),
|
||||
"\b\b\bxy",
|
||||
);
|
||||
});
|
||||
23
components/terminal/runtime/altKeyOptions.test.ts
Normal file
23
components/terminal/runtime/altKeyOptions.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { terminalAltKeyOptions } from "./altKeyOptions";
|
||||
|
||||
// Issue #1078: with "Use Option as Meta key" enabled, macOS Option must send
|
||||
// ESC-prefixed (Meta) sequences. xterm.js gates that on `macOptionIsMeta`. The
|
||||
// flag was read from settings but only ever wired to the mouse alt-click
|
||||
// behavior, so Option kept emitting layout characters (ƒ, ∫, …) instead of Meta.
|
||||
|
||||
test("Option-as-Meta enabled: Option emits Meta and alt-click cursor move is disabled", () => {
|
||||
assert.deepEqual(terminalAltKeyOptions(true), {
|
||||
macOptionIsMeta: true,
|
||||
altClickMovesCursor: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("Option-as-Meta disabled: xterm keeps default macOS Option behavior", () => {
|
||||
assert.deepEqual(terminalAltKeyOptions(false), {
|
||||
macOptionIsMeta: false,
|
||||
altClickMovesCursor: true,
|
||||
});
|
||||
});
|
||||
20
components/terminal/runtime/altKeyOptions.ts
Normal file
20
components/terminal/runtime/altKeyOptions.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export interface TerminalAltKeyOptions {
|
||||
/** xterm.js: treat macOS Option as the Meta key (emit ESC-prefixed sequences). */
|
||||
macOptionIsMeta: boolean;
|
||||
/** xterm.js: Option+click moves the cursor. Must be off when Option is Meta. */
|
||||
altClickMovesCursor: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the user's "Use Option as Meta key" setting to xterm.js options.
|
||||
*
|
||||
* Kept in one place so terminal init (createXTermRuntime) and the live settings
|
||||
* sync (Terminal.tsx) can't drift — that drift is what left `macOptionIsMeta`
|
||||
* unset everywhere and broke Option/Meta shortcuts on macOS (issue #1078).
|
||||
*/
|
||||
export function terminalAltKeyOptions(altAsMeta: boolean): TerminalAltKeyOptions {
|
||||
return {
|
||||
macOptionIsMeta: altAsMeta,
|
||||
altClickMovesCursor: !altAsMeta,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { createTerminalSessionStarters, getMissingChainHostIds } from "./createTerminalSessionStarters";
|
||||
import {
|
||||
createTerminalSessionStarters,
|
||||
getMissingChainHostIds,
|
||||
splitStartupCommandLines,
|
||||
normalizeStartupCommandDelay,
|
||||
} from "./createTerminalSessionStarters";
|
||||
import { createPromptLineBreakState } from "./promptLineBreak";
|
||||
import { pasteTextIntoTerminal } from "./terminalUserPaste";
|
||||
|
||||
@@ -2279,6 +2284,110 @@ test("startTelnet waits for auto-login before running the startup command", asyn
|
||||
assert.equal(disposedAutoLoginCancelListener, true);
|
||||
});
|
||||
|
||||
test("startTelnet runs a multi-line startup command in sequence", async () => {
|
||||
const writtenCommands: string[] = [];
|
||||
const executedCommands: string[] = [];
|
||||
let capturedOptions: Record<string, unknown> | null = null;
|
||||
let autoLoginComplete: ((evt: { sessionId: string }) => void) | null = null;
|
||||
let disposedAutoLoginCancelListener = false;
|
||||
|
||||
const terminalBackend = {
|
||||
backendAvailable: () => true,
|
||||
telnetAvailable: () => true,
|
||||
moshAvailable: () => true,
|
||||
localAvailable: () => true,
|
||||
serialAvailable: () => true,
|
||||
execAvailable: () => true,
|
||||
startSSHSession: async () => "ssh-session",
|
||||
startTelnetSession: async (options: Record<string, unknown>) => {
|
||||
capturedOptions = options;
|
||||
return "telnet-session";
|
||||
},
|
||||
startMoshSession: async () => "mosh-session",
|
||||
startLocalSession: async () => "local-session",
|
||||
startSerialSession: async () => "serial-session",
|
||||
execCommand: async () => ({}),
|
||||
onSessionData: () => noop,
|
||||
onSessionExit: () => noop,
|
||||
onTelnetAutoLoginComplete: (sessionId: string, cb: (evt: { sessionId: string }) => void) => {
|
||||
assert.equal(sessionId, "session-1");
|
||||
autoLoginComplete = cb;
|
||||
return noop;
|
||||
},
|
||||
onTelnetAutoLoginCancelled: () => () => {
|
||||
disposedAutoLoginCancelListener = true;
|
||||
},
|
||||
onChainProgress: () => noop,
|
||||
writeToSession: (_sessionId: string, data: string) => {
|
||||
writtenCommands.push(data);
|
||||
},
|
||||
resizeSession: noop,
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Example",
|
||||
hostname: "example.test",
|
||||
username: "ssh-user",
|
||||
telnetUsername: "telnet-user",
|
||||
telnetPassword: "",
|
||||
telnetPort: 2323,
|
||||
startupCommand: "first cmd\nsecond cmd",
|
||||
},
|
||||
keys: [],
|
||||
resolvedChainHosts: [],
|
||||
sessionId: "session-1",
|
||||
terminalSettings: { startupCommandDelayMs: 20 },
|
||||
terminalBackend,
|
||||
sessionRef: { current: null },
|
||||
hasConnectedRef: { current: false },
|
||||
hasRunStartupCommandRef: { current: false },
|
||||
disposeDataRef: { current: null },
|
||||
disposeExitRef: { current: null },
|
||||
fitAddonRef: { current: null },
|
||||
serializeAddonRef: { current: null },
|
||||
pendingAuthRef: { current: null },
|
||||
updateStatus: noop,
|
||||
setStatus: noop,
|
||||
setError: noop,
|
||||
setNeedsAuth: noop,
|
||||
setAuthRetryMessage: noop,
|
||||
setAuthPassword: noop,
|
||||
setProgressLogs: noop,
|
||||
setProgressValue: noop,
|
||||
setChainProgress: noop,
|
||||
onCommandExecuted: (command: string) => {
|
||||
executedCommands.push(command);
|
||||
},
|
||||
};
|
||||
|
||||
const term = {
|
||||
cols: 120,
|
||||
rows: 32,
|
||||
write: noop,
|
||||
writeln: noop,
|
||||
scrollToBottom: noop,
|
||||
};
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startTelnet(term as never);
|
||||
assert.ok(capturedOptions);
|
||||
assert.ok(autoLoginComplete);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 700));
|
||||
assert.deepEqual(writtenCommands, []);
|
||||
assert.deepEqual(executedCommands, []);
|
||||
|
||||
autoLoginComplete({ sessionId: "session-1" });
|
||||
|
||||
// Wait long enough for both lines (delay before first + delay between).
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
assert.deepEqual(writtenCommands, ["first cmd\r", "second cmd\r"]);
|
||||
assert.deepEqual(executedCommands, ["first cmd", "second cmd"]);
|
||||
assert.equal(disposedAutoLoginCancelListener, true);
|
||||
});
|
||||
|
||||
test("startTelnet cancels pending startup command when user takes over", async () => {
|
||||
const writtenCommands: string[] = [];
|
||||
let capturedOptions: Record<string, unknown> | null = null;
|
||||
@@ -2623,3 +2732,25 @@ test("startTelnet rejects configured proxies instead of connecting directly", as
|
||||
assert.equal(started, false);
|
||||
assert.match(error, /Telnet does not support proxy/);
|
||||
});
|
||||
|
||||
test("splitStartupCommandLines drops blank lines but keeps content verbatim", () => {
|
||||
assert.deepEqual(splitStartupCommandLines("sudo -i\nalias dc=\"docker compose\""), [
|
||||
"sudo -i",
|
||||
'alias dc="docker compose"',
|
||||
]);
|
||||
// Single-line content is preserved verbatim (leading/trailing spaces kept).
|
||||
assert.deepEqual(splitStartupCommandLines(" cd /app "), [" cd /app "]);
|
||||
assert.deepEqual(splitStartupCommandLines("a\n\n \nb"), ["a", "b"]);
|
||||
assert.deepEqual(splitStartupCommandLines("echo hi\r\nwhoami"), ["echo hi", "whoami"]);
|
||||
assert.deepEqual(splitStartupCommandLines(""), []);
|
||||
assert.deepEqual(splitStartupCommandLines(" "), []);
|
||||
});
|
||||
|
||||
test("normalizeStartupCommandDelay defaults and clamps", () => {
|
||||
assert.equal(normalizeStartupCommandDelay(undefined), 600);
|
||||
assert.equal(normalizeStartupCommandDelay(Number.NaN), 600);
|
||||
assert.equal(normalizeStartupCommandDelay(0), 0);
|
||||
assert.equal(normalizeStartupCommandDelay(1500), 1500);
|
||||
assert.equal(normalizeStartupCommandDelay(-50), 0);
|
||||
assert.equal(normalizeStartupCommandDelay(999999), 10000);
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
syncPromptLineBreakState,
|
||||
type PromptLineBreakState,
|
||||
} from "./promptLineBreak";
|
||||
import { createOutputFlowController, type OutputFlowController } from "./outputFlowController";
|
||||
|
||||
/**
|
||||
* Per-connection token for stale-timer detection. The renderer reuses the
|
||||
@@ -97,6 +98,8 @@ type TerminalBackendApi = {
|
||||
) => (() => void) | undefined;
|
||||
writeToSession: (sessionId: string, data: string, options?: { automated?: boolean }) => void;
|
||||
resizeSession: (sessionId: string, cols: number, rows: number) => void;
|
||||
/** Pause/resume the source stream for output back-pressure (optional). */
|
||||
setSessionFlowPaused?: (sessionId: string, paused: boolean) => void;
|
||||
};
|
||||
|
||||
export type PendingAuth = {
|
||||
@@ -251,6 +254,38 @@ const enqueueTerminalWrite = (
|
||||
}
|
||||
};
|
||||
|
||||
// Output back-pressure. Without this the renderer can't slow a flooding source,
|
||||
// so a busy stream grows the write queue and xterm's buffer unbounded. The
|
||||
// controller tracks bytes received-but-not-yet-rendered and asks the main
|
||||
// process to pause/resume the session's source stream at these watermarks.
|
||||
const FLOW_HIGH_WATER_MARK = 256 * 1024; // pause the source above ~256KB backlog
|
||||
const FLOW_LOW_WATER_MARK = 64 * 1024; // resume once drained to ~64KB
|
||||
|
||||
const terminalFlowControllers = new WeakMap<XTerm, OutputFlowController>();
|
||||
|
||||
const getFlowController = (
|
||||
ctx: TerminalSessionStartersContext,
|
||||
term: XTerm,
|
||||
): OutputFlowController => {
|
||||
let controller = terminalFlowControllers.get(term);
|
||||
if (!controller) {
|
||||
controller = createOutputFlowController({
|
||||
highWaterMark: FLOW_HIGH_WATER_MARK,
|
||||
lowWaterMark: FLOW_LOW_WATER_MARK,
|
||||
onPause: () => {
|
||||
const id = ctx.sessionRef.current;
|
||||
if (id) ctx.terminalBackend.setSessionFlowPaused?.(id, true);
|
||||
},
|
||||
onResume: () => {
|
||||
const id = ctx.sessionRef.current;
|
||||
if (id) ctx.terminalBackend.setSessionFlowPaused?.(id, false);
|
||||
},
|
||||
});
|
||||
terminalFlowControllers.set(term, controller);
|
||||
}
|
||||
return controller;
|
||||
};
|
||||
|
||||
const writeTerminalLine = (
|
||||
ctx: TerminalSessionStartersContext,
|
||||
term: XTerm,
|
||||
@@ -268,6 +303,8 @@ const writeSessionData = (
|
||||
term: XTerm,
|
||||
data: string,
|
||||
) => {
|
||||
const flow = getFlowController(ctx, term);
|
||||
flow.received(data.length);
|
||||
enqueueTerminalWrite(term, (done) => {
|
||||
const settings = ctx.terminalSettingsRef?.current ?? ctx.terminalSettings;
|
||||
const forcePromptNewLine = settings?.forcePromptNewLine ?? false;
|
||||
@@ -301,6 +338,8 @@ const writeSessionData = (
|
||||
handleTerminalOutputAutoScroll(ctx, term);
|
||||
}
|
||||
done();
|
||||
// Acknowledge the chunk so back-pressure can ease once xterm catches up.
|
||||
flow.written(data.length);
|
||||
};
|
||||
|
||||
term.write(displayData, afterWrite);
|
||||
@@ -320,6 +359,8 @@ const attachSessionToTerminal = (
|
||||
},
|
||||
) => {
|
||||
ctx.sessionRef.current = id;
|
||||
// Clear any stale back-pressure accounting from a prior session on this term.
|
||||
getFlowController(ctx, term).reset();
|
||||
ctx.onSessionAttached?.(id);
|
||||
|
||||
ctx.disposeDataRef.current = ctx.terminalBackend.onSessionData(id, (chunk) => {
|
||||
@@ -375,6 +416,29 @@ const attachSessionToTerminal = (
|
||||
});
|
||||
};
|
||||
|
||||
const STARTUP_COMMAND_DEFAULT_DELAY_MS = 600;
|
||||
const STARTUP_COMMAND_MAX_DELAY_MS = 10000;
|
||||
|
||||
/**
|
||||
* Split a (possibly multi-line) startup command into non-empty lines, dropping
|
||||
* blank/whitespace-only lines but preserving each line's content verbatim — so
|
||||
* a single-line command stays byte-identical to what the user typed (e.g. a
|
||||
* leading space for `HISTCONTROL=ignorespace` is kept). Trailing `\r` from
|
||||
* CRLF input is normalized away.
|
||||
*/
|
||||
export function splitStartupCommandLines(commandText: string): string[] {
|
||||
return String(commandText || "")
|
||||
.split("\n")
|
||||
.map((line) => line.replace(/\r$/, ""))
|
||||
.filter((line) => line.trim().length > 0);
|
||||
}
|
||||
|
||||
/** Clamp a configured startup-command delay; fall back to the default when unset/invalid. */
|
||||
export function normalizeStartupCommandDelay(raw: number | undefined): number {
|
||||
const value = typeof raw === "number" && Number.isFinite(raw) ? raw : STARTUP_COMMAND_DEFAULT_DELAY_MS;
|
||||
return Math.max(0, Math.min(STARTUP_COMMAND_MAX_DELAY_MS, value));
|
||||
}
|
||||
|
||||
const scheduleStartupCommand = (
|
||||
ctx: TerminalSessionStartersContext,
|
||||
term: XTerm,
|
||||
@@ -386,24 +450,65 @@ const scheduleStartupCommand = (
|
||||
|
||||
ctx.hasRunStartupCommandRef.current = true;
|
||||
const scheduledSessionId = id;
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!ctx.sessionRef.current || ctx.sessionRef.current !== scheduledSessionId) {
|
||||
const settings = ctx.terminalSettingsRef?.current ?? ctx.terminalSettings;
|
||||
const delayMs = normalizeStartupCommandDelay(settings?.startupCommandDelayMs);
|
||||
|
||||
let cancelled = false;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
const sessionIsCurrent = () =>
|
||||
!!ctx.sessionRef.current && ctx.sessionRef.current === scheduledSessionId;
|
||||
|
||||
// noAutoRun (snippet "type but don't execute"): type the command as-is, no
|
||||
// Enter and no line-splitting — unchanged behavior.
|
||||
if (ctx.noAutoRun) {
|
||||
timeoutId = setTimeout(() => {
|
||||
if (cancelled) return;
|
||||
if (!sessionIsCurrent()) {
|
||||
onSettled?.();
|
||||
return;
|
||||
}
|
||||
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, commandToRun, { automated: true });
|
||||
onSettled?.();
|
||||
}, delayMs);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
};
|
||||
}
|
||||
|
||||
// Auto-run: send each non-empty line in sequence, waiting delayMs before the
|
||||
// first and between each, so a line runs inside any sub-shell opened by a
|
||||
// previous line (e.g. `sudo -i` then `alias ...`).
|
||||
const lines = splitStartupCommandLines(commandToRun);
|
||||
if (lines.length === 0) {
|
||||
onSettled?.();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let index = 0;
|
||||
const runNext = () => {
|
||||
if (cancelled) return;
|
||||
if (!sessionIsCurrent()) {
|
||||
onSettled?.();
|
||||
return;
|
||||
}
|
||||
const suffix = ctx.noAutoRun ? "" : "\r";
|
||||
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, `${commandToRun}${suffix}`, {
|
||||
automated: true,
|
||||
});
|
||||
if (!ctx.noAutoRun) {
|
||||
markPromptLineBreakCommandPending(ctx.promptLineBreakStateRef, term, commandToRun);
|
||||
const line = lines[index];
|
||||
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, `${line}\r`, { automated: true });
|
||||
markPromptLineBreakCommandPending(ctx.promptLineBreakStateRef, term, line);
|
||||
ctx.onCommandExecuted?.(line, ctx.host.id, ctx.host.label, ctx.sessionId);
|
||||
index += 1;
|
||||
if (index < lines.length) {
|
||||
timeoutId = setTimeout(runNext, delayMs);
|
||||
} else {
|
||||
onSettled?.();
|
||||
}
|
||||
onSettled?.();
|
||||
if (!ctx.noAutoRun && ctx.onCommandExecuted) {
|
||||
ctx.onCommandExecuted(commandToRun, ctx.host.id, ctx.host.label, ctx.sessionId);
|
||||
}
|
||||
}, 600);
|
||||
return () => clearTimeout(timeoutId);
|
||||
};
|
||||
|
||||
timeoutId = setTimeout(runNext, delayMs);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
};
|
||||
};
|
||||
|
||||
const runDistroDetection = async (
|
||||
@@ -1204,6 +1309,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
});
|
||||
|
||||
ctx.sessionRef.current = id;
|
||||
getFlowController(ctx, term).reset();
|
||||
ctx.disposeDataRef.current = ctx.terminalBackend.onSessionData(id, (chunk) => {
|
||||
writeSessionData(ctx, term, chunk);
|
||||
if (!ctx.hasConnectedRef.current) {
|
||||
|
||||
@@ -43,6 +43,9 @@ import {
|
||||
} from "./kittyKeyboardProtocol";
|
||||
import { installKittyKeyboardProtocolHandlers } from "./kittyKeyboardRuntime";
|
||||
import { installUserCursorPreferenceGuard } from "./cursorPreference";
|
||||
import { terminalAltKeyOptions } from "./altKeyOptions";
|
||||
import { optionArrowWordJumpSequence } from "./optionArrowWordJump";
|
||||
import { watchDevicePixelRatio } from "./rendererDprWatch";
|
||||
import { handleSerialLineModeInput } from "./serialLineInput";
|
||||
import {
|
||||
markExpectedTerminalCursorPositionReport,
|
||||
@@ -79,6 +82,13 @@ export type XTermRuntime = {
|
||||
/** Current working directory detected via OSC 7 */
|
||||
currentCwd: string | undefined;
|
||||
keywordHighlighter: KeywordHighlighter;
|
||||
/**
|
||||
* Clear the WebGL renderer's glyph texture atlas so glyphs re-rasterize on the
|
||||
* next frame. No-op when the DOM renderer is active. Used to recover from the
|
||||
* persistent "garbled / 花屏" corruption (issue #1049) that the WebGL atlas can
|
||||
* fall into after font changes or device pixel ratio changes.
|
||||
*/
|
||||
clearTextureAtlas: () => void;
|
||||
};
|
||||
|
||||
export type CreateXTermRuntimeContext = {
|
||||
@@ -286,7 +296,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
smoothScrollDuration,
|
||||
scrollOnUserInput,
|
||||
macOptionClickForcesSelection: true,
|
||||
altClickMovesCursor: !altIsMeta,
|
||||
...terminalAltKeyOptions(altIsMeta),
|
||||
wordSeparator,
|
||||
theme: {
|
||||
...ctx.terminalTheme.colors,
|
||||
@@ -386,6 +396,45 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
? "dom"
|
||||
: "webgl";
|
||||
|
||||
// The WebGL renderer caches rasterized glyphs in a texture atlas. Heavy TUIs
|
||||
// (claude code / gemini cli / opencode and other full-screen agents), font
|
||||
// changes, and device pixel ratio changes can leave that atlas in a corrupted
|
||||
// state that persists for the life of the terminal — the "garbled / 花屏"
|
||||
// report in issue #1049 where only opening a brand-new terminal helps. Clearing
|
||||
// the atlas forces glyphs to re-rasterize at the correct scale on the next
|
||||
// frame. No-op for the DOM renderer.
|
||||
const clearWebglTextureAtlas = () => {
|
||||
if (!webglAddon) return;
|
||||
try {
|
||||
webglAddon.clearTextureAtlas();
|
||||
} catch (err) {
|
||||
logger.warn("[XTerm] clearTextureAtlas failed", err);
|
||||
}
|
||||
};
|
||||
|
||||
// Recover the renderer when the device pixel ratio changes (moving the window
|
||||
// between monitors with different DPI, or changing OS display scaling — a
|
||||
// common Windows trigger). matchMedia change does not fire a normal resize, so
|
||||
// this is needed in addition to the resize handling below.
|
||||
let stopDprWatch: () => void = () => {};
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
typeof window.matchMedia === "function"
|
||||
) {
|
||||
stopDprWatch = watchDevicePixelRatio({
|
||||
getDevicePixelRatio: () => window.devicePixelRatio || 1,
|
||||
matchMedia: (query) => window.matchMedia(query),
|
||||
onChange: () => {
|
||||
clearWebglTextureAtlas();
|
||||
try {
|
||||
fitAddon.fit();
|
||||
} catch (err) {
|
||||
logger.warn("[XTerm] fit after devicePixelRatio change failed", err);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const webLinksAddon = new WebLinksAddon((event, uri) => {
|
||||
const currentLinkModifier = ctx.terminalSettingsRef.current?.linkModifier ?? "none";
|
||||
let shouldOpen = false;
|
||||
@@ -609,6 +658,29 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
}
|
||||
}
|
||||
|
||||
// macOS Option+←/→ → Meta-b / Meta-f so the shell jumps by word (discussion
|
||||
// #826). After kitty mode so apps using the kitty protocol keep their own
|
||||
// arrow encoding; read live so the toggle applies without reconnecting.
|
||||
const wordJumpSequence = optionArrowWordJumpSequence(
|
||||
e,
|
||||
ctx.terminalSettingsRef.current?.optionArrowWordJump ?? false,
|
||||
isMacPlatform(),
|
||||
);
|
||||
if (wordJumpSequence) {
|
||||
const id = ctx.sessionRef.current;
|
||||
if (id) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
ctx.onAutocompleteInput?.(wordJumpSequence);
|
||||
ctx.terminalBackend.writeToSession(id, wordJumpSequence);
|
||||
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
|
||||
ctx.onBroadcastInputRef.current(wordJumpSequence, ctx.sessionId);
|
||||
}
|
||||
scrollToBottomAfterInput(wordJumpSequence);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -858,6 +930,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
let resizeTimeout: NodeJS.Timeout | null = null;
|
||||
const resizeDebounceMs = XTERM_PERFORMANCE_CONFIG.resize.debounceMs;
|
||||
term.onResize(({ cols, rows }) => {
|
||||
// A reflow can leave stale glyphs in the WebGL atlas; clear it so the new
|
||||
// dimensions re-rasterize cleanly (issue #1049).
|
||||
clearWebglTextureAtlas();
|
||||
const id = ctx.sessionRef.current;
|
||||
if (!id) return;
|
||||
if (resizeTimeout) clearTimeout(resizeTimeout);
|
||||
@@ -876,8 +951,10 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
serializeAddon,
|
||||
searchAddon,
|
||||
keywordHighlighter,
|
||||
clearTextureAtlas: clearWebglTextureAtlas,
|
||||
dispose: () => {
|
||||
cleanupMiddleClick?.();
|
||||
stopDprWatch();
|
||||
keywordHighlighter.dispose();
|
||||
eraseScrollbackDisposable.dispose();
|
||||
for (const disposable of cursorPositionReportRequestDisposables) {
|
||||
|
||||
51
components/terminal/runtime/optionArrowWordJump.test.ts
Normal file
51
components/terminal/runtime/optionArrowWordJump.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { optionArrowWordJumpSequence } from "./optionArrowWordJump";
|
||||
|
||||
// Discussion #826: on macOS, Option+←/→ defaults to xterm's ^[[1;3D / ^[[1;3C,
|
||||
// which most shells don't bind. When enabled, remap them to Meta-b / Meta-f so
|
||||
// readline/zle does backward-word / forward-word out of the box (Termius-style).
|
||||
// Gated to macOS so the syncable setting can't rewrite Alt+←/→ on other platforms.
|
||||
|
||||
const ev = (over: Partial<Parameters<typeof optionArrowWordJumpSequence>[0]> = {}) => ({
|
||||
key: "ArrowLeft",
|
||||
altKey: true,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
test("Option+Left → Meta-b (backward-word) when enabled on macOS", () => {
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowLeft" }), true, true), "\x1bb");
|
||||
});
|
||||
|
||||
test("Option+Right → Meta-f (forward-word) when enabled on macOS", () => {
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowRight" }), true, true), "\x1bf");
|
||||
});
|
||||
|
||||
test("not macOS → null (don't rewrite Alt+←/→ on Linux/Windows even if synced on)", () => {
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowLeft" }), true, false), null);
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowRight" }), true, false), null);
|
||||
});
|
||||
|
||||
test("disabled → null (xterm default ^[[1;3D/C is kept)", () => {
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowLeft" }), false, true), null);
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowRight" }), false, true), null);
|
||||
});
|
||||
|
||||
test("no Option held → null", () => {
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ altKey: false }), true, true), null);
|
||||
});
|
||||
|
||||
test("extra modifiers with Option → null (don't hijack Shift/Ctrl/Cmd combos)", () => {
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ shiftKey: true }), true, true), null);
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ ctrlKey: true }), true, true), null);
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ metaKey: true }), true, true), null);
|
||||
});
|
||||
|
||||
test("non-arrow keys → null", () => {
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowUp" }), true, true), null);
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ key: "f" }), true, true), null);
|
||||
});
|
||||
33
components/terminal/runtime/optionArrowWordJump.ts
Normal file
33
components/terminal/runtime/optionArrowWordJump.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export interface OptionArrowKeyEvent {
|
||||
key: string;
|
||||
altKey: boolean;
|
||||
ctrlKey: boolean;
|
||||
metaKey: boolean;
|
||||
shiftKey: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* macOS Option+←/→ word-jump (discussion #826).
|
||||
*
|
||||
* When enabled, maps a bare Option+Left/Right to the Meta-b / Meta-f sequence so
|
||||
* readline/zle does backward-word / forward-word without per-host bindkey setup.
|
||||
* Returns the bytes to send, or null when the mapping doesn't apply (disabled,
|
||||
* non-macOS, not an arrow, or other modifiers held) — in which case xterm's
|
||||
* default ^[[1;3D / ^[[1;3C is left untouched.
|
||||
*
|
||||
* Gated to macOS (`isMac`): the setting is syncable, so without the gate,
|
||||
* enabling it on a Mac would also rewrite Alt+←/→ on synced Linux/Windows
|
||||
* devices (discussion #826 review).
|
||||
*/
|
||||
export function optionArrowWordJumpSequence(
|
||||
e: OptionArrowKeyEvent,
|
||||
enabled: boolean,
|
||||
isMac: boolean,
|
||||
): string | null {
|
||||
if (!enabled || !isMac) return null;
|
||||
// Only a bare Option+Arrow — leave Shift/Ctrl/Cmd combos to xterm's defaults.
|
||||
if (!e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return null;
|
||||
if (e.key === "ArrowLeft") return "\x1bb"; // Meta-b → backward-word
|
||||
if (e.key === "ArrowRight") return "\x1bf"; // Meta-f → forward-word
|
||||
return null;
|
||||
}
|
||||
89
components/terminal/runtime/outputFlowController.test.ts
Normal file
89
components/terminal/runtime/outputFlowController.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { createOutputFlowController } from "./outputFlowController.ts";
|
||||
|
||||
function make(high = 100, low = 30) {
|
||||
const events: string[] = [];
|
||||
const controller = createOutputFlowController({
|
||||
highWaterMark: high,
|
||||
lowWaterMark: low,
|
||||
onPause: () => events.push("pause"),
|
||||
onResume: () => events.push("resume"),
|
||||
});
|
||||
return { controller, events };
|
||||
}
|
||||
|
||||
test("does not pause while below the high watermark", () => {
|
||||
const { controller, events } = make(100, 30);
|
||||
controller.received(50);
|
||||
controller.received(49); // 99 < 100
|
||||
assert.deepEqual(events, []);
|
||||
assert.equal(controller.isPaused(), false);
|
||||
});
|
||||
|
||||
test("pauses once when crossing the high watermark", () => {
|
||||
const { controller, events } = make(100, 30);
|
||||
controller.received(60);
|
||||
controller.received(60); // 120 >= 100 -> pause
|
||||
assert.deepEqual(events, ["pause"]);
|
||||
assert.equal(controller.isPaused(), true);
|
||||
// Further received while already paused must not re-fire pause.
|
||||
controller.received(100);
|
||||
assert.deepEqual(events, ["pause"]);
|
||||
});
|
||||
|
||||
test("resumes once when draining to at/below the low watermark", () => {
|
||||
const { controller, events } = make(100, 30);
|
||||
controller.received(120); // pause
|
||||
controller.written(50); // 70 still > 30, no resume
|
||||
assert.deepEqual(events, ["pause"]);
|
||||
controller.written(50); // 20 <= 30 -> resume
|
||||
assert.deepEqual(events, ["pause", "resume"]);
|
||||
assert.equal(controller.isPaused(), false);
|
||||
});
|
||||
|
||||
test("does not resume when still above the low watermark", () => {
|
||||
const { controller, events } = make(100, 30);
|
||||
controller.received(120); // pause
|
||||
controller.written(80); // 40 > 30
|
||||
assert.deepEqual(events, ["pause"]);
|
||||
assert.equal(controller.isPaused(), true);
|
||||
});
|
||||
|
||||
test("never lets pending go negative", () => {
|
||||
const { controller } = make(100, 30);
|
||||
controller.received(10);
|
||||
controller.written(50); // over-written
|
||||
assert.equal(controller.pendingBytes(), 0);
|
||||
});
|
||||
|
||||
test("supports repeated pause/resume cycles", () => {
|
||||
const { controller, events } = make(100, 30);
|
||||
controller.received(120); // pause
|
||||
controller.written(120); // resume (0 <= 30)
|
||||
controller.received(120); // pause again
|
||||
controller.written(120); // resume again
|
||||
assert.deepEqual(events, ["pause", "resume", "pause", "resume"]);
|
||||
});
|
||||
|
||||
test("reset clears state without firing callbacks", () => {
|
||||
const { controller, events } = make(100, 30);
|
||||
controller.received(120); // pause
|
||||
controller.reset();
|
||||
assert.equal(controller.isPaused(), false);
|
||||
assert.equal(controller.pendingBytes(), 0);
|
||||
assert.deepEqual(events, ["pause"]); // reset itself is silent
|
||||
// A fresh cycle works after reset.
|
||||
controller.received(120);
|
||||
assert.deepEqual(events, ["pause", "pause"]);
|
||||
});
|
||||
|
||||
test("ignores non-positive amounts", () => {
|
||||
const { controller, events } = make(100, 30);
|
||||
controller.received(0);
|
||||
controller.written(0);
|
||||
controller.received(-5);
|
||||
assert.equal(controller.pendingBytes(), 0);
|
||||
assert.deepEqual(events, []);
|
||||
});
|
||||
73
components/terminal/runtime/outputFlowController.ts
Normal file
73
components/terminal/runtime/outputFlowController.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Watermark-based flow control for terminal output.
|
||||
*
|
||||
* SSH/PTY output has no back-pressure by default: the source streams as fast as
|
||||
* it can, the main process forwards it over IPC, and the renderer queues every
|
||||
* chunk into xterm. When output outpaces rendering (e.g. `cat` of a big file, a
|
||||
* noisy build, `tail -f`, `yes`), the renderer-side backlog and xterm's internal
|
||||
* buffer grow without bound — memory climbs and the whole UI, typing included,
|
||||
* janks.
|
||||
*
|
||||
* This tracks bytes that have been received but not yet acknowledged by xterm's
|
||||
* write callback. When the backlog crosses `highWaterMark` it asks the caller to
|
||||
* pause the source; once it drains back to `lowWaterMark` it asks to resume. The
|
||||
* hysteresis gap avoids rapid pause/resume flapping. During interactive use the
|
||||
* backlog hovers near zero, so this never engages.
|
||||
*/
|
||||
export interface OutputFlowController {
|
||||
/** Account bytes handed to xterm (call when a chunk is received). */
|
||||
received(bytes: number): void;
|
||||
/** Account bytes whose xterm write callback has fired. */
|
||||
written(bytes: number): void;
|
||||
/** Clear all state (e.g. on a fresh session attach). Fires no callbacks. */
|
||||
reset(): void;
|
||||
pendingBytes(): number;
|
||||
isPaused(): boolean;
|
||||
}
|
||||
|
||||
export interface OutputFlowControllerOptions {
|
||||
highWaterMark: number;
|
||||
lowWaterMark: number;
|
||||
/** Asked to pause the source when the backlog crosses the high watermark. */
|
||||
onPause: () => void;
|
||||
/** Asked to resume the source when the backlog drains to the low watermark. */
|
||||
onResume: () => void;
|
||||
}
|
||||
|
||||
export function createOutputFlowController(
|
||||
options: OutputFlowControllerOptions,
|
||||
): OutputFlowController {
|
||||
const { highWaterMark, lowWaterMark, onPause, onResume } = options;
|
||||
let pending = 0;
|
||||
let paused = false;
|
||||
|
||||
return {
|
||||
received(bytes: number): void {
|
||||
if (bytes <= 0) return;
|
||||
pending += bytes;
|
||||
if (!paused && pending >= highWaterMark) {
|
||||
paused = true;
|
||||
onPause();
|
||||
}
|
||||
},
|
||||
written(bytes: number): void {
|
||||
if (bytes <= 0) return;
|
||||
pending -= bytes;
|
||||
if (pending < 0) pending = 0;
|
||||
if (paused && pending <= lowWaterMark) {
|
||||
paused = false;
|
||||
onResume();
|
||||
}
|
||||
},
|
||||
reset(): void {
|
||||
pending = 0;
|
||||
paused = false;
|
||||
},
|
||||
pendingBytes(): number {
|
||||
return pending;
|
||||
},
|
||||
isPaused(): boolean {
|
||||
return paused;
|
||||
},
|
||||
};
|
||||
}
|
||||
158
components/terminal/runtime/rendererDprWatch.test.ts
Normal file
158
components/terminal/runtime/rendererDprWatch.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
type MediaQueryListLike,
|
||||
watchDevicePixelRatio,
|
||||
} from "./rendererDprWatch";
|
||||
|
||||
class FakeMediaQueryList implements MediaQueryListLike {
|
||||
readonly query: string;
|
||||
modernListeners: Array<() => void> = [];
|
||||
legacyListeners: Array<() => void> = [];
|
||||
private readonly supportsModern: boolean;
|
||||
|
||||
constructor(query: string, supportsModern = true) {
|
||||
this.query = query;
|
||||
this.supportsModern = supportsModern;
|
||||
if (!supportsModern) {
|
||||
// Strip the modern API to emulate legacy environments.
|
||||
this.addEventListener = undefined;
|
||||
this.removeEventListener = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
addEventListener? = (_type: "change", listener: () => void) => {
|
||||
this.modernListeners.push(listener);
|
||||
};
|
||||
|
||||
removeEventListener? = (_type: "change", listener: () => void) => {
|
||||
this.modernListeners = this.modernListeners.filter((l) => l !== listener);
|
||||
};
|
||||
|
||||
addListener = (listener: () => void) => {
|
||||
this.legacyListeners.push(listener);
|
||||
};
|
||||
|
||||
removeListener = (listener: () => void) => {
|
||||
this.legacyListeners = this.legacyListeners.filter((l) => l !== listener);
|
||||
};
|
||||
|
||||
trigger() {
|
||||
for (const l of [...this.modernListeners, ...this.legacyListeners]) l();
|
||||
}
|
||||
|
||||
get listenerCount() {
|
||||
return this.modernListeners.length + this.legacyListeners.length;
|
||||
}
|
||||
}
|
||||
|
||||
function makeEnv(initialDpr: number, supportsModern = true) {
|
||||
let dpr = initialDpr;
|
||||
const created: FakeMediaQueryList[] = [];
|
||||
return {
|
||||
created,
|
||||
getDevicePixelRatio: () => dpr,
|
||||
matchMedia: (query: string) => {
|
||||
const mql = new FakeMediaQueryList(query, supportsModern);
|
||||
created.push(mql);
|
||||
return mql;
|
||||
},
|
||||
setDpr: (value: number) => {
|
||||
dpr = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("registers a change listener for the current devicePixelRatio", () => {
|
||||
const env = makeEnv(1);
|
||||
watchDevicePixelRatio({
|
||||
getDevicePixelRatio: env.getDevicePixelRatio,
|
||||
matchMedia: env.matchMedia,
|
||||
onChange: () => {},
|
||||
});
|
||||
|
||||
assert.equal(env.created.length, 1);
|
||||
assert.equal(env.created[0].query, "(resolution: 1dppx)");
|
||||
assert.equal(env.created[0].listenerCount, 1);
|
||||
});
|
||||
|
||||
test("invokes onChange when the media query reports a change", () => {
|
||||
const env = makeEnv(1);
|
||||
let calls = 0;
|
||||
watchDevicePixelRatio({
|
||||
getDevicePixelRatio: env.getDevicePixelRatio,
|
||||
matchMedia: env.matchMedia,
|
||||
onChange: () => {
|
||||
calls += 1;
|
||||
},
|
||||
});
|
||||
|
||||
env.setDpr(2);
|
||||
env.created[0].trigger();
|
||||
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test("re-registers for the new ratio so subsequent changes still fire", () => {
|
||||
const env = makeEnv(1);
|
||||
let calls = 0;
|
||||
watchDevicePixelRatio({
|
||||
getDevicePixelRatio: env.getDevicePixelRatio,
|
||||
matchMedia: env.matchMedia,
|
||||
onChange: () => {
|
||||
calls += 1;
|
||||
},
|
||||
});
|
||||
|
||||
env.setDpr(2);
|
||||
env.created[0].trigger();
|
||||
|
||||
assert.equal(env.created.length, 2);
|
||||
assert.equal(env.created[1].query, "(resolution: 2dppx)");
|
||||
// The stale listener must be detached so it cannot double-fire.
|
||||
assert.equal(env.created[0].listenerCount, 0);
|
||||
|
||||
env.setDpr(3);
|
||||
env.created[1].trigger();
|
||||
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
test("cleanup stops further onChange callbacks", () => {
|
||||
const env = makeEnv(1);
|
||||
let calls = 0;
|
||||
const stop = watchDevicePixelRatio({
|
||||
getDevicePixelRatio: env.getDevicePixelRatio,
|
||||
matchMedia: env.matchMedia,
|
||||
onChange: () => {
|
||||
calls += 1;
|
||||
},
|
||||
});
|
||||
|
||||
stop();
|
||||
|
||||
assert.equal(env.created[0].listenerCount, 0);
|
||||
env.created[0].trigger();
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test("falls back to addListener/removeListener when addEventListener is unavailable", () => {
|
||||
const env = makeEnv(1, /* supportsModern */ false);
|
||||
let calls = 0;
|
||||
const stop = watchDevicePixelRatio({
|
||||
getDevicePixelRatio: env.getDevicePixelRatio,
|
||||
matchMedia: env.matchMedia,
|
||||
onChange: () => {
|
||||
calls += 1;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(env.created[0].legacyListeners.length, 1);
|
||||
env.created[0].trigger();
|
||||
assert.equal(calls, 1);
|
||||
|
||||
stop();
|
||||
// After cleanup the most recently registered query has no listeners.
|
||||
const latest = env.created[env.created.length - 1];
|
||||
assert.equal(latest.listenerCount, 0);
|
||||
});
|
||||
72
components/terminal/runtime/rendererDprWatch.ts
Normal file
72
components/terminal/runtime/rendererDprWatch.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Watches for devicePixelRatio changes (e.g. moving the window between monitors
|
||||
* with different DPI, or changing the OS display scaling on Windows) and invokes
|
||||
* a callback so the renderer can be repaired.
|
||||
*
|
||||
* The WebGL renderer caches rasterized glyphs in a texture atlas keyed to the
|
||||
* device pixel ratio at creation time. When the ratio changes the cached glyphs
|
||||
* are drawn at the wrong scale, producing the persistent "garbled / 花屏"
|
||||
* corruption reported in issue #1049 that only goes away when a brand-new
|
||||
* terminal is opened. xterm.js recommends calling `clearTextureAtlas()` on DPR
|
||||
* change so glyphs re-rasterize at the new scale.
|
||||
*
|
||||
* `matchMedia('(resolution: Ndppx)')` only matches a single ratio, so after each
|
||||
* change we must re-register the listener against the new ratio.
|
||||
*/
|
||||
export interface MediaQueryListLike {
|
||||
addEventListener?: (type: "change", listener: () => void) => void;
|
||||
removeEventListener?: (type: "change", listener: () => void) => void;
|
||||
// Legacy API (older Safari / Electron) where addEventListener is unavailable.
|
||||
addListener?: (listener: () => void) => void;
|
||||
removeListener?: (listener: () => void) => void;
|
||||
}
|
||||
|
||||
export interface WatchDevicePixelRatioOptions {
|
||||
getDevicePixelRatio: () => number;
|
||||
matchMedia: (query: string) => MediaQueryListLike;
|
||||
onChange: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start watching for devicePixelRatio changes. Returns a cleanup function that
|
||||
* removes the active listener.
|
||||
*/
|
||||
export function watchDevicePixelRatio(
|
||||
options: WatchDevicePixelRatioOptions,
|
||||
): () => void {
|
||||
const { getDevicePixelRatio, matchMedia, onChange } = options;
|
||||
let current: { mql: MediaQueryListLike; listener: () => void } | null = null;
|
||||
|
||||
const detach = () => {
|
||||
if (!current) return;
|
||||
const { mql, listener } = current;
|
||||
if (mql.removeEventListener) {
|
||||
mql.removeEventListener("change", listener);
|
||||
} else if (mql.removeListener) {
|
||||
mql.removeListener(listener);
|
||||
}
|
||||
current = null;
|
||||
};
|
||||
|
||||
const attach = () => {
|
||||
const dpr = getDevicePixelRatio();
|
||||
const mql = matchMedia(`(resolution: ${dpr}dppx)`);
|
||||
const listener = () => {
|
||||
// A media query only matches the ratio it was created with, so detach the
|
||||
// stale listener and re-register against the new ratio before notifying.
|
||||
detach();
|
||||
attach();
|
||||
onChange();
|
||||
};
|
||||
if (mql.addEventListener) {
|
||||
mql.addEventListener("change", listener);
|
||||
} else if (mql.addListener) {
|
||||
mql.addListener(listener);
|
||||
}
|
||||
current = { mql, listener };
|
||||
};
|
||||
|
||||
attach();
|
||||
|
||||
return detach;
|
||||
}
|
||||
48
components/terminal/snippetCompleter.test.ts
Normal file
48
components/terminal/snippetCompleter.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { getSnippetSuggestions } from "./autocomplete/snippetCompleter";
|
||||
import type { Snippet } from "../../domain/models";
|
||||
|
||||
const snip = (over: Partial<Snippet>): Snippet => ({
|
||||
id: over.id ?? "s1",
|
||||
label: over.label ?? "deploy",
|
||||
command: over.command ?? "echo deploy",
|
||||
...over,
|
||||
});
|
||||
|
||||
test("matches by label prefix and carries the snippet + command preview", () => {
|
||||
const s = snip({ id: "a", label: "deploy", command: "kubectl apply -f .\nkubectl rollout status deploy" });
|
||||
const out = getSnippetSuggestions("dep", [s], {});
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].source, "snippet");
|
||||
assert.equal(out[0].displayText, "deploy");
|
||||
assert.equal(out[0].description, "kubectl apply -f .\nkubectl rollout status deploy");
|
||||
assert.equal(out[0].snippet?.id, "a");
|
||||
});
|
||||
|
||||
test("matches by command first line", () => {
|
||||
const s = snip({ id: "b", label: "k8s", command: "kubectl get pods" });
|
||||
const out = getSnippetSuggestions("kubectl", [s], {});
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].snippet?.id, "b");
|
||||
});
|
||||
|
||||
test("is case-insensitive and prefix outranks substring", () => {
|
||||
const a = snip({ id: "p", label: "Backup", command: "tar czf b.tgz ." });
|
||||
const b = snip({ id: "q", label: "db-backup", command: "pg_dump" });
|
||||
const out = getSnippetSuggestions("backup", [a, b], {});
|
||||
assert.deepEqual(out.map((o) => o.snippet?.id), ["p", "q"]);
|
||||
});
|
||||
|
||||
test("filters by host targets when set", () => {
|
||||
const scoped = snip({ id: "t", label: "restart", command: "systemctl restart x", targets: ["host-2"] });
|
||||
const global = snip({ id: "g", label: "restart-all", command: "echo all" });
|
||||
assert.deepEqual(getSnippetSuggestions("restart", [scoped, global], { hostId: "host-1" }).map((o) => o.snippet?.id), ["g"]);
|
||||
assert.deepEqual(getSnippetSuggestions("restart", [scoped, global], { hostId: "host-2" }).map((o) => o.snippet?.id).sort(), ["g", "t"]);
|
||||
});
|
||||
|
||||
test("no match returns empty; empty input returns empty", () => {
|
||||
assert.deepEqual(getSnippetSuggestions("zzz", [snip({})], {}), []);
|
||||
assert.deepEqual(getSnippetSuggestions("", [snip({})], {}), []);
|
||||
});
|
||||
@@ -37,5 +37,6 @@ export const terminalLayerAreEqual = (
|
||||
prev.isBroadcastEnabled === next.isBroadcastEnabled &&
|
||||
prev.onToggleBroadcast === next.onToggleBroadcast &&
|
||||
prev.toggleScriptsSidePanelRef === next.toggleScriptsSidePanelRef &&
|
||||
prev.toggleSidePanelRef === next.toggleSidePanelRef &&
|
||||
prev.identities === next.identities
|
||||
);
|
||||
|
||||
@@ -3,12 +3,14 @@ import assert from "node:assert/strict";
|
||||
|
||||
import type { Host } from "./models.ts";
|
||||
import {
|
||||
detectVendorFromSshVersion,
|
||||
normalizePrimaryTelnetState,
|
||||
resolveHostKeepalive,
|
||||
resolveTelnetPort,
|
||||
resolveTelnetPassword,
|
||||
resolveTelnetUsername,
|
||||
sanitizeHost,
|
||||
shouldProbeSessionCwd,
|
||||
upsertHostById,
|
||||
} from "./host.ts";
|
||||
|
||||
@@ -158,6 +160,39 @@ test("sanitizeHost keeps a still-valid fontFamily untouched", () => {
|
||||
assert.equal(after.fontFamilyOverride, true);
|
||||
});
|
||||
|
||||
test("detectVendorFromSshVersion recognizes legacy Huawei VRP dash banner", () => {
|
||||
assert.equal(detectVendorFromSshVersion("-"), "huawei");
|
||||
assert.equal(detectVendorFromSshVersion("SSH-2.0--"), "huawei");
|
||||
});
|
||||
|
||||
test("shouldProbeSessionCwd allows the probe on a plain Linux host", () => {
|
||||
assert.equal(
|
||||
shouldProbeSessionCwd({ isNetworkDevice: false, remoteSshVersion: "OpenSSH_9.6" }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldProbeSessionCwd skips the probe on an already-classified network device", () => {
|
||||
// Reconnect / manual deviceType='network': host.distro already says network.
|
||||
assert.equal(
|
||||
shouldProbeSessionCwd({ isNetworkDevice: true, remoteSshVersion: "OpenSSH_9.6" }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldProbeSessionCwd skips the probe when the SSH banner reveals a network vendor", () => {
|
||||
// First connect to a brand-new Huawei VRP: host.distro not persisted yet, so
|
||||
// isNetworkDevice is still false — the banner is the only signal (#1043).
|
||||
assert.equal(
|
||||
shouldProbeSessionCwd({ isNetworkDevice: false, remoteSshVersion: "-" }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldProbeSessionCwd({ isNetworkDevice: false, remoteSshVersion: "SSH-1.99--" }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
const GLOBAL_KEEPALIVE = { keepaliveInterval: 30, keepaliveCountMax: 10 };
|
||||
|
||||
test("resolveHostKeepalive falls back to global when override is not set", () => {
|
||||
|
||||
@@ -78,7 +78,7 @@ export const normalizeDistroId = (value?: string) => {
|
||||
* plain `OpenSSH_*` with no distinct vendor marker.
|
||||
*/
|
||||
export const detectVendorFromSshVersion = (softwareVersion?: string): '' | NetworkDeviceVendor => {
|
||||
const s = (softwareVersion || '').trim();
|
||||
const s = (softwareVersion || '').trim().replace(/^SSH-(?:2\.0|1\.99)-/i, '');
|
||||
if (!s) return '';
|
||||
|
||||
// Cisco family — IOS, IOS XA, Wireless LAN Controller
|
||||
@@ -97,6 +97,7 @@ export const detectVendorFromSshVersion = (softwareVersion?: string): '' | Netwo
|
||||
if (/^NetScreen\b/.test(s)) return 'juniper';
|
||||
|
||||
// Huawei VRP and related products
|
||||
if (s === '-') return 'huawei';
|
||||
if (/^HUAWEI[-_]/i.test(s)) return 'huawei';
|
||||
if (/^VRP-/i.test(s)) return 'huawei';
|
||||
|
||||
@@ -135,6 +136,24 @@ export const classifyDistroId = (distroId?: string): DeviceClass => {
|
||||
return 'other';
|
||||
};
|
||||
|
||||
/**
|
||||
* Decide whether it is safe to run the post-connect `pwd` probe that
|
||||
* discovers the session's working directory. The probe opens an extra exec
|
||||
* channel running a POSIX-shell script; strict network-device CLIs such as
|
||||
* Huawei VRP respond by closing the whole SSH session (#1043), so it must be
|
||||
* skipped for them.
|
||||
*
|
||||
* `isNetworkDevice` covers hosts we already classified (a reconnect, or an
|
||||
* explicit `deviceType: 'network'`). On a brand-new host that field is not
|
||||
* populated yet, so we also inspect the SSH server identification banner —
|
||||
* captured for free at handshake — which identifies most vendors directly.
|
||||
*/
|
||||
export const shouldProbeSessionCwd = (opts: {
|
||||
isNetworkDevice: boolean;
|
||||
remoteSshVersion?: string;
|
||||
}): boolean =>
|
||||
!opts.isNetworkDevice && !detectVendorFromSshVersion(opts.remoteSshVersion);
|
||||
|
||||
export const getEffectiveHostDistro = (
|
||||
host?: Pick<Host, 'distro' | 'manualDistro' | 'distroMode'> | null,
|
||||
) => {
|
||||
|
||||
@@ -435,6 +435,7 @@ export const DEFAULT_KEY_BINDINGS: KeyBinding[] = [
|
||||
{ id: 'new-workspace', action: 'newWorkspace', label: 'New Workspace', mac: '⌘ + Shift + J', pc: 'Ctrl + Shift + J', category: 'app' },
|
||||
{ id: 'snippets', action: 'snippets', label: 'Open Snippets', mac: '⌘ + Shift + S', pc: 'Ctrl + Shift + S', category: 'app' },
|
||||
{ id: 'broadcast', action: 'broadcast', label: 'Switch the Broadcast Mode', mac: '⌘ + B', pc: 'Ctrl + B', category: 'app' },
|
||||
{ id: 'toggle-side-panel', action: 'toggleSidePanel', label: 'Toggle Side Panel', mac: '⌘ + \\', pc: 'Ctrl + \\', category: 'app' },
|
||||
{ id: 'open-settings', action: 'openSettings', label: 'Open Settings', mac: '⌘ + ,', pc: 'Ctrl + ,', category: 'app' },
|
||||
|
||||
// SFTP Operations
|
||||
@@ -476,6 +477,7 @@ export interface TerminalSettings {
|
||||
scrollback: number; // Number of lines kept in buffer
|
||||
drawBoldInBrightColors: boolean; // Draw bold text in bright colors
|
||||
terminalEmulationType: TerminalEmulationType; // Terminal emulation type (TERM env var)
|
||||
startupCommandDelayMs: number; // Delay (ms) after connect before sending the startup command; also used between multiple lines
|
||||
|
||||
// Font
|
||||
fontLigatures: boolean; // Enable font ligatures
|
||||
@@ -493,6 +495,7 @@ export interface TerminalSettings {
|
||||
|
||||
// Keyboard
|
||||
altAsMeta: boolean; // Use ⌥ as the Meta key
|
||||
optionArrowWordJump: boolean; // macOS: Option+←/→ send Meta-b/f for word jump
|
||||
scrollOnInput: boolean; // Scroll terminal to bottom on input
|
||||
scrollOnOutput: boolean; // Scroll terminal to bottom on output
|
||||
scrollOnKeyPress: boolean; // Scroll terminal to bottom on key press
|
||||
@@ -683,6 +686,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
scrollback: 10000,
|
||||
drawBoldInBrightColors: true,
|
||||
terminalEmulationType: 'xterm-256color',
|
||||
startupCommandDelayMs: 600,
|
||||
fontLigatures: true,
|
||||
fontWeight: 400,
|
||||
fontWeightBold: 700,
|
||||
@@ -692,6 +696,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
cursorBlink: true,
|
||||
minimumContrastRatio: 1,
|
||||
altAsMeta: false,
|
||||
optionArrowWordJump: false,
|
||||
scrollOnInput: true,
|
||||
scrollOnOutput: false,
|
||||
scrollOnKeyPress: false,
|
||||
|
||||
@@ -192,6 +192,8 @@ export interface SyncPayload {
|
||||
// Terminal
|
||||
terminalTheme?: string;
|
||||
followAppTerminalTheme?: boolean;
|
||||
terminalThemeDark?: string;
|
||||
terminalThemeLight?: string;
|
||||
terminalFontFamily?: string;
|
||||
terminalFontSize?: number;
|
||||
terminalSettings?: Record<string, unknown>;
|
||||
|
||||
@@ -4,6 +4,8 @@ import assert from "node:assert/strict";
|
||||
import {
|
||||
applyCustomAccentToTerminalTheme,
|
||||
mergeTerminalHostUpdate,
|
||||
resolveFollowedTerminalThemeId,
|
||||
TERMINAL_THEME_AUTO,
|
||||
} from "./terminalAppearance";
|
||||
import type { Host, TerminalTheme } from "./models";
|
||||
|
||||
@@ -149,3 +151,73 @@ test("terminal appearance reset clears only appearance fields", () => {
|
||||
assert.equal(merged.fontSize, undefined);
|
||||
assert.equal(merged.fontSizeOverride, false);
|
||||
});
|
||||
|
||||
test("follow-theme resolver: dark + auto follows the active dark UI preset", () => {
|
||||
assert.equal(
|
||||
resolveFollowedTerminalThemeId({
|
||||
resolvedTheme: "dark",
|
||||
terminalThemeDarkId: TERMINAL_THEME_AUTO,
|
||||
terminalThemeLightId: TERMINAL_THEME_AUTO,
|
||||
lightUiThemeId: "snow",
|
||||
darkUiThemeId: "midnight",
|
||||
fallbackThemeId: "netcatty-dark",
|
||||
}),
|
||||
"ui-midnight",
|
||||
);
|
||||
});
|
||||
|
||||
test("follow-theme resolver: light + auto follows the active light UI preset", () => {
|
||||
assert.equal(
|
||||
resolveFollowedTerminalThemeId({
|
||||
resolvedTheme: "light",
|
||||
terminalThemeDarkId: TERMINAL_THEME_AUTO,
|
||||
terminalThemeLightId: TERMINAL_THEME_AUTO,
|
||||
lightUiThemeId: "snow",
|
||||
darkUiThemeId: "midnight",
|
||||
fallbackThemeId: "netcatty-dark",
|
||||
}),
|
||||
"ui-snow",
|
||||
);
|
||||
});
|
||||
|
||||
test("follow-theme resolver: explicit dark override wins over auto-matching", () => {
|
||||
assert.equal(
|
||||
resolveFollowedTerminalThemeId({
|
||||
resolvedTheme: "dark",
|
||||
terminalThemeDarkId: "dracula",
|
||||
terminalThemeLightId: TERMINAL_THEME_AUTO,
|
||||
lightUiThemeId: "snow",
|
||||
darkUiThemeId: "midnight",
|
||||
fallbackThemeId: "netcatty-dark",
|
||||
}),
|
||||
"dracula",
|
||||
);
|
||||
});
|
||||
|
||||
test("follow-theme resolver: explicit light override wins over auto-matching", () => {
|
||||
assert.equal(
|
||||
resolveFollowedTerminalThemeId({
|
||||
resolvedTheme: "light",
|
||||
terminalThemeDarkId: TERMINAL_THEME_AUTO,
|
||||
terminalThemeLightId: "solarized-light",
|
||||
lightUiThemeId: "snow",
|
||||
darkUiThemeId: "midnight",
|
||||
fallbackThemeId: "netcatty-dark",
|
||||
}),
|
||||
"solarized-light",
|
||||
);
|
||||
});
|
||||
|
||||
test("follow-theme resolver: auto with no UI match falls back to fallbackThemeId", () => {
|
||||
assert.equal(
|
||||
resolveFollowedTerminalThemeId({
|
||||
resolvedTheme: "dark",
|
||||
terminalThemeDarkId: TERMINAL_THEME_AUTO,
|
||||
terminalThemeLightId: TERMINAL_THEME_AUTO,
|
||||
lightUiThemeId: "no-such-ui-theme",
|
||||
darkUiThemeId: "no-such-ui-theme",
|
||||
fallbackThemeId: "netcatty-dark",
|
||||
}),
|
||||
"netcatty-dark",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -88,6 +88,39 @@ const UI_TO_TERMINAL_THEME: Record<string, string> = {
|
||||
export const getTerminalThemeForUiTheme = (uiThemeId: string): string | undefined =>
|
||||
UI_TO_TERMINAL_THEME[uiThemeId];
|
||||
|
||||
/**
|
||||
* Sentinel stored in the per-mode follow-theme settings meaning "let the
|
||||
* terminal theme follow the active UI theme preset" (the legacy
|
||||
* auto-matching behavior), as opposed to a concrete terminal theme id.
|
||||
*/
|
||||
export const TERMINAL_THEME_AUTO = 'auto';
|
||||
|
||||
/**
|
||||
* Resolve which terminal theme id to use while "Follow Application Theme" is
|
||||
* enabled, honoring the user's per-mode override.
|
||||
*
|
||||
* - A concrete theme id in the active mode's setting is used as-is.
|
||||
* - `TERMINAL_THEME_AUTO` (the default) keeps the legacy behavior: match the
|
||||
* active UI theme preset, then `fallbackThemeId` when no UI match exists.
|
||||
*/
|
||||
export const resolveFollowedTerminalThemeId = (args: {
|
||||
resolvedTheme: 'light' | 'dark';
|
||||
terminalThemeDarkId: string;
|
||||
terminalThemeLightId: string;
|
||||
lightUiThemeId: string;
|
||||
darkUiThemeId: string;
|
||||
fallbackThemeId: string;
|
||||
}): string => {
|
||||
const selected = args.resolvedTheme === 'dark'
|
||||
? args.terminalThemeDarkId
|
||||
: args.terminalThemeLightId;
|
||||
if (selected && selected !== TERMINAL_THEME_AUTO) return selected;
|
||||
const activeUiThemeId = args.resolvedTheme === 'dark'
|
||||
? args.darkUiThemeId
|
||||
: args.lightUiThemeId;
|
||||
return getTerminalThemeForUiTheme(activeUiThemeId) ?? args.fallbackThemeId;
|
||||
};
|
||||
|
||||
type ParsedHslToken = {
|
||||
hue: number;
|
||||
saturation: number;
|
||||
|
||||
@@ -9,3 +9,12 @@ test("normalizeTerminalSettings disables prompt line breaks by default", () => {
|
||||
assert.equal(settings.forcePromptNewLine, false);
|
||||
});
|
||||
|
||||
test("normalizeTerminalSettings defaults startupCommandDelayMs to 600", () => {
|
||||
assert.equal(normalizeTerminalSettings().startupCommandDelayMs, 600);
|
||||
});
|
||||
|
||||
test("normalizeTerminalSettings preserves a provided startupCommandDelayMs", () => {
|
||||
assert.equal(normalizeTerminalSettings({ startupCommandDelayMs: 0 }).startupCommandDelayMs, 0);
|
||||
assert.equal(normalizeTerminalSettings({ startupCommandDelayMs: 1500 }).startupCommandDelayMs, 1500);
|
||||
});
|
||||
|
||||
|
||||
50
electron/bridges/ai/claudeAuth.cjs
Normal file
50
electron/bridges/ai/claudeAuth.cjs
Normal file
@@ -0,0 +1,50 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Claude Code auth/config detection helpers (main process).
|
||||
*
|
||||
* claude-agent-acp authenticates from env (ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN)
|
||||
* or from credentials stored under CLAUDE_CONFIG_DIR (default ~/.claude). We use
|
||||
* this to turn opaque "-32603 Internal error" failures into an actionable message
|
||||
* when no auth is configured. NOTE: macOS may store credentials in the Keychain
|
||||
* rather than a file, so 'none' is a heuristic — callers must NOT hard-block on it;
|
||||
* only use it to improve the error message after an actual failure.
|
||||
*/
|
||||
|
||||
const { existsSync } = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
/**
|
||||
* Expand a leading "~" to the user's home directory. Env vars handed to a
|
||||
* child process are NOT shell-expanded, so "~/.claude" would otherwise be
|
||||
* treated as a literal directory named "~". Only a leading "~", "~/" or "~\"
|
||||
* is expanded (not "~user"); other values pass through unchanged.
|
||||
*/
|
||||
function expandHomePath(p) {
|
||||
if (typeof p !== "string") return p;
|
||||
const trimmed = p.trim();
|
||||
if (trimmed === "~") return os.homedir();
|
||||
if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) {
|
||||
return path.join(os.homedir(), trimmed.slice(2));
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
function getClaudeConfigDir(env) {
|
||||
const custom = typeof env?.CLAUDE_CONFIG_DIR === "string" ? env.CLAUDE_CONFIG_DIR.trim() : "";
|
||||
return custom ? expandHomePath(custom) : path.join(os.homedir(), ".claude");
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {'env'|'credentials-file'|'none'}
|
||||
*/
|
||||
function detectClaudeAuthPresence(env, fileExists = existsSync) {
|
||||
const apiKey = typeof env?.ANTHROPIC_API_KEY === "string" ? env.ANTHROPIC_API_KEY.trim() : "";
|
||||
const authToken = typeof env?.ANTHROPIC_AUTH_TOKEN === "string" ? env.ANTHROPIC_AUTH_TOKEN.trim() : "";
|
||||
if (apiKey || authToken) return "env";
|
||||
if (fileExists(path.join(getClaudeConfigDir(env), ".credentials.json"))) return "credentials-file";
|
||||
return "none";
|
||||
}
|
||||
|
||||
module.exports = { detectClaudeAuthPresence, getClaudeConfigDir, expandHomePath };
|
||||
56
electron/bridges/ai/claudeAuth.test.cjs
Normal file
56
electron/bridges/ai/claudeAuth.test.cjs
Normal file
@@ -0,0 +1,56 @@
|
||||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
|
||||
const { detectClaudeAuthPresence, getClaudeConfigDir, expandHomePath } = require("./claudeAuth.cjs");
|
||||
|
||||
test("getClaudeConfigDir: defaults to ~/.claude", () => {
|
||||
assert.equal(getClaudeConfigDir({}), path.join(os.homedir(), ".claude"));
|
||||
});
|
||||
|
||||
test("getClaudeConfigDir: honors CLAUDE_CONFIG_DIR", () => {
|
||||
assert.equal(getClaudeConfigDir({ CLAUDE_CONFIG_DIR: "/custom/dir" }), "/custom/dir");
|
||||
});
|
||||
|
||||
test("getClaudeConfigDir: expands a leading ~ in CLAUDE_CONFIG_DIR", () => {
|
||||
assert.equal(
|
||||
getClaudeConfigDir({ CLAUDE_CONFIG_DIR: "~/.claude-work" }),
|
||||
path.join(os.homedir(), ".claude-work"),
|
||||
);
|
||||
});
|
||||
|
||||
test("expandHomePath: expands '~' and '~/...', leaves others unchanged", () => {
|
||||
assert.equal(expandHomePath("~"), os.homedir());
|
||||
assert.equal(expandHomePath("~/x/y"), path.join(os.homedir(), "x/y"));
|
||||
assert.equal(expandHomePath("/abs/path"), "/abs/path");
|
||||
assert.equal(expandHomePath("~user/x"), "~user/x");
|
||||
assert.equal(expandHomePath(""), "");
|
||||
});
|
||||
|
||||
test("detectClaudeAuthPresence: ANTHROPIC_API_KEY in env => 'env'", () => {
|
||||
assert.equal(detectClaudeAuthPresence({ ANTHROPIC_API_KEY: "sk-x" }, () => false), "env");
|
||||
});
|
||||
|
||||
test("detectClaudeAuthPresence: ANTHROPIC_AUTH_TOKEN in env => 'env'", () => {
|
||||
assert.equal(detectClaudeAuthPresence({ ANTHROPIC_AUTH_TOKEN: "tok" }, () => false), "env");
|
||||
});
|
||||
|
||||
test("detectClaudeAuthPresence: blank env token is ignored", () => {
|
||||
assert.equal(detectClaudeAuthPresence({ ANTHROPIC_API_KEY: " " }, () => false), "none");
|
||||
});
|
||||
|
||||
test("detectClaudeAuthPresence: credentials file under config dir => 'credentials-file'", () => {
|
||||
const seen = [];
|
||||
const result = detectClaudeAuthPresence(
|
||||
{ CLAUDE_CONFIG_DIR: "/custom/dir" },
|
||||
(p) => { seen.push(p); return p === path.join("/custom/dir", ".credentials.json"); },
|
||||
);
|
||||
assert.equal(result, "credentials-file");
|
||||
assert.ok(seen.includes(path.join("/custom/dir", ".credentials.json")));
|
||||
});
|
||||
|
||||
test("detectClaudeAuthPresence: nothing => 'none'", () => {
|
||||
assert.equal(detectClaudeAuthPresence({}, () => false), "none");
|
||||
});
|
||||
@@ -58,6 +58,32 @@ function extractTrailingIdlePrompt(output) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// bash and csh/tcsh print a banner to the terminal right before exiting due to
|
||||
// the shell's TMOUT idle-timeout setting ("timed out waiting for input:
|
||||
// auto-logout" / "auto-logout"). That exit is a clean shell exit — numeric
|
||||
// code, no signal — so it is indistinguishable from a user-typed `exit` by
|
||||
// exit code alone (verified: bash auto-logout exits 0). The banner is the only
|
||||
// reliable discriminator, letting the SSH bridge keep the tab open for
|
||||
// reconnect instead of auto-closing it (#1062, regression of #977).
|
||||
const IDLE_AUTO_LOGOUT_PATTERN = /(?:timed out waiting for input:\s*)?auto-?logout$/i;
|
||||
|
||||
function looksLikeIdleAutoLogout(outputTail) {
|
||||
if (typeof outputTail !== "string" || !outputTail) return false;
|
||||
// The shell prints this banner on its own line as the very last thing before
|
||||
// it exits, so anchor on the final non-empty line rather than a loose
|
||||
// substring. Otherwise unrelated output that merely mentions "auto-logout"
|
||||
// (e.g. `grep auto-logout /etc/profile`) followed by an intentional `exit`
|
||||
// would be misclassified as a timeout and wrongly keep the tab open.
|
||||
const lines = stripAnsi(outputTail.slice(-512)).replace(/\r/g, "\n").split("\n");
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
// Drop control bytes (e.g. the BEL bash rings before the banner) and trim.
|
||||
const line = lines[i].replace(/[\x00-\x1f\x7f]/g, "").trim();
|
||||
if (!line) continue;
|
||||
return IDLE_AUTO_LOGOUT_PATTERN.test(line);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function trackSessionIdlePrompt(session, chunk) {
|
||||
if (!session || typeof chunk !== "string" || !chunk) return "";
|
||||
|
||||
@@ -399,6 +425,7 @@ module.exports = {
|
||||
getFreshIdlePrompt,
|
||||
isDefaultPowerShellPromptLine,
|
||||
trackSessionIdlePrompt,
|
||||
looksLikeIdleAutoLogout,
|
||||
isLocalhostHostname,
|
||||
extractFirstNonLocalhostUrl,
|
||||
normalizeCliPathForPlatform,
|
||||
|
||||
@@ -7,6 +7,7 @@ const {
|
||||
getFreshIdlePrompt,
|
||||
isDefaultPowerShellPromptLine,
|
||||
isPlausibleCliVersionOutput,
|
||||
looksLikeIdleAutoLogout,
|
||||
prepareCommandForSpawn,
|
||||
trackSessionIdlePrompt,
|
||||
} = require("./shellUtils.cjs");
|
||||
@@ -175,3 +176,64 @@ test("getFreshIdlePrompt and trackSessionIdlePrompt round-trip through a real PT
|
||||
// with the cached PS line, so downstream wrapper selection sees "".
|
||||
assert.equal(getFreshIdlePrompt(session), "");
|
||||
});
|
||||
|
||||
test("looksLikeIdleAutoLogout detects the bash TMOUT banner at the tail", () => {
|
||||
// bash prints this immediately before a TMOUT auto-logout exit. The exit
|
||||
// itself is a clean shell exit (code 0, no signal), so the banner is the
|
||||
// only reliable discriminator from a user-typed `exit` (#1062 / #977).
|
||||
assert.equal(
|
||||
looksLikeIdleAutoLogout("user@host:~$ \x07timed out waiting for input: auto-logout\r\n"),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("looksLikeIdleAutoLogout detects the csh/tcsh auto-logout banner", () => {
|
||||
assert.equal(looksLikeIdleAutoLogout("\r\nauto-logout\r\n"), true);
|
||||
});
|
||||
|
||||
test("looksLikeIdleAutoLogout sees through ANSI escapes around the banner", () => {
|
||||
assert.equal(
|
||||
looksLikeIdleAutoLogout("\x1b[0m\x1b[33mtimed out waiting for input: auto-logout\x1b[0m\r\n"),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("looksLikeIdleAutoLogout ignores a plain (non-timeout) logout", () => {
|
||||
// A normal login-shell exit prints "logout" — without the "auto-" prefix —
|
||||
// and must still auto-close the tab.
|
||||
assert.equal(looksLikeIdleAutoLogout("user@host:~$ logout\r\n"), false);
|
||||
});
|
||||
|
||||
test("looksLikeIdleAutoLogout ignores the banner when it is not at the tail", () => {
|
||||
// "auto-logout" scrolled past long ago; the user then ran more commands and
|
||||
// exited normally. Only the tail end is inspected, so this is not a timeout.
|
||||
const tail = "auto-logout\n" + "x".repeat(400) + "\nuser@host:~$ logout\r\n";
|
||||
assert.equal(looksLikeIdleAutoLogout(tail), false);
|
||||
});
|
||||
|
||||
test("looksLikeIdleAutoLogout ignores auto-logout in command output before an intentional exit", () => {
|
||||
// Investigating TMOUT: the user greps the profile (output mentions
|
||||
// "auto-logout"), reads it, then exits on purpose. The banner is not the
|
||||
// final line, so the tab must still auto-close. Guards against matching an
|
||||
// unanchored substring anywhere in the recent output.
|
||||
const tail =
|
||||
"root@h:~# grep -i auto-logout /etc/profile\r\n" +
|
||||
"# bash TMOUT auto-logout setting\r\nTMOUT=300\r\n" +
|
||||
"root@h:~# exit\r\nlogout\r\n";
|
||||
assert.equal(looksLikeIdleAutoLogout(tail), false);
|
||||
});
|
||||
|
||||
test("looksLikeIdleAutoLogout matches the real-server banner shape (prompt + banner on one line)", () => {
|
||||
// The banner can share a line with the trailing prompt after ANSI/control
|
||||
// bytes are stripped (observed over real SSH); anchoring on the line end
|
||||
// must still match.
|
||||
const tail =
|
||||
"\x1b]0;root@VM:~\x07root@VM:~# \x1b[?2004l\x07timed out waiting for input: auto-logout\n";
|
||||
assert.equal(looksLikeIdleAutoLogout(tail), true);
|
||||
});
|
||||
|
||||
test("looksLikeIdleAutoLogout returns false for empty / non-string input", () => {
|
||||
assert.equal(looksLikeIdleAutoLogout(""), false);
|
||||
assert.equal(looksLikeIdleAutoLogout(undefined), false);
|
||||
assert.equal(looksLikeIdleAutoLogout(null), false);
|
||||
});
|
||||
|
||||
@@ -37,6 +37,11 @@ const {
|
||||
toUnpackedAsarPath,
|
||||
} = require("./ai/shellUtils.cjs");
|
||||
|
||||
const { detectClaudeAuthPresence, expandHomePath } = require("./ai/claudeAuth.cjs");
|
||||
|
||||
const CLAUDE_AUTH_HELP_MESSAGE =
|
||||
"Claude Code has no usable authentication. Open Settings -> AI -> Claude Code and set a Config directory (point it at a folder where you've run `claude` login) or add an ANTHROPIC_API_KEY under Environment variables. Alternatively, run `claude` in a terminal to log in.";
|
||||
|
||||
const {
|
||||
codexLoginSessions,
|
||||
resolveCodexAcpBinaryPath,
|
||||
@@ -559,6 +564,12 @@ function normalizeAgentEnv(env) {
|
||||
if (!key || value == null) continue;
|
||||
result[key] = String(value);
|
||||
}
|
||||
// CLAUDE_CONFIG_DIR is consumed as a filesystem path by the spawned agent,
|
||||
// which won't shell-expand "~". Expand it here so "~/.claude" works and the
|
||||
// stored value stays portable (each device expands to its own home).
|
||||
if (result.CLAUDE_CONFIG_DIR) {
|
||||
result.CLAUDE_CONFIG_DIR = expandHomePath(result.CLAUDE_CONFIG_DIR);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -2451,6 +2462,9 @@ function registerHandlers(ipcMain) {
|
||||
return { ok: false, error: "Unauthorized IPC sender" };
|
||||
}
|
||||
let abortController = null;
|
||||
// Hoisted so the catch block can reference them for Claude-specific error handling.
|
||||
let isClaudeAgent = false;
|
||||
let claudeAuthPresence = null;
|
||||
try {
|
||||
const existingRun = acpChatRuns.get(chatSessionId);
|
||||
if (existingRun && existingRun.requestId !== requestId) {
|
||||
@@ -2503,8 +2517,14 @@ function registerHandlers(ipcMain) {
|
||||
if (shouldAbortStartup()) return { ok: true };
|
||||
const sessionCwd = cwd || process.cwd();
|
||||
const isCodexAgent = matchesAgentCommand(acpCommand, "codex-acp");
|
||||
const isClaudeAgent = matchesAgentCommand(acpCommand, "claude-agent-acp");
|
||||
isClaudeAgent = matchesAgentCommand(acpCommand, "claude-agent-acp");
|
||||
const isCopilotAgent = matchesAgentCommand(acpCommand, "copilot");
|
||||
// For Claude: detect whether any auth is reachable so we can turn an
|
||||
// opaque "-32603 Internal error" into actionable guidance on failure.
|
||||
// Heuristic only (macOS may keep creds in Keychain) — never hard-block.
|
||||
claudeAuthPresence = isClaudeAgent
|
||||
? detectClaudeAuthPresence({ ...shellEnv, ...normalizeAgentEnv(requestedAgentEnv) })
|
||||
: null;
|
||||
const agentLabel = isCodexAgent ? "codex" : isClaudeAgent ? "claude" : isCopilotAgent ? "copilot" : acpCommand;
|
||||
const effectiveToolIntegrationMode = normalizeToolIntegrationMode(toolIntegrationMode);
|
||||
debugMcpLog("ACP request start", {
|
||||
@@ -2568,7 +2588,13 @@ function registerHandlers(ipcMain) {
|
||||
|
||||
const authFingerprint = isCodexAgent
|
||||
? getAcpProviderAuthFingerprint(apiKey, resolvedProvider?.provider, codexCustomConfig)
|
||||
: null;
|
||||
: isClaudeAgent
|
||||
// Fingerprint the Claude agent env (config dir + user env vars) so a
|
||||
// settings change invalidates the cached per-session provider and the
|
||||
// next turn respawns with the new config instead of reusing a stale
|
||||
// process spawned with the old env.
|
||||
? JSON.stringify(normalizeAgentEnv(requestedAgentEnv))
|
||||
: null;
|
||||
const mcpSnapshot = isCodexAgent
|
||||
? await resolveCodexMcpSnapshot(sessionCwd)
|
||||
: { mcpServers: [], fingerprint: getCodexMcpFingerprint([]) };
|
||||
@@ -3009,11 +3035,18 @@ function registerHandlers(ipcMain) {
|
||||
if (!isActiveAcpRun(chatSessionId, requestId)) {
|
||||
return { ok: true };
|
||||
}
|
||||
if (isClaudeAgent) {
|
||||
// Reap the persistent agent process so a failed turn doesn't leak
|
||||
// node.exe processes (provider uses persistSession:true).
|
||||
cleanupAcpProvider(chatSessionId);
|
||||
}
|
||||
safeSend(event.sender, "netcatty:ai:acp:error", {
|
||||
requestId,
|
||||
error: isCodexAgent
|
||||
? "Codex returned an empty response. Connect Codex in Settings -> AI, or configure an enabled OpenAI provider API key."
|
||||
: "Agent returned an empty response.",
|
||||
: isClaudeAgent && claudeAuthPresence === "none"
|
||||
? CLAUDE_AUTH_HELP_MESSAGE
|
||||
: "Agent returned an empty response.",
|
||||
});
|
||||
} else {
|
||||
// Clear replay fallback when the recovered turn either streamed
|
||||
@@ -3040,19 +3073,40 @@ function registerHandlers(ipcMain) {
|
||||
} catch (err) {
|
||||
console.error("[ACP] Handler caught error:", err?.message || err, err?.stack?.split("\n").slice(0, 3).join("\n"));
|
||||
const normalized = extractCodexError(err);
|
||||
const errMsg = normalized.message;
|
||||
// #3 (light): include JSON-RPC code/data when present so Claude's bare
|
||||
// "Internal error" isn't shown context-free.
|
||||
const errCode = typeof err?.code === "number" ? err.code : err?.data?.code;
|
||||
// Only surface data fields we don't already show (message/code) so the
|
||||
// detail doesn't echo them back.
|
||||
let errDetail = "";
|
||||
if (err?.data && typeof err.data === "object") {
|
||||
const extra = { ...err.data };
|
||||
delete extra.code;
|
||||
delete extra.message;
|
||||
if (Object.keys(extra).length > 0) {
|
||||
try { errDetail = JSON.stringify(extra); } catch { errDetail = ""; }
|
||||
}
|
||||
}
|
||||
const errMsg = [normalized.message, errCode != null ? `(code ${errCode})` : "", errDetail]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const isAuthErr = isCodexAuthError(normalized);
|
||||
|
||||
if (isAuthErr) {
|
||||
console.error("[ACP] Auth error — user needs to re-login:", errMsg);
|
||||
cleanupAcpProvider(chatSessionId);
|
||||
} else if (isClaudeAgent) {
|
||||
// #4: always reap the Claude provider/process tree on error.
|
||||
cleanupAcpProvider(chatSessionId);
|
||||
}
|
||||
|
||||
safeSend(event.sender, "netcatty:ai:acp:error", {
|
||||
requestId,
|
||||
error: isAuthErr
|
||||
? `Authentication failed. Connect Codex in Settings -> AI, or configure an enabled OpenAI provider API key.\n\nDetails: ${errMsg}`
|
||||
: errMsg,
|
||||
: isClaudeAgent && claudeAuthPresence === "none"
|
||||
? `${CLAUDE_AUTH_HELP_MESSAGE}\n\nDetails: ${errMsg}`
|
||||
: errMsg,
|
||||
});
|
||||
} finally {
|
||||
acpActiveStreams.delete(requestId);
|
||||
|
||||
@@ -98,6 +98,8 @@ const buildS3Client = (config) =>
|
||||
region: config.region,
|
||||
endpoint: normalizeEndpoint(config.endpoint),
|
||||
forcePathStyle: config.forcePathStyle ?? true,
|
||||
requestChecksumCalculation: "WHEN_REQUIRED",
|
||||
responseChecksumValidation: "WHEN_REQUIRED",
|
||||
credentials: {
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
@@ -299,4 +301,5 @@ module.exports = {
|
||||
// Exposed for tests
|
||||
handleWebdavInitialize,
|
||||
buildBasicAuthHeader,
|
||||
buildS3Client,
|
||||
};
|
||||
|
||||
25
electron/bridges/cloudSyncBridge.s3Checksum.test.cjs
Normal file
25
electron/bridges/cloudSyncBridge.s3Checksum.test.cjs
Normal file
@@ -0,0 +1,25 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const {
|
||||
buildS3Client,
|
||||
} = require("./cloudSyncBridge.cjs");
|
||||
|
||||
const config = {
|
||||
endpoint: "https://s3.example.com",
|
||||
region: "us-east-1",
|
||||
bucket: "netcatty-test",
|
||||
accessKeyId: "access",
|
||||
secretAccessKey: "secret",
|
||||
forcePathStyle: true,
|
||||
};
|
||||
|
||||
test("S3 client only sends request checksums when required", async () => {
|
||||
const client = buildS3Client(config);
|
||||
assert.equal(await client.config.requestChecksumCalculation(), "WHEN_REQUIRED");
|
||||
});
|
||||
|
||||
test("S3 client only validates response checksums when required", async () => {
|
||||
const client = buildS3Client(config);
|
||||
assert.equal(await client.config.responseChecksumValidation(), "WHEN_REQUIRED");
|
||||
});
|
||||
63
electron/bridges/ptyOutputBuffer.cjs
Normal file
63
electron/bridges/ptyOutputBuffer.cjs
Normal file
@@ -0,0 +1,63 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Coalescing output buffer for terminal/PTY data on its way to the renderer.
|
||||
*
|
||||
* Incoming shell data is accumulated and delivered to `sendFn` in batches to
|
||||
* keep IPC traffic down, but the batch is flushed on the *next event-loop turn*
|
||||
* (`setImmediate`) rather than after a fixed time interval. A fixed interval
|
||||
* adds that whole interval as latency to interactive echo — every keystroke
|
||||
* round-trips through the buffer and waits out the timer before it can paint.
|
||||
* Turn-based flushing coalesces only the data that has already arrived in the
|
||||
* current turn, so a single echoed keystroke is forwarded almost immediately
|
||||
* while bursts of output still collapse into one send.
|
||||
*
|
||||
* A byte cap still forces an immediate, synchronous flush so a flood of output
|
||||
* can't grow the buffer without bound between turns.
|
||||
*
|
||||
* @param {(data: string) => void} sendFn delivers an accumulated batch
|
||||
* @param {{ maxBufferSize?: number }} [options]
|
||||
* @returns {{ bufferData: (data: string) => void, flush: () => void }}
|
||||
*/
|
||||
function createPtyOutputBuffer(sendFn, options = {}) {
|
||||
const maxBufferSize = options.maxBufferSize ?? 16384; // 16KB
|
||||
|
||||
let dataBuffer = "";
|
||||
let scheduled = null;
|
||||
|
||||
const cancelScheduled = () => {
|
||||
if (scheduled) {
|
||||
clearImmediate(scheduled);
|
||||
scheduled = null;
|
||||
}
|
||||
};
|
||||
|
||||
const flushNow = () => {
|
||||
scheduled = null;
|
||||
if (dataBuffer.length > 0) {
|
||||
const pending = dataBuffer;
|
||||
dataBuffer = "";
|
||||
sendFn(pending);
|
||||
}
|
||||
};
|
||||
|
||||
const bufferData = (data) => {
|
||||
dataBuffer += data;
|
||||
if (dataBuffer.length >= maxBufferSize) {
|
||||
// Large enough to ship right now — don't wait for the turn flush.
|
||||
cancelScheduled();
|
||||
flushNow();
|
||||
} else if (!scheduled) {
|
||||
scheduled = setImmediate(flushNow);
|
||||
}
|
||||
};
|
||||
|
||||
const flush = () => {
|
||||
cancelScheduled();
|
||||
flushNow();
|
||||
};
|
||||
|
||||
return { bufferData, flush };
|
||||
}
|
||||
|
||||
module.exports = { createPtyOutputBuffer };
|
||||
90
electron/bridges/ptyOutputBuffer.test.cjs
Normal file
90
electron/bridges/ptyOutputBuffer.test.cjs
Normal file
@@ -0,0 +1,90 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const { createPtyOutputBuffer } = require("./ptyOutputBuffer.cjs");
|
||||
|
||||
/** Resolve after one event-loop turn (immediates have run). */
|
||||
const tick = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
test("coalesces data buffered within the same turn into a single send", async () => {
|
||||
const sends = [];
|
||||
const buffer = createPtyOutputBuffer((data) => sends.push(data));
|
||||
|
||||
buffer.bufferData("a");
|
||||
buffer.bufferData("b");
|
||||
buffer.bufferData("c");
|
||||
|
||||
// Nothing is sent synchronously while still in the same turn.
|
||||
assert.equal(sends.length, 0);
|
||||
|
||||
await tick();
|
||||
|
||||
assert.deepEqual(sends, ["abc"]);
|
||||
});
|
||||
|
||||
test("flushes within a single event-loop turn (not on a fixed delay)", async () => {
|
||||
const sends = [];
|
||||
const buffer = createPtyOutputBuffer((data) => sends.push(data));
|
||||
|
||||
buffer.bufferData("x");
|
||||
|
||||
// A fixed-interval (e.g. 8ms) buffer would NOT have flushed after one
|
||||
// immediate turn. Turn-based flushing must have delivered it by now.
|
||||
await tick();
|
||||
|
||||
assert.deepEqual(sends, ["x"]);
|
||||
});
|
||||
|
||||
test("flushes immediately and synchronously once the size cap is reached", async () => {
|
||||
const sends = [];
|
||||
const buffer = createPtyOutputBuffer((data) => sends.push(data), {
|
||||
maxBufferSize: 4,
|
||||
});
|
||||
|
||||
buffer.bufferData("ab");
|
||||
assert.equal(sends.length, 0); // under cap, still pending
|
||||
|
||||
buffer.bufferData("cd"); // now "abcd" hits the 4-byte cap
|
||||
|
||||
// Cap flush happens synchronously, without waiting for the turn.
|
||||
assert.deepEqual(sends, ["abcd"]);
|
||||
|
||||
// The pending turn flush must have been cancelled — no empty/duplicate send.
|
||||
await tick();
|
||||
assert.deepEqual(sends, ["abcd"]);
|
||||
});
|
||||
|
||||
test("flush() forces a synchronous send and cancels the pending turn", async () => {
|
||||
const sends = [];
|
||||
const buffer = createPtyOutputBuffer((data) => sends.push(data));
|
||||
|
||||
buffer.bufferData("hello");
|
||||
buffer.flush();
|
||||
|
||||
assert.deepEqual(sends, ["hello"]);
|
||||
|
||||
await tick();
|
||||
assert.deepEqual(sends, ["hello"]); // not sent twice
|
||||
});
|
||||
|
||||
test("flush() with an empty buffer does not send", async () => {
|
||||
const sends = [];
|
||||
const buffer = createPtyOutputBuffer((data) => sends.push(data));
|
||||
|
||||
buffer.flush();
|
||||
|
||||
assert.equal(sends.length, 0);
|
||||
});
|
||||
|
||||
test("keeps batching after a flush", async () => {
|
||||
const sends = [];
|
||||
const buffer = createPtyOutputBuffer((data) => sends.push(data));
|
||||
|
||||
buffer.bufferData("first");
|
||||
await tick();
|
||||
|
||||
buffer.bufferData("second");
|
||||
await tick();
|
||||
|
||||
assert.deepEqual(sends, ["first", "second"]);
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const crypto = require("node:crypto");
|
||||
const { KexInit, HANDLERS: KEX_HANDLERS } = require("../../node_modules/ssh2/lib/protocol/kex.js");
|
||||
const { COMPAT, COMPAT_CHECKS, MESSAGE } = require("../../node_modules/ssh2/lib/protocol/constants.js");
|
||||
|
||||
const sshBridge = require("./sshBridge.cjs");
|
||||
const sftpBridge = require("./sftpBridge.cjs");
|
||||
@@ -45,6 +47,81 @@ function withAlgorithmRuntime({ unsupportedGroups = new Set(), hashes = ["sha1",
|
||||
}
|
||||
}
|
||||
|
||||
function kexPayloadFrom(init) {
|
||||
const payload = Buffer.alloc(1 + 16 + init.totalSize + 1 + 4);
|
||||
payload[0] = MESSAGE.KEXINIT;
|
||||
init.copyAllTo(payload, 17);
|
||||
return payload;
|
||||
}
|
||||
|
||||
function buildKexInit(algorithms) {
|
||||
return new KexInit({
|
||||
kex: algorithms.kex,
|
||||
serverHostKey: algorithms.serverHostKey,
|
||||
cs: {
|
||||
cipher: algorithms.cipher,
|
||||
mac: algorithms.hmac,
|
||||
compress: algorithms.compress,
|
||||
lang: [],
|
||||
},
|
||||
sc: {
|
||||
cipher: algorithms.cipher,
|
||||
mac: algorithms.hmac,
|
||||
compress: algorithms.compress,
|
||||
lang: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function readLegacyGexRequestBits(compatFlags) {
|
||||
const algorithms = sshBridge.buildAlgorithms(true);
|
||||
const writtenPackets = [];
|
||||
const protocol = {
|
||||
_server: false,
|
||||
_compatFlags: compatFlags,
|
||||
_offer: buildKexInit(algorithms),
|
||||
_debug: undefined,
|
||||
_strictMode: undefined,
|
||||
_kex: undefined,
|
||||
_kexinit: Buffer.from("local-kexinit"),
|
||||
_identRaw: Buffer.from("SSH-2.0-netcatty-test"),
|
||||
_remoteIdentRaw: Buffer.from("SSH-2.0-Comware-5.20"),
|
||||
_packetRW: {
|
||||
write: {
|
||||
allocStartKEX: 0,
|
||||
alloc(size) {
|
||||
return Buffer.alloc(size);
|
||||
},
|
||||
finalize(packet) {
|
||||
return packet;
|
||||
},
|
||||
},
|
||||
},
|
||||
_cipher: {
|
||||
encrypt(packet) {
|
||||
writtenPackets.push(Buffer.from(packet));
|
||||
},
|
||||
},
|
||||
};
|
||||
const remote = buildKexInit({
|
||||
kex: ["diffie-hellman-group-exchange-sha1"],
|
||||
serverHostKey: ["ecdsa-sha2-nistp256", "ssh-rsa"],
|
||||
cipher: ["aes128-ctr"],
|
||||
hmac: ["hmac-sha2-256"],
|
||||
compress: ["none"],
|
||||
});
|
||||
|
||||
KEX_HANDLERS[MESSAGE.KEXINIT](protocol, kexPayloadFrom(remote));
|
||||
|
||||
const request = writtenPackets.find((packet) => packet[0] === MESSAGE.KEXDH_GEX_REQUEST);
|
||||
assert.ok(request, "expected a DH group-exchange request packet");
|
||||
return {
|
||||
min: request.readUInt32BE(1),
|
||||
preferred: request.readUInt32BE(5),
|
||||
max: request.readUInt32BE(9),
|
||||
};
|
||||
}
|
||||
|
||||
for (const [label, buildAlgorithms] of [
|
||||
["SSH", sshBridge.buildAlgorithms],
|
||||
["SFTP", sftpBridge.buildSftpAlgorithms],
|
||||
@@ -123,3 +200,17 @@ test("legacy HMAC algorithms skip MD5 when the runtime disables it", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("Comware legacy group-exchange requests OpenSSH 6.4-sized DH groups", () => {
|
||||
const comwareCompatRule = COMPAT_CHECKS.find(([pattern, flags]) => (
|
||||
pattern instanceof RegExp
|
||||
&& pattern.test("Comware-5.20")
|
||||
&& (flags & COMPAT.COMWARE_DHGEX_1024)
|
||||
));
|
||||
|
||||
assert.ok(comwareCompatRule, "Comware servers should opt into the old DH group-exchange request size");
|
||||
assert.deepEqual(
|
||||
readLegacyGexRequestBits(COMPAT.COMWARE_DHGEX_1024),
|
||||
{ min: 1024, preferred: 1024, max: 8192 },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ const passphraseHandler = require("./passphraseHandler.cjs");
|
||||
const hostKeyVerifier = require("./hostKeyVerifier.cjs");
|
||||
const { createProxySocket } = require("./proxyUtils.cjs");
|
||||
const { attachX11Forwarding } = require("./x11Forwarding.cjs");
|
||||
const { createPtyOutputBuffer } = require("./ptyOutputBuffer.cjs");
|
||||
const {
|
||||
buildAuthHandler,
|
||||
createKeyboardInteractiveHandler,
|
||||
@@ -33,7 +34,7 @@ const {
|
||||
isPassphraseCancelledError,
|
||||
} = require("./sshAuthHelper.cjs");
|
||||
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
|
||||
const { trackSessionIdlePrompt } = require("./ai/shellUtils.cjs");
|
||||
const { trackSessionIdlePrompt, looksLikeIdleAutoLogout } = require("./ai/shellUtils.cjs");
|
||||
const { createZmodemSentry } = require("./zmodemHelper.cjs");
|
||||
const {
|
||||
buildAlgorithms,
|
||||
@@ -365,6 +366,8 @@ function resolveLangFromCharset(charset) {
|
||||
|
||||
const { safeSend } = require("./ipcUtils.cjs");
|
||||
|
||||
const zmodemOverwritePending = new Map(); // requestId -> (decision) => void
|
||||
|
||||
/**
|
||||
* Initialize the SSH bridge with dependencies
|
||||
*/
|
||||
@@ -1285,35 +1288,14 @@ async function startSSHSession(event, options) {
|
||||
});
|
||||
}
|
||||
|
||||
// Data buffering for reduced IPC overhead
|
||||
let dataBuffer = '';
|
||||
let flushTimeout = null;
|
||||
const FLUSH_INTERVAL = 8; // ms - flush every 8ms for ~120fps equivalent
|
||||
const MAX_BUFFER_SIZE = 16384; // 16KB - flush immediately if buffer gets too large
|
||||
|
||||
const flushBuffer = () => {
|
||||
if (dataBuffer.length > 0) {
|
||||
const contents = event.sender;
|
||||
safeSend(contents, "netcatty:data", { sessionId, data: dataBuffer });
|
||||
dataBuffer = '';
|
||||
}
|
||||
flushTimeout = null;
|
||||
};
|
||||
|
||||
const bufferData = (data) => {
|
||||
dataBuffer += data;
|
||||
// Immediate flush for large chunks
|
||||
if (dataBuffer.length >= MAX_BUFFER_SIZE) {
|
||||
if (flushTimeout) {
|
||||
clearTimeout(flushTimeout);
|
||||
flushTimeout = null;
|
||||
}
|
||||
flushBuffer();
|
||||
} else if (!flushTimeout) {
|
||||
// Schedule flush
|
||||
flushTimeout = setTimeout(flushBuffer, FLUSH_INTERVAL);
|
||||
}
|
||||
};
|
||||
// Coalesce shell output and deliver it to the renderer on the next
|
||||
// event-loop turn (see ptyOutputBuffer) rather than on a fixed timer,
|
||||
// so interactive echo isn't held back by the batch interval. A size
|
||||
// cap still forces an immediate flush for bursts of output.
|
||||
const { bufferData, flush: flushBuffer } = createPtyOutputBuffer((data) => {
|
||||
const contents = event.sender;
|
||||
safeSend(contents, "netcatty:data", { sessionId, data });
|
||||
});
|
||||
|
||||
const sshZmodemSentry = createZmodemSentry({
|
||||
sessionId,
|
||||
@@ -1330,6 +1312,31 @@ async function startSSHSession(event, options) {
|
||||
interruptRemote() {
|
||||
try { stream.signal?.("INT"); } catch { /* ignore */ }
|
||||
},
|
||||
probeReceiveConflicts(names) {
|
||||
return probeReceiveConflicts(sessions.get(sessionId), names);
|
||||
},
|
||||
removeRemoteFiles(paths) {
|
||||
return removeRemoteFiles(sessions.get(sessionId), paths);
|
||||
},
|
||||
restoreRemoteModes(entries) {
|
||||
return restoreRemoteModes(sessions.get(sessionId), entries);
|
||||
},
|
||||
requestOverwriteDecision(filename) {
|
||||
return new Promise((resolve) => {
|
||||
const requestId = randomUUID();
|
||||
const timer = setTimeout(() => {
|
||||
zmodemOverwritePending.delete(requestId);
|
||||
resolve({ action: "skip", applyToRest: false });
|
||||
}, 120000);
|
||||
zmodemOverwritePending.set(requestId, (payload) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ action: payload.action, applyToRest: !!payload.applyToRest });
|
||||
});
|
||||
safeSend(event.sender, "netcatty:zmodem:overwrite-request", {
|
||||
sessionId, requestId, filename,
|
||||
});
|
||||
});
|
||||
},
|
||||
getWebContents() {
|
||||
return event.sender;
|
||||
},
|
||||
@@ -1365,10 +1372,8 @@ async function startSSHSession(event, options) {
|
||||
});
|
||||
|
||||
stream.on("close", () => {
|
||||
// Always flush buffered data regardless of session state
|
||||
if (flushTimeout) {
|
||||
clearTimeout(flushTimeout);
|
||||
}
|
||||
// Always flush buffered data regardless of session state.
|
||||
// flushBuffer() cancels any pending scheduled flush internally.
|
||||
flushBuffer();
|
||||
sessionLogStreamManager.stopStream(sessionId, logStreamToken);
|
||||
if (detachX11Forwarding) {
|
||||
@@ -1386,7 +1391,14 @@ async function startSSHSession(event, options) {
|
||||
if (transportError) {
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: transportError, reason: "error" });
|
||||
} else {
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: streamExitCode, reason: streamExited ? "exited" : "closed" });
|
||||
// A shell TMOUT auto-logout is a clean exit (numeric code, no
|
||||
// signal) — identical to a user-typed `exit` by code/signal —
|
||||
// so detect it via the banner the shell prints just before
|
||||
// exiting and report it as a timeout. That keeps the tab open
|
||||
// for reconnect instead of auto-closing it (#1062 / #977).
|
||||
const idleTimedOut = streamExited && looksLikeIdleAutoLogout(session?._promptTrackTail);
|
||||
const reason = idleTimedOut ? "timeout" : (streamExited ? "exited" : "closed");
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: streamExitCode, reason });
|
||||
}
|
||||
sessions.get(sessionId)?.zmodemSentry?.cancel();
|
||||
sessions.delete(sessionId);
|
||||
@@ -2012,24 +2024,58 @@ async function getSessionPwd(event, payload) {
|
||||
// so sh keeps the same PID and $PPID = sshd. Starting another shell
|
||||
// without exec would make $PPID point at the intermediate shell instead.
|
||||
const posixScript = `SELF=$$
|
||||
find_child_shell() {
|
||||
mode=$2
|
||||
ps -e -o pid=,ppid=,stat=,comm= 2>/dev/null | awk -v pp="$1" -v self="$SELF" -v mode="$mode" '
|
||||
$1 != self && $2 == pp && $4 ~ /^(ba|z|fi|k|da)?sh$/ {
|
||||
if (index($3, "+") > 0) { print $1; found=1; exit }
|
||||
if (mode != "foreground" && pid == "") pid=$1
|
||||
# Find the interactive shell child of this exec channel's sshd ($PPID).
|
||||
# Prefer the one attached to a controlling tty (the user's shell): probe exec
|
||||
# channels like this one have no tty ("?"), and ps output is unsorted, so
|
||||
# without the tty preference a concurrent probe's shell could be picked when
|
||||
# several exist under the same sshd (#1065 review). Falls back to any shell
|
||||
# child if none has a tty.
|
||||
find_login_shell() {
|
||||
ps -e -o pid=,ppid=,tty=,comm= 2>/dev/null | awk -v pp="$1" -v self="$SELF" '
|
||||
$1 != self && $2 == pp && $4 ~ /^-?(ba|z|fi|k|da|a)?sh$/ {
|
||||
if ($3 != "?") { print $1; found=1; exit }
|
||||
if (any == "") any=$1
|
||||
}
|
||||
END { if (!found && mode != "foreground" && pid != "") print pid }
|
||||
END { if (!found && any != "") print any }
|
||||
'
|
||||
}
|
||||
pid=$(find_child_shell "$PPID" any)
|
||||
while [ -n "$pid" ]; do
|
||||
child=$(find_child_shell "$pid" foreground)
|
||||
[ -n "$child" ] || break
|
||||
pid="$child"
|
||||
done
|
||||
if [ -n "$pid" ]; then
|
||||
# From the login shell, pick the DEEPEST foreground shell in its process
|
||||
# subtree. "Foreground" = the controlling tty's foreground process group ("+"
|
||||
# in stat), i.e. the shell the user is actually typing in. Walking the whole
|
||||
# subtree (rather than only direct shell children) lets us follow through
|
||||
# non-shell foreground parents like su / sudo, so we read the cwd of the
|
||||
# su'd / sudo'd shell instead of stopping at the login shell (#1065). Falls
|
||||
# back to the login shell when no foreground shell is found.
|
||||
find_active_shell() {
|
||||
ps -e -o pid=,ppid=,stat=,comm= 2>/dev/null | awk -v start="$1" '
|
||||
{ pp[$1]=$2; st[$1]=$3; cm[$1]=$4; ord[NR]=$1 }
|
||||
function isshell(c) { return c ~ /^-?(ba|z|fi|k|da|a)?sh$/ }
|
||||
function depth(p, d) { d=0; while (p != "" && d < 64) { if (p == start) return d; p=pp[p]; d++ } return -1 }
|
||||
END {
|
||||
best=-1; bp="";
|
||||
for (i=1; i<=NR; i++) {
|
||||
p=ord[i];
|
||||
if (!isshell(cm[p])) continue;
|
||||
if (index(st[p], "+") == 0) continue;
|
||||
d=depth(p); if (d < 0) continue;
|
||||
if (d > best) { best=d; bp=p }
|
||||
}
|
||||
print (bp != "" ? bp : start)
|
||||
}
|
||||
'
|
||||
}
|
||||
login=$(find_login_shell "$PPID")
|
||||
if [ -n "$login" ]; then
|
||||
pid=$(find_active_shell "$login")
|
||||
[ -n "$pid" ] || pid="$login"
|
||||
cwd=$(readlink /proc/$pid/cwd 2>/dev/null)
|
||||
# /proc/<pid>/cwd is only readable for same-uid processes (ptrace perms), so
|
||||
# this unprivileged exec channel cannot read a su'd / sudo'd shell owned by
|
||||
# another user. Fall back to the same-uid login shell's cwd before giving up
|
||||
# to the home directory (#1065 review).
|
||||
if [ -z "$cwd" ] && [ "$pid" != "$login" ]; then
|
||||
cwd=$(readlink /proc/$login/cwd 2>/dev/null)
|
||||
fi
|
||||
[ -n "$cwd" ] && printf '%s\\n' "$cwd" && exit 0
|
||||
fi
|
||||
emit_home() {
|
||||
@@ -2077,6 +2123,103 @@ exit 1`;
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve the directory the running `rz` writes to (its own cwd) and report
|
||||
// which of `names` already exist there. Returns { dir, existing } or null.
|
||||
function probeReceiveConflicts(session, names) {
|
||||
return new Promise((resolve) => {
|
||||
if (!session || !session.conn || !Array.isArray(names) || names.length === 0) {
|
||||
return resolve(null);
|
||||
}
|
||||
const timer = setTimeout(() => resolve(null), 5000);
|
||||
const script = `SELF=$$
|
||||
find_login_shell() {
|
||||
ps -e -o pid=,ppid=,tty=,comm= 2>/dev/null | awk -v pp="$1" -v self="$SELF" '
|
||||
$1 != self && $2 == pp && $4 ~ /^-?(ba|z|fi|k|da|a)?sh$/ {
|
||||
if ($3 != "?") { print $1; found=1; exit }
|
||||
if (any == "") any=$1
|
||||
}
|
||||
END { if (!found && any != "") print any }'
|
||||
}
|
||||
find_fg_leaf() {
|
||||
ps -e -o pid=,ppid=,stat=,comm= 2>/dev/null | awk -v start="$1" '
|
||||
{ pp[$1]=$2; st[$1]=$3; ord[NR]=$1 }
|
||||
function depth(p, d){ d=0; while(p!="" && d<64){ if(p==start) return d; p=pp[p]; d++ } return -1 }
|
||||
END { best=-1; bp=""; for(i=1;i<=NR;i++){ p=ord[i];
|
||||
if(index(st[p],"+")==0) continue; d=depth(p); if(d<0) continue;
|
||||
if(d>best){best=d; bp=p} } print bp }'
|
||||
}
|
||||
login=$(find_login_shell "$PPID")
|
||||
[ -n "$login" ] || exit 0
|
||||
leaf=$(find_fg_leaf "$login")
|
||||
[ -n "$leaf" ] || leaf="$login"
|
||||
dir=$(readlink /proc/$leaf/cwd 2>/dev/null)
|
||||
[ -n "$dir" ] || exit 0
|
||||
printf 'DIR\\t%s\\n' "$dir"
|
||||
cd "$dir" 2>/dev/null || exit 0
|
||||
for n in "$@"; do
|
||||
[ -e "$n" ] || continue
|
||||
m=$(stat -c %a -- "$n" 2>/dev/null || stat -f %Lp -- "$n" 2>/dev/null)
|
||||
printf 'EXIST\\t%s\\t%s\\n' "$n" "$m"
|
||||
done`;
|
||||
const argv = names.map((n) => quoteShellArg(n)).join(" ");
|
||||
const cmd = `exec sh -c ${quoteShellArg(script)} sh ${argv}`;
|
||||
session.conn.exec(cmd, (err, stream) => {
|
||||
if (err) { clearTimeout(timer); return resolve(null); }
|
||||
let out = "";
|
||||
stream.on("data", (d) => { out += d.toString(); });
|
||||
stream.on("close", () => {
|
||||
clearTimeout(timer);
|
||||
let dir = null; const existing = []; const modes = {};
|
||||
for (const line of out.split("\n")) {
|
||||
const [tag, val, mode] = line.split("\t");
|
||||
if (tag === "DIR") dir = val;
|
||||
else if (tag === "EXIST" && val) {
|
||||
existing.push(val);
|
||||
if (mode && /^[0-7]{3,4}$/.test(mode)) modes[val] = mode;
|
||||
}
|
||||
}
|
||||
resolve(dir ? { dir, existing, modes } : null);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// rm -f the given absolute remote paths (quoted; injection-safe).
|
||||
function removeRemoteFiles(session, paths) {
|
||||
return new Promise((resolve) => {
|
||||
if (!session || !session.conn || !Array.isArray(paths) || paths.length === 0) return resolve();
|
||||
const argv = paths.map((p) => quoteShellArg(p)).join(" ");
|
||||
const timer = setTimeout(resolve, 5000);
|
||||
session.conn.exec(`exec sh -c 'rm -f -- "$@"' sh ${argv}`, (err, stream) => {
|
||||
if (err) { clearTimeout(timer); return resolve(); }
|
||||
stream.on("data", () => {}); stream.stderr?.on("data", () => {});
|
||||
stream.on("close", () => { clearTimeout(timer); resolve(); });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// chmod the given { path, mode } entries back to their captured permissions
|
||||
// (parameterized; injection-safe). Modes are validated octal before use.
|
||||
function restoreRemoteModes(session, entries) {
|
||||
return new Promise((resolve) => {
|
||||
if (!session || !session.conn || !Array.isArray(entries) || entries.length === 0) return resolve();
|
||||
const args = [];
|
||||
for (const e of entries) {
|
||||
if (!e || !e.path || !/^[0-7]{3,4}$/.test(String(e.mode))) continue;
|
||||
args.push(quoteShellArg(String(e.mode)));
|
||||
args.push(quoteShellArg(e.path));
|
||||
}
|
||||
if (args.length === 0) return resolve();
|
||||
const timer = setTimeout(resolve, 5000);
|
||||
const script = 'while [ "$#" -ge 2 ]; do chmod "$1" "$2" 2>/dev/null; shift 2; done';
|
||||
session.conn.exec(`exec sh -c ${quoteShellArg(script)} sh ${args.join(" ")}`, (err, stream) => {
|
||||
if (err) { clearTimeout(timer); return resolve(); }
|
||||
stream.on("data", () => {}); stream.stderr?.on("data", () => {});
|
||||
stream.on("close", () => { clearTimeout(timer); resolve(); });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List directory contents on remote machine for path autocomplete.
|
||||
* Uses a separate exec channel — does not touch the interactive shell.
|
||||
@@ -2653,6 +2796,10 @@ function registerHandlers(ipcMain) {
|
||||
}
|
||||
return keys;
|
||||
});
|
||||
ipcMain.on("netcatty:zmodem:overwrite-response", (_event, payload) => {
|
||||
const resolve = zmodemOverwritePending.get(payload?.requestId);
|
||||
if (resolve) { zmodemOverwritePending.delete(payload.requestId); resolve(payload); }
|
||||
});
|
||||
// Register the shared keyboard-interactive response handler
|
||||
keyboardInteractiveHandler.registerHandler(ipcMain);
|
||||
// Register the passphrase response handler
|
||||
|
||||
@@ -81,6 +81,7 @@ test("execCommand stops when an identity file passphrase prompt is cancelled", a
|
||||
handle(channel, handler) {
|
||||
this.handlers.set(channel, handler);
|
||||
},
|
||||
on() {},
|
||||
};
|
||||
bridge.registerHandlers(ipcMain);
|
||||
const execHandler = ipcMain.handlers.get("netcatty:ssh:exec");
|
||||
|
||||
@@ -25,6 +25,7 @@ const moshHandshake = require("./moshHandshake.cjs");
|
||||
const tempDirBridge = require("./tempDirBridge.cjs");
|
||||
const { createTelnetAutoLogin } = require("./telnetAutoLogin.cjs");
|
||||
const telnetProtocol = require("./telnetProtocol.cjs");
|
||||
const { createPtyOutputBuffer } = require("./ptyOutputBuffer.cjs");
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -80,51 +81,6 @@ function init(deps) {
|
||||
electronModule = deps.electronModule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an 8ms/16KB PTY data buffer for reduced IPC overhead.
|
||||
* Mirrors the SSH stream buffering strategy in sshBridge.cjs.
|
||||
* @param {Function} sendFn - called with the accumulated string to deliver
|
||||
* @returns {{ bufferData: (data: string) => void, flush: () => void }}
|
||||
*/
|
||||
function createPtyBuffer(sendFn) {
|
||||
const FLUSH_INTERVAL = 8; // ms - flush every 8ms (~120fps equivalent)
|
||||
const MAX_BUFFER_SIZE = 16384; // 16KB - flush immediately if buffer grows too large
|
||||
|
||||
let dataBuffer = '';
|
||||
let flushTimeout = null;
|
||||
|
||||
const flushBuffer = () => {
|
||||
if (dataBuffer.length > 0) {
|
||||
sendFn(dataBuffer);
|
||||
dataBuffer = '';
|
||||
}
|
||||
flushTimeout = null;
|
||||
};
|
||||
|
||||
const flush = () => {
|
||||
if (flushTimeout) {
|
||||
clearTimeout(flushTimeout);
|
||||
flushTimeout = null;
|
||||
}
|
||||
flushBuffer();
|
||||
};
|
||||
|
||||
const bufferData = (data) => {
|
||||
dataBuffer += data;
|
||||
if (dataBuffer.length >= MAX_BUFFER_SIZE) {
|
||||
if (flushTimeout) {
|
||||
clearTimeout(flushTimeout);
|
||||
flushTimeout = null;
|
||||
}
|
||||
flushBuffer();
|
||||
} else if (!flushTimeout) {
|
||||
flushTimeout = setTimeout(flushBuffer, FLUSH_INTERVAL);
|
||||
}
|
||||
};
|
||||
|
||||
return { bufferData, flush };
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate an executable on POSIX systems by name.
|
||||
*
|
||||
@@ -454,7 +410,7 @@ function startLocalSession(event, payload) {
|
||||
});
|
||||
}
|
||||
|
||||
const { bufferData: bufferLocalData, flush: flushLocal } = createPtyBuffer((data) => {
|
||||
const { bufferData: bufferLocalData, flush: flushLocal } = createPtyOutputBuffer((data) => {
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:data", { sessionId, data });
|
||||
});
|
||||
@@ -662,7 +618,7 @@ async function startTelnetSession(event, options) {
|
||||
const telnetDecoderRef = { current: iconv.getDecoder(initialTelnetEncoding) };
|
||||
|
||||
const telnetWebContentsId = event.sender.id;
|
||||
const { bufferData: bufferTelnetData, flush: flushTelnet } = createPtyBuffer((data) => {
|
||||
const { bufferData: bufferTelnetData, flush: flushTelnet } = createPtyOutputBuffer((data) => {
|
||||
const contents = electronModule.webContents.fromId(telnetWebContentsId);
|
||||
contents?.send("netcatty:data", { sessionId, data });
|
||||
});
|
||||
@@ -1189,7 +1145,7 @@ async function startMoshSessionViaHandshake(event, options, { bareClient, sshExe
|
||||
// it to scope its stopStream call.
|
||||
session.logStreamToken = logStreamToken;
|
||||
|
||||
const { bufferData, flush } = createPtyBuffer((data) => {
|
||||
const { bufferData, flush } = createPtyOutputBuffer((data) => {
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:data", { sessionId, data });
|
||||
});
|
||||
@@ -1593,6 +1549,31 @@ function writeToSession(event, payload) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* flooding source can't outrun the terminal renderer. Works across session
|
||||
* kinds: ssh2 channel (stream), node-pty (proc), telnet socket, serial port —
|
||||
* all expose pause()/resume().
|
||||
*/
|
||||
function setSessionFlowPaused(event, payload) {
|
||||
const session = sessions.get(payload.sessionId);
|
||||
if (!session) return;
|
||||
const target = session.stream || session.proc || session.socket || session.serialPort;
|
||||
if (!target) return;
|
||||
try {
|
||||
if (payload.paused) {
|
||||
target.pause?.();
|
||||
} else {
|
||||
target.resume?.();
|
||||
}
|
||||
} catch (err) {
|
||||
if (err?.code !== 'EPIPE' && err?.code !== 'ERR_STREAM_DESTROYED') {
|
||||
console.warn("Flow control toggle failed", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize a session terminal
|
||||
*/
|
||||
@@ -1707,6 +1688,7 @@ function registerHandlers(ipcMain) {
|
||||
ipcMain.handle("netcatty:terminal:setEncoding", setSessionEncoding);
|
||||
ipcMain.on("netcatty:write", writeToSession);
|
||||
ipcMain.on("netcatty:resize", resizeSession);
|
||||
ipcMain.on("netcatty:flow", setSessionFlowPaused);
|
||||
ipcMain.on("netcatty:close", closeSession);
|
||||
}
|
||||
|
||||
@@ -1892,6 +1874,7 @@ module.exports = {
|
||||
listSerialPorts,
|
||||
writeToSession,
|
||||
resizeSession,
|
||||
setSessionFlowPaused,
|
||||
closeSession,
|
||||
cleanupAllSessions,
|
||||
getDefaultShell,
|
||||
|
||||
@@ -20,6 +20,58 @@ function getElectron() {
|
||||
return _electron;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve per-file overwrite choices into an upload plan. Pure (no I/O):
|
||||
* `resolveDecision(name)` is awaited only for files in `existingList`, in input
|
||||
* order; `{ applyToRest: true }` reuses that action for the remaining conflicts.
|
||||
* Returns indices into the original `names` array so callers preserve per-file
|
||||
* identity even when two files share a basename.
|
||||
* Actions: 'overwrite' (rm remote then send), 'skip' (don't send), 'cancel' (abort all).
|
||||
*/
|
||||
async function buildUploadPlan(names, existingList, resolveDecision) {
|
||||
const existing = new Set(existingList);
|
||||
const offerIndices = [];
|
||||
const removeIndices = [];
|
||||
let bulkAction = null;
|
||||
for (let idx = 0; idx < names.length; idx++) {
|
||||
const name = names[idx];
|
||||
if (!existing.has(name)) { offerIndices.push(idx); continue; }
|
||||
let action = bulkAction;
|
||||
if (!action) {
|
||||
const decision = (await resolveDecision(name)) || { action: "skip" };
|
||||
action = decision.action;
|
||||
if (decision.applyToRest && action !== "cancel") bulkAction = action;
|
||||
}
|
||||
if (action === "cancel") return { offerIndices: [], removeIndices: [], aborted: true };
|
||||
if (action === "overwrite") { removeIndices.push(idx); offerIndices.push(idx); }
|
||||
// 'skip' → omit from both
|
||||
}
|
||||
return { offerIndices, removeIndices, aborted: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which overwritten files need their original mode restored after rz
|
||||
* re-creates them. rz writes new files with the remote umask, dropping the
|
||||
* prior permission bits (issue #1079). Pure: returns absolute `{ path, mode }`
|
||||
* entries for the overwritten files, skipping any whose mode wasn't captured
|
||||
* and de-duplicating shared basenames.
|
||||
*/
|
||||
function buildModeRestores(dir, names, removeIndices, modes) {
|
||||
const base = String(dir).replace(/\/+$/, "");
|
||||
const seen = new Set();
|
||||
const restores = [];
|
||||
for (const i of removeIndices) {
|
||||
const name = names[i];
|
||||
const mode = modes && modes[name];
|
||||
if (!mode) continue;
|
||||
const target = `${base}/${name}`;
|
||||
if (seen.has(target)) continue;
|
||||
seen.add(target);
|
||||
restores.push({ path: target, mode });
|
||||
}
|
||||
return restores;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a ZMODEM sentry that wraps a session's data stream.
|
||||
*
|
||||
@@ -524,10 +576,45 @@ async function handleUpload(zsession, opts) {
|
||||
const filePaths = result.filePaths;
|
||||
const fileStats = filePaths.map((fp) => fs.statSync(fp));
|
||||
|
||||
for (let i = 0; i < filePaths.length; i++) {
|
||||
const filePath = filePaths[i];
|
||||
const stat = fileStats[i];
|
||||
const name = path.basename(filePath);
|
||||
const allNames = filePaths.map((fp) => path.basename(fp));
|
||||
|
||||
// Conflict handling (SSH only — callbacks absent on local/telnet/serial).
|
||||
// On any failure we fall back to today's behavior (rz silently skips).
|
||||
let plan = { offerIndices: allNames.map((_, i) => i), removeIndices: [], aborted: false };
|
||||
let probeDir = null;
|
||||
let probeModes = null;
|
||||
if (opts.probeReceiveConflicts && opts.requestOverwriteDecision) {
|
||||
try {
|
||||
const probe = await opts.probeReceiveConflicts(allNames);
|
||||
if (probe && probe.dir && Array.isArray(probe.existing) && probe.existing.length > 0) {
|
||||
probeDir = probe.dir;
|
||||
probeModes = probe.modes || {};
|
||||
plan = await buildUploadPlan(allNames, probe.existing, opts.requestOverwriteDecision);
|
||||
if (plan.aborted) {
|
||||
try { zsession.abort(); } catch { /* ignore */ }
|
||||
abortRemoteProcess(opts.writeToRemote);
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
if (plan.removeIndices.length && opts.removeRemoteFiles) {
|
||||
const base = probe.dir.replace(/\/+$/, "");
|
||||
const targets = [...new Set(plan.removeIndices.map((i) => `${base}/${allNames[i]}`))];
|
||||
try {
|
||||
await opts.removeRemoteFiles(targets);
|
||||
} catch (err) {
|
||||
console.warn("[ZMODEM] removeRemoteFiles failed; rz will skip:", err?.message || err);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message === "Transfer cancelled") throw err;
|
||||
console.warn("[ZMODEM] conflict probe failed; proceeding:", err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
const offers = plan.offerIndices.map((i) => ({ filePath: filePaths[i], stat: fileStats[i], name: allNames[i] }));
|
||||
|
||||
for (let i = 0; i < offers.length; i++) {
|
||||
const { filePath, stat, name } = offers[i];
|
||||
|
||||
safeSend(contents, "netcatty:zmodem:progress", {
|
||||
sessionId,
|
||||
@@ -535,18 +622,18 @@ async function handleUpload(zsession, opts) {
|
||||
transferred: 0,
|
||||
total: stat.size,
|
||||
fileIndex: i,
|
||||
fileCount: filePaths.length,
|
||||
fileCount: offers.length,
|
||||
transferType: "upload",
|
||||
});
|
||||
|
||||
let bytesRemaining = 0;
|
||||
for (let j = i; j < fileStats.length; j++) bytesRemaining += fileStats[j].size;
|
||||
for (let j = i; j < offers.length; j++) bytesRemaining += offers[j].stat.size;
|
||||
|
||||
const xfer = await zsession.send_offer({
|
||||
name,
|
||||
size: stat.size,
|
||||
mtime: new Date(stat.mtimeMs),
|
||||
files_remaining: filePaths.length - i,
|
||||
files_remaining: offers.length - i,
|
||||
bytes_remaining: bytesRemaining,
|
||||
});
|
||||
|
||||
@@ -579,7 +666,7 @@ async function handleUpload(zsession, opts) {
|
||||
transferred: sent,
|
||||
total: stat.size,
|
||||
fileIndex: i,
|
||||
fileCount: filePaths.length,
|
||||
fileCount: offers.length,
|
||||
transferType: "upload",
|
||||
});
|
||||
|
||||
@@ -597,7 +684,7 @@ async function handleUpload(zsession, opts) {
|
||||
transferred: stat.size,
|
||||
total: stat.size,
|
||||
fileIndex: i,
|
||||
fileCount: filePaths.length,
|
||||
fileCount: offers.length,
|
||||
transferType: "upload",
|
||||
finalizing: true,
|
||||
});
|
||||
@@ -608,6 +695,20 @@ async function handleUpload(zsession, opts) {
|
||||
}
|
||||
|
||||
await withTimeout(zsession.close(), 120000);
|
||||
|
||||
// rz re-creates overwritten files with the remote umask, dropping their
|
||||
// original permission bits. Now that everything is on disk, restore them
|
||||
// to the modes captured before the rm (issue #1079).
|
||||
if (plan.removeIndices.length && probeDir && opts.restoreRemoteModes) {
|
||||
const restores = buildModeRestores(probeDir, allNames, plan.removeIndices, probeModes);
|
||||
if (restores.length) {
|
||||
try {
|
||||
await opts.restoreRemoteModes(restores);
|
||||
} catch (err) {
|
||||
console.warn("[ZMODEM] restoreRemoteModes failed:", err?.message || err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -791,4 +892,4 @@ function safeSend(contents, channel, data) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { createZmodemSentry };
|
||||
module.exports = { createZmodemSentry, buildUploadPlan, buildModeRestores };
|
||||
|
||||
74
electron/bridges/zmodemHelper.test.cjs
Normal file
74
electron/bridges/zmodemHelper.test.cjs
Normal file
@@ -0,0 +1,74 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { buildUploadPlan, buildModeRestores } = require("./zmodemHelper.cjs");
|
||||
|
||||
const never = () => { throw new Error("resolver should not be called"); };
|
||||
|
||||
test("no conflicts: all indices offered, none removed, resolver untouched", async () => {
|
||||
const plan = await buildUploadPlan(["a.txt", "b.txt"], [], never);
|
||||
assert.deepEqual(plan, { offerIndices: [0, 1], removeIndices: [], aborted: false });
|
||||
});
|
||||
|
||||
test("overwrite a conflict: index both removed and offered", async () => {
|
||||
const plan = await buildUploadPlan(["a.txt", "b.txt"], ["b.txt"], async () => ({ action: "overwrite" }));
|
||||
assert.deepEqual(plan, { offerIndices: [0, 1], removeIndices: [1], aborted: false });
|
||||
});
|
||||
|
||||
test("skip a conflict: index omitted from offer and remove", async () => {
|
||||
const plan = await buildUploadPlan(["a.txt", "b.txt"], ["b.txt"], async () => ({ action: "skip" }));
|
||||
assert.deepEqual(plan, { offerIndices: [0], removeIndices: [], aborted: false });
|
||||
});
|
||||
|
||||
test("cancel aborts the whole transfer", async () => {
|
||||
const plan = await buildUploadPlan(["a.txt", "b.txt"], ["b.txt"], async () => ({ action: "cancel" }));
|
||||
assert.deepEqual(plan, { offerIndices: [], removeIndices: [], aborted: true });
|
||||
});
|
||||
|
||||
test("applyToRest reuses the action and stops prompting", async () => {
|
||||
let calls = 0;
|
||||
const plan = await buildUploadPlan(["a", "b", "c"], ["a", "b", "c"],
|
||||
async () => { calls++; return { action: "overwrite", applyToRest: true }; });
|
||||
assert.equal(calls, 1);
|
||||
assert.deepEqual(plan, { offerIndices: [0, 1, 2], removeIndices: [0, 1, 2], aborted: false });
|
||||
});
|
||||
|
||||
test("only conflicting files invoke the resolver; order preserved", async () => {
|
||||
const seen = [];
|
||||
const plan = await buildUploadPlan(["a", "b", "c"], ["b"],
|
||||
async (n) => { seen.push(n); return { action: "skip" }; });
|
||||
assert.deepEqual(seen, ["b"]);
|
||||
assert.deepEqual(plan.offerIndices, [0, 2]);
|
||||
});
|
||||
|
||||
test("duplicate basenames keep independent per-file decisions", async () => {
|
||||
// Two different local files share a basename; skip the first, overwrite the second.
|
||||
const actions = ["skip", "overwrite"];
|
||||
let i = 0;
|
||||
const plan = await buildUploadPlan(["x.txt", "x.txt"], ["x.txt"],
|
||||
async () => ({ action: actions[i++] }));
|
||||
assert.deepEqual(plan, { offerIndices: [1], removeIndices: [1], aborted: false });
|
||||
});
|
||||
|
||||
// Issue #1079: overwriting (rm + rz re-create) drops the original permission
|
||||
// bits. buildModeRestores resolves which overwritten files to chmod back.
|
||||
|
||||
test("buildModeRestores maps overwritten files to their captured modes", () => {
|
||||
assert.deepEqual(
|
||||
buildModeRestores("/home/u", ["a.sh", "b.txt"], [0], { "a.sh": "755" }),
|
||||
[{ path: "/home/u/a.sh", mode: "755" }],
|
||||
);
|
||||
});
|
||||
|
||||
test("buildModeRestores skips files whose mode was not captured", () => {
|
||||
assert.deepEqual(
|
||||
buildModeRestores("/srv", ["a", "b"], [0, 1], { a: "644" }),
|
||||
[{ path: "/srv/a", mode: "644" }],
|
||||
);
|
||||
});
|
||||
|
||||
test("buildModeRestores strips trailing slashes and dedupes duplicate basenames", () => {
|
||||
assert.deepEqual(
|
||||
buildModeRestores("/srv//", ["x", "x"], [0, 1], { x: "600" }),
|
||||
[{ path: "/srv/x", mode: "600" }],
|
||||
);
|
||||
});
|
||||
@@ -10,6 +10,7 @@ const transferErrorListeners = new Map();
|
||||
const transferCancelledListeners = new Map();
|
||||
const chainProgressListeners = new Map();
|
||||
const zmodemListeners = new Map();
|
||||
const zmodemOverwriteListeners = new Map(); // sessionId -> Set<cb>
|
||||
const sftpConnectionProgressListeners = new Set();
|
||||
const authFailedListeners = new Map();
|
||||
const telnetAutoLoginCompleteListeners = new Map();
|
||||
@@ -137,6 +138,10 @@ ipcRenderer.on("netcatty:zmodem:error", (_event, payload) => {
|
||||
if (!set) return;
|
||||
set.forEach((cb) => { try { cb({ type: "error", ...payload }); } catch {} });
|
||||
});
|
||||
ipcRenderer.on("netcatty:zmodem:overwrite-request", (_event, payload) => {
|
||||
const set = zmodemOverwriteListeners.get(payload.sessionId);
|
||||
if (set) set.forEach((cb) => cb(payload));
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:data", (_event, payload) => {
|
||||
const set = dataListeners.get(payload.sessionId);
|
||||
@@ -185,6 +190,7 @@ ipcRenderer.on("netcatty:exit", (_event, payload) => {
|
||||
telnetAutoLoginCompleteListeners.delete(payload.sessionId);
|
||||
telnetAutoLoginCancelledListeners.delete(payload.sessionId);
|
||||
zmodemListeners.delete(payload.sessionId);
|
||||
zmodemOverwriteListeners.delete(payload.sessionId);
|
||||
const pendingTimer = _mcpFlushTimers.get(payload.sessionId);
|
||||
if (pendingTimer) {
|
||||
clearTimeout(pendingTimer);
|
||||
@@ -663,6 +669,9 @@ const api = {
|
||||
resizeSession: (sessionId, cols, rows) => {
|
||||
ipcRenderer.send("netcatty:resize", { sessionId, cols, rows });
|
||||
},
|
||||
setSessionFlowPaused: (sessionId, paused) => {
|
||||
ipcRenderer.send("netcatty:flow", { sessionId, paused: Boolean(paused) });
|
||||
},
|
||||
closeSession: (sessionId) => {
|
||||
ipcRenderer.send("netcatty:close", { sessionId });
|
||||
},
|
||||
@@ -682,6 +691,14 @@ const api = {
|
||||
cancelZmodem: (sessionId) => {
|
||||
ipcRenderer.send("netcatty:zmodem:cancel", { sessionId });
|
||||
},
|
||||
onZmodemOverwriteRequest: (sessionId, cb) => {
|
||||
if (!zmodemOverwriteListeners.has(sessionId)) zmodemOverwriteListeners.set(sessionId, new Set());
|
||||
zmodemOverwriteListeners.get(sessionId).add(cb);
|
||||
return () => zmodemOverwriteListeners.get(sessionId)?.delete(cb);
|
||||
},
|
||||
respondZmodemOverwrite: (payload) => {
|
||||
ipcRenderer.send("netcatty:zmodem:overwrite-response", payload);
|
||||
},
|
||||
onSessionData: (sessionId, cb) => {
|
||||
if (!dataListeners.has(sessionId)) dataListeners.set(sessionId, new Set());
|
||||
dataListeners.get(sessionId).add(cb);
|
||||
|
||||
@@ -3,11 +3,16 @@ import tsParser from "@typescript-eslint/parser";
|
||||
import tsPlugin from "@typescript-eslint/eslint-plugin";
|
||||
import unusedImports from "eslint-plugin-unused-imports";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import globals from "globals";
|
||||
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
// The recommended preset has no file scope of its own, so scope it off all of
|
||||
// electron/ — that main-process tree is historically unlinted. The bridges
|
||||
// get a focused rule set in the dedicated block at the end of this config;
|
||||
// every other electron/ file matches no config and stays unlinted as before.
|
||||
{ ...js.configs.recommended, ignores: ["electron/**"] },
|
||||
{
|
||||
ignores: ["node_modules/**", "dist/**", "electron/**", "scripts/**", "public/monaco/**", ".github/**", ".claude/**", "release/**", ".worktrees/**"],
|
||||
ignores: ["node_modules/**", "dist/**", "scripts/**", "public/monaco/**", ".github/**", ".claude/**", "release/**", ".worktrees/**"],
|
||||
},
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
@@ -168,4 +173,26 @@ export default [
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
// Electron main-process bridges are CommonJS and were historically excluded
|
||||
// from linting. Lint them for undefined references only — the cheap,
|
||||
// high-value guard against e.g. a removed variable still referenced
|
||||
// elsewhere. (The TS config disables no-undef because the type-checker
|
||||
// already covers it there; these .cjs files have no such safety net.)
|
||||
files: ["electron/bridges/**/*.cjs"],
|
||||
languageOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "commonjs",
|
||||
globals: globals.node,
|
||||
},
|
||||
linterOptions: {
|
||||
// Only no-undef is enabled here, so pre-existing eslint-disable comments
|
||||
// for other rules (no-console, no-control-regex, …) would all report as
|
||||
// "unused". Don't flag them — they stay valid for future rule additions.
|
||||
reportUnusedDisableDirectives: "off",
|
||||
},
|
||||
rules: {
|
||||
"no-undef": "error",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
10
global.d.ts
vendored
10
global.d.ts
vendored
@@ -323,6 +323,7 @@ declare global {
|
||||
setSessionEncoding?(sessionId: string, encoding: string): Promise<{ ok: boolean; encoding: string }>;
|
||||
writeToSession(sessionId: string, data: string, options?: { automated?: boolean }): void;
|
||||
resizeSession(sessionId: string, cols: number, rows: number): void;
|
||||
setSessionFlowPaused(sessionId: string, paused: boolean): void;
|
||||
closeSession(sessionId: string): void;
|
||||
// ZMODEM file transfer
|
||||
onZmodemEvent?(
|
||||
@@ -341,6 +342,15 @@ declare global {
|
||||
}) => void
|
||||
): () => void;
|
||||
cancelZmodem?(sessionId: string): void;
|
||||
onZmodemOverwriteRequest?(
|
||||
sessionId: string,
|
||||
cb: (payload: { sessionId: string; requestId: string; filename: string }) => void
|
||||
): () => void;
|
||||
respondZmodemOverwrite?(payload: {
|
||||
requestId: string;
|
||||
action: "overwrite" | "skip" | "cancel";
|
||||
applyToRest: boolean;
|
||||
}): void;
|
||||
onSessionData(sessionId: string, cb: (data: string) => void): () => void;
|
||||
onSessionExit(
|
||||
sessionId: string,
|
||||
|
||||
@@ -13,6 +13,8 @@ export const STORAGE_KEY_UI_FONT_FAMILY = 'netcatty_ui_font_family_v1';
|
||||
export const STORAGE_KEY_SYNC = 'netcatty_sync_v1';
|
||||
export const STORAGE_KEY_TERM_THEME = 'netcatty_term_theme_v1';
|
||||
export const STORAGE_KEY_TERM_FOLLOW_APP_THEME = 'netcatty_term_follow_app_theme_v1';
|
||||
export const STORAGE_KEY_TERM_THEME_DARK = 'netcatty_term_theme_dark_v1';
|
||||
export const STORAGE_KEY_TERM_THEME_LIGHT = 'netcatty_term_theme_light_v1';
|
||||
export const STORAGE_KEY_TERM_FONT_FAMILY = 'netcatty_term_font_family_v1';
|
||||
export const STORAGE_KEY_TERM_FONT_SIZE = 'netcatty_term_font_size_v1';
|
||||
export const STORAGE_KEY_TERM_SETTINGS = 'netcatty_term_settings_v1';
|
||||
|
||||
@@ -194,6 +194,8 @@ export class S3Adapter {
|
||||
region: config.region,
|
||||
endpoint: config.endpoint,
|
||||
forcePathStyle: config.forcePathStyle ?? true,
|
||||
requestChecksumCalculation: 'WHEN_REQUIRED',
|
||||
responseChecksumValidation: 'WHEN_REQUIRED',
|
||||
credentials: {
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
|
||||
66
lib/tabInteractions.test.ts
Normal file
66
lib/tabInteractions.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type React from "react";
|
||||
|
||||
import {
|
||||
MIDDLE_MOUSE_BUTTON,
|
||||
handleTabMiddleClickClose,
|
||||
handleTabMiddleMouseDown,
|
||||
} from "./tabInteractions.ts";
|
||||
|
||||
interface FakeMouseEvent {
|
||||
button: number;
|
||||
preventDefault: () => void;
|
||||
stopPropagation: () => void;
|
||||
}
|
||||
|
||||
const makeEvent = (button: number) => {
|
||||
const calls = { preventDefault: 0, stopPropagation: 0 };
|
||||
const event = {
|
||||
button,
|
||||
preventDefault: () => {
|
||||
calls.preventDefault++;
|
||||
},
|
||||
stopPropagation: () => {
|
||||
calls.stopPropagation++;
|
||||
},
|
||||
} satisfies FakeMouseEvent;
|
||||
return { event: event as unknown as React.MouseEvent, calls };
|
||||
};
|
||||
|
||||
test("handleTabMiddleClickClose closes the tab on a middle click", () => {
|
||||
let closed = 0;
|
||||
const { event, calls } = makeEvent(MIDDLE_MOUSE_BUTTON);
|
||||
|
||||
handleTabMiddleClickClose(event, () => {
|
||||
closed++;
|
||||
});
|
||||
|
||||
assert.equal(closed, 1);
|
||||
assert.equal(calls.preventDefault, 1);
|
||||
assert.equal(calls.stopPropagation, 1);
|
||||
});
|
||||
|
||||
test("handleTabMiddleClickClose ignores left and right clicks", () => {
|
||||
for (const button of [0, 2]) {
|
||||
let closed = 0;
|
||||
const { event, calls } = makeEvent(button);
|
||||
|
||||
handleTabMiddleClickClose(event, () => {
|
||||
closed++;
|
||||
});
|
||||
|
||||
assert.equal(closed, 0, `button ${button} must not close the tab`);
|
||||
assert.equal(calls.preventDefault, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("handleTabMiddleMouseDown suppresses autoscroll only for the middle button", () => {
|
||||
const middle = makeEvent(MIDDLE_MOUSE_BUTTON);
|
||||
handleTabMiddleMouseDown(middle.event);
|
||||
assert.equal(middle.calls.preventDefault, 1);
|
||||
|
||||
const left = makeEvent(0);
|
||||
handleTabMiddleMouseDown(left.event);
|
||||
assert.equal(left.calls.preventDefault, 0);
|
||||
});
|
||||
34
lib/tabInteractions.ts
Normal file
34
lib/tabInteractions.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type React from "react";
|
||||
|
||||
/**
|
||||
* The DOM `MouseEvent.button` value for the middle mouse button (wheel click).
|
||||
* 0 = left/primary, 1 = middle, 2 = right/secondary.
|
||||
*/
|
||||
export const MIDDLE_MOUSE_BUTTON = 1;
|
||||
|
||||
/**
|
||||
* Suppress the Chromium/Electron middle-click autoscroll affordance on a tab.
|
||||
* Wire to `onMouseDown`: autoscroll is armed on mousedown, so preventing the
|
||||
* default there stops the panning-cursor overlay from appearing when a user
|
||||
* middle-clicks a tab to close it (#1044).
|
||||
*/
|
||||
export const handleTabMiddleMouseDown = (e: React.MouseEvent): void => {
|
||||
if (e.button === MIDDLE_MOUSE_BUTTON) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Close a tab when it is middle-clicked. Wire to `onAuxClick`, which fires for
|
||||
* a completed non-primary click. Left clicks (tab activation) and right clicks
|
||||
* (context menu) are ignored so existing behavior is untouched.
|
||||
*/
|
||||
export const handleTabMiddleClickClose = (
|
||||
e: React.MouseEvent,
|
||||
close: () => void,
|
||||
): void => {
|
||||
if (e.button !== MIDDLE_MOUSE_BUTTON) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
close();
|
||||
};
|
||||
@@ -28,7 +28,7 @@
|
||||
"pack:linux": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --publish=never",
|
||||
"pack:linux-x64": "npm run build && cross-env npm_config_arch=x64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --x64 --publish=never",
|
||||
"pack:linux-arm64": "npm run build && cross-env npm_config_arch=arm64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --arm64 --publish=never",
|
||||
"postinstall": "electron-builder install-app-deps && patch-package",
|
||||
"postinstall": "electron-builder install-app-deps && patch-package && node scripts/patch-xterm-webgl-atlas.cjs",
|
||||
"rebuild": "electron-builder install-app-deps",
|
||||
"tool:cli": "node electron/cli/netcatty-tool-cli.cjs",
|
||||
"lint": "eslint .",
|
||||
|
||||
@@ -738,3 +738,40 @@ index 9f33c02..9751164 100644
|
||||
}
|
||||
if (names !== undefined) {
|
||||
sftp._debug && sftp._debug(
|
||||
diff --git a/node_modules/ssh2/lib/protocol/constants.js b/node_modules/ssh2/lib/protocol/constants.js
|
||||
index ad77592..4b3f71a 100644
|
||||
--- a/node_modules/ssh2/lib/protocol/constants.js
|
||||
+++ b/node_modules/ssh2/lib/protocol/constants.js
|
||||
@@ -160,4 +160,5 @@ const COMPAT = {
|
||||
DYN_RPORT_BUG: 1 << 2,
|
||||
BUG_DHGEX_LARGE: 1 << 3,
|
||||
IMPLY_RSA_SHA2_SIGALGS: 1 << 4,
|
||||
+ COMWARE_DHGEX_1024: 1 << 5,
|
||||
};
|
||||
@@ -330,6 +331,7 @@ module.exports = {
|
||||
COMPAT_CHECKS: [
|
||||
[ 'Cisco-1.25', COMPAT.BAD_DHGEX ],
|
||||
[ /^Cisco-1[.]/, COMPAT.BUG_DHGEX_LARGE ],
|
||||
+ [ /^Comware-/, COMPAT.COMWARE_DHGEX_1024 ],
|
||||
[ /^[0-9.]+$/, COMPAT.OLD_EXIT ], // old SSH.com implementations
|
||||
[ /^OpenSSH_5[.][0-9]+/, COMPAT.DYN_RPORT_BUG ],
|
||||
[ /^OpenSSH_7[.]4/, COMPAT.IMPLY_RSA_SHA2_SIGALGS ],
|
||||
diff --git a/node_modules/ssh2/lib/protocol/kex.js b/node_modules/ssh2/lib/protocol/kex.js
|
||||
index 811e631..4b5f792 100644
|
||||
--- a/node_modules/ssh2/lib/protocol/kex.js
|
||||
+++ b/node_modules/ssh2/lib/protocol/kex.js
|
||||
@@ -1377,8 +1377,13 @@ const createKeyExchange = (() => {
|
||||
this._generator = null;
|
||||
this._minBits = GEX_MIN_BITS;
|
||||
this._prefBits = dhEstimate(this.negotiated);
|
||||
- if (this._protocol._compatFlags & COMPAT.BUG_DHGEX_LARGE)
|
||||
+ if (hashName === 'sha1'
|
||||
+ && (this._protocol._compatFlags & COMPAT.COMWARE_DHGEX_1024)) {
|
||||
+ this._minBits = 1024;
|
||||
+ this._prefBits = 1024;
|
||||
+ } else if (this._protocol._compatFlags & COMPAT.BUG_DHGEX_LARGE) {
|
||||
this._prefBits = Math.min(this._prefBits, 4096);
|
||||
+ }
|
||||
this._maxBits = GEX_MAX_BITS;
|
||||
}
|
||||
start() {
|
||||
|
||||
74
scripts/patch-xterm-webgl-atlas.cjs
Normal file
74
scripts/patch-xterm-webgl-atlas.cjs
Normal file
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Disable @xterm/addon-webgl's cross-terminal texture-atlas sharing.
|
||||
*
|
||||
* xterm's WebGL addon shares ONE TextureAtlas across terminal instances whose
|
||||
* config (font / size / theme / device-pixel-ratio) is equal — see
|
||||
* `acquireTextureAtlas`, which does `if (configEquals) { ownedBy.push; return
|
||||
* atlas }`. In a split workspace two panes then share an atlas, so clearing or
|
||||
* rebuilding it for one pane (which netcatty does on resize / DPR change / font
|
||||
* change / tab show to recover from glyph corruption) corrupts the OTHER pane's
|
||||
* rendering — the persistent "花屏 / garbled" report in issue #1063, most
|
||||
* visible in split view where both panes stay on screen.
|
||||
*
|
||||
* Fix: give every terminal its own atlas by removing the "reuse a matching
|
||||
* atlas" loop, so each terminal falls through to creating its own. The published
|
||||
* package is minified, so we string-replace the exact loop in both the CJS and
|
||||
* ESM builds. This runs from `postinstall` (after patch-package).
|
||||
*
|
||||
* Idempotent. If the upstream code changes (e.g. an @xterm/addon-webgl upgrade)
|
||||
* the loop won't be found; we warn loudly but do not fail the install, and the
|
||||
* strings below must then be refreshed for the new version.
|
||||
*/
|
||||
"use strict";
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const MARKER = "/*netcatty:#1063 atlas-isolation*/";
|
||||
|
||||
// Exact (minified) "reuse a shared atlas" loop, per @xterm/addon-webgl@0.19.0.
|
||||
const TARGETS = [
|
||||
{
|
||||
file: "node_modules/@xterm/addon-webgl/lib/addon-webgl.mjs",
|
||||
loop: "for(let h=0;h<le.length;h++){let f=le[h];if(Mi(f.config,u))return f.ownedBy.push(i),f.atlas}",
|
||||
},
|
||||
{
|
||||
file: "node_modules/@xterm/addon-webgl/lib/addon-webgl.js",
|
||||
loop: "for(let t=0;t<r.length;t++){const i=r[t];if((0,n.configEquals)(i.config,d))return i.ownedBy.push(e),i.atlas}",
|
||||
},
|
||||
];
|
||||
|
||||
let patched = 0;
|
||||
let already = 0;
|
||||
let missing = 0;
|
||||
|
||||
for (const { file, loop } of TARGETS) {
|
||||
const abs = path.resolve(process.cwd(), file);
|
||||
let src;
|
||||
try {
|
||||
src = fs.readFileSync(abs, "utf8");
|
||||
} catch {
|
||||
console.warn(`[patch-xterm-webgl-atlas] skip (not found): ${file}`);
|
||||
missing++;
|
||||
continue;
|
||||
}
|
||||
if (src.includes(MARKER)) {
|
||||
already++;
|
||||
continue;
|
||||
}
|
||||
if (!src.includes(loop)) {
|
||||
console.warn(
|
||||
`[patch-xterm-webgl-atlas] WARNING: atlas-sharing loop not found in ${file}. ` +
|
||||
"@xterm/addon-webgl likely changed — split-view WebGL may garble again (#1063). " +
|
||||
"Refresh the minified target strings in scripts/patch-xterm-webgl-atlas.cjs.",
|
||||
);
|
||||
missing++;
|
||||
continue;
|
||||
}
|
||||
fs.writeFileSync(abs, src.replace(loop, MARKER));
|
||||
patched++;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[patch-xterm-webgl-atlas] atlas isolation: patched=${patched} already=${already} missing=${missing}`,
|
||||
);
|
||||
@@ -44,7 +44,6 @@ export default defineConfig(() => {
|
||||
output: {
|
||||
manualChunks: {
|
||||
// Vendor chunks - rarely change, can be cached aggressively
|
||||
'vendor-react': ['react', 'react-dom'],
|
||||
'vendor-radix': [
|
||||
'@radix-ui/react-collapsible',
|
||||
'@radix-ui/react-context-menu',
|
||||
|
||||
Reference in New Issue
Block a user