Compare commits

...

31 Commits

Author SHA1 Message Date
陈大猫
b9e88cd99d Simplify SFTP conflict dialog
Some checks failed
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
Simplify the SFTP file conflict dialog and reduce visual noise.
2026-06-12 18:12:28 +08:00
陈大猫
32afade4f9 Fix SFTP type-mismatch upload conflicts (#1449) 2026-06-12 17:46:32 +08:00
lengyuqu
66de2db912 Fix CodeBuddy Windows CLI discovery (#1448) 2026-06-12 17:27:52 +08:00
陈大猫
0a38da8867 Implement ZMODEM drag-and-drop file upload support in terminal
Adds SFTP fallback when rz is unavailable and cleans up drag-drop upload edge cases.
2026-06-12 16:58:50 +08:00
bincxz
5e739f8293 Merge remote PR branch updates 2026-06-12 16:58:20 +08:00
bincxz
6f64245d10 Add SFTP fallback for missing rz uploads 2026-06-12 16:56:48 +08:00
陈大猫
d48ca65a1e Slim release package (#1446) 2026-06-12 16:38:45 +08:00
bincxz
285fcd55a9 Merge main into terminal drag-drop zmodem 2026-06-12 16:37:01 +08:00
陈大猫
05b713ab18 [codex] Add configurable middle-click terminal behavior (#1443)
* Add configurable middle-click terminal behavior

* Fix middle-click terminal behavior edge cases
2026-06-12 16:28:09 +08:00
shideqin
293b15f67a Merge branch 'main' into feature/terminal-drag-drop-zmodem
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 16:14:04 +08:00
shideqin
83aec35f2f Merge main into feature/terminal-drag-drop-zmodem
Resolve conflicts in terminal backend, Terminal, and TerminalView to keep ZMODEM drag-drop alongside serial YMODEM receive and terminal selection AI settings from main.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 16:11:45 +08:00
陈大猫
910ef72205 [codex] Fix known host fingerprint coverage (#1442)
* Fix known host fingerprint coverage

* Tighten SFTP host key verification
2026-06-12 16:09:29 +08:00
陈大猫
550a37b379 [codex] Add serial YMODEM receive (#1438)
* Add serial YMODEM receive

* Address YMODEM receive review feedback
2026-06-12 15:47:24 +08:00
陈大猫
2b396c14e3 [codex] Fix CentOS 7 process listing (#1440)
* Fix CentOS 7 process listing

* Tighten CentOS 7 process listing regression test
2026-06-12 14:43:10 +08:00
陈大猫
36724a3abd Add SFTP tab duplication menu
Fixes #1423
2026-06-12 14:40:22 +08:00
陈大猫
4459aa4ef3 Add terminal zoom disable setting
Adds a Shortcuts setting to disable terminal zoom shortcuts.
2026-06-12 14:33:17 +08:00
陈大猫
64a6986d01 Sync terminal selection AI preference (#1441)
Syncs the terminal selection Add to Conversation preference with AI settings so cloud sync, local backups, restores, and settings-only auto-sync detect the value.

Follow-up for #1436.
2026-06-12 14:28:33 +08:00
陈大猫
a301ecb2ca Add terminal selection AI toggle (#1436)
Adds a Settings > AI switch to hide the automatic Add to Conversation button on terminal selection while keeping the context menu action available.

Closes #1397
2026-06-12 14:16:09 +08:00
shideqin
f16429e30f Implement ZMODEM drag-and-drop file upload support in terminal
- Remove outdated SFTP upload message and replace it with ZMODEM-specific messages in English, Russian, and Chinese locales.
- Add a new function to handle ZMODEM drag-and-drop uploads in the terminal backend.
- Update terminal components to support ZMODEM drag-and-drop functionality.
- Enhance error handling for file uploads and provide user feedback for no files to upload.
- Introduce tests to verify ZMODEM upload behavior and fallback to SFTP for network devices.
2026-06-12 14:12:34 +08:00
陈大猫
46b9bf6ccb [codex] Hide managed startup commands from history
Hide Netcatty-managed Docker and tmux terminal launch commands from command history.

Validated locally with lint, full tests, and build. Multi-agent review completed with no remaining issues.
2026-06-12 14:00:16 +08:00
陈大猫
17c8f11194 Fix macOS package ad-hoc signing (#1433)
Some checks failed
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
2026-06-12 12:36:46 +08:00
陈大猫
4d1a7ea55a Prevent app reload shortcut from closing sessions (#1432) 2026-06-12 12:10:59 +08:00
陈大猫
babe06a944 Fix local debug launch
Fix local debug launch
2026-06-12 11:54:26 +08:00
陈大猫
9e31d53bdd Slim release package
Slim release package
2026-06-12 11:48:59 +08:00
lengyuqu
ea24841939 Fix Windows AI model selection
Some checks failed
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
Fix the Windows issue where AI models could not be selected.
2026-06-12 01:42:38 +08:00
陈大猫
bf9f557e42 Fix popup timestamps and sidebar return animation
Hide timestamp controls in popup shell windows and keep the terminal host sidebar width while root pages are shown so returning to terminal tabs does not replay the open animation.
2026-06-12 01:35:50 +08:00
陈大猫
106e748a9b Fix terminal tab theme sync (#1421) 2026-06-12 01:21:28 +08:00
陈大猫
94fff62f9b [codex] Virtualize process manager list
Render only visible process rows while preserving sorting, search, status pills, and row actions.
2026-06-12 00:57:05 +08:00
陈大猫
324253f23a [codex] Optimize terminal side panel performance
Optimize terminal side panel performance, keep process status labels visible, and improve heavy panel loading behavior.
2026-06-12 00:47:17 +08:00
陈大猫
e9a2e44a91 Improve terminal timestamp gutter (#1417) 2026-06-12 00:45:08 +08:00
陈大猫
7b4f046001 [codex] Optimize AI settings agent performance (#1416)
* Optimize AI settings agent performance

* Address AI settings review feedback

* Retry interrupted agent discovery

* Harden agent CLI probe timeout cleanup

* Avoid duplicate agent discovery retries

* Ensure timed-out CLI probes are killed
2026-06-11 22:38:08 +08:00
177 changed files with 9352 additions and 1821 deletions

View File

@@ -28,6 +28,7 @@ import { resolveHostAuth } from './domain/sshAuth';
import { isEncryptedCredentialPlaceholder } from './domain/credentials';
import {
mergeTerminalHostUpdate,
type TerminalHostUpdate,
} from './domain/terminalAppearance';
import { selectConnectionLogForTerminalDataCapture } from './domain/connectionLog';
import { collectSessionIds } from './domain/workspace';
@@ -193,7 +194,6 @@ function App({ settings }: { settings: SettingsState }) {
const keysRef = useRef(keys);
keysRef.current = keys;
const knownHostsRef = useRef(knownHosts);
knownHostsRef.current = knownHosts;
// Bridge the gap while useVaultState hydrates: its async init awaits
// hosts/keys/identities/proxyProfiles decryption before reading knownHosts,
// so the state is briefly [] at boot even when localStorage has entries.
@@ -204,6 +204,7 @@ function App({ settings }: { settings: SettingsState }) {
() => getEffectiveKnownHosts(knownHosts) ?? [],
[knownHosts],
);
knownHostsRef.current = effectiveKnownHosts;
const {
sessions,
@@ -874,7 +875,7 @@ function App({ settings }: { settings: SettingsState }) {
}
}, [updateSessionStatus, updateHostLastConnected]);
const handleUpdateHostFromTerminal = useCallback((host: Host) => {
const handleUpdateHostFromTerminal = useCallback((host: TerminalHostUpdate) => {
updateHosts(hosts.map((h) => (
h.id === host.id ? mergeTerminalHostUpdate(h, host) : h
)));

View File

@@ -214,9 +214,11 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
hosts={hosts}
keys={keys}
identities={identities}
knownHosts={effectiveKnownHosts}
proxyProfiles={proxyProfiles}
groupConfigs={groupConfigs}
updateHosts={updateHosts}
onAddKnownHost={handleAddKnownHost}
sftpDefaultViewMode={sftpDefaultViewMode}
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
sftpAutoSync={sftpAutoSync}
@@ -250,6 +252,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
terminalFontFamilyId={terminalFontFamilyId}
fontSize={terminalFontSize}
hotkeyScheme={hotkeyScheme}
disableTerminalFontZoom={settings.disableTerminalFontZoom}
keyBindings={keyBindings}
onHotkeyAction={handleHotkeyAction}
onUpdateTerminalThemeId={setTerminalThemeId}

View File

@@ -3,9 +3,11 @@ import type { Messages } from '../types';
export const enAiMessages: Messages = {
// AI Settings
'ai.agentSettings': 'Agent Settings',
'ai.chat.preparing': 'Preparing…',
'ai.title': 'AI',
'ai.description': 'Configure AI providers, agents, and safety settings',
'ai.providers': 'Providers',
'ai.agents': 'Agents',
'ai.providers.empty': 'No providers configured. Add a provider to get started.',
'ai.providers.add': 'Add Provider',
'ai.providers.active': 'Active',
@@ -265,6 +267,11 @@ export const enAiMessages: Messages = {
'ai.chat.slashNoResults': 'No matching commands',
'ai.chat.slashEmptyHint': 'Add prompts in Settings → AI → Quick Messages.',
// AI Chat Shortcuts
'ai.chatShortcuts.title': 'Chat Shortcuts',
'ai.chatShortcuts.selectionAction': 'Show Add to Conversation when selecting terminal text',
'ai.chatShortcuts.selectionAction.description': 'Show a small AI button next to selected terminal text.',
// AI Error
'ai.codex.bridgeError': 'Codex main-process handlers are not loaded yet. Fully restart Netcatty, or restart the Electron dev process, then try again.',

View File

@@ -341,6 +341,11 @@ export const enCoreMessages: Messages = {
'settings.terminal.behavior.middleClickPaste': 'Middle-click paste',
'settings.terminal.behavior.middleClickPaste.desc':
'Paste clipboard content on middle-click',
'settings.terminal.behavior.middleClick': 'Middle-click behavior',
'settings.terminal.behavior.middleClick.desc': 'Action when middle-clicking in terminal',
'settings.terminal.behavior.middleClick.menu': 'Show menu',
'settings.terminal.behavior.middleClick.paste': 'Paste',
'settings.terminal.behavior.middleClick.disabled': 'Do nothing',
'settings.terminal.behavior.bracketedPaste': 'Bracketed paste mode',
'settings.terminal.behavior.bracketedPaste.desc':
'Wrap pasted text with escape sequences so the shell can distinguish paste from typed input. Disable if you see ^[[200~ artifacts.',
@@ -476,6 +481,8 @@ export const enCoreMessages: Messages = {
'settings.shortcuts.scheme.disabled': 'Disabled',
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
'settings.shortcuts.disableTerminalFontZoom.label': 'Disable terminal zoom',
'settings.shortcuts.disableTerminalFontZoom.desc': 'Turn off terminal font zoom shortcuts, including Cmd/Ctrl + mouse wheel.',
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': 'Number keys skip pinned tabs',
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': 'When enabled, Cmd/Ctrl+[1...9] switches only work tabs (terminals, workspaces, editors), not the pinned Vault or SFTP tabs.',
'settings.shortcuts.section.custom': 'Custom Shortcuts',

View File

@@ -44,6 +44,8 @@ export const enSystemManagerMessages: Messages = {
'systemManager.processes.elapsed': 'Elapsed',
'systemManager.processes.stat': 'State',
'systemManager.processes.meta': '{{count}} process(es)',
'systemManager.processes.loading': 'Loading processes…',
'systemManager.processes.loadingMore': 'Loading more processes…',
'systemManager.processes.state.running': 'Running',
'systemManager.processes.state.sleeping': 'Sleeping',
'systemManager.processes.state.stopped': 'Stopped',
@@ -55,6 +57,10 @@ export const enSystemManagerMessages: Messages = {
'systemManager.processes.sort.user': 'User',
'systemManager.common.dismiss': 'Dismiss',
'systemManager.common.checkingAvailability': 'Checking availability…',
'systemManager.common.loading': 'Loading…',
'systemManager.common.loadingDetails': 'Loading details…',
'systemManager.common.loadingStats': 'Loading stats…',
'systemManager.tmux.new': 'New',
'systemManager.tmux.search': 'Search sessions…',

View File

@@ -6,6 +6,7 @@ export const enTerminalMessages: Messages = {
'terminal.toolbar.openSftp': 'Open SFTP',
'terminal.toolbar.availableAfterConnect': 'Available after connect',
'terminal.toolbar.sendYmodem': 'Send with YMODEM',
'terminal.toolbar.receiveYmodem': 'Receive with YMODEM',
'terminal.toolbar.sftp': 'SFTP',
'terminal.toolbar.more': 'More actions',
'terminal.toolbar.scripts': 'Scripts',
@@ -30,6 +31,8 @@ export const enTerminalMessages: Messages = {
'terminal.toolbar.terminalSettings': 'Terminal settings',
'terminal.toolbar.searchTerminal': 'Search terminal',
'terminal.toolbar.search': 'Search',
'terminal.toolbar.timestampsEnable': 'Show timestamps',
'terminal.toolbar.timestampsDisable': 'Hide timestamps',
'terminal.toolbar.broadcast': 'Broadcast',
'terminal.toolbar.broadcastEnable': 'Enable Broadcast Mode',
'terminal.toolbar.broadcastDisable': 'Disable Broadcast Mode',
@@ -86,7 +89,9 @@ export const enTerminalMessages: Messages = {
'terminal.dragDrop.localTitle': 'Drop to Insert Paths',
'terminal.dragDrop.localMessage': 'File paths will be inserted into the terminal',
'terminal.dragDrop.remoteTitle': 'Drop to Upload Files',
'terminal.dragDrop.remoteMessage': 'Files will be uploaded via SFTP',
'terminal.dragDrop.remoteZmodemMessage': 'Files will be uploaded via ZMODEM (PTY)',
'terminal.dragDrop.remoteSftpMessage': 'Files will be uploaded via SFTP',
'terminal.dragDrop.noFiles': 'No files to upload',
'terminal.dragDrop.notConnected': 'Cannot drop files - terminal is not connected',
'terminal.dragDrop.errorTitle': 'Drop Error',
'terminal.dragDrop.errorMessage': 'Failed to process dropped files',
@@ -101,6 +106,7 @@ export const enTerminalMessages: Messages = {
'terminal.menu.selectAll': 'Select All',
'terminal.menu.reconnect': 'Reconnect',
'terminal.menu.sendYmodem': 'Send with YMODEM',
'terminal.menu.receiveYmodem': 'Receive with YMODEM',
'terminal.menu.splitHorizontal': 'Split Horizontal',
'terminal.menu.splitVertical': 'Split Vertical',
'terminal.menu.clearBuffer': 'Clear Buffer',
@@ -110,6 +116,12 @@ export const enTerminalMessages: Messages = {
'terminal.ymodem.started': 'YMODEM sending {fileName}',
'terminal.ymodem.complete': 'YMODEM sent {fileName}',
'terminal.ymodem.failed': 'YMODEM send failed',
'terminal.ymodem.selectReceiveDirectory': 'Select folder to save received files',
'terminal.ymodem.receiveStarted': 'YMODEM receiving...',
'terminal.ymodem.receiveComplete': 'YMODEM received {fileName}',
'terminal.ymodem.receiveCompleteMultiple': 'YMODEM received {count} files',
'terminal.ymodem.receiveEmpty': 'No YMODEM files received',
'terminal.ymodem.receiveFailed': 'YMODEM receive failed',
'terminal.ymodem.unavailable': 'YMODEM is unavailable',
'terminal.selection.addToAI': 'Add to Conversation',
'terminal.selection.addToAIDesc': 'Attach selected terminal output to the AI draft',

View File

@@ -258,6 +258,8 @@ export const enVaultMessages: Messages = {
'sftp.tabs.addTab': 'Add new tab',
'sftp.tabs.closeTab': 'Close tab',
'sftp.tabs.newTab': 'New Tab',
'sftp.tabs.copyDefaultPath': 'Copy tab (default path)',
'sftp.tabs.copyCurrentPath': 'Copy and go to current path',
'sftp.conflict.title': 'File Conflict',
'sftp.conflict.desc': 'A file with the same name already exists at the destination',
'sftp.conflict.alreadyExistsSuffix': 'already exists',
@@ -530,8 +532,8 @@ export const enVaultMessages: Messages = {
'hostDetails.deviceType.warning': 'AI agent commands will be sent directly without exit code tracking. Only enable for devices that do not run a standard shell.',
'hostDetails.section.sshAlgorithms': 'SSH Algorithms',
'hostDetails.section.terminalBehavior': 'Terminal Behavior',
'hostDetails.lineTimestamps': 'Prefix output with timestamps',
'hostDetails.lineTimestamps.desc': 'Add local time before visible output lines for this host. Disable it for prompts that render incorrectly when output is prefixed.',
'hostDetails.lineTimestamps': 'Show output timestamps',
'hostDetails.lineTimestamps.desc': 'Show local time beside visible output lines for this host without changing terminal text.',
'hostDetails.legacyAlgorithms': 'Allow Legacy Algorithms',
'hostDetails.legacyAlgorithms.desc': 'Enable deprecated SSH algorithms (diffie-hellman-group1, ssh-dss, 3des-cbc, etc.) for connecting to older network equipment.',
'hostDetails.legacyAlgorithms.warning': 'These algorithms have known security weaknesses. Only enable for legacy devices that do not support modern cryptography.',

View File

@@ -3,9 +3,11 @@ import type { Messages } from '../types';
export const ruAiMessages: Messages = {
// AI Settings
'ai.agentSettings': 'Настройки агента',
'ai.chat.preparing': 'Подготовка…',
'ai.title': 'AI',
'ai.description': 'Настройка AI-провайдеров, агентов и параметров безопасности',
'ai.providers': 'Провайдеры',
'ai.agents': 'Агенты',
'ai.providers.empty': 'Провайдеры не настроены. Добавьте провайдера, чтобы начать.',
'ai.providers.add': 'Добавить провайдера',
'ai.providers.active': 'Активен',
@@ -265,6 +267,11 @@ export const ruAiMessages: Messages = {
'ai.chat.slashNoResults': 'Нет подходящих команд',
'ai.chat.slashEmptyHint': 'Добавьте подсказки в Настройки → AI → Быстрые сообщения.',
// AI Chat Shortcuts
'ai.chatShortcuts.title': 'Быстрые действия чата',
'ai.chatShortcuts.selectionAction': 'Показывать «Добавить в чат» при выделении в терминале',
'ai.chatShortcuts.selectionAction.description': 'Показывать небольшую кнопку AI рядом с выделенным текстом терминала.',
// AI Error
'ai.codex.bridgeError': 'Обработчики главного процесса Codex ещё не загружены. Полностью перезапустите Netcatty или dev-процесс Electron и попробуйте снова.',

View File

@@ -341,6 +341,11 @@ export const ruCoreMessages: Messages = {
'settings.terminal.behavior.middleClickPaste': 'Вставка средней кнопкой мыши',
'settings.terminal.behavior.middleClickPaste.desc':
'Вставлять содержимое буфера обмена по щелчку средней кнопкой',
'settings.terminal.behavior.middleClick': 'Поведение средней кнопки мыши',
'settings.terminal.behavior.middleClick.desc': 'Действие при щелчке средней кнопкой в терминале',
'settings.terminal.behavior.middleClick.menu': 'Показать меню',
'settings.terminal.behavior.middleClick.paste': 'Вставить',
'settings.terminal.behavior.middleClick.disabled': 'Ничего не делать',
'settings.terminal.behavior.bracketedPaste': 'Режим bracketed paste',
'settings.terminal.behavior.bracketedPaste.desc':
'Оборачивать вставляемый текст escape-последовательностями, чтобы оболочка отличала вставку от обычного ввода. Отключите, если видите артефакты вида ^[[200~.',
@@ -476,6 +481,8 @@ export const ruCoreMessages: Messages = {
'settings.shortcuts.scheme.disabled': 'Отключено',
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
'settings.shortcuts.disableTerminalFontZoom.label': 'Отключить масштаб терминала',
'settings.shortcuts.disableTerminalFontZoom.desc': 'Отключает быстрый масштаб текста в терминале, включая Cmd/Ctrl + колесо мыши.',
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': 'Цифры без закреплённых вкладок',
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': 'Если включено, Cmd/Ctrl+[1...9] переключает только рабочие вкладки (терминалы, рабочие области, редакторы), а не закреплённые Vault и SFTP.',
'settings.shortcuts.section.custom': 'Пользовательские сочетания',

View File

@@ -44,6 +44,8 @@ export const ruSystemManagerMessages: Messages = {
'systemManager.processes.elapsed': 'Время работы',
'systemManager.processes.stat': 'Состояние',
'systemManager.processes.meta': '{{count}} проц.',
'systemManager.processes.loading': 'Загрузка процессов…',
'systemManager.processes.loadingMore': 'Загрузка следующих процессов…',
'systemManager.processes.state.running': 'Активен',
'systemManager.processes.state.sleeping': 'Сон',
'systemManager.processes.state.stopped': 'Остановлен',
@@ -55,6 +57,10 @@ export const ruSystemManagerMessages: Messages = {
'systemManager.processes.sort.user': 'Пользователь',
'systemManager.common.dismiss': 'Закрыть',
'systemManager.common.checkingAvailability': 'Проверка доступности…',
'systemManager.common.loading': 'Загрузка…',
'systemManager.common.loadingDetails': 'Загрузка деталей…',
'systemManager.common.loadingStats': 'Загрузка статистики…',
'systemManager.tmux.new': 'Создать',
'systemManager.tmux.search': 'Поиск сессий…',

View File

@@ -27,6 +27,7 @@ export const ruTerminalMessages: Messages = {
'terminal.toolbar.openSftp': 'Открыть SFTP',
'terminal.toolbar.availableAfterConnect': 'Доступно после подключения',
'terminal.toolbar.sendYmodem': 'Отправить через YMODEM',
'terminal.toolbar.receiveYmodem': 'Получить через YMODEM',
'terminal.toolbar.sftp': 'SFTP',
'terminal.toolbar.more': 'Другие действия',
'terminal.toolbar.scripts': 'Скрипты',
@@ -51,6 +52,8 @@ export const ruTerminalMessages: Messages = {
'terminal.toolbar.terminalSettings': 'Настройки терминала',
'terminal.toolbar.searchTerminal': 'Поиск по терминалу',
'terminal.toolbar.search': 'Поиск',
'terminal.toolbar.timestampsEnable': 'Показать время',
'terminal.toolbar.timestampsDisable': 'Скрыть время',
'terminal.toolbar.broadcast': 'Трансляция',
'terminal.toolbar.broadcastEnable': 'Включить режим трансляции',
'terminal.toolbar.broadcastDisable': 'Отключить режим трансляции',
@@ -107,7 +110,9 @@ export const ruTerminalMessages: Messages = {
'terminal.dragDrop.localTitle': 'Перетащите для вставки путей',
'terminal.dragDrop.localMessage': 'Пути к файлам будут вставлены в терминал',
'terminal.dragDrop.remoteTitle': 'Перетащите для загрузки файлов',
'terminal.dragDrop.remoteMessage': 'Файлы будут загружены через SFTP',
'terminal.dragDrop.remoteZmodemMessage': 'Файлы будут загружены через ZMODEM (PTY)',
'terminal.dragDrop.remoteSftpMessage': 'Файлы будут загружены через SFTP',
'terminal.dragDrop.noFiles': 'Нет файлов для загрузки',
'terminal.dragDrop.notConnected': 'Нельзя перетащить файлы — терминал не подключён',
'terminal.dragDrop.errorTitle': 'Ошибка перетаскивания',
'terminal.dragDrop.errorMessage': 'Не удалось обработать перетащенные файлы',
@@ -122,6 +127,7 @@ export const ruTerminalMessages: Messages = {
'terminal.menu.selectAll': 'Выбрать всё',
'terminal.menu.reconnect': 'Переподключиться',
'terminal.menu.sendYmodem': 'Отправить через YMODEM',
'terminal.menu.receiveYmodem': 'Получить через YMODEM',
'terminal.menu.splitHorizontal': 'Разделить по горизонтали',
'terminal.menu.splitVertical': 'Разделить по вертикали',
'terminal.menu.clearBuffer': 'Очистить буфер',
@@ -131,6 +137,12 @@ export const ruTerminalMessages: Messages = {
'terminal.ymodem.started': 'YMODEM отправляет {fileName}',
'terminal.ymodem.complete': 'YMODEM отправил {fileName}',
'terminal.ymodem.failed': 'Не удалось отправить через YMODEM',
'terminal.ymodem.selectReceiveDirectory': 'Выберите папку для полученных файлов',
'terminal.ymodem.receiveStarted': 'YMODEM получает...',
'terminal.ymodem.receiveComplete': 'YMODEM получил {fileName}',
'terminal.ymodem.receiveCompleteMultiple': 'YMODEM получил файлов: {count}',
'terminal.ymodem.receiveEmpty': 'Файлы YMODEM не получены',
'terminal.ymodem.receiveFailed': 'Не удалось получить через YMODEM',
'terminal.ymodem.unavailable': 'YMODEM недоступен',
'terminal.selection.addToAI': 'Добавить в чат',
'terminal.selection.addToAIDesc': 'Прикрепить выбранный вывод терминала к черновику AI',

View File

@@ -293,6 +293,8 @@ export const ruVaultMessages: Messages = {
'sftp.tabs.addTab': 'Добавить новую вкладку',
'sftp.tabs.closeTab': 'Закрыть вкладку',
'sftp.tabs.newTab': 'Новая вкладка',
'sftp.tabs.copyDefaultPath': 'Копировать вкладку (путь по умолчанию)',
'sftp.tabs.copyCurrentPath': 'Копировать и перейти к текущему пути',
'sftp.conflict.title': 'Конфликт файлов',
'sftp.conflict.desc': 'В месте назначения уже существует файл с таким именем',
'sftp.conflict.alreadyExistsSuffix': 'уже существует',
@@ -562,8 +564,8 @@ export const ruVaultMessages: Messages = {
'hostDetails.deviceType.warning': 'Команды AI-агента будут отправляться напрямую без отслеживания кода выхода. Включайте только для устройств, на которых нет стандартной оболочки.',
'hostDetails.section.sshAlgorithms': 'SSH-алгоритмы',
'hostDetails.section.terminalBehavior': 'Поведение терминала',
'hostDetails.lineTimestamps': 'Добавлять время к выводу',
'hostDetails.lineTimestamps.desc': 'Добавлять локальное время перед видимыми строками вывода только для этого хоста. Отключите, если из-за этого некорректно отображается приглашение.',
'hostDetails.lineTimestamps': 'Показывать время вывода',
'hostDetails.lineTimestamps.desc': 'Показывать локальное время рядом с видимыми строками вывода для этого хоста, не изменяя текст терминала.',
'hostDetails.legacyAlgorithms': 'Разрешить устаревшие алгоритмы',
'hostDetails.legacyAlgorithms.desc': 'Включить устаревшие SSH-алгоритмы (diffie-hellman-group1, ssh-dss, 3des-cbc и т. д.) для подключения к старому сетевому оборудованию.',
'hostDetails.legacyAlgorithms.warning': 'У этих алгоритмов есть известные слабые места безопасности. Включайте только для устаревших устройств, которые не поддерживают современную криптографию.',

View File

@@ -3,9 +3,11 @@ import type { Messages } from '../types';
export const zhCNAiMessages: Messages = {
// AI Settings
'ai.agentSettings': 'Agent 设置',
'ai.chat.preparing': '准备中…',
'ai.title': 'AI',
'ai.description': '配置 AI 提供商、Agent 和安全设置',
'ai.providers': '提供商',
'ai.agents': 'Agent',
'ai.providers.empty': '尚未配置提供商。添加一个提供商以开始使用。',
'ai.providers.add': '添加提供商',
'ai.providers.active': '活跃',
@@ -265,6 +267,11 @@ export const zhCNAiMessages: Messages = {
'ai.chat.slashNoResults': '没有匹配的命令',
'ai.chat.slashEmptyHint': '可在 设置 → AI → 快捷消息 中添加常用提示词。',
// AI 聊天快捷入口
'ai.chatShortcuts.title': '聊天快捷入口',
'ai.chatShortcuts.selectionAction': '选中终端内容时显示“添加到对话”',
'ai.chatShortcuts.selectionAction.description': '在终端里选中文本后显示 AI 快捷按钮。',
// AI Error
'ai.codex.bridgeError': 'Codex 主进程处理器尚未加载。请完全重启 Netcatty 或重启 Electron 开发进程,然后重试。',

View File

@@ -44,6 +44,8 @@ export const zhCnSystemManagerMessages: Messages = {
'systemManager.processes.elapsed': '运行时长',
'systemManager.processes.stat': '状态',
'systemManager.processes.meta': '{{count}} 个进程',
'systemManager.processes.loading': '正在加载进程…',
'systemManager.processes.loadingMore': '正在显示更多进程…',
'systemManager.processes.state.running': '运行中',
'systemManager.processes.state.sleeping': '睡眠',
'systemManager.processes.state.stopped': '已暂停',
@@ -55,6 +57,10 @@ export const zhCnSystemManagerMessages: Messages = {
'systemManager.processes.sort.user': '用户',
'systemManager.common.dismiss': '关闭',
'systemManager.common.checkingAvailability': '正在检查可用状态…',
'systemManager.common.loading': '正在加载…',
'systemManager.common.loadingDetails': '正在加载详情…',
'systemManager.common.loadingStats': '正在加载性能数据…',
'systemManager.tmux.new': '新建',
'systemManager.tmux.search': '搜索会话…',

View File

@@ -2,6 +2,8 @@ import type { Messages } from '../types';
export const zhCNTerminalMessages: Messages = {
'terminal.sudoHint.pressEnter': '按 Enter 粘贴 sudo 密码',
'terminal.toolbar.timestampsEnable': '显示时间戳',
'terminal.toolbar.timestampsDisable': '隐藏时间戳',
'terminal.connection.protocol.et': 'EternalTerminal',
'terminal.et.proxyUnsupported': 'EternalTerminal 目前不支持 Netcatty 的代理设置。请改用 SSH或移除该主机的代理。',
'terminal.et.multiJumpUnsupported': 'EternalTerminal 目前在 Netcatty 中最多支持一个跳板机。',
@@ -210,6 +212,11 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.behavior.copyOnSelect.desc': '自动复制选中的文本。在 tmux/vim 鼠标模式下macOS 按住 OptionWindows/Linux 按住 Shift 拖选即可选中文本',
'settings.terminal.behavior.middleClickPaste': '中键粘贴',
'settings.terminal.behavior.middleClickPaste.desc': '中键点击时粘贴剪贴板内容',
'settings.terminal.behavior.middleClick': '中键行为',
'settings.terminal.behavior.middleClick.desc': '在终端中点击鼠标中键时执行的操作',
'settings.terminal.behavior.middleClick.menu': '显示菜单',
'settings.terminal.behavior.middleClick.paste': '粘贴',
'settings.terminal.behavior.middleClick.disabled': '无动作',
'settings.terminal.behavior.bracketedPaste': '括号粘贴模式',
'settings.terminal.behavior.bracketedPaste.desc':
'粘贴文本时使用转义序列包裹,以便终端区分粘贴和键入。如果出现 ^[[200~ 字样请关闭此选项。',
@@ -334,6 +341,8 @@ export const zhCNTerminalMessages: Messages = {
'settings.shortcuts.scheme.disabled': '禁用',
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
'settings.shortcuts.disableTerminalFontZoom.label': '禁用终端缩放',
'settings.shortcuts.disableTerminalFontZoom.desc': '关闭终端文字缩放快捷操作,包括 Cmd/Ctrl 加滚轮。',
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': '数字键跳过固定标签',
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': '开启后Cmd/Ctrl+[1...9] 仅在终端、工作区、编辑器等可关闭标签页之间切换,不包括固定的 Vault 和 SFTP 标签页。',
'settings.shortcuts.section.custom': '自定义快捷键',

View File

@@ -113,8 +113,8 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.deviceType.warning': 'AI 代理命令将直接发送,无法获取退出码。仅建议在设备不运行标准 Shell 时启用。',
'hostDetails.section.sshAlgorithms': 'SSH 算法',
'hostDetails.section.terminalBehavior': '终端行为',
'hostDetails.lineTimestamps': '输出时间',
'hostDetails.lineTimestamps.desc': '仅为这个主机的终端输出行添加本地时间。如果提示符因此渲染异常,请关闭。',
'hostDetails.lineTimestamps': '显示输出时间',
'hostDetails.lineTimestamps.desc': '在终端输出行旁边显示本地时间,不改变终端文本内容。',
'hostDetails.legacyAlgorithms': '允许旧版算法',
'hostDetails.legacyAlgorithms.desc': '启用已弃用的 SSH 算法diffie-hellman-group1、ssh-dss、3des-cbc 等)以连接老旧网络设备。',
'hostDetails.legacyAlgorithms.warning': '这些算法存在已知安全漏洞,仅建议在老旧设备不支持现代加密时启用。',
@@ -216,6 +216,7 @@ export const zhCNVaultMessages: Messages = {
'terminal.toolbar.openSftp': '打开 SFTP',
'terminal.toolbar.availableAfterConnect': '连接后可用',
'terminal.toolbar.sendYmodem': 'YMODEM 发送',
'terminal.toolbar.receiveYmodem': 'YMODEM 接收',
'terminal.toolbar.sftp': 'SFTP',
'terminal.toolbar.more': '更多操作',
'terminal.toolbar.scripts': '脚本',
@@ -281,7 +282,9 @@ export const zhCNVaultMessages: Messages = {
'terminal.dragDrop.localTitle': '拖放以插入路径',
'terminal.dragDrop.localMessage': '文件路径将被插入到终端',
'terminal.dragDrop.remoteTitle': '拖放以上传文件',
'terminal.dragDrop.remoteMessage': '文件将通过 SFTP 上传',
'terminal.dragDrop.remoteZmodemMessage': '文件将通过 ZMODEMPTY上传',
'terminal.dragDrop.remoteSftpMessage': '文件将通过 SFTP 上传',
'terminal.dragDrop.noFiles': '没有可上传的文件',
'terminal.dragDrop.notConnected': '无法拖放文件 - 终端未连接',
'terminal.dragDrop.errorTitle': '拖放错误',
'terminal.dragDrop.errorMessage': '处理拖放文件失败',
@@ -296,6 +299,7 @@ export const zhCNVaultMessages: Messages = {
'terminal.menu.selectAll': '全选',
'terminal.menu.reconnect': '重新连接',
'terminal.menu.sendYmodem': 'YMODEM 发送',
'terminal.menu.receiveYmodem': 'YMODEM 接收',
'terminal.menu.splitHorizontal': '水平分屏',
'terminal.menu.splitVertical': '垂直分屏',
'terminal.menu.clearBuffer': '清空缓冲区',
@@ -305,6 +309,12 @@ export const zhCNVaultMessages: Messages = {
'terminal.ymodem.started': '正在通过 YMODEM 发送 {fileName}',
'terminal.ymodem.complete': 'YMODEM 已发送 {fileName}',
'terminal.ymodem.failed': 'YMODEM 发送失败',
'terminal.ymodem.selectReceiveDirectory': '选择接收文件保存位置',
'terminal.ymodem.receiveStarted': '正在通过 YMODEM 接收...',
'terminal.ymodem.receiveComplete': 'YMODEM 已接收 {fileName}',
'terminal.ymodem.receiveCompleteMultiple': 'YMODEM 已接收 {count} 个文件',
'terminal.ymodem.receiveEmpty': '没有接收到 YMODEM 文件',
'terminal.ymodem.receiveFailed': 'YMODEM 接收失败',
'terminal.ymodem.unavailable': 'YMODEM 当前不可用',
'terminal.selection.addToAI': '添加到对话',
'terminal.selection.addToAIDesc': '将选中的终端输出作为附件加入 AI 草稿',
@@ -676,6 +686,8 @@ export const zhCNVaultMessages: Messages = {
'sftp.tabs.addTab': '新建标签页',
'sftp.tabs.closeTab': '关闭标签页',
'sftp.tabs.newTab': '新标签页',
'sftp.tabs.copyDefaultPath': '复制标签页(默认路径)',
'sftp.tabs.copyCurrentPath': '复制并跳转到当前路径',
'sftp.conflict.title': '文件冲突',
'sftp.conflict.desc': '目标位置已存在同名文件',
'sftp.conflict.alreadyExistsSuffix': '已存在',

View File

@@ -196,6 +196,21 @@ export function setLatestAIActiveSessionMapSnapshot(activeSessionIdMap: Record<s
latestAIActiveSessionMapSnapshot = activeSessionIdMap;
}
export function prewarmAIStateStorageSnapshots() {
try {
if (latestAISessionsSnapshot === null) {
latestAISessionsSnapshot =
localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? [];
}
if (latestAIActiveSessionMapSnapshot === null) {
latestAIActiveSessionMapSnapshot =
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {};
}
} catch (error) {
console.warn('[AIState] Failed to prewarm AI state storage snapshots:', error);
}
}
export function setLatestAIDraftsByScopeSnapshot(draftsByScope: DraftsByScope) {
latestAIDraftsByScopeSnapshot = draftsByScope;
}

View File

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

View File

@@ -65,6 +65,7 @@ export const DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT = false;
export const DEFAULT_SHOW_SFTP_TAB = true;
export const DEFAULT_SHOW_HOST_TREE_SIDEBAR = true;
export const DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS = false;
export const DEFAULT_DISABLE_TERMINAL_FONT_ZOOM = false;
// Editor defaults
export const DEFAULT_EDITOR_WORD_WRAP = false;

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { SftpConnection, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
import { KnownHost, SftpConnection, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
export interface SftpPane {
id: string;
@@ -15,6 +15,22 @@ export interface SftpPane {
transferMutationToken: number;
}
export interface SftpHostKeyInfo {
hostname: string;
port: number;
keyType: string;
fingerprint: string;
publicKey?: string;
status?: "unknown" | "changed";
knownHostId?: string;
knownFingerprint?: string;
}
export interface SftpHostKeyVerificationState {
hostKeyInfo: SftpHostKeyInfo;
progressLogs: string[];
}
// Multi-tab state for left and right sides
export interface SftpSideTabs {
tabs: SftpPane[];
@@ -70,4 +86,6 @@ export interface SftpStateOptions {
* is honored for SFTP browsing too (not just the terminal session).
*/
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
knownHosts?: KnownHost[];
onAddKnownHost?: (knownHost: KnownHost) => void;
}

View File

@@ -1,16 +1,19 @@
import React, { useCallback, useEffect, useRef } from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import type { MutableRefObject } from "react";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import type { Host, Identity, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";
import type { SftpPane } from "./types";
import type { Host, Identity, KnownHost, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";
import type { SftpHostKeyInfo, SftpHostKeyVerificationState, SftpPane } from "./types";
import { useSftpDirectoryListing } from "./useSftpDirectoryListing";
import { useSftpHostCredentials } from "./useSftpHostCredentials";
import { buildCacheKey, getSharedRemoteHostCache, setSharedRemoteHostCache } from "./sharedRemoteHostCache";
import { resolveRemoteSftpStartState } from "./sftpConnectStartPath";
interface UseSftpConnectionsParams {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
knownHosts?: KnownHost[];
onAddKnownHost?: (knownHost: KnownHost) => void;
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
leftTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
rightTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
@@ -34,17 +37,61 @@ interface UseSftpConnectionsParams {
autoConnectLocalOnMount?: boolean;
}
export interface SftpConnectOptions {
forceNewTab?: boolean;
ignoreSharedCache?: boolean;
initialPath?: string;
onTabCreated?: (tabId: string) => void;
sourceSessionId?: string;
}
interface UseSftpConnectionsResult {
connect: (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean; onTabCreated?: (tabId: string) => void }) => Promise<void>;
connect: (side: "left" | "right", host: Host | "local", options?: SftpConnectOptions) => Promise<void>;
disconnect: (side: "left" | "right") => Promise<void>;
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
hostKeyVerification: SftpHostKeyVerificationState | null;
rejectHostKeyVerification: () => void;
acceptHostKeyVerification: () => void;
acceptAndSaveHostKeyVerification: () => void;
}
type HostKeyVerificationRequest = SftpHostKeyInfo & {
requestId: string;
sessionId?: string;
};
const toSftpHostKeyInfo = (request: HostKeyVerificationRequest): SftpHostKeyInfo => ({
hostname: request.hostname,
port: request.port || 22,
keyType: request.keyType,
fingerprint: request.fingerprint,
publicKey: request.publicKey,
status: request.status,
knownHostId: request.knownHostId,
knownFingerprint: request.knownFingerprint,
});
const createKnownHostFromSftpHostKeyInfo = (
hostKeyInfo: SftpHostKeyInfo,
now = Date.now(),
idSuffix = Math.random().toString(36).slice(2, 11),
): KnownHost => ({
id: hostKeyInfo.knownHostId || `kh-${now}-${idSuffix}`,
hostname: hostKeyInfo.hostname,
port: hostKeyInfo.port || 22,
keyType: hostKeyInfo.keyType,
publicKey: hostKeyInfo.publicKey || `SHA256:${hostKeyInfo.fingerprint}`,
fingerprint: hostKeyInfo.fingerprint,
discoveredAt: now,
});
export const useSftpConnections = ({
hosts,
keys,
identities,
knownHosts,
onAddKnownHost,
terminalSettings,
leftTabsRef,
rightTabsRef,
@@ -67,11 +114,79 @@ export const useSftpConnections = ({
createEmptyPane,
autoConnectLocalOnMount = true,
}: UseSftpConnectionsParams): UseSftpConnectionsResult => {
const getHostCredentials = useSftpHostCredentials({ hosts, keys, identities, terminalSettings });
const getHostCredentials = useSftpHostCredentials({ hosts, keys, identities, knownHosts, terminalSettings });
const { listLocalFiles, listRemoteFiles } = useSftpDirectoryListing();
const [hostKeyVerification, setHostKeyVerification] = useState<SftpHostKeyVerificationState | null>(null);
const hostKeyVerificationRef = useRef<(SftpHostKeyVerificationState & { requestId: string; sessionId: string }) | null>(null);
const activeHostKeySessionsRef = useRef<Map<string, { side: "left" | "right"; tabId: string }>>(new Map());
const setPendingHostKeyVerification = useCallback((
next: (SftpHostKeyVerificationState & { requestId: string; sessionId: string }) | null,
) => {
hostKeyVerificationRef.current = next;
setHostKeyVerification(next ? {
hostKeyInfo: next.hostKeyInfo,
progressLogs: next.progressLogs,
} : null);
}, []);
useEffect(() => {
const dispose = netcattyBridge.get()?.onHostKeyVerification?.((request: HostKeyVerificationRequest) => {
const sessionId = request.sessionId;
if (!sessionId) return;
const activeSession = activeHostKeySessionsRef.current.get(sessionId);
if (!activeSession) return;
const hostKeyInfo = toSftpHostKeyInfo(request);
const logLine = request.status === "changed"
? `Host key changed for ${request.hostname}. Waiting for confirmation...`
: `Host key verification required for ${request.hostname}.`;
updateTab(activeSession.side, activeSession.tabId, (prev) => ({
...prev,
connectionLogs: [...prev.connectionLogs, logLine],
}));
setPendingHostKeyVerification({
requestId: request.requestId,
sessionId,
hostKeyInfo,
progressLogs: [logLine],
});
});
return () => {
dispose?.();
};
}, [setPendingHostKeyVerification, updateTab]);
const respondToHostKeyVerification = useCallback((accept: boolean, addToKnownHosts = false) => {
const pending = hostKeyVerificationRef.current;
if (!pending) return;
if (accept && addToKnownHosts) {
onAddKnownHost?.(createKnownHostFromSftpHostKeyInfo(pending.hostKeyInfo));
}
void netcattyBridge.get()?.respondHostKeyVerification?.(
pending.requestId,
accept,
addToKnownHosts,
);
setPendingHostKeyVerification(null);
}, [onAddKnownHost, setPendingHostKeyVerification]);
const rejectHostKeyVerification = useCallback(() => {
respondToHostKeyVerification(false);
}, [respondToHostKeyVerification]);
const acceptHostKeyVerification = useCallback(() => {
respondToHostKeyVerification(true, false);
}, [respondToHostKeyVerification]);
const acceptAndSaveHostKeyVerification = useCallback(() => {
respondToHostKeyVerification(true, true);
}, [respondToHostKeyVerification]);
const connect = useCallback(
async (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean; onTabCreated?: (tabId: string) => void; sourceSessionId?: string }) => {
async (side: "left" | "right", host: Host | "local", options?: SftpConnectOptions) => {
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
let activeTabId: string | null = null;
@@ -101,6 +216,33 @@ export const useSftpConnections = ({
navSeqRef.current[side] += 1;
const connectRequestId = navSeqRef.current[side];
const getTargetPane = () => {
const tabs = side === "left" ? leftTabsRef.current.tabs : rightTabsRef.current.tabs;
return tabs.find((tab) => tab.id === activeTabId) ?? null;
};
const isTargetConnectionCurrent = () => {
const pane = getTargetPane();
if (!pane) return false;
if (pane.connection?.id === connectionId) return true;
return !pane.connection && navSeqRef.current[side] === connectRequestId;
};
const isTargetConnectionAtPath = (path: string) => {
const connection = getTargetPane()?.connection;
if (!connection) return navSeqRef.current[side] === connectRequestId;
return connection?.id === connectionId && connection.currentPath === path;
};
const closeSftpSessionForConnection = async () => {
const sftpId = sftpSessionsRef.current.get(connectionId);
sftpSessionsRef.current.delete(connectionId);
connectionCacheKeyMapRef.current.delete(connectionId);
clearCacheForConnection(connectionId);
if (!sftpId) return;
try {
await netcattyBridge.get()?.closeSftp(sftpId);
} catch {
// Ignore errors when closing stale SFTP sessions
}
};
lastConnectedHostRef.current[side] = host;
// Store the cache key for this connection so pane actions can look it up
@@ -147,13 +289,15 @@ export const useSftpConnections = ({
homeDir = isWindows ? "C:\\Users\\damao" : "/Users/damao";
}
const startPath = options?.initialPath || homeDir;
const connection: SftpConnection = {
id: connectionId,
hostId: "local",
hostLabel: "Local",
isLocal: true,
status: "connected",
currentPath: homeDir,
currentPath: startPath,
homeDir,
};
@@ -168,9 +312,9 @@ export const useSftpConnections = ({
}));
try {
const files = await listLocalFiles(homeDir);
if (navSeqRef.current[side] !== connectRequestId) return;
dirCacheRef.current.set(makeCacheKey(connectionId, homeDir, filenameEncoding), {
const files = await listLocalFiles(startPath);
if (!isTargetConnectionAtPath(startPath)) return;
dirCacheRef.current.set(makeCacheKey(connectionId, startPath, filenameEncoding), {
files,
timestamp: Date.now(),
});
@@ -182,7 +326,7 @@ export const useSftpConnections = ({
reconnecting: false,
}));
} catch (err) {
if (navSeqRef.current[side] !== connectRequestId) return;
if (!isTargetConnectionAtPath(startPath)) return;
reconnectingRef.current[side] = false;
updateTab(side, activeTabId, (prev) => ({
...prev,
@@ -193,12 +337,15 @@ export const useSftpConnections = ({
}
} else {
const hostCacheKey = buildCacheKey(host.id, host.hostname, host.port, host.protocol, host.sftpSudo, host.username);
const sharedHostCacheCandidate = getSharedRemoteHostCache(hostCacheKey);
const sharedHostCache =
sharedHostCacheCandidate?.filenameEncoding === filenameEncoding
? sharedHostCacheCandidate
: null;
const cachedStartPath = sharedHostCache?.path ?? "/";
const sharedHostCacheCandidate = options?.ignoreSharedCache
? null
: getSharedRemoteHostCache(hostCacheKey);
const { initialPath, sharedHostCache, cachedStartPath } = resolveRemoteSftpStartState({
filenameEncoding,
ignoreSharedCache: options?.ignoreSharedCache,
initialPath: options?.initialPath,
sharedHostCacheCandidate,
});
const connection: SftpConnection = {
id: connectionId,
@@ -230,6 +377,7 @@ export const useSftpConnections = ({
// Subscribe to SFTP connection progress events for auth logging
const sftpSessionId = `sftp-${connectionId}`;
activeHostKeySessionsRef.current.set(sftpSessionId, { side, tabId: activeTabId });
let unsubSftpProgress: (() => void) | undefined;
const bridge = netcattyBridge.get();
if (bridge?.onSftpConnectionProgress) {
@@ -264,7 +412,7 @@ export const useSftpConnections = ({
logLine = `${label} - ${status}${detail ? `: ${detail}` : ''}`;
}
// Only update if this is still the active request (avoids stale logs leaking)
if (navSeqRef.current[side] !== connectRequestId) return;
if (!isTargetConnectionCurrent()) return;
updateTab(side, activeTabId, (prev) => ({
...prev,
connectionLogs: [...prev.connectionLogs, logLine],
@@ -295,7 +443,7 @@ export const useSftpConnections = ({
if (hasKey) {
try {
const keyFirstCredentials = {
sessionId: `sftp-${connectionId}`,
sessionId: sftpSessionId,
...credentials,
sourceSessionId: options?.sourceSessionId,
};
@@ -306,7 +454,7 @@ export const useSftpConnections = ({
} catch (err) {
if (hasPassword && isAuthError(err)) {
sftpId = await openSftp({
sessionId: `sftp-${connectionId}`,
sessionId: sftpSessionId,
...credentials,
sourceSessionId: options?.sourceSessionId,
privateKey: undefined,
@@ -322,7 +470,7 @@ export const useSftpConnections = ({
}
} else {
sftpId = await openSftp({
sessionId: `sftp-${connectionId}`,
sessionId: sftpSessionId,
...credentials,
sourceSessionId: options?.sourceSessionId,
});
@@ -331,6 +479,10 @@ export const useSftpConnections = ({
if (!sftpId) throw new Error("Failed to open SFTP session");
sftpSessionsRef.current.set(connectionId, sftpId);
if (!isTargetConnectionCurrent()) {
await closeSftpSessionForConnection();
return;
}
let startPath = sharedHostCache?.path ?? "/";
let homeDir = sharedHostCache?.homeDir ?? startPath;
@@ -395,6 +547,10 @@ export const useSftpConnections = ({
}
}
if (initialPath) {
startPath = initialPath;
}
const provisionalCacheKey = sharedHostCache
? makeCacheKey(connectionId, startPath, filenameEncoding)
: null;
@@ -438,7 +594,10 @@ export const useSftpConnections = ({
throw new Error("Cannot list any remote directory");
}
}
if (navSeqRef.current[side] !== connectRequestId) return;
if (!isTargetConnectionCurrent()) {
await closeSftpSessionForConnection();
return;
}
dirCacheRef.current.set(makeCacheKey(connectionId, startPath, filenameEncoding), {
files,
timestamp: Date.now(),
@@ -469,7 +628,10 @@ export const useSftpConnections = ({
connectionLogs: [], // Clear after successful connect to avoid replay during navigation
}));
} catch (err) {
if (navSeqRef.current[side] !== connectRequestId) return;
if (!isTargetConnectionCurrent()) {
await closeSftpSessionForConnection();
return;
}
reconnectingRef.current[side] = false;
updateTab(side, activeTabId, (prev) => ({
...prev,
@@ -489,6 +651,10 @@ export const useSftpConnections = ({
reconnecting: false,
}));
} finally {
activeHostKeySessionsRef.current.delete(sftpSessionId);
if (hostKeyVerificationRef.current?.sessionId === sftpSessionId) {
setPendingHostKeyVerification(null);
}
unsubSftpProgress?.();
}
}
@@ -503,6 +669,7 @@ export const useSftpConnections = ({
makeCacheKey,
listLocalFiles,
listRemoteFiles,
setPendingHostKeyVerification,
],
);
@@ -588,5 +755,9 @@ export const useSftpConnections = ({
disconnect,
listLocalFiles,
listRemoteFiles,
hostKeyVerification,
rejectHostKeyVerification,
acceptHostKeyVerification,
acceptAndSaveHostKeyVerification,
};
};

View File

@@ -1,5 +1,6 @@
import { useCallback, useRef, useMemo, useState } from "react";
import { FileConflict, FileConflictAction, TransferStatus, SftpFilenameEncoding } from "../../../domain/models";
import { getSftpConflictTypeKey } from "../../../domain/sftpConflict";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { logger } from "../../../lib/logger";
import { notify } from "../../notification";
@@ -501,7 +502,7 @@ export const useSftpExternalOperations = (
newModified: number;
applyToAllCount: number;
}): Promise<FileConflictAction> => {
const conflictType = conflict.isDirectory ? "directory" : "file";
const conflictType = getSftpConflictTypeKey(conflict.isDirectory, conflict.existingType);
const defaultAction = conflictDefaults.get(conflictType);
if (defaultAction) return defaultAction;

View File

@@ -2,7 +2,7 @@ import test from "node:test";
import assert from "node:assert/strict";
import { buildSftpHostCredentials } from "./useSftpHostCredentials.ts";
import type { Host, SSHKey } from "../../../domain/models.ts";
import type { Host, KnownHost, SSHKey } from "../../../domain/models.ts";
const host = (overrides: Partial<Host> = {}): Host => ({
id: "host-1",
@@ -102,6 +102,28 @@ test("buildSftpHostCredentials passes reference keys as identity file paths", ()
assert.equal(credentials.passphrase, "saved-passphrase");
});
test("buildSftpHostCredentials forwards known hosts for SFTP host-key checks", () => {
const knownHosts: KnownHost[] = [{
id: "kh-1",
hostname: "example.com",
port: 22,
keyType: "ssh-ed25519",
publicKey: "SHA256:abc",
fingerprint: "abc",
discoveredAt: 1,
}];
const credentials = buildSftpHostCredentials({
host: host(),
hosts: [],
keys: [],
identities: [],
knownHosts,
});
assert.equal(credentials.knownHosts, knownHosts);
});
test("buildSftpHostCredentials passes jump host reference keys as identity file paths", () => {
const key: SSHKey = {
id: "jump-key",

View File

@@ -1,5 +1,5 @@
import { useCallback } from "react";
import type { Host, Identity, SSHKey, TerminalSettings } from "../../../domain/models";
import type { Host, Identity, KnownHost, SSHKey, TerminalSettings } from "../../../domain/models";
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "../../../domain/credentials";
import { resolveBridgeKeyAuth, resolveHostAuth } from "../../../domain/sshAuth";
import { resolveHostKeepalive } from "../../../domain/host";
@@ -14,6 +14,7 @@ interface UseSftpHostCredentialsParams {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
knownHosts?: KnownHost[];
terminalSettings?: Pick<TerminalSettings, 'keepaliveInterval' | 'keepaliveCountMax'>;
}
@@ -22,6 +23,7 @@ export const buildSftpHostCredentials = ({
hosts,
keys,
identities,
knownHosts,
terminalSettings,
}: UseSftpHostCredentialsParams & { host: Host }): NetcattySSHOptions => {
const globalKeepalive = terminalSettings ?? FALLBACK_KEEPALIVE;
@@ -165,6 +167,7 @@ export const buildSftpHostCredentials = ({
identityFilePaths: keyAuth.identityFilePaths,
keepaliveInterval: targetKeepalive.interval,
keepaliveCountMax: targetKeepalive.countMax,
knownHosts,
// Algorithm settings — must reach the SFTP bridge or hosts that need
// legacy mode / the ECDSA skip / advanced overrides would still hit
// the original negotiation failure when opening their SFTP pane,
@@ -179,9 +182,10 @@ export const useSftpHostCredentials = ({
hosts,
keys,
identities,
knownHosts,
terminalSettings,
}: UseSftpHostCredentialsParams) =>
useCallback(
(host: Host): NetcattySSHOptions => buildSftpHostCredentials({ host, hosts, keys, identities, terminalSettings }),
[hosts, identities, keys, terminalSettings],
(host: Host): NetcattySSHOptions => buildSftpHostCredentials({ host, hosts, keys, identities, knownHosts, terminalSettings }),
[hosts, identities, keys, knownHosts, terminalSettings],
);

View File

@@ -7,6 +7,12 @@ import {
TransferStatus,
TransferTask,
} from "../../../domain/models";
import {
canReplaceSftpConflict,
describeSftpExistingKind,
describeSftpIncomingKind,
getSftpConflictTypeKey,
} from "../../../domain/sftpConflict";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { logger } from "../../../lib/logger";
import { SftpPane } from "./types";
@@ -69,8 +75,14 @@ export const useSftpTransfers = ({
);
const conflictDefaultKey = useCallback(
(batchId: string | undefined, isDirectory: boolean) =>
`${batchId ?? "global"}:${isDirectory ? "directory" : "file"}`,
(batchId: string | undefined, isDirectory: boolean, existingType?: "file" | "directory" | "symlink") =>
`${batchId ?? "global"}:${getSftpConflictTypeKey(isDirectory, existingType)}`,
[],
);
const buildReplaceTypeMismatchError = useCallback(
(isDirectory: boolean, existingType: "file" | "directory" | "symlink" | undefined, targetPath: string) =>
`Cannot replace existing ${describeSftpExistingKind(existingType)} with ${describeSftpIncomingKind(isDirectory)}: ${targetPath}`,
[],
);
@@ -233,6 +245,33 @@ export const useSftpTransfers = ({
const existingStat = await statTargetPath(targetPane, targetSftpId, task.targetPath, targetEncoding);
if (existingStat) {
const applyToAllCount = task.batchId
? await (async () => {
const candidates = transfersRef.current.filter((candidate) =>
candidate.batchId === task.batchId &&
candidate.isDirectory === task.isDirectory &&
!candidate.parentTaskId &&
candidate.status !== "completed" &&
candidate.status !== "cancelled",
);
const matches = await Promise.all(candidates.map(async (candidate) => {
if (candidate.id === task.id) return true;
try {
const candidateStat = await statTargetPath(
targetPane,
targetSftpId,
candidate.targetPath,
targetEncoding,
);
return candidateStat?.type === existingStat.type;
} catch {
return false;
}
}));
return Math.max(1, matches.filter(Boolean).length);
})()
: 1;
return {
transferId: task.id,
batchId: task.batchId,
@@ -241,15 +280,7 @@ export const useSftpTransfers = ({
targetPath: task.targetPath,
isDirectory: task.isDirectory,
existingType: existingStat.type,
applyToAllCount: task.batchId
? transfersRef.current.filter((candidate) =>
candidate.batchId === task.batchId &&
candidate.isDirectory === task.isDirectory &&
!candidate.parentTaskId &&
candidate.status !== "completed" &&
candidate.status !== "cancelled",
).length
: 1,
applyToAllCount,
existingSize: existingStat.size,
newSize: sourceStat?.size || task.totalBytes || 0,
existingModified: existingStat.mtime,
@@ -271,7 +302,9 @@ export const useSftpTransfers = ({
const conflict = await conflictCheckPromise;
if (conflict) {
const defaultAction = conflictDefaultsRef.current.get(conflictDefaultKey(task.batchId, task.isDirectory));
const defaultAction = conflictDefaultsRef.current.get(
conflictDefaultKey(task.batchId, task.isDirectory, conflict.existingType),
);
if (defaultAction) {
if (defaultAction === "stop") {
await markBatchStopped(task);
@@ -285,6 +318,16 @@ export const useSftpTransfers = ({
return "cancelled";
}
if (defaultAction === "replace" && !canReplaceSftpConflict(task.isDirectory, conflict.existingType)) {
updateTask({
status: "failed",
endTime: Date.now(),
error: buildReplaceTypeMismatchError(task.isDirectory, conflict.existingType, task.targetPath),
retryable: false,
});
return "failed";
}
const duplicateTarget = defaultAction === "duplicate"
? await getDuplicateTarget(task, targetPane, targetSftpId, targetEncoding)
: null;
@@ -728,16 +771,19 @@ export const useSftpTransfers = ({
return;
}
const selectedConflictKey = conflictDefaultKey(task.batchId, task.isDirectory);
const selectedConflictKey = conflictDefaultKey(conflict.batchId, conflict.isDirectory, conflict.existingType);
const affectedConflicts = applyToAll
? conflictsRef.current.filter((candidate) =>
conflictDefaultKey(candidate.batchId, candidate.isDirectory) === selectedConflictKey,
conflictDefaultKey(candidate.batchId, candidate.isDirectory, candidate.existingType) === selectedConflictKey,
)
: [conflict];
const affectedConflictIds = new Set(affectedConflicts.map((candidate) => candidate.transferId));
const affectedTasks = affectedConflicts
.map((candidate) => transfersRef.current.find((transfer) => transfer.id === candidate.transferId))
.filter((candidate): candidate is TransferTask => Boolean(candidate));
const affectedConflictById = new Map<string, FileConflict>(
affectedConflicts.map((candidate): [string, FileConflict] => [candidate.transferId, candidate]),
);
if (applyToAll) {
conflictDefaultsRef.current.set(selectedConflictKey, action);
@@ -771,9 +817,11 @@ export const useSftpTransfers = ({
}
const updatedTasks: TransferTask[] = [];
const blockedReplaceTasks: Array<{ task: TransferTask; conflict: FileConflict }> = [];
for (const affectedTask of affectedTasks) {
let updatedTask = { ...affectedTask };
const affectedConflict = affectedConflictById.get(affectedTask.id);
if (action === "duplicate") {
const endpoints = resolveTaskEndpoints(affectedTask);
@@ -792,6 +840,13 @@ export const useSftpTransfers = ({
skipConflictCheck: true,
};
} else if (action === "replace") {
if (
affectedConflict &&
!canReplaceSftpConflict(affectedTask.isDirectory, affectedConflict.existingType)
) {
blockedReplaceTasks.push({ task: affectedTask, conflict: affectedConflict });
continue;
}
updatedTask = {
...affectedTask,
skipConflictCheck: true,
@@ -808,6 +863,28 @@ export const useSftpTransfers = ({
updatedTasks.push(updatedTask);
}
if (blockedReplaceTasks.length > 0) {
const blockedTaskIds = new Set(blockedReplaceTasks.map(({ task }) => task.id));
const blockedErrors = new Map(
blockedReplaceTasks.map(({ task, conflict }) => [
task.id,
buildReplaceTypeMismatchError(task.isDirectory, conflict.existingType, task.targetPath),
]),
);
setTransfers((prev) =>
prev.map((t) => blockedTaskIds.has(t.id)
? {
...t,
status: "failed" as TransferStatus,
endTime: Date.now(),
error: blockedErrors.get(t.id),
retryable: false,
}
: t,
),
);
}
const updatedTaskMap = new Map(updatedTasks.map((updatedTask) => [updatedTask.id, updatedTask]));
setTransfers((prev) =>
prev.map((t) => {

View File

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

View File

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

View File

@@ -74,3 +74,56 @@ test("runThemeTransition uses view transition API when available", async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(finished, true);
});
test("runThemeTransition handles skipped view transitions", async () => {
const root = createRoot();
let applied = false;
let rejectFinished!: (reason: unknown) => void;
const doc = {
startViewTransition: (callback: () => void) => {
callback();
return {
finished: new Promise<void>((_, reject) => {
rejectFinished = reject;
}),
skipTransition: () => {},
};
},
};
(root as { ownerDocument: typeof doc }).ownerDocument = doc;
runThemeTransition(() => {
applied = true;
}, root);
rejectFinished(new DOMException("Transition was skipped", "AbortError"));
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(applied, true);
assert.equal(root.getAttribute(THEME_TRANSITION_ATTR), null);
});
test("runThemeTransition can apply without animation for heavy tab switches", () => {
const root = createRoot();
let applied = false;
let startViewTransitionCalled = false;
const doc = {
startViewTransition: (callback: () => void) => {
startViewTransitionCalled = true;
callback();
return {
finished: Promise.resolve(),
skipTransition: () => {},
};
},
};
(root as { ownerDocument: typeof doc }).ownerDocument = doc;
runThemeTransition(() => {
applied = true;
}, { root, mode: "instant" });
assert.equal(applied, true);
assert.equal(startViewTransitionCalled, false);
assert.equal(root.getAttribute(THEME_TRANSITION_ATTR), null);
});

View File

@@ -2,6 +2,7 @@ import { TERMINAL_HOST_TREE_ANIMATION_MS } from './terminalHostTreeAnimation';
export const THEME_TRANSITION_ATTR = 'data-theme-transition';
export const THEME_TRANSITION_MS = TERMINAL_HOST_TREE_ANIMATION_MS;
export type ThemeTransitionMode = 'view' | 'css' | 'instant';
type DocumentWithViewTransition = Document & {
startViewTransition?: (callback: () => void | Promise<void>) => {
@@ -10,12 +11,57 @@ type DocumentWithViewTransition = Document & {
};
};
type ThemeTransitionOptions = {
root?: HTMLElement;
mode?: ThemeTransitionMode;
};
let cancelThemeTransitionReset: (() => void) | null = null;
function resolveOptions(rootOrOptions?: HTMLElement | ThemeTransitionOptions): Required<ThemeTransitionOptions> {
if (
rootOrOptions
&& (
Object.prototype.hasOwnProperty.call(rootOrOptions, 'root')
|| Object.prototype.hasOwnProperty.call(rootOrOptions, 'mode')
)
) {
const options = rootOrOptions as ThemeTransitionOptions;
return {
root: options.root ?? document.documentElement,
mode: options.mode ?? 'view',
};
}
return {
root: rootOrOptions as HTMLElement | undefined ?? document.documentElement,
mode: 'view',
};
}
function runCssThemeTransition(apply: () => void, root: HTMLElement, cleanup: () => void): void {
root.setAttribute(THEME_TRANSITION_ATTR, 'true');
apply();
const timer = globalThis.setTimeout(cleanup, THEME_TRANSITION_MS + 40);
cancelThemeTransitionReset = () => {
globalThis.clearTimeout(timer);
cleanup();
};
}
function skipViewTransition(transition: ReturnType<NonNullable<DocumentWithViewTransition['startViewTransition']>>): void {
try {
transition.skipTransition();
} catch {
// Already finished or skipped by the browser.
}
}
export function runThemeTransition(
apply: () => void,
root: HTMLElement = document.documentElement,
rootOrOptions?: HTMLElement | ThemeTransitionOptions,
): void {
const { root, mode } = resolveOptions(rootOrOptions);
cancelThemeTransitionReset?.();
const cleanup = () => {
@@ -23,6 +69,17 @@ export function runThemeTransition(
cancelThemeTransitionReset = null;
};
if (mode === 'instant') {
apply();
cleanup();
return;
}
if (mode === 'css') {
runCssThemeTransition(apply, root, cleanup);
return;
}
const doc = root.ownerDocument as DocumentWithViewTransition | null;
const startViewTransition = doc?.startViewTransition?.bind(doc);
@@ -33,29 +90,19 @@ export function runThemeTransition(
apply();
});
} catch {
root.setAttribute(THEME_TRANSITION_ATTR, 'true');
apply();
const timer = globalThis.setTimeout(cleanup, THEME_TRANSITION_MS + 40);
cancelThemeTransitionReset = () => {
globalThis.clearTimeout(timer);
cleanup();
};
runCssThemeTransition(apply, root, cleanup);
return;
}
cancelThemeTransitionReset = () => {
transition?.skipTransition();
if (transition) {
skipViewTransition(transition);
}
cleanup();
};
void transition.finished.finally(cleanup);
void transition.finished.then(cleanup, cleanup);
return;
}
root.setAttribute(THEME_TRANSITION_ATTR, 'true');
apply();
const timer = globalThis.setTimeout(cleanup, THEME_TRANSITION_MS + 40);
cancelThemeTransitionReset = () => {
globalThis.clearTimeout(timer);
cleanup();
};
runCssThemeTransition(apply, root, cleanup);
}

View File

@@ -139,6 +139,86 @@ test("uploads picked folder files with their relative directory structure", asyn
]);
});
test("does not replace an existing directory when uploading a same-named file", async () => {
const file = new File(["local"], "dddd", { lastModified: 1234 });
const deletedPaths: string[] = [];
const uploadedPaths: string[] = [];
const results = await uploadFromFileList(
[file],
{
targetPath: "/target",
sftpId: "sftp-1",
isLocal: false,
bridge: {
mkdirSftp: async () => {},
statSftp: async (_sftpId, path) =>
path === "/target/dddd"
? { type: "directory", size: 0, lastModified: 1000 }
: null,
deleteSftp: async (_sftpId, path) => {
deletedPaths.push(path);
},
writeSftpBinary: async (_sftpId, path) => {
uploadedPaths.push(path);
},
},
joinPath: (base, name) => `${base}/${name}`,
resolveConflict: async () => "replace",
},
);
assert.deepEqual(deletedPaths, []);
assert.deepEqual(uploadedPaths, []);
assert.equal(results.length, 1);
assert.equal(results[0].fileName, "dddd");
assert.equal(results[0].success, false);
assert.match(results[0].error ?? "", /directory/i);
});
test("counts apply-to-all upload conflicts by incoming and existing type", async () => {
const files = [
new File(["local"], "existing-file", { lastModified: 1234 }),
new File(["local"], "existing-directory", { lastModified: 1234 }),
];
const conflictCounts: number[] = [];
const results = await uploadFromFileList(
files,
{
targetPath: "/target",
sftpId: "sftp-1",
isLocal: false,
bridge: {
mkdirSftp: async () => {},
statSftp: async (_sftpId, path) => {
if (path === "/target/existing-file") {
return { type: "file", size: 2, lastModified: 1000 };
}
if (path === "/target/existing-directory") {
return { type: "directory", size: 0, lastModified: 1000 };
}
return null;
},
writeSftpBinary: async () => {
throw new Error("skipped conflicts should not upload");
},
},
joinPath: (base, name) => `${base}/${name}`,
resolveConflict: async (conflict) => {
conflictCounts.push(conflict.applyToAllCount);
return "skip";
},
},
);
assert.deepEqual(conflictCounts, [1, 1]);
assert.deepEqual(results, [
{ fileName: "existing-file", success: false, cancelled: true },
{ fileName: "existing-directory", success: false, cancelled: true },
]);
});
test("uploads path-backed clipboard files through stream transfer", async () => {
const transfers: Array<{ sourcePath: string; targetPath: string; totalBytes?: number }> = [];
const taskTotals: number[] = [];

View File

@@ -0,0 +1,350 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import {
STORAGE_KEY_AI_PROVIDERS,
STORAGE_KEY_AI_ACTIVE_PROVIDER,
STORAGE_KEY_AI_ACTIVE_MODEL,
STORAGE_KEY_AI_PERMISSION_MODE,
STORAGE_KEY_AI_TOOL_INTEGRATION_MODE,
STORAGE_KEY_AI_EXTERNAL_AGENTS,
STORAGE_KEY_AI_DEFAULT_AGENT,
STORAGE_KEY_AI_COMMAND_BLOCKLIST,
STORAGE_KEY_AI_COMMAND_TIMEOUT,
STORAGE_KEY_AI_MAX_ITERATIONS,
STORAGE_KEY_AI_AGENT_MODEL_MAP,
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
STORAGE_KEY_AI_WEB_SEARCH,
STORAGE_KEY_AI_QUICK_MESSAGES,
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
} from '../../infrastructure/config/storageKeys';
import type { AIQuickMessage } from '../../infrastructure/ai/quickMessages';
import { sanitizeQuickMessages } from '../../infrastructure/ai/quickMessages';
import type {
AIPermissionMode,
AIToolIntegrationMode,
ExternalAgentConfig,
ProviderConfig,
WebSearchConfig,
} from '../../infrastructure/ai/types';
import { DEFAULT_COMMAND_BLOCKLIST } from '../../infrastructure/ai/types';
import { removeProviderReferences } from './aiProviderCleanup';
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
import { getAIBridge } from './aiStateSnapshots';
import { useStoredBoolean } from './useStoredBoolean';
function readPermissionMode(): AIPermissionMode {
const stored = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
if (stored === 'observer' || stored === 'confirm' || stored === 'autonomous') return stored;
return 'confirm';
}
function readToolIntegrationMode(): AIToolIntegrationMode {
return localStorageAdapter.readString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE) === 'skills'
? 'skills'
: 'mcp';
}
export function useAISettingsState() {
const [providers, setProvidersRaw] = useState<ProviderConfig[]>(() =>
localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS) ?? []
);
const [activeProviderId, setActiveProviderIdRaw] = useState<string>(() =>
localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_PROVIDER) ?? ''
);
const [activeModelId, setActiveModelIdRaw] = useState<string>(() =>
localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_MODEL) ?? ''
);
const [globalPermissionMode, setGlobalPermissionModeRaw] = useState<AIPermissionMode>(readPermissionMode);
const [toolIntegrationMode, setToolIntegrationModeRaw] = useState<AIToolIntegrationMode>(readToolIntegrationMode);
const [externalAgents, setExternalAgentsRaw] = useState<ExternalAgentConfig[]>(() =>
localStorageAdapter.read<ExternalAgentConfig[]>(STORAGE_KEY_AI_EXTERNAL_AGENTS) ?? []
);
const [defaultAgentId, setDefaultAgentIdRaw] = useState<string>(() =>
localStorageAdapter.readString(STORAGE_KEY_AI_DEFAULT_AGENT) ?? 'catty'
);
const [commandBlocklist, setCommandBlocklistRaw] = useState<string[]>(() =>
localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST) ?? [...DEFAULT_COMMAND_BLOCKLIST]
);
const [commandTimeout, setCommandTimeoutRaw] = useState<number>(() =>
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? 60
);
const [maxIterations, setMaxIterationsRaw] = useState<number>(() =>
localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20
);
const [webSearchConfig, setWebSearchConfigRaw] = useState<WebSearchConfig | null>(() =>
localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null
);
const [quickMessages, setQuickMessagesRaw] = useState<AIQuickMessage[]>(() =>
sanitizeQuickMessages(localStorageAdapter.read<unknown>(STORAGE_KEY_AI_QUICK_MESSAGES)),
);
const [showTerminalSelectionAIAction, setShowTerminalSelectionAIAction] = useStoredBoolean(
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
true,
);
const setProviders = useCallback((value: ProviderConfig[] | ((prev: ProviderConfig[]) => ProviderConfig[])) => {
setProvidersRaw((prev) => {
const next = typeof value === 'function' ? value(prev) : value;
localStorageAdapter.write(STORAGE_KEY_AI_PROVIDERS, next);
return next;
});
}, []);
const addProvider = useCallback((provider: ProviderConfig) => {
setProviders((prev) => [...prev, provider]);
}, [setProviders]);
const updateProvider = useCallback((id: string, updates: Partial<ProviderConfig>) => {
setProviders((prev) => prev.map((provider) => (
provider.id === id ? { ...provider, ...updates } : provider
)));
}, [setProviders]);
const removeProvider = useCallback((id: string) => {
setProviders((prev) => prev.filter((provider) => provider.id !== id));
setActiveProviderIdRaw((prevId) => {
if (prevId !== id) return prevId;
localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_PROVIDER, '');
return '';
});
const agentProviderMap =
localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_PROVIDER_MAP) ?? {};
const agentModelMap =
localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP) ?? {};
const cleanup = removeProviderReferences(id, agentProviderMap, agentModelMap);
if (cleanup.providerMapChanged) {
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_PROVIDER_MAP, cleanup.agentProviderMap);
}
if (cleanup.modelMapChanged) {
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_MODEL_MAP, cleanup.agentModelMap);
}
}, [setProviders]);
const setActiveProviderId = useCallback((id: string) => {
setActiveProviderIdRaw(id);
localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_PROVIDER, id);
}, []);
const setActiveModelId = useCallback((id: string) => {
setActiveModelIdRaw(id);
localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_MODEL, id);
}, []);
const setGlobalPermissionMode = useCallback((mode: AIPermissionMode) => {
setGlobalPermissionModeRaw(mode);
localStorageAdapter.writeString(STORAGE_KEY_AI_PERMISSION_MODE, mode);
getAIBridge()?.aiMcpSetPermissionMode?.(mode);
}, []);
const setToolIntegrationMode = useCallback((mode: AIToolIntegrationMode) => {
setToolIntegrationModeRaw(mode);
localStorageAdapter.writeString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE, mode);
getAIBridge()?.aiMcpSetToolIntegrationMode?.(mode);
}, []);
const setExternalAgents = useCallback((value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => {
setExternalAgentsRaw((prev) => {
const next = typeof value === 'function' ? value(prev) : value;
localStorageAdapter.write(STORAGE_KEY_AI_EXTERNAL_AGENTS, next);
return next;
});
}, []);
const setDefaultAgentId = useCallback((id: string) => {
setDefaultAgentIdRaw(id);
localStorageAdapter.writeString(STORAGE_KEY_AI_DEFAULT_AGENT, id);
}, []);
const setCommandBlocklist = useCallback((value: string[]) => {
setCommandBlocklistRaw(value);
localStorageAdapter.write(STORAGE_KEY_AI_COMMAND_BLOCKLIST, value);
getAIBridge()?.aiMcpSetCommandBlocklist?.(value);
}, []);
const setCommandTimeout = useCallback((value: number) => {
setCommandTimeoutRaw(value);
localStorageAdapter.writeNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT, value);
getAIBridge()?.aiMcpSetCommandTimeout?.(value);
}, []);
const setMaxIterations = useCallback((value: number) => {
setMaxIterationsRaw(value);
localStorageAdapter.writeNumber(STORAGE_KEY_AI_MAX_ITERATIONS, value);
getAIBridge()?.aiMcpSetMaxIterations?.(value);
}, []);
const setWebSearchConfig = useCallback((config: WebSearchConfig | null) => {
setWebSearchConfigRaw(config);
if (config) {
localStorageAdapter.write(STORAGE_KEY_AI_WEB_SEARCH, config);
} else {
localStorageAdapter.remove(STORAGE_KEY_AI_WEB_SEARCH);
}
}, []);
const setQuickMessages = useCallback((value: AIQuickMessage[] | ((prev: AIQuickMessage[]) => AIQuickMessage[])) => {
setQuickMessagesRaw((prev) => {
const nextRaw = typeof value === 'function' ? value(prev) : value;
const next = sanitizeQuickMessages(nextRaw);
localStorageAdapter.write(STORAGE_KEY_AI_QUICK_MESSAGES, next);
emitAIStateChanged(STORAGE_KEY_AI_QUICK_MESSAGES);
return next;
});
}, []);
useEffect(() => {
const syncFromStorageKey = (key: string | null) => {
try {
switch (key) {
case STORAGE_KEY_AI_PROVIDERS: {
const parsed = localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS);
if (parsed != null && !Array.isArray(parsed)) break;
setProvidersRaw(parsed ?? []);
break;
}
case STORAGE_KEY_AI_ACTIVE_PROVIDER:
setActiveProviderIdRaw(localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_PROVIDER) ?? '');
break;
case STORAGE_KEY_AI_ACTIVE_MODEL:
setActiveModelIdRaw(localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_MODEL) ?? '');
break;
case STORAGE_KEY_AI_PERMISSION_MODE:
setGlobalPermissionModeRaw(readPermissionMode());
getAIBridge()?.aiMcpSetPermissionMode?.(readPermissionMode());
break;
case STORAGE_KEY_AI_TOOL_INTEGRATION_MODE:
setToolIntegrationModeRaw(readToolIntegrationMode());
getAIBridge()?.aiMcpSetToolIntegrationMode?.(readToolIntegrationMode());
break;
case STORAGE_KEY_AI_EXTERNAL_AGENTS: {
const agents = localStorageAdapter.read<ExternalAgentConfig[]>(STORAGE_KEY_AI_EXTERNAL_AGENTS);
if (agents != null && !Array.isArray(agents)) break;
setExternalAgentsRaw(agents ?? []);
break;
}
case STORAGE_KEY_AI_DEFAULT_AGENT:
setDefaultAgentIdRaw(localStorageAdapter.readString(STORAGE_KEY_AI_DEFAULT_AGENT) ?? 'catty');
break;
case STORAGE_KEY_AI_COMMAND_BLOCKLIST: {
const list = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
if (list != null && !Array.isArray(list)) break;
const blocklist = list ?? [...DEFAULT_COMMAND_BLOCKLIST];
setCommandBlocklistRaw(blocklist);
getAIBridge()?.aiMcpSetCommandBlocklist?.(blocklist);
break;
}
case STORAGE_KEY_AI_COMMAND_TIMEOUT: {
const timeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? 60;
if (!Number.isFinite(timeout)) break;
setCommandTimeoutRaw(timeout);
getAIBridge()?.aiMcpSetCommandTimeout?.(timeout);
break;
}
case STORAGE_KEY_AI_MAX_ITERATIONS: {
const iters = localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20;
if (!Number.isFinite(iters)) break;
setMaxIterationsRaw(iters);
getAIBridge()?.aiMcpSetMaxIterations?.(iters);
break;
}
case STORAGE_KEY_AI_WEB_SEARCH:
setWebSearchConfigRaw(localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null);
break;
case STORAGE_KEY_AI_QUICK_MESSAGES:
setQuickMessagesRaw(sanitizeQuickMessages(localStorageAdapter.read<unknown>(STORAGE_KEY_AI_QUICK_MESSAGES)));
break;
}
} catch (err) {
console.warn('[useAISettingsState] Failed to process AI settings storage change', key, err);
}
};
const handleStorage = (event: StorageEvent) => syncFromStorageKey(event.key);
const handleLocalStateChanged = (event: Event) => {
syncFromStorageKey((event as CustomEvent<{ key?: string }>).detail?.key ?? null);
};
window.addEventListener('storage', handleStorage);
window.addEventListener(AI_STATE_CHANGED_EVENT, handleLocalStateChanged);
return () => {
window.removeEventListener('storage', handleStorage);
window.removeEventListener(AI_STATE_CHANGED_EVENT, handleLocalStateChanged);
};
}, []);
useEffect(() => {
const bridge = getAIBridge();
bridge?.aiMcpSetCommandBlocklist?.(commandBlocklist);
bridge?.aiMcpSetCommandTimeout?.(commandTimeout);
bridge?.aiMcpSetMaxIterations?.(maxIterations);
bridge?.aiMcpSetPermissionMode?.(globalPermissionMode);
bridge?.aiMcpSetToolIntegrationMode?.(toolIntegrationMode);
}, [commandBlocklist, commandTimeout, globalPermissionMode, maxIterations, toolIntegrationMode]);
const activeProvider = providers.find((provider) => provider.id === activeProviderId) ?? null;
return useMemo(() => ({
providers,
setProviders,
addProvider,
updateProvider,
removeProvider,
activeProviderId,
setActiveProviderId,
activeModelId,
setActiveModelId,
activeProvider,
globalPermissionMode,
setGlobalPermissionMode,
toolIntegrationMode,
setToolIntegrationMode,
externalAgents,
setExternalAgents,
defaultAgentId,
setDefaultAgentId,
commandBlocklist,
setCommandBlocklist,
commandTimeout,
setCommandTimeout,
maxIterations,
setMaxIterations,
webSearchConfig,
setWebSearchConfig,
quickMessages,
setQuickMessages,
showTerminalSelectionAIAction,
setShowTerminalSelectionAIAction,
}), [
providers,
setProviders,
addProvider,
updateProvider,
removeProvider,
activeProviderId,
setActiveProviderId,
activeModelId,
setActiveModelId,
activeProvider,
globalPermissionMode,
setGlobalPermissionMode,
toolIntegrationMode,
setToolIntegrationMode,
externalAgents,
setExternalAgents,
defaultAgentId,
setDefaultAgentId,
commandBlocklist,
setCommandBlocklist,
commandTimeout,
setCommandTimeout,
maxIterations,
setMaxIterations,
webSearchConfig,
setWebSearchConfig,
quickMessages,
setQuickMessages,
showTerminalSelectionAIAction,
setShowTerminalSelectionAIAction,
]);
}

View File

@@ -115,7 +115,9 @@ export function useAIState() {
// ── Sessions ──
const [sessions, setSessionsRaw] = useState<AISession[]>(() =>
localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? []
latestAISessionsSnapshot
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
?? []
);
// Ref that always holds the latest sessions for use inside debounced callbacks
const sessionsRef = useRef(sessions);
@@ -124,7 +126,9 @@ export function useAIState() {
}, [sessions]);
// Per-scope active session: keyed by `${scopeType}:${scopeTargetId}`
const [activeSessionIdMap, setActiveSessionIdMapRaw] = useState<Record<string, string | null>>(() =>
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {}
latestAIActiveSessionMapSnapshot
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
?? {}
);
// Per-scope draft/view state is intentionally memory-only so a relaunch
// does not restore stale composer input or panel intent against new history.
@@ -185,7 +189,7 @@ export function useAIState() {
}, [panelViewByScope]);
useEffect(() => {
const validSessionIds = new Set(sessions.map((session) => session.id));
const validSessionIds = new Set<string>(sessions.map((session) => session.id));
let changed = false;
const nextActiveSessionIdMap: Record<string, string | null> = {};

View File

@@ -3,7 +3,19 @@ import test from "node:test";
import {
scheduleChromeLayoutAnimation,
syncActiveChromeTheme,
themeFingerprint,
} from "./useActiveChromeTheme.ts";
import { TERMINAL_THEMES } from "../../infrastructure/config/terminalThemes.ts";
function createInlineStyle() {
const values = new Map<string, string>();
return {
getPropertyValue: (name: string) => values.get(name) ?? "",
setProperty: (name: string, value: string) => values.set(name, value),
removeProperty: (name: string) => values.delete(name),
};
}
function createRafRoot() {
const callbacks = new Map<number, FrameRequestCallback>();
@@ -47,3 +59,37 @@ test("chrome layout animations wait until theme settle frames complete", () => {
assert.equal(ran, true);
cancel();
});
test("syncActiveChromeTheme refreshes top tabs when the active theme fingerprint is unchanged", () => {
const globalWithDocument = globalThis as typeof globalThis & { document?: Document };
const originalDocument = globalWithDocument.document;
const theme = TERMINAL_THEMES[0];
assert.ok(theme);
const topTabsRoot = {
style: createInlineStyle(),
};
const documentElement = {
dataset: { activeChromeTheme: themeFingerprint(theme) },
};
const fakeDocument = {
documentElement,
querySelector: (selector: string) => selector === "[data-top-tabs-root]" ? topTabsRoot : null,
};
globalWithDocument.document = fakeDocument as unknown as Document;
try {
syncActiveChromeTheme(theme, () => {
throw new Error("app theme should not be restored for an unchanged active chrome theme");
});
assert.notEqual(topTabsRoot.style.getPropertyValue("--top-tabs-bg"), "");
assert.notEqual(topTabsRoot.style.getPropertyValue("--top-tabs-active-bg"), "");
assert.notEqual(topTabsRoot.style.getPropertyValue("--top-tabs-accent"), "");
} finally {
if (originalDocument) {
globalWithDocument.document = originalDocument;
} else {
delete globalWithDocument.document;
}
}
});

View File

@@ -208,10 +208,17 @@ function applyActiveChromeTheme(theme: TerminalTheme) {
}
style.textContent = getChromeCss(theme);
root.dataset.activeChromeTheme = themeFingerprint(theme);
refreshActiveChromeThemeSurfaces(theme);
}, { mode: "instant" });
}
function refreshActiveChromeThemeSurfaces(theme: TerminalTheme) {
const targetClass = theme.type === "dark" ? "dark" : "light";
if (typeof window !== "undefined") {
netcattyBridge.get()?.setTheme?.(targetClass);
netcattyBridge.get()?.setBackgroundColor?.(theme.colors.background);
applyTopTabsChromeThemeVars(theme);
});
}
applyTopTabsChromeThemeVars(theme);
}
export function syncActiveChromeTheme(
@@ -220,7 +227,14 @@ export function syncActiveChromeTheme(
): void {
const nextFingerprint = activeTheme ? themeFingerprint(activeTheme) : null;
const appliedFingerprint = getAppliedChromeFingerprint();
if (nextFingerprint === appliedFingerprint) return;
if (nextFingerprint === appliedFingerprint) {
if (activeTheme) {
refreshActiveChromeThemeSurfaces(activeTheme);
} else {
clearTopTabsChromeThemeVars();
}
return;
}
if (activeTheme) {
applyActiveChromeTheme(activeTheme);
@@ -231,7 +245,7 @@ export function syncActiveChromeTheme(
runThemeTransition(() => {
removeActiveChromeTheme();
applyAppTheme();
});
}, { mode: "instant" });
}
export function useActiveChromeTheme({

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import { startTransition, useCallback, useEffect, useRef, useState } from 'react';
import type { DiscoveredAgent, ExternalAgentConfig } from '../../infrastructure/ai/types';
import { getExternalAgentSdkBackend } from '../../infrastructure/ai/managedAgents';
@@ -10,6 +10,15 @@ function getBridge(): NetcattyBridge | undefined {
return (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
}
const AGENT_DISCOVERY_CACHE_TTL_MS = 60_000;
let agentDiscoveryCache: {
agents: DiscoveredAgent[];
apiKeyPresent: boolean;
updatedAt: number;
} | null = null;
const agentDiscoveryPromises = new Map<string, Promise<DiscoveredAgent[]>>();
let agentDiscoveryWriteGeneration = 0;
export function useAgentDiscovery(
externalAgents: ExternalAgentConfig[],
setExternalAgents?: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void,
@@ -18,29 +27,87 @@ export function useAgentDiscovery(
const enabled = options?.enabled ?? true;
const [discoveredAgents, setDiscoveredAgents] = useState<DiscoveredAgent[]>([]);
const [isDiscovering, setIsDiscovering] = useState(false);
const discoverSeqRef = useRef(0);
const mountedRef = useRef(true);
const enabledRef = useRef(enabled);
enabledRef.current = enabled;
useEffect(() => () => {
mountedRef.current = false;
discoverSeqRef.current += 1;
}, []);
const cursorApiKeyPresent = externalAgents.some(
(agent) => agent.id === "discovered_cursor" && Boolean(agent.apiKey),
);
const discover = useCallback(async (discoverOptions?: { refreshShellEnv?: boolean }) => {
if (!enabledRef.current) return;
const bridge = getBridge();
if (!bridge) return;
const forceRefresh = discoverOptions?.refreshShellEnv === true;
const cacheFresh =
agentDiscoveryCache
&& agentDiscoveryCache.apiKeyPresent === cursorApiKeyPresent
&& Date.now() - agentDiscoveryCache.updatedAt < AGENT_DISCOVERY_CACHE_TTL_MS;
if (!forceRefresh && cacheFresh) {
startTransition(() => setDiscoveredAgents(agentDiscoveryCache?.agents ?? []));
return;
}
setIsDiscovering(true);
const discoverSeq = ++discoverSeqRef.current;
const writeGeneration = ++agentDiscoveryWriteGeneration;
const promiseKey = JSON.stringify({
apiKeyPresent: cursorApiKeyPresent,
refreshShellEnv: forceRefresh,
});
try {
const agents = await bridge.aiDiscoverAgents({
...discoverOptions,
let discoveryPromise = agentDiscoveryPromises.get(promiseKey) ?? null;
if (!discoveryPromise) {
const sharedPromise = bridge.aiDiscoverAgents({
...discoverOptions,
apiKeyPresent: cursorApiKeyPresent,
}).finally(() => {
if (agentDiscoveryPromises.get(promiseKey) === sharedPromise) {
agentDiscoveryPromises.delete(promiseKey);
}
});
agentDiscoveryPromises.set(promiseKey, sharedPromise);
discoveryPromise = sharedPromise;
}
const agents = await discoveryPromise;
if (
!mountedRef.current
|| !enabledRef.current
|| discoverSeq !== discoverSeqRef.current
|| writeGeneration !== agentDiscoveryWriteGeneration
) return;
agentDiscoveryCache = {
agents,
apiKeyPresent: cursorApiKeyPresent,
});
setDiscoveredAgents(agents);
updatedAt: Date.now(),
};
startTransition(() => setDiscoveredAgents(agents));
} catch (err) {
console.error('Agent discovery failed:', err);
} finally {
setIsDiscovering(false);
if (mountedRef.current && discoverSeq === discoverSeqRef.current) {
setIsDiscovering(false);
}
}
}, [cursorApiKeyPresent]);
useEffect(() => {
discoverSeqRef.current += 1;
if (!enabled) {
setIsDiscovering(false);
}
}, [cursorApiKeyPresent, enabled]);
useEffect(() => {
if (!enabled) return;
@@ -68,6 +135,7 @@ export function useAgentDiscovery(
// the canonical args from discovery change (e.g. after an app update).
useEffect(() => {
if (!setExternalAgents || discoveredAgents.length === 0) return;
if (!enabled) return;
setExternalAgents((prev) => {
let changed = false;
@@ -102,7 +170,7 @@ export function useAgentDiscovery(
});
return changed ? next : prev;
});
}, [discoveredAgents, setExternalAgents]);
}, [discoveredAgents, enabled, setExternalAgents]);
// Filter out agents that are already configured as external agents
const unconfiguredAgents = discoveredAgents.filter(

View File

@@ -47,6 +47,7 @@ import {
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
} from '../../infrastructure/config/storageKeys';
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
import {
@@ -89,6 +90,7 @@ import {
DEFAULT_SHOW_SFTP_TAB,
DEFAULT_SHOW_HOST_TREE_SIDEBAR,
DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
DEFAULT_DISABLE_TERMINAL_FONT_ZOOM,
DEFAULT_SSH_DEBUG_LOGS_ENABLED,
DEFAULT_TERMINAL_THEME,
DEFAULT_THEME,
@@ -244,6 +246,10 @@ export const useSettingsState = () => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
return stored ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS;
});
const [disableTerminalFontZoom, setDisableTerminalFontZoomState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM);
return stored ?? DEFAULT_DISABLE_TERMINAL_FONT_ZOOM;
});
const [sftpTransferConcurrency, setSftpTransferConcurrencyState] = useState<number>(() => {
const stored = localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY);
return stored != null && stored >= 1 && stored <= 16 ? stored : 4;
@@ -343,7 +349,14 @@ export const useSettingsState = () => {
const mergeIncomingTerminalSettings = useCallback((incoming: Partial<TerminalSettings>) => {
setTerminalSettingsState((prev) => {
const next = normalizeTerminalSettings({ ...prev, ...incoming });
const merged: Partial<TerminalSettings> = { ...prev, ...incoming };
if (
!Object.prototype.hasOwnProperty.call(incoming, 'middleClickBehavior') &&
Object.prototype.hasOwnProperty.call(incoming, 'middleClickPaste')
) {
delete merged.middleClickBehavior;
}
const next = normalizeTerminalSettings(merged);
if (areTerminalSettingsEqual(prev, next)) {
return prev;
}
@@ -544,6 +557,8 @@ export const useSettingsState = () => {
setShowHostTreeSidebarState(storedShowHostTreeSidebar ?? DEFAULT_SHOW_HOST_TREE_SIDEBAR);
const storedShellOnlyTabNumberShortcuts = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
setShellOnlyTabNumberShortcutsState(storedShellOnlyTabNumberShortcuts ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
const storedDisableTerminalFontZoom = localStorageAdapter.readBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM);
setDisableTerminalFontZoomState(storedDisableTerminalFontZoom ?? DEFAULT_DISABLE_TERMINAL_FONT_ZOOM);
// Workspace focus style
const storedFocusStyle = readStoredString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
@@ -635,6 +650,7 @@ export const useSettingsState = () => {
setSftpDefaultViewMode,
setWorkspaceFocusStyleState,
setShowHostTreeSidebarState,
setDisableTerminalFontZoomState,
setSftpTransferConcurrencyState,
});
@@ -661,7 +677,7 @@ export const useSettingsState = () => {
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
@@ -670,7 +686,7 @@ export const useSettingsState = () => {
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setShellOnlyTabNumberShortcutsState,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled,
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
@@ -791,6 +807,13 @@ export const useSettingsState = () => {
notifySettingsChanged(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, enabled);
}, [notifySettingsChanged]);
const setDisableTerminalFontZoom = useCallback((enabled: boolean) => {
setDisableTerminalFontZoomState(enabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM, enabled);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM, enabled);
}, [notifySettingsChanged]);
// Apply and persist custom CSS
useEffect(() => {
applyCustomCssToDocument(customCSS);
@@ -1031,6 +1054,8 @@ export const useSettingsState = () => {
setShowHostTreeSidebar,
shellOnlyTabNumberShortcuts,
setShellOnlyTabNumberShortcuts,
disableTerminalFontZoom,
setDisableTerminalFontZoom,
sftpTransferConcurrency,
setSftpTransferConcurrency,
// Editor Settings
@@ -1075,7 +1100,7 @@ export const useSettingsState = () => {
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
customKeyBindings, editorWordWrap,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
]),
};

View File

@@ -170,10 +170,21 @@ export const useSftpState = (
useSftpSessionCleanup(sftpSessionsRef);
useSftpFileWatch(options);
const { connect, disconnect, listLocalFiles, listRemoteFiles } = useSftpConnections({
const {
connect,
disconnect,
listLocalFiles,
listRemoteFiles,
hostKeyVerification,
rejectHostKeyVerification,
acceptHostKeyVerification,
acceptAndSaveHostKeyVerification,
} = useSftpConnections({
hosts,
keys,
identities,
knownHosts: options?.knownHosts,
onAddKnownHost: options?.onAddKnownHost,
terminalSettings: options?.terminalSettings,
leftTabsRef,
rightTabsRef,
@@ -402,6 +413,9 @@ export const useSftpState = (
resolveConflict: resolveAnyConflict,
getSftpIdForConnection,
reportSessionError: handleSessionError,
rejectHostKeyVerification,
acceptHostKeyVerification,
acceptAndSaveHostKeyVerification,
});
methodsRef.current = {
getFilteredFiles,
@@ -460,6 +474,9 @@ export const useSftpState = (
resolveConflict: resolveAnyConflict,
getSftpIdForConnection,
reportSessionError: handleSessionError,
rejectHostKeyVerification,
acceptHostKeyVerification,
acceptAndSaveHostKeyVerification,
};
// Create stable method wrappers that call through methodsRef
@@ -532,6 +549,9 @@ export const useSftpState = (
resolveConflict: (...args: Parameters<typeof resolveAnyConflict>) => methodsRef.current.resolveConflict(...args),
getSftpIdForConnection: (...args: Parameters<typeof getSftpIdForConnection>) => methodsRef.current.getSftpIdForConnection(...args),
reportSessionError: (...args: Parameters<typeof handleSessionError>) => methodsRef.current.reportSessionError(...args),
rejectHostKeyVerification: () => methodsRef.current.rejectHostKeyVerification(),
acceptHostKeyVerification: () => methodsRef.current.acceptHostKeyVerification(),
acceptAndSaveHostKeyVerification: () => methodsRef.current.acceptAndSaveHostKeyVerification(),
activeFileWatchCountRef,
}), [activeFileWatchCountRef]); // activeFileWatchCountRef is a stable ref
@@ -546,6 +566,7 @@ export const useSftpState = (
transfers,
activeTransfersCount,
conflicts,
hostKeyVerification,
// Stable methods - never change reference
...stableMethods,
@@ -566,6 +587,7 @@ export const useSftpState = (
transfers,
activeTransfersCount,
conflicts,
hostKeyVerification,
stableMethods,
]);
};

View File

@@ -180,17 +180,33 @@ export const useTerminalBackend = () => {
return !!bridge?.sendSerialYmodem;
}, []);
const serialYmodemReceiveAvailable = useCallback(() => {
const bridge = netcattyBridge.get();
return !!bridge?.receiveSerialYmodem;
}, []);
const selectFileAvailable = useCallback(() => {
const bridge = netcattyBridge.get();
return !!bridge?.selectFile;
}, []);
const selectDirectoryAvailable = useCallback(() => {
const bridge = netcattyBridge.get();
return !!bridge?.selectDirectory;
}, []);
const sendSerialYmodem = useCallback(async (sessionId: string, filePath: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.sendSerialYmodem) return { success: false, error: 'sendSerialYmodem unavailable' };
return bridge.sendSerialYmodem(sessionId, filePath);
}, []);
const receiveSerialYmodem = useCallback(async (sessionId: string, destinationDir: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.receiveSerialYmodem) return { success: false, error: 'receiveSerialYmodem unavailable' };
return bridge.receiveSerialYmodem(sessionId, destinationDir);
}, []);
const selectFile = useCallback(async (
title?: string,
defaultPath?: string,
@@ -201,6 +217,42 @@ export const useTerminalBackend = () => {
return bridge.selectFile(title, defaultPath, filters);
}, []);
const selectDirectory = useCallback(async (title?: string, defaultPath?: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.selectDirectory) return null;
return bridge.selectDirectory(title, defaultPath);
}, []);
const startZmodemDragDropUpload = useCallback(async (
sessionId: string,
files: Array<{
path?: string;
name: string;
remoteName: string;
data?: ArrayBuffer;
}>,
uploadCommand?: string,
) => {
const bridge = netcattyBridge.get();
if (!bridge?.startZmodemDragDropUpload) {
return { success: false, error: "startZmodemDragDropUpload unavailable" };
}
return bridge.startZmodemDragDropUpload(sessionId, files, uploadCommand);
}, []);
const cancelZmodem = useCallback((sessionId: string, options?: { interrupt?: boolean }) => {
const bridge = netcattyBridge.get();
bridge?.cancelZmodem?.(sessionId, options);
}, []);
const onZmodemEvent = useCallback((
sessionId: string,
cb: Parameters<NonNullable<NetcattyBridge["onZmodemEvent"]>>[1],
) => {
const bridge = netcattyBridge.get();
return bridge?.onZmodemEvent?.(sessionId, cb) ?? (() => {});
}, []);
const getSessionPwd = useCallback(async (sessionId: string, options?: { allowHomeFallback?: boolean }) => {
const bridge = netcattyBridge.get();
if (!bridge?.getSessionPwd) return { success: false, error: 'getSessionPwd unavailable' };
@@ -256,9 +308,16 @@ export const useTerminalBackend = () => {
startSerialSession,
listSerialPorts,
serialYmodemAvailable,
serialYmodemReceiveAvailable,
selectFileAvailable,
selectDirectoryAvailable,
sendSerialYmodem,
receiveSerialYmodem,
selectFile,
selectDirectory,
startZmodemDragDropUpload,
cancelZmodem,
onZmodemEvent,
execCommand,
getSessionPwd,
getSessionRemoteInfo,
@@ -297,9 +356,16 @@ export const useTerminalBackend = () => {
startSerialSession,
listSerialPorts,
serialYmodemAvailable,
serialYmodemReceiveAvailable,
selectFileAvailable,
selectDirectoryAvailable,
sendSerialYmodem,
receiveSerialYmodem,
selectFile,
selectDirectory,
startZmodemDragDropUpload,
cancelZmodem,
onZmodemEvent,
execCommand,
getSessionPwd,
getSessionRemoteInfo,

View File

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

View File

@@ -44,6 +44,7 @@ const {
hasCloudSyncEntityData,
hasMeaningfulCloudSyncData,
shouldPromptCloudVaultRecovery,
SYNCABLE_SETTING_STORAGE_KEYS,
} = await import("./syncPayload.ts");
const storageKeys = await import("../infrastructure/config/storageKeys.ts");
@@ -124,6 +125,7 @@ test("buildSyncPayload includes AI configuration settings", () => {
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP, JSON.stringify({ codex: "gpt-test" }));
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP, JSON.stringify({ catty: "openai-main" }));
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify(webSearch));
localStorage.setItem(storageKeys.STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION, "false");
const payload = buildSyncPayload(vault([]));
@@ -140,9 +142,18 @@ test("buildSyncPayload includes AI configuration settings", () => {
agentModelMap: { codex: "gpt-test" },
agentProviderMap: { catty: "openai-main" },
webSearchConfig: webSearch,
showTerminalSelectionAction: false,
});
});
test("terminal selection AI preference is syncable for auto-sync detection", () => {
assert.ok(
(SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(
storageKeys.STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
),
);
});
test("buildSyncPayload includes host tree sidebar visibility setting", () => {
localStorage.setItem(storageKeys.STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, "false");
@@ -215,6 +226,7 @@ test("applySyncPayload restores AI configuration settings", async () => {
agentModelMap: { claude: "claude-test" },
agentProviderMap: { catty: "anthropic-main" },
webSearchConfig: webSearch,
showTerminalSelectionAction: false,
},
},
syncedAt: 1,
@@ -234,6 +246,7 @@ test("applySyncPayload restores AI configuration settings", async () => {
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP)!), { claude: "claude-test" });
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP)!), { catty: "anthropic-main" });
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH)!), webSearch);
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION), "false");
});
test("applySyncPayload restores host tree sidebar visibility setting", async () => {
@@ -529,6 +542,7 @@ test("buildSyncPayload includes syncable terminal options from settings", () =>
localStorage.setItem(storageKeys.STORAGE_KEY_TERM_SETTINGS, JSON.stringify({
terminalEmulationType: "vt100",
altAsMeta: true,
middleClickBehavior: "context-menu",
showServerStats: false,
serverStatsRefreshInterval: 12,
rendererType: "dom",
@@ -541,6 +555,7 @@ test("buildSyncPayload includes syncable terminal options from settings", () =>
assert.deepEqual(payload.settings?.terminalSettings, {
terminalEmulationType: "vt100",
altAsMeta: true,
middleClickBehavior: "context-menu",
showServerStats: false,
serverStatsRefreshInterval: 12,
rendererType: "dom",
@@ -805,6 +820,42 @@ test("applySyncPayload writes incoming fallbackFont into local TERM_SETTINGS", a
assert.equal(parsed.fallbackFont, "Sarasa Mono SC");
});
test("applySyncPayload lets legacy middle-click paste update the new middle-click behavior", async () => {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({
scrollback: 2000,
middleClickBehavior: "paste",
middleClickPaste: true,
}),
);
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
settings: {
terminalSettings: {
middleClickPaste: false,
},
},
} as SyncPayload;
await applySyncPayload(payload, {
importVaultData: () => {},
});
const raw = localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS);
assert.ok(raw, "TERM_SETTINGS should be written");
const parsed = JSON.parse(raw!);
assert.equal(parsed.scrollback, 2000);
assert.equal(parsed.middleClickBehavior, "disabled");
assert.equal(parsed.middleClickPaste, false);
});
test("applySyncPayload from legacy client (no fallbackFont) preserves local value", async () => {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,

View File

@@ -67,6 +67,7 @@ import {
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_AI_PROVIDERS,
STORAGE_KEY_AI_ACTIVE_PROVIDER,
@@ -82,6 +83,7 @@ import {
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
STORAGE_KEY_AI_WEB_SEARCH,
STORAGE_KEY_AI_QUICK_MESSAGES,
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
STORAGE_KEY_PORT_FORWARDING,
} from '../infrastructure/config/storageKeys';
@@ -193,7 +195,7 @@ const SYNCABLE_TERMINAL_KEYS = [
'linePadding', 'cursorShape', 'cursorBlink', 'minimumContrastRatio',
'altAsMeta', 'optionArrowWordJump', 'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
'smoothScrolling',
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
'rightClickBehavior', 'middleClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
'keepaliveInterval', 'keepaliveCountMax', 'disableBracketedPaste', 'clearWipesScrollback',
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'showServerStats',
@@ -251,6 +253,7 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
STORAGE_KEY_AI_WEB_SEARCH,
STORAGE_KEY_AI_QUICK_MESSAGES,
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
] as const;
const isRecord = (value: unknown): value is Record<string, unknown> =>
@@ -416,6 +419,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
if (showSftpTab != null) settings.showSftpTab = showSftpTab;
const shellOnlyTabNumberShortcuts = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
if (shellOnlyTabNumberShortcuts != null) settings.shellOnlyTabNumberShortcuts = shellOnlyTabNumberShortcuts;
const disableTerminalFontZoom = localStorageAdapter.readBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM);
if (disableTerminalFontZoom != null) settings.disableTerminalFontZoom = disableTerminalFontZoom;
const showHostTreeSidebar = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
if (showHostTreeSidebar != null) settings.showHostTreeSidebar = showHostTreeSidebar;
const workspaceFocusStyle = localStorageAdapter.readString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
@@ -457,6 +462,10 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
if (webSearchConfig) ai.webSearchConfig = stripDeviceBoundApiKey(webSearchConfig);
const quickMessages = readArraySetting(STORAGE_KEY_AI_QUICK_MESSAGES);
if (quickMessages) ai.quickMessages = sanitizeQuickMessages(quickMessages);
const showTerminalSelectionAction = localStorageAdapter.readBoolean(STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION);
if (showTerminalSelectionAction != null) {
ai.showTerminalSelectionAction = showTerminalSelectionAction;
}
if (Object.keys(ai).length > 0) settings.ai = ai;
return Object.keys(settings).length > 0 ? settings : undefined;
@@ -495,11 +504,27 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
try { existing = JSON.parse(raw); } catch { /* ignore */ }
}
const merged = { ...existing };
const hasIncomingMiddleClickBehavior = 'middleClickBehavior' in settings.terminalSettings;
const hasIncomingMiddleClickPaste = 'middleClickPaste' in settings.terminalSettings;
for (const key of SYNCABLE_TERMINAL_KEYS) {
if (key in settings.terminalSettings) {
merged[key] = settings.terminalSettings[key];
}
}
if (hasIncomingMiddleClickBehavior) {
const behavior = settings.terminalSettings.middleClickBehavior;
if (
behavior === 'context-menu' ||
behavior === 'paste' ||
behavior === 'disabled'
) {
merged.middleClickPaste = behavior === 'paste';
}
} else if (hasIncomingMiddleClickPaste) {
merged.middleClickBehavior = settings.terminalSettings.middleClickPaste === false
? 'disabled'
: 'paste';
}
localStorageAdapter.writeString(STORAGE_KEY_TERM_SETTINGS, JSON.stringify(merged));
}
@@ -553,6 +578,9 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
if (settings.shellOnlyTabNumberShortcuts != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, settings.shellOnlyTabNumberShortcuts);
}
if (settings.disableTerminalFontZoom != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM, settings.disableTerminalFontZoom);
}
if (settings.showHostTreeSidebar != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, settings.showHostTreeSidebar);
}
@@ -594,6 +622,12 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
if (ai.quickMessages != null) {
localStorageAdapter.write(STORAGE_KEY_AI_QUICK_MESSAGES, sanitizeQuickMessages(ai.quickMessages));
}
if (ai.showTerminalSelectionAction != null) {
localStorageAdapter.writeBoolean(
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
ai.showTerminalSelectionAction,
);
}
// After all AI writes, reconcile per-agent bindings against the final
// provider list. Sync payloads can land with a new `providers` set but
// no `agentProviderMap`, or with a stale `agentProviderMap` that
@@ -635,6 +669,9 @@ function notifyAIStateAfterSync(ai: NonNullable<SyncPayload['settings']>['ai']):
}
if (ai.webSearchConfig !== undefined) touched.push(STORAGE_KEY_AI_WEB_SEARCH);
if (ai.quickMessages != null) touched.push(STORAGE_KEY_AI_QUICK_MESSAGES);
if (ai.showTerminalSelectionAction != null) {
touched.push(STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION);
}
for (const key of touched) {
emitAIStateChanged(key);
}

View File

@@ -3,6 +3,7 @@ import test from 'node:test';
import type { AIDraft, AISession } from '../infrastructure/ai/types';
import {
aiChatSidePanelPropsAreEqual,
hasAIChatSidePanelRetainedContent,
shouldKeepAIChatSidePanelMounted,
} from './AIChatSidePanel.tsx';
@@ -100,3 +101,17 @@ test('hidden AI side panel is retained when it has session messages', () => {
test('visible AI side panel is always mounted even when empty', () => {
assert.equal(shouldKeepAIChatSidePanelMounted(baseProps({ isVisible: true })), true);
});
test('AI side panel re-renders when retained content becomes visible again', () => {
const hiddenProps = baseProps({
isVisible: false,
draftsByScope: {
'terminal:terminal-1': draft({ text: 'hello' }),
},
});
assert.equal(aiChatSidePanelPropsAreEqual(
hiddenProps,
{ ...hiddenProps, isVisible: true },
), false);
});

View File

@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useDeferredValue, useMemo, useRef, useState } from 'react';
import { Loader2 } from 'lucide-react';
import { useI18n } from '../application/i18n/I18nProvider';
import { useWindowControls } from '../application/state/useWindowControls';
import type {
@@ -19,6 +20,7 @@ import {
getNextSelectedUserSkillSlugsMap,
type UserSkillOption,
} from './ai/userSkillsState';
import { subscribeUserSkillsStatusChanged } from './ai/userSkillsStatusEvents';
import {
applyDraftEntrySelection,
applyHistorySessionSelection,
@@ -55,6 +57,77 @@ import {
profileAIPanelCalculation,
} from './ai/aiPanelDiagnostics';
type UserSkillsStatusResult = { ok: boolean; skills?: Array<{
id: string;
slug: string;
name: string;
description: string;
status: 'ready' | 'warning';
}> } | null;
type UserSkillsStatusLoadResult = UserSkillsStatusResult | undefined;
const USER_SKILLS_STATUS_CACHE_TTL_MS = 60_000;
let userSkillsStatusCache: {
version: number;
result: UserSkillsStatusResult;
updatedAt: number;
} | null = null;
let userSkillsStatusPromise: {
version: number;
promise: Promise<UserSkillsStatusLoadResult>;
} | null = null;
let userSkillsStatusCacheVersion = 0;
function invalidateUserSkillsStatusCache() {
userSkillsStatusCacheVersion += 1;
userSkillsStatusCache = null;
userSkillsStatusPromise = null;
}
if (typeof window !== 'undefined') {
subscribeUserSkillsStatusChanged(invalidateUserSkillsStatusCache);
}
function loadUserSkillsStatus(
bridge: ReturnType<typeof getNetcattyBridge>,
): Promise<UserSkillsStatusLoadResult> {
const requestVersion = userSkillsStatusCacheVersion;
if (!bridge?.aiUserSkillsGetStatus) {
userSkillsStatusCache = { version: requestVersion, result: null, updatedAt: Date.now() };
return Promise.resolve(null);
}
if (
userSkillsStatusCache
&& userSkillsStatusCache.version === requestVersion
&& Date.now() - userSkillsStatusCache.updatedAt < USER_SKILLS_STATUS_CACHE_TTL_MS
) {
return Promise.resolve(userSkillsStatusCache.result);
}
if (!userSkillsStatusPromise || userSkillsStatusPromise.version !== requestVersion) {
const promise = bridge.aiUserSkillsGetStatus()
.then((result) => {
if (userSkillsStatusCacheVersion !== requestVersion) return undefined;
userSkillsStatusCache = { version: requestVersion, result, updatedAt: Date.now() };
return result;
})
.catch(() => {
if (userSkillsStatusCacheVersion !== requestVersion) return undefined;
userSkillsStatusCache = { version: requestVersion, result: null, updatedAt: Date.now() };
return null;
})
.finally(() => {
if (userSkillsStatusPromise?.version === requestVersion) {
userSkillsStatusPromise = null;
}
});
userSkillsStatusPromise = { version: requestVersion, promise };
}
return userSkillsStatusPromise.promise;
}
export function hasAIChatSidePanelRetainedContent(props: Pick<
AIChatSidePanelProps,
'activeSessionIdMap' | 'draftsByScope' | 'sessions' | 'scopeTargetId' | 'scopeType'
@@ -90,6 +163,49 @@ export function shouldKeepAIChatSidePanelMounted(props: AIChatSidePanelProps): b
return isAIChatSessionStreaming(sessionId);
}
function shouldDelayAIChatSidePanelActivation(props: AIChatSidePanelProps): boolean {
if (!(props.isVisible ?? true)) return false;
const scopeKey = `${props.scopeType}:${props.scopeTargetId ?? ''}`;
const sessionId = props.activeSessionIdMap[scopeKey] ?? null;
if (isAIChatSessionStreaming(sessionId)) return false;
return !hasAIChatSidePanelRetainedContent(props);
}
function schedulePanelActivation(callback: () => void): () => void {
let timeoutId: number | null = null;
if (typeof requestAnimationFrame === 'function') {
const rafId = requestAnimationFrame(() => {
timeoutId = window.setTimeout(callback, 0);
});
return () => {
cancelAnimationFrame(rafId);
if (timeoutId !== null) window.clearTimeout(timeoutId);
};
}
timeoutId = window.setTimeout(callback, 0);
return () => {
if (timeoutId !== null) window.clearTimeout(timeoutId);
};
}
const AIChatSidePanelPreparing = React.memo(function AIChatSidePanelPreparing() {
const { t } = useI18n();
return (
<div className="flex h-full flex-col bg-background" data-section="ai-chat-panel-preparing">
<div className="shrink-0 border-b border-border/50 px-2.5 py-1.5">
<div className="h-8 w-36 rounded-md bg-muted/45" />
</div>
<div className="flex flex-1 items-center justify-center text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<Loader2 size={14} className="animate-spin" />
{t('ai.chat.preparing')}
</div>
</div>
</div>
);
});
const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
sessions,
activeSessionIdMap,
@@ -141,6 +257,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const [showHistory, setShowHistory] = useState(false);
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, AgentModelPreset[]>>({});
const [userSkillOptions, setUserSkillOptions] = useState<UserSkillOption[]>([]);
const [userSkillsStatusVersion, setUserSkillsStatusVersion] = useState(0);
const { openSettingsWindow } = useWindowControls();
const terminalSessionsRef = useRef(terminalSessions);
terminalSessionsRef.current = terminalSessions;
@@ -367,25 +484,25 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
};
const bridge = getNetcattyBridge();
if (!bridge?.aiUserSkillsGetStatus) {
applyUserSkillsStatus(null);
return;
}
void bridge.aiUserSkillsGetStatus()
void loadUserSkillsStatus(bridge)
.then((result) => {
if (cancelled) return;
if (result === undefined) return;
applyUserSkillsStatus(result);
})
.catch(() => {
if (cancelled) return;
applyUserSkillsStatus(null);
});
.catch(() => {});
return () => {
cancelled = true;
};
}, [isVisible, scopeKey, toolIntegrationMode, updateScopeDraft]);
}, [isVisible, scopeKey, toolIntegrationMode, updateScopeDraft, userSkillsStatusVersion]);
useEffect(() => {
const handleUserSkillsChanged = () => {
setUserSkillsStatusVersion((version) => version + 1);
};
return subscribeUserSkillsStatusChanged(handleUserSkillsChanged);
}, []);
useEffect(() => {
if (!isVisible) return;
@@ -1034,7 +1151,7 @@ const AI_CHAT_SIDE_PANEL_AI_STATE_KEYS = [
'quickMessages',
] as const satisfies readonly (keyof AIChatSidePanelProps)[];
function aiChatSidePanelPropsAreEqual(
export function aiChatSidePanelPropsAreEqual(
prev: AIChatSidePanelProps,
next: AIChatSidePanelProps,
): boolean {
@@ -1050,6 +1167,7 @@ function aiChatSidePanelPropsAreEqual(
if (prev.scopeType !== next.scopeType) return false;
if (prev.scopeTargetId !== next.scopeTargetId) return false;
if (prev.scopeLabel !== next.scopeLabel) return false;
if ((prev.isVisible ?? true) !== (next.isVisible ?? true)) return false;
if (prev.scopeHostIds !== next.scopeHostIds) return false;
if (prev.terminalSessions !== next.terminalSessions) return false;
if (prev.resolveExecutorContext !== next.resolveExecutorContext) return false;
@@ -1061,7 +1179,25 @@ function aiChatSidePanelPropsAreEqual(
}
const AIChatSidePanel = React.memo(function AIChatSidePanel(props: AIChatSidePanelProps) {
if (!shouldKeepAIChatSidePanelMounted(props)) return null;
const shouldKeepMounted = shouldKeepAIChatSidePanelMounted(props);
const shouldDelayActivation = shouldKeepMounted && shouldDelayAIChatSidePanelActivation(props);
const activationKey = `${props.scopeType}:${props.scopeTargetId ?? ''}`;
const [activationReady, setActivationReady] = useState(!shouldDelayActivation);
useEffect(() => {
if (!shouldDelayActivation) {
setActivationReady(true);
return undefined;
}
setActivationReady(false);
return schedulePanelActivation(() => setActivationReady(true));
}, [activationKey, shouldDelayActivation]);
if (!shouldKeepMounted) return null;
if (shouldDelayActivation && !activationReady) {
return <AIChatSidePanelPreparing />;
}
// Keep hidden panels alive only when they contain real work (messages, draft
// content, or an active stream). Empty hidden panels can drop their heavy
// input/agent-picker subtree and remount cheaply when shown again.

View File

@@ -22,7 +22,9 @@ export function isCopilotAgentConfig(agent?: ExternalAgentConfig): boolean {
getExternalAgentSdkBackend(agent),
]
.filter((value): value is string => typeof value === 'string' && value.length > 0)
.map((value) => value.split('/').pop()?.toLowerCase() ?? value.toLowerCase());
// Split on both separators so Windows command paths (e.g. "...\\copilot.exe")
// reduce to their basename rather than staying as the full path.
.map((value) => value.split(/[\\/]/).pop()?.toLowerCase() ?? value.toLowerCase());
return tokens.some((token) => token.includes('copilot'));
}

View File

@@ -5,12 +5,12 @@
import { AppWindow, Cloud, FileType, HardDrive, Keyboard, Palette, Sparkles, TerminalSquare, X } from "lucide-react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useSettingsState } from "../application/state/useSettingsState";
import { useAISettingsState } from "../application/state/useAISettingsState";
import { useAvailableFonts } from "../application/state/fontStore";
import { usePortForwardingState } from "../application/state/usePortForwardingState";
import { useVaultState } from "../application/state/useVaultState";
import { useWindowControls } from "../application/state/useWindowControls";
import { useUpdateCheck } from "../application/state/useUpdateCheck";
import { useAIState } from "../application/state/useAIState";
import { I18nProvider, useI18n } from "../application/i18n/I18nProvider";
import { sanitizePortForwardingRulesForSync } from "../application/syncPayload";
import { toast } from "./ui/toast";
@@ -126,7 +126,7 @@ const SettingsTerminalTabContainer = React.memo<TerminalTabSettingsProps>(functi
});
const SettingsAITabContainer: React.FC = () => {
const aiState = useAIState();
const aiState = useAISettingsState();
return (
<AITabErrorBoundary>
@@ -157,6 +157,8 @@ const SettingsAITabContainer: React.FC = () => {
setWebSearchConfig={aiState.setWebSearchConfig}
quickMessages={aiState.quickMessages}
setQuickMessages={aiState.setQuickMessages}
showTerminalSelectionAIAction={aiState.showTerminalSelectionAIAction}
setShowTerminalSelectionAIAction={aiState.setShowTerminalSelectionAIAction}
/>
</AITabErrorBoundary>
);
@@ -401,6 +403,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
setHotkeyScheme={settings.setHotkeyScheme}
shellOnlyTabNumberShortcuts={settings.shellOnlyTabNumberShortcuts}
setShellOnlyTabNumberShortcuts={settings.setShellOnlyTabNumberShortcuts}
disableTerminalFontZoom={settings.disableTerminalFontZoom}
setDisableTerminalFontZoom={settings.setDisableTerminalFontZoom}
keyBindings={settings.keyBindings}
updateKeyBinding={settings.updateKeyBinding}
resetKeyBinding={settings.resetKeyBinding}

View File

@@ -24,7 +24,7 @@ import { getParentPath, isConcreteTransferTargetPath } from "../application/stat
import { buildCacheKey } from "../application/state/sftp/sharedRemoteHostCache";
import { logger } from "../lib/logger";
import type { DropEntry } from "../lib/sftpFileUtils";
import { Host, Identity, SSHKey } from "../types";
import { Host, Identity, KnownHost, SSHKey } from "../types";
import type { TransferTask } from "../types";
import { toast } from "./ui/toast";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
@@ -47,7 +47,9 @@ interface SftpSidePanelProps {
writableHosts?: Host[];
keys: SSHKey[];
identities: Identity[];
knownHosts?: KnownHost[];
updateHosts: (hosts: Host[]) => void;
onAddKnownHost?: (knownHost: KnownHost) => void;
sftpDefaultViewMode: "list" | "tree";
/** The host to connect to (follows focused terminal) */
activeHost: Host | null;
@@ -87,7 +89,9 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
writableHosts,
keys,
identities,
knownHosts = [],
updateHosts,
onAddKnownHost,
sftpDefaultViewMode,
activeHost,
activeSessionId,
@@ -134,7 +138,9 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
defaultShowHiddenFiles: sftpShowHiddenFiles,
autoConnectLocalOnMount: false,
terminalSettings,
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles, terminalSettings]);
knownHosts,
onAddKnownHost,
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles, terminalSettings, knownHosts, onAddKnownHost]);
const sftp = useSftpState(hosts, keys, identities, sftpOptions);
const {
@@ -964,7 +970,9 @@ const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps):
prev.writableHosts === next.writableHosts &&
prev.keys === next.keys &&
prev.identities === next.identities &&
prev.knownHosts === next.knownHosts &&
prev.updateHosts === next.updateHosts &&
prev.onAddKnownHost === next.onAddKnownHost &&
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
prev.activeHost === next.activeHost &&
prev.activeSessionId === next.activeSessionId &&

View File

@@ -24,7 +24,7 @@ import { HotkeyScheme, KeyBinding } from "../domain/models";
import { logger } from "../lib/logger";
import { useRenderTracker } from "../lib/useRenderTracker";
import { cn } from "../lib/utils";
import { Host, Identity, ProxyProfile, SSHKey, TransferTask } from "../types";
import { Host, Identity, KnownHost, ProxyProfile, SSHKey, TransferTask } from "../types";
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
@@ -54,9 +54,11 @@ interface SftpViewProps {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
knownHosts?: KnownHost[];
groupConfigs?: import('../domain/models').GroupConfig[];
proxyProfiles?: ProxyProfile[];
updateHosts: (hosts: Host[]) => void;
onAddKnownHost?: (knownHost: KnownHost) => void;
sftpDefaultViewMode: "list" | "tree";
sftpDoubleClickBehavior: "open" | "transfer";
sftpAutoSync: boolean;
@@ -73,9 +75,11 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
hosts,
keys,
identities,
knownHosts = [],
groupConfigs = [],
proxyProfiles = [],
updateHosts,
onAddKnownHost,
sftpDefaultViewMode,
sftpDoubleClickBehavior,
sftpAutoSync,
@@ -110,7 +114,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
useCompressedUpload: sftpUseCompressedUpload,
defaultShowHiddenFiles: sftpShowHiddenFiles,
terminalSettings,
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles, terminalSettings]);
knownHosts,
onAddKnownHost,
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles, terminalSettings, knownHosts, onAddKnownHost]);
// Pre-resolve group defaults so SFTP connections inherit group config
const effectiveHosts = useMemo(() => {
@@ -374,9 +380,11 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
handleReorderTabsRight,
handleMoveTabFromLeftToRight,
handleMoveTabFromRightToLeft,
handleDuplicateTabLeft,
handleDuplicateTabRight,
handleHostSelectLeft,
handleHostSelectRight,
} = useSftpViewTabs({ sftp, sftpRef });
} = useSftpViewTabs({ sftp, sftpRef, hosts: effectiveHosts });
const handleAddTabLeftWithFocus = useCallback(() => {
const tabId = handleAddTabLeft();
@@ -398,6 +406,26 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
handlePaneFocus("right", tabId);
}, [handlePaneFocus, handleSelectTabRight]);
const handleDuplicateTabLeftWithFocus = useCallback(
async (...args: Parameters<typeof handleDuplicateTabLeft>) => {
const tabId = await handleDuplicateTabLeft(...args);
if (tabId) {
handlePaneFocus("left", tabId);
}
},
[handleDuplicateTabLeft, handlePaneFocus],
);
const handleDuplicateTabRightWithFocus = useCallback(
async (...args: Parameters<typeof handleDuplicateTabRight>) => {
const tabId = await handleDuplicateTabRight(...args);
if (tabId) {
handlePaneFocus("right", tabId);
}
},
[handleDuplicateTabRight, handlePaneFocus],
);
return (
<SftpContextProvider
hosts={effectiveHosts}
@@ -444,6 +472,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
onAddTab={handleAddTabLeftWithFocus}
onReorderTabs={handleReorderTabsLeft}
onMoveTabToOtherSide={handleMoveTabFromRightToLeft}
onDuplicateTab={handleDuplicateTabLeftWithFocus}
/>
)}
<div className="relative flex-1 min-h-0">
@@ -504,6 +533,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
onAddTab={handleAddTabRightWithFocus}
onReorderTabs={handleReorderTabsRight}
onMoveTabToOtherSide={handleMoveTabFromLeftToRight}
onDuplicateTab={handleDuplicateTabRightWithFocus}
/>
)}
<div className="relative flex-1 min-h-0">
@@ -588,9 +618,11 @@ const sftpViewAreEqual = (prev: SftpViewProps, next: SftpViewProps): boolean =>
prev.hosts === next.hosts &&
prev.keys === next.keys &&
prev.identities === next.identities &&
prev.knownHosts === next.knownHosts &&
prev.groupConfigs === next.groupConfigs &&
prev.proxyProfiles === next.proxyProfiles &&
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
prev.onAddKnownHost === next.onAddKnownHost &&
prev.sftpDoubleClickBehavior === next.sftpDoubleClickBehavior &&
prev.sftpAutoSync === next.sftpAutoSync &&
prev.sftpShowHiddenFiles === next.sftpShowHiddenFiles &&

View File

@@ -3,7 +3,7 @@ import { FitAddon } from "@xterm/addon-fit";
import { SerializeAddon } from "@xterm/addon-serialize";
import { SearchAddon } from "@xterm/addon-search";
import "@xterm/xterm/css/xterm.css";
import { Activity, Cpu, Copy, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine, Sparkles } from "lucide-react";
import { Activity, Cpu, Clock3, Copy, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine, Sparkles } from "lucide-react";
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { detectLocalOs } from "../lib/localShell";
@@ -22,8 +22,10 @@ import {
import {
applyCustomAccentToTerminalTheme,
resolveHostTerminalThemeId,
type TerminalHostUpdate,
} from "../domain/terminalAppearance";
import { classifyDistroId, shouldProbeSessionCwd } from "../domain/host";
import { supportsZmodemTerminalDragDrop } from "../lib/zmodemDragDrop";
import { resolveHostAuth } from "../domain/sshAuth";
import { useTerminalBackend } from "../application/state/useTerminalBackend";
import { useTerminalLayoutSuppressActive } from "../application/state/terminalLayoutSuppressStore";
@@ -93,6 +95,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
snippets,
snippetPackages = [],
compactToolbar = false,
lineTimestampsAvailable = true,
chainHosts = [],
themePreviewId,
knownHosts = [],
@@ -115,6 +118,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
reuseConnectionFromSessionId,
serialConfig,
hotkeyScheme = "disabled",
disableTerminalFontZoom = false,
keyBindings = [],
onHotkeyAction,
onTerminalFontSizeChange,
@@ -145,6 +149,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
sessionLog,
sshDebugLogEnabled,
sudoAutofillPassword,
showSelectionAIAction,
onAddSelectionToAI,
}) => {
const layoutSuppressActive = useTerminalLayoutSuppressActive();
@@ -187,6 +192,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const terminalSettingsRef = useRef(terminalSettings);
terminalSettingsRef.current = terminalSettings;
const handleUpdateHostFromTerminal = useCallback((hostUpdate: TerminalHostUpdate) => {
onUpdateHost?.(hostUpdate as Host);
}, [onUpdateHost]);
onTerminalDataCaptureRef.current = onTerminalDataCapture;
const isVisibleRef = useRef(isVisible);
isVisibleRef.current = isVisible;
@@ -215,9 +223,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}, [captureTerminalLogData]);
const hotkeySchemeRef = useRef(hotkeyScheme);
const disableTerminalFontZoomRef = useRef(disableTerminalFontZoom);
const keyBindingsRef = useRef(keyBindings);
const onHotkeyActionRef = useRef(onHotkeyAction);
hotkeySchemeRef.current = hotkeyScheme;
disableTerminalFontZoomRef.current = disableTerminalFontZoom;
keyBindingsRef.current = keyBindings;
onHotkeyActionRef.current = onHotkeyAction;
@@ -242,10 +252,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const terminalBackend = useTerminalBackend();
const {
resizeSession,
receiveSerialYmodem,
selectDirectory,
selectDirectoryAvailable,
selectFile,
selectFileAvailable,
sendSerialYmodem,
serialYmodemAvailable,
serialYmodemReceiveAvailable,
setSessionEncoding,
} = terminalBackend;
@@ -457,6 +471,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const detectedDeviceClass = classifyDistroId(host.distro);
const isNetworkDevice =
host.deviceType === 'network' || detectedDeviceClass === 'network-device';
const remoteDragDropUsesZmodem = supportsZmodemTerminalDragDrop(host, isNetworkDevice);
// Check if this is a local or serial connection (doesn't need connection dialog during connecting)
const isLocalConnection = host.protocol === "local";
@@ -500,7 +515,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
host,
pendingAuthRef,
termRef,
onUpdateHost,
onUpdateHost: handleUpdateHostFromTerminal,
onStartSession: (term) => {
const starters = sessionStartersRef.current;
if (!starters) return;
@@ -956,6 +971,43 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
}, [isSerialConnection, selectFile, selectFileAvailable, sendSerialYmodem, serialYmodemAvailable, sessionId, t]);
const handleReceiveYmodem = useCallback(async () => {
if (!isSerialConnection || statusRef.current !== "connected") return;
if (!selectDirectoryAvailable() || !serialYmodemReceiveAvailable()) {
toast.error(t("terminal.ymodem.unavailable"));
return;
}
try {
const destinationDir = await selectDirectory(t("terminal.ymodem.selectReceiveDirectory"));
if (!destinationDir) return;
toast.info(t("terminal.ymodem.receiveStarted"));
const result = await receiveSerialYmodem(sessionRef.current || sessionId, destinationDir);
if (result.success) {
if (result.fileCount && result.fileCount > 1) {
toast.success(t("terminal.ymodem.receiveCompleteMultiple", { count: result.fileCount }));
} else if (result.fileName) {
toast.success(t("terminal.ymodem.receiveComplete", { fileName: result.fileName }));
} else {
toast.success(t("terminal.ymodem.receiveEmpty"));
}
} else {
toast.error(t("terminal.ymodem.receiveFailed"));
}
} catch {
toast.error(t("terminal.ymodem.receiveFailed"));
}
}, [
isSerialConnection,
receiveSerialYmodem,
selectDirectory,
selectDirectoryAvailable,
serialYmodemReceiveAvailable,
sessionId,
t,
]);
const handleCancelConnect = () => {
if (pendingHostKeyRequestId) {
void terminalBackend.respondHostKeyVerification(pendingHostKeyRequestId, false);
@@ -1112,6 +1164,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
} = useTerminalDragDrop({
host,
isLocalConnection,
isNetworkDevice,
onOpenSftp,
resolveSftpInitialPath,
scrollToBottomAfterProgrammaticInput,
@@ -1147,10 +1200,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onSnippetClick={(snippet) => { void executeSnippet(snippet); }}
onOpenSFTP={handleOpenSFTP}
onSendYmodem={isSerialConnection ? handleSendYmodem : undefined}
onReceiveYmodem={isSerialConnection ? handleReceiveYmodem : undefined}
onOpenScripts={onOpenScripts ?? (() => {})}
onOpenHistory={onOpenHistory}
onOpenTheme={onOpenTheme ?? (() => {})}
onUpdateHost={onUpdateHost}
onUpdateHost={handleUpdateHostFromTerminal}
showClose={opts?.showClose}
onClose={() => onCloseSession?.(sessionId)}
isSearchOpen={isSearchOpen}
@@ -1164,6 +1218,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
compactToolbar,
executeSnippet,
handleOpenSFTP,
handleReceiveYmodem,
handleSendYmodem,
handleSetTerminalEncoding,
handleToggleSearch,
@@ -1178,7 +1233,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onOpenHistory,
onOpenTheme,
onToggleComposeBar,
onUpdateHost,
handleUpdateHostFromTerminal,
sessionId,
snippetPackages,
snippets,
@@ -1203,9 +1258,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const effectiveComposeBarOpen = inWorkspace ? !!isWorkspaceComposeBarOpen : isComposeBarOpen;
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef });
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef });
return <TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, containerRef, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onExpandToFocus, onOpenSystem, onSplitHorizontal, onSplitVertical, onToggleBroadcast, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, scrollToBottomAfterProgrammaticInput, searchMatchCount, selectionOverlayPosition, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, snippets, status, statusDotTone, sudoHintRef, sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />;
return <TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, remoteDragDropUsesZmodem, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onExpandToFocus, onOpenSystem, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, scrollToBottomAfterProgrammaticInput, searchMatchCount, selectionOverlayPosition, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, showSelectionAIAction, snippets, status, statusDotTone, sudoHintRef, sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />;
};
const Terminal = memo(TerminalComponent, terminalPropsAreEqual);

View File

@@ -3,6 +3,7 @@ import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef,
import { activeTabStore } from '../application/state/activeTabStore';
import { canReuseTerminalConnection } from '../application/state/terminalConnectionReuse';
import { resolveTerminalSessionExitIntent, type TerminalSessionExitEvent } from '../application/state/resolveTerminalSessionExitIntent';
import { prewarmAIStateStorageSnapshots } from '../application/state/aiStateSnapshots';
import {
getSessionActivityIdsToClear,
getValidSessionActivityIds,
@@ -98,6 +99,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
terminalFontFamilyId,
fontSize = 14,
hotkeyScheme = 'disabled',
disableTerminalFontZoom = false,
keyBindings = [],
onHotkeyAction,
onUpdateTerminalThemeId,
@@ -161,9 +163,23 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const cwdProbeCancelersRef = useRef<Map<string, () => void>>(new Map());
const cwdProbeGenerationRef = useRef<Map<string, number>>(new Map());
useEffect(() => {
const runPrewarm = () => prewarmAIStateStorageSnapshots();
if (typeof window.requestIdleCallback === 'function') {
const idleId = window.requestIdleCallback(runPrewarm, { timeout: 2500 });
return () => window.cancelIdleCallback(idleId);
}
const timeoutId = window.setTimeout(runPrewarm, 500);
return () => window.clearTimeout(timeoutId);
}, []);
const handleTerminalCwdChange = useCallback((sessionId: string, cwd: string | null) => {
if (cwd && cwd.trim().length > 0) {
terminalRendererCwdBySessionRef.current.set(sessionId, cwd);
const currentCwd = terminalRendererCwdBySessionRef.current.get(sessionId) ?? null;
const nextCwd = cwd && cwd.trim().length > 0 ? cwd : null;
if (currentCwd === nextCwd) return;
if (nextCwd) {
terminalRendererCwdBySessionRef.current.set(sessionId, nextCwd);
} else {
terminalRendererCwdBySessionRef.current.delete(sessionId);
}
@@ -1103,6 +1119,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
hosts,
hostsRef,
hotkeyScheme,
disableTerminalFontZoom,
identities,
isBroadcastEnabled,
isComposeBarOpen,

View File

@@ -7,9 +7,11 @@ import { useTerminalPopupWindow } from '../application/state/useTerminalPopupWin
import { useVaultState } from '../application/state/useVaultState';
import { useWindowControls } from '../application/state/useWindowControls';
import { shouldCloseTerminalPopupOnExit } from '../application/state/resolveTerminalSessionExitIntent';
import { upsertKnownHost } from '../domain/knownHosts';
import type { TerminalPopupPayload } from '../domain/systemManager/types';
import type { TerminalTheme } from '../domain/models';
import type { Host } from '../types';
import type { Host, KnownHost } from '../types';
import { getEffectiveKnownHosts } from '../infrastructure/syncHelpers';
import { cn } from '../lib/utils';
const Terminal = lazy(() => import('./Terminal'));
@@ -195,11 +197,22 @@ function TerminalPopupPageInner() {
const { close, setWindowTitle, onPopupConfig } = useTerminalPopupWindow();
const { notifyRendererReady, onWindowCommandCloseRequested } = useWindowControls();
const settings = useSettingsState();
const { isInitialized: vaultInitialized, hosts, keys, identities, knownHosts, snippets, snippetPackages } = useVaultState();
const { isInitialized: vaultInitialized, hosts, keys, identities, knownHosts, snippets, snippetPackages, updateKnownHosts } = useVaultState();
const [config, setConfig] = useState<TerminalPopupPayload | null>(null);
const [terminalReady, setTerminalReady] = useState(false);
const [startupError, setStartupError] = useState<string | null>(null);
const sessionId = useMemo(() => crypto.randomUUID(), []);
const knownHostsRef = React.useRef(knownHosts);
const effectiveKnownHosts = useMemo(
() => getEffectiveKnownHosts(knownHosts) ?? [],
[knownHosts],
);
knownHostsRef.current = effectiveKnownHosts;
const handleAddKnownHost = useCallback((knownHost: KnownHost) => {
const nextKnownHosts = upsertKnownHost(knownHostsRef.current, knownHost);
knownHostsRef.current = nextKnownHosts;
updateKnownHosts(nextKnownHosts);
}, [updateKnownHosts]);
const popupThemeVars = useMemo(
() => buildPopupThemeVars(settings.currentTerminalTheme),
[settings.currentTerminalTheme],
@@ -306,7 +319,9 @@ function TerminalPopupPageInner() {
snippets={snippets}
snippetPackages={snippetPackages}
compactToolbar
knownHosts={knownHosts}
lineTimestampsAvailable={false}
knownHosts={effectiveKnownHosts}
onAddKnownHost={handleAddKnownHost}
isVisible
isFocused
fontFamilyId={settings.terminalFontFamilyId}
@@ -316,6 +331,7 @@ function TerminalPopupPageInner() {
accentMode={settings.accentMode}
customAccent={settings.customAccent}
terminalSettings={settings.terminalSettings}
disableTerminalFontZoom={settings.disableTerminalFontZoom}
sessionId={sessionId}
startupCommand={config.startupCommand}
reuseConnectionFromSessionId={reuseId}

View File

@@ -0,0 +1,38 @@
export const USER_SKILLS_STATUS_CHANGED_EVENT = 'netcatty:user-skills-status-changed';
const USER_SKILLS_STATUS_CHANGED_KEY = 'ai:user-skills-status-changed';
type SettingsBridge = {
notifySettingsChanged?: (payload: { key: string; value: unknown }) => void;
onSettingsChanged?: (callback: (payload: { key: string; value: unknown }) => void) => () => void;
};
function getSettingsBridge(): SettingsBridge | undefined {
return (window as unknown as { netcatty?: SettingsBridge }).netcatty;
}
export function notifyUserSkillsStatusChanged() {
if (typeof window === 'undefined') return;
window.dispatchEvent(new Event(USER_SKILLS_STATUS_CHANGED_EVENT));
getSettingsBridge()?.notifySettingsChanged?.({
key: USER_SKILLS_STATUS_CHANGED_KEY,
value: Date.now(),
});
}
export function subscribeUserSkillsStatusChanged(callback: () => void): () => void {
if (typeof window === 'undefined') return () => {};
const handleLocalEvent = () => callback();
window.addEventListener(USER_SKILLS_STATUS_CHANGED_EVENT, handleLocalEvent);
const unsubscribeSettings = getSettingsBridge()?.onSettingsChanged?.((payload) => {
if (payload.key === USER_SKILLS_STATUS_CHANGED_KEY) {
callback();
}
});
return () => {
window.removeEventListener(USER_SKILLS_STATUS_CHANGED_EVENT, handleLocalEvent);
unsubscribeSettings?.();
};
}

View File

@@ -0,0 +1,18 @@
import assert from "node:assert/strict";
import test from "node:test";
import * as terminalBehaviorSettings from "./tabs/TerminalBehaviorSettings.tsx";
const middleClickBehaviorOptions = (
terminalBehaviorSettings as {
MIDDLE_CLICK_BEHAVIOR_OPTIONS?: Array<{ value: string; labelKey: string }>;
}
).MIDDLE_CLICK_BEHAVIOR_OPTIONS;
test("middle-click settings expose only supported behaviors", () => {
assert.ok(Array.isArray(middleClickBehaviorOptions));
assert.deepEqual(
middleClickBehaviorOptions.map((option) => option.value),
["context-menu", "paste", "disabled"],
);
});

View File

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

View File

@@ -21,9 +21,11 @@ import type { ManagedAgentKey } from "../../../infrastructure/ai/managedAgents";
import { PROVIDER_PRESETS } from "../../../infrastructure/ai/types";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { Button } from "../../ui/button";
import { Select, SettingCard, SettingsSection, SettingsTabContent, SettingRow } from "../settings-ui";
import { Select, SettingCard, SettingsSection, SettingsTabContent, SettingRow, Toggle } from "../settings-ui";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../ui/tabs";
import { AgentIconBadge } from "../../ai/AgentIconBadge";
import { canSendWithAgent } from "../../ai/agentSendEligibility";
import { notifyUserSkillsStatusChanged } from "../../ai/userSkillsStatusEvents";
import type {
AgentPathInfo,
@@ -56,6 +58,57 @@ import {
import { splitClaudeEnv, buildClaudeEnv } from "./ai/claudeConfigEnv";
import { splitCodebuddyEnv } from "./ai/codebuddyConfigEnv";
type IdleWindow = Window & {
requestIdleCallback?: (callback: IdleRequestCallback, options?: IdleRequestOptions) => number;
cancelIdleCallback?: (handle: number) => void;
};
function scheduleAfterFirstPaint(callback: () => void, delayMs = 0): () => void {
let cancelled = false;
let idleHandle: number | null = null;
const timeoutHandle = window.setTimeout(() => {
if (cancelled) return;
const idleWindow = window as IdleWindow;
if (typeof idleWindow.requestIdleCallback === "function") {
idleHandle = idleWindow.requestIdleCallback(() => {
if (!cancelled) callback();
}, { timeout: 1200 });
return;
}
callback();
}, delayMs);
return () => {
cancelled = true;
window.clearTimeout(timeoutHandle);
if (idleHandle !== null) {
(window as IdleWindow).cancelIdleCallback?.(idleHandle);
}
};
}
type AISettingsSubTab = "providers" | "agents" | "tools" | "search" | "safety";
function getSavedManagedAgentPathInfo(
agents: ExternalAgentConfig[],
agentKey: ManagedAgentKey,
): AgentPathInfo | null {
const managed = agents.find((agent) => agent.id === `discovered_${agentKey}`);
const command = typeof managed?.command === "string" ? managed.command.trim() : "";
if (!managed || !command) return null;
const savedAvailable = managed.available === true || managed.enabled === true;
return {
path: command,
binPath: command,
version: null,
available: savedAvailable,
installed: true,
authenticated: undefined,
authSource: null,
};
}
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
@@ -87,6 +140,8 @@ interface SettingsAITabProps {
setWebSearchConfig: (config: WebSearchConfig | null) => void;
quickMessages: AIQuickMessage[];
setQuickMessages: (value: AIQuickMessage[] | ((prev: AIQuickMessage[]) => AIQuickMessage[])) => void;
showTerminalSelectionAIAction: boolean;
setShowTerminalSelectionAIAction: (value: boolean | ((prev: boolean) => boolean)) => void;
}
// ---------------------------------------------------------------------------
@@ -120,6 +175,8 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
setWebSearchConfig,
quickMessages,
setQuickMessages,
showTerminalSelectionAIAction,
setShowTerminalSelectionAIAction,
}) => {
const { t } = useI18n();
const [editingProviderId, setEditingProviderId] = useState<string | null>(null);
@@ -127,14 +184,29 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
const [codexLoginSession, setCodexLoginSession] = useState<CodexLoginSession | null>(null);
const [isCodexLoading, setIsCodexLoading] = useState(false);
const [codexError, setCodexError] = useState<string | null>(null);
const initialManagedPathsRef = useRef<{
codex: string;
claude: string;
copilot: string;
cursor: string;
codebuddy: string;
} | null>(null);
if (!initialManagedPathsRef.current) {
initialManagedPathsRef.current = getInitialManagedAgentPaths(externalAgents);
}
// Path detection state
const [codexPathInfo, setCodexPathInfo] = useState<AgentPathInfo | null>(null);
const [codexCustomPath, setCodexCustomPath] = useState("");
const [codexPathInfo, setCodexPathInfo] = useState<AgentPathInfo | null>(
() => getSavedManagedAgentPathInfo(externalAgents, "codex"),
);
const [codexCustomPath, setCodexCustomPath] = useState(() => initialManagedPathsRef.current?.codex ?? "");
const [isResolvingCodex, setIsResolvingCodex] = useState(false);
const [activeSubTab, setActiveSubTab] = useState<AISettingsSubTab>("providers");
const [claudePathInfo, setClaudePathInfo] = useState<AgentPathInfo | null>(null);
const [claudeCustomPath, setClaudeCustomPath] = useState("");
const [claudePathInfo, setClaudePathInfo] = useState<AgentPathInfo | null>(
() => getSavedManagedAgentPathInfo(externalAgents, "claude"),
);
const [claudeCustomPath, setClaudeCustomPath] = useState(() => initialManagedPathsRef.current?.claude ?? "");
const [isResolvingClaude, setIsResolvingClaude] = useState(false);
const claudeManagedEnv = useMemo(
@@ -160,26 +232,21 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
[setExternalAgents],
);
const initialManagedPathsRef = useRef<{
codex: string;
claude: string;
copilot: string;
cursor: string;
codebuddy: string;
} | null>(null);
if (!initialManagedPathsRef.current) {
initialManagedPathsRef.current = getInitialManagedAgentPaths(externalAgents);
}
const [copilotPathInfo, setCopilotPathInfo] = useState<AgentPathInfo | null>(null);
const [copilotCustomPath, setCopilotCustomPath] = useState("");
const [copilotPathInfo, setCopilotPathInfo] = useState<AgentPathInfo | null>(
() => getSavedManagedAgentPathInfo(externalAgents, "copilot"),
);
const [copilotCustomPath, setCopilotCustomPath] = useState(() => initialManagedPathsRef.current?.copilot ?? "");
const [isResolvingCopilot, setIsResolvingCopilot] = useState(false);
const [cursorPathInfo, setCursorPathInfo] = useState<AgentPathInfo | null>(null);
const [cursorPathInfo, setCursorPathInfo] = useState<AgentPathInfo | null>(
() => getSavedManagedAgentPathInfo(externalAgents, "cursor"),
);
const [isResolvingCursor, setIsResolvingCursor] = useState(false);
const [codebuddyPathInfo, setCodebuddyPathInfo] = useState<AgentPathInfo | null>(null);
const [codebuddyCustomPath, setCodebuddyCustomPath] = useState("");
const [codebuddyPathInfo, setCodebuddyPathInfo] = useState<AgentPathInfo | null>(
() => getSavedManagedAgentPathInfo(externalAgents, "codebuddy"),
);
const [codebuddyCustomPath, setCodebuddyCustomPath] = useState(() => initialManagedPathsRef.current?.codebuddy ?? "");
const [isResolvingCodebuddy, setIsResolvingCodebuddy] = useState(false);
const codebuddyManagedEnv = useMemo(
@@ -209,15 +276,22 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
// Ref to read current defaultAgentId without adding it as a dependency.
const defaultAgentIdRef = useRef(defaultAgentId);
defaultAgentIdRef.current = defaultAgentId;
const autoResolvedAgentStateRef = useRef<Partial<Record<ManagedAgentKey, "pending" | "done">>>({});
const codexIntegrationLoadedRef = useRef(false);
const userSkillsLoadedRef = useRef(false);
const mountedRef = useRef(true);
const agentPathRequestIdRef = useRef<Partial<Record<ManagedAgentKey, number>>>({});
const codexRequestIdRef = useRef(0);
const resolveAgentPath = useCallback(async (
agentKey: ManagedAgentKey,
customPath = "",
options?: { apiKeyPresent?: boolean },
) => {
const bridge = getBridge();
if (!bridge?.aiResolveCli) return null;
useEffect(() => () => {
mountedRef.current = false;
codexRequestIdRef.current += 1;
for (const key of ["codex", "claude", "copilot", "cursor", "codebuddy"] as ManagedAgentKey[]) {
agentPathRequestIdRef.current[key] = (agentPathRequestIdRef.current[key] ?? 0) + 1;
}
}, []);
const applyResolvedAgentPath = useCallback((agentKey: ManagedAgentKey, result: AgentPathInfo | null) => {
const setInfo = agentKey === "codex"
? setCodexPathInfo
: agentKey === "claude"
@@ -227,6 +301,31 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
: agentKey === "cursor"
? setCursorPathInfo
: setCodebuddyPathInfo;
setInfo(result);
let nextDefaultId: string | null = null;
setExternalAgents((prev) => {
const state = buildManagedAgentState(prev, defaultAgentIdRef.current, agentKey, result);
if (state.defaultAgentId !== defaultAgentIdRef.current) {
nextDefaultId = state.defaultAgentId;
defaultAgentIdRef.current = state.defaultAgentId;
}
return areExternalAgentListsEqual(prev, state.agents) ? prev : state.agents;
});
if (nextDefaultId !== null) {
setDefaultAgentId(nextDefaultId);
}
}, [setDefaultAgentId, setExternalAgents]);
const resolveAgentPath = useCallback(async (
agentKey: ManagedAgentKey,
customPath = "",
options?: { apiKeyPresent?: boolean; refreshShellEnv?: boolean },
) => {
const bridge = getBridge();
if (!bridge?.aiResolveCli) return null;
const setResolving = agentKey === "codex"
? setIsResolvingCodex
: agentKey === "claude"
@@ -238,49 +337,66 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
: setIsResolvingCodebuddy;
setResolving(true);
const requestId = (agentPathRequestIdRef.current[agentKey] ?? 0) + 1;
agentPathRequestIdRef.current[agentKey] = requestId;
const isCurrentRequest = () => (
mountedRef.current
&& agentPathRequestIdRef.current[agentKey] === requestId
);
try {
const result = await bridge.aiResolveCli({
command: agentKey,
customPath: customPath.trim(),
refreshShellEnv: agentKey === "cursor",
refreshShellEnv: Boolean(options?.refreshShellEnv),
...(agentKey === "cursor" ? { apiKeyPresent: Boolean(options?.apiKeyPresent ?? cursorApiKeyEncrypted) } : {}),
});
setInfo(result);
// Consolidate managed agent entries using the callback form of
// setExternalAgents so we never depend on externalAgents directly.
// All three agents resolve concurrently on mount — React runs
// state updater callbacks sequentially, so updating the ref inside
// ensures later calls see earlier defaultAgentId changes.
let nextDefaultId: string | null = null;
setExternalAgents((prev) => {
const state = buildManagedAgentState(prev, defaultAgentIdRef.current, agentKey, result);
if (state.defaultAgentId !== defaultAgentIdRef.current) {
nextDefaultId = state.defaultAgentId;
defaultAgentIdRef.current = state.defaultAgentId;
}
return areExternalAgentListsEqual(prev, state.agents) ? prev : state.agents;
});
if (nextDefaultId !== null) {
setDefaultAgentId(nextDefaultId);
}
if (!isCurrentRequest()) return null;
applyResolvedAgentPath(agentKey, result);
return result;
} catch (err) {
console.error("Path resolution failed:", err);
return null;
} finally {
setResolving(false);
if (isCurrentRequest()) {
setResolving(false);
}
}
}, [cursorApiKeyEncrypted, setExternalAgents, setDefaultAgentId]);
}, [applyResolvedAgentPath, cursorApiKeyEncrypted]);
useEffect(() => {
void resolveAgentPath("codex", initialManagedPathsRef.current?.codex ?? "");
void resolveAgentPath("claude", initialManagedPathsRef.current?.claude ?? "");
void resolveAgentPath("copilot", initialManagedPathsRef.current?.copilot ?? "");
void resolveAgentPath("cursor", initialManagedPathsRef.current?.cursor ?? "", { apiKeyPresent: Boolean(cursorApiKeyEncrypted) });
void resolveAgentPath("codebuddy", initialManagedPathsRef.current?.codebuddy ?? "");
}, [cursorApiKeyEncrypted, resolveAgentPath]);
if (activeSubTab !== "agents") return;
const initialPaths = initialManagedPathsRef.current;
const tasks: Array<{
key: ManagedAgentKey;
delayMs: number;
path: string;
options?: { apiKeyPresent?: boolean };
}> = [
{ key: "codex", delayMs: 160, path: initialPaths?.codex ?? "" },
{ key: "claude", delayMs: 440, path: initialPaths?.claude ?? "" },
{ key: "copilot", delayMs: 720, path: initialPaths?.copilot ?? "" },
{
key: "cursor",
delayMs: 1000,
path: initialPaths?.cursor ?? "",
options: { apiKeyPresent: Boolean(cursorApiKeyEncrypted) },
},
{ key: "codebuddy", delayMs: 1280, path: initialPaths?.codebuddy ?? "" },
];
const cancelTasks = tasks
.filter((task) => !autoResolvedAgentStateRef.current[task.key])
.map((task) => scheduleAfterFirstPaint(() => {
autoResolvedAgentStateRef.current[task.key] = "pending";
void resolveAgentPath(task.key, task.path, task.options).finally(() => {
autoResolvedAgentStateRef.current[task.key] = "done";
});
}, task.delayMs));
return () => {
for (const cancel of cancelTasks) cancel();
};
}, [activeSubTab, cursorApiKeyEncrypted, resolveAgentPath]);
// Validate a custom path for an agent
const handleCheckCustomPath = useCallback(async (agentKey: ManagedAgentKey) => {
@@ -293,7 +409,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
: agentKey === "codebuddy"
? codebuddyCustomPath
: "";
await resolveAgentPath(agentKey, customPath);
await resolveAgentPath(agentKey, customPath, { refreshShellEnv: true });
}, [claudeCustomPath, codexCustomPath, copilotCustomPath, codebuddyCustomPath, resolveAgentPath]);
const handleSaveCursorApiKey = useCallback(async (apiKey: string) => {
@@ -373,25 +489,46 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
}
}, [agentOptions, defaultAgentId, setDefaultAgentId]);
const refreshCodexIntegration = useCallback(async (opts?: { refreshShellEnv?: boolean }) => {
useEffect(() => {
const bridge = getBridge();
if (!bridge?.aiPrewarmShellEnv) return;
return scheduleAfterFirstPaint(() => {
void bridge.aiPrewarmShellEnv?.();
}, 900);
}, []);
const refreshCodexIntegration = useCallback(async (opts?: { refreshShellEnv?: boolean; validateChatGptAuth?: boolean }) => {
const bridge = getBridge();
if (!bridge?.aiCodexGetIntegration) return;
const requestId = codexRequestIdRef.current + 1;
codexRequestIdRef.current = requestId;
const isCurrentRequest = () => mountedRef.current && codexRequestIdRef.current === requestId;
setIsCodexLoading(true);
setCodexError(null);
try {
const integration = await bridge.aiCodexGetIntegration(opts);
if (!isCurrentRequest()) return;
setCodexIntegration(integration);
} catch (err) {
setCodexError(normalizeCodexBridgeError(err));
if (isCurrentRequest()) {
setCodexError(normalizeCodexBridgeError(err));
}
} finally {
setIsCodexLoading(false);
if (isCurrentRequest()) {
setIsCodexLoading(false);
}
}
}, []);
useEffect(() => {
void refreshCodexIntegration();
}, [refreshCodexIntegration]);
if (activeSubTab !== "agents") return;
if (codexIntegrationLoadedRef.current) return;
return scheduleAfterFirstPaint(() => {
codexIntegrationLoadedRef.current = true;
void refreshCodexIntegration();
}, 620);
}, [activeSubTab, refreshCodexIntegration]);
useEffect(() => {
if (!codexLoginSession || codexLoginSession.state !== "running") {
@@ -411,7 +548,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
setCodexLoginSession(result.session);
if (result.session.state !== "running") {
if (result.session.state === "success") {
void refreshCodexIntegration();
void refreshCodexIntegration({ validateChatGptAuth: true });
}
}
}).catch((err) => {
@@ -431,18 +568,26 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
const bridge = getBridge();
if (!bridge?.aiCodexStartLogin) return;
const requestId = codexRequestIdRef.current + 1;
codexRequestIdRef.current = requestId;
const isCurrentRequest = () => mountedRef.current && codexRequestIdRef.current === requestId;
setCodexError(null);
setIsCodexLoading(true);
try {
const result = await bridge.aiCodexStartLogin();
if (!isCurrentRequest()) return;
if (!result.ok || !result.session) {
throw new Error(result.error || "Failed to start Codex login");
}
setCodexLoginSession(result.session);
} catch (err) {
setCodexError(normalizeCodexBridgeError(err));
if (isCurrentRequest()) {
setCodexError(normalizeCodexBridgeError(err));
}
} finally {
setIsCodexLoading(false);
if (isCurrentRequest()) {
setIsCodexLoading(false);
}
}
}, []);
@@ -474,19 +619,27 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
const bridge = getBridge();
if (!bridge?.aiCodexLogout) return;
const requestId = codexRequestIdRef.current + 1;
codexRequestIdRef.current = requestId;
const isCurrentRequest = () => mountedRef.current && codexRequestIdRef.current === requestId;
setCodexError(null);
setIsCodexLoading(true);
try {
const result = await bridge.aiCodexLogout();
if (!isCurrentRequest()) return;
if (!result.ok) {
throw new Error(result.error || "Failed to log out from Codex");
}
setCodexLoginSession(null);
await refreshCodexIntegration();
await refreshCodexIntegration({ refreshShellEnv: true, validateChatGptAuth: true });
} catch (err) {
setCodexError(normalizeCodexBridgeError(err));
if (isCurrentRequest()) {
setCodexError(normalizeCodexBridgeError(err));
}
} finally {
setIsCodexLoading(false);
if (isCurrentRequest()) {
setIsCodexLoading(false);
}
}
}, [refreshCodexIntegration]);
@@ -504,6 +657,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
try {
const result = await bridge.aiUserSkillsGetStatus();
setUserSkillsStatus(result);
notifyUserSkillsStatusChanged();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setUserSkillsStatus({ ok: false, error: message });
@@ -513,14 +667,13 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
}, [t]);
useEffect(() => {
let cancelled = false;
void refreshUserSkillsStatus().then(() => {
if (cancelled) return;
});
return () => {
cancelled = true;
};
}, [refreshUserSkillsStatus]);
if (activeSubTab !== "tools") return;
if (userSkillsLoadedRef.current) return;
return scheduleAfterFirstPaint(() => {
userSkillsLoadedRef.current = true;
void refreshUserSkillsStatus();
}, 520);
}, [activeSubTab, refreshUserSkillsStatus]);
const reservedUserSkillSlugs = useMemo(
() => (userSkillsStatus?.ok && userSkillsStatus.skills
@@ -539,6 +692,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
try {
const result = await bridge.aiUserSkillsOpenFolder();
setUserSkillsStatus(result);
notifyUserSkillsStatusChanged();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setUserSkillsStatus({ ok: false, error: message });
@@ -549,6 +703,16 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
return (
<SettingsTabContent value="ai">
<Tabs value={activeSubTab} onValueChange={(value) => setActiveSubTab(value as AISettingsSubTab)} className="space-y-5">
<TabsList className="h-auto flex-wrap justify-start bg-muted/50">
<TabsTrigger value="providers">{t('ai.providers')}</TabsTrigger>
<TabsTrigger value="agents">{t('ai.agents')}</TabsTrigger>
<TabsTrigger value="tools">{t('ai.toolAccess.title')}</TabsTrigger>
<TabsTrigger value="search">{t("ai.webSearch.title")}</TabsTrigger>
<TabsTrigger value="safety">{t('ai.safety.title')}</TabsTrigger>
</TabsList>
<TabsContent value="providers" className="m-0 space-y-6">
<SettingsSection
title={t('ai.providers')}
actions={<AddProviderDropdown onAdd={handleAddProvider} />}
@@ -569,7 +733,6 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
isActive={provider.id === activeProviderId}
onToggleEnabled={(enabled) => {
if (enabled) {
// Activate this provider, deactivate all others
setActiveProviderId(provider.id);
if (provider.defaultModel) {
setActiveModelId(provider.defaultModel);
@@ -577,12 +740,11 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
for (const p of providers) {
if (p.id === provider.id) {
if (!p.enabled) updateProvider(p.id, { enabled: true });
} else {
if (p.enabled) updateProvider(p.id, { enabled: false });
} else if (p.enabled) {
updateProvider(p.id, { enabled: false });
}
}
} else {
// Deactivate this provider
if (activeProviderId === provider.id) {
setActiveProviderId("");
setActiveModelId("");
@@ -598,7 +760,6 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
onRemove={() => handleRemoveProvider(provider.id)}
onUpdate={(updates) => {
updateProvider(provider.id, updates);
// If this is the active provider and model changed, update activeModelId
if (provider.id === activeProviderId && updates.defaultModel !== undefined) {
setActiveModelId(updates.defaultModel || "");
}
@@ -610,7 +771,9 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
</div>
)}
</SettingsSection>
</TabsContent>
<TabsContent value="agents" className="m-0 space-y-6">
<SettingsSection
title={t('ai.codex')}
leading={<AgentIconBadge agent={{ id: "codex", icon: "openai", name: "Codex CLI" }} variant="plain" className="h-5 w-5 text-muted-foreground/90" />}
@@ -625,7 +788,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
loginSession={codexLoginSession}
isLoading={isCodexLoading}
error={codexError}
onRefresh={() => void refreshCodexIntegration({ refreshShellEnv: true })}
onRefresh={() => void refreshCodexIntegration({ refreshShellEnv: true, validateChatGptAuth: true })}
onConnect={() => void handleStartCodexLogin()}
onCancel={() => void handleCancelCodexLogin()}
onOpenUrl={handleOpenCodexLoginUrl}
@@ -709,6 +872,23 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
</SettingCard>
</SettingsSection>
)}
</TabsContent>
<TabsContent value="tools" className="m-0 space-y-6">
<SettingsSection title={t('ai.chatShortcuts.title')}>
<SettingCard divided>
<SettingRow
label={t('ai.chatShortcuts.selectionAction')}
description={t('ai.chatShortcuts.selectionAction.description')}
>
<Toggle
checked={showTerminalSelectionAIAction}
onChange={setShowTerminalSelectionAIAction}
ariaLabel={t('ai.chatShortcuts.selectionAction')}
/>
</SettingRow>
</SettingCard>
</SettingsSection>
<SettingsSection title={t('ai.toolAccess.title')}>
<SettingCard>
@@ -778,10 +958,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
{userSkillsStatus?.ok && userSkillsStatus.skills && userSkillsStatus.skills.length > 0 ? (
<div className="border-t border-border/60 divide-y divide-border/60">
{userSkillsStatus.skills.map((skill) => (
<div
key={skill.id}
className="py-3"
>
<div key={skill.id} className="py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="text-sm font-medium">{skill.name}</div>
@@ -828,13 +1005,16 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
setQuickMessages={setQuickMessages}
reservedUserSkillSlugs={reservedUserSkillSlugs}
/>
</TabsContent>
<TabsContent value="search" className="m-0 space-y-6">
<WebSearchSettings
webSearchConfig={webSearchConfig}
setWebSearchConfig={setWebSearchConfig}
/>
</TabsContent>
{/* -- Safety Section -- */}
<TabsContent value="safety" className="m-0 space-y-6">
<SafetySettings
globalPermissionMode={globalPermissionMode}
setGlobalPermissionMode={setGlobalPermissionMode}
@@ -845,6 +1025,8 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
maxIterations={maxIterations}
setMaxIterations={setMaxIterations}
/>
</TabsContent>
</Tabs>
</SettingsTabContent>
);
};

View File

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

View File

@@ -1,5 +1,5 @@
import React from "react";
import type { LinkModifier, RightClickBehavior, TerminalSettings } from "../../../domain/models";
import type { LinkModifier, MiddleClickBehavior, RightClickBehavior, TerminalSettings } from "../../../domain/models";
import { Input } from "../../ui/input";
import { Label } from "../../ui/label";
import { SectionHeader, Select, SettingRow, Toggle } from "../settings-ui";
@@ -12,6 +12,15 @@ interface TerminalBehaviorSettingsProps {
updateTerminalSetting: <K extends keyof TerminalSettings>(key: K, value: TerminalSettings[K]) => void;
}
export const MIDDLE_CLICK_BEHAVIOR_OPTIONS: Array<{
value: MiddleClickBehavior;
labelKey: string;
}> = [
{ value: "context-menu", labelKey: "settings.terminal.behavior.middleClick.menu" },
{ value: "paste", labelKey: "settings.terminal.behavior.middleClick.paste" },
{ value: "disabled", labelKey: "settings.terminal.behavior.middleClick.disabled" },
];
export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> = ({
t,
terminalSettings,
@@ -44,10 +53,18 @@ export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> =
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.middleClickPaste")}
description={t("settings.terminal.behavior.middleClickPaste.desc")}
label={t("settings.terminal.behavior.middleClick")}
description={t("settings.terminal.behavior.middleClick.desc")}
>
<Toggle checked={terminalSettings.middleClickPaste} onChange={(v) => updateTerminalSetting("middleClickPaste", v)} />
<Select
value={terminalSettings.middleClickBehavior}
options={MIDDLE_CLICK_BEHAVIOR_OPTIONS.map((option) => ({
value: option.value,
label: t(option.labelKey),
}))}
onChange={(v) => updateTerminalSetting("middleClickBehavior", v as MiddleClickBehavior)}
className="w-36"
/>
</SettingRow>
<SettingRow

View File

@@ -103,7 +103,9 @@ export interface FetchBridge {
}
export interface NetcattyAiBridge {
aiCodexGetIntegration?: (options?: { refreshShellEnv?: boolean }) => Promise<CodexIntegrationStatus>;
aiDiscoverAgents?: (options?: { refreshShellEnv?: boolean; apiKeyPresent?: boolean }) => Promise<Array<AgentPathInfo & { command: string }>>;
aiPrewarmShellEnv?: () => Promise<{ ok: boolean; error?: string }>;
aiCodexGetIntegration?: (options?: { refreshShellEnv?: boolean; validateChatGptAuth?: boolean }) => Promise<CodexIntegrationStatus>;
aiCodexStartLogin?: () => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
aiCodexGetLoginSession?: (sessionId: string) => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
aiCodexCancelLogin?: (sessionId: string) => Promise<{ ok: boolean; found?: boolean; session?: CodexLoginSession; error?: string }>;

View File

@@ -0,0 +1,25 @@
import test from "node:test";
import assert from "node:assert/strict";
import { canReplaceConflict } from "./SftpConflictDialog.tsx";
test("does not offer replace when a file upload conflicts with an existing directory", () => {
assert.equal(canReplaceConflict({
isDirectory: false,
existingType: "directory",
}), false);
});
test("does not offer replace when a directory upload conflicts with an existing file", () => {
assert.equal(canReplaceConflict({
isDirectory: true,
existingType: "file",
}), false);
});
test("offers replace when a file upload conflicts with an existing file", () => {
assert.equal(canReplaceConflict({
isDirectory: false,
existingType: "file",
}), true);
});

View File

@@ -5,6 +5,7 @@
import { AlertCircle } from 'lucide-react';
import React, { memo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { canReplaceSftpConflict, getSftpConflictTypeKey } from '../../domain/sftpConflict';
import { Button } from '../ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
import type { FileConflictAction } from '../../domain/models';
@@ -23,12 +24,53 @@ interface ConflictItem {
newModified: number;
}
export const canReplaceConflict = (conflict: Pick<ConflictItem, 'isDirectory' | 'existingType'>): boolean => {
return canReplaceSftpConflict(conflict.isDirectory, conflict.existingType);
};
const getConflictTypeKey = (conflict: Pick<ConflictItem, 'isDirectory' | 'existingType'>): string =>
getSftpConflictTypeKey(conflict.isDirectory, conflict.existingType);
interface SftpConflictDialogProps {
conflicts: ConflictItem[];
onResolve: (conflictId: string, action: FileConflictAction, applyToAll?: boolean) => void;
formatFileSize: (size: number) => string;
}
interface ConflictFileSummaryProps {
title: string;
sizeLabel: string;
modifiedLabel: string;
size: string;
modified: string;
}
const ConflictFileSummary: React.FC<ConflictFileSummaryProps> = ({
title,
sizeLabel,
modifiedLabel,
size,
modified,
}) => (
<div className="rounded-md border border-border/60 bg-secondary/25 px-4 py-3">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-sm font-medium text-foreground">
{title}
</div>
</div>
<dl className="space-y-2 text-sm">
<div className="grid grid-cols-[5.5rem_minmax(0,1fr)] gap-3">
<dt className="text-muted-foreground">{sizeLabel}</dt>
<dd className="min-w-0 text-foreground">{size}</dd>
</div>
<div className="grid grid-cols-[5.5rem_minmax(0,1fr)] gap-3">
<dt className="text-muted-foreground">{modifiedLabel}</dt>
<dd className="min-w-0 break-words leading-relaxed text-foreground">{modified}</dd>
</div>
</dl>
</div>
);
const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts, onResolve, formatFileSize }) => {
const { t } = useI18n();
const [applyToAll, setApplyToAll] = useState(false);
@@ -42,9 +84,10 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
const sameTypeConflictCount = Math.max(
conflict.applyToAllCount ?? 1,
conflicts.filter((item) => item.isDirectory === conflict.isDirectory).length,
conflicts.filter((item) => getConflictTypeKey(item) === getConflictTypeKey(conflict)).length,
);
const canMerge = conflict.isDirectory && conflict.existingType === 'directory';
const canReplace = canReplaceConflict(conflict);
const handleAction = (action: FileConflictAction) => {
onResolve(conflict.transferId, action, applyToAll);
@@ -53,55 +96,46 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
return (
<Dialog open={!!conflict} onOpenChange={() => handleAction('skip')}>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-yellow-500" />
<DialogContent className="gap-5 p-5 sm:max-w-[520px] sm:p-6">
<DialogHeader className="space-y-2 pr-8">
<DialogTitle className="flex items-center gap-3 text-xl leading-tight">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-border/70 text-muted-foreground">
<AlertCircle className="h-5 w-5" />
</span>
{t('sftp.conflict.title')}
</DialogTitle>
<DialogDescription>
<DialogDescription className="text-[15px] leading-6">
{t('sftp.conflict.desc')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="text-sm">
<span className="font-medium">{conflict.fileName}</span>
<span className="text-muted-foreground ml-1">{t('sftp.conflict.alreadyExistsSuffix')}</span>
<div className="space-y-4">
<div className="rounded-md border border-border/60 bg-muted/25 px-4 py-3 text-sm leading-6">
<div className="min-w-0 break-words">
<span className="font-medium text-foreground">{conflict.fileName}</span>
<span className="ml-1 text-muted-foreground">{t('sftp.conflict.alreadyExistsSuffix')}</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4 text-xs">
<div className="p-3 rounded-lg bg-secondary/50 border border-border/60">
<div className="font-medium mb-2 text-muted-foreground">{t('sftp.conflict.existingFile')}</div>
<div className="space-y-1">
<div className="flex justify-between">
<span className="text-muted-foreground">{t('sftp.conflict.size')}</span>
<span>{formatFileSize(conflict.existingSize)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">{t('sftp.conflict.modified')}</span>
<span>{formatDate(conflict.existingModified)}</span>
</div>
</div>
</div>
<div className="p-3 rounded-lg bg-primary/10 border border-primary/30">
<div className="font-medium mb-2 text-primary">{t('sftp.conflict.newFile')}</div>
<div className="space-y-1">
<div className="flex justify-between">
<span className="text-muted-foreground">{t('sftp.conflict.size')}</span>
<span>{formatFileSize(conflict.newSize)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">{t('sftp.conflict.modified')}</span>
<span>{formatDate(conflict.newModified)}</span>
</div>
</div>
</div>
<div className="space-y-3">
<ConflictFileSummary
title={t('sftp.conflict.existingFile')}
sizeLabel={t('sftp.conflict.size')}
modifiedLabel={t('sftp.conflict.modified')}
size={formatFileSize(conflict.existingSize)}
modified={formatDate(conflict.existingModified)}
/>
<ConflictFileSummary
title={t('sftp.conflict.newFile')}
sizeLabel={t('sftp.conflict.size')}
modifiedLabel={t('sftp.conflict.modified')}
size={formatFileSize(conflict.newSize)}
modified={formatDate(conflict.newModified)}
/>
</div>
{sameTypeConflictCount > 1 && (
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<label className="flex cursor-pointer items-center gap-2 rounded-md border border-border/60 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
<input
type="checkbox"
checked={applyToAll}
@@ -113,25 +147,25 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
)}
</div>
<DialogFooter className="flex flex-wrap gap-2 sm:justify-end sm:space-x-0">
<DialogFooter className="flex flex-wrap gap-2 sm:items-center sm:justify-end sm:space-x-0">
<Button
variant="destructive"
variant="outline"
onClick={() => handleAction('stop')}
className="flex-1"
className="min-w-24 border-border/70 text-muted-foreground hover:text-destructive sm:mr-auto"
>
{t('sftp.conflict.action.stop')}
</Button>
<Button
variant="outline"
onClick={() => handleAction('skip')}
className="flex-1"
className="min-w-24"
>
{t('sftp.conflict.action.skip')}
</Button>
<Button
variant="outline"
onClick={() => handleAction('duplicate')}
className="flex-1"
className="min-w-24"
>
{t('sftp.conflict.action.duplicate')}
</Button>
@@ -140,18 +174,20 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
variant="outline"
onClick={() => handleAction('merge')}
disabled={!canMerge}
className="flex-1"
className="min-w-24"
>
{t('sftp.conflict.action.merge')}
</Button>
)}
<Button
variant="default"
onClick={() => handleAction('replace')}
className="flex-1"
>
{t('sftp.conflict.action.replace')}
</Button>
{canReplace && (
<Button
variant="default"
onClick={() => handleAction('replace')}
className="min-w-28"
>
{t('sftp.conflict.action.replace')}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -7,6 +7,8 @@ import type { TransferTask } from "../../types";
import FileOpenerDialog from "../FileOpenerDialog";
import TextEditorModal from "../TextEditorModal";
import type { TextEditorModalSnapshot } from "../TextEditorModal";
import { TerminalHostKeyVerification } from "../terminal/TerminalHostKeyVerification";
import { Dialog, DialogContent, DialogTitle } from "../ui/dialog";
import { SftpConflictDialog } from "./SftpConflictDialog";
import { SftpHostPicker } from "./SftpHostPicker";
import { SftpPermissionsDialog } from "./SftpPermissionsDialog";
@@ -139,6 +141,27 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
formatFileSize={sftp.formatFileSize}
/>
<Dialog
open={!!sftp.hostKeyVerification}
onOpenChange={(open) => {
if (!open) sftp.rejectHostKeyVerification();
}}
>
<DialogContent className="max-w-lg" hideCloseButton>
<DialogTitle className="sr-only">Confirm host key</DialogTitle>
{sftp.hostKeyVerification && (
<TerminalHostKeyVerification
hostKeyInfo={sftp.hostKeyVerification.hostKeyInfo}
showLogs={sftp.hostKeyVerification.progressLogs.length > 0}
progressLogs={sftp.hostKeyVerification.progressLogs}
onClose={sftp.rejectHostKeyVerification}
onContinue={sftp.acceptHostKeyVerification}
onAddAndContinue={sftp.acceptAndSaveHostKeyVerification}
/>
)}
</DialogContent>
</Dialog>
<SftpPermissionsDialog
open={!!permissionsState}
onOpenChange={(open) => !open && setPermissionsState(null)}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
import { Pause, Pencil, Play, Trash2, Zap } from 'lucide-react';
import React, { memo, useCallback, useState } from 'react';
import { Loader2, Pause, Pencil, Play, Trash2, Zap } from 'lucide-react';
import React, { memo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
import type { DockerContainerAction, DockerContainerInfo, DockerStatInfo } from '../../domain/systemManager/types';
import { getContainerFlags } from '../../domain/systemManager/containerState';
import { DockerInspectView } from './DockerInspectView';
@@ -9,18 +8,17 @@ import { ResourceBar } from './ResourceBar';
import {
SystemPanelActionChip,
SystemPanelDetailStrip,
SystemPanelInlineError,
} from './SystemPanelUi';
import { SystemPanelPromptDialog } from './SystemPanelPromptDialog';
import { usePolling } from './hooks/useSystemManager';
type Backend = ReturnType<typeof useSystemManagerBackend>;
interface DockerContainerDetailProps {
container: DockerContainerInfo;
sessionId: string;
backend: Backend;
statsRefreshIntervalSec: number;
inspect: Record<string, unknown> | null;
inspectError?: string | null;
inspectLoading?: boolean;
stat?: DockerStatInfo | null;
statsLoading?: boolean;
pendingAction: DockerContainerAction | null;
onCloseInspect: () => void;
onRunAction: (containerId: string, action: DockerContainerAction, newName?: string) => Promise<void>;
@@ -28,10 +26,11 @@ interface DockerContainerDetailProps {
export const DockerContainerDetail = memo(function DockerContainerDetail({
container,
sessionId,
backend,
statsRefreshIntervalSec,
inspect,
inspectError = null,
inspectLoading = false,
stat = null,
statsLoading = false,
pendingAction,
onCloseInspect,
onRunAction,
@@ -40,20 +39,6 @@ export const DockerContainerDetail = memo(function DockerContainerDetail({
const shortId = container.id.slice(0, 12);
const { isRunning, isPaused } = getContainerFlags(container);
const statsFetcher = useCallback(async () => {
const result = await backend.getDockerStats({ sessionId, ids: [container.id] });
if (!result.success || !result.stats) {
throw new Error(result.error || t('systemManager.errors.loadDockerStats'));
}
return result.stats;
}, [backend, container.id, sessionId, t]);
const statsIntervalMs = Math.max(2, statsRefreshIntervalSec) * 1000;
// docker stats still reports paused containers, so keep polling them.
const { data: stats } = usePolling<DockerStatInfo[]>(statsFetcher, statsIntervalMs, isRunning || isPaused);
const stat = stats?.find((s) => s.id === container.id || s.id.startsWith(shortId)) ?? stats?.[0];
const [renameOpen, setRenameOpen] = useState(false);
const actionBusy = pendingAction !== null;
@@ -61,7 +46,7 @@ export const DockerContainerDetail = memo(function DockerContainerDetail({
<>
<SystemPanelDetailStrip>
{container.ports && (
<div className="text-[10px] text-muted-foreground mb-2 truncate">{container.ports}</div>
<div className="text-[10px] text-muted-foreground mb-2 break-all">{container.ports}</div>
)}
{stat && (
<div className="space-y-1 mb-2">
@@ -70,6 +55,12 @@ export const DockerContainerDetail = memo(function DockerContainerDetail({
<div className="text-[10px] text-muted-foreground">{stat.netIO} · {stat.memUsage}</div>
</div>
)}
{!stat && statsLoading && (isRunning || isPaused) && (
<div className="mb-2 flex items-center gap-1.5 text-[10px] text-muted-foreground">
<Loader2 size={11} className="animate-spin" />
{t('systemManager.common.loadingStats')}
</div>
)}
<div className="flex flex-wrap items-center gap-0.5">
<SystemPanelActionChip title={t('systemManager.docker.renamePrompt')} disabled={actionBusy} onClick={() => setRenameOpen(true)}>
<Pencil size={11} /> {t('common.rename')}
@@ -94,6 +85,15 @@ export const DockerContainerDetail = memo(function DockerContainerDetail({
</SystemPanelActionChip>
</div>
</SystemPanelDetailStrip>
{inspectLoading && !inspect && (
<div className="flex items-center gap-1.5 border-b border-border/40 bg-muted/20 px-3 py-2 text-[10px] text-muted-foreground">
<Loader2 size={11} className="animate-spin" />
{t('systemManager.common.loadingDetails')}
</div>
)}
{inspectError && !inspect && (
<SystemPanelInlineError message={inspectError} />
)}
{inspect && (
<DockerInspectView
kind="container"

View File

@@ -1,10 +1,10 @@
import { Box, FileText, Play, RotateCcw, Square, Terminal } from 'lucide-react';
import React, { memo, useCallback, useMemo, useRef, useState } from 'react';
import React, { memo, useCallback, useMemo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
import { writeSystemManagerDiagnostic } from '../../application/state/systemManagerDiagnostics';
import type { TerminalSession } from '../../types';
import type { DockerContainerAction, DockerContainerInfo, TerminalPopupIcon } from '../../domain/systemManager/types';
import type { DockerContainerAction, DockerContainerInfo, DockerStatInfo, TerminalPopupIcon } from '../../domain/systemManager/types';
import { dockerContainerInfoEqual } from '../../domain/systemManager/pollEquals';
import { getContainerFlags, getContainerTone } from '../../domain/systemManager/containerState';
import { buildDockerExecShellCommand, buildDockerLogsCommand } from '../../domain/systemManager/dockerShell';
@@ -16,6 +16,7 @@ import {
SystemPanelEmpty,
SystemPanelError,
SystemPanelList,
SystemPanelLoading,
SystemPanelMetaBar,
SystemPanelRefreshButton,
SystemPanelRoundButton,
@@ -25,6 +26,7 @@ import {
SystemPanelStatusBadge,
SystemPanelToolbar,
} from './SystemPanelUi';
import { useAsyncRecordCache } from './hooks/useAsyncRecordCache';
import { usePolling, useStableTranslate } from './hooks/useSystemManager';
import { openInteractiveTerminal } from './openInteractiveTerminal';
import { showSystemManagerError } from './systemManagerToast';
@@ -53,6 +55,7 @@ interface DockerContainersPanelProps {
sessionId: string;
parentSession: TerminalSession;
isVisible: boolean;
warmupEnabled?: boolean;
backend: Backend;
listRefreshIntervalSec: number;
statsRefreshIntervalSec: number;
@@ -150,6 +153,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
sessionId,
parentSession,
isVisible,
warmupEnabled = false,
backend,
listRefreshIntervalSec,
statsRefreshIntervalSec,
@@ -159,10 +163,6 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
const [query, setQuery] = useState('');
const [filter, setFilter] = useState<ContainerFilter>('all');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [inspect, setInspect] = useState<Record<string, unknown> | null>(null);
// Invalidates in-flight inspect fetches when the selection changes —
// a slow response for container A must not render under container B.
const inspectSeqRef = useRef(0);
// Spinner feedback while a container action (stop/restart/…) runs;
// cleared only after the follow-up list refresh lands.
const [pendingAction, setPendingAction] = useState<{ id: string; action: DockerContainerAction } | null>(null);
@@ -179,13 +179,15 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
const { data: containers, error, loading, refresh } = usePolling<DockerContainerInfo[]>(
containersFetcher,
listIntervalMs,
isVisible,
isVisible || warmupEnabled,
(prev, next) => mergePollListByKey(prev, next, (c) => c.id, dockerContainerInfoEqual),
{ poll: isVisible, resetKey: sessionId },
);
const matched = useMemo(() => {
const matched = useMemo<DockerContainerInfo[]>(() => {
const q = query.trim().toLowerCase();
return (containers ?? []).filter((container) => {
const containerList = containers ?? [];
return containerList.filter((container) => {
const { isRunning, isPaused } = getContainerFlags(container);
if (filter === 'running' && !isRunning) return false;
if (filter === 'stopped' && (isRunning || isPaused)) return false;
@@ -202,7 +204,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
(a: DockerContainerInfo, b: DockerContainerInfo) => a.name.localeCompare(b.name),
[],
);
const displayList = useStableListOrder(
const displayList = useStableListOrder<DockerContainerInfo, string>(
matched,
(c) => c.id,
`${filter}|${query}`,
@@ -214,6 +216,69 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
[displayList, selectedId],
);
const statContainerIds = useMemo(
() => {
if (!selectedContainer) return [];
const { isRunning, isPaused } = getContainerFlags(selectedContainer);
return isRunning || isPaused ? [selectedContainer.id] : [];
},
[selectedContainer],
);
const statsFetcher = useCallback(async () => {
if (statContainerIds.length === 0) return [];
const result = await backend.getDockerStats({ sessionId, ids: statContainerIds });
if (!result.success || !result.stats) {
throw new Error(result.error || stableT('systemManager.errors.loadDockerStats'));
}
return result.stats;
}, [backend, sessionId, stableT, statContainerIds]);
const statsIntervalMs = Math.max(2, statsRefreshIntervalSec) * 1000;
const { data: stats, loading: statsLoading } = usePolling<DockerStatInfo[]>(
statsFetcher,
statsIntervalMs,
isVisible && statContainerIds.length > 0,
undefined,
{ poll: isVisible, resetKey: `${sessionId}:${statContainerIds.join(',')}` },
);
const statsByContainerId = useMemo(() => {
const map = new Map<string, DockerStatInfo>();
for (const stat of stats ?? []) {
map.set(stat.id, stat);
map.set(stat.id.slice(0, 12), stat);
}
return map;
}, [stats]);
const getContainerInspectKey = useCallback((container: DockerContainerInfo) => (
`${sessionId}:${container.id}`
), [sessionId]);
const fetchContainerInspect = useCallback(async (container: DockerContainerInfo) => {
const result = await backend.dockerInspect({
sessionId,
containerId: container.id.slice(0, 12),
});
if (!result.success) {
throw new Error(result.error || stableT('systemManager.errors.actionFailed'));
}
return result.inspect ?? null;
}, [backend, sessionId, stableT]);
const {
records: inspectByContainerId,
loadRecord: loadContainerInspect,
refreshRecord: refreshContainerInspect,
invalidateMatching: invalidateContainerInspectMatching,
} = useAsyncRecordCache<DockerContainerInfo, Record<string, unknown>>({
items: containers ?? [],
enabled: isVisible && (containers?.length ?? 0) > 0,
getKey: getContainerInspectKey,
fetchRecord: fetchContainerInspect,
prefetchLimit: 24,
prefetchDelayMs: 40,
staleTimeMs: 20_000,
});
const runAction = useCallback(async (
containerId: string,
action: DockerContainerAction,
@@ -234,34 +299,42 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
return;
}
const affectedContainer = (containers ?? []).find((container) => (
container.id === containerId || container.id.startsWith(containerId)
));
invalidateContainerInspectMatching((key) => (
key === `${sessionId}:${containerId}` || key.startsWith(`${sessionId}:${containerId}`)
));
if (action === 'rm') {
setSelectedId(null);
setInspect(null);
inspectSeqRef.current += 1;
}
await refresh();
if (affectedContainer && action !== 'rm') {
void refreshContainerInspect(affectedContainer);
}
} finally {
setPendingAction(null);
}
}, [backend, refresh, sessionId, t]);
}, [
backend,
containers,
invalidateContainerInspectMatching,
refresh,
refreshContainerInspect,
sessionId,
t,
]);
const handleRowAction = useCallback((container: DockerContainerInfo, action: DockerContainerAction) => {
void runAction(container.id.slice(0, 12), action);
}, [runAction]);
const selectContainer = useCallback(async (container: DockerContainerInfo) => {
const selectContainer = useCallback((container: DockerContainerInfo) => {
const next = selectedId === container.id ? null : container.id;
setSelectedId(next);
setInspect(null);
const seq = ++inspectSeqRef.current;
if (!next) return;
const result = await backend.dockerInspect({
sessionId,
containerId: container.id.slice(0, 12),
});
if (inspectSeqRef.current !== seq) return;
setInspect(result.success ? (result.inspect ?? null) : null);
}, [backend, selectedId, sessionId]);
void loadContainerInspect(container, { force: true, urgent: true });
}, [loadContainerInspect, selectedId]);
const openShell = useCallback(async (container: DockerContainerInfo) => {
const id = container.id.slice(0, 12);
@@ -354,6 +427,9 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
{error && (
<SystemPanelError message={error} onRetry={() => void refresh()} retryLabel={t('history.action.retry')} loading={loading} />
)}
{!error && displayList.length === 0 && loading && (
<SystemPanelLoading message={t('systemManager.common.loading')} />
)}
{!error && displayList.length === 0 && !loading && (
<SystemPanelEmpty icon={Box} message={t('systemManager.docker.empty')} />
)}
@@ -363,6 +439,8 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
const rowPending = pendingAction && pendingAction.id === container.id.slice(0, 12)
? pendingAction.action
: null;
const selectedInspectKey = selectedContainer ? getContainerInspectKey(selectedContainer) : null;
const selectedInspectRecord = selectedInspectKey ? inspectByContainerId[selectedInspectKey] : undefined;
return (
<React.Fragment key={container.id}>
<DockerContainerRow
@@ -378,12 +456,13 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
{selectedContainer && (
<DockerContainerDetail
container={selectedContainer}
sessionId={sessionId}
backend={backend}
statsRefreshIntervalSec={statsRefreshIntervalSec}
inspect={inspect}
inspect={selectedInspectRecord?.data ?? null}
inspectError={selectedInspectRecord?.error ?? null}
inspectLoading={selectedInspectRecord?.loading ?? false}
stat={statsByContainerId.get(selectedContainer.id) ?? statsByContainerId.get(selectedContainer.id.slice(0, 12)) ?? null}
statsLoading={statsLoading}
pendingAction={rowPending}
onCloseInspect={() => { setSelectedId(null); setInspect(null); }}
onCloseInspect={() => { setSelectedId(null); }}
onRunAction={runAction}
/>
)}

View File

@@ -1,5 +1,5 @@
import { Layers, Tag, Trash2 } from 'lucide-react';
import React, { memo, useCallback, useMemo, useRef, useState } from 'react';
import { Layers, Loader2, Tag, Trash2 } from 'lucide-react';
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
import { dockerImageRowKey, type DockerImageInfo } from '../../domain/systemManager/types';
@@ -11,7 +11,9 @@ import {
SystemPanelCollapsible,
SystemPanelEmpty,
SystemPanelError,
SystemPanelInlineError,
SystemPanelList,
SystemPanelLoading,
SystemPanelMetaBar,
SystemPanelRefreshButton,
SystemPanelRoundButton,
@@ -20,6 +22,7 @@ import {
SystemPanelToolbar,
} from './SystemPanelUi';
import { SystemPanelPromptDialog } from './SystemPanelPromptDialog';
import { useAsyncRecordCache } from './hooks/useAsyncRecordCache';
import { usePolling, useStableTranslate } from './hooks/useSystemManager';
import { showSystemManagerError } from './systemManagerToast';
@@ -28,6 +31,7 @@ type Backend = ReturnType<typeof useSystemManagerBackend>;
interface DockerImagesPanelProps {
sessionId: string;
isVisible: boolean;
warmupEnabled?: boolean;
backend: Backend;
listRefreshIntervalSec: number;
}
@@ -81,6 +85,7 @@ const DockerImageRow = memo(function DockerImageRow({
export const DockerImagesPanel = memo(function DockerImagesPanel({
sessionId,
isVisible,
warmupEnabled = false,
backend,
listRefreshIntervalSec,
}: DockerImagesPanelProps) {
@@ -88,8 +93,12 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
const stableT = useStableTranslate();
const [query, setQuery] = useState('');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [inspect, setInspect] = useState<Record<string, unknown> | null>(null);
const inspectSeqRef = useRef(0);
const [tagTarget, setTagTarget] = useState<DockerImageInfo | null>(null);
useEffect(() => {
setSelectedId(null);
setTagTarget(null);
}, [sessionId]);
const imagesFetcher = useCallback(async () => {
const result = await backend.listDockerImages(sessionId);
@@ -103,8 +112,9 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
const { data: images, error, loading, refresh } = usePolling<DockerImageInfo[]>(
imagesFetcher,
listIntervalMs,
isVisible,
isVisible || warmupEnabled,
(prev, next) => mergePollListByKey(prev, next, dockerImageRowKey, dockerImageInfoEqual),
{ poll: isVisible, resetKey: sessionId },
);
const filtered = useMemo(() => {
@@ -130,6 +140,33 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
);
const displayList = useStableListOrder(filtered, dockerImageRowKey, query, compareImages);
const getImageInspectKey = useCallback((image: DockerImageInfo) => (
`${sessionId}:${dockerImageRowKey(image)}`
), [sessionId]);
const fetchImageInspect = useCallback(async (image: DockerImageInfo) => {
const result = await backend.dockerImageInspect({
sessionId,
imageId: image.id.slice(0, 12),
});
if (!result.success) {
throw new Error(result.error || stableT('systemManager.errors.actionFailed'));
}
return result.inspect ?? null;
}, [backend, sessionId, stableT]);
const {
records: inspectByImageKey,
loadRecord: loadImageInspect,
invalidateRecord: invalidateImageInspect,
} = useAsyncRecordCache<DockerImageInfo, Record<string, unknown>>({
items: images ?? [],
enabled: isVisible && (images?.length ?? 0) > 0,
getKey: getImageInspectKey,
fetchRecord: fetchImageInspect,
prefetchLimit: 24,
prefetchDelayMs: 40,
staleTimeMs: 20_000,
});
const handleRemove = useCallback(async (image: DockerImageInfo) => {
const label = image.name || image.id.slice(0, 12);
const ok = window.confirm(t('systemManager.docker.confirmRemoveImage', { name: label }));
@@ -146,11 +183,10 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
}
if (selectedId === dockerImageRowKey(image)) {
setSelectedId(null);
setInspect(null);
inspectSeqRef.current += 1;
}
invalidateImageInspect(getImageInspectKey(image));
await refresh();
}, [backend, refresh, selectedId, sessionId, t]);
}, [backend, getImageInspectKey, invalidateImageInspect, refresh, selectedId, sessionId, t]);
const handlePrune = async (all: boolean) => {
const ok = window.confirm(all
@@ -165,8 +201,6 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
await refresh();
};
const [tagTarget, setTagTarget] = useState<DockerImageInfo | null>(null);
const handleTagSubmit = async (image: DockerImageInfo, repository: string, tag: string) => {
const result = await backend.dockerImageAction({
sessionId,
@@ -182,20 +216,13 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
await refresh();
};
const selectImage = useCallback(async (image: DockerImageInfo) => {
const selectImage = useCallback((image: DockerImageInfo) => {
const rowKey = dockerImageRowKey(image);
const next = selectedId === rowKey ? null : rowKey;
setSelectedId(next);
setInspect(null);
const seq = ++inspectSeqRef.current;
if (!next) return;
const result = await backend.dockerImageInspect({
sessionId,
imageId: image.id.slice(0, 12),
});
if (inspectSeqRef.current !== seq) return;
setInspect(result.success ? (result.inspect ?? null) : null);
}, [backend, selectedId, sessionId]);
void loadImageInspect(image, { force: true, urgent: true });
}, [loadImageInspect, selectedId]);
const openTagDialog = useCallback((image: DockerImageInfo) => {
setTagTarget(image);
@@ -243,12 +270,16 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
{error && (
<SystemPanelError message={error} onRetry={() => void refresh()} retryLabel={t('history.action.retry')} loading={loading} />
)}
{!error && displayList.length === 0 && loading && (
<SystemPanelLoading message={t('systemManager.common.loading')} />
)}
{!error && displayList.length === 0 && !loading && (
<SystemPanelEmpty icon={Layers} message={t('systemManager.docker.imagesEmpty')} />
)}
{displayList.map((image) => {
const rowKey = dockerImageRowKey(image);
const inspectKey = getImageInspectKey(image);
const shortId = image.id.slice(0, 12);
const displayName = image.repository && image.tag
? `${image.repository}:${image.tag}`
@@ -266,11 +297,20 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
onRemove={handleRemove}
/>
<SystemPanelCollapsible open={selected}>
{inspect && (
{inspectByImageKey[inspectKey]?.loading && !inspectByImageKey[inspectKey]?.data && (
<div className="flex items-center gap-1.5 border-b border-border/40 bg-muted/20 px-3 py-2 text-[10px] text-muted-foreground">
<Loader2 size={11} className="animate-spin" />
{t('systemManager.common.loadingDetails')}
</div>
)}
{inspectByImageKey[inspectKey]?.error && !inspectByImageKey[inspectKey]?.data && (
<SystemPanelInlineError message={inspectByImageKey[inspectKey].error} />
)}
{inspectByImageKey[inspectKey]?.data && (
<DockerInspectView
kind="image"
data={inspect}
onClose={() => { setSelectedId(null); setInspect(null); }}
data={inspectByImageKey[inspectKey].data}
onClose={() => { setSelectedId(null); }}
/>
)}
</SystemPanelCollapsible>

View File

@@ -23,7 +23,7 @@ function InspectList({ label, items }: { label: string; items: string[] }) {
return (
<div className="text-[10px] leading-relaxed">
<div className="text-muted-foreground mb-0.5">{label}</div>
<div className="space-y-0.5 max-h-28 overflow-y-auto font-mono">
<div className="space-y-0.5 font-mono">
{items.map((item, index) => (
<div key={index} className="break-all text-foreground/90">{item}</div>
))}

View File

@@ -15,6 +15,7 @@ interface DockerManagerTabProps {
sessionId: string;
parentSession: TerminalSession;
isVisible: boolean;
warmupEnabled?: boolean;
backend: Backend;
listRefreshIntervalSec: number;
statsRefreshIntervalSec: number;
@@ -24,6 +25,7 @@ export const DockerManagerTab = memo(function DockerManagerTab({
sessionId,
parentSession,
isVisible,
warmupEnabled = false,
backend,
listRefreshIntervalSec,
statsRefreshIntervalSec,
@@ -58,23 +60,26 @@ export const DockerManagerTab = memo(function DockerManagerTab({
</div>
<div className="flex-1 min-h-0 flex flex-col">
{subTab === 'containers' ? (
<div className={cn('flex-1 min-h-0 flex flex-col', subTab !== 'containers' && 'hidden')}>
<DockerContainersPanel
sessionId={sessionId}
parentSession={parentSession}
isVisible={isVisible}
isVisible={isVisible && subTab === 'containers'}
warmupEnabled={warmupEnabled || (isVisible && subTab !== 'containers')}
backend={backend}
listRefreshIntervalSec={listRefreshIntervalSec}
statsRefreshIntervalSec={statsRefreshIntervalSec}
/>
) : (
</div>
<div className={cn('flex-1 min-h-0 flex flex-col', subTab !== 'images' && 'hidden')}>
<DockerImagesPanel
sessionId={sessionId}
isVisible={isVisible}
isVisible={isVisible && subTab === 'images'}
warmupEnabled={warmupEnabled || (isVisible && subTab !== 'images')}
backend={backend}
listRefreshIntervalSec={listRefreshIntervalSec}
/>
)}
</div>
</div>
</SystemPanelShell>
);

View File

@@ -1,7 +1,7 @@
import {
Gauge, LayoutList, Pause, Play, Skull, XCircle,
Gauge, LayoutList, Loader2, Pause, Play, Skull, XCircle,
} from 'lucide-react';
import React, { memo, useCallback, useMemo, useState } from 'react';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
import {
@@ -12,6 +12,7 @@ import {
import type { SystemProcessInfo } from '../../domain/systemManager/types';
import { systemProcessInfoEqual } from '../../domain/systemManager/pollEquals';
import { cn } from '../../lib/utils';
import { VariableSizeVirtualList } from '../ui/VariableSizeVirtualList';
import { ResourceBar } from './ResourceBar';
import { useStableListOrder, mergePollListByKey } from './listStable';
import {
@@ -27,7 +28,6 @@ import {
SystemPanelSearch,
SystemPanelSegmented,
SystemPanelShell,
SystemPanelCollapsible,
SystemPanelStatusBadge,
SystemPanelToolbar,
} from './SystemPanelUi';
@@ -38,6 +38,16 @@ type Backend = ReturnType<typeof useSystemManagerBackend>;
type SortKey = 'cpuPercent' | 'memPercent' | 'pid' | 'command' | 'user';
type ProcessFilter = 'all' | 'running';
const PROCESS_CACHE_TTL_MS = 30_000;
const PROCESS_ROW_HEIGHT = 56;
const PROCESS_DETAIL_HEIGHT = 112;
const PROCESS_OVERSCAN_ROWS = 8;
const processListCache = new Map<string, {
processes: SystemProcessInfo[];
updatedAt: number;
}>();
const SORT_OPTIONS: Array<{ key: SortKey; labelKey: string }> = [
{ key: 'cpuPercent', labelKey: 'systemManager.processes.sort.cpu' },
{ key: 'memPercent', labelKey: 'systemManager.processes.sort.mem' },
@@ -61,6 +71,29 @@ const mergeProcesses = (
next: SystemProcessInfo[],
) => mergePollListByKey(prev, next, (p) => p.pid, systemProcessInfoEqual);
function getCachedProcesses(sessionId: string): SystemProcessInfo[] | null {
const cached = processListCache.get(sessionId);
if (!cached) return null;
if (Date.now() - cached.updatedAt > PROCESS_CACHE_TTL_MS) {
processListCache.delete(sessionId);
return null;
}
return cached.processes;
}
const ProcessListLoading = memo(function ProcessListLoading({
message,
}: {
message: string;
}) {
return (
<div className="flex min-h-[180px] flex-col items-center justify-center px-4 py-10 text-center text-xs text-muted-foreground">
<Loader2 size={18} className="mb-2 animate-spin opacity-70" />
<span>{message}</span>
</div>
);
});
interface ProcessRowProps {
proc: SystemProcessInfo;
selected: boolean;
@@ -79,72 +112,125 @@ const ProcessRow = memo(function ProcessRow({
const { t } = useI18n();
const { isStopped, isZombie } = getProcessFlags(proc);
const actions = (
<div className="flex w-[112px] shrink-0 items-center justify-end gap-1">
{!isStopped && !isZombie && (
<SystemPanelRoundButton
title={t('systemManager.processes.stop')}
onClick={() => onSignal(proc.pid, 'STOP')}
>
<Pause size={12} />
</SystemPanelRoundButton>
)}
{isStopped && !isZombie && (
<SystemPanelRoundButton
title={t('systemManager.processes.cont')}
onClick={() => onSignal(proc.pid, 'CONT')}
>
<Play size={12} />
</SystemPanelRoundButton>
)}
<SystemPanelRoundButton
title={t('systemManager.processes.term')}
onClick={() => onSignal(proc.pid, 'TERM')}
>
<XCircle size={12} />
</SystemPanelRoundButton>
<SystemPanelRoundButton
title={t('systemManager.processes.kill')}
destructive
onClick={() => onSignal(proc.pid, 'KILL')}
>
<Skull size={12} />
</SystemPanelRoundButton>
<SystemPanelRoundButton
title={t('systemManager.processes.renice')}
onClick={() => onRenice(proc.pid)}
>
<Gauge size={12} />
</SystemPanelRoundButton>
</div>
);
return (
<>
<div className="h-full overflow-hidden">
<SystemPanelRow
selected={selected}
onClick={() => onToggle(proc.pid)}
title={proc.command}
subtitle={`${proc.user || '—'} · PID ${proc.pid}`}
className="h-14"
trailing={(
<div className="flex shrink-0 items-center gap-1">
<div className="flex w-[88px] shrink-0 items-center justify-end">
<SystemPanelStatusBadge tone={getProcessTone(proc)}>
{t(getProcessStatusLabelKey(proc))}
</SystemPanelStatusBadge>
{!isStopped && !isZombie && (
<SystemPanelRoundButton
title={t('systemManager.processes.stop')}
onClick={() => onSignal(proc.pid, 'STOP')}
>
<Pause size={12} />
</SystemPanelRoundButton>
)}
{isStopped && !isZombie && (
<SystemPanelRoundButton
title={t('systemManager.processes.cont')}
onClick={() => onSignal(proc.pid, 'CONT')}
>
<Play size={12} />
</SystemPanelRoundButton>
)}
<SystemPanelRoundButton
title={t('systemManager.processes.term')}
onClick={() => onSignal(proc.pid, 'TERM')}
>
<XCircle size={12} />
</SystemPanelRoundButton>
<SystemPanelRoundButton
title={t('systemManager.processes.kill')}
destructive
onClick={() => onSignal(proc.pid, 'KILL')}
>
<Skull size={12} />
</SystemPanelRoundButton>
<SystemPanelRoundButton
title={t('systemManager.processes.renice')}
onClick={() => onRenice(proc.pid)}
>
<Gauge size={12} />
</SystemPanelRoundButton>
</div>
)}
actions={actions}
/>
<SystemPanelCollapsible open={selected}>
<SystemPanelDetailStrip>
{selected && (
<SystemPanelDetailStrip className="h-28 overflow-hidden">
<div className="grid grid-cols-2 gap-x-3 gap-y-1 text-[10px] text-muted-foreground mb-2">
<span>{t('systemManager.processes.ppid')}: {proc.ppid}</span>
<span>{t('systemManager.processes.stat')}: {proc.stat}</span>
<span>{t('systemManager.processes.elapsed')}: {proc.elapsed || '—'}</span>
<span>{t('systemManager.processes.rss')}: {formatKb(proc.rssKb)}</span>
<span className="col-span-2">{t('systemManager.processes.vsz')}: {formatKb(proc.vszKb)}</span>
<span className="min-w-0 truncate">{t('systemManager.processes.ppid')}: {proc.ppid}</span>
<span className="min-w-0 truncate">{t('systemManager.processes.stat')}: {proc.stat}</span>
<span className="min-w-0 truncate">{t('systemManager.processes.elapsed')}: {proc.elapsed || '—'}</span>
<span className="min-w-0 truncate">{t('systemManager.processes.rss')}: {formatKb(proc.rssKb)}</span>
<span className="col-span-2 min-w-0 truncate">{t('systemManager.processes.vsz')}: {formatKb(proc.vszKb)}</span>
</div>
<div className="space-y-1">
<ResourceBar label="CPU" value={proc.cpuPercent} />
<ResourceBar label="MEM" value={proc.memPercent} />
</div>
</SystemPanelDetailStrip>
</SystemPanelCollapsible>
</>
)}
</div>
);
});
interface ProcessVirtualListProps {
processes: SystemProcessInfo[];
selectedPid: number | null;
onToggle: (pid: number) => void;
onSignal: (pid: number, signal: string) => void;
onRenice: (pid: number) => void;
}
const ProcessVirtualList = memo(function ProcessVirtualList({
processes,
selectedPid,
onToggle,
onSignal,
onRenice,
}: ProcessVirtualListProps) {
const getItemHeight = useCallback(
(proc: SystemProcessInfo) => (
proc.pid === selectedPid
? PROCESS_ROW_HEIGHT + PROCESS_DETAIL_HEIGHT
: PROCESS_ROW_HEIGHT
),
[selectedPid],
);
const renderItem = useCallback((proc: SystemProcessInfo) => (
<ProcessRow
proc={proc}
selected={selectedPid === proc.pid}
onToggle={onToggle}
onSignal={onSignal}
onRenice={onRenice}
/>
), [onRenice, onSignal, onToggle, selectedPid]);
return (
<VariableSizeVirtualList<SystemProcessInfo>
items={processes}
getItemHeight={getItemHeight}
className="flex-1 min-h-0"
overscan={PROCESS_OVERSCAN_ROWS}
getItemKey={(proc) => String(proc.pid)}
renderItem={renderItem}
/>
);
});
@@ -170,14 +256,52 @@ export const ProcessManagerTab = memo(function ProcessManagerTab({
const [selectedPid, setSelectedPid] = useState<number | null>(null);
const [reniceTarget, setReniceTarget] = useState<number | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [cachedProcesses, setCachedProcesses] = useState<SystemProcessInfo[] | null>(() => getCachedProcesses(sessionId));
const [cachedProcessesSessionId, setCachedProcessesSessionId] = useState(sessionId);
const [processListPending, setProcessListPending] = useState(false);
const processFetchGenerationRef = useRef(0);
const currentSessionIdRef = useRef(sessionId);
if (currentSessionIdRef.current !== sessionId) {
currentSessionIdRef.current = sessionId;
processFetchGenerationRef.current += 1;
}
useEffect(() => {
processFetchGenerationRef.current += 1;
setCachedProcesses(getCachedProcesses(sessionId));
setCachedProcessesSessionId(sessionId);
setProcessListPending(false);
}, [sessionId]);
useEffect(() => () => {
processFetchGenerationRef.current += 1;
}, []);
const fetcher = useCallback(async () => {
const result = await backend.listSystemProcesses(sessionId);
if (result.pending) return null;
if (!result.success || !result.processes) {
throw new Error(result.error || stableT('systemManager.errors.loadProcesses'));
const fetchGeneration = processFetchGenerationRef.current;
const fetchSessionId = sessionId;
const isCurrentFetch = () => (
processFetchGenerationRef.current === fetchGeneration
&& currentSessionIdRef.current === fetchSessionId
);
try {
const result = await backend.listSystemProcesses(sessionId);
if (!isCurrentFetch()) return null;
if (result.pending) {
setProcessListPending(true);
return null;
}
setProcessListPending(false);
if (!result.success || !result.processes) {
throw new Error(result.error || stableT('systemManager.errors.loadProcesses'));
}
return result.processes;
} catch (err) {
if (!isCurrentFetch()) return null;
setProcessListPending(false);
throw err;
}
return result.processes;
}, [backend, sessionId, stableT]);
const intervalMs = Math.max(2, refreshIntervalSec) * 1000;
@@ -186,10 +310,24 @@ export const ProcessManagerTab = memo(function ProcessManagerTab({
intervalMs,
isVisible,
mergeProcesses,
{ resetKey: sessionId },
);
const matched = useMemo(() => {
const list = processes ?? [];
useEffect(() => {
if (!processes) return;
processListCache.set(sessionId, { processes, updatedAt: Date.now() });
setCachedProcesses(processes);
setCachedProcessesSessionId(sessionId);
}, [processes, sessionId]);
const sessionCachedProcesses = cachedProcessesSessionId === sessionId
? cachedProcesses
: getCachedProcesses(sessionId);
const visibleProcesses = processes ?? sessionCachedProcesses;
const showingCachedProcesses = processes === null && sessionCachedProcesses !== null;
const matched = useMemo<SystemProcessInfo[]>(() => {
const list = visibleProcesses ?? [];
const q = query.trim().toLowerCase();
return list.filter((p) => {
if (filter === 'running' && !isProcessRunning(p.stat)) return false;
@@ -199,7 +337,7 @@ export const ProcessManagerTab = memo(function ProcessManagerTab({
|| p.user.toLowerCase().includes(q)
|| p.command.toLowerCase().includes(q);
});
}, [processes, query, filter]);
}, [visibleProcesses, query, filter]);
const compareProcesses = useCallback((a: SystemProcessInfo, b: SystemProcessInfo) => {
let cmp = 0;
@@ -216,7 +354,16 @@ export const ProcessManagerTab = memo(function ProcessManagerTab({
}, [sortAsc, sortKey]);
const sortToken = `${sortKey}|${sortAsc}|${filter}|${query}`;
const displayList = useStableListOrder(matched, (p) => p.pid, sortToken, compareProcesses);
const displayList = useStableListOrder<SystemProcessInfo, number>(
matched,
(p) => p.pid,
sortToken,
compareProcesses,
);
const isProcessRefreshActive = loading || processListPending;
const showInitialLoading = isProcessRefreshActive && displayList.length === 0;
const showBlockingError = Boolean(error && !isProcessRefreshActive && displayList.length === 0);
const showInlineRefreshError = Boolean(error && !isProcessRefreshActive && displayList.length > 0);
const cycleSort = (key: SortKey) => {
if (sortKey === key) setSortAsc((v) => !v);
@@ -265,7 +412,7 @@ export const ProcessManagerTab = memo(function ProcessManagerTab({
trailing={(
<SystemPanelRefreshButton
title={t('history.action.refresh')}
loading={loading}
loading={isProcessRefreshActive}
onClick={() => void refresh()}
/>
)}
@@ -305,29 +452,36 @@ export const ProcessManagerTab = memo(function ProcessManagerTab({
))}
</div>
)}>
{t('systemManager.processes.meta', { count: String(displayList.length) })}
<span className={cn(showingCachedProcesses && isProcessRefreshActive && 'inline-flex items-center gap-1.5')}>
{showingCachedProcesses && isProcessRefreshActive && <Loader2 size={10} className="animate-spin" />}
{t('systemManager.processes.meta', { count: String(displayList.length) })}
</span>
</SystemPanelMetaBar>
{actionError && <SystemPanelInlineError message={actionError} />}
{showInlineRefreshError && error && <SystemPanelInlineError message={error} />}
<SystemPanelList>
{error && (
<SystemPanelError message={error} onRetry={() => void refresh()} retryLabel={t('history.action.retry')} loading={loading} />
)}
{!error && displayList.length === 0 && !loading && (
<SystemPanelEmpty icon={LayoutList} message={t('systemManager.empty')} />
)}
{displayList.map((proc) => (
<ProcessRow
key={proc.pid}
proc={proc}
selected={selectedPid === proc.pid}
onToggle={togglePid}
onSignal={signalProcess}
onRenice={openRenicePrompt}
/>
))}
</SystemPanelList>
{(showBlockingError || showInitialLoading || (!error && displayList.length === 0 && !loading && !showInitialLoading)) ? (
<SystemPanelList>
{showBlockingError && error && (
<SystemPanelError message={error} onRetry={() => void refresh()} retryLabel={t('history.action.retry')} loading={loading} />
)}
{showInitialLoading && (
<ProcessListLoading message={t('systemManager.processes.loading')} />
)}
{!error && displayList.length === 0 && !loading && !showInitialLoading && (
<SystemPanelEmpty icon={LayoutList} message={t('systemManager.empty')} />
)}
</SystemPanelList>
) : (
<ProcessVirtualList
processes={displayList}
selectedPid={selectedPid}
onToggle={togglePid}
onSignal={signalProcess}
onRenice={openRenicePrompt}
/>
)}
<SystemPanelPromptDialog
open={reniceTarget !== null}

View File

@@ -1,4 +1,4 @@
import { Activity, Box, LayoutList, TerminalSquare } from 'lucide-react';
import { Activity, Box, LayoutList, Loader2, TerminalSquare } from 'lucide-react';
import React, { memo, useMemo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
@@ -15,6 +15,19 @@ import { WorkspaceSidebarHostHeader } from '../terminalLayer/WorkspaceSidebarHos
import { SystemPanelEmpty, SystemPanelShell } from './SystemPanelUi';
import { useSessionCapabilities } from './hooks/useSystemManager';
const SystemPanelChecking = memo(function SystemPanelChecking({
message,
}: {
message: string;
}) {
return (
<div className="flex h-full min-h-[180px] flex-col items-center justify-center px-4 py-10 text-center text-xs text-muted-foreground">
<Loader2 size={18} className="mb-2 animate-spin opacity-70" />
<span>{message}</span>
</div>
);
});
interface SystemManagerSidePanelProps {
session: TerminalSession | null;
sessionHost: Host | null;
@@ -82,6 +95,8 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
const dockerReady = capabilities?.hasDocker === true;
const tmuxUnavailable = !probing && capabilities !== undefined && !tmuxReady;
const dockerUnavailable = !probing && capabilities !== undefined && !dockerReady;
const tmuxChecking = resolvedTab === 'tmux' && !tmuxReady && !tmuxUnavailable;
const dockerChecking = resolvedTab === 'docker' && !dockerReady && !dockerUnavailable;
return (
<SystemPanelShell section="system-manager-panel">
@@ -106,42 +121,56 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
</div>
<div className="flex-1 min-h-0 flex flex-col">
{resolvedTab === 'processes' && (
<div className={cn('flex-1 min-h-0 flex flex-col', resolvedTab !== 'processes' && 'hidden')}>
<ProcessManagerTab
sessionId={sessionId}
isVisible={isVisible}
isVisible={isVisible && resolvedTab === 'processes'}
backend={backend}
refreshIntervalSec={terminalSettings.systemManagerProcessRefreshInterval}
/>
)}
{resolvedTab === 'tmux' && (
tmuxUnavailable ? (
</div>
{tmuxUnavailable && resolvedTab === 'tmux' ? (
<div className="flex-1 min-h-0">
<SystemPanelEmpty icon={TerminalSquare} message={t('systemManager.tmux.unavailable')} />
) : (
</div>
) : tmuxChecking ? (
<div className="flex-1 min-h-0">
<SystemPanelChecking message={t('systemManager.common.checkingAvailability')} />
</div>
) : tmuxReady ? (
<div className={cn('flex-1 min-h-0 flex flex-col', resolvedTab !== 'tmux' && 'hidden')}>
<TmuxManagerTab
sessionId={sessionId}
parentSession={session}
isVisible={isVisible && tmuxReady}
isVisible={isVisible && resolvedTab === 'tmux'}
warmupEnabled={isVisible && resolvedTab !== 'tmux'}
backend={backend}
refreshIntervalSec={terminalSettings.systemManagerTmuxRefreshInterval}
snippets={snippets}
/>
)
)}
{resolvedTab === 'docker' && (
dockerUnavailable ? (
</div>
) : null}
{dockerUnavailable && resolvedTab === 'docker' ? (
<div className="flex-1 min-h-0">
<SystemPanelEmpty icon={Box} message={t('systemManager.docker.unavailable')} />
) : (
</div>
) : dockerChecking ? (
<div className="flex-1 min-h-0">
<SystemPanelChecking message={t('systemManager.common.checkingAvailability')} />
</div>
) : dockerReady ? (
<div className={cn('flex-1 min-h-0 flex flex-col', resolvedTab !== 'docker' && 'hidden')}>
<DockerManagerTab
sessionId={sessionId}
parentSession={session}
isVisible={isVisible && dockerReady}
isVisible={isVisible && resolvedTab === 'docker'}
warmupEnabled={isVisible && resolvedTab !== 'docker'}
backend={backend}
listRefreshIntervalSec={terminalSettings.systemManagerDockerListRefreshInterval}
statsRefreshIntervalSec={terminalSettings.systemManagerDockerStatsRefreshInterval}
/>
)
)}
</div>
) : null}
</div>
</SystemPanelShell>
);

View File

@@ -203,6 +203,19 @@ export const SystemPanelEmpty = memo(function SystemPanelEmpty({
);
});
export const SystemPanelLoading = memo(function SystemPanelLoading({
message,
}: {
message: string;
}) {
return (
<div className="flex min-h-[180px] flex-col items-center justify-center px-4 py-10 text-center text-xs text-muted-foreground">
<Loader2 size={18} className="mb-2 animate-spin opacity-70" />
<span>{message}</span>
</div>
);
});
export const SystemPanelError = memo(function SystemPanelError({
message,
onRetry,
@@ -270,6 +283,7 @@ export const SystemPanelRow = memo(function SystemPanelRow({
subtitle,
trailing,
actions,
className,
}: {
selected?: boolean;
onClick?: () => void;
@@ -279,6 +293,7 @@ export const SystemPanelRow = memo(function SystemPanelRow({
subtitle?: ReactNode;
trailing?: ReactNode;
actions?: ReactNode;
className?: string;
}) {
const content = (
<>
@@ -292,7 +307,7 @@ export const SystemPanelRow = memo(function SystemPanelRow({
{trailing}
{actions && (
<div
className="flex shrink-0 items-center justify-end gap-0.5 invisible group-hover:visible group-focus-within:visible"
className="flex shrink-0 items-center justify-end gap-0.5"
onClick={(e) => e.stopPropagation()}
>
{actions}
@@ -301,10 +316,11 @@ export const SystemPanelRow = memo(function SystemPanelRow({
</>
);
const className = cn(
const rowClassName = cn(
'group flex items-center gap-2.5 pr-2.5 py-2.5 min-h-[44px] border-b border-border/30',
selected && 'bg-accent/30',
onClick && 'cursor-pointer hover:bg-accent/50',
className,
);
const style = { paddingLeft: 12 + depth * 14 };
@@ -315,7 +331,7 @@ export const SystemPanelRow = memo(function SystemPanelRow({
<div
role="button"
tabIndex={0}
className={cn('w-full text-left', className)}
className={cn('w-full text-left', rowClassName)}
style={style}
onClick={onClick}
onKeyDown={(e) => {
@@ -332,7 +348,7 @@ export const SystemPanelRow = memo(function SystemPanelRow({
}
return (
<div className={className} style={style}>
<div className={rowClassName} style={style}>
{content}
</div>
);

View File

@@ -1,21 +1,23 @@
import { Plus, TerminalSquare } from 'lucide-react';
import React, { memo, useCallback, useMemo, useState } from 'react';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
import type { Snippet, TerminalSession } from '../../types';
import type { TmuxSessionInfo } from '../../domain/systemManager/types';
import type { TmuxClientInfo, TmuxSessionInfo, TmuxWindowInfo } from '../../domain/systemManager/types';
import { tmuxSessionInfoEqual } from '../../domain/systemManager/pollEquals';
import {
SystemPanelEmpty,
SystemPanelError,
SystemPanelIconButton,
SystemPanelList,
SystemPanelLoading,
SystemPanelMetaBar,
SystemPanelRefreshButton,
SystemPanelSearch,
SystemPanelShell,
SystemPanelToolbar,
} from './SystemPanelUi';
import { useAsyncRecordCache } from './hooks/useAsyncRecordCache';
import { usePolling, useStableTranslate } from './hooks/useSystemManager';
import { TmuxNewSessionModal } from './TmuxNewSessionModal';
import { TmuxSessionCard } from './TmuxSessionCard';
@@ -23,10 +25,16 @@ import { useStableListOrder, mergePollListByKey } from './listStable';
type Backend = ReturnType<typeof useSystemManagerBackend>;
export interface TmuxSessionDetails {
windows: TmuxWindowInfo[];
clients: TmuxClientInfo[];
}
interface TmuxManagerTabProps {
sessionId: string;
parentSession: TerminalSession;
isVisible: boolean;
warmupEnabled?: boolean;
backend: Backend;
refreshIntervalSec: number;
snippets: Snippet[];
@@ -36,6 +44,7 @@ export const TmuxManagerTab = memo(function TmuxManagerTab({
sessionId,
parentSession,
isVisible,
warmupEnabled = false,
backend,
refreshIntervalSec,
snippets,
@@ -48,11 +57,20 @@ export const TmuxManagerTab = memo(function TmuxManagerTab({
const [modalError, setModalError] = useState<string | null>(null);
const [tmuxVersion, setTmuxVersion] = useState<string | null>(null);
const currentSessionIdRef = useRef(sessionId);
currentSessionIdRef.current = sessionId;
useEffect(() => {
setTmuxVersion(null);
}, [sessionId]);
const fetcher = useCallback(async () => {
const fetchSessionId = sessionId;
const result = await backend.listTmuxSessions(sessionId);
const version = result.tmuxVersion ?? null;
setTmuxVersion((prev) => (prev === version ? prev : version));
if (currentSessionIdRef.current === fetchSessionId) {
setTmuxVersion((prev) => (prev === version ? prev : version));
}
if (!result.success) {
throw new Error(result.error || stableT('systemManager.errors.loadTmux'));
}
@@ -63,11 +81,12 @@ export const TmuxManagerTab = memo(function TmuxManagerTab({
const { data: sessions, error, loading, refresh } = usePolling<TmuxSessionInfo[]>(
fetcher,
intervalMs,
isVisible,
isVisible || warmupEnabled,
(prev, next) => mergePollListByKey(prev, next, (s) => s.name, tmuxSessionInfoEqual),
{ poll: isVisible, resetKey: sessionId },
);
const filtered = useMemo(() => {
const filtered = useMemo<TmuxSessionInfo[]>(() => {
const q = query.trim().toLowerCase();
const list = sessions ?? [];
if (!q) return list;
@@ -78,13 +97,69 @@ export const TmuxManagerTab = memo(function TmuxManagerTab({
(a: TmuxSessionInfo, b: TmuxSessionInfo) => a.name.localeCompare(b.name),
[],
);
const displaySessions = useStableListOrder(
const displaySessions = useStableListOrder<TmuxSessionInfo, string>(
filtered,
(s) => s.name,
query,
compareSessions,
);
const formatTmuxLoadError = useCallback((
message: string,
debug?: { lastOutput?: string; tried?: string[] },
) => {
const parts = [message];
if (debug?.lastOutput) parts.push(debug.lastOutput);
if (debug?.tried?.length) {
parts.push(t('systemManager.tmux.lastCommand', { command: debug.tried[debug.tried.length - 1] ?? '' }));
}
return parts.filter(Boolean).join(' · ');
}, [t]);
const getTmuxDetailsKey = useCallback((session: TmuxSessionInfo) => (
`${sessionId}:${session.name}:${session.created}`
), [sessionId]);
const fetchTmuxDetails = useCallback(async (session: TmuxSessionInfo): Promise<TmuxSessionDetails> => {
const [windowsResult, clientsResult] = await Promise.all([
backend.listTmuxWindows({ sessionId, sessionName: session.name }),
backend.listTmuxClients({ sessionId, sessionName: session.name }),
]);
if (!windowsResult.success) {
throw new Error(formatTmuxLoadError(
windowsResult.error || stableT('systemManager.errors.loadTmuxWindows'),
windowsResult.debug,
));
}
if (!clientsResult.success) {
throw new Error(clientsResult.error || stableT('systemManager.errors.loadTmuxClients'));
}
const freshWindows = windowsResult.windows ?? [];
if (freshWindows.length === 0 && session.windows > 0) {
throw new Error(formatTmuxLoadError(
stableT('systemManager.tmux.windowsMismatch', { count: String(session.windows) }),
windowsResult.debug,
));
}
return {
windows: freshWindows,
clients: clientsResult.clients ?? [],
};
}, [backend, formatTmuxLoadError, sessionId, stableT]);
const {
records: tmuxDetailsByName,
loadRecord: loadTmuxDetails,
refreshRecord: refreshTmuxDetails,
} = useAsyncRecordCache<TmuxSessionInfo, TmuxSessionDetails>({
items: sessions ?? [],
enabled: isVisible && (sessions?.length ?? 0) > 0,
getKey: getTmuxDetailsKey,
fetchRecord: fetchTmuxDetails,
prefetchLimit: 16,
prefetchDelayMs: 40,
staleTimeMs: 20_000,
});
const handleCreate = useCallback(async (name: string, command: string) => {
setCreating(true);
setModalError(null);
@@ -140,6 +215,9 @@ export const TmuxManagerTab = memo(function TmuxManagerTab({
</SystemPanelMetaBar>
<SystemPanelList>
{!error && displaySessions.length === 0 && loading && (
<SystemPanelLoading message={t('systemManager.common.loading')} />
)}
{!error && displaySessions.length === 0 && !loading && (
<SystemPanelEmpty icon={TerminalSquare} message={t('systemManager.tmux.empty')} />
)}
@@ -148,11 +226,14 @@ export const TmuxManagerTab = memo(function TmuxManagerTab({
)}
{displaySessions.map((session) => (
<TmuxSessionCard
key={session.name}
key={`${session.name}:${session.created}`}
session={session}
sessionId={sessionId}
parentSession={parentSession}
backend={backend}
detailsRecord={tmuxDetailsByName[getTmuxDetailsKey(session)]}
onLoadDetails={loadTmuxDetails}
onRefreshDetails={refreshTmuxDetails}
onSessionsChanged={refresh}
/>
))}

View File

@@ -1,17 +1,17 @@
import {
Loader2, MonitorPlay, Pencil, Plus, Trash2, Unplug,
} from 'lucide-react';
import React, { memo, useCallback, useEffect, useState } from 'react';
import React, { memo, useEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
import { buildTmuxAttachCommand } from '../../domain/systemManager/tmuxShell';
import type {
TmuxClientInfo,
TmuxManageAction,
TmuxSessionInfo,
TmuxWindowInfo,
} from '../../domain/systemManager/types';
import type { TerminalSession } from '../../types';
import type { AsyncRecordState } from './hooks/useAsyncRecordCache';
import type { TmuxSessionDetails } from './TmuxManagerTab';
import {
SystemPanelCollapsible,
SystemPanelDetailStrip,
@@ -46,6 +46,9 @@ interface TmuxSessionCardProps {
sessionId: string;
parentSession: TerminalSession;
backend: Backend;
detailsRecord?: AsyncRecordState<TmuxSessionDetails>;
onLoadDetails: (session: TmuxSessionInfo, options?: { force?: boolean; urgent?: boolean }) => Promise<void>;
onRefreshDetails: (session: TmuxSessionInfo) => Promise<void>;
onSessionsChanged: () => Promise<void>;
}
@@ -54,74 +57,42 @@ export const TmuxSessionCard = memo(function TmuxSessionCard({
sessionId,
parentSession,
backend,
detailsRecord,
onLoadDetails,
onRefreshDetails,
onSessionsChanged,
}: TmuxSessionCardProps) {
const { t } = useI18n();
const [expanded, setExpanded] = useState(false);
const [loadingDetails, setLoadingDetails] = useState(false);
const [windows, setWindows] = useState<TmuxWindowInfo[]>([]);
const [clients, setClients] = useState<TmuxClientInfo[]>([]);
const [renamePrompt, setRenamePrompt] = useState<RenamePromptTarget | null>(null);
const [newWindowOpen, setNewWindowOpen] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const [windowsLoadDetail, setWindowsLoadDetail] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [pending, setPending] = useState<PendingTarget | null>(null);
const formatTmuxLoadError = useCallback((
message: string,
debug?: { lastOutput?: string; tried?: string[] },
) => {
const parts = [message];
if (debug?.lastOutput) parts.push(debug.lastOutput);
if (debug?.tried?.length) {
parts.push(t('systemManager.tmux.lastCommand', { command: debug.tried[debug.tried.length - 1] ?? '' }));
}
return parts.filter(Boolean).join(' · ');
}, [t]);
const loadDetails = useCallback(async (): Promise<TmuxWindowInfo[] | null> => {
setLoadingDetails(true);
setActionError(null);
setWindowsLoadDetail(null);
try {
const [windowsResult, clientsResult] = await Promise.all([
backend.listTmuxWindows({ sessionId, sessionName: session.name }),
backend.listTmuxClients({ sessionId, sessionName: session.name }),
]);
if (!windowsResult.success) {
const detail = formatTmuxLoadError(
windowsResult.error || t('systemManager.errors.loadTmuxWindows'),
windowsResult.debug,
);
setWindowsLoadDetail(detail);
throw new Error(detail);
}
if (!clientsResult.success) throw new Error(clientsResult.error || t('systemManager.errors.loadTmuxClients'));
const freshWindows = windowsResult.windows ?? [];
if (freshWindows.length === 0 && session.windows > 0) {
const detail = formatTmuxLoadError(
t('systemManager.tmux.windowsMismatch', { count: String(session.windows) }),
windowsResult.debug,
);
setWindowsLoadDetail(detail);
throw new Error(detail);
}
setWindows(freshWindows);
setClients(clientsResult.clients ?? []);
return freshWindows;
} catch (err) {
setActionError(err instanceof Error ? err.message : t('systemManager.errors.actionFailed'));
setWindows([]);
return null;
} finally {
setLoadingDetails(false);
}
}, [backend, formatTmuxLoadError, session.name, session.windows, sessionId, t]);
const windows = detailsRecord?.data?.windows ?? [];
const clients = detailsRecord?.data?.clients ?? [];
const loadingDetails = detailsRecord?.loading ?? false;
const windowsLoadDetail = detailsRecord?.error ?? null;
const summaryKey = useMemo(
() => `${session.name}|${session.created}|${session.windows}|${session.attached}|${session.activity ?? ''}`,
[session.activity, session.attached, session.created, session.name, session.windows],
);
const lastExpandedSummaryKeyRef = useRef<string | null>(null);
useEffect(() => {
if (expanded) void loadDetails();
}, [expanded, loadDetails]);
if (!expanded) {
lastExpandedSummaryKeyRef.current = null;
return;
}
if (lastExpandedSummaryKeyRef.current === null) {
lastExpandedSummaryKeyRef.current = summaryKey;
return;
}
if (lastExpandedSummaryKeyRef.current === summaryKey) return;
lastExpandedSummaryKeyRef.current = summaryKey;
void onRefreshDetails(session);
}, [expanded, onRefreshDetails, session, summaryKey]);
const runAction = async (action: TmuxManageAction) => {
setBusy(true);
@@ -135,7 +106,7 @@ export const TmuxSessionCard = memo(function TmuxSessionCard({
if (!result.success) throw new Error(result.error || t('systemManager.errors.actionFailed'));
const cardWillRemount = action.action === 'killSession' || action.action === 'renameSession';
if (!cardWillRemount && expanded) {
await loadDetails();
await onRefreshDetails(session);
}
await onSessionsChanged();
} catch (err) {
@@ -170,7 +141,13 @@ export const TmuxSessionCard = memo(function TmuxSessionCard({
<>
<SystemPanelRow
selected={expanded}
onClick={() => setExpanded((v) => !v)}
onClick={() => {
const nextExpanded = !expanded;
setExpanded(nextExpanded);
if (nextExpanded) {
void onLoadDetails(session, { force: true, urgent: true });
}
}}
title={session.name}
subtitle={t('systemManager.tmux.windows', { count: String(session.windows) })}
trailing={(

View File

@@ -0,0 +1,269 @@
import { startTransition, useCallback, useEffect, useRef, useState } from 'react';
export interface AsyncRecordState<T> {
data: T | null;
loading: boolean;
error: string | null;
updatedAt: number | null;
}
type RecordMap<T> = Record<string, AsyncRecordState<T>>;
interface UseAsyncRecordCacheOptions<TItem, TValue> {
items: TItem[];
enabled: boolean;
getKey: (item: TItem) => string;
fetchRecord: (item: TItem) => Promise<TValue | null>;
prefetchLimit?: number;
prefetchDelayMs?: number;
staleTimeMs?: number;
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
function scheduleIdleTask(callback: () => void): () => void {
if (typeof window.requestIdleCallback === 'function') {
const id = window.requestIdleCallback(callback, { timeout: 1200 });
return () => window.cancelIdleCallback(id);
}
const id = window.setTimeout(callback, 80);
return () => window.clearTimeout(id);
}
function normalizeRecordError(error: unknown): string {
return error instanceof Error ? error.message : String(error || 'Unknown error');
}
function isRecordFresh<TValue>(record: AsyncRecordState<TValue> | undefined, staleTimeMs: number): boolean {
if (!record || record.error || record.updatedAt === null) return false;
if (!Number.isFinite(staleTimeMs)) return true;
return Date.now() - record.updatedAt < staleTimeMs;
}
const EMPTY_RECORDS = {};
export function useAsyncRecordCache<TItem, TValue>({
items,
enabled,
getKey,
fetchRecord,
prefetchLimit = 64,
prefetchDelayMs = 16,
staleTimeMs = 30_000,
}: UseAsyncRecordCacheOptions<TItem, TValue>) {
const [records, setRecords] = useState<RecordMap<TValue>>(() => EMPTY_RECORDS);
const recordsRef = useRef<RecordMap<TValue>>(records);
const enabledRef = useRef(enabled);
const inflightRef = useRef(new Set<string>());
const requestVersionRef = useRef(new Map<string, number>());
const queuedForceRef = useRef(new Set<string>());
const loadRecordRef = useRef<(
item: TItem,
options?: { force?: boolean; urgent?: boolean },
) => Promise<void>>(async () => {});
recordsRef.current = records;
enabledRef.current = enabled;
const commitRecords = useCallback((
updater: (prev: RecordMap<TValue>) => RecordMap<TValue>,
urgent = false,
) => {
const apply = () => {
setRecords((prev) => {
const next = updater(prev);
recordsRef.current = next;
return next;
});
};
if (urgent) {
apply();
return;
}
startTransition(apply);
}, []);
const loadRecord = useCallback(async (
item: TItem,
options?: { force?: boolean; urgent?: boolean },
) => {
if (!enabledRef.current) return;
const key = getKey(item);
if (!key) return;
if (inflightRef.current.has(key)) {
if (options?.force) {
queuedForceRef.current.add(key);
requestVersionRef.current.set(key, (requestVersionRef.current.get(key) ?? 0) + 1);
}
return;
}
const existing = recordsRef.current[key];
if (!options?.force && isRecordFresh(existing, staleTimeMs)) {
return;
}
const requestVersion = (requestVersionRef.current.get(key) ?? 0) + 1;
requestVersionRef.current.set(key, requestVersion);
inflightRef.current.add(key);
commitRecords((prev) => ({
...prev,
[key]: {
data: prev[key]?.data ?? null,
loading: true,
error: null,
updatedAt: prev[key]?.updatedAt ?? null,
},
}), options?.urgent);
try {
const data = await fetchRecord(item);
if (requestVersionRef.current.get(key) !== requestVersion) return;
commitRecords((prev) => ({
...prev,
[key]: {
data,
loading: false,
error: null,
updatedAt: Date.now(),
},
}));
} catch (error) {
if (requestVersionRef.current.get(key) !== requestVersion) return;
commitRecords((prev) => ({
...prev,
[key]: {
data: prev[key]?.data ?? null,
loading: false,
error: normalizeRecordError(error),
updatedAt: prev[key]?.updatedAt ?? null,
},
}));
} finally {
inflightRef.current.delete(key);
if (queuedForceRef.current.has(key)) {
if (enabledRef.current) {
queuedForceRef.current.delete(key);
void loadRecordRef.current(item, { force: true, urgent: options?.urgent });
} else {
commitRecords((prev) => {
const current = prev[key];
if (!current?.loading) return prev;
return {
...prev,
[key]: {
...current,
loading: false,
},
};
}, true);
}
}
}
}, [commitRecords, fetchRecord, getKey, staleTimeMs]);
loadRecordRef.current = loadRecord;
useEffect(() => {
const itemKeys = new Set(items.map(getKey).filter(Boolean));
for (const key of queuedForceRef.current) {
if (!itemKeys.has(key)) {
queuedForceRef.current.delete(key);
}
}
commitRecords((prev) => {
let changed = false;
const next: RecordMap<TValue> = {};
for (const [key, value] of Object.entries(prev) as Array<[string, AsyncRecordState<TValue>]>) {
if (!itemKeys.has(key)) {
changed = true;
continue;
}
next[key] = value;
}
return changed ? next : prev;
});
}, [commitRecords, getKey, items]);
useEffect(() => {
if (!enabled || queuedForceRef.current.size === 0) return;
for (const item of items) {
const key = getKey(item);
if (!key || !queuedForceRef.current.has(key)) continue;
queuedForceRef.current.delete(key);
void loadRecord(item, { force: true, urgent: true });
}
}, [enabled, getKey, items, loadRecord]);
useEffect(() => {
if (!enabled || items.length === 0 || prefetchLimit <= 0) return undefined;
let cancelled = false;
const candidates = items.slice(0, prefetchLimit);
const cancelIdleTask = scheduleIdleTask(() => {
void (async () => {
for (const item of candidates) {
if (cancelled) return;
await loadRecord(item);
if (prefetchDelayMs > 0) {
await delay(prefetchDelayMs);
}
}
})();
});
return () => {
cancelled = true;
cancelIdleTask();
};
}, [enabled, items, loadRecord, prefetchDelayMs, prefetchLimit]);
const invalidateRecord = useCallback((key: string) => {
requestVersionRef.current.set(key, (requestVersionRef.current.get(key) ?? 0) + 1);
queuedForceRef.current.delete(key);
commitRecords((prev) => {
if (!(key in prev)) return prev;
const { [key]: _removed, ...next } = prev;
return next;
}, true);
}, [commitRecords]);
const invalidateMatching = useCallback((matches: (key: string) => boolean) => {
for (const key of requestVersionRef.current.keys()) {
if (matches(key)) {
requestVersionRef.current.set(key, (requestVersionRef.current.get(key) ?? 0) + 1);
queuedForceRef.current.delete(key);
}
}
commitRecords((prev) => {
let changed = false;
const next: RecordMap<TValue> = {};
for (const [key, value] of Object.entries(prev) as Array<[string, AsyncRecordState<TValue>]>) {
if (matches(key)) {
changed = true;
continue;
}
next[key] = value;
}
return changed ? next : prev;
}, true);
}, [commitRecords]);
const refreshRecord = useCallback(
(item: TItem) => loadRecord(item, { force: true, urgent: true }),
[loadRecord],
);
return {
records,
loadRecord,
refreshRecord,
invalidateRecord,
invalidateMatching,
};
}

View File

@@ -113,20 +113,37 @@ export function usePolling<T>(
intervalMs: number,
enabled: boolean,
merge?: (prev: T | null, next: T) => T,
options?: { poll?: boolean; resetKey?: string },
) {
const stableT = useStableTranslate();
const resetKey = options?.resetKey ?? '';
const [data, setData] = useState<T | null>(null);
const [dataKey, setDataKey] = useState(resetKey);
const [error, setError] = useState<string | null>(null);
const [errorKey, setErrorKey] = useState(resetKey);
const [loading, setLoading] = useState(false);
const [loadingKey, setLoadingKey] = useState(resetKey);
const failuresRef = useRef(0);
const hasDataRef = useRef(false);
const inflightRef = useRef(false);
const enabledRef = useRef(enabled);
const generationRef = useRef(0);
const runIdRef = useRef(0);
const loadingRunIdRef = useRef(0);
const inflightRef = useRef<{ generation: number; runId: number } | null>(null);
const queuedRunRef = useRef<{
options?: { withLoading?: boolean; minLoadingMs?: number };
resolve: () => void;
} | null>(null);
const fetcherRef = useRef(fetcher);
const mergeRef = useRef(merge);
const pollRef = useRef(options?.poll ?? true);
const resetKeyRef = useRef(resetKey);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
enabledRef.current = enabled;
fetcherRef.current = fetcher;
mergeRef.current = merge;
pollRef.current = options?.poll ?? true;
const clearPollTimer = useCallback(() => {
if (timerRef.current !== null) {
@@ -140,70 +157,163 @@ export function usePolling<T>(
return intervalMs;
}, [intervalMs]);
const resolveQueuedRun = useCallback(() => {
queuedRunRef.current?.resolve();
queuedRunRef.current = null;
}, []);
const run = useCallback(async (options?: { withLoading?: boolean; minLoadingMs?: number }) => {
if (!enabled || inflightRef.current) return;
inflightRef.current = true;
const generation = generationRef.current;
const runResetKey = resetKeyRef.current;
if (!enabledRef.current) return;
if (inflightRef.current?.generation === generation) {
if (options?.withLoading) {
queuedRunRef.current?.resolve();
loadingRunIdRef.current = 0;
setLoadingKey(runResetKey);
setLoading(true);
return new Promise<void>((resolve) => {
queuedRunRef.current = { options, resolve };
});
}
return;
}
const runId = ++runIdRef.current;
inflightRef.current = { generation, runId };
const showLoading = options?.withLoading ?? !hasDataRef.current;
const startedAt = Date.now();
if (showLoading) setLoading(true);
const isCurrent = () => (
generationRef.current === generation
&& enabledRef.current
&& inflightRef.current?.runId === runId
&& resetKeyRef.current === runResetKey
);
if (showLoading) {
loadingRunIdRef.current = runId;
setLoadingKey(runResetKey);
setLoading(true);
}
try {
const result = await fetcherRef.current();
if (!isCurrent()) return;
if (result !== null) {
setDataKey(runResetKey);
setData((prev) => {
const mergeFn = mergeRef.current;
const next = mergeFn ? mergeFn(prev, result) : nextPollData(prev, result);
if (next !== prev) hasDataRef.current = true;
return next;
});
setErrorKey(runResetKey);
setError(null);
failuresRef.current = 0;
}
} catch (err) {
if (!isCurrent()) return;
failuresRef.current += 1;
setDataKey(runResetKey);
setData(null);
hasDataRef.current = false;
setErrorKey(runResetKey);
setError(normalizePollingErrorMessage(err, stableT));
} finally {
inflightRef.current = false;
if (inflightRef.current?.runId === runId) {
inflightRef.current = null;
}
if (showLoading) {
const remaining = Math.max(0, (options?.minLoadingMs ?? 0) - (Date.now() - startedAt));
if (remaining > 0) await delay(remaining);
setLoading(false);
if (
generationRef.current === generation
&& enabledRef.current
&& resetKeyRef.current === runResetKey
&& loadingRunIdRef.current === runId
) {
loadingRunIdRef.current = 0;
setLoadingKey(runResetKey);
setLoading(false);
}
}
const queued = queuedRunRef.current;
if (
queued
&& generationRef.current === generation
&& enabledRef.current
&& resetKeyRef.current === runResetKey
) {
queuedRunRef.current = null;
await run(queued.options);
queued.resolve();
}
}
}, [enabled, stableT]);
}, [stableT]);
const scheduleNextPoll = useCallback(() => {
clearPollTimer();
if (!enabled) return;
if (!enabledRef.current || !pollRef.current) return;
const generation = generationRef.current;
timerRef.current = setTimeout(() => {
void run({ withLoading: false }).finally(() => {
scheduleNextPoll();
if (generationRef.current === generation) {
scheduleNextPoll();
}
});
}, pollDelayMs());
}, [clearPollTimer, enabled, pollDelayMs, run]);
}, [clearPollTimer, pollDelayMs, run]);
useEffect(() => {
const resetChanged = resetKeyRef.current !== resetKey;
resetKeyRef.current = resetKey;
generationRef.current += 1;
inflightRef.current = null;
clearPollTimer();
if (!enabled) {
clearPollTimer();
resolveQueuedRun();
loadingRunIdRef.current = 0;
setLoading(false);
setLoadingKey(resetKey);
setDataKey(resetKey);
setData(null);
setErrorKey(resetKey);
setError(null);
failuresRef.current = 0;
hasDataRef.current = false;
return undefined;
}
if (resetChanged) {
resolveQueuedRun();
loadingRunIdRef.current = 0;
setLoading(false);
setLoadingKey(resetKey);
setDataKey(resetKey);
setData(null);
setErrorKey(resetKey);
setError(null);
failuresRef.current = 0;
hasDataRef.current = false;
}
const generation = generationRef.current;
void run({ withLoading: true }).finally(() => {
scheduleNextPoll();
if (generationRef.current === generation && pollRef.current) scheduleNextPoll();
});
return () => {
generationRef.current += 1;
resolveQueuedRun();
loadingRunIdRef.current = 0;
inflightRef.current = null;
clearPollTimer();
};
}, [clearPollTimer, enabled, intervalMs, run, scheduleNextPoll]);
}, [clearPollTimer, enabled, intervalMs, options?.poll, resetKey, resolveQueuedRun, run, scheduleNextPoll]);
const refresh = useCallback(async () => {
failuresRef.current = 0;
await run({ withLoading: true, minLoadingMs: 450 });
}, [run]);
return { data, error, loading, refresh };
return {
data: dataKey === resetKey ? data : null,
error: errorKey === resetKey ? error : null,
loading: loadingKey === resetKey ? loading : enabled,
refresh,
};
}

View File

@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
import en from "../../application/i18n/locales/en.ts";
import zhCN from "../../application/i18n/locales/zh-CN.ts";
import { markMiddleClickContextMenuEvent } from "./runtime/middleClickBehavior.ts";
import * as terminalContextMenu from "./TerminalContextMenu.tsx";
import { shouldEnableYmodemAction } from "./TerminalView.tsx";
@@ -22,6 +23,30 @@ const shouldSuppressMouseTrackingContextMenu = (
}) => boolean;
}
).shouldSuppressMouseTrackingContextMenu;
const shouldShowAddSelectionToAIContextMenuAction = (
terminalContextMenu as {
shouldShowAddSelectionToAIContextMenuAction?: (onAddSelectionToAI?: () => void) => boolean;
}
).shouldShowAddSelectionToAIContextMenuAction;
const shouldOpenTerminalContextMenu = (
terminalContextMenu as {
shouldOpenTerminalContextMenu?: (options: {
event: { shiftKey?: boolean; nativeEvent: MouseEvent };
rightClickBehavior?: "context-menu" | "paste" | "select-word";
isAlternateScreen?: boolean;
showReconnectAction?: boolean;
}) => boolean;
}
).shouldOpenTerminalContextMenu;
const shouldRenderTerminalContextMenuContent = (
terminalContextMenu as {
shouldRenderTerminalContextMenuContent?: (options: {
isAlternateScreen?: boolean;
showReconnectAction?: boolean;
allowSuppressedMenuContent?: boolean;
}) => boolean;
}
).shouldRenderTerminalContextMenuContent;
test("shows reconnect only for reconnectable terminals with a handler", () => {
assert.equal(typeof shouldShowReconnectAction, "function");
@@ -49,11 +74,23 @@ test("localizes the reconnect context menu label", () => {
assert.equal(zhCN["terminal.menu.reconnect"], "重新连接");
});
test("shows add selection to AI context menu action when a handler exists", () => {
assert.equal(typeof shouldShowAddSelectionToAIContextMenuAction, "function");
if (typeof shouldShowAddSelectionToAIContextMenuAction !== "function") return;
assert.equal(shouldShowAddSelectionToAIContextMenuAction(() => {}), true);
assert.equal(shouldShowAddSelectionToAIContextMenuAction(), false);
});
test("localizes the YMODEM serial send actions", () => {
assert.equal(en["terminal.menu.sendYmodem"], "Send with YMODEM");
assert.equal(en["terminal.menu.receiveYmodem"], "Receive with YMODEM");
assert.equal(en["terminal.toolbar.sendYmodem"], "Send with YMODEM");
assert.equal(en["terminal.toolbar.receiveYmodem"], "Receive with YMODEM");
assert.equal(zhCN["terminal.menu.sendYmodem"], "YMODEM 发送");
assert.equal(zhCN["terminal.menu.receiveYmodem"], "YMODEM 接收");
assert.equal(zhCN["terminal.toolbar.sendYmodem"], "YMODEM 发送");
assert.equal(zhCN["terminal.toolbar.receiveYmodem"], "YMODEM 接收");
});
test("enables YMODEM action only for connected serial terminals", () => {
@@ -64,6 +101,16 @@ test("enables YMODEM action only for connected serial terminals", () => {
status: "connected",
handleSendYmodem: handler,
}), true);
assert.equal(shouldEnableYmodemAction({
isSerialConnection: true,
status: "connected",
handleReceiveYmodem: handler,
}), true);
assert.equal(shouldEnableYmodemAction({
isSerialConnection: true,
status: "disconnected",
handleReceiveYmodem: handler,
}), false);
assert.equal(shouldEnableYmodemAction({
isSerialConnection: true,
status: "disconnected",
@@ -99,3 +146,83 @@ test("allows reconnect menu while stale mouse tracking is still active", () => {
true,
);
});
test("opens a middle-click menu even when right-click is configured to paste", () => {
assert.equal(typeof shouldOpenTerminalContextMenu, "function");
if (typeof shouldOpenTerminalContextMenu !== "function") return;
assert.equal(
shouldOpenTerminalContextMenu({
event: {
shiftKey: false,
nativeEvent: markMiddleClickContextMenuEvent({} as MouseEvent),
},
rightClickBehavior: "paste",
}),
true,
);
assert.equal(
shouldOpenTerminalContextMenu({
event: {
shiftKey: false,
nativeEvent: {} as MouseEvent,
},
rightClickBehavior: "paste",
}),
false,
);
});
test("opens and renders middle-click menu while alternate-screen mouse tracking suppresses right-click menus", () => {
assert.equal(typeof shouldOpenTerminalContextMenu, "function");
assert.equal(typeof shouldRenderTerminalContextMenuContent, "function");
if (
typeof shouldOpenTerminalContextMenu !== "function" ||
typeof shouldRenderTerminalContextMenuContent !== "function"
) {
return;
}
assert.equal(
shouldOpenTerminalContextMenu({
event: {
shiftKey: false,
nativeEvent: markMiddleClickContextMenuEvent({} as MouseEvent),
},
rightClickBehavior: "paste",
isAlternateScreen: true,
showReconnectAction: false,
}),
true,
);
assert.equal(
shouldRenderTerminalContextMenuContent({
isAlternateScreen: true,
showReconnectAction: false,
allowSuppressedMenuContent: true,
}),
true,
);
assert.equal(
shouldOpenTerminalContextMenu({
event: {
shiftKey: false,
nativeEvent: {} as MouseEvent,
},
rightClickBehavior: "context-menu",
isAlternateScreen: true,
showReconnectAction: false,
}),
false,
);
assert.equal(
shouldRenderTerminalContextMenuContent({
isAlternateScreen: true,
showReconnectAction: false,
allowSuppressedMenuContent: false,
}),
false,
);
});

View File

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

View File

@@ -0,0 +1,63 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
TERMINAL_TIMESTAMP_GUTTER_HORIZONTAL_PADDING,
TERMINAL_TIMESTAMP_GUTTER_MIN_WIDTH,
getTerminalTimestampTypography,
resolveTerminalTimestampGutterColor,
resolveTerminalTimestampGutterWidth,
} from "./TerminalTimestampGutter.tsx";
test("timestamp gutter uses a bright color from the active terminal theme", () => {
assert.equal(
resolveTerminalTimestampGutterColor({
brightCyan: "#66e8ff",
brightYellow: "#ffe066",
foreground: "#dddddd",
}),
"#66e8ff",
);
});
test("timestamp gutter falls back within the terminal theme palette", () => {
assert.equal(
resolveTerminalTimestampGutterColor({
brightYellow: "#ffe066",
foreground: "#dddddd",
}),
"#ffe066",
);
assert.equal(
resolveTerminalTimestampGutterColor({
foreground: "#dddddd",
}),
"#dddddd",
);
});
test("timestamp gutter width follows measured timestamp text width", () => {
assert.equal(
resolveTerminalTimestampGutterWidth({ measuredTextWidth: 84, fontSize: 14 }),
84 + TERMINAL_TIMESTAMP_GUTTER_HORIZONTAL_PADDING,
);
assert.equal(
resolveTerminalTimestampGutterWidth({ measuredTextWidth: 1, fontSize: 14 }),
TERMINAL_TIMESTAMP_GUTTER_MIN_WIDTH,
);
});
test("timestamp gutter typography follows terminal typography", () => {
assert.deepEqual(
getTerminalTimestampTypography({
fontFamily: '"JetBrains Mono", monospace',
fontSize: 15,
fontWeight: 500,
}),
{
fontFamily: '"JetBrains Mono", monospace',
fontSize: 15,
fontWeight: 500,
},
);
});

View File

@@ -0,0 +1,281 @@
import { useEffect, useLayoutEffect, useRef } from "react";
import type { RefObject } from "react";
import type { Terminal as XTerm } from "@xterm/xterm";
import {
getVisibleTerminalLineTimestampRows,
onTerminalLineTimestampsChange,
} from "./runtime/terminalLineTimestamps";
export const TERMINAL_TIMESTAMP_GUTTER_MIN_WIDTH = 56;
export const TERMINAL_TIMESTAMP_GUTTER_HORIZONTAL_PADDING = 16;
export const TERMINAL_TIMESTAMP_SAMPLE_LABEL = "88:88:88";
type TerminalTimestampGutterProps = {
termRef: RefObject<XTerm | null>;
containerRef: RefObject<HTMLDivElement | null>;
enabled: boolean;
top: string;
sessionId: string;
color: string;
fontFamily: string;
fontSize: number;
fontWeight: string | number;
width: number;
onWidthChange?: (width: number) => void;
};
type DisposableLike = {
dispose: () => void;
};
type TerminalTimestampTypography = {
fontFamily?: string;
fontSize?: number;
fontWeight?: string | number;
};
const getTerminalScreen = (container: HTMLElement): HTMLElement => (
container.querySelector<HTMLElement>(".xterm-screen") ?? container
);
const clearElement = (element: HTMLElement) => {
while (element.firstChild) {
element.removeChild(element.firstChild);
}
};
export const resolveTerminalTimestampGutterColor = (
colors: Partial<Record<"brightCyan" | "brightYellow" | "brightMagenta" | "foreground", string>>,
): string => (
colors.brightCyan
|| colors.brightYellow
|| colors.brightMagenta
|| colors.foreground
|| "currentColor"
);
const normalizeTerminalTimestampFontSize = (fontSize?: number): number => (
Number.isFinite(fontSize) && fontSize && fontSize > 0 ? fontSize : 12
);
export const getTerminalTimestampTypography = ({
fontFamily,
fontSize,
fontWeight,
}: TerminalTimestampTypography) => ({
fontFamily: fontFamily || "monospace",
fontSize: normalizeTerminalTimestampFontSize(fontSize),
fontWeight: fontWeight ?? 400,
});
const estimateTerminalTimestampTextWidth = (
fontSize: number,
label = TERMINAL_TIMESTAMP_SAMPLE_LABEL,
): number => (
normalizeTerminalTimestampFontSize(fontSize) * label.length * 0.62
);
export const resolveTerminalTimestampGutterWidth = ({
measuredTextWidth,
fontSize,
label = TERMINAL_TIMESTAMP_SAMPLE_LABEL,
}: {
measuredTextWidth?: number;
fontSize?: number;
label?: string;
}): number => {
const textWidth =
Number.isFinite(measuredTextWidth) && measuredTextWidth !== undefined && measuredTextWidth > 0
? measuredTextWidth
: estimateTerminalTimestampTextWidth(normalizeTerminalTimestampFontSize(fontSize), label);
return Math.ceil(Math.max(
TERMINAL_TIMESTAMP_GUTTER_MIN_WIDTH,
textWidth + TERMINAL_TIMESTAMP_GUTTER_HORIZONTAL_PADDING,
));
};
export function TerminalTimestampGutter({
termRef,
containerRef,
enabled,
top,
sessionId,
color,
fontFamily,
fontSize,
fontWeight,
width,
onWidthChange,
}: TerminalTimestampGutterProps) {
const gutterRef = useRef<HTMLDivElement>(null);
const typography = getTerminalTimestampTypography({ fontFamily, fontSize, fontWeight });
useLayoutEffect(() => {
if (!enabled || !onWidthChange) return;
const gutter = gutterRef.current;
if (!gutter) return;
let disposed = false;
const measure = () => {
if (disposed) return;
const probe = document.createElement("span");
probe.textContent = TERMINAL_TIMESTAMP_SAMPLE_LABEL;
probe.style.position = "absolute";
probe.style.visibility = "hidden";
probe.style.pointerEvents = "none";
probe.style.whiteSpace = "nowrap";
probe.style.fontFamily = typography.fontFamily;
probe.style.fontSize = `${typography.fontSize}px`;
probe.style.fontWeight = String(typography.fontWeight);
probe.style.fontVariantNumeric = "tabular-nums";
gutter.appendChild(probe);
const measuredTextWidth = probe.getBoundingClientRect().width;
probe.remove();
onWidthChange(resolveTerminalTimestampGutterWidth({
measuredTextWidth,
fontSize: typography.fontSize,
}));
};
measure();
const fonts = (document as Document & { fonts?: { ready?: Promise<unknown> } }).fonts;
void fonts?.ready?.then(measure);
return () => {
disposed = true;
};
}, [enabled, onWidthChange, sessionId, typography.fontFamily, typography.fontSize, typography.fontWeight]);
useEffect(() => {
const gutter = gutterRef.current;
if (!gutter) return;
let disposed = false;
let rafId: number | null = null;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let disposables: DisposableLike[] = [];
let resizeObserver: ResizeObserver | null = null;
const render = () => {
rafId = null;
const term = termRef.current;
const container = containerRef.current;
if (!enabled || !term || !container) {
clearElement(gutter);
return;
}
const screen = getTerminalScreen(container);
const rows = Math.max(1, term.rows || 1);
const cellHeight = screen.clientHeight / rows;
if (!Number.isFinite(cellHeight) || cellHeight <= 0) {
clearElement(gutter);
return;
}
const screenRect = screen.getBoundingClientRect();
const gutterRect = gutter.getBoundingClientRect();
const screenTop = screenRect.top - gutterRect.top;
const fragment = document.createDocumentFragment();
for (const { row, label } of getVisibleTerminalLineTimestampRows(term)) {
const item = document.createElement("div");
item.textContent = label;
item.className = "absolute left-0 right-0 px-2 text-right tabular-nums whitespace-nowrap";
item.style.top = `${screenTop + row * cellHeight}px`;
item.style.height = `${cellHeight}px`;
item.style.lineHeight = `${cellHeight}px`;
item.style.color = color;
item.style.fontFamily = typography.fontFamily;
item.style.fontSize = `${typography.fontSize}px`;
item.style.fontWeight = String(typography.fontWeight);
item.style.fontVariantNumeric = "tabular-nums";
fragment.appendChild(item);
}
clearElement(gutter);
gutter.appendChild(fragment);
};
const scheduleRender = () => {
if (disposed || rafId !== null) return;
if (typeof requestAnimationFrame === "function") {
rafId = requestAnimationFrame(render);
} else {
render();
}
};
const attach = () => {
if (disposed) return;
const term = termRef.current;
const container = containerRef.current;
if (!enabled || !term || !container) {
clearElement(gutter);
if (enabled) {
retryTimer = setTimeout(attach, 50);
}
return;
}
disposables = [
term.onScroll?.(scheduleRender),
term.onRender?.(scheduleRender),
term.onResize?.(scheduleRender),
].filter(Boolean) as DisposableLike[];
disposables.push({ dispose: onTerminalLineTimestampsChange(term, scheduleRender) });
if (typeof ResizeObserver !== "undefined") {
resizeObserver = new ResizeObserver(scheduleRender);
resizeObserver.observe(container);
resizeObserver.observe(getTerminalScreen(container));
}
scheduleRender();
};
attach();
return () => {
disposed = true;
if (rafId !== null && typeof cancelAnimationFrame === "function") {
cancelAnimationFrame(rafId);
}
if (retryTimer) {
clearTimeout(retryTimer);
}
for (const disposable of disposables) {
disposable.dispose();
}
resizeObserver?.disconnect();
clearElement(gutter);
};
}, [
color,
containerRef,
enabled,
sessionId,
termRef,
top,
typography.fontFamily,
typography.fontSize,
typography.fontWeight,
]);
if (!enabled) return null;
return (
<div
ref={gutterRef}
aria-hidden="true"
className="pointer-events-none absolute bottom-0 left-0 z-[1] overflow-hidden select-none border-r border-white/5 bg-black/10 text-[color:var(--terminal-ui-fg)]"
style={{
top,
width,
}}
data-section="terminal-timestamp-gutter"
/>
);
}

View File

@@ -69,12 +69,15 @@ test("hides SFTP for local terminal sessions", () => {
test("shows YMODEM send only for connected serial sessions", () => {
const connectedSerial = renderToolbar(serialHost, "connected", {
onSendYmodem: () => {},
onReceiveYmodem: () => {},
});
const disconnectedSerial = renderToolbar(serialHost, "disconnected", {
onSendYmodem: () => {},
onReceiveYmodem: () => {},
});
const ssh = renderToolbar(sshHost, "connected", {
onSendYmodem: () => {},
onReceiveYmodem: () => {},
});
const local = renderToolbar({
...sshHost,
@@ -82,14 +85,20 @@ test("shows YMODEM send only for connected serial sessions", () => {
protocol: "local",
}, "connected", {
onSendYmodem: () => {},
onReceiveYmodem: () => {},
});
assert.equal(connectedSerial.includes('aria-label="Send with YMODEM"'), true);
assert.equal(connectedSerial.includes('aria-label="Receive with YMODEM"'), true);
assert.doesNotMatch(connectedSerial, /aria-label="Send with YMODEM"[^>]*disabled/);
assert.equal(disconnectedSerial.includes('aria-label="Available after connect"'), true);
assert.match(disconnectedSerial, /aria-label="Available after connect"[^>]*disabled/);
assert.equal(disconnectedSerial.includes('aria-label="Send with YMODEM - Available after connect"'), true);
assert.equal(disconnectedSerial.includes('aria-label="Receive with YMODEM - Available after connect"'), true);
assert.match(disconnectedSerial, /aria-label="Send with YMODEM - Available after connect"[^>]*disabled/);
assert.match(disconnectedSerial, /aria-label="Receive with YMODEM - Available after connect"[^>]*disabled/);
assert.equal(ssh.includes('aria-label="Send with YMODEM"'), false);
assert.equal(ssh.includes('aria-label="Receive with YMODEM"'), false);
assert.equal(local.includes('aria-label="Send with YMODEM"'), false);
assert.equal(local.includes('aria-label="Receive with YMODEM"'), false);
});
test("uses the terminal active button color for pressed toolbar actions", () => {

View File

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

View File

@@ -0,0 +1,72 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import {
getLineTimestampToggleHostUpdate,
shouldShowSelectionAIOverlay,
shouldShowLineTimestampToolbarToggle,
} from "./TerminalView.tsx";
test("line timestamp toggle creates a persistent host update", () => {
const host = {
id: "host-1",
label: "Host",
showLineTimestamps: false,
theme: "default",
};
assert.deepEqual(getLineTimestampToggleHostUpdate(host), {
id: "host-1",
showLineTimestamps: true,
});
assert.deepEqual(getLineTimestampToggleHostUpdate({ ...host, showLineTimestamps: true }), {
id: "host-1",
showLineTimestamps: false,
});
});
test("line timestamp toolbar toggle is hidden when timestamps are unavailable", () => {
assert.equal(shouldShowLineTimestampToolbarToggle(false, () => {}), false);
assert.equal(shouldShowLineTimestampToolbarToggle(true, () => {}), true);
assert.equal(shouldShowLineTimestampToolbarToggle(undefined, () => {}), true);
assert.equal(shouldShowLineTimestampToolbarToggle(true, undefined), false);
});
test("selection AI overlay honors the visibility preference", () => {
const overlayPosition = { left: 120, top: 80 };
const addSelection = () => {};
assert.equal(
shouldShowSelectionAIOverlay({
hasSelection: true,
selectionOverlayPosition: overlayPosition,
onAddSelectionToAI: addSelection,
}),
true,
);
assert.equal(
shouldShowSelectionAIOverlay({
hasSelection: true,
selectionOverlayPosition: overlayPosition,
onAddSelectionToAI: addSelection,
showSelectionAIAction: true,
}),
true,
);
assert.equal(
shouldShowSelectionAIOverlay({
hasSelection: true,
selectionOverlayPosition: overlayPosition,
onAddSelectionToAI: addSelection,
showSelectionAIAction: false,
}),
false,
);
});
test("popup terminals disable line timestamp controls", () => {
const source = readFileSync(new URL("../TerminalPopupPage.tsx", import.meta.url), "utf8");
assert.match(source, /lineTimestampsAvailable=\{false\}/);
});

View File

@@ -1,20 +1,66 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { memo } from 'react';
import React, { memo, useCallback, useEffect, useState } from 'react';
import { TerminalServerStats } from './TerminalServerStats';
import {
TerminalTimestampGutter,
resolveTerminalTimestampGutterColor,
resolveTerminalTimestampGutterWidth,
} from './TerminalTimestampGutter';
type TerminalViewContext = Record<string, any>;
type HostLineTimestampToggle = {
id: string;
showLineTimestamps?: boolean;
};
export function getLineTimestampToggleHostUpdate<T extends HostLineTimestampToggle>(
host: T,
): Pick<T, "id"> & { showLineTimestamps: boolean } {
return {
id: host.id,
showLineTimestamps: host.showLineTimestamps !== true,
};
}
export function shouldShowLineTimestampToolbarToggle(
lineTimestampsAvailable: boolean | undefined,
onUpdateHost: unknown,
): boolean {
return lineTimestampsAvailable !== false && Boolean(onUpdateHost);
}
export function shouldEnableYmodemAction({
isSerialConnection,
status,
handleSendYmodem,
handleReceiveYmodem,
}: {
isSerialConnection?: boolean;
status?: string;
handleSendYmodem?: () => void;
handleReceiveYmodem?: () => void;
}): boolean {
return Boolean(isSerialConnection && status === "connected" && handleSendYmodem);
return Boolean(isSerialConnection && status === "connected" && (handleSendYmodem || handleReceiveYmodem));
}
export function shouldShowSelectionAIOverlay({
hasSelection,
selectionOverlayPosition,
onAddSelectionToAI,
showSelectionAIAction,
}: {
hasSelection: boolean;
selectionOverlayPosition?: { left: number; top: number } | null;
onAddSelectionToAI?: unknown;
showSelectionAIAction?: boolean;
}): boolean {
return Boolean(
showSelectionAIAction !== false
&& hasSelection
&& selectionOverlayPosition
&& onAddSelectionToAI,
);
}
/**
@@ -42,7 +88,30 @@ function terminalViewCtxEqual(
}
function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
const { Activity, Button, Copy, Maximize2, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, containerRef, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onCloseSession, onExpandToFocus, onOpenSystem, onSplitHorizontal, onSplitVertical, onToggleBroadcast, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, searchMatchCount, selectionOverlayPosition, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, snippets, status, statusDotTone, sudoHintRef, sudoHintText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem } = ctx;
const { Activity, Button, Clock3, Copy, Maximize2, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, remoteDragDropUsesZmodem, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onCloseSession, onExpandToFocus, onOpenSystem, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, searchMatchCount, selectionOverlayPosition, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, showSelectionAIAction, snippets, status, statusDotTone, sudoHintRef, sudoHintText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem } = ctx;
const ymodemActionEnabled = shouldEnableYmodemAction({
isSerialConnection,
status,
handleSendYmodem,
handleReceiveYmodem,
});
const terminalContentTop = isSearchOpen ? "64px" : "30px";
const showLineTimestampGutter = lineTimestampsAvailable !== false && host.showLineTimestamps === true;
const lineTimestampColor = resolveTerminalTimestampGutterColor(effectiveTheme.colors);
const [lineTimestampGutterWidth, setLineTimestampGutterWidth] = useState(() => (
resolveTerminalTimestampGutterWidth({ fontSize: effectiveFontSize })
));
useEffect(() => {
if (showLineTimestampGutter) return;
setLineTimestampGutterWidth(resolveTerminalTimestampGutterWidth({ fontSize: effectiveFontSize }));
}, [effectiveFontSize, effectiveFontWeight, resolvedFontFamily, sessionId, showLineTimestampGutter]);
const handleLineTimestampGutterWidthChange = useCallback((width: number) => {
setLineTimestampGutterWidth((current) => (current === width ? current : width));
}, []);
const activeLineTimestampGutterWidth = showLineTimestampGutter ? lineTimestampGutterWidth : 0;
const lineTimestampToggleLabel = showLineTimestampGutter
? t("terminal.toolbar.timestampsDisable")
: t("terminal.toolbar.timestampsEnable");
return (
<TerminalContextMenu
hasSelection={hasSelection}
@@ -58,7 +127,8 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
onSelectWord={terminalContextActions.onSelectWord}
onSplitHorizontal={onSplitHorizontal}
onSplitVertical={onSplitVertical}
onSendYmodem={shouldEnableYmodemAction({ isSerialConnection, status, handleSendYmodem }) ? handleSendYmodem : undefined}
onSendYmodem={ymodemActionEnabled ? handleSendYmodem : undefined}
onReceiveYmodem={ymodemActionEnabled ? handleReceiveYmodem : undefined}
isReconnectable={status === "disconnected"}
onReconnect={handleRetry}
onClose={inWorkspace ? () => onCloseSession?.(sessionId) : undefined}
@@ -89,7 +159,9 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
<div className="text-sm text-muted-foreground">
{isLocalConnection
? t("terminal.dragDrop.localMessage")
: t("terminal.dragDrop.remoteMessage")
: remoteDragDropUsesZmodem
? t("terminal.dragDrop.remoteZmodemMessage")
: t("terminal.dragDrop.remoteSftpMessage")
}
</div>
</div>
@@ -119,6 +191,34 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
statusDotTone,
)}
/>
{shouldShowLineTimestampToolbarToggle(lineTimestampsAvailable, onUpdateHost) && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
"ml-0.5 p-0.5 rounded transition-colors flex-shrink-0",
"hover:bg-[color:var(--terminal-toolbar-btn-hover)]",
showLineTimestampGutter ? "opacity-100" : "opacity-60 hover:opacity-100",
)}
style={
showLineTimestampGutter
? {
backgroundColor: 'var(--terminal-toolbar-btn-active)',
color: lineTimestampColor,
}
: undefined
}
onClick={() => onUpdateHost(getLineTimestampToggleHostUpdate(host))}
aria-label={lineTimestampToggleLabel}
aria-pressed={showLineTimestampGutter}
>
<Clock3 size={10} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{lineTimestampToggleLabel}</TooltipContent>
</Tooltip>
)}
{host.protocol !== "local" && host.hostname && host.hostname !== "localhost" && (
<Tooltip>
<TooltipTrigger asChild>
@@ -237,12 +337,31 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
ref={containerRef}
className="xterm-container absolute inset-x-0 bottom-0"
style={{
top: isSearchOpen ? "64px" : "30px",
top: terminalContentTop,
left: activeLineTimestampGutterWidth,
paddingLeft: 6,
backgroundColor: 'var(--terminal-ui-bg)',
}}
/>
{hasSelection && selectionOverlayPosition && ctx.onAddSelectionToAI && handleAddSelectionToAI && (
<TerminalTimestampGutter
termRef={termRef}
containerRef={containerRef}
enabled={showLineTimestampGutter}
top={terminalContentTop}
sessionId={sessionId}
color={lineTimestampColor}
fontFamily={resolvedFontFamily}
fontSize={effectiveFontSize}
fontWeight={effectiveFontWeight}
width={lineTimestampGutterWidth}
onWidthChange={handleLineTimestampGutterWidthChange}
/>
{shouldShowSelectionAIOverlay({
hasSelection,
selectionOverlayPosition,
onAddSelectionToAI: ctx.onAddSelectionToAI,
showSelectionAIAction,
}) && handleAddSelectionToAI && (
<div
className="absolute z-30 pointer-events-none"
style={{

View File

@@ -3,6 +3,15 @@ import type React from "react";
import { useRef, useState } from "react";
import { logger } from "../../../lib/logger";
import {
buildZmodemDragDropFiles,
buildZmodemDragDropUploadCommand,
containsZmodemRzMissingMarker,
createZmodemRzMissingToken,
supportsZmodemDragDropSftpFallback,
supportsZmodemTerminalDragDrop,
type ZmodemDragDropFile,
} from "../../../lib/zmodemDragDrop";
import { extractDropEntries, type DropEntry } from "../../../lib/sftpFileUtils";
import type { Host, TerminalSession } from "../../../types";
import { toast } from "../../ui/toast";
@@ -14,6 +23,7 @@ import {
interface UseTerminalDragDropOptions {
host: Host;
isLocalConnection: boolean;
isNetworkDevice?: boolean;
onOpenSftp?: TerminalProps["onOpenSftp"];
resolveSftpInitialPath: (options?: { preferFreshBackend?: boolean }) => Promise<string | undefined>;
scrollToBottomAfterProgrammaticInput: (data: string) => void;
@@ -23,37 +33,112 @@ interface UseTerminalDragDropOptions {
t: (key: string) => string;
terminalBackend: {
writeToSession: (sessionId: string, data: string, options?: { automated?: boolean }) => void;
cancelZmodem?: (sessionId: string, options?: { interrupt?: boolean }) => void;
onSessionData?: (sessionId: string, cb: (chunk: string) => void) => () => void;
onZmodemEvent?: (
sessionId: string,
cb: (event: { type: string; transferType?: string }) => void,
) => () => void;
startZmodemDragDropUpload?: (
sessionId: string,
files: ZmodemDragDropFile[],
uploadCommand?: string,
) => Promise<{ success: boolean; error?: string }>;
};
rzMissingFallbackTimeoutMs?: number;
termRef: React.MutableRefObject<XTerm | null>;
}
const RZ_MISSING_FALLBACK_TIMEOUT_MS = 2500;
export async function resolveTerminalDropUploadInitialPath(
resolveSftpInitialPath: UseTerminalDragDropOptions["resolveSftpInitialPath"],
): Promise<string | undefined> {
return resolveSftpInitialPath({ preferFreshBackend: true });
}
function createRzMissingWatcher({
sessionId,
terminalBackend,
token,
timeoutMs = RZ_MISSING_FALLBACK_TIMEOUT_MS,
}: {
sessionId: string;
terminalBackend: Pick<UseTerminalDragDropOptions["terminalBackend"], "onSessionData" | "onZmodemEvent">;
token: string;
timeoutMs?: number;
}): { promise: Promise<"missing" | "detected" | "timeout">; stop: () => void } {
let settled = false;
let timeout: ReturnType<typeof setTimeout> | undefined;
let buffer = "";
let unsubscribeData: (() => void) | undefined;
let unsubscribeZmodem: (() => void) | undefined;
let settle: (result: "missing" | "detected" | "timeout") => void = () => {};
const cleanup = () => {
if (timeout) clearTimeout(timeout);
timeout = undefined;
unsubscribeData?.();
unsubscribeData = undefined;
unsubscribeZmodem?.();
unsubscribeZmodem = undefined;
};
const promise = new Promise<"missing" | "detected" | "timeout">((resolve) => {
settle = (result) => {
if (settled) return;
settled = true;
cleanup();
resolve(result);
};
unsubscribeData = terminalBackend.onSessionData?.(sessionId, (chunk) => {
buffer = `${buffer}${chunk}`.slice(-512);
if (containsZmodemRzMissingMarker(buffer, token)) {
settle("missing");
}
});
unsubscribeZmodem = terminalBackend.onZmodemEvent?.(sessionId, (event) => {
if (event.type === "detect" && event.transferType === "upload") {
settle("detected");
}
});
timeout = setTimeout(() => settle("timeout"), timeoutMs);
});
return {
promise,
stop: () => settle("detected"),
};
}
export async function handleTerminalDropEntries({
dropEntries,
host,
isLocalConnection,
isNetworkDevice = false,
onOpenSftp,
resolveSftpInitialPath,
scrollToBottomAfterProgrammaticInput,
sessionId,
sessionRef,
terminalBackend,
rzMissingFallbackTimeoutMs,
termRef,
}: Pick<
UseTerminalDragDropOptions,
| "host"
| "isLocalConnection"
| "isNetworkDevice"
| "onOpenSftp"
| "resolveSftpInitialPath"
| "scrollToBottomAfterProgrammaticInput"
| "sessionId"
| "sessionRef"
| "terminalBackend"
| "rzMissingFallbackTimeoutMs"
| "termRef"
> & {
dropEntries: DropEntry[];
@@ -74,7 +159,67 @@ export async function handleTerminalDropEntries({
return;
}
if (onOpenSftp) {
const requiresSftpForDirectoryDrop = dropEntries.some((entry) => (
entry.isDirectory || /[\\/]/.test(entry.relativePath)
));
if (
requiresSftpForDirectoryDrop
&& onOpenSftp
&& supportsZmodemDragDropSftpFallback(host)
) {
const initialPath = await resolveTerminalDropUploadInitialPath(resolveSftpInitialPath);
onOpenSftp(host, initialPath, dropEntries, sessionId);
} else if (supportsZmodemTerminalDragDrop(host, isNetworkDevice)) {
const files = await buildZmodemDragDropFiles(dropEntries);
if (files.length === 0) {
throw new Error("No files to upload");
}
if (!terminalBackend.startZmodemDragDropUpload) {
throw new Error("ZMODEM drag-drop upload is unavailable");
}
const shouldFallbackToSftpWhenRzMissing = Boolean(
onOpenSftp
&& supportsZmodemDragDropSftpFallback(host)
&& terminalBackend.onSessionData
&& terminalBackend.cancelZmodem,
);
const rzMissingToken = shouldFallbackToSftpWhenRzMissing
? createZmodemRzMissingToken()
: undefined;
const rzMissingWatcher = rzMissingToken
? createRzMissingWatcher({
sessionId,
terminalBackend,
token: rzMissingToken,
timeoutMs: rzMissingFallbackTimeoutMs,
})
: undefined;
const uploadCommand = rzMissingToken
? buildZmodemDragDropUploadCommand(rzMissingToken)
: undefined;
let result: { success: boolean; error?: string };
try {
result = await terminalBackend.startZmodemDragDropUpload(sessionId, files, uploadCommand);
} catch (error) {
rzMissingWatcher?.stop();
throw error;
}
if (!result.success) {
rzMissingWatcher?.stop();
throw new Error(result.error || "ZMODEM upload failed");
}
const fallbackResult = rzMissingWatcher ? await rzMissingWatcher.promise : "detected";
if (fallbackResult === "missing" || fallbackResult === "timeout") {
terminalBackend.cancelZmodem?.(sessionId, { interrupt: fallbackResult === "timeout" });
const initialPath = await resolveTerminalDropUploadInitialPath(resolveSftpInitialPath);
onOpenSftp?.(host, initialPath, dropEntries, sessionId);
}
} else if (onOpenSftp) {
const initialPath = await resolveTerminalDropUploadInitialPath(resolveSftpInitialPath);
onOpenSftp(host, initialPath, dropEntries, sessionId);
}
@@ -83,6 +228,7 @@ export async function handleTerminalDropEntries({
export function useTerminalDragDrop({
host,
isLocalConnection,
isNetworkDevice = false,
onOpenSftp,
resolveSftpInitialPath,
scrollToBottomAfterProgrammaticInput,
@@ -91,6 +237,7 @@ export function useTerminalDragDrop({
status,
t,
terminalBackend,
rzMissingFallbackTimeoutMs,
termRef,
}: UseTerminalDragDropOptions) {
const [isDraggingOver, setIsDraggingOver] = useState(false);
@@ -143,17 +290,22 @@ export function useTerminalDragDrop({
dropEntries,
host,
isLocalConnection,
isNetworkDevice,
onOpenSftp,
resolveSftpInitialPath,
scrollToBottomAfterProgrammaticInput,
sessionId,
sessionRef,
terminalBackend,
rzMissingFallbackTimeoutMs,
termRef,
});
} catch (error) {
logger.error("Failed to handle file drop", error);
toast.error(t("terminal.dragDrop.errorMessage"), t("terminal.dragDrop.errorTitle"));
const message = error instanceof Error && error.message === "No files to upload"
? t("terminal.dragDrop.noFiles")
: t("terminal.dragDrop.errorMessage");
toast.error(message, t("terminal.dragDrop.errorTitle"));
}
};

View File

@@ -613,7 +613,10 @@ test("local session runs startup command after attaching", async () => {
test("local session resets terminal timestamp state when reusing a terminal", async () => {
const writes: string[] = [];
const markerLines: number[] = [];
const disposedMarkerLines: number[] = [];
let onData: ((data: string) => void) | null = null;
let cursorLine = 0;
const terminalBackend = {
backendAvailable: () => true,
@@ -681,8 +684,26 @@ test("local session resets terminal timestamp state when reusing a terminal", as
buffer: { active: { type: "normal" } },
write: (data: string, callback?: () => void) => {
writes.push(data);
for (const char of data) {
if (char === "\n") {
cursorLine += 1;
}
}
callback?.();
},
registerMarker: (offset: number) => {
const line = cursorLine + offset;
markerLines.push(line);
const marker = {
line,
isDisposed: false,
dispose() {
marker.isDisposed = true;
disposedMarkerLines.push(line);
},
};
return marker;
},
writeln: noop,
scrollToBottom: noop,
};
@@ -694,9 +715,10 @@ test("local session resets terminal timestamp state when reusing a terminal", as
onData?.("fresh");
assert.equal(writes.length, 2);
assert.equal((writes[0].match(/\[\d{2}:\d{2}:\d{2}\]/g) ?? []).length, 1);
assert.equal((writes[1].match(/\[\d{2}:\d{2}:\d{2}\]/g) ?? []).length, 1);
assert.ok(writes[1].endsWith("] \x1b[22;39mfresh"));
assert.equal(writes[0], "unfinished");
assert.equal(writes[1], "fresh");
assert.deepEqual(markerLines, [0, 0]);
assert.deepEqual(disposedMarkerLines, [0]);
});
test("session data waits for prior terminal writes before evaluating prompt line breaks", async () => {

View File

@@ -9,7 +9,7 @@ import {
closeOrphanBackendSession,
getFlowController,
isTerminalBootActive,
resetTerminalOutputTimestamps,
resetTerminalLineTimestampState,
tryAttachSessionToTerminal,
writeSessionData,
writeTerminalLine,
@@ -1079,7 +1079,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
ctx.sessionRef.current = id;
getFlowController(ctx, term).reset();
resetTerminalOutputTimestamps(term);
resetTerminalLineTimestampState(term);
ctx.disposeDataRef.current = ctx.terminalBackend.onSessionData(id, (chunk) => {
writeSessionData(ctx, term, chunk);
if (!ctx.hasConnectedRef.current) {

View File

@@ -48,10 +48,17 @@ import { terminalAltKeyOptions } from "./altKeyOptions";
import { optionArrowWordJumpSequence } from "./optionArrowWordJump";
import { watchDevicePixelRatio } from "./rendererDprWatch";
import { shouldDeferWebglUntilVisible } from "./webglRendererPolicy";
import {
captureMiddleClickTerminalMouseEvent,
markMiddleClickContextMenuEvent,
resolveMiddleClickBehavior,
} from "./middleClickBehavior";
import { handleSerialLineModeInput } from "./serialLineInput";
import {
isTerminalFontSizeAction,
nextTerminalFontSizeForAction,
nextTerminalFontSizeForWheel,
shouldHandleTerminalFontSizeAction,
terminalFontSizeWheelListenerOptions,
} from "./terminalFontZoom";
import {
@@ -122,6 +129,7 @@ export type CreateXTermRuntimeContext = {
sessionRef: RefObject<string | null>;
hotkeySchemeRef: RefObject<"disabled" | "mac" | "pc">;
disableTerminalFontZoomRef: RefObject<boolean>;
keyBindingsRef: RefObject<KeyBinding[]>;
onHotkeyActionRef: RefObject<
((action: string, event: KeyboardEvent) => void) | undefined
@@ -173,6 +181,11 @@ export type CreateXTermRuntimeContext = {
// Autocomplete input handler — called on every character input
onAutocompleteInput?: (data: string) => void;
terminalContextActionsRef?: RefObject<{
onPaste?: () => void | Promise<void>;
onSelectWord?: () => void;
} | undefined>;
// Set to true while we're programmatically restoring a selection so that
// copy-on-select listeners can suppress redundant clipboard writes.
isRestoringSelectionRef?: RefObject<boolean>;
@@ -562,6 +575,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
event,
currentTerminalFontSize(),
isMac,
ctx.disableTerminalFontZoomRef.current,
);
if (nextFontSize === null) return;
event.preventDefault();
@@ -684,6 +698,12 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
}
if (terminalActions.has(action)) {
if (
isTerminalFontSizeAction(action)
&& !shouldHandleTerminalFontSizeAction(action, ctx.disableTerminalFontZoomRef.current)
) {
return true;
}
e.preventDefault();
e.stopPropagation();
switch (action) {
@@ -731,7 +751,11 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
case "decreaseTerminalFontSize":
case "resetTerminalFontSize": {
applyTerminalFontSize(
nextTerminalFontSizeForAction(action, currentTerminalFontSize()),
nextTerminalFontSizeForAction(
action,
currentTerminalFontSize(),
ctx.disableTerminalFontZoomRef.current,
),
);
break;
}
@@ -783,29 +807,35 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
return true;
});
let cleanupMiddleClick: (() => void) | null = null;
const middleClickPaste = settings?.middleClickPaste ?? true;
if (middleClickPaste) {
const handleMiddleClick = async (e: MouseEvent) => {
if (e.button !== 1) return;
e.preventDefault();
try {
const text = await navigator.clipboard.readText();
if (text && ctx.sessionRef.current) {
pasteTextIntoTerminal(term, text, {
scrollOnPaste: shouldScrollOnTerminalPaste(ctx.terminalSettingsRef.current),
onPasteData: broadcastUserPasteData,
});
}
} catch (err) {
logger.warn("[Terminal] Failed to paste from clipboard:", err);
}
};
const handleMiddleClick = (e: MouseEvent) => {
if (e.button !== 1) return;
e.preventDefault();
ctx.container.addEventListener("auxclick", handleMiddleClick);
cleanupMiddleClick = () =>
ctx.container.removeEventListener("auxclick", handleMiddleClick);
}
const behavior = resolveMiddleClickBehavior(ctx.terminalSettingsRef.current);
if (behavior === "disabled") return;
if (behavior === "paste") {
void ctx.terminalContextActionsRef?.current?.onPaste?.();
return;
}
const contextMenuEvent = markMiddleClickContextMenuEvent(new MouseEvent("contextmenu", {
bubbles: true,
cancelable: true,
clientX: e.clientX,
clientY: e.clientY,
screenX: e.screenX,
screenY: e.screenY,
button: 2,
buttons: 0,
view: window,
}));
ctx.container.dispatchEvent(contextMenuEvent);
};
ctx.container.addEventListener("mousedown", captureMiddleClickTerminalMouseEvent, true);
ctx.container.addEventListener("mouseup", captureMiddleClickTerminalMouseEvent, true);
ctx.container.addEventListener("auxclick", handleMiddleClick);
fitAddon.fit();
term.focus();
@@ -1093,7 +1123,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
handleFontSizeWheel,
terminalFontSizeWheelListenerOptions,
);
cleanupMiddleClick?.();
ctx.container.removeEventListener("auxclick", handleMiddleClick);
ctx.container.removeEventListener("mousedown", captureMiddleClickTerminalMouseEvent, true);
ctx.container.removeEventListener("mouseup", captureMiddleClickTerminalMouseEvent, true);
stopDprWatch();
keywordHighlighter.dispose();
eraseScrollbackDisposable.dispose();

View File

@@ -0,0 +1,74 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
isMiddleClickContextMenuEvent,
markMiddleClickContextMenuEvent,
captureMiddleClickTerminalMouseEvent,
resolveMiddleClickBehavior,
shouldInterceptMouseTrackingContextMenu,
} from "./middleClickBehavior";
test("resolveMiddleClickBehavior uses the explicit middle-click behavior", () => {
assert.equal(resolveMiddleClickBehavior({ middleClickBehavior: "context-menu" }), "context-menu");
assert.equal(resolveMiddleClickBehavior({ middleClickBehavior: "disabled" }), "disabled");
});
test("resolveMiddleClickBehavior ignores unsupported middle-click behavior values", () => {
assert.equal(
resolveMiddleClickBehavior({ middleClickBehavior: "select-word" as never }),
"paste",
);
});
test("resolveMiddleClickBehavior falls back to the legacy middle-click paste flag", () => {
assert.equal(resolveMiddleClickBehavior({ middleClickPaste: true }), "paste");
assert.equal(resolveMiddleClickBehavior({ middleClickPaste: false }), "disabled");
assert.equal(resolveMiddleClickBehavior(undefined), "paste");
});
test("middle-click context menu events are identifiable", () => {
const event = {} as MouseEvent;
assert.equal(isMiddleClickContextMenuEvent(event), false);
assert.equal(isMiddleClickContextMenuEvent(markMiddleClickContextMenuEvent(event)), true);
});
test("mouse-tracking context menu capture lets middle-click menu events pass through", () => {
assert.equal(
shouldInterceptMouseTrackingContextMenu({
event: markMiddleClickContextMenuEvent({} as MouseEvent),
mouseTracking: true,
status: "connected",
}),
false,
);
assert.equal(
shouldInterceptMouseTrackingContextMenu({
event: {} as MouseEvent,
mouseTracking: true,
status: "connected",
}),
true,
);
});
test("middle-click terminal mouse down/up events are captured before xterm sees them", () => {
const calls: string[] = [];
const middleClickEvent = {
button: 1,
preventDefault: () => calls.push("preventDefault"),
stopImmediatePropagation: () => calls.push("stopImmediatePropagation"),
} as unknown as MouseEvent;
assert.equal(captureMiddleClickTerminalMouseEvent(middleClickEvent), true);
assert.deepEqual(calls, ["preventDefault", "stopImmediatePropagation"]);
calls.length = 0;
assert.equal(captureMiddleClickTerminalMouseEvent({
button: 0,
preventDefault: () => calls.push("preventDefault"),
stopImmediatePropagation: () => calls.push("stopImmediatePropagation"),
} as unknown as MouseEvent), false);
assert.deepEqual(calls, []);
});

View File

@@ -0,0 +1,54 @@
import type { MiddleClickBehavior, TerminalSettings } from "../../../domain/models";
type MiddleClickSettings = Partial<Pick<TerminalSettings, "middleClickBehavior" | "middleClickPaste">>;
const MIDDLE_CONTEXT_MENU_EVENT_KEY = "__netcattyMiddleContextMenu";
type MiddleClickContextMenuEvent = MouseEvent & {
[MIDDLE_CONTEXT_MENU_EVENT_KEY]?: boolean;
};
export interface MouseTrackingContextMenuCaptureState {
event: MouseEvent;
mouseTracking: boolean;
status?: string | null;
}
export const resolveMiddleClickBehavior = (
settings?: MiddleClickSettings | null,
): MiddleClickBehavior => {
const behavior = settings?.middleClickBehavior;
if (
behavior === "context-menu" ||
behavior === "paste" ||
behavior === "disabled"
) {
return behavior;
}
return settings?.middleClickPaste === false ? "disabled" : "paste";
};
export const markMiddleClickContextMenuEvent = (event: MouseEvent): MouseEvent => {
Object.defineProperty(event, MIDDLE_CONTEXT_MENU_EVENT_KEY, {
value: true,
configurable: true,
});
return event;
};
export const isMiddleClickContextMenuEvent = (event: MouseEvent): boolean =>
(event as MiddleClickContextMenuEvent)[MIDDLE_CONTEXT_MENU_EVENT_KEY] === true;
export const shouldInterceptMouseTrackingContextMenu = ({
event,
mouseTracking,
status,
}: MouseTrackingContextMenuCaptureState): boolean =>
mouseTracking && status === "connected" && !isMiddleClickContextMenuEvent(event);
export const captureMiddleClickTerminalMouseEvent = (event: MouseEvent): boolean => {
if (event.button !== 1) return false;
event.preventDefault();
event.stopImmediatePropagation();
return true;
};

View File

@@ -4,6 +4,7 @@ import test from 'node:test';
import {
nextTerminalFontSizeForAction,
nextTerminalFontSizeForWheel,
shouldHandleTerminalFontSizeAction,
terminalFontSizeWheelListenerOptions,
} from './terminalFontZoom.ts';
@@ -16,6 +17,20 @@ test('terminal font size actions step and reset within bounds', () => {
assert.equal(nextTerminalFontSizeForAction('copy', 14), null);
});
test('terminal font size actions return null when terminal font zoom is disabled', () => {
assert.equal(nextTerminalFontSizeForAction('increaseTerminalFontSize', 14, true), null);
assert.equal(nextTerminalFontSizeForAction('decreaseTerminalFontSize', 14, true), null);
assert.equal(nextTerminalFontSizeForAction('resetTerminalFontSize', 18, true), null);
});
test('terminal font size actions are not handled when terminal font zoom is disabled', () => {
assert.equal(shouldHandleTerminalFontSizeAction('increaseTerminalFontSize', false), true);
assert.equal(shouldHandleTerminalFontSizeAction('decreaseTerminalFontSize', false), true);
assert.equal(shouldHandleTerminalFontSizeAction('resetTerminalFontSize', false), true);
assert.equal(shouldHandleTerminalFontSizeAction('increaseTerminalFontSize', true), false);
assert.equal(shouldHandleTerminalFontSizeAction('copy', true), false);
});
test('wheel adjusts terminal font size with the platform modifier only', () => {
assert.equal(nextTerminalFontSizeForWheel({ ctrlKey: true, metaKey: false, deltaY: -1 }, 14, false), 15);
assert.equal(nextTerminalFontSizeForWheel({ ctrlKey: true, metaKey: false, deltaY: 1 }, 14, false), 13);
@@ -27,6 +42,11 @@ test('wheel adjusts terminal font size with the platform modifier only', () => {
assert.equal(nextTerminalFontSizeForWheel({ ctrlKey: true, metaKey: false, deltaY: 0 }, 14, false), null);
});
test('wheel zoom returns null when terminal font zoom is disabled', () => {
assert.equal(nextTerminalFontSizeForWheel({ ctrlKey: true, metaKey: false, deltaY: -1 }, 14, false, true), null);
assert.equal(nextTerminalFontSizeForWheel({ ctrlKey: false, metaKey: true, deltaY: -1 }, 14, true, true), null);
});
test('wheel font-size listener runs before xterm consumes terminal scrolling', () => {
assert.equal(terminalFontSizeWheelListenerOptions.capture, true);
assert.equal(terminalFontSizeWheelListenerOptions.passive, false);

View File

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

View File

@@ -0,0 +1,135 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
createTerminalLineTimestampSegmenter,
formatTerminalLineTimestamp,
resolveTerminalTimestampGutterRows,
writeTerminalDataWithLineTimestamps,
} from "./terminalLineTimestamps.ts";
const createFakeTerm = () => {
const writes: string[] = [];
const markerLines: number[] = [];
const disposedMarkerLines: number[] = [];
let cursorLine = 0;
const term = {
buffer: {
active: { type: "normal", viewportY: 0 },
},
rows: 24,
write(data: string, callback?: () => void) {
writes.push(data);
for (const char of data) {
if (char === "\n") {
cursorLine += 1;
}
}
callback?.();
},
registerMarker(offset: number) {
const line = cursorLine + offset;
markerLines.push(line);
const marker = {
line,
isDisposed: false,
dispose() {
marker.isDisposed = true;
disposedMarkerLines.push(line);
},
};
return marker;
},
};
return { term, writes, markerLines, disposedMarkerLines };
};
test("segments terminal output into raw bytes plus timestamp markers", () => {
const segmenter = createTerminalLineTimestampSegmenter({
now: () => new Date(2026, 5, 6, 9, 8, 7),
});
assert.deepEqual(segmenter.append("hello\r\nnext"), [
{ kind: "timestamp", label: "09:08:07" },
{ kind: "data", data: "hello\r\n" },
{ kind: "timestamp", label: "09:08:07" },
{ kind: "data", data: "next" },
]);
});
test("does not create timestamp markers for alternate screen output", () => {
const segmenter = createTerminalLineTimestampSegmenter({
now: () => new Date(2026, 5, 6, 9, 8, 7),
});
assert.deepEqual(segmenter.append("\x1b[?1049hvim\r\ntext"), [
{ kind: "data", data: "\x1b[?1049hvim\r\ntext" },
]);
assert.deepEqual(segmenter.append("\x1b[?1049lprompt"), [
{ kind: "data", data: "\x1b[?1049l" },
{ kind: "timestamp", label: "09:08:07" },
{ kind: "data", data: "prompt" },
]);
});
test("resolves visible timestamp rows from marker lines", () => {
assert.deepEqual(
resolveTerminalTimestampGutterRows({
viewportY: 10,
rows: 4,
entries: [
{ marker: { line: 9 }, label: "before" },
{ marker: { line: 10 }, label: "10:00:00" },
{ marker: { line: 12 }, label: "10:00:02" },
{ marker: { line: 14 }, label: "after" },
],
}),
[
{ row: 0, label: "10:00:00" },
{ row: 2, label: "10:00:02" },
],
);
});
test("resolves timestamp rows for wrapped continuations", () => {
assert.deepEqual(
resolveTerminalTimestampGutterRows({
viewportY: 11,
rows: 4,
entries: [
{ marker: { line: 10 }, label: "10:00:10" },
{ marker: { line: 13 }, label: "10:00:13" },
],
isWrappedLine: (line) => line === 11 || line === 12,
}),
[
{ row: 0, label: "10:00:10" },
{ row: 1, label: "10:00:10" },
{ row: 2, label: "10:00:13" },
],
);
});
test("formats timestamp labels without terminal escape codes", () => {
assert.equal(formatTerminalLineTimestamp(new Date(2026, 5, 6, 1, 2, 3)), "01:02:03");
});
test("records line timestamps even while the gutter is hidden", () => {
const { term, writes, markerLines } = createFakeTerm();
writeTerminalDataWithLineTimestamps(term as never, "before\r\nnext", () => {});
assert.equal(writes.join(""), "before\r\nnext");
assert.deepEqual(markerLines, [0, 1]);
});
test("keeps recording and preserves existing timestamps when the gutter is hidden", () => {
const { term, markerLines, disposedMarkerLines } = createFakeTerm();
writeTerminalDataWithLineTimestamps(term as never, "shown\r\n", () => {});
writeTerminalDataWithLineTimestamps(term as never, "hidden\r\n", () => {});
writeTerminalDataWithLineTimestamps(term as never, "shown again", () => {});
assert.deepEqual(markerLines, [0, 1, 2]);
assert.deepEqual(disposedMarkerLines, []);
});

View File

@@ -0,0 +1,436 @@
import type { Terminal as XTerm } from "@xterm/xterm";
export type TerminalLineTimestampSegment =
| { kind: "data"; data: string }
| { kind: "timestamp"; label: string };
export type TerminalLineTimestampSegmenter = {
append: (data: string) => TerminalLineTimestampSegment[];
reset: () => void;
setAlternateScreenActive: (active: boolean) => void;
};
type TerminalLineTimestampSegmenterOptions = {
now?: () => Date;
};
type TimestampMarker = {
line: number;
isDisposed?: boolean;
dispose?: () => void;
onDispose?: (listener: () => void) => { dispose: () => void };
};
type TimestampEntry = {
marker: TimestampMarker;
label: string;
disposeListener?: { dispose: () => void };
};
type TimestampStore = {
segmenter: TerminalLineTimestampSegmenter;
entries: TimestampEntry[];
listeners: Set<() => void>;
};
export type TerminalTimestampGutterEntry = {
marker: { line: number; isDisposed?: boolean };
label: string;
};
export type TerminalTimestampGutterRow = {
row: number;
label: string;
};
const stores = new WeakMap<XTerm, TimestampStore>();
const pad2 = (value: number): string => value.toString().padStart(2, "0");
export const formatTerminalLineTimestamp = (date: Date): string => (
`${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`
);
const isCsiFinalByte = (char: string): boolean => char >= "@" && char <= "~";
const readEscapeSequence = (
data: string,
startIndex: number,
): { sequence: string; endIndex: number; complete: boolean } | null => {
if (data[startIndex] !== "\x1b") return null;
const next = data[startIndex + 1];
if (!next) {
return { sequence: "\x1b", endIndex: startIndex, complete: false };
}
if (next === "[") {
for (let index = startIndex + 2; index < data.length; index += 1) {
if (isCsiFinalByte(data[index])) {
return {
sequence: data.slice(startIndex, index + 1),
endIndex: index,
complete: true,
};
}
}
return {
sequence: data.slice(startIndex),
endIndex: data.length - 1,
complete: false,
};
}
if (next === "]") {
for (let index = startIndex + 2; index < data.length; index += 1) {
if (data[index] === "\u0007") {
return {
sequence: data.slice(startIndex, index + 1),
endIndex: index,
complete: true,
};
}
if (data[index] === "\x1b" && data[index + 1] === "\\") {
return {
sequence: data.slice(startIndex, index + 2),
endIndex: index + 1,
complete: true,
};
}
}
return {
sequence: data.slice(startIndex),
endIndex: data.length - 1,
complete: false,
};
}
if (next === "P" || next === "^" || next === "_" || next === "X") {
for (let index = startIndex + 2; index < data.length; index += 1) {
if (data[index] === "\x1b" && data[index + 1] === "\\") {
return {
sequence: data.slice(startIndex, index + 2),
endIndex: index + 1,
complete: true,
};
}
}
return {
sequence: data.slice(startIndex),
endIndex: data.length - 1,
complete: false,
};
}
return {
sequence: data.slice(startIndex, startIndex + 2),
endIndex: startIndex + 1,
complete: true,
};
};
const getCsiFinal = (sequence: string): string | null => {
if (!sequence.startsWith("\x1b[") || sequence.length < 3) return null;
return sequence.at(-1) ?? null;
};
const getAlternateScreenAction = (sequence: string): "enter" | "leave" | null => {
const final = getCsiFinal(sequence);
if (final !== "h" && final !== "l") return null;
const params = sequence.slice(2, -1);
if (!params.startsWith("?")) return null;
const modes = params
.slice(1)
.split(";")
.map((part) => Number.parseInt(part, 10))
.filter(Number.isFinite);
if (!modes.some((mode) => mode === 47 || mode === 1047 || mode === 1049)) {
return null;
}
return final === "h" ? "enter" : "leave";
};
const isPrintableOutput = (char: string): boolean => {
if (char === "\t") return true;
const code = char.codePointAt(0);
return code !== undefined && code >= 0x20 && code !== 0x7f;
};
const pushDataSegment = (
segments: TerminalLineTimestampSegment[],
data: string,
) => {
if (!data) return;
const previous = segments.at(-1);
if (previous?.kind === "data") {
previous.data += data;
return;
}
segments.push({ kind: "data", data });
};
export const createTerminalLineTimestampSegmenter = (
options: TerminalLineTimestampSegmenterOptions = {},
): TerminalLineTimestampSegmenter => {
const now = options.now ?? (() => new Date());
let atLineStart = true;
let currentLineStamped = false;
let pendingEscapeSequence = "";
let suspendedForAlternateScreen = false;
const resetLineState = () => {
atLineStart = true;
currentLineStamped = false;
};
const pushTimestampIfNeeded = (segments: TerminalLineTimestampSegment[]) => {
if (!atLineStart || currentLineStamped) return;
currentLineStamped = true;
atLineStart = false;
segments.push({
kind: "timestamp",
label: formatTerminalLineTimestamp(now()),
});
};
return {
append(data: string) {
const input = pendingEscapeSequence ? `${pendingEscapeSequence}${data}` : data;
pendingEscapeSequence = "";
const segments: TerminalLineTimestampSegment[] = [];
for (let index = 0; index < input.length; index += 1) {
const char = input[index];
if (char === "\x1b") {
const sequence = readEscapeSequence(input, index);
if (sequence) {
if (!sequence.complete) {
pendingEscapeSequence = sequence.sequence;
break;
}
const alternateScreenAction = getAlternateScreenAction(sequence.sequence);
if (alternateScreenAction === "enter") {
pushDataSegment(segments, sequence.sequence);
suspendedForAlternateScreen = true;
resetLineState();
index = sequence.endIndex;
continue;
}
if (alternateScreenAction === "leave") {
pushDataSegment(segments, sequence.sequence);
suspendedForAlternateScreen = false;
resetLineState();
index = sequence.endIndex;
continue;
}
pushDataSegment(segments, sequence.sequence);
index = sequence.endIndex;
continue;
}
}
if (!suspendedForAlternateScreen && isPrintableOutput(char)) {
pushTimestampIfNeeded(segments);
}
pushDataSegment(segments, char);
if (suspendedForAlternateScreen) {
continue;
}
if (char === "\n") {
resetLineState();
} else if (char === "\r") {
atLineStart = true;
} else if (isPrintableOutput(char)) {
atLineStart = false;
}
}
return segments;
},
reset() {
resetLineState();
pendingEscapeSequence = "";
suspendedForAlternateScreen = false;
},
setAlternateScreenActive(active: boolean) {
suspendedForAlternateScreen = active;
if (active) {
resetLineState();
}
},
};
};
const notifyTimestampStore = (store: TimestampStore) => {
for (const listener of store.listeners) {
listener();
}
};
const getTimestampStore = (term: XTerm): TimestampStore => {
let store = stores.get(term);
if (!store) {
store = {
segmenter: createTerminalLineTimestampSegmenter(),
entries: [],
listeners: new Set(),
};
stores.set(term, store);
}
return store;
};
const pruneDisposedEntries = (store: TimestampStore) => {
store.entries = store.entries.filter((entry) => !entry.marker.isDisposed);
};
const resetTimestampStore = (store: TimestampStore) => {
for (const entry of store.entries) {
entry.disposeListener?.dispose();
entry.marker.dispose?.();
}
store.entries = [];
store.segmenter.reset();
notifyTimestampStore(store);
};
const recordTerminalLineTimestamp = (
term: XTerm,
store: TimestampStore,
label: string,
) => {
const registerMarker = (term as XTerm & { registerMarker?: (offset: number) => TimestampMarker | undefined }).registerMarker;
const marker = registerMarker?.call(term, 0);
if (!marker) return;
const entry: TimestampEntry = { marker, label };
entry.disposeListener = marker.onDispose?.(() => {
store.entries = store.entries.filter((candidate) => candidate !== entry);
entry.disposeListener?.dispose();
notifyTimestampStore(store);
});
store.entries.push(entry);
notifyTimestampStore(store);
};
export const resetTerminalLineTimestamps = (term: XTerm) => {
resetTimestampStore(getTimestampStore(term));
};
export const onTerminalLineTimestampsChange = (
term: XTerm,
listener: () => void,
) => {
const store = getTimestampStore(term);
store.listeners.add(listener);
return () => {
store.listeners.delete(listener);
};
};
export const resolveTerminalTimestampGutterRows = ({
viewportY,
rows,
entries,
isWrappedLine,
}: {
viewportY: number;
rows: number;
entries: readonly TerminalTimestampGutterEntry[];
isWrappedLine?: (line: number) => boolean;
}): TerminalTimestampGutterRow[] => {
const labelByLine = new Map<number, string>();
for (const entry of entries) {
if (entry.marker.isDisposed) continue;
labelByLine.set(entry.marker.line, entry.label);
}
const rowLabels = new Map<number, string>();
for (let row = 0; row < rows; row += 1) {
const line = viewportY + row;
const directLabel = labelByLine.get(line);
if (directLabel) {
rowLabels.set(row, directLabel);
continue;
}
if (!isWrappedLine?.(line)) continue;
let sourceLine = line;
while (sourceLine > 0 && isWrappedLine(sourceLine)) {
sourceLine -= 1;
}
const wrappedLabel = labelByLine.get(sourceLine);
if (wrappedLabel) {
rowLabels.set(row, wrappedLabel);
}
}
return [...rowLabels.entries()]
.sort(([a], [b]) => a - b)
.map(([row, label]) => ({ row, label }));
};
export const getVisibleTerminalLineTimestampRows = (
term: XTerm,
): TerminalTimestampGutterRow[] => {
if ((term.buffer.active as { type?: string }).type === "alternate") {
return [];
}
const store = getTimestampStore(term);
pruneDisposedEntries(store);
return resolveTerminalTimestampGutterRows({
viewportY: term.buffer.active.viewportY,
rows: term.rows,
entries: store.entries,
isWrappedLine: (line) => term.buffer.active.getLine(line)?.isWrapped === true,
});
};
export const writeTerminalDataWithLineTimestamps = (
term: XTerm,
data: string,
done: () => void,
) => {
const registerMarker = (term as XTerm & { registerMarker?: unknown }).registerMarker;
if (typeof registerMarker !== "function") {
term.write(data, done);
return;
}
const store = getTimestampStore(term);
store.segmenter.setAlternateScreenActive(
((term.buffer?.active as { type?: string } | undefined)?.type) === "alternate",
);
const segments = store.segmenter.append(data);
let index = 0;
const writeNext = () => {
const segment = segments[index];
index += 1;
if (!segment) {
done();
return;
}
if (segment.kind === "timestamp") {
recordTerminalLineTimestamp(term, store, segment.label);
writeNext();
return;
}
if (!segment.data) {
writeNext();
return;
}
term.write(segment.data, writeNext);
};
writeNext();
};

View File

@@ -1,128 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
createTerminalOutputTimestampPrefixer,
formatTerminalOutputTimestamp,
} from "./terminalOutputTimestamps.ts";
test("formats terminal output timestamps as bracketed local time", () => {
assert.equal(
formatTerminalOutputTimestamp(new Date(2026, 5, 6, 9, 8, 7)),
"\x1b[2;90m[09:08:07] \x1b[22;39m",
);
});
test("prefixes each non-empty terminal output line across chunks", () => {
const prefixer = createTerminalOutputTimestampPrefixer({
now: () => new Date(2026, 5, 6, 10, 11, 12),
});
assert.equal(prefixer.append("hello"), "\x1b[2;90m[10:11:12] \x1b[22;39mhello");
assert.equal(prefixer.append(" world\r\nnext"), " world\r\n\x1b[2;90m[10:11:12] \x1b[22;39mnext");
assert.equal(prefixer.append("\r\n"), "\r\n");
});
test("does not timestamp blank lines or repeated carriage-return updates", () => {
const prefixer = createTerminalOutputTimestampPrefixer({
now: () => new Date(2026, 5, 6, 1, 2, 3),
});
assert.equal(
prefixer.append("\r\n\r\nprogress 1\rprogress 2\n"),
"\r\n\r\n\x1b[2;90m[01:02:03] \x1b[22;39mprogress 1\rprogress 2\n",
);
});
test("waits until printable output after leading terminal controls", () => {
const prefixer = createTerminalOutputTimestampPrefixer({
now: () => new Date(2026, 5, 6, 4, 5, 6),
});
assert.equal(
prefixer.append("\x1b[?2004l\rpermission denied\r\n\x1b[01;32muser@host\x1b[00m$ "),
"\x1b[?2004l\r\x1b[2;90m[04:05:06] \x1b[22;39mpermission denied\r\n\x1b[01;32m\x1b[2;90m[04:05:06] \x1b[22;39m\x1b[1;32muser@host\x1b[00m$ ",
);
});
test("does not split timestamps into fragmented terminal controls", () => {
const prefixer = createTerminalOutputTimestampPrefixer({
now: () => new Date(2026, 5, 6, 7, 8, 9),
});
assert.equal(prefixer.append("\x1b"), "");
assert.equal(
prefixer.append("[?2004l\rhello"),
"\x1b[?2004l\r\x1b[2;90m[07:08:09] \x1b[22;39mhello",
);
assert.equal(prefixer.append("\r\n\x1b[01;"), "\r\n");
assert.equal(
prefixer.append("32muser"),
"\x1b[01;32m\x1b[2;90m[07:08:09] \x1b[22;39m\x1b[1;32muser",
);
});
test("keeps long terminal control strings untouched across chunks", () => {
const prefixer = createTerminalOutputTimestampPrefixer({
now: () => new Date(2026, 5, 6, 8, 9, 10),
});
assert.equal(prefixer.append("\x1bPtmux;payload"), "");
assert.equal(
prefixer.append("\x1b\\hello"),
"\x1bPtmux;payload\x1b\\\x1b[2;90m[08:09:10] \x1b[22;39mhello",
);
});
test("keeps alternate screen output untouched and resumes after exit", () => {
const prefixer = createTerminalOutputTimestampPrefixer({
now: () => new Date(2026, 5, 6, 11, 12, 13),
});
assert.equal(
prefixer.append("\x1b[?1049hvim screen\r\nstill vim"),
"\x1b[?1049hvim screen\r\nstill vim",
);
assert.equal(
prefixer.append("\x1b[?1049lprompt"),
"\x1b[?1049l\x1b[2;90m[11:12:13] \x1b[22;39mprompt",
);
});
test("restores active text color after timestamp color", () => {
const prefixer = createTerminalOutputTimestampPrefixer({
now: () => new Date(2026, 5, 6, 14, 15, 16),
});
assert.equal(
prefixer.append("\x1b[31mred\r\nnext"),
"\x1b[31m\x1b[2;90m[14:15:16] \x1b[22;39m\x1b[31mred\r\n\x1b[2;90m[14:15:16] \x1b[22;39m\x1b[31mnext",
);
});
test("does not timestamp output that has no printable text", () => {
const prefixer = createTerminalOutputTimestampPrefixer({
now: () => new Date(2026, 5, 6, 17, 18, 19),
});
assert.equal(prefixer.append("\x07\b\x1b[0m\r\n"), "\x07\b\x1b[0m\r\n");
assert.equal(prefixer.append("visible"), "\x1b[2;90m[17:18:19] \x1b[22;39mvisible");
});
test("timestamps printable text after leading invisible controls", () => {
const prefixer = createTerminalOutputTimestampPrefixer({
now: () => new Date(2026, 5, 6, 20, 21, 22),
});
assert.equal(prefixer.append("\x07visible"), "\x07\x1b[2;90m[20:21:22] \x1b[22;39mvisible");
prefixer.reset();
assert.equal(prefixer.append("\bvisible"), "\b\x1b[2;90m[20:21:22] \x1b[22;39mvisible");
});
test("timestamps before a leading tab", () => {
const prefixer = createTerminalOutputTimestampPrefixer({
now: () => new Date(2026, 5, 6, 23, 24, 25),
});
assert.equal(prefixer.append("\tvisible"), "\x1b[2;90m[23:24:25] \x1b[22;39m\tvisible");
});

View File

@@ -1,322 +0,0 @@
export type TerminalOutputTimestampPrefixer = {
append: (data: string) => string;
reset: () => void;
setAlternateScreenActive: (active: boolean) => void;
};
type TerminalOutputTimestampPrefixerOptions = {
now?: () => Date;
};
const pad2 = (value: number): string => value.toString().padStart(2, "0");
export const formatTerminalOutputTimestamp = (date: Date, restoreSequence = ""): string => (
`\x1b[2;90m[${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}] \x1b[22;39m${restoreSequence}`
);
const isCsiFinalByte = (char: string): boolean => char >= "@" && char <= "~";
const readEscapeSequence = (
data: string,
startIndex: number,
): { sequence: string; endIndex: number; complete: boolean; isColorSequence: boolean } | null => {
if (data[startIndex] !== "\x1b") return null;
const next = data[startIndex + 1];
if (!next) {
return { sequence: "\x1b", endIndex: startIndex, complete: false, isColorSequence: false };
}
if (next === "[") {
for (let index = startIndex + 2; index < data.length; index += 1) {
if (isCsiFinalByte(data[index])) {
return {
sequence: data.slice(startIndex, index + 1),
endIndex: index,
complete: true,
isColorSequence: data[index] === "m",
};
}
}
return {
sequence: data.slice(startIndex),
endIndex: data.length - 1,
complete: false,
isColorSequence: false,
};
}
if (next === "]") {
for (let index = startIndex + 2; index < data.length; index += 1) {
if (data[index] === "\u0007") {
return {
sequence: data.slice(startIndex, index + 1),
endIndex: index,
complete: true,
isColorSequence: false,
};
}
if (data[index] === "\x1b" && data[index + 1] === "\\") {
return {
sequence: data.slice(startIndex, index + 2),
endIndex: index + 1,
complete: true,
isColorSequence: false,
};
}
}
return {
sequence: data.slice(startIndex),
endIndex: data.length - 1,
complete: false,
isColorSequence: false,
};
}
if (next === "P" || next === "^" || next === "_" || next === "X") {
for (let index = startIndex + 2; index < data.length; index += 1) {
if (data[index] === "\x1b" && data[index + 1] === "\\") {
return {
sequence: data.slice(startIndex, index + 2),
endIndex: index + 1,
complete: true,
isColorSequence: false,
};
}
}
return {
sequence: data.slice(startIndex),
endIndex: data.length - 1,
complete: false,
isColorSequence: false,
};
}
return {
sequence: data.slice(startIndex, startIndex + 2),
endIndex: startIndex + 1,
complete: true,
isColorSequence: false,
};
};
const getCsiFinal = (sequence: string): string | null => {
if (!sequence.startsWith("\x1b[") || sequence.length < 3) return null;
return sequence.at(-1) ?? null;
};
const getCsiParams = (sequence: string): string => sequence.slice(2, -1);
const getAlternateScreenAction = (sequence: string): "enter" | "leave" | null => {
const final = getCsiFinal(sequence);
if (final !== "h" && final !== "l") return null;
const params = getCsiParams(sequence);
if (!params.startsWith("?")) return null;
const modes = params
.slice(1)
.split(";")
.map((part) => Number.parseInt(part, 10))
.filter(Number.isFinite);
if (!modes.some((mode) => mode === 47 || mode === 1047 || mode === 1049)) {
return null;
}
return final === "h" ? "enter" : "leave";
};
const parseSgrParams = (sequence: string): number[] => {
if (getCsiFinal(sequence) !== "m") return [];
const params = getCsiParams(sequence);
if (params === "") return [0];
return params.split(";").map((part) => {
if (part === "") return 0;
const value = Number.parseInt(part, 10);
return Number.isFinite(value) ? value : 0;
});
};
const isPrintableOutput = (char: string): boolean => {
if (char === "\t") return true;
const code = char.codePointAt(0);
return code !== undefined && code >= 0x20 && code !== 0x7f;
};
export const createTerminalOutputTimestampPrefixer = (
options: TerminalOutputTimestampPrefixerOptions = {},
): TerminalOutputTimestampPrefixer => {
const now = options.now ?? (() => new Date());
let atLineStart = true;
let currentLinePrefixed = false;
let pendingEscapeSequence = "";
let suspendedForAlternateScreen = false;
const activeStyleFlags = new Set<number>();
let activeForeground: number[] = [];
let activeBackground: number[] = [];
const restoreActiveSgr = () => {
const orderedFlags = [1, 2, 3, 4, 7, 9].filter((code) => activeStyleFlags.has(code));
const codes = [...orderedFlags, ...activeForeground, ...activeBackground];
return codes.length ? `\x1b[${codes.join(";")}m` : "";
};
const setFlag = (code: number) => {
activeStyleFlags.add(code);
};
const applySgrSequence = (sequence: string) => {
const codes = parseSgrParams(sequence);
for (let index = 0; index < codes.length; index += 1) {
const code = codes[index] ?? 0;
if (code === 0) {
activeStyleFlags.clear();
activeForeground = [];
activeBackground = [];
} else if (code === 1 || code === 2 || code === 3 || code === 4 || code === 7 || code === 9) {
setFlag(code);
} else if (code === 21) {
activeStyleFlags.delete(1);
} else if (code === 22) {
activeStyleFlags.delete(1);
activeStyleFlags.delete(2);
} else if (code === 23) {
activeStyleFlags.delete(3);
} else if (code === 24) {
activeStyleFlags.delete(4);
} else if (code === 27) {
activeStyleFlags.delete(7);
} else if (code === 29) {
activeStyleFlags.delete(9);
} else if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) {
activeForeground = [code];
} else if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) {
activeBackground = [code];
} else if (code === 39) {
activeForeground = [];
} else if (code === 49) {
activeBackground = [];
} else if (code === 38 && codes[index + 1] === 5 && codes[index + 2] !== undefined) {
activeForeground = [38, 5, codes[index + 2]];
index += 2;
} else if (
code === 38 &&
codes[index + 1] === 2 &&
codes[index + 2] !== undefined &&
codes[index + 3] !== undefined &&
codes[index + 4] !== undefined
) {
activeForeground = [38, 2, codes[index + 2], codes[index + 3], codes[index + 4]];
index += 4;
} else if (code === 48 && codes[index + 1] === 5 && codes[index + 2] !== undefined) {
activeBackground = [48, 5, codes[index + 2]];
index += 2;
} else if (
code === 48 &&
codes[index + 1] === 2 &&
codes[index + 2] !== undefined &&
codes[index + 3] !== undefined &&
codes[index + 4] !== undefined
) {
activeBackground = [48, 2, codes[index + 2], codes[index + 3], codes[index + 4]];
index += 4;
}
}
};
const prefixIfNeeded = () => {
if (!atLineStart || currentLinePrefixed) return "";
currentLinePrefixed = true;
atLineStart = false;
return formatTerminalOutputTimestamp(now(), restoreActiveSgr());
};
const resetLineState = () => {
atLineStart = true;
currentLinePrefixed = false;
};
return {
append(data: string) {
const input = pendingEscapeSequence ? `${pendingEscapeSequence}${data}` : data;
pendingEscapeSequence = "";
let output = "";
for (let index = 0; index < input.length; index += 1) {
const char = input[index];
if (char === "\x1b") {
const sequence = readEscapeSequence(input, index);
if (sequence) {
if (!sequence.complete) {
pendingEscapeSequence = sequence.sequence;
break;
}
const alternateScreenAction = getAlternateScreenAction(sequence.sequence);
if (alternateScreenAction === "enter") {
output += sequence.sequence;
suspendedForAlternateScreen = true;
resetLineState();
index = sequence.endIndex;
continue;
}
if (alternateScreenAction === "leave") {
output += sequence.sequence;
suspendedForAlternateScreen = false;
resetLineState();
index = sequence.endIndex;
continue;
}
if (suspendedForAlternateScreen) {
output += sequence.sequence;
index = sequence.endIndex;
continue;
}
if (sequence.isColorSequence) {
output += sequence.sequence;
applySgrSequence(sequence.sequence);
} else {
output += sequence.sequence;
}
index = sequence.endIndex;
continue;
}
}
if (suspendedForAlternateScreen) {
output += char;
continue;
}
if (isPrintableOutput(char)) {
output += prefixIfNeeded();
}
output += char;
if (char === "\n") {
resetLineState();
} else if (char === "\r") {
atLineStart = true;
} else if (isPrintableOutput(char)) {
atLineStart = false;
}
}
return output;
},
reset() {
resetLineState();
pendingEscapeSequence = "";
suspendedForAlternateScreen = false;
activeStyleFlags.clear();
activeForeground = [];
activeBackground = [];
},
setAlternateScreenActive(active: boolean) {
suspendedForAlternateScreen = active;
if (active) {
resetLineState();
}
},
};
};

Some files were not shown because too many files have changed in this diff Show More