Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bf2220d0b | ||
|
|
683756324e | ||
|
|
80fbf0da2f | ||
|
|
556a14178c | ||
|
|
7e566efe9c | ||
|
|
1d2489b02c | ||
|
|
5ad3d0ce32 | ||
|
|
edf013164b | ||
|
|
504b576e1c | ||
|
|
890abd1c4c | ||
|
|
0827dd416f | ||
|
|
24df4b6548 | ||
|
|
7db4b18cce | ||
|
|
844c55e99d | ||
|
|
778b43ceff | ||
|
|
6b2e5041d2 | ||
|
|
1464cba6da | ||
|
|
d74d9e28a0 | ||
|
|
32b74f4fea | ||
|
|
f284fb0505 | ||
|
|
1769edb881 | ||
|
|
a7873672c5 | ||
|
|
d2fe0ecefe | ||
|
|
3261e481ee | ||
|
|
3dfc84918b | ||
|
|
3dc9581be6 | ||
|
|
4e7d69c9ff | ||
|
|
7649243021 | ||
|
|
b770dbe6f5 | ||
|
|
1e0979e441 | ||
|
|
9dbd2a5cf7 | ||
|
|
702700d93c | ||
|
|
0413e02bf0 | ||
|
|
1cccbfe5fb | ||
|
|
1c5960a054 | ||
|
|
2ae1219bb7 | ||
|
|
591b2ba010 | ||
|
|
e26f1350f5 | ||
|
|
d36fc2db1b | ||
|
|
32ebc01552 | ||
|
|
6f93a741ff | ||
|
|
d77b0531f6 | ||
|
|
0bc45417c7 | ||
|
|
fd88b3a36b | ||
|
|
6ac36be04b | ||
|
|
8ed1588fdb | ||
|
|
762255443b | ||
|
|
fdf38b0a6a |
15
.github/workflows/build.yml
vendored
@@ -43,6 +43,21 @@ jobs:
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Install cross-platform native binaries
|
||||
shell: bash
|
||||
run: |
|
||||
# npm ci only installs optional deps for the host platform, but
|
||||
# electron-builder produces both arm64 and x64 binaries, so we
|
||||
# need the native codex-acp binary for the other architecture too.
|
||||
# Platform-specific codex-acp packages declare cpu/os constraints,
|
||||
# so --force is needed to install the non-host-arch binary.
|
||||
CODEX_VER=$(node -e "console.log(require('./node_modules/@zed-industries/codex-acp/package.json').version)")
|
||||
if [[ "${{ matrix.name }}" == "macos" ]]; then
|
||||
npm install "@zed-industries/codex-acp-darwin-x64@${CODEX_VER}" "@zed-industries/codex-acp-darwin-arm64@${CODEX_VER}" --no-save --force
|
||||
elif [[ "${{ matrix.name }}" == "windows" ]]; then
|
||||
npm install "@zed-industries/codex-acp-win32-x64@${CODEX_VER}" "@zed-industries/codex-acp-win32-arm64@${CODEX_VER}" --no-save --force
|
||||
fi
|
||||
|
||||
- name: Set version
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
126
App.tsx
@@ -32,10 +32,12 @@ import { Input } from './components/ui/input';
|
||||
import { Label } from './components/ui/label';
|
||||
import { ToastProvider, toast } from './components/ui/toast';
|
||||
import { VaultView, VaultSection } from './components/VaultView';
|
||||
import { QuickAddSnippetDialog } from './components/QuickAddSnippetDialog';
|
||||
import { KeyboardInteractiveModal, KeyboardInteractiveRequest } from './components/KeyboardInteractiveModal';
|
||||
import { PassphraseModal, PassphraseRequest } from './components/PassphraseModal';
|
||||
import { cn } from './lib/utils';
|
||||
import { classifyLocalShellType } from './lib/localShell';
|
||||
import { useDiscoveredShells, resolveShellSetting } from './lib/useDiscoveredShells';
|
||||
import { ConnectionLog, Host, HostProtocol, SerialConfig, TerminalSession, TerminalTheme } from './types';
|
||||
import { LogView as LogViewType } from './application/state/useSessionState';
|
||||
import type { SftpView as SftpViewComponent } from './components/SftpView';
|
||||
@@ -186,6 +188,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
terminalFontSize,
|
||||
setTerminalFontSize,
|
||||
terminalSettings,
|
||||
updateTerminalSetting,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
isHotkeyRecording,
|
||||
@@ -204,6 +207,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
workspaceFocusStyle,
|
||||
} = settings;
|
||||
|
||||
const discoveredShells = useDiscoveredShells();
|
||||
|
||||
// Sync workspace focus indicator style to DOM for CSS targeting
|
||||
useEffect(() => {
|
||||
if (workspaceFocusStyle === 'border') {
|
||||
@@ -487,6 +492,25 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const _handleGlobalHotkeyKeyDown = useEffectEvent((e: KeyboardEvent) => {
|
||||
const isMac = hotkeyScheme === 'mac';
|
||||
const target = e.target as HTMLElement;
|
||||
const isCloseTabHotkey = closeTabKeyStr ? matchesKeyBinding(e, closeTabKeyStr, isMac) : false;
|
||||
const dialogHotkeyScope = target.closest?.('[data-hotkey-close-tab="true"]');
|
||||
|
||||
if (isCloseTabHotkey && dialogHotkeyScope) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCloseTabHotkey) {
|
||||
const openDialogs = Array.from(document.querySelectorAll<HTMLElement>('[role="dialog"][data-state="open"]'));
|
||||
const topmostOpenDialog = openDialogs[openDialogs.length - 1] ?? null;
|
||||
const topmostDialogClose = topmostOpenDialog?.querySelector<HTMLElement>('[data-dialog-close="true"]');
|
||||
if (topmostDialogClose) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
topmostDialogClose.click();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const isFormElement = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
|
||||
const isMonacoElement =
|
||||
target instanceof HTMLElement &&
|
||||
@@ -699,6 +723,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
// Add to queue instead of replacing - supports multiple concurrent sessions
|
||||
setKeyboardInteractiveQueue(prev => [...prev, {
|
||||
requestId: request.requestId,
|
||||
sessionId: request.sessionId,
|
||||
name: request.name,
|
||||
instructions: request.instructions,
|
||||
prompts: request.prompts,
|
||||
@@ -713,14 +738,29 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}, []);
|
||||
|
||||
// Handle keyboard-interactive submit
|
||||
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[]) => {
|
||||
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[], savePassword?: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.respondKeyboardInteractive) {
|
||||
void bridge.respondKeyboardInteractive(requestId, responses, false);
|
||||
}
|
||||
// Save password to host if requested
|
||||
if (savePassword) {
|
||||
const request = keyboardInteractiveQueue.find(r => r.requestId === requestId);
|
||||
if (request?.sessionId) {
|
||||
const session = sessions.find(s => s.id === request.sessionId);
|
||||
// Only save when the prompting hostname matches the session's host,
|
||||
// to avoid overwriting the destination host's password with a jump host's password
|
||||
if (session?.hostId && (!request.hostname || request.hostname === session.hostname)) {
|
||||
const host = hosts.find(h => h.id === session.hostId);
|
||||
if (host) {
|
||||
updateHosts(hosts.map(h => h.id === host.id ? { ...h, password: savePassword } : h));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Remove from queue by requestId
|
||||
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
|
||||
}, []);
|
||||
}, [keyboardInteractiveQueue, sessions, hosts, updateHosts]);
|
||||
|
||||
// Handle keyboard-interactive cancel
|
||||
const handleKeyboardInteractiveCancel = useCallback((requestId: string) => {
|
||||
@@ -811,22 +851,37 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
addConnectionLogRef.current = addConnectionLog;
|
||||
|
||||
const createLocalTerminalWithCurrentShell = useCallback(() => {
|
||||
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
|
||||
const matchedShell = discoveredShells.find(s => s.id === terminalSettings.localShell);
|
||||
return createLocalTerminal({
|
||||
shellType: classifyLocalShellType(terminalSettings.localShell, navigator.userAgent),
|
||||
shellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, navigator.userAgent),
|
||||
shell: resolved?.command,
|
||||
shellArgs: resolved?.args,
|
||||
shellName: matchedShell?.name,
|
||||
shellIcon: matchedShell?.icon,
|
||||
});
|
||||
}, [createLocalTerminal, terminalSettings.localShell]);
|
||||
}, [createLocalTerminal, terminalSettings.localShell, discoveredShells]);
|
||||
|
||||
const splitSessionWithCurrentShell = useCallback((sessionId: string, direction: 'horizontal' | 'vertical') => {
|
||||
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
|
||||
return splitSession(sessionId, direction, {
|
||||
localShellType: classifyLocalShellType(terminalSettings.localShell, navigator.userAgent),
|
||||
localShellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, navigator.userAgent),
|
||||
});
|
||||
}, [splitSession, terminalSettings.localShell]);
|
||||
}, [splitSession, terminalSettings.localShell, discoveredShells]);
|
||||
|
||||
const copySessionWithCurrentShell = useCallback((sessionId: string) => {
|
||||
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
|
||||
return copySession(sessionId, {
|
||||
localShellType: classifyLocalShellType(terminalSettings.localShell, navigator.userAgent),
|
||||
localShellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, navigator.userAgent),
|
||||
});
|
||||
}, [copySession, terminalSettings.localShell]);
|
||||
}, [copySession, terminalSettings.localShell, discoveredShells]);
|
||||
|
||||
const closeTabKeyStr = useMemo(() => {
|
||||
if (hotkeyScheme === 'disabled') return null;
|
||||
const closeTabBinding = keyBindings.find((binding) => binding.action === 'closeTab');
|
||||
if (!closeTabBinding) return null;
|
||||
return hotkeyScheme === 'mac' ? closeTabBinding.mac : closeTabBinding.pc;
|
||||
}, [hotkeyScheme, keyBindings]);
|
||||
|
||||
// Shared hotkey action handler - used by both global handler and terminal callback
|
||||
const executeHotkeyAction = useCallback((action: string, e: KeyboardEvent) => {
|
||||
@@ -931,32 +986,32 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
break;
|
||||
}
|
||||
case 'splitHorizontal': {
|
||||
// Split current terminal horizontally (top/bottom)
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
// Check if it's a standalone session or we're in a workspace
|
||||
const activeSession = sessions.find(s => s.id === currentId);
|
||||
const activeWs = workspaces.find(w => w.id === currentId);
|
||||
if (activeSession && !activeSession.workspaceId) {
|
||||
// Standalone session - split it
|
||||
splitSessionWithCurrentShell(activeSession.id, 'horizontal');
|
||||
} else if (activeWs) {
|
||||
// In a workspace - need to determine focused session
|
||||
// For now, we'll need the terminal to handle this via context menu
|
||||
if (IS_DEV) console.log('[Hotkey] Split horizontal in workspace - use context menu on specific terminal');
|
||||
const liveIds = collectSessionIds(activeWs.root);
|
||||
const targetId = (activeWs.focusedSessionId && liveIds.includes(activeWs.focusedSessionId))
|
||||
? activeWs.focusedSessionId
|
||||
: liveIds[0];
|
||||
if (targetId) splitSessionWithCurrentShell(targetId, 'horizontal');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'splitVertical': {
|
||||
// Split current terminal vertically (left/right)
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
const activeSession = sessions.find(s => s.id === currentId);
|
||||
const activeWs = workspaces.find(w => w.id === currentId);
|
||||
if (activeSession && !activeSession.workspaceId) {
|
||||
// Standalone session - split it
|
||||
splitSessionWithCurrentShell(activeSession.id, 'vertical');
|
||||
} else if (activeWs) {
|
||||
// In a workspace - need to determine focused session
|
||||
if (IS_DEV) console.log('[Hotkey] Split vertical in workspace - use context menu on specific terminal');
|
||||
const liveIds = collectSessionIds(activeWs.root);
|
||||
const targetId = (activeWs.focusedSessionId && liveIds.includes(activeWs.focusedSessionId))
|
||||
? activeWs.focusedSessionId
|
||||
: liveIds[0];
|
||||
if (targetId) splitSessionWithCurrentShell(targetId, 'vertical');
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -1065,13 +1120,24 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}, []);
|
||||
|
||||
// Wrapper to create local terminal with logging
|
||||
const handleCreateLocalTerminal = useCallback(() => {
|
||||
const handleCreateLocalTerminal = useCallback((shell?: { command: string; args?: string[]; name?: string; icon?: string }) => {
|
||||
const { username, hostname } = systemInfoRef.current;
|
||||
const sessionId = createLocalTerminalWithCurrentShell();
|
||||
const resolved = shell ?? resolveShellSetting(terminalSettings.localShell, discoveredShells);
|
||||
// Match by ID (not command) to avoid WSL distros all sharing wsl.exe
|
||||
const matchedShell = !shell ? discoveredShells.find(s => s.id === terminalSettings.localShell) : undefined;
|
||||
const shellName = shell?.name ?? matchedShell?.name;
|
||||
const shellIcon = shell?.icon ?? matchedShell?.icon;
|
||||
const sessionId = createLocalTerminal({
|
||||
shellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, navigator.userAgent),
|
||||
shell: resolved?.command,
|
||||
shellArgs: resolved?.args,
|
||||
shellName,
|
||||
shellIcon,
|
||||
});
|
||||
addConnectionLog({
|
||||
sessionId,
|
||||
hostId: '',
|
||||
hostLabel: 'Local Terminal',
|
||||
hostLabel: shellName || 'Local Terminal',
|
||||
hostname: 'localhost',
|
||||
username: username,
|
||||
protocol: 'local',
|
||||
@@ -1080,7 +1146,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
localHostname: hostname,
|
||||
saved: false,
|
||||
});
|
||||
}, [addConnectionLog, createLocalTerminalWithCurrentShell]);
|
||||
}, [addConnectionLog, createLocalTerminal, terminalSettings.localShell, discoveredShells]);
|
||||
|
||||
const resolveEffectiveHost = useCallback((host: Host): Host => {
|
||||
if (!host.group) return host;
|
||||
@@ -1423,6 +1489,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
onUpdateTerminalThemeId={setTerminalThemeId}
|
||||
onUpdateTerminalFontFamilyId={setTerminalFontFamilyId}
|
||||
onUpdateTerminalFontSize={setTerminalFontSize}
|
||||
onUpdateTerminalFontWeight={(w) => updateTerminalSetting('fontWeight', w)}
|
||||
onCloseSession={closeSession}
|
||||
onUpdateSessionStatus={handleSessionStatusChange}
|
||||
onUpdateHostDistro={updateHostDistro}
|
||||
@@ -1472,6 +1539,17 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Global "quick add snippet" dialog, triggered by the
|
||||
netcatty:snippets:add window event (from ScriptsSidePanel "+"). */}
|
||||
<QuickAddSnippetDialog
|
||||
snippets={snippets}
|
||||
packages={snippetPackages}
|
||||
onCreateSnippet={(snippet) => updateSnippets([...snippets, snippet])}
|
||||
onCreatePackage={(pkg) =>
|
||||
updateSnippetPackages(Array.from(new Set([...snippetPackages, pkg])))
|
||||
}
|
||||
/>
|
||||
|
||||
{isQuickSwitcherOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<LazyQuickSwitcher
|
||||
@@ -1487,8 +1565,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setQuickSearch('');
|
||||
}}
|
||||
onCreateLocalTerminal={() => {
|
||||
handleCreateLocalTerminal();
|
||||
onCreateLocalTerminal={(shell) => {
|
||||
handleCreateLocalTerminal(shell);
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setQuickSearch('');
|
||||
}}
|
||||
|
||||
@@ -237,9 +237,9 @@ const en: Messages = {
|
||||
'settings.appearance.themeColor.dark': 'Dark palette',
|
||||
'settings.appearance.customCss': 'Custom CSS',
|
||||
'settings.appearance.customCss.desc':
|
||||
'Add custom CSS to personalize the app appearance. Changes apply immediately.',
|
||||
'Add custom CSS to personalize the app appearance. Changes apply immediately. Major UI regions expose a [data-section="..."] attribute you can target — e.g. snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar, top-tabs.',
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* Example: */\n.terminal { background: #1a1a2e !important; }\n:root { --radius: 0.25rem; }',
|
||||
'/* Examples — use !important to beat Tailwind utility specificity */\n\n/* Make snippet sidebar text larger */\n[data-section="snippets-panel"] {\n font-size: 14px !important;\n}\n\n/* Custom terminal background */\n.terminal { background: #1a1a2e !important; }\n\n/* Tweak global border radius */\n:root { --radius: 0.25rem; }',
|
||||
'settings.appearance.language': 'Language',
|
||||
'settings.appearance.language.desc': 'Choose the UI language',
|
||||
'settings.appearance.uiFont': 'Interface Font',
|
||||
@@ -343,6 +343,11 @@ const en: Messages = {
|
||||
'settings.terminal.localShell.shell.detected': 'Detected',
|
||||
'settings.terminal.localShell.shell.notFound': 'Shell executable not found',
|
||||
'settings.terminal.localShell.shell.isDirectory': 'Path is a directory, not an executable',
|
||||
'settings.terminal.localShell.shell.default': 'System Default',
|
||||
'settings.terminal.localShell.shell.custom': 'Custom...',
|
||||
'settings.terminal.localShell.shell.customPath': 'Shell executable path',
|
||||
'settings.terminal.localShell.shell.commonPaths': 'Common paths',
|
||||
'settings.terminal.localShell.shell.pathValid': 'Path valid',
|
||||
'settings.terminal.localShell.startDir': 'Starting directory',
|
||||
'settings.terminal.localShell.startDir.desc': 'Directory to start in when opening a local terminal. Leave empty for home directory.',
|
||||
'settings.terminal.localShell.startDir.placeholder': 'Home directory',
|
||||
@@ -361,7 +366,7 @@ const en: Messages = {
|
||||
// Settings > Terminal > Rendering
|
||||
'settings.terminal.section.rendering': 'Rendering',
|
||||
'settings.terminal.rendering.renderer': 'Renderer',
|
||||
'settings.terminal.rendering.renderer.desc': 'Choose the terminal rendering technology. Auto will use Canvas on low-memory devices. Changes take effect on new terminal sessions.',
|
||||
'settings.terminal.rendering.renderer.desc': 'Choose the terminal rendering technology. Auto will use DOM on low-memory devices. Changes take effect on new terminal sessions.',
|
||||
'settings.terminal.rendering.auto': 'Auto',
|
||||
|
||||
// Settings > Terminal > Workspace Focus Indicator
|
||||
@@ -524,6 +529,7 @@ const en: Messages = {
|
||||
'vault.hosts.deselectAll': 'Deselect All',
|
||||
'vault.hosts.deleteSelected': 'Delete ({count})',
|
||||
'vault.hosts.deleteMultiple.success': 'Deleted {count} hosts',
|
||||
'vault.hosts.moveToGroup.success': 'Moved {host} to {group}',
|
||||
'vault.hosts.empty.title': 'Set up your hosts',
|
||||
'vault.hosts.empty.desc': 'Save hosts to quickly connect to your servers, VMs, and containers.',
|
||||
|
||||
@@ -911,6 +917,8 @@ const en: Messages = {
|
||||
'qs.search.placeholder': 'Search hosts or tabs',
|
||||
'qs.jumpTo': 'Jump To',
|
||||
'qs.localTerminal': 'Local Terminal',
|
||||
'qs.localShells': 'Local Shells',
|
||||
'qs.default': 'Default',
|
||||
|
||||
// Select Host panel
|
||||
'selectHost.title': 'Select Host',
|
||||
@@ -1008,6 +1016,8 @@ const en: Messages = {
|
||||
'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.',
|
||||
'hostDetails.backspaceBehavior': 'Backspace Behavior',
|
||||
'hostDetails.backspaceBehavior.default': 'Default',
|
||||
'hostDetails.jumpHosts': 'Proxy via Hosts',
|
||||
'hostDetails.jumpHosts.hops': '{count} hop(s)',
|
||||
'hostDetails.jumpHosts.direct': 'Direct',
|
||||
@@ -1215,6 +1225,7 @@ const en: Messages = {
|
||||
'terminal.themeModal.globalTheme': 'Global Theme',
|
||||
'terminal.themeModal.globalFont': 'Global Font',
|
||||
'terminal.themeModal.fontSize': 'Font Size',
|
||||
'terminal.themeModal.fontWeight': 'Font Weight',
|
||||
'terminal.themeModal.livePreview': 'Live Preview',
|
||||
'terminal.themeModal.themeType': '{type} theme',
|
||||
|
||||
@@ -1633,10 +1644,7 @@ const en: Messages = {
|
||||
'keyboard.interactive.enterResponse': 'Enter response',
|
||||
'keyboard.interactive.submit': 'Submit',
|
||||
'keyboard.interactive.verifying': 'Verifying...',
|
||||
'keyboard.interactive.fill': 'Fill',
|
||||
'keyboard.interactive.fillSaved': 'Fill with saved password',
|
||||
'keyboard.interactive.useSaved': 'Use saved',
|
||||
'keyboard.interactive.useSavedPassword': 'Use saved password',
|
||||
'keyboard.interactive.savePassword': 'Save password',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'SSH Key Passphrase',
|
||||
|
||||
@@ -220,9 +220,10 @@ const zhCN: Messages = {
|
||||
'settings.appearance.themeColor.light': '浅色主题',
|
||||
'settings.appearance.themeColor.dark': '深色主题',
|
||||
'settings.appearance.customCss': '自定义 CSS',
|
||||
'settings.appearance.customCss.desc': '使用自定义 CSS 个性化界面,修改会立即生效。',
|
||||
'settings.appearance.customCss.desc':
|
||||
'使用自定义 CSS 个性化界面,修改会立即生效。主要 UI 区块都暴露了 [data-section="..."] 属性供你定位,比如:snippets-panel、host-details-panel、group-details-panel、serial-host-details-panel、ai-chat-panel、vault-sidebar、vault-main、vault-hosts-header、vault-host-list、vault-view、terminal-workspace、terminal-workspace-sidebar、top-tabs。',
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* 示例:*/\n.terminal { background: #1a1a2e !important; }\n:root { --radius: 0.25rem; }',
|
||||
'/* 示例 — 由于 Tailwind 优先级较高,需要使用 !important */\n\n/* 放大代码片段侧边栏字号 */\n[data-section="snippets-panel"] {\n font-size: 14px !important;\n}\n\n/* 自定义终端背景色 */\n.terminal { background: #1a1a2e !important; }\n\n/* 调整全局圆角 */\n:root { --radius: 0.25rem; }',
|
||||
'settings.appearance.language': '语言',
|
||||
'settings.appearance.language.desc': '选择界面语言',
|
||||
'settings.appearance.uiFont': '界面字体',
|
||||
@@ -349,6 +350,7 @@ const zhCN: Messages = {
|
||||
'vault.hosts.deselectAll': '取消全选',
|
||||
'vault.hosts.deleteSelected': '删除 ({count})',
|
||||
'vault.hosts.deleteMultiple.success': '已删除 {count} 个主机',
|
||||
'vault.hosts.moveToGroup.success': '已将 {host} 移动到 {group}',
|
||||
'vault.hosts.empty.title': '设置你的主机',
|
||||
'vault.hosts.empty.desc': '保存主机以快速连接到你的服务器、虚拟机和容器。',
|
||||
|
||||
@@ -563,6 +565,8 @@ const zhCN: Messages = {
|
||||
'qs.search.placeholder': '搜索主机或标签页',
|
||||
'qs.jumpTo': '跳转到',
|
||||
'qs.localTerminal': '本地终端',
|
||||
'qs.localShells': '本地 Shell',
|
||||
'qs.default': '默认',
|
||||
|
||||
// Select Host panel
|
||||
'selectHost.title': '选择主机',
|
||||
@@ -656,6 +660,8 @@ const zhCN: Messages = {
|
||||
'hostDetails.legacyAlgorithms': '允许旧版算法',
|
||||
'hostDetails.legacyAlgorithms.desc': '启用已弃用的 SSH 算法(diffie-hellman-group1、ssh-dss、3des-cbc 等)以连接老旧网络设备。',
|
||||
'hostDetails.legacyAlgorithms.warning': '这些算法存在已知安全漏洞,仅建议在老旧设备不支持现代加密时启用。',
|
||||
'hostDetails.backspaceBehavior': 'Backspace 行为',
|
||||
'hostDetails.backspaceBehavior.default': '默认',
|
||||
'hostDetails.jumpHosts': '通过主机代理',
|
||||
'hostDetails.jumpHosts.hops': '{count} 跳',
|
||||
'hostDetails.jumpHosts.direct': '直连',
|
||||
@@ -835,6 +841,7 @@ const zhCN: Messages = {
|
||||
'terminal.themeModal.globalTheme': '全局主题',
|
||||
'terminal.themeModal.globalFont': '全局字体',
|
||||
'terminal.themeModal.fontSize': '字体大小',
|
||||
'terminal.themeModal.fontWeight': '字体粗细',
|
||||
'terminal.themeModal.livePreview': '实时预览',
|
||||
'terminal.themeModal.themeType': '{type} 主题',
|
||||
|
||||
@@ -1319,6 +1326,11 @@ const zhCN: Messages = {
|
||||
'settings.terminal.localShell.shell.detected': '检测到',
|
||||
'settings.terminal.localShell.shell.notFound': '未找到 Shell 可执行文件',
|
||||
'settings.terminal.localShell.shell.isDirectory': '路径是目录,不是可执行文件',
|
||||
'settings.terminal.localShell.shell.default': '系统默认',
|
||||
'settings.terminal.localShell.shell.custom': '自定义...',
|
||||
'settings.terminal.localShell.shell.customPath': 'Shell 可执行文件路径',
|
||||
'settings.terminal.localShell.shell.commonPaths': '常用路径',
|
||||
'settings.terminal.localShell.shell.pathValid': '路径有效',
|
||||
'settings.terminal.localShell.startDir': '起始目录',
|
||||
'settings.terminal.localShell.startDir.desc': '打开本地终端时的起始目录。留空使用用户主目录。',
|
||||
'settings.terminal.localShell.startDir.placeholder': '用户主目录',
|
||||
@@ -1337,7 +1349,7 @@ const zhCN: Messages = {
|
||||
// Settings > Terminal > Rendering
|
||||
'settings.terminal.section.rendering': '渲染',
|
||||
'settings.terminal.rendering.renderer': '渲染器',
|
||||
'settings.terminal.rendering.renderer.desc': '选择终端渲染技术。自动模式会在低内存设备上使用 Canvas。更改将在新终端会话中生效。',
|
||||
'settings.terminal.rendering.renderer.desc': '选择终端渲染技术。自动模式会在低内存设备上使用 DOM 渲染。更改将在新终端会话中生效。',
|
||||
'settings.terminal.rendering.auto': '自动',
|
||||
|
||||
// Settings > Terminal > Autocomplete
|
||||
@@ -1640,10 +1652,7 @@ const zhCN: Messages = {
|
||||
'keyboard.interactive.enterResponse': '输入响应',
|
||||
'keyboard.interactive.submit': '提交',
|
||||
'keyboard.interactive.verifying': '验证中...',
|
||||
'keyboard.interactive.fill': '填入',
|
||||
'keyboard.interactive.fillSaved': '填入已保存的密码',
|
||||
'keyboard.interactive.useSaved': '使用已保存',
|
||||
'keyboard.interactive.useSavedPassword': '使用已保存的密码',
|
||||
'keyboard.interactive.savePassword': '保存密码',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'SSH 密钥密码',
|
||||
|
||||
@@ -68,8 +68,14 @@ class FontStore {
|
||||
// Add default fonts first
|
||||
TERMINAL_FONTS.forEach(font => fontMap.set(font.id, font));
|
||||
|
||||
// Add local fonts with a distinct ID namespace to avoid collisions
|
||||
// Build a set of built-in font family names for dedup (case-insensitive)
|
||||
const builtinFamilyNames = new Set(
|
||||
TERMINAL_FONTS.map(f => f.name.toLowerCase())
|
||||
);
|
||||
|
||||
// Add local fonts, skipping those already covered by built-in fonts
|
||||
localFonts.forEach(font => {
|
||||
if (builtinFamilyNames.has(font.name.toLowerCase())) return;
|
||||
const localId = font.id.startsWith('local-') ? font.id : `local-${font.id}`;
|
||||
fontMap.set(localId, { ...font, id: localId });
|
||||
});
|
||||
|
||||
@@ -144,6 +144,7 @@ function applyImmersiveStyle(css: string, isDark: boolean, bg: string) {
|
||||
|
||||
function removeImmersiveStyle() {
|
||||
document.getElementById(STYLE_ID)?.remove();
|
||||
delete document.documentElement.dataset.immersiveTheme;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -174,6 +175,7 @@ export function useImmersiveMode({
|
||||
overrideActiveRef.current = true;
|
||||
appliedFpRef.current = fp;
|
||||
applyImmersiveStyle(getImmersiveCss(activeTerminalTheme), activeTerminalTheme.type === 'dark', activeTerminalTheme.colors.background);
|
||||
document.documentElement.dataset.immersiveTheme = fp;
|
||||
}
|
||||
}, [isTerminalTab, activeTerminalTheme]);
|
||||
|
||||
|
||||
@@ -40,18 +40,26 @@ export const useSessionState = () => {
|
||||
|
||||
const createLocalTerminal = useCallback((options?: {
|
||||
shellType?: TerminalSession['shellType'];
|
||||
shell?: string;
|
||||
shellArgs?: string[];
|
||||
shellName?: string;
|
||||
shellIcon?: string;
|
||||
}) => {
|
||||
const sessionId = crypto.randomUUID();
|
||||
const localHostId = `local-${sessionId}`;
|
||||
const newSession: TerminalSession = {
|
||||
id: sessionId,
|
||||
hostId: localHostId,
|
||||
hostLabel: 'Local Terminal',
|
||||
hostLabel: options?.shellName || 'Local Terminal',
|
||||
hostname: 'localhost',
|
||||
username: 'local',
|
||||
status: 'connecting',
|
||||
protocol: 'local',
|
||||
shellType: options?.shellType,
|
||||
localShell: options?.shell,
|
||||
localShellArgs: options?.shellArgs,
|
||||
localShellName: options?.shellName,
|
||||
localShellIcon: options?.shellIcon,
|
||||
};
|
||||
setSessions(prev => [...prev, newSession]);
|
||||
setActiveTabId(sessionId);
|
||||
@@ -451,6 +459,10 @@ export const useSessionState = () => {
|
||||
moshEnabled: session.moshEnabled,
|
||||
shellType: nextShellType,
|
||||
charset: session.charset,
|
||||
localShell: session.localShell,
|
||||
localShellArgs: session.localShellArgs,
|
||||
localShellName: session.localShellName,
|
||||
localShellIcon: session.localShellIcon,
|
||||
};
|
||||
|
||||
// Add pane to existing workspace
|
||||
@@ -483,6 +495,10 @@ export const useSessionState = () => {
|
||||
moshEnabled: session.moshEnabled,
|
||||
shellType: nextShellType,
|
||||
charset: session.charset,
|
||||
localShell: session.localShell,
|
||||
localShellArgs: session.localShellArgs,
|
||||
localShellName: session.localShellName,
|
||||
localShellIcon: session.localShellIcon,
|
||||
};
|
||||
|
||||
const hint: SplitHint = {
|
||||
@@ -659,6 +675,10 @@ export const useSessionState = () => {
|
||||
shellType: nextShellType,
|
||||
charset: session.charset,
|
||||
serialConfig: session.serialConfig,
|
||||
localShell: session.localShell,
|
||||
localShellArgs: session.localShellArgs,
|
||||
localShellName: session.localShellName,
|
||||
localShellIcon: session.localShellIcon,
|
||||
};
|
||||
|
||||
setActiveTabId(newSession.id);
|
||||
|
||||
@@ -775,7 +775,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background">
|
||||
<div className="flex flex-col h-full bg-background" data-section="ai-chat-panel">
|
||||
{/* ── Header ── */}
|
||||
<div className="px-2.5 py-1.5 flex items-center justify-between border-b border-border/50 shrink-0">
|
||||
<AgentSelector
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
import {
|
||||
AsidePanel,
|
||||
AsidePanelContent,
|
||||
type AsidePanelLayout,
|
||||
} from "./ui/aside-panel";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Button } from "./ui/button";
|
||||
@@ -63,6 +64,7 @@ interface GroupDetailsPanelProps {
|
||||
terminalFontSize: number;
|
||||
onSave: (config: GroupConfig, newName?: string, newParent?: string | null) => void;
|
||||
onCancel: () => void;
|
||||
layout?: AsidePanelLayout;
|
||||
}
|
||||
|
||||
const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
@@ -76,6 +78,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
terminalFontSize,
|
||||
onSave,
|
||||
onCancel,
|
||||
layout = "overlay",
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const availableFonts = useAvailableFonts();
|
||||
@@ -99,7 +102,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
c.protocol === 'ssh' ||
|
||||
c.port !== undefined || !!c.username || !!c.password || !!c.identityFileId ||
|
||||
c.agentForwarding !== undefined || c.authMethod !== undefined || !!c.identityId ||
|
||||
!!c.proxyConfig || !!c.hostChain || !!c.startupCommand || c.legacyAlgorithms !== undefined ||
|
||||
!!c.proxyConfig || !!c.hostChain || !!c.startupCommand || c.legacyAlgorithms !== undefined || c.backspaceBehavior !== undefined ||
|
||||
(c.environmentVariables && c.environmentVariables.length > 0) ||
|
||||
c.moshEnabled !== undefined || !!c.moshServerPath ||
|
||||
(c.identityFilePaths && c.identityFilePaths.length > 0);
|
||||
@@ -149,6 +152,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
delete next.agentForwarding;
|
||||
delete next.startupCommand;
|
||||
delete next.legacyAlgorithms;
|
||||
delete next.backspaceBehavior;
|
||||
delete next.proxyConfig;
|
||||
delete next.hostChain;
|
||||
delete next.environmentVariables;
|
||||
@@ -305,6 +309,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
...(form.agentForwarding !== undefined && { agentForwarding: form.agentForwarding }),
|
||||
...(form.startupCommand !== undefined && { startupCommand: form.startupCommand }),
|
||||
...(form.legacyAlgorithms !== undefined && { legacyAlgorithms: form.legacyAlgorithms }),
|
||||
...(form.backspaceBehavior !== undefined && { backspaceBehavior: form.backspaceBehavior }),
|
||||
...(form.proxyConfig !== undefined && { proxyConfig: form.proxyConfig }),
|
||||
...(form.hostChain !== undefined && { hostChain: form.hostChain }),
|
||||
...(form.environmentVariables !== undefined && { environmentVariables: form.environmentVariables }),
|
||||
@@ -326,6 +331,8 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
...(form.fontFamilyOverride !== undefined && { fontFamilyOverride: form.fontFamilyOverride }),
|
||||
...(form.fontSize !== undefined && { fontSize: form.fontSize }),
|
||||
...(form.fontSizeOverride !== undefined && { fontSizeOverride: form.fontSizeOverride }),
|
||||
...(form.fontWeight !== undefined && { fontWeight: form.fontWeight }),
|
||||
...(form.fontWeightOverride !== undefined && { fontWeightOverride: form.fontWeightOverride }),
|
||||
};
|
||||
|
||||
const nameChanged = trimmedName !== originalName;
|
||||
@@ -347,6 +354,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
onClearProxy={clearProxyConfig}
|
||||
onBack={() => setActiveSubPanel("none")}
|
||||
onCancel={onCancel}
|
||||
layout={layout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -364,6 +372,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
onClearChain={clearHostChain}
|
||||
onBack={() => setActiveSubPanel("none")}
|
||||
onCancel={onCancel}
|
||||
layout={layout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -391,6 +400,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
}}
|
||||
onBack={() => setActiveSubPanel("none")}
|
||||
onCancel={onCancel}
|
||||
layout={layout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -407,6 +417,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
onClose={onCancel}
|
||||
onBack={() => setActiveSubPanel("none")}
|
||||
showBackButton={true}
|
||||
layout={layout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -422,7 +433,9 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
open={true}
|
||||
onClose={onCancel}
|
||||
width="w-[380px]"
|
||||
dataSection="group-details-panel"
|
||||
title={t("vault.groups.details")}
|
||||
layout={layout}
|
||||
actions={
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -799,6 +812,19 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
onToggle={() => update("legacyAlgorithms", !form.legacyAlgorithms)}
|
||||
/>
|
||||
|
||||
{/* Backspace behavior */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">{t("hostDetails.backspaceBehavior")}</p>
|
||||
<select
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
|
||||
value={form.backspaceBehavior ?? ""}
|
||||
onChange={(e) => update("backspaceBehavior", e.target.value || undefined)}
|
||||
>
|
||||
<option value="">{t("hostDetails.backspaceBehavior.default")}</option>
|
||||
<option value="ctrl-h">^H (0x08)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Proxy */}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
AsidePanel,
|
||||
AsidePanelContent,
|
||||
AsidePanelFooter,
|
||||
type AsidePanelLayout,
|
||||
} from "./ui/aside-panel";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
|
||||
@@ -100,6 +101,7 @@ interface HostDetailsPanelProps {
|
||||
onCreateGroup?: (groupPath: string) => void; // Callback to create a new group
|
||||
onCreateTag?: (tag: string) => void; // Callback to create a new tag
|
||||
groupDefaults?: Partial<import('../domain/models').GroupConfig>;
|
||||
layout?: AsidePanelLayout;
|
||||
}
|
||||
|
||||
const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
@@ -118,6 +120,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
onCreateGroup,
|
||||
onCreateTag,
|
||||
groupDefaults,
|
||||
layout = "overlay",
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { checkSshAgent } = useApplicationBackend();
|
||||
@@ -502,6 +505,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
onSave={handleCreateGroup}
|
||||
onBack={() => setActiveSubPanel("none")}
|
||||
onCancel={onCancel}
|
||||
layout={layout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -514,6 +518,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
onClearProxy={clearProxyConfig}
|
||||
onBack={() => setActiveSubPanel("none")}
|
||||
onCancel={onCancel}
|
||||
layout={layout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -531,6 +536,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
onClearChain={clearHostChain}
|
||||
onBack={() => setActiveSubPanel("none")}
|
||||
onCancel={onCancel}
|
||||
layout={layout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -559,6 +565,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
}}
|
||||
onBack={() => setActiveSubPanel("none")}
|
||||
onCancel={onCancel}
|
||||
layout={layout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -576,6 +583,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
onClose={onCancel}
|
||||
onBack={() => setActiveSubPanel("none")}
|
||||
showBackButton={true}
|
||||
layout={layout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -614,6 +622,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
onClose={onCancel}
|
||||
onBack={() => setActiveSubPanel("none")}
|
||||
showBackButton={true}
|
||||
layout={layout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -624,6 +633,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
open={true}
|
||||
onClose={onCancel}
|
||||
width="w-[420px]"
|
||||
layout={layout}
|
||||
dataSection="host-details-panel"
|
||||
title={
|
||||
initialData ? t("hostDetails.title.details") : t("hostDetails.title.new")
|
||||
}
|
||||
@@ -1605,6 +1616,17 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">{t("hostDetails.backspaceBehavior")}</p>
|
||||
<select
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
|
||||
value={form.backspaceBehavior ?? ""}
|
||||
onChange={(e) => update("backspaceBehavior", e.target.value || undefined)}
|
||||
>
|
||||
<option value="">{t("hostDetails.backspaceBehavior.default")}</option>
|
||||
<option value="ctrl-h">^H (0x08)</option>
|
||||
</select>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Proxy via Hosts (Jump Hosts / ProxyJump) */}
|
||||
|
||||
@@ -36,6 +36,8 @@ interface HostTreeViewProps {
|
||||
isMultiSelectMode?: boolean;
|
||||
selectedHostIds?: Set<string>;
|
||||
toggleHostSelection?: (hostId: string) => void;
|
||||
getDropTargetClasses?: (target: string) => string;
|
||||
setDragOverDropTarget?: (target: string | null) => void;
|
||||
}
|
||||
|
||||
interface TreeNodeProps {
|
||||
@@ -61,6 +63,8 @@ interface TreeNodeProps {
|
||||
isMultiSelectMode?: boolean;
|
||||
selectedHostIds?: Set<string>;
|
||||
toggleHostSelection?: (hostId: string) => void;
|
||||
getDropTargetClasses?: (target: string) => string;
|
||||
setDragOverDropTarget?: (target: string | null) => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +91,8 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
isMultiSelectMode,
|
||||
selectedHostIds,
|
||||
toggleHostSelection,
|
||||
getDropTargetClasses,
|
||||
setDragOverDropTarget,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isExpanded = expandedPaths.has(node.path);
|
||||
@@ -140,6 +146,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center py-2 pr-3 text-sm font-medium cursor-pointer transition-colors select-none group hover:bg-secondary/60 rounded-lg",
|
||||
getDropTargetClasses?.(node.path),
|
||||
)}
|
||||
style={{ paddingLeft }}
|
||||
draggable
|
||||
@@ -147,10 +154,19 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverDropTarget?.(node.path);
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
const nextTarget = e.relatedTarget;
|
||||
if (nextTarget instanceof Node && e.currentTarget.contains(nextTarget)) {
|
||||
return;
|
||||
}
|
||||
setDragOverDropTarget?.(null);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverDropTarget?.(null);
|
||||
const hostId = e.dataTransfer.getData("host-id");
|
||||
const groupPath = e.dataTransfer.getData("group-path");
|
||||
if (hostId) moveHostToGroup(hostId, node.path);
|
||||
@@ -242,6 +258,8 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
toggleHostSelection={toggleHostSelection}
|
||||
getDropTargetClasses={getDropTargetClasses}
|
||||
setDragOverDropTarget={setDragOverDropTarget}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -425,9 +443,11 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
isMultiSelectMode,
|
||||
selectedHostIds,
|
||||
toggleHostSelection,
|
||||
getDropTargetClasses,
|
||||
setDragOverDropTarget,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
|
||||
// Use external state if provided, otherwise use local persistent state
|
||||
const localTreeState = useTreeExpandedState(STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED);
|
||||
|
||||
@@ -548,6 +568,8 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
toggleHostSelection={toggleHostSelection}
|
||||
getDropTargetClasses={getDropTargetClasses}
|
||||
setDragOverDropTarget={setDragOverDropTarget}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -578,4 +600,4 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* This modal displays prompts from the SSH server and collects user responses.
|
||||
*/
|
||||
import { Eye, EyeOff, KeyRound, Loader2 } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
@@ -24,6 +24,7 @@ export interface KeyboardInteractivePrompt {
|
||||
|
||||
export interface KeyboardInteractiveRequest {
|
||||
requestId: string;
|
||||
sessionId?: string;
|
||||
name: string;
|
||||
instructions: string;
|
||||
prompts: KeyboardInteractivePrompt[];
|
||||
@@ -31,9 +32,18 @@ export interface KeyboardInteractiveRequest {
|
||||
savedPassword?: string | null;
|
||||
}
|
||||
|
||||
const isAPasswordPrompt = (prompt: KeyboardInteractivePrompt) => {
|
||||
if (prompt.echo) return false;
|
||||
const lower = prompt.prompt.toLowerCase();
|
||||
if (!lower.includes("password")) return false;
|
||||
// Exclude OTP / one-time password / verification code prompts
|
||||
if (lower.includes("one-time") || lower.includes("otp") || lower.includes("verification") || lower.includes("token") || lower.includes("code")) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
interface KeyboardInteractiveModalProps {
|
||||
request: KeyboardInteractiveRequest | null;
|
||||
onSubmit: (requestId: string, responses: string[]) => void;
|
||||
onSubmit: (requestId: string, responses: string[], savePassword?: string) => void;
|
||||
onCancel: (requestId: string) => void;
|
||||
}
|
||||
|
||||
@@ -46,15 +56,28 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
|
||||
const [responses, setResponses] = useState<string[]>([]);
|
||||
const [showPasswords, setShowPasswords] = useState<boolean[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [savePassword, setSavePassword] = useState(false);
|
||||
|
||||
// Index of the first password prompt (if any)
|
||||
const passwordPromptIndex = useMemo(() => {
|
||||
if (!request) return -1;
|
||||
return request.prompts.findIndex(p => isAPasswordPrompt(p));
|
||||
}, [request]);
|
||||
|
||||
// Reset state when request changes
|
||||
useEffect(() => {
|
||||
if (request) {
|
||||
setResponses(request.prompts.map(() => ""));
|
||||
const initial = request.prompts.map(() => "");
|
||||
// Auto-fill saved password into the password prompt
|
||||
if (request.savedPassword && passwordPromptIndex >= 0) {
|
||||
initial[passwordPromptIndex] = request.savedPassword;
|
||||
}
|
||||
setResponses(initial);
|
||||
setShowPasswords(request.prompts.map(() => false));
|
||||
setIsSubmitting(false);
|
||||
setSavePassword(false);
|
||||
}
|
||||
}, [request]);
|
||||
}, [request, passwordPromptIndex]);
|
||||
|
||||
const handleResponseChange = useCallback((index: number, value: string) => {
|
||||
setResponses((prev) => {
|
||||
@@ -75,8 +98,11 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!request || isSubmitting) return;
|
||||
setIsSubmitting(true);
|
||||
onSubmit(request.requestId, responses);
|
||||
}, [request, responses, onSubmit, isSubmitting]);
|
||||
const passwordToSave = savePassword && passwordPromptIndex >= 0
|
||||
? responses[passwordPromptIndex]
|
||||
: undefined;
|
||||
onSubmit(request.requestId, responses, passwordToSave);
|
||||
}, [request, responses, onSubmit, isSubmitting, savePassword, passwordPromptIndex]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (!request) return;
|
||||
@@ -154,19 +180,20 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Use saved password button - shown below input, right-aligned */}
|
||||
{isPassword && request.savedPassword && !responses[index] && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 text-xs text-primary hover:text-primary/80 disabled:opacity-50"
|
||||
onClick={() => handleResponseChange(index, request.savedPassword!)}
|
||||
{/* Save password checkbox - shown only for the first password prompt */}
|
||||
{index === passwordPromptIndex && (
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={savePassword}
|
||||
onChange={(e) => setSavePassword(e.target.checked)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<KeyRound size={12} />
|
||||
<span>{t("keyboard.interactive.useSavedPassword")}</span>
|
||||
</button>
|
||||
</div>
|
||||
className="accent-primary"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("keyboard.interactive.savePassword")}
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
185
components/QuickAddSnippetDialog.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* QuickAddSnippetDialog — lightweight "new snippet" modal mounted at the
|
||||
* App root and triggered by the `netcatty:snippets:add` window event.
|
||||
*
|
||||
* Intentionally minimal: label + command + package only. Advanced fields
|
||||
* (target hosts, shortkey, tags) can be set later via the full Snippets
|
||||
* manager. This keeps the user in their terminal context instead of
|
||||
* navigating to the Vault view just to add a command.
|
||||
*/
|
||||
|
||||
import { Package } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import type { Snippet } from '../domain/models';
|
||||
import { Button } from './ui/button';
|
||||
import { Combobox } from './ui/combobox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Textarea } from './ui/textarea';
|
||||
|
||||
export interface QuickAddSnippetDialogProps {
|
||||
snippets: Snippet[];
|
||||
packages: string[];
|
||||
onCreateSnippet: (snippet: Snippet) => void;
|
||||
onCreatePackage?: (packagePath: string) => void;
|
||||
}
|
||||
|
||||
export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
snippets,
|
||||
packages,
|
||||
onCreateSnippet,
|
||||
onCreatePackage,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [label, setLabel] = useState('');
|
||||
const [command, setCommand] = useState('');
|
||||
const [packagePath, setPackagePath] = useState('');
|
||||
const labelInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Listen for the global "add snippet" request dispatched by the
|
||||
// terminal-side ScriptsSidePanel + button. We reset form state on
|
||||
// every open so stale input from a previous cancel does not leak.
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
setLabel('');
|
||||
setCommand('');
|
||||
setPackagePath('');
|
||||
setOpen(true);
|
||||
};
|
||||
window.addEventListener('netcatty:snippets:add', handler);
|
||||
return () => window.removeEventListener('netcatty:snippets:add', handler);
|
||||
}, []);
|
||||
|
||||
// Auto-focus the label input once the dialog renders, so the user can
|
||||
// start typing immediately after clicking the + button.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const id = window.setTimeout(() => labelInputRef.current?.focus(), 50);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [open]);
|
||||
|
||||
// Derive combobox options from the union of existing packages (from
|
||||
// props) and any package path referenced by an existing snippet, so
|
||||
// the user can reuse anything they see in the main snippets view.
|
||||
const packageOptions = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const p of packages) {
|
||||
if (p) set.add(p);
|
||||
}
|
||||
for (const s of snippets) {
|
||||
if (s.package) set.add(s.package);
|
||||
}
|
||||
return Array.from(set).sort().map((value) => ({ value, label: value }));
|
||||
}, [packages, snippets]);
|
||||
|
||||
const canSave = label.trim().length > 0 && command.trim().length > 0;
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!canSave) return;
|
||||
const trimmedPackage = packagePath.trim();
|
||||
// If the user typed a brand new package name, surface it to the parent
|
||||
// so it can be added to the user's package list alongside the snippet.
|
||||
if (trimmedPackage && !packages.includes(trimmedPackage)) {
|
||||
onCreatePackage?.(trimmedPackage);
|
||||
}
|
||||
onCreateSnippet({
|
||||
id: crypto.randomUUID(),
|
||||
label: label.trim(),
|
||||
command, // preserve whitespace in multi-line commands
|
||||
tags: [],
|
||||
package: trimmedPackage || '',
|
||||
targets: [],
|
||||
});
|
||||
setOpen(false);
|
||||
}, [canSave, packagePath, packages, onCreatePackage, onCreateSnippet, label, command]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
// Cmd/Ctrl+Enter from anywhere in the dialog saves the snippet.
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && canSave) {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
},
|
||||
[canSave, handleSave],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-md" onKeyDown={handleKeyDown}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('snippets.panel.newTitle')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('snippets.empty.desc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="quick-add-snippet-label" className="text-xs">
|
||||
{t('snippets.field.description')}
|
||||
</Label>
|
||||
<Input
|
||||
id="quick-add-snippet-label"
|
||||
ref={labelInputRef}
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder={t('snippets.field.descriptionPlaceholder')}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="quick-add-snippet-command" className="text-xs">
|
||||
{t('snippets.field.scriptRequired')}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="quick-add-snippet-command"
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
placeholder="echo hello"
|
||||
className="min-h-[120px] font-mono text-xs"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs flex items-center gap-1.5">
|
||||
<Package size={12} /> {t('snippets.field.package')}
|
||||
</Label>
|
||||
<Combobox
|
||||
value={packagePath}
|
||||
onValueChange={setPackagePath}
|
||||
options={packageOptions}
|
||||
placeholder={t('snippets.field.packagePlaceholder')}
|
||||
allowCreate
|
||||
onCreateNew={setPackagePath}
|
||||
createText={t('snippets.field.createPackage')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!canSave}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuickAddSnippetDialog;
|
||||
@@ -10,9 +10,10 @@ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { Host, TerminalSession, Workspace } from "../types";
|
||||
import { KeyBinding } from "../domain/models";
|
||||
import { useDiscoveredShells, getShellIconPath, isMonochromeShellIcon } from "../lib/useDiscoveredShells";
|
||||
|
||||
type QuickSwitcherItem = {
|
||||
type: "host" | "tab" | "workspace" | "action";
|
||||
type: "host" | "tab" | "workspace" | "action" | "shell";
|
||||
id: string;
|
||||
data?: Host | TerminalSession | Workspace;
|
||||
};
|
||||
@@ -66,7 +67,7 @@ interface QuickSwitcherProps {
|
||||
onSelect: (host: Host) => void;
|
||||
onSelectTab: (tabId: string) => void;
|
||||
onClose: () => void;
|
||||
onCreateLocalTerminal?: () => void;
|
||||
onCreateLocalTerminal?: (shell?: { command: string; args?: string[]; name?: string; icon?: string }) => void;
|
||||
// onCreateWorkspace removed - feature not currently used
|
||||
keyBindings?: KeyBinding[];
|
||||
}
|
||||
@@ -85,6 +86,18 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
keyBindings,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const discoveredShells = useDiscoveredShells();
|
||||
|
||||
const filteredShells = useMemo(() => {
|
||||
const list = !query.trim()
|
||||
? discoveredShells
|
||||
: discoveredShells.filter(
|
||||
(s) => s.name.toLowerCase().includes(query.toLowerCase()) || s.id.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
// Default shell first
|
||||
return [...list].sort((a, b) => (a.isDefault === b.isDefault ? 0 : a.isDefault ? -1 : 1));
|
||||
}, [discoveredShells, query]);
|
||||
|
||||
// Get hotkey display strings
|
||||
const getHotkeyLabel = useCallback((actionId: string) => {
|
||||
const binding = keyBindings?.find(k => k.id === actionId);
|
||||
@@ -155,13 +168,23 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
workspaces.forEach((w) =>
|
||||
items.push({ type: "workspace", id: w.id, data: w }),
|
||||
);
|
||||
// Quick connect actions
|
||||
items.push({ type: "action", id: "local-terminal" });
|
||||
// Local shells (or fallback action if discovery not ready)
|
||||
if (filteredShells.length > 0) {
|
||||
filteredShells.forEach((shell) =>
|
||||
items.push({ type: "shell", id: shell.id }),
|
||||
);
|
||||
} else {
|
||||
items.push({ type: "action", id: "local-terminal" });
|
||||
}
|
||||
} else {
|
||||
// Recent connections only
|
||||
results.forEach((host) =>
|
||||
items.push({ type: "host", id: host.id, data: host }),
|
||||
);
|
||||
// Also include matching shells in search results
|
||||
filteredShells.forEach((shell) =>
|
||||
items.push({ type: "shell", id: shell.id }),
|
||||
);
|
||||
}
|
||||
|
||||
// Build index map for O(1) lookup
|
||||
@@ -171,7 +194,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
});
|
||||
|
||||
return { flatItems: items, itemIndexMap: indexMap };
|
||||
}, [showCategorized, results, orphanSessions, workspaces]);
|
||||
}, [showCategorized, results, orphanSessions, workspaces, filteredShells]);
|
||||
|
||||
// O(1) index lookup
|
||||
const getItemIndex = useCallback((type: string, id: string) => {
|
||||
@@ -210,6 +233,14 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
onClose();
|
||||
}
|
||||
break;
|
||||
case "shell": {
|
||||
const shell = discoveredShells.find(s => s.id === item.id);
|
||||
if (shell && onCreateLocalTerminal) {
|
||||
onCreateLocalTerminal({ command: shell.command, args: shell.args, name: shell.name, icon: shell.icon });
|
||||
onClose();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -369,21 +400,60 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Quick connect section */}
|
||||
<div>
|
||||
<div className="px-4 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Quick connect
|
||||
</span>
|
||||
{/* Local Shells section */}
|
||||
{/* Local Shells or fallback Local Terminal */}
|
||||
{filteredShells.length > 0 ? (
|
||||
<div>
|
||||
<div className="px-4 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t("qs.localShells")}
|
||||
</span>
|
||||
</div>
|
||||
{filteredShells.map((shell) => {
|
||||
const idx = getItemIndex("shell", shell.id);
|
||||
const isSelected = idx === selectedIndex;
|
||||
return (
|
||||
<div
|
||||
key={shell.id}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${
|
||||
isSelected ? "bg-primary/15" : "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (onCreateLocalTerminal) {
|
||||
onCreateLocalTerminal({ command: shell.command, args: shell.args, name: shell.name, icon: shell.icon });
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
>
|
||||
<img
|
||||
src={getShellIconPath(shell.icon)}
|
||||
alt={shell.name}
|
||||
className={`h-6 w-6 shrink-0${isMonochromeShellIcon(shell.icon) ? " dark:invert" : ""}`}
|
||||
/>
|
||||
<span className="text-sm font-medium">{shell.name}</span>
|
||||
{shell.isDefault && (
|
||||
<span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||
{t("qs.default")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Local Terminal */}
|
||||
{onCreateLocalTerminal && (
|
||||
) : onCreateLocalTerminal && (
|
||||
<div>
|
||||
<div className="px-4 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t("qs.localShells")}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${getItemIndex("action", "local-terminal") === selectedIndex
|
||||
? "bg-primary/15"
|
||||
: "hover:bg-muted/50"
|
||||
}`}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${
|
||||
getItemIndex("action", "local-terminal") === selectedIndex
|
||||
? "bg-primary/15"
|
||||
: "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onCreateLocalTerminal();
|
||||
onClose();
|
||||
@@ -397,10 +467,8 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
</div>
|
||||
<span className="text-sm font-medium">{t("qs.localTerminal")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Serial removed (not supported) */}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* Clicking a snippet executes it in the focused terminal session.
|
||||
*/
|
||||
|
||||
import { ChevronRight, Package, Search, Zap } from 'lucide-react';
|
||||
import { ChevronRight, Package, Plus, Search, Zap } from 'lucide-react';
|
||||
import React, { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { cn } from '../lib/utils';
|
||||
@@ -119,15 +119,25 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
onSnippetClick(command, noAutoRun);
|
||||
}, [onSnippetClick]);
|
||||
|
||||
const handleAddSnippet = useCallback(() => {
|
||||
// Let the App shell listen and navigate to the Snippets section with
|
||||
// the "add" panel pre-opened, so the user does not have to leave the
|
||||
// terminal to jump back and click "New Snippet".
|
||||
window.dispatchEvent(new CustomEvent('netcatty:snippets:add'));
|
||||
}, []);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
const hasAnyContent = snippets.length > 0 || packages.length > 0;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-background overflow-hidden">
|
||||
{/* Search */}
|
||||
<div className="shrink-0 px-2 py-1.5 border-b border-border/50">
|
||||
<div className="relative">
|
||||
<div
|
||||
className="h-full flex flex-col bg-background overflow-hidden"
|
||||
data-section="snippets-panel"
|
||||
>
|
||||
{/* Search + Add */}
|
||||
<div className="shrink-0 px-2 py-1.5 border-b border-border/50 flex items-center gap-1.5">
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
@@ -136,6 +146,15 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
className="h-7 pl-7 text-xs bg-muted/30 border-none"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddSnippet}
|
||||
title={t('snippets.action.newSnippet')}
|
||||
aria-label={t('snippets.action.newSnippet')}
|
||||
className="shrink-0 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb */}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
AsidePanel,
|
||||
AsidePanelContent,
|
||||
AsidePanelFooter,
|
||||
type AsidePanelLayout,
|
||||
} from './ui/aside-panel';
|
||||
|
||||
interface SerialPort {
|
||||
@@ -35,6 +36,7 @@ interface SerialHostDetailsPanelProps {
|
||||
groups?: string[];
|
||||
onSave: (host: Host) => void;
|
||||
onCancel: () => void;
|
||||
layout?: AsidePanelLayout;
|
||||
}
|
||||
|
||||
const BAUD_RATES = [300, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600];
|
||||
@@ -49,6 +51,7 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
|
||||
groups = [],
|
||||
onSave,
|
||||
onCancel,
|
||||
layout = 'overlay',
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const terminalBackend = useTerminalBackend();
|
||||
@@ -164,6 +167,8 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
|
||||
title={t('serial.edit.title')}
|
||||
subtitle={initialData.label}
|
||||
className="z-40"
|
||||
layout={layout}
|
||||
dataSection="serial-host-details-panel"
|
||||
>
|
||||
<AsidePanelContent>
|
||||
{/* Label */}
|
||||
|
||||
@@ -671,6 +671,8 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
handleSaveTextFile={handleSaveTextFile}
|
||||
editorWordWrap={editorWordWrap}
|
||||
setEditorWordWrap={setEditorWordWrap}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
showFileOpenerDialog={showFileOpenerDialog}
|
||||
setShowFileOpenerDialog={setShowFileOpenerDialog}
|
||||
fileOpenerTarget={fileOpenerTarget}
|
||||
@@ -700,6 +702,8 @@ const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps):
|
||||
prev.sftpAutoSync === next.sftpAutoSync &&
|
||||
prev.sftpShowHiddenFiles === next.sftpShowHiddenFiles &&
|
||||
prev.sftpUseCompressedUpload === next.sftpUseCompressedUpload &&
|
||||
prev.hotkeyScheme === next.hotkeyScheme &&
|
||||
prev.keyBindings === next.keyBindings &&
|
||||
prev.editorWordWrap === next.editorWordWrap &&
|
||||
prev.setEditorWordWrap === next.setEditorWordWrap &&
|
||||
prev.onGetTerminalCwd === next.onGetTerminalCwd &&
|
||||
|
||||
@@ -467,6 +467,8 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
handleSaveTextFile={handleSaveTextFile}
|
||||
editorWordWrap={editorWordWrap}
|
||||
setEditorWordWrap={setEditorWordWrap}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
showFileOpenerDialog={showFileOpenerDialog}
|
||||
setShowFileOpenerDialog={setShowFileOpenerDialog}
|
||||
fileOpenerTarget={fileOpenerTarget}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { SortDropdown, SortMode } from './ui/sort-dropdown';
|
||||
import { Textarea } from './ui/textarea';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
|
||||
|
||||
interface SnippetsManagerProps {
|
||||
snippets: Snippet[];
|
||||
@@ -951,8 +952,9 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="h-full flex gap-3 relative">
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="flex-1 flex flex-col min-h-0 min-w-0 overflow-hidden">
|
||||
<header className="border-b border-border/50 bg-secondary/80 backdrop-blur">
|
||||
<div className="h-14 px-4 py-2 flex items-center gap-2">
|
||||
{/* Search box */}
|
||||
@@ -1059,7 +1061,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
<ContextMenuTrigger>
|
||||
<div
|
||||
className={cn(
|
||||
"group cursor-pointer",
|
||||
"group cursor-pointer overflow-hidden",
|
||||
viewMode === 'grid'
|
||||
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
|
||||
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors"
|
||||
@@ -1079,11 +1081,11 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
}}
|
||||
onClick={() => setSelectedPackage(pkg.path)}
|
||||
>
|
||||
<div className="flex items-center gap-3 h-full">
|
||||
<div className="flex items-center gap-3 h-full min-w-0">
|
||||
<div className="h-11 w-11 rounded-xl bg-primary/15 text-primary flex items-center justify-center flex-shrink-0">
|
||||
<Package size={18} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="w-0 flex-1">
|
||||
<div className="text-sm font-semibold truncate">{pkg.name}</div>
|
||||
<div className="text-[11px] text-muted-foreground">{t('snippets.package.count', { count: pkg.count })}</div>
|
||||
</div>
|
||||
@@ -1114,7 +1116,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
<ContextMenuTrigger>
|
||||
<div
|
||||
className={cn(
|
||||
"group cursor-pointer",
|
||||
"group cursor-pointer overflow-hidden",
|
||||
viewMode === 'grid'
|
||||
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
|
||||
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors"
|
||||
@@ -1126,15 +1128,22 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
}}
|
||||
onClick={() => handleEdit(snippet)}
|
||||
>
|
||||
<div className="flex items-center gap-3 h-full">
|
||||
<div className="flex items-center gap-3 h-full min-w-0">
|
||||
<div className="h-11 w-11 rounded-xl bg-primary/15 text-primary flex items-center justify-center flex-shrink-0">
|
||||
<FileCode size={18} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="w-0 flex-1">
|
||||
<div className="text-sm font-semibold truncate">{snippet.label}</div>
|
||||
<div className="text-[11px] text-muted-foreground font-mono leading-4 truncate">
|
||||
{snippet.command.replace(/\s+/g, ' ') || t('snippets.commandFallback')}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="text-[11px] text-muted-foreground font-mono leading-4 truncate">
|
||||
{snippet.command.replace(/\s+/g, ' ') || t('snippets.commandFallback')}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-sm break-all font-mono text-xs">
|
||||
{snippet.command}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{snippet.shortkey && (
|
||||
<div className="shrink-0 px-2 py-1 text-[10px] font-mono rounded border border-border bg-muted/50 text-muted-foreground">
|
||||
@@ -1254,6 +1263,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
{/* Right Panel */}
|
||||
{renderRightPanel()}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ import { TerminalSearchBar } from "./terminal/TerminalSearchBar";
|
||||
import { ZmodemProgressIndicator } from "./terminal/ZmodemProgressIndicator";
|
||||
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
|
||||
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
|
||||
import { createXTermRuntime, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
|
||||
import { createXTermRuntime, primaryFontFamily, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
|
||||
import { XTERM_PERFORMANCE_CONFIG } from "../infrastructure/config/xtermPerformance";
|
||||
import { useTerminalSearch } from "./terminal/hooks/useTerminalSearch";
|
||||
import { useTerminalContextActions } from "./terminal/hooks/useTerminalContextActions";
|
||||
@@ -256,6 +256,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
isVisibleRef.current = isVisible;
|
||||
const pendingOutputScrollRef = useRef(false);
|
||||
const lastFittedSizeRef = useRef<{ width: number; height: number } | null>(null);
|
||||
const fontWeightFixupDoneRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (xtermRuntimeRef.current) {
|
||||
@@ -329,6 +330,23 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const statusRef = useRef<TerminalSession["status"]>(status);
|
||||
statusRef.current = status;
|
||||
|
||||
// Work around xterm.js WebGL renderer bug: glyphs rendered via the constructor
|
||||
// look different from dynamically-set ones. After text appears on screen (status
|
||||
// becomes "connected"), do a fontWeight round-trip to normalize the rendering.
|
||||
useEffect(() => {
|
||||
if (status !== 'connected' || fontWeightFixupDoneRef.current || !termRef.current) return;
|
||||
fontWeightFixupDoneRef.current = true;
|
||||
const timer = setTimeout(() => {
|
||||
if (!termRef.current) return;
|
||||
// Re-read the current weight at fire time to avoid stale closures
|
||||
const w = termRef.current.options.fontWeight;
|
||||
if (w === 'normal' || w === 400) return;
|
||||
termRef.current.options.fontWeight = 'normal';
|
||||
termRef.current.options.fontWeight = w;
|
||||
}, 200);
|
||||
return () => clearTimeout(timer);
|
||||
}, [status]);
|
||||
|
||||
const [chainProgress, setChainProgress] = useState<{
|
||||
currentHop: number;
|
||||
totalHops: number;
|
||||
@@ -576,10 +594,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const customThemes = useCustomThemes();
|
||||
const hasFontSizeOverride = host.fontSizeOverride === true || (host.fontSizeOverride === undefined && host.fontSize != null);
|
||||
const hasFontFamilyOverride = host.fontFamilyOverride === true || (host.fontFamilyOverride === undefined && !!host.fontFamily);
|
||||
const hasFontWeightOverride = host.fontWeightOverride === true || (host.fontWeightOverride === undefined && host.fontWeight != null);
|
||||
const effectiveFontSize = useMemo(
|
||||
() => (hasFontSizeOverride && host.fontSize != null ? host.fontSize : fontSize),
|
||||
[fontSize, hasFontSizeOverride, host.fontSize],
|
||||
);
|
||||
const effectiveFontWeight = useMemo(
|
||||
() => (hasFontWeightOverride && host.fontWeight != null ? host.fontWeight : (terminalSettings?.fontWeight ?? 400)),
|
||||
[terminalSettings?.fontWeight, hasFontWeightOverride, host.fontWeight],
|
||||
);
|
||||
const resolvedFontFamily = useMemo(() => {
|
||||
const hostFontId = hasFontFamilyOverride && host.fontFamily
|
||||
? host.fontFamily
|
||||
@@ -923,6 +946,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
termRef.current.options.theme = {
|
||||
...effectiveTheme.colors,
|
||||
selectionBackground: effectiveTheme.colors.selection,
|
||||
scrollbarSliderBackground: effectiveTheme.colors.foreground + '33',
|
||||
scrollbarSliderHoverBackground: effectiveTheme.colors.foreground + '66',
|
||||
scrollbarSliderActiveBackground: effectiveTheme.colors.foreground + '80',
|
||||
};
|
||||
}
|
||||
}, [effectiveTheme]);
|
||||
@@ -936,7 +962,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
termRef.current.options.cursorStyle = terminalSettings.cursorShape;
|
||||
termRef.current.options.cursorBlink = terminalSettings.cursorBlink;
|
||||
termRef.current.options.scrollback = terminalSettings.scrollback;
|
||||
termRef.current.options.fontWeight = terminalSettings.fontWeight as
|
||||
termRef.current.options.fontWeight = effectiveFontWeight as
|
||||
| 100
|
||||
| 200
|
||||
| 300
|
||||
@@ -951,10 +977,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
if (typeof document === "undefined" || !document.fonts?.check) {
|
||||
return terminalSettings.fontWeightBold;
|
||||
}
|
||||
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${fontFamily}`;
|
||||
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${primaryFontFamily(fontFamily)}`;
|
||||
return document.fonts.check(weightSpec)
|
||||
? terminalSettings.fontWeightBold
|
||||
: terminalSettings.fontWeight;
|
||||
: effectiveFontWeight;
|
||||
})();
|
||||
|
||||
termRef.current.options.fontWeightBold = resolvedFontWeightBold as
|
||||
@@ -989,7 +1015,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
lastFittedSizeRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [effectiveFontSize, resolvedFontFamily, terminalSettings]);
|
||||
}, [effectiveFontSize, effectiveFontWeight, resolvedFontFamily, terminalSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
@@ -1038,10 +1064,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
if (terminalSettings && termRef.current) {
|
||||
const fontFamily = termRef.current.options?.fontFamily || "";
|
||||
if (typeof document !== "undefined" && document.fonts?.check) {
|
||||
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${fontFamily}`;
|
||||
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${primaryFontFamily(fontFamily)}`;
|
||||
const resolvedBold = document.fonts.check(weightSpec)
|
||||
? terminalSettings.fontWeightBold
|
||||
: terminalSettings.fontWeight;
|
||||
: effectiveFontWeight;
|
||||
termRef.current.options.fontWeightBold = resolvedBold as
|
||||
| 100
|
||||
| 200
|
||||
@@ -1072,7 +1098,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [effectiveFontSize, resizeSession, terminalSettings]);
|
||||
}, [effectiveFontSize, effectiveFontWeight, resizeSession, terminalSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible || !containerRef.current || !fitAddonRef.current) return;
|
||||
@@ -1109,10 +1135,16 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible || !fitAddonRef.current) return;
|
||||
const timer = setTimeout(() => {
|
||||
// Fit twice: once after initial layout (100ms) and again after layout settles
|
||||
// (350ms) to handle race conditions during split operations where the container
|
||||
// dimensions may not be final on the first pass.
|
||||
const timer1 = setTimeout(() => {
|
||||
safeFit({ requireVisible: true });
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
const timer2 = setTimeout(() => {
|
||||
safeFit({ force: true, requireVisible: true });
|
||||
}, 350);
|
||||
return () => { clearTimeout(timer1); clearTimeout(timer2); };
|
||||
}, [inWorkspace, isVisible]);
|
||||
|
||||
// When search bar opens/closes, re-fit terminal and maintain scroll position
|
||||
@@ -1398,6 +1430,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const handleRetry = () => {
|
||||
if (!termRef.current) return;
|
||||
cleanupSession();
|
||||
// Reset terminal state: disable mouse tracking modes and clear screen so
|
||||
// stale SGR mouse sequences don't leak into the new session as text input.
|
||||
termRef.current.write('\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l');
|
||||
termRef.current.reset();
|
||||
auth.resetForRetry();
|
||||
terminalDataCapturedRef.current = false;
|
||||
hasRunStartupCommandRef.current = false;
|
||||
@@ -1980,7 +2016,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute inset-x-0 bottom-0"
|
||||
className="xterm-container absolute inset-x-0 bottom-0"
|
||||
style={{
|
||||
top: isSearchOpen ? "64px" : "30px",
|
||||
paddingLeft: 6,
|
||||
|
||||
@@ -14,12 +14,15 @@ import { KeyBinding, TerminalSettings } from '../domain/models';
|
||||
import {
|
||||
clearHostFontFamilyOverride,
|
||||
clearHostFontSizeOverride,
|
||||
clearHostFontWeightOverride,
|
||||
clearHostThemeOverride,
|
||||
hasHostFontFamilyOverride,
|
||||
hasHostFontSizeOverride,
|
||||
hasHostFontWeightOverride,
|
||||
hasHostThemeOverride,
|
||||
resolveHostTerminalFontFamilyId,
|
||||
resolveHostTerminalFontSize,
|
||||
resolveHostTerminalFontWeight,
|
||||
resolveHostTerminalThemeId,
|
||||
} from '../domain/terminalAppearance';
|
||||
import { cn, normalizeLineEndings } from '../lib/utils';
|
||||
@@ -358,6 +361,7 @@ interface TerminalLayerProps {
|
||||
onUpdateTerminalThemeId?: (themeId: string) => void;
|
||||
onUpdateTerminalFontFamilyId?: (fontFamilyId: string) => void;
|
||||
onUpdateTerminalFontSize?: (fontSize: number) => void;
|
||||
onUpdateTerminalFontWeight?: (fontWeight: number) => void;
|
||||
onCloseSession: (sessionId: string, e?: React.MouseEvent) => void;
|
||||
onUpdateSessionStatus: (sessionId: string, status: TerminalSession['status']) => void;
|
||||
onUpdateHostDistro: (hostId: string, distro: string) => void;
|
||||
@@ -412,6 +416,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onUpdateTerminalThemeId,
|
||||
onUpdateTerminalFontFamilyId,
|
||||
onUpdateTerminalFontSize,
|
||||
onUpdateTerminalFontWeight,
|
||||
onCloseSession,
|
||||
onUpdateSessionStatus,
|
||||
onUpdateHostDistro,
|
||||
@@ -813,6 +818,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
protocol: session.protocol ?? 'local' as const,
|
||||
moshEnabled: session.moshEnabled,
|
||||
charset: session.charset,
|
||||
localShell: session.localShell,
|
||||
localShellArgs: session.localShellArgs,
|
||||
localShellName: session.localShellName,
|
||||
localShellIcon: session.localShellIcon,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1370,6 +1379,13 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const isFocusedHostLocal = useMemo(() => {
|
||||
return focusedHost?.protocol === 'local' || !!focusedHost?.id?.startsWith('local-');
|
||||
}, [focusedHost]);
|
||||
// Hosts not in the persisted hostMap (e.g. quick-connect) are ephemeral —
|
||||
// sidebar appearance changes should update global settings, not per-host overrides.
|
||||
const isFocusedHostEphemeral = useMemo(() => {
|
||||
if (isFocusedHostLocal) return true;
|
||||
if (!focusedHost) return true;
|
||||
return !hostMap.has(focusedHost.id);
|
||||
}, [focusedHost, isFocusedHostLocal, hostMap]);
|
||||
const previewTargetSessionId = activeWorkspace?.focusedSessionId ?? activeSession?.id ?? null;
|
||||
const activeThemePreviewId = themePreview.targetSessionId === previewTargetSessionId
|
||||
? themePreview.themeId
|
||||
@@ -1382,6 +1398,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const focusedThemeOverridden = hasHostThemeOverride(focusedHost);
|
||||
const focusedFontFamilyOverridden = hasHostFontFamilyOverride(focusedHost);
|
||||
const focusedFontSizeOverridden = hasHostFontSizeOverride(focusedHost);
|
||||
const focusedFontWeight = resolveHostTerminalFontWeight(focusedHost, terminalSettings?.fontWeight ?? 400);
|
||||
const focusedFontWeightOverridden = hasHostFontWeightOverride(focusedHost);
|
||||
const activeTopTabsThemeId = activeSidePanelTab === 'theme' && previewTargetSessionId
|
||||
? (activeThemePreviewId ?? focusedThemeId)
|
||||
: (isVisible ? focusedThemeId : null);
|
||||
@@ -1514,14 +1532,14 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
}
|
||||
themeCommitTimerRef.current = setTimeout(() => {
|
||||
startTransition(() => {
|
||||
if (isFocusedHostLocal) {
|
||||
if (isFocusedHostEphemeral) {
|
||||
onUpdateTerminalThemeId?.(themeId);
|
||||
return;
|
||||
}
|
||||
onUpdateHost({ ...focusedHost, theme: themeId, themeOverride: true });
|
||||
});
|
||||
}, 160);
|
||||
}, [applyTerminalPreviewVars, applyTopTabsPreviewVars, focusedHost, focusedThemeId, isFocusedHostLocal, onUpdateTerminalThemeId, onUpdateHost, previewTargetSessionId]);
|
||||
}, [applyTerminalPreviewVars, applyTopTabsPreviewVars, focusedHost, focusedThemeId, isFocusedHostEphemeral, onUpdateTerminalThemeId, onUpdateHost, previewTargetSessionId]);
|
||||
|
||||
const handleThemeResetForFocusedSession = useCallback(() => {
|
||||
if (themeCommitTimerRef.current) {
|
||||
@@ -1529,41 +1547,64 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
}
|
||||
clearTerminalPreviewVars(previewTargetSessionId);
|
||||
setThemePreview({ targetSessionId: null, themeId: null });
|
||||
if (!focusedHost || isFocusedHostLocal) return;
|
||||
if (!focusedHost || isFocusedHostEphemeral) return;
|
||||
onUpdateHost(clearHostThemeOverride(focusedHost));
|
||||
}, [focusedHost, isFocusedHostLocal, onUpdateHost, previewTargetSessionId]);
|
||||
}, [focusedHost, isFocusedHostEphemeral, onUpdateHost, previewTargetSessionId]);
|
||||
|
||||
const handleFontFamilyChangeForFocusedSession = useCallback((fontFamilyId: string) => {
|
||||
if (!focusedHost || fontFamilyId === focusedFontFamilyId) return;
|
||||
startTransition(() => {
|
||||
if (isFocusedHostLocal) {
|
||||
if (isFocusedHostEphemeral) {
|
||||
onUpdateTerminalFontFamilyId?.(fontFamilyId);
|
||||
return;
|
||||
}
|
||||
onUpdateHost({ ...focusedHost, fontFamily: fontFamilyId, fontFamilyOverride: true });
|
||||
});
|
||||
}, [focusedHost, focusedFontFamilyId, isFocusedHostLocal, onUpdateTerminalFontFamilyId, onUpdateHost]);
|
||||
}, [focusedHost, focusedFontFamilyId, isFocusedHostEphemeral, onUpdateTerminalFontFamilyId, onUpdateHost]);
|
||||
|
||||
const handleFontFamilyResetForFocusedSession = useCallback(() => {
|
||||
if (!focusedHost || isFocusedHostLocal) return;
|
||||
if (!focusedHost || isFocusedHostEphemeral) return;
|
||||
onUpdateHost(clearHostFontFamilyOverride(focusedHost));
|
||||
}, [focusedHost, isFocusedHostLocal, onUpdateHost]);
|
||||
}, [focusedHost, isFocusedHostEphemeral, onUpdateHost]);
|
||||
|
||||
const handleFontSizeChangeForFocusedSession = useCallback((newFontSize: number) => {
|
||||
if (!focusedHost || newFontSize === focusedFontSize) return;
|
||||
startTransition(() => {
|
||||
if (isFocusedHostLocal) {
|
||||
if (isFocusedHostEphemeral) {
|
||||
onUpdateTerminalFontSize?.(newFontSize);
|
||||
return;
|
||||
}
|
||||
onUpdateHost({ ...focusedHost, fontSize: newFontSize, fontSizeOverride: true });
|
||||
});
|
||||
}, [focusedHost, focusedFontSize, isFocusedHostLocal, onUpdateTerminalFontSize, onUpdateHost]);
|
||||
}, [focusedHost, focusedFontSize, isFocusedHostEphemeral, onUpdateTerminalFontSize, onUpdateHost]);
|
||||
|
||||
const handleFontSizeResetForFocusedSession = useCallback(() => {
|
||||
if (!focusedHost || isFocusedHostLocal) return;
|
||||
if (!focusedHost || isFocusedHostEphemeral) return;
|
||||
onUpdateHost(clearHostFontSizeOverride(focusedHost));
|
||||
}, [focusedHost, isFocusedHostLocal, onUpdateHost]);
|
||||
}, [focusedHost, isFocusedHostEphemeral, onUpdateHost]);
|
||||
|
||||
const handleFontWeightChangeForFocusedSession = useCallback((newFontWeight: number) => {
|
||||
if (!focusedHost || newFontWeight === focusedFontWeight) return;
|
||||
startTransition(() => {
|
||||
if (isFocusedHostEphemeral) {
|
||||
onUpdateTerminalFontWeight?.(newFontWeight);
|
||||
return;
|
||||
}
|
||||
// Prefer raw (un-merged) host to avoid flattening group defaults
|
||||
const rawHost = hostMap.get(focusedHost.id);
|
||||
if (rawHost) {
|
||||
onUpdateHost({ ...rawHost, fontWeight: newFontWeight, fontWeightOverride: true });
|
||||
}
|
||||
});
|
||||
}, [focusedHost, focusedFontWeight, isFocusedHostEphemeral, onUpdateTerminalFontWeight, onUpdateHost, hostMap]);
|
||||
|
||||
const handleFontWeightResetForFocusedSession = useCallback(() => {
|
||||
if (!focusedHost || isFocusedHostEphemeral) return;
|
||||
const rawHost = hostMap.get(focusedHost.id);
|
||||
if (rawHost) {
|
||||
onUpdateHost(clearHostFontWeightOverride(rawHost));
|
||||
}
|
||||
}, [focusedHost, isFocusedHostEphemeral, onUpdateHost, hostMap]);
|
||||
|
||||
// Keep MCP/ACP approval IPC listener alive for the entire terminal lifecycle.
|
||||
// Must live here (TerminalLayer), not inside the AI panel subtree, so closing
|
||||
@@ -1778,7 +1819,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
if (!activeWorkspace || !isFocusMode) return null;
|
||||
|
||||
return (
|
||||
<div className="w-56 flex-shrink-0 bg-secondary/50 border-r border-border/50 flex flex-col">
|
||||
<div
|
||||
className="w-56 flex-shrink-0 bg-secondary/50 border-r border-border/50 flex flex-col"
|
||||
data-section="terminal-workspace-sidebar"
|
||||
>
|
||||
{/* Header with view toggle */}
|
||||
<div className="h-10 flex items-center justify-between px-3 border-b border-border/50">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
@@ -1849,6 +1893,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
<div
|
||||
ref={workspaceOuterRef}
|
||||
className="absolute inset-0 bg-background flex flex-col"
|
||||
data-section="terminal-workspace"
|
||||
style={{
|
||||
visibility: isTerminalLayerVisible ? 'visible' : 'hidden',
|
||||
pointerEvents: isTerminalLayerVisible ? 'auto' : 'none',
|
||||
@@ -2035,15 +2080,19 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
currentFontFamilyId={focusedFontFamilyId}
|
||||
globalFontFamilyId={terminalFontFamilyId}
|
||||
currentFontSize={focusedFontSize}
|
||||
currentFontWeight={focusedFontWeight}
|
||||
canResetTheme={focusedThemeOverridden}
|
||||
canResetFontFamily={focusedFontFamilyOverridden}
|
||||
canResetFontSize={focusedFontSizeOverridden}
|
||||
canResetFontWeight={focusedFontWeightOverridden}
|
||||
onThemeChange={handleThemeChangeForFocusedSession}
|
||||
onThemeReset={handleThemeResetForFocusedSession}
|
||||
onFontFamilyChange={handleFontFamilyChangeForFocusedSession}
|
||||
onFontFamilyReset={handleFontFamilyResetForFocusedSession}
|
||||
onFontSizeChange={handleFontSizeChangeForFocusedSession}
|
||||
onFontSizeReset={handleFontSizeResetForFocusedSession}
|
||||
onFontWeightChange={handleFontWeightChangeForFocusedSession}
|
||||
onFontWeightReset={handleFontWeightResetForFocusedSession}
|
||||
previewColors={resolvedPreviewTheme.colors}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@ loader.config({ paths: { vs: monacoBasePath } });
|
||||
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useClipboardBackend } from '../application/state/useClipboardBackend';
|
||||
import { HotkeyScheme, KeyBinding, matchesKeyBinding } from '../domain/models';
|
||||
import { getLanguageId, getLanguageName, getSupportedLanguages } from '../lib/sftpFileUtils';
|
||||
import { Button } from './ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
@@ -34,6 +35,8 @@ interface TextEditorModalProps {
|
||||
onSave: (content: string) => Promise<void>;
|
||||
editorWordWrap: boolean;
|
||||
onToggleWordWrap: () => void;
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
keyBindings: KeyBinding[];
|
||||
}
|
||||
|
||||
// Map our language IDs to Monaco language IDs
|
||||
@@ -122,12 +125,38 @@ const hslToHex = (hslString: string): string => {
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||
};
|
||||
|
||||
// Get background color from CSS variable
|
||||
const getBackgroundColor = (): string => {
|
||||
const bgValue = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--background')
|
||||
// Read a CSS custom-property and convert from HSL to hex
|
||||
const getCssColor = (varName: string, fallback: string): string => {
|
||||
const value = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(varName)
|
||||
.trim();
|
||||
return bgValue ? hslToHex(bgValue) : '#1e1e1e';
|
||||
return value ? hslToHex(value) : fallback;
|
||||
};
|
||||
|
||||
interface EditorColors {
|
||||
bg: string;
|
||||
fg: string;
|
||||
primary: string;
|
||||
card: string;
|
||||
mutedFg: string;
|
||||
border: string;
|
||||
}
|
||||
|
||||
/** Read all UI CSS variables that matter for the Monaco theme. */
|
||||
const getEditorColors = (isDark: boolean): EditorColors => ({
|
||||
bg: getCssColor('--background', isDark ? '#1e1e1e' : '#ffffff'),
|
||||
fg: getCssColor('--foreground', isDark ? '#d4d4d4' : '#1e1e1e'),
|
||||
primary: getCssColor('--primary', isDark ? '#569cd6' : '#0078d4'),
|
||||
card: getCssColor('--card', isDark ? '#252526' : '#f3f3f3'),
|
||||
mutedFg: getCssColor('--muted-foreground', isDark ? '#858585' : '#858585'),
|
||||
border: getCssColor('--border', isDark ? '#3c3c3c' : '#d4d4d4'),
|
||||
});
|
||||
|
||||
/** Build a fingerprint string so we can detect immersive-mode color changes cheaply. */
|
||||
const getThemeSignal = (): string => {
|
||||
const root = document.documentElement;
|
||||
return root.dataset.immersiveTheme
|
||||
?? getComputedStyle(root).getPropertyValue('--background').trim();
|
||||
};
|
||||
|
||||
export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
@@ -138,6 +167,8 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
onSave,
|
||||
editorWordWrap,
|
||||
onToggleWordWrap,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { readClipboardText: readClipboardTextFromBridge } = useClipboardBackend();
|
||||
@@ -158,49 +189,64 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
document.documentElement.classList.contains('dark')
|
||||
);
|
||||
|
||||
// Track background color for custom theme
|
||||
const [bgColor, setBgColor] = useState(() => getBackgroundColor());
|
||||
// Track a signal that changes whenever immersive-mode or base theme colors change
|
||||
const [themeSignal, setThemeSignal] = useState(() => getThemeSignal());
|
||||
|
||||
// Custom theme name
|
||||
const customThemeName = isDarkTheme ? 'netcatty-dark' : 'netcatty-light';
|
||||
|
||||
// Define and update custom Monaco themes based on UI background color
|
||||
// Define and update custom Monaco themes — syncs with immersive-mode / base UI colors
|
||||
useEffect(() => {
|
||||
if (!monaco) return;
|
||||
|
||||
// Define dark theme with custom background
|
||||
const colors = getEditorColors(isDarkTheme);
|
||||
|
||||
const themeColors: Record<string, string> = {
|
||||
'editor.background': colors.bg,
|
||||
'editor.foreground': colors.fg,
|
||||
'editorCursor.foreground': colors.primary,
|
||||
'editor.selectionBackground': colors.primary + '40',
|
||||
'editor.inactiveSelectionBackground': colors.primary + '25',
|
||||
'editorLineNumber.foreground': colors.mutedFg,
|
||||
'editorLineNumber.activeForeground': colors.fg,
|
||||
'editor.lineHighlightBackground': colors.fg + '08',
|
||||
'editorWidget.background': colors.card,
|
||||
'editorWidget.foreground': colors.fg,
|
||||
'editorWidget.border': colors.border,
|
||||
'input.background': colors.card,
|
||||
'input.foreground': colors.fg,
|
||||
'input.border': colors.border,
|
||||
};
|
||||
|
||||
monaco.editor.defineTheme('netcatty-dark', {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: {
|
||||
'editor.background': bgColor,
|
||||
},
|
||||
colors: themeColors,
|
||||
});
|
||||
|
||||
// Define light theme with custom background
|
||||
monaco.editor.defineTheme('netcatty-light', {
|
||||
base: 'vs',
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: {
|
||||
'editor.background': bgColor,
|
||||
},
|
||||
colors: themeColors,
|
||||
});
|
||||
|
||||
// Apply the current theme
|
||||
monaco.editor.setTheme(customThemeName);
|
||||
}, [monaco, isDarkTheme, bgColor, customThemeName]);
|
||||
}, [monaco, isDarkTheme, themeSignal, customThemeName]);
|
||||
|
||||
// Listen for theme changes via MutationObserver on <html> class and style
|
||||
// Listen for theme changes via MutationObserver on <html> class, style, and immersive data attr
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
const updateTheme = () => {
|
||||
setIsDarkTheme(root.classList.contains('dark'));
|
||||
setBgColor(getBackgroundColor());
|
||||
setThemeSignal(getThemeSignal());
|
||||
};
|
||||
const observer = new MutationObserver(updateTheme);
|
||||
observer.observe(root, { attributes: true, attributeFilter: ['class', 'style'] });
|
||||
observer.observe(root, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'style', 'data-immersive-theme'],
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
@@ -216,6 +262,11 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
setHasChanges(content !== initialContent);
|
||||
}, [content, initialContent]);
|
||||
|
||||
const closeTabBinding = useMemo(
|
||||
() => keyBindings.find((binding) => binding.action === 'closeTab'),
|
||||
[keyBindings],
|
||||
);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
@@ -347,8 +398,33 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
}
|
||||
void handlePasteRef.current();
|
||||
});
|
||||
|
||||
editor.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
editorRef.current?.focus();
|
||||
});
|
||||
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [open]);
|
||||
|
||||
const handleDialogKeyDownCapture = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (hotkeyScheme === 'disabled' || !closeTabBinding) return;
|
||||
|
||||
const isMac = hotkeyScheme === 'mac';
|
||||
const keyStr = isMac ? closeTabBinding.mac : closeTabBinding.pc;
|
||||
if (!matchesKeyBinding(e.nativeEvent, keyStr, isMac)) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.nativeEvent.stopPropagation();
|
||||
handleClose();
|
||||
}, [closeTabBinding, handleClose, hotkeyScheme]);
|
||||
|
||||
// Trigger search dialog
|
||||
const handleSearch = useCallback(() => {
|
||||
if (editorRef.current) {
|
||||
@@ -370,7 +446,12 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
|
||||
<DialogContent className="max-w-5xl h-[85vh] flex flex-col p-0 gap-0" hideCloseButton>
|
||||
<DialogContent
|
||||
className="max-w-5xl h-[85vh] flex flex-col p-0 gap-0"
|
||||
hideCloseButton
|
||||
data-hotkey-close-tab="true"
|
||||
onKeyDownCapture={handleDialogKeyDownCapture}
|
||||
>
|
||||
{/* Header */}
|
||||
<DialogHeader className="px-4 py-3 border-b border-border/60 flex-shrink-0">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import {
|
||||
AsidePanel,
|
||||
AsidePanelContent,
|
||||
type AsidePanelLayout,
|
||||
} from './ui/aside-panel';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { ThemeList } from './ThemeList';
|
||||
@@ -13,6 +14,7 @@ interface ThemeSelectPanelProps {
|
||||
onClose: () => void;
|
||||
onBack?: () => void;
|
||||
showBackButton?: boolean;
|
||||
layout?: AsidePanelLayout;
|
||||
}
|
||||
|
||||
const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
|
||||
@@ -22,6 +24,7 @@ const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
|
||||
onClose,
|
||||
onBack,
|
||||
showBackButton = true,
|
||||
layout = 'overlay',
|
||||
}) => {
|
||||
return (
|
||||
<AsidePanel
|
||||
@@ -30,6 +33,7 @@ const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
|
||||
title="Select Color Theme"
|
||||
showBackButton={showBackButton}
|
||||
onBack={onBack}
|
||||
layout={layout}
|
||||
>
|
||||
<AsidePanelContent className="p-0">
|
||||
<ScrollArea className="h-full">
|
||||
|
||||
@@ -10,6 +10,7 @@ import { getEffectiveHostDistro } from '../domain/host';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Host, TerminalSession, Workspace } from '../types';
|
||||
import { DISTRO_LOGOS, DISTRO_COLORS } from './DistroAvatar';
|
||||
import { getShellIconPath, isMonochromeShellIcon } from '../lib/useDiscoveredShells';
|
||||
import { Button } from './ui/button';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from './ui/context-menu';
|
||||
import { SyncStatusButton } from './SyncStatusButton';
|
||||
@@ -54,7 +55,7 @@ const localOsId = (() => {
|
||||
})();
|
||||
|
||||
// Lightweight OS/distro icon for session tabs — matches DistroAvatar "sm" style
|
||||
const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; protocol?: string }> = memo(({ host, isActive, protocol }) => {
|
||||
const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; protocol?: string; shellIcon?: string }> = memo(({ host, isActive, protocol, shellIcon }) => {
|
||||
const boxBase = "shrink-0 h-4 w-4 rounded flex items-center justify-center";
|
||||
const iconSize = "h-2.5 w-2.5";
|
||||
const fallbackStyle = { color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' };
|
||||
@@ -68,8 +69,19 @@ const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; prot
|
||||
);
|
||||
}
|
||||
|
||||
// Local protocol → OS-specific icon (protocol may be undefined for local sessions)
|
||||
// Local protocol → shell-specific icon if available, else OS-specific icon
|
||||
if (protocol === 'local' || host?.protocol === 'local' || (!protocol && !host)) {
|
||||
// Use shell icon from discovery when available
|
||||
const iconId = shellIcon || host?.localShellIcon;
|
||||
if (iconId) {
|
||||
return (
|
||||
<img
|
||||
src={getShellIconPath(iconId)}
|
||||
alt={iconId}
|
||||
className={cn("shrink-0 h-4 w-4 object-contain", isMonochromeShellIcon(iconId) && "dark:invert")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const logo = DISTRO_LOGOS[localOsId];
|
||||
const bg = DISTRO_COLORS[localOsId] || DISTRO_COLORS.default;
|
||||
if (logo) {
|
||||
@@ -540,7 +552,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<SessionTabIcon host={hostMap.get(session.hostId)} isActive={activeTabId === session.id} protocol={session.protocol} />
|
||||
<SessionTabIcon host={hostMap.get(session.hostId)} isActive={activeTabId === session.id} protocol={session.protocol} shellIcon={session.localShellIcon} />
|
||||
<span className="truncate">{session.hostLabel}</span>
|
||||
<div className="flex-shrink-0">{sessionStatusDot(session.status, hasActivity)}</div>
|
||||
</div>
|
||||
@@ -753,6 +765,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
return (
|
||||
<div
|
||||
data-top-tabs-root
|
||||
data-section="top-tabs"
|
||||
className="relative w-full bg-secondary app-drag"
|
||||
style={{
|
||||
...dragRegionNoSelect,
|
||||
|
||||
@@ -101,6 +101,10 @@ const LazyConnectionLogsManager = lazy(() => import("./ConnectionLogsManager"));
|
||||
|
||||
export type VaultSection = "hosts" | "keys" | "snippets" | "port" | "knownhosts" | "logs";
|
||||
|
||||
type DropTarget =
|
||||
| { kind: "root" }
|
||||
| { kind: "group"; path: string };
|
||||
|
||||
// Props without isActive - it's now subscribed internally
|
||||
interface VaultViewProps {
|
||||
hosts: Host[];
|
||||
@@ -222,7 +226,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
false,
|
||||
);
|
||||
|
||||
const [isBreadcrumbDragOver, setIsBreadcrumbDragOver] = useState(false);
|
||||
const [dragOverDropTarget, setDragOverDropTarget] = useState<DropTarget | null>(null);
|
||||
const [confirmedDropTarget, setConfirmedDropTarget] = useState<DropTarget | null>(null);
|
||||
const dropTargetPulseTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
const [showRecentHosts, _setShowRecentHosts] = useStoredBoolean(
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
@@ -237,6 +243,14 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
}
|
||||
}, [navigateToSection, onNavigateToSectionHandled]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (dropTargetPulseTimeoutRef.current !== null) {
|
||||
window.clearTimeout(dropTargetPulseTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// View mode, sorting, and tag filter state
|
||||
const [viewMode, setViewMode] = useStoredViewMode(
|
||||
STORAGE_KEY_VAULT_HOSTS_VIEW_MODE,
|
||||
@@ -253,6 +267,21 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
const [editingHost, setEditingHost] = useState<Host | null>(null);
|
||||
const [newHostGroupPath, setNewHostGroupPath] = useState<string | null>(null);
|
||||
|
||||
// Close host panel if the host being edited was deleted.
|
||||
// Track previous host IDs so we only close for actual deletions, not for
|
||||
// unsaved new/duplicated hosts whose IDs were never in the hosts array.
|
||||
const knownHostIdsRef = useRef(new Set(hosts.map(h => h.id)));
|
||||
useEffect(() => {
|
||||
const currentIds = new Set(hosts.map(h => h.id));
|
||||
// Check against previous IDs before updating the ref
|
||||
if (editingHost && knownHostIdsRef.current.has(editingHost.id) && !currentIds.has(editingHost.id)) {
|
||||
setIsHostPanelOpen(false);
|
||||
setEditingHost(null);
|
||||
setNewHostGroupPath(null);
|
||||
}
|
||||
knownHostIdsRef.current = currentIds;
|
||||
}, [hosts, editingHost]);
|
||||
|
||||
// Group panel state
|
||||
const [isGroupPanelOpen, setIsGroupPanelOpen] = useState(false);
|
||||
const [editingGroupPath, setEditingGroupPath] = useState<string | null>(null);
|
||||
@@ -928,19 +957,12 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
}
|
||||
return filtered
|
||||
.sort((a, b) => (b.lastConnectedAt || 0) - (a.lastConnectedAt || 0))
|
||||
.slice(0, 20);
|
||||
.slice(0, 6);
|
||||
}, [hosts, selectedGroupPath, search, selectedTags]);
|
||||
|
||||
// IDs of hosts already shown in Pinned/Recent sections at root level,
|
||||
// so the main host list can exclude them to avoid duplicates.
|
||||
const pinnedRecentIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const h of pinnedHosts) ids.add(h.id);
|
||||
if (showRecentHosts) {
|
||||
for (const h of recentHosts) ids.add(h.id);
|
||||
}
|
||||
return ids;
|
||||
}, [pinnedHosts, recentHosts, showRecentHosts]);
|
||||
// No longer deduplicate pinned/recent hosts from the main list,
|
||||
// so hosts always appear in their groups regardless of pinned/recent status.
|
||||
const pinnedRecentIds = useMemo(() => new Set<string>(), []);
|
||||
|
||||
// For tree view: apply search, tag filter, and sorting, but not group filtering
|
||||
const treeViewHosts = useMemo(() => {
|
||||
@@ -1421,9 +1443,46 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
}, [managedSources]);
|
||||
|
||||
const isHostsSectionActive = currentSection === "hosts";
|
||||
const hasHostsSidePanel =
|
||||
isHostsSectionActive &&
|
||||
((isGroupPanelOpen && !!editingGroupPath) || isHostPanelOpen);
|
||||
const splitViewGridStyle = hasHostsSidePanel
|
||||
? {
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(min(100%, 220px), 280px))",
|
||||
justifyContent: "start" as const,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const moveHostToGroup = (hostId: string, groupPath: string | null) => {
|
||||
const isSameDropTarget = useCallback((a: DropTarget | null, b: DropTarget | null) => {
|
||||
if (!a || !b) return a === b;
|
||||
if (a.kind !== b.kind) return false;
|
||||
if (a.kind === "root") return true;
|
||||
return a.path === b.path;
|
||||
}, []);
|
||||
|
||||
const pulseDropTarget = useCallback((target: DropTarget) => {
|
||||
setConfirmedDropTarget(target);
|
||||
if (dropTargetPulseTimeoutRef.current !== null) {
|
||||
window.clearTimeout(dropTargetPulseTimeoutRef.current);
|
||||
}
|
||||
dropTargetPulseTimeoutRef.current = window.setTimeout(() => {
|
||||
setConfirmedDropTarget((current) => (isSameDropTarget(current, target) ? null : current));
|
||||
dropTargetPulseTimeoutRef.current = null;
|
||||
}, 900);
|
||||
}, [isSameDropTarget]);
|
||||
|
||||
const setGroupDragOverDropTarget = useCallback((path: string | null) => {
|
||||
setDragOverDropTarget(path ? { kind: "group", path } : null);
|
||||
}, []);
|
||||
|
||||
const moveHostToGroup = useCallback((hostId: string, groupPath: string | null) => {
|
||||
const targetGroup = groupPath || "";
|
||||
const hostToMove = hosts.find((h) => h.id === hostId);
|
||||
if (!hostToMove || (hostToMove.group || "") === targetGroup) {
|
||||
setDragOverDropTarget(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the most specific (deepest) managed source that matches the target group
|
||||
const targetManagedSource = managedSources
|
||||
.filter(s => targetGroup === s.groupName || targetGroup.startsWith(s.groupName + "/"))
|
||||
@@ -1450,7 +1509,23 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
};
|
||||
}),
|
||||
);
|
||||
};
|
||||
setDragOverDropTarget(null);
|
||||
pulseDropTarget(groupPath ? { kind: "group", path: groupPath } : { kind: "root" });
|
||||
toast.success(
|
||||
t("vault.hosts.moveToGroup.success", {
|
||||
host: hostToMove.label,
|
||||
group: groupPath || t("vault.hosts.allHosts"),
|
||||
}),
|
||||
);
|
||||
}, [hosts, managedSources, onUpdateHosts, pulseDropTarget, t]);
|
||||
|
||||
const getDropTargetClasses = (target: DropTarget) =>
|
||||
cn(
|
||||
isSameDropTarget(dragOverDropTarget, target) &&
|
||||
"!bg-[#e7ebf0] dark:!bg-white/[0.10]",
|
||||
isSameDropTarget(confirmedDropTarget, target) &&
|
||||
"!bg-[#dde3ea] dark:!bg-white/[0.14]",
|
||||
);
|
||||
|
||||
const handleUnmanageGroup = useCallback((groupPath: string) => {
|
||||
const source = managedSources.find(s => s.groupName === groupPath);
|
||||
@@ -1479,13 +1554,16 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
|
||||
// Component no longer handles visibility - that's done by VaultViewWrapper
|
||||
return (
|
||||
<div ref={rootRef} className="absolute inset-0 min-h-0 flex">
|
||||
<div ref={rootRef} className="absolute inset-0 min-h-0 flex" data-section="vault-view">
|
||||
{/* Sidebar */}
|
||||
<TooltipProvider delayDuration={100}>
|
||||
<div className={cn(
|
||||
"bg-secondary/80 border-r border-border/60 flex flex-col transition-all duration-200",
|
||||
sidebarCollapsed ? "w-14" : "w-52"
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-secondary/80 border-r border-border/60 flex flex-col transition-all duration-200",
|
||||
sidebarCollapsed ? "w-14" : "w-52"
|
||||
)}
|
||||
data-section="vault-sidebar"
|
||||
>
|
||||
<div className={cn(
|
||||
"py-4 flex items-center",
|
||||
sidebarCollapsed ? "px-2 justify-center" : "px-4"
|
||||
@@ -1648,12 +1726,16 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
</TooltipProvider>
|
||||
|
||||
{/* Main Area */}
|
||||
<div className="flex-1 flex flex-col min-h-0 relative">
|
||||
<div
|
||||
className="flex-1 min-w-0 flex flex-col min-h-0 relative"
|
||||
data-section="vault-main"
|
||||
>
|
||||
<header
|
||||
className={cn(
|
||||
"border-b border-border/50 bg-secondary/80 backdrop-blur app-drag",
|
||||
!isHostsSectionActive && "hidden",
|
||||
)}
|
||||
data-section="vault-hosts-header"
|
||||
>
|
||||
<div className="h-14 px-4 py-2 flex items-center gap-3">
|
||||
<div className="relative flex-1 app-no-drag">
|
||||
@@ -1757,14 +1839,25 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
<CheckSquare size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
{/* New Host split button */}
|
||||
<div className="flex items-center app-no-drag">
|
||||
{/* New Host split button — collapses with an animation when the
|
||||
host details / new-host aside panel is open, since the button
|
||||
would be a no-op in that state. */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center app-no-drag overflow-hidden transition-[max-width,opacity,margin] duration-200 ease-in-out",
|
||||
isHostPanelOpen
|
||||
? "max-w-0 opacity-0 -ml-2 pointer-events-none"
|
||||
: "max-w-[260px] opacity-100",
|
||||
)}
|
||||
aria-hidden={isHostPanelOpen}
|
||||
>
|
||||
<Dropdown>
|
||||
<div className="flex items-center rounded-md bg-primary text-primary-foreground">
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-10 px-3 rounded-r-none bg-transparent hover:bg-white/10 shadow-none app-no-drag"
|
||||
onClick={handleNewHost}
|
||||
tabIndex={isHostPanelOpen ? -1 : 0}
|
||||
>
|
||||
<Plus size={14} className="mr-2" /> {t("vault.hosts.newHost")}
|
||||
</Button>
|
||||
@@ -1772,6 +1865,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-10 px-2 rounded-l-none bg-transparent hover:bg-white/10 border-l border-primary-foreground/20 shadow-none app-no-drag"
|
||||
tabIndex={isHostPanelOpen ? -1 : 0}
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</Button>
|
||||
@@ -1808,22 +1902,37 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-10 px-3 app-no-drag bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40"
|
||||
onClick={onCreateLocalTerminal}
|
||||
{/* Terminal + Serial — collapse together with an animation when
|
||||
the host details / new-host aside panel is open, freeing
|
||||
horizontal space for the panel. */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 overflow-hidden transition-[max-width,opacity,margin] duration-200 ease-in-out",
|
||||
isHostPanelOpen
|
||||
? "max-w-0 opacity-0 -ml-3 pointer-events-none"
|
||||
: "max-w-[320px] opacity-100",
|
||||
)}
|
||||
aria-hidden={isHostPanelOpen}
|
||||
>
|
||||
<TerminalSquare size={14} className="mr-2" /> {t("common.terminal")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-10 px-3 app-no-drag bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40"
|
||||
onClick={() => setIsSerialModalOpen(true)}
|
||||
>
|
||||
<Usb size={14} className="mr-2" /> {t("serial.button")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-10 px-3 app-no-drag bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40"
|
||||
onClick={onCreateLocalTerminal}
|
||||
tabIndex={isHostPanelOpen ? -1 : 0}
|
||||
>
|
||||
<TerminalSquare size={14} className="mr-2" /> {t("common.terminal")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-10 px-3 app-no-drag bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40"
|
||||
onClick={() => setIsSerialModalOpen(true)}
|
||||
tabIndex={isHostPanelOpen ? -1 : 0}
|
||||
>
|
||||
<Usb size={14} className="mr-2" /> {t("serial.button")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1833,24 +1942,34 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
"flex-1 overflow-auto px-4 py-4 space-y-6",
|
||||
!isHostsSectionActive && "hidden",
|
||||
)}
|
||||
data-section="vault-host-list"
|
||||
onDragEndCapture={() => setDragOverDropTarget(null)}
|
||||
>
|
||||
<section className="space-y-2">
|
||||
{viewMode !== "tree" && (
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<button
|
||||
className={cn(
|
||||
"text-primary hover:underline transition-all rounded px-1 -mx-1",
|
||||
isBreadcrumbDragOver && "ring-2 ring-primary bg-primary/10",
|
||||
"text-primary hover:underline transition-colors duration-150 rounded px-1 -mx-1",
|
||||
getDropTargetClasses({ kind: "root" }),
|
||||
)}
|
||||
onClick={() => setSelectedGroupPath(null)}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsBreadcrumbDragOver(true);
|
||||
setDragOverDropTarget({ kind: "root" });
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
const nextTarget = e.relatedTarget;
|
||||
if (nextTarget instanceof Node && e.currentTarget.contains(nextTarget)) {
|
||||
return;
|
||||
}
|
||||
setDragOverDropTarget((current) =>
|
||||
current?.kind === "root" ? null : current,
|
||||
);
|
||||
}}
|
||||
onDragLeave={() => setIsBreadcrumbDragOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setIsBreadcrumbDragOver(false);
|
||||
setDragOverDropTarget(null);
|
||||
const groupPath = e.dataTransfer.getData("group-path");
|
||||
const hostId = e.dataTransfer.getData("host-id");
|
||||
if (groupPath) moveGroup(groupPath, null);
|
||||
@@ -1898,9 +2017,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
</h3>
|
||||
<div className={cn(
|
||||
viewMode === "grid"
|
||||
? "grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
? cn(
|
||||
"grid gap-3",
|
||||
!hasHostsSidePanel && "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4",
|
||||
)
|
||||
: "flex flex-col gap-0",
|
||||
)}>
|
||||
)}
|
||||
style={viewMode === "grid" ? splitViewGridStyle : undefined}>
|
||||
{pinnedHosts.map((host) => {
|
||||
const safeHost = sanitizeHost(host);
|
||||
const effectiveDistro = getEffectiveHostDistro(safeHost);
|
||||
@@ -1998,9 +2121,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
</h3>
|
||||
<div className={cn(
|
||||
viewMode === "grid"
|
||||
? "grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
? cn(
|
||||
"grid gap-3",
|
||||
!hasHostsSidePanel && "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4",
|
||||
)
|
||||
: "flex flex-col gap-0",
|
||||
)}>
|
||||
)}
|
||||
style={viewMode === "grid" ? splitViewGridStyle : undefined}>
|
||||
{recentHosts.map((host) => {
|
||||
const safeHost = sanitizeHost(host);
|
||||
const effectiveDistro = getEffectiveHostDistro(safeHost);
|
||||
@@ -2099,9 +2226,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
className={cn(
|
||||
displayedGroups.length === 0 ? "hidden" : "",
|
||||
viewMode === "grid"
|
||||
? "grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
? cn(
|
||||
"grid gap-3",
|
||||
!hasHostsSidePanel && "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4",
|
||||
)
|
||||
: "flex flex-col gap-0",
|
||||
)}
|
||||
style={viewMode === "grid" ? splitViewGridStyle : undefined}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
@@ -2120,10 +2251,11 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"group cursor-pointer",
|
||||
"group cursor-pointer transition-colors duration-150",
|
||||
viewMode === "grid"
|
||||
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
|
||||
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors",
|
||||
getDropTargetClasses({ kind: "group", path: node.path }),
|
||||
)}
|
||||
draggable
|
||||
onDragStart={(e) =>
|
||||
@@ -2136,10 +2268,21 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverDropTarget({ kind: "group", path: node.path });
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
const nextTarget = e.relatedTarget;
|
||||
if (nextTarget instanceof Node && e.currentTarget.contains(nextTarget)) {
|
||||
return;
|
||||
}
|
||||
setDragOverDropTarget((current) =>
|
||||
current?.kind === "group" && current.path === node.path ? null : current,
|
||||
);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverDropTarget(null);
|
||||
const hostId =
|
||||
e.dataTransfer.getData("host-id");
|
||||
const groupPath =
|
||||
@@ -2306,6 +2449,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
toggleHostSelection={toggleHostSelection}
|
||||
getDropTargetClasses={(path) =>
|
||||
getDropTargetClasses({ kind: "group", path })
|
||||
}
|
||||
setDragOverDropTarget={setGroupDragOverDropTarget}
|
||||
/>
|
||||
) : sortMode === "group" && groupedDisplayHosts ? (
|
||||
<div className="space-y-6">
|
||||
@@ -2323,9 +2470,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
<div
|
||||
className={cn(
|
||||
viewMode === "grid"
|
||||
? "grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
? cn(
|
||||
"grid gap-3",
|
||||
!hasHostsSidePanel && "grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4",
|
||||
)
|
||||
: "flex flex-col gap-0",
|
||||
)}
|
||||
style={viewMode === "grid" ? splitViewGridStyle : undefined}
|
||||
>
|
||||
{group.hosts.filter((h) => selectedGroupPath || !pinnedRecentIds.has(h.id)).map((host) => {
|
||||
const safeHost = sanitizeHost(host);
|
||||
@@ -2464,9 +2615,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
<div
|
||||
className={cn(
|
||||
viewMode === "grid"
|
||||
? "grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
? cn(
|
||||
"grid gap-3",
|
||||
!hasHostsSidePanel && "grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4",
|
||||
)
|
||||
: "flex flex-col gap-0",
|
||||
)}
|
||||
style={viewMode === "grid" ? splitViewGridStyle : undefined}
|
||||
>
|
||||
{displayedHosts.filter((h) => selectedGroupPath || !pinnedRecentIds.has(h.id)).map((host) => {
|
||||
const safeHost = sanitizeHost(host);
|
||||
@@ -2732,6 +2887,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
setIsGroupPanelOpen(false);
|
||||
setEditingGroupPath(null);
|
||||
}}
|
||||
layout="inline"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -2771,6 +2927,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
Array.from(new Set([...customGroups, groupPath])),
|
||||
);
|
||||
}}
|
||||
layout="inline"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -2791,6 +2948,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
setIsHostPanelOpen(false);
|
||||
setEditingHost(null);
|
||||
}}
|
||||
layout="inline"
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import React, { useMemo, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Host } from '../../types';
|
||||
import { DistroAvatar } from '../DistroAvatar';
|
||||
import { AsidePanel } from '../ui/aside-panel';
|
||||
import { AsidePanel, type AsidePanelLayout } from '../ui/aside-panel';
|
||||
import { Button } from '../ui/button';
|
||||
import { Card } from '../ui/card';
|
||||
import { Input } from '../ui/input';
|
||||
@@ -24,6 +24,7 @@ export interface ChainPanelProps {
|
||||
onClearChain: () => void;
|
||||
onBack: () => void;
|
||||
onCancel: () => void;
|
||||
layout?: AsidePanelLayout;
|
||||
}
|
||||
|
||||
export const ChainPanel: React.FC<ChainPanelProps> = ({
|
||||
@@ -37,6 +38,7 @@ export const ChainPanel: React.FC<ChainPanelProps> = ({
|
||||
onClearChain,
|
||||
onBack,
|
||||
onCancel,
|
||||
layout = 'overlay',
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -54,6 +56,7 @@ export const ChainPanel: React.FC<ChainPanelProps> = ({
|
||||
title={t('hostDetails.chain.title')}
|
||||
showBackButton={true}
|
||||
onBack={onBack}
|
||||
layout={layout}
|
||||
actions={
|
||||
<Button size="sm" onClick={onBack}>
|
||||
{t('common.save')}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { FolderPlus,HelpCircle,Plus } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { AsidePanel,AsidePanelContent } from '../ui/aside-panel';
|
||||
import { AsidePanel,AsidePanelContent,type AsidePanelLayout } from '../ui/aside-panel';
|
||||
import { Button } from '../ui/button';
|
||||
import { Card } from '../ui/card';
|
||||
import { Input } from '../ui/input';
|
||||
@@ -42,6 +42,7 @@ export interface CreateGroupPanelProps {
|
||||
onSave: () => void;
|
||||
onBack: () => void;
|
||||
onCancel: () => void;
|
||||
layout?: AsidePanelLayout;
|
||||
}
|
||||
|
||||
export const CreateGroupPanel: React.FC<CreateGroupPanelProps> = ({
|
||||
@@ -53,6 +54,7 @@ export const CreateGroupPanel: React.FC<CreateGroupPanelProps> = ({
|
||||
onSave,
|
||||
onBack,
|
||||
onCancel,
|
||||
layout = 'overlay',
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
@@ -62,6 +64,7 @@ export const CreateGroupPanel: React.FC<CreateGroupPanelProps> = ({
|
||||
title={t('hostDetails.group.title')}
|
||||
showBackButton={true}
|
||||
onBack={onBack}
|
||||
layout={layout}
|
||||
actions={
|
||||
<Button size="sm" onClick={onSave} disabled={!newGroupName.trim()}>
|
||||
{t('common.save')}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Plus,X } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { EnvVar } from '../../types';
|
||||
import { AsidePanel,AsidePanelContent } from '../ui/aside-panel';
|
||||
import { AsidePanel,AsidePanelContent,type AsidePanelLayout } from '../ui/aside-panel';
|
||||
import { Button } from '../ui/button';
|
||||
import { Card } from '../ui/card';
|
||||
import { Input } from '../ui/input';
|
||||
@@ -25,6 +25,7 @@ export interface EnvVarsPanelProps {
|
||||
onSave: () => void;
|
||||
onBack: () => void;
|
||||
onCancel: () => void;
|
||||
layout?: AsidePanelLayout;
|
||||
}
|
||||
|
||||
export const EnvVarsPanel: React.FC<EnvVarsPanelProps> = ({
|
||||
@@ -41,6 +42,7 @@ export const EnvVarsPanel: React.FC<EnvVarsPanelProps> = ({
|
||||
onSave,
|
||||
onBack,
|
||||
onCancel,
|
||||
layout = 'overlay',
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
@@ -50,6 +52,7 @@ export const EnvVarsPanel: React.FC<EnvVarsPanelProps> = ({
|
||||
title={t('hostDetails.envVars.title')}
|
||||
showBackButton={true}
|
||||
onBack={onBack}
|
||||
layout={layout}
|
||||
actions={
|
||||
<Button size="sm" onClick={onSave}>
|
||||
{t('common.save')}
|
||||
|
||||
@@ -7,7 +7,7 @@ import React from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { ProxyConfig } from '../../types';
|
||||
import { AsidePanel,AsidePanelContent } from '../ui/aside-panel';
|
||||
import { AsidePanel,AsidePanelContent,type AsidePanelLayout } from '../ui/aside-panel';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Button } from '../ui/button';
|
||||
import { Card } from '../ui/card';
|
||||
@@ -19,6 +19,7 @@ export interface ProxyPanelProps {
|
||||
onClearProxy: () => void;
|
||||
onBack: () => void;
|
||||
onCancel: () => void;
|
||||
layout?: AsidePanelLayout;
|
||||
}
|
||||
|
||||
export const ProxyPanel: React.FC<ProxyPanelProps> = ({
|
||||
@@ -27,6 +28,7 @@ export const ProxyPanel: React.FC<ProxyPanelProps> = ({
|
||||
onClearProxy,
|
||||
onBack,
|
||||
onCancel,
|
||||
layout = 'overlay',
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
@@ -36,6 +38,7 @@ export const ProxyPanel: React.FC<ProxyPanelProps> = ({
|
||||
title={t('hostDetails.proxyPanel.title')}
|
||||
showBackButton={true}
|
||||
onBack={onBack}
|
||||
layout={layout}
|
||||
actions={
|
||||
<Button size="sm" onClick={onBack} disabled={!proxyConfig?.host}>
|
||||
{t('common.save')}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { TERMINAL_THEMES } from "../../../infrastructure/config/terminalThemes";
|
||||
import { customThemeStore, useCustomThemes } from "../../../application/state/customThemeStore";
|
||||
import { parseItermcolors } from "../../../infrastructure/parsers/itermcolorsParser";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { useDiscoveredShells } from "../../../lib/useDiscoveredShells";
|
||||
import { Button } from "../../ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "../../ui/dialog";
|
||||
import { Input } from "../../ui/input";
|
||||
@@ -294,6 +295,20 @@ export default function SettingsTerminalTab(props: {
|
||||
const [defaultShell, setDefaultShell] = useState<string>("");
|
||||
const [shellValidation, setShellValidation] = useState<{ valid: boolean; message?: string } | null>(null);
|
||||
const [dirValidation, setDirValidation] = useState<{ valid: boolean; message?: string } | null>(null);
|
||||
|
||||
const discoveredShells = useDiscoveredShells();
|
||||
const [showCustomShellInput, setShowCustomShellInput] = useState(() => {
|
||||
if (!terminalSettings.localShell) return false;
|
||||
return !discoveredShells.some(s => s.id === terminalSettings.localShell);
|
||||
});
|
||||
const [customShellModalOpen, setCustomShellModalOpen] = useState(false);
|
||||
const [customShellDraft, setCustomShellDraft] = useState("");
|
||||
|
||||
// Update showCustomShellInput once discovered shells load
|
||||
useEffect(() => {
|
||||
if (!terminalSettings.localShell) return;
|
||||
setShowCustomShellInput(!discoveredShells.some(s => s.id === terminalSettings.localShell));
|
||||
}, [discoveredShells, terminalSettings.localShell]);
|
||||
const [themeModalOpen, setThemeModalOpen] = useState(false);
|
||||
|
||||
// Subscribe to custom theme changes so editing in-place triggers re-render
|
||||
@@ -398,7 +413,7 @@ export default function SettingsTerminalTab(props: {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Validate shell path when it changes
|
||||
// Validate shell path when it changes (only for custom paths, not discovered shell ids)
|
||||
useEffect(() => {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
const shellPath = terminalSettings.localShell;
|
||||
@@ -408,6 +423,12 @@ export default function SettingsTerminalTab(props: {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip validation for discovered shell ids — only validate custom paths
|
||||
if (discoveredShells.some(s => s.id === shellPath)) {
|
||||
setShellValidation(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!bridge?.validatePath) {
|
||||
setShellValidation(null);
|
||||
return;
|
||||
@@ -428,7 +449,7 @@ export default function SettingsTerminalTab(props: {
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [terminalSettings.localShell, t]);
|
||||
}, [terminalSettings.localShell, discoveredShells, t]);
|
||||
|
||||
// Validate directory path when it changes
|
||||
useEffect(() => {
|
||||
@@ -896,24 +917,43 @@ export default function SettingsTerminalTab(props: {
|
||||
description={t("settings.terminal.localShell.shell.desc")}
|
||||
>
|
||||
<div className="flex flex-col gap-1 items-end">
|
||||
<Input
|
||||
value={terminalSettings.localShell}
|
||||
placeholder={t("settings.terminal.localShell.shell.placeholder")}
|
||||
onChange={(e) => updateTerminalSetting("localShell", e.target.value)}
|
||||
className={cn(
|
||||
"w-48",
|
||||
shellValidation && !shellValidation.valid && "border-destructive focus-visible:ring-destructive"
|
||||
)}
|
||||
/>
|
||||
{defaultShell && !terminalSettings.localShell && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("settings.terminal.localShell.shell.detected")}: {defaultShell}
|
||||
<select
|
||||
className="h-9 w-48 rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={
|
||||
showCustomShellInput
|
||||
? "__custom__"
|
||||
: terminalSettings.localShell || ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "__custom__") {
|
||||
setCustomShellDraft(terminalSettings.localShell || "");
|
||||
setCustomShellModalOpen(true);
|
||||
} else {
|
||||
setShowCustomShellInput(false);
|
||||
updateTerminalSetting("localShell", value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">
|
||||
{t("settings.terminal.localShell.shell.default")}
|
||||
{defaultShell ? ` (${defaultShell.split(/[/\\]/).pop()})` : ""}
|
||||
</option>
|
||||
{discoveredShells.map((shell) => (
|
||||
<option key={shell.id} value={shell.id}>
|
||||
{shell.name}
|
||||
</option>
|
||||
))}
|
||||
<option value="__custom__">{t("settings.terminal.localShell.shell.custom")}</option>
|
||||
</select>
|
||||
{showCustomShellInput && (
|
||||
<span className="text-xs text-muted-foreground truncate max-w-48">
|
||||
{terminalSettings.localShell}
|
||||
</span>
|
||||
)}
|
||||
{shellValidation && !shellValidation.valid && shellValidation.message && (
|
||||
<span className="text-xs text-destructive flex items-center gap-1">
|
||||
<AlertCircle size={12} />
|
||||
{shellValidation.message}
|
||||
{!showCustomShellInput && defaultShell && !terminalSettings.localShell && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("settings.terminal.localShell.shell.detected")}: {defaultShell}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -1013,9 +1053,9 @@ export default function SettingsTerminalTab(props: {
|
||||
options={[
|
||||
{ value: "auto", label: t("settings.terminal.rendering.auto") },
|
||||
{ value: "webgl", label: "WebGL" },
|
||||
{ value: "canvas", label: "Canvas" },
|
||||
{ value: "dom", label: "DOM" },
|
||||
]}
|
||||
onChange={(v) => updateTerminalSetting("rendererType", v as "auto" | "webgl" | "canvas")}
|
||||
onChange={(v) => updateTerminalSetting("rendererType", v as "auto" | "webgl" | "dom")}
|
||||
className="w-32"
|
||||
/>
|
||||
</SettingRow>
|
||||
@@ -1070,6 +1110,73 @@ export default function SettingsTerminalTab(props: {
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
{/* Custom Shell Modal */}
|
||||
<Dialog open={customShellModalOpen} onOpenChange={setCustomShellModalOpen}>
|
||||
<DialogContent className="sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("settings.terminal.localShell.shell.custom")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">{t("settings.terminal.localShell.shell.customPath")}</label>
|
||||
<Input
|
||||
value={customShellDraft}
|
||||
placeholder={t("settings.terminal.localShell.shell.placeholder")}
|
||||
onChange={(e) => setCustomShellDraft(e.target.value)}
|
||||
className="w-full"
|
||||
autoFocus
|
||||
/>
|
||||
{shellValidation && !shellValidation.valid && shellValidation.message && (
|
||||
<span className="text-xs text-destructive flex items-center gap-1">
|
||||
<AlertCircle size={12} />
|
||||
{shellValidation.message}
|
||||
</span>
|
||||
)}
|
||||
{shellValidation?.valid && (
|
||||
<span className="text-xs text-emerald-600 dark:text-emerald-400 flex items-center gap-1">
|
||||
✓ {t("settings.terminal.localShell.shell.pathValid")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs text-muted-foreground">{t("settings.terminal.localShell.shell.commonPaths")}</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{["/bin/bash", "/bin/zsh", "/usr/bin/fish", "/bin/sh", "powershell.exe", "pwsh.exe", "cmd.exe"].map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
onClick={() => setCustomShellDraft(p)}
|
||||
className="text-xs px-2 py-1 rounded-md border border-border bg-muted/50 hover:bg-muted transition-colors"
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCustomShellModalOpen(false)}
|
||||
className="px-3 py-1.5 text-sm rounded-md border border-border hover:bg-muted transition-colors"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
updateTerminalSetting("localShell", customShellDraft);
|
||||
setShowCustomShellInput(true);
|
||||
setCustomShellModalOpen(false);
|
||||
}}
|
||||
disabled={!customShellDraft.trim()}
|
||||
className="px-3 py-1.5 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{t("common.save")}
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</SettingsTabContent>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from "react";
|
||||
import type { Host, SftpFileEntry } from "../../types";
|
||||
import type { FileOpenerType, SystemAppInfo } from "../../lib/sftpFileUtils";
|
||||
import type { useSftpState } from "../../application/state/useSftpState";
|
||||
import type { HotkeyScheme, KeyBinding } from "../../domain/models";
|
||||
import FileOpenerDialog from "../FileOpenerDialog";
|
||||
import TextEditorModal from "../TextEditorModal";
|
||||
import { SftpConflictDialog, SftpHostPicker, SftpPermissionsDialog } from "./index";
|
||||
@@ -35,6 +36,8 @@ interface SftpOverlaysProps {
|
||||
handleSaveTextFile: (content: string) => Promise<void>;
|
||||
editorWordWrap: boolean;
|
||||
setEditorWordWrap: (enabled: boolean) => void;
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
keyBindings: KeyBinding[];
|
||||
showFileOpenerDialog: boolean;
|
||||
setShowFileOpenerDialog: (open: boolean) => void;
|
||||
fileOpenerTarget: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null;
|
||||
@@ -69,6 +72,8 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
|
||||
handleSaveTextFile,
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
showFileOpenerDialog,
|
||||
setShowFileOpenerDialog,
|
||||
fileOpenerTarget,
|
||||
@@ -139,6 +144,8 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
|
||||
onSave={handleSaveTextFile}
|
||||
editorWordWrap={editorWordWrap}
|
||||
onToggleWordWrap={() => setEditorWordWrap(!editorWordWrap)}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
/>
|
||||
|
||||
{/* File Opener Dialog */}
|
||||
|
||||
@@ -75,21 +75,26 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
const splitVShortcut = getShortcut('split-vertical');
|
||||
const clearShortcut = getShortcut('clear-buffer');
|
||||
|
||||
const showContextMenu = rightClickBehavior === 'context-menu' && !isAlternateScreen;
|
||||
|
||||
// Handle right-click: intercept for paste/select-word unless Shift is held
|
||||
// or rightClickBehavior is 'context-menu'. The ContextMenuTrigger stays always
|
||||
// enabled so Shift+Right-Click opens the menu on the first click.
|
||||
const handleRightClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// In alternate screen (tmux, vim, etc.), let the terminal application
|
||||
// handle right-click natively to avoid conflicting menus
|
||||
if (isAlternateScreen) return;
|
||||
|
||||
if (rightClickBehavior === 'paste') {
|
||||
if (isAlternateScreen) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
// Shift+Right-Click or context-menu mode: let Radix open the menu
|
||||
if (e.shiftKey || rightClickBehavior === 'context-menu') return;
|
||||
|
||||
// Paste / select-word: intercept and prevent the context menu
|
||||
e.preventDefault();
|
||||
if (rightClickBehavior === 'paste') {
|
||||
onPaste?.();
|
||||
} else if (rightClickBehavior === 'select-word') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onSelectWord?.();
|
||||
}
|
||||
},
|
||||
@@ -102,12 +107,11 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger
|
||||
asChild
|
||||
disabled={!showContextMenu}
|
||||
onContextMenu={!showContextMenu ? handleRightClick : undefined}
|
||||
onContextMenu={handleRightClick}
|
||||
>
|
||||
{children}
|
||||
</ContextMenuTrigger>
|
||||
{showContextMenu && (
|
||||
{!isAlternateScreen && (
|
||||
<ContextMenuContent className="w-56">
|
||||
<ContextMenuItem onClick={onCopy} disabled={!hasSelection}>
|
||||
<Copy size={14} className="mr-2" />
|
||||
|
||||
@@ -131,15 +131,19 @@ interface ThemeSidePanelProps {
|
||||
currentFontFamilyId: string;
|
||||
globalFontFamilyId: string;
|
||||
currentFontSize: number;
|
||||
currentFontWeight: number;
|
||||
canResetTheme?: boolean;
|
||||
canResetFontFamily?: boolean;
|
||||
canResetFontSize?: boolean;
|
||||
canResetFontWeight?: boolean;
|
||||
onThemeChange: (themeId: string) => void;
|
||||
onThemeReset?: () => void;
|
||||
onFontFamilyChange: (fontFamilyId: string) => void;
|
||||
onFontFamilyReset?: () => void;
|
||||
onFontSizeChange: (fontSize: number) => void;
|
||||
onFontSizeReset?: () => void;
|
||||
onFontWeightChange: (fontWeight: number) => void;
|
||||
onFontWeightReset?: () => void;
|
||||
isVisible?: boolean;
|
||||
previewColors?: {
|
||||
background: string;
|
||||
@@ -153,15 +157,19 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
|
||||
currentFontFamilyId,
|
||||
globalFontFamilyId,
|
||||
currentFontSize,
|
||||
currentFontWeight,
|
||||
canResetTheme = false,
|
||||
canResetFontFamily = false,
|
||||
canResetFontSize = false,
|
||||
canResetFontWeight = false,
|
||||
onThemeChange,
|
||||
onThemeReset,
|
||||
onFontFamilyChange,
|
||||
onFontFamilyReset,
|
||||
onFontSizeChange,
|
||||
onFontSizeReset,
|
||||
onFontWeightChange,
|
||||
onFontWeightReset,
|
||||
isVisible = true,
|
||||
previewColors,
|
||||
}) => {
|
||||
@@ -497,10 +505,52 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Font Weight Control (only in font tab) */}
|
||||
{activeTab === 'font' && (
|
||||
<div className="p-2.5 border-t shrink-0" style={{ borderColor: 'var(--terminal-panel-border)' }}>
|
||||
<div className="flex items-center justify-between gap-2 mb-1.5">
|
||||
<div className="text-[9px] uppercase tracking-wider font-semibold" style={{ color: 'var(--terminal-panel-muted)' }}>
|
||||
{t('terminal.themeModal.fontWeight')}
|
||||
</div>
|
||||
{canResetFontWeight && (
|
||||
<button
|
||||
onClick={onFontWeightReset}
|
||||
className="text-[10px] font-medium hover:opacity-80 transition-opacity"
|
||||
style={{ color: 'var(--terminal-panel-fg)' }}
|
||||
>
|
||||
{t('common.useGlobal')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-lg p-1.5" style={{ backgroundColor: 'var(--terminal-panel-hover)' }}>
|
||||
<select
|
||||
value={currentFontWeight}
|
||||
onChange={(e) => onFontWeightChange(Number(e.target.value))}
|
||||
className="flex-1 h-7 rounded-md border text-xs px-2 cursor-pointer"
|
||||
style={{
|
||||
backgroundColor: 'var(--terminal-panel-bg)',
|
||||
color: 'var(--terminal-panel-fg)',
|
||||
borderColor: 'var(--terminal-panel-border)',
|
||||
}}
|
||||
>
|
||||
<option value={100}>100 Thin</option>
|
||||
<option value={200}>200 ExtraLight</option>
|
||||
<option value={300}>300 Light</option>
|
||||
<option value={400}>400 Normal</option>
|
||||
<option value={500}>500 Medium</option>
|
||||
<option value={600}>600 SemiBold</option>
|
||||
<option value={700}>700 Bold</option>
|
||||
<option value={800}>800 ExtraBold</option>
|
||||
<option value={900}>900 Black</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current selection info */}
|
||||
<div className="px-2.5 py-1.5 border-t shrink-0" style={{ borderColor: 'var(--terminal-panel-border)' }}>
|
||||
<div className="text-[9px] truncate" style={{ color: 'var(--terminal-panel-muted)' }}>
|
||||
{allThemes.find(t => t.id === currentThemeId)?.name ?? currentThemeId} • {availableFonts.find(f => f.id === currentFontFamilyId)?.name ?? currentFontFamilyId} • {currentFontSize}px
|
||||
{allThemes.find(t => t.id === currentThemeId)?.name ?? currentThemeId} • {availableFonts.find(f => f.id === currentFontFamilyId)?.name ?? currentFontFamilyId} • {currentFontSize}px • {currentFontWeight}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
85
components/terminal/clearTerminalViewport.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
|
||||
type CsiParam = number | number[];
|
||||
type InternalTerminal = XTerm & {
|
||||
_core?: {
|
||||
scroll?: (eraseAttr: unknown, isWrapped?: boolean) => void;
|
||||
_inputHandler?: {
|
||||
_eraseAttrData?: () => unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const getVisibleContentRowCount = (term: XTerm): number => {
|
||||
const buffer = term.buffer.active;
|
||||
if (buffer.type !== "normal") {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const baseY = buffer.baseY;
|
||||
for (let row = term.rows - 1; row >= 0; row--) {
|
||||
const line = buffer.getLine(baseY + row);
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
if (line.translateToString(true).length > 0) {
|
||||
return row + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
export const preserveTerminalViewportInScrollback = (term: XTerm): void => {
|
||||
const rowsToPreserve = getVisibleContentRowCount(term);
|
||||
if (rowsToPreserve <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const internal = term as InternalTerminal;
|
||||
const scroll = internal._core?.scroll;
|
||||
const eraseAttr = internal._core?._inputHandler?._eraseAttrData?.();
|
||||
|
||||
if (typeof scroll !== "function" || eraseAttr === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let row = 0; row < rowsToPreserve; row++) {
|
||||
scroll.call(internal._core, eraseAttr, false);
|
||||
}
|
||||
};
|
||||
|
||||
export const clearTerminalViewport = (term: XTerm): void => {
|
||||
const buffer = term.buffer.active;
|
||||
if (buffer.type !== "normal") return;
|
||||
|
||||
const cursorY = buffer.cursorY;
|
||||
const cursorX = buffer.cursorX;
|
||||
|
||||
if (cursorY === 0 && buffer.baseY === 0) return;
|
||||
|
||||
const internal = term as InternalTerminal;
|
||||
const scroll = internal._core?.scroll;
|
||||
const eraseAttr = internal._core?._inputHandler?._eraseAttrData?.();
|
||||
|
||||
if (typeof scroll !== "function" || eraseAttr === undefined) return;
|
||||
|
||||
// Push lines above cursor into scrollback so they are preserved.
|
||||
// After cursorY scrolls the prompt line shifts to active-screen row 0.
|
||||
for (let i = 0; i < cursorY; i++) {
|
||||
scroll.call(internal._core, eraseAttr, false);
|
||||
}
|
||||
|
||||
// Clear everything below the prompt and reposition the cursor on it.
|
||||
// CSI coordinates are 1-indexed.
|
||||
const col = cursorX + 1;
|
||||
term.write(`\x1b[2;1H\x1b[J\x1b[1;${col}H`, () => {
|
||||
term.scrollToBottom();
|
||||
});
|
||||
};
|
||||
|
||||
export const isEraseScrollbackSequence = (params: CsiParam[]): boolean =>
|
||||
params.length > 0 && params[0] === 3;
|
||||
|
||||
export const isEraseViewportSequence = (params: CsiParam[]): boolean =>
|
||||
params.length > 0 && params[0] === 2;
|
||||
@@ -3,6 +3,7 @@ import { useCallback } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { normalizeLineEndings, wrapBracketedPaste } from "../../../lib/utils";
|
||||
import { clearTerminalViewport } from "../clearTerminalViewport";
|
||||
|
||||
type TerminalBackendWriteApi = {
|
||||
writeToSession: (sessionId: string, data: string) => void;
|
||||
@@ -65,7 +66,7 @@ export const useTerminalContextActions = ({
|
||||
const onClear = useCallback(() => {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
term.clear();
|
||||
clearTerminalViewport(term);
|
||||
}, [termRef]);
|
||||
|
||||
const onSelectWord = useCallback(() => {
|
||||
|
||||
@@ -764,15 +764,21 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
}
|
||||
|
||||
try {
|
||||
// Get local shell configuration from terminal settings
|
||||
const localShell = ctx.terminalSettings?.localShell;
|
||||
// Per-session shell (from QuickSwitcher discovery or split/copy) takes priority.
|
||||
// The global terminalSettings.localShell may contain a shell ID (e.g., "wsl-ubuntu")
|
||||
// which was already resolved to command+args and stored on the session object by App.tsx.
|
||||
// Only pass shell/shellArgs when we have concrete per-session values;
|
||||
// otherwise omit them so the backend uses its own default shell detection.
|
||||
const sessionShell = ctx.host.localShell;
|
||||
const sessionShellArgs = ctx.host.localShellArgs;
|
||||
const localStartDir = ctx.terminalSettings?.localStartDir;
|
||||
|
||||
const id = await ctx.terminalBackend.startLocalSession({
|
||||
sessionId: ctx.sessionId,
|
||||
cols: term.cols,
|
||||
rows: term.rows,
|
||||
shell: localShell,
|
||||
shell: sessionShell || undefined,
|
||||
shellArgs: sessionShellArgs || undefined,
|
||||
cwd: localStartDir,
|
||||
env: {
|
||||
TERM: ctx.terminalSettings?.terminalEmulationType ?? "xterm-256color",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { SearchAddon } from "@xterm/addon-search";
|
||||
import { SerializeAddon } from "@xterm/addon-serialize";
|
||||
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
||||
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { WebglAddon } from "@xterm/addon-webgl";
|
||||
import { Terminal as XTerm } from "@xterm/xterm";
|
||||
@@ -26,10 +26,17 @@ import {
|
||||
import {
|
||||
resolveHostTerminalFontFamilyId,
|
||||
resolveHostTerminalFontSize,
|
||||
resolveHostTerminalFontWeight,
|
||||
} from "../../../domain/terminalAppearance";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { isMacPlatform, normalizeLineEndings, wrapBracketedPaste } from "../../../lib/utils";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import {
|
||||
clearTerminalViewport,
|
||||
isEraseViewportSequence,
|
||||
isEraseScrollbackSequence,
|
||||
preserveTerminalViewportInScrollback,
|
||||
} from "../clearTerminalViewport";
|
||||
import type {
|
||||
Host,
|
||||
KeyBinding,
|
||||
@@ -128,6 +135,21 @@ const detectPlatform = (): XTermPlatform => {
|
||||
return "darwin";
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract the primary font family from a CSS font-family string that may
|
||||
* include fallback fonts. `document.fonts.check` returns `false` when *any*
|
||||
* listed font is still loading, so passing the entire CJK fallback stack
|
||||
* causes false negatives during early terminal creation – which in turn makes
|
||||
* `fontWeightBold` fall back to the normal weight and renders bold text too
|
||||
* thin.
|
||||
*/
|
||||
export const primaryFontFamily = (fontFamily: string): string => {
|
||||
// Split on commas that are NOT inside quotes to handle font names like "Foo, Bar"
|
||||
const match = fontFamily.match(/^(?:"[^"]*"|'[^']*'|[^,])+/);
|
||||
const first = match?.[0]?.trim();
|
||||
return first || fontFamily;
|
||||
};
|
||||
|
||||
export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime => {
|
||||
const platform = detectPlatform();
|
||||
const deviceMemoryGb =
|
||||
@@ -162,7 +184,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
const cursorBlink = settings?.cursorBlink ?? true;
|
||||
const scrollback = settings?.scrollback ?? 10000;
|
||||
const drawBoldTextInBrightColors = settings?.drawBoldInBrightColors ?? true;
|
||||
const fontWeight = settings?.fontWeight ?? 400;
|
||||
const fontWeight = resolveHostTerminalFontWeight(ctx.host, settings?.fontWeight ?? 400);
|
||||
const fontWeightBold = settings?.fontWeightBold ?? 700;
|
||||
const lineHeight = 1 + (settings?.linePadding ?? 0) / 10;
|
||||
const minimumContrastRatio = settings?.minimumContrastRatio ?? 1;
|
||||
@@ -179,7 +201,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
if (typeof document === "undefined" || !document.fonts?.check) {
|
||||
return fontWeightBold;
|
||||
}
|
||||
const weightSpec = `${fontWeightBold} ${effectiveFontSize}px ${fontFamily}`;
|
||||
const weightSpec = `${fontWeightBold} ${effectiveFontSize}px ${primaryFontFamily(fontFamily)}`;
|
||||
return document.fonts.check(weightSpec) ? fontWeightBold : fontWeight;
|
||||
})();
|
||||
|
||||
@@ -188,6 +210,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
...(windowsPty ? { windowsPty } : {}),
|
||||
// Override ignoreBracketedPasteMode if user explicitly disables bracketed paste
|
||||
ignoreBracketedPasteMode: settings?.disableBracketedPaste ?? performanceConfig.options.ignoreBracketedPasteMode,
|
||||
// Rescale glyphs that would visually overlap into the next cell (CJK compliance)
|
||||
rescaleOverlappingGlyphs: true,
|
||||
fontSize: effectiveFontSize,
|
||||
fontFamily,
|
||||
fontWeight: fontWeight as
|
||||
@@ -230,6 +254,10 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
theme: {
|
||||
...ctx.terminalTheme.colors,
|
||||
selectionBackground: ctx.terminalTheme.colors.selection,
|
||||
// Scrollbar theming (xterm 6.0) — derive from foreground color
|
||||
scrollbarSliderBackground: ctx.terminalTheme.colors.foreground + '33', // 20% opacity
|
||||
scrollbarSliderHoverBackground: ctx.terminalTheme.colors.foreground + '66', // 40% opacity
|
||||
scrollbarSliderActiveBackground: ctx.terminalTheme.colors.foreground + '80', // 50% opacity
|
||||
},
|
||||
});
|
||||
|
||||
@@ -307,19 +335,19 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
webglLoaded = true;
|
||||
} catch (webglErr) {
|
||||
logger.warn(
|
||||
"[XTerm] WebGL addon failed, using canvas renderer. Error:",
|
||||
"[XTerm] WebGL addon failed, using DOM renderer. Error:",
|
||||
webglErr instanceof Error ? webglErr.message : webglErr,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
logger.info(
|
||||
"[XTerm] Skipping WebGL addon (canvas preferred for macOS profile or low-memory devices)",
|
||||
"[XTerm] Skipping WebGL addon (DOM preferred for low-memory devices)",
|
||||
);
|
||||
}
|
||||
|
||||
scopedWindow.__xtermWebGLLoaded = webglLoaded;
|
||||
scopedWindow.__xtermRendererPreference = performanceConfig.preferCanvasRenderer
|
||||
? "canvas"
|
||||
scopedWindow.__xtermRendererPreference = performanceConfig.preferDOMRenderer
|
||||
? "dom"
|
||||
: "webgl";
|
||||
|
||||
const webLinksAddon = new WebLinksAddon((event, uri) => {
|
||||
@@ -354,9 +382,10 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
});
|
||||
term.loadAddon(webLinksAddon);
|
||||
|
||||
// Enable Unicode 11 for better Nerd Fonts / Powerline / CJK character width handling
|
||||
term.loadAddon(new Unicode11Addon());
|
||||
term.unicode.activeVersion = '11';
|
||||
// Enable Unicode graphemes for accurate CJK / emoji / Nerd Font character width handling
|
||||
const unicodeGraphemes = new UnicodeGraphemesAddon();
|
||||
term.loadAddon(unicodeGraphemes);
|
||||
term.unicode.activeVersion = '15-graphemes';
|
||||
|
||||
logRenderer();
|
||||
|
||||
@@ -475,7 +504,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
break;
|
||||
}
|
||||
case "clearBuffer": {
|
||||
term.clear();
|
||||
clearTerminalViewport(term);
|
||||
break;
|
||||
}
|
||||
case "searchTerminal": {
|
||||
@@ -562,7 +591,12 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
}
|
||||
} else {
|
||||
// Character mode (default): send immediately
|
||||
ctx.terminalBackend.writeToSession(id, data);
|
||||
// When backspaceBehavior is configured, remap the Backspace key output
|
||||
let outData = data;
|
||||
if (data === "\x7f" && ctx.host.backspaceBehavior === "ctrl-h") {
|
||||
outData = "\x08";
|
||||
}
|
||||
ctx.terminalBackend.writeToSession(id, outData);
|
||||
|
||||
// Local echo for serial connections only when explicitly enabled
|
||||
if (ctx.host.protocol === "serial" && ctx.serialLocalEcho) {
|
||||
@@ -579,7 +613,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
}
|
||||
|
||||
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
|
||||
ctx.onBroadcastInputRef.current(data, ctx.sessionId);
|
||||
// Use remapped data so broadcast peers also receive the correct byte
|
||||
const broadcastData = (data === "\x7f" && ctx.host.backspaceBehavior === "ctrl-h") ? "\x08" : data;
|
||||
ctx.onBroadcastInputRef.current(broadcastData, ctx.sessionId);
|
||||
}
|
||||
|
||||
scrollToBottomAfterInput(data);
|
||||
@@ -611,6 +647,17 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
// OSC 7 format: \x1b]7;file://hostname/path\x07 or \x1b]7;file://hostname/path\x1b\\
|
||||
let currentCwd: string | undefined = undefined;
|
||||
|
||||
const eraseScrollbackDisposable = term.parser.registerCsiHandler({ final: "J" }, (params) => {
|
||||
if (isEraseViewportSequence(params)) {
|
||||
preserveTerminalViewportInScrollback(term);
|
||||
return false;
|
||||
}
|
||||
if (!isEraseScrollbackSequence(params)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Register OSC 7 handler using xterm.js parser
|
||||
// OSC 7 is the standard way for shells to report the current working directory
|
||||
const osc7Disposable = term.parser.registerOscHandler(7, (data) => {
|
||||
@@ -733,6 +780,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
dispose: () => {
|
||||
cleanupMiddleClick?.();
|
||||
keywordHighlighter.dispose();
|
||||
eraseScrollbackDisposable.dispose();
|
||||
osc7Disposable.dispose();
|
||||
osc52Disposable.dispose();
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ArrowLeft, MoreVertical, X } from 'lucide-react';
|
||||
import React, { createContext, ReactNode, useCallback, useContext, useState } from 'react';
|
||||
import React, { createContext, ReactNode, useCallback, useContext, useMemo, useState } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './popover';
|
||||
import { ScrollArea } from './scroll-area';
|
||||
@@ -44,6 +44,12 @@ interface AsidePanelProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
width?: string;
|
||||
layout?: AsidePanelLayout;
|
||||
/**
|
||||
* Optional stable identifier emitted as `data-section` on the panel
|
||||
* root. Used as a targeting hook for Custom CSS (Settings → Appearance).
|
||||
*/
|
||||
dataSection?: string;
|
||||
}
|
||||
|
||||
interface AsidePanelHeaderProps {
|
||||
@@ -171,14 +177,40 @@ interface AsidePanelStackProps {
|
||||
initialItem: AsideContentItem;
|
||||
className?: string;
|
||||
width?: string;
|
||||
layout?: AsidePanelLayout;
|
||||
/**
|
||||
* Optional stable identifier emitted as `data-section` on the panel
|
||||
* root. Used as a targeting hook for Custom CSS.
|
||||
*/
|
||||
dataSection?: string;
|
||||
}
|
||||
|
||||
export type AsidePanelLayout = 'overlay' | 'inline';
|
||||
|
||||
const resolveInlineWidth = (width: string) => {
|
||||
const arbitraryWidthMatch = width.match(/w-\[(.+)\]/);
|
||||
if (arbitraryWidthMatch) {
|
||||
return arbitraryWidthMatch[1];
|
||||
}
|
||||
|
||||
switch (width) {
|
||||
case 'w-full':
|
||||
return '100%';
|
||||
case 'w-screen':
|
||||
return '100vw';
|
||||
default:
|
||||
return '380px';
|
||||
}
|
||||
};
|
||||
|
||||
export const AsidePanelStack: React.FC<AsidePanelStackProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
initialItem,
|
||||
className,
|
||||
width = 'w-[380px]',
|
||||
layout = 'overlay',
|
||||
dataSection,
|
||||
}) => {
|
||||
const [stack, setStack] = useState<AsideContentItem[]>([initialItem]);
|
||||
|
||||
@@ -205,6 +237,13 @@ export const AsidePanelStack: React.FC<AsidePanelStackProps> = ({
|
||||
|
||||
const currentItem = stack[stack.length - 1];
|
||||
const canGoBack = stack.length > 1;
|
||||
const inlineWidth = useMemo(() => resolveInlineWidth(width), [width]);
|
||||
const inlineStyle = layout === 'inline'
|
||||
? ({
|
||||
width: inlineWidth,
|
||||
['--aside-inline-width' as string]: inlineWidth,
|
||||
} as React.CSSProperties)
|
||||
: undefined;
|
||||
|
||||
// Reset stack when panel closes/opens
|
||||
React.useEffect(() => {
|
||||
@@ -218,10 +257,14 @@ export const AsidePanelStack: React.FC<AsidePanelStackProps> = ({
|
||||
return (
|
||||
<AsidePanelContext.Provider value={{ push, pop, replace, clear, canGoBack, currentItem }}>
|
||||
<div className={cn(
|
||||
"absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden",
|
||||
width,
|
||||
layout === 'inline'
|
||||
? "relative split-panel-enter shrink-0 h-full min-h-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden shadow-[-16px_0_32px_hsl(var(--foreground)/0.08)]"
|
||||
: "absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden",
|
||||
layout === 'overlay' && width,
|
||||
className
|
||||
)}>
|
||||
)}
|
||||
style={inlineStyle}
|
||||
data-section={dataSection}>
|
||||
<AsidePanelHeader
|
||||
title={currentItem.title}
|
||||
subtitle={currentItem.subtitle}
|
||||
@@ -248,15 +291,29 @@ export const AsidePanel: React.FC<AsidePanelProps> = ({
|
||||
children,
|
||||
className,
|
||||
width = 'w-[380px]',
|
||||
layout = 'overlay',
|
||||
dataSection,
|
||||
}) => {
|
||||
if (!open) return null;
|
||||
|
||||
const inlineWidth = resolveInlineWidth(width);
|
||||
const inlineStyle = layout === 'inline'
|
||||
? ({
|
||||
width: inlineWidth,
|
||||
['--aside-inline-width' as string]: inlineWidth,
|
||||
} as React.CSSProperties)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden",
|
||||
width,
|
||||
layout === 'inline'
|
||||
? "relative split-panel-enter shrink-0 h-full min-h-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden shadow-[-16px_0_32px_hsl(var(--foreground)/0.08)]"
|
||||
: "absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden",
|
||||
layout === 'overlay' && width,
|
||||
className
|
||||
)}>
|
||||
)}
|
||||
style={inlineStyle}
|
||||
data-section={dataSection}>
|
||||
{title && (
|
||||
<AsidePanelHeader
|
||||
title={title}
|
||||
|
||||
@@ -48,8 +48,19 @@ const DialogContent = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close
|
||||
data-dialog-close="true"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
className="sr-only"
|
||||
>
|
||||
{t("common.close")}
|
||||
</DialogPrimitive.Close>
|
||||
{!hideCloseButton && (
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-md p-1 transition-all hover:bg-muted hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:pointer-events-none text-muted-foreground">
|
||||
<DialogPrimitive.Close
|
||||
data-dialog-close="true"
|
||||
className="absolute right-4 top-4 rounded-md p-1 transition-all hover:bg-muted hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:pointer-events-none text-muted-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">{t("common.close")}</span>
|
||||
</DialogPrimitive.Close>
|
||||
|
||||
@@ -31,7 +31,8 @@ const INHERITABLE_KEYS: (keyof GroupConfig)[] = [
|
||||
'port', 'protocol', 'agentForwarding', 'proxyConfig', 'hostChain', 'startupCommand',
|
||||
'legacyAlgorithms', 'environmentVariables', 'charset', 'moshEnabled', 'moshServerPath',
|
||||
'telnetEnabled', 'telnetPort', 'telnetUsername', 'telnetPassword',
|
||||
'theme', 'themeOverride', 'fontFamily', 'fontFamilyOverride', 'fontSize', 'fontSizeOverride',
|
||||
'theme', 'themeOverride', 'fontFamily', 'fontFamilyOverride', 'fontSize', 'fontSizeOverride', 'fontWeight', 'fontWeightOverride',
|
||||
'backspaceBehavior',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -95,6 +95,8 @@ export interface Host {
|
||||
fontFamilyOverride?: boolean; // Explicitly override the global terminal font family for this host
|
||||
fontSize?: number; // Terminal font size for this host (pt)
|
||||
fontSizeOverride?: boolean; // Explicitly override the global terminal font size for this host
|
||||
fontWeight?: number; // Terminal font weight for this host (100-900)
|
||||
fontWeightOverride?: boolean; // Explicitly override the global terminal font weight for this host
|
||||
distro?: string; // detected distro id (e.g., ubuntu, debian)
|
||||
distroMode?: 'auto' | 'manual'; // whether distro icon comes from detection or manual override
|
||||
manualDistro?: string; // manually selected distro id when distroMode='manual'
|
||||
@@ -117,6 +119,8 @@ export interface Host {
|
||||
keywordHighlightEnabled?: boolean;
|
||||
// Legacy SSH algorithm support for older network equipment (switches, routers)
|
||||
legacyAlgorithms?: boolean;
|
||||
// What the Backspace key sends: undefined = xterm default (no interception), 'ctrl-h' = ^H (0x08)
|
||||
backspaceBehavior?: 'ctrl-h';
|
||||
// Local SSH key file paths (from SSH config IdentityFile or user-added)
|
||||
// Resolved at connection time — the app reads the file content when connecting.
|
||||
identityFilePaths?: string[];
|
||||
@@ -124,6 +128,11 @@ export interface Host {
|
||||
pinned?: boolean;
|
||||
// Timestamp of last successful connection, used for Recently Connected section
|
||||
lastConnectedAt?: number;
|
||||
// Per-session shell override for local terminals (from shell discovery)
|
||||
localShell?: string;
|
||||
localShellArgs?: string[];
|
||||
localShellName?: string;
|
||||
localShellIcon?: string;
|
||||
}
|
||||
|
||||
export type KeyType = 'RSA' | 'ECDSA' | 'ED25519';
|
||||
@@ -213,6 +222,9 @@ export interface GroupConfig {
|
||||
fontFamilyOverride?: boolean;
|
||||
fontSize?: number;
|
||||
fontSizeOverride?: boolean;
|
||||
fontWeight?: number;
|
||||
fontWeightOverride?: boolean;
|
||||
backspaceBehavior?: 'ctrl-h';
|
||||
}
|
||||
|
||||
export interface SyncConfig {
|
||||
@@ -488,7 +500,7 @@ export interface TerminalSettings {
|
||||
osc52Clipboard: 'off' | 'write-only' | 'read-write' | 'prompt'; // OSC-52 clipboard access: off, write-only (default), read-write, or prompt on read
|
||||
|
||||
// Rendering
|
||||
rendererType: 'auto' | 'webgl' | 'canvas'; // Terminal renderer: auto (detect based on hardware), webgl, or canvas
|
||||
rendererType: 'auto' | 'webgl' | 'dom'; // Terminal renderer: auto (detect based on hardware), webgl, or dom
|
||||
|
||||
// Autocomplete
|
||||
autocompleteEnabled: boolean; // Enable terminal command autocomplete
|
||||
@@ -564,8 +576,14 @@ export const normalizeTerminalSettings = (
|
||||
...(settings ?? {}),
|
||||
};
|
||||
|
||||
// Migrate legacy 'canvas' renderer to 'dom' (canvas removed in xterm.js 6.0)
|
||||
const rendererType = (mergedSettings.rendererType as string) === 'canvas'
|
||||
? 'dom' as const
|
||||
: mergedSettings.rendererType;
|
||||
|
||||
return {
|
||||
...mergedSettings,
|
||||
rendererType,
|
||||
autocompleteGhostText: mergedSettings.autocompletePopupMenu
|
||||
? false
|
||||
: mergedSettings.autocompleteGhostText,
|
||||
@@ -663,6 +681,10 @@ export interface TerminalSession {
|
||||
charset?: string; // Connection-time charset override (e.g. for quick-connect serial)
|
||||
// Serial-specific connection settings
|
||||
serialConfig?: SerialConfig;
|
||||
localShell?: string; // Shell command for local terminals (from discovery)
|
||||
localShellArgs?: string[]; // Shell args for local terminals (from discovery)
|
||||
localShellName?: string; // Display name for local shell (e.g., "Zsh", "Ubuntu (WSL)")
|
||||
localShellIcon?: string; // Icon identifier for local shell (e.g., "zsh", "ubuntu")
|
||||
}
|
||||
|
||||
export interface RemoteFile {
|
||||
|
||||
@@ -47,3 +47,15 @@ export const resolveHostTerminalFontFamilyId = (host: Host | null | undefined, d
|
||||
export const resolveHostTerminalFontSize = (host: Host | null | undefined, defaultFontSize: number): number =>
|
||||
hasHostFontSizeOverride(host) && host?.fontSize != null ? host.fontSize : defaultFontSize;
|
||||
|
||||
export const hasHostFontWeightOverride = (host?: Pick<Host, 'fontWeightOverride' | 'fontWeight'> | null): boolean =>
|
||||
hasEffectiveOverride(host?.fontWeightOverride, hasLegacyNumberValue(host?.fontWeight));
|
||||
|
||||
export const clearHostFontWeightOverride = (host: Host): Host => ({
|
||||
...host,
|
||||
fontWeight: undefined,
|
||||
fontWeightOverride: false,
|
||||
});
|
||||
|
||||
export const resolveHostTerminalFontWeight = (host: Host | null | undefined, defaultFontWeight: number): number =>
|
||||
hasHostFontWeightOverride(host) && host?.fontWeight != null ? host.fontWeight : defaultFontWeight;
|
||||
|
||||
|
||||
@@ -155,6 +155,7 @@ const createHost = (input: {
|
||||
label?: string;
|
||||
hostname: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
port?: number;
|
||||
protocol?: Exclude<HostProtocol, "mosh">;
|
||||
group?: string;
|
||||
@@ -167,6 +168,7 @@ const createHost = (input: {
|
||||
hostname: input.hostname.trim(),
|
||||
port: input.port ?? DEFAULT_SSH_PORT,
|
||||
username: input.username?.trim() ?? "",
|
||||
password: input.password || undefined,
|
||||
group: normalizeGroupPath(input.group),
|
||||
tags: (input.tags ?? []).filter(Boolean),
|
||||
os: "linux",
|
||||
@@ -189,6 +191,7 @@ const dedupeHosts = (hosts: Host[]): { hosts: Host[]; duplicates: number } => {
|
||||
duplicates++;
|
||||
const mergedTags = Array.from(new Set([...(existing.tags ?? []), ...(host.tags ?? [])]));
|
||||
existing.tags = mergedTags;
|
||||
if (!existing.password && host.password) existing.password = host.password;
|
||||
if (existing.group == null && host.group != null) existing.group = host.group;
|
||||
if (existing.label === existing.hostname && host.label && host.label !== host.hostname) {
|
||||
existing.label = host.label;
|
||||
@@ -333,6 +336,7 @@ const importFromCsv = (text: string): VaultImportResult => {
|
||||
const protocolIdx = findHeaderIndex(header, ["protocol", "proto", "scheme"]);
|
||||
const portIdx = findHeaderIndex(header, ["port"]);
|
||||
const usernameIdx = findHeaderIndex(header, ["username", "user", "login"]);
|
||||
const passwordIdx = findHeaderIndex(header, ["password", "pass", "passwd"]);
|
||||
|
||||
if (hostnameIdx === -1) {
|
||||
return {
|
||||
@@ -378,12 +382,14 @@ const importFromCsv = (text: string): VaultImportResult => {
|
||||
"ssh";
|
||||
const port = parsePort(portIdx >= 0 ? row[portIdx] : undefined) ?? target.port;
|
||||
const username = (usernameIdx >= 0 ? row[usernameIdx] : undefined)?.trim() || target.username;
|
||||
const password = (passwordIdx >= 0 ? row[passwordIdx] : undefined) || undefined;
|
||||
|
||||
parsedHosts.push(
|
||||
createHost({
|
||||
label,
|
||||
hostname: target.hostname,
|
||||
username,
|
||||
password,
|
||||
port,
|
||||
protocol,
|
||||
group,
|
||||
@@ -993,12 +999,12 @@ export const getVaultCsvTemplate = (
|
||||
opts: VaultCsvTemplateOptions = {},
|
||||
): string => {
|
||||
const includeExampleRows = opts.includeExampleRows !== false;
|
||||
const header = ["Groups", "Label", "Tags", "Hostname/IP", "Protocol", "Port", "Username"];
|
||||
const header = ["Groups", "Label", "Tags", "Hostname/IP", "Protocol", "Port", "Username", "Password"];
|
||||
const rows: string[][] = [header];
|
||||
if (includeExampleRows) {
|
||||
rows.push(["Project/Dev", "Web Server (dev)", "dev,web", "192.168.1.10", "ssh", "22", "root"]);
|
||||
rows.push(["Project/Prod", "Web Server (prod)", "prod,web", "server-a.example.com", "ssh", "22", "ubuntu"]);
|
||||
rows.push(["Database", "DB", "db,mysql", "db.example.com", "ssh", "4567", "admin"]);
|
||||
rows.push(["Project/Dev", "Web Server (dev)", "dev,web", "192.168.1.10", "ssh", "22", "root", ""]);
|
||||
rows.push(["Project/Prod", "Web Server (prod)", "prod,web", "server-a.example.com", "ssh", "22", "ubuntu", ""]);
|
||||
rows.push(["Database", "DB", "db,mysql", "db.example.com", "ssh", "4567", "admin", ""]);
|
||||
}
|
||||
|
||||
const escapeCsv = (value: string) => {
|
||||
@@ -1011,13 +1017,14 @@ export const getVaultCsvTemplate = (
|
||||
};
|
||||
|
||||
const exportHostsToCsv = (hosts: Host[]): string => {
|
||||
const header = ["Groups", "Label", "Tags", "Hostname/IP", "Protocol", "Port", "Username"];
|
||||
const header = ["Groups", "Label", "Tags", "Hostname/IP", "Protocol", "Port", "Username", "Password"];
|
||||
const rows: string[][] = [header];
|
||||
|
||||
const escapeCsv = (value: string) => {
|
||||
const escapeCsv = (value: string, skipFormulaGuard = false) => {
|
||||
// Prevent CSV formula injection by prefixing dangerous characters with a single quote
|
||||
// These characters can be interpreted as formulas by spreadsheet applications
|
||||
if (/^[=+\-@\t\r]/.test(value)) {
|
||||
// Skip for password fields to preserve credentials verbatim for round-trip
|
||||
if (!skipFormulaGuard && /^[=+\-@\t\r]/.test(value)) {
|
||||
value = "'" + value;
|
||||
}
|
||||
if (value.includes('"')) value = value.replace(/"/g, '""');
|
||||
@@ -1059,10 +1066,12 @@ const exportHostsToCsv = (hosts: Host[]): string => {
|
||||
host.protocol ?? "ssh",
|
||||
String(effectivePort),
|
||||
effectiveUsername,
|
||||
host.password ?? "",
|
||||
]);
|
||||
}
|
||||
|
||||
return rows.map((r) => r.map((c) => escapeCsv(c)).join(",")).join("\r\n") + "\r\n";
|
||||
const passwordColIdx = header.indexOf("Password");
|
||||
return rows.map((r, rowIdx) => r.map((c, i) => escapeCsv(c, rowIdx > 0 && i === passwordColIdx)).join(",")).join("\r\n") + "\r\n";
|
||||
};
|
||||
|
||||
interface ExportHostsResult {
|
||||
|
||||
@@ -82,13 +82,13 @@ function resolveCodexAcpBinaryPath(shellEnv, electronModule) {
|
||||
// Packaged build (or dev fallback): use npm-bundled binary
|
||||
try {
|
||||
const pkgName = getCodexPackageName();
|
||||
if (!pkgName) return binaryName;
|
||||
if (!pkgName) return null;
|
||||
|
||||
const pkgRoot = path.dirname(require.resolve("@zed-industries/codex-acp/package.json"));
|
||||
const resolved = require.resolve(`${pkgName}/bin/${binaryName}`, { paths: [pkgRoot] });
|
||||
return toUnpackedAsarPath(resolved);
|
||||
} catch {
|
||||
return binaryName;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -145,6 +145,668 @@ function findEndMarker(outputText, marker) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizePtyOutput(stdout, {
|
||||
stripMarkers = false,
|
||||
expectedPrompt = "",
|
||||
trimOutput = true,
|
||||
stripPrompt = true,
|
||||
markerToStrip = null,
|
||||
} = {}) {
|
||||
let cleaned = stripAnsi(stdout || "").replace(/\r/g, "");
|
||||
if (stripMarkers) {
|
||||
// Prefer the job-specific marker so user output that contains "__NCMCP_"
|
||||
// (e.g. printf '__NCMCP_demo\n') is preserved.
|
||||
const pattern = markerToStrip
|
||||
? new RegExp(`^[^\r\n]*${markerToStrip}[^\r\n]*[\r\n]*`, "gm")
|
||||
: /^[^\r\n]*__NCMCP_[^\r\n]*[\r\n]*/gm;
|
||||
cleaned = cleaned.replace(pattern, "");
|
||||
}
|
||||
const normalizedPrompt = stripAnsi(String(expectedPrompt || "")).replace(/\r/g, "");
|
||||
if (stripPrompt && normalizedPrompt && cleaned.endsWith(normalizedPrompt)) {
|
||||
cleaned = cleaned.slice(0, cleaned.length - normalizedPrompt.length);
|
||||
}
|
||||
return trimOutput ? cleaned.trim() : cleaned;
|
||||
}
|
||||
|
||||
function appendBoundedOutput(current, chunk, maxBufferedChars) {
|
||||
const combined = `${current || ""}${chunk || ""}`;
|
||||
const limit = Number.isFinite(maxBufferedChars) ? Math.max(0, Math.floor(maxBufferedChars)) : 0;
|
||||
if (limit <= 0 || combined.length <= limit) {
|
||||
return { text: combined, dropped: 0 };
|
||||
}
|
||||
const dropped = combined.length - limit;
|
||||
return {
|
||||
text: combined.slice(dropped),
|
||||
dropped,
|
||||
};
|
||||
}
|
||||
|
||||
function consumeVisibleText(carry, chunk) {
|
||||
const input = `${carry || ""}${chunk || ""}`;
|
||||
if (!input) {
|
||||
return { visibleText: "", carry: "" };
|
||||
}
|
||||
|
||||
let visibleText = "";
|
||||
let index = 0;
|
||||
|
||||
while (index < input.length) {
|
||||
const ch = input[index];
|
||||
|
||||
if (ch === "\r") {
|
||||
// Preserve \r so consumers / serializers can collapse progress-bar
|
||||
// redraws to the latest frame. \r\n becomes a single \n.
|
||||
if (input[index + 1] === "\n") {
|
||||
visibleText += "\n";
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
visibleText += "\r";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch !== "\u001b") {
|
||||
visibleText += ch;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (index + 1 >= input.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
const next = input[index + 1];
|
||||
|
||||
if (next === "[") {
|
||||
let cursor = index + 2;
|
||||
let complete = false;
|
||||
while (cursor < input.length) {
|
||||
const code = input.charCodeAt(cursor);
|
||||
if (code >= 0x40 && code <= 0x7e) {
|
||||
index = cursor + 1;
|
||||
complete = true;
|
||||
break;
|
||||
}
|
||||
cursor += 1;
|
||||
}
|
||||
if (!complete) break;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (next === "]") {
|
||||
let cursor = index + 2;
|
||||
let complete = false;
|
||||
while (cursor < input.length) {
|
||||
const oscChar = input[cursor];
|
||||
if (oscChar === "\u0007") {
|
||||
index = cursor + 1;
|
||||
complete = true;
|
||||
break;
|
||||
}
|
||||
if (oscChar === "\u001b") {
|
||||
if (cursor + 1 >= input.length) break;
|
||||
if (input[cursor + 1] === "\\") {
|
||||
index = cursor + 2;
|
||||
complete = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
cursor += 1;
|
||||
}
|
||||
if (!complete) break;
|
||||
continue;
|
||||
}
|
||||
|
||||
visibleText += ch;
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
visibleText,
|
||||
carry: input.slice(index),
|
||||
};
|
||||
}
|
||||
|
||||
function startPtyJob(ptyStream, command, options) {
|
||||
const {
|
||||
stripMarkers = false,
|
||||
trackForCancellation = null,
|
||||
timeoutMs = 60000,
|
||||
shellKind,
|
||||
chatSessionId,
|
||||
abortSignal,
|
||||
expectedPrompt,
|
||||
typedInput = false,
|
||||
echoCommand,
|
||||
maxBufferedChars = 0,
|
||||
normalizeFinalOutput = true,
|
||||
enforceWallTimeout = false,
|
||||
} = options || {};
|
||||
|
||||
const marker = `__NCMCP_${Date.now().toString(36)}_${crypto.randomBytes(16).toString('hex')}__`;
|
||||
const resolvedShellKind = shellKind || "posix";
|
||||
const CANCEL_RETRY_MS = 5000;
|
||||
const CANCEL_WALL_TIMEOUT_MS = 30000;
|
||||
|
||||
let output = "";
|
||||
let foundStart = false;
|
||||
let preStartOutput = "";
|
||||
let visibleOutput = "";
|
||||
let visibleOutputOffset = 0;
|
||||
// Monotonic high-water mark for the visible byte stream. Increases on every
|
||||
// append; never decreases when CR redraws collapse visibleOutput. Used as
|
||||
// the polling nextOffset so callers' offsets stay monotonic.
|
||||
let visibleHighWatermark = 0;
|
||||
let visibleCarry = "";
|
||||
let timeoutId = null;
|
||||
let wallTimeoutId = null;
|
||||
let startupTimeoutId = null;
|
||||
let promptFallbackTimer = null;
|
||||
let cancelRetryTimerId = null;
|
||||
// Track one-shot timers scheduled inside requestCancel so finish() can
|
||||
// clear them when the job exits early; otherwise they keep the Node
|
||||
// event loop alive after the resultPromise has already resolved.
|
||||
const cancelOneShotTimers = [];
|
||||
let cancelRequested = false;
|
||||
let finished = false;
|
||||
let unsubscribe = null;
|
||||
const cleanupFns = [];
|
||||
let pendingStart = "";
|
||||
let resolveResult;
|
||||
const resultPromise = new Promise((resolve) => {
|
||||
resolveResult = resolve;
|
||||
});
|
||||
|
||||
function clearPromptFallback() {
|
||||
if (promptFallbackTimer) {
|
||||
clearTimeout(promptFallbackTimer);
|
||||
promptFallbackTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearCancelRetryTimer() {
|
||||
if (cancelRetryTimerId) {
|
||||
clearTimeout(cancelRetryTimerId);
|
||||
cancelRetryTimerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function armOutputTimeout() {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => {
|
||||
sendInterrupt();
|
||||
if (cancelRequested) {
|
||||
armOutputTimeout();
|
||||
return;
|
||||
}
|
||||
const timeoutSec = Math.round(timeoutMs / 1000);
|
||||
finish(foundStart ? output : preStartOutput, -1, `Command timed out after ${timeoutSec}s without output`);
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
// Hard wall-clock deadline: opt-in via enforceWallTimeout. Used by callers
|
||||
// that have a strict tool-call budget (e.g. MCP terminal_execute, where the
|
||||
// model can fall back to terminal_start). Default is off so existing
|
||||
// foreground execution paths (Catty Agent) keep their inactivity-based
|
||||
// timeout for long-running streaming commands.
|
||||
function armWallTimeout() {
|
||||
if (!enforceWallTimeout || maxBufferedChars > 0) return;
|
||||
wallTimeoutId = setTimeout(() => {
|
||||
if (finished) return;
|
||||
sendInterrupt();
|
||||
const timeoutSec = Math.round(timeoutMs / 1000);
|
||||
finish(foundStart ? output : preStartOutput, -1, `Command timed out (${timeoutSec}s)`);
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
// Bounded startup deadline: we always need a hard limit on how long we
|
||||
// wait for the wrapped command's start marker. Otherwise an already-chatty
|
||||
// PTY (e.g. a tab running tail -f) would let onData re-arm the inactivity
|
||||
// timer forever before _S arrives, hanging the call and the session lock.
|
||||
// Foreground execs use the configured timeoutMs as the deadline (matching
|
||||
// the pre-PR behavior); background jobs use a fixed 30s since their main
|
||||
// timeout is much longer (1 hour) and meant for the actual command.
|
||||
const BG_STARTUP_TIMEOUT_MS = 30000;
|
||||
function armStartupTimeout() {
|
||||
const startupMs = maxBufferedChars > 0 ? BG_STARTUP_TIMEOUT_MS : timeoutMs;
|
||||
startupTimeoutId = setTimeout(() => {
|
||||
if (finished || foundStart) return;
|
||||
sendInterrupt();
|
||||
const label = maxBufferedChars > 0 ? "Background job startup" : "Command startup";
|
||||
finish(preStartOutput, -1, `${label} timed out — start marker never arrived`);
|
||||
}, startupMs);
|
||||
}
|
||||
function clearStartupTimeout() {
|
||||
if (startupTimeoutId) {
|
||||
clearTimeout(startupTimeoutId);
|
||||
startupTimeoutId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function sendInterrupt() {
|
||||
try {
|
||||
if (typeof ptyStream.signal === "function") {
|
||||
ptyStream.signal("INT");
|
||||
}
|
||||
} catch {
|
||||
// Ignore signal failures and fall back to ETX.
|
||||
}
|
||||
try {
|
||||
if (typeof ptyStream.write === "function") {
|
||||
ptyStream.write("\x03");
|
||||
}
|
||||
} catch {
|
||||
// Ignore PTY write failures during cancellation.
|
||||
}
|
||||
}
|
||||
|
||||
function requestCancel() {
|
||||
if (finished || cancelRequested) return;
|
||||
cancelRequested = true;
|
||||
clearPromptFallback();
|
||||
clearCancelRetryTimer();
|
||||
// Cancel the startup timer too — otherwise a pre-start cancel resolves
|
||||
// as "Background job startup timed out" instead of "Cancelled".
|
||||
clearStartupTimeout();
|
||||
// For pre-start cancellation on sessions without a known idle prompt,
|
||||
// schedule a short fallback to finish the job after Ctrl+C has had time
|
||||
// to take effect. Without this, the cancel waits the full forced-cancel
|
||||
// window even though the shell may have returned to idle quickly.
|
||||
if (!foundStart && !expectedPrompt) {
|
||||
const t = setTimeout(() => {
|
||||
if (finished || foundStart) return;
|
||||
finish(preStartOutput, 130, "Cancelled");
|
||||
}, 2000);
|
||||
cancelOneShotTimers.push(t);
|
||||
}
|
||||
sendInterrupt();
|
||||
cancelRetryTimerId = setTimeout(function retryCancel() {
|
||||
if (finished || !cancelRequested) return;
|
||||
sendInterrupt();
|
||||
cancelRetryTimerId = setTimeout(retryCancel, CANCEL_RETRY_MS);
|
||||
}, CANCEL_RETRY_MS);
|
||||
armOutputTimeout();
|
||||
const t150 = setTimeout(() => {
|
||||
if (!finished) sendInterrupt();
|
||||
}, 150);
|
||||
cancelOneShotTimers.push(t150);
|
||||
// Hard wall-clock deadline for cancellation: if the process ignores
|
||||
// Ctrl+C and never redraws the prompt, force-finish after a bounded
|
||||
// period so the session is not stuck in "stopping" forever.
|
||||
// Mark as "forced" so callers can tell the shell may still be busy.
|
||||
const tWall = setTimeout(() => {
|
||||
if (!finished) {
|
||||
finish(foundStart ? output : preStartOutput, 130, "Cancelled (forced — process may still be running)");
|
||||
}
|
||||
}, CANCEL_WALL_TIMEOUT_MS);
|
||||
cancelOneShotTimers.push(tWall);
|
||||
}
|
||||
|
||||
function schedulePromptFallback() {
|
||||
clearPromptFallback();
|
||||
if (!hasExpectedPromptSuffix(output, expectedPrompt)) return;
|
||||
// Background jobs use a much longer delay (30s) so commands that open
|
||||
// child shells / REPLs with the same prompt have time to print past
|
||||
// their initial prompt and avoid being misdetected as completed.
|
||||
// Foreground execs use 250ms to match the pre-PR behavior.
|
||||
const delayMs = maxBufferedChars > 0 ? 30000 : 250;
|
||||
promptFallbackTimer = setTimeout(() => {
|
||||
if (!hasExpectedPromptSuffix(output, expectedPrompt)) return;
|
||||
finish(output, null, null);
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
function checkEnd() {
|
||||
const found = findEndMarker(output, marker);
|
||||
if (!found) return;
|
||||
const stdout = output.slice(0, found.endIdx);
|
||||
finish(stdout, found.exitCode);
|
||||
}
|
||||
|
||||
// Carry buffer for incomplete marker lines split across chunks.
|
||||
let visibleMarkerCarry = "";
|
||||
|
||||
// Note: we intentionally do NOT collapse CR redraws in visibleOutput.
|
||||
// Doing so makes polling offsets non-monotonic and can drop finalized
|
||||
// lines after a CR rewrite. Instead, the buffer stores raw bytes
|
||||
// (including \r) and the bounded-buffer cap (256KB) keeps progress-bar
|
||||
// accumulation under control. Consumers that want a "collapsed" view
|
||||
// can apply CR processing themselves.
|
||||
|
||||
function appendToVisible(text) {
|
||||
if (!text) return;
|
||||
const normalized = consumeVisibleText(visibleCarry, text);
|
||||
visibleCarry = normalized.carry;
|
||||
if (!normalized.visibleText) return;
|
||||
|
||||
let cleanVisible = normalized.visibleText;
|
||||
if (maxBufferedChars > 0) {
|
||||
// Rejoin with any incomplete line from the previous chunk so marker
|
||||
// lines split across PTY data boundaries are matched as a whole.
|
||||
cleanVisible = visibleMarkerCarry + cleanVisible;
|
||||
visibleMarkerCarry = "";
|
||||
// We must withhold any trailing line that *might* be the start of an
|
||||
// internal marker line, even if the random marker token isn't fully
|
||||
// present yet (the chunk boundary may split the marker mid-token).
|
||||
// Detect this by looking for the constant prefix "__NCMCP_" — only
|
||||
// user output that *contains an unrelated __NCMCP_ string and ends
|
||||
// with a newline* will be preserved through the next strip step.
|
||||
const NCMCP_PREFIX = "__NCMCP_";
|
||||
const lastNl = cleanVisible.lastIndexOf("\n");
|
||||
if (lastNl === -1) {
|
||||
if (cleanVisible.includes(NCMCP_PREFIX)) {
|
||||
visibleMarkerCarry = cleanVisible;
|
||||
return;
|
||||
}
|
||||
} else if (lastNl < cleanVisible.length - 1) {
|
||||
const trailing = cleanVisible.slice(lastNl + 1);
|
||||
if (trailing.includes(NCMCP_PREFIX)) {
|
||||
visibleMarkerCarry = trailing;
|
||||
cleanVisible = cleanVisible.slice(0, lastNl + 1);
|
||||
}
|
||||
}
|
||||
// Strip only this job's specific marker lines so user output that
|
||||
// happens to contain "__NCMCP_" (e.g. printf '__NCMCP_demo\n') is
|
||||
// preserved.
|
||||
cleanVisible = cleanVisible.replace(new RegExp(`^[^\r\n]*${marker}[^\r\n]*[\r\n]*`, "gm"), "");
|
||||
if (!cleanVisible) return;
|
||||
}
|
||||
visibleHighWatermark += cleanVisible.length;
|
||||
const next = appendBoundedOutput(visibleOutput, cleanVisible, maxBufferedChars);
|
||||
visibleOutput = next.text;
|
||||
visibleOutputOffset += next.dropped;
|
||||
}
|
||||
|
||||
function appendToOutput(text) {
|
||||
if (!text) return;
|
||||
const next = appendBoundedOutput(output, text, maxBufferedChars);
|
||||
output = next.text;
|
||||
appendToVisible(text);
|
||||
}
|
||||
|
||||
function finish(stdout, exitCode, error) {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
clearTimeout(timeoutId);
|
||||
clearTimeout(wallTimeoutId);
|
||||
clearStartupTimeout();
|
||||
clearPromptFallback();
|
||||
clearCancelRetryTimer();
|
||||
// Clear any pending one-shot cancel timers so they do not keep the
|
||||
// Node event loop alive after the job has resolved.
|
||||
while (cancelOneShotTimers.length) {
|
||||
clearTimeout(cancelOneShotTimers.pop());
|
||||
}
|
||||
unsubscribe?.();
|
||||
for (const fn of cleanupFns) {
|
||||
try {
|
||||
fn();
|
||||
} catch {
|
||||
// Ignore cleanup failures
|
||||
}
|
||||
}
|
||||
if (trackForCancellation) {
|
||||
trackForCancellation.delete(marker);
|
||||
}
|
||||
|
||||
// Flush any incomplete marker carry — if it wasn't this job's marker, append it.
|
||||
if (visibleMarkerCarry) {
|
||||
const leftover = visibleMarkerCarry.replace(new RegExp(`^[^\r\n]*${marker}[^\r\n]*[\r\n]*`, "gm"), "");
|
||||
visibleMarkerCarry = "";
|
||||
if (leftover) {
|
||||
const next = appendBoundedOutput(visibleOutput, leftover, maxBufferedChars);
|
||||
visibleOutput = next.text;
|
||||
visibleOutputOffset += next.dropped;
|
||||
}
|
||||
}
|
||||
|
||||
// For background jobs (maxBufferedChars > 0), use the already-stripped
|
||||
// visibleOutput so completion offsets are consistent with polling offsets.
|
||||
// Re-normalizing from the raw buffer would produce a shorter result because
|
||||
// ANSI codes inflate the raw buffer, causing it to truncate earlier.
|
||||
let cleaned;
|
||||
let outputBaseOffset;
|
||||
let totalOutputChars;
|
||||
if (maxBufferedChars > 0 && foundStart) {
|
||||
// Always strip this job's markers from the visible buffer — it accumulates
|
||||
// raw PTY data including the end-marker line that must not leak to callers.
|
||||
const strippedVisible = normalizePtyOutput(visibleOutput, {
|
||||
stripMarkers: true,
|
||||
markerToStrip: marker,
|
||||
expectedPrompt,
|
||||
trimOutput: normalizeFinalOutput,
|
||||
stripPrompt: true,
|
||||
});
|
||||
cleaned = strippedVisible;
|
||||
outputBaseOffset = visibleOutputOffset;
|
||||
totalOutputChars = outputBaseOffset + visibleOutput.length;
|
||||
} else {
|
||||
const visibleStdout = normalizePtyOutput(stdout, {
|
||||
stripMarkers,
|
||||
markerToStrip: marker,
|
||||
expectedPrompt,
|
||||
trimOutput: false,
|
||||
stripPrompt: true,
|
||||
});
|
||||
cleaned = normalizeFinalOutput
|
||||
? normalizePtyOutput(stdout, {
|
||||
stripMarkers,
|
||||
markerToStrip: marker,
|
||||
expectedPrompt,
|
||||
trimOutput: true,
|
||||
stripPrompt: true,
|
||||
})
|
||||
: visibleStdout;
|
||||
outputBaseOffset = foundStart ? visibleOutputOffset : 0;
|
||||
totalOutputChars = outputBaseOffset + visibleStdout.length;
|
||||
}
|
||||
const finalError = (!error && cancelRequested) ? "Cancelled" : error;
|
||||
const finalExitCode = finalError === "Cancelled" ? (exitCode ?? 130) : exitCode;
|
||||
if (finalError) {
|
||||
resolveResult({
|
||||
ok: false,
|
||||
stdout: cleaned,
|
||||
stderr: "",
|
||||
exitCode: finalExitCode ?? -1,
|
||||
error: finalError,
|
||||
outputBaseOffset,
|
||||
totalOutputChars,
|
||||
outputTruncated: outputBaseOffset > 0,
|
||||
});
|
||||
} else {
|
||||
resolveResult({
|
||||
ok: exitCode === 0 || exitCode === null,
|
||||
stdout: cleaned,
|
||||
stderr: "",
|
||||
exitCode: finalExitCode ?? 0,
|
||||
outputBaseOffset,
|
||||
totalOutputChars,
|
||||
outputTruncated: outputBaseOffset > 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onData(data) {
|
||||
const text = data.toString();
|
||||
armOutputTimeout();
|
||||
|
||||
if (!foundStart) {
|
||||
preStartOutput += text;
|
||||
// Cap preStartOutput for background jobs so a noisy idle PTY can't
|
||||
// accumulate megabytes before the start marker arrives. We only need
|
||||
// enough tail to find the marker boundary.
|
||||
if (maxBufferedChars > 0 && preStartOutput.length > maxBufferedChars) {
|
||||
preStartOutput = preStartOutput.slice(preStartOutput.length - maxBufferedChars);
|
||||
}
|
||||
const combined = pendingStart + text;
|
||||
pendingStart = "";
|
||||
const startMarker = marker + "_S";
|
||||
let matched = false;
|
||||
|
||||
const lines = combined.split(/\r?\n/);
|
||||
const trailingPartial = /[\r\n]$/.test(combined) ? "" : lines.pop() || "";
|
||||
for (const line of lines) {
|
||||
if (stripAnsi(line).trim() === startMarker) {
|
||||
foundStart = true;
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
pendingStart = trailingPartial;
|
||||
|
||||
if (foundStart) {
|
||||
clearStartupTimeout();
|
||||
// Use the *last* occurrence of the start marker to skip the echoed
|
||||
// wrapper command and capture only output after the real printf line.
|
||||
const markerPattern = new RegExp(`${marker}_S[^\n\r]*(?:\r?\n|$)`, "g");
|
||||
let boundary = -1;
|
||||
let m;
|
||||
while ((m = markerPattern.exec(preStartOutput)) !== null) {
|
||||
boundary = m.index;
|
||||
}
|
||||
if (boundary !== -1) {
|
||||
const afterBoundary = preStartOutput.slice(boundary);
|
||||
const firstNl = afterBoundary.search(/\r?\n/);
|
||||
const initialOutput = firstNl === -1 ? "" : afterBoundary.slice(firstNl).replace(/^\r?\n/, "");
|
||||
output = "";
|
||||
visibleOutput = "";
|
||||
visibleOutputOffset = 0;
|
||||
visibleCarry = "";
|
||||
appendToOutput(initialOutput);
|
||||
}
|
||||
preStartOutput = "";
|
||||
schedulePromptFallback();
|
||||
checkEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!matched) {
|
||||
const fallbackEnd = findEndMarker(preStartOutput, marker);
|
||||
if (fallbackEnd) {
|
||||
let stdout = preStartOutput.slice(0, fallbackEnd.endIdx);
|
||||
const lastStartIdx = stdout.lastIndexOf(startMarker);
|
||||
if (lastStartIdx !== -1) {
|
||||
const nlAfterStart = stdout.indexOf("\n", lastStartIdx);
|
||||
if (nlAfterStart !== -1) {
|
||||
stdout = stdout.slice(nlAfterStart + 1);
|
||||
}
|
||||
}
|
||||
finish(stdout, fallbackEnd.exitCode);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// If we're cancelling a still-queued command and the shell has returned
|
||||
// to its idle prompt, finish immediately as Cancelled instead of waiting
|
||||
// for the cancel wall-clock timer.
|
||||
if (cancelRequested && hasExpectedPromptSuffix(preStartOutput, expectedPrompt)) {
|
||||
finish(preStartOutput, 130, "Cancelled");
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
appendToOutput(text);
|
||||
if (!cancelRequested) {
|
||||
schedulePromptFallback();
|
||||
} else if (hasExpectedPromptSuffix(output, expectedPrompt)) {
|
||||
finish(output, 130, "Cancelled");
|
||||
return;
|
||||
}
|
||||
checkEnd();
|
||||
}
|
||||
|
||||
if (abortSignal?.aborted) {
|
||||
finish("", -1, "Cancelled");
|
||||
return {
|
||||
marker,
|
||||
cancel: () => {},
|
||||
getSnapshot: () => ({ stdout: "", status: "cancelled", foundStart: false }),
|
||||
resultPromise,
|
||||
};
|
||||
}
|
||||
|
||||
armOutputTimeout();
|
||||
armWallTimeout();
|
||||
armStartupTimeout();
|
||||
|
||||
unsubscribe = subscribeToPtyData(ptyStream, onData);
|
||||
|
||||
const cancel = () => {
|
||||
requestCancel();
|
||||
};
|
||||
|
||||
if (trackForCancellation) {
|
||||
trackForCancellation.set(marker, {
|
||||
ptyStream,
|
||||
chatSessionId: chatSessionId || null,
|
||||
cancel,
|
||||
cleanup: () => {
|
||||
clearTimeout(timeoutId);
|
||||
unsubscribe?.();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof ptyStream.on === "function") {
|
||||
const onClose = () => finish(foundStart ? output : preStartOutput, null, cancelRequested ? "Cancelled" : "Stream closed unexpectedly");
|
||||
const onError = (err) => finish(foundStart ? output : preStartOutput, -1, cancelRequested ? "Cancelled" : `Stream error: ${err?.message || err}`);
|
||||
ptyStream.on("close", onClose);
|
||||
ptyStream.on("end", onClose);
|
||||
ptyStream.on("error", onError);
|
||||
cleanupFns.push(() => {
|
||||
try { ptyStream.removeListener("close", onClose); } catch {}
|
||||
try { ptyStream.removeListener("end", onClose); } catch {}
|
||||
try { ptyStream.removeListener("error", onError); } catch {}
|
||||
});
|
||||
}
|
||||
if (typeof ptyStream.onExit === "function") {
|
||||
const disposable = ptyStream.onExit(() => finish(foundStart ? output : preStartOutput, null, cancelRequested ? "Cancelled" : "Process exited"));
|
||||
cleanupFns.push(() => {
|
||||
try {
|
||||
disposable?.dispose?.();
|
||||
} catch {
|
||||
// Ignore cleanup failures
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (abortSignal) {
|
||||
const onAbort = () => {
|
||||
requestCancel();
|
||||
};
|
||||
abortSignal.addEventListener("abort", onAbort, { once: true });
|
||||
cleanupFns.push(() => abortSignal.removeEventListener("abort", onAbort));
|
||||
}
|
||||
|
||||
if (typedInput && typeof echoCommand === "function") {
|
||||
try {
|
||||
echoCommand(command);
|
||||
} catch {
|
||||
// Ignore synthetic echo failures.
|
||||
}
|
||||
}
|
||||
|
||||
ptyStream.write(buildWrappedCommand(command, resolvedShellKind, marker));
|
||||
|
||||
return {
|
||||
marker,
|
||||
cancel,
|
||||
// Until the start marker arrives, return empty stdout/zero offsets so
|
||||
// an early poll cannot advance nextOffset past pre-start PTY noise that
|
||||
// gets discarded once the real command begins.
|
||||
getSnapshot: () => ({
|
||||
stdout: foundStart ? visibleOutput : "",
|
||||
outputBaseOffset: foundStart ? visibleOutputOffset : 0,
|
||||
totalOutputChars: foundStart ? visibleOutputOffset + visibleOutput.length : 0,
|
||||
outputTruncated: foundStart ? visibleOutputOffset > 0 : false,
|
||||
status: finished ? "finished" : (cancelRequested ? "stopping" : "running"),
|
||||
foundStart,
|
||||
}),
|
||||
resultPromise,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute command through a terminal PTY stream.
|
||||
* The user sees the command typed and output in their terminal.
|
||||
@@ -163,228 +825,7 @@ function findEndMarker(outputText, marker) {
|
||||
* @param {(command: string) => void} [options.echoCommand] - Callback used to display synthetic command echo
|
||||
*/
|
||||
function execViaPty(ptyStream, command, options) {
|
||||
const {
|
||||
stripMarkers = false,
|
||||
trackForCancellation = null,
|
||||
timeoutMs = 60000,
|
||||
shellKind,
|
||||
chatSessionId,
|
||||
abortSignal,
|
||||
expectedPrompt,
|
||||
typedInput = false,
|
||||
echoCommand,
|
||||
} = options || {};
|
||||
|
||||
const marker = `__NCMCP_${Date.now().toString(36)}_${crypto.randomBytes(16).toString('hex')}__`;
|
||||
const resolvedShellKind = shellKind || "posix";
|
||||
|
||||
// Fast-path: already aborted before we even start
|
||||
if (abortSignal?.aborted) {
|
||||
return Promise.resolve({ ok: false, stdout: "", stderr: "", exitCode: -1, error: "Cancelled" });
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let output = "";
|
||||
let foundStart = false;
|
||||
let preStartOutput = "";
|
||||
let timeoutId = null;
|
||||
let promptFallbackTimer = null;
|
||||
let finished = false;
|
||||
let unsubscribe = null;
|
||||
const cleanupFns = [];
|
||||
|
||||
// Buffer for incomplete line data when searching for start marker.
|
||||
// SSH channels can split data at arbitrary byte boundaries, so the
|
||||
// start marker may arrive across two chunks. We keep the content
|
||||
// after the last \n (i.e. the current incomplete line) and prepend
|
||||
// it to the next chunk so indexOf can match the full marker.
|
||||
let pendingStart = "";
|
||||
|
||||
const onData = (data) => {
|
||||
const text = data.toString();
|
||||
|
||||
if (!foundStart) {
|
||||
preStartOutput += text;
|
||||
const combined = pendingStart + text;
|
||||
pendingStart = "";
|
||||
const startMarker = marker + "_S";
|
||||
let matched = false;
|
||||
let pos = 0;
|
||||
while (pos < combined.length) {
|
||||
const idx = combined.indexOf(startMarker, pos);
|
||||
if (idx === -1) break;
|
||||
if (idx === 0 || combined[idx - 1] === '\n' || combined[idx - 1] === '\r') {
|
||||
foundStart = true;
|
||||
matched = true;
|
||||
const afterMarker = combined.slice(idx);
|
||||
const nlIdx = afterMarker.indexOf("\n");
|
||||
if (nlIdx !== -1) {
|
||||
output += afterMarker.slice(nlIdx + 1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
pos = idx + 1;
|
||||
}
|
||||
if (!matched) {
|
||||
// Keep the last incomplete line for cross-chunk matching
|
||||
const lastNl = combined.lastIndexOf("\n");
|
||||
pendingStart = lastNl === -1 ? combined : combined.slice(lastNl + 1);
|
||||
}
|
||||
if (foundStart) {
|
||||
preStartOutput = "";
|
||||
schedulePromptFallback();
|
||||
checkEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: if strict start-marker detection missed (e.g. due shell
|
||||
// control sequence prefixes), still complete as soon as we observe a
|
||||
// valid end marker with exit code.
|
||||
const fallbackEnd = findEndMarker(preStartOutput, marker);
|
||||
if (fallbackEnd) {
|
||||
let stdout = preStartOutput.slice(0, fallbackEnd.endIdx);
|
||||
const lastStartIdx = stdout.lastIndexOf(startMarker);
|
||||
if (lastStartIdx !== -1) {
|
||||
const nlAfterStart = stdout.indexOf("\n", lastStartIdx);
|
||||
if (nlAfterStart !== -1) {
|
||||
stdout = stdout.slice(nlAfterStart + 1);
|
||||
}
|
||||
}
|
||||
finish(stdout, fallbackEnd.exitCode);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
output += text;
|
||||
schedulePromptFallback();
|
||||
checkEnd();
|
||||
};
|
||||
|
||||
function clearPromptFallback() {
|
||||
if (promptFallbackTimer) {
|
||||
clearTimeout(promptFallbackTimer);
|
||||
promptFallbackTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function schedulePromptFallback() {
|
||||
clearPromptFallback();
|
||||
if (!hasExpectedPromptSuffix(output, expectedPrompt)) return;
|
||||
|
||||
// Fallback for shells that visibly return to the same idle prompt but
|
||||
// never emit the wrapped end marker line.
|
||||
promptFallbackTimer = setTimeout(() => {
|
||||
if (!hasExpectedPromptSuffix(output, expectedPrompt)) return;
|
||||
finish(output, null, null);
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function checkEnd() {
|
||||
// Look for the end marker at a line boundary (actual printf output),
|
||||
// not inside the echo of the printf command argument.
|
||||
const found = findEndMarker(output, marker);
|
||||
if (!found) return;
|
||||
const stdout = output.slice(0, found.endIdx);
|
||||
finish(stdout, found.exitCode);
|
||||
}
|
||||
|
||||
function finish(stdout, exitCode, error) {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
clearTimeout(timeoutId);
|
||||
clearPromptFallback();
|
||||
unsubscribe?.();
|
||||
for (const fn of cleanupFns) { try { fn(); } catch { /* ignore */ } }
|
||||
if (trackForCancellation) {
|
||||
trackForCancellation.delete(marker);
|
||||
}
|
||||
|
||||
let cleaned = stripAnsi(stdout || "").replace(/\r/g, "");
|
||||
if (stripMarkers) {
|
||||
cleaned = cleaned.replace(/^[^\r\n]*__NCMCP_[^\r\n]*[\r\n]*/gm, "");
|
||||
}
|
||||
const normalizedPrompt = stripAnsi(String(expectedPrompt || "")).replace(/\r/g, "");
|
||||
if (normalizedPrompt && cleaned.endsWith(normalizedPrompt)) {
|
||||
cleaned = cleaned.slice(0, cleaned.length - normalizedPrompt.length);
|
||||
}
|
||||
cleaned = cleaned.trim();
|
||||
if (error) {
|
||||
resolve({ ok: false, stdout: cleaned, stderr: "", exitCode: exitCode ?? -1, error });
|
||||
} else {
|
||||
resolve({
|
||||
ok: exitCode === 0 || exitCode === null,
|
||||
stdout: cleaned,
|
||||
stderr: "",
|
||||
exitCode: exitCode ?? 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
// Send Ctrl+C to kill the timed-out command
|
||||
if (typeof ptyStream.write === "function") ptyStream.write("\x03");
|
||||
const timeoutSec = Math.round(timeoutMs / 1000);
|
||||
finish(output, -1, `Command timed out (${timeoutSec}s)`);
|
||||
}, timeoutMs);
|
||||
|
||||
unsubscribe = subscribeToPtyData(ptyStream, onData);
|
||||
|
||||
// Register for cancellation if tracking map provided
|
||||
if (trackForCancellation) {
|
||||
trackForCancellation.set(marker, {
|
||||
ptyStream,
|
||||
chatSessionId: chatSessionId || null,
|
||||
cancel: () => {
|
||||
if (typeof ptyStream.write === "function") ptyStream.write("\x03");
|
||||
finish(output, -1, "Cancelled");
|
||||
},
|
||||
cleanup: () => {
|
||||
clearTimeout(timeoutId);
|
||||
unsubscribe?.();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Stream close/error detection — resolve immediately instead of waiting for timeout
|
||||
if (typeof ptyStream.on === "function") {
|
||||
const onClose = () => finish(output, null, "Stream closed unexpectedly");
|
||||
const onError = (err) => finish(output, -1, `Stream error: ${err?.message || err}`);
|
||||
ptyStream.on("close", onClose);
|
||||
ptyStream.on("end", onClose);
|
||||
ptyStream.on("error", onError);
|
||||
cleanupFns.push(() => {
|
||||
try { ptyStream.removeListener("close", onClose); } catch { /* */ }
|
||||
try { ptyStream.removeListener("end", onClose); } catch { /* */ }
|
||||
try { ptyStream.removeListener("error", onError); } catch { /* */ }
|
||||
});
|
||||
}
|
||||
// node-pty uses onExit instead of close/end
|
||||
if (typeof ptyStream.onExit === "function") {
|
||||
const disposable = ptyStream.onExit(() => finish(output, null, "Process exited"));
|
||||
cleanupFns.push(() => { try { disposable?.dispose?.(); } catch { /* */ } });
|
||||
}
|
||||
|
||||
// AbortSignal handling — send Ctrl+C and resolve when aborted
|
||||
if (abortSignal) {
|
||||
const onAbort = () => {
|
||||
if (typeof ptyStream.write === "function") ptyStream.write("\x03");
|
||||
finish(output, -1, "Cancelled");
|
||||
};
|
||||
abortSignal.addEventListener("abort", onAbort, { once: true });
|
||||
cleanupFns.push(() => abortSignal.removeEventListener("abort", onAbort));
|
||||
}
|
||||
|
||||
if (typedInput && typeof echoCommand === "function") {
|
||||
try {
|
||||
echoCommand(command);
|
||||
} catch {
|
||||
// Ignore synthetic echo failures.
|
||||
}
|
||||
}
|
||||
|
||||
// Markers are filtered from terminal display by preload.cjs.
|
||||
ptyStream.write(buildWrappedCommand(command, resolvedShellKind, marker));
|
||||
});
|
||||
return startPtyJob(ptyStream, command, options).resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -659,6 +1100,7 @@ execViaRawPty._seq = 0;
|
||||
|
||||
module.exports = {
|
||||
execViaPty,
|
||||
startPtyJob,
|
||||
execViaChannel,
|
||||
execViaRawPty,
|
||||
detectShellKind,
|
||||
|
||||
@@ -1029,6 +1029,19 @@ function registerHandlers(ipcMain) {
|
||||
return { ok: false, error: "Session not found" };
|
||||
}
|
||||
|
||||
// Honor the per-session execution lock so this IPC path does not race with
|
||||
// long-running background jobs started via terminal_start.
|
||||
const busyErr = mcpServerBridge.getSessionBusyError?.(sessionId);
|
||||
if (busyErr) return busyErr;
|
||||
const reservation = mcpServerBridge.reserveSessionExecution?.(sessionId, "exec");
|
||||
if (reservation && !reservation.ok) return reservation;
|
||||
const sessionToken = reservation?.token;
|
||||
const releaseLock = () => {
|
||||
if (sessionToken) {
|
||||
try { mcpServerBridge.releaseSessionExecution?.(sessionId, sessionToken); } catch {}
|
||||
}
|
||||
};
|
||||
|
||||
// Look up device type from metadata (set by renderer from Host.deviceType).
|
||||
// Mosh sessions use a shell-backed PTY, so network device mode only applies to SSH/serial.
|
||||
// Prefer session.protocol (runtime truth) over meta.protocol (renderer hint)
|
||||
@@ -1043,12 +1056,26 @@ function registerHandlers(ipcMain) {
|
||||
if (!isNetworkDevice) {
|
||||
const safety = mcpServerBridge.checkCommandSafety(command);
|
||||
if (safety.blocked) {
|
||||
releaseLock();
|
||||
return { ok: false, error: `Command blocked by safety policy. Pattern: ${safety.matchedPattern}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: ensure the session lock is released once the promise settles
|
||||
// (or immediately on a synchronous error/early return).
|
||||
const withLockRelease = (factory) => {
|
||||
try {
|
||||
const result = factory();
|
||||
return Promise.resolve(result).finally(releaseLock);
|
||||
} catch (err) {
|
||||
releaseLock();
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
if ((session.protocol === "local" || session.type === "local") && session.shellKind === "unknown") {
|
||||
releaseLock();
|
||||
return {
|
||||
ok: false,
|
||||
error: "AI execution is not supported for this local shell executable. Configure the local terminal to use bash/zsh/sh, fish, PowerShell/pwsh, or cmd.exe.",
|
||||
@@ -1062,18 +1089,18 @@ function registerHandlers(ipcMain) {
|
||||
if (isNetworkDevice && ptyStream && typeof ptyStream.write === "function") {
|
||||
const { execViaRawPty } = require("./ai/ptyExec.cjs");
|
||||
const timeoutMs = mcpServerBridge.getCommandTimeoutMs ? mcpServerBridge.getCommandTimeoutMs() : 60000;
|
||||
return execViaRawPty(ptyStream, command, {
|
||||
return withLockRelease(() => execViaRawPty(ptyStream, command, {
|
||||
timeoutMs,
|
||||
trackForCancellation: mcpServerBridge.activePtyExecs,
|
||||
chatSessionId,
|
||||
encoding: "utf8", // SSH PTY streams use UTF-8, not latin1
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
// Prefer PTY stream (visible in terminal)
|
||||
if (ptyStream && typeof ptyStream.write === "function") {
|
||||
const timeoutMs = mcpServerBridge.getCommandTimeoutMs ? mcpServerBridge.getCommandTimeoutMs() : 60000;
|
||||
return execViaPty(ptyStream, command, {
|
||||
return withLockRelease(() => execViaPty(ptyStream, command, {
|
||||
stripMarkers: true,
|
||||
trackForCancellation: mcpServerBridge.activePtyExecs,
|
||||
timeoutMs,
|
||||
@@ -1089,11 +1116,16 @@ function registerHandlers(ipcMain) {
|
||||
syntheticEcho: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
// Catty Agent has no terminal_start fallback for long-running
|
||||
// commands, so do NOT enforce a hard wall-clock timeout here.
|
||||
// The inactivity timeout still applies, so genuinely hung
|
||||
// processes are still terminated.
|
||||
}));
|
||||
}
|
||||
|
||||
// Network devices require an interactive PTY for raw command execution.
|
||||
if (isNetworkDevice) {
|
||||
releaseLock();
|
||||
return { ok: false, error: "Network device session has no writable PTY stream for command execution" };
|
||||
}
|
||||
|
||||
@@ -1102,27 +1134,29 @@ function registerHandlers(ipcMain) {
|
||||
if (sshClient && typeof sshClient.exec === "function") {
|
||||
const { execViaChannel } = require("./ai/ptyExec.cjs");
|
||||
const channelTimeoutMs = mcpServerBridge.getCommandTimeoutMs ? mcpServerBridge.getCommandTimeoutMs() : 60000;
|
||||
return execViaChannel(sshClient, command, {
|
||||
return withLockRelease(() => execViaChannel(sshClient, command, {
|
||||
timeoutMs: channelTimeoutMs,
|
||||
trackForCancellation: mcpServerBridge.activePtyExecs,
|
||||
chatSessionId,
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
// Serial port: raw command execution (no shell wrapping)
|
||||
if (session.protocol === "serial" && session.serialPort && typeof session.serialPort.write === "function") {
|
||||
const { execViaRawPty } = require("./ai/ptyExec.cjs");
|
||||
const serialTimeoutMs = mcpServerBridge.getCommandTimeoutMs ? mcpServerBridge.getCommandTimeoutMs() : 60000;
|
||||
return execViaRawPty(session.serialPort, command, {
|
||||
return withLockRelease(() => execViaRawPty(session.serialPort, command, {
|
||||
timeoutMs: serialTimeoutMs,
|
||||
trackForCancellation: mcpServerBridge.activePtyExecs,
|
||||
chatSessionId,
|
||||
encoding: session.serialEncoding || "utf8",
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
releaseLock();
|
||||
return { ok: false, error: "No terminal stream or SSH client available for this session" };
|
||||
} catch (err) {
|
||||
releaseLock();
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
}
|
||||
});
|
||||
@@ -1208,8 +1242,14 @@ function registerHandlers(ipcMain) {
|
||||
|
||||
const { createACPProvider } = require("@mcpc-tech/acp-ai-provider");
|
||||
const shellEnv = await getShellEnv();
|
||||
const resolvedCommand = resolveCodexAcpBinaryPath(shellEnv, electronModule);
|
||||
if (!resolvedCommand) {
|
||||
const result = { ok: false, checkedAt: now, error: "codex-acp binary not found", code: "ENOENT" };
|
||||
setCodexValidationCache(result);
|
||||
return result;
|
||||
}
|
||||
const provider = createACPProvider({
|
||||
command: resolveCodexAcpBinaryPath(shellEnv, electronModule),
|
||||
command: resolvedCommand,
|
||||
env: shellEnv,
|
||||
session: {
|
||||
cwd: process.cwd(),
|
||||
@@ -1927,6 +1967,9 @@ function registerHandlers(ipcMain) {
|
||||
: claudeAcp
|
||||
? claudeAcp.command
|
||||
: acpCommand;
|
||||
if (!resolvedCommand) {
|
||||
return { ok: false, models: [], error: `${agentLabel} binary not found` };
|
||||
}
|
||||
const resolvedArgs = claudeAcp
|
||||
? [...claudeAcp.prependArgs, ...(acpArgs || [])]
|
||||
: acpArgs || [];
|
||||
@@ -2117,6 +2160,9 @@ function registerHandlers(ipcMain) {
|
||||
: claudeAcp
|
||||
? claudeAcp.command
|
||||
: acpCommand;
|
||||
if (!resolvedCommand) {
|
||||
throw new Error(`${agentLabel} binary not found`);
|
||||
}
|
||||
const resolvedArgs = claudeAcp
|
||||
? [...claudeAcp.prependArgs, ...(acpArgs || [])]
|
||||
: acpArgs || [];
|
||||
@@ -2185,12 +2231,16 @@ function registerHandlers(ipcMain) {
|
||||
cleanupAcpProvider(chatSessionId);
|
||||
|
||||
const fallbackClaudeAcp = isClaudeAgent ? resolveClaudeAcpBinaryPath(shellEnv, electronModule) : null;
|
||||
const fallbackCommand = isCodexAgent
|
||||
? resolveCodexAcpBinaryPath(shellEnv, electronModule)
|
||||
: fallbackClaudeAcp
|
||||
? fallbackClaudeAcp.command
|
||||
: acpCommand;
|
||||
if (!fallbackCommand) {
|
||||
throw new Error(`${agentLabel} binary not found`);
|
||||
}
|
||||
const fallbackProvider = createACPProvider({
|
||||
command: isCodexAgent
|
||||
? resolveCodexAcpBinaryPath(shellEnv, electronModule)
|
||||
: fallbackClaudeAcp
|
||||
? fallbackClaudeAcp.command
|
||||
: acpCommand,
|
||||
command: fallbackCommand,
|
||||
args: fallbackClaudeAcp
|
||||
? [...fallbackClaudeAcp.prependArgs, ...(acpArgs || [])]
|
||||
: acpArgs || [],
|
||||
@@ -2250,7 +2300,9 @@ function registerHandlers(ipcMain) {
|
||||
`Use the "netcatty-remote-hosts" MCP tools to operate only on the terminal sessions exposed by Netcatty. ` +
|
||||
`Those sessions may be remote hosts, a local terminal, or Mosh-backed shells. ` +
|
||||
`Call get_environment first to discover available sessions and their IDs. ` +
|
||||
`For normal shell commands, use terminal_execute so you receive command output. ` +
|
||||
`Use terminal_execute only for commands likely to finish within about 60 seconds. ` +
|
||||
`For long-running commands such as builds, scans, follow/log streaming, watch commands, or anything likely to exceed 60 seconds on PTY-backed shell sessions, use terminal_start, then terminal_poll until completed is true. Reuse the returned nextOffset for the next poll. If terminal_poll reports outputTruncated=true, only the retained tail starting at outputBaseOffset is still available. Do not poll aggressively: wait at least about 30 seconds between polls, and increase the interval further when there is no new output, to avoid wasting tokens. As soon as completed is true, stop polling and analyze the result immediately. Note: terminal_start requires a PTY-backed session; for sessions that only support exec-channel execution (no writable PTY), use terminal_execute instead. ` +
|
||||
`Use terminal_stop if you need to interrupt a started long-running command. ` +
|
||||
`For serial/raw sessions and network device sessions (deviceType: network), commands are sent as-is without shell wrapping and exit codes are unavailable. Use vendor CLI commands directly.]\n\n${prompt}`;
|
||||
|
||||
// Build message content: text + optional attachments
|
||||
@@ -2427,7 +2479,11 @@ function registerHandlers(ipcMain) {
|
||||
const effectiveChatSessionId = chatSessionId || acpRequestSessions.get(requestId);
|
||||
const activeRun = effectiveChatSessionId ? acpChatRuns.get(effectiveChatSessionId) : null;
|
||||
const effectiveRequestId = requestId || activeRun?.requestId || "";
|
||||
// Cancel PTY executions scoped to this chat session (send Ctrl+C)
|
||||
// Cancel synchronous PTY executions scoped to this chat session (send Ctrl+C).
|
||||
// Do NOT cancel terminal_start background jobs here — they were intentionally
|
||||
// launched as long-running and should keep running when the user only wants
|
||||
// to stop the model's polling/output. Background jobs are still cleaned up
|
||||
// when the chat session itself is deleted (see cleanupScopedMetadata).
|
||||
mcpServerBridge.cancelPtyExecsForSession(effectiveChatSessionId);
|
||||
mcpServerBridge.setChatSessionCancelled?.(effectiveChatSessionId, true);
|
||||
mcpServerBridge.clearPendingApprovals(effectiveChatSessionId);
|
||||
|
||||
@@ -12,7 +12,7 @@ const path = require("node:path");
|
||||
const { existsSync } = require("node:fs");
|
||||
|
||||
const { toUnpackedAsarPath } = require("./ai/shellUtils.cjs");
|
||||
const { execViaPty, execViaChannel, execViaRawPty } = require("./ai/ptyExec.cjs");
|
||||
const { execViaPty, startPtyJob, execViaChannel, execViaRawPty } = require("./ai/ptyExec.cjs");
|
||||
const { safeSend } = require("./ipcUtils.cjs");
|
||||
|
||||
let sessions = null; // Map<sessionId, { sshClient, stream, pty, proc, conn, ... }>
|
||||
@@ -48,6 +48,13 @@ let permissionMode = "confirm";
|
||||
// Track active PTY executions for cancellation
|
||||
const activePtyExecs = new Map(); // marker → { ptyStream, cleanup }
|
||||
const cancelledChatSessions = new Set();
|
||||
const backgroundJobs = new Map(); // jobId -> job metadata
|
||||
const activeSessionExecutions = new Map(); // sessionId -> { kind, startedAt, token }
|
||||
const pendingSessionWriteApprovals = new Map(); // sessionId -> method
|
||||
const DEFAULT_BACKGROUND_JOB_TIMEOUT_MS = 60 * 60 * 1000;
|
||||
const DEFAULT_BACKGROUND_JOB_POLL_INTERVAL_MS = 30 * 1000;
|
||||
const BACKGROUND_JOB_RETENTION_MS = 10 * 60 * 1000;
|
||||
const MAX_BACKGROUND_JOB_OUTPUT_CHARS = 256 * 1024;
|
||||
|
||||
// ── Approval gate (for confirm mode with ACP/MCP agents) ──
|
||||
let getMainWindowFn = null; // () => BrowserWindow | null
|
||||
@@ -161,6 +168,207 @@ function cancelPtyExecsForSession(chatSessionId) {
|
||||
}
|
||||
}
|
||||
|
||||
function createBackgroundJobId() {
|
||||
return `job_${Date.now().toString(36)}_${crypto.randomBytes(6).toString("hex")}`;
|
||||
}
|
||||
|
||||
function cancelBackgroundJobsForSession(chatSessionId) {
|
||||
if (!chatSessionId) return;
|
||||
for (const [, job] of backgroundJobs) {
|
||||
if (job.chatSessionId !== chatSessionId) continue;
|
||||
if (job.status !== "running") continue;
|
||||
try {
|
||||
job.handle?.cancel?.();
|
||||
job.status = "stopping";
|
||||
job.error = "Cancellation requested";
|
||||
job.updatedAt = Date.now();
|
||||
} catch {
|
||||
// Ignore cancellation failures
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readBackgroundJobSnapshot(job) {
|
||||
if (!job) {
|
||||
return {
|
||||
stdout: "",
|
||||
outputBaseOffset: 0,
|
||||
totalOutputChars: 0,
|
||||
outputTruncated: false,
|
||||
};
|
||||
}
|
||||
if (job.status === "running" || job.status === "stopping") {
|
||||
const snapshot = job.handle?.getSnapshot?.();
|
||||
if (snapshot) {
|
||||
const stdout = String(snapshot.stdout || "");
|
||||
const outputBaseOffset = Math.max(0, Number(snapshot.outputBaseOffset) || 0);
|
||||
const totalOutputChars = Math.max(outputBaseOffset + stdout.length, Number(snapshot.totalOutputChars) || 0);
|
||||
return {
|
||||
stdout,
|
||||
outputBaseOffset,
|
||||
totalOutputChars,
|
||||
outputTruncated: Boolean(snapshot.outputTruncated),
|
||||
};
|
||||
}
|
||||
}
|
||||
const stdout = String(job.stdout || "");
|
||||
const outputBaseOffset = Math.max(0, Number(job.outputBaseOffset) || 0);
|
||||
const totalOutputChars = Math.max(outputBaseOffset + stdout.length, Number(job.totalOutputChars) || 0);
|
||||
return {
|
||||
stdout,
|
||||
outputBaseOffset,
|
||||
totalOutputChars,
|
||||
outputTruncated: Boolean(job.outputTruncated),
|
||||
};
|
||||
}
|
||||
|
||||
function createOutputWindow(stdout) {
|
||||
const fullText = String(stdout || "");
|
||||
const totalOutputChars = fullText.length;
|
||||
const outputBaseOffset = Math.max(0, totalOutputChars - MAX_BACKGROUND_JOB_OUTPUT_CHARS);
|
||||
return {
|
||||
stdout: outputBaseOffset > 0 ? fullText.slice(outputBaseOffset) : fullText,
|
||||
outputBaseOffset,
|
||||
totalOutputChars,
|
||||
outputTruncated: outputBaseOffset > 0,
|
||||
};
|
||||
}
|
||||
|
||||
function refreshRunningJobSnapshot(job) {
|
||||
if (!job || (job.status !== "running" && job.status !== "stopping")) return;
|
||||
const snapshot = readBackgroundJobSnapshot(job);
|
||||
job.stdout = snapshot.stdout;
|
||||
job.outputBaseOffset = snapshot.outputBaseOffset;
|
||||
job.totalOutputChars = snapshot.totalOutputChars;
|
||||
job.outputTruncated = snapshot.outputTruncated;
|
||||
}
|
||||
|
||||
function storeCompletedJobOutput(job, stdout, metadata = null) {
|
||||
if (metadata && typeof metadata === "object") {
|
||||
const normalizedStdout = String(metadata.stdout ?? stdout ?? "");
|
||||
const outputBaseOffset = Math.max(0, Number(metadata.outputBaseOffset) || 0);
|
||||
const totalOutputChars = Math.max(outputBaseOffset + normalizedStdout.length, Number(metadata.totalOutputChars) || 0);
|
||||
job.stdout = normalizedStdout;
|
||||
job.outputBaseOffset = outputBaseOffset;
|
||||
job.totalOutputChars = totalOutputChars;
|
||||
job.outputTruncated = Boolean(metadata.outputTruncated);
|
||||
job.handle = null;
|
||||
return;
|
||||
}
|
||||
const window = createOutputWindow(stdout);
|
||||
job.stdout = window.stdout;
|
||||
job.outputBaseOffset = window.outputBaseOffset;
|
||||
job.totalOutputChars = window.totalOutputChars;
|
||||
job.outputTruncated = window.outputTruncated;
|
||||
job.handle = null;
|
||||
}
|
||||
|
||||
function pruneCompletedBackgroundJobs(now = Date.now()) {
|
||||
for (const [jobId, job] of backgroundJobs) {
|
||||
if (job.status === "running" || job.status === "stopping") continue;
|
||||
const updatedAt = Number(job.updatedAt) || 0;
|
||||
if (updatedAt > 0 && now - updatedAt > BACKGROUND_JOB_RETENTION_MS) {
|
||||
backgroundJobs.delete(jobId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collapse carriage-return progress redraws to the latest frame.
|
||||
// Each \r resets the cursor to the start of the current line; the next
|
||||
// non-\r character overwrites the existing line content. A trailing \r
|
||||
// (with no following content) leaves the existing line intact, so a
|
||||
// snapshot taken between redraws still shows the latest visible frame.
|
||||
// Used at serialize time so the stored buffer can keep raw monotonic
|
||||
// offsets while polled output shows the latest frame.
|
||||
function collapseCarriageReturns(text) {
|
||||
if (!text || text.indexOf("\r") === -1) return text;
|
||||
let result = "";
|
||||
let crPending = false;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (ch === "\r") {
|
||||
crPending = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\n") {
|
||||
crPending = false;
|
||||
result += ch;
|
||||
continue;
|
||||
}
|
||||
if (crPending) {
|
||||
const lastNl = result.lastIndexOf("\n");
|
||||
result = lastNl >= 0 ? result.slice(0, lastNl + 1) : "";
|
||||
crPending = false;
|
||||
}
|
||||
result += ch;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function serializeBackgroundJob(job, offset = 0) {
|
||||
if (job.status === "running" || job.status === "stopping") {
|
||||
refreshRunningJobSnapshot(job);
|
||||
}
|
||||
const stdout = job.stdout || "";
|
||||
const outputBaseOffset = job.outputBaseOffset || 0;
|
||||
const totalOutputChars = Math.max(outputBaseOffset + stdout.length, job.totalOutputChars || 0);
|
||||
const numericOffset = Math.max(0, Number(offset) || 0);
|
||||
const relativeOffset = numericOffset <= outputBaseOffset
|
||||
? 0
|
||||
: Math.min(numericOffset - outputBaseOffset, stdout.length);
|
||||
return {
|
||||
ok: true,
|
||||
jobId: job.id,
|
||||
sessionId: job.sessionId,
|
||||
command: job.command,
|
||||
status: job.status,
|
||||
completed: job.status !== "running" && job.status !== "stopping",
|
||||
exitCode: job.exitCode,
|
||||
error: job.error,
|
||||
startedAt: job.startedAt,
|
||||
updatedAt: job.updatedAt,
|
||||
output: collapseCarriageReturns(stdout.slice(relativeOffset)),
|
||||
nextOffset: totalOutputChars,
|
||||
totalOutputChars,
|
||||
outputBaseOffset,
|
||||
outputTruncated: Boolean(job.outputTruncated),
|
||||
recommendedPollIntervalMs: DEFAULT_BACKGROUND_JOB_POLL_INTERVAL_MS,
|
||||
};
|
||||
}
|
||||
|
||||
function describeActiveSessionExecution(entry) {
|
||||
if (!entry) return "another command";
|
||||
return entry.kind === "job" ? "a long-running command" : "another command";
|
||||
}
|
||||
|
||||
function getSessionBusyError(sessionId) {
|
||||
const active = activeSessionExecutions.get(sessionId);
|
||||
if (!active) return null;
|
||||
return {
|
||||
ok: false,
|
||||
error: `Session already has ${describeActiveSessionExecution(active)} in progress. Wait for it to finish or stop it before starting another command.`,
|
||||
};
|
||||
}
|
||||
|
||||
function reserveSessionExecution(sessionId, kind) {
|
||||
const existing = getSessionBusyError(sessionId);
|
||||
if (existing) return existing;
|
||||
const token = `${kind}_${Date.now().toString(36)}_${crypto.randomBytes(6).toString("hex")}`;
|
||||
activeSessionExecutions.set(sessionId, {
|
||||
kind,
|
||||
startedAt: Date.now(),
|
||||
token,
|
||||
});
|
||||
return { ok: true, token };
|
||||
}
|
||||
|
||||
function releaseSessionExecution(sessionId, token) {
|
||||
const active = activeSessionExecutions.get(sessionId);
|
||||
if (!active) return;
|
||||
if (token && active.token !== token) return;
|
||||
activeSessionExecutions.delete(sessionId);
|
||||
}
|
||||
|
||||
function init(deps) {
|
||||
sessions = deps.sessions;
|
||||
electronModule = deps.electronModule || null;
|
||||
@@ -413,14 +621,35 @@ async function handleMessage(socket, line) {
|
||||
// Methods that modify remote state — blocked in observer mode
|
||||
const WRITE_METHODS = new Set([
|
||||
"netcatty/exec",
|
||||
"netcatty/jobStart",
|
||||
"netcatty/jobStop",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Validate that a sessionId is allowed in the current scope.
|
||||
* Checks both process-level SCOPED_SESSION_IDS and per-chatSession scoped metadata.
|
||||
* Checks explicit per-call scopedSessionIds first (static MCP scope mode),
|
||||
* then per-chatSession scoped metadata (dynamic mode), then global scope.
|
||||
*
|
||||
* An explicit empty array (`[]`) means "no access" — not "fall through to
|
||||
* global scope" — matching the documented behavior in handleGetContext.
|
||||
*/
|
||||
function validateSessionScope(sessionId, chatSessionId) {
|
||||
function validateSessionScope(sessionId, chatSessionId, explicitScopedIds = null) {
|
||||
if (!sessionId) return null; // will fail at handler level
|
||||
if (Array.isArray(explicitScopedIds)) {
|
||||
if (!explicitScopedIds.includes(sessionId)) {
|
||||
return `Session "${sessionId}" is not in the current scope.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// If a chat has explicit scoped metadata (even an empty array), enforce it.
|
||||
// Only fall through to fallback/global when no chat-scoped context exists.
|
||||
if (chatSessionId && scopedMetadata.has(chatSessionId)) {
|
||||
const chatScoped = scopedMetadata.get(chatSessionId)?.sessionIds || [];
|
||||
if (!chatScoped.includes(sessionId)) {
|
||||
return `Session "${sessionId}" is not in the current scope.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const scopedIds = getScopedSessionIds(chatSessionId);
|
||||
if (scopedIds && scopedIds.length > 0 && !scopedIds.includes(sessionId)) {
|
||||
return `Session "${sessionId}" is not in the current scope.`;
|
||||
@@ -429,36 +658,78 @@ function validateSessionScope(sessionId, chatSessionId) {
|
||||
}
|
||||
|
||||
async function dispatch(method, params) {
|
||||
// Observer mode: block all write operations
|
||||
if (permissionMode === "observer" && WRITE_METHODS.has(method)) {
|
||||
const sessionWriteLockId = (method === "netcatty/exec" || method === "netcatty/jobStart") ? params?.sessionId : null;
|
||||
pruneCompletedBackgroundJobs();
|
||||
|
||||
// Observer mode: block all write operations *except* netcatty/jobStop,
|
||||
// which must remain available so users can interrupt long-running jobs
|
||||
// they started before switching to observer mode (otherwise the job
|
||||
// would hold the per-session lock until it exits on its own).
|
||||
if (permissionMode === "observer" && WRITE_METHODS.has(method) && method !== "netcatty/jobStop") {
|
||||
return { ok: false, error: `Operation denied: permission mode is "observer" (read-only). Change to "confirm" or "autonomous" in Settings → AI → Safety to allow this action.` };
|
||||
}
|
||||
|
||||
if (WRITE_METHODS.has(method) && isChatSessionCancelled(params?.chatSessionId)) {
|
||||
// netcatty/jobStop must remain callable after ACP cancel so users can stop
|
||||
// a long-running terminal_start job (which intentionally survives ACP Stop)
|
||||
// even from a chat session whose write methods are otherwise blocked.
|
||||
if (WRITE_METHODS.has(method) && method !== "netcatty/jobStop" && isChatSessionCancelled(params?.chatSessionId)) {
|
||||
return { ok: false, error: "Operation cancelled: the ACP session was stopped." };
|
||||
}
|
||||
|
||||
// Confirm mode: request user approval for write operations
|
||||
if (permissionMode === "confirm" && WRITE_METHODS.has(method)) {
|
||||
const { chatSessionId, ...toolArgs } = params || {};
|
||||
const approved = await requestApprovalFromRenderer(method, toolArgs, chatSessionId);
|
||||
if (!approved) {
|
||||
return { ok: false, error: "Operation denied by user." };
|
||||
}
|
||||
}
|
||||
|
||||
// Scope validation for session-targeted operations
|
||||
// Validate session scope *first* so out-of-scope callers cannot infer the
|
||||
// existence or activity of foreign sessions through busy-state error
|
||||
// messages, and so requests fail fast without blocking the write lock.
|
||||
if (method !== "netcatty/getContext" && params?.sessionId) {
|
||||
const scopeErr = validateSessionScope(params.sessionId, params?.chatSessionId);
|
||||
const scopeErr = validateSessionScope(params.sessionId, params?.chatSessionId, params?.scopedSessionIds);
|
||||
if (scopeErr) return { ok: false, error: scopeErr };
|
||||
}
|
||||
switch (method) {
|
||||
case "netcatty/getContext":
|
||||
return handleGetContext(params);
|
||||
case "netcatty/exec":
|
||||
return handleExec(params);
|
||||
default:
|
||||
throw new Error(`Unknown method: ${method}`);
|
||||
|
||||
if ((method === "netcatty/exec" || method === "netcatty/jobStart") && params?.sessionId) {
|
||||
const busy = getSessionBusyError(params.sessionId);
|
||||
if (busy) return busy;
|
||||
}
|
||||
|
||||
if (sessionWriteLockId) {
|
||||
const pendingMethod = pendingSessionWriteApprovals.get(sessionWriteLockId);
|
||||
if (pendingMethod) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Session already has another command request awaiting approval or startup. Wait for it to finish before starting a new command.",
|
||||
};
|
||||
}
|
||||
pendingSessionWriteApprovals.set(sessionWriteLockId, method);
|
||||
}
|
||||
|
||||
try {
|
||||
// Confirm mode: request user approval for write operations.
|
||||
// netcatty/jobStop bypasses approval — it's a stop/cancel action that
|
||||
// must remain available even if the renderer is unavailable; otherwise
|
||||
// a runaway terminal_start job could not be interrupted at all.
|
||||
if (permissionMode === "confirm" && WRITE_METHODS.has(method) && method !== "netcatty/jobStop") {
|
||||
const { chatSessionId, ...toolArgs } = params || {};
|
||||
const approved = await requestApprovalFromRenderer(method, toolArgs, chatSessionId);
|
||||
if (!approved) {
|
||||
return { ok: false, error: "Operation denied by user." };
|
||||
}
|
||||
}
|
||||
switch (method) {
|
||||
case "netcatty/getContext":
|
||||
return handleGetContext(params);
|
||||
case "netcatty/exec":
|
||||
return handleExec(params);
|
||||
case "netcatty/jobStart":
|
||||
return handleJobStart(params);
|
||||
case "netcatty/jobPoll":
|
||||
return handleJobPoll(params);
|
||||
case "netcatty/jobStop":
|
||||
return handleJobStop(params);
|
||||
default:
|
||||
throw new Error(`Unknown method: ${method}`);
|
||||
}
|
||||
} finally {
|
||||
if (sessionWriteLockId) {
|
||||
pendingSessionWriteApprovals.delete(sessionWriteLockId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -526,7 +797,7 @@ function handleGetContext(params) {
|
||||
|
||||
// ── Handler: exec ──
|
||||
|
||||
function handleExec(params) {
|
||||
function resolveExecContext(params) {
|
||||
const { sessionId, command } = params;
|
||||
if (!sessionId || !command) throw new Error("sessionId and command are required");
|
||||
if (typeof command !== 'string' || !command.trim()) {
|
||||
@@ -574,60 +845,296 @@ function handleExec(params) {
|
||||
|
||||
const sshClient = session.conn || session.sshClient;
|
||||
const ptyStream = session.stream || session.pty || session.proc;
|
||||
return {
|
||||
ok: true,
|
||||
context: {
|
||||
sessionId,
|
||||
command,
|
||||
session,
|
||||
chatSessionId,
|
||||
sessionProtocol,
|
||||
isNetworkDevice,
|
||||
sshClient,
|
||||
ptyStream,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function handleExec(params) {
|
||||
const resolved = resolveExecContext(params);
|
||||
if (!resolved.ok) return resolved;
|
||||
const {
|
||||
sessionId,
|
||||
command,
|
||||
session,
|
||||
chatSessionId,
|
||||
sessionProtocol,
|
||||
isNetworkDevice,
|
||||
sshClient,
|
||||
ptyStream,
|
||||
} = resolved.context;
|
||||
const reservation = reserveSessionExecution(sessionId, "exec");
|
||||
if (!reservation.ok) return reservation;
|
||||
const sessionToken = reservation.token;
|
||||
|
||||
const runExecution = (factory) => {
|
||||
try {
|
||||
return Promise.resolve(factory()).finally(() => {
|
||||
releaseSessionExecution(sessionId, sessionToken);
|
||||
});
|
||||
} catch (err) {
|
||||
releaseSessionExecution(sessionId, sessionToken);
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
}
|
||||
};
|
||||
|
||||
// Network devices (switches/routers) connected via SSH: use raw execution.
|
||||
// Their vendor CLIs (Huawei VRP, Cisco IOS, etc.) don't run a POSIX shell,
|
||||
// so shell-wrapped commands with markers would fail. Raw mode sends commands
|
||||
// as-is with idle-timeout completion detection — same as serial sessions.
|
||||
if (isNetworkDevice && ptyStream && typeof ptyStream.write === "function") {
|
||||
return execViaRawPty(ptyStream, command, {
|
||||
return runExecution(() => execViaRawPty(ptyStream, command, {
|
||||
timeoutMs: commandTimeoutMs,
|
||||
trackForCancellation: activePtyExecs,
|
||||
chatSessionId: params?.chatSessionId,
|
||||
encoding: "utf8", // SSH PTY streams use UTF-8, not latin1
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
// Prefer the interactive PTY so the user sees command/output in-session.
|
||||
if (ptyStream && typeof ptyStream.write === "function") {
|
||||
return execViaPty(ptyStream, command, {
|
||||
return runExecution(() => execViaPty(ptyStream, command, {
|
||||
trackForCancellation: activePtyExecs,
|
||||
timeoutMs: commandTimeoutMs,
|
||||
shellKind: session.shellKind,
|
||||
expectedPrompt: session.lastIdlePrompt || "",
|
||||
typedInput: true,
|
||||
echoCommand: (rawCommand) => echoCommandToSession(session, sessionId, rawCommand),
|
||||
});
|
||||
// MCP callers have terminal_start as a fallback for long commands,
|
||||
// so enforce a hard wall-clock timeout here to match the MCP budget.
|
||||
enforceWallTimeout: true,
|
||||
}));
|
||||
}
|
||||
|
||||
// Network devices require an interactive PTY for raw command execution.
|
||||
// If we got here, ptyStream wasn't writable — there's no usable channel.
|
||||
if (isNetworkDevice) {
|
||||
releaseSessionExecution(sessionId, sessionToken);
|
||||
return { ok: false, error: "Network device session has no writable PTY stream for command execution" };
|
||||
}
|
||||
|
||||
// Fallback: SSH exec channel (invisible to terminal).
|
||||
// At this point ptyStream is not writable (already returned above if it was).
|
||||
if (sshClient && typeof sshClient.exec === "function") {
|
||||
return execViaChannel(sshClient, command, {
|
||||
return runExecution(() => execViaChannel(sshClient, command, {
|
||||
timeoutMs: commandTimeoutMs,
|
||||
trackForCancellation: activePtyExecs,
|
||||
});
|
||||
// Pass chatSessionId so cancelPtyExecsForSession can interrupt this
|
||||
// exec channel when the originating ACP run is stopped.
|
||||
chatSessionId: params?.chatSessionId,
|
||||
}));
|
||||
}
|
||||
|
||||
// Serial port: raw command execution (no shell wrapping)
|
||||
if (session.protocol === "serial" && session.serialPort && typeof session.serialPort.write === "function") {
|
||||
return execViaRawPty(session.serialPort, command, {
|
||||
return runExecution(() => execViaRawPty(session.serialPort, command, {
|
||||
timeoutMs: commandTimeoutMs,
|
||||
trackForCancellation: activePtyExecs,
|
||||
chatSessionId: params?.chatSessionId,
|
||||
encoding: session.serialEncoding || "utf8",
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
releaseSessionExecution(sessionId, sessionToken);
|
||||
return { ok: false, error: "Session does not support command execution" };
|
||||
}
|
||||
|
||||
function handleJobStart(params) {
|
||||
const resolved = resolveExecContext(params);
|
||||
if (!resolved.ok) return resolved;
|
||||
const {
|
||||
sessionId,
|
||||
command,
|
||||
session,
|
||||
chatSessionId,
|
||||
isNetworkDevice,
|
||||
sessionProtocol,
|
||||
ptyStream,
|
||||
} = resolved.context;
|
||||
|
||||
if (isNetworkDevice || sessionProtocol === "serial") {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Background execution currently supports shell-backed PTY sessions only.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!ptyStream || typeof ptyStream.write !== "function") {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Background execution requires a writable PTY-backed terminal session.",
|
||||
};
|
||||
}
|
||||
|
||||
const reservation = reserveSessionExecution(sessionId, "job");
|
||||
if (!reservation.ok) return reservation;
|
||||
const sessionToken = reservation.token;
|
||||
|
||||
const jobId = createBackgroundJobId();
|
||||
const timeoutMs = Math.max(commandTimeoutMs, DEFAULT_BACKGROUND_JOB_TIMEOUT_MS);
|
||||
let handle;
|
||||
try {
|
||||
handle = startPtyJob(ptyStream, command, {
|
||||
// Intentionally do NOT register in activePtyExecs: terminal_start jobs
|
||||
// are designed to survive ACP "Stop" so the model can stop polling
|
||||
// without aborting a long-running build/scan/log stream. The job is
|
||||
// managed via terminal_stop and the per-session execution lock.
|
||||
timeoutMs,
|
||||
shellKind: session.shellKind,
|
||||
chatSessionId,
|
||||
expectedPrompt: session.lastIdlePrompt || "",
|
||||
typedInput: true,
|
||||
echoCommand: (rawCommand) => echoCommandToSession(session, sessionId, rawCommand),
|
||||
maxBufferedChars: MAX_BACKGROUND_JOB_OUTPUT_CHARS,
|
||||
normalizeFinalOutput: false,
|
||||
});
|
||||
} catch (err) {
|
||||
releaseSessionExecution(sessionId, sessionToken);
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
const job = {
|
||||
id: jobId,
|
||||
sessionId,
|
||||
chatSessionId: chatSessionId || null,
|
||||
command,
|
||||
status: "running",
|
||||
startedAt,
|
||||
updatedAt: startedAt,
|
||||
exitCode: null,
|
||||
error: null,
|
||||
stdout: "",
|
||||
outputBaseOffset: 0,
|
||||
totalOutputChars: 0,
|
||||
outputTruncated: false,
|
||||
handle,
|
||||
};
|
||||
backgroundJobs.set(jobId, job);
|
||||
|
||||
handle.resultPromise.then((result) => {
|
||||
job.updatedAt = Date.now();
|
||||
job.exitCode = result.exitCode ?? null;
|
||||
storeCompletedJobOutput(job, result.stdout || "", result);
|
||||
const isForcedCancel = typeof result.error === "string" && result.error.includes("forced");
|
||||
if (result.error === "Cancelled" || isForcedCancel) {
|
||||
// Forced cancel means the process ignored SIGINT for the cancel
|
||||
// wall-clock window. We mark the job as cancelled and release the
|
||||
// lock so the session is reusable; the error message tells the
|
||||
// caller the process may still be running so subsequent commands
|
||||
// should be considered carefully. This is consistent: callers see
|
||||
// completed=true exactly when the lock is no longer held.
|
||||
job.status = "cancelled";
|
||||
job.error = result.error;
|
||||
releaseSessionExecution(sessionId, sessionToken);
|
||||
return;
|
||||
}
|
||||
if (result.error) {
|
||||
job.status = "failed";
|
||||
job.error = result.error;
|
||||
releaseSessionExecution(sessionId, sessionToken);
|
||||
return;
|
||||
}
|
||||
// A non-zero exit code without an error message still represents a
|
||||
// failed command (e.g. a build/test that returned 1). Mark it as failed
|
||||
// so callers don't have to special-case exitCode against status.
|
||||
if (typeof result.exitCode === "number" && result.exitCode !== 0) {
|
||||
job.status = "failed";
|
||||
job.error = `Command exited with code ${result.exitCode}`;
|
||||
releaseSessionExecution(sessionId, sessionToken);
|
||||
return;
|
||||
}
|
||||
job.status = "completed";
|
||||
releaseSessionExecution(sessionId, sessionToken);
|
||||
}).catch((err) => {
|
||||
job.updatedAt = Date.now();
|
||||
job.status = "failed";
|
||||
job.error = err?.message || String(err);
|
||||
storeCompletedJobOutput(job, job.stdout || "");
|
||||
releaseSessionExecution(sessionId, sessionToken);
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
jobId,
|
||||
sessionId,
|
||||
command,
|
||||
status: "running",
|
||||
startedAt,
|
||||
outputMode: "foreground-mirrored",
|
||||
recommendedPollIntervalMs: DEFAULT_BACKGROUND_JOB_POLL_INTERVAL_MS,
|
||||
};
|
||||
}
|
||||
|
||||
function getScopedJob(jobId, chatSessionId) {
|
||||
const job = backgroundJobs.get(jobId);
|
||||
if (!job) return null;
|
||||
// Per-chat isolation: a job started under a chat session can only be
|
||||
// accessed by callers presenting the same chatSessionId. Unscoped or
|
||||
// statically-scoped callers cannot reach into another chat's jobs.
|
||||
if (job.chatSessionId) {
|
||||
if (!chatSessionId || job.chatSessionId !== chatSessionId) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return job;
|
||||
}
|
||||
|
||||
function handleJobPoll(params) {
|
||||
const { jobId, offset = 0, chatSessionId, scopedSessionIds } = params || {};
|
||||
if (!jobId) throw new Error("jobId is required");
|
||||
const job = getScopedJob(jobId, chatSessionId || null);
|
||||
if (!job) return { ok: false, error: "Background job not found" };
|
||||
// Re-check session scope so a caller that lost access to the host
|
||||
// cannot continue reading output from jobs on that session.
|
||||
// Covers dynamic (chatSessionId), static (scopedSessionIds), and global modes.
|
||||
if (job.sessionId) {
|
||||
const scopeErr = validateSessionScope(job.sessionId, chatSessionId || null, scopedSessionIds);
|
||||
if (scopeErr) return { ok: false, error: scopeErr };
|
||||
}
|
||||
return serializeBackgroundJob(job, offset);
|
||||
}
|
||||
|
||||
function handleJobStop(params) {
|
||||
const { jobId, chatSessionId, scopedSessionIds } = params || {};
|
||||
if (!jobId) throw new Error("jobId is required");
|
||||
const job = getScopedJob(jobId, chatSessionId || null);
|
||||
if (!job) return { ok: false, error: "Background job not found" };
|
||||
// For statically scoped MCP clients, validate that the job's session is
|
||||
// within the caller's static scope so a foreign jobId cannot cancel jobs
|
||||
// outside the caller's allowed sessions. Dynamic chat scope is already
|
||||
// enforced by getScopedJob (caller's chatSessionId must match the job's),
|
||||
// and we intentionally do NOT re-check dynamic scope here so jobs can
|
||||
// still be stopped after workspace membership changes — otherwise the
|
||||
// session lock would stay held forever.
|
||||
if (Array.isArray(scopedSessionIds) && job.sessionId) {
|
||||
if (!scopedSessionIds.includes(job.sessionId)) {
|
||||
return { ok: false, error: `Session "${job.sessionId}" is not in the current scope.` };
|
||||
}
|
||||
}
|
||||
if (job.status === "running") {
|
||||
try {
|
||||
job.handle?.cancel?.();
|
||||
} catch (err) {
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
}
|
||||
job.status = "stopping";
|
||||
job.error = "Cancellation requested";
|
||||
job.updatedAt = Date.now();
|
||||
}
|
||||
return serializeBackgroundJob(job, 0);
|
||||
}
|
||||
|
||||
// ── MCP Server Config Builder ──
|
||||
|
||||
function resolveMcpServerRuntimeCommand() {
|
||||
@@ -695,6 +1202,12 @@ function cleanupScopedMetadata(chatSessionId) {
|
||||
if (chatSessionId) {
|
||||
scopedMetadata.delete(chatSessionId);
|
||||
cancelledChatSessions.delete(chatSessionId);
|
||||
cancelBackgroundJobsForSession(chatSessionId);
|
||||
// Resolve any in-flight approval requests so dispatch()'s finally block
|
||||
// releases its pendingSessionWriteApprovals entry. Without this, a chat
|
||||
// deleted while an approval was pending would leave the per-session
|
||||
// write lock held until the 5-minute approval timeout.
|
||||
clearPendingApprovals(chatSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -705,6 +1218,15 @@ function cleanup() {
|
||||
tcpPort = null;
|
||||
}
|
||||
scopedMetadata.clear();
|
||||
for (const [, job] of backgroundJobs) {
|
||||
try {
|
||||
job.handle?.cancel?.();
|
||||
} catch {
|
||||
// Ignore cancellation failures during cleanup
|
||||
}
|
||||
}
|
||||
backgroundJobs.clear();
|
||||
activeSessionExecutions.clear();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
@@ -723,6 +1245,7 @@ module.exports = {
|
||||
getOrCreateHost,
|
||||
buildMcpServerConfig,
|
||||
activePtyExecs,
|
||||
cancelBackgroundJobsForSession,
|
||||
cancelAllPtyExecs,
|
||||
cancelPtyExecsForSession,
|
||||
getSessionMeta,
|
||||
@@ -731,4 +1254,7 @@ module.exports = {
|
||||
setMainWindowGetter,
|
||||
resolveApprovalFromRenderer,
|
||||
clearPendingApprovals,
|
||||
reserveSessionExecution,
|
||||
releaseSessionExecution,
|
||||
getSessionBusyError,
|
||||
};
|
||||
|
||||
710
electron/bridges/shellDiscovery.cjs
Normal file
@@ -0,0 +1,710 @@
|
||||
/**
|
||||
* Shell Discovery — cross-platform shell detection
|
||||
*
|
||||
* Detects available shells on Windows (CMD, PowerShell, WSL, Git Bash, Cygwin)
|
||||
* and Unix/macOS (via /etc/shells). Registry access on Windows uses `reg.exe`
|
||||
* via child_process — no native npm dependency.
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const EXEC_OPTS = { encoding: "utf8", timeout: 5000, windowsHide: true };
|
||||
|
||||
/** Module-level cache for later use by the unified discoverShells() (Task 3). */
|
||||
let cachedShells = null;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Query a specific value from a Windows registry key.
|
||||
* Returns the value string, or `null` on failure.
|
||||
*
|
||||
* @param {string} keyPath e.g. "HKLM\\SOFTWARE\\GitForWindows"
|
||||
* @param {string} valueName e.g. "InstallPath"
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function regQueryValue(keyPath, valueName) {
|
||||
try {
|
||||
// /ve queries the default (unnamed) value; /v queries a named value.
|
||||
const args =
|
||||
valueName === "" || valueName == null
|
||||
? ["query", keyPath, "/ve"]
|
||||
: ["query", keyPath, "/v", valueName];
|
||||
const output = execFileSync("reg", args, EXEC_OPTS);
|
||||
// Output format:
|
||||
// HKEY_LOCAL_MACHINE\SOFTWARE\GitForWindows
|
||||
// InstallPath REG_SZ C:\Program Files\Git
|
||||
const lines = output.split(/\r?\n/).filter(Boolean);
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^\s+.+?\s+REG_\w+\s+(.+)$/);
|
||||
if (match) {
|
||||
return match[1].trim();
|
||||
}
|
||||
}
|
||||
} catch (_err) {
|
||||
// Key or value not found — expected on many systems.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate immediate subkey names under a registry key.
|
||||
* Returns an array of full subkey paths, or an empty array on failure.
|
||||
*
|
||||
* @param {string} keyPath e.g. "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss"
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function regEnumSubkeys(keyPath) {
|
||||
try {
|
||||
const output = execFileSync(
|
||||
"reg",
|
||||
["query", keyPath],
|
||||
EXEC_OPTS,
|
||||
);
|
||||
// `reg query <key>` prints the key itself, then each subkey on its own line
|
||||
// prefixed with the full path. Values appear with leading whitespace.
|
||||
const lines = output.split(/\r?\n/).filter(Boolean);
|
||||
const subkeys = [];
|
||||
const normalizedParent = keyPath.toLowerCase();
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
// Subkeys start with "HK" and are longer than the parent key.
|
||||
if (
|
||||
trimmed.toLowerCase().startsWith("hk") &&
|
||||
trimmed.toLowerCase() !== normalizedParent &&
|
||||
trimmed.toLowerCase().startsWith(normalizedParent + "\\")
|
||||
) {
|
||||
subkeys.push(trimmed);
|
||||
}
|
||||
}
|
||||
return subkeys;
|
||||
} catch (_err) {
|
||||
// Key not found or access denied.
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate an executable on the system PATH using `where.exe`.
|
||||
* Returns the first valid, non-alias path, or `null` if not found.
|
||||
*
|
||||
* @param {string} name Executable name, e.g. "pwsh"
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function findExecutableOnPath(name) {
|
||||
try {
|
||||
const result = execFileSync("where.exe", [name], EXEC_OPTS);
|
||||
const candidates = result
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!fs.existsSync(candidate)) continue;
|
||||
// Skip Windows App Execution Aliases (WindowsApps zero-byte stubs).
|
||||
try {
|
||||
const localAppData = (process.env.LOCALAPPDATA || "").toLowerCase();
|
||||
if (
|
||||
localAppData &&
|
||||
candidate.toLowerCase().startsWith(
|
||||
path.join(localAppData, "Microsoft", "WindowsApps").toLowerCase() +
|
||||
path.sep,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
} catch (_e) {
|
||||
// Ignore — just use the candidate.
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
} catch (_err) {
|
||||
// Not found on PATH.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a WSL distro name to an icon identifier for SVG lookup.
|
||||
*
|
||||
* @param {string} distroName e.g. "Ubuntu-22.04", "Debian", "kali-linux"
|
||||
* @returns {string}
|
||||
*/
|
||||
function mapWslDistroIcon(distroName) {
|
||||
const lower = (distroName || "").toLowerCase();
|
||||
|
||||
if (lower.includes("ubuntu")) return "ubuntu";
|
||||
if (lower.includes("debian")) return "debian";
|
||||
if (lower.includes("kali")) return "kali";
|
||||
if (lower.includes("alpine")) return "alpine";
|
||||
if (lower.includes("opensuse") || lower.includes("suse")) return "opensuse";
|
||||
if (lower.includes("fedora")) return "fedora";
|
||||
if (lower.includes("arch")) return "arch";
|
||||
if (lower.includes("oracle")) return "oracle";
|
||||
|
||||
return "linux";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Individual shell detectors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Detect CMD.
|
||||
* @returns {object|null} DiscoveredShell or null
|
||||
*/
|
||||
function detectCmd() {
|
||||
try {
|
||||
const comSpec = process.env.ComSpec;
|
||||
const cmdPath = comSpec || "cmd.exe";
|
||||
// Verify the path actually exists when ComSpec provides a full path.
|
||||
if (comSpec && !fs.existsSync(comSpec)) {
|
||||
// Fallback to bare name — Windows will resolve it.
|
||||
return {
|
||||
id: "cmd",
|
||||
name: "CMD",
|
||||
command: "cmd.exe",
|
||||
args: [],
|
||||
icon: "cmd",
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: "cmd",
|
||||
name: "CMD",
|
||||
command: cmdPath,
|
||||
args: [],
|
||||
icon: "cmd",
|
||||
};
|
||||
} catch (_err) {
|
||||
// Should never fail, but guard anyway.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect Windows PowerShell 5.1.
|
||||
* @returns {object|null}
|
||||
*/
|
||||
function detectPowerShell() {
|
||||
try {
|
||||
// Try where.exe first.
|
||||
const found = findExecutableOnPath("powershell");
|
||||
if (found) {
|
||||
return {
|
||||
id: "powershell",
|
||||
name: "Windows PowerShell",
|
||||
command: found,
|
||||
args: ["-NoLogo"],
|
||||
icon: "powershell",
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: well-known path.
|
||||
const fallback = path.join(
|
||||
process.env.SystemRoot || "C:\\Windows",
|
||||
"System32",
|
||||
"WindowsPowerShell",
|
||||
"v1.0",
|
||||
"powershell.exe",
|
||||
);
|
||||
if (fs.existsSync(fallback)) {
|
||||
return {
|
||||
id: "powershell",
|
||||
name: "Windows PowerShell",
|
||||
command: fallback,
|
||||
args: ["-NoLogo"],
|
||||
icon: "powershell",
|
||||
};
|
||||
}
|
||||
} catch (_err) {
|
||||
// Detection failed — not critical.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect PowerShell Core (pwsh 7+).
|
||||
* @returns {object|null}
|
||||
*/
|
||||
function detectPwsh() {
|
||||
try {
|
||||
// 1. where.exe
|
||||
const found = findExecutableOnPath("pwsh");
|
||||
if (found) {
|
||||
return {
|
||||
id: "pwsh",
|
||||
name: "PowerShell 7",
|
||||
command: found,
|
||||
args: ["-NoLogo"],
|
||||
icon: "pwsh",
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Registry App Paths
|
||||
const regPath = regQueryValue(
|
||||
"HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\pwsh.exe",
|
||||
"",
|
||||
);
|
||||
if (regPath && fs.existsSync(regPath)) {
|
||||
return {
|
||||
id: "pwsh",
|
||||
name: "PowerShell 7",
|
||||
command: regPath,
|
||||
args: ["-NoLogo"],
|
||||
icon: "pwsh",
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Common fallback path.
|
||||
const fallback = path.join(
|
||||
process.env.ProgramFiles || "C:\\Program Files",
|
||||
"PowerShell",
|
||||
"7",
|
||||
"pwsh.exe",
|
||||
);
|
||||
if (fs.existsSync(fallback)) {
|
||||
return {
|
||||
id: "pwsh",
|
||||
name: "PowerShell 7",
|
||||
command: fallback,
|
||||
args: ["-NoLogo"],
|
||||
icon: "pwsh",
|
||||
};
|
||||
}
|
||||
} catch (_err) {
|
||||
// Detection failed.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect installed WSL distributions via the registry.
|
||||
* @returns {object[]} Array of DiscoveredShell objects (may be empty).
|
||||
*/
|
||||
function detectWslDistros() {
|
||||
const wslExe = path.join(
|
||||
process.env.SystemRoot || "C:\\Windows",
|
||||
"System32",
|
||||
"wsl.exe",
|
||||
);
|
||||
if (!fs.existsSync(wslExe)) return [];
|
||||
|
||||
const distros = [];
|
||||
|
||||
// Primary: use `wsl.exe -l -q` which lists installed distros one per line.
|
||||
// More reliable than registry parsing across Windows versions.
|
||||
// Note: wsl.exe outputs UTF-16LE on some builds, so we read as buffer and decode.
|
||||
try {
|
||||
const buf = execFileSync(wslExe, ["-l", "-q"], {
|
||||
timeout: 5000,
|
||||
windowsHide: true,
|
||||
maxBuffer: 1024 * 64,
|
||||
});
|
||||
// wsl.exe outputs UTF-16LE on most Windows builds (has NUL bytes between chars).
|
||||
// Detect by checking for NUL bytes in the raw buffer; if present → UTF-16LE, else UTF-8.
|
||||
const isUtf16 = buf.length >= 2 && buf.includes(0x00);
|
||||
const output = buf.toString(isUtf16 ? "utf16le" : "utf8");
|
||||
const names = output
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.replace(/\0/g, "").trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (const distroName of names) {
|
||||
distros.push({
|
||||
id: `wsl-${distroName.toLowerCase().replace(/[^a-z0-9-]/g, "-")}`,
|
||||
name: `${distroName} (WSL)`,
|
||||
command: wslExe,
|
||||
args: ["-d", distroName],
|
||||
icon: mapWslDistroIcon(distroName),
|
||||
});
|
||||
}
|
||||
if (distros.length > 0) return distros;
|
||||
} catch (_err) {
|
||||
// wsl.exe -l -q failed, fall through to registry method.
|
||||
}
|
||||
|
||||
// Fallback: enumerate registry subkeys under Lxss
|
||||
try {
|
||||
const lxssKey = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss";
|
||||
const subkeys = regEnumSubkeys(lxssKey);
|
||||
|
||||
for (const subkey of subkeys) {
|
||||
try {
|
||||
const distroName = regQueryValue(subkey, "DistributionName");
|
||||
if (!distroName) continue;
|
||||
|
||||
distros.push({
|
||||
id: `wsl-${distroName.toLowerCase().replace(/[^a-z0-9-]/g, "-")}`,
|
||||
name: `${distroName} (WSL)`,
|
||||
command: wslExe,
|
||||
args: ["-d", distroName],
|
||||
icon: mapWslDistroIcon(distroName),
|
||||
});
|
||||
} catch (_err) {
|
||||
// Skip this distro but continue with others.
|
||||
}
|
||||
}
|
||||
} catch (_err) {
|
||||
// WSL not installed or registry not accessible.
|
||||
}
|
||||
return distros;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect Git Bash (from Git for Windows).
|
||||
* @returns {object|null}
|
||||
*/
|
||||
function detectGitBash() {
|
||||
try {
|
||||
// Try registry first.
|
||||
const installPath = regQueryValue(
|
||||
"HKLM\\SOFTWARE\\GitForWindows",
|
||||
"InstallPath",
|
||||
);
|
||||
if (installPath) {
|
||||
const bashExe = path.join(installPath, "bin", "bash.exe");
|
||||
if (fs.existsSync(bashExe)) {
|
||||
return {
|
||||
id: "git-bash",
|
||||
name: "Git Bash",
|
||||
command: bashExe,
|
||||
args: ["--login", "-i"],
|
||||
icon: "git-bash",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: common installation path.
|
||||
const fallbackPaths = [
|
||||
path.join(
|
||||
process.env.ProgramFiles || "C:\\Program Files",
|
||||
"Git",
|
||||
"bin",
|
||||
"bash.exe",
|
||||
),
|
||||
path.join(
|
||||
process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)",
|
||||
"Git",
|
||||
"bin",
|
||||
"bash.exe",
|
||||
),
|
||||
];
|
||||
for (const p of fallbackPaths) {
|
||||
if (fs.existsSync(p)) {
|
||||
return {
|
||||
id: "git-bash",
|
||||
name: "Git Bash",
|
||||
command: p,
|
||||
args: ["--login", "-i"],
|
||||
icon: "git-bash",
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (_err) {
|
||||
// Git Bash not installed.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect Cygwin bash.
|
||||
* @returns {object|null}
|
||||
*/
|
||||
function detectCygwin() {
|
||||
try {
|
||||
// Try 64-bit registry key first, then 32-bit (WOW6432Node).
|
||||
const rootDir =
|
||||
regQueryValue("HKLM\\SOFTWARE\\Cygwin\\setup", "rootdir") ||
|
||||
regQueryValue("HKLM\\SOFTWARE\\WOW6432Node\\Cygwin\\setup", "rootdir");
|
||||
|
||||
if (rootDir) {
|
||||
const bashExe = path.join(rootDir, "bin", "bash.exe");
|
||||
if (fs.existsSync(bashExe)) {
|
||||
return {
|
||||
id: "cygwin",
|
||||
name: "Cygwin",
|
||||
command: bashExe,
|
||||
args: ["--login", "-i"],
|
||||
icon: "cygwin",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: common path.
|
||||
const fallback = "C:\\cygwin64\\bin\\bash.exe";
|
||||
if (fs.existsSync(fallback)) {
|
||||
return {
|
||||
id: "cygwin",
|
||||
name: "Cygwin",
|
||||
command: fallback,
|
||||
args: ["--login", "-i"],
|
||||
icon: "cygwin",
|
||||
};
|
||||
}
|
||||
} catch (_err) {
|
||||
// Cygwin not installed.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main discovery entry point for Windows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Discover all available shells on a Windows system.
|
||||
* Returns an array of DiscoveredShell objects. Exactly one shell will have
|
||||
* `isDefault: true` based on priority: pwsh > powershell > cmd.
|
||||
*
|
||||
* @returns {Array<{id: string, name: string, command: string, args: string[], icon: string, isDefault?: boolean}>}
|
||||
*/
|
||||
function discoverWindowsShells() {
|
||||
const shells = [];
|
||||
|
||||
// Detect each shell type independently — failures are isolated.
|
||||
const cmd = detectCmd();
|
||||
if (cmd) shells.push(cmd);
|
||||
|
||||
const powershell = detectPowerShell();
|
||||
if (powershell) shells.push(powershell);
|
||||
|
||||
const pwsh = detectPwsh();
|
||||
if (pwsh) shells.push(pwsh);
|
||||
|
||||
const wslDistros = detectWslDistros();
|
||||
shells.push(...wslDistros);
|
||||
|
||||
const gitBash = detectGitBash();
|
||||
if (gitBash) shells.push(gitBash);
|
||||
|
||||
const cygwin = detectCygwin();
|
||||
if (cygwin) shells.push(cygwin);
|
||||
|
||||
// Assign default: pwsh > powershell > cmd
|
||||
const defaultShell =
|
||||
shells.find((s) => s.id === "pwsh") ||
|
||||
shells.find((s) => s.id === "powershell") ||
|
||||
shells.find((s) => s.id === "cmd");
|
||||
if (defaultShell) {
|
||||
defaultShell.isDefault = true;
|
||||
}
|
||||
|
||||
return shells;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unix shell detection helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Map a Unix shell binary basename to a human-readable display name.
|
||||
*
|
||||
* @param {string} basename e.g. "zsh", "bash", "nu"
|
||||
* @returns {string}
|
||||
*/
|
||||
function mapUnixShellName(basename) {
|
||||
const map = {
|
||||
zsh: "Zsh",
|
||||
bash: "Bash",
|
||||
fish: "Fish",
|
||||
sh: "sh",
|
||||
ksh: "Ksh",
|
||||
tcsh: "Tcsh",
|
||||
csh: "Csh",
|
||||
dash: "Dash",
|
||||
nu: "Nushell",
|
||||
pwsh: "PowerShell",
|
||||
};
|
||||
return map[basename] || basename;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a Unix shell binary basename to an icon identifier.
|
||||
*
|
||||
* @param {string} basename e.g. "zsh", "fish", "nu"
|
||||
* @returns {string}
|
||||
*/
|
||||
function mapUnixShellIcon(basename) {
|
||||
const map = {
|
||||
zsh: "zsh",
|
||||
bash: "bash",
|
||||
fish: "fish",
|
||||
sh: "terminal",
|
||||
ksh: "terminal",
|
||||
tcsh: "terminal",
|
||||
csh: "terminal",
|
||||
dash: "terminal",
|
||||
nu: "nushell",
|
||||
pwsh: "pwsh",
|
||||
};
|
||||
return map[basename] || "terminal";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true for shells that should be launched with the `-l` (login) flag.
|
||||
*
|
||||
* @param {string} basename
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isLoginShell(basename) {
|
||||
return ["bash", "zsh", "fish", "ksh", "sh"].includes(basename);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main discovery entry point for Unix
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Discover all available shells on a Unix/macOS system by reading /etc/shells.
|
||||
* The shell referenced by $SHELL is marked as default. If $SHELL is not in
|
||||
* /etc/shells it is prepended to the list.
|
||||
*
|
||||
* @returns {Array<{id: string, name: string, command: string, args: string[], icon: string, isDefault?: boolean}>}
|
||||
*/
|
||||
function discoverUnixShells() {
|
||||
const shells = [];
|
||||
const seen = new Set();
|
||||
|
||||
// Read /etc/shells — each non-comment line is an absolute path.
|
||||
let etcShellPaths = [];
|
||||
try {
|
||||
const content = fs.readFileSync("/etc/shells", "utf8");
|
||||
etcShellPaths = content
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l && !l.startsWith("#"));
|
||||
} catch (_err) {
|
||||
// /etc/shells not readable — fall through to $SHELL only.
|
||||
}
|
||||
|
||||
// Filter to existing files and deduplicate by real path.
|
||||
const validPaths = [];
|
||||
for (const shellPath of etcShellPaths) {
|
||||
try {
|
||||
if (!fs.existsSync(shellPath)) continue;
|
||||
const real = fs.realpathSync(shellPath);
|
||||
if (seen.has(real)) continue;
|
||||
seen.add(real);
|
||||
validPaths.push(shellPath);
|
||||
} catch (_err) {
|
||||
// Skip unresolvable paths.
|
||||
}
|
||||
}
|
||||
|
||||
// Build DiscoveredShell objects.
|
||||
// Track basename counts to detect duplicates (e.g., /bin/bash vs /usr/local/bin/bash)
|
||||
const baseCount = new Map();
|
||||
for (const shellPath of validPaths) {
|
||||
const base = path.basename(shellPath);
|
||||
baseCount.set(base, (baseCount.get(base) || 0) + 1);
|
||||
}
|
||||
|
||||
for (const shellPath of validPaths) {
|
||||
const base = path.basename(shellPath);
|
||||
const args = isLoginShell(base) ? ["-l"] : [];
|
||||
// Use basename as id when unique, otherwise use path slug to guarantee uniqueness
|
||||
const needsDisambiguation = baseCount.get(base) > 1;
|
||||
const id = needsDisambiguation
|
||||
? shellPath.replace(/^\/+/, "").replace(/[/\\]+/g, "-")
|
||||
: base;
|
||||
const name = needsDisambiguation
|
||||
? `${mapUnixShellName(base)} (${shellPath})`
|
||||
: mapUnixShellName(base);
|
||||
shells.push({
|
||||
id,
|
||||
name,
|
||||
command: shellPath,
|
||||
args,
|
||||
icon: mapUnixShellIcon(base),
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure $SHELL is present — prepend it if missing.
|
||||
const envShell = process.env.SHELL;
|
||||
if (envShell) {
|
||||
try {
|
||||
const envReal = fs.realpathSync(envShell);
|
||||
if (!seen.has(envReal) && fs.existsSync(envShell)) {
|
||||
const base = path.basename(envShell);
|
||||
const args = isLoginShell(base) ? ["-l"] : [];
|
||||
// Check if basename already exists in the list to disambiguate
|
||||
const hasDuplicate = shells.some((s) => path.basename(s.command) === base);
|
||||
const id = hasDuplicate
|
||||
? envShell.replace(/^\/+/, "").replace(/[/\\]+/g, "-")
|
||||
: base;
|
||||
const name = hasDuplicate
|
||||
? `${mapUnixShellName(base)} (${envShell})`
|
||||
: mapUnixShellName(base);
|
||||
shells.unshift({
|
||||
id,
|
||||
name,
|
||||
command: envShell,
|
||||
args,
|
||||
icon: mapUnixShellIcon(base),
|
||||
});
|
||||
}
|
||||
} catch (_err) {
|
||||
// $SHELL path invalid — ignore.
|
||||
}
|
||||
}
|
||||
|
||||
// Mark $SHELL as default (match by command path or basename).
|
||||
if (envShell) {
|
||||
const defaultShell =
|
||||
shells.find((s) => s.command === envShell) ||
|
||||
shells.find((s) => s.id === path.basename(envShell));
|
||||
if (defaultShell) {
|
||||
defaultShell.isDefault = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: mark first shell as default if none matched.
|
||||
if (shells.length > 0 && !shells.some((s) => s.isDefault)) {
|
||||
shells[0].isDefault = true;
|
||||
}
|
||||
|
||||
return shells;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unified shell discovery entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Discover all available shells for the current platform.
|
||||
* Results are cached after the first call.
|
||||
*
|
||||
* @returns {Array<{id: string, name: string, command: string, args: string[], icon: string, isDefault?: boolean}>}
|
||||
*/
|
||||
function discoverShells() {
|
||||
if (cachedShells) return cachedShells;
|
||||
|
||||
if (process.platform === "win32") {
|
||||
cachedShells = discoverWindowsShells();
|
||||
} else {
|
||||
cachedShells = discoverUnixShells();
|
||||
}
|
||||
|
||||
return cachedShells;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
module.exports = {
|
||||
discoverShells,
|
||||
discoverWindowsShells,
|
||||
discoverUnixShells,
|
||||
mapUnixShellName,
|
||||
mapUnixShellIcon,
|
||||
isLoginShell,
|
||||
regQueryValue,
|
||||
regEnumSubkeys,
|
||||
findExecutableOnPath,
|
||||
mapWslDistroIcon,
|
||||
};
|
||||
@@ -15,6 +15,7 @@ const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
|
||||
const { detectShellKind } = require("./ai/ptyExec.cjs");
|
||||
const { trackSessionIdlePrompt } = require("./ai/shellUtils.cjs");
|
||||
const { createZmodemSentry } = require("./zmodemHelper.cjs");
|
||||
const { discoverShells } = require("./shellDiscovery.cjs");
|
||||
|
||||
// Shared references
|
||||
let sessions = null;
|
||||
@@ -252,8 +253,20 @@ function startLocalSession(event, payload) {
|
||||
payload?.sessionId ||
|
||||
`${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const defaultShell = getDefaultLocalShell();
|
||||
const shell = normalizeExecutablePath(payload?.shell) || defaultShell;
|
||||
const shellArgs = getLocalShellArgs(shell);
|
||||
// payload.shell may be a discovered shell ID (e.g., "wsl-ubuntu") — resolve it
|
||||
let resolvedShell = payload?.shell;
|
||||
let resolvedArgs = payload?.shellArgs;
|
||||
if (resolvedShell && !/[/\\]/.test(resolvedShell)) {
|
||||
// Looks like a shell ID, not a path — try to resolve from discovery cache
|
||||
const shells = discoverShells();
|
||||
const match = shells.find((s) => s.id === resolvedShell);
|
||||
if (match) {
|
||||
resolvedShell = match.command;
|
||||
resolvedArgs = resolvedArgs ?? match.args;
|
||||
}
|
||||
}
|
||||
const shell = normalizeExecutablePath(resolvedShell) || defaultShell;
|
||||
const shellArgs = resolvedArgs ?? getLocalShellArgs(shell);
|
||||
const shellKind = detectShellKind(shell);
|
||||
const env = applyLocaleDefaults({
|
||||
...process.env,
|
||||
@@ -1044,6 +1057,7 @@ function registerHandlers(ipcMain) {
|
||||
ipcMain.handle("netcatty:serial:list", listSerialPorts);
|
||||
ipcMain.handle("netcatty:local:defaultShell", getDefaultShell);
|
||||
ipcMain.handle("netcatty:local:validatePath", validatePath);
|
||||
ipcMain.handle("netcatty:shells:discover", () => discoverShells());
|
||||
ipcMain.on("netcatty:write", writeToSession);
|
||||
ipcMain.on("netcatty:resize", resizeSession);
|
||||
ipcMain.on("netcatty:close", closeSession);
|
||||
|
||||
@@ -44,6 +44,10 @@ const OAUTH_LOOPBACK_PORT = 45678; // must match electron/bridges/oauthBridge.cj
|
||||
const WINDOW_STATE_FILE = "window-state.json";
|
||||
const DEFAULT_WINDOW_WIDTH = 1400;
|
||||
const DEFAULT_WINDOW_HEIGHT = 900;
|
||||
// Minimum window size: enough to render the expanded sidebar + a usable
|
||||
// host list + the 420px host details / new-host aside panel without overflow.
|
||||
const MIN_WINDOW_WIDTH = 1100;
|
||||
const MIN_WINDOW_HEIGHT = 640;
|
||||
|
||||
function debugLog(...args) {
|
||||
if (!DEBUG_WINDOWS) return;
|
||||
@@ -626,9 +630,10 @@ async function createWindow(electronModule, options) {
|
||||
};
|
||||
|
||||
if (savedState) {
|
||||
// Use saved dimensions
|
||||
windowBounds.width = savedState.width;
|
||||
windowBounds.height = savedState.height;
|
||||
// Use saved dimensions, but clamp to the minimum so a previously
|
||||
// shrunk window from an older build cannot start below the minimum.
|
||||
windowBounds.width = Math.max(savedState.width, MIN_WINDOW_WIDTH);
|
||||
windowBounds.height = Math.max(savedState.height, MIN_WINDOW_HEIGHT);
|
||||
|
||||
// Only use saved position if the screen is available at that location
|
||||
if (typeof savedState.x === "number" && typeof savedState.y === "number") {
|
||||
@@ -658,6 +663,8 @@ async function createWindow(electronModule, options) {
|
||||
|
||||
const win = new BrowserWindow({
|
||||
...windowBounds,
|
||||
minWidth: MIN_WINDOW_WIDTH,
|
||||
minHeight: MIN_WINDOW_HEIGHT,
|
||||
backgroundColor,
|
||||
icon: appIcon,
|
||||
show: false,
|
||||
@@ -721,6 +728,20 @@ async function createWindow(electronModule, options) {
|
||||
win.webContents.on("will-navigate", blockUntrustedNavigation);
|
||||
win.webContents.on("will-redirect", blockUntrustedNavigation);
|
||||
|
||||
// Prevent Chromium from consuming Alt+Arrow as browser back/forward navigation.
|
||||
// Terminal apps need these keys to pass through to the remote shell (e.g., byobu, tmux).
|
||||
// Using setIgnoreMenuShortcuts lets the keydown still reach the page (xterm.js)
|
||||
// while preventing Chromium's built-in shortcuts from triggering.
|
||||
win.webContents.on("before-input-event", (_event, input) => {
|
||||
if (input.alt && !input.control && !input.meta) {
|
||||
if (input.key === "ArrowLeft" || input.key === "ArrowRight") {
|
||||
win.webContents.setIgnoreMenuShortcuts(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
win.webContents.setIgnoreMenuShortcuts(false);
|
||||
});
|
||||
|
||||
// Restore maximized state if it was saved
|
||||
if (savedState?.isMaximized && !savedState?.isFullScreen) {
|
||||
win.once("ready-to-show", () => {
|
||||
|
||||
@@ -216,7 +216,7 @@ server.tool(
|
||||
// Tool: terminal_execute
|
||||
server.tool(
|
||||
"terminal_execute",
|
||||
"Execute a command on a Netcatty terminal session. For shell sessions, the command runs in the session's shell. For serial/raw sessions and network device sessions (deviceType: network), commands are sent as-is without shell wrapping and exit codes are unavailable.",
|
||||
"Execute a short command on a Netcatty terminal session and wait for the full result. Use this only for commands expected to finish within about 60 seconds. For long-running commands such as builds, scans, log-following, or anything likely to exceed that budget, use terminal_start and then terminal_poll instead.",
|
||||
{
|
||||
sessionId: z.string().describe("The terminal session ID (from get_environment) to execute on."),
|
||||
command: z.string().describe("The command to execute in the target session."),
|
||||
@@ -242,6 +242,69 @@ server.tool(
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"terminal_start",
|
||||
"Start a long-running command on a Netcatty terminal session without waiting for final completion. The command still runs in the visible terminal/PTTY so the user can watch live output. Prefer this whenever the command may exceed about 2 minutes, or when it streams output for an extended period, such as builds, scans, watch commands, and log-follow commands. After starting, wait at least about 30 seconds before the first terminal_poll unless you have a strong reason to check sooner.",
|
||||
{
|
||||
sessionId: z.string().describe("The terminal session ID (from get_environment) to execute on."),
|
||||
command: z.string().describe("The command to start in the target session."),
|
||||
},
|
||||
async ({ sessionId, command }) => {
|
||||
const guardErr = guardWriteOperation(command, { skipBlocklist: true });
|
||||
if (guardErr) {
|
||||
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
|
||||
}
|
||||
const result = await rpcCall("netcatty/jobStart", { ...scopeParams, sessionId, command });
|
||||
if (!result.ok) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error || "Failed to start background command"}` }], isError: true };
|
||||
}
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
jobId: result.jobId,
|
||||
sessionId: result.sessionId,
|
||||
status: result.status,
|
||||
startedAt: result.startedAt,
|
||||
outputMode: result.outputMode,
|
||||
recommendedPollIntervalMs: result.recommendedPollIntervalMs,
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"terminal_poll",
|
||||
"Poll a long-running Netcatty command that was started with terminal_start. Returns incremental output since the given offset and the current status. Use the returned nextOffset for the next poll. If outputTruncated is true, only the retained tail starting at outputBaseOffset is still available. Do not poll aggressively: wait at least about 30 seconds between polls unless the tool output explicitly justifies checking sooner. As soon as completed is true, stop polling and analyze the final result immediately.",
|
||||
{
|
||||
jobId: z.string().describe("The background job ID returned by terminal_start."),
|
||||
offset: z.number().int().min(0).optional().describe("Character offset previously returned as nextOffset. Omit or use 0 on the first poll."),
|
||||
},
|
||||
async ({ jobId, offset }) => {
|
||||
const result = await rpcCall("netcatty/jobPoll", { ...scopeParams, jobId, offset: offset || 0 });
|
||||
if (!result.ok) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error || "Failed to poll background command"}` }], isError: true };
|
||||
}
|
||||
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"terminal_stop",
|
||||
"Stop a long-running Netcatty command that was started with terminal_start. This sends Ctrl+C to the running terminal job and returns its latest state.",
|
||||
{
|
||||
jobId: z.string().describe("The background job ID returned by terminal_start."),
|
||||
},
|
||||
async ({ jobId }) => {
|
||||
const result = await rpcCall("netcatty/jobStop", { ...scopeParams, jobId });
|
||||
if (!result.ok) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error || "Failed to stop background command"}` }], isError: true };
|
||||
}
|
||||
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
// ── Start ──
|
||||
|
||||
async function main() {
|
||||
|
||||
@@ -561,6 +561,7 @@ const api = {
|
||||
getDefaultShell: async () => {
|
||||
return ipcRenderer.invoke("netcatty:local:defaultShell");
|
||||
},
|
||||
discoverShells: () => ipcRenderer.invoke("netcatty:shells:discover"),
|
||||
validatePath: async (path, type) => {
|
||||
return ipcRenderer.invoke("netcatty:local:validatePath", { path, type });
|
||||
},
|
||||
|
||||
13
global.d.ts
vendored
@@ -25,6 +25,16 @@ declare global {
|
||||
password?: string;
|
||||
}
|
||||
|
||||
// Discovered local shell (e.g. CMD, PowerShell, WSL, Git Bash)
|
||||
interface DiscoveredShell {
|
||||
id: string;
|
||||
name: string;
|
||||
command: string;
|
||||
args?: string[];
|
||||
icon: string;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
// Jump host configuration for SSH tunneling
|
||||
interface NetcattyJumpHost {
|
||||
hostname: string;
|
||||
@@ -176,7 +186,7 @@ declare global {
|
||||
env?: Record<string, string>;
|
||||
sessionLog?: { enabled: boolean; directory: string; format: string };
|
||||
}): Promise<string>;
|
||||
startLocalSession?(options: { sessionId?: string; cols?: number; rows?: number; shell?: string; cwd?: string; env?: Record<string, string>; sessionLog?: { enabled: boolean; directory: string; format: string } }): Promise<string>;
|
||||
startLocalSession?(options: { sessionId?: string; cols?: number; rows?: number; shell?: string; shellArgs?: string[]; cwd?: string; env?: Record<string, string>; sessionLog?: { enabled: boolean; directory: string; format: string } }): Promise<string>;
|
||||
startSerialSession?(options: {
|
||||
sessionId?: string;
|
||||
path: string;
|
||||
@@ -197,6 +207,7 @@ declare global {
|
||||
pnpId: string;
|
||||
}>>;
|
||||
getDefaultShell?(): Promise<string>;
|
||||
discoverShells?(): Promise<DiscoveredShell[]>;
|
||||
validatePath?(path: string, type?: 'file' | 'directory' | 'any'): Promise<{ exists: boolean; isFile: boolean; isDirectory: boolean }>;
|
||||
generateKeyPair?(options: {
|
||||
type: 'RSA' | 'ECDSA' | 'ED25519';
|
||||
|
||||
33
index.css
@@ -102,6 +102,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes split-panel-enter {
|
||||
0% {
|
||||
width: 0;
|
||||
min-width: 0;
|
||||
opacity: 0;
|
||||
transform: translateX(22px);
|
||||
}
|
||||
55% {
|
||||
opacity: 0.88;
|
||||
}
|
||||
100% {
|
||||
width: var(--aside-inline-width);
|
||||
min-width: var(--aside-inline-width);
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.split-panel-enter {
|
||||
animation: split-panel-enter 220ms cubic-bezier(0.24, 0.84, 0.32, 1) both;
|
||||
will-change: width, opacity, transform;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
@@ -166,6 +189,16 @@ body {
|
||||
background: linear-gradient(180deg, hsl(var(--background)) 0%, hsl(var(--background)) 60%, hsl(var(--background) / 0.9) 100%);
|
||||
}
|
||||
|
||||
/* Slim down xterm 6.0 VS Code scrollbar — wide hit area, thin visual slider */
|
||||
.xterm .xterm-scrollable-element > .scrollbar.vertical {
|
||||
width: 12px !important;
|
||||
}
|
||||
.xterm .xterm-scrollable-element > .scrollbar.vertical > .slider {
|
||||
width: 6px !important;
|
||||
border-radius: 3px;
|
||||
left: 3px !important;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
|
||||
@@ -46,8 +46,8 @@ export const XTERM_PERFORMANCE_CONFIG = {
|
||||
// Enable WebGL by default for GPU acceleration
|
||||
enabled: true,
|
||||
|
||||
// User can choose Canvas renderer on any platform
|
||||
preferCanvas: false,
|
||||
// User can choose DOM renderer on any platform (canvas removed in xterm 6.0)
|
||||
preferDOM: false,
|
||||
|
||||
// Handle WebGL context loss gracefully
|
||||
enableContextLoss: true,
|
||||
@@ -107,7 +107,7 @@ export const XTERM_PERFORMANCE_CONFIG = {
|
||||
|
||||
export type XTermPlatform = "darwin" | "win32" | "linux";
|
||||
|
||||
type RendererType = "canvas" | "dom";
|
||||
type RendererType = "dom";
|
||||
type LogLevel = "off" | "error" | "warn" | "info" | "debug";
|
||||
|
||||
export type ResolvedXTermPerformance = {
|
||||
@@ -127,7 +127,7 @@ export type ResolvedXTermPerformance = {
|
||||
rendererType?: RendererType;
|
||||
};
|
||||
useWebGLAddon: boolean;
|
||||
preferCanvasRenderer: boolean;
|
||||
preferDOMRenderer: boolean;
|
||||
};
|
||||
|
||||
const isLowMemoryDevice = (deviceMemoryGb?: number) =>
|
||||
@@ -141,11 +141,11 @@ export function getXTermConfig(platform: XTermPlatform = "darwin") {
|
||||
return resolveXTermPerformanceConfig({ platform }).options;
|
||||
}
|
||||
|
||||
export type RendererPreference = "auto" | "webgl" | "canvas";
|
||||
export type RendererPreference = "auto" | "webgl" | "dom";
|
||||
|
||||
/**
|
||||
* Resolve a platform and hardware aware performance profile.
|
||||
* When rendererType is 'auto', uses Canvas on low-memory devices to avoid WebGL overhead.
|
||||
* When rendererType is 'auto', uses DOM on low-memory devices to avoid WebGL overhead.
|
||||
*/
|
||||
export function resolveXTermPerformanceConfig({
|
||||
platform = "darwin",
|
||||
@@ -160,15 +160,15 @@ export function resolveXTermPerformanceConfig({
|
||||
|
||||
const lowMem = isLowMemoryDevice(deviceMemoryGb);
|
||||
|
||||
// Determine if we should use Canvas renderer
|
||||
let resolvedPreferCanvas: boolean;
|
||||
if (rendererType === "canvas") {
|
||||
resolvedPreferCanvas = true;
|
||||
// Determine if we should use DOM renderer (canvas removed in xterm 6.0)
|
||||
let resolvedPreferDOM: boolean;
|
||||
if (rendererType === "dom") {
|
||||
resolvedPreferDOM = true;
|
||||
} else if (rendererType === "webgl") {
|
||||
resolvedPreferCanvas = false;
|
||||
resolvedPreferDOM = false;
|
||||
} else {
|
||||
// Auto mode: use Canvas on low-memory devices
|
||||
resolvedPreferCanvas = baseConfig.webgl.preferCanvas || lowMem;
|
||||
// Auto mode: use DOM on low-memory devices
|
||||
resolvedPreferDOM = baseConfig.webgl.preferDOM || lowMem;
|
||||
}
|
||||
|
||||
const scrollbackProfile = lowMem
|
||||
@@ -177,7 +177,7 @@ export function resolveXTermPerformanceConfig({
|
||||
? "macOS"
|
||||
: "default";
|
||||
|
||||
const resolvedRendererType = resolvedPreferCanvas ? ("canvas" as const) : undefined;
|
||||
const resolvedRendererType = resolvedPreferDOM ? ("dom" as const) : undefined;
|
||||
|
||||
const baseOptions = {
|
||||
scrollback: baseConfig.scrollback[scrollbackProfile],
|
||||
@@ -200,7 +200,7 @@ export function resolveXTermPerformanceConfig({
|
||||
|
||||
return {
|
||||
options,
|
||||
useWebGLAddon: baseConfig.webgl.enabled && !resolvedPreferCanvas,
|
||||
preferCanvasRenderer: resolvedPreferCanvas,
|
||||
useWebGLAddon: baseConfig.webgl.enabled && !resolvedPreferDOM,
|
||||
preferDOMRenderer: resolvedPreferDOM,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -104,11 +104,12 @@ export async function getMonospaceFonts(): Promise<TerminalFont[]> {
|
||||
// Filter monospace fonts using robust word boundary matching
|
||||
const monoFonts = fonts.filter(f => isMonospaceFont(f.family));
|
||||
|
||||
// Deduplicate by family name (API may return multiple entries per family)
|
||||
// Deduplicate by family name, case-insensitive (API may return multiple entries per family)
|
||||
const uniqueFamilies = new Set<string>();
|
||||
const dedupedFonts = monoFonts.filter(f => {
|
||||
if (uniqueFamilies.has(f.family)) return false;
|
||||
uniqueFamilies.add(f.family);
|
||||
const key = f.family.toLowerCase();
|
||||
if (uniqueFamilies.has(key)) return false;
|
||||
uniqueFamilies.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ export type LocalOs = 'linux' | 'macos' | 'windows';
|
||||
const POWERSHELL_SHELLS = new Set(['powershell', 'powershell.exe', 'pwsh', 'pwsh.exe']);
|
||||
const CMD_SHELLS = new Set(['cmd', 'cmd.exe']);
|
||||
const FISH_SHELLS = new Set(['fish']);
|
||||
const POSIX_SHELLS = new Set(['sh', 'bash', 'zsh', 'ksh', 'dash', 'ash']);
|
||||
const POSIX_SHELLS = new Set(['sh', 'bash', 'zsh', 'ksh', 'dash', 'ash', 'bash.exe']);
|
||||
// WSL launcher — runs a Linux shell inside WSL, classify as posix
|
||||
const WSL_SHELLS = new Set(['wsl', 'wsl.exe']);
|
||||
|
||||
const getExecutableBaseName = (filePath: string | undefined): string => {
|
||||
const normalized = String(filePath || '').trim();
|
||||
@@ -29,6 +31,7 @@ export const classifyLocalShellType = (
|
||||
if (CMD_SHELLS.has(shellName)) return 'cmd';
|
||||
if (FISH_SHELLS.has(shellName)) return 'fish';
|
||||
if (POSIX_SHELLS.has(shellName)) return 'posix';
|
||||
if (WSL_SHELLS.has(shellName)) return 'posix';
|
||||
if (!shellName) {
|
||||
return detectLocalOs(platformLike) === 'windows' ? 'powershell' : 'posix';
|
||||
}
|
||||
|
||||
77
lib/useDiscoveredShells.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { netcattyBridge } from "../infrastructure/services/netcattyBridge";
|
||||
|
||||
let shellCache: DiscoveredShell[] | null = null;
|
||||
let shellPromise: Promise<DiscoveredShell[]> | null = null;
|
||||
|
||||
export function useDiscoveredShells(): DiscoveredShell[] {
|
||||
const [shells, setShells] = useState<DiscoveredShell[]>(shellCache ?? []);
|
||||
|
||||
useEffect(() => {
|
||||
if (shellCache) {
|
||||
setShells(shellCache);
|
||||
return;
|
||||
}
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.discoverShells) return;
|
||||
|
||||
if (!shellPromise) {
|
||||
shellPromise = bridge.discoverShells();
|
||||
}
|
||||
|
||||
shellPromise.then((result) => {
|
||||
shellCache = result;
|
||||
setShells(result);
|
||||
}).catch((err) => {
|
||||
console.warn("Failed to discover shells:", err);
|
||||
// Clear the failed promise so the next mount can retry
|
||||
shellPromise = null;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return shells;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a localShell setting value to shell command and args.
|
||||
* The value can be a discovered shell id (e.g., "wsl-ubuntu", "pwsh")
|
||||
* or a custom path/command (e.g., "/usr/local/bin/fish" or "fish").
|
||||
* Returns { command, args } or null when discovery hasn't loaded yet
|
||||
* and the value might be a shell ID that can't be resolved yet.
|
||||
*/
|
||||
export function resolveShellSetting(
|
||||
localShell: string,
|
||||
discoveredShells: DiscoveredShell[]
|
||||
): { command: string; args?: string[] } | null {
|
||||
if (!localShell) return null;
|
||||
|
||||
// Try to match as a discovered shell id
|
||||
const shell = discoveredShells.find(s => s.id === localShell);
|
||||
if (shell) {
|
||||
return { command: shell.command, args: shell.args };
|
||||
}
|
||||
|
||||
// No ID match — treat as a custom shell path/command and pass through.
|
||||
// This handles both custom executables (e.g., "/usr/local/bin/fish", "pwsh-preview")
|
||||
// and stale/synced IDs that no longer exist on this machine (graceful fallback
|
||||
// to whatever the OS resolves the name to, or a spawn error the user can see).
|
||||
return { command: localShell };
|
||||
}
|
||||
|
||||
const DISTRO_ICONS = new Set([
|
||||
"ubuntu", "debian", "kali", "alpine", "opensuse",
|
||||
"fedora", "arch", "oracle", "linux",
|
||||
]);
|
||||
|
||||
export function getShellIconPath(iconId: string): string {
|
||||
if (DISTRO_ICONS.has(iconId)) {
|
||||
return `/distro/${iconId}.svg`;
|
||||
}
|
||||
return `/shells/${iconId}.svg`;
|
||||
}
|
||||
|
||||
/** Distro icons are monochrome black and need `dark:invert` in dark mode */
|
||||
export function isMonochromeShellIcon(iconId: string): boolean {
|
||||
return DISTRO_ICONS.has(iconId);
|
||||
}
|
||||
87
package-lock.json
generated
@@ -32,13 +32,13 @@
|
||||
"@streamdown/cjk": "^1.0.2",
|
||||
"@streamdown/code": "^1.1.0",
|
||||
"@withfig/autocomplete": "^2.692.3",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-search": "^0.15.0",
|
||||
"@xterm/addon-serialize": "^0.13.0",
|
||||
"@xterm/addon-unicode11": "^0.9.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/addon-webgl": "^0.18.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-search": "^0.16.0",
|
||||
"@xterm/addon-serialize": "^0.14.0",
|
||||
"@xterm/addon-unicode-graphemes": "^0.4.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"@zed-industries/claude-agent-acp": "0.22.2",
|
||||
"@zed-industries/codex-acp": "0.10.0",
|
||||
"ai": "^6.0.116",
|
||||
@@ -6685,62 +6685,49 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
|
||||
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
|
||||
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/addon-search": {
|
||||
"version": "0.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.15.0.tgz",
|
||||
"integrity": "sha512-ZBZKLQ+EuKE83CqCmSSz5y1tx+aNOCUaA7dm6emgOX+8J9H1FWXZyrKfzjwzV+V14TV3xToz1goIeRhXBS5qjg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz",
|
||||
"integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/addon-serialize": {
|
||||
"version": "0.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.13.0.tgz",
|
||||
"integrity": "sha512-kGs8o6LWAmN1l2NpMp01/YkpxbmO4UrfWybeGu79Khw5K9+Krp7XhXbBTOTc3GJRRhd6EmILjpR8k5+odY39YQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
"version": "0.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0.tgz",
|
||||
"integrity": "sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/addon-unicode11": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0.tgz",
|
||||
"integrity": "sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==",
|
||||
"node_modules/@xterm/addon-unicode-graphemes": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode-graphemes/-/addon-unicode-graphemes-0.4.0.tgz",
|
||||
"integrity": "sha512-9+/CqwbKcnlkJU4d3wIgO+wjsL8f6vyz+UwUWLu6nADQz8Gr8ONqGCJfdDjIdI+yYZLABQqQy47FzEM6AWELjw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/addon-web-links": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz",
|
||||
"integrity": "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
|
||||
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/addon-webgl": {
|
||||
"version": "0.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.18.0.tgz",
|
||||
"integrity": "sha512-xCnfMBTI+/HKPdRnSOHaJDRqEpq2Ugy8LEj9GiY4J3zJObo3joylIFaMvzBwbYRg8zLtkO0KQaStCeSfoaI2/w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
"version": "0.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz",
|
||||
"integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"workspaces": [
|
||||
"addons/*"
|
||||
]
|
||||
},
|
||||
"node_modules/@yarnpkg/lockfile": {
|
||||
"version": "1.1.0",
|
||||
|
||||
14
package.json
@@ -50,13 +50,13 @@
|
||||
"@streamdown/cjk": "^1.0.2",
|
||||
"@streamdown/code": "^1.1.0",
|
||||
"@withfig/autocomplete": "^2.692.3",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-search": "^0.15.0",
|
||||
"@xterm/addon-serialize": "^0.13.0",
|
||||
"@xterm/addon-unicode11": "^0.9.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/addon-webgl": "^0.18.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-search": "^0.16.0",
|
||||
"@xterm/addon-serialize": "^0.14.0",
|
||||
"@xterm/addon-unicode-graphemes": "^0.4.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"@zed-industries/claude-agent-acp": "0.22.2",
|
||||
"@zed-industries/codex-acp": "0.10.0",
|
||||
"ai": "^6.0.116",
|
||||
|
||||
6
public/shells/bash.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<!-- Dark charcoal rounded square -->
|
||||
<rect width="32" height="32" rx="6" fill="#2D2D2D"/>
|
||||
<!-- Green $_ prompt, bold and centered -->
|
||||
<text x="7" y="21" font-family="monospace" font-size="16" font-weight="bold" fill="#4EC9B0">$_</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 313 B |
8
public/shells/cmd.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<!-- Dark background -->
|
||||
<rect width="32" height="32" rx="6" fill="#1E1E1E"/>
|
||||
<!-- Classic green C:\> prompt -->
|
||||
<text x="3" y="15" font-family="monospace" font-size="8" font-weight="bold" fill="#00FF00">C:\></text>
|
||||
<!-- Blinking cursor line -->
|
||||
<rect x="3" y="20" width="8" height="2" fill="#00FF00" opacity="0.8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 400 B |
8
public/shells/cygwin.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<!-- Dark rounded square -->
|
||||
<rect width="32" height="32" rx="6" fill="#1A1A2E"/>
|
||||
<!-- Gold border accent -->
|
||||
<rect x="1" y="1" width="30" height="30" rx="5" fill="none" stroke="#FFD700" stroke-width="1.5"/>
|
||||
<!-- CY text in gold -->
|
||||
<text x="16" y="21" font-family="monospace" font-size="13" font-weight="bold" fill="#FFD700" text-anchor="middle">CY</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 437 B |
15
public/shells/fish.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<!-- Fish shell: simple fish shape in Fish shell green -->
|
||||
<rect width="32" height="32" rx="6" fill="#1A2E1A"/>
|
||||
<!-- Fish body -->
|
||||
<ellipse cx="15" cy="16" rx="9" ry="6" fill="#4DB380"/>
|
||||
<!-- Fish tail -->
|
||||
<path d="M24 16 L29 11 L29 21 Z" fill="#3A9966"/>
|
||||
<!-- Fish eye -->
|
||||
<circle cx="10" cy="14" r="1.5" fill="#FFFFFF"/>
|
||||
<circle cx="10" cy="14" r="0.8" fill="#1A2E1A"/>
|
||||
<!-- Fish fin -->
|
||||
<path d="M13 11 Q16 8 19 11" fill="none" stroke="#98C379" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<!-- Subtle highlight -->
|
||||
<ellipse cx="14" cy="14" rx="4" ry="2" fill="#98C379" opacity="0.3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 682 B |
13
public/shells/git-bash.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<!-- Git logo inspired icon -->
|
||||
<rect width="32" height="32" rx="5" fill="#F05032"/>
|
||||
<!-- Git diamond shape -->
|
||||
<path d="M16 4 L28 16 L16 28 L4 16 Z" fill="none" stroke="#FFFFFF" stroke-width="2.5"/>
|
||||
<!-- Git branch lines inside -->
|
||||
<circle cx="16" cy="11" r="2.5" fill="#FFFFFF"/>
|
||||
<circle cx="11" cy="16" r="2.5" fill="#FFFFFF"/>
|
||||
<circle cx="21" cy="16" r="2.5" fill="#FFFFFF"/>
|
||||
<line x1="16" y1="13.5" x2="16" y2="22" stroke="#FFFFFF" stroke-width="2"/>
|
||||
<line x1="13.3" y1="17.5" x2="16" y2="22" stroke="#FFFFFF" stroke-width="2"/>
|
||||
<circle cx="16" cy="22" r="2" fill="#FFFFFF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 671 B |
9
public/shells/nushell.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<!-- Dark background -->
|
||||
<rect width="32" height="32" rx="6" fill="#0D1117"/>
|
||||
<!-- Teal "nu" text -->
|
||||
<text x="5" y="17" font-family="monospace" font-size="12" font-weight="bold" fill="#4EAA97">nu</text>
|
||||
<!-- > prompt with cursor -->
|
||||
<text x="5" y="27" font-family="monospace" font-size="10" font-weight="bold" fill="#56D4C0">></text>
|
||||
<text x="13" y="27" font-family="monospace" font-size="10" fill="#3A9985">_</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 503 B |
7
public/shells/powershell.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<!-- PowerShell blue rounded square -->
|
||||
<rect width="32" height="32" rx="6" fill="#012456"/>
|
||||
<!-- PS> prompt -->
|
||||
<text x="4" y="15" font-family="monospace" font-size="10" font-weight="bold" fill="#FFFFFF">PS</text>
|
||||
<text x="4" y="27" font-family="monospace" font-size="12" font-weight="bold" fill="#2CA5E0">>_</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 398 B |
8
public/shells/pwsh.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<!-- Darker background for PowerShell Core -->
|
||||
<rect width="32" height="32" rx="6" fill="#0D1117"/>
|
||||
<!-- PS7 label -->
|
||||
<text x="4" y="16" font-family="monospace" font-size="9" font-weight="bold" fill="#5BC4F5">PS7</text>
|
||||
<!-- >_ prompt -->
|
||||
<text x="4" y="27" font-family="monospace" font-size="11" font-weight="bold" fill="#2CA5E0">>_</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 425 B |
6
public/shells/terminal.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<!-- Neutral gray rounded square -->
|
||||
<rect width="32" height="32" rx="6" fill="#3C3C3C"/>
|
||||
<!-- White >_ prompt -->
|
||||
<text x="6" y="21" font-family="monospace" font-size="16" font-weight="bold" fill="#FFFFFF">>_</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 296 B |
8
public/shells/zsh.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<!-- Dark navy rounded square -->
|
||||
<rect width="32" height="32" rx="6" fill="#1B1F3B"/>
|
||||
<!-- Teal % prompt -->
|
||||
<text x="8" y="18" font-family="monospace" font-size="14" font-weight="bold" fill="#00D4AA">%</text>
|
||||
<!-- zsh label underneath -->
|
||||
<text x="16" y="28" font-family="sans-serif" font-size="7" font-weight="bold" fill="#00D4AA" text-anchor="middle" opacity="0.7">zsh</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 460 B |