Compare commits
139 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce1a00bed9 | ||
|
|
7df88f5bf7 | ||
|
|
eeb42b1d20 | ||
|
|
23475fb1ce | ||
|
|
fadd84606a | ||
|
|
d3e1a96702 | ||
|
|
91fd44cccf | ||
|
|
5b6f45c896 | ||
|
|
c924259fc0 | ||
|
|
f896f2a071 | ||
|
|
1851a8de71 | ||
|
|
53dd266f42 | ||
|
|
5e05d25c2b | ||
|
|
2d57015ac5 | ||
|
|
579dab56c2 | ||
|
|
f1fea53af6 | ||
|
|
aabae00970 | ||
|
|
9136569809 | ||
|
|
f2bcbe5123 | ||
|
|
3dcb792a55 | ||
|
|
5ca996d2d2 | ||
|
|
9ea1c3a92e | ||
|
|
af85401a69 | ||
|
|
5d3af6d107 | ||
|
|
68ab65764e | ||
|
|
514bea824a | ||
|
|
de874fc8c5 | ||
|
|
14ba1e779c | ||
|
|
0c1e269718 | ||
|
|
a96f5c332c | ||
|
|
a0b8d74582 | ||
|
|
e6166a1de3 | ||
|
|
ae797e5fb1 | ||
|
|
9a7d4decff | ||
|
|
fa29515095 | ||
|
|
34f9d2a663 | ||
|
|
90d161c1b5 | ||
|
|
7a5b6f506e | ||
|
|
c49346f6cc | ||
|
|
39a398aa2b | ||
|
|
0b7c52523e | ||
|
|
cb63f105aa | ||
|
|
316e46de4b | ||
|
|
1af5182b59 | ||
|
|
35194036cb | ||
|
|
6a077a3855 | ||
|
|
43f4687bb9 | ||
|
|
bbb888ae1e | ||
|
|
c74b78a49d | ||
|
|
7b2590e54e | ||
|
|
a7f42ec93e | ||
|
|
a886d509f8 | ||
|
|
d6fea6c328 | ||
|
|
b6169f1735 | ||
|
|
c97470a085 | ||
|
|
98cb9d09df | ||
|
|
9deb39dec2 | ||
|
|
bb45279d4e | ||
|
|
6b1d9ee409 | ||
|
|
c0c0378df0 | ||
|
|
093951150c | ||
|
|
a0418039c4 | ||
|
|
559e71cfcc | ||
|
|
a0a2567fa5 | ||
|
|
d080a43ae6 | ||
|
|
2c551cf5e8 | ||
|
|
c54aa52191 | ||
|
|
b8c838059a | ||
|
|
007b4bd389 | ||
|
|
13fd198243 | ||
|
|
2c562463c4 | ||
|
|
859d4b8156 | ||
|
|
c6e07cf149 | ||
|
|
0ab18ce186 | ||
|
|
f814719b32 | ||
|
|
ee6b05892d | ||
|
|
0f98ffd4f7 | ||
|
|
7ca5d0c832 | ||
|
|
1a76d34696 | ||
|
|
0b2d1b613b | ||
|
|
ded989b374 | ||
|
|
04c6348bc0 | ||
|
|
54297859e3 | ||
|
|
d236adcd48 | ||
|
|
4971f18bbe | ||
|
|
15687bd56e | ||
|
|
76675ec515 | ||
|
|
7c6304c355 | ||
|
|
8fdcbf87c2 | ||
|
|
0326ba7556 | ||
|
|
964230a737 | ||
|
|
5d551ee8e9 | ||
|
|
ec4e209972 | ||
|
|
c141fbc11e | ||
|
|
8e61ccac91 | ||
|
|
7c5047f22e | ||
|
|
c10100a314 | ||
|
|
5a294aa306 | ||
|
|
54b3ba2c01 | ||
|
|
f25822fdae | ||
|
|
69f433c161 | ||
|
|
6087343203 | ||
|
|
bb63de2658 | ||
|
|
fd938a84e4 | ||
|
|
c2e629ad61 | ||
|
|
4bf61c02a0 | ||
|
|
4747217929 | ||
|
|
fb3cdd0661 | ||
|
|
11ca8fba87 | ||
|
|
7ffc4b4c7f | ||
|
|
fe27dd8a9d | ||
|
|
eca11e9d2a | ||
|
|
779aa31ef8 | ||
|
|
2c8670a6c6 | ||
|
|
a94293d31e | ||
|
|
04b62f7ba3 | ||
|
|
45794b7f6f | ||
|
|
314072a631 | ||
|
|
c9f1951e28 | ||
|
|
7f83b22c95 | ||
|
|
b7082ab198 | ||
|
|
9369495e22 | ||
|
|
e3fdb1f7ff | ||
|
|
b9bc6b95e5 | ||
|
|
5cbaae8d2f | ||
|
|
915e571c63 | ||
|
|
86a43655e1 | ||
|
|
e47d86874f | ||
|
|
369de6fff2 | ||
|
|
3aa414ad05 | ||
|
|
356c27d0fb | ||
|
|
ae94e7e529 | ||
|
|
5828503ffc | ||
|
|
1c0f45e410 | ||
|
|
5c791cebe5 | ||
|
|
0ce6b0f777 | ||
|
|
6fca38a209 | ||
|
|
52541a6066 | ||
|
|
6d35301436 |
110
App.tsx
110
App.tsx
@@ -1,6 +1,7 @@
|
||||
import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { activeTabStore, useActiveTabId, useIsSftpActive, useIsTerminalLayerVisible, useIsVaultActive } from './application/state/activeTabStore';
|
||||
import { useAutoSync } from './application/state/useAutoSync';
|
||||
import { useImmersiveMode } from './application/state/useImmersiveMode';
|
||||
import { useManagedSourceSync } from './application/state/useManagedSourceSync';
|
||||
import { usePortForwardingAutoStart } from './application/state/usePortForwardingAutoStart';
|
||||
import { usePortForwardingState } from './application/state/usePortForwardingState';
|
||||
@@ -14,10 +15,15 @@ import { initializeUIFonts } from './application/state/uiFontStore';
|
||||
import { I18nProvider, useI18n } from './application/i18n/I18nProvider';
|
||||
import { matchesKeyBinding } from './domain/models';
|
||||
import { resolveHostAuth } from './domain/sshAuth';
|
||||
import { applySyncPayload } from './domain/syncPayload';
|
||||
import { resolveHostTerminalThemeId } from './domain/terminalAppearance';
|
||||
import { collectSessionIds } from './domain/workspace';
|
||||
import { TERMINAL_THEMES } from './infrastructure/config/terminalThemes';
|
||||
import { useCustomThemes } from './application/state/customThemeStore';
|
||||
import { applySyncPayload } from './application/syncPayload';
|
||||
import { getCredentialProtectionAvailability } from './infrastructure/services/credentialProtection';
|
||||
import { netcattyBridge } from './infrastructure/services/netcattyBridge';
|
||||
import { localStorageAdapter } from './infrastructure/persistence/localStorageAdapter';
|
||||
import { STORAGE_KEY_DEBUG_HOTKEYS } from './infrastructure/config/storageKeys';
|
||||
import { TopTabs } from './components/TopTabs';
|
||||
import { Button } from './components/ui/button';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from './components/ui/dialog';
|
||||
@@ -29,7 +35,7 @@ import { KeyboardInteractiveModal, KeyboardInteractiveRequest } from './componen
|
||||
import { PassphraseModal, PassphraseRequest } from './components/PassphraseModal';
|
||||
import { cn } from './lib/utils';
|
||||
import { classifyLocalShellType } from './lib/localShell';
|
||||
import { ConnectionLog, Host, HostProtocol, SerialConfig, TerminalTheme } from './types';
|
||||
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';
|
||||
import type { TerminalLayer as TerminalLayerComponent } from './components/TerminalLayer';
|
||||
@@ -98,8 +104,7 @@ const LazyCreateWorkspaceDialog = lazy(() =>
|
||||
const IS_DEV = import.meta.env.DEV;
|
||||
const HOTKEY_DEBUG =
|
||||
IS_DEV &&
|
||||
typeof window !== "undefined" &&
|
||||
window.localStorage?.getItem("debug.hotkeys") === "1";
|
||||
localStorageAdapter.readString(STORAGE_KEY_DEBUG_HOTKEYS) === "1";
|
||||
|
||||
const LazySftpView = lazy(() =>
|
||||
import('./components/SftpView').then((m) => ({ default: m.SftpView })),
|
||||
@@ -172,6 +177,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const {
|
||||
setTheme,
|
||||
resolvedTheme,
|
||||
terminalThemeId,
|
||||
setTerminalThemeId,
|
||||
currentTerminalTheme,
|
||||
terminalFontFamilyId,
|
||||
@@ -192,6 +198,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
sessionLogsEnabled,
|
||||
sessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
reapplyCurrentTheme,
|
||||
immersiveMode,
|
||||
} = settings;
|
||||
|
||||
const {
|
||||
@@ -271,6 +279,56 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
// isMacClient is used for window controls styling
|
||||
const isMacClient = typeof navigator !== 'undefined' && /Mac|Macintosh/.test(navigator.userAgent);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Immersive Mode — derive UI chrome colors from the active terminal's theme
|
||||
// ---------------------------------------------------------------------------
|
||||
const activeTabId = useActiveTabId();
|
||||
const customThemes = useCustomThemes();
|
||||
|
||||
// Resolve the effective TerminalTheme for the currently focused terminal tab
|
||||
const activeTerminalTheme = useMemo<TerminalTheme | null>(() => {
|
||||
if (activeTabId === 'vault' || activeTabId === 'sftp') return null;
|
||||
|
||||
const resolveTheme = (s: TerminalSession): TerminalTheme => {
|
||||
const host = hosts.find(h => h.id === s.hostId) ?? null;
|
||||
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
|
||||
return TERMINAL_THEMES.find(t => t.id === themeId)
|
||||
|| customThemes.find(t => t.id === themeId)
|
||||
|| currentTerminalTheme;
|
||||
};
|
||||
|
||||
// Workspace
|
||||
const workspace = workspaces.find(w => w.id === activeTabId);
|
||||
if (workspace) {
|
||||
// Focus mode: use the focused (or first remaining) session's theme
|
||||
if (workspace.viewMode === 'focus') {
|
||||
const wsSessionIds = collectSessionIds(workspace.root);
|
||||
const focused = sessions.find(s => s.id === workspace.focusedSessionId)
|
||||
?? sessions.find(s => wsSessionIds.includes(s.id));
|
||||
return focused ? resolveTheme(focused) : null;
|
||||
}
|
||||
// Split mode: require all sessions to share the same theme
|
||||
const sessionIds = collectSessionIds(workspace.root);
|
||||
const wsSessions = sessionIds.map(id => sessions.find(s => s.id === id)).filter(Boolean) as TerminalSession[];
|
||||
if (wsSessions.length === 0) return null;
|
||||
const firstTheme = resolveTheme(wsSessions[0]);
|
||||
const allSame = wsSessions.every(s => resolveTheme(s).id === firstTheme.id);
|
||||
return allSame ? firstTheme : null;
|
||||
}
|
||||
|
||||
// Single session tab
|
||||
const session = sessions.find(s => s.id === activeTabId);
|
||||
if (!session) return null;
|
||||
return resolveTheme(session);
|
||||
}, [activeTabId, sessions, workspaces, hosts, currentTerminalTheme, customThemes]);
|
||||
|
||||
useImmersiveMode({
|
||||
isImmersive: immersiveMode,
|
||||
activeTabId,
|
||||
activeTerminalTheme,
|
||||
restoreOriginalTheme: reapplyCurrentTheme,
|
||||
});
|
||||
|
||||
// Get port forwarding rules and import function
|
||||
const { rules: portForwardingRules, importRules: importPortForwardingRules, startTunnel, stopTunnel } = usePortForwardingState();
|
||||
|
||||
@@ -316,7 +374,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}, [handleSyncNow]);
|
||||
|
||||
// Update check hook - checks for new versions on startup
|
||||
const { updateState, dismissUpdate, openReleasePage, installUpdate } = useUpdateCheck();
|
||||
const { updateState, dismissUpdate, installUpdate } = useUpdateCheck();
|
||||
|
||||
// Window controls - must be before update toast effect which uses openSettingsWindow
|
||||
const { openSettingsWindow } = useWindowControls();
|
||||
@@ -351,7 +409,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}, [updateState.hasUpdate, updateState.latestRelease, updateState.autoDownloadStatus, t, openSettingsWindow, dismissUpdate]);
|
||||
|
||||
// Track previous autoDownloadStatus so toast effects fire only on actual transitions,
|
||||
// not when unrelated deps (openReleasePage, installUpdate) change their reference.
|
||||
// not when unrelated deps (installUpdate, openSettingsWindow) change their reference.
|
||||
const prevAutoDownloadStatusRef = useRef(updateState.autoDownloadStatus);
|
||||
useEffect(() => {
|
||||
const prev = prevAutoDownloadStatusRef.current;
|
||||
@@ -374,23 +432,18 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
t('update.downloadFailed.message'),
|
||||
{
|
||||
title: t('update.downloadFailed.title'),
|
||||
actionLabel: t('update.openReleases'),
|
||||
onClick: () => openReleasePage(),
|
||||
actionLabel: t('update.viewInSettings'),
|
||||
onClick: () => void openSettingsWindow(),
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [updateState.autoDownloadStatus, updateState.latestRelease?.version, t, installUpdate, openReleasePage]);
|
||||
|
||||
// Memoize keys for port forwarding to prevent unnecessary re-renders
|
||||
const portForwardingKeys = useMemo(
|
||||
() => keys.map((k) => ({ id: k.id, privateKey: k.privateKey, passphrase: k.passphrase, })),
|
||||
[keys]
|
||||
);
|
||||
}, [updateState.autoDownloadStatus, updateState.latestRelease?.version, t, installUpdate, openSettingsWindow]);
|
||||
|
||||
// Auto-start port forwarding rules on app launch
|
||||
usePortForwardingAutoStart({
|
||||
hosts,
|
||||
keys: portForwardingKeys,
|
||||
keys,
|
||||
identities,
|
||||
});
|
||||
|
||||
// Sync tray menu data + handle tray actions
|
||||
@@ -452,9 +505,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
return;
|
||||
}
|
||||
|
||||
const keysForPf = keys.map((k) => ({ id: k.id, privateKey: k.privateKey, passphrase: k.passphrase }));
|
||||
if (start) {
|
||||
void startTunnel(rule, host, keysForPf, (status, error) => {
|
||||
void startTunnel(rule, host, hosts, keys, identities, (status, error) => {
|
||||
if (status === "error" && error) toast.error(error);
|
||||
}, rule.autoStart);
|
||||
} else {
|
||||
@@ -466,7 +518,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
unsubscribeFocus?.();
|
||||
unsubscribeToggle?.();
|
||||
};
|
||||
}, [hosts, keys, portForwardingRules, sessions, setActiveTabId, setWorkspaceFocusedSession, startTunnel, stopTunnel, t]);
|
||||
}, [hosts, identities, keys, portForwardingRules, sessions, setActiveTabId, setWorkspaceFocusedSession, startTunnel, stopTunnel, t]);
|
||||
|
||||
// Tray panel actions (from main process)
|
||||
useEffect(() => {
|
||||
@@ -1210,7 +1262,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" onContextMenu={handleRootContextMenu}>
|
||||
<div className={cn("flex flex-col h-screen text-foreground font-sans netcatty-shell", immersiveMode && activeTerminalTheme && "immersive-transition")} onContextMenu={handleRootContextMenu}>
|
||||
<TopTabs
|
||||
theme={resolvedTheme}
|
||||
hosts={hosts}
|
||||
@@ -1231,6 +1283,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
onToggleTheme={handleToggleTheme}
|
||||
onOpenSettings={handleOpenSettings}
|
||||
onSyncNow={handleSyncNowManual}
|
||||
isImmersiveActive={immersiveMode && activeTerminalTheme !== null}
|
||||
onStartSessionDrag={setDraggingSessionId}
|
||||
onEndSessionDrag={handleEndSessionDrag}
|
||||
onReorderTabs={reorderTabs}
|
||||
@@ -1252,6 +1305,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
sessions={sessions}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
terminalThemeId={terminalThemeId}
|
||||
terminalFontSize={terminalFontSize}
|
||||
onOpenSettings={handleOpenSettings}
|
||||
onOpenQuickSwitcher={handleOpenQuickSwitcher}
|
||||
onCreateLocalTerminal={handleCreateLocalTerminal}
|
||||
@@ -1280,7 +1335,20 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
/>
|
||||
</VaultViewContainer>
|
||||
|
||||
<SftpViewMount hosts={hosts} keys={keys} identities={identities} updateHosts={updateHosts} />
|
||||
<SftpViewMount
|
||||
hosts={hosts}
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
updateHosts={updateHosts}
|
||||
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
|
||||
sftpAutoSync={sftpAutoSync}
|
||||
sftpShowHiddenFiles={sftpShowHiddenFiles}
|
||||
sftpUseCompressedUpload={sftpUseCompressedUpload}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
editorWordWrap={editorWordWrap}
|
||||
setEditorWordWrap={setEditorWordWrap}
|
||||
/>
|
||||
|
||||
<TerminalLayerMount
|
||||
hosts={hosts}
|
||||
|
||||
@@ -231,6 +231,9 @@ const en: Messages = {
|
||||
'settings.appearance.themeColor.desc': 'Pick a preset palette for each theme',
|
||||
'settings.appearance.themeColor.light': 'Light palette',
|
||||
'settings.appearance.themeColor.dark': 'Dark palette',
|
||||
'settings.appearance.immersiveMode': 'Immersive Mode',
|
||||
'settings.appearance.immersiveMode.desc':
|
||||
'When enabled, the UI chrome (tab bar, sidebar, status bar) adapts its colors to match the active terminal theme for a visually cohesive experience.',
|
||||
'settings.appearance.customCss': 'Custom CSS',
|
||||
'settings.appearance.customCss.desc':
|
||||
'Add custom CSS to personalize the app appearance. Changes apply immediately.',
|
||||
@@ -352,6 +355,15 @@ const en: Messages = {
|
||||
'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.auto': 'Auto',
|
||||
|
||||
// Settings > Terminal > Autocomplete
|
||||
'settings.terminal.section.autocomplete': 'Autocomplete',
|
||||
'settings.terminal.autocomplete.enabled': 'Enable autocomplete',
|
||||
'settings.terminal.autocomplete.enabled.desc': 'Show command suggestions based on history and command specs as you type.',
|
||||
'settings.terminal.autocomplete.ghostText': 'Ghost text',
|
||||
'settings.terminal.autocomplete.ghostText.desc': 'Show inline gray suggestion text after the cursor (like fish shell).',
|
||||
'settings.terminal.autocomplete.popupMenu': 'Popup menu',
|
||||
'settings.terminal.autocomplete.popupMenu.desc': 'Show a floating list of multiple suggestions.',
|
||||
|
||||
// Settings > Shortcuts
|
||||
'settings.shortcuts.section.scheme': 'Hotkey Scheme',
|
||||
'settings.shortcuts.scheme.label': 'Keyboard shortcuts',
|
||||
@@ -601,6 +613,8 @@ const en: Messages = {
|
||||
'sftp.filter.placeholder': 'Filter by filename...',
|
||||
'sftp.bookmark.add': 'Bookmark this path',
|
||||
'sftp.bookmark.remove': 'Remove bookmark',
|
||||
'sftp.bookmark.addGlobal': '+Global',
|
||||
'sftp.bookmark.addGlobalTooltip': 'Save as global bookmark (shared across all hosts)',
|
||||
'sftp.bookmark.empty': 'No bookmarks yet',
|
||||
'sftp.columns.name': 'Name',
|
||||
'sftp.columns.modified': 'Modified',
|
||||
@@ -712,6 +726,7 @@ const en: Messages = {
|
||||
'sftp.upload.phase.compressed': 'Compressed',
|
||||
|
||||
// SFTP File Opener
|
||||
'sftp.context.copyPath': 'Copy file path',
|
||||
'sftp.context.openWith': 'Open with...',
|
||||
'sftp.context.edit': 'Edit',
|
||||
'sftp.context.preview': 'Preview',
|
||||
@@ -894,9 +909,12 @@ const en: Messages = {
|
||||
'hostDetails.password.save': 'Save password',
|
||||
'hostDetails.identity.suggestions': 'Identities',
|
||||
'hostDetails.identity.missing': 'Identity not found',
|
||||
'hostDetails.credential.keyCertificate': 'Key, Certificate',
|
||||
'hostDetails.credential.keyCertificate': 'Key, Certificate, Local Key File',
|
||||
'hostDetails.credential.key': 'Key',
|
||||
'hostDetails.credential.certificate': 'Certificate',
|
||||
'hostDetails.credential.localKeyFile': 'Local Key File',
|
||||
'hostDetails.credential.localKeyFilePlaceholder': '~/.ssh/id_ed25519',
|
||||
'hostDetails.credential.browseKeyFile': 'Browse...',
|
||||
'hostDetails.credential.missing': 'Credential not found',
|
||||
'hostDetails.keys.search': 'Search keys...',
|
||||
'hostDetails.keys.empty': 'No keys available',
|
||||
@@ -1581,6 +1599,10 @@ const en: Messages = {
|
||||
'ai.providers.noMatchingModels': 'No matching models',
|
||||
'ai.providers.clickToLoadModels': 'Click to load models',
|
||||
'ai.providers.showingModels': 'Showing first 100 of {count} models. Type to filter.',
|
||||
'ai.providers.advancedParams': 'Advanced Parameters',
|
||||
'ai.providers.advancedParams.hint': 'Leave blank to use provider defaults.',
|
||||
'ai.providers.advancedParams.maxTokens.placeholder': 'e.g. 4096',
|
||||
'ai.providers.advancedParams.default': 'Provider default',
|
||||
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
|
||||
@@ -215,6 +215,9 @@ const zhCN: Messages = {
|
||||
'settings.appearance.themeColor.desc': '为浅色与深色主题选择预设配色',
|
||||
'settings.appearance.themeColor.light': '浅色主题',
|
||||
'settings.appearance.themeColor.dark': '深色主题',
|
||||
'settings.appearance.immersiveMode': '沉浸模式',
|
||||
'settings.appearance.immersiveMode.desc':
|
||||
'启用后,UI 外观(标签栏、侧边栏、状态栏)会自动适配当前终端主题的配色,营造视觉一体化的沉浸体验。',
|
||||
'settings.appearance.customCss': '自定义 CSS',
|
||||
'settings.appearance.customCss.desc': '使用自定义 CSS 个性化界面,修改会立即生效。',
|
||||
'settings.appearance.customCss.placeholder':
|
||||
@@ -425,6 +428,8 @@ const zhCN: Messages = {
|
||||
'sftp.filter.placeholder': '按文件名筛选...',
|
||||
'sftp.bookmark.add': '收藏此路径',
|
||||
'sftp.bookmark.remove': '取消收藏',
|
||||
'sftp.bookmark.addGlobal': '+全局',
|
||||
'sftp.bookmark.addGlobalTooltip': '保存为全局收藏(所有主机共享)',
|
||||
'sftp.bookmark.empty': '暂无收藏路径',
|
||||
'sftp.columns.name': '名称',
|
||||
'sftp.columns.modified': '修改时间',
|
||||
@@ -583,9 +588,12 @@ const zhCN: Messages = {
|
||||
'hostDetails.password.save': '保存密码',
|
||||
'hostDetails.identity.suggestions': '身份',
|
||||
'hostDetails.identity.missing': '身份不存在',
|
||||
'hostDetails.credential.keyCertificate': '密钥 / 证书',
|
||||
'hostDetails.credential.keyCertificate': '密钥 / 证书 / 本地密钥',
|
||||
'hostDetails.credential.key': '密钥',
|
||||
'hostDetails.credential.certificate': '证书',
|
||||
'hostDetails.credential.localKeyFile': '本地密钥文件',
|
||||
'hostDetails.credential.localKeyFilePlaceholder': '~/.ssh/id_ed25519',
|
||||
'hostDetails.credential.browseKeyFile': '浏览…',
|
||||
'hostDetails.credential.missing': '凭据不存在',
|
||||
'hostDetails.keys.search': '搜索密钥…',
|
||||
'hostDetails.keys.empty': '暂无密钥',
|
||||
@@ -1050,6 +1058,7 @@ const zhCN: Messages = {
|
||||
'sftp.upload.phase.compressed': '压缩传输',
|
||||
|
||||
// SFTP File Opener
|
||||
'sftp.context.copyPath': '复制文件路径',
|
||||
'sftp.context.openWith': '打开方式...',
|
||||
'sftp.context.edit': '编辑',
|
||||
'sftp.context.preview': '预览',
|
||||
@@ -1259,6 +1268,15 @@ const zhCN: Messages = {
|
||||
'settings.terminal.rendering.renderer.desc': '选择终端渲染技术。自动模式会在低内存设备上使用 Canvas。更改将在新终端会话中生效。',
|
||||
'settings.terminal.rendering.auto': '自动',
|
||||
|
||||
// Settings > Terminal > Autocomplete
|
||||
'settings.terminal.section.autocomplete': '自动补全',
|
||||
'settings.terminal.autocomplete.enabled': '启用自动补全',
|
||||
'settings.terminal.autocomplete.enabled.desc': '输入时根据历史命令和命令规范显示补全建议。',
|
||||
'settings.terminal.autocomplete.ghostText': '行内建议',
|
||||
'settings.terminal.autocomplete.ghostText.desc': '在光标后显示灰色的建议文本(类似 fish shell)。',
|
||||
'settings.terminal.autocomplete.popupMenu': '弹出菜单',
|
||||
'settings.terminal.autocomplete.popupMenu.desc': '显示包含多个建议的浮动列表。',
|
||||
|
||||
// Settings > Shortcuts
|
||||
'settings.shortcuts.section.scheme': '快捷键方案',
|
||||
'settings.shortcuts.scheme.label': '键盘快捷键',
|
||||
@@ -1595,6 +1613,10 @@ const zhCN: Messages = {
|
||||
'ai.providers.noMatchingModels': '没有匹配的模型',
|
||||
'ai.providers.clickToLoadModels': '点击加载模型',
|
||||
'ai.providers.showingModels': '显示前 100 个,共 {count} 个模型。输入以筛选。',
|
||||
'ai.providers.advancedParams': '高级参数',
|
||||
'ai.providers.advancedParams.hint': '留空则使用提供商默认值。',
|
||||
'ai.providers.advancedParams.maxTokens.placeholder': '例如 4096',
|
||||
'ai.providers.advancedParams.default': '提供商默认',
|
||||
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
|
||||
38
application/notification.ts
Normal file
38
application/notification.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Application-layer notification port.
|
||||
*
|
||||
* UI layers (e.g. toast) register their implementation via `setNotify`.
|
||||
* Application code calls `notify.*` without importing any UI module.
|
||||
*/
|
||||
|
||||
export interface NotifyOptions {
|
||||
title?: string;
|
||||
duration?: number;
|
||||
onClick?: () => void;
|
||||
actionLabel?: string;
|
||||
}
|
||||
|
||||
type NotifyFn = (message: string, titleOrOptions?: string | NotifyOptions) => void;
|
||||
|
||||
interface Notify {
|
||||
success: NotifyFn;
|
||||
error: NotifyFn;
|
||||
warning: NotifyFn;
|
||||
info: NotifyFn;
|
||||
}
|
||||
|
||||
const noop: NotifyFn = () => {};
|
||||
|
||||
let _impl: Notify = { success: noop, error: noop, warning: noop, info: noop };
|
||||
|
||||
/** Called once by the UI layer to wire up the real implementation. */
|
||||
export function setNotify(impl: Notify): void {
|
||||
_impl = impl;
|
||||
}
|
||||
|
||||
export const notify: Notify = {
|
||||
success: (...args) => _impl.success(...args),
|
||||
error: (...args) => _impl.error(...args),
|
||||
warning: (...args) => _impl.warning(...args),
|
||||
info: (...args) => _impl.info(...args),
|
||||
};
|
||||
46
application/state/sessionActivity.ts
Normal file
46
application/state/sessionActivity.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { TerminalSession } from '../../types';
|
||||
|
||||
type SessionActivityMap = Record<string, boolean>;
|
||||
|
||||
export const getValidSessionActivityIds = (sessions: TerminalSession[]): Set<string> => {
|
||||
return new Set(sessions.map((session) => session.id));
|
||||
};
|
||||
|
||||
export const shouldMarkSessionActivity = (
|
||||
activeTabId: string | null,
|
||||
session: Pick<TerminalSession, 'id' | 'workspaceId'>,
|
||||
): boolean => {
|
||||
return activeTabId !== session.id && activeTabId !== session.workspaceId;
|
||||
};
|
||||
|
||||
export const getSessionActivityIdsToClear = (
|
||||
activeTabId: string | null,
|
||||
sessions: TerminalSession[],
|
||||
): string[] => {
|
||||
if (!activeTabId || activeTabId === 'vault' || activeTabId === 'sftp') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const activeSession = sessions.find((session) => session.id === activeTabId);
|
||||
if (activeSession) {
|
||||
return [activeSession.id];
|
||||
}
|
||||
|
||||
return sessions
|
||||
.filter((session) => session.workspaceId === activeTabId)
|
||||
.map((session) => session.id);
|
||||
};
|
||||
|
||||
export const buildWorkspaceActivityMap = (
|
||||
sessions: TerminalSession[],
|
||||
sessionActivityMap: SessionActivityMap,
|
||||
): Map<string, boolean> => {
|
||||
const workspaceActivityMap = new Map<string, boolean>();
|
||||
|
||||
for (const session of sessions) {
|
||||
if (!session.workspaceId || !sessionActivityMap[session.id]) continue;
|
||||
workspaceActivityMap.set(session.workspaceId, true);
|
||||
}
|
||||
|
||||
return workspaceActivityMap;
|
||||
};
|
||||
78
application/state/sessionActivityStore.ts
Normal file
78
application/state/sessionActivityStore.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
class SessionActivityStore {
|
||||
private snapshot: Record<string, boolean> = {};
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
getSnapshot = () => this.snapshot;
|
||||
|
||||
subscribe = (listener: Listener) => {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
};
|
||||
|
||||
private emit() {
|
||||
this.listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
setTabActive = (tabId: string, hasActivity: boolean) => {
|
||||
const alreadyActive = !!this.snapshot[tabId];
|
||||
if (alreadyActive === hasActivity) return;
|
||||
|
||||
if (hasActivity) {
|
||||
this.snapshot = { ...this.snapshot, [tabId]: true };
|
||||
} else {
|
||||
const { [tabId]: _removed, ...rest } = this.snapshot;
|
||||
this.snapshot = rest;
|
||||
}
|
||||
|
||||
this.emit();
|
||||
};
|
||||
|
||||
clearTab = (tabId: string) => {
|
||||
this.setTabActive(tabId, false);
|
||||
};
|
||||
|
||||
clearTabs = (tabIds: Iterable<string>) => {
|
||||
let changed = false;
|
||||
const next = { ...this.snapshot };
|
||||
|
||||
for (const tabId of tabIds) {
|
||||
if (!next[tabId]) continue;
|
||||
delete next[tabId];
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) return;
|
||||
this.snapshot = next;
|
||||
this.emit();
|
||||
};
|
||||
|
||||
prune = (validTabIds: Set<string>) => {
|
||||
let changed = false;
|
||||
const next: Record<string, boolean> = {};
|
||||
|
||||
for (const tabId of Object.keys(this.snapshot)) {
|
||||
if (validTabIds.has(tabId)) {
|
||||
next[tabId] = true;
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) return;
|
||||
this.snapshot = next;
|
||||
this.emit();
|
||||
};
|
||||
}
|
||||
|
||||
export const sessionActivityStore = new SessionActivityStore();
|
||||
|
||||
export const useSessionActivityMap = () => {
|
||||
return useSyncExternalStore(
|
||||
sessionActivityStore.subscribe,
|
||||
sessionActivityStore.getSnapshot,
|
||||
);
|
||||
};
|
||||
@@ -7,6 +7,7 @@ export interface SftpPane {
|
||||
loading: boolean;
|
||||
reconnecting: boolean;
|
||||
error: string | null;
|
||||
connectionLogs: string[];
|
||||
selectedFiles: Set<string>;
|
||||
filter: string;
|
||||
filenameEncoding: SftpFilenameEncoding;
|
||||
@@ -33,6 +34,7 @@ export const createEmptyPane = (
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
error: null,
|
||||
connectionLogs: [],
|
||||
selectedFiles: new Set(),
|
||||
filter: "",
|
||||
filenameEncoding: "auto",
|
||||
|
||||
@@ -159,6 +159,7 @@ export const useSftpConnections = ({
|
||||
loading: true,
|
||||
reconnecting: false,
|
||||
error: null,
|
||||
connectionLogs: [],
|
||||
filenameEncoding, // Reset encoding for new connection
|
||||
}));
|
||||
|
||||
@@ -213,13 +214,57 @@ export const useSftpConnections = ({
|
||||
loading: true,
|
||||
reconnecting: prev.reconnecting,
|
||||
error: null,
|
||||
connectionLogs: [],
|
||||
files: prev.reconnecting ? prev.files : (sharedHostCache?.files ?? []),
|
||||
filenameEncoding, // Reset encoding for new connection
|
||||
}));
|
||||
|
||||
// Subscribe to SFTP connection progress events for auth logging
|
||||
const sftpSessionId = `sftp-${connectionId}`;
|
||||
let unsubSftpProgress: (() => void) | undefined;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.onSftpConnectionProgress) {
|
||||
unsubSftpProgress = bridge.onSftpConnectionProgress((sid, label, status, detail) => {
|
||||
if (sid !== sftpSessionId) return;
|
||||
let logLine: string;
|
||||
switch (status) {
|
||||
case 'connecting':
|
||||
logLine = `Connecting to ${label}...`;
|
||||
break;
|
||||
case 'authenticating':
|
||||
logLine = `${label} - Key exchange complete`;
|
||||
break;
|
||||
case 'auth-attempt':
|
||||
if (detail?.endsWith('rejected')) {
|
||||
logLine = `${label} - ✗ ${detail}`;
|
||||
} else if (detail === 'all methods exhausted') {
|
||||
logLine = `${label} - ✗ All authentication methods exhausted`;
|
||||
} else if (detail === 'waiting for user input...' || detail === 'user responded') {
|
||||
logLine = `${label} - ${detail}`;
|
||||
} else {
|
||||
logLine = `${label} - Trying ${detail}...`;
|
||||
}
|
||||
break;
|
||||
case 'connected':
|
||||
logLine = `${label} - Connected`;
|
||||
break;
|
||||
case 'error':
|
||||
logLine = `${label} - Error${detail ? `: ${detail}` : ''}`;
|
||||
break;
|
||||
default:
|
||||
logLine = `${label} - ${status}${detail ? `: ${detail}` : ''}`;
|
||||
}
|
||||
// Only update if this is still the active request (avoids stale logs leaking)
|
||||
if (navSeqRef.current[side] !== connectRequestId) return;
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
connectionLogs: [...prev.connectionLogs, logLine],
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const credentials = getHostCredentials(host);
|
||||
const bridge = netcattyBridge.get();
|
||||
const openSftp = bridge?.openSftp;
|
||||
if (!openSftp) throw new Error("SFTP bridge unavailable");
|
||||
|
||||
@@ -407,6 +452,7 @@ export const useSftpConnections = ({
|
||||
files,
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
connectionLogs: [], // Clear after successful connect to avoid replay during navigation
|
||||
}));
|
||||
} catch (err) {
|
||||
if (navSeqRef.current[side] !== connectRequestId) return;
|
||||
@@ -424,6 +470,8 @@ export const useSftpConnections = ({
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
}));
|
||||
} finally {
|
||||
unsubSftpProgress?.();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -49,6 +49,7 @@ export const useSftpDirectoryListing = () => {
|
||||
sizeFormatted: formatFileSize(size),
|
||||
lastModified,
|
||||
lastModifiedFormatted: formatDate(lastModified),
|
||||
permissions: f.permissions,
|
||||
linkTarget: f.linkTarget as "file" | "directory" | null | undefined,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback } from "react";
|
||||
import type { Host, Identity, SSHKey } from "../../../domain/models";
|
||||
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "../../../domain/credentials";
|
||||
import { resolveHostAuth } from "../../../domain/sshAuth";
|
||||
|
||||
interface UseSftpHostCredentialsParams {
|
||||
@@ -24,22 +25,32 @@ export const useSftpHostCredentials = ({
|
||||
host: host.proxyConfig.host,
|
||||
port: host.proxyConfig.port,
|
||||
username: host.proxyConfig.username,
|
||||
password: host.proxyConfig.password,
|
||||
password: sanitizeCredentialValue(host.proxyConfig.password),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
let jumpHosts: NetcattyJumpHost[] | undefined;
|
||||
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
|
||||
jumpHosts = host.hostChain.hostIds
|
||||
.map((hostId) => hosts.find((h) => h.id === hostId))
|
||||
.filter((h): h is Host => !!h)
|
||||
.map((jumpHost) => {
|
||||
.map((jumpHost, index) => {
|
||||
const jumpAuth = resolveHostAuth({
|
||||
host: jumpHost,
|
||||
keys,
|
||||
identities,
|
||||
});
|
||||
const jumpKey = jumpAuth.key;
|
||||
const hasConfiguredJumpProxyEndpoint =
|
||||
index === 0 &&
|
||||
!!(jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port);
|
||||
if (
|
||||
hasConfiguredJumpProxyEndpoint &&
|
||||
jumpHost.proxyConfig?.username &&
|
||||
isEncryptedCredentialPlaceholder(jumpHost.proxyConfig.password) &&
|
||||
!sanitizeCredentialValue(jumpHost.proxyConfig.password)
|
||||
) {
|
||||
throw new Error(`Proxy credentials for jump host "${jumpHost.label || jumpHost.hostname}" cannot be decrypted on this device. Open host settings and re-enter the proxy password.`);
|
||||
}
|
||||
return {
|
||||
hostname: jumpHost.hostname,
|
||||
port: jumpHost.port || 22,
|
||||
@@ -52,9 +63,23 @@ export const useSftpHostCredentials = ({
|
||||
keyId: jumpAuth.keyId,
|
||||
keySource: jumpKey?.source,
|
||||
label: jumpHost.label,
|
||||
proxy: jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port
|
||||
? {
|
||||
type: jumpHost.proxyConfig.type,
|
||||
host: jumpHost.proxyConfig.host,
|
||||
port: jumpHost.proxyConfig.port,
|
||||
username: jumpHost.proxyConfig.username,
|
||||
password: sanitizeCredentialValue(jumpHost.proxyConfig.password),
|
||||
}
|
||||
: undefined,
|
||||
identityFilePaths: jumpHost.identityFilePaths,
|
||||
};
|
||||
});
|
||||
}
|
||||
const usesTargetProxyForFirstHop = !!proxyConfig && !jumpHosts?.[0]?.proxy;
|
||||
if (usesTargetProxyForFirstHop && host.proxyConfig?.username && isEncryptedCredentialPlaceholder(host.proxyConfig.password) && !proxyConfig?.password) {
|
||||
throw new Error("Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.");
|
||||
}
|
||||
|
||||
return {
|
||||
hostname: host.hostname,
|
||||
@@ -70,6 +95,7 @@ export const useSftpHostCredentials = ({
|
||||
proxy: proxyConfig,
|
||||
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
|
||||
sudo: host.sftpSudo,
|
||||
identityFilePaths: host.identityFilePaths,
|
||||
};
|
||||
},
|
||||
[hosts, identities, keys],
|
||||
|
||||
@@ -48,7 +48,7 @@ export const joinPath = (base: string, name: string): string => {
|
||||
return `${normalizedBase}\\${name}`;
|
||||
}
|
||||
if (base === "/") return `/${name}`;
|
||||
return `${base}/${name}`;
|
||||
return `${base.replace(/\/+$/, "")}/${name}`;
|
||||
};
|
||||
|
||||
export const getParentPath = (path: string): string => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
STORAGE_KEY_AI_COMMAND_TIMEOUT,
|
||||
STORAGE_KEY_AI_MAX_ITERATIONS,
|
||||
STORAGE_KEY_AI_SESSIONS,
|
||||
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
|
||||
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
@@ -32,6 +33,12 @@ function getAIBridge() {
|
||||
return (window as unknown as { netcatty?: Record<string, (...args: unknown[]) => unknown> }).netcatty;
|
||||
}
|
||||
|
||||
const AI_STATE_CHANGED_EVENT = 'netcatty:ai-state-changed';
|
||||
|
||||
function emitAIStateChanged(key: string) {
|
||||
window.dispatchEvent(new CustomEvent<{ key: string }>(AI_STATE_CHANGED_EVENT, { detail: { key } }));
|
||||
}
|
||||
|
||||
function cleanupAcpSessions(sessionIds: string[]) {
|
||||
const bridge = getAIBridge();
|
||||
if (!bridge?.aiAcpCleanup || sessionIds.length === 0) return;
|
||||
@@ -40,6 +47,48 @@ function cleanupAcpSessions(sessionIds: string[]) {
|
||||
}
|
||||
}
|
||||
|
||||
export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
const currentSessions = latestAISessionsSnapshot
|
||||
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
||||
?? [];
|
||||
const removedSessionIds = currentSessions
|
||||
.filter((session) => session.scope.targetId && !activeTargetIds.has(session.scope.targetId))
|
||||
.map((session) => session.id);
|
||||
|
||||
if (removedSessionIds.length === 0) return;
|
||||
|
||||
cleanupAcpSessions(removedSessionIds);
|
||||
|
||||
const removedSessionIdSet = new Set(removedSessionIds);
|
||||
|
||||
const nextSessions = currentSessions.filter((session) => {
|
||||
if (!session.scope.targetId) return true;
|
||||
return activeTargetIds.has(session.scope.targetId);
|
||||
});
|
||||
setLatestAISessionsSnapshot(nextSessions);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, pruneSessionsForStorage(nextSessions));
|
||||
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
|
||||
|
||||
const activeSessionIdMap = latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {};
|
||||
let activeSessionMapChanged = false;
|
||||
const nextActiveSessionIdMap = { ...activeSessionIdMap };
|
||||
|
||||
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
|
||||
if (sessionId && removedSessionIdSet.has(sessionId)) {
|
||||
nextActiveSessionIdMap[scopeKey] = null;
|
||||
activeSessionMapChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (activeSessionMapChanged) {
|
||||
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Maximum number of sessions to keep in localStorage. */
|
||||
const MAX_STORED_SESSIONS = 50;
|
||||
@@ -66,6 +115,17 @@ function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
|
||||
});
|
||||
}
|
||||
|
||||
let latestAISessionsSnapshot: AISession[] | null = null;
|
||||
let latestAIActiveSessionMapSnapshot: Record<string, string | null> | null = null;
|
||||
|
||||
function setLatestAISessionsSnapshot(sessions: AISession[]) {
|
||||
latestAISessionsSnapshot = sessions;
|
||||
}
|
||||
|
||||
function setLatestAIActiveSessionMapSnapshot(activeSessionIdMap: Record<string, string | null>) {
|
||||
latestAIActiveSessionMapSnapshot = activeSessionIdMap;
|
||||
}
|
||||
|
||||
export function useAIState() {
|
||||
// ── Provider Config ──
|
||||
const [providers, setProvidersRaw] = useState<ProviderConfig[]>(() =>
|
||||
@@ -117,7 +177,9 @@ export function useAIState() {
|
||||
sessionsRef.current = sessions;
|
||||
}, [sessions]);
|
||||
// Per-scope active session: keyed by `${scopeType}:${scopeTargetId}`
|
||||
const [activeSessionIdMap, setActiveSessionIdMapRaw] = useState<Record<string, string | null>>({});
|
||||
const [activeSessionIdMap, setActiveSessionIdMapRaw] = useState<Record<string, string | null>>(() =>
|
||||
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {}
|
||||
);
|
||||
|
||||
// Per-agent model selection: remembers last selected model per agent
|
||||
const [agentModelMap, setAgentModelMapRaw] = useState<Record<string, string>>(() =>
|
||||
@@ -129,8 +191,43 @@ export function useAIState() {
|
||||
localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setLatestAISessionsSnapshot(sessions);
|
||||
}, [sessions]);
|
||||
|
||||
useEffect(() => {
|
||||
setLatestAIActiveSessionMapSnapshot(activeSessionIdMap);
|
||||
}, [activeSessionIdMap]);
|
||||
|
||||
useEffect(() => {
|
||||
const validSessionIds = new Set(sessions.map((session) => session.id));
|
||||
let changed = false;
|
||||
const nextActiveSessionIdMap: Record<string, string | null> = {};
|
||||
|
||||
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
|
||||
const nextSessionId = sessionId && validSessionIds.has(sessionId) ? sessionId : null;
|
||||
nextActiveSessionIdMap[scopeKey] = nextSessionId;
|
||||
if (nextSessionId !== sessionId) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) return;
|
||||
|
||||
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
|
||||
setActiveSessionIdMapRaw(nextActiveSessionIdMap);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
}, [sessions, activeSessionIdMap]);
|
||||
|
||||
const setActiveSessionId = useCallback((scopeKey: string, id: string | null) => {
|
||||
setActiveSessionIdMapRaw(prev => ({ ...prev, [scopeKey]: id }));
|
||||
setActiveSessionIdMapRaw(prev => {
|
||||
const next = { ...prev, [scopeKey]: id };
|
||||
setLatestAIActiveSessionMapSnapshot(next);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setAgentModel = useCallback((agentId: string, modelId: string) => {
|
||||
@@ -303,9 +400,22 @@ export function useAIState() {
|
||||
setHostPermissionsRaw(perms ?? []);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_SESSIONS: {
|
||||
const nextSessions = localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? [];
|
||||
setLatestAISessionsSnapshot(nextSessions);
|
||||
setSessionsRaw(nextSessions);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_AGENT_MODEL_MAP:
|
||||
setAgentModelMapRaw(localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP) ?? {});
|
||||
break;
|
||||
case STORAGE_KEY_AI_ACTIVE_SESSION_MAP: {
|
||||
const nextActiveSessionIdMap =
|
||||
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {};
|
||||
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
|
||||
setActiveSessionIdMapRaw(nextActiveSessionIdMap);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_WEB_SEARCH:
|
||||
setWebSearchConfigRaw(localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null);
|
||||
break;
|
||||
@@ -315,7 +425,33 @@ export function useAIState() {
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', handleStorage);
|
||||
return () => window.removeEventListener('storage', handleStorage);
|
||||
const handleLocalStateChanged = (event: Event) => {
|
||||
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
|
||||
if (!key) return;
|
||||
switch (key) {
|
||||
case STORAGE_KEY_AI_SESSIONS:
|
||||
setSessionsRaw(
|
||||
latestAISessionsSnapshot
|
||||
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
||||
?? [],
|
||||
);
|
||||
return;
|
||||
case STORAGE_KEY_AI_ACTIVE_SESSION_MAP:
|
||||
setActiveSessionIdMapRaw(
|
||||
latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {},
|
||||
);
|
||||
return;
|
||||
default:
|
||||
handleStorage({ key } as StorageEvent);
|
||||
}
|
||||
};
|
||||
window.addEventListener(AI_STATE_CHANGED_EVENT, handleLocalStateChanged);
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorage);
|
||||
window.removeEventListener(AI_STATE_CHANGED_EVENT, handleLocalStateChanged);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ── Sync initial safety settings to MCP Server on mount ──
|
||||
@@ -375,6 +511,7 @@ export function useAIState() {
|
||||
};
|
||||
setSessionsRaw(prev => {
|
||||
const next = [session, ...prev];
|
||||
setLatestAISessionsSnapshot(next);
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
@@ -391,12 +528,19 @@ export function useAIState() {
|
||||
}
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.filter(s => s.id !== sessionId);
|
||||
setLatestAISessionsSnapshot(next);
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
if (scopeKey) {
|
||||
setActiveSessionIdMapRaw(prev => {
|
||||
if (prev[scopeKey] === sessionId) return { ...prev, [scopeKey]: null };
|
||||
if (prev[scopeKey] === sessionId) {
|
||||
const next = { ...prev, [scopeKey]: null };
|
||||
setLatestAIActiveSessionMapSnapshot(next);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
return next;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
@@ -415,12 +559,19 @@ export function useAIState() {
|
||||
const next = prev.filter(s => {
|
||||
return !(s.scope.type === scopeType && s.scope.targetId === targetId);
|
||||
});
|
||||
setLatestAISessionsSnapshot(next);
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
const scopeKey = `${scopeType}:${targetId}`;
|
||||
setActiveSessionIdMapRaw(prev => {
|
||||
if (prev[scopeKey] != null) return { ...prev, [scopeKey]: null };
|
||||
if (prev[scopeKey] != null) {
|
||||
const next = { ...prev, [scopeKey]: null };
|
||||
setLatestAIActiveSessionMapSnapshot(next);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
return next;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, [persistSessions]);
|
||||
@@ -428,6 +579,7 @@ export function useAIState() {
|
||||
const updateSessionTitle = useCallback((sessionId: string, title: string) => {
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.map(s => s.id === sessionId ? { ...s, title, updatedAt: Date.now() } : s);
|
||||
setLatestAISessionsSnapshot(next);
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
@@ -440,6 +592,7 @@ export function useAIState() {
|
||||
? { ...s, externalSessionId, updatedAt: Date.now() }
|
||||
: s
|
||||
));
|
||||
setLatestAISessionsSnapshot(next);
|
||||
debouncedPersistSessions();
|
||||
return next;
|
||||
});
|
||||
@@ -463,6 +616,7 @@ export function useAIState() {
|
||||
}
|
||||
return { ...s, messages: msgs, updatedAt: Date.now() };
|
||||
});
|
||||
setLatestAISessionsSnapshot(next);
|
||||
debouncedPersistSessions();
|
||||
return next;
|
||||
});
|
||||
@@ -476,6 +630,7 @@ export function useAIState() {
|
||||
msgs[msgs.length - 1] = updater(msgs[msgs.length - 1]);
|
||||
return { ...s, messages: msgs, updatedAt: Date.now() };
|
||||
});
|
||||
setLatestAISessionsSnapshot(next);
|
||||
debouncedPersistSessions();
|
||||
return next;
|
||||
});
|
||||
@@ -491,6 +646,7 @@ export function useAIState() {
|
||||
msgs[idx] = updater(msgs[idx]);
|
||||
return { ...s, messages: msgs, updatedAt: Date.now() };
|
||||
});
|
||||
setLatestAISessionsSnapshot(next);
|
||||
debouncedPersistSessions();
|
||||
return next;
|
||||
});
|
||||
@@ -503,29 +659,21 @@ export function useAIState() {
|
||||
}
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.map(s => s.id === sessionId ? { ...s, messages: [], updatedAt: Date.now() } : s);
|
||||
setLatestAISessionsSnapshot(next);
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
}, [persistSessions]);
|
||||
|
||||
const cleanupOrphanedSessions = useCallback((activeTargetIds: Set<string>) => {
|
||||
const removedSessionIds = sessionsRef.current
|
||||
.filter(s => s.scope.targetId && !activeTargetIds.has(s.scope.targetId))
|
||||
.map(s => s.id);
|
||||
cleanupAcpSessions(removedSessionIds);
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.filter(s => {
|
||||
// Keep sessions without a targetId (global scope)
|
||||
if (!s.scope.targetId) return true;
|
||||
// Keep sessions whose target still exists
|
||||
return activeTargetIds.has(s.scope.targetId);
|
||||
});
|
||||
if (next.length !== prev.length) {
|
||||
persistSessions(next);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [persistSessions]);
|
||||
cleanupOrphanedAISessions(activeTargetIds);
|
||||
setSessionsRaw(latestAISessionsSnapshot ?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? []);
|
||||
setActiveSessionIdMapRaw(
|
||||
latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {},
|
||||
);
|
||||
}, []);
|
||||
|
||||
// ── Provider CRUD helpers ──
|
||||
const addProvider = useCallback((provider: ProviderConfig) => {
|
||||
|
||||
@@ -16,11 +16,11 @@ import {
|
||||
findSyncPayloadEncryptedCredentialPaths,
|
||||
} from '../../domain/credentials';
|
||||
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
|
||||
import { collectSyncableSettings } from '../../domain/syncPayload';
|
||||
import { collectSyncableSettings } from '../syncPayload';
|
||||
import { STORAGE_KEY_PORT_FORWARDING } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { getEffectiveKnownHosts } from '../../infrastructure/syncHelpers';
|
||||
import { toast } from '../../components/ui/toast';
|
||||
import { notify } from '../notification';
|
||||
|
||||
interface AutoSyncConfig {
|
||||
// Data to sync
|
||||
@@ -189,7 +189,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
throw error;
|
||||
}
|
||||
console.error('[AutoSync] Sync failed:', error);
|
||||
toast.error(
|
||||
notify.error(
|
||||
error instanceof Error ? error.message : t('common.unknownError'),
|
||||
t('sync.autoSync.failedTitle'),
|
||||
);
|
||||
@@ -231,7 +231,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// Don't save base or skip auto-sync — let the data-change effect
|
||||
// naturally trigger an upload of the merged payload (which will
|
||||
// go through syncAllProviders and save base on success).
|
||||
toast.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
|
||||
notify.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AutoSync] Failed to check remote version:', error);
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
isProviderReadyForSync,
|
||||
} from '../../domain/sync';
|
||||
import {
|
||||
CloudSyncManager,
|
||||
getCloudSyncManager,
|
||||
type SyncManagerState,
|
||||
} from '../../infrastructure/services/CloudSyncManager';
|
||||
@@ -103,12 +102,6 @@ export interface CloudSyncHook {
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
export interface GitHubAuthState {
|
||||
isAuthenticating: boolean;
|
||||
deviceFlowState: DeviceFlowState | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hook Implementation
|
||||
// ============================================================================
|
||||
@@ -472,60 +465,4 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Convenience Hooks
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Hook for just the security state (lighter weight)
|
||||
*/
|
||||
export const useSecurityState = () => {
|
||||
const [manager] = useState<CloudSyncManager>(() => getCloudSyncManager());
|
||||
const [securityState, setSecurityState] = useState<SecurityState>(
|
||||
() => manager.getSecurityState()
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = manager.subscribe((event) => {
|
||||
if (event.type === 'SECURITY_STATE_CHANGED') {
|
||||
setSecurityState(event.state);
|
||||
}
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [manager]);
|
||||
|
||||
return {
|
||||
securityState,
|
||||
isUnlocked: securityState === 'UNLOCKED',
|
||||
isLocked: securityState === 'LOCKED',
|
||||
hasNoKey: securityState === 'NO_KEY',
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for provider status indicators
|
||||
*/
|
||||
export const useProviderStatus = (provider: CloudProvider) => {
|
||||
const [manager] = useState<CloudSyncManager>(() => getCloudSyncManager());
|
||||
const [connection, setConnection] = useState<ProviderConnection>(
|
||||
() => manager.getProviderConnection(provider)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = manager.subscribe(() => {
|
||||
setConnection(manager.getProviderConnection(provider));
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [manager, provider]);
|
||||
|
||||
return {
|
||||
...connection,
|
||||
isConnected: isProviderReadyForSync(connection),
|
||||
isSyncing: connection.status === 'syncing',
|
||||
hasError: connection.status === 'error',
|
||||
dotColor: getSyncDotColor(connection.status),
|
||||
lastSyncFormatted: formatLastSync(connection.lastSync),
|
||||
};
|
||||
};
|
||||
|
||||
export default useCloudSync;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { KeyBinding, matchesKeyBinding } from '../../domain/models';
|
||||
|
||||
export interface HotkeyActions {
|
||||
interface HotkeyActions {
|
||||
// Tab management
|
||||
switchToTab: (tabIndex: number) => void;
|
||||
nextTab: () => void;
|
||||
|
||||
214
application/state/useImmersiveMode.ts
Normal file
214
application/state/useImmersiveMode.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Immersive Mode — makes the entire UI chrome adapt colors to match the active terminal's theme.
|
||||
*
|
||||
* Performance strategy:
|
||||
* - All built-in themes' CSS strings are pre-computed at module load (zero cost at switch time)
|
||||
* - Custom/unknown themes are computed lazily and cached
|
||||
* - A single `<style>` tag with `!important` overrides inline CSS variables atomically
|
||||
* - `useLayoutEffect` ensures the update happens before browser paint (no flash)
|
||||
*/
|
||||
import { useEffect, useLayoutEffect, useRef } from 'react';
|
||||
import { TerminalTheme } from '../../domain/models';
|
||||
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hex → HSL conversion (returns "H S% L%" without the hsl() wrapper)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function hexToHsl(hex: string): string {
|
||||
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
||||
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
||||
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
let h = 0;
|
||||
let s = 0;
|
||||
const l = (max + min) / 2;
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
|
||||
case g: h = ((b - r) / d + 2) / 6; break;
|
||||
case b: h = ((r - g) / d + 4) / 6; break;
|
||||
}
|
||||
}
|
||||
return `${Math.round(h * 3600) / 10} ${Math.round(s * 1000) / 10}% ${Math.round(l * 1000) / 10}%`;
|
||||
}
|
||||
|
||||
function adjustLightness(hsl: string, delta: number): string {
|
||||
const parts = hsl.split(/\s+/);
|
||||
const newL = Math.max(0, Math.min(100, parseFloat(parts[2]) + delta));
|
||||
return `${parts[0]} ${parts[1]} ${Math.round(newL * 10) / 10}%`;
|
||||
}
|
||||
|
||||
function adjustSaturation(hsl: string, factor: number): string {
|
||||
const parts = hsl.split(/\s+/);
|
||||
const newS = Math.max(0, Math.min(100, parseFloat(parts[1]) * factor));
|
||||
return `${parts[0]} ${Math.round(newS * 10) / 10}% ${parts[2]}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build the CSS rule string from a TerminalTheme
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CSS_VARS = [
|
||||
'background', 'foreground', 'card', 'card-foreground',
|
||||
'popover', 'popover-foreground', 'primary', 'primary-foreground',
|
||||
'secondary', 'secondary-foreground', 'muted', 'muted-foreground',
|
||||
'accent', 'accent-foreground', 'destructive', 'destructive-foreground',
|
||||
'border', 'input', 'ring',
|
||||
] as const;
|
||||
|
||||
function buildImmersiveCss(theme: TerminalTheme): string {
|
||||
const bg = hexToHsl(theme.colors.background);
|
||||
const fg = hexToHsl(theme.colors.foreground);
|
||||
const cursor = hexToHsl(theme.colors.cursor);
|
||||
const isDark = theme.type === 'dark';
|
||||
|
||||
const card = adjustLightness(bg, isDark ? 4 : -3);
|
||||
const secondary = adjustLightness(bg, isDark ? 6 : -5);
|
||||
const muted = adjustLightness(bg, isDark ? 10 : -8);
|
||||
const mutedFg = adjustSaturation(adjustLightness(fg, isDark ? -20 : 20), 0.5);
|
||||
const border = adjustLightness(bg, isDark ? 12 : -10);
|
||||
const cursorL = parseFloat(cursor.split(' ')[2] ?? '50');
|
||||
const primaryFg = cursorL > 55 ? '0 0% 0%' : '0 0% 100%';
|
||||
|
||||
const values = [
|
||||
bg, fg, card, fg, // background, foreground, card, card-foreground
|
||||
card, fg, // popover, popover-foreground
|
||||
cursor, primaryFg, // primary, primary-foreground
|
||||
secondary, fg, // secondary, secondary-foreground
|
||||
muted, mutedFg, // muted, muted-foreground
|
||||
cursor, primaryFg, // accent, accent-foreground
|
||||
'0 70% 50%', '0 0% 100%', // destructive, destructive-foreground
|
||||
border, border, cursor, // border, input, ring
|
||||
];
|
||||
|
||||
const rules = CSS_VARS.map((name, i) => `--${name}: ${values[i]} !important`).join('; ');
|
||||
return `:root { ${rules}; }`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pre-compute CSS for all built-in themes at module load — O(1) lookup at switch time
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const cssCache = new Map<string, string>();
|
||||
|
||||
// Fingerprint: id + type + 3 key colors (detects in-place edits including dark↔light)
|
||||
function themeFingerprint(t: TerminalTheme): string {
|
||||
return `${t.id}\0${t.type}\0${t.colors.background}\0${t.colors.foreground}\0${t.colors.cursor}`;
|
||||
}
|
||||
|
||||
// Pre-compute built-in themes
|
||||
for (const theme of TERMINAL_THEMES) {
|
||||
cssCache.set(themeFingerprint(theme), buildImmersiveCss(theme));
|
||||
}
|
||||
|
||||
/** Get (or lazily compute & cache) the immersive CSS for a theme. */
|
||||
function getImmersiveCss(theme: TerminalTheme): string {
|
||||
const fp = themeFingerprint(theme);
|
||||
let css = cssCache.get(fp);
|
||||
if (!css) {
|
||||
css = buildImmersiveCss(theme);
|
||||
cssCache.set(fp, css);
|
||||
}
|
||||
return css;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Style tag management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const STYLE_ID = 'netcatty-immersive-override';
|
||||
|
||||
function applyImmersiveStyle(css: string, isDark: boolean, bg: string) {
|
||||
const root = document.documentElement;
|
||||
const targetClass = isDark ? 'dark' : 'light';
|
||||
if (!root.classList.contains(targetClass)) {
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(targetClass);
|
||||
}
|
||||
let style = document.getElementById(STYLE_ID) as HTMLStyleElement | null;
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
style.id = STYLE_ID;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
style.textContent = css;
|
||||
// Sync native Electron window chrome
|
||||
netcattyBridge.get()?.setTheme?.(isDark ? 'dark' : 'light');
|
||||
netcattyBridge.get()?.setBackgroundColor?.(bg);
|
||||
}
|
||||
|
||||
function removeImmersiveStyle() {
|
||||
document.getElementById(STYLE_ID)?.remove();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useImmersiveMode({
|
||||
isImmersive,
|
||||
activeTabId,
|
||||
activeTerminalTheme,
|
||||
restoreOriginalTheme,
|
||||
}: {
|
||||
isImmersive: boolean;
|
||||
activeTabId: string;
|
||||
activeTerminalTheme: TerminalTheme | null;
|
||||
restoreOriginalTheme: () => void;
|
||||
}) {
|
||||
const overrideActiveRef = useRef(false);
|
||||
const appliedFpRef = useRef<string | null>(null);
|
||||
const restoreRef = useRef(restoreOriginalTheme);
|
||||
restoreRef.current = restoreOriginalTheme;
|
||||
|
||||
const isTerminalTab = activeTabId !== 'vault' && activeTabId !== 'sftp' && !activeTabId.startsWith('log-');
|
||||
|
||||
// APPLY: useLayoutEffect — runs before paint, O(1) Map lookup, single DOM write
|
||||
useLayoutEffect(() => {
|
||||
if (isImmersive && isTerminalTab && activeTerminalTheme) {
|
||||
const fp = themeFingerprint(activeTerminalTheme);
|
||||
if (appliedFpRef.current === fp) return;
|
||||
overrideActiveRef.current = true;
|
||||
appliedFpRef.current = fp;
|
||||
applyImmersiveStyle(getImmersiveCss(activeTerminalTheme), activeTerminalTheme.type === 'dark', activeTerminalTheme.colors.background);
|
||||
}
|
||||
}, [isImmersive, isTerminalTab, activeTerminalTheme]);
|
||||
|
||||
// RESTORE: useEffect — runs after paint, with fade overlay
|
||||
useEffect(() => {
|
||||
if (isImmersive && isTerminalTab && activeTerminalTheme) return;
|
||||
if (!overrideActiveRef.current) return;
|
||||
overrideActiveRef.current = false;
|
||||
appliedFpRef.current = null;
|
||||
const bg = getComputedStyle(document.documentElement).getPropertyValue('--background').trim();
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'immersive-fade-overlay';
|
||||
overlay.style.backgroundColor = `hsl(${bg})`;
|
||||
document.body.appendChild(overlay);
|
||||
removeImmersiveStyle();
|
||||
restoreOriginalTheme();
|
||||
requestAnimationFrame(() => {
|
||||
overlay.classList.add('fade-out');
|
||||
overlay.addEventListener('transitionend', () => overlay.remove(), { once: true });
|
||||
});
|
||||
const fallback = setTimeout(() => { if (overlay.parentNode) overlay.remove(); }, 400);
|
||||
return () => { clearTimeout(fallback); if (overlay.parentNode) overlay.remove(); };
|
||||
}, [isImmersive, isTerminalTab, activeTerminalTheme, restoreOriginalTheme]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
removeImmersiveStyle();
|
||||
appliedFpRef.current = null;
|
||||
if (overrideActiveRef.current) {
|
||||
overrideActiveRef.current = false;
|
||||
restoreRef.current();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
* This should be used at the App level to ensure auto-start happens
|
||||
* when the application starts, not when the user navigates to the port forwarding page.
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Host, PortForwardingRule } from "../../domain/models";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { Host, Identity, PortForwardingRule, SSHKey } from "../../domain/models";
|
||||
import { STORAGE_KEY_PORT_FORWARDING } from "../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import {
|
||||
@@ -17,7 +17,8 @@ import { logger } from "../../lib/logger";
|
||||
|
||||
export interface UsePortForwardingAutoStartOptions {
|
||||
hosts: Host[];
|
||||
keys: { id: string; privateKey: string; passphrase: string }[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,10 +28,37 @@ export interface UsePortForwardingAutoStartOptions {
|
||||
export const usePortForwardingAutoStart = ({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
}: UsePortForwardingAutoStartOptions): void => {
|
||||
const autoStartExecutedRef = useRef(false);
|
||||
const hostsRef = useRef<Host[]>(hosts);
|
||||
const keysRef = useRef<{ id: string; privateKey: string; passphrase: string }[]>(keys);
|
||||
const keysRef = useRef<SSHKey[]>(keys);
|
||||
const identitiesRef = useRef<Identity[]>(identities);
|
||||
|
||||
const isHostAuthReady = useCallback((host: Host, seen = new Set<string>()): boolean => {
|
||||
if (!host || seen.has(host.id)) return true;
|
||||
seen.add(host.id);
|
||||
|
||||
if (host.identityId) {
|
||||
const identity = identitiesRef.current.find((candidate) => candidate.id === host.identityId);
|
||||
if (!identity) return false;
|
||||
if (identity.keyId && !keysRef.current.some((key) => key.id === identity.keyId)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (host.identityFileId && !keysRef.current.some((key) => key.id === host.identityFileId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const chainIds = host.hostChain?.hostIds || [];
|
||||
for (const chainId of chainIds) {
|
||||
const chainHost = hostsRef.current.find((candidate) => candidate.id === chainId);
|
||||
if (!chainHost) return false;
|
||||
if (!isHostAuthReady(chainHost, seen)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
// Keep refs in sync
|
||||
useEffect(() => {
|
||||
@@ -41,6 +69,10 @@ export const usePortForwardingAutoStart = ({
|
||||
keysRef.current = keys;
|
||||
}, [keys]);
|
||||
|
||||
useEffect(() => {
|
||||
identitiesRef.current = identities;
|
||||
}, [identities]);
|
||||
|
||||
// Set up the reconnect callback
|
||||
useEffect(() => {
|
||||
const handleReconnect = async (
|
||||
@@ -62,7 +94,7 @@ export const usePortForwardingAutoStart = ({
|
||||
return { success: false, error: "Host not found" };
|
||||
}
|
||||
|
||||
return startPortForward(rule, host, keysRef.current, onStatusChange, true);
|
||||
return startPortForward(rule, host, hostsRef.current, keysRef.current, identitiesRef.current, onStatusChange, true);
|
||||
};
|
||||
|
||||
setReconnectCallback(handleReconnect);
|
||||
@@ -76,6 +108,17 @@ export const usePortForwardingAutoStart = ({
|
||||
if (autoStartExecutedRef.current) return;
|
||||
if (hosts.length === 0) return;
|
||||
|
||||
const storedRules = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
) ?? [];
|
||||
const pendingAutoStartRules = storedRules.filter((rule) => rule.autoStart && rule.hostId);
|
||||
if (pendingAutoStartRules.some((rule) => {
|
||||
const host = hosts.find((candidate) => candidate.id === rule.hostId);
|
||||
return !host || !isHostAuthReady(host);
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark as executed immediately to prevent duplicate runs
|
||||
// (React StrictMode or dependency changes could cause re-runs)
|
||||
autoStartExecutedRef.current = true;
|
||||
@@ -108,7 +151,9 @@ export const usePortForwardingAutoStart = ({
|
||||
void startPortForward(
|
||||
rule,
|
||||
host,
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
(status, error) => {
|
||||
// Update the rule status in storage
|
||||
const currentRules = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
@@ -135,5 +180,5 @@ export const usePortForwardingAutoStart = ({
|
||||
};
|
||||
|
||||
void runAutoStart();
|
||||
}, [hosts, keys]);
|
||||
}, [hosts, identities, isHostAuthReady, keys]);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Host, PortForwardingRule } from "../../domain/models";
|
||||
import { Host, Identity, PortForwardingRule, SSHKey } from "../../domain/models";
|
||||
import {
|
||||
STORAGE_KEY_PF_PREFER_FORM_MODE,
|
||||
STORAGE_KEY_PF_VIEW_MODE,
|
||||
@@ -63,7 +63,9 @@ export interface UsePortForwardingStateResult {
|
||||
startTunnel: (
|
||||
rule: PortForwardingRule,
|
||||
host: Host,
|
||||
keys: { id: string; privateKey: string; passphrase: string }[],
|
||||
hosts: Host[],
|
||||
keys: SSHKey[],
|
||||
identities: Identity[],
|
||||
onStatusChange?: (status: PortForwardingRule["status"], error?: string) => void,
|
||||
enableReconnect?: boolean,
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
@@ -377,14 +379,16 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
async (
|
||||
rule: PortForwardingRule,
|
||||
host: Host,
|
||||
keys: { id: string; privateKey: string; passphrase: string }[],
|
||||
hosts: Host[],
|
||||
keys: SSHKey[],
|
||||
identities: Identity[],
|
||||
onStatusChange?: (
|
||||
status: PortForwardingRule["status"],
|
||||
error?: string,
|
||||
) => void,
|
||||
enableReconnect = false,
|
||||
) => {
|
||||
return startPortForward(rule, host, keys, (status, error) => {
|
||||
return startPortForward(rule, host, hosts, keys, identities, (status, error) => {
|
||||
setRuleStatus(rule.id, status, error);
|
||||
onStatusChange?.(status, error ?? undefined);
|
||||
}, enableReconnect);
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
STORAGE_KEY_CLOSE_TO_TRAY,
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
STORAGE_KEY_AUTO_UPDATE_ENABLED,
|
||||
STORAGE_KEY_IMMERSIVE_MODE,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
|
||||
@@ -38,7 +39,6 @@ import { DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
|
||||
import { DARK_UI_THEMES, LIGHT_UI_THEMES, UiThemeTokens, getUiThemeById } from '../../infrastructure/config/uiThemes';
|
||||
import { UI_FONTS, DEFAULT_UI_FONT_ID } from '../../infrastructure/config/uiFonts';
|
||||
import { uiFontStore, useUIFontsLoaded } from './uiFontStore';
|
||||
import { useAvailableFonts } from './fontStore';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
@@ -121,8 +121,11 @@ const applyThemeTokens = (
|
||||
accentOverride: string,
|
||||
) => {
|
||||
const root = window.document.documentElement;
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(resolvedTheme);
|
||||
// If immersive mode is active (style tag present), it owns the dark/light class — don't override
|
||||
if (!document.getElementById('netcatty-immersive-override')) {
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(resolvedTheme);
|
||||
}
|
||||
root.style.setProperty('--background', tokens.background);
|
||||
root.style.setProperty('--foreground', tokens.foreground);
|
||||
root.style.setProperty('--card', tokens.card);
|
||||
@@ -155,7 +158,6 @@ const applyThemeTokens = (
|
||||
};
|
||||
|
||||
export const useSettingsState = () => {
|
||||
const availableFonts = useAvailableFonts();
|
||||
const uiFontsLoaded = useUIFontsLoaded();
|
||||
const [theme, setTheme] = useState<'dark' | 'light' | 'system'>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_THEME);
|
||||
@@ -287,6 +289,10 @@ export const useSettingsState = () => {
|
||||
const localTerminalSettingsVersionRef = useRef(0);
|
||||
const broadcastedLocalTerminalSettingsVersionRef = useRef(0);
|
||||
|
||||
// Fix 1: Mount guard — skip redundant IPC broadcasts & localStorage writes on initial mount.
|
||||
// Set to true by the LAST useEffect declaration; all persist effects see false on first render.
|
||||
const persistMountedRef = useRef(false);
|
||||
|
||||
const setTerminalSettings = useCallback((nextValue: SetStateAction<TerminalSettings>) => {
|
||||
setTerminalSettingsState((prev) => {
|
||||
const candidate = typeof nextValue === 'function'
|
||||
@@ -322,6 +328,21 @@ export const useSettingsState = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [immersiveMode, setImmersiveModeState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readString(STORAGE_KEY_IMMERSIVE_MODE);
|
||||
if (stored === null || stored === '') {
|
||||
// Persist default so collectSyncableSettings() can include it
|
||||
localStorageAdapter.writeString(STORAGE_KEY_IMMERSIVE_MODE, 'true');
|
||||
return true;
|
||||
}
|
||||
return stored === 'true';
|
||||
});
|
||||
const setImmersiveMode = useCallback((enabled: boolean) => {
|
||||
setImmersiveModeState(enabled);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_IMMERSIVE_MODE, String(enabled));
|
||||
notifySettingsChanged(STORAGE_KEY_IMMERSIVE_MODE, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const syncAppearanceFromStorage = useCallback(() => {
|
||||
const storedTheme = readStoredString(STORAGE_KEY_THEME);
|
||||
const nextTheme = storedTheme && isValidTheme(storedTheme) ? storedTheme : theme;
|
||||
@@ -334,6 +355,17 @@ export const useSettingsState = () => {
|
||||
const storedAccent = readStoredString(STORAGE_KEY_COLOR);
|
||||
const nextAccent = storedAccent && isValidHslToken(storedAccent) ? storedAccent.trim() : customAccent;
|
||||
|
||||
// Fix 2: Skip expensive DOM operations if nothing actually changed
|
||||
if (
|
||||
nextTheme === theme &&
|
||||
nextLightId === lightUiThemeId &&
|
||||
nextDarkId === darkUiThemeId &&
|
||||
nextAccentMode === accentMode &&
|
||||
nextAccent === customAccent
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTheme(nextTheme);
|
||||
setLightUiThemeId(nextLightId);
|
||||
setDarkUiThemeId(nextDarkId);
|
||||
@@ -402,9 +434,17 @@ export const useSettingsState = () => {
|
||||
const storedAutoOpenSidebar = readStoredString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
|
||||
if (storedAutoOpenSidebar === 'true' || storedAutoOpenSidebar === 'false') setSftpAutoOpenSidebar(storedAutoOpenSidebar === 'true');
|
||||
|
||||
// Immersive mode
|
||||
const storedImmersive = readStoredString(STORAGE_KEY_IMMERSIVE_MODE);
|
||||
if (storedImmersive === 'true' || storedImmersive === 'false') {
|
||||
const val = storedImmersive === 'true';
|
||||
setImmersiveModeState(val);
|
||||
notifySettingsChanged(STORAGE_KEY_IMMERSIVE_MODE, val);
|
||||
}
|
||||
|
||||
// Custom terminal themes
|
||||
customThemeStore.loadFromStorage();
|
||||
}, [syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings]);
|
||||
}, [syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings, notifySettingsChanged]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
|
||||
@@ -414,12 +454,11 @@ export const useSettingsState = () => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_ACCENT_MODE, accentMode);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_COLOR, customAccent);
|
||||
// Notify other windows
|
||||
// Fix 1: Skip IPC broadcast on initial mount (values already match localStorage)
|
||||
if (!persistMountedRef.current) return;
|
||||
// Fix 3: Send a single IPC instead of 5 — the receiver calls syncAppearanceFromStorage()
|
||||
// which re-reads ALL appearance values from localStorage.
|
||||
notifySettingsChanged(STORAGE_KEY_THEME, theme);
|
||||
notifySettingsChanged(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId);
|
||||
notifySettingsChanged(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId);
|
||||
notifySettingsChanged(STORAGE_KEY_ACCENT_MODE, accentMode);
|
||||
notifySettingsChanged(STORAGE_KEY_COLOR, customAccent);
|
||||
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, notifySettingsChanged]);
|
||||
|
||||
// Listen for OS color scheme changes to keep systemPreference in sync
|
||||
@@ -437,7 +476,10 @@ export const useSettingsState = () => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_UI_LANGUAGE, uiLanguage);
|
||||
document.documentElement.lang = uiLanguage;
|
||||
netcattyBridge.get()?.setLanguage?.(uiLanguage);
|
||||
notifySettingsChanged(STORAGE_KEY_UI_LANGUAGE, uiLanguage);
|
||||
// Fix 1: Skip IPC broadcast on initial mount
|
||||
if (persistMountedRef.current) {
|
||||
notifySettingsChanged(STORAGE_KEY_UI_LANGUAGE, uiLanguage);
|
||||
}
|
||||
}, [uiLanguage, notifySettingsChanged]);
|
||||
|
||||
// Apply and persist UI font family
|
||||
@@ -446,7 +488,10 @@ export const useSettingsState = () => {
|
||||
const font = uiFontStore.getFontById(uiFontFamilyId);
|
||||
document.documentElement.style.setProperty('--font-sans', font.family);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_UI_FONT_FAMILY, uiFontFamilyId);
|
||||
notifySettingsChanged(STORAGE_KEY_UI_FONT_FAMILY, uiFontFamilyId);
|
||||
// Fix 1: Skip IPC broadcast on initial mount
|
||||
if (persistMountedRef.current) {
|
||||
notifySettingsChanged(STORAGE_KEY_UI_FONT_FAMILY, uiFontFamilyId);
|
||||
}
|
||||
}, [uiFontFamilyId, uiFontsLoaded, notifySettingsChanged]);
|
||||
|
||||
// Listen for settings changes from other windows via IPC
|
||||
@@ -540,6 +585,9 @@ export const useSettingsState = () => {
|
||||
if (key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && typeof value === 'boolean') {
|
||||
setSftpAutoOpenSidebar((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_IMMERSIVE_MODE && typeof value === 'boolean') {
|
||||
setImmersiveModeState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
try {
|
||||
@@ -567,53 +615,76 @@ export const useSettingsState = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Fix 4: Keep a ref snapshot of current settings so the storage event handler
|
||||
// can compare without capturing 25+ state variables in its closure / dep array.
|
||||
// This avoids constant listener detach/reattach on every state change.
|
||||
const settingsSnapshotRef = useRef({
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, immersiveMode,
|
||||
});
|
||||
settingsSnapshotRef.current = {
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, immersiveMode,
|
||||
};
|
||||
|
||||
// Listen for storage changes from other windows (cross-window sync)
|
||||
useEffect(() => {
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
const s = settingsSnapshotRef.current;
|
||||
if (e.key === STORAGE_KEY_THEME && e.newValue) {
|
||||
if (isValidTheme(e.newValue) && e.newValue !== theme) {
|
||||
if (isValidTheme(e.newValue) && e.newValue !== s.theme) {
|
||||
setTheme(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_UI_THEME_LIGHT && e.newValue) {
|
||||
if (isValidUiThemeId('light', e.newValue) && e.newValue !== lightUiThemeId) {
|
||||
if (isValidUiThemeId('light', e.newValue) && e.newValue !== s.lightUiThemeId) {
|
||||
setLightUiThemeId(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_UI_THEME_DARK && e.newValue) {
|
||||
if (isValidUiThemeId('dark', e.newValue) && e.newValue !== darkUiThemeId) {
|
||||
if (isValidUiThemeId('dark', e.newValue) && e.newValue !== s.darkUiThemeId) {
|
||||
setDarkUiThemeId(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_ACCENT_MODE && e.newValue) {
|
||||
if ((e.newValue === 'theme' || e.newValue === 'custom') && e.newValue !== accentMode) {
|
||||
if ((e.newValue === 'theme' || e.newValue === 'custom') && e.newValue !== s.accentMode) {
|
||||
setAccentMode(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_COLOR && e.newValue) {
|
||||
if (isValidHslToken(e.newValue) && e.newValue !== customAccent) {
|
||||
if (isValidHslToken(e.newValue) && e.newValue !== s.customAccent) {
|
||||
setCustomAccent(e.newValue.trim());
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_CUSTOM_CSS && e.newValue !== null) {
|
||||
if (e.newValue !== customCSS) {
|
||||
if (e.newValue !== s.customCSS) {
|
||||
setCustomCSS(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_UI_FONT_FAMILY && e.newValue) {
|
||||
if (isValidUiFontId(e.newValue) && e.newValue !== uiFontFamilyId) {
|
||||
if (isValidUiFontId(e.newValue) && e.newValue !== s.uiFontFamilyId) {
|
||||
setUiFontFamilyId(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_HOTKEY_SCHEME && e.newValue) {
|
||||
const newScheme = e.newValue as HotkeyScheme;
|
||||
if (newScheme !== hotkeyScheme) {
|
||||
if (newScheme !== s.hotkeyScheme) {
|
||||
setHotkeyScheme(newScheme);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_UI_LANGUAGE && e.newValue) {
|
||||
const next = resolveSupportedLocale(e.newValue);
|
||||
if (next !== uiLanguage) {
|
||||
if (next !== s.uiLanguage) {
|
||||
setUiLanguage(next as UILanguage);
|
||||
}
|
||||
}
|
||||
@@ -636,64 +707,64 @@ export const useSettingsState = () => {
|
||||
}
|
||||
// Sync terminal theme from other windows
|
||||
if (e.key === STORAGE_KEY_TERM_THEME && e.newValue) {
|
||||
if (e.newValue !== terminalThemeId) {
|
||||
if (e.newValue !== s.terminalThemeId) {
|
||||
setTerminalThemeId(e.newValue);
|
||||
}
|
||||
}
|
||||
// Sync terminal font family from other windows
|
||||
if (e.key === STORAGE_KEY_TERM_FONT_FAMILY && e.newValue) {
|
||||
if (e.newValue !== terminalFontFamilyId) {
|
||||
if (e.newValue !== s.terminalFontFamilyId) {
|
||||
setTerminalFontFamilyId(e.newValue);
|
||||
}
|
||||
}
|
||||
// Sync terminal font size from other windows
|
||||
if (e.key === STORAGE_KEY_TERM_FONT_SIZE && e.newValue) {
|
||||
const newSize = parseInt(e.newValue, 10);
|
||||
if (!isNaN(newSize) && newSize !== terminalFontSize) {
|
||||
if (!isNaN(newSize) && newSize !== s.terminalFontSize) {
|
||||
setTerminalFontSize(newSize);
|
||||
}
|
||||
}
|
||||
// Sync SFTP double-click behavior from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR && e.newValue) {
|
||||
if ((e.newValue === 'open' || e.newValue === 'transfer') && e.newValue !== sftpDoubleClickBehavior) {
|
||||
if ((e.newValue === 'open' || e.newValue === 'transfer') && e.newValue !== s.sftpDoubleClickBehavior) {
|
||||
setSftpDoubleClickBehavior(e.newValue);
|
||||
}
|
||||
}
|
||||
// Sync SFTP auto-sync setting from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_AUTO_SYNC && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== sftpAutoSync) {
|
||||
if (newValue !== s.sftpAutoSync) {
|
||||
setSftpAutoSync(newValue);
|
||||
}
|
||||
}
|
||||
// Sync SFTP show hidden files setting from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== sftpShowHiddenFiles) {
|
||||
if (newValue !== s.sftpShowHiddenFiles) {
|
||||
setSftpShowHiddenFiles(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_EDITOR_WORD_WRAP && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== editorWordWrap) {
|
||||
if (newValue !== s.editorWordWrap) {
|
||||
setEditorWordWrapState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SESSION_LOGS_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== sessionLogsEnabled) {
|
||||
if (newValue !== s.sessionLogsEnabled) {
|
||||
setSessionLogsEnabled(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SESSION_LOGS_DIR && e.newValue !== null) {
|
||||
if (e.newValue !== sessionLogsDir) {
|
||||
if (e.newValue !== s.sessionLogsDir) {
|
||||
setSessionLogsDir(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SESSION_LOGS_FORMAT && e.newValue) {
|
||||
if (
|
||||
(e.newValue === 'txt' || e.newValue === 'raw' || e.newValue === 'html') &&
|
||||
e.newValue !== sessionLogsFormat
|
||||
e.newValue !== s.sessionLogsFormat
|
||||
) {
|
||||
setSessionLogsFormat(e.newValue);
|
||||
}
|
||||
@@ -701,54 +772,65 @@ export const useSettingsState = () => {
|
||||
// Sync SFTP compressed upload setting from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true' || e.newValue === 'enabled';
|
||||
if (newValue !== sftpUseCompressedUpload) {
|
||||
if (newValue !== s.sftpUseCompressedUpload) {
|
||||
setSftpUseCompressedUpload(newValue);
|
||||
}
|
||||
}
|
||||
// Sync SFTP auto-open sidebar setting from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== sftpAutoOpenSidebar) {
|
||||
if (newValue !== s.sftpAutoOpenSidebar) {
|
||||
setSftpAutoOpenSidebar(newValue);
|
||||
}
|
||||
}
|
||||
// Sync global hotkey enabled setting from other windows
|
||||
if (e.key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== globalHotkeyEnabled) {
|
||||
if (newValue !== s.globalHotkeyEnabled) {
|
||||
setGlobalHotkeyEnabled(newValue);
|
||||
}
|
||||
}
|
||||
// Sync auto-update enabled setting from other windows
|
||||
if (e.key === STORAGE_KEY_AUTO_UPDATE_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== autoUpdateEnabled) {
|
||||
if (newValue !== s.autoUpdateEnabled) {
|
||||
setAutoUpdateEnabled(newValue);
|
||||
}
|
||||
}
|
||||
// Sync immersive mode from other windows
|
||||
if (e.key === STORAGE_KEY_IMMERSIVE_MODE && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.immersiveMode) {
|
||||
setImmersiveModeState(newValue);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
return () => window.removeEventListener('storage', handleStorageChange);
|
||||
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, globalHotkeyEnabled, autoUpdateEnabled, mergeIncomingTerminalSettings]);
|
||||
}, [mergeIncomingTerminalSettings]); // Fix 4: stable deps only — state comparisons use settingsSnapshotRef
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_TERM_THEME, terminalThemeId);
|
||||
}, [terminalThemeId, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, terminalFontFamilyId);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_TERM_FONT_FAMILY, terminalFontFamilyId);
|
||||
}, [terminalFontFamilyId, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_TERM_FONT_SIZE, terminalFontSize);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_TERM_FONT_SIZE, terminalFontSize);
|
||||
}, [terminalFontSize, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.write(STORAGE_KEY_TERM_SETTINGS, terminalSettings);
|
||||
if (!persistMountedRef.current) return;
|
||||
const currentSignature = serializeTerminalSettings(terminalSettings);
|
||||
const hasPendingUnbroadcastLocalChanges =
|
||||
localTerminalSettingsVersionRef.current !== broadcastedLocalTerminalSettingsVersionRef.current;
|
||||
@@ -763,11 +845,13 @@ export const useSettingsState = () => {
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_HOTKEY_SCHEME, hotkeyScheme);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_HOTKEY_SCHEME, hotkeyScheme);
|
||||
}, [hotkeyScheme, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.write(STORAGE_KEY_CUSTOM_KEY_BINDINGS, customKeyBindings);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_CUSTOM_KEY_BINDINGS, customKeyBindings);
|
||||
}, [customKeyBindings, notifySettingsChanged]);
|
||||
|
||||
@@ -778,10 +862,7 @@ export const useSettingsState = () => {
|
||||
|
||||
// Apply and persist custom CSS
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_CSS, customCSS);
|
||||
notifySettingsChanged(STORAGE_KEY_CUSTOM_CSS, customCSS);
|
||||
|
||||
// Apply custom CSS to document
|
||||
// Always apply CSS to document (needed on mount)
|
||||
let styleEl = document.getElementById('netcatty-custom-css') as HTMLStyleElement | null;
|
||||
if (!styleEl) {
|
||||
styleEl = document.createElement('style');
|
||||
@@ -789,59 +870,69 @@ export const useSettingsState = () => {
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
styleEl.textContent = customCSS;
|
||||
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_CSS, customCSS);
|
||||
// Skip IPC on initial mount
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_CUSTOM_CSS, customCSS);
|
||||
}, [customCSS, notifySettingsChanged]);
|
||||
|
||||
// Persist SFTP double-click behavior
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR, sftpDoubleClickBehavior);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR, sftpDoubleClickBehavior);
|
||||
}, [sftpDoubleClickBehavior, notifySettingsChanged]);
|
||||
|
||||
// Persist SFTP auto-sync setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_SYNC, sftpAutoSync ? 'true' : 'false');
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_AUTO_SYNC, sftpAutoSync);
|
||||
}, [sftpAutoSync, notifySettingsChanged]);
|
||||
|
||||
// Persist SFTP show hidden files setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, sftpShowHiddenFiles ? 'true' : 'false');
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, sftpShowHiddenFiles);
|
||||
}, [sftpShowHiddenFiles, notifySettingsChanged]);
|
||||
|
||||
// Persist SFTP compressed upload setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, sftpUseCompressedUpload ? 'true' : 'false');
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, sftpUseCompressedUpload);
|
||||
}, [sftpUseCompressedUpload, notifySettingsChanged]);
|
||||
|
||||
// Persist SFTP auto-open sidebar setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, sftpAutoOpenSidebar ? 'true' : 'false');
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, sftpAutoOpenSidebar);
|
||||
}, [sftpAutoOpenSidebar, notifySettingsChanged]);
|
||||
|
||||
// Persist Session Logs settings
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_ENABLED, sessionLogsEnabled ? 'true' : 'false');
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_ENABLED, sessionLogsEnabled);
|
||||
}, [sessionLogsEnabled, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_DIR, sessionLogsDir);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_DIR, sessionLogsDir);
|
||||
}, [sessionLogsDir, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_FORMAT, sessionLogsFormat);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_FORMAT, sessionLogsFormat);
|
||||
}, [sessionLogsFormat, notifySettingsChanged]);
|
||||
|
||||
// Persist and sync toggle window hotkey setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
|
||||
notifySettingsChanged(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
|
||||
// Register/unregister the global hotkey in main process
|
||||
// Register/unregister the global hotkey in main process (needed on mount)
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.registerGlobalHotkey) {
|
||||
if (toggleWindowHotkey && globalHotkeyEnabled) {
|
||||
@@ -865,25 +956,32 @@ export const useSettingsState = () => {
|
||||
});
|
||||
}
|
||||
}
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
|
||||
// Skip IPC on initial mount
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
|
||||
}, [toggleWindowHotkey, globalHotkeyEnabled, notifySettingsChanged]);
|
||||
|
||||
// Persist global hotkey enabled setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, globalHotkeyEnabled ? 'true' : 'false');
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, globalHotkeyEnabled);
|
||||
}, [globalHotkeyEnabled, notifySettingsChanged]);
|
||||
|
||||
// Persist and sync close to tray setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray ? 'true' : 'false');
|
||||
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
|
||||
// Update main process tray behavior
|
||||
// Update main process tray behavior (needed on mount)
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.setCloseToTray) {
|
||||
bridge.setCloseToTray(closeToTray).catch((err) => {
|
||||
console.warn('[SystemTray] Failed to set close-to-tray:', err);
|
||||
});
|
||||
}
|
||||
localStorageAdapter.writeString(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray ? 'true' : 'false');
|
||||
// Skip IPC on initial mount
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
|
||||
}, [closeToTray, notifySettingsChanged]);
|
||||
|
||||
// Hydrate auto-update state from the main-process preference file on mount.
|
||||
@@ -904,16 +1002,11 @@ export const useSettingsState = () => {
|
||||
}, []);
|
||||
|
||||
// Persist auto-update enabled setting.
|
||||
// Skip IPC on initial mount to avoid overwriting the main-process preference
|
||||
// file when localStorage has been cleared (where the default is true).
|
||||
const autoUpdateMountedRef = useRef(false);
|
||||
// Initial mount still writes localStorage, but skips cross-window/main-process IPC.
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AUTO_UPDATE_ENABLED, autoUpdateEnabled ? 'true' : 'false');
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_AUTO_UPDATE_ENABLED, autoUpdateEnabled);
|
||||
if (!autoUpdateMountedRef.current) {
|
||||
autoUpdateMountedRef.current = true;
|
||||
return; // Skip IPC on initial mount
|
||||
}
|
||||
// Notify main process on user-initiated changes
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.setAutoUpdate?.(autoUpdateEnabled).catch((err: unknown) => {
|
||||
@@ -921,6 +1014,13 @@ export const useSettingsState = () => {
|
||||
});
|
||||
}, [autoUpdateEnabled, notifySettingsChanged]);
|
||||
|
||||
// Fix 1: Mark all persist effects as mounted.
|
||||
// This MUST be declared AFTER all persist useEffects so that React runs it last
|
||||
// during the initial mount cycle (effects fire in declaration order).
|
||||
useEffect(() => {
|
||||
persistMountedRef.current = true;
|
||||
}, []);
|
||||
|
||||
// Get merged key bindings (defaults + custom overrides)
|
||||
const keyBindings = useMemo((): KeyBinding[] => {
|
||||
return DEFAULT_KEY_BINDINGS.map(binding => {
|
||||
@@ -983,11 +1083,6 @@ export const useSettingsState = () => {
|
||||
[terminalThemeId, customThemes]
|
||||
);
|
||||
|
||||
const currentTerminalFont = useMemo(
|
||||
() => availableFonts.find(f => f.id === terminalFontFamilyId) || availableFonts[0],
|
||||
[terminalFontFamilyId, availableFonts]
|
||||
);
|
||||
|
||||
const updateTerminalSetting = useCallback(<K extends keyof TerminalSettings>(
|
||||
key: K,
|
||||
value: TerminalSettings[K]
|
||||
@@ -995,6 +1090,12 @@ export const useSettingsState = () => {
|
||||
setTerminalSettings(prev => ({ ...prev, [key]: value }));
|
||||
}, [setTerminalSettings]);
|
||||
|
||||
/** Re-apply the current UI theme tokens (used to restore after immersive mode override). */
|
||||
const reapplyCurrentTheme = useCallback(() => {
|
||||
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
|
||||
applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
|
||||
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
|
||||
|
||||
return {
|
||||
theme,
|
||||
setTheme,
|
||||
@@ -1018,7 +1119,6 @@ export const useSettingsState = () => {
|
||||
currentTerminalTheme,
|
||||
terminalFontFamilyId,
|
||||
setTerminalFontFamilyId,
|
||||
currentTerminalFont,
|
||||
terminalFontSize,
|
||||
setTerminalFontSize,
|
||||
terminalSettings,
|
||||
@@ -1052,7 +1152,6 @@ export const useSettingsState = () => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_EDITOR_WORD_WRAP, String(enabled));
|
||||
notifySettingsChanged(STORAGE_KEY_EDITOR_WORD_WRAP, enabled);
|
||||
}, [notifySettingsChanged]),
|
||||
availableFonts,
|
||||
// Session Logs
|
||||
sessionLogsEnabled,
|
||||
setSessionLogsEnabled,
|
||||
@@ -1071,6 +1170,9 @@ export const useSettingsState = () => {
|
||||
globalHotkeyEnabled,
|
||||
setGlobalHotkeyEnabled,
|
||||
rehydrateAllFromStorage,
|
||||
reapplyCurrentTheme,
|
||||
immersiveMode,
|
||||
setImmersiveMode,
|
||||
// Opaque version that changes when any synced setting changes, used by useAutoSync.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
settingsVersion: useMemo(() => Math.random(), [
|
||||
@@ -1079,7 +1181,7 @@ export const useSettingsState = () => {
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
||||
customKeyBindings, editorWordWrap,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar,
|
||||
customThemes,
|
||||
customThemes, immersiveMode,
|
||||
]),
|
||||
};
|
||||
};
|
||||
|
||||
29
application/state/useStoredNumber.ts
Normal file
29
application/state/useStoredNumber.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
|
||||
/**
|
||||
* Hook for reading a number from localStorage with lazy persistence.
|
||||
* Unlike useStoredString/useStoredBoolean, this hook does NOT auto-persist
|
||||
* on every state change — call `persist()` explicitly when ready (e.g. on
|
||||
* mouseup after a drag). This avoids flooding localStorage during
|
||||
* high-frequency updates like resize drags.
|
||||
*/
|
||||
export const useStoredNumber = (
|
||||
storageKey: string,
|
||||
fallback: number,
|
||||
clamp?: { min: number; max: number },
|
||||
) => {
|
||||
const [value, setValue] = useState<number>(() => {
|
||||
const stored = localStorageAdapter.readNumber(storageKey);
|
||||
if (stored === null) return fallback;
|
||||
if (clamp) return Math.max(clamp.min, Math.min(clamp.max, stored));
|
||||
return stored;
|
||||
});
|
||||
|
||||
const persist = useCallback(
|
||||
(v: number) => localStorageAdapter.writeNumber(storageKey, v),
|
||||
[storageKey],
|
||||
);
|
||||
|
||||
return [value, setValue, persist] as const;
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { checkForUpdates, getReleaseUrl, type ReleaseInfo, type UpdateCheckResult } from '../../infrastructure/services/updateService';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { STORAGE_KEY_UPDATE_DISMISSED_VERSION, STORAGE_KEY_UPDATE_LAST_CHECK, STORAGE_KEY_UPDATE_LATEST_RELEASE, STORAGE_KEY_AUTO_UPDATE_ENABLED } from '../../infrastructure/config/storageKeys';
|
||||
import { STORAGE_KEY_UPDATE_DISMISSED_VERSION, STORAGE_KEY_UPDATE_LAST_CHECK, STORAGE_KEY_UPDATE_LATEST_RELEASE, STORAGE_KEY_AUTO_UPDATE_ENABLED, STORAGE_KEY_DEBUG_UPDATE_DEMO } from '../../infrastructure/config/storageKeys';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
// Check for updates at most once per hour
|
||||
@@ -13,8 +13,7 @@ const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000;
|
||||
// arrives after 8s the duplicate check is avoided.
|
||||
const STARTUP_CHECK_DELAY_MS = 8000;
|
||||
// Enable demo mode for development (set via localStorage: localStorage.setItem('debug.updateDemo', '1'))
|
||||
const IS_UPDATE_DEMO_MODE = typeof window !== 'undefined' &&
|
||||
window.localStorage?.getItem('debug.updateDemo') === '1';
|
||||
const IS_UPDATE_DEMO_MODE = localStorageAdapter.readString(STORAGE_KEY_DEBUG_UPDATE_DEMO) === '1';
|
||||
|
||||
// Debug logging for update checks (no-op in production)
|
||||
const debugLog = (..._args: unknown[]) => {};
|
||||
@@ -44,6 +43,8 @@ export interface UseUpdateCheckResult {
|
||||
dismissUpdate: () => void;
|
||||
openReleasePage: () => void;
|
||||
installUpdate: () => void;
|
||||
startDownload: () => void;
|
||||
isUpdateDemoMode: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -514,6 +515,46 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean }): UseUp
|
||||
netcattyBridge.get()?.installUpdate?.();
|
||||
}, []);
|
||||
|
||||
const startDownload = useCallback(async () => {
|
||||
if (autoDownloadStatusRef.current === 'downloading' || autoDownloadStatusRef.current === 'ready') return;
|
||||
const bridge = netcattyBridge.get();
|
||||
try {
|
||||
const checkResult = await bridge?.checkForUpdate?.();
|
||||
if (!checkResult || checkResult.checking === true || checkResult.ready === true || checkResult.downloading === true) return;
|
||||
if (checkResult.supported === false) {
|
||||
openReleasePage();
|
||||
return;
|
||||
}
|
||||
if (checkResult.available === false) {
|
||||
openReleasePage();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
setUpdateState((prev) => ({
|
||||
...prev,
|
||||
autoDownloadStatus: 'downloading',
|
||||
downloadPercent: 0,
|
||||
downloadError: null,
|
||||
}));
|
||||
void bridge?.downloadUpdate?.().then((res) => {
|
||||
if (res && !res.success) {
|
||||
setUpdateState((prev) => ({
|
||||
...prev,
|
||||
autoDownloadStatus: 'error',
|
||||
downloadError: res.error || 'Download failed',
|
||||
}));
|
||||
}
|
||||
}).catch(() => {
|
||||
setUpdateState((prev) => ({
|
||||
...prev,
|
||||
autoDownloadStatus: 'error',
|
||||
downloadError: 'Download failed',
|
||||
}));
|
||||
});
|
||||
}, [openReleasePage]);
|
||||
|
||||
// Startup check with delay - runs once on mount
|
||||
useEffect(() => {
|
||||
debugLog('Startup check effect mounted, IS_UPDATE_DEMO_MODE:', IS_UPDATE_DEMO_MODE);
|
||||
@@ -653,5 +694,7 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean }): UseUp
|
||||
dismissUpdate,
|
||||
openReleasePage,
|
||||
installUpdate,
|
||||
startDownload,
|
||||
isUpdateDemoMode: IS_UPDATE_DEMO_MODE,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ import type {
|
||||
PortForwardingRule,
|
||||
Snippet,
|
||||
SSHKey,
|
||||
} from './models';
|
||||
import type { SyncPayload } from './sync';
|
||||
} from '../domain/models';
|
||||
import type { SyncPayload } from '../domain/sync';
|
||||
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
|
||||
import {
|
||||
STORAGE_KEY_THEME,
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_CUSTOM_THEMES,
|
||||
STORAGE_KEY_IMMERSIVE_MODE,
|
||||
} from '../infrastructure/config/storageKeys';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -78,6 +79,8 @@ const SYNCABLE_TERMINAL_KEYS = [
|
||||
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
|
||||
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
|
||||
'keepaliveInterval', 'disableBracketedPaste', 'osc52Clipboard',
|
||||
'autocompleteEnabled', 'autocompleteGhostText', 'autocompletePopupMenu',
|
||||
'autocompleteDebounceMs', 'autocompleteMinChars', 'autocompleteMaxSuggestions',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
@@ -158,6 +161,10 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
const autoOpenSidebar = localStorageAdapter.readString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
|
||||
if (autoOpenSidebar === 'true' || autoOpenSidebar === 'false') settings.sftpAutoOpenSidebar = autoOpenSidebar === 'true';
|
||||
|
||||
// Immersive mode
|
||||
const immersive = localStorageAdapter.readString(STORAGE_KEY_IMMERSIVE_MODE);
|
||||
if (immersive === 'true' || immersive === 'false') settings.immersiveMode = immersive === 'true';
|
||||
|
||||
return Object.keys(settings).length > 0 ? settings : undefined;
|
||||
}
|
||||
|
||||
@@ -216,6 +223,9 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
|
||||
if (settings.sftpShowHiddenFiles != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, String(settings.sftpShowHiddenFiles));
|
||||
if (settings.sftpUseCompressedUpload != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, String(settings.sftpUseCompressedUpload));
|
||||
if (settings.sftpAutoOpenSidebar != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, String(settings.sftpAutoOpenSidebar));
|
||||
|
||||
// Immersive mode
|
||||
if (settings.immersiveMode != null) localStorageAdapter.writeString(STORAGE_KEY_IMMERSIVE_MODE, String(settings.immersiveMode));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -420,7 +420,9 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
|
||||
/** Ensure a session exists for the current scope and return its ID. */
|
||||
const ensureSession = useCallback((): string => {
|
||||
if (activeSessionId) return activeSessionId;
|
||||
if (activeSessionId && sessionsRef.current.some((session) => session.id === activeSessionId)) {
|
||||
return activeSessionId;
|
||||
}
|
||||
const scope: AISessionScope = { type: scopeType, targetId: scopeTargetId, hostIds: scopeHostIds };
|
||||
const session = createSession(scope, currentAgentId);
|
||||
setActiveSessionId(session.id);
|
||||
@@ -543,6 +545,10 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
}));
|
||||
// Clear pending approvals for this session (so tool execute functions don't hang)
|
||||
clearAllPendingApprovals(activeSessionId);
|
||||
// Cancel in-flight command executions (Catty Agent + ACP Agent)
|
||||
const bridge = getNetcattyBridge();
|
||||
bridge?.aiCattyCancelExec?.(activeSessionId);
|
||||
bridge?.aiAcpCancel?.('', activeSessionId);
|
||||
}, [activeSessionId, setStreamingForScope, updateLastMessage, abortControllersRef]);
|
||||
|
||||
const handleSelectSession = useCallback(
|
||||
|
||||
@@ -611,7 +611,7 @@ interface SyncDashboardProps {
|
||||
onClearLocalData?: () => void;
|
||||
}
|
||||
|
||||
export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
onBuildPayload,
|
||||
onApplyPayload,
|
||||
onClearLocalData,
|
||||
|
||||
@@ -20,6 +20,9 @@ import {
|
||||
Tag,
|
||||
TerminalSquare,
|
||||
User,
|
||||
FileKey,
|
||||
FolderOpen,
|
||||
Trash2,
|
||||
Variable,
|
||||
Wifi,
|
||||
X,
|
||||
@@ -27,7 +30,6 @@ import {
|
||||
import React, { useEffect, useMemo, useState, useCallback } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useApplicationBackend } from "../application/state/useApplicationBackend";
|
||||
import { useSettingsState } from "../application/state/useSettingsState";
|
||||
import { getEffectiveHostDistro, LINUX_DISTRO_OPTIONS } from "../domain/host";
|
||||
import { customThemeStore } from "../application/state/customThemeStore";
|
||||
import {
|
||||
@@ -69,7 +71,7 @@ import {
|
||||
ProxyPanel,
|
||||
} from "./host-details";
|
||||
|
||||
type CredentialType = "sshid" | "key" | "certificate" | null;
|
||||
type CredentialType = "sshid" | "key" | "certificate" | "localKeyFile" | null;
|
||||
type SubPanel =
|
||||
| "none"
|
||||
| "create-group"
|
||||
@@ -90,6 +92,8 @@ interface HostDetailsPanelProps {
|
||||
allTags?: string[]; // All available tags for autocomplete
|
||||
allHosts?: Host[]; // All hosts for chain selection
|
||||
defaultGroup?: string | null; // Default group for new hosts (from current navigation)
|
||||
terminalThemeId: string;
|
||||
terminalFontSize: number;
|
||||
onSave: (host: Host) => void;
|
||||
onCancel: () => void;
|
||||
onCreateGroup?: (groupPath: string) => void; // Callback to create a new group
|
||||
@@ -105,6 +109,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
allTags = [],
|
||||
allHosts = [],
|
||||
defaultGroup,
|
||||
terminalThemeId,
|
||||
terminalFontSize,
|
||||
onSave,
|
||||
onCancel,
|
||||
onCreateGroup,
|
||||
@@ -112,7 +118,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { checkSshAgent } = useApplicationBackend();
|
||||
const { terminalThemeId, terminalFontSize } = useSettingsState();
|
||||
const [form, setForm] = useState<Host>(
|
||||
() =>
|
||||
initialData ||
|
||||
@@ -147,6 +152,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
// Password visibility state
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
// Local key file path input state
|
||||
const [newKeyFilePath, setNewKeyFilePath] = useState("");
|
||||
|
||||
// New group creation state
|
||||
const [newGroupName, setNewGroupName] = useState("");
|
||||
const [newGroupParent, setNewGroupParent] = useState("");
|
||||
@@ -469,6 +477,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
authMethod: identity.authMethod,
|
||||
password: undefined,
|
||||
identityFileId: undefined,
|
||||
identityFilePaths: undefined,
|
||||
}));
|
||||
setSelectedCredentialType(null);
|
||||
setCredentialPopoverOpen(false);
|
||||
@@ -969,6 +978,31 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Local key file paths display */}
|
||||
{!selectedIdentity && !form.identityFileId && form.identityFilePaths && form.identityFilePaths.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{form.identityFilePaths.map((keyPath, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60">
|
||||
<FileKey size={14} className="text-primary shrink-0" />
|
||||
<span className="text-xs flex-1 truncate font-mono" title={keyPath}>
|
||||
{keyPath}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={() => {
|
||||
const paths = form.identityFilePaths?.filter((_, i) => i !== idx) || [];
|
||||
update("identityFilePaths", paths.length > 0 ? paths : undefined);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selected credential display */}
|
||||
{!selectedIdentity && form.identityFileId && (
|
||||
<div className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60">
|
||||
@@ -1046,6 +1080,20 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
{t("hostDetails.credential.certificate")}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-secondary/80 transition-colors text-left"
|
||||
onClick={() => {
|
||||
setSelectedCredentialType("localKeyFile");
|
||||
setCredentialPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<FileKey size={16} className="text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
{t("hostDetails.credential.localKeyFile")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
@@ -1067,6 +1115,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
onValueChange={(val) => {
|
||||
update("identityFileId", val);
|
||||
update("authMethod", "key");
|
||||
update("identityFilePaths", undefined);
|
||||
setSelectedCredentialType(null);
|
||||
}}
|
||||
placeholder={t("hostDetails.keys.search")}
|
||||
@@ -1102,6 +1151,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
onValueChange={(val) => {
|
||||
update("identityFileId", val);
|
||||
update("authMethod", "certificate");
|
||||
update("identityFilePaths", undefined);
|
||||
setSelectedCredentialType(null);
|
||||
}}
|
||||
placeholder={t("hostDetails.certs.search")}
|
||||
@@ -1121,6 +1171,67 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Local key file path input - appears after selecting "Local Key File" type */}
|
||||
{!selectedIdentity &&
|
||||
selectedCredentialType === "localKeyFile" &&
|
||||
!form.identityFileId && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 h-8 px-2 text-xs font-mono bg-background border border-border/60 rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
placeholder={t("hostDetails.credential.localKeyFilePlaceholder")}
|
||||
value={newKeyFilePath}
|
||||
onChange={(e) => setNewKeyFilePath(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && newKeyFilePath.trim()) {
|
||||
e.preventDefault();
|
||||
const paths = [...(form.identityFilePaths || []), newKeyFilePath.trim()];
|
||||
update("identityFilePaths", paths);
|
||||
update("identityFileId", undefined);
|
||||
update("authMethod", "key");
|
||||
setNewKeyFilePath("");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
title={t("hostDetails.credential.browseKeyFile")}
|
||||
onClick={async () => {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
if (!bridge?.selectFile) return;
|
||||
const filePath = await bridge.selectFile(
|
||||
"Select SSH Private Key",
|
||||
undefined,
|
||||
[{ name: "All Files", extensions: ["*"] }]
|
||||
);
|
||||
if (filePath) {
|
||||
const paths = [...(form.identityFilePaths || []), filePath];
|
||||
update("identityFilePaths", paths);
|
||||
update("identityFileId", undefined);
|
||||
update("authMethod", "key");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => {
|
||||
setSelectedCredentialType(null);
|
||||
setNewKeyFilePath("");
|
||||
}}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -130,7 +130,9 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
const result = await startTunnel(
|
||||
rule,
|
||||
_host,
|
||||
keys.map((k) => ({ id: k.id, privateKey: k.privateKey, passphrase: k.passphrase })),
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
(status, error) => {
|
||||
// Show toast on error (only once)
|
||||
if (status === "error" && error && !errorShown) {
|
||||
@@ -159,7 +161,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
});
|
||||
}
|
||||
},
|
||||
[hosts, keys, setRuleStatus, startTunnel, t],
|
||||
[hosts, identities, keys, setRuleStatus, startTunnel, t],
|
||||
);
|
||||
|
||||
// Stop a port forwarding tunnel
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
Folder,
|
||||
LayoutGrid,
|
||||
Search,
|
||||
Shield,
|
||||
FolderLock,
|
||||
Terminal,
|
||||
TerminalSquare,
|
||||
} from "lucide-react";
|
||||
@@ -287,7 +287,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
const isSelected = idx === selectedIndex;
|
||||
const icon =
|
||||
tabId === "vault" ? (
|
||||
<Shield size={16} />
|
||||
<FolderLock size={16} />
|
||||
) : (
|
||||
<Folder size={16} />
|
||||
);
|
||||
|
||||
@@ -68,9 +68,11 @@ interface SettingsApplicationTabProps {
|
||||
checkNow: UseUpdateCheckResult['checkNow'];
|
||||
openReleasePage: UseUpdateCheckResult['openReleasePage'];
|
||||
installUpdate: UseUpdateCheckResult['installUpdate'];
|
||||
startDownload: UseUpdateCheckResult['startDownload'];
|
||||
isUpdateDemoMode: boolean;
|
||||
}
|
||||
|
||||
export default function SettingsApplicationTab({ updateState, checkNow, openReleasePage, installUpdate }: SettingsApplicationTabProps) {
|
||||
export default function SettingsApplicationTab({ updateState, checkNow, openReleasePage, installUpdate, startDownload, isUpdateDemoMode }: SettingsApplicationTabProps) {
|
||||
const { t } = useI18n();
|
||||
const { openExternal, getApplicationInfo } = useApplicationBackend();
|
||||
const [appInfo, setAppInfo] = useState<AppInfo>({ name: "Netcatty", version: "" });
|
||||
@@ -94,10 +96,6 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
|
||||
};
|
||||
}, [getApplicationInfo]);
|
||||
|
||||
// Check if demo mode is enabled for development testing
|
||||
const isUpdateDemoMode = typeof window !== 'undefined' &&
|
||||
window.localStorage?.getItem('debug.updateDemo') === '1';
|
||||
|
||||
const handleCheckForUpdates = async () => {
|
||||
// In demo mode, allow checking even for dev builds
|
||||
if (!isUpdateDemoMode && (!appInfo.version || appInfo.version === '0.0.0')) {
|
||||
@@ -150,7 +148,7 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
|
||||
{/* Update badge - reflects auto-download state */}
|
||||
{updateState.latestRelease && (updateState.hasUpdate || updateState.autoDownloadStatus === 'downloading' || updateState.autoDownloadStatus === 'ready') && (
|
||||
<button
|
||||
onClick={() => updateState.autoDownloadStatus === 'ready' ? installUpdate() : void openReleasePage()}
|
||||
onClick={() => updateState.autoDownloadStatus === 'ready' ? installUpdate() : updateState.autoDownloadStatus === 'downloading' ? undefined : startDownload()}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium",
|
||||
updateState.autoDownloadStatus === 'ready'
|
||||
@@ -177,7 +175,7 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
|
||||
variant="secondary"
|
||||
className="gap-2"
|
||||
onClick={() => void handleCheckForUpdates()}
|
||||
disabled={updateState.isChecking}
|
||||
disabled={updateState.isChecking || updateState.manualCheckStatus === 'checking' || updateState.autoDownloadStatus === 'downloading' || updateState.autoDownloadStatus === 'ready'}
|
||||
>
|
||||
{updateState.isChecking ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { AppWindow, Cloud, FileType, HardDrive, Keyboard, Palette, Sparkles, TerminalSquare, X } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useSettingsState } from "../application/state/useSettingsState";
|
||||
import { useAvailableFonts } from "../application/state/fontStore";
|
||||
import { usePortForwardingState } from "../application/state/usePortForwardingState";
|
||||
import { useVaultState } from "../application/state/useVaultState";
|
||||
import { useWindowControls } from "../application/state/useWindowControls";
|
||||
@@ -19,7 +20,6 @@ import SettingsTerminalTab from "./settings/tabs/SettingsTerminalTab";
|
||||
import SettingsSystemTab from "./settings/tabs/SettingsSystemTab";
|
||||
const SettingsAITab = React.lazy(() => import("./settings/tabs/SettingsAITab"));
|
||||
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
|
||||
import type { TerminalFont } from "../infrastructure/config/fonts";
|
||||
|
||||
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
|
||||
|
||||
@@ -45,12 +45,63 @@ class AITabErrorBoundary extends React.Component<
|
||||
}
|
||||
}
|
||||
|
||||
type SettingsState = ReturnType<typeof useSettingsState> & {
|
||||
availableFonts: TerminalFont[];
|
||||
};
|
||||
type SettingsState = ReturnType<typeof useSettingsState>;
|
||||
|
||||
const SettingsSyncTab = React.lazy(() => import("./settings/tabs/SettingsSyncTab"));
|
||||
|
||||
const SettingsTerminalTabContainer: React.FC<{ settings: SettingsState }> = ({ settings }) => {
|
||||
const availableFonts = useAvailableFonts();
|
||||
|
||||
return (
|
||||
<SettingsTerminalTab
|
||||
terminalThemeId={settings.terminalThemeId}
|
||||
setTerminalThemeId={settings.setTerminalThemeId}
|
||||
terminalFontFamilyId={settings.terminalFontFamilyId}
|
||||
setTerminalFontFamilyId={settings.setTerminalFontFamilyId}
|
||||
terminalFontSize={settings.terminalFontSize}
|
||||
setTerminalFontSize={settings.setTerminalFontSize}
|
||||
terminalSettings={settings.terminalSettings}
|
||||
updateTerminalSetting={settings.updateTerminalSetting}
|
||||
availableFonts={availableFonts}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const SettingsAITabContainer: React.FC = () => {
|
||||
const aiState = useAIState();
|
||||
|
||||
return (
|
||||
<AITabErrorBoundary>
|
||||
<React.Suspense fallback={<div className="flex-1 px-6 py-5 text-sm text-muted-foreground">Loading AI settings...</div>}>
|
||||
<SettingsAITab
|
||||
providers={aiState.providers}
|
||||
addProvider={aiState.addProvider}
|
||||
updateProvider={aiState.updateProvider}
|
||||
removeProvider={aiState.removeProvider}
|
||||
activeProviderId={aiState.activeProviderId}
|
||||
setActiveProviderId={aiState.setActiveProviderId}
|
||||
activeModelId={aiState.activeModelId}
|
||||
setActiveModelId={aiState.setActiveModelId}
|
||||
globalPermissionMode={aiState.globalPermissionMode}
|
||||
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
|
||||
externalAgents={aiState.externalAgents}
|
||||
setExternalAgents={aiState.setExternalAgents}
|
||||
defaultAgentId={aiState.defaultAgentId}
|
||||
setDefaultAgentId={aiState.setDefaultAgentId}
|
||||
commandBlocklist={aiState.commandBlocklist}
|
||||
setCommandBlocklist={aiState.setCommandBlocklist}
|
||||
commandTimeout={aiState.commandTimeout}
|
||||
setCommandTimeout={aiState.setCommandTimeout}
|
||||
maxIterations={aiState.maxIterations}
|
||||
setMaxIterations={aiState.setMaxIterations}
|
||||
webSearchConfig={aiState.webSearchConfig}
|
||||
setWebSearchConfig={aiState.setWebSearchConfig}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</AITabErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = ({ onSettingsApplied }) => {
|
||||
const {
|
||||
hosts,
|
||||
@@ -98,10 +149,13 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
|
||||
const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }) => {
|
||||
const { t } = useI18n();
|
||||
const { notifyRendererReady, closeSettingsWindow } = useWindowControls();
|
||||
const { updateState, checkNow, installUpdate, openReleasePage } = useUpdateCheck({ autoUpdateEnabled: settings.autoUpdateEnabled });
|
||||
const aiState = useAIState();
|
||||
const { updateState, checkNow, installUpdate, openReleasePage, startDownload, isUpdateDemoMode } = useUpdateCheck({ autoUpdateEnabled: settings.autoUpdateEnabled });
|
||||
const [activeTab, setActiveTab] = useState("application");
|
||||
const [mountedTabs, setMountedTabs] = useState(() => new Set(["application"]));
|
||||
const isImmersive = settings.immersiveMode;
|
||||
const toggleImmersive = useCallback(() => {
|
||||
settings.setImmersiveMode(!isImmersive);
|
||||
}, [settings, isImmersive]);
|
||||
|
||||
useEffect(() => {
|
||||
notifyRendererReady();
|
||||
@@ -206,6 +260,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
checkNow={checkNow}
|
||||
openReleasePage={openReleasePage}
|
||||
installUpdate={installUpdate}
|
||||
startDownload={startDownload}
|
||||
isUpdateDemoMode={isUpdateDemoMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -227,21 +283,13 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
setUiLanguage={settings.setUiLanguage}
|
||||
customCSS={settings.customCSS}
|
||||
setCustomCSS={settings.setCustomCSS}
|
||||
isImmersive={isImmersive}
|
||||
onToggleImmersive={toggleImmersive}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mountedTabs.has("terminal") && (
|
||||
<SettingsTerminalTab
|
||||
terminalThemeId={settings.terminalThemeId}
|
||||
setTerminalThemeId={settings.setTerminalThemeId}
|
||||
terminalFontFamilyId={settings.terminalFontFamilyId}
|
||||
setTerminalFontFamilyId={settings.setTerminalFontFamilyId}
|
||||
terminalFontSize={settings.terminalFontSize}
|
||||
setTerminalFontSize={settings.setTerminalFontSize}
|
||||
terminalSettings={settings.terminalSettings}
|
||||
updateTerminalSetting={settings.updateTerminalSetting}
|
||||
availableFonts={settings.availableFonts}
|
||||
/>
|
||||
<SettingsTerminalTabContainer settings={settings} />
|
||||
)}
|
||||
|
||||
{mountedTabs.has("shortcuts") && (
|
||||
@@ -261,34 +309,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
)}
|
||||
|
||||
{mountedTabs.has("ai") && (
|
||||
<AITabErrorBoundary>
|
||||
<React.Suspense fallback={null}>
|
||||
<SettingsAITab
|
||||
providers={aiState.providers}
|
||||
addProvider={aiState.addProvider}
|
||||
updateProvider={aiState.updateProvider}
|
||||
removeProvider={aiState.removeProvider}
|
||||
activeProviderId={aiState.activeProviderId}
|
||||
setActiveProviderId={aiState.setActiveProviderId}
|
||||
activeModelId={aiState.activeModelId}
|
||||
setActiveModelId={aiState.setActiveModelId}
|
||||
globalPermissionMode={aiState.globalPermissionMode}
|
||||
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
|
||||
externalAgents={aiState.externalAgents}
|
||||
setExternalAgents={aiState.setExternalAgents}
|
||||
defaultAgentId={aiState.defaultAgentId}
|
||||
setDefaultAgentId={aiState.setDefaultAgentId}
|
||||
commandBlocklist={aiState.commandBlocklist}
|
||||
setCommandBlocklist={aiState.setCommandBlocklist}
|
||||
commandTimeout={aiState.commandTimeout}
|
||||
setCommandTimeout={aiState.setCommandTimeout}
|
||||
maxIterations={aiState.maxIterations}
|
||||
setMaxIterations={aiState.setMaxIterations}
|
||||
webSearchConfig={aiState.webSearchConfig}
|
||||
setWebSearchConfig={aiState.setWebSearchConfig}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</AITabErrorBoundary>
|
||||
<SettingsAITabContainer />
|
||||
)}
|
||||
|
||||
{mountedTabs.has("sync") && (
|
||||
@@ -318,6 +339,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
checkNow={checkNow}
|
||||
installUpdate={installUpdate}
|
||||
openReleasePage={openReleasePage}
|
||||
startDownload={startDownload}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -19,10 +19,11 @@ import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useIsSftpActive } from "../application/state/activeTabStore";
|
||||
import { useSftpState } from "../application/state/useSftpState";
|
||||
import { useSftpBackend } from "../application/state/useSftpBackend";
|
||||
import { useSettingsState } from "../application/state/useSettingsState";
|
||||
import { HotkeyScheme, KeyBinding } from "../domain/models";
|
||||
import { logger } from "../lib/logger";
|
||||
import { useRenderTracker } from "../lib/useRenderTracker";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useInstantThemeSwitch } from "../lib/useInstantThemeSwitch";
|
||||
import { Host, Identity, SSHKey } from "../types";
|
||||
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
|
||||
import { toast } from "./ui/toast";
|
||||
@@ -49,21 +50,35 @@ interface SftpViewProps {
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
sftpDoubleClickBehavior: "open" | "transfer";
|
||||
sftpAutoSync: boolean;
|
||||
sftpShowHiddenFiles: boolean;
|
||||
sftpUseCompressedUpload: boolean;
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
keyBindings: KeyBinding[];
|
||||
editorWordWrap: boolean;
|
||||
setEditorWordWrap: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updateHosts }) => {
|
||||
const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
updateHosts,
|
||||
sftpDoubleClickBehavior,
|
||||
sftpAutoSync,
|
||||
sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isActive = useIsSftpActive();
|
||||
const {
|
||||
sftpDoubleClickBehavior,
|
||||
sftpAutoSync,
|
||||
sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
} = useSettingsState();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useInstantThemeSwitch(rootRef);
|
||||
|
||||
// File watch event handlers (stable refs to avoid re-creating the useSftpState options)
|
||||
const fileWatchHandlers = useMemo(() => ({
|
||||
@@ -246,6 +261,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
|
||||
rightCallbacks={rightCallbacks}
|
||||
>
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={cn(
|
||||
"absolute inset-0 min-h-0 flex flex-col",
|
||||
isActive ? "z-20" : "",
|
||||
@@ -408,7 +424,17 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
|
||||
};
|
||||
|
||||
const sftpViewAreEqual = (prev: SftpViewProps, next: SftpViewProps): boolean =>
|
||||
prev.hosts === next.hosts && prev.keys === next.keys && prev.identities === next.identities;
|
||||
prev.hosts === next.hosts &&
|
||||
prev.keys === next.keys &&
|
||||
prev.identities === next.identities &&
|
||||
prev.sftpDoubleClickBehavior === next.sftpDoubleClickBehavior &&
|
||||
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;
|
||||
|
||||
export const SftpView = memo(SftpViewInner, sftpViewAreEqual);
|
||||
SftpView.displayName = "SftpView";
|
||||
|
||||
@@ -4,11 +4,11 @@ import { SerializeAddon } from "@xterm/addon-serialize";
|
||||
import { SearchAddon } from "@xterm/addon-search";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { Cpu, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine } from "lucide-react";
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
// flushSync removed - no longer needed
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { logger } from "../lib/logger";
|
||||
import { cn } from "../lib/utils";
|
||||
import { cn, normalizeLineEndings, wrapBracketedPaste } from "../lib/utils";
|
||||
import {
|
||||
Host,
|
||||
Identity,
|
||||
@@ -26,8 +26,6 @@ import {
|
||||
shouldScrollOnTerminalInput,
|
||||
} from "../domain/terminalScroll";
|
||||
import {
|
||||
resolveHostTerminalFontFamilyId,
|
||||
resolveHostTerminalFontSize,
|
||||
resolveHostTerminalThemeId,
|
||||
} from "../domain/terminalAppearance";
|
||||
import { resolveHostAuth } from "../domain/sshAuth";
|
||||
@@ -54,6 +52,7 @@ import { useTerminalContextActions } from "./terminal/hooks/useTerminalContextAc
|
||||
import { useTerminalAuthState } from "./terminal/hooks/useTerminalAuthState";
|
||||
import { useServerStats } from "./terminal/hooks/useServerStats";
|
||||
import { extractDropEntries, getPathForFile, DropEntry } from "../lib/sftpFileUtils";
|
||||
import { useTerminalAutocomplete, AutocompletePopup } from "./terminal/autocomplete";
|
||||
|
||||
/**
|
||||
* Extract unique root paths from drop entries for local terminal path insertion.
|
||||
@@ -110,7 +109,8 @@ interface TerminalProps {
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
snippets: Snippet[];
|
||||
allHosts?: Host[];
|
||||
chainHosts?: Host[];
|
||||
themePreviewId?: string;
|
||||
knownHosts?: KnownHost[];
|
||||
isVisible: boolean;
|
||||
inWorkspace?: boolean;
|
||||
@@ -157,6 +157,10 @@ interface TerminalProps {
|
||||
onToggleComposeBar?: () => void;
|
||||
isWorkspaceComposeBarOpen?: boolean;
|
||||
onBroadcastInput?: (data: string, sourceSessionId: string) => void;
|
||||
onSnippetExecutorChange?: (
|
||||
sessionId: string,
|
||||
executor: ((command: string, noAutoRun?: boolean) => void) | null,
|
||||
) => void;
|
||||
// Session log configuration for real-time streaming
|
||||
sessionLog?: { enabled: boolean; directory: string; format: string };
|
||||
}
|
||||
@@ -179,7 +183,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
keys,
|
||||
identities,
|
||||
snippets,
|
||||
allHosts = [],
|
||||
chainHosts = [],
|
||||
themePreviewId,
|
||||
knownHosts: _knownHosts = [],
|
||||
isVisible,
|
||||
inWorkspace,
|
||||
@@ -216,6 +221,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onToggleComposeBar,
|
||||
isWorkspaceComposeBarOpen,
|
||||
onBroadcastInput,
|
||||
onSnippetExecutorChange,
|
||||
sessionLog,
|
||||
}) => {
|
||||
// Timeout for connection - increased to 120s to allow time for keyboard-interactive (2FA) authentication
|
||||
@@ -228,6 +234,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const serializeAddonRef = useRef<SerializeAddon | null>(null);
|
||||
const searchAddonRef = useRef<SearchAddon | null>(null);
|
||||
const xtermRuntimeRef = useRef<XTermRuntime | null>(null);
|
||||
const knownCwdRef = useRef<string | undefined>(undefined);
|
||||
const disposeDataRef = useRef<(() => void) | null>(null);
|
||||
const disposeExitRef = useRef<(() => void) | null>(null);
|
||||
const sessionRef = useRef<string | null>(null);
|
||||
@@ -291,6 +298,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const snippetsRef = useRef(snippets);
|
||||
snippetsRef.current = snippets;
|
||||
|
||||
// Autocomplete handler refs (set after hook initialization)
|
||||
const autocompleteKeyEventRef = useRef<((e: KeyboardEvent) => boolean) | undefined>(undefined);
|
||||
const autocompleteInputRef = useRef<((data: string) => void) | undefined>(undefined);
|
||||
const autocompleteRepositionRef = useRef<(() => void) | undefined>(undefined);
|
||||
|
||||
const terminalBackend = useTerminalBackend();
|
||||
const { resizeSession, setSessionEncoding } = terminalBackend;
|
||||
|
||||
@@ -342,16 +354,145 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
handleCloseSearch,
|
||||
} = terminalSearch;
|
||||
|
||||
// Terminal autocomplete — onAcceptText writes directly to session (no CustomEvent)
|
||||
const autocompleteAcceptTextRef = useRef<((text: string) => void) | undefined>(undefined);
|
||||
autocompleteAcceptTextRef.current = (text: string) => {
|
||||
const id = sessionRef.current;
|
||||
if (id && text) {
|
||||
// Serial line mode: buffer text and handle local echo instead of direct send
|
||||
if (host.protocol === "serial" && serialConfig?.lineMode) {
|
||||
for (const ch of text) {
|
||||
if (ch === "\r") {
|
||||
const line = serialLineBufferRef.current + "\r";
|
||||
terminalBackend.writeToSession(id, line);
|
||||
serialLineBufferRef.current = "";
|
||||
if (serialConfig?.localEcho) termRef.current?.write("\r\n");
|
||||
} else if (ch === "\x15") {
|
||||
if (serialConfig?.localEcho && serialLineBufferRef.current.length > 0) {
|
||||
termRef.current?.write("\b \b".repeat(serialLineBufferRef.current.length));
|
||||
}
|
||||
serialLineBufferRef.current = "";
|
||||
} else if (ch === "\b" || ch === "\x7f") {
|
||||
if (serialLineBufferRef.current.length > 0) {
|
||||
serialLineBufferRef.current = serialLineBufferRef.current.slice(0, -1);
|
||||
if (serialConfig?.localEcho) termRef.current?.write("\b \b");
|
||||
}
|
||||
} else if (ch.charCodeAt(0) >= 32) {
|
||||
serialLineBufferRef.current += ch;
|
||||
if (serialConfig?.localEcho) termRef.current?.write(ch);
|
||||
}
|
||||
}
|
||||
// Still update commandBuffer and broadcast for serial line mode
|
||||
// (fall through to shared bookkeeping below — don't return early)
|
||||
} else if (host.protocol === "serial" && serialConfig?.localEcho) {
|
||||
// Serial character mode with local echo: echo accepted text locally
|
||||
terminalBackend.writeToSession(id, text);
|
||||
for (const ch of text) {
|
||||
if (ch === "\r") {
|
||||
termRef.current?.write("\r\n");
|
||||
} else if (ch.charCodeAt(0) >= 32) {
|
||||
termRef.current?.write(ch);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
terminalBackend.writeToSession(id, text);
|
||||
}
|
||||
|
||||
// Broadcast to other sessions if broadcast mode is enabled
|
||||
if (isBroadcastEnabledRef.current && onBroadcastInputRef.current) {
|
||||
onBroadcastInputRef.current(text, sessionId);
|
||||
}
|
||||
|
||||
// Update command buffer for onCommandExecuted tracking
|
||||
for (const ch of text) {
|
||||
if (ch === "\r" || ch === "\n") {
|
||||
const cmd = commandBufferRef.current.trim();
|
||||
if (cmd && onCommandExecuted) onCommandExecuted(cmd, host.id, host.label, sessionId);
|
||||
commandBufferRef.current = "";
|
||||
} else if (ch === "\x15") {
|
||||
// Ctrl+U: clear line — reset command buffer (fuzzy match sends this)
|
||||
commandBufferRef.current = "";
|
||||
} else if (ch === "\b" || ch === "\x7f") {
|
||||
// Backspace: remove last character (Windows fuzzy replacement uses \b)
|
||||
commandBufferRef.current = commandBufferRef.current.slice(0, -1);
|
||||
} else if (ch.charCodeAt(0) >= 32) {
|
||||
commandBufferRef.current += ch;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const autocomplete = useTerminalAutocomplete({
|
||||
termRef,
|
||||
sessionId,
|
||||
hostId: host.id,
|
||||
hostOs: host.os || (host.protocol === "local"
|
||||
? (navigator.platform?.startsWith("Win") ? "windows" : navigator.platform?.startsWith("Mac") ? "macos" : "linux")
|
||||
: "linux"),
|
||||
settings: terminalSettings ? {
|
||||
enabled: terminalSettings.autocompleteEnabled ?? true,
|
||||
showGhostText: terminalSettings.autocompleteGhostText ?? true,
|
||||
showPopupMenu: terminalSettings.autocompletePopupMenu ?? true,
|
||||
debounceMs: terminalSettings.autocompleteDebounceMs ?? 100,
|
||||
minChars: terminalSettings.autocompleteMinChars ?? 1,
|
||||
maxSuggestions: terminalSettings.autocompleteMaxSuggestions ?? 8,
|
||||
} : undefined,
|
||||
onAcceptText: (text) => autocompleteAcceptTextRef.current?.(text),
|
||||
protocol: host.protocol,
|
||||
getCwd: () => knownCwdRef.current ?? xtermRuntimeRef.current?.currentCwd,
|
||||
});
|
||||
|
||||
// Wire up autocomplete handler refs so createXTermRuntime can use them
|
||||
autocompleteKeyEventRef.current = autocomplete.handleKeyEvent;
|
||||
autocompleteInputRef.current = autocomplete.handleInput;
|
||||
autocompleteRepositionRef.current = autocomplete.repositionPopup;
|
||||
const autocompleteClosePopup = autocomplete.closePopup;
|
||||
|
||||
useEffect(() => {
|
||||
knownCwdRef.current = undefined;
|
||||
}, [sessionId, host.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (host.protocol === "local" || host.protocol === "serial" || host.protocol === "telnet") {
|
||||
return;
|
||||
}
|
||||
if (status !== "connected" || !sessionRef.current || knownCwdRef.current) return;
|
||||
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(async () => {
|
||||
if (!sessionRef.current) return;
|
||||
try {
|
||||
const result = await terminalBackend.getSessionPwd(sessionRef.current);
|
||||
if (!cancelled && result.success && result.cwd) {
|
||||
knownCwdRef.current = result.cwd;
|
||||
}
|
||||
} catch {
|
||||
// Best effort only.
|
||||
}
|
||||
}, 150);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [host.protocol, status, terminalBackend]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
autocompleteClosePopup();
|
||||
}
|
||||
}, [isVisible, autocompleteClosePopup]);
|
||||
|
||||
// Check if this is a local or serial connection (doesn't need connection dialog during connecting)
|
||||
const isLocalConnection = host.protocol === "local";
|
||||
const isSerialConnection = host.protocol === "serial";
|
||||
|
||||
// Server stats (CPU, Memory, Disk) for Linux servers
|
||||
// Server stats (CPU, Memory, Disk) — only for Linux/macOS
|
||||
const { stats: serverStats } = useServerStats({
|
||||
sessionId,
|
||||
enabled: terminalSettings?.showServerStats ?? true,
|
||||
refreshInterval: terminalSettings?.serverStatsRefreshInterval ?? 5,
|
||||
isLinux: host.os === 'linux',
|
||||
isSupportedOs: host.os === 'linux' || host.os === 'macos',
|
||||
isConnected: status === 'connected',
|
||||
});
|
||||
|
||||
@@ -406,21 +547,35 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
// Subscribe to custom theme changes so editing triggers re-render
|
||||
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 effectiveFontSize = useMemo(
|
||||
() => (hasFontSizeOverride && host.fontSize != null ? host.fontSize : fontSize),
|
||||
[fontSize, hasFontSizeOverride, host.fontSize],
|
||||
);
|
||||
const resolvedFontFamily = useMemo(() => {
|
||||
const hostFontId = hasFontFamilyOverride && host.fontFamily
|
||||
? host.fontFamily
|
||||
: fontFamilyId;
|
||||
const resolvedFontId = hostFontId || "menlo";
|
||||
return (availableFonts.find((f) => f.id === resolvedFontId) || availableFonts[0]).family;
|
||||
}, [availableFonts, fontFamilyId, hasFontFamilyOverride, host.fontFamily]);
|
||||
|
||||
const effectiveTheme = useMemo(() => {
|
||||
const themeId = resolveHostTerminalThemeId(host, terminalTheme.id);
|
||||
const themeId = themePreviewId ?? resolveHostTerminalThemeId(
|
||||
{ theme: host.theme, themeOverride: host.themeOverride } as Pick<Host, 'theme' | 'themeOverride'>,
|
||||
terminalTheme.id,
|
||||
);
|
||||
if (themeId) {
|
||||
const hostTheme = TERMINAL_THEMES.find((t) => t.id === themeId)
|
||||
|| customThemes.find((t) => t.id === themeId);
|
||||
if (hostTheme) return hostTheme;
|
||||
}
|
||||
return terminalTheme;
|
||||
}, [host, terminalTheme, customThemes]);
|
||||
}, [customThemes, host.theme, host.themeOverride, terminalTheme, themePreviewId]);
|
||||
|
||||
const resolvedChainHosts =
|
||||
(host.hostChain?.hostIds
|
||||
?.map((id) => allHosts.find((h) => h.id === id))
|
||||
.filter(Boolean) as Host[]) || [];
|
||||
chainHosts;
|
||||
|
||||
const updateStatus = (next: TerminalSession["status"]) => {
|
||||
setStatus(next);
|
||||
@@ -539,7 +694,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
serialLocalEcho: serialConfig?.localEcho,
|
||||
serialLineMode: serialConfig?.lineMode,
|
||||
serialLineBufferRef,
|
||||
onCwdChange: (cwd: string) => {
|
||||
knownCwdRef.current = cwd;
|
||||
},
|
||||
onOsc52ReadRequest: handleOsc52ReadRequest,
|
||||
// Autocomplete integration
|
||||
onAutocompleteKeyEvent: (e: KeyboardEvent) => autocompleteKeyEventRef.current?.(e) ?? true,
|
||||
onAutocompleteInput: (data: string) => autocompleteInputRef.current?.(data),
|
||||
});
|
||||
|
||||
xtermRuntimeRef.current = runtime;
|
||||
@@ -691,6 +852,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
if (!options?.force) {
|
||||
const lastSize = lastFittedSizeRef.current;
|
||||
if (lastSize && lastSize.width === width && lastSize.height === height) {
|
||||
autocompleteRepositionRef.current?.();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -699,6 +861,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
try {
|
||||
lastFittedSizeRef.current = { width, height };
|
||||
fitAddon.fit();
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
requestAnimationFrame(() => {
|
||||
autocompleteRepositionRef.current?.();
|
||||
});
|
||||
} else {
|
||||
autocompleteRepositionRef.current?.();
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn("Fit failed", err);
|
||||
}
|
||||
@@ -714,15 +883,20 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Sync xterm theme before browser paint so canvas + DOM CSS vars update in the same frame
|
||||
useLayoutEffect(() => {
|
||||
if (termRef.current) {
|
||||
const effectiveFontSize = resolveHostTerminalFontSize(host, fontSize);
|
||||
termRef.current.options.fontSize = effectiveFontSize;
|
||||
|
||||
termRef.current.options.theme = {
|
||||
...effectiveTheme.colors,
|
||||
selectionBackground: effectiveTheme.colors.selection,
|
||||
};
|
||||
}
|
||||
}, [effectiveTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (termRef.current) {
|
||||
termRef.current.options.fontSize = effectiveFontSize;
|
||||
termRef.current.options.fontFamily = resolvedFontFamily;
|
||||
|
||||
if (terminalSettings) {
|
||||
termRef.current.options.cursorStyle = terminalSettings.cursorShape;
|
||||
@@ -775,27 +949,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
termRef.current.options.ignoreBracketedPasteMode = terminalSettings.disableBracketedPaste ?? false;
|
||||
}
|
||||
|
||||
setTimeout(() => safeFit({ force: true }), 50);
|
||||
if (isVisibleRef.current) {
|
||||
setTimeout(() => safeFit({ force: true, requireVisible: true }), 50);
|
||||
} else {
|
||||
lastFittedSizeRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [fontSize, effectiveTheme, terminalSettings, host]);
|
||||
|
||||
useEffect(() => {
|
||||
if (termRef.current) {
|
||||
const effectiveFontSize = resolveHostTerminalFontSize(host, fontSize);
|
||||
termRef.current.options.fontSize = effectiveFontSize;
|
||||
|
||||
const hostFontId = resolveHostTerminalFontFamilyId(host, fontFamilyId) || "menlo";
|
||||
const fontObj = availableFonts.find((f) => f.id === hostFontId) || availableFonts[0];
|
||||
termRef.current.options.fontFamily = fontObj.family;
|
||||
|
||||
termRef.current.options.theme = {
|
||||
...effectiveTheme.colors,
|
||||
selectionBackground: effectiveTheme.colors.selection,
|
||||
};
|
||||
|
||||
setTimeout(() => safeFit({ force: true }), 50);
|
||||
}
|
||||
}, [host, fontFamilyId, fontSize, effectiveTheme, availableFonts]);
|
||||
}, [effectiveFontSize, resolvedFontFamily, terminalSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
@@ -843,7 +1003,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
if (terminalSettings && termRef.current) {
|
||||
const fontFamily = termRef.current.options?.fontFamily || "";
|
||||
const effectiveFontSize = resolveHostTerminalFontSize(host, fontSize);
|
||||
if (typeof document !== "undefined" && document.fonts?.check) {
|
||||
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${fontFamily}`;
|
||||
const resolvedBold = document.fonts.check(weightSpec)
|
||||
@@ -879,7 +1038,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [host, fontFamilyId, fontSize, resizeSession, sessionId, terminalSettings]);
|
||||
}, [effectiveFontSize, resizeSession, terminalSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible || !containerRef.current || !fitAddonRef.current) return;
|
||||
@@ -1042,11 +1201,43 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const scrollOnPasteRef = useRef(terminalSettings?.scrollOnPaste ?? true);
|
||||
scrollOnPasteRef.current = terminalSettings?.scrollOnPaste ?? true;
|
||||
|
||||
const scrollToBottomAfterProgrammaticInput = (data: string) => {
|
||||
const scrollToBottomAfterProgrammaticInput = useCallback((data: string) => {
|
||||
if (termRef.current && shouldScrollOnTerminalInput(terminalSettingsRef.current, data)) {
|
||||
termRef.current.scrollToBottom();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const executeSnippetCommand = useCallback((command: string, noAutoRun?: boolean) => {
|
||||
const term = termRef.current;
|
||||
const id = sessionRef.current;
|
||||
if (!term || !id) return;
|
||||
|
||||
let data = normalizeLineEndings(command);
|
||||
const isMultiLine = data.includes('\n');
|
||||
// Wrap in bracketed paste BEFORE appending \r so the Enter is sent
|
||||
// outside the paste markers — otherwise shells treat it as pasted text
|
||||
// instead of a submit action.
|
||||
if (isMultiLine && term.modes.bracketedPasteMode && !disableBracketedPasteRef.current) {
|
||||
data = wrapBracketedPaste(data);
|
||||
}
|
||||
if (!noAutoRun) data = `${data}\r`;
|
||||
|
||||
terminalBackend.writeToSession(id, data);
|
||||
scrollToBottomAfterProgrammaticInput(data);
|
||||
term.focus();
|
||||
}, [scrollToBottomAfterProgrammaticInput, terminalBackend]);
|
||||
|
||||
// Only register the snippet executor once the terminal session is ready.
|
||||
// Before that, TerminalLayer falls back to raw writeToSession which is the
|
||||
// correct path for sessions that are still connecting.
|
||||
useEffect(() => {
|
||||
if (status !== "connected") {
|
||||
onSnippetExecutorChange?.(sessionId, null);
|
||||
return;
|
||||
}
|
||||
onSnippetExecutorChange?.(sessionId, executeSnippetCommand);
|
||||
return () => onSnippetExecutorChange?.(sessionId, null);
|
||||
}, [executeSnippetCommand, onSnippetExecutorChange, sessionId, status]);
|
||||
|
||||
const terminalContextActions = useTerminalContextActions({
|
||||
termRef,
|
||||
@@ -1285,6 +1476,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
: status === "connecting"
|
||||
? "bg-amber-400"
|
||||
: "bg-rose-500";
|
||||
const terminalPreviewVars = useMemo(() => ({
|
||||
['--terminal-ui-bg' as never]: `var(--terminal-preview-bg, ${effectiveTheme.colors.background})`,
|
||||
['--terminal-ui-fg' as never]: `var(--terminal-preview-fg, ${effectiveTheme.colors.foreground})`,
|
||||
['--terminal-ui-border' as never]: `var(--terminal-preview-border, color-mix(in srgb, ${effectiveTheme.colors.foreground} 8%, ${effectiveTheme.colors.background} 92%))`,
|
||||
['--terminal-ui-toolbar-btn' as never]: `var(--terminal-preview-toolbar-btn, color-mix(in srgb, ${effectiveTheme.colors.background} 88%, ${effectiveTheme.colors.foreground} 12%))`,
|
||||
['--terminal-ui-toolbar-btn-hover' as never]: `var(--terminal-preview-toolbar-btn-hover, color-mix(in srgb, ${effectiveTheme.colors.background} 78%, ${effectiveTheme.colors.foreground} 22%))`,
|
||||
['--terminal-ui-toolbar-btn-active' as never]: `var(--terminal-preview-toolbar-btn-active, color-mix(in srgb, ${effectiveTheme.colors.background} 68%, ${effectiveTheme.colors.foreground} 32%))`,
|
||||
}), [effectiveTheme.colors.background, effectiveTheme.colors.foreground]);
|
||||
|
||||
return (
|
||||
<TerminalContextMenu
|
||||
@@ -1307,6 +1506,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
"relative h-full w-full flex overflow-hidden bg-gradient-to-br from-[#050910] via-[#06101a] to-[#0b1220]",
|
||||
isComposeBarOpen && !inWorkspace && "flex-col"
|
||||
)}
|
||||
style={terminalPreviewVars}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
@@ -1337,14 +1537,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
<div
|
||||
className="flex items-center gap-1 px-2 py-0.5 backdrop-blur-md pointer-events-auto min-w-0 border-b-[0.5px]"
|
||||
style={{
|
||||
backgroundColor: effectiveTheme.colors.background,
|
||||
color: effectiveTheme.colors.foreground,
|
||||
borderColor: `color-mix(in srgb, ${effectiveTheme.colors.foreground} 8%, ${effectiveTheme.colors.background} 92%)`,
|
||||
['--terminal-toolbar-fg' as never]: effectiveTheme.colors.foreground,
|
||||
['--terminal-toolbar-bg' as never]: effectiveTheme.colors.background,
|
||||
['--terminal-toolbar-btn' as never]: `color-mix(in srgb, ${effectiveTheme.colors.background} 88%, ${effectiveTheme.colors.foreground} 12%)`,
|
||||
['--terminal-toolbar-btn-hover' as never]: `color-mix(in srgb, ${effectiveTheme.colors.background} 78%, ${effectiveTheme.colors.foreground} 22%)`,
|
||||
['--terminal-toolbar-btn-active' as never]: `color-mix(in srgb, ${effectiveTheme.colors.background} 68%, ${effectiveTheme.colors.foreground} 32%)`,
|
||||
backgroundColor: 'var(--terminal-ui-bg)',
|
||||
color: 'var(--terminal-ui-fg)',
|
||||
borderColor: 'var(--terminal-ui-border)',
|
||||
['--terminal-toolbar-fg' as never]: 'var(--terminal-ui-fg)',
|
||||
['--terminal-toolbar-bg' as never]: 'var(--terminal-ui-bg)',
|
||||
['--terminal-toolbar-btn' as never]: 'var(--terminal-ui-toolbar-btn)',
|
||||
['--terminal-toolbar-btn-hover' as never]: 'var(--terminal-ui-toolbar-btn-hover)',
|
||||
['--terminal-toolbar-btn-active' as never]: 'var(--terminal-ui-toolbar-btn-active)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1 text-[11px] font-semibold">
|
||||
@@ -1356,8 +1556,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{/* Server Stats Display - Linux only */}
|
||||
{host.os === 'linux' && terminalSettings?.showServerStats && status === 'connected' && serverStats.lastUpdated && (
|
||||
{/* Server Stats Display */}
|
||||
{terminalSettings?.showServerStats && status === 'connected' && serverStats.lastUpdated && (
|
||||
<div className="flex items-center gap-2.5 ml-2 text-[10px] opacity-80 flex-nowrap overflow-hidden min-w-0">
|
||||
{/* CPU with HoverCard for per-core details */}
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
@@ -1404,6 +1604,24 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : serverStats.cpu !== null ? (
|
||||
<div className="flex flex-col gap-1.5 min-w-[160px]">
|
||||
<div className="w-full h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all",
|
||||
serverStats.cpu >= 90 ? "bg-red-500" : serverStats.cpu >= 70 ? "bg-amber-500" : "bg-emerald-500"
|
||||
)}
|
||||
style={{ width: `${serverStats.cpu}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className={cn(
|
||||
"text-center text-[11px] font-medium",
|
||||
serverStats.cpu >= 90 ? "text-red-400" : serverStats.cpu >= 70 ? "text-amber-400" : "text-emerald-400"
|
||||
)}>
|
||||
{serverStats.cpu}% · {serverStats.cpuCores ?? '?'} cores
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground">{t("terminal.serverStats.noData")}</div>
|
||||
)}
|
||||
@@ -1701,7 +1919,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
<div
|
||||
className="h-full flex-1 min-w-0 relative overflow-hidden pt-8"
|
||||
style={{ backgroundColor: effectiveTheme.colors.background }}
|
||||
style={{ backgroundColor: 'var(--terminal-ui-bg)' }}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
@@ -1709,10 +1927,33 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
style={{
|
||||
top: isSearchOpen ? "64px" : "30px",
|
||||
paddingLeft: 6,
|
||||
backgroundColor: effectiveTheme.colors.background,
|
||||
backgroundColor: 'var(--terminal-ui-bg)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Autocomplete popup — rendered via Portal to escape overflow:hidden */}
|
||||
{isVisible && autocomplete.state.popupVisible && autocomplete.state.suggestions.length > 0 &&
|
||||
ReactDOM.createPortal(
|
||||
<AutocompletePopup
|
||||
suggestions={autocomplete.state.suggestions}
|
||||
selectedIndex={autocomplete.state.selectedIndex}
|
||||
position={autocomplete.state.popupPosition}
|
||||
cursorLineTop={autocomplete.state.popupCursorLineTop}
|
||||
cursorLineBottom={autocomplete.state.popupCursorLineBottom}
|
||||
visible={autocomplete.state.popupVisible}
|
||||
expandUpward={autocomplete.state.expandUpward}
|
||||
themeColors={effectiveTheme.colors}
|
||||
onSelect={autocomplete.selectSuggestion}
|
||||
subDirPanels={autocomplete.state.subDirPanels}
|
||||
subDirFocusLevel={autocomplete.state.subDirFocusLevel}
|
||||
containerRef={containerRef}
|
||||
onRequestReposition={autocomplete.repositionPopup}
|
||||
searchBarOffset={isSearchOpen ? 64 : 30}
|
||||
/>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
{needsHostKeyVerification && pendingHostKeyInfo && (
|
||||
<div className="absolute inset-0 z-30 bg-background">
|
||||
<KnownHostConfirmDialog
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -151,6 +151,7 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
// Ref to store the latest save function to avoid stale closure in keyboard shortcut
|
||||
const handleSaveRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
||||
const handlePasteRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
||||
const readClipboardTextRef = useRef<() => Promise<string | null>>(() => Promise.resolve(null));
|
||||
|
||||
// Track theme from document.documentElement class (syncs with app theme)
|
||||
const [isDarkTheme, setIsDarkTheme] = useState(() =>
|
||||
@@ -254,6 +255,10 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
}
|
||||
}, [readClipboardTextFromBridge]);
|
||||
|
||||
useEffect(() => {
|
||||
readClipboardTextRef.current = readClipboardText;
|
||||
}, [readClipboardText]);
|
||||
|
||||
const handlePaste = useCallback(async () => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
@@ -316,7 +321,30 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
});
|
||||
|
||||
// Fallback paste path for Electron environments where Monaco paste can fail.
|
||||
// Skip custom paste when focus is inside the find/replace widget so that
|
||||
// its input fields receive the pasted text via default browser behavior.
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyV, () => {
|
||||
const active = document.activeElement;
|
||||
if (active?.closest('.find-widget')) {
|
||||
// Read clipboard and insert into the find/replace input field.
|
||||
void (async () => {
|
||||
try {
|
||||
const text = await readClipboardTextRef.current();
|
||||
if (!text) return;
|
||||
// Monaco find widget inputs are <textarea> elements inside .monaco-inputbox
|
||||
if (active instanceof HTMLTextAreaElement || active instanceof HTMLInputElement) {
|
||||
const start = active.selectionStart ?? active.value.length;
|
||||
const end = active.selectionEnd ?? active.value.length;
|
||||
active.focus();
|
||||
active.setSelectionRange(start, end);
|
||||
document.execCommand('insertText', false, text);
|
||||
}
|
||||
} catch {
|
||||
// Ignore – paste simply won't work
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
void handlePasteRef.current();
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Bell, Copy, FileText, Folder, LayoutGrid, Minus, Moon, MoreHorizontal, Plus, Server, Shield, Sparkles, Square, Sun, TerminalSquare, Usb, X } from 'lucide-react';
|
||||
import { Bell, Copy, FileText, Folder, FolderLock, LayoutGrid, Minus, Moon, MoreHorizontal, Plus, Server, Sparkles, Square, Sun, TerminalSquare, Usb, X } from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { activeTabStore, useActiveTabId } from '../application/state/activeTabStore';
|
||||
import { buildWorkspaceActivityMap } from '../application/state/sessionActivity';
|
||||
import { useSessionActivityMap } from '../application/state/sessionActivityStore';
|
||||
import { LogView } from '../application/state/useSessionState';
|
||||
import { useWindowControls } from '../application/state/useWindowControls';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
@@ -36,6 +38,7 @@ interface TopTabsProps {
|
||||
onToggleTheme: () => void;
|
||||
onOpenSettings: () => void;
|
||||
onSyncNow?: () => Promise<void>;
|
||||
isImmersiveActive?: boolean;
|
||||
onStartSessionDrag: (sessionId: string) => void;
|
||||
onEndSessionDrag: () => void;
|
||||
onReorderTabs: (draggedId: string, targetId: string, position: 'before' | 'after') => void;
|
||||
@@ -54,7 +57,7 @@ const localOsId = (() => {
|
||||
const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; protocol?: string }> = memo(({ host, isActive, protocol }) => {
|
||||
const boxBase = "shrink-0 h-4 w-4 rounded flex items-center justify-center";
|
||||
const iconSize = "h-2.5 w-2.5";
|
||||
const fallbackIcon = cn(iconSize, isActive ? "text-accent" : "text-muted-foreground");
|
||||
const fallbackStyle = { color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' };
|
||||
|
||||
// Serial protocol → USB icon
|
||||
if (protocol === 'serial' || host?.protocol === 'serial') {
|
||||
@@ -81,7 +84,7 @@ const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; prot
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={cn(boxBase, "bg-primary/15 text-primary")}>
|
||||
<div className={boxBase} style={{ backgroundColor: 'color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 15%, transparent)', color: 'var(--top-tabs-accent, hsl(var(--accent)))' }}>
|
||||
<TerminalSquare className={iconSize} />
|
||||
</div>
|
||||
);
|
||||
@@ -108,22 +111,33 @@ const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; prot
|
||||
// Fallback: generic server icon for remote, terminal for unknown
|
||||
if (host && host.protocol !== 'local') {
|
||||
return (
|
||||
<div className={cn(boxBase, "bg-primary/15 text-primary")}>
|
||||
<div className={boxBase} style={{ backgroundColor: 'color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 15%, transparent)', color: 'var(--top-tabs-accent, hsl(var(--accent)))' }}>
|
||||
<Server className={iconSize} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <TerminalSquare className={fallbackIcon} />;
|
||||
return <TerminalSquare className={iconSize} style={fallbackStyle} />;
|
||||
});
|
||||
SessionTabIcon.displayName = 'SessionTabIcon';
|
||||
|
||||
const sessionStatusDot = (status: TerminalSession['status']) => {
|
||||
const sessionStatusDot = (status: TerminalSession['status'], hasActivity: boolean) => {
|
||||
const tone = status === 'connected'
|
||||
? "bg-emerald-400"
|
||||
: status === 'connecting'
|
||||
? "bg-amber-400"
|
||||
: "bg-rose-500";
|
||||
return <span className={cn("inline-block h-2 w-2 rounded-full ring-2 ring-background/60", tone)} />;
|
||||
return (
|
||||
<span className="relative inline-flex h-2 w-2 shrink-0 items-center justify-center">
|
||||
<span
|
||||
className={cn(
|
||||
"relative inline-block h-2 w-2 rounded-full ring-2",
|
||||
tone,
|
||||
hasActivity && "session-activity-dot",
|
||||
)}
|
||||
style={{ boxShadow: '0 0 0 2px color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 60%, transparent)' }}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// Custom window controls for Windows/Linux (frameless window)
|
||||
@@ -167,14 +181,16 @@ const WindowControls: React.FC = memo(() => {
|
||||
<div className="flex items-center app-drag h-full">
|
||||
<button
|
||||
onClick={handleMinimize}
|
||||
className="h-full w-10 flex items-center justify-center text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all duration-150 app-no-drag"
|
||||
className="h-full w-10 flex items-center justify-center transition-all duration-150 app-no-drag"
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
title="Minimize"
|
||||
>
|
||||
<Minus size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleMaximize}
|
||||
className="h-full w-10 flex items-center justify-center text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all duration-150 app-no-drag"
|
||||
className="h-full w-10 flex items-center justify-center transition-all duration-150 app-no-drag"
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
title={isMaximized ? "Restore" : "Maximize"}
|
||||
>
|
||||
{isMaximized ? (
|
||||
@@ -217,6 +233,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onToggleTheme,
|
||||
onOpenSettings,
|
||||
onSyncNow,
|
||||
isImmersiveActive,
|
||||
onStartSessionDrag,
|
||||
onEndSessionDrag,
|
||||
onReorderTabs,
|
||||
@@ -225,6 +242,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
// Subscribe to activeTabId from external store
|
||||
const { maximize, isFullscreen, onFullscreenChanged } = useWindowControls();
|
||||
const activeTabId = useActiveTabId();
|
||||
const sessionActivityMap = useSessionActivityMap();
|
||||
const isVaultActive = activeTabId === 'vault';
|
||||
const isSftpActive = activeTabId === 'sftp';
|
||||
const onSelectTab = activeTabStore.setActiveTabId;
|
||||
@@ -328,6 +346,10 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
return map;
|
||||
}, [hosts]);
|
||||
|
||||
const workspaceActivityMap = useMemo(() => {
|
||||
return buildWorkspaceActivityMap(sessions, sessionActivityMap);
|
||||
}, [sessionActivityMap, sessions]);
|
||||
|
||||
// Pre-compute session counts per workspace for O(1) access
|
||||
const workspacePaneCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
@@ -451,6 +473,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
|
||||
if (item.type === 'session') {
|
||||
const session = item.session;
|
||||
const hasActivity = !!sessionActivityMap[session.id];
|
||||
const isBeingDragged = draggingSessionId === session.id;
|
||||
const shiftStyle = tabShiftStyles[session.id] || {};
|
||||
const showDropIndicatorBefore = dropIndicator?.tabId === session.id && dropIndicator.position === 'before';
|
||||
@@ -470,30 +493,56 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onDrop={(e) => handleTabDrop(e, session.id)}
|
||||
className={cn(
|
||||
"relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"transition-all duration-150",
|
||||
activeTabId === session.id
|
||||
? "bg-background text-foreground"
|
||||
: "text-muted-foreground hover:bg-background/40 hover:text-foreground",
|
||||
"transition-transform duration-150",
|
||||
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : ""
|
||||
)}
|
||||
style={shiftStyle}
|
||||
style={{
|
||||
...shiftStyle,
|
||||
backgroundColor: activeTabId === session.id
|
||||
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
|
||||
: 'transparent',
|
||||
color: activeTabId === session.id
|
||||
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
|
||||
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (activeTabId !== session.id) {
|
||||
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (activeTabId !== session.id) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Active tab top accent line */}
|
||||
{activeTabId === session.id && (
|
||||
<div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" />
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-[2px]"
|
||||
style={{ backgroundColor: 'var(--top-tabs-fg, hsl(var(--foreground)))' }}
|
||||
/>
|
||||
)}
|
||||
{/* Drop indicator line - before */}
|
||||
{showDropIndicatorBefore && isDraggingForReorder && (
|
||||
<div className="absolute -left-0.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
|
||||
<div
|
||||
className="absolute -left-0.5 top-1 bottom-1 w-0.5 rounded-full animate-pulse"
|
||||
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))', boxShadow: '0 0 8px 2px color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 50%, transparent)' }}
|
||||
/>
|
||||
)}
|
||||
{/* Drop indicator line - after */}
|
||||
{showDropIndicatorAfter && isDraggingForReorder && (
|
||||
<div className="absolute -right-0.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
|
||||
<div
|
||||
className="absolute -right-0.5 top-1 bottom-1 w-0.5 rounded-full animate-pulse"
|
||||
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))', boxShadow: '0 0 8px 2px color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 50%, transparent)' }}
|
||||
/>
|
||||
)}
|
||||
<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} />
|
||||
<span className="truncate">{session.hostLabel}</span>
|
||||
<div className="flex-shrink-0">{sessionStatusDot(session.status)}</div>
|
||||
<div className="flex-shrink-0">{sessionStatusDot(session.status, hasActivity)}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => onCloseSession(session.id, e)}
|
||||
@@ -522,6 +571,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
if (item.type === 'workspace') {
|
||||
const workspace = item.workspace;
|
||||
const paneCount = item.paneCount;
|
||||
const hasActivity = !!workspaceActivityMap.get(workspace.id);
|
||||
const isActive = activeTabId === workspace.id;
|
||||
const isBeingDragged = draggingSessionId === workspace.id;
|
||||
const shiftStyle = tabShiftStyles[workspace.id] || {};
|
||||
@@ -542,32 +592,71 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onDrop={(e) => handleTabDrop(e, workspace.id)}
|
||||
className={cn(
|
||||
"relative h-7 pl-3 pr-2 min-w-[150px] max-w-[260px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"transition-all duration-150",
|
||||
isActive
|
||||
? "bg-background text-foreground"
|
||||
: "text-muted-foreground hover:bg-background/40 hover:text-foreground",
|
||||
"transition-transform duration-150",
|
||||
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : ""
|
||||
)}
|
||||
style={shiftStyle}
|
||||
style={{
|
||||
...shiftStyle,
|
||||
backgroundColor: isActive
|
||||
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
|
||||
: 'transparent',
|
||||
color: isActive
|
||||
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
|
||||
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Active tab top accent line */}
|
||||
{isActive && (
|
||||
<div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" />
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-[2px]"
|
||||
style={{ backgroundColor: 'var(--top-tabs-fg, hsl(var(--foreground)))' }}
|
||||
/>
|
||||
)}
|
||||
{/* Drop indicator line - before */}
|
||||
{showDropIndicatorBefore && isDraggingForReorder && (
|
||||
<div className="absolute -left-0.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
|
||||
<div
|
||||
className="absolute -left-0.5 top-1 bottom-1 w-0.5 rounded-full animate-pulse"
|
||||
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))', boxShadow: '0 0 8px 2px color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 50%, transparent)' }}
|
||||
/>
|
||||
)}
|
||||
{/* Drop indicator line - after */}
|
||||
{showDropIndicatorAfter && isDraggingForReorder && (
|
||||
<div className="absolute -right-0.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
|
||||
<div
|
||||
className="absolute -right-0.5 top-1 bottom-1 w-0.5 rounded-full animate-pulse"
|
||||
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))', boxShadow: '0 0 8px 2px color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 50%, transparent)' }}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<LayoutGrid size={14} className={cn("shrink-0", isActive ? "text-primary" : "text-muted-foreground")} />
|
||||
<LayoutGrid
|
||||
size={14}
|
||||
className="shrink-0"
|
||||
style={{ color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
/>
|
||||
<span className="truncate">{workspace.title}</span>
|
||||
</div>
|
||||
<div className="text-[10px] px-1.5 py-0.5 rounded-full border border-border/70 bg-background/60 min-w-[22px] text-center">
|
||||
{paneCount}
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{hasActivity && sessionStatusDot('connected', true)}
|
||||
<div
|
||||
className="text-[10px] px-1.5 py-0.5 rounded-full min-w-[22px] text-center"
|
||||
style={{
|
||||
border: '1px solid color-mix(in srgb, var(--top-tabs-fg, hsl(var(--foreground))) 18%, transparent)',
|
||||
backgroundColor: 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 60%, transparent)',
|
||||
}}
|
||||
>
|
||||
{paneCount}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
@@ -595,18 +684,41 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onClick={() => onSelectTab(logView.id)}
|
||||
className={cn(
|
||||
"relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"transition-colors duration-150",
|
||||
isActive
|
||||
? "bg-background text-foreground"
|
||||
: "text-muted-foreground hover:bg-background/40 hover:text-foreground"
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: isActive
|
||||
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
|
||||
: 'transparent',
|
||||
color: isActive
|
||||
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
|
||||
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Active tab top accent line */}
|
||||
{isActive && (
|
||||
<div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" />
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-[2px]"
|
||||
style={{ backgroundColor: 'var(--top-tabs-fg, hsl(var(--foreground)))' }}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<FileText size={14} className={cn("shrink-0", isActive ? "text-accent" : "text-muted-foreground")} />
|
||||
<FileText
|
||||
size={14}
|
||||
className="shrink-0"
|
||||
style={{ color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
/>
|
||||
<span className="truncate">
|
||||
{t('tabs.logPrefix')} {isLocal ? t('tabs.logLocal') : logView.log.hostname}
|
||||
</span>
|
||||
@@ -640,8 +752,13 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
data-top-tabs-root
|
||||
className="relative w-full bg-secondary app-drag"
|
||||
style={dragRegionNoSelect}
|
||||
style={{
|
||||
...dragRegionNoSelect,
|
||||
backgroundColor: 'var(--top-tabs-bg, hsl(var(--secondary)))',
|
||||
color: 'var(--top-tabs-fg, hsl(var(--foreground)))',
|
||||
}}
|
||||
onDoubleClick={handleTitleBarDoubleClick}
|
||||
>
|
||||
{/* Always-on drag stripe so the window can be moved even when tabs fill the bar */}
|
||||
@@ -656,25 +773,62 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onClick={() => onSelectTab('vault')}
|
||||
className={cn(
|
||||
"relative h-7 px-3 rounded text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
|
||||
"transition-colors duration-150",
|
||||
isVaultActive
|
||||
? "bg-foreground/10 text-foreground"
|
||||
: "text-muted-foreground hover:bg-background/40 hover:text-foreground"
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: isVaultActive
|
||||
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
|
||||
: 'transparent',
|
||||
color: isVaultActive
|
||||
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
|
||||
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isVaultActive) {
|
||||
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isVaultActive) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Shield size={14} /> Vaults
|
||||
<FolderLock size={14} /> Vaults
|
||||
</div>
|
||||
<div
|
||||
onClick={() => onSelectTab('sftp')}
|
||||
className={cn(
|
||||
"relative h-7 px-3 rounded-none text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
|
||||
"transition-colors duration-150",
|
||||
isSftpActive
|
||||
? "bg-background text-foreground"
|
||||
: "text-muted-foreground hover:bg-background/40 hover:text-foreground"
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: isSftpActive
|
||||
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
|
||||
: 'transparent',
|
||||
color: isSftpActive
|
||||
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
|
||||
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSftpActive) {
|
||||
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSftpActive) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isSftpActive && <div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" />}
|
||||
{isSftpActive && (
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-[2px]"
|
||||
style={{ backgroundColor: 'var(--top-tabs-fg, hsl(var(--foreground)))' }}
|
||||
/>
|
||||
)}
|
||||
<Folder size={14} /> SFTP
|
||||
</div>
|
||||
</div>
|
||||
@@ -696,7 +850,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
{canScrollLeft && (
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 w-8 pointer-events-none z-10"
|
||||
style={{ background: 'linear-gradient(to right, hsl(var(--secondary) / 0.9), transparent)' }}
|
||||
style={{ background: 'linear-gradient(to right, var(--top-tabs-bg, hsl(var(--secondary))), transparent)' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -713,6 +867,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 flex-shrink-0 app-no-drag mb-0 rounded-none"
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
onClick={onOpenQuickSwitcher}
|
||||
title="Open quick switcher"
|
||||
>
|
||||
@@ -727,7 +882,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
{canScrollRight && (
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-8 pointer-events-none z-10"
|
||||
style={{ background: 'linear-gradient(to left, hsl(var(--secondary) / 0.9), transparent)' }}
|
||||
style={{ background: 'linear-gradient(to left, var(--top-tabs-bg, hsl(var(--secondary))), transparent)' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -738,6 +893,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 flex-shrink-0 app-no-drag self-end rounded-none"
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
onClick={onOpenQuickSwitcher}
|
||||
title="More tabs"
|
||||
>
|
||||
@@ -750,21 +906,24 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-foreground app-no-drag"
|
||||
className="h-6 w-6 app-no-drag"
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
title="AI Assistant"
|
||||
onClick={() => window.dispatchEvent(new CustomEvent('netcatty:toggle-ai-panel'))}
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 text-muted-foreground hover:text-foreground app-no-drag">
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 app-no-drag" style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}>
|
||||
<Bell size={16} />
|
||||
</Button>
|
||||
<SyncStatusButton onOpenSettings={onOpenSettings} onSyncNow={onSyncNow} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-foreground app-no-drag"
|
||||
className="h-6 w-6 app-no-drag"
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
onClick={onToggleTheme}
|
||||
disabled={isImmersiveActive}
|
||||
title="Toggle theme"
|
||||
>
|
||||
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
|
||||
@@ -788,10 +947,12 @@ const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean => {
|
||||
prev.orphanSessions === next.orphanSessions &&
|
||||
prev.workspaces === next.workspaces &&
|
||||
prev.orderedTabs === next.orderedTabs &&
|
||||
prev.logViews === next.logViews &&
|
||||
prev.draggingSessionId === next.draggingSessionId &&
|
||||
prev.isMacClient === next.isMacClient &&
|
||||
prev.onOpenSettings === next.onOpenSettings &&
|
||||
prev.onSyncNow === next.onSyncNow
|
||||
prev.onSyncNow === next.onSyncNow &&
|
||||
prev.isImmersiveActive === next.isImmersiveActive
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ const TrayPanelContent: React.FC = () => {
|
||||
onTrayPanelMenuData,
|
||||
} = useTrayPanelBackend();
|
||||
|
||||
const { hosts, keys } = useVaultState();
|
||||
const { hosts, keys, identities } = useVaultState();
|
||||
useSessionState();
|
||||
const { rules: portForwardingRules, startTunnel, stopTunnel } = usePortForwardingState();
|
||||
const activeTabId = useActiveTabId();
|
||||
@@ -151,11 +151,6 @@ const TrayPanelContent: React.FC = () => {
|
||||
return () => unsubscribe?.();
|
||||
}, [onTrayPanelRefresh]);
|
||||
|
||||
const keysForPf = useMemo(
|
||||
() => keys.map((k) => ({ id: k.id, privateKey: k.privateKey, passphrase: k.passphrase })),
|
||||
[keys],
|
||||
);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
void hideTrayPanel();
|
||||
}, [hideTrayPanel]);
|
||||
@@ -339,7 +334,7 @@ const TrayPanelContent: React.FC = () => {
|
||||
if (isActive) {
|
||||
void stopTunnel(rule.id);
|
||||
} else {
|
||||
void startTunnel(rule, host, keysForPf, (status, error) => {
|
||||
void startTunnel(rule, host, hosts, keys, identities, (status, error) => {
|
||||
if (status === "error" && error) toast.error(error);
|
||||
}, rule.autoStart);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
import { cn } from '../../lib/utils';
|
||||
import type { ComponentProps, HTMLAttributes, ReactNode } from 'react';
|
||||
import type { ComponentProps } from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom';
|
||||
import { ArrowDown } from 'lucide-react';
|
||||
@@ -25,41 +25,6 @@ export const ConversationContent = ({ className, ...props }: ConversationContent
|
||||
/>
|
||||
);
|
||||
|
||||
export interface ConversationEmptyStateProps extends HTMLAttributes<HTMLDivElement> {
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
export const ConversationEmptyState = ({
|
||||
className,
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
children,
|
||||
...props
|
||||
}: ConversationEmptyStateProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-full flex-col items-center justify-center gap-3 p-8 text-center',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
{icon && <div className="text-muted-foreground">{icon}</div>}
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-medium text-sm">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const ConversationScrollButton = ({ className, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
|
||||
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
|
||||
|
||||
|
||||
@@ -8,8 +8,6 @@
|
||||
import { ArrowUp, Square, X } from 'lucide-react';
|
||||
import type {
|
||||
ComponentProps,
|
||||
ComponentPropsWithoutRef,
|
||||
ElementRef,
|
||||
FormEvent,
|
||||
HTMLAttributes,
|
||||
KeyboardEvent,
|
||||
@@ -17,13 +15,6 @@ import type {
|
||||
} from 'react';
|
||||
import { forwardRef, useCallback, useRef } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../ui/select';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
|
||||
import {
|
||||
InputGroup,
|
||||
@@ -254,30 +245,3 @@ export const PromptInputSubmit = forwardRef<HTMLButtonElement, PromptInputSubmit
|
||||
);
|
||||
PromptInputSubmit.displayName = 'PromptInputSubmit';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PromptInputSelect (thin wrappers around the project's Select component)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const PromptInputSelect = Select;
|
||||
|
||||
export const PromptInputSelectTrigger = forwardRef<
|
||||
ElementRef<typeof SelectTrigger>,
|
||||
ComponentPropsWithoutRef<typeof SelectTrigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-7 min-w-0 w-auto gap-1 border-none bg-transparent px-2 text-[11px]',
|
||||
'text-muted-foreground/40 hover:text-muted-foreground/70',
|
||||
'focus:ring-0 focus:ring-offset-0',
|
||||
'[&>svg]:h-3 [&>svg]:w-3 [&>svg]:opacity-40',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
PromptInputSelectTrigger.displayName = 'PromptInputSelectTrigger';
|
||||
|
||||
export const PromptInputSelectContent = SelectContent;
|
||||
export const PromptInputSelectItem = SelectItem;
|
||||
export const PromptInputSelectValue = SelectValue;
|
||||
|
||||
@@ -75,17 +75,18 @@ export const ToolCall = ({
|
||||
: approvalStatus === 'denied'
|
||||
? 'border-red-500/20 bg-red-500/[0.03]'
|
||||
: 'border-border/25 bg-muted/10';
|
||||
const statusIconClass = 'shrink-0';
|
||||
|
||||
const statusIcon = approvalStatus === 'pending' ? (
|
||||
<ShieldAlert size={12} className="text-yellow-500/70 shrink-0" />
|
||||
<ShieldAlert size={12} className={cn('text-yellow-500/70', statusIconClass)} />
|
||||
) : isLoading ? (
|
||||
<Loader2 size={12} className="animate-spin text-blue-400/70" />
|
||||
<Loader2 size={12} className={cn('animate-spin text-blue-400/70', statusIconClass)} />
|
||||
) : isInterrupted ? (
|
||||
<Slash size={12} className="text-muted-foreground/55" />
|
||||
<Slash size={12} className={cn('text-muted-foreground/55', statusIconClass)} />
|
||||
) : isError ? (
|
||||
<XCircle size={12} className="text-red-400/70" />
|
||||
<XCircle size={12} className={cn('text-red-400/70', statusIconClass)} />
|
||||
) : result !== undefined ? (
|
||||
<CheckCircle2 size={12} className="text-green-400/70" />
|
||||
<CheckCircle2 size={12} className={cn('text-green-400/70', statusIconClass)} />
|
||||
) : null;
|
||||
|
||||
return (
|
||||
@@ -105,7 +106,13 @@ export const ToolCall = ({
|
||||
? <ChevronDown size={12} className="text-muted-foreground/40 shrink-0" />
|
||||
: <ChevronRight size={12} className="text-muted-foreground/40 shrink-0" />
|
||||
}
|
||||
<span className="font-mono text-muted-foreground/70 truncate">{name}</span>
|
||||
{name === 'terminal_execute' && args?.command ? (
|
||||
<span className="font-mono text-muted-foreground/70 truncate" title={String(args.command)}>
|
||||
<span className="text-muted-foreground/40">$ </span>{String(args.command)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-mono text-muted-foreground/70 truncate">{name}</span>
|
||||
)}
|
||||
<span className="flex-1" />
|
||||
{/* Approval badge for resolved approvals */}
|
||||
{approvalStatus === 'approved' && (
|
||||
|
||||
@@ -154,13 +154,6 @@ function getAgentIconKey(agent: AgentLike | 'add-more'): AgentIconKey {
|
||||
return 'terminal';
|
||||
}
|
||||
|
||||
export function getAgentCommandLabel(agent: AgentLike): string | undefined {
|
||||
if (agent.type === 'builtin') {
|
||||
return 'Built-in terminal assistant';
|
||||
}
|
||||
return agent.command ? `CLI: ${agent.command}` : 'External CLI agent';
|
||||
}
|
||||
|
||||
export const AgentIconBadge: React.FC<{
|
||||
agent: AgentLike | 'add-more';
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
@@ -187,18 +180,27 @@ export const AgentIconBadge: React.FC<{
|
||||
|
||||
if (variant === 'plain') {
|
||||
return (
|
||||
<img
|
||||
src={visual.src}
|
||||
alt=""
|
||||
<div
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
className={cn('shrink-0', imageSize, visual.imageClassName, className)}
|
||||
className={cn('shrink-0', imageSize, className)}
|
||||
style={{
|
||||
maskImage: `url(${visual.src})`,
|
||||
WebkitMaskImage: `url(${visual.src})`,
|
||||
maskSize: 'contain',
|
||||
WebkitMaskSize: 'contain',
|
||||
maskRepeat: 'no-repeat',
|
||||
WebkitMaskRepeat: 'no-repeat',
|
||||
maskPosition: 'center',
|
||||
WebkitMaskPosition: 'center',
|
||||
backgroundColor: 'currentColor',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-agent-badge=""
|
||||
className={cn(
|
||||
'flex shrink-0 items-center justify-center overflow-hidden border',
|
||||
badgeSize,
|
||||
|
||||
@@ -208,7 +208,7 @@ const AgentSelector: React.FC<AgentSelectorProps> = ({
|
||||
<DropdownContent
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="w-[288px] rounded-2xl border border-border/50 bg-popover p-0 text-foreground shadow-lg supports-[backdrop-filter]:backdrop-blur-xl"
|
||||
className="w-[288px] overflow-hidden rounded-2xl border border-border/50 bg-popover p-0 text-foreground shadow-lg supports-[backdrop-filter]:backdrop-blur-xl"
|
||||
>
|
||||
{BUILTIN_AGENTS.map((agent) => (
|
||||
<AgentMenuRow
|
||||
|
||||
@@ -144,12 +144,14 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
.flatMap((m) => m.toolResults?.map((tr) => tr.toolCallId) ?? []),
|
||||
);
|
||||
|
||||
// Build a map from toolCallId → toolName for display
|
||||
// Build maps from toolCallId → toolName / toolArgs for display
|
||||
const toolCallNames = new Map<string, string>();
|
||||
const toolCallArgs = new Map<string, Record<string, unknown>>();
|
||||
for (const m of visibleMessages) {
|
||||
if (m.role === 'assistant' && m.toolCalls) {
|
||||
for (const tc of m.toolCalls) {
|
||||
toolCallNames.set(tc.id, tc.name);
|
||||
if (tc.arguments) toolCallArgs.set(tc.id, tc.arguments);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,6 +180,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
<ToolCall
|
||||
key={tr.toolCallId}
|
||||
name={toolCallNames.get(tr.toolCallId) || tr.toolCallId}
|
||||
args={toolCallArgs.get(tr.toolCallId)}
|
||||
result={tr.content}
|
||||
isError={tr.isError}
|
||||
/>
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
/**
|
||||
* ExecutionPlan - Renders a multi-step execution plan for AI agent tasks.
|
||||
*
|
||||
* Shows a numbered list of steps with status indicators, host badges,
|
||||
* optional command previews, and action buttons.
|
||||
*/
|
||||
|
||||
import {
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Loader2,
|
||||
SkipForward,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Types
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
interface ExecutionPlanStep {
|
||||
description: string;
|
||||
host?: string;
|
||||
command?: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
|
||||
}
|
||||
|
||||
interface ExecutionPlanProps {
|
||||
steps: ExecutionPlanStep[];
|
||||
onApprove: () => void;
|
||||
onModify: () => void;
|
||||
onReject: () => void;
|
||||
isExecuting: boolean;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Status icon mapping
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function StepStatusIcon({
|
||||
status,
|
||||
}: {
|
||||
status: ExecutionPlanStep['status'];
|
||||
}) {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return <Circle size={16} className="text-muted-foreground" />;
|
||||
case 'running':
|
||||
return (
|
||||
<Loader2 size={16} className="text-blue-500 animate-spin" />
|
||||
);
|
||||
case 'completed':
|
||||
return <CheckCircle2 size={16} className="text-green-500" />;
|
||||
case 'failed':
|
||||
return <XCircle size={16} className="text-destructive" />;
|
||||
case 'skipped':
|
||||
return (
|
||||
<SkipForward size={16} className="text-muted-foreground/60" />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Component
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const ExecutionPlan: React.FC<ExecutionPlanProps> = ({
|
||||
steps,
|
||||
onApprove,
|
||||
onModify,
|
||||
onReject,
|
||||
isExecuting,
|
||||
}) => {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-muted/30 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-3 py-2 border-b border-border/60 bg-muted/50">
|
||||
<span className="text-sm font-medium">
|
||||
Execution Plan ({steps.length} step{steps.length !== 1 ? 's' : ''})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Steps list */}
|
||||
<div className="divide-y divide-border/30">
|
||||
{steps.map((step, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'flex items-start gap-3 px-3 py-2.5 transition-colors',
|
||||
step.status === 'running' && 'bg-blue-500/5',
|
||||
step.status === 'completed' && 'bg-green-500/5',
|
||||
step.status === 'failed' && 'bg-destructive/5',
|
||||
step.status === 'skipped' && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
{/* Step number + status icon */}
|
||||
<div className="flex items-center gap-2 shrink-0 pt-0.5">
|
||||
<span className="text-xs text-muted-foreground font-mono w-4 text-right">
|
||||
{index + 1}
|
||||
</span>
|
||||
<StepStatusIcon status={step.status} />
|
||||
</div>
|
||||
|
||||
{/* Step content */}
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm',
|
||||
step.status === 'skipped' && 'line-through',
|
||||
)}
|
||||
>
|
||||
{step.description}
|
||||
</span>
|
||||
{step.host && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
{step.host}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{step.command && (
|
||||
<code className="block text-xs font-mono bg-muted/80 px-2 py-1 rounded text-muted-foreground truncate">
|
||||
{step.command}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="px-3 py-2.5 border-t border-border/60 flex items-center justify-end gap-2">
|
||||
{isExecuting ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={onReject}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={onReject}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onModify}>
|
||||
Modify Plan
|
||||
</Button>
|
||||
<Button size="sm" onClick={onApprove}>
|
||||
Approve
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ExecutionPlan.displayName = 'ExecutionPlan';
|
||||
|
||||
export default ExecutionPlan;
|
||||
export { ExecutionPlan };
|
||||
export type { ExecutionPlanProps, ExecutionPlanStep };
|
||||
@@ -1,200 +0,0 @@
|
||||
/**
|
||||
* PermissionDialog - Modal for AI agent tool call permission requests.
|
||||
*
|
||||
* Shown when the agent needs user approval to execute a tool call.
|
||||
* Displays tool name, arguments, recommendation, and approve/reject actions.
|
||||
*/
|
||||
|
||||
import { ShieldAlert } from 'lucide-react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Button } from '../ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '../ui/dialog';
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Types
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
interface PermissionDialogProps {
|
||||
open: boolean;
|
||||
toolCall: { name: string; arguments: Record<string, unknown> } | null;
|
||||
recommendation: 'allow' | 'confirm' | 'deny';
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Component
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const PermissionDialog: React.FC<PermissionDialogProps> = ({
|
||||
open,
|
||||
toolCall,
|
||||
recommendation,
|
||||
onApprove,
|
||||
onReject,
|
||||
onDismiss,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isDenied = recommendation === 'deny';
|
||||
|
||||
// Keyboard shortcuts: Enter to approve, Escape to reject
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !isDenied) {
|
||||
e.preventDefault();
|
||||
onApprove();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onReject();
|
||||
}
|
||||
},
|
||||
[isDenied, onApprove, onReject],
|
||||
);
|
||||
|
||||
// Format arguments as readable code block content
|
||||
let formattedArgs = '';
|
||||
if (toolCall) {
|
||||
try {
|
||||
formattedArgs = JSON.stringify(toolCall.arguments, null, 2);
|
||||
} catch {
|
||||
formattedArgs = String(toolCall.arguments);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract host/session info from arguments if present
|
||||
const sessionId =
|
||||
toolCall?.arguments?.sessionId as string | undefined;
|
||||
const sessionIds =
|
||||
toolCall?.arguments?.sessionIds as string[] | undefined;
|
||||
|
||||
const recommendationBadge = () => {
|
||||
switch (recommendation) {
|
||||
case 'allow':
|
||||
return (
|
||||
<Badge className="bg-green-600/20 text-green-400 border-green-600/30">
|
||||
{t('ai.chat.recommendAllow')}
|
||||
</Badge>
|
||||
);
|
||||
case 'confirm':
|
||||
return (
|
||||
<Badge className="bg-yellow-600/20 text-yellow-400 border-yellow-600/30">
|
||||
{t('ai.chat.recommendConfirm')}
|
||||
</Badge>
|
||||
);
|
||||
case 'deny':
|
||||
return <Badge variant="destructive">{t('ai.chat.recommendDeny')}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onDismiss()}>
|
||||
<DialogContent hideCloseButton onKeyDown={handleKeyDown}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<ShieldAlert
|
||||
size={20}
|
||||
className={cn(
|
||||
isDenied ? 'text-destructive' : 'text-yellow-500',
|
||||
)}
|
||||
/>
|
||||
{t('ai.chat.permissionRequired')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('ai.chat.permissionDescription')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{toolCall && (
|
||||
<div className="space-y-3">
|
||||
{/* Tool name and recommendation */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">{t('ai.chat.toolLabel')}:</span>
|
||||
<code className="text-sm font-mono bg-muted px-1.5 py-0.5 rounded">
|
||||
{toolCall.name}
|
||||
</code>
|
||||
</div>
|
||||
{recommendationBadge()}
|
||||
</div>
|
||||
|
||||
{/* Target session(s) */}
|
||||
{(sessionId || sessionIds) && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">{t('ai.chat.targetLabel')}:</span>
|
||||
{sessionId && (
|
||||
<code className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded">
|
||||
{sessionId}
|
||||
</code>
|
||||
)}
|
||||
{sessionIds && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{sessionIds.map((id) => (
|
||||
<code
|
||||
key={id}
|
||||
className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded"
|
||||
>
|
||||
{id}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Arguments code block */}
|
||||
<div className="rounded-md border border-border bg-muted/50 p-3 max-h-48 overflow-auto">
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap break-all text-foreground">
|
||||
{formattedArgs}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Deny warning */}
|
||||
{isDenied && (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3">
|
||||
<p className="text-sm text-destructive">
|
||||
{t('ai.chat.commandBlocked')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{isDenied ? (
|
||||
<Button variant="destructive" onClick={onReject} className="w-full">
|
||||
{t('ai.chat.reject')}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onReject}
|
||||
className="border-destructive/30 text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
{t('ai.chat.reject')}
|
||||
</Button>
|
||||
<Button onClick={onApprove}>{t('ai.chat.approve')}</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
PermissionDialog.displayName = 'PermissionDialog';
|
||||
|
||||
export default PermissionDialog;
|
||||
export { PermissionDialog };
|
||||
export type { PermissionDialogProps };
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
ChatMessage,
|
||||
ChatMessageAttachment,
|
||||
ExternalAgentConfig,
|
||||
ProviderAdvancedParams,
|
||||
ProviderConfig,
|
||||
WebSearchConfig,
|
||||
} from '../../../infrastructure/ai/types';
|
||||
@@ -186,6 +187,7 @@ export interface UseAIChatStreamingReturn {
|
||||
sdkMessages: Array<ModelMessage>,
|
||||
signal: AbortSignal,
|
||||
currentAssistantMsgId: string,
|
||||
advancedParams?: ProviderAdvancedParams,
|
||||
) => Promise<void>;
|
||||
/** Send a message to the Catty agent (built-in). */
|
||||
sendToCattyAgent: (
|
||||
@@ -320,6 +322,7 @@ export function useAIChatStreaming({
|
||||
sdkMessages: Array<ModelMessage>,
|
||||
signal: AbortSignal,
|
||||
currentAssistantMsgId: string,
|
||||
advancedParams?: ProviderAdvancedParams,
|
||||
): Promise<void> => {
|
||||
const result = streamText({
|
||||
model,
|
||||
@@ -328,6 +331,11 @@ export function useAIChatStreaming({
|
||||
tools,
|
||||
stopWhen: stepCountIs(maxIterations),
|
||||
abortSignal: signal,
|
||||
...(advancedParams?.maxTokens != null && { maxOutputTokens: advancedParams.maxTokens }),
|
||||
...(advancedParams?.temperature != null && { temperature: advancedParams.temperature }),
|
||||
...(advancedParams?.topP != null && { topP: advancedParams.topP }),
|
||||
...(advancedParams?.frequencyPenalty != null && { frequencyPenalty: advancedParams.frequencyPenalty }),
|
||||
...(advancedParams?.presencePenalty != null && { presencePenalty: advancedParams.presencePenalty }),
|
||||
});
|
||||
|
||||
// Track the current assistant message ID so updates target the correct message
|
||||
@@ -804,7 +812,7 @@ export function useAIChatStreaming({
|
||||
sdkMessages.push({ role: 'user', content: trimmed });
|
||||
}
|
||||
|
||||
await processCattyStream(sessionId, model, systemPrompt, tools, sdkMessages, abortController.signal, assistantMsgId);
|
||||
await processCattyStream(sessionId, model, systemPrompt, tools, sdkMessages, abortController.signal, assistantMsgId, context.activeProvider?.advancedParams);
|
||||
} catch (err) {
|
||||
console.error('[Catty] streamText error:', err);
|
||||
reportStreamError(sessionId, abortController.signal, err);
|
||||
|
||||
@@ -25,6 +25,8 @@ export default function SettingsAppearanceTab(props: {
|
||||
setUiLanguage: (language: string) => void;
|
||||
customCSS: string;
|
||||
setCustomCSS: (css: string) => void;
|
||||
isImmersive?: boolean;
|
||||
onToggleImmersive?: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const availableUIFonts = useAvailableUIFonts();
|
||||
@@ -45,6 +47,8 @@ export default function SettingsAppearanceTab(props: {
|
||||
setUiLanguage,
|
||||
customCSS,
|
||||
setCustomCSS,
|
||||
isImmersive,
|
||||
onToggleImmersive,
|
||||
} = props;
|
||||
|
||||
const getHslStyle = useCallback((hsl: string) => ({ backgroundColor: `hsl(${hsl})` }), []);
|
||||
@@ -254,6 +258,19 @@ export default function SettingsAppearanceTab(props: {
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<SectionHeader title={t("settings.appearance.immersiveMode")} />
|
||||
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
|
||||
<SettingRow
|
||||
label={t("settings.appearance.immersiveMode")}
|
||||
description={t("settings.appearance.immersiveMode.desc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={!!isImmersive}
|
||||
onChange={() => onToggleImmersive?.()}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<SectionHeader title={t("settings.appearance.customCss")} />
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useCallback } from "react";
|
||||
import type { PortForwardingRule } from "../../../domain/models";
|
||||
import type { SyncPayload } from "../../../domain/sync";
|
||||
import { buildSyncPayload, applySyncPayload } from "../../../domain/syncPayload";
|
||||
import type { SyncableVaultData } from "../../../domain/syncPayload";
|
||||
import { buildSyncPayload, applySyncPayload } from "../../../application/syncPayload";
|
||||
import type { SyncableVaultData } from "../../../application/syncPayload";
|
||||
import { STORAGE_KEY_PORT_FORWARDING } from "../../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
import { getEffectiveKnownHosts } from "../../../infrastructure/syncHelpers";
|
||||
|
||||
@@ -89,6 +89,7 @@ interface SettingsSystemTabProps {
|
||||
checkNow: () => Promise<unknown>;
|
||||
installUpdate: () => void;
|
||||
openReleasePage: () => void;
|
||||
startDownload: () => void;
|
||||
}
|
||||
|
||||
const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
@@ -111,6 +112,7 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
checkNow,
|
||||
installUpdate,
|
||||
openReleasePage,
|
||||
startDownload,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isMac = typeof navigator !== "undefined" && /Mac/i.test(navigator.platform);
|
||||
@@ -463,7 +465,16 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Open releases — shown when update found on unsupported platform, or on check error */}
|
||||
{/* Download button — shown when update found and no download in progress */}
|
||||
{updateState.autoDownloadStatus === 'idle' &&
|
||||
updateState.manualCheckStatus === 'available' && (
|
||||
<Button variant="outline" size="sm" onClick={startDownload}>
|
||||
<Download size={14} className="mr-1.5" />
|
||||
{t('update.downloadNow')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Open releases — fallback for unsupported platforms or check errors */}
|
||||
{updateState.autoDownloadStatus === 'idle' &&
|
||||
(updateState.manualCheckStatus === 'available' || updateState.manualCheckStatus === 'error' || (updateState.manualCheckStatus === 'idle' && updateState.hasUpdate)) && (
|
||||
<Button variant="ghost" size="sm" onClick={openReleasePage}>
|
||||
|
||||
@@ -114,6 +114,20 @@ export default function SettingsTerminalTab(props: {
|
||||
|| TERMINAL_THEMES[0];
|
||||
}, [terminalThemeId, customThemes]);
|
||||
|
||||
const handleAutocompleteGhostTextChange = useCallback((enabled: boolean) => {
|
||||
updateTerminalSetting("autocompleteGhostText", enabled);
|
||||
if (enabled) {
|
||||
updateTerminalSetting("autocompletePopupMenu", false);
|
||||
}
|
||||
}, [updateTerminalSetting]);
|
||||
|
||||
const handleAutocompletePopupMenuChange = useCallback((enabled: boolean) => {
|
||||
updateTerminalSetting("autocompletePopupMenu", enabled);
|
||||
if (enabled) {
|
||||
updateTerminalSetting("autocompleteGhostText", false);
|
||||
}
|
||||
}, [updateTerminalSetting]);
|
||||
|
||||
// Import .itermcolors file
|
||||
const importFileRef = useRef<HTMLInputElement>(null);
|
||||
const handleImportItermcolors = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -851,6 +865,39 @@ export default function SettingsTerminalTab(props: {
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
{/* Autocomplete */}
|
||||
<SectionHeader title={t("settings.terminal.section.autocomplete")} />
|
||||
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
|
||||
<SettingRow
|
||||
label={t("settings.terminal.autocomplete.enabled")}
|
||||
description={t("settings.terminal.autocomplete.enabled.desc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={terminalSettings.autocompleteEnabled}
|
||||
onChange={(v) => updateTerminalSetting("autocompleteEnabled", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("settings.terminal.autocomplete.ghostText")}
|
||||
description={t("settings.terminal.autocomplete.ghostText.desc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={terminalSettings.autocompleteGhostText}
|
||||
onChange={handleAutocompleteGhostTextChange}
|
||||
disabled={!terminalSettings.autocompleteEnabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("settings.terminal.autocomplete.popupMenu")}
|
||||
description={t("settings.terminal.autocomplete.popupMenu.desc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={terminalSettings.autocompletePopupMenu}
|
||||
onChange={handleAutocompletePopupMenuChange}
|
||||
disabled={!terminalSettings.autocompleteEnabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</SettingsTabContent>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { Check, Eye, EyeOff } from "lucide-react";
|
||||
import type { ProviderConfig } from "../../../../infrastructure/ai/types";
|
||||
import { Check, ChevronDown, ChevronRight, Eye, EyeOff } from "lucide-react";
|
||||
import type { ProviderConfig, ProviderAdvancedParams } from "../../../../infrastructure/ai/types";
|
||||
import { PROVIDER_PRESETS } from "../../../../infrastructure/ai/types";
|
||||
import { encryptField, decryptField } from "../../../../infrastructure/persistence/secureFieldAdapter";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
@@ -20,10 +20,12 @@ export const ProviderConfigForm: React.FC<{
|
||||
baseURL: provider.baseURL ?? PROVIDER_PRESETS[provider.providerId]?.defaultBaseURL ?? "",
|
||||
defaultModel: provider.defaultModel ?? "",
|
||||
skipTLSVerify: provider.skipTLSVerify ?? false,
|
||||
advancedParams: provider.advancedParams ?? {},
|
||||
});
|
||||
const isCustom = provider.providerId === "custom";
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [isDecrypting, setIsDecrypting] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
const preset = PROVIDER_PRESETS[provider.providerId];
|
||||
|
||||
@@ -43,11 +45,37 @@ export const ProviderConfigForm: React.FC<{
|
||||
}
|
||||
}, [provider.apiKey]);
|
||||
|
||||
const [advancedParamRaw, setAdvancedParamRaw] = useState<Record<string, string>>({});
|
||||
const handleAdvancedParam = useCallback((key: keyof ProviderAdvancedParams, raw: string) => {
|
||||
setAdvancedParamRaw((prev) => ({ ...prev, [key]: raw }));
|
||||
setForm((prev) => {
|
||||
const next = { ...prev.advancedParams };
|
||||
if (raw.trim() === "" || raw.trim() === "-") {
|
||||
delete next[key];
|
||||
} else {
|
||||
const num = Number(raw);
|
||||
if (!Number.isNaN(num)) {
|
||||
next[key] = num;
|
||||
}
|
||||
}
|
||||
return { ...prev, advancedParams: next };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const cleanedParams: ProviderAdvancedParams = {};
|
||||
const ap = form.advancedParams;
|
||||
if (ap.maxTokens != null && Number.isFinite(ap.maxTokens) && ap.maxTokens > 0) cleanedParams.maxTokens = Math.max(1, Math.round(ap.maxTokens));
|
||||
if (ap.temperature != null) cleanedParams.temperature = Math.min(2, Math.max(0, ap.temperature));
|
||||
if (ap.topP != null) cleanedParams.topP = Math.min(1, Math.max(0, ap.topP));
|
||||
if (ap.frequencyPenalty != null) cleanedParams.frequencyPenalty = Math.min(2, Math.max(-2, ap.frequencyPenalty));
|
||||
if (ap.presencePenalty != null) cleanedParams.presencePenalty = Math.min(2, Math.max(-2, ap.presencePenalty));
|
||||
|
||||
const updates: Partial<ProviderConfig> = {
|
||||
baseURL: form.baseURL || undefined,
|
||||
defaultModel: form.defaultModel || undefined,
|
||||
skipTLSVerify: form.skipTLSVerify || undefined,
|
||||
advancedParams: Object.keys(cleanedParams).length > 0 ? cleanedParams : undefined,
|
||||
...(isCustom && form.name.trim() ? { name: form.name.trim() } : {}),
|
||||
};
|
||||
|
||||
@@ -137,6 +165,92 @@ export const ProviderConfigForm: React.FC<{
|
||||
<span className="text-xs text-muted-foreground">{t('ai.providers.skipTLSVerify')}</span>
|
||||
</label>
|
||||
|
||||
{/* Advanced Parameters */}
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showAdvanced ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
{t('ai.providers.advancedParams')}
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="space-y-2.5 pl-1 border-l-2 border-border/40 ml-1">
|
||||
<p className="text-[11px] text-muted-foreground/70 pl-3">{t('ai.providers.advancedParams.hint')}</p>
|
||||
{/* max_tokens */}
|
||||
<div className="space-y-1 pl-3">
|
||||
<label className="text-xs text-muted-foreground">max_tokens</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={advancedParamRaw.maxTokens ?? (form.advancedParams.maxTokens != null ? String(form.advancedParams.maxTokens) : "")}
|
||||
onChange={(e) => handleAdvancedParam("maxTokens", e.target.value)}
|
||||
placeholder={t('ai.providers.advancedParams.maxTokens.placeholder')}
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
{/* temperature */}
|
||||
<div className="space-y-1 pl-3">
|
||||
<label className="text-xs text-muted-foreground">temperature <span className="text-muted-foreground/50">(0–2)</span></label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
value={advancedParamRaw.temperature ?? (form.advancedParams.temperature != null ? String(form.advancedParams.temperature) : "")}
|
||||
onChange={(e) => handleAdvancedParam("temperature", e.target.value)}
|
||||
placeholder={t('ai.providers.advancedParams.default')}
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
{/* top_p */}
|
||||
<div className="space-y-1 pl-3">
|
||||
<label className="text-xs text-muted-foreground">top_p <span className="text-muted-foreground/50">(0–1)</span></label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={advancedParamRaw.topP ?? (form.advancedParams.topP != null ? String(form.advancedParams.topP) : "")}
|
||||
onChange={(e) => handleAdvancedParam("topP", e.target.value)}
|
||||
placeholder={t('ai.providers.advancedParams.default')}
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
{/* frequency_penalty */}
|
||||
<div className="space-y-1 pl-3">
|
||||
<label className="text-xs text-muted-foreground">frequency_penalty <span className="text-muted-foreground/50">(-2–2)</span></label>
|
||||
<input
|
||||
type="number"
|
||||
min={-2}
|
||||
max={2}
|
||||
step={0.1}
|
||||
value={advancedParamRaw.frequencyPenalty ?? (form.advancedParams.frequencyPenalty != null ? String(form.advancedParams.frequencyPenalty) : "")}
|
||||
onChange={(e) => handleAdvancedParam("frequencyPenalty", e.target.value)}
|
||||
placeholder={t('ai.providers.advancedParams.default')}
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
{/* presence_penalty */}
|
||||
<div className="space-y-1 pl-3">
|
||||
<label className="text-xs text-muted-foreground">presence_penalty <span className="text-muted-foreground/50">(-2–2)</span></label>
|
||||
<input
|
||||
type="number"
|
||||
min={-2}
|
||||
max={2}
|
||||
step={0.1}
|
||||
value={advancedParamRaw.presencePenalty ?? (form.advancedParams.presencePenalty != null ? String(form.advancedParams.presencePenalty) : "")}
|
||||
onChange={(e) => handleAdvancedParam("presencePenalty", e.target.value)}
|
||||
placeholder={t('ai.providers.advancedParams.default')}
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Button variant="default" size="sm" onClick={() => void handleSave()}>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import type {
|
||||
AIProviderId,
|
||||
ExternalAgentConfig,
|
||||
ProviderAdvancedParams,
|
||||
} from "../../../../infrastructure/ai/types";
|
||||
|
||||
export type CodexIntegrationState =
|
||||
@@ -42,6 +43,7 @@ export interface ProviderFormState {
|
||||
baseURL: string;
|
||||
defaultModel: string;
|
||||
skipTLSVerify: boolean;
|
||||
advancedParams: ProviderAdvancedParams;
|
||||
}
|
||||
|
||||
export interface FetchedModel {
|
||||
|
||||
@@ -88,7 +88,7 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
|
||||
<Link size={8} className="absolute -bottom-0.5 -right-0.5 text-muted-foreground" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
<span className={cn("truncate", entry.type === 'symlink' && "italic pr-1")}>
|
||||
<span className={cn("truncate", entry.type === 'symlink' && "italic pr-1")} title={entry.name}>
|
||||
{entry.name}
|
||||
{entry.type === 'symlink' && <span className="sr-only"> (symbolic link)</span>}
|
||||
</span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { AlertCircle, ArrowDown, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderPlus, Loader2, Pencil, RefreshCw, Shield, Trash2 } from "lucide-react";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { ArrowDown, ChevronDown, ClipboardCopy, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderPlus, Loader2, Pencil, RefreshCw, Shield, Trash2, Unplug } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
ContextMenu,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ContextMenuTrigger,
|
||||
} from "../ui/context-menu";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { joinPath } from "../../application/state/sftp/utils";
|
||||
import type { SftpFileEntry } from "../../types";
|
||||
import type { SftpPane } from "../../application/state/sftp/types";
|
||||
import type { ColumnWidths, SortField, SortOrder } from "./utils";
|
||||
@@ -58,6 +59,46 @@ interface SftpPaneFileListProps {
|
||||
visibleRows: { entry: SftpFileEntry; index: number; top: number }[];
|
||||
}
|
||||
|
||||
const SftpErrorWithLogs: React.FC<{
|
||||
error: string;
|
||||
connectionLogs: string[];
|
||||
onRetry: () => void;
|
||||
t: (key: string) => string;
|
||||
}> = ({ error, connectionLogs, onRetry, t }) => {
|
||||
const [showLogs, setShowLogs] = useState(connectionLogs.length > 0);
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3 text-muted-foreground">
|
||||
<Unplug size={28} className="text-destructive/70" />
|
||||
<span className="text-xs text-center px-6 max-w-xs leading-relaxed">{t(error)}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={onRetry}>
|
||||
{t("sftp.retry")}
|
||||
</Button>
|
||||
{connectionLogs.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-muted-foreground"
|
||||
onClick={() => setShowLogs(!showLogs)}
|
||||
>
|
||||
<ChevronDown size={14} className={`mr-1 transition-transform ${showLogs ? 'rotate-180' : ''}`} />
|
||||
{showLogs ? "Hide logs" : "Show logs"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{showLogs && connectionLogs.length > 0 && (
|
||||
<div className="w-full max-w-sm mt-1 p-2 rounded-md bg-secondary/50 border border-border/60 space-y-0.5 max-h-40 overflow-y-auto">
|
||||
{connectionLogs.map((log, i) => (
|
||||
<div key={i} className="text-[11px] text-muted-foreground truncate font-mono">
|
||||
{log}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
t,
|
||||
pane,
|
||||
@@ -178,6 +219,14 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
<Copy size={14} className="mr-2" />{" "}
|
||||
{t("sftp.context.copyToOtherPane")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(joinPath(pane.connection.currentPath, entry.name));
|
||||
}}
|
||||
>
|
||||
<ClipboardCopy size={14} className="mr-2" />{" "}
|
||||
{t("sftp.context.copyPath")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => openRenameDialog(entry.name)}>
|
||||
<Pencil size={14} className="mr-2" /> {t("common.rename")}
|
||||
@@ -340,17 +389,25 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
onScroll={handleFileListScroll}
|
||||
>
|
||||
{pane.loading && sortedDisplayFiles.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="flex flex-col items-center justify-center h-full gap-2">
|
||||
<Loader2 size={24} className="animate-spin text-muted-foreground" />
|
||||
{pane.connectionLogs.length > 0 && (
|
||||
<div className="w-full max-w-sm mt-2 space-y-0.5 px-4">
|
||||
{pane.connectionLogs.map((log, i) => (
|
||||
<div key={i} className="text-[11px] text-muted-foreground truncate">
|
||||
{log}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : pane.error && !pane.reconnecting ? (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-2 text-destructive">
|
||||
<AlertCircle size={24} />
|
||||
<span className="text-sm">{t(pane.error)}</span>
|
||||
<Button variant="outline" size="sm" onClick={onRefresh}>
|
||||
{t("sftp.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
<SftpErrorWithLogs
|
||||
error={pane.error}
|
||||
connectionLogs={pane.connectionLogs}
|
||||
onRetry={onRefresh}
|
||||
t={t}
|
||||
/>
|
||||
) : sortedDisplayFiles.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||
<Folder size={32} className="mb-2 opacity-50" />
|
||||
@@ -410,10 +467,19 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Loading overlay - covers entire pane when navigating directories */}
|
||||
{/* Loading overlay - covers entire pane when navigating or reconnecting */}
|
||||
{pane.loading && sortedDisplayFiles.length > 0 && !pane.reconnecting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/40 backdrop-blur-[1px] z-10">
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-background/40 backdrop-blur-[1px] z-10">
|
||||
<Loader2 size={24} className="animate-spin text-muted-foreground" />
|
||||
{pane.connectionLogs.length > 0 && (
|
||||
<div className="w-full max-w-sm mt-2 space-y-0.5 px-4">
|
||||
{pane.connectionLogs.map((log, i) => (
|
||||
<div key={i} className="text-[11px] text-muted-foreground truncate">
|
||||
{log}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Bookmark, Check, Eye, EyeOff, FilePlus, Folder, FolderPlus, Home, Languages, MoreHorizontal, RefreshCw, Search, TerminalSquare, Trash2, X } from "lucide-react";
|
||||
import { Bookmark, Check, Eye, EyeOff, FilePlus, Folder, FolderPlus, Globe, Home, Languages, MoreHorizontal, RefreshCw, Search, TerminalSquare, Trash2, X } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import { Input } from "../ui/input";
|
||||
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
@@ -46,6 +46,8 @@ interface SftpPaneToolbarProps {
|
||||
bookmarks: SftpBookmark[];
|
||||
isCurrentPathBookmarked: boolean;
|
||||
onToggleBookmark: () => void;
|
||||
onAddGlobalBookmark: (path: string) => void;
|
||||
isCurrentPathGlobalBookmarked: boolean;
|
||||
onNavigateToBookmark: (path: string) => void;
|
||||
onDeleteBookmark: (id: string) => void;
|
||||
showHiddenFiles: boolean;
|
||||
@@ -92,6 +94,8 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
|
||||
bookmarks,
|
||||
isCurrentPathBookmarked,
|
||||
onToggleBookmark,
|
||||
onAddGlobalBookmark,
|
||||
isCurrentPathGlobalBookmarked,
|
||||
onNavigateToBookmark,
|
||||
onDeleteBookmark,
|
||||
showHiddenFiles,
|
||||
@@ -440,16 +444,31 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
|
||||
<TooltipContent>{isCurrentPathBookmarked ? t("sftp.bookmark.remove") : t("sftp.bookmark.add")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent className="w-64 p-0" align="start">
|
||||
<div className="p-2 border-b border-border/40">
|
||||
<div className="p-2 border-b border-border/40 flex gap-1">
|
||||
<Button
|
||||
variant={isCurrentPathBookmarked ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="w-full justify-start text-xs h-7"
|
||||
className="flex-1 justify-start text-xs h-7"
|
||||
onClick={onToggleBookmark}
|
||||
>
|
||||
<Bookmark size={12} fill={isCurrentPathBookmarked ? "currentColor" : "none"} className={cn("mr-2", isCurrentPathBookmarked && "text-yellow-500")} />
|
||||
{isCurrentPathBookmarked ? t("sftp.bookmark.remove") : t("sftp.bookmark.add")}
|
||||
</Button>
|
||||
{pane.connection?.currentPath && !isCurrentPathGlobalBookmarked && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs h-7 px-2 shrink-0"
|
||||
onClick={() => pane.connection?.currentPath && onAddGlobalBookmark(pane.connection.currentPath)}
|
||||
>
|
||||
{t("sftp.bookmark.addGlobal")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.bookmark.addGlobalTooltip")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{bookmarks.length > 0 ? (
|
||||
<div className="max-h-48 overflow-auto py-1">
|
||||
@@ -458,6 +477,9 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
|
||||
key={bm.id}
|
||||
className="flex items-center gap-1 px-2 py-1 hover:bg-secondary/60 group"
|
||||
>
|
||||
{bm.global && (
|
||||
<Globe size={10} className="shrink-0 text-primary" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 text-left text-xs truncate font-mono"
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useSftpPaneVirtualList } from "./hooks/useSftpPaneVirtualList";
|
||||
import { useSftpDialogActionHandler } from "./hooks/useSftpDialogAction";
|
||||
import { useSftpBookmarks } from "./hooks/useSftpBookmarks";
|
||||
import { useLocalSftpBookmarks } from "./hooks/useLocalSftpBookmarks";
|
||||
import { useGlobalSftpBookmarks } from "./hooks/useGlobalSftpBookmarks";
|
||||
|
||||
interface SftpPaneWrapperProps {
|
||||
side: "left" | "right";
|
||||
@@ -109,12 +110,36 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
const localBookmarks = useLocalSftpBookmarks({
|
||||
currentPath: pane.connection?.currentPath,
|
||||
});
|
||||
const {
|
||||
bookmarks,
|
||||
isCurrentPathBookmarked,
|
||||
toggleBookmark,
|
||||
deleteBookmark,
|
||||
} = pane.connection?.isLocal ? localBookmarks : remoteBookmarks;
|
||||
const globalBookmarks = useGlobalSftpBookmarks({
|
||||
currentPath: pane.connection?.currentPath,
|
||||
});
|
||||
const hostBookmarks = pane.connection?.isLocal ? localBookmarks : remoteBookmarks;
|
||||
const mergedBookmarks = useMemo(
|
||||
() => [...globalBookmarks.bookmarks.map((b) => ({ ...b, global: true as const })), ...hostBookmarks.bookmarks],
|
||||
[hostBookmarks.bookmarks, globalBookmarks.bookmarks],
|
||||
);
|
||||
const isCurrentPathBookmarked = hostBookmarks.isCurrentPathBookmarked || globalBookmarks.isCurrentPathBookmarked;
|
||||
const toggleBookmark = useCallback(() => {
|
||||
if (globalBookmarks.isCurrentPathBookmarked && !hostBookmarks.isCurrentPathBookmarked) {
|
||||
const currentPath = pane.connection?.currentPath;
|
||||
if (currentPath) {
|
||||
const bm = globalBookmarks.bookmarks.find((b) => b.path === currentPath);
|
||||
if (bm) globalBookmarks.deleteBookmark(bm.id);
|
||||
}
|
||||
} else {
|
||||
hostBookmarks.toggleBookmark();
|
||||
}
|
||||
}, [hostBookmarks, globalBookmarks, pane.connection?.currentPath]);
|
||||
const deleteBookmark = useCallback(
|
||||
(id: string) => {
|
||||
if (id.startsWith("gbm-")) {
|
||||
globalBookmarks.deleteBookmark(id);
|
||||
} else {
|
||||
hostBookmarks.deleteBookmark(id);
|
||||
}
|
||||
},
|
||||
[hostBookmarks, globalBookmarks],
|
||||
);
|
||||
|
||||
const { filteredFiles, sortedDisplayFiles } = useSftpPaneFiles({
|
||||
files: pane.files,
|
||||
@@ -329,9 +354,11 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
setShowNewFileDialog={setShowNewFileDialog}
|
||||
setShowNewFolderDialog={setShowNewFolderDialog}
|
||||
setNewFolderName={setNewFolderName}
|
||||
bookmarks={bookmarks}
|
||||
bookmarks={mergedBookmarks}
|
||||
isCurrentPathBookmarked={isCurrentPathBookmarked}
|
||||
onToggleBookmark={toggleBookmark}
|
||||
onAddGlobalBookmark={globalBookmarks.addBookmark}
|
||||
isCurrentPathGlobalBookmarked={globalBookmarks.isCurrentPathBookmarked}
|
||||
onNavigateToBookmark={callbacks.onNavigateTo}
|
||||
onDeleteBookmark={deleteBookmark}
|
||||
showHiddenFiles={pane.showHiddenFiles}
|
||||
|
||||
67
components/sftp/hooks/useGlobalSftpBookmarks.ts
Normal file
67
components/sftp/hooks/useGlobalSftpBookmarks.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
||||
import type { SftpBookmark } from "../../../domain/models";
|
||||
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
import { STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS } from "../../../infrastructure/config/storageKeys";
|
||||
|
||||
type Listener = () => void;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
let snapshot: SftpBookmark[] =
|
||||
localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS) ?? [];
|
||||
|
||||
function subscribe(listener: Listener) {
|
||||
listeners.add(listener);
|
||||
return () => { listeners.delete(listener); };
|
||||
}
|
||||
|
||||
function getSnapshot() {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function setBookmarks(next: SftpBookmark[] | ((prev: SftpBookmark[]) => SftpBookmark[])) {
|
||||
snapshot = typeof next === "function" ? next(snapshot) : next;
|
||||
localStorageAdapter.write(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS, snapshot);
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
|
||||
interface UseGlobalSftpBookmarksParams {
|
||||
currentPath: string | undefined;
|
||||
}
|
||||
|
||||
export const useGlobalSftpBookmarks = ({
|
||||
currentPath,
|
||||
}: UseGlobalSftpBookmarksParams) => {
|
||||
const bookmarks = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
|
||||
const isCurrentPathBookmarked = useMemo(
|
||||
() => !!currentPath && bookmarks.some((b) => b.path === currentPath),
|
||||
[currentPath, bookmarks],
|
||||
);
|
||||
|
||||
const addBookmark = useCallback((path: string) => {
|
||||
if (!path) return;
|
||||
if (bookmarks.some((b) => b.path === path)) return;
|
||||
const isRoot = path === "/" || /^[A-Za-z]:\\?$/.test(path);
|
||||
const label = isRoot
|
||||
? path
|
||||
: path.split(/[\\/]/).filter(Boolean).pop() || path;
|
||||
const newBookmark: SftpBookmark = {
|
||||
id: `gbm-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
path,
|
||||
label,
|
||||
global: true,
|
||||
};
|
||||
setBookmarks((prev) => [...prev, newBookmark]);
|
||||
}, [bookmarks]);
|
||||
|
||||
const deleteBookmark = useCallback((id: string) => {
|
||||
setBookmarks((prev) => prev.filter((b) => b.id !== id));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
bookmarks,
|
||||
isCurrentPathBookmarked,
|
||||
addBookmark,
|
||||
deleteBookmark,
|
||||
};
|
||||
};
|
||||
@@ -2,7 +2,7 @@
|
||||
* Terminal Authentication Dialog
|
||||
* Displays auth form with password/key selection for SSH connection
|
||||
*/
|
||||
import { AlertCircle, BadgeCheck, ChevronDown, Eye, EyeOff, Key, Lock } from 'lucide-react';
|
||||
import { BadgeCheck, ChevronDown, Eye, EyeOff, Key, Lock, Unplug } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { cn } from '../../lib/utils';
|
||||
@@ -80,38 +80,42 @@ export const TerminalAuthDialog: React.FC<TerminalAuthDialogProps> = ({
|
||||
return (
|
||||
<>
|
||||
{/* Auth method tabs */}
|
||||
<div className="flex gap-1 p-1 bg-secondary/80 rounded-lg border border-border/60">
|
||||
<div className="flex gap-1 p-1 bg-secondary/65 rounded-xl border border-border/50">
|
||||
<button
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-2 py-2 text-sm font-medium rounded-md transition-all",
|
||||
"flex-1 flex items-center justify-center gap-1.5 py-1.5 text-xs font-medium rounded-lg transition-all",
|
||||
authMethod === 'password'
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-secondary"
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-background/40"
|
||||
)}
|
||||
onClick={() => setAuthMethod('password')}
|
||||
>
|
||||
<Lock size={14} />
|
||||
<Lock size={13} />
|
||||
{t("terminal.auth.password")}
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-2 py-2 text-sm font-medium rounded-md transition-all",
|
||||
"flex-1 flex items-center justify-center gap-1.5 py-1.5 text-xs font-medium rounded-lg transition-all",
|
||||
authMethod === 'key' || authMethod === 'certificate'
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-secondary"
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-background/40"
|
||||
)}
|
||||
onClick={() => setAuthMethod('key')}
|
||||
>
|
||||
<Key size={14} />
|
||||
<Key size={13} />
|
||||
{t("terminal.auth.sshKey")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Auth retry error message */}
|
||||
{authRetryMessage && (
|
||||
<div className="p-3 rounded-lg bg-destructive/10 border border-destructive/20 text-destructive text-sm flex items-center gap-2">
|
||||
<AlertCircle size={16} />
|
||||
{authRetryMessage}
|
||||
<div className="flex items-center gap-2.5 rounded-xl border border-destructive/20 bg-destructive/7 px-3 py-2.5 text-xs text-foreground/90">
|
||||
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-destructive/12 text-destructive">
|
||||
<Unplug size={11} />
|
||||
</div>
|
||||
<div className="min-w-0 leading-4 text-destructive/95">
|
||||
{authRetryMessage}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -66,13 +66,15 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
|
||||
|
||||
const bg = themeColors?.background ?? '#0a0a0a';
|
||||
const fg = themeColors?.foreground ?? '#d4d4d4';
|
||||
const resolvedBg = 'var(--terminal-ui-bg, ' + bg + ')';
|
||||
const resolvedFg = 'var(--terminal-ui-fg, ' + fg + ')';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-shrink-0"
|
||||
style={{
|
||||
background: `linear-gradient(to top, ${bg}, color-mix(in srgb, ${fg} 4%, ${bg} 96%))`,
|
||||
borderTop: `1px solid color-mix(in srgb, ${fg} 10%, ${bg} 90%)`,
|
||||
background: `linear-gradient(to top, ${resolvedBg}, color-mix(in srgb, ${resolvedFg} 4%, ${resolvedBg} 96%))`,
|
||||
borderTop: `1px solid color-mix(in srgb, ${resolvedFg} 10%, ${resolvedBg} 90%)`,
|
||||
borderRadius: '0 0 8px 8px',
|
||||
padding: '6px 10px',
|
||||
}}
|
||||
@@ -97,24 +99,24 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
|
||||
"placeholder:opacity-40",
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: `color-mix(in srgb, ${fg} 6%, ${bg} 94%)`,
|
||||
color: fg,
|
||||
border: `1px solid color-mix(in srgb, ${fg} 25%, ${bg} 75%)`,
|
||||
backgroundColor: `color-mix(in srgb, ${resolvedFg} 6%, ${resolvedBg} 94%)`,
|
||||
color: resolvedFg,
|
||||
border: `1px solid color-mix(in srgb, ${resolvedFg} 25%, ${resolvedBg} 75%)`,
|
||||
minHeight: '28px',
|
||||
maxHeight: '120px',
|
||||
boxShadow: `inset 0 1px 3px color-mix(in srgb, ${bg} 80%, transparent)`,
|
||||
boxShadow: `inset 0 1px 3px color-mix(in srgb, ${resolvedBg} 80%, transparent)`,
|
||||
}}
|
||||
rows={1}
|
||||
placeholder={t("terminal.composeBar.placeholder")}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = `color-mix(in srgb, ${fg} 40%, ${bg} 60%)`;
|
||||
e.currentTarget.style.boxShadow = `inset 0 1px 3px color-mix(in srgb, ${bg} 80%, transparent), 0 0 0 1px color-mix(in srgb, ${fg} 8%, transparent)`;
|
||||
e.currentTarget.style.borderColor = `color-mix(in srgb, ${resolvedFg} 40%, ${resolvedBg} 60%)`;
|
||||
e.currentTarget.style.boxShadow = `inset 0 1px 3px color-mix(in srgb, ${resolvedBg} 80%, transparent), 0 0 0 1px color-mix(in srgb, ${resolvedFg} 8%, transparent)`;
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = `color-mix(in srgb, ${fg} 25%, ${bg} 75%)`;
|
||||
e.currentTarget.style.boxShadow = `inset 0 1px 3px color-mix(in srgb, ${bg} 80%, transparent)`;
|
||||
e.currentTarget.style.borderColor = `color-mix(in srgb, ${resolvedFg} 25%, ${resolvedBg} 75%)`;
|
||||
e.currentTarget.style.boxShadow = `inset 0 1px 3px color-mix(in srgb, ${resolvedBg} 80%, transparent)`;
|
||||
}}
|
||||
onCompositionStart={() => { isComposingRef.current = true; }}
|
||||
onCompositionEnd={() => { isComposingRef.current = false; }}
|
||||
@@ -125,14 +127,14 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
|
||||
<button
|
||||
className="h-7 w-7 flex items-center justify-center rounded-md transition-colors duration-150"
|
||||
style={{
|
||||
color: fg,
|
||||
background: `color-mix(in srgb, ${fg} 20%, ${bg} 80%)`,
|
||||
color: resolvedFg,
|
||||
background: `color-mix(in srgb, ${resolvedFg} 20%, ${resolvedBg} 80%)`,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = `color-mix(in srgb, ${fg} 30%, ${bg} 70%)`;
|
||||
e.currentTarget.style.background = `color-mix(in srgb, ${resolvedFg} 30%, ${resolvedBg} 70%)`;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = `color-mix(in srgb, ${fg} 20%, ${bg} 80%)`;
|
||||
e.currentTarget.style.background = `color-mix(in srgb, ${resolvedFg} 20%, ${resolvedBg} 80%)`;
|
||||
}}
|
||||
onClick={handleSend}
|
||||
title={t("terminal.composeBar.send")}
|
||||
@@ -142,16 +144,16 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
|
||||
<button
|
||||
className="h-7 w-7 flex items-center justify-center rounded-md transition-colors duration-150"
|
||||
style={{
|
||||
color: `color-mix(in srgb, ${fg} 60%, ${bg} 40%)`,
|
||||
background: `color-mix(in srgb, ${fg} 12%, ${bg} 88%)`,
|
||||
color: `color-mix(in srgb, ${resolvedFg} 60%, ${resolvedBg} 40%)`,
|
||||
background: `color-mix(in srgb, ${resolvedFg} 12%, ${resolvedBg} 88%)`,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = `color-mix(in srgb, ${fg} 22%, ${bg} 78%)`;
|
||||
e.currentTarget.style.color = fg;
|
||||
e.currentTarget.style.background = `color-mix(in srgb, ${resolvedFg} 22%, ${resolvedBg} 78%)`;
|
||||
e.currentTarget.style.color = resolvedFg;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = `color-mix(in srgb, ${fg} 12%, ${bg} 88%)`;
|
||||
e.currentTarget.style.color = `color-mix(in srgb, ${fg} 60%, ${bg} 40%)`;
|
||||
e.currentTarget.style.background = `color-mix(in srgb, ${resolvedFg} 12%, ${resolvedBg} 88%)`;
|
||||
e.currentTarget.style.color = `color-mix(in srgb, ${resolvedFg} 60%, ${resolvedBg} 40%)`;
|
||||
}}
|
||||
onClick={onClose}
|
||||
title={t("terminal.composeBar.close")}
|
||||
|
||||
@@ -84,14 +84,21 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
|
||||
"absolute inset-0 z-20 flex items-center justify-center",
|
||||
needsAuth ? "bg-black" : "bg-black/30"
|
||||
)}>
|
||||
<div className="w-[560px] max-w-[90vw] bg-background/95 border border-border/60 rounded-xl shadow-xl p-6 space-y-4">
|
||||
<div
|
||||
className="w-[480px] max-w-[88vw] rounded-xl shadow-xl p-4 space-y-3"
|
||||
style={{
|
||||
backgroundColor: 'color-mix(in srgb, var(--terminal-ui-bg, var(--background)) 95%, transparent)',
|
||||
border: '1px solid color-mix(in srgb, var(--terminal-ui-fg, var(--foreground)) 12%, var(--terminal-ui-bg, var(--background)) 88%)',
|
||||
color: 'var(--terminal-ui-fg, var(--foreground))',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} className="h-10 w-10 rounded-lg shrink-0" />
|
||||
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
||||
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} className="h-8 w-8 rounded-md shrink-0" />
|
||||
<div className="min-w-0">
|
||||
{chainProgress ? (
|
||||
<>
|
||||
<div className="text-sm font-semibold truncate">
|
||||
<div className="text-xs font-semibold truncate">
|
||||
<span className="text-muted-foreground">
|
||||
{t('terminal.connection.chainOf', {
|
||||
current: chainProgress.currentHop,
|
||||
@@ -101,14 +108,20 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
|
||||
</span>
|
||||
<span>{chainProgress.currentHostLabel}</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground font-mono truncate">
|
||||
<div
|
||||
className="text-[10px] font-mono truncate"
|
||||
style={{ color: 'color-mix(in srgb, var(--terminal-ui-fg, var(--foreground)) 58%, transparent)' }}
|
||||
>
|
||||
{t(protocolInfo.i18nKey)} {protocolInfo.showPort ? formatHostPort(host.hostname, protocolInfo.port) : host.hostname}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-lg font-semibold truncate">{host.label}</div>
|
||||
<div className="text-[11px] text-muted-foreground font-mono truncate">
|
||||
<div className="text-base font-semibold truncate">{host.label}</div>
|
||||
<div
|
||||
className="text-[10px] font-mono truncate"
|
||||
style={{ color: 'color-mix(in srgb, var(--terminal-ui-fg, var(--foreground)) 58%, transparent)' }}
|
||||
>
|
||||
{t(protocolInfo.i18nKey)} {protocolInfo.showPort ? formatHostPort(host.hostname, protocolInfo.port) : host.hostname}
|
||||
</div>
|
||||
</>
|
||||
@@ -120,7 +133,7 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 text-xs"
|
||||
className="h-7 px-3 text-[11px]"
|
||||
onClick={() => setShowLogs(!showLogs)}
|
||||
>
|
||||
{showLogs ? t('terminal.connection.hideLogs') : t('terminal.connection.showLogs')}
|
||||
@@ -130,7 +143,7 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 text-xs"
|
||||
className="h-7 px-3 text-[11px]"
|
||||
onClick={progressProps.onCancelConnect}
|
||||
disabled={progressProps.isCancelling}
|
||||
>
|
||||
@@ -141,7 +154,7 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
className="h-7 w-7"
|
||||
aria-label={t('terminal.connection.dismissDisconnectedDialog')}
|
||||
title={t('terminal.connection.dismissDisconnectedDialog')}
|
||||
onClick={onDismissDisconnected}
|
||||
@@ -152,10 +165,10 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn(
|
||||
"h-8 w-8 rounded-lg flex items-center justify-center flex-shrink-0",
|
||||
"h-7 w-7 rounded-md flex items-center justify-center flex-shrink-0",
|
||||
needsAuth
|
||||
? "bg-primary text-primary-foreground"
|
||||
: hasError
|
||||
@@ -164,7 +177,7 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
|
||||
? "bg-primary/15 text-primary"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
<Plug size={14} />
|
||||
<Plug size={13} />
|
||||
</div>
|
||||
<div className="flex-1 h-1.5 rounded-full bg-border/60 overflow-hidden relative">
|
||||
<div
|
||||
@@ -178,13 +191,13 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
|
||||
/>
|
||||
</div>
|
||||
<div className={cn(
|
||||
"h-8 w-8 rounded-lg flex items-center justify-center flex-shrink-0",
|
||||
"h-7 w-7 rounded-md flex items-center justify-center flex-shrink-0",
|
||||
hasError ? "bg-destructive/20 text-destructive" : "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
{isConnecting ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
) : (
|
||||
<TerminalSquare size={14} />
|
||||
<TerminalSquare size={13} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -35,7 +35,7 @@ export const TerminalConnectionProgress: React.FC<TerminalConnectionProgressProp
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-3 text-xs text-muted-foreground">
|
||||
<div className="flex items-start justify-between gap-3 text-[11px] text-muted-foreground">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
{status === 'connecting' ? (
|
||||
<>
|
||||
@@ -57,8 +57,8 @@ export const TerminalConnectionProgress: React.FC<TerminalConnectionProgressProp
|
||||
|
||||
{showLogs && (
|
||||
<div className="rounded-md border border-border/35 bg-background/40">
|
||||
<ScrollArea className="max-h-52 p-3">
|
||||
<div className="space-y-1 text-sm text-foreground/90">
|
||||
<ScrollArea className="max-h-44 p-2.5">
|
||||
<div className="space-y-1 text-xs text-foreground/90">
|
||||
{progressLogs.map((line, idx) => (
|
||||
<div key={idx} className="flex items-start gap-2">
|
||||
<div className="mt-[0.4rem] h-1.5 w-1.5 flex-shrink-0 rounded-full bg-emerald-500" />
|
||||
@@ -79,11 +79,11 @@ export const TerminalConnectionProgress: React.FC<TerminalConnectionProgressProp
|
||||
<div className="flex justify-end gap-2">
|
||||
{status !== 'connecting' && (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" className="h-8" onClick={onCloseSession}>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-3 text-[11px]" onClick={onCloseSession}>
|
||||
{t('terminal.toolbar.closeSession')}
|
||||
</Button>
|
||||
<Button size="sm" className="h-8" onClick={onRetry}>
|
||||
<Play className="h-3 w-3 mr-2" /> {t('terminal.progress.startOver')}
|
||||
<Button size="sm" className="h-7 px-3 text-[11px]" onClick={onRetry}>
|
||||
<Play className="h-3 w-3 mr-1.5" /> {t('terminal.progress.startOver')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -73,12 +73,19 @@ export const TerminalSearchBar: React.FC<TerminalSearchBarProps> = ({
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2 pt-0 pb-2 bg-black/50 backdrop-blur-sm"
|
||||
style={{
|
||||
backgroundColor: 'color-mix(in srgb, var(--terminal-ui-bg, #000000) 86%, transparent)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Search input */}
|
||||
<div className="relative flex-1">
|
||||
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-white/40" />
|
||||
<Search
|
||||
size={12}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2"
|
||||
style={{ color: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 40%, transparent)' }}
|
||||
/>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
@@ -88,13 +95,20 @@ export const TerminalSearchBar: React.FC<TerminalSearchBarProps> = ({
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
placeholder={t("terminal.search.placeholder")}
|
||||
className="w-full h-6 pl-7 pr-2 text-[11px] bg-white/5 border-none rounded text-white placeholder:text-white/30 focus:outline-none focus:bg-white/10"
|
||||
className="w-full h-6 pl-7 pr-2 text-[11px] border-none rounded placeholder:opacity-40 focus:outline-none"
|
||||
style={{
|
||||
backgroundColor: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 5%, transparent)',
|
||||
color: 'var(--terminal-ui-fg, #ffffff)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Match count indicator - only show when no results */}
|
||||
{searchTerm.length > 0 && matchCount?.total === 0 && (
|
||||
<span className="text-[10px] text-white/50 flex-shrink-0">
|
||||
<span
|
||||
className="text-[10px] flex-shrink-0"
|
||||
style={{ color: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 50%, transparent)' }}
|
||||
>
|
||||
{t("terminal.search.noResults")}
|
||||
</span>
|
||||
)}
|
||||
@@ -105,7 +119,10 @@ export const TerminalSearchBar: React.FC<TerminalSearchBarProps> = ({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-white/60 hover:text-white hover:bg-white/10 disabled:opacity-30"
|
||||
className="h-6 w-6 disabled:opacity-30"
|
||||
style={{
|
||||
color: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 60%, transparent)',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -123,7 +140,10 @@ export const TerminalSearchBar: React.FC<TerminalSearchBarProps> = ({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-white/60 hover:text-white hover:bg-white/10 disabled:opacity-30"
|
||||
className="h-6 w-6 disabled:opacity-30"
|
||||
style={{
|
||||
color: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 60%, transparent)',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -39,16 +39,20 @@ const ThemeItem = memo(({
|
||||
onClick={() => onSelect(theme.id)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(theme.id); } }}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2.5 px-3 py-2 text-left transition-colors group cursor-pointer',
|
||||
isSelected
|
||||
? 'bg-accent/50'
|
||||
: 'hover:bg-accent/50'
|
||||
'w-full flex items-center gap-2.5 px-3 py-2 text-left group cursor-pointer'
|
||||
)}
|
||||
style={{ backgroundColor: isSelected ? 'var(--terminal-panel-active)' : 'transparent' }}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelected) e.currentTarget.style.backgroundColor = 'var(--terminal-panel-hover)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelected) e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
>
|
||||
{/* Color swatch */}
|
||||
<div
|
||||
className="w-6 h-6 rounded-md flex-shrink-0 flex flex-col justify-center items-start pl-0.5 gap-0.5 border border-border/50"
|
||||
style={{ backgroundColor: theme.colors.background }}
|
||||
className="h-6 w-8 rounded-[4px] flex-shrink-0 flex flex-col justify-center items-start pl-1 gap-0.5 border-[0.5px]"
|
||||
style={{ backgroundColor: theme.colors.background, borderColor: 'var(--terminal-panel-border)' }}
|
||||
>
|
||||
<div className="h-0.5 w-2.5 rounded-full" style={{ backgroundColor: theme.colors.green }} />
|
||||
<div className="h-0.5 w-4 rounded-full" style={{ backgroundColor: theme.colors.blue }} />
|
||||
@@ -58,7 +62,7 @@ const ThemeItem = memo(({
|
||||
<div className="text-xs font-medium truncate">
|
||||
{theme.name}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground capitalize">
|
||||
<div className="text-[10px] capitalize" style={{ color: 'var(--terminal-panel-muted)' }}>
|
||||
{theme.type}
|
||||
{theme.isCustom && ' • custom'}
|
||||
</div>
|
||||
@@ -69,13 +73,14 @@ const ThemeItem = memo(({
|
||||
tabIndex={0}
|
||||
onClick={(e) => { e.stopPropagation(); onEdit(theme.id); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.stopPropagation(); e.preventDefault(); onEdit(theme.id); } }}
|
||||
className="w-5 h-5 rounded flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted/80 opacity-0 group-hover:opacity-100 transition-all"
|
||||
className="w-5 h-5 rounded flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all"
|
||||
style={{ color: 'var(--terminal-panel-muted)' }}
|
||||
>
|
||||
<Pencil size={10} />
|
||||
</div>
|
||||
)}
|
||||
{isSelected && !onEdit && (
|
||||
<Check size={12} className="text-primary flex-shrink-0" />
|
||||
<Check size={12} className="flex-shrink-0" style={{ color: 'var(--terminal-panel-fg)' }} />
|
||||
)}
|
||||
</div>
|
||||
));
|
||||
@@ -94,11 +99,15 @@ const FontItem = memo(({
|
||||
<button
|
||||
onClick={() => onSelect(font.id)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2.5 px-3 py-2 text-left transition-colors',
|
||||
isSelected
|
||||
? 'bg-accent/50'
|
||||
: 'hover:bg-accent/50'
|
||||
'w-full flex items-center gap-2.5 px-3 py-2 text-left transition-colors'
|
||||
)}
|
||||
style={{ backgroundColor: isSelected ? 'var(--terminal-panel-active)' : 'transparent' }}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelected) e.currentTarget.style.backgroundColor = 'var(--terminal-panel-hover)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelected) e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
@@ -107,10 +116,10 @@ const FontItem = memo(({
|
||||
>
|
||||
{font.name}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground truncate">{font.description}</div>
|
||||
<div className="text-[10px] truncate" style={{ color: 'var(--terminal-panel-muted)' }}>{font.description}</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<Check size={12} className="text-primary flex-shrink-0" />
|
||||
<Check size={12} className="flex-shrink-0" style={{ color: 'var(--terminal-panel-fg)' }} />
|
||||
)}
|
||||
</button>
|
||||
));
|
||||
@@ -132,6 +141,10 @@ interface ThemeSidePanelProps {
|
||||
onFontSizeChange: (fontSize: number) => void;
|
||||
onFontSizeReset?: () => void;
|
||||
isVisible?: boolean;
|
||||
previewColors?: {
|
||||
background: string;
|
||||
foreground: string;
|
||||
};
|
||||
}
|
||||
|
||||
const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
|
||||
@@ -150,6 +163,7 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
|
||||
onFontSizeChange,
|
||||
onFontSizeReset,
|
||||
isVisible = true,
|
||||
previewColors,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const availableFonts = useAvailableFonts();
|
||||
@@ -245,44 +259,57 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
|
||||
if (!isVisible) return null;
|
||||
|
||||
const builtinThemes = TERMINAL_THEMES;
|
||||
const panelVars = {
|
||||
['--terminal-panel-bg' as never]: previewColors?.background ?? 'var(--background)',
|
||||
['--terminal-panel-fg' as never]: previewColors?.foreground ?? 'var(--foreground)',
|
||||
['--terminal-panel-muted' as never]: 'color-mix(in srgb, var(--terminal-panel-fg) 58%, var(--terminal-panel-bg) 42%)',
|
||||
['--terminal-panel-border' as never]: 'color-mix(in srgb, var(--terminal-panel-fg) 12%, var(--terminal-panel-bg) 88%)',
|
||||
['--terminal-panel-hover' as never]: 'color-mix(in srgb, var(--terminal-panel-fg) 12%, var(--terminal-panel-bg) 88%)',
|
||||
['--terminal-panel-active' as never]: 'color-mix(in srgb, var(--terminal-panel-fg) 16%, var(--terminal-panel-bg) 84%)',
|
||||
} as React.CSSProperties;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full flex flex-col bg-background overflow-hidden">
|
||||
<div
|
||||
className="h-full flex flex-col overflow-hidden"
|
||||
style={{
|
||||
...panelVars,
|
||||
backgroundColor: 'var(--terminal-panel-bg)',
|
||||
color: 'var(--terminal-panel-fg)',
|
||||
borderColor: 'var(--terminal-panel-border)',
|
||||
}}
|
||||
>
|
||||
{/* Tab Bar */}
|
||||
<div className="flex p-1.5 gap-0.5 shrink-0 border-b border-border/50">
|
||||
<div className="flex p-1.5 gap-0.5 shrink-0 border-b" style={{ borderColor: 'var(--terminal-panel-border)' }}>
|
||||
<button
|
||||
onClick={() => { setActiveTab('theme'); setEditingTheme(null); }}
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all',
|
||||
activeTab === 'theme'
|
||||
? 'bg-primary/15 text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
||||
)}
|
||||
className="flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all"
|
||||
style={{
|
||||
backgroundColor: activeTab === 'theme' ? 'var(--terminal-panel-active)' : 'transparent',
|
||||
color: activeTab === 'theme' ? 'var(--terminal-panel-fg)' : 'var(--terminal-panel-muted)',
|
||||
}}
|
||||
>
|
||||
<Palette size={12} />
|
||||
{t('terminal.themeModal.tab.theme')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('font')}
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all',
|
||||
activeTab === 'font'
|
||||
? 'bg-primary/15 text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
||||
)}
|
||||
className="flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all"
|
||||
style={{
|
||||
backgroundColor: activeTab === 'font' ? 'var(--terminal-panel-active)' : 'transparent',
|
||||
color: activeTab === 'font' ? 'var(--terminal-panel-fg)' : 'var(--terminal-panel-muted)',
|
||||
}}
|
||||
>
|
||||
<Type size={12} />
|
||||
{t('terminal.themeModal.tab.font')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('custom')}
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all',
|
||||
activeTab === 'custom'
|
||||
? 'bg-primary/15 text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
||||
)}
|
||||
className="flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all"
|
||||
style={{
|
||||
backgroundColor: activeTab === 'custom' ? 'var(--terminal-panel-active)' : 'transparent',
|
||||
color: activeTab === 'custom' ? 'var(--terminal-panel-fg)' : 'var(--terminal-panel-muted)',
|
||||
}}
|
||||
>
|
||||
<Sparkles size={12} />
|
||||
{t('terminal.themeModal.tab.custom')}
|
||||
@@ -304,7 +331,7 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
|
||||
))}
|
||||
{customThemes.length > 0 && (
|
||||
<>
|
||||
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mt-2 mb-1 px-1 font-semibold">
|
||||
<div className="text-[9px] uppercase tracking-wider mt-2 mb-1 px-1 font-semibold" style={{ color: 'var(--terminal-panel-muted)' }}>
|
||||
{t('terminal.customTheme.section')}
|
||||
</div>
|
||||
{customThemes.map(theme => (
|
||||
@@ -320,7 +347,7 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
|
||||
)}
|
||||
{canResetTheme && (
|
||||
<>
|
||||
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mt-2 mb-1 px-1 font-semibold">
|
||||
<div className="text-[9px] uppercase tracking-wider mt-2 mb-1 px-1 font-semibold" style={{ color: 'var(--terminal-panel-muted)' }}>
|
||||
{t('terminal.themeModal.globalTheme')}
|
||||
</div>
|
||||
<ThemeItem
|
||||
@@ -344,7 +371,7 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
|
||||
))}
|
||||
{canResetFontFamily && (
|
||||
<>
|
||||
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mt-2 mb-1 px-1 font-semibold">
|
||||
<div className="text-[9px] uppercase tracking-wider mt-2 mb-1 px-1 font-semibold" style={{ color: 'var(--terminal-panel-muted)' }}>
|
||||
{t('terminal.themeModal.globalFont')}
|
||||
</div>
|
||||
<FontItem
|
||||
@@ -360,26 +387,36 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
|
||||
<div>
|
||||
<button
|
||||
onClick={handleNewTheme}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-left hover:bg-accent/50 transition-colors"
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-left transition-colors"
|
||||
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = 'var(--terminal-panel-hover)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; }}
|
||||
>
|
||||
<div className="w-6 h-6 rounded-md flex items-center justify-center bg-primary/10 text-primary shrink-0">
|
||||
<Plus size={12} />
|
||||
</div>
|
||||
<div
|
||||
className="w-6 h-6 rounded-md flex items-center justify-center shrink-0"
|
||||
style={{
|
||||
backgroundColor: 'color-mix(in srgb, var(--terminal-panel-fg) 10%, transparent)',
|
||||
color: 'var(--terminal-panel-fg)',
|
||||
}}
|
||||
>
|
||||
<Plus size={12} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-foreground">{t('terminal.customTheme.new')}</div>
|
||||
<div className="text-[10px] text-muted-foreground">{t('terminal.customTheme.newDesc')}</div>
|
||||
<div className="text-xs font-medium">{t('terminal.customTheme.new')}</div>
|
||||
<div className="text-[10px]" style={{ color: 'var(--terminal-panel-muted)' }}>{t('terminal.customTheme.newDesc')}</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleImportFile}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-left hover:bg-accent/50 transition-colors"
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-left transition-colors"
|
||||
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = 'var(--terminal-panel-hover)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; }}
|
||||
>
|
||||
<div className="w-6 h-6 rounded-md flex items-center justify-center bg-blue-500/10 text-blue-500 shrink-0">
|
||||
<Download size={12} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-foreground">{t('terminal.customTheme.import')}</div>
|
||||
<div className="text-[10px] text-muted-foreground">{t('terminal.customTheme.importDesc')}</div>
|
||||
<div className="text-xs font-medium">{t('terminal.customTheme.import')}</div>
|
||||
<div className="text-[10px]" style={{ color: 'var(--terminal-panel-muted)' }}>{t('terminal.customTheme.importDesc')}</div>
|
||||
</div>
|
||||
</button>
|
||||
<input
|
||||
@@ -391,7 +428,7 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
|
||||
/>
|
||||
{customThemes.length > 0 && (
|
||||
<>
|
||||
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mt-2 mb-1 px-1 font-semibold">
|
||||
<div className="text-[9px] uppercase tracking-wider mt-2 mb-1 px-1 font-semibold" style={{ color: 'var(--terminal-panel-muted)' }}>
|
||||
{t('terminal.customTheme.yourThemes')}
|
||||
</div>
|
||||
{customThemes.map(theme => (
|
||||
@@ -412,36 +449,47 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
|
||||
|
||||
{/* Font Size Control (only in font tab) */}
|
||||
{activeTab === 'font' && (
|
||||
<div className="p-2.5 border-t border-border/50 shrink-0">
|
||||
<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 text-muted-foreground font-semibold">
|
||||
<div className="text-[9px] uppercase tracking-wider font-semibold" style={{ color: 'var(--terminal-panel-muted)' }}>
|
||||
{t('terminal.themeModal.fontSize')}
|
||||
</div>
|
||||
{canResetFontSize && (
|
||||
<button
|
||||
onClick={onFontSizeReset}
|
||||
className="text-[10px] font-medium text-primary hover:opacity-80 transition-opacity"
|
||||
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 justify-between gap-2 bg-muted/30 rounded-lg p-1.5">
|
||||
<div className="flex items-center justify-between gap-2 rounded-lg p-1.5" style={{ backgroundColor: 'var(--terminal-panel-hover)' }}>
|
||||
<button
|
||||
onClick={() => handleFontSizeChange(-1)}
|
||||
disabled={currentFontSize <= MIN_FONT_SIZE}
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center bg-background hover:bg-accent text-foreground disabled:opacity-30 disabled:cursor-not-allowed transition-colors border border-border"
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed transition-colors border"
|
||||
style={{
|
||||
backgroundColor: 'var(--terminal-panel-bg)',
|
||||
color: 'var(--terminal-panel-fg)',
|
||||
borderColor: 'var(--terminal-panel-border)',
|
||||
}}
|
||||
>
|
||||
<Minus size={12} />
|
||||
</button>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-lg font-bold text-foreground tabular-nums">{currentFontSize}</span>
|
||||
<span className="text-[9px] text-muted-foreground">px</span>
|
||||
<span className="text-lg font-bold tabular-nums">{currentFontSize}</span>
|
||||
<span className="text-[9px]" style={{ color: 'var(--terminal-panel-muted)' }}>px</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleFontSizeChange(1)}
|
||||
disabled={currentFontSize >= MAX_FONT_SIZE}
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center bg-background hover:bg-accent text-foreground disabled:opacity-30 disabled:cursor-not-allowed transition-colors border border-border"
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed transition-colors border"
|
||||
style={{
|
||||
backgroundColor: 'var(--terminal-panel-bg)',
|
||||
color: 'var(--terminal-panel-fg)',
|
||||
borderColor: 'var(--terminal-panel-border)',
|
||||
}}
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
@@ -450,8 +498,8 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
|
||||
)}
|
||||
|
||||
{/* Current selection info */}
|
||||
<div className="px-2.5 py-1.5 border-t border-border/50 shrink-0">
|
||||
<div className="text-[9px] text-muted-foreground truncate">
|
||||
<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
|
||||
</div>
|
||||
</div>
|
||||
|
||||
439
components/terminal/autocomplete/AutocompletePopup.tsx
Normal file
439
components/terminal/autocomplete/AutocompletePopup.tsx
Normal file
@@ -0,0 +1,439 @@
|
||||
/**
|
||||
* Popup autocomplete menu for terminal.
|
||||
* Renders a floating list of completion suggestions near the terminal cursor.
|
||||
* Shows a detail tooltip for the selected/hovered item with full description.
|
||||
* Colors are derived from the active terminal theme for visual consistency.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef, useState, memo } from "react";
|
||||
import { Folder, File, Link } from "lucide-react";
|
||||
import type { CompletionSuggestion, SuggestionSource } from "./completionEngine";
|
||||
|
||||
export interface AutocompleteThemeColors {
|
||||
background: string;
|
||||
foreground: string;
|
||||
selection: string;
|
||||
cursor: string;
|
||||
}
|
||||
|
||||
export interface SubDirEntry {
|
||||
name: string;
|
||||
type: "file" | "directory" | "symlink";
|
||||
}
|
||||
|
||||
export interface SubDirPanel {
|
||||
entries: SubDirEntry[];
|
||||
selectedIndex: number;
|
||||
dirPath: string;
|
||||
}
|
||||
|
||||
interface AutocompletePopupProps {
|
||||
suggestions: CompletionSuggestion[];
|
||||
selectedIndex: number;
|
||||
/** Position relative to the terminal container (not viewport) */
|
||||
position: { x: number; y: number };
|
||||
/** Current input line bounds relative to the terminal container */
|
||||
cursorLineTop: number;
|
||||
cursorLineBottom: number;
|
||||
visible: boolean;
|
||||
expandUpward?: boolean;
|
||||
themeColors?: AutocompleteThemeColors;
|
||||
onSelect: (suggestion: CompletionSuggestion) => void;
|
||||
maxHeight?: number;
|
||||
subDirPanels?: SubDirPanel[];
|
||||
subDirFocusLevel?: number;
|
||||
/** Reference to the terminal container for calculating fixed position */
|
||||
containerRef?: React.RefObject<HTMLDivElement | null>;
|
||||
/** Ask the autocomplete controller to recompute cursor-relative popup position */
|
||||
onRequestReposition?: () => void;
|
||||
/** Offset from top of container to terminal content area (toolbar + search bar) */
|
||||
searchBarOffset?: number;
|
||||
}
|
||||
|
||||
const SOURCE_LABELS: Record<SuggestionSource, { label: string; fullLabel: string; fallbackColor: string }> = {
|
||||
history: { label: "h", fullLabel: "History", fallbackColor: "#FBBF24" },
|
||||
command: { label: "c", fullLabel: "Command", fallbackColor: "#34D399" },
|
||||
subcommand: { label: "s", fullLabel: "Subcommand", fallbackColor: "#60A5FA" },
|
||||
option: { label: "o", fullLabel: "Option", fallbackColor: "#A78BFA" },
|
||||
arg: { label: "a", fullLabel: "Argument", fallbackColor: "#F87171" },
|
||||
path: { label: "p", fullLabel: "Path", fallbackColor: "#38BDF8" },
|
||||
};
|
||||
|
||||
/** Lucide icon components for file types in path suggestions */
|
||||
const FILE_TYPE_CONFIG: Record<string, { Icon: React.FC<{ size?: number; color?: string }>; color: string }> = {
|
||||
directory: { Icon: Folder, color: "#38BDF8" },
|
||||
file: { Icon: File, color: "#94A3B8" },
|
||||
symlink: { Icon: Link, color: "#A78BFA" },
|
||||
};
|
||||
|
||||
const FileTypeIcon: React.FC<{ fileType: string }> = ({ fileType }) => {
|
||||
const cfg = FILE_TYPE_CONFIG[fileType] ?? FILE_TYPE_CONFIG.file;
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
width: "18px",
|
||||
height: "18px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<cfg.Icon size={14} color={cfg.color} />
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
/** Chevron indicator for expandable directory items */
|
||||
const DirExpandIndicator: React.FC<{ visible: boolean; color: string }> = ({ visible, color }) => (
|
||||
<span style={{ fontSize: "10px", color, opacity: visible ? 0.6 : 0, flexShrink: 0, marginLeft: "2px" }}>›</span>
|
||||
);
|
||||
|
||||
const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
|
||||
suggestions,
|
||||
selectedIndex,
|
||||
position,
|
||||
cursorLineTop,
|
||||
cursorLineBottom,
|
||||
visible,
|
||||
expandUpward = false,
|
||||
themeColors,
|
||||
onSelect,
|
||||
maxHeight = 240,
|
||||
subDirPanels = [],
|
||||
subDirFocusLevel = -1,
|
||||
containerRef,
|
||||
onRequestReposition,
|
||||
searchBarOffset: _searchBarOffset = 30,
|
||||
}) => {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const selectedRef = useRef<HTMLDivElement>(null);
|
||||
const [hoveredIndex, setHoveredIndex] = useState(-1);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedRef.current && listRef.current) {
|
||||
selectedRef.current.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "instant" as ScrollBehavior,
|
||||
});
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
// Reset hover when suggestions change
|
||||
useEffect(() => {
|
||||
setHoveredIndex(-1);
|
||||
}, [suggestions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !onRequestReposition) return;
|
||||
|
||||
let frameId = 0;
|
||||
const requestReposition = () => {
|
||||
if (frameId) cancelAnimationFrame(frameId);
|
||||
frameId = requestAnimationFrame(() => {
|
||||
frameId = 0;
|
||||
onRequestReposition();
|
||||
});
|
||||
};
|
||||
|
||||
const container = containerRef?.current;
|
||||
const observer = container ? new ResizeObserver(requestReposition) : null;
|
||||
observer?.observe(container);
|
||||
window.addEventListener("resize", requestReposition);
|
||||
|
||||
return () => {
|
||||
if (frameId) cancelAnimationFrame(frameId);
|
||||
observer?.disconnect();
|
||||
window.removeEventListener("resize", requestReposition);
|
||||
};
|
||||
}, [containerRef, onRequestReposition, visible]);
|
||||
|
||||
if (!visible || suggestions.length === 0) return null;
|
||||
|
||||
const bg = themeColors?.background ?? "#1e1e2e";
|
||||
const fg = themeColors?.foreground ?? "#cdd6f4";
|
||||
const popupBg = `color-mix(in srgb, ${bg} 92%, ${fg} 8%)`;
|
||||
const popupBorder = `color-mix(in srgb, ${bg} 75%, ${fg} 25%)`;
|
||||
const selectedBg = `color-mix(in srgb, ${bg} 78%, ${fg} 22%)`;
|
||||
const hoverBg = `color-mix(in srgb, ${bg} 85%, ${fg} 15%)`;
|
||||
const textColor = fg;
|
||||
const dimTextColor = `color-mix(in srgb, ${fg} 50%, ${bg} 50%)`;
|
||||
|
||||
// Determine which item to show the detail tooltip for
|
||||
const detailIndex = hoveredIndex >= 0 ? hoveredIndex : selectedIndex;
|
||||
const detailItem = detailIndex >= 0 ? suggestions[detailIndex] : null;
|
||||
const showDetail = detailItem?.description && detailItem.description.length > 0;
|
||||
|
||||
// Calculate fixed viewport position from container rect + relative cursor position.
|
||||
// containerRef already has top offset for toolbar/search bar, so don't add it again.
|
||||
const containerRect = containerRef?.current?.getBoundingClientRect();
|
||||
const fixedLeft = (containerRect?.left ?? 0) + position.x;
|
||||
const fixedLineTop = (containerRect?.top ?? 0) + cursorLineTop;
|
||||
const fixedLineBottom = (containerRect?.top ?? 0) + cursorLineBottom;
|
||||
|
||||
const viewportPadding = 8;
|
||||
const anchorGap = 8;
|
||||
const viewportHeight = typeof window !== "undefined" ? window.innerHeight : 800;
|
||||
const viewportWidth = typeof window !== "undefined" ? window.innerWidth : 1200;
|
||||
const estimatedPopupHeight = Math.min(maxHeight, suggestions.length * 28 + 8);
|
||||
const estimatedDetailHeight = showDetail && detailItem && detailItem.source !== "path" ? 96 : 0;
|
||||
const desiredContentHeight = Math.min(
|
||||
maxHeight,
|
||||
Math.max(estimatedPopupHeight, estimatedDetailHeight),
|
||||
);
|
||||
const spaceAbove = Math.max(0, fixedLineTop - viewportPadding - anchorGap);
|
||||
const spaceBelow = Math.max(0, viewportHeight - fixedLineBottom - viewportPadding - anchorGap);
|
||||
const canFullyRenderAbove = spaceAbove >= desiredContentHeight;
|
||||
const canFullyRenderBelow = spaceBelow >= desiredContentHeight;
|
||||
const renderUpward = canFullyRenderBelow
|
||||
? false
|
||||
: canFullyRenderAbove
|
||||
? true
|
||||
: expandUpward
|
||||
? spaceAbove >= Math.min(spaceBelow, 80)
|
||||
: spaceAbove > spaceBelow;
|
||||
const availableVerticalSpace = renderUpward ? spaceAbove : spaceBelow;
|
||||
const effectiveMaxHeight = Math.max(0, Math.min(maxHeight, availableVerticalSpace));
|
||||
const contentHeightForPlacement = Math.min(
|
||||
effectiveMaxHeight,
|
||||
desiredContentHeight,
|
||||
);
|
||||
const anchoredTop = renderUpward
|
||||
? Math.max(viewportPadding, fixedLineTop - anchorGap - contentHeightForPlacement)
|
||||
: Math.min(fixedLineBottom + anchorGap, viewportHeight - viewportPadding - contentHeightForPlacement);
|
||||
const clampedLeft = Math.max(viewportPadding, Math.min(fixedLeft, viewportWidth - viewportPadding - 400));
|
||||
|
||||
const sharedBoxStyle = {
|
||||
backgroundColor: popupBg,
|
||||
border: `1px solid ${popupBorder}`,
|
||||
borderRadius: "6px",
|
||||
boxShadow: renderUpward
|
||||
? "0 -2px 6px rgba(0, 0, 0, 0.15)"
|
||||
: "0 2px 6px rgba(0, 0, 0, 0.15)",
|
||||
fontFamily: "inherit",
|
||||
fontSize: "13px",
|
||||
color: textColor,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: `${clampedLeft}px`,
|
||||
top: `${anchoredTop}px`,
|
||||
zIndex: 10000,
|
||||
display: "flex",
|
||||
alignItems: renderUpward ? "flex-end" : "flex-start",
|
||||
gap: "4px",
|
||||
pointerEvents: "auto", // Re-enable on popup itself (parent is pointer-events-none)
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{/* Main suggestion list */}
|
||||
<div
|
||||
ref={listRef}
|
||||
className="xterm-autocomplete-popup"
|
||||
style={{
|
||||
...sharedBoxStyle,
|
||||
maxHeight: `${effectiveMaxHeight}px`,
|
||||
minWidth: "180px",
|
||||
maxWidth: "400px",
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
padding: "4px 0",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{suggestions.map((suggestion, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
const isHovered = index === hoveredIndex;
|
||||
const sourceInfo = SOURCE_LABELS[suggestion.source];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${suggestion.text}-${index}`}
|
||||
ref={isSelected ? selectedRef : undefined}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "5px 10px",
|
||||
cursor: "pointer",
|
||||
backgroundColor: isSelected ? selectedBg : isHovered ? hoverBg : "transparent",
|
||||
gap: "8px",
|
||||
lineHeight: "1.4",
|
||||
}}
|
||||
onMouseEnter={() => setHoveredIndex(index)}
|
||||
onMouseLeave={() => setHoveredIndex(-1)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onSelect(suggestion);
|
||||
}}
|
||||
>
|
||||
{/* Source / file type indicator */}
|
||||
{suggestion.source === "path" && suggestion.fileType ? (
|
||||
<FileTypeIcon fileType={suggestion.fileType} />
|
||||
) : (
|
||||
<span
|
||||
style={{
|
||||
width: "18px",
|
||||
height: "18px",
|
||||
borderRadius: "3px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: "10px",
|
||||
fontWeight: 600,
|
||||
color: sourceInfo.fallbackColor,
|
||||
backgroundColor: `${sourceInfo.fallbackColor}15`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{sourceInfo.label}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Command text */}
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
color: textColor,
|
||||
fontWeight: isSelected ? 500 : 400,
|
||||
}}
|
||||
>
|
||||
{suggestion.displayText}
|
||||
</span>
|
||||
|
||||
{/* Inline description (truncated) */}
|
||||
{suggestion.description && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
color: dimTextColor,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
maxWidth: "160px",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{suggestion.description}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Frequency badge for history */}
|
||||
{suggestion.frequency && suggestion.frequency > 1 && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "10px",
|
||||
color: dimTextColor,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
×{suggestion.frequency}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Expand indicator for directories */}
|
||||
{suggestion.source === "path" && suggestion.fileType === "directory" && (
|
||||
<DirExpandIndicator visible={isSelected || isHovered} color={dimTextColor} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Cascading sub-directory panels */}
|
||||
{subDirPanels.map((panel, level) => (
|
||||
<div
|
||||
key={panel.dirPath}
|
||||
style={{
|
||||
...sharedBoxStyle,
|
||||
maxHeight: `${effectiveMaxHeight}px`,
|
||||
minWidth: "150px",
|
||||
maxWidth: "240px",
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
padding: "4px 0",
|
||||
userSelect: "none",
|
||||
alignSelf: "flex-start",
|
||||
}}
|
||||
>
|
||||
{panel.entries.map((entry, idx) => {
|
||||
const isFocused = level === subDirFocusLevel;
|
||||
const isSubSelected = isFocused && idx === panel.selectedIndex;
|
||||
return (
|
||||
<div
|
||||
key={entry.name}
|
||||
ref={isSubSelected ? (el) => { el?.scrollIntoView({ block: "nearest" }); } : undefined}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "4px 10px",
|
||||
cursor: "pointer",
|
||||
backgroundColor: isSubSelected ? selectedBg
|
||||
: (idx === panel.selectedIndex && level < subDirFocusLevel) ? hoverBg
|
||||
: "transparent",
|
||||
gap: "8px",
|
||||
lineHeight: "1.4",
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<FileTypeIcon fileType={entry.type} />
|
||||
<span style={{
|
||||
flex: 1, overflow: "hidden", textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap", color: textColor,
|
||||
}}>
|
||||
{entry.name}{entry.type === "directory" ? "/" : ""}
|
||||
</span>
|
||||
{entry.type === "directory" && (
|
||||
<DirExpandIndicator visible={isSubSelected || (idx === panel.selectedIndex && level < subDirFocusLevel)} color={dimTextColor} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Detail tooltip panel — shows full description for non-path items */}
|
||||
{showDetail && detailItem && detailItem.source !== "path" && (
|
||||
<div
|
||||
style={{
|
||||
...sharedBoxStyle,
|
||||
padding: "10px 12px",
|
||||
maxWidth: "280px",
|
||||
minWidth: "160px",
|
||||
alignSelf: renderUpward ? "flex-end" : "flex-start",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "6px", marginBottom: "6px" }}>
|
||||
<span style={{ fontWeight: 600, fontSize: "13px" }}>{detailItem.displayText}</span>
|
||||
<span style={{
|
||||
fontSize: "10px",
|
||||
color: SOURCE_LABELS[detailItem.source].fallbackColor,
|
||||
padding: "1px 5px",
|
||||
borderRadius: "3px",
|
||||
backgroundColor: `${SOURCE_LABELS[detailItem.source].fallbackColor}15`,
|
||||
}}>
|
||||
{SOURCE_LABELS[detailItem.source].fullLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: "12px", color: dimTextColor, lineHeight: "1.5", wordBreak: "break-word" }}>
|
||||
{detailItem.description}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(AutocompletePopup);
|
||||
180
components/terminal/autocomplete/GhostTextAddon.ts
Normal file
180
components/terminal/autocomplete/GhostTextAddon.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Ghost Text addon for xterm.js.
|
||||
* Renders inline suggestion text after the cursor in a dimmed style,
|
||||
* similar to fish shell's autosuggestions.
|
||||
*
|
||||
* Uses a CSS overlay positioned relative to the terminal cursor,
|
||||
* avoiding modification of the terminal buffer.
|
||||
*/
|
||||
|
||||
import type { Terminal as XTerm, IDisposable } from "@xterm/xterm";
|
||||
import { getXTermCellDimensions, invalidateCellDimensionCache } from "./xtermUtils";
|
||||
|
||||
export class GhostTextAddon implements IDisposable {
|
||||
private term: XTerm | null = null;
|
||||
private ghostElement: HTMLSpanElement | null = null;
|
||||
private containerElement: HTMLDivElement | null = null;
|
||||
private currentSuggestion: string = "";
|
||||
private currentInput: string = "";
|
||||
private disposed = false;
|
||||
private disposables: IDisposable[] = [];
|
||||
private lastLeft = -1;
|
||||
private lastTop = -1;
|
||||
|
||||
activate(term: XTerm): void {
|
||||
this.term = term;
|
||||
|
||||
const termElement = term.element;
|
||||
if (!termElement) return;
|
||||
|
||||
this.containerElement = document.createElement("div");
|
||||
this.containerElement.className = "xterm-ghost-text-container";
|
||||
Object.assign(this.containerElement.style, {
|
||||
position: "absolute",
|
||||
top: "0",
|
||||
left: "0",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
pointerEvents: "none",
|
||||
overflow: "hidden",
|
||||
zIndex: "1",
|
||||
});
|
||||
|
||||
this.ghostElement = document.createElement("span");
|
||||
this.ghostElement.className = "xterm-ghost-text";
|
||||
Object.assign(this.ghostElement.style, {
|
||||
position: "absolute",
|
||||
opacity: "0.4",
|
||||
pointerEvents: "none",
|
||||
whiteSpace: "pre",
|
||||
fontFamily: "inherit",
|
||||
fontSize: "inherit",
|
||||
lineHeight: "inherit",
|
||||
color: "inherit",
|
||||
display: "none",
|
||||
});
|
||||
|
||||
this.containerElement.appendChild(this.ghostElement);
|
||||
|
||||
const screenEl = termElement.querySelector(".xterm-screen");
|
||||
if (screenEl) {
|
||||
screenEl.appendChild(this.containerElement);
|
||||
} else {
|
||||
termElement.appendChild(this.containerElement);
|
||||
}
|
||||
|
||||
// Update position on scroll and render to keep ghost text aligned
|
||||
this.disposables.push(
|
||||
term.onRender(() => {
|
||||
if (this.isVisible()) this.updatePosition();
|
||||
}),
|
||||
);
|
||||
|
||||
// Invalidate cell dimension cache on resize so measurements stay accurate
|
||||
this.disposables.push(
|
||||
term.onResize(() => {
|
||||
invalidateCellDimensionCache();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show ghost text suggestion.
|
||||
* @param fullSuggestion The complete suggested command
|
||||
* @param currentInput The text the user has typed so far
|
||||
*/
|
||||
show(fullSuggestion: string, currentInput: string): void {
|
||||
if (this.disposed || !this.ghostElement || !this.term) return;
|
||||
|
||||
const ghostText = fullSuggestion.startsWith(currentInput)
|
||||
? fullSuggestion.substring(currentInput.length)
|
||||
: "";
|
||||
|
||||
if (!ghostText) {
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentSuggestion = fullSuggestion;
|
||||
this.currentInput = currentInput;
|
||||
|
||||
this.updatePosition();
|
||||
this.ghostElement.textContent = ghostText;
|
||||
this.ghostElement.style.display = "block";
|
||||
// Set font properties once per show (not per frame in updatePosition)
|
||||
this.ghostElement.style.fontSize = `${this.term.options.fontSize}px`;
|
||||
this.ghostElement.style.fontFamily = this.term.options.fontFamily || "inherit";
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
if (this.ghostElement) {
|
||||
this.ghostElement.style.display = "none";
|
||||
this.ghostElement.textContent = "";
|
||||
}
|
||||
this.currentSuggestion = "";
|
||||
this.currentInput = "";
|
||||
}
|
||||
|
||||
getSuggestion(): string {
|
||||
return this.currentSuggestion;
|
||||
}
|
||||
|
||||
isVisible(): boolean {
|
||||
return !!(this.ghostElement && this.ghostElement.style.display !== "none" &&
|
||||
this.currentSuggestion);
|
||||
}
|
||||
|
||||
getGhostText(): string {
|
||||
if (!this.currentSuggestion || !this.currentInput) return "";
|
||||
return this.currentSuggestion.startsWith(this.currentInput)
|
||||
? this.currentSuggestion.substring(this.currentInput.length)
|
||||
: "";
|
||||
}
|
||||
|
||||
getNextWord(): string {
|
||||
const ghost = this.getGhostText();
|
||||
if (!ghost) return "";
|
||||
|
||||
const trimmed = ghost.replace(/^\s+/, "");
|
||||
const leadingSpace = ghost.length - trimmed.length;
|
||||
|
||||
if (trimmed.length === 0) return ghost; // Only whitespace
|
||||
|
||||
// Search for word boundary starting from index 1 (skip leading separator chars like /)
|
||||
const wordEnd = trimmed.substring(1).search(/[\s/\\-]/);
|
||||
if (wordEnd < 0) return ghost; // Single word, accept all
|
||||
|
||||
// Include leading whitespace + the word up to (and including) the separator
|
||||
return ghost.substring(0, leadingSpace + 1 + wordEnd + 1);
|
||||
}
|
||||
|
||||
private updatePosition(): void {
|
||||
if (!this.term || !this.ghostElement) return;
|
||||
|
||||
const dims = getXTermCellDimensions(this.term);
|
||||
|
||||
const buffer = this.term.buffer.active;
|
||||
const left = buffer.cursorX * dims.width;
|
||||
const top = buffer.cursorY * dims.height;
|
||||
|
||||
// Skip DOM writes if position hasn't changed (avoids unnecessary style recalc)
|
||||
if (left === this.lastLeft && top === this.lastTop) return;
|
||||
this.lastLeft = left;
|
||||
this.lastTop = top;
|
||||
|
||||
this.ghostElement.style.left = `${left}px`;
|
||||
this.ghostElement.style.top = `${top}px`;
|
||||
this.ghostElement.style.lineHeight = `${dims.height}px`;
|
||||
this.ghostElement.style.height = `${dims.height}px`;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
for (const d of this.disposables) d.dispose();
|
||||
this.disposables = [];
|
||||
this.containerElement?.remove();
|
||||
this.containerElement = null;
|
||||
this.ghostElement = null;
|
||||
this.term = null;
|
||||
}
|
||||
}
|
||||
424
components/terminal/autocomplete/commandHistoryStore.ts
Normal file
424
components/terminal/autocomplete/commandHistoryStore.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* Persistent command history store for terminal autocomplete.
|
||||
* Stores commands per host with frequency tracking and timestamp ordering.
|
||||
* Uses localStorageAdapter as the persistence layer (works in renderer process).
|
||||
*/
|
||||
|
||||
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
|
||||
const STORAGE_KEY = "netcatty:commandHistory";
|
||||
const MAX_ENTRIES = 10000;
|
||||
const MAX_ENTRIES_PER_HOST = 5000;
|
||||
|
||||
export interface HistoryEntry {
|
||||
command: string;
|
||||
hostId: string;
|
||||
/** OS type for cross-host matching */
|
||||
os: "linux" | "windows" | "macos";
|
||||
/** Number of times this exact command was executed */
|
||||
frequency: number;
|
||||
/** Timestamp of last execution */
|
||||
lastUsedAt: number;
|
||||
/** Timestamp of first execution */
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
interface HistoryStore {
|
||||
entries: HistoryEntry[];
|
||||
version: number;
|
||||
}
|
||||
|
||||
let cachedStore: HistoryStore | null = null;
|
||||
|
||||
function loadStore(): HistoryStore {
|
||||
if (cachedStore) return cachedStore;
|
||||
try {
|
||||
const parsed = localStorageAdapter.read<HistoryStore>(STORAGE_KEY);
|
||||
if (parsed) {
|
||||
cachedStore = parsed;
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
// Corrupted data, reset
|
||||
}
|
||||
cachedStore = { entries: [], version: 1 };
|
||||
return cachedStore;
|
||||
}
|
||||
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function saveStore(store: HistoryStore): void {
|
||||
cachedStore = store;
|
||||
// Debounce saves to avoid excessive writes
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(() => {
|
||||
const ok = localStorageAdapter.write(STORAGE_KEY, store);
|
||||
if (!ok) {
|
||||
// Storage full — evict lowest scored entries (not just oldest by insertion)
|
||||
const now = Date.now();
|
||||
store.entries.sort((a, b) => scoreEntryAt(b, now) - scoreEntryAt(a, now));
|
||||
store.entries = store.entries.slice(0, Math.floor(MAX_ENTRIES / 2));
|
||||
localStorageAdapter.write(STORAGE_KEY, store);
|
||||
}
|
||||
saveTimer = null;
|
||||
}, 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a command execution. Updates frequency if the command already exists
|
||||
* for this host, otherwise creates a new entry.
|
||||
*/
|
||||
export function recordCommand(
|
||||
command: string,
|
||||
hostId: string,
|
||||
os: "linux" | "windows" | "macos" = "linux",
|
||||
): void {
|
||||
const trimmed = command.trim();
|
||||
if (!trimmed || trimmed.length > 2000) return;
|
||||
|
||||
const store = loadStore();
|
||||
const now = Date.now();
|
||||
|
||||
// Find existing entry for same command + host
|
||||
const existingIdx = store.entries.findIndex(
|
||||
(e) => e.command === trimmed && e.hostId === hostId,
|
||||
);
|
||||
|
||||
if (existingIdx >= 0) {
|
||||
store.entries[existingIdx].frequency++;
|
||||
store.entries[existingIdx].lastUsedAt = now;
|
||||
} else {
|
||||
store.entries.push({
|
||||
command: trimmed,
|
||||
hostId,
|
||||
os,
|
||||
frequency: 1,
|
||||
lastUsedAt: now,
|
||||
createdAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
// Enforce per-host limit (evict by score, not insertion order)
|
||||
const hostEntries = store.entries.filter((e) => e.hostId === hostId);
|
||||
if (hostEntries.length > MAX_ENTRIES_PER_HOST) {
|
||||
hostEntries.sort((a, b) => scoreEntryAt(a, now) - scoreEntryAt(b, now));
|
||||
const toRemove = new Set(
|
||||
hostEntries.slice(0, hostEntries.length - MAX_ENTRIES_PER_HOST).map((e) => e.command),
|
||||
);
|
||||
store.entries = store.entries.filter(
|
||||
(e) => e.hostId !== hostId || !toRemove.has(e.command),
|
||||
);
|
||||
}
|
||||
|
||||
// Enforce global limit
|
||||
if (store.entries.length > MAX_ENTRIES) {
|
||||
store.entries.sort((a, b) => scoreEntryAt(b, now) - scoreEntryAt(a, now));
|
||||
store.entries = store.entries.slice(0, MAX_ENTRIES);
|
||||
}
|
||||
|
||||
saveStore(store);
|
||||
}
|
||||
|
||||
/**
|
||||
* Score an entry for ranking at a specific timestamp.
|
||||
* Caches Date.now() at query boundaries to avoid repeated syscalls during sort.
|
||||
*/
|
||||
function scoreEntryAt(entry: HistoryEntry, now: number): number {
|
||||
const ageMs = now - entry.lastUsedAt;
|
||||
const ageHours = ageMs / (1000 * 60 * 60);
|
||||
// Exponential decay: halve relevance every 24 hours
|
||||
const recencyScore = Math.pow(0.5, ageHours / 24);
|
||||
return entry.frequency * recencyScore;
|
||||
}
|
||||
|
||||
export interface HistoryQueryOptions {
|
||||
/** Filter by host ID (strict isolation — only this host's history) */
|
||||
hostId?: string;
|
||||
/** Maximum number of results */
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface RecentHistoryQueryOptions extends HistoryQueryOptions {
|
||||
/** Base command name, e.g. `cd` or `ls` */
|
||||
commandName: string;
|
||||
/** Exact command text to exclude from results */
|
||||
excludeCommand?: string;
|
||||
/** Optional path prefix to require on the current argument */
|
||||
argumentPrefix?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query history entries matching a prefix.
|
||||
* Returns entries sorted by relevance (frequency * recency).
|
||||
*/
|
||||
export function queryHistory(
|
||||
prefix: string,
|
||||
options: HistoryQueryOptions = {},
|
||||
): HistoryEntry[] {
|
||||
const { hostId, limit = 20 } = options;
|
||||
if (limit <= 0) return [];
|
||||
const store = loadStore();
|
||||
const lowerPrefix = prefix.toLowerCase();
|
||||
const now = Date.now(); // Cache once per query
|
||||
|
||||
const filtered = store.entries.filter((entry) => {
|
||||
// Must match prefix
|
||||
if (!entry.command.toLowerCase().startsWith(lowerPrefix)) return false;
|
||||
// Must not be identical to prefix
|
||||
if (entry.command === prefix) return false;
|
||||
|
||||
// Host filtering: strict per-host isolation
|
||||
if (hostId) {
|
||||
return entry.hostId === hostId;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Sort by score (frequency * recency)
|
||||
filtered.sort((a, b) => scoreEntryAt(b, now) - scoreEntryAt(a, now));
|
||||
|
||||
// Deduplicate by command text (keep highest scored)
|
||||
const seen = new Set<string>();
|
||||
const results: HistoryEntry[] = [];
|
||||
for (const entry of filtered) {
|
||||
if (seen.has(entry.command)) continue;
|
||||
seen.add(entry.command);
|
||||
results.push(entry);
|
||||
if (results.length >= limit) break;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuzzy query: matches commands containing all characters of the query
|
||||
* in order (not necessarily contiguous). Used as a fallback when prefix
|
||||
* matching yields few results.
|
||||
*/
|
||||
export function fuzzyQueryHistory(
|
||||
query: string,
|
||||
options: HistoryQueryOptions = {},
|
||||
): HistoryEntry[] {
|
||||
const { hostId, limit = 10 } = options;
|
||||
if (limit <= 0) return [];
|
||||
const store = loadStore();
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const now = Date.now(); // Cache once per query
|
||||
|
||||
const scored: { entry: HistoryEntry; matchScore: number }[] = [];
|
||||
|
||||
for (const entry of store.entries) {
|
||||
// Host filtering
|
||||
if (hostId) {
|
||||
if (entry.hostId !== hostId) continue;
|
||||
}
|
||||
|
||||
const matchScore = fuzzyScore(lowerQuery, entry.command.toLowerCase());
|
||||
if (matchScore > 0 && entry.command !== query) {
|
||||
scored.push({ entry, matchScore });
|
||||
}
|
||||
}
|
||||
|
||||
scored.sort((a, b) =>
|
||||
b.matchScore * scoreEntryAt(b.entry, now) - a.matchScore * scoreEntryAt(a.entry, now),
|
||||
);
|
||||
|
||||
const seen = new Set<string>();
|
||||
const results: HistoryEntry[] = [];
|
||||
for (const { entry } of scored) {
|
||||
if (seen.has(entry.command)) continue;
|
||||
seen.add(entry.command);
|
||||
results.push(entry);
|
||||
if (results.length >= limit) break;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the most recently used history entries for the same command name.
|
||||
* Useful when the user is currently completing a path argument and wants
|
||||
* a few recent command-line examples (e.g. recent `cd ...` commands).
|
||||
*/
|
||||
export function queryRecentHistoryByCommand(
|
||||
options: RecentHistoryQueryOptions,
|
||||
): HistoryEntry[] {
|
||||
const {
|
||||
commandName,
|
||||
excludeCommand,
|
||||
argumentPrefix,
|
||||
hostId,
|
||||
limit = 3,
|
||||
} = options;
|
||||
if (!commandName || limit <= 0) return [];
|
||||
|
||||
const store = loadStore();
|
||||
const trimmedCommandName = commandName.trim().toLowerCase();
|
||||
const commandPrefix = `${trimmedCommandName} `;
|
||||
const normalizedArgumentPrefix = normalizeArgumentToken(argumentPrefix ?? "");
|
||||
|
||||
const filtered = store.entries.filter((entry) => {
|
||||
const lowerCommand = entry.command.toLowerCase();
|
||||
if (lowerCommand !== trimmedCommandName && !lowerCommand.startsWith(commandPrefix)) {
|
||||
return false;
|
||||
}
|
||||
if (excludeCommand && entry.command === excludeCommand) return false;
|
||||
|
||||
if (normalizedArgumentPrefix) {
|
||||
const currentToken = normalizeArgumentToken(getCurrentCommandToken(entry.command));
|
||||
if (!currentToken.startsWith(normalizedArgumentPrefix)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (hostId) {
|
||||
return entry.hostId === hostId;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
filtered.sort((a, b) => b.lastUsedAt - a.lastUsedAt);
|
||||
|
||||
const seen = new Set<string>();
|
||||
const results: HistoryEntry[] = [];
|
||||
for (const entry of filtered) {
|
||||
if (seen.has(entry.command)) continue;
|
||||
seen.add(entry.command);
|
||||
results.push(entry);
|
||||
if (results.length >= limit) break;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function getCurrentCommandToken(command: string): string {
|
||||
const tokens = tokenizeShellLike(command);
|
||||
return tokens.length > 0 ? (tokens[tokens.length - 1] || "") : "";
|
||||
}
|
||||
|
||||
function normalizeArgumentToken(token: string): string {
|
||||
return token
|
||||
.trim()
|
||||
.replace(/^['"]/, "")
|
||||
.replace(/['"]$/, "")
|
||||
.replace(/\\ /g, " ")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function tokenizeShellLike(input: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
let current = "";
|
||||
let inSingleQuote = false;
|
||||
let inDoubleQuote = false;
|
||||
let escaped = false;
|
||||
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const ch = input[i];
|
||||
|
||||
if (escaped) {
|
||||
current += ch;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "\\") {
|
||||
escaped = true;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "'" && !inDoubleQuote) {
|
||||
inSingleQuote = !inSingleQuote;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '"' && !inSingleQuote) {
|
||||
inDoubleQuote = !inDoubleQuote;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === " " && !inSingleQuote && !inDoubleQuote) {
|
||||
if (current.length > 0) {
|
||||
tokens.push(current);
|
||||
current = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
current += ch;
|
||||
}
|
||||
|
||||
tokens.push(current);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a fuzzy match score. Returns 0 for no match.
|
||||
* Higher score = better match quality.
|
||||
* Rewards: first-char match, consecutive matches, word-boundary matches.
|
||||
*/
|
||||
function fuzzyScore(query: string, target: string): number {
|
||||
if (query.length === 0) return 0;
|
||||
if (query.length > target.length) return 0;
|
||||
|
||||
let score = 0;
|
||||
let queryIdx = 0;
|
||||
let prevMatchIdx = -2;
|
||||
|
||||
for (let i = 0; i < target.length && queryIdx < query.length; i++) {
|
||||
if (target[i] === query[queryIdx]) {
|
||||
queryIdx++;
|
||||
// First character bonus
|
||||
if (i === 0) score += 10;
|
||||
// Consecutive match bonus
|
||||
if (i === prevMatchIdx + 1) score += 5;
|
||||
// Word boundary bonus
|
||||
if (i === 0 || target[i - 1] === " " || target[i - 1] === "/" ||
|
||||
target[i - 1] === "-" || target[i - 1] === "_") {
|
||||
score += 3;
|
||||
}
|
||||
score += 1;
|
||||
prevMatchIdx = i;
|
||||
}
|
||||
}
|
||||
|
||||
// All query characters must be matched
|
||||
return queryIdx === query.length ? score : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a specific command from history for a host.
|
||||
*/
|
||||
export function deleteHistoryEntry(command: string, hostId: string): void {
|
||||
const store = loadStore();
|
||||
store.entries = store.entries.filter(
|
||||
(e) => !(e.command === command && e.hostId === hostId),
|
||||
);
|
||||
saveStore(store);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all history for a specific host, or all history if no hostId given.
|
||||
*/
|
||||
export function clearHistory(hostId?: string): void {
|
||||
const store = loadStore();
|
||||
if (hostId) {
|
||||
store.entries = store.entries.filter((e) => e.hostId !== hostId);
|
||||
} else {
|
||||
store.entries = [];
|
||||
}
|
||||
saveStore(store);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total number of stored history entries.
|
||||
*/
|
||||
export function getHistoryCount(hostId?: string): number {
|
||||
const store = loadStore();
|
||||
if (hostId) {
|
||||
return store.entries.filter((e) => e.hostId === hostId).length;
|
||||
}
|
||||
return store.entries.length;
|
||||
}
|
||||
616
components/terminal/autocomplete/completionEngine.ts
Normal file
616
components/terminal/autocomplete/completionEngine.ts
Normal file
@@ -0,0 +1,616 @@
|
||||
/**
|
||||
* Context-aware completion engine.
|
||||
* Combines multiple data sources:
|
||||
* 1. Command history (highest priority)
|
||||
* 2. @withfig/autocomplete specs (subcommands, options, args)
|
||||
* 3. Fuzzy history matching (fallback)
|
||||
*
|
||||
* Parses the current command line to determine context (command, subcommand,
|
||||
* option, or argument position) and provides appropriate suggestions.
|
||||
*/
|
||||
|
||||
import {
|
||||
queryHistory,
|
||||
queryRecentHistoryByCommand,
|
||||
fuzzyQueryHistory,
|
||||
type HistoryQueryOptions,
|
||||
} from "./commandHistoryStore";
|
||||
import {
|
||||
loadSpec,
|
||||
hasSpec,
|
||||
getAvailableSpecs,
|
||||
normalizeCommandName,
|
||||
resolveNames,
|
||||
type FigSpec,
|
||||
type FigSubcommand,
|
||||
type FigOption,
|
||||
} from "./figSpecLoader";
|
||||
import {
|
||||
shouldDoPathCompletion,
|
||||
getPathSuggestions,
|
||||
resolvePathComponents,
|
||||
} from "./remotePathCompleter";
|
||||
|
||||
/** Source indicator for where a suggestion came from */
|
||||
export type SuggestionSource = "history" | "command" | "subcommand" | "option" | "arg" | "path";
|
||||
|
||||
export interface CompletionSuggestion {
|
||||
/** The text to insert */
|
||||
text: string;
|
||||
/** Display text (may differ from insert text) */
|
||||
displayText: string;
|
||||
/** Optional description */
|
||||
description?: string;
|
||||
/** Source of this suggestion */
|
||||
source: SuggestionSource;
|
||||
/** Relevance score (higher = more relevant) */
|
||||
score: number;
|
||||
/** For history entries: execution frequency */
|
||||
frequency?: number;
|
||||
/** For path suggestions: file type */
|
||||
fileType?: "file" | "directory" | "symlink";
|
||||
}
|
||||
|
||||
export interface CompletionContext {
|
||||
/** Full command line text */
|
||||
commandLine: string;
|
||||
/** Current word being typed */
|
||||
currentWord: string;
|
||||
/** Index of the current word in the parsed tokens */
|
||||
wordIndex: number;
|
||||
/** Parsed command tokens */
|
||||
tokens: string[];
|
||||
/** The base command name (first token) */
|
||||
commandName: string;
|
||||
/** Whether the current position is after a recognized option that expects an argument */
|
||||
isOptionArg: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a command line string into tokens, handling quoting.
|
||||
*/
|
||||
function tokenize(input: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
let current = "";
|
||||
let inSingleQuote = false;
|
||||
let inDoubleQuote = false;
|
||||
let escaped = false;
|
||||
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const ch = input[i];
|
||||
|
||||
if (escaped) {
|
||||
current += ch;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "\\") {
|
||||
escaped = true;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "'" && !inDoubleQuote) {
|
||||
inSingleQuote = !inSingleQuote;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '"' && !inSingleQuote) {
|
||||
inDoubleQuote = !inDoubleQuote;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === " " && !inSingleQuote && !inDoubleQuote) {
|
||||
if (current.length > 0) {
|
||||
tokens.push(current);
|
||||
current = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
current += ch;
|
||||
}
|
||||
|
||||
// Always include the last token (even if empty, to indicate trailing space)
|
||||
tokens.push(current);
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the current command line into a CompletionContext.
|
||||
*/
|
||||
export function parseCommandLine(input: string): CompletionContext {
|
||||
const tokens = tokenize(input);
|
||||
const wordIndex = tokens.length - 1;
|
||||
const currentWord = tokens[wordIndex] || "";
|
||||
const commandName = tokens.length > 0 ? normalizeCommandName(tokens[0]) : "";
|
||||
|
||||
return {
|
||||
commandLine: input,
|
||||
currentWord,
|
||||
wordIndex,
|
||||
tokens,
|
||||
commandName,
|
||||
isOptionArg: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Main completion function. Returns sorted suggestions from all sources.
|
||||
* Ghost text should use completions[0].text instead of a separate query.
|
||||
*/
|
||||
export async function getCompletions(
|
||||
input: string,
|
||||
options: {
|
||||
hostId?: string;
|
||||
os?: "linux" | "windows" | "macos";
|
||||
maxResults?: number;
|
||||
/** Session ID for remote path completion */
|
||||
sessionId?: string;
|
||||
/** Connection protocol (ssh, local, telnet, serial) */
|
||||
protocol?: string;
|
||||
/** Current working directory (from OSC 7) */
|
||||
cwd?: string;
|
||||
} = {},
|
||||
): Promise<CompletionSuggestion[]> {
|
||||
const { hostId, maxResults = 15 } = options;
|
||||
|
||||
if (!input || input.trim().length === 0) return [];
|
||||
|
||||
const ctx = parseCommandLine(input);
|
||||
const suggestions: CompletionSuggestion[] = [];
|
||||
const seenSuggestionTexts = new Set<string>();
|
||||
const pathCheck = ctx.commandName && ctx.wordIndex >= 1
|
||||
? shouldDoPathCompletion(ctx, undefined)
|
||||
: { shouldComplete: false, foldersOnly: false };
|
||||
const preferPathSuggestions = pathCheck.shouldComplete;
|
||||
const resultLimit = preferPathSuggestions ? Math.max(maxResults, 24) : maxResults;
|
||||
|
||||
// 1. History suggestions (full command line prefix match)
|
||||
// Cap history to leave room for spec suggestions in the popup
|
||||
const historyOpts: HistoryQueryOptions = {
|
||||
hostId,
|
||||
limit: preferPathSuggestions ? 0 : 5,
|
||||
};
|
||||
|
||||
const historyMatches = queryHistory(input, historyOpts);
|
||||
for (const entry of historyMatches) {
|
||||
const suggestion = {
|
||||
text: entry.command,
|
||||
displayText: entry.command,
|
||||
source: "history",
|
||||
score: 1000 + entry.frequency,
|
||||
frequency: entry.frequency,
|
||||
} satisfies CompletionSuggestion;
|
||||
suggestions.push(suggestion);
|
||||
seenSuggestionTexts.add(suggestion.text);
|
||||
}
|
||||
|
||||
if (preferPathSuggestions && ctx.commandName) {
|
||||
const recentHistory = queryRecentHistoryByCommand({
|
||||
commandName: ctx.commandName,
|
||||
excludeCommand: input,
|
||||
argumentPrefix: normalizeHistoryPathPrefix(ctx.currentWord),
|
||||
hostId,
|
||||
limit: 3,
|
||||
});
|
||||
for (let index = 0; index < recentHistory.length; index++) {
|
||||
const entry = recentHistory[index];
|
||||
if (seenSuggestionTexts.has(entry.command)) continue;
|
||||
const suggestion = {
|
||||
text: entry.command,
|
||||
displayText: entry.command,
|
||||
source: "history",
|
||||
score: 900 - index,
|
||||
frequency: entry.frequency,
|
||||
} satisfies CompletionSuggestion;
|
||||
suggestions.push(suggestion);
|
||||
seenSuggestionTexts.add(suggestion.text);
|
||||
}
|
||||
}
|
||||
|
||||
const canQueryPaths = options.protocol === "local" || options.sessionId !== undefined;
|
||||
|
||||
const specPromise = ctx.commandName && ctx.wordIndex >= 0
|
||||
? getSpecSuggestions(ctx)
|
||||
: Promise.resolve([]);
|
||||
const pathPromise = canQueryPaths && pathCheck.shouldComplete
|
||||
? getPathSuggestions(ctx, {
|
||||
sessionId: options.sessionId,
|
||||
protocol: options.protocol,
|
||||
cwd: options.cwd,
|
||||
foldersOnly: pathCheck.foldersOnly,
|
||||
})
|
||||
: Promise.resolve([]);
|
||||
|
||||
const [specSugs, pathEntries] = await Promise.all([specPromise, pathPromise]);
|
||||
|
||||
for (const suggestion of specSugs) {
|
||||
suggestions.push(suggestion);
|
||||
seenSuggestionTexts.add(suggestion.text);
|
||||
}
|
||||
|
||||
if (pathEntries.length > 0) {
|
||||
const { pathPrefix, quoteSuffix } = resolvePathComponents(ctx.currentWord, options.cwd);
|
||||
const isQuotedPath = ctx.currentWord.startsWith('"') || ctx.currentWord.startsWith("'");
|
||||
for (const entry of pathEntries) {
|
||||
const insertName = isQuotedPath || !entry.name.includes(" ")
|
||||
? entry.name
|
||||
: entry.name.replace(/ /g, "\\ ");
|
||||
const suffix = entry.type === "directory" ? "/" : "";
|
||||
const fullPath = pathPrefix + insertName + suffix + quoteSuffix;
|
||||
const suggestion = {
|
||||
text: rebuildCommand(ctx.tokens, ctx.wordIndex, fullPath),
|
||||
displayText: entry.name + suffix,
|
||||
source: "path",
|
||||
score: 750,
|
||||
fileType: entry.type,
|
||||
} satisfies CompletionSuggestion;
|
||||
suggestions.push(suggestion);
|
||||
seenSuggestionTexts.add(suggestion.text);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fuzzy history fallback (if prefix match yields few results)
|
||||
if (!preferPathSuggestions && suggestions.length < 3 && input.length >= 2) {
|
||||
const fuzzyMatches = fuzzyQueryHistory(input, {
|
||||
...historyOpts,
|
||||
limit: 5,
|
||||
});
|
||||
for (const entry of fuzzyMatches) {
|
||||
if (seenSuggestionTexts.has(entry.command)) continue;
|
||||
const suggestion = {
|
||||
text: entry.command,
|
||||
displayText: entry.command,
|
||||
source: "history",
|
||||
score: 500 + entry.frequency,
|
||||
frequency: entry.frequency,
|
||||
} satisfies CompletionSuggestion;
|
||||
suggestions.push(suggestion);
|
||||
seenSuggestionTexts.add(suggestion.text);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score descending
|
||||
suggestions.sort((a, b) => b.score - a.score);
|
||||
|
||||
// Deduplicate
|
||||
const seen = new Set<string>();
|
||||
const unique: CompletionSuggestion[] = [];
|
||||
for (const s of suggestions) {
|
||||
if (seen.has(s.text)) continue;
|
||||
seen.add(s.text);
|
||||
unique.push(s);
|
||||
if (unique.length >= resultLimit) break;
|
||||
}
|
||||
|
||||
return unique;
|
||||
}
|
||||
|
||||
function normalizeHistoryPathPrefix(token: string): string {
|
||||
return token
|
||||
.trim()
|
||||
.replace(/^['"]/, "")
|
||||
.replace(/['"]$/, "")
|
||||
.replace(/\\ /g, " ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get suggestions from Fig spec + return resolved args (for path detection reuse).
|
||||
*/
|
||||
async function getSpecSuggestions(ctx: CompletionContext): Promise<CompletionSuggestion[]> {
|
||||
const suggestions: CompletionSuggestion[] = [];
|
||||
|
||||
const specAvailable = await hasSpec(ctx.commandName);
|
||||
if (!specAvailable) {
|
||||
if (ctx.wordIndex === 0 && ctx.currentWord.length >= 1) {
|
||||
return await getCommandNameSuggestions(ctx.currentWord);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
const spec = await loadSpec(ctx.commandName);
|
||||
if (!spec) return [];
|
||||
|
||||
// If we're still typing the command name (partial match, not yet complete)
|
||||
if (ctx.wordIndex === 0) {
|
||||
const typedLower = ctx.currentWord.toLowerCase();
|
||||
const specNames = resolveNames(spec.name);
|
||||
const isExactMatch = specNames.some((n) => n.toLowerCase() === typedLower);
|
||||
if (!isExactMatch) return [];
|
||||
|
||||
// Show subcommands as preview (user typed full command but no space yet)
|
||||
if (spec.subcommands) {
|
||||
for (const sub of spec.subcommands) {
|
||||
const names = resolveNames(sub.name);
|
||||
suggestions.push({
|
||||
text: ctx.currentWord + " " + names[0],
|
||||
displayText: names[0],
|
||||
description: sub.description,
|
||||
source: "subcommand",
|
||||
score: 800,
|
||||
});
|
||||
if (suggestions.length >= 10) break;
|
||||
}
|
||||
}
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
// Navigate the spec tree based on typed tokens
|
||||
let resolved = resolveSpecContext(spec, ctx.tokens.slice(1, ctx.wordIndex));
|
||||
const currentToken = ctx.currentWord;
|
||||
|
||||
// Check if currentToken exactly matches a subcommand — if so, navigate into it
|
||||
// and show its children as preview (e.g., "git commit" shows commit's options)
|
||||
if (currentToken && resolved.subcommands) {
|
||||
const exactMatch = resolved.subcommands.find((s) => {
|
||||
const names = resolveNames(s.name);
|
||||
return names.includes(currentToken);
|
||||
});
|
||||
if (exactMatch) {
|
||||
// Navigate into the matched subcommand and show its children
|
||||
const childResolved = resolveSpecContext(spec, ctx.tokens.slice(1, ctx.wordIndex + 1));
|
||||
|
||||
// Show child subcommands
|
||||
if (childResolved.subcommands) {
|
||||
for (const sub of childResolved.subcommands) {
|
||||
const names = resolveNames(sub.name);
|
||||
suggestions.push({
|
||||
text: ctx.commandLine + " " + names[0],
|
||||
displayText: names[0],
|
||||
description: sub.description,
|
||||
source: "subcommand",
|
||||
score: 800,
|
||||
});
|
||||
if (suggestions.length >= 10) break;
|
||||
}
|
||||
}
|
||||
// Show child options
|
||||
appendOptionPreviewSuggestions(
|
||||
suggestions,
|
||||
ctx.commandLine,
|
||||
childResolved.options?.length ? childResolved.options : childResolved.fallbackOptions,
|
||||
15,
|
||||
);
|
||||
return suggestions;
|
||||
}
|
||||
}
|
||||
|
||||
// Suggest subcommands (prefix match, excluding exact matches)
|
||||
if (resolved.subcommands) {
|
||||
for (const sub of resolved.subcommands) {
|
||||
const names = resolveNames(sub.name);
|
||||
for (const name of names) {
|
||||
if (name.startsWith(currentToken) && name !== currentToken) {
|
||||
suggestions.push({
|
||||
text: rebuildCommand(ctx.tokens, ctx.wordIndex, name),
|
||||
displayText: name,
|
||||
description: sub.description,
|
||||
source: "subcommand",
|
||||
score: 800,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Suggest options
|
||||
const hasDirectOptionSuggestions = appendOptionSuggestions(
|
||||
suggestions,
|
||||
ctx,
|
||||
currentToken,
|
||||
resolved.options,
|
||||
);
|
||||
if (!hasDirectOptionSuggestions) {
|
||||
appendOptionSuggestions(suggestions, ctx, currentToken, resolved.fallbackOptions);
|
||||
}
|
||||
|
||||
// Suggest argument values from suggestions in the spec
|
||||
if (resolved.args) {
|
||||
const args = Array.isArray(resolved.args) ? resolved.args : [resolved.args];
|
||||
for (const arg of args) {
|
||||
if (arg.suggestions) {
|
||||
for (const sug of arg.suggestions) {
|
||||
const sugName = typeof sug === "string" ? sug : (Array.isArray(sug.name) ? sug.name[0] : sug.name);
|
||||
const sugDesc = typeof sug === "string" ? undefined : sug.description;
|
||||
if (sugName.startsWith(currentToken) && sugName !== currentToken) {
|
||||
suggestions.push({
|
||||
text: rebuildCommand(ctx.tokens, ctx.wordIndex, sugName),
|
||||
displayText: sugName,
|
||||
description: sugDesc,
|
||||
source: "arg",
|
||||
score: 600,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get command name suggestions by matching against available specs.
|
||||
* Uses the already-imported getAvailableSpecs directly (no dynamic self-import).
|
||||
*/
|
||||
async function getCommandNameSuggestions(prefix: string): Promise<CompletionSuggestion[]> {
|
||||
const specs = await getAvailableSpecs();
|
||||
const lower = prefix.toLowerCase();
|
||||
const suggestions: CompletionSuggestion[] = [];
|
||||
|
||||
for (const name of specs) {
|
||||
// Skip sub-path specs like "aws/s3", "dotnet/dotnet-build" — not direct shell commands
|
||||
if (name.includes("/")) continue;
|
||||
if (name.startsWith(lower) && name !== lower) {
|
||||
suggestions.push({
|
||||
text: name,
|
||||
displayText: name,
|
||||
source: "command",
|
||||
score: 600,
|
||||
});
|
||||
if (suggestions.length >= 10) break;
|
||||
}
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
interface ResolvedContext {
|
||||
subcommands?: FigSubcommand[];
|
||||
options?: FigOption[];
|
||||
fallbackOptions?: FigOption[];
|
||||
args?: FigSubcommand["args"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the spec tree following the typed tokens to find the current context.
|
||||
* Handles options with arguments (e.g., --name value) by skipping the value token.
|
||||
*/
|
||||
function resolveSpecContext(spec: FigSpec, consumedTokens: string[]): ResolvedContext {
|
||||
let current: FigSubcommand = spec;
|
||||
let inheritedOptions: FigOption[] = [];
|
||||
let skipNext = false;
|
||||
let lastOptionArgs: FigSubcommand["args"] | undefined;
|
||||
|
||||
for (const token of consumedTokens) {
|
||||
// Skip this token if it's the argument value of a previous option
|
||||
if (skipNext) {
|
||||
skipNext = false;
|
||||
lastOptionArgs = undefined;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle option flags
|
||||
if (token.startsWith("-")) {
|
||||
// Check if this option expects an argument
|
||||
const opt = [...(current.options ?? []), ...inheritedOptions].find((candidate) => {
|
||||
const names = resolveNames(candidate.name);
|
||||
return names.includes(token);
|
||||
});
|
||||
if (opt?.args) {
|
||||
// This option expects an argument — the next token is its value
|
||||
const args = Array.isArray(opt.args) ? opt.args : [opt.args];
|
||||
if (args.length > 0 && !args[0].isOptional) {
|
||||
skipNext = true;
|
||||
lastOptionArgs = opt.args; // Track for the case where next token is currentWord
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to find a matching subcommand
|
||||
if (current.subcommands) {
|
||||
const sub = current.subcommands.find((s) => {
|
||||
const names = resolveNames(s.name);
|
||||
return names.includes(token);
|
||||
});
|
||||
if (sub) {
|
||||
inheritedOptions = mergeOptionLists(inheritedOptions, current.options);
|
||||
current = sub;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// If no subcommand matched, we're at the args level
|
||||
break;
|
||||
}
|
||||
|
||||
// If skipNext is still true, the currentWord is an option's arg value
|
||||
// (e.g., "git archive --format |" — currentWord is the format value)
|
||||
// Return the option's args instead of the subcommand's args.
|
||||
if (skipNext && lastOptionArgs) {
|
||||
return {
|
||||
subcommands: undefined,
|
||||
options: undefined,
|
||||
fallbackOptions: inheritedOptions.length > 0 ? inheritedOptions : undefined,
|
||||
args: lastOptionArgs,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
subcommands: current.subcommands,
|
||||
options: current.options ? [...current.options] : undefined,
|
||||
fallbackOptions: inheritedOptions.length > 0 ? inheritedOptions : undefined,
|
||||
args: current.args,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeOptionLists(
|
||||
left: FigOption[] | undefined,
|
||||
right: FigOption[] | undefined,
|
||||
): FigOption[] {
|
||||
const merged: FigOption[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const option of [...(left ?? []), ...(right ?? [])]) {
|
||||
const key = resolveNames(option.name).sort().join("\0");
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
merged.push(option);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function appendOptionSuggestions(
|
||||
suggestions: CompletionSuggestion[],
|
||||
ctx: CompletionContext,
|
||||
currentToken: string,
|
||||
options: FigOption[] | undefined,
|
||||
): boolean {
|
||||
if (!options || options.length === 0) return false;
|
||||
|
||||
let added = false;
|
||||
for (const opt of options) {
|
||||
const names = resolveNames(opt.name);
|
||||
for (const name of names) {
|
||||
if (name.startsWith(currentToken) && name !== currentToken) {
|
||||
suggestions.push({
|
||||
text: rebuildCommand(ctx.tokens, ctx.wordIndex, name),
|
||||
displayText: name,
|
||||
description: opt.description,
|
||||
source: "option",
|
||||
score: 700,
|
||||
});
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return added;
|
||||
}
|
||||
|
||||
function appendOptionPreviewSuggestions(
|
||||
suggestions: CompletionSuggestion[],
|
||||
commandLine: string,
|
||||
options: FigOption[] | undefined,
|
||||
limit: number,
|
||||
): void {
|
||||
if (!options || options.length === 0 || suggestions.length >= limit) return;
|
||||
|
||||
for (const opt of options) {
|
||||
const names = resolveNames(opt.name);
|
||||
suggestions.push({
|
||||
text: commandLine + " " + names[0],
|
||||
displayText: names[0],
|
||||
description: opt.description,
|
||||
source: "option",
|
||||
score: 700,
|
||||
});
|
||||
if (suggestions.length >= limit) break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the full command text with a replacement at a specific token index.
|
||||
*/
|
||||
function rebuildCommand(tokens: string[], replaceIndex: number, replacement: string): string {
|
||||
const rebuilt = [...tokens];
|
||||
rebuilt[replaceIndex] = replacement;
|
||||
return rebuilt.join(" ");
|
||||
}
|
||||
198
components/terminal/autocomplete/figSpecLoader.ts
Normal file
198
components/terminal/autocomplete/figSpecLoader.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Loader for @withfig/autocomplete command specifications.
|
||||
* Loads specs via Electron main process IPC (Node.js require),
|
||||
* which reliably accesses node_modules in both dev and production.
|
||||
*/
|
||||
|
||||
/** Minimal Fig spec types — mirrors @withfig/autocomplete-types */
|
||||
export interface FigOption {
|
||||
name: string | string[];
|
||||
description?: string;
|
||||
args?: FigArg | FigArg[];
|
||||
isRequired?: boolean;
|
||||
isPersistent?: boolean;
|
||||
exclusiveOn?: string[];
|
||||
}
|
||||
|
||||
export interface FigArg {
|
||||
name?: string;
|
||||
description?: string;
|
||||
suggestions?: (string | FigSuggestion)[];
|
||||
template?: string | string[];
|
||||
isOptional?: boolean;
|
||||
isVariadic?: boolean;
|
||||
generators?: unknown;
|
||||
}
|
||||
|
||||
export interface FigSuggestion {
|
||||
name: string | string[];
|
||||
description?: string;
|
||||
icon?: string;
|
||||
type?: string;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
export interface FigSubcommand {
|
||||
name: string | string[];
|
||||
description?: string;
|
||||
subcommands?: FigSubcommand[];
|
||||
options?: FigOption[];
|
||||
args?: FigArg | FigArg[];
|
||||
}
|
||||
|
||||
export interface FigSpec extends FigSubcommand {
|
||||
// Top-level spec may include additional metadata
|
||||
}
|
||||
|
||||
// Bridge type augmentation
|
||||
interface FigSpecBridge {
|
||||
listFigSpecs?: () => Promise<string[]>;
|
||||
loadFigSpec?: (commandName: string) => Promise<FigSpec | null>;
|
||||
}
|
||||
|
||||
function getBridge(): FigSpecBridge | undefined {
|
||||
return (window as Window & { netcatty?: FigSpecBridge }).netcatty;
|
||||
}
|
||||
|
||||
// Cache loaded specs
|
||||
const specCache = new Map<string, FigSpec | null>();
|
||||
|
||||
// In-flight loading promises to avoid duplicate loads
|
||||
const inFlightLoads = new Map<string, Promise<FigSpec | null>>();
|
||||
|
||||
// All available spec names
|
||||
let availableSpecs: string[] | null = null;
|
||||
let availableSpecsSet: Set<string> | null = null;
|
||||
|
||||
/**
|
||||
* Get the list of all available command specs via IPC.
|
||||
*/
|
||||
export async function getAvailableSpecs(): Promise<string[]> {
|
||||
// Only return cache if it has actual specs (not an empty failure)
|
||||
if (availableSpecs && availableSpecs.length > 0) return availableSpecs;
|
||||
|
||||
try {
|
||||
const bridge = getBridge();
|
||||
if (bridge?.listFigSpecs) {
|
||||
const specs = await bridge.listFigSpecs();
|
||||
if (Array.isArray(specs) && specs.length > 0) {
|
||||
availableSpecs = specs;
|
||||
availableSpecsSet = new Set(specs);
|
||||
return specs;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[Autocomplete] figspec bridge error:", err);
|
||||
}
|
||||
|
||||
// Don't cache empty — allow retry on next call
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a command specification by name via IPC.
|
||||
* Uses in-flight deduplication to avoid loading the same spec twice concurrently.
|
||||
*/
|
||||
export async function loadSpec(commandName: string): Promise<FigSpec | null> {
|
||||
if (specCache.has(commandName)) {
|
||||
return specCache.get(commandName) ?? null;
|
||||
}
|
||||
|
||||
const existing = inFlightLoads.get(commandName);
|
||||
if (existing) return existing;
|
||||
|
||||
const loadPromise = (async (): Promise<FigSpec | null> => {
|
||||
try {
|
||||
const bridge = getBridge();
|
||||
if (!bridge?.loadFigSpec) {
|
||||
// Don't cache — bridge may not be ready yet (dev reload, non-Electron preview)
|
||||
return null;
|
||||
}
|
||||
|
||||
const spec = await bridge.loadFigSpec(commandName);
|
||||
if (spec) {
|
||||
specCache.set(commandName, spec);
|
||||
}
|
||||
// Don't cache null — the load may have failed transiently (bridge not ready, etc.)
|
||||
// Only cache null when we're confident the spec doesn't exist (hasSpec returned false)
|
||||
return spec;
|
||||
} catch {
|
||||
// Don't cache failures — allow retry on next request
|
||||
return null;
|
||||
} finally {
|
||||
inFlightLoads.delete(commandName);
|
||||
}
|
||||
})();
|
||||
|
||||
inFlightLoads.set(commandName, loadPromise);
|
||||
return loadPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a spec exists for a given command name (without loading it).
|
||||
*/
|
||||
export async function hasSpec(commandName: string): Promise<boolean> {
|
||||
// Only trust positive cache hits (spec loaded successfully).
|
||||
// Null entries may be stale failures from preload — ignore them.
|
||||
const cached = specCache.get(commandName);
|
||||
if (cached) return true;
|
||||
|
||||
await getAvailableSpecs();
|
||||
return availableSpecsSet?.has(commandName) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload commonly used specs in batches to avoid overwhelming IPC.
|
||||
* Only call this when autocomplete is enabled.
|
||||
*/
|
||||
export function preloadCommonSpecs(): void {
|
||||
const common = [
|
||||
"git", "docker", "kubectl", "npm", "yarn", "pnpm",
|
||||
"ls", "cd", "cat", "grep", "find", "ssh", "scp",
|
||||
"curl", "wget", "tar", "zip", "unzip", "make",
|
||||
"python", "python3", "pip", "pip3", "node",
|
||||
"systemctl", "journalctl", "apt", "yum", "brew",
|
||||
"vim", "nano", "less", "head", "tail", "sort",
|
||||
"awk", "sed", "chmod", "chown", "cp", "mv", "rm", "mkdir",
|
||||
];
|
||||
|
||||
const BATCH_SIZE = 8;
|
||||
let offset = 0;
|
||||
|
||||
const loadBatch = () => {
|
||||
const batch = common.slice(offset, offset + BATCH_SIZE);
|
||||
if (batch.length === 0) return;
|
||||
|
||||
for (const name of batch) {
|
||||
loadSpec(name).catch(() => {});
|
||||
}
|
||||
|
||||
offset += BATCH_SIZE;
|
||||
if (offset < common.length) {
|
||||
if (typeof requestIdleCallback === "function") {
|
||||
requestIdleCallback(() => loadBatch());
|
||||
} else {
|
||||
setTimeout(loadBatch, 100);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(loadBatch, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get normalized name variants (e.g., "git" from "/usr/bin/git").
|
||||
*/
|
||||
export function normalizeCommandName(rawCommand: string): string {
|
||||
const parts = rawCommand.split("/");
|
||||
let name = parts[parts.length - 1];
|
||||
name = name.replace(/\.(exe|cmd|bat|sh|bash|zsh|fish)$/i, "");
|
||||
return name.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve names from a Fig spec name field (which can be string or string[]).
|
||||
*/
|
||||
export function resolveNames(name: string | string[]): string[] {
|
||||
return Array.isArray(name) ? name : [name];
|
||||
}
|
||||
5
components/terminal/autocomplete/index.ts
Normal file
5
components/terminal/autocomplete/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { useTerminalAutocomplete, DEFAULT_AUTOCOMPLETE_SETTINGS } from "./useTerminalAutocomplete";
|
||||
export type { AutocompleteSettings, AutocompleteState, TerminalAutocompleteHandle } from "./useTerminalAutocomplete";
|
||||
export { default as AutocompletePopup } from "./AutocompletePopup";
|
||||
export type { CompletionSuggestion, SuggestionSource } from "./completionEngine";
|
||||
export { recordCommand, clearHistory, deleteHistoryEntry, getHistoryCount } from "./commandHistoryStore";
|
||||
225
components/terminal/autocomplete/promptDetector.ts
Normal file
225
components/terminal/autocomplete/promptDetector.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Prompt detector for terminal autocomplete.
|
||||
* Detects whether the user is currently at a shell prompt (vs. inside a running program).
|
||||
* Uses xterm.js buffer analysis to identify common prompt patterns.
|
||||
*
|
||||
* Strategy: scan left-to-right for the FIRST prompt-ending character ($ # % > etc.)
|
||||
* followed by a space. Exclude false positives like $HOME, $PATH, etc.
|
||||
*/
|
||||
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
|
||||
/**
|
||||
* Patterns that indicate the user is NOT at a prompt
|
||||
* (e.g., inside vim, less, man, top, etc.)
|
||||
*/
|
||||
const NON_PROMPT_PATTERNS = [
|
||||
/^~$/, // vim empty line marker
|
||||
/^\s*--\s*More\s*--/, // less/more pager
|
||||
/^\s*\(END\)/, // less end marker
|
||||
/^:\s*$/, // vim command mode
|
||||
/^\s*~\s*$/, // vim tilde lines
|
||||
/^>{1,3}\s/, // Bare > (bash PS2 continuation), >> or >>> (python REPL)
|
||||
/^\w+>\s/, // mysql> / sqlite> / redis-cli> REPL prompts
|
||||
];
|
||||
|
||||
export interface PromptDetectionResult {
|
||||
/** Whether a prompt is detected on the current line */
|
||||
isAtPrompt: boolean;
|
||||
/** The detected prompt text (everything before user input) */
|
||||
promptText: string;
|
||||
/** The user's current input (after the prompt) */
|
||||
userInput: string;
|
||||
/** The cursor column position within the user input */
|
||||
cursorOffset: number;
|
||||
}
|
||||
|
||||
const NO_PROMPT: PromptDetectionResult = {
|
||||
isAtPrompt: false, promptText: "", userInput: "", cursorOffset: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect whether the terminal cursor is at a shell prompt and extract the current user input.
|
||||
*/
|
||||
export function detectPrompt(term: XTerm): PromptDetectionResult {
|
||||
const buffer = term.buffer.active;
|
||||
const cursorY = buffer.cursorY + buffer.baseY;
|
||||
const cursorX = buffer.cursorX;
|
||||
const line = buffer.getLine(cursorY);
|
||||
|
||||
if (!line) return NO_PROMPT;
|
||||
|
||||
// translateToString(false) preserves trailing spaces — important for cursor-based
|
||||
// input extraction (trailing space triggers empty token for option suggestions)
|
||||
const lineText = line.translateToString(false);
|
||||
|
||||
// Check for non-prompt patterns (pagers, editors, etc.)
|
||||
for (const pattern of NON_PROMPT_PATTERNS) {
|
||||
if (pattern.test(lineText)) return NO_PROMPT;
|
||||
}
|
||||
|
||||
// Empty line
|
||||
if (lineText.trim().length === 0) return NO_PROMPT;
|
||||
|
||||
// Try to find the prompt boundary on the current line
|
||||
const promptEnd = findPromptBoundary(lineText);
|
||||
if (promptEnd >= 0) {
|
||||
const promptText = lineText.substring(0, promptEnd);
|
||||
// Use cursor position to determine actual input length — don't trim trailing
|
||||
// spaces since they're significant for autocomplete (e.g., "git commit " should
|
||||
// produce an empty trailing token to trigger option suggestions).
|
||||
const rawInput = lineText.substring(promptEnd);
|
||||
const userInput = rawInput.substring(0, Math.max(0, cursorX - promptEnd));
|
||||
const cursorOffset = Math.max(0, cursorX - promptEnd);
|
||||
|
||||
return { isAtPrompt: true, promptText, userInput, cursorOffset };
|
||||
}
|
||||
|
||||
// Handle wrapped lines: if the prompt is on a previous row (e.g., long path or
|
||||
// long command wrapped onto multiple rows), look upward for the prompt line.
|
||||
// The current row's content is continuation of the command.
|
||||
if (line.isWrapped) {
|
||||
// Walk up to find the first non-wrapped line (the prompt line)
|
||||
let promptRow = cursorY - 1;
|
||||
while (promptRow >= 0) {
|
||||
const prevLine = buffer.getLine(promptRow);
|
||||
if (!prevLine) break;
|
||||
if (!prevLine.isWrapped) break;
|
||||
promptRow--;
|
||||
}
|
||||
|
||||
const promptLine = buffer.getLine(promptRow);
|
||||
if (promptLine) {
|
||||
const promptLineText = promptLine.translateToString(false);
|
||||
const pEnd = findPromptBoundary(promptLineText);
|
||||
if (pEnd >= 0) {
|
||||
const promptText = promptLineText.substring(0, pEnd);
|
||||
// Concatenate all rows from promptRow to cursorY to get full input
|
||||
let fullInput = promptLineText.substring(pEnd);
|
||||
for (let row = promptRow + 1; row <= cursorY; row++) {
|
||||
const rowLine = buffer.getLine(row);
|
||||
if (rowLine) fullInput += rowLine.translateToString(false);
|
||||
}
|
||||
// Trim to cursor position on the last row
|
||||
const totalCols = term.cols;
|
||||
const charsBeforeCursorRow = (cursorY - promptRow) * totalCols - pEnd;
|
||||
const userInput = fullInput.substring(0, charsBeforeCursorRow + cursorX);
|
||||
const cursorOffset = userInput.length;
|
||||
|
||||
return { isAtPrompt: true, promptText, userInput, cursorOffset };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NO_PROMPT;
|
||||
}
|
||||
|
||||
/** Characters that commonly end a shell prompt */
|
||||
const PROMPT_CHARS = new Set(["$", "#", "%", ">", "❯", "❮", "→", "➜", "➤", "⟩", "»", "›"]);
|
||||
|
||||
/**
|
||||
* Find the boundary between prompt and user input.
|
||||
* Scans left-to-right within the first 80 chars for a prompt character followed by space.
|
||||
* Avoids false positives: $VAR, $(...), ${...} are not prompt endings.
|
||||
* Returns the character index where user input begins, or -1 if no prompt detected.
|
||||
*/
|
||||
function findPromptBoundary(lineText: string): number {
|
||||
// Scan for prompt boundary. Take the LAST candidate.
|
||||
// For ambiguous chars like >, limit scan to first 60% to avoid matching redirections.
|
||||
// For unambiguous prompt chars ($, #), scan the full line since they're rarely
|
||||
// confused with shell syntax in a prompt position.
|
||||
const lineLen = lineText.trimEnd().length;
|
||||
const scanLimit = Math.min(lineLen, 200);
|
||||
let lastBoundary = -1;
|
||||
|
||||
// Ambiguous chars (>) only scan first 60% to avoid matching redirections
|
||||
const ambiguousScanLimit = Math.min(scanLimit, Math.max(40, Math.floor(lineLen * 0.6)));
|
||||
|
||||
for (let i = 0; i < scanLimit; i++) {
|
||||
const ch = lineText[i];
|
||||
|
||||
if (!PROMPT_CHARS.has(ch)) continue;
|
||||
|
||||
// For ambiguous prompt chars like >, only accept in the first 60% of the line
|
||||
if ((ch === ">" || ch === "›") && i >= ambiguousScanLimit) continue;
|
||||
|
||||
// Must be followed by a space or end-of-line.
|
||||
const nextChar = i + 1 < lineText.length ? lineText[i + 1] : null;
|
||||
if (nextChar !== null && nextChar !== " ") {
|
||||
// Special case: cmd.exe prompt `C:\path>command` — allow > without space
|
||||
// only if preceded by a path-like pattern (drive letter or backslash)
|
||||
if (ch === ">" && i > 1 && (lineText[i - 1] === "\\" || lineText[i - 1] === "/" || /^[A-Za-z]:/.test(lineText))) {
|
||||
// Looks like a path ending — accept as prompt
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// For '$': exclude shell variable references ($HOME, $PATH, ${...}, $(...))
|
||||
if (ch === "$") {
|
||||
// Check what comes AFTER the space — but more importantly check what
|
||||
// comes BEFORE to see if this looks like a prompt ending vs mid-command $.
|
||||
// A prompt $ is typically preceded by: space, ), ], digit, username chars, or is at position 0.
|
||||
// A variable $ is typically inside a command: echo $HOME, export PATH=$PATH:...
|
||||
//
|
||||
// Heuristic: if the $ is preceded by a letter/digit/underscore without a space before it
|
||||
// (i.e., it's part of a token like "echo" or "=$PATH"), it's likely a variable.
|
||||
if (i > 0) {
|
||||
const prev = lineText[i - 1];
|
||||
// If preceded by = or / or another non-separator, it's a variable reference
|
||||
if (prev === "=" || prev === "/" || prev === ":") continue;
|
||||
// If preceded by a letter and there's no space between, it could be $HOME-style
|
||||
// But actually: "user@host:~$ " has letter before $. So check if there's
|
||||
// a valid prompt pattern before the $.
|
||||
}
|
||||
|
||||
// Check what follows: if after "$ " there's more content with $ in variable positions
|
||||
// Actually the simplest reliable check: if the character after the space is alphanumeric
|
||||
// or $ or (, this is likely the START of a command (i.e., this $ IS the prompt ending).
|
||||
// That's always true for a prompt. So the $ check is really about false positives mid-line.
|
||||
//
|
||||
// Better heuristic: if we haven't seen a space before this $ (meaning the $ is inside
|
||||
// the first token), it's likely a prompt. If we've already passed spaces (meaning
|
||||
// we're past the first "word"), a $ is more likely a variable.
|
||||
let seenSpaceBeforeDollar = false;
|
||||
for (let j = 0; j < i; j++) {
|
||||
if (lineText[j] === " ") { seenSpaceBeforeDollar = true; break; }
|
||||
}
|
||||
// If there was a space before this $, it might be mid-command (like "echo $HOME")
|
||||
// Only accept if the $ is reasonably close to common prompt patterns
|
||||
if (seenSpaceBeforeDollar) {
|
||||
// Check if this looks like a bracketed prompt ending: "]$ " or ")$ "
|
||||
if (i > 0 && (lineText[i - 1] === "]" || lineText[i - 1] === ")" ||
|
||||
lineText[i - 1] === " " || lineText[i - 1] === "~")) {
|
||||
// Likely a prompt ending like [user@host ~]$
|
||||
} else {
|
||||
continue; // Skip — likely a variable reference mid-command
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Record this as a candidate boundary
|
||||
lastBoundary = nextChar === " " ? i + 2 : i + 1;
|
||||
}
|
||||
|
||||
return lastBoundary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplified prompt detection: just check if we're likely at a prompt.
|
||||
*/
|
||||
export function isLikelyAtPrompt(term: XTerm): boolean {
|
||||
const buffer = term.buffer.active;
|
||||
const cursorY = buffer.cursorY + buffer.baseY;
|
||||
const line = buffer.getLine(cursorY);
|
||||
if (!line) return false;
|
||||
|
||||
const lineText = line.translateToString(false);
|
||||
if (lineText.trim().length === 0) return false;
|
||||
|
||||
for (const pattern of NON_PROMPT_PATTERNS) {
|
||||
if (pattern.test(lineText)) return false;
|
||||
}
|
||||
|
||||
return findPromptBoundary(lineText) >= 0;
|
||||
}
|
||||
436
components/terminal/autocomplete/remotePathCompleter.ts
Normal file
436
components/terminal/autocomplete/remotePathCompleter.ts
Normal file
@@ -0,0 +1,436 @@
|
||||
/**
|
||||
* Remote path completion for terminal autocomplete.
|
||||
* Lists files/directories on the remote (or local) machine
|
||||
* when the user types commands that expect path arguments.
|
||||
*/
|
||||
|
||||
import type { CompletionContext } from "./completionEngine";
|
||||
import type { FigArg } from "./figSpecLoader";
|
||||
|
||||
/** Directory entry returned from IPC */
|
||||
export interface DirEntry {
|
||||
name: string;
|
||||
type: "file" | "directory" | "symlink";
|
||||
}
|
||||
|
||||
/** Bridge interface for directory listing */
|
||||
interface PathBridge {
|
||||
listAutocompleteRemoteDir?: (
|
||||
sessionId: string,
|
||||
path: string,
|
||||
foldersOnly: boolean,
|
||||
filterPrefix?: string,
|
||||
limit?: number,
|
||||
) => Promise<{ success: boolean; entries: DirEntry[] }>;
|
||||
listAutocompleteLocalDir?: (
|
||||
path: string,
|
||||
foldersOnly: boolean,
|
||||
filterPrefix?: string,
|
||||
limit?: number,
|
||||
) => Promise<{ success: boolean; entries: DirEntry[] }>;
|
||||
}
|
||||
|
||||
function getBridge(): PathBridge | undefined {
|
||||
return (window as Window & { netcatty?: PathBridge }).netcatty;
|
||||
}
|
||||
|
||||
// Cache directory listings for 5 seconds. Full-directory cache is shared between
|
||||
// popup suggestions and cascading sub-directory panels; filtered cache avoids
|
||||
// repeated round-trips while the user keeps typing within the same directory.
|
||||
const fullDirCache = new Map<string, { entries: DirEntry[]; timestamp: number }>();
|
||||
const filteredDirCache = new Map<string, { entries: DirEntry[]; timestamp: number }>();
|
||||
const inFlightRequests = new Map<string, Promise<DirEntry[]>>();
|
||||
const CACHE_TTL_MS = 5000;
|
||||
const MAX_CACHE_SIZE = 30;
|
||||
const MAX_FILTERED_CACHE_SIZE = 60;
|
||||
|
||||
/** Commands that commonly accept file/directory path arguments */
|
||||
const PATH_COMMANDS = new Set([
|
||||
"cd", "ls", "ll", "la", "dir", "cat", "less", "more", "head", "tail",
|
||||
"vim", "vi", "nvim", "nano", "emacs", "code", "subl",
|
||||
"cp", "mv", "rm", "mkdir", "rmdir", "touch", "chmod", "chown", "chgrp",
|
||||
"stat", "file", "source", ".", "bat", "rg", "find", "tree",
|
||||
"tar", "zip", "unzip", "gzip", "gunzip",
|
||||
"scp", "rsync", "diff",
|
||||
"python", "python3", "node", "ruby", "perl", "bash", "sh", "zsh",
|
||||
]);
|
||||
|
||||
/** Commands that only accept directories (not files) */
|
||||
const FOLDER_ONLY_COMMANDS = new Set(["cd", "mkdir", "rmdir", "pushd"]);
|
||||
|
||||
/**
|
||||
* Check if the current command context expects a path argument.
|
||||
*/
|
||||
export function shouldDoPathCompletion(
|
||||
ctx: CompletionContext,
|
||||
resolvedArgs?: FigArg | FigArg[],
|
||||
): { shouldComplete: boolean; foldersOnly: boolean } {
|
||||
const currentWord = stripWrappingQuotes(ctx.currentWord);
|
||||
|
||||
// 1. Typed path trigger: if current word starts with path-like prefix, always complete
|
||||
if (currentWord.startsWith("/") || currentWord.startsWith("./") ||
|
||||
currentWord.startsWith("../") || currentWord.startsWith("~/") ||
|
||||
currentWord === "." || currentWord === ".." || currentWord === "~") {
|
||||
const foldersOnly = FOLDER_ONLY_COMMANDS.has(ctx.commandName);
|
||||
return { shouldComplete: true, foldersOnly };
|
||||
}
|
||||
|
||||
// 2. Fig spec template check
|
||||
if (resolvedArgs) {
|
||||
const args = Array.isArray(resolvedArgs) ? resolvedArgs : [resolvedArgs];
|
||||
for (const arg of args) {
|
||||
const templates = Array.isArray(arg.template) ? arg.template : arg.template ? [arg.template] : [];
|
||||
if (templates.includes("filepaths") || templates.includes("folders")) {
|
||||
return {
|
||||
shouldComplete: true,
|
||||
foldersOnly: templates.includes("folders") && !templates.includes("filepaths"),
|
||||
};
|
||||
}
|
||||
// Generators field often indicates path completion (e.g., cd)
|
||||
if (arg.generators) {
|
||||
const foldersOnly = FOLDER_ONLY_COMMANDS.has(ctx.commandName);
|
||||
return { shouldComplete: true, foldersOnly };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Hardcoded command list (for commands without fig specs)
|
||||
if (ctx.wordIndex >= 1 && PATH_COMMANDS.has(ctx.commandName)) {
|
||||
// Only if we're past the command name and not typing an option
|
||||
if (!currentWord.startsWith("-")) {
|
||||
return {
|
||||
shouldComplete: true,
|
||||
foldersOnly: FOLDER_ONLY_COMMANDS.has(ctx.commandName),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { shouldComplete: false, foldersOnly: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the current word into directory-to-list and filter prefix.
|
||||
*/
|
||||
export function resolvePathComponents(
|
||||
currentWord: string,
|
||||
cwd: string | undefined,
|
||||
): { dirToList: string; filterPrefix: string; pathPrefix: string; quoteSuffix: string } {
|
||||
const quotePrefix = getLeadingQuote(currentWord);
|
||||
const quoteSuffix = getTrailingMatchingQuote(currentWord, quotePrefix);
|
||||
const unquotedWord = stripWrappingQuotes(currentWord);
|
||||
|
||||
// Handle empty input — list CWD
|
||||
if (!unquotedWord || unquotedWord === "." || unquotedWord === "~" || unquotedWord === "..") {
|
||||
const dir = unquotedWord === "~"
|
||||
? "~"
|
||||
: unquotedWord === ".."
|
||||
? resolveDirLookup("../", cwd)
|
||||
: (cwd || ".");
|
||||
const visiblePrefix = unquotedWord ? `${quotePrefix}${unquotedWord}/` : quotePrefix;
|
||||
return { dirToList: dir, filterPrefix: "", pathPrefix: visiblePrefix, quoteSuffix };
|
||||
}
|
||||
|
||||
// Find the last path separator
|
||||
const lastSlash = unquotedWord.lastIndexOf("/");
|
||||
|
||||
if (lastSlash >= 0) {
|
||||
const dirPart = unquotedWord.substring(0, lastSlash + 1); // includes trailing /
|
||||
const filterPart = unquotedWord.substring(lastSlash + 1);
|
||||
const decodedDirPart = decodeShellPathFragment(dirPart);
|
||||
const decodedFilterPart = decodeShellPathFragment(filterPart);
|
||||
|
||||
const dirToList = resolveDirLookup(decodedDirPart, cwd);
|
||||
|
||||
return { dirToList, filterPrefix: decodedFilterPart, pathPrefix: quotePrefix + dirPart, quoteSuffix };
|
||||
}
|
||||
|
||||
// No slash — filter CWD entries by the typed prefix
|
||||
return {
|
||||
dirToList: cwd || ".",
|
||||
filterPrefix: decodeShellPathFragment(unquotedWord),
|
||||
pathPrefix: quotePrefix,
|
||||
quoteSuffix,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePathTokenForLookup(token: string, cwd?: string): string {
|
||||
const { dirToList, filterPrefix } = resolvePathComponents(token, cwd);
|
||||
if (!filterPrefix) return dirToList;
|
||||
|
||||
if (!dirToList || dirToList === ".") {
|
||||
return filterPrefix;
|
||||
}
|
||||
|
||||
const needsSeparator = !dirToList.endsWith("/");
|
||||
return `${dirToList}${needsSeparator ? "/" : ""}${filterPrefix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get path completion suggestions.
|
||||
*/
|
||||
export async function getPathSuggestions(
|
||||
ctx: CompletionContext,
|
||||
options: {
|
||||
sessionId?: string;
|
||||
protocol?: string;
|
||||
cwd?: string;
|
||||
foldersOnly: boolean;
|
||||
},
|
||||
): Promise<{ name: string; type: DirEntry["type"] }[]> {
|
||||
const { sessionId, protocol, cwd, foldersOnly } = options;
|
||||
const { dirToList, filterPrefix } = resolvePathComponents(ctx.currentWord, cwd);
|
||||
|
||||
const entries = await listDirectoryEntries(dirToList, {
|
||||
sessionId,
|
||||
protocol,
|
||||
foldersOnly,
|
||||
filterPrefix,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
return sortPathEntries(entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* List directory contents via IPC, with shared caching and in-flight dedup.
|
||||
*/
|
||||
export async function listDirectoryEntries(
|
||||
dirPath: string,
|
||||
options: {
|
||||
sessionId?: string;
|
||||
protocol?: string;
|
||||
foldersOnly: boolean;
|
||||
filterPrefix?: string;
|
||||
limit?: number;
|
||||
},
|
||||
): Promise<DirEntry[]> {
|
||||
const {
|
||||
sessionId,
|
||||
protocol,
|
||||
foldersOnly,
|
||||
filterPrefix = "",
|
||||
limit = 100,
|
||||
} = options;
|
||||
const normalizedPrefix = filterPrefix.toLowerCase();
|
||||
const maxEntries = clampLimit(limit);
|
||||
const baseKey = `${protocol || "auto"}:${sessionId || "local"}:${dirPath}:${foldersOnly}`;
|
||||
const fullCacheKey = `${baseKey}:all`;
|
||||
const filteredCacheKey = `${baseKey}:prefix:${normalizedPrefix}:${maxEntries}`;
|
||||
|
||||
// Full directory cache can satisfy both full and filtered lookups.
|
||||
const fullCached = fullDirCache.get(fullCacheKey);
|
||||
if (isFresh(fullCached)) {
|
||||
return filterEntries(fullCached.entries, normalizedPrefix, maxEntries);
|
||||
}
|
||||
|
||||
if (normalizedPrefix) {
|
||||
const filteredCached = filteredDirCache.get(filteredCacheKey);
|
||||
if (isFresh(filteredCached)) {
|
||||
return filteredCached.entries;
|
||||
}
|
||||
}
|
||||
|
||||
const inFlightFull = inFlightRequests.get(fullCacheKey);
|
||||
if (inFlightFull) {
|
||||
return filterEntries(await inFlightFull, normalizedPrefix, maxEntries);
|
||||
}
|
||||
|
||||
const requestKey = normalizedPrefix ? filteredCacheKey : fullCacheKey;
|
||||
const inFlight = inFlightRequests.get(requestKey);
|
||||
if (inFlight) return inFlight;
|
||||
|
||||
// Make IPC call
|
||||
const promise = (async (): Promise<DirEntry[]> => {
|
||||
try {
|
||||
const bridge = getBridge();
|
||||
if (!bridge) return [];
|
||||
|
||||
let result: { success: boolean; entries: DirEntry[] };
|
||||
|
||||
if (protocol === "local" || !sessionId) {
|
||||
if (!bridge.listAutocompleteLocalDir) return [];
|
||||
result = await bridge.listAutocompleteLocalDir(
|
||||
dirPath,
|
||||
foldersOnly,
|
||||
normalizedPrefix || undefined,
|
||||
maxEntries,
|
||||
);
|
||||
} else {
|
||||
if (!bridge.listAutocompleteRemoteDir) return [];
|
||||
result = await bridge.listAutocompleteRemoteDir(
|
||||
sessionId,
|
||||
dirPath,
|
||||
foldersOnly,
|
||||
normalizedPrefix || undefined,
|
||||
maxEntries,
|
||||
);
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
const timestamp = Date.now();
|
||||
if (normalizedPrefix) {
|
||||
filteredDirCache.set(requestKey, { entries: result.entries, timestamp });
|
||||
evictOldest(filteredDirCache, MAX_FILTERED_CACHE_SIZE);
|
||||
return result.entries;
|
||||
}
|
||||
|
||||
fullDirCache.set(requestKey, { entries: result.entries, timestamp });
|
||||
evictOldest(fullDirCache, MAX_CACHE_SIZE);
|
||||
return result.entries;
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch {
|
||||
return [];
|
||||
} finally {
|
||||
inFlightRequests.delete(requestKey);
|
||||
}
|
||||
})();
|
||||
|
||||
inFlightRequests.set(requestKey, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
function clampLimit(limit: number): number {
|
||||
if (!Number.isFinite(limit)) return 100;
|
||||
return Math.max(1, Math.min(200, Math.floor(limit)));
|
||||
}
|
||||
|
||||
function resolveDirLookup(pathToken: string, cwd: string | undefined): string {
|
||||
if (!pathToken) return cwd || ".";
|
||||
if (pathToken.startsWith("/")) return normalizePosixLikePath(pathToken);
|
||||
if (pathToken === "~" || pathToken.startsWith("~/")) return normalizePosixLikePath(pathToken);
|
||||
if (cwd) return normalizePosixLikePath(`${cwd}/${pathToken}`);
|
||||
return normalizePosixLikePath(pathToken);
|
||||
}
|
||||
|
||||
function normalizePosixLikePath(input: string): string {
|
||||
if (!input) return ".";
|
||||
|
||||
const hasLeadingSlash = input.startsWith("/");
|
||||
const hasTildeRoot = input === "~" || input.startsWith("~/");
|
||||
const hasTrailingSlash = input.length > 1 && input.endsWith("/");
|
||||
const fixedRootSegments = hasTildeRoot ? 1 : 0;
|
||||
const raw = hasLeadingSlash
|
||||
? input.slice(1)
|
||||
: hasTildeRoot
|
||||
? input.slice(2)
|
||||
: input;
|
||||
const segments = hasTildeRoot ? ["~"] : [];
|
||||
|
||||
for (const segment of raw.split("/")) {
|
||||
if (!segment || segment === ".") continue;
|
||||
if (segment === "..") {
|
||||
if (
|
||||
segments.length > fixedRootSegments &&
|
||||
segments[segments.length - 1] !== ".."
|
||||
) {
|
||||
segments.pop();
|
||||
} else if (!hasLeadingSlash || hasTildeRoot) {
|
||||
segments.push(segment);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
segments.push(segment);
|
||||
}
|
||||
|
||||
let result: string;
|
||||
if (hasLeadingSlash) {
|
||||
result = "/" + segments.join("/");
|
||||
if (result === "/") return result;
|
||||
} else if (segments.length > 0) {
|
||||
result = segments.join("/");
|
||||
} else if (hasTildeRoot) {
|
||||
result = "~";
|
||||
} else {
|
||||
result = ".";
|
||||
}
|
||||
|
||||
if (hasTrailingSlash && result !== "/" && result !== "." && result !== "~") {
|
||||
result += "/";
|
||||
} else if (hasTrailingSlash && result === "~") {
|
||||
result = "~/";
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function isFresh(
|
||||
cached: { entries: DirEntry[]; timestamp: number } | undefined,
|
||||
): cached is { entries: DirEntry[]; timestamp: number } {
|
||||
return Boolean(cached && Date.now() - cached.timestamp < CACHE_TTL_MS);
|
||||
}
|
||||
|
||||
function filterEntries(entries: DirEntry[], filterPrefix: string, limit: number): DirEntry[] {
|
||||
if (!filterPrefix) return entries.slice(0, limit);
|
||||
|
||||
const filtered: DirEntry[] = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.name.toLowerCase().startsWith(filterPrefix)) {
|
||||
filtered.push(entry);
|
||||
if (filtered.length >= limit) break;
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function evictOldest(
|
||||
cache: Map<string, { entries: DirEntry[]; timestamp: number }>,
|
||||
maxSize: number,
|
||||
): void {
|
||||
while (cache.size > maxSize) {
|
||||
const oldestKey = cache.keys().next().value;
|
||||
if (!oldestKey) break;
|
||||
cache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
function decodeShellPathFragment(value: string): string {
|
||||
let result = "";
|
||||
let escaped = false;
|
||||
|
||||
for (const ch of value) {
|
||||
if (escaped) {
|
||||
result += ch;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
result += ch;
|
||||
}
|
||||
|
||||
if (escaped) result += "\\";
|
||||
return result;
|
||||
}
|
||||
|
||||
function getLeadingQuote(value: string): string {
|
||||
return value.startsWith('"') || value.startsWith("'") ? value[0] : "";
|
||||
}
|
||||
|
||||
function getTrailingMatchingQuote(value: string, quotePrefix: string): string {
|
||||
return quotePrefix && value.endsWith(quotePrefix) ? quotePrefix : "";
|
||||
}
|
||||
|
||||
function stripWrappingQuotes(value: string): string {
|
||||
if (!value) return value;
|
||||
let result = value;
|
||||
if (result.startsWith('"') || result.startsWith("'")) {
|
||||
result = result.slice(1);
|
||||
}
|
||||
if (result.endsWith('"') || result.endsWith("'")) {
|
||||
result = result.slice(0, -1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function sortPathEntries(entries: DirEntry[]): DirEntry[] {
|
||||
return [...entries].sort((left, right) => {
|
||||
const leftRank = left.type === "directory" ? 0 : left.type === "symlink" ? 1 : 2;
|
||||
const rightRank = right.type === "directory" ? 0 : right.type === "symlink" ? 1 : 2;
|
||||
if (leftRank !== rightRank) return leftRank - rightRank;
|
||||
return left.name.localeCompare(right.name, undefined, { sensitivity: "base" });
|
||||
});
|
||||
}
|
||||
1098
components/terminal/autocomplete/useTerminalAutocomplete.ts
Normal file
1098
components/terminal/autocomplete/useTerminalAutocomplete.ts
Normal file
File diff suppressed because it is too large
Load Diff
89
components/terminal/autocomplete/xtermUtils.ts
Normal file
89
components/terminal/autocomplete/xtermUtils.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Utility functions for xterm.js cell dimension access.
|
||||
* Centralizes access to xterm's internal renderer API to reduce upgrade risk.
|
||||
* Falls back to DOM measurement if the internal API is unavailable.
|
||||
*/
|
||||
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
|
||||
export interface CellDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
// Cache to avoid repeated DOM measurements (invalidated on resize)
|
||||
let cachedDims: CellDimensions | null = null;
|
||||
let cachedTermId: number = 0;
|
||||
let termIdCounter = 0;
|
||||
const termIdMap = new WeakMap<XTerm, number>();
|
||||
|
||||
function getTermId(term: XTerm): number {
|
||||
let id = termIdMap.get(term);
|
||||
if (id === undefined) {
|
||||
id = ++termIdCounter;
|
||||
termIdMap.set(term, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cell dimensions (width/height in CSS pixels) from an xterm instance.
|
||||
* Tries the internal renderer API first (fast path), falls back to DOM measurement.
|
||||
*/
|
||||
export function getXTermCellDimensions(term: XTerm): CellDimensions {
|
||||
// Try xterm core renderer API (fast path)
|
||||
const coreAccess = term as XTerm & {
|
||||
_core?: { _renderService?: { dimensions?: { css?: { cell?: CellDimensions } } } };
|
||||
};
|
||||
const coreDims = coreAccess._core?._renderService?.dimensions?.css?.cell;
|
||||
if (coreDims && coreDims.width > 0 && coreDims.height > 0) {
|
||||
// Update cache while we have a good value
|
||||
const id = getTermId(term);
|
||||
cachedDims = { width: coreDims.width, height: coreDims.height };
|
||||
cachedTermId = id;
|
||||
return cachedDims;
|
||||
}
|
||||
|
||||
// Check cache (same terminal instance)
|
||||
const id = getTermId(term);
|
||||
if (cachedDims && cachedTermId === id) {
|
||||
return cachedDims;
|
||||
}
|
||||
|
||||
// Fallback: measure from DOM (triggers single reflow)
|
||||
const dims = measureCellFromDOM(term);
|
||||
cachedDims = dims;
|
||||
cachedTermId = id;
|
||||
return dims;
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure cell dimensions by inserting a temporary span into the terminal element.
|
||||
* Triggers a single reflow (reading offsetWidth + offsetHeight).
|
||||
*/
|
||||
function measureCellFromDOM(term: XTerm): CellDimensions {
|
||||
const element = term.element;
|
||||
if (!element) return { width: 8, height: 16 };
|
||||
|
||||
const span = document.createElement("span");
|
||||
span.textContent = "W";
|
||||
Object.assign(span.style, {
|
||||
position: "absolute",
|
||||
visibility: "hidden",
|
||||
fontFamily: term.options.fontFamily || "monospace",
|
||||
fontSize: `${term.options.fontSize}px`,
|
||||
lineHeight: "normal",
|
||||
});
|
||||
element.appendChild(span);
|
||||
const width = span.offsetWidth || 8;
|
||||
const height = span.offsetHeight || 16;
|
||||
span.remove();
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the cached cell dimensions (call on terminal resize).
|
||||
*/
|
||||
export function invalidateCellDimensionCache(): void {
|
||||
cachedDims = null;
|
||||
}
|
||||
@@ -48,7 +48,7 @@ interface UseServerStatsOptions {
|
||||
sessionId: string;
|
||||
enabled: boolean; // Whether stats collection is enabled (from settings)
|
||||
refreshInterval: number; // Refresh interval in seconds
|
||||
isLinux: boolean; // Only collect stats for Linux servers
|
||||
isSupportedOs: boolean; // Only collect stats for Linux/macOS servers
|
||||
isConnected: boolean; // Only collect when connected
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ export function useServerStats({
|
||||
sessionId,
|
||||
enabled,
|
||||
refreshInterval,
|
||||
isLinux,
|
||||
isSupportedOs,
|
||||
isConnected,
|
||||
}: UseServerStatsOptions) {
|
||||
const [stats, setStats] = useState<ServerStats>({
|
||||
@@ -86,7 +86,7 @@ export function useServerStats({
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
if (!enabled || !isLinux || !isConnected || !sessionId) {
|
||||
if (!enabled || !isSupportedOs || !isConnected || !sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ export function useServerStats({
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, [sessionId, enabled, isLinux, isConnected]);
|
||||
}, [sessionId, enabled, isSupportedOs, isConnected]);
|
||||
|
||||
// Initial fetch and periodic refresh
|
||||
useEffect(() => {
|
||||
@@ -149,8 +149,7 @@ export function useServerStats({
|
||||
intervalRef.current = null;
|
||||
}
|
||||
|
||||
// Don't run if not enabled or not a Linux system
|
||||
if (!enabled || !isLinux || !isConnected) {
|
||||
if (!enabled || !isSupportedOs || !isConnected) {
|
||||
// Reset stats when disabled or not connected
|
||||
setStats({
|
||||
cpu: null,
|
||||
@@ -193,7 +192,7 @@ export function useServerStats({
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [enabled, isLinux, isConnected, refreshInterval, fetchStats]);
|
||||
}, [enabled, isSupportedOs, isConnected, refreshInterval, fetchStats]);
|
||||
|
||||
// Manual refresh function
|
||||
const refresh = useCallback(() => {
|
||||
|
||||
@@ -323,7 +323,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
: undefined;
|
||||
|
||||
const jumpHostsWithUnavailableCredentials: string[] = [];
|
||||
const jumpHosts = ctx.resolvedChainHosts.map<NetcattyJumpHost>((jumpHost) => {
|
||||
const jumpHosts = ctx.resolvedChainHosts.map<NetcattyJumpHost>((jumpHost, index) => {
|
||||
const jumpAuth = resolveHostAuth({
|
||||
host: jumpHost,
|
||||
keys: ctx.keys,
|
||||
@@ -336,13 +336,20 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
const jumpPassword = sanitizeCredentialValue(rawJumpPassword);
|
||||
const jumpPrivateKey = sanitizeCredentialValue(rawJumpPrivateKey);
|
||||
const jumpPassphrase = sanitizeCredentialValue(rawJumpPassphrase);
|
||||
const hasConfiguredJumpProxyEndpoint =
|
||||
index === 0 &&
|
||||
!!(jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port);
|
||||
const hasEncryptedJumpProxyCredential =
|
||||
hasConfiguredJumpProxyEndpoint &&
|
||||
Boolean(jumpHost.proxyConfig?.username) &&
|
||||
isEncryptedCredentialPlaceholder(jumpHost.proxyConfig?.password);
|
||||
|
||||
const hasEncryptedJumpCredential =
|
||||
isEncryptedCredentialPlaceholder(rawJumpPassword) ||
|
||||
isEncryptedCredentialPlaceholder(rawJumpPrivateKey) ||
|
||||
isEncryptedCredentialPlaceholder(rawJumpPassphrase);
|
||||
|
||||
if (hasEncryptedJumpCredential && !jumpPassword && !jumpPrivateKey) {
|
||||
if (hasEncryptedJumpProxyCredential || (hasEncryptedJumpCredential && !jumpPassword && !jumpPrivateKey)) {
|
||||
jumpHostsWithUnavailableCredentials.push(jumpHost.label || jumpHost.hostname);
|
||||
}
|
||||
|
||||
@@ -358,10 +365,21 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
keyId: jumpAuth.keyId,
|
||||
keySource: jumpKey?.source,
|
||||
label: jumpHost.label,
|
||||
proxy: jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port
|
||||
? {
|
||||
type: jumpHost.proxyConfig.type,
|
||||
host: jumpHost.proxyConfig.host,
|
||||
port: jumpHost.proxyConfig.port,
|
||||
username: jumpHost.proxyConfig.username,
|
||||
password: sanitizeCredentialValue(jumpHost.proxyConfig.password),
|
||||
}
|
||||
: undefined,
|
||||
identityFilePaths: jumpHost.identityFilePaths,
|
||||
};
|
||||
});
|
||||
|
||||
if (hasEncryptedProxyPassword && !proxyConfig?.password && proxyConfig?.username) {
|
||||
const usesTargetProxyForFirstHop = !!proxyConfig && !jumpHosts[0]?.proxy;
|
||||
if (usesTargetProxyForFirstHop && hasEncryptedProxyPassword && !proxyConfig?.password && proxyConfig?.username) {
|
||||
const message = tr(
|
||||
"terminal.auth.proxyCredentialsUnavailable",
|
||||
"Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.",
|
||||
@@ -431,7 +449,15 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
logLine = `${prefix}${label} - ${tr("terminal.progress.keyExchangeComplete", "Key exchange complete")}`;
|
||||
break;
|
||||
case 'auth-attempt':
|
||||
logLine = `${prefix}${label} - ${tr("terminal.progress.trying", "Trying")} ${error}...`;
|
||||
if (error?.endsWith('rejected')) {
|
||||
logLine = `${prefix}${label} - ✗ ${error}`;
|
||||
} else if (error === 'all methods exhausted') {
|
||||
logLine = `${prefix}${label} - ✗ All authentication methods exhausted`;
|
||||
} else if (error === 'waiting for user input...' || error === 'user responded') {
|
||||
logLine = `${prefix}${label} - ${error}`;
|
||||
} else {
|
||||
logLine = `${prefix}${label} - ${tr("terminal.progress.trying", "Trying")} ${error}...`;
|
||||
}
|
||||
break;
|
||||
case 'authenticated':
|
||||
logLine = `${prefix}${label} - ${tr("terminal.progress.authenticated", "Authenticated")}`;
|
||||
@@ -491,6 +517,8 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
jumpHosts: jumpHosts.length > 0 ? jumpHosts : undefined,
|
||||
keepaliveInterval: ctx.terminalSettings?.keepaliveInterval,
|
||||
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
|
||||
// Only pass local key paths if no vault key is explicitly configured
|
||||
identityFilePaths: attempt.key ? undefined : ctx.host.identityFilePaths,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -582,6 +610,18 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
}
|
||||
}, 600);
|
||||
}
|
||||
|
||||
// Run OS detection only after successful connection
|
||||
setTimeout(
|
||||
() =>
|
||||
void runDistroDetection(ctx, {
|
||||
username: effectiveUsername,
|
||||
password: usedPassword,
|
||||
key: usedKey,
|
||||
passphrase: effectivePassphrase,
|
||||
}),
|
||||
600,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const authError = isAuthError(err);
|
||||
@@ -607,17 +647,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
ctx.setChainProgress(null);
|
||||
if (unsubscribeChainProgress) unsubscribeChainProgress();
|
||||
}
|
||||
|
||||
setTimeout(
|
||||
() =>
|
||||
void runDistroDetection(ctx, {
|
||||
username: effectiveUsername,
|
||||
password: usedPassword,
|
||||
key: usedKey,
|
||||
passphrase: effectivePassphrase,
|
||||
}),
|
||||
600,
|
||||
);
|
||||
};
|
||||
|
||||
const startTelnet = async (term: XTerm) => {
|
||||
|
||||
@@ -101,6 +101,11 @@ export type CreateXTermRuntimeContext = {
|
||||
|
||||
// Callback when remote requests clipboard read in 'prompt' mode; resolves to user's decision
|
||||
onOsc52ReadRequest?: () => Promise<boolean>;
|
||||
|
||||
// Autocomplete key event handler — returns false if event was consumed
|
||||
onAutocompleteKeyEvent?: (e: KeyboardEvent) => boolean;
|
||||
// Autocomplete input handler — called on every character input
|
||||
onAutocompleteInput?: (data: string) => void;
|
||||
};
|
||||
|
||||
const detectPlatform = (): XTermPlatform => {
|
||||
@@ -375,6 +380,13 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
if (e.type !== "keydown") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Autocomplete key handler (must be checked before other handlers)
|
||||
if (ctx.onAutocompleteKeyEvent) {
|
||||
const consumed = ctx.onAutocompleteKeyEvent(e);
|
||||
if (!consumed) return false; // Event was consumed by autocomplete
|
||||
}
|
||||
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "f" && e.type === "keydown") {
|
||||
e.preventDefault();
|
||||
ctx.setIsSearchOpen(true);
|
||||
@@ -567,6 +579,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
|
||||
scrollToBottomAfterInput(data);
|
||||
|
||||
// Notify autocomplete of input
|
||||
ctx.onAutocompleteInput?.(data);
|
||||
|
||||
if (ctx.statusRef.current === "connected" && ctx.onCommandExecuted) {
|
||||
if (data === "\r" || data === "\n") {
|
||||
const cmd = ctx.commandBufferRef.current.trim();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AlertCircle, AlertTriangle, CheckCircle, Info, X } from 'lucide-react';
|
||||
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { setNotify } from '../../application/notification';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'warning' | 'info';
|
||||
@@ -96,6 +97,7 @@ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
// Register global toast function
|
||||
useEffect(() => {
|
||||
globalShowToast = showToast;
|
||||
setNotify(toast);
|
||||
return () => {
|
||||
globalShowToast = null;
|
||||
};
|
||||
|
||||
@@ -55,6 +55,7 @@ export interface SftpBookmark {
|
||||
id: string;
|
||||
path: string;
|
||||
label: string;
|
||||
global?: boolean;
|
||||
}
|
||||
|
||||
export interface Host {
|
||||
@@ -113,6 +114,9 @@ export interface Host {
|
||||
keywordHighlightEnabled?: boolean;
|
||||
// Legacy SSH algorithm support for older network equipment (switches, routers)
|
||||
legacyAlgorithms?: boolean;
|
||||
// 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[];
|
||||
}
|
||||
|
||||
export type KeyType = 'RSA' | 'ECDSA' | 'ED25519';
|
||||
@@ -442,6 +446,14 @@ export interface TerminalSettings {
|
||||
|
||||
// Rendering
|
||||
rendererType: 'auto' | 'webgl' | 'canvas'; // Terminal renderer: auto (detect based on hardware), webgl, or canvas
|
||||
|
||||
// Autocomplete
|
||||
autocompleteEnabled: boolean; // Enable terminal command autocomplete
|
||||
autocompleteGhostText: boolean; // Show inline ghost text suggestions (like fish shell)
|
||||
autocompletePopupMenu: boolean; // Show popup menu with multiple suggestions
|
||||
autocompleteDebounceMs: number; // Debounce delay for fetching suggestions (ms)
|
||||
autocompleteMinChars: number; // Minimum characters before showing suggestions
|
||||
autocompleteMaxSuggestions: number; // Maximum suggestions in popup menu
|
||||
}
|
||||
|
||||
const STRICT_IPV4_OCTET_PATTERN = '(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)';
|
||||
@@ -511,6 +523,9 @@ export const normalizeTerminalSettings = (
|
||||
|
||||
return {
|
||||
...mergedSettings,
|
||||
autocompleteGhostText: mergedSettings.autocompletePopupMenu
|
||||
? false
|
||||
: mergedSettings.autocompleteGhostText,
|
||||
keywordHighlightRules: normalizeKeywordHighlightRules(
|
||||
mergedSettings.keywordHighlightRules,
|
||||
),
|
||||
@@ -534,7 +549,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
scrollOnOutput: false,
|
||||
scrollOnKeyPress: false,
|
||||
scrollOnPaste: true,
|
||||
smoothScrolling: true,
|
||||
smoothScrolling: false,
|
||||
rightClickBehavior: 'context-menu',
|
||||
copyOnSelect: false,
|
||||
middleClickPaste: true,
|
||||
@@ -550,6 +565,12 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
disableBracketedPaste: false, // Bracketed paste enabled by default
|
||||
osc52Clipboard: 'write-only', // OSC-52: allow remote programs to write clipboard by default
|
||||
rendererType: 'auto', // Auto-detect best renderer based on hardware
|
||||
autocompleteEnabled: true, // Autocomplete enabled by default
|
||||
autocompleteGhostText: false, // Mutually exclusive with popup menu
|
||||
autocompletePopupMenu: true, // Popup menu enabled by default
|
||||
autocompleteDebounceMs: 100, // 100ms debounce
|
||||
autocompleteMinChars: 1, // Start suggesting after 1 character
|
||||
autocompleteMaxSuggestions: 8, // Show up to 8 suggestions
|
||||
};
|
||||
|
||||
export interface TerminalTheme {
|
||||
|
||||
@@ -113,6 +113,15 @@ export const serializeHostsToSshConfig = (hosts: Host[], allHosts?: Host[]): str
|
||||
lines.push(` Port ${host.port}`);
|
||||
}
|
||||
|
||||
// Serialize IdentityFile paths
|
||||
if (host.identityFilePaths && host.identityFilePaths.length > 0) {
|
||||
for (const keyPath of host.identityFilePaths) {
|
||||
// Quote paths that contain spaces
|
||||
const formatted = keyPath.includes(" ") ? `"${keyPath}"` : keyPath;
|
||||
lines.push(` IdentityFile ${formatted}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Serialize ProxyJump if host has a chain
|
||||
const proxyJumpValue = buildProxyJumpValue(host, hostsForLookup, managedHostIds);
|
||||
if (proxyJumpValue) {
|
||||
|
||||
@@ -198,6 +198,8 @@ export interface SyncPayload {
|
||||
sftpShowHiddenFiles?: boolean;
|
||||
sftpUseCompressedUpload?: boolean;
|
||||
sftpAutoOpenSidebar?: boolean;
|
||||
// Immersive mode
|
||||
immersiveMode?: boolean;
|
||||
};
|
||||
|
||||
// Sync metadata
|
||||
|
||||
@@ -519,6 +519,7 @@ const importFromSshConfig = (text: string): VaultImportResult => {
|
||||
username?: string;
|
||||
port?: number;
|
||||
proxyJump?: string;
|
||||
identityFiles?: string[];
|
||||
};
|
||||
|
||||
const blocks: Block[] = [];
|
||||
@@ -557,6 +558,12 @@ const importFromSshConfig = (text: string): VaultImportResult => {
|
||||
else if (keyword === "user") current.username = value;
|
||||
else if (keyword === "port") current.port = parsePort(value);
|
||||
else if (keyword === "proxyjump") current.proxyJump = value;
|
||||
else if (keyword === "identityfile") {
|
||||
if (!current.identityFiles) current.identityFiles = [];
|
||||
// Remove surrounding quotes (ssh_config allows quoted paths with spaces)
|
||||
const unquoted = value.replace(/^["']|["']$/g, "");
|
||||
current.identityFiles.push(unquoted);
|
||||
}
|
||||
}
|
||||
|
||||
flush();
|
||||
@@ -597,6 +604,11 @@ const importFromSshConfig = (text: string): VaultImportResult => {
|
||||
protocol: "ssh",
|
||||
});
|
||||
|
||||
// Attach IdentityFile paths if present
|
||||
if (block.identityFiles && block.identityFiles.length > 0) {
|
||||
host.identityFilePaths = [...block.identityFiles];
|
||||
}
|
||||
|
||||
parsedHosts.push(host);
|
||||
|
||||
// Store ProxyJump using hostname key (survives deduplication)
|
||||
|
||||
@@ -6,7 +6,12 @@ module.exports = {
|
||||
productName: 'Netcatty',
|
||||
artifactName: '${productName}-${version}-${os}-${arch}.${ext}',
|
||||
icon: 'public/icon.png',
|
||||
npmRebuild: false,
|
||||
// npmRebuild must stay enabled for macOS and Windows builds — without it,
|
||||
// node-pty's native module is not recompiled for the Electron ABI, causing
|
||||
// "posix_spawnp failed" on macOS. Linux builds set npm_config_arch in CI
|
||||
// and run ensure-node-pty-linux.sh before packaging, so the rebuild is
|
||||
// redundant but harmless there.
|
||||
npmRebuild: true,
|
||||
directories: {
|
||||
buildResources: 'build',
|
||||
output: 'release'
|
||||
|
||||
@@ -43,53 +43,89 @@ function subscribeToPtyData(ptyStream, onData) {
|
||||
throw new Error("PTY stream does not support data subscriptions");
|
||||
}
|
||||
|
||||
function hasExpectedPromptSuffix(text, expectedPrompt) {
|
||||
if (!expectedPrompt) return false;
|
||||
const normalizedText = stripAnsi(String(text || "")).replace(/\r/g, "");
|
||||
const normalizedPrompt = stripAnsi(String(expectedPrompt || "")).replace(/\r/g, "");
|
||||
return !!normalizedPrompt && normalizedText.endsWith(normalizedPrompt);
|
||||
}
|
||||
|
||||
function escapePosixSingleQuoted(text) {
|
||||
return String(text || "").replace(/'/g, "'\\''");
|
||||
}
|
||||
|
||||
function escapePowerShellSingleQuoted(text) {
|
||||
return String(text || "").replace(/'/g, "''");
|
||||
}
|
||||
|
||||
function escapeFishSingleQuoted(text) {
|
||||
return String(text || "").replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
||||
}
|
||||
|
||||
function escapeCmdForNestedShell(text) {
|
||||
return String(text || "").replace(/"/g, '""').replace(/%/g, "%%");
|
||||
}
|
||||
|
||||
function buildWrappedCommand(command, shellKind, marker) {
|
||||
switch (shellKind) {
|
||||
case "powershell": {
|
||||
// Combine into 2 PTY lines (like posix) to minimise prompt echo duplication:
|
||||
// Line 1: start marker + pager env + user command
|
||||
// Line 2: capture exit code + end marker
|
||||
// __NCMCP_ prefix ensures the echo line is buffered/filtered even if
|
||||
// the PTY delivers it in small chunks (the marker must appear early).
|
||||
const psPager = "$env:PAGER='cat'; $env:SYSTEMD_PAGER=''; $env:GIT_PAGER='cat'; $env:LESS=''; ";
|
||||
const psEscaped = escapePowerShellSingleQuoted(command);
|
||||
return (
|
||||
`Write-Output '${marker}_S'; ${psPager}${command}\r\n` +
|
||||
`Write-Output "${marker}_E:$LASTEXITCODE"\r\n`
|
||||
`$${marker}=0; $${marker}_cmd='${psEscaped}'; Write-Host '> ${psEscaped}'; & { Write-Output '${marker}_S'; ${psPager}$LASTEXITCODE=$null; try { Invoke-Expression $${marker}_cmd; $${marker}_rc = if ($LASTEXITCODE -ne $null) { $LASTEXITCODE } elseif ($?) { 0 } else { 1 } } catch { $${marker}_rc = 1 }; Write-Output "${marker}_E:$${marker}_rc" }\r\n`
|
||||
);
|
||||
}
|
||||
|
||||
case "cmd":
|
||||
return [
|
||||
'set "PAGER=cat"',
|
||||
'set "SYSTEMD_PAGER="',
|
||||
'set "GIT_PAGER=cat"',
|
||||
'set "LESS="',
|
||||
`echo ${marker}_S`,
|
||||
command,
|
||||
`echo ${marker}_E:%errorlevel%`,
|
||||
"",
|
||||
].join("\r\n");
|
||||
case "cmd": {
|
||||
const cmdEscaped = escapeCmdForNestedShell(command);
|
||||
return (
|
||||
`set "${marker}=0" & set "${marker}_CMD=${cmdEscaped}" & call <nul set /p "=^> %%${marker}_CMD%%" & echo( & (echo ${marker}_S & set "PAGER=cat" & set "SYSTEMD_PAGER=" & set "GIT_PAGER=cat" & set "LESS=" & call cmd /d /s /c "%%${marker}_CMD%%" & call echo ${marker}_E:^%errorlevel^%)\r\n`
|
||||
);
|
||||
}
|
||||
|
||||
case "fish":
|
||||
return [
|
||||
"set -gx PAGER cat",
|
||||
"set -gx SYSTEMD_PAGER ''",
|
||||
"set -gx GIT_PAGER cat",
|
||||
"set -gx LESS ''",
|
||||
`printf '%s\\n' '${marker}_S'`,
|
||||
command,
|
||||
"set __NCMCP_rc $status",
|
||||
`printf '%s\\n' '${marker}_E:'$__NCMCP_rc`,
|
||||
"",
|
||||
].join("\n");
|
||||
// set __NCMCP_... at the start ensures early marker presence in echo.
|
||||
return (
|
||||
`set ${marker} 0; function __ncmcp_int --on-signal INT; printf '%s\\n' '${marker}_E:130'; functions -e __ncmcp_int; end; ` +
|
||||
// Clear the current terminal row before the user-visible echo.
|
||||
`set -l ${marker}_cmd '${escapeFishSingleQuoted(command)}'; printf '\\r\\033[2K> %s\\n' '${escapeFishSingleQuoted(command)}'; ` +
|
||||
`begin; set -gx PAGER cat; set -gx SYSTEMD_PAGER ''; set -gx GIT_PAGER cat; set -gx LESS ''; ` +
|
||||
`printf '%s\\n' '${marker}_S'; eval -- \$${marker}_cmd; set __NCMCP_rc $status; ` +
|
||||
`functions -e __ncmcp_int; printf '%s\\n' '${marker}_E:'\$__NCMCP_rc; end\n`
|
||||
);
|
||||
|
||||
case "posix":
|
||||
default: {
|
||||
// Combine into 2 PTY lines to minimise prompt echo duplication:
|
||||
// Line 1: start marker + pager env + user command
|
||||
// Line 2: capture exit code + end marker + restore exit code
|
||||
// Single-line compound command with early marker & visible command echo.
|
||||
//
|
||||
// Layout: __NCMCP_xxx=0; printf echo; { ... MARKER_S; eval command; MARKER_E; }
|
||||
//
|
||||
// Key design decisions:
|
||||
//
|
||||
// 1) __NCMCP_xxx=0 at the VERY START ensures the PTY echo line
|
||||
// contains __NCMCP_ in its first few bytes. This is critical:
|
||||
// preload.cjs filters chunks by buffering incomplete lines that
|
||||
// contain __NCMCP_. Without this prefix, the first chunk of a
|
||||
// long echo line might not contain the marker and would leak
|
||||
// through to the terminal as garbage.
|
||||
//
|
||||
// 2) printf clears the current row and outputs "> command\n"
|
||||
// (no marker) → visible to user without prompt residue.
|
||||
//
|
||||
// 3) The user command is executed via eval on a quoted string. This
|
||||
// keeps shell syntax errors inside the eval call so the wrapper
|
||||
// can still emit the end marker and return a non-zero exit code.
|
||||
//
|
||||
// 4) Single-line { ... } is parsed fully before execution, so SIGINT
|
||||
// cannot cause bash to flush the end marker from the input buffer.
|
||||
// trap ':' INT lets child processes receive SIGINT normally while
|
||||
// preventing the shell from aborting the compound command.
|
||||
const noPager = "PAGER=cat SYSTEMD_PAGER= GIT_PAGER=cat LESS= ";
|
||||
const escaped = escapePosixSingleQuoted(command);
|
||||
return (
|
||||
`printf '%s\\n' '${marker}_S';${noPager}${command}\n` +
|
||||
`__NCMCP_rc=$?;printf '%s\\n' '${marker}_E:'"$__NCMCP_rc";(exit $__NCMCP_rc)\n`
|
||||
`${marker}=0; ${marker}_cmd='${escaped}'; printf '\\r\\033[2K> %s\\n' '${escaped}'; { printf '%s\\n' '${marker}_S'; trap ':' INT; ${noPager}eval "$${marker}_cmd"; __NCMCP_rc=$?; trap - INT; printf '%s\\n' '${marker}_E:'\"$__NCMCP_rc\"; (exit $__NCMCP_rc); }\n`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -106,6 +142,9 @@ function buildWrappedCommand(command, shellKind, marker) {
|
||||
* @param {boolean} [options.stripMarkers=false] - Strip leaked MCP markers from output
|
||||
* @param {Map} [options.trackForCancellation] - Map to register this execution in for cancellation
|
||||
* @param {number} [options.timeoutMs=60000] - Command timeout in milliseconds
|
||||
* @param {string} [options.chatSessionId] - Chat session ID for scoped cancellation
|
||||
* @param {AbortSignal} [options.abortSignal] - AbortSignal to cancel execution
|
||||
* @param {string} [options.expectedPrompt] - Last observed idle prompt for exact fallback matching
|
||||
*/
|
||||
function execViaPty(ptyStream, command, options) {
|
||||
const {
|
||||
@@ -113,33 +152,51 @@ function execViaPty(ptyStream, command, options) {
|
||||
trackForCancellation = null,
|
||||
timeoutMs = 60000,
|
||||
shellKind,
|
||||
chatSessionId,
|
||||
abortSignal,
|
||||
expectedPrompt,
|
||||
} = 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 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) {
|
||||
// Look for the start marker at a line boundary (actual printf output),
|
||||
// not inside the echo of the printf command argument.
|
||||
const combined = pendingStart + text;
|
||||
pendingStart = "";
|
||||
const startMarker = marker + "_S";
|
||||
let matched = false;
|
||||
let pos = 0;
|
||||
while (pos < text.length) {
|
||||
const idx = text.indexOf(startMarker, pos);
|
||||
while (pos < combined.length) {
|
||||
const idx = combined.indexOf(startMarker, pos);
|
||||
if (idx === -1) break;
|
||||
// Accept if at start of text, or preceded by \n or \r (line boundary)
|
||||
if (idx === 0 || text[idx - 1] === '\n' || text[idx - 1] === '\r') {
|
||||
if (idx === 0 || combined[idx - 1] === '\n' || combined[idx - 1] === '\r') {
|
||||
foundStart = true;
|
||||
const afterMarker = text.slice(idx);
|
||||
matched = true;
|
||||
const afterMarker = combined.slice(idx);
|
||||
const nlIdx = afterMarker.indexOf("\n");
|
||||
if (nlIdx !== -1) {
|
||||
output += afterMarker.slice(nlIdx + 1);
|
||||
@@ -148,14 +205,42 @@ function execViaPty(ptyStream, command, options) {
|
||||
}
|
||||
pos = idx + 1;
|
||||
}
|
||||
if (foundStart) checkEnd();
|
||||
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) {
|
||||
schedulePromptFallback();
|
||||
checkEnd();
|
||||
}
|
||||
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.
|
||||
@@ -179,39 +264,43 @@ function execViaPty(ptyStream, command, options) {
|
||||
}
|
||||
}
|
||||
|
||||
function finish(stdout, 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 || "").trim();
|
||||
let cleaned = stripAnsi(stdout || "").replace(/\r/g, "");
|
||||
if (stripMarkers) {
|
||||
cleaned = cleaned.replace(/^[^\r\n]*__NCMCP_[^\r\n]*[\r\n]*/gm, "").trim();
|
||||
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,
|
||||
});
|
||||
}
|
||||
resolve({
|
||||
ok: exitCode === 0 || exitCode === null,
|
||||
stdout: cleaned,
|
||||
stderr: "",
|
||||
exitCode: exitCode ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
unsubscribe?.();
|
||||
if (trackForCancellation) {
|
||||
trackForCancellation.delete(marker);
|
||||
}
|
||||
// Send Ctrl+C to kill the timed-out command
|
||||
if (typeof ptyStream.write === "function") ptyStream.write("\x03");
|
||||
const cleaned = stripAnsi(output).trim();
|
||||
const timeoutSec = Math.round(timeoutMs / 1000);
|
||||
resolve({ ok: false, stdout: cleaned, stderr: "", exitCode: -1, error: `Command timed out (${timeoutSec}s)` });
|
||||
finish(output, -1, `Command timed out (${timeoutSec}s)`);
|
||||
}, timeoutMs);
|
||||
|
||||
unsubscribe = subscribeToPtyData(ptyStream, onData);
|
||||
@@ -220,6 +309,11 @@ function execViaPty(ptyStream, command, options) {
|
||||
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?.();
|
||||
@@ -227,6 +321,35 @@ function execViaPty(ptyStream, command, options) {
|
||||
});
|
||||
}
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
// Markers are filtered from terminal display by preload.cjs (MCP_MARKER_RE).
|
||||
ptyStream.write(buildWrappedCommand(command, resolvedShellKind, marker));
|
||||
});
|
||||
@@ -244,6 +367,7 @@ function execViaChannel(sshClient, command, options) {
|
||||
const {
|
||||
timeoutMs = 60000,
|
||||
trackForCancellation = null,
|
||||
chatSessionId,
|
||||
} = options || {};
|
||||
|
||||
return new Promise((resolve) => {
|
||||
@@ -276,6 +400,11 @@ function execViaChannel(sshClient, command, options) {
|
||||
}, timeoutMs);
|
||||
if (trackForCancellation) {
|
||||
trackForCancellation.set(marker, {
|
||||
chatSessionId: chatSessionId || null,
|
||||
cancel: () => {
|
||||
try { execStream.close(); } catch { /* ignore */ }
|
||||
finish({ ok: false, stdout, stderr, exitCode: -1, error: "Cancelled" });
|
||||
},
|
||||
cleanup: () => {
|
||||
clearTimeout(timeoutId);
|
||||
try { execStream.close(); } catch { /* ignore */ }
|
||||
@@ -296,9 +425,209 @@ function execViaChannel(sshClient, command, options) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute command on a raw serial port (no shell wrapping).
|
||||
*
|
||||
* Used for network devices (Cisco IOS, Huawei VRP, etc.) and embedded systems
|
||||
* that do not run a standard POSIX/PowerShell/CMD shell.
|
||||
*
|
||||
* The command is sent as-is followed by CR. Completion is detected via idle
|
||||
* timeout (no new data for `idleMs` milliseconds). The idle timer does NOT
|
||||
* start until the first data chunk arrives, so slow devices won't time out
|
||||
* before producing any output.
|
||||
*
|
||||
* Exit code is always `null` because vendor CLIs do not expose exit codes.
|
||||
*
|
||||
* @param {object} serialPort - The SerialPort instance with .write() and .on("data")
|
||||
* @param {string} command - The raw command to send
|
||||
* @param {object} [options]
|
||||
* @param {number} [options.timeoutMs=60000] - Overall timeout
|
||||
* @param {number} [options.idleMs=3000] - Idle timeout to detect command completion
|
||||
* @param {Map} [options.trackForCancellation] - Map for cancellation tracking
|
||||
* @param {string} [options.chatSessionId] - Chat session ID for scoped cancellation
|
||||
* @param {AbortSignal} [options.abortSignal] - AbortSignal to cancel execution
|
||||
*/
|
||||
function execViaRawPty(serialPort, command, options) {
|
||||
const {
|
||||
timeoutMs = 60000,
|
||||
idleMs = 3000,
|
||||
trackForCancellation = null,
|
||||
chatSessionId,
|
||||
abortSignal,
|
||||
} = options || {};
|
||||
|
||||
// Simple incrementing key for the cancellation map (no markers sent to device)
|
||||
const cancelKey = `__NCRAW_${Date.now().toString(36)}_${(++execViaRawPty._seq).toString(36)}`;
|
||||
|
||||
if (abortSignal?.aborted) {
|
||||
return Promise.resolve({ ok: false, stdout: "", stderr: "", exitCode: null, error: "Cancelled" });
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let output = "";
|
||||
let finished = false;
|
||||
let overallTimer = null;
|
||||
let idleTimer = null;
|
||||
const cleanupFns = [];
|
||||
|
||||
function safeWrite(data) {
|
||||
try {
|
||||
if (typeof serialPort.write === "function") serialPort.write(data);
|
||||
} catch { /* serial port may already be closed */ }
|
||||
}
|
||||
|
||||
// finish signature differs from execViaPty intentionally: no exitCode param
|
||||
// because vendor CLIs have no exit code concept (always null).
|
||||
function finish(stdout, error) {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
clearTimeout(overallTimer);
|
||||
clearTimeout(idleTimer);
|
||||
for (const fn of cleanupFns) { try { fn(); } catch { /* ignore */ } }
|
||||
if (trackForCancellation) {
|
||||
trackForCancellation.delete(cancelKey);
|
||||
}
|
||||
|
||||
let cleaned = stripAnsi(stdout || "").replace(/\r/g, "");
|
||||
|
||||
// Strip echoed command from the beginning of output.
|
||||
// Network devices typically echo back the typed command on the first line,
|
||||
// often prefixed by the device prompt (e.g. "Router#show version").
|
||||
// Only strip when the first line is a close match to avoid removing
|
||||
// legitimate output on devices that don't echo.
|
||||
const lines = cleaned.split("\n");
|
||||
if (lines.length > 1) {
|
||||
const firstLine = lines[0].trim();
|
||||
const cmdTrimmed = command.trim();
|
||||
if (cmdTrimmed && (firstLine === cmdTrimmed || firstLine.endsWith(cmdTrimmed))) {
|
||||
lines.shift();
|
||||
}
|
||||
}
|
||||
cleaned = lines.join("\n").trim();
|
||||
|
||||
if (error) {
|
||||
resolve({ ok: false, stdout: cleaned, stderr: "", exitCode: null, error });
|
||||
} else {
|
||||
resolve({ ok: true, stdout: cleaned, stderr: "", exitCode: null });
|
||||
}
|
||||
}
|
||||
|
||||
// Track data chunks to distinguish echo phase from real output.
|
||||
// The first 1-2 chunks are typically the echoed command + prompt.
|
||||
// Use a longer idle timeout during this phase so that commands like
|
||||
// ping/traceroute/copy that stay quiet after the echo aren't truncated.
|
||||
let chunkCount = 0;
|
||||
const ECHO_PHASE_CHUNKS = 2;
|
||||
|
||||
function resetIdleTimer() {
|
||||
clearTimeout(idleTimer);
|
||||
// During echo phase (first few chunks), use 2× idleMs to avoid
|
||||
// truncating commands that produce output after a delay.
|
||||
const effectiveIdle = chunkCount <= ECHO_PHASE_CHUNKS ? idleMs * 2 : idleMs;
|
||||
idleTimer = setTimeout(() => {
|
||||
finish(output, null);
|
||||
}, effectiveIdle);
|
||||
}
|
||||
|
||||
let noResponseTimer = null;
|
||||
|
||||
// Cap output to prevent unbounded accumulation on noisy serial consoles
|
||||
// (e.g. devices that continuously emit syslog/debug messages). Once the cap
|
||||
// is reached, stop resetting the idle timer so the function can resolve.
|
||||
const MAX_OUTPUT_BYTES = 512 * 1024; // 512 KB
|
||||
|
||||
const onData = (data) => {
|
||||
// Use latin1 to match the terminal display decoder in terminalBridge.cjs.
|
||||
const chunk = data.toString("latin1");
|
||||
chunkCount++;
|
||||
// Cancel the no-response fallback on first data
|
||||
if (noResponseTimer) {
|
||||
clearTimeout(noResponseTimer);
|
||||
noResponseTimer = null;
|
||||
}
|
||||
if (output.length < MAX_OUTPUT_BYTES) {
|
||||
output += chunk;
|
||||
// Only reset idle timer while accumulating — once capped, let it fire
|
||||
// so noisy sessions don't hang until the overall timeout.
|
||||
resetIdleTimer();
|
||||
}
|
||||
};
|
||||
|
||||
// Subscribe to serial port data
|
||||
if (typeof serialPort.on === "function") {
|
||||
serialPort.on("data", onData);
|
||||
cleanupFns.push(() => {
|
||||
try { serialPort.removeListener("data", onData); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
// Error / close detection
|
||||
const onError = (err) => finish(output, `Serial port error: ${err?.message || err}`);
|
||||
const onClose = () => finish(output, "Serial port closed unexpectedly");
|
||||
serialPort.on("error", onError);
|
||||
serialPort.on("close", onClose);
|
||||
cleanupFns.push(() => {
|
||||
try { serialPort.removeListener("error", onError); } catch { /* */ }
|
||||
try { serialPort.removeListener("close", onClose); } catch { /* */ }
|
||||
});
|
||||
}
|
||||
|
||||
// Overall timeout
|
||||
overallTimer = setTimeout(() => {
|
||||
safeWrite("\x03");
|
||||
const timeoutSec = Math.round(timeoutMs / 1000);
|
||||
finish(output, `Command timed out (${timeoutSec}s)`);
|
||||
}, timeoutMs);
|
||||
|
||||
// Cancellation tracking
|
||||
if (trackForCancellation) {
|
||||
trackForCancellation.set(cancelKey, {
|
||||
chatSessionId: chatSessionId || null,
|
||||
cancel: () => {
|
||||
safeWrite("\x03");
|
||||
finish(output, "Cancelled");
|
||||
},
|
||||
cleanup: () => {
|
||||
clearTimeout(overallTimer);
|
||||
clearTimeout(idleTimer);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// AbortSignal handling
|
||||
if (abortSignal) {
|
||||
const onAbort = () => {
|
||||
safeWrite("\x03");
|
||||
finish(output, "Cancelled");
|
||||
};
|
||||
abortSignal.addEventListener("abort", onAbort, { once: true });
|
||||
cleanupFns.push(() => abortSignal.removeEventListener("abort", onAbort));
|
||||
}
|
||||
|
||||
// Send the raw command followed by CR (network devices expect \r).
|
||||
safeWrite(command + "\r");
|
||||
|
||||
// Start a "no-response" fallback timer. If the device produces no output at
|
||||
// all (e.g. silent mode-changing commands like "enable", "configure terminal",
|
||||
// or devices with echo disabled), the idle timer never starts because onData
|
||||
// never fires. This fallback resolves successfully to avoid waiting for the
|
||||
// full overall timeout. Uses min(idleMs * 4, timeoutMs / 4) to balance between
|
||||
// not waiting too long for silent commands and not truncating slow operations.
|
||||
// Cleared on first data in onData.
|
||||
const noResponseMs = Math.min(idleMs * 4, Math.floor(timeoutMs / 4));
|
||||
noResponseTimer = setTimeout(() => {
|
||||
// Resolve with ok:true but include a hint that no output was received,
|
||||
// so the AI knows the command may still be running or produced no output.
|
||||
finish(output || "(no output received — command may have completed silently or may still be running)", null);
|
||||
}, noResponseMs);
|
||||
cleanupFns.push(() => clearTimeout(noResponseTimer));
|
||||
});
|
||||
}
|
||||
execViaRawPty._seq = 0;
|
||||
|
||||
module.exports = {
|
||||
execViaPty,
|
||||
execViaChannel,
|
||||
execViaRawPty,
|
||||
detectShellKind,
|
||||
stripAnsi,
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ const ANSI_ESCAPE_REGEX = /\u001B\[[0-?]*[ -/]*[@-~]/g;
|
||||
const ANSI_OSC_REGEX = /\u001B\][^\u0007]*(?:\u0007|\u001B\\)/g;
|
||||
const URL_CANDIDATE_REGEX = /https?:\/\/[^\s]+/g;
|
||||
const WINDOWS_RUNNABLE_EXTENSIONS = [".exe", ".cmd", ".bat", ".com"];
|
||||
const MAX_PROMPT_TRACK_TAIL = 4096;
|
||||
|
||||
// ── ANSI stripping ──
|
||||
|
||||
@@ -23,6 +24,36 @@ function stripAnsi(input) {
|
||||
return String(input || "").replace(ANSI_OSC_REGEX, "").replace(ANSI_ESCAPE_REGEX, "");
|
||||
}
|
||||
|
||||
function extractTrailingIdlePrompt(output) {
|
||||
const normalized = stripAnsi(output).replace(/\r/g, "");
|
||||
if (!normalized || normalized.endsWith("\n")) return "";
|
||||
|
||||
const lastLine = normalized.split("\n").pop() || "";
|
||||
const rightTrimmed = lastLine.replace(/\s+$/, "");
|
||||
if (!rightTrimmed) return "";
|
||||
|
||||
if (/^[^\s@]+@[^\s:]+(?::[^\n\r]*)?[#$]$/.test(rightTrimmed)) {
|
||||
return lastLine;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function trackSessionIdlePrompt(session, chunk) {
|
||||
if (!session || typeof chunk !== "string" || !chunk) return "";
|
||||
|
||||
const nextTail = `${session._promptTrackTail || ""}${chunk}`.slice(-MAX_PROMPT_TRACK_TAIL);
|
||||
session._promptTrackTail = nextTail;
|
||||
|
||||
const prompt = extractTrailingIdlePrompt(nextTail);
|
||||
if (prompt) {
|
||||
session.lastIdlePrompt = prompt;
|
||||
session.lastIdlePromptAt = Date.now();
|
||||
}
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
// ── URL helpers ──
|
||||
|
||||
function isLocalhostHostname(hostname) {
|
||||
@@ -271,6 +302,8 @@ function serializeStreamChunk(chunk) {
|
||||
|
||||
module.exports = {
|
||||
stripAnsi,
|
||||
extractTrailingIdlePrompt,
|
||||
trackSessionIdlePrompt,
|
||||
isLocalhostHostname,
|
||||
extractFirstNonLocalhostUrl,
|
||||
normalizeCliPathForPlatform,
|
||||
|
||||
@@ -10,7 +10,6 @@ const http = require("node:http");
|
||||
const { URL } = require("node:url");
|
||||
const { spawn, execFileSync } = require("node:child_process");
|
||||
const { existsSync } = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const mcpServerBridge = require("./mcpServerBridge.cjs");
|
||||
|
||||
@@ -60,6 +59,7 @@ const MAX_CONCURRENT_AGENTS = 5;
|
||||
const acpProviders = new Map();
|
||||
const acpActiveStreams = new Map();
|
||||
const acpRequestSessions = new Map();
|
||||
const acpPendingCancelRequests = new Set();
|
||||
const acpForceProviderReset = new Set();
|
||||
const acpChatRuns = new Map();
|
||||
|
||||
@@ -223,14 +223,7 @@ function killTrackedProcessTree(rootPid, childPids) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely send an IPC message to a renderer, guarding against destroyed senders.
|
||||
*/
|
||||
function safeSend(sender, channel, ...args) {
|
||||
if (sender && !sender.isDestroyed()) {
|
||||
sender.send(channel, ...args);
|
||||
}
|
||||
}
|
||||
const { safeSend } = require("./ipcUtils.cjs");
|
||||
|
||||
function init(deps) {
|
||||
sessions = deps.sessions;
|
||||
@@ -881,7 +874,7 @@ function registerHandlers(ipcMain) {
|
||||
});
|
||||
|
||||
// Execute a command on a terminal session (for Catty Agent)
|
||||
ipcMain.handle("netcatty:ai:exec", async (event, { sessionId, command }) => {
|
||||
ipcMain.handle("netcatty:ai:exec", async (event, { sessionId, command, chatSessionId }) => {
|
||||
// Validate IPC sender (Issue #17)
|
||||
if (!validateSender(event)) {
|
||||
return { ok: false, error: "Unauthorized IPC sender" };
|
||||
@@ -890,17 +883,20 @@ function registerHandlers(ipcMain) {
|
||||
if (mcpServerBridge.getPermissionMode() === "observer") {
|
||||
return { ok: false, error: "Execution blocked: permission mode is 'observer'" };
|
||||
}
|
||||
// Check command against safety blocklist before executing
|
||||
const safety = mcpServerBridge.checkCommandSafety(command);
|
||||
if (safety.blocked) {
|
||||
return { ok: false, error: `Command blocked by safety policy. Pattern: ${safety.matchedPattern}` };
|
||||
}
|
||||
|
||||
const session = sessions?.get(sessionId);
|
||||
if (!session) {
|
||||
return { ok: false, error: "Session not found" };
|
||||
}
|
||||
|
||||
// Shell blocklist is meaningless on network device CLIs (e.g. "shutdown"
|
||||
// disables an interface on Cisco). Skip for serial sessions.
|
||||
if (session.protocol !== "serial") {
|
||||
const safety = mcpServerBridge.checkCommandSafety(command);
|
||||
if (safety.blocked) {
|
||||
return { ok: false, error: `Command blocked by safety policy. Pattern: ${safety.matchedPattern}` };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if ((session.protocol === "local" || session.type === "local") && session.shellKind === "unknown") {
|
||||
return {
|
||||
@@ -915,8 +911,11 @@ function registerHandlers(ipcMain) {
|
||||
const timeoutMs = mcpServerBridge.getCommandTimeoutMs ? mcpServerBridge.getCommandTimeoutMs() : 60000;
|
||||
return execViaPty(ptyStream, command, {
|
||||
stripMarkers: true,
|
||||
trackForCancellation: mcpServerBridge.activePtyExecs,
|
||||
timeoutMs,
|
||||
shellKind: session.shellKind,
|
||||
chatSessionId,
|
||||
expectedPrompt: session.lastIdlePrompt || "",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -925,7 +924,22 @@ 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, { timeoutMs: channelTimeoutMs });
|
||||
return 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, {
|
||||
timeoutMs: serialTimeoutMs,
|
||||
trackForCancellation: mcpServerBridge.activePtyExecs,
|
||||
chatSessionId,
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: false, error: "No terminal stream or SSH client available for this session" };
|
||||
@@ -934,43 +948,13 @@ function registerHandlers(ipcMain) {
|
||||
}
|
||||
});
|
||||
|
||||
// Write to terminal session (send input like a user typing)
|
||||
ipcMain.handle("netcatty:ai:terminal:write", async (event, { sessionId, data }) => {
|
||||
// Validate IPC sender (Issue #17)
|
||||
// Cancel in-flight Catty Agent command executions for a chat session
|
||||
ipcMain.handle("netcatty:ai:catty:cancel", async (event, { chatSessionId }) => {
|
||||
if (!validateSender(event)) {
|
||||
return { ok: false, error: "Unauthorized IPC sender" };
|
||||
}
|
||||
// Block writes in observer mode (Issue #11)
|
||||
if (mcpServerBridge.getPermissionMode() === "observer") {
|
||||
return { ok: false, error: "Terminal write blocked: permission mode is 'observer'" };
|
||||
}
|
||||
// Check input against safety blocklist before writing
|
||||
const safety = mcpServerBridge.checkCommandSafety(data);
|
||||
if (safety.blocked) {
|
||||
return { ok: false, error: `Input blocked by safety policy. Pattern: ${safety.matchedPattern}` };
|
||||
}
|
||||
|
||||
const session = sessions?.get(sessionId);
|
||||
if (!session) {
|
||||
return { ok: false, error: "Session not found" };
|
||||
}
|
||||
try {
|
||||
if (session.stream) {
|
||||
session.stream.write(data);
|
||||
return { ok: true };
|
||||
}
|
||||
if (session.pty) {
|
||||
session.pty.write(data);
|
||||
return { ok: true };
|
||||
}
|
||||
if (session.proc) {
|
||||
session.proc.write(data);
|
||||
return { ok: true };
|
||||
}
|
||||
return { ok: false, error: "No writable stream for session" };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
}
|
||||
mcpServerBridge.cancelPtyExecsForSession(chatSessionId);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
async function runCommand(command, args, options) {
|
||||
@@ -1715,11 +1699,39 @@ function registerHandlers(ipcMain) {
|
||||
}
|
||||
let abortController = null;
|
||||
try {
|
||||
const existingRun = acpChatRuns.get(chatSessionId);
|
||||
if (existingRun && existingRun.requestId !== requestId) {
|
||||
existingRun.cancelRequested = true;
|
||||
const existingController = acpActiveStreams.get(existingRun.requestId);
|
||||
if (existingController) {
|
||||
existingController.abort();
|
||||
acpActiveStreams.delete(existingRun.requestId);
|
||||
}
|
||||
acpRequestSessions.delete(existingRun.requestId);
|
||||
cleanupAcpProvider(chatSessionId);
|
||||
}
|
||||
|
||||
mcpServerBridge.setChatSessionCancelled?.(chatSessionId, false);
|
||||
abortController = new AbortController();
|
||||
acpActiveStreams.set(requestId, abortController);
|
||||
acpRequestSessions.set(requestId, chatSessionId);
|
||||
acpChatRuns.set(chatSessionId, { requestId, cancelRequested: false });
|
||||
|
||||
const consumePendingStartupCancel = () => {
|
||||
if (!acpPendingCancelRequests.has(requestId)) return false;
|
||||
acpPendingCancelRequests.delete(requestId);
|
||||
abortController?.abort();
|
||||
return true;
|
||||
};
|
||||
|
||||
const shouldAbortStartup = () =>
|
||||
Boolean(abortController?.signal?.aborted || consumePendingStartupCancel());
|
||||
|
||||
const { createACPProvider } = require("@mcpc-tech/acp-ai-provider");
|
||||
const { streamText, stepCountIs } = require("ai");
|
||||
|
||||
const shellEnv = await getShellEnv();
|
||||
if (shouldAbortStartup()) return { ok: true };
|
||||
const sessionCwd = cwd || process.cwd();
|
||||
const isCodexAgent = acpCommand === "codex-acp";
|
||||
const isClaudeAgent = acpCommand === "claude-agent-acp";
|
||||
@@ -1730,6 +1742,7 @@ function registerHandlers(ipcMain) {
|
||||
|
||||
if (isCodexAgent && !apiKey) {
|
||||
const validation = await validateCodexChatGptAuth({ maxAgeMs: 10000 });
|
||||
if (shouldAbortStartup()) return { ok: true };
|
||||
if (!validation.ok) {
|
||||
if (isCodexAuthError(validation)) {
|
||||
try {
|
||||
@@ -1752,6 +1765,7 @@ function registerHandlers(ipcMain) {
|
||||
const mcpSnapshot = isCodexAgent
|
||||
? await resolveCodexMcpSnapshot(sessionCwd)
|
||||
: { mcpServers: [], fingerprint: getCodexMcpFingerprint([]) };
|
||||
if (shouldAbortStartup()) return { ok: true };
|
||||
|
||||
// Inject Netcatty MCP server for scoped terminal-session access
|
||||
try {
|
||||
@@ -1762,23 +1776,12 @@ function registerHandlers(ipcMain) {
|
||||
} catch (err) {
|
||||
console.error("[ACP] Failed to inject Netcatty MCP server:", err?.message || err);
|
||||
}
|
||||
if (shouldAbortStartup()) return { ok: true };
|
||||
|
||||
// Recalculate fingerprint after injection
|
||||
mcpSnapshot.fingerprint = getCodexMcpFingerprint(mcpSnapshot.mcpServers);
|
||||
|
||||
const currentPermissionMode = mcpServerBridge.getPermissionMode();
|
||||
const existingRun = acpChatRuns.get(chatSessionId);
|
||||
if (existingRun && existingRun.requestId !== requestId) {
|
||||
existingRun.cancelRequested = true;
|
||||
const existingController = acpActiveStreams.get(existingRun.requestId);
|
||||
if (existingController) {
|
||||
existingController.abort();
|
||||
acpActiveStreams.delete(existingRun.requestId);
|
||||
}
|
||||
acpRequestSessions.delete(existingRun.requestId);
|
||||
cleanupAcpProvider(chatSessionId);
|
||||
}
|
||||
|
||||
let providerEntry = acpProviders.get(chatSessionId);
|
||||
const shouldForceProviderReset = acpForceProviderReset.has(chatSessionId);
|
||||
const shouldReuseProvider = Boolean(
|
||||
@@ -1841,6 +1844,7 @@ function registerHandlers(ipcMain) {
|
||||
let modelInstance = providerEntry.provider.languageModel(model || undefined);
|
||||
try {
|
||||
await providerEntry.provider.initSession(providerEntry.provider.tools);
|
||||
if (shouldAbortStartup()) return { ok: true };
|
||||
} catch (err) {
|
||||
const attemptedResumeSessionId = providerEntry.provider?.getSessionId?.() || existingSessionId;
|
||||
if (!attemptedResumeSessionId || !isUnsupportedLoadSessionError(err)) {
|
||||
@@ -1882,6 +1886,7 @@ function registerHandlers(ipcMain) {
|
||||
acpProviders.set(chatSessionId, providerEntry);
|
||||
modelInstance = providerEntry.provider.languageModel(model || undefined);
|
||||
await providerEntry.provider.initSession(providerEntry.provider.tools);
|
||||
if (shouldAbortStartup()) return { ok: true };
|
||||
}
|
||||
const activeProviderSessionId = providerEntry.provider.getSessionId?.() || null;
|
||||
if (activeProviderSessionId) {
|
||||
@@ -1891,11 +1896,6 @@ function registerHandlers(ipcMain) {
|
||||
});
|
||||
}
|
||||
|
||||
abortController = new AbortController();
|
||||
acpActiveStreams.set(requestId, abortController);
|
||||
acpRequestSessions.set(requestId, chatSessionId);
|
||||
acpChatRuns.set(chatSessionId, { requestId, cancelRequested: false });
|
||||
|
||||
// Prepend context hint so the agent uses Netcatty MCP tools for the scoped sessions
|
||||
const contextualPrompt =
|
||||
`[Context: You are inside Netcatty, a multi-session terminal manager. ` +
|
||||
@@ -1903,8 +1903,7 @@ function registerHandlers(ipcMain) {
|
||||
`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_send_input only to respond to an interactive prompt that is already running; it does not read back the updated terminal output. ` +
|
||||
`SFTP file tools only work for remote SSH sessions, not local terminals.]\n\n${prompt}`;
|
||||
`For serial/raw sessions (network devices), commands are sent as-is without shell wrapping and exit codes are unavailable.]\n\n${prompt}`;
|
||||
|
||||
// Build message content: text + optional attachments
|
||||
// ACP provider only supports image/* and audio/* inline via `type: "file"`.
|
||||
@@ -2055,6 +2054,7 @@ function registerHandlers(ipcMain) {
|
||||
} finally {
|
||||
acpActiveStreams.delete(requestId);
|
||||
acpRequestSessions.delete(requestId);
|
||||
acpPendingCancelRequests.delete(requestId);
|
||||
const activeRun = acpChatRuns.get(chatSessionId);
|
||||
if (activeRun?.requestId === requestId) {
|
||||
if (abortController?.signal?.aborted || activeRun.cancelRequested) {
|
||||
@@ -2069,20 +2069,24 @@ function registerHandlers(ipcMain) {
|
||||
|
||||
ipcMain.handle("netcatty:ai:acp:cancel", async (event, { requestId, chatSessionId }) => {
|
||||
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
// Cancel any active PTY executions (send Ctrl+C)
|
||||
mcpServerBridge.cancelAllPtyExecs();
|
||||
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)
|
||||
mcpServerBridge.cancelPtyExecsForSession(effectiveChatSessionId);
|
||||
mcpServerBridge.setChatSessionCancelled?.(effectiveChatSessionId, true);
|
||||
mcpServerBridge.clearPendingApprovals(effectiveChatSessionId);
|
||||
const activeRun = effectiveChatSessionId ? acpChatRuns.get(effectiveChatSessionId) : null;
|
||||
if (activeRun && activeRun.requestId === requestId) {
|
||||
if (activeRun && activeRun.requestId === effectiveRequestId) {
|
||||
activeRun.cancelRequested = true;
|
||||
}
|
||||
const controller = acpActiveStreams.get(requestId);
|
||||
const controller = acpActiveStreams.get(effectiveRequestId);
|
||||
let cancelled = false;
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
acpActiveStreams.delete(requestId);
|
||||
acpActiveStreams.delete(effectiveRequestId);
|
||||
cancelled = true;
|
||||
} else if (effectiveRequestId) {
|
||||
acpPendingCancelRequests.add(effectiveRequestId);
|
||||
cancelled = true;
|
||||
}
|
||||
if (effectiveChatSessionId) {
|
||||
@@ -2093,7 +2097,7 @@ function registerHandlers(ipcMain) {
|
||||
// continue within the same persisted conversation context. Full provider
|
||||
// cleanup is handled by netcatty:ai:acp:cleanup when the chat is deleted.
|
||||
if (effectiveChatSessionId) cancelled = true;
|
||||
acpRequestSessions.delete(requestId);
|
||||
if (effectiveRequestId) acpRequestSessions.delete(effectiveRequestId);
|
||||
return cancelled ? { ok: true } : { ok: false, error: "Stream not found" };
|
||||
});
|
||||
|
||||
|
||||
@@ -283,6 +283,17 @@ function registerHandlers(ipcMain) {
|
||||
return { available: false, supported: true, checking: true };
|
||||
}
|
||||
|
||||
// If a download is already in progress or the update is ready to install,
|
||||
// skip the check entirely — calling checkForUpdates() while downloading
|
||||
// can cause electron-updater to error, which corrupts the download state
|
||||
// and forces the user to download manually (GitHub issue #522).
|
||||
if (_isDownloading) {
|
||||
return { available: true, supported: true, downloading: true, version: _lastStatus.version };
|
||||
}
|
||||
if (_lastStatus.status === 'ready') {
|
||||
return { available: true, supported: true, ready: true, version: _lastStatus.version };
|
||||
}
|
||||
|
||||
try {
|
||||
_isChecking = true;
|
||||
_lastStatus = { ..._lastStatus, isChecking: true };
|
||||
@@ -324,16 +335,22 @@ function registerHandlers(ipcMain) {
|
||||
|
||||
// ---- Download update ---------------------------------------------------
|
||||
ipcMain.handle("netcatty:update:download", async () => {
|
||||
if (_isDownloading) {
|
||||
return { success: true };
|
||||
}
|
||||
const updater = getAutoUpdater();
|
||||
if (!updater) {
|
||||
return { success: false, error: "Update module not available." };
|
||||
}
|
||||
try {
|
||||
// Global listeners (registered in setupGlobalListeners) handle all
|
||||
// progress/downloaded/error events. Just trigger the download.
|
||||
_isDownloading = true;
|
||||
_lastStatus = { ..._lastStatus, status: 'downloading', percent: 0, error: null };
|
||||
await updater.downloadUpdate();
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
_isDownloading = false;
|
||||
_lastStatus = { ..._lastStatus, status: 'error', error: err?.message || "Download failed", percent: 0 };
|
||||
// Don't broadcast here — the global updater "error" listener already handles it
|
||||
console.error("[AutoUpdate] Download failed:", err?.message || err);
|
||||
return { success: false, error: err?.message || "Download failed" };
|
||||
}
|
||||
|
||||
@@ -551,6 +551,4 @@ function registerHandlers(ipcMain) {
|
||||
module.exports = {
|
||||
init,
|
||||
registerHandlers,
|
||||
checkTarAvailable,
|
||||
checkRemoteTarAvailable,
|
||||
};
|
||||
|
||||
@@ -380,10 +380,5 @@ function cleanup() {
|
||||
module.exports = {
|
||||
init,
|
||||
registerHandlers,
|
||||
startWatching,
|
||||
stopWatching,
|
||||
stopWatchersForSession,
|
||||
listWatchers,
|
||||
registerTempFile,
|
||||
cleanup,
|
||||
};
|
||||
|
||||
@@ -726,14 +726,6 @@ function cleanup() {
|
||||
module.exports = {
|
||||
init,
|
||||
registerHandlers,
|
||||
registerGlobalHotkey,
|
||||
unregisterGlobalHotkey,
|
||||
setCloseToTray,
|
||||
isCloseToTrayEnabled,
|
||||
handleWindowClose,
|
||||
toggleWindowVisibility,
|
||||
getHotkeyStatus,
|
||||
setTrayMenuData,
|
||||
updateTrayMenu,
|
||||
cleanup,
|
||||
};
|
||||
|
||||
20
electron/bridges/ipcUtils.cjs
Normal file
20
electron/bridges/ipcUtils.cjs
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Shared IPC utilities for bridge modules.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Safely send an IPC message to a renderer, guarding against destroyed senders.
|
||||
* @param {Electron.WebContents} sender
|
||||
* @param {string} channel
|
||||
* @param {...unknown} args
|
||||
*/
|
||||
function safeSend(sender, channel, ...args) {
|
||||
try {
|
||||
if (!sender || sender.isDestroyed()) return;
|
||||
sender.send(channel, ...args);
|
||||
} catch {
|
||||
// Ignore destroyed webContents during shutdown / HMR reload.
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { safeSend };
|
||||
@@ -2,8 +2,7 @@
|
||||
* MCP Server Bridge — TCP host in Electron main process
|
||||
*
|
||||
* Starts a local TCP server that the netcatty-mcp-server.cjs child process
|
||||
* connects to. Handles JSON-RPC calls by dispatching to real SSH sessions
|
||||
* and SFTP clients.
|
||||
* connects to. Handles JSON-RPC calls by dispatching to real terminal sessions.
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
@@ -13,10 +12,9 @@ const path = require("node:path");
|
||||
const { existsSync } = require("node:fs");
|
||||
|
||||
const { toUnpackedAsarPath } = require("./ai/shellUtils.cjs");
|
||||
const { execViaPty, execViaChannel } = require("./ai/ptyExec.cjs");
|
||||
const { execViaPty, execViaChannel, execViaRawPty } = require("./ai/ptyExec.cjs");
|
||||
|
||||
let sessions = null; // Map<sessionId, { sshClient, stream, pty, proc, conn, ... }>
|
||||
let sftpClients = null; // Map<sftpId, SFTPWrapper>
|
||||
let tcpServer = null;
|
||||
let tcpPort = null;
|
||||
let authToken = null; // Random token generated when TCP server starts
|
||||
@@ -24,14 +22,6 @@ let authToken = null; // Random token generated when TCP server starts
|
||||
// Track which sockets have completed authentication
|
||||
const authenticatedSockets = new WeakSet();
|
||||
|
||||
/**
|
||||
* Safely quote a string for use in a POSIX shell command.
|
||||
* Wraps the value in single quotes and escapes any embedded single quotes.
|
||||
*/
|
||||
function shellQuote(s) {
|
||||
return "'" + s.replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
|
||||
// Per-scope metadata: chatSessionId → { sessionIds: string[], metadata: Map<sessionId, meta> }
|
||||
// Each chat session only sees the hosts registered for its scope.
|
||||
const scopedMetadata = new Map();
|
||||
@@ -145,19 +135,32 @@ function clearPendingApprovals(chatSessionId) {
|
||||
function cancelAllPtyExecs() {
|
||||
for (const [marker, entry] of activePtyExecs) {
|
||||
try {
|
||||
entry.cleanup();
|
||||
// Send Ctrl+C to kill the running command
|
||||
if (entry.ptyStream && typeof entry.ptyStream.write === "function") {
|
||||
entry.ptyStream.write("\x03");
|
||||
}
|
||||
if (typeof entry.cancel === "function") entry.cancel();
|
||||
else entry.cleanup();
|
||||
} catch { /* ignore */ }
|
||||
activePtyExecs.delete(marker);
|
||||
}
|
||||
activePtyExecs.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel PTY executions scoped to a specific chat session.
|
||||
* Only affects entries whose chatSessionId matches.
|
||||
*/
|
||||
function cancelPtyExecsForSession(chatSessionId) {
|
||||
if (!chatSessionId) return;
|
||||
for (const [marker, entry] of activePtyExecs) {
|
||||
if (entry.chatSessionId !== chatSessionId) continue;
|
||||
try {
|
||||
if (typeof entry.cancel === "function") entry.cancel();
|
||||
else entry.cleanup();
|
||||
} catch { /* ignore */ }
|
||||
activePtyExecs.delete(marker);
|
||||
}
|
||||
}
|
||||
|
||||
function init(deps) {
|
||||
sessions = deps.sessions;
|
||||
sftpClients = deps.sftpClients;
|
||||
if (deps.commandBlocklist) {
|
||||
commandBlocklist = deps.commandBlocklist;
|
||||
}
|
||||
@@ -276,38 +279,9 @@ function getSessionMeta(sessionId, chatSessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function sessionSupportsSftp(session) {
|
||||
const sshClient = session?.conn || session?.sshClient;
|
||||
return !!(sshClient && typeof sshClient.exec === "function");
|
||||
}
|
||||
|
||||
function scopeHasSftpSessions(sessionIds) {
|
||||
if (!Array.isArray(sessionIds) || sessionIds.length === 0) return false;
|
||||
for (const sessionId of sessionIds) {
|
||||
const session = sessions?.get(sessionId);
|
||||
if (sessionSupportsSftp(session)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an array of async task factories with a concurrency limit.
|
||||
*/
|
||||
async function limitConcurrency(tasks, limit) {
|
||||
const results = [];
|
||||
const executing = new Set();
|
||||
for (let i = 0; i < tasks.length; i++) {
|
||||
const task = tasks[i];
|
||||
const p = task().then(r => { results[i] = r; }).finally(() => executing.delete(p));
|
||||
executing.add(p);
|
||||
if (executing.size >= limit) {
|
||||
await Promise.race(executing);
|
||||
}
|
||||
}
|
||||
await Promise.all(executing);
|
||||
return results;
|
||||
}
|
||||
|
||||
function checkCommandSafety(command) {
|
||||
for (let i = 0; i < compiledBlocklist.length; i++) {
|
||||
const re = compiledBlocklist[i];
|
||||
@@ -424,12 +398,6 @@ async function handleMessage(socket, line) {
|
||||
// Methods that modify remote state — blocked in observer mode
|
||||
const WRITE_METHODS = new Set([
|
||||
"netcatty/exec",
|
||||
"netcatty/terminalWrite",
|
||||
"netcatty/sftpWrite",
|
||||
"netcatty/sftpMkdir",
|
||||
"netcatty/sftpRemove",
|
||||
"netcatty/sftpRename",
|
||||
"netcatty/multiExec",
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -469,37 +437,11 @@ async function dispatch(method, params) {
|
||||
const scopeErr = validateSessionScope(params.sessionId, params?.chatSessionId);
|
||||
if (scopeErr) return { ok: false, error: scopeErr };
|
||||
}
|
||||
// For multi-exec, validate all session IDs
|
||||
if (method === "netcatty/multiExec" && Array.isArray(params?.sessionIds)) {
|
||||
for (const sid of params.sessionIds) {
|
||||
const scopeErr = validateSessionScope(sid, params?.chatSessionId);
|
||||
if (scopeErr) return { ok: false, error: scopeErr };
|
||||
}
|
||||
}
|
||||
|
||||
switch (method) {
|
||||
case "netcatty/getContext":
|
||||
return handleGetContext(params);
|
||||
case "netcatty/exec":
|
||||
return handleExec(params);
|
||||
case "netcatty/terminalWrite":
|
||||
return handleTerminalWrite(params);
|
||||
case "netcatty/sftpList":
|
||||
return handleSftpList(params);
|
||||
case "netcatty/sftpRead":
|
||||
return handleSftpRead(params);
|
||||
case "netcatty/sftpWrite":
|
||||
return handleSftpWrite(params);
|
||||
case "netcatty/sftpMkdir":
|
||||
return handleSftpMkdir(params);
|
||||
case "netcatty/sftpRemove":
|
||||
return handleSftpRemove(params);
|
||||
case "netcatty/sftpRename":
|
||||
return handleSftpRename(params);
|
||||
case "netcatty/sftpStat":
|
||||
return handleSftpStat(params);
|
||||
case "netcatty/multiExec":
|
||||
return handleMultiExec(params);
|
||||
default:
|
||||
throw new Error(`Unknown method: ${method}`);
|
||||
}
|
||||
@@ -536,7 +478,8 @@ function handleGetContext(params) {
|
||||
const sshClient = session.conn || session.sshClient;
|
||||
const hasCommandablePty = ptyStream && typeof ptyStream.write === "function";
|
||||
const hasSshExec = sshClient && typeof sshClient.exec === "function";
|
||||
if (!hasCommandablePty && !hasSshExec) continue;
|
||||
const hasSerialPort = session.serialPort && typeof session.serialPort.write === "function";
|
||||
if (!hasCommandablePty && !hasSshExec && !hasSerialPort) continue;
|
||||
|
||||
// Look up metadata scoped to this chat session
|
||||
const meta = getSessionMeta(sessionId, chatSessionId) || {};
|
||||
@@ -548,17 +491,16 @@ function handleGetContext(params) {
|
||||
username: meta.username || session.username || "",
|
||||
protocol: meta.protocol || session.protocol || session.type || "",
|
||||
shellType: meta.shellType || session.shellKind || "",
|
||||
supportsSftp: sessionSupportsSftp(session),
|
||||
connected: meta.connected !== undefined ? meta.connected : !!(session.sshClient || session.conn || ptyStream),
|
||||
connected: meta.connected !== undefined ? meta.connected : !!(session.sshClient || session.conn || ptyStream || session.serialPort),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
environment: "netcatty-terminal",
|
||||
description: "You are operating inside Netcatty, a multi-session terminal manager. " +
|
||||
"The available sessions may be remote hosts, local terminals, or Mosh-backed shells. " +
|
||||
"The available sessions may be remote hosts, local terminals, Mosh-backed shells, or serial port connections (network devices, embedded systems). " +
|
||||
"Use the provided tools to execute commands through the sessions exposed by Netcatty. " +
|
||||
"SFTP tools only work for remote SSH sessions. " +
|
||||
"Serial sessions (protocol: serial, shellType: raw) do not run a standard shell — commands are sent as-is. " +
|
||||
"Always prefer these tools over suggesting the user to do things manually.",
|
||||
hosts,
|
||||
hostCount: hosts.length,
|
||||
@@ -574,14 +516,27 @@ function handleExec(params) {
|
||||
return { ok: false, error: 'Invalid command', exitCode: 1 };
|
||||
}
|
||||
|
||||
const safety = checkCommandSafety(command);
|
||||
if (safety.blocked) {
|
||||
return { ok: false, error: `Command blocked by safety policy. Pattern: ${safety.matchedPattern}` };
|
||||
}
|
||||
|
||||
const session = sessions?.get(sessionId);
|
||||
if (!session) return { ok: false, error: "Session not found" };
|
||||
|
||||
// The blocklist targets shell-specific patterns (rm -rf, eval, $(), etc.) that
|
||||
// are meaningless on network device CLIs. Serial sessions skip the check because
|
||||
// commands like "shutdown" (disable an interface) are routine on Cisco/Huawei.
|
||||
//
|
||||
// Design note: the serial protocol is explicitly chosen by the user in the UI
|
||||
// for network devices / embedded systems. While startSerialSession technically
|
||||
// supports PTY devices, users connecting to a Linux/BusyBox shell should use
|
||||
// the "local" protocol (which goes through the normal shell path with blocklist).
|
||||
// Additionally, execViaRawPty sends commands without shell wrapping, so shell
|
||||
// metacharacters in blocklist patterns (eval, $(), backticks, pipes) cannot
|
||||
// actually be interpreted even if sent to a serial-connected shell.
|
||||
if (session.protocol !== "serial") {
|
||||
const safety = checkCommandSafety(command);
|
||||
if (safety.blocked) {
|
||||
return { ok: false, error: `Command blocked by safety policy. Pattern: ${safety.matchedPattern}` };
|
||||
}
|
||||
}
|
||||
|
||||
if ((session.protocol === "local" || session.type === "local") && session.shellKind === "unknown") {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -598,291 +553,29 @@ function handleExec(params) {
|
||||
trackForCancellation: activePtyExecs,
|
||||
timeoutMs: commandTimeoutMs,
|
||||
shellKind: session.shellKind,
|
||||
expectedPrompt: session.lastIdlePrompt || "",
|
||||
});
|
||||
}
|
||||
|
||||
// If no PTY stream, fall back to exec channel for SSH sessions only.
|
||||
if (!sshClient || typeof sshClient.exec !== "function") {
|
||||
return { ok: false, error: "Session does not support command execution" };
|
||||
}
|
||||
|
||||
if (!ptyStream || typeof ptyStream.write !== "function") {
|
||||
// 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, {
|
||||
timeoutMs: commandTimeoutMs,
|
||||
trackForCancellation: activePtyExecs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Handler: terminalWrite ──
|
||||
|
||||
function handleTerminalWrite(params) {
|
||||
const { sessionId, input } = params;
|
||||
if (!sessionId || input == null) throw new Error("sessionId and input are required");
|
||||
|
||||
// Validate input against command blocklist
|
||||
const safety = checkCommandSafety(input);
|
||||
if (safety.blocked) {
|
||||
return { ok: false, error: `Input blocked by safety policy. Pattern: ${safety.matchedPattern}` };
|
||||
}
|
||||
|
||||
const session = sessions?.get(sessionId);
|
||||
if (!session) return { ok: false, error: "Session not found" };
|
||||
|
||||
if (session.stream) {
|
||||
session.stream.write(input);
|
||||
return { ok: true };
|
||||
}
|
||||
if (session.pty) {
|
||||
session.pty.write(input);
|
||||
return { ok: true };
|
||||
}
|
||||
if (session.proc) {
|
||||
session.proc.write(input);
|
||||
return { ok: true };
|
||||
}
|
||||
return { ok: false, error: "No writable stream" };
|
||||
}
|
||||
|
||||
// ── SFTP Helpers ──
|
||||
|
||||
function findSftpForSession(sessionId) {
|
||||
// Try to find an SFTP client keyed by the same sessionId
|
||||
if (sftpClients?.has(sessionId)) {
|
||||
return sftpClients.get(sessionId);
|
||||
}
|
||||
// Look through all SFTP clients for one sharing the same SSH connection
|
||||
const session = sessions?.get(sessionId);
|
||||
if (!session?.sshClient) return null;
|
||||
|
||||
for (const [, client] of sftpClients || []) {
|
||||
if (client.client === session.sshClient || client._sshClient === session.sshClient) {
|
||||
return client;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Handler: sftpList ──
|
||||
|
||||
async function handleSftpList(params) {
|
||||
const { sessionId, path: dirPath } = params;
|
||||
if (!sessionId || !dirPath) throw new Error("sessionId and path are required");
|
||||
|
||||
const sftpClient = findSftpForSession(sessionId);
|
||||
if (sftpClient) {
|
||||
try {
|
||||
const list = await sftpClient.list(dirPath);
|
||||
return {
|
||||
files: list.map(f => ({
|
||||
name: f.name,
|
||||
type: f.type === "d" ? "directory" : f.type === "l" ? "symlink" : "file",
|
||||
size: f.size,
|
||||
lastModified: f.modifyTime,
|
||||
permissions: f.rights ? `${f.rights.user}${f.rights.group}${f.rights.other}` : undefined,
|
||||
})),
|
||||
};
|
||||
} catch (err) {
|
||||
return { ok: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use SSH exec
|
||||
const result = await handleExec({ sessionId, command: `ls -la ${shellQuote(dirPath)}` });
|
||||
if (!result.ok) return { ok: false, error: result.error };
|
||||
return { output: result.stdout || "(empty directory)" };
|
||||
}
|
||||
|
||||
// ── Handler: sftpRead ──
|
||||
|
||||
async function handleSftpRead(params) {
|
||||
const { sessionId, path: filePath } = params;
|
||||
if (params.maxBytes != null && (typeof params.maxBytes !== 'number' || params.maxBytes < 1 || params.maxBytes > 10 * 1024 * 1024)) {
|
||||
return { ok: false, error: 'maxBytes must be a positive number between 1 and 10485760' };
|
||||
}
|
||||
// Clamp maxBytes to a safe upper bound (10MB)
|
||||
const maxBytes = Math.max(1, Math.min(Number(params.maxBytes) || 10000, 10 * 1024 * 1024));
|
||||
if (!sessionId || !filePath) throw new Error("sessionId and path are required");
|
||||
|
||||
// Fallback to SSH exec (more reliable across SFTP client states)
|
||||
const result = await handleExec({ sessionId, command: `head -c ${maxBytes} ${shellQuote(filePath)}` });
|
||||
if (!result.ok) return { ok: false, error: result.error };
|
||||
return { content: result.stdout || "(empty file)" };
|
||||
}
|
||||
|
||||
// ── Handler: sftpWrite ──
|
||||
|
||||
async function handleSftpWrite(params) {
|
||||
const { sessionId, path: filePath, content } = params;
|
||||
if (!sessionId || !filePath || content == null) throw new Error("sessionId, path and content are required");
|
||||
|
||||
const sftpClient = findSftpForSession(sessionId);
|
||||
if (sftpClient) {
|
||||
try {
|
||||
await sftpClient.put(Buffer.from(content, "utf-8"), filePath);
|
||||
return { written: filePath };
|
||||
} catch {
|
||||
// Fallback to SSH
|
||||
}
|
||||
}
|
||||
|
||||
// Use base64 encoding to avoid heredoc delimiter collision issues
|
||||
const b64 = Buffer.from(content, "utf-8").toString("base64");
|
||||
const result = await handleExec({ sessionId, command: `echo ${shellQuote(b64)} | base64 -d > ${shellQuote(filePath)}` });
|
||||
if (!result.ok) return { ok: false, error: result.error };
|
||||
return { written: filePath };
|
||||
}
|
||||
|
||||
// ── Handler: sftpMkdir ──
|
||||
|
||||
async function handleSftpMkdir(params) {
|
||||
const { sessionId, path: dirPath } = params;
|
||||
if (!sessionId || !dirPath) throw new Error("sessionId and path are required");
|
||||
|
||||
const sftpClient = findSftpForSession(sessionId);
|
||||
if (sftpClient) {
|
||||
try {
|
||||
await sftpClient.mkdir(dirPath, true); // recursive
|
||||
return { created: dirPath };
|
||||
} catch {
|
||||
// Fallback
|
||||
}
|
||||
}
|
||||
|
||||
const result = await handleExec({ sessionId, command: `mkdir -p ${shellQuote(dirPath)}` });
|
||||
if (!result.ok) return { ok: false, error: result.error };
|
||||
return { created: dirPath };
|
||||
}
|
||||
|
||||
// ── Handler: sftpRemove ──
|
||||
|
||||
// Critical paths that must never be removed (module-level constant)
|
||||
const CRITICAL_PATHS = new Set([
|
||||
"/", "/root", "/home", "/etc", "/var", "/usr", "/boot",
|
||||
"/bin", "/sbin", "/lib", "/lib64", "/dev", "/proc", "/sys", "/tmp", "/opt",
|
||||
]);
|
||||
|
||||
async function handleSftpRemove(params) {
|
||||
const { sessionId, path: targetPath } = params;
|
||||
if (!sessionId || !targetPath) throw new Error("sessionId and path are required");
|
||||
|
||||
// Guard against deleting root or critical system directories
|
||||
// Normalize to resolve "..", "//", and trailing slashes before checking
|
||||
const normalizedPath = path.posix.normalize(targetPath).replace(/\/+$/, "") || "/";
|
||||
if (CRITICAL_PATHS.has(normalizedPath) || /^\/[^/]+$/.test(normalizedPath)) {
|
||||
return { ok: false, error: `Refusing to remove critical or root-level path: ${targetPath}` };
|
||||
}
|
||||
|
||||
// Use rm -r (without -f) so permission errors surface instead of being silently ignored
|
||||
const result = await handleExec({ sessionId, command: `rm -r ${shellQuote(targetPath)}` });
|
||||
if (!result.ok) return { ok: false, error: result.error };
|
||||
return { removed: targetPath };
|
||||
}
|
||||
|
||||
// ── Handler: sftpRename ──
|
||||
|
||||
async function handleSftpRename(params) {
|
||||
const { sessionId, oldPath, newPath } = params;
|
||||
if (!sessionId || !oldPath || !newPath) throw new Error("sessionId, oldPath and newPath are required");
|
||||
|
||||
const sftpClient = findSftpForSession(sessionId);
|
||||
if (sftpClient) {
|
||||
try {
|
||||
await sftpClient.rename(oldPath, newPath);
|
||||
return { renamed: `${oldPath} → ${newPath}` };
|
||||
} catch {
|
||||
// Fallback
|
||||
}
|
||||
}
|
||||
|
||||
const result = await handleExec({ sessionId, command: `mv ${shellQuote(oldPath)} ${shellQuote(newPath)}` });
|
||||
if (!result.ok) return { ok: false, error: result.error };
|
||||
return { renamed: `${oldPath} → ${newPath}` };
|
||||
}
|
||||
|
||||
// ── Handler: sftpStat ──
|
||||
|
||||
async function handleSftpStat(params) {
|
||||
const { sessionId, path: targetPath } = params;
|
||||
if (!sessionId || !targetPath) throw new Error("sessionId and path are required");
|
||||
|
||||
const sftpClient = findSftpForSession(sessionId);
|
||||
if (sftpClient) {
|
||||
try {
|
||||
const stat = await sftpClient.stat(targetPath);
|
||||
return {
|
||||
name: path.basename(targetPath),
|
||||
type: stat.isDirectory ? "directory" : stat.isSymbolicLink ? "symlink" : "file",
|
||||
size: stat.size,
|
||||
lastModified: stat.modifyTime,
|
||||
permissions: stat.mode ? (stat.mode & 0o777).toString(8) : undefined,
|
||||
};
|
||||
} catch {
|
||||
// Fallback
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use stat command
|
||||
const result = await handleExec({ sessionId, command: `stat -c '{"size":%s,"mode":"%a","mtime":%Y,"type":"%F"}' ${shellQuote(targetPath)}` });
|
||||
if (!result.ok) return { ok: false, error: result.error };
|
||||
try {
|
||||
const parsed = JSON.parse(result.stdout.trim());
|
||||
return {
|
||||
name: path.basename(targetPath),
|
||||
type: parsed.type?.includes("directory") ? "directory" : "file",
|
||||
size: parsed.size,
|
||||
lastModified: parsed.mtime * 1000,
|
||||
permissions: parsed.mode,
|
||||
};
|
||||
} catch {
|
||||
return { ok: false, error: "Failed to parse stat output" };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Handler: multiExec ──
|
||||
|
||||
async function handleMultiExec(params) {
|
||||
const { sessionIds, command, mode = "parallel", stopOnError = false } = params;
|
||||
if (!Array.isArray(sessionIds) || !command) throw new Error("sessionIds and command are required");
|
||||
if (sessionIds.length > 50) {
|
||||
return { ok: false, error: 'Too many session IDs: maximum is 50' };
|
||||
}
|
||||
if (typeof command !== 'string' || !command.trim()) {
|
||||
return { ok: false, error: 'Invalid command' };
|
||||
}
|
||||
|
||||
const safety = checkCommandSafety(command);
|
||||
if (safety.blocked) {
|
||||
return { ok: false, error: `Command blocked by safety policy. Pattern: ${safety.matchedPattern}` };
|
||||
}
|
||||
|
||||
const results = {};
|
||||
|
||||
if (mode === "sequential") {
|
||||
for (const sid of sessionIds) {
|
||||
const result = await handleExec({ sessionId: sid, command });
|
||||
results[sid] = {
|
||||
ok: result.ok,
|
||||
output: result.ok ? (result.stdout || "(no output)") : `Error: ${result.error || result.stderr || "Failed"}`,
|
||||
};
|
||||
if (!result.ok && stopOnError) break;
|
||||
}
|
||||
} else {
|
||||
// Parallel execution with concurrency limit
|
||||
const tasks = sessionIds.map((sid) => () => {
|
||||
return Promise.resolve(handleExec({ sessionId: sid, command })).then(result => ({
|
||||
sid,
|
||||
ok: result.ok,
|
||||
output: result.ok ? (result.stdout || "(no output)") : `Error: ${result.error || result.stderr || "Failed"}`,
|
||||
}));
|
||||
// Serial port: raw command execution (no shell wrapping)
|
||||
if (session.protocol === "serial" && session.serialPort && typeof session.serialPort.write === "function") {
|
||||
return execViaRawPty(session.serialPort, command, {
|
||||
timeoutMs: commandTimeoutMs,
|
||||
trackForCancellation: activePtyExecs,
|
||||
chatSessionId: params?.chatSessionId,
|
||||
});
|
||||
const resolved = await limitConcurrency(tasks, 10);
|
||||
for (const r of resolved) {
|
||||
results[r.sid] = { ok: r.ok, output: r.output };
|
||||
}
|
||||
}
|
||||
|
||||
return { results };
|
||||
return { ok: false, error: "Session does not support command execution" };
|
||||
}
|
||||
|
||||
// ── MCP Server Config Builder ──
|
||||
@@ -916,11 +609,6 @@ function buildMcpServerConfig(port, scopedSessionIds, chatSessionId) {
|
||||
env.push({ name: "NETCATTY_MCP_CHAT_SESSION_ID", value: chatSessionId });
|
||||
}
|
||||
|
||||
env.push({
|
||||
name: "NETCATTY_MCP_ENABLE_SFTP",
|
||||
value: scopeHasSftpSessions(effectiveIds) ? "1" : "0",
|
||||
});
|
||||
|
||||
// Pass permission mode so MCP server can enforce it locally (defense-in-depth)
|
||||
env.push({ name: "NETCATTY_MCP_PERMISSION_MODE", value: permissionMode });
|
||||
|
||||
@@ -966,7 +654,9 @@ module.exports = {
|
||||
getScopedSessionIds,
|
||||
getOrCreateHost,
|
||||
buildMcpServerConfig,
|
||||
activePtyExecs,
|
||||
cancelAllPtyExecs,
|
||||
cancelPtyExecsForSession,
|
||||
cleanupScopedMetadata,
|
||||
cleanup,
|
||||
setMainWindowGetter,
|
||||
|
||||
@@ -3,31 +3,40 @@
|
||||
* Extracted from main.cjs for single responsibility
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const net = require("node:net");
|
||||
const { Client: SSHClient } = require("ssh2");
|
||||
const { NetcattyAgent } = require("./netcattyAgent.cjs");
|
||||
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
const { connectThroughChain } = require("./sshBridge.cjs");
|
||||
const { createProxySocket } = require("./proxyUtils.cjs");
|
||||
const {
|
||||
buildAuthHandler,
|
||||
createKeyboardInteractiveHandler,
|
||||
applyAuthToConnOpts,
|
||||
findAllDefaultPrivateKeys: findAllDefaultPrivateKeysFromHelper,
|
||||
isKeyEncrypted,
|
||||
} = require("./sshAuthHelper.cjs");
|
||||
const passphraseHandler = require("./passphraseHandler.cjs");
|
||||
|
||||
// Active port forwarding tunnels
|
||||
const portForwardingTunnels = new Map();
|
||||
|
||||
/**
|
||||
* Send message to renderer safely
|
||||
*/
|
||||
function safeSend(sender, channel, payload) {
|
||||
try {
|
||||
if (!sender || sender.isDestroyed()) return;
|
||||
sender.send(channel, payload);
|
||||
} catch {
|
||||
// Ignore destroyed webContents during shutdown.
|
||||
function cleanupChainConnections(connections) {
|
||||
if (!Array.isArray(connections)) return;
|
||||
for (const chainConn of connections) {
|
||||
try { chainConn.end(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
function isTunnelCancelled(tunnelState) {
|
||||
return Boolean(tunnelState?.cancelled);
|
||||
}
|
||||
|
||||
const { safeSend } = require("./ipcUtils.cjs");
|
||||
|
||||
/**
|
||||
* Start a port forwarding tunnel
|
||||
*/
|
||||
@@ -44,11 +53,30 @@ async function startPortForward(event, payload) {
|
||||
username,
|
||||
password,
|
||||
privateKey,
|
||||
certificate,
|
||||
keyId,
|
||||
passphrase,
|
||||
proxy,
|
||||
jumpHosts = [],
|
||||
identityFilePaths,
|
||||
} = payload;
|
||||
|
||||
const conn = new SSHClient();
|
||||
const sender = event.sender;
|
||||
const hasJumpHosts = jumpHosts.length > 0;
|
||||
const hasProxy = !!proxy;
|
||||
let chainConnections = [];
|
||||
let connectionSocket = null;
|
||||
const tunnelState = {
|
||||
type,
|
||||
conn,
|
||||
pendingConn: null,
|
||||
server: null,
|
||||
chainConnections,
|
||||
status: 'connecting',
|
||||
webContentsId: sender.id,
|
||||
cancelled: false,
|
||||
};
|
||||
|
||||
const sendStatus = (status, error = null) => {
|
||||
if (!sender.isDestroyed()) {
|
||||
@@ -66,9 +94,53 @@ async function startPortForward(event, payload) {
|
||||
tryKeyboard: true,
|
||||
};
|
||||
|
||||
if (privateKey) {
|
||||
const hasCertificate = typeof certificate === "string" && certificate.trim().length > 0;
|
||||
|
||||
if (hasCertificate) {
|
||||
connectOpts.agent = new NetcattyAgent({
|
||||
mode: "certificate",
|
||||
webContents: sender,
|
||||
meta: {
|
||||
label: keyId || username || "",
|
||||
certificate,
|
||||
privateKey,
|
||||
passphrase,
|
||||
},
|
||||
});
|
||||
} else if (privateKey) {
|
||||
connectOpts.privateKey = privateKey;
|
||||
}
|
||||
|
||||
// Read identity files from local paths (e.g. SSH config IdentityFile)
|
||||
// when no explicit key/certificate was already configured.
|
||||
if (!connectOpts.privateKey && !connectOpts.agent && identityFilePaths?.length > 0) {
|
||||
for (const keyPath of identityFilePaths) {
|
||||
try {
|
||||
const resolvedPath = keyPath.startsWith("~/")
|
||||
? path.join(os.homedir(), keyPath.slice(2))
|
||||
: keyPath;
|
||||
const keyContent = await fs.promises.readFile(resolvedPath, "utf8");
|
||||
connectOpts.privateKey = keyContent;
|
||||
if (isKeyEncrypted(keyContent)) {
|
||||
const result = await passphraseHandler.requestPassphrase(
|
||||
sender,
|
||||
resolvedPath,
|
||||
path.basename(resolvedPath),
|
||||
hostname,
|
||||
);
|
||||
if (result?.passphrase) {
|
||||
connectOpts.passphrase = result.passphrase;
|
||||
} else {
|
||||
delete connectOpts.privateKey;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
break;
|
||||
} catch (err) {
|
||||
console.warn(`[PortForward] Failed to read identity file ${keyPath}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (passphrase) {
|
||||
connectOpts.passphrase = passphrase;
|
||||
}
|
||||
@@ -76,19 +148,101 @@ async function startPortForward(event, payload) {
|
||||
connectOpts.password = password;
|
||||
}
|
||||
|
||||
// Get default keys
|
||||
const defaultKeys = await findAllDefaultPrivateKeysFromHelper();
|
||||
sendStatus('connecting');
|
||||
portForwardingTunnels.set(tunnelId, tunnelState);
|
||||
|
||||
// Build auth handler using shared helper
|
||||
const authConfig = buildAuthHandler({
|
||||
privateKey,
|
||||
password,
|
||||
passphrase,
|
||||
username: connectOpts.username,
|
||||
logPrefix: "[PortForward]",
|
||||
defaultKeys,
|
||||
});
|
||||
applyAuthToConnOpts(connectOpts, authConfig);
|
||||
let defaultKeys = [];
|
||||
try {
|
||||
// Get default keys
|
||||
defaultKeys = await findAllDefaultPrivateKeysFromHelper();
|
||||
if (isTunnelCancelled(tunnelState)) {
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
return { tunnelId, success: false, cancelled: true };
|
||||
}
|
||||
|
||||
// Build auth handler using shared helper
|
||||
const authConfig = buildAuthHandler({
|
||||
privateKey: connectOpts.privateKey,
|
||||
password,
|
||||
passphrase: connectOpts.passphrase,
|
||||
agent: connectOpts.agent,
|
||||
username: connectOpts.username,
|
||||
logPrefix: "[PortForward]",
|
||||
defaultKeys,
|
||||
});
|
||||
applyAuthToConnOpts(connectOpts, authConfig);
|
||||
if (isTunnelCancelled(tunnelState)) {
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
return { tunnelId, success: false, cancelled: true };
|
||||
}
|
||||
|
||||
if (hasJumpHosts) {
|
||||
const chainResult = await connectThroughChain(
|
||||
event,
|
||||
{
|
||||
hostname,
|
||||
port,
|
||||
username,
|
||||
password,
|
||||
privateKey,
|
||||
passphrase,
|
||||
proxy,
|
||||
jumpHosts,
|
||||
_defaultKeys: defaultKeys,
|
||||
_connectionsRef: chainConnections,
|
||||
_tunnelRef: tunnelState,
|
||||
},
|
||||
jumpHosts,
|
||||
hostname,
|
||||
port,
|
||||
tunnelId,
|
||||
);
|
||||
connectionSocket = chainResult.socket;
|
||||
chainConnections = chainResult.connections;
|
||||
tunnelState.chainConnections = chainConnections;
|
||||
if (isTunnelCancelled(tunnelState)) {
|
||||
cleanupChainConnections(chainConnections);
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
return { tunnelId, success: false, cancelled: true };
|
||||
}
|
||||
connectOpts.sock = connectionSocket;
|
||||
delete connectOpts.host;
|
||||
delete connectOpts.port;
|
||||
} else if (hasProxy) {
|
||||
connectionSocket = await createProxySocket(proxy, hostname, port, {
|
||||
onSocket: (socket) => {
|
||||
tunnelState.pendingConn = socket;
|
||||
},
|
||||
});
|
||||
if (isTunnelCancelled(tunnelState)) {
|
||||
try { connectionSocket?.end?.(); } catch { /* ignore */ }
|
||||
try { connectionSocket?.destroy?.(); } catch { /* ignore */ }
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
return { tunnelId, success: false, cancelled: true };
|
||||
}
|
||||
tunnelState.pendingConn = null;
|
||||
connectOpts.sock = connectionSocket;
|
||||
delete connectOpts.host;
|
||||
delete connectOpts.port;
|
||||
}
|
||||
} catch (err) {
|
||||
if (isTunnelCancelled(tunnelState)) {
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
return { tunnelId, success: false, cancelled: true };
|
||||
}
|
||||
tunnelState.cancelled = true;
|
||||
if (tunnelState.pendingConn) {
|
||||
try { tunnelState.pendingConn.end(); } catch { /* ignore */ }
|
||||
}
|
||||
cleanupChainConnections(tunnelState.chainConnections);
|
||||
if (connectionSocket) {
|
||||
try { connectionSocket.end?.(); } catch { /* ignore */ }
|
||||
try { connectionSocket.destroy?.(); } catch { /* ignore */ }
|
||||
}
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
sendStatus('error', err?.message || String(err));
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Handle keyboard-interactive authentication (2FA/MFA)
|
||||
conn.on("keyboard-interactive", createKeyboardInteractiveHandler({
|
||||
@@ -133,20 +287,20 @@ async function startPortForward(event, payload) {
|
||||
console.error(`[PortForward] Server error:`, err.message);
|
||||
sendStatus('error', err.message);
|
||||
conn.end();
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
settled = true;
|
||||
reject(err);
|
||||
});
|
||||
|
||||
server.listen(localPort, bindAddress, () => {
|
||||
console.log(`[PortForward] Local forwarding active: ${bindAddress}:${localPort} -> ${remoteHost}:${remotePort}`);
|
||||
portForwardingTunnels.set(tunnelId, {
|
||||
type: 'local',
|
||||
conn,
|
||||
server,
|
||||
status: 'active',
|
||||
webContentsId: sender.id
|
||||
});
|
||||
tunnelState.type = 'local';
|
||||
tunnelState.conn = conn;
|
||||
tunnelState.server = server;
|
||||
tunnelState.chainConnections = chainConnections;
|
||||
tunnelState.status = 'active';
|
||||
tunnelState.webContentsId = sender.id;
|
||||
tunnelState.pendingConn = null;
|
||||
portForwardingTunnels.set(tunnelId, tunnelState);
|
||||
sendStatus('active');
|
||||
settled = true;
|
||||
resolve({ tunnelId, success: true });
|
||||
@@ -165,12 +319,14 @@ async function startPortForward(event, payload) {
|
||||
}
|
||||
|
||||
console.log(`[PortForward] Remote forwarding active: remote ${bindAddress}:${localPort} -> local ${remoteHost}:${remotePort}`);
|
||||
portForwardingTunnels.set(tunnelId, {
|
||||
type: 'remote',
|
||||
conn,
|
||||
status: 'active',
|
||||
webContentsId: sender.id
|
||||
});
|
||||
tunnelState.type = 'remote';
|
||||
tunnelState.conn = conn;
|
||||
tunnelState.server = null;
|
||||
tunnelState.chainConnections = chainConnections;
|
||||
tunnelState.status = 'active';
|
||||
tunnelState.webContentsId = sender.id;
|
||||
tunnelState.pendingConn = null;
|
||||
portForwardingTunnels.set(tunnelId, tunnelState);
|
||||
sendStatus('active');
|
||||
settled = true;
|
||||
resolve({ tunnelId, success: true });
|
||||
@@ -273,20 +429,20 @@ async function startPortForward(event, payload) {
|
||||
console.error(`[PortForward] SOCKS server error:`, err.message);
|
||||
sendStatus('error', err.message);
|
||||
conn.end();
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
settled = true;
|
||||
reject(err);
|
||||
});
|
||||
|
||||
server.listen(localPort, bindAddress, () => {
|
||||
console.log(`[PortForward] Dynamic SOCKS5 proxy active on ${bindAddress}:${localPort}`);
|
||||
portForwardingTunnels.set(tunnelId, {
|
||||
type: 'dynamic',
|
||||
conn,
|
||||
server,
|
||||
status: 'active',
|
||||
webContentsId: sender.id
|
||||
});
|
||||
tunnelState.type = 'dynamic';
|
||||
tunnelState.conn = conn;
|
||||
tunnelState.server = server;
|
||||
tunnelState.chainConnections = chainConnections;
|
||||
tunnelState.status = 'active';
|
||||
tunnelState.webContentsId = sender.id;
|
||||
tunnelState.pendingConn = null;
|
||||
portForwardingTunnels.set(tunnelId, tunnelState);
|
||||
sendStatus('active');
|
||||
settled = true;
|
||||
resolve({ tunnelId, success: true });
|
||||
@@ -297,10 +453,11 @@ async function startPortForward(event, payload) {
|
||||
}
|
||||
});
|
||||
|
||||
conn.once('error', (err) => {
|
||||
conn.on('error', (err) => {
|
||||
console.error(`[PortForward] SSH error:`, err.message);
|
||||
if (settled) return;
|
||||
sendStatus('error', err.message);
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
cleanupChainConnections(chainConnections);
|
||||
settled = true;
|
||||
reject(err);
|
||||
});
|
||||
@@ -314,6 +471,12 @@ async function startPortForward(event, payload) {
|
||||
if (tunnel.server) {
|
||||
try { tunnel.server.close(); } catch { }
|
||||
}
|
||||
if (Array.isArray(tunnel.chainConnections)) {
|
||||
cleanupChainConnections(tunnel.chainConnections);
|
||||
}
|
||||
if (tunnel.pendingConn) {
|
||||
try { tunnel.pendingConn.end(); } catch { /* ignore */ }
|
||||
}
|
||||
sendStatus('inactive');
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
}
|
||||
@@ -329,18 +492,6 @@ async function startPortForward(event, payload) {
|
||||
}
|
||||
});
|
||||
|
||||
sendStatus('connecting');
|
||||
// Register the connection BEFORE the handshake starts so that
|
||||
// stopPortForwardByRuleId can find and kill it at any point,
|
||||
// including during the SSH handshake window. The conn.on('ready')
|
||||
// handler updates the entry to include the server object later.
|
||||
portForwardingTunnels.set(tunnelId, {
|
||||
type,
|
||||
conn,
|
||||
server: null,
|
||||
status: 'connecting',
|
||||
webContentsId: sender.id,
|
||||
});
|
||||
conn.connect(connectOpts);
|
||||
});
|
||||
}
|
||||
@@ -363,6 +514,10 @@ async function stopPortForward(event, payload) {
|
||||
if (tunnel.server) {
|
||||
tunnel.server.close();
|
||||
}
|
||||
if (tunnel.pendingConn) {
|
||||
tunnel.pendingConn.end();
|
||||
}
|
||||
cleanupChainConnections(tunnel.chainConnections);
|
||||
if (tunnel.conn) {
|
||||
tunnel.conn.end();
|
||||
}
|
||||
@@ -417,6 +572,10 @@ function stopAllPortForwards() {
|
||||
if (tunnel.server) {
|
||||
tunnel.server.close();
|
||||
}
|
||||
if (tunnel.pendingConn) {
|
||||
tunnel.pendingConn.end();
|
||||
}
|
||||
cleanupChainConnections(tunnel.chainConnections);
|
||||
if (tunnel.conn) {
|
||||
tunnel.conn.end();
|
||||
}
|
||||
@@ -446,6 +605,8 @@ function stopPortForwardByRuleId(_event, { ruleId }) {
|
||||
// close handler resolves gracefully instead of rejecting.
|
||||
tunnel.cancelled = true;
|
||||
if (tunnel.server) tunnel.server.close();
|
||||
if (tunnel.pendingConn) tunnel.pendingConn.end();
|
||||
cleanupChainConnections(tunnel.chainConnections);
|
||||
if (tunnel.conn) tunnel.conn.end();
|
||||
// Don't delete here — let the conn.on('close') handler delete
|
||||
// the entry so it can read tunnel.cancelled first.
|
||||
|
||||
@@ -15,9 +15,12 @@ const net = require("node:net");
|
||||
* @param {string} [proxy.password] - Optional password for auth
|
||||
* @param {string} targetHost - Target host to connect through proxy
|
||||
* @param {number} targetPort - Target port to connect through proxy
|
||||
* @param {Object} [options]
|
||||
* @param {(socket: net.Socket) => void} [options.onSocket] - Called immediately with the underlying socket
|
||||
* @returns {Promise<net.Socket>} Connected socket through proxy
|
||||
*/
|
||||
function createProxySocket(proxy, targetHost, targetPort) {
|
||||
function createProxySocket(proxy, targetHost, targetPort, options = {}) {
|
||||
const { onSocket } = options;
|
||||
return new Promise((resolve, reject) => {
|
||||
if (proxy.type === 'http') {
|
||||
// HTTP CONNECT proxy
|
||||
@@ -45,6 +48,7 @@ function createProxySocket(proxy, targetHost, targetPort) {
|
||||
};
|
||||
socket.on('data', onData);
|
||||
});
|
||||
try { onSocket?.(socket); } catch { /* ignore */ }
|
||||
socket.on('error', reject);
|
||||
} else if (proxy.type === 'socks5') {
|
||||
// SOCKS5 proxy
|
||||
@@ -123,6 +127,7 @@ function createProxySocket(proxy, targetHost, targetPort) {
|
||||
|
||||
socket.on('data', onData);
|
||||
});
|
||||
try { onSocket?.(socket); } catch { /* ignore */ }
|
||||
socket.on('error', reject);
|
||||
} else {
|
||||
reject(new Error(`Unknown proxy type: ${proxy.type}`));
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
const net = require("node:net");
|
||||
const { TextDecoder } = require("node:util");
|
||||
const SftpClient = require("ssh2-sftp-client");
|
||||
const { Client: SSHClient } = require("ssh2");
|
||||
@@ -22,12 +21,14 @@ try {
|
||||
const { NetcattyAgent } = require("./netcattyAgent.cjs");
|
||||
const fileWatcherBridge = require("./fileWatcherBridge.cjs");
|
||||
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
const passphraseHandler = require("./passphraseHandler.cjs");
|
||||
const { createProxySocket } = require("./proxyUtils.cjs");
|
||||
const {
|
||||
buildAuthHandler,
|
||||
createKeyboardInteractiveHandler,
|
||||
applyAuthToConnOpts,
|
||||
safeSend: authSafeSend,
|
||||
isKeyEncrypted,
|
||||
findAllDefaultPrivateKeys: findAllDefaultPrivateKeysFromHelper,
|
||||
getAvailableAgentSocket,
|
||||
} = require("./sshAuthHelper.cjs");
|
||||
@@ -410,17 +411,7 @@ function buildSftpAlgorithms(legacyEnabled) {
|
||||
return algorithms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to renderer safely
|
||||
*/
|
||||
function safeSend(sender, channel, payload) {
|
||||
try {
|
||||
if (!sender || sender.isDestroyed()) return;
|
||||
sender.send(channel, payload);
|
||||
} catch {
|
||||
// Ignore destroyed webContents during shutdown.
|
||||
}
|
||||
}
|
||||
const { safeSend } = require("./ipcUtils.cjs");
|
||||
|
||||
/**
|
||||
* Initialize the SFTP bridge with dependencies
|
||||
@@ -430,6 +421,18 @@ function init(deps) {
|
||||
electronModule = deps.electronModule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send SFTP connection progress to the renderer for user-visible logging
|
||||
*/
|
||||
function sendSftpProgress(sender, sessionId, label, status, detail) {
|
||||
try {
|
||||
if (!sender || sender.isDestroyed()) return;
|
||||
sender.send("netcatty:sftp:connection-progress", { sessionId, label, status, detail });
|
||||
} catch {
|
||||
// Ignore destroyed webContents
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect through a chain of jump hosts for SFTP
|
||||
*/
|
||||
@@ -447,6 +450,7 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
const hopLabel = jump.label || (jump.hostname.includes(':') && !jump.hostname.startsWith('[') ? `[${jump.hostname}]:${jump.port || 22}` : `${jump.hostname}:${jump.port || 22}`);
|
||||
|
||||
console.log(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: Connecting to ${hopLabel}...`);
|
||||
sendSftpProgress(sender, connId, hopLabel, 'connecting');
|
||||
|
||||
const conn = new SSHClient();
|
||||
// Increase max listeners to prevent Node.js warning
|
||||
@@ -485,7 +489,59 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
connOpts.agent = authAgent;
|
||||
} else if (jump.privateKey) {
|
||||
connOpts.privateKey = jump.privateKey;
|
||||
if (jump.passphrase) connOpts.passphrase = jump.passphrase;
|
||||
if (jump.passphrase) {
|
||||
connOpts.passphrase = jump.passphrase;
|
||||
} else if (isKeyEncrypted(jump.privateKey)) {
|
||||
// Key is encrypted but no passphrase provided — prompt the user
|
||||
console.log(`[SFTP Chain] Hop ${i + 1}: key is encrypted, requesting passphrase`);
|
||||
const keyLabel = jump.label || hopLabel;
|
||||
const result = await passphraseHandler.requestPassphrase(
|
||||
sender,
|
||||
`SSH key for ${keyLabel}`,
|
||||
keyLabel,
|
||||
hopLabel
|
||||
);
|
||||
if (result?.passphrase) {
|
||||
connOpts.passphrase = result.passphrase;
|
||||
} else {
|
||||
delete connOpts.privateKey;
|
||||
if (result?.cancelled) {
|
||||
throw new Error(`Passphrase entry cancelled for ${hopLabel}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read identity files from local paths (e.g. from SSH config IdentityFile)
|
||||
if (!connOpts.privateKey && !connOpts.agent && jump.identityFilePaths?.length > 0) {
|
||||
for (const keyPath of jump.identityFilePaths) {
|
||||
try {
|
||||
const resolvedPath = keyPath.startsWith("~/")
|
||||
? path.join(os.homedir(), keyPath.slice(2))
|
||||
: keyPath;
|
||||
const keyContent = await fs.promises.readFile(resolvedPath, "utf8");
|
||||
connOpts.privateKey = keyContent;
|
||||
if (isKeyEncrypted(keyContent)) {
|
||||
console.log(`[SFTP Chain] Hop ${i + 1}: identity file ${resolvedPath} is encrypted, requesting passphrase`);
|
||||
const result = await passphraseHandler.requestPassphrase(
|
||||
sender,
|
||||
resolvedPath,
|
||||
path.basename(resolvedPath),
|
||||
hopLabel
|
||||
);
|
||||
if (result?.passphrase) {
|
||||
connOpts.passphrase = result.passphrase;
|
||||
} else {
|
||||
delete connOpts.privateKey;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
console.log(`[SFTP Chain] Hop ${i + 1}: loaded identity file ${resolvedPath}`);
|
||||
break;
|
||||
} catch (err) {
|
||||
console.warn(`[SFTP Chain] Hop ${i + 1}: failed to read identity file ${keyPath}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (jump.password) connOpts.password = jump.password;
|
||||
@@ -505,12 +561,17 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
unlockedEncryptedKeys: options._unlockedEncryptedKeys || [],
|
||||
defaultKeys,
|
||||
sshAgentSocketOverride: agentSocket,
|
||||
onAuthAttempt: (method) => {
|
||||
sendSftpProgress(sender, connId, hopLabel, 'auth-attempt', method);
|
||||
},
|
||||
});
|
||||
applyAuthToConnOpts(connOpts, authConfig);
|
||||
|
||||
// If first hop and proxy is configured, connect through proxy
|
||||
if (isFirst && options.proxy) {
|
||||
currentSocket = await createProxySocket(options.proxy, jump.hostname, jump.port || 22);
|
||||
const hasUsableJumpProxy = !!(jump.proxy?.host && jump.proxy?.port);
|
||||
const effectiveHopProxy = isFirst ? ((hasUsableJumpProxy ? jump.proxy : null) || options.proxy) : null;
|
||||
if (effectiveHopProxy) {
|
||||
currentSocket = await createProxySocket(effectiveHopProxy, jump.hostname, jump.port || 22);
|
||||
connOpts.sock = currentSocket;
|
||||
delete connOpts.host;
|
||||
delete connOpts.port;
|
||||
@@ -523,8 +584,12 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
|
||||
// Connect this hop
|
||||
await new Promise((resolve, reject) => {
|
||||
conn.once('handshake', () => {
|
||||
sendSftpProgress(sender, connId, hopLabel, 'authenticating');
|
||||
});
|
||||
conn.once('ready', () => {
|
||||
console.log(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: ${hopLabel} connected`);
|
||||
sendSftpProgress(sender, connId, hopLabel, 'connected');
|
||||
resolve();
|
||||
});
|
||||
conn.on('error', (err) => {
|
||||
@@ -534,6 +599,7 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
return;
|
||||
}
|
||||
console.error(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: ${hopLabel} error:`, err.message);
|
||||
sendSftpProgress(sender, connId, hopLabel, 'error', err.message);
|
||||
reject(err);
|
||||
});
|
||||
conn.once('timeout', () => {
|
||||
@@ -541,13 +607,23 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
reject(new Error(`Connection timeout to ${hopLabel}`));
|
||||
});
|
||||
// Handle keyboard-interactive authentication for jump hosts (2FA/MFA)
|
||||
conn.on('keyboard-interactive', createKeyboardInteractiveHandler({
|
||||
const sftpChainKiHandler = createKeyboardInteractiveHandler({
|
||||
sender,
|
||||
sessionId: connId,
|
||||
hostname: hopLabel,
|
||||
password: jump.password,
|
||||
logPrefix: `[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}`,
|
||||
}));
|
||||
});
|
||||
conn.on('keyboard-interactive', (name, instructions, lang, prompts, finish) => {
|
||||
if (prompts && prompts.length > 0) {
|
||||
sendSftpProgress(sender, connId, hopLabel, 'auth-attempt', 'waiting for user input...');
|
||||
}
|
||||
const wrappedFinish = (...args) => {
|
||||
sendSftpProgress(sender, connId, hopLabel, 'auth-attempt', 'user responded');
|
||||
finish(...args);
|
||||
};
|
||||
sftpChainKiHandler(name, instructions, lang, prompts, wrappedFinish);
|
||||
});
|
||||
conn.connect(connOpts);
|
||||
});
|
||||
|
||||
@@ -906,7 +982,69 @@ async function openSftp(event, options) {
|
||||
connectOpts.agent = authAgent;
|
||||
} else if (options.privateKey) {
|
||||
connectOpts.privateKey = options.privateKey;
|
||||
if (options.passphrase) connectOpts.passphrase = options.passphrase;
|
||||
if (options.passphrase) {
|
||||
connectOpts.passphrase = options.passphrase;
|
||||
} else if (isKeyEncrypted(options.privateKey)) {
|
||||
// Key is encrypted but no passphrase provided — prompt the user
|
||||
console.log(`[SFTP] Key is encrypted, requesting passphrase for ${options.hostname}`);
|
||||
const result = await passphraseHandler.requestPassphrase(
|
||||
event.sender,
|
||||
`SSH key for ${options.hostname}`,
|
||||
options.hostname,
|
||||
options.hostname
|
||||
);
|
||||
if (result?.passphrase) {
|
||||
connectOpts.passphrase = result.passphrase;
|
||||
} else {
|
||||
delete connectOpts.privateKey;
|
||||
if (result?.cancelled) {
|
||||
// Clean up any chain/proxy connections and proxy socket opened earlier
|
||||
for (const c of chainConnections) {
|
||||
try { c.end(); } catch {}
|
||||
}
|
||||
if (connectionSocket) {
|
||||
try { connectionSocket.destroy(); } catch {}
|
||||
}
|
||||
// Use "authentication" in the message so the SFTP frontend's
|
||||
// isAuthError() check recognizes this and falls back to password.
|
||||
const err = new Error(`Authentication cancelled — passphrase not provided for ${options.hostname}`);
|
||||
err.level = 'client-authentication';
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read identity files from local paths (e.g. from SSH config IdentityFile)
|
||||
if (!connectOpts.privateKey && !connectOpts.agent && options.identityFilePaths?.length > 0) {
|
||||
for (const keyPath of options.identityFilePaths) {
|
||||
try {
|
||||
const resolvedPath = keyPath.startsWith("~/")
|
||||
? path.join(os.homedir(), keyPath.slice(2))
|
||||
: keyPath;
|
||||
const keyContent = await fs.promises.readFile(resolvedPath, "utf8");
|
||||
connectOpts.privateKey = keyContent;
|
||||
if (isKeyEncrypted(keyContent)) {
|
||||
console.log(`[SFTP] Identity file ${resolvedPath} is encrypted, requesting passphrase`);
|
||||
const result = await passphraseHandler.requestPassphrase(
|
||||
event.sender,
|
||||
resolvedPath,
|
||||
path.basename(resolvedPath),
|
||||
options.hostname
|
||||
);
|
||||
if (result?.passphrase) {
|
||||
connectOpts.passphrase = result.passphrase;
|
||||
} else {
|
||||
delete connectOpts.privateKey;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
console.log(`[SFTP] Loaded identity file ${resolvedPath}`);
|
||||
break;
|
||||
} catch (err) {
|
||||
console.warn(`[SFTP] Failed to read identity file ${keyPath}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options.password) connectOpts.password = options.password;
|
||||
@@ -922,6 +1060,9 @@ async function openSftp(event, options) {
|
||||
logPrefix: "[SFTP]",
|
||||
defaultKeys,
|
||||
sshAgentSocketOverride: agentSocket,
|
||||
onAuthAttempt: (method) => {
|
||||
sendSftpProgress(event.sender, connId, options.hostname, 'auth-attempt', method);
|
||||
},
|
||||
});
|
||||
applyAuthToConnOpts(connectOpts, authConfig);
|
||||
|
||||
@@ -935,7 +1076,17 @@ async function openSftp(event, options) {
|
||||
});
|
||||
|
||||
// Add keyboard-interactive listener BEFORE connecting
|
||||
client.on("keyboard-interactive", kiHandler);
|
||||
// Wrap to emit progress events for the SFTP connection log
|
||||
client.on("keyboard-interactive", (name, instructions, lang, prompts, finish) => {
|
||||
if (prompts && prompts.length > 0) {
|
||||
sendSftpProgress(event.sender, connId, options.hostname, 'auth-attempt', 'waiting for user input...');
|
||||
}
|
||||
const wrappedFinish = (...args) => {
|
||||
sendSftpProgress(event.sender, connId, options.hostname, 'auth-attempt', 'user responded');
|
||||
finish(...args);
|
||||
};
|
||||
kiHandler(name, instructions, lang, prompts, wrappedFinish);
|
||||
});
|
||||
|
||||
// Increase timeout to allow for keyboard-interactive auth
|
||||
connectOpts.readyTimeout = 120000; // 2 minutes for 2FA input
|
||||
@@ -983,14 +1134,24 @@ async function openSftp(event, options) {
|
||||
sshClient.removeListener('error', onError);
|
||||
sshClient.removeListener('end', onEnd);
|
||||
sshClient.removeListener('close', onClose);
|
||||
// Keep a catch-all error listener so post-ready errors (e.g. connection
|
||||
// drops during an active SFTP session) don't become uncaught exceptions.
|
||||
sshClient.on('error', (err) => {
|
||||
console.error(`[SFTP] Post-ready SSH error for ${connId}:`, err.message);
|
||||
});
|
||||
};
|
||||
|
||||
sshClient.on('error', onError);
|
||||
sshClient.on('end', onEnd);
|
||||
sshClient.on('close', onClose);
|
||||
|
||||
sshClient.once('handshake', () => {
|
||||
sendSftpProgress(event.sender, connId, options.hostname, 'authenticating');
|
||||
});
|
||||
|
||||
sshClient.once('ready', () => {
|
||||
cleanup();
|
||||
sendSftpProgress(event.sender, connId, options.hostname, 'connected');
|
||||
|
||||
if (options.sudo) {
|
||||
console.log(`[SFTP] Using sudo mode for connection: ${connId}`);
|
||||
@@ -1033,6 +1194,7 @@ async function openSftp(event, options) {
|
||||
}
|
||||
});
|
||||
|
||||
sendSftpProgress(event.sender, connId, options.hostname, 'connecting');
|
||||
try {
|
||||
sshClient.connect(connectOpts);
|
||||
} catch (e) {
|
||||
|
||||
@@ -11,7 +11,20 @@ const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
const passphraseHandler = require("./passphraseHandler.cjs");
|
||||
|
||||
// Default SSH key names in priority order
|
||||
const DEFAULT_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
||||
const PREFERRED_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
||||
const SSH_KEY_PATTERN = /^id_[\w-]+$/;
|
||||
|
||||
/**
|
||||
* Quick check if file content looks like an SSH private key.
|
||||
* Rejects non-key files that happen to match the id_* filename pattern.
|
||||
*/
|
||||
function looksLikePrivateKey(content) {
|
||||
if (!content || typeof content !== "string") return false;
|
||||
const trimmed = content.trimStart();
|
||||
return trimmed.startsWith("-----BEGIN") ||
|
||||
trimmed.startsWith("openssh-key-v1") ||
|
||||
trimmed.startsWith("PuTTY-User-Key-File");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an SSH private key is encrypted (requires passphrase)
|
||||
@@ -21,6 +34,13 @@ const DEFAULT_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
||||
function isKeyEncrypted(keyContent) {
|
||||
if (!keyContent || typeof keyContent !== "string") return false;
|
||||
|
||||
// Check for PuTTY PPK encrypted format (Encryption: aes256-cbc, etc.)
|
||||
// PPK keys with "Encryption: none" are unencrypted
|
||||
const ppkEncMatch = keyContent.match(/^Encryption:\s*(.+)$/m);
|
||||
if (ppkEncMatch && ppkEncMatch[1].trim() !== "none") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for PKCS#8 encrypted format (-----BEGIN ENCRYPTED PRIVATE KEY-----)
|
||||
if (keyContent.includes("-----BEGIN ENCRYPTED PRIVATE KEY-----")) {
|
||||
return true;
|
||||
@@ -73,14 +93,25 @@ function isKeyEncrypted(keyContent) {
|
||||
*/
|
||||
async function findDefaultPrivateKey() {
|
||||
const sshDir = path.join(os.homedir(), ".ssh");
|
||||
for (const name of DEFAULT_KEY_NAMES) {
|
||||
let allNames = [];
|
||||
try {
|
||||
const entries = await fs.promises.readdir(sshDir);
|
||||
allNames = entries.filter(f => SSH_KEY_PATTERN.test(f));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const preferred = PREFERRED_KEY_NAMES.filter(n => allNames.includes(n));
|
||||
const rest = allNames.filter(n => !PREFERRED_KEY_NAMES.includes(n)).sort();
|
||||
const sorted = [...preferred, ...rest];
|
||||
|
||||
for (const name of sorted) {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
try {
|
||||
await fs.promises.access(keyPath, fs.constants.F_OK);
|
||||
const stat = await fs.promises.stat(keyPath);
|
||||
if (!stat.isFile()) continue; // Skip directories, FIFOs, sockets, etc.
|
||||
const privateKey = await fs.promises.readFile(keyPath, "utf8");
|
||||
if (isKeyEncrypted(privateKey)) {
|
||||
continue;
|
||||
}
|
||||
if (!looksLikePrivateKey(privateKey)) continue;
|
||||
if (isKeyEncrypted(privateKey)) continue;
|
||||
return { privateKey, keyPath, keyName: name };
|
||||
} catch {
|
||||
continue;
|
||||
@@ -99,11 +130,24 @@ async function findAllDefaultPrivateKeys(options = {}) {
|
||||
const { includeEncrypted = false } = options;
|
||||
const sshDir = path.join(os.homedir(), ".ssh");
|
||||
|
||||
const promises = DEFAULT_KEY_NAMES.map(async (name) => {
|
||||
let allNames = [];
|
||||
try {
|
||||
const entries = await fs.promises.readdir(sshDir);
|
||||
allNames = entries.filter(f => SSH_KEY_PATTERN.test(f));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const preferred = PREFERRED_KEY_NAMES.filter(n => allNames.includes(n));
|
||||
const rest = allNames.filter(n => !PREFERRED_KEY_NAMES.includes(n)).sort();
|
||||
const sorted = [...preferred, ...rest];
|
||||
|
||||
const promises = sorted.map(async (name) => {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
try {
|
||||
await fs.promises.access(keyPath, fs.constants.F_OK);
|
||||
const stat = await fs.promises.stat(keyPath);
|
||||
if (!stat.isFile()) return null;
|
||||
const privateKey = await fs.promises.readFile(keyPath, "utf8");
|
||||
if (!looksLikePrivateKey(privateKey)) return null;
|
||||
const encrypted = isKeyEncrypted(privateKey);
|
||||
if (encrypted && !includeEncrypted) {
|
||||
return null;
|
||||
@@ -259,7 +303,7 @@ function buildAuthHandler(options) {
|
||||
|
||||
// If only simple auth methods and no fallback keys needed, use array-based handler
|
||||
if (hasExplicitAuth && !hasFallbackOptions) {
|
||||
const authMethods = [];
|
||||
const authMethods = ["none"]; // Always try none first per RFC 4252
|
||||
if (effectiveAgent) authMethods.push("agent");
|
||||
if (privateKey) authMethods.push("publickey");
|
||||
if (password) authMethods.push("password");
|
||||
@@ -380,11 +424,29 @@ function buildAuthHandler(options) {
|
||||
|
||||
// Use dynamic authHandler to try all keys
|
||||
let authIndex = 0;
|
||||
let lastAttemptedLabel = null;
|
||||
const attemptedMethodIds = new Set();
|
||||
|
||||
let triedNone = false;
|
||||
|
||||
const authHandler = (methodsLeft, partialSuccess, callback) => {
|
||||
// Per RFC 4252, always try "none" first to discover available methods
|
||||
// and to support passwordless login (e.g. embedded devices).
|
||||
// This matches the behavior of OpenSSH and Tabby.
|
||||
if (methodsLeft === null && !triedNone) {
|
||||
triedNone = true;
|
||||
lastAttemptedLabel = "none (no credentials)";
|
||||
onAuthAttempt?.("none (no credentials)");
|
||||
return callback("none");
|
||||
}
|
||||
|
||||
const availableMethods = methodsLeft || ["publickey", "password", "keyboard-interactive", "agent"];
|
||||
|
||||
// Log rejection of previous method (authHandler is called again when server rejects)
|
||||
if (lastAttemptedLabel && !partialSuccess) {
|
||||
onAuthAttempt?.(`${lastAttemptedLabel} rejected`);
|
||||
}
|
||||
|
||||
while (authIndex < authMethods.length) {
|
||||
const method = authMethods[authIndex];
|
||||
authIndex++;
|
||||
@@ -394,6 +456,7 @@ function buildAuthHandler(options) {
|
||||
|
||||
if (method.type === "agent" && (availableMethods.includes("publickey") || availableMethods.includes("agent"))) {
|
||||
console.log(`${logPrefix} Trying agent auth`);
|
||||
lastAttemptedLabel = "SSH agent";
|
||||
onAuthAttempt?.("SSH agent");
|
||||
return callback("agent");
|
||||
} else if (method.type === "publickey" && availableMethods.includes("publickey")) {
|
||||
@@ -406,6 +469,7 @@ function buildAuthHandler(options) {
|
||||
: method.id === "publickey-user"
|
||||
? "configured key"
|
||||
: method.id;
|
||||
lastAttemptedLabel = keyLabel;
|
||||
onAuthAttempt?.(keyLabel);
|
||||
const pubkeyAuth = {
|
||||
type: "publickey",
|
||||
@@ -418,6 +482,7 @@ function buildAuthHandler(options) {
|
||||
return callback(pubkeyAuth);
|
||||
} else if (method.type === "password" && availableMethods.includes("password")) {
|
||||
console.log(`${logPrefix} Trying password auth`);
|
||||
lastAttemptedLabel = "password";
|
||||
onAuthAttempt?.("password");
|
||||
return callback({
|
||||
type: "password",
|
||||
@@ -425,10 +490,12 @@ function buildAuthHandler(options) {
|
||||
password,
|
||||
});
|
||||
} else if (method.type === "keyboard-interactive" && availableMethods.includes("keyboard-interactive")) {
|
||||
lastAttemptedLabel = "keyboard-interactive";
|
||||
onAuthAttempt?.("keyboard-interactive");
|
||||
return callback("keyboard-interactive");
|
||||
}
|
||||
}
|
||||
onAuthAttempt?.("all methods exhausted");
|
||||
return callback(false);
|
||||
};
|
||||
|
||||
@@ -498,17 +565,7 @@ function createKeyboardInteractiveHandler(options) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to renderer safely
|
||||
*/
|
||||
function safeSend(sender, channel, payload) {
|
||||
try {
|
||||
if (!sender || sender.isDestroyed()) return;
|
||||
sender.send(channel, payload);
|
||||
} catch {
|
||||
// Ignore destroyed webContents during shutdown.
|
||||
}
|
||||
}
|
||||
const { safeSend } = require("./ipcUtils.cjs");
|
||||
|
||||
/**
|
||||
* Apply auth configuration to connection options
|
||||
@@ -589,7 +646,9 @@ async function requestPassphrasesForEncryptedKeys(sender, hostname) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_KEY_NAMES,
|
||||
PREFERRED_KEY_NAMES,
|
||||
SSH_KEY_PATTERN,
|
||||
looksLikePrivateKey,
|
||||
isKeyEncrypted,
|
||||
findDefaultPrivateKey,
|
||||
findAllDefaultPrivateKeys,
|
||||
|
||||
@@ -23,9 +23,24 @@ const {
|
||||
getSshAgentSocket,
|
||||
} = require("./sshAuthHelper.cjs");
|
||||
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
|
||||
const { trackSessionIdlePrompt } = require("./ai/shellUtils.cjs");
|
||||
|
||||
// Default SSH key names in priority order
|
||||
const DEFAULT_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
||||
// Default SSH key names in priority order (preferred keys tried first)
|
||||
const PREFERRED_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
||||
// Match any private key file: id_* but not *.pub
|
||||
const SSH_KEY_PATTERN = /^id_[\w-]+$/;
|
||||
|
||||
/**
|
||||
* Quick check if file content looks like an SSH private key.
|
||||
* Rejects non-key files that happen to match the id_* filename pattern.
|
||||
*/
|
||||
function looksLikePrivateKey(content) {
|
||||
if (!content || typeof content !== "string") return false;
|
||||
const trimmed = content.trimStart();
|
||||
return trimmed.startsWith("-----BEGIN") ||
|
||||
trimmed.startsWith("openssh-key-v1") ||
|
||||
trimmed.startsWith("PuTTY-User-Key-File");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an SSH private key is encrypted (requires passphrase)
|
||||
@@ -33,6 +48,12 @@ const DEFAULT_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
||||
* @returns {boolean} - True if the key is encrypted
|
||||
*/
|
||||
function isKeyEncrypted(keyContent) {
|
||||
// Check for PuTTY PPK encrypted format
|
||||
const ppkEncMatch = keyContent.match(/^Encryption:\s*(.+)$/m);
|
||||
if (ppkEncMatch && ppkEncMatch[1].trim() !== "none") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for PKCS#8 encrypted format (-----BEGIN ENCRYPTED PRIVATE KEY-----)
|
||||
if (keyContent.includes("-----BEGIN ENCRYPTED PRIVATE KEY-----")) {
|
||||
return true;
|
||||
@@ -82,14 +103,31 @@ function isKeyEncrypted(keyContent) {
|
||||
*/
|
||||
async function findDefaultPrivateKey() {
|
||||
const sshDir = path.join(os.homedir(), ".ssh");
|
||||
log("Searching for default SSH keys", { sshDir, keyNames: DEFAULT_KEY_NAMES });
|
||||
for (const name of DEFAULT_KEY_NAMES) {
|
||||
// Scan ~/.ssh/ for all files matching id_* (same as Tabby/OpenSSH),
|
||||
// with preferred key types tried first
|
||||
let allNames = [];
|
||||
try {
|
||||
const entries = await fs.promises.readdir(sshDir);
|
||||
allNames = entries.filter(f => SSH_KEY_PATTERN.test(f));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// Sort: preferred keys first (in order), then rest alphabetically
|
||||
const preferred = PREFERRED_KEY_NAMES.filter(n => allNames.includes(n));
|
||||
const rest = allNames.filter(n => !PREFERRED_KEY_NAMES.includes(n)).sort();
|
||||
const sorted = [...preferred, ...rest];
|
||||
log("Searching for default SSH keys", { sshDir, found: sorted });
|
||||
|
||||
for (const name of sorted) {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
try {
|
||||
await fs.promises.access(keyPath, fs.constants.F_OK);
|
||||
const stat = await fs.promises.stat(keyPath);
|
||||
if (!stat.isFile()) continue;
|
||||
const privateKey = await fs.promises.readFile(keyPath, "utf8");
|
||||
// Skip encrypted keys - they require a passphrase and would abort
|
||||
// authentication before password/keyboard-interactive can be tried
|
||||
if (!looksLikePrivateKey(privateKey)) {
|
||||
log("Skipping non-key file", { keyPath, keyName: name });
|
||||
continue;
|
||||
}
|
||||
const encrypted = isKeyEncrypted(privateKey);
|
||||
log("Key file read", { keyPath, keyName: name, encrypted, keyLength: privateKey.length });
|
||||
if (encrypted) {
|
||||
@@ -114,13 +152,28 @@ async function findDefaultPrivateKey() {
|
||||
*/
|
||||
async function findAllDefaultPrivateKeys() {
|
||||
const sshDir = path.join(os.homedir(), ".ssh");
|
||||
log("Searching for ALL default SSH keys", { sshDir, keyNames: DEFAULT_KEY_NAMES });
|
||||
let allNames = [];
|
||||
try {
|
||||
const entries = await fs.promises.readdir(sshDir);
|
||||
allNames = entries.filter(f => SSH_KEY_PATTERN.test(f));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const preferred = PREFERRED_KEY_NAMES.filter(n => allNames.includes(n));
|
||||
const rest = allNames.filter(n => !PREFERRED_KEY_NAMES.includes(n)).sort();
|
||||
const sorted = [...preferred, ...rest];
|
||||
log("Searching for ALL default SSH keys", { sshDir, found: sorted });
|
||||
|
||||
const promises = DEFAULT_KEY_NAMES.map(async (name) => {
|
||||
const promises = sorted.map(async (name) => {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
try {
|
||||
await fs.promises.access(keyPath, fs.constants.F_OK);
|
||||
const stat = await fs.promises.stat(keyPath);
|
||||
if (!stat.isFile()) return null;
|
||||
const privateKey = await fs.promises.readFile(keyPath, "utf8");
|
||||
if (!looksLikePrivateKey(privateKey)) {
|
||||
log("Skipping non-key file", { keyPath, keyName: name });
|
||||
return null;
|
||||
}
|
||||
const encrypted = isKeyEncrypted(privateKey);
|
||||
if (!encrypted) {
|
||||
log("Found default key for fallback", { keyPath, keyName: name });
|
||||
@@ -308,14 +361,7 @@ function resolveLangFromCharset(charset) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function safeSend(sender, channel, payload) {
|
||||
try {
|
||||
if (!sender || sender.isDestroyed()) return;
|
||||
sender.send(channel, payload);
|
||||
} catch {
|
||||
// Ignore destroyed webContents during shutdown.
|
||||
}
|
||||
}
|
||||
const { safeSend } = require("./ipcUtils.cjs");
|
||||
|
||||
/**
|
||||
* Initialize the SSH bridge with dependencies
|
||||
@@ -330,7 +376,7 @@ function init(deps) {
|
||||
*/
|
||||
async function connectThroughChain(event, options, jumpHosts, targetHost, targetPort, sessionId) {
|
||||
const sender = event.sender;
|
||||
const connections = [];
|
||||
const connections = options?._connectionsRef || [];
|
||||
let currentSocket = null;
|
||||
|
||||
const sendProgress = (hop, total, label, status, error) => {
|
||||
@@ -352,6 +398,10 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
sendProgress(i + 1, totalHops + 1, hopLabel, 'connecting');
|
||||
|
||||
const conn = new SSHClient();
|
||||
if (options?._tunnelRef) {
|
||||
options._tunnelRef.pendingConn = conn;
|
||||
options._tunnelRef.chainConnections = connections;
|
||||
}
|
||||
|
||||
// Build connection options
|
||||
const connOpts = {
|
||||
@@ -387,7 +437,64 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
connOpts.agent = authAgent;
|
||||
} else if (jump.privateKey) {
|
||||
connOpts.privateKey = jump.privateKey;
|
||||
if (jump.passphrase) connOpts.passphrase = jump.passphrase;
|
||||
if (jump.passphrase) {
|
||||
connOpts.passphrase = jump.passphrase;
|
||||
} else if (isKeyEncrypted(jump.privateKey)) {
|
||||
// Key is encrypted but no passphrase provided — prompt the user
|
||||
console.log(`[Chain] Hop ${i + 1}: key is encrypted, requesting passphrase`);
|
||||
sendProgress(i + 1, totalHops + 1, hopLabel, 'auth-attempt', 'passphrase required');
|
||||
const keyLabel = jump.label || hopLabel;
|
||||
const result = await passphraseHandler.requestPassphrase(
|
||||
sender,
|
||||
`SSH key for ${keyLabel}`,
|
||||
keyLabel,
|
||||
hopLabel
|
||||
);
|
||||
if (result?.passphrase) {
|
||||
connOpts.passphrase = result.passphrase;
|
||||
} else {
|
||||
// No passphrase (cancelled/skipped/timeout) — remove the encrypted
|
||||
// key so buildAuthHandler won't try it and stall auth.
|
||||
delete connOpts.privateKey;
|
||||
if (result?.cancelled) {
|
||||
throw new Error(`Passphrase entry cancelled for ${hopLabel}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read identity files from local paths (e.g. from SSH config IdentityFile)
|
||||
if (!connOpts.privateKey && !connOpts.agent && jump.identityFilePaths?.length > 0) {
|
||||
for (const keyPath of jump.identityFilePaths) {
|
||||
try {
|
||||
const resolvedPath = keyPath.startsWith("~/")
|
||||
? path.join(os.homedir(), keyPath.slice(2))
|
||||
: keyPath;
|
||||
const keyContent = await fs.promises.readFile(resolvedPath, "utf8");
|
||||
connOpts.privateKey = keyContent;
|
||||
if (isKeyEncrypted(keyContent)) {
|
||||
console.log(`[Chain] Hop ${i + 1}: identity file ${resolvedPath} is encrypted, requesting passphrase`);
|
||||
sendProgress(i + 1, totalHops + 1, hopLabel, 'auth-attempt', 'passphrase required');
|
||||
const result = await passphraseHandler.requestPassphrase(
|
||||
sender,
|
||||
resolvedPath,
|
||||
path.basename(resolvedPath),
|
||||
hopLabel
|
||||
);
|
||||
if (result?.passphrase) {
|
||||
connOpts.passphrase = result.passphrase;
|
||||
} else {
|
||||
// Cancelled/skipped/timeout — clear encrypted key, try next file
|
||||
delete connOpts.privateKey;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
console.log(`[Chain] Hop ${i + 1}: loaded identity file ${resolvedPath}`);
|
||||
break;
|
||||
} catch (err) {
|
||||
console.warn(`[Chain] Hop ${i + 1}: failed to read identity file ${keyPath}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (jump.password) connOpts.password = jump.password;
|
||||
@@ -413,8 +520,21 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
applyAuthToConnOpts(connOpts, authConfig);
|
||||
|
||||
// If first hop and proxy is configured, connect through proxy
|
||||
if (isFirst && options.proxy) {
|
||||
currentSocket = await createProxySocket(options.proxy, jump.hostname, jump.port || 22);
|
||||
const hasUsableJumpProxy = !!(jump.proxy?.host && jump.proxy?.port);
|
||||
const effectiveHopProxy = isFirst ? ((hasUsableJumpProxy ? jump.proxy : null) || options.proxy) : null;
|
||||
if (effectiveHopProxy) {
|
||||
currentSocket = await createProxySocket(effectiveHopProxy, jump.hostname, jump.port || 22, {
|
||||
onSocket: (socket) => {
|
||||
if (options?._tunnelRef) {
|
||||
options._tunnelRef.pendingConn = socket;
|
||||
options._tunnelRef.chainConnections = connections;
|
||||
}
|
||||
},
|
||||
});
|
||||
if (options?._tunnelRef) {
|
||||
options._tunnelRef.pendingConn = null;
|
||||
options._tunnelRef.chainConnections = connections;
|
||||
}
|
||||
connOpts.sock = currentSocket;
|
||||
delete connOpts.host;
|
||||
delete connOpts.port;
|
||||
@@ -434,6 +554,10 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
conn.once('ready', () => {
|
||||
console.log(`[Chain] Hop ${i + 1}/${totalHops}: ${hopLabel} connected`);
|
||||
sendProgress(i + 1, totalHops + 1, hopLabel, 'connected');
|
||||
if (options?._tunnelRef) {
|
||||
options._tunnelRef.pendingConn = null;
|
||||
options._tunnelRef.chainConnections = connections;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
conn.once('error', (err) => {
|
||||
@@ -448,13 +572,23 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
reject(new Error(errMsg));
|
||||
});
|
||||
// Handle keyboard-interactive authentication for jump hosts (2FA/MFA)
|
||||
conn.on('keyboard-interactive', createKeyboardInteractiveHandler({
|
||||
const chainKiHandler = createKeyboardInteractiveHandler({
|
||||
sender,
|
||||
sessionId,
|
||||
hostname: hopLabel,
|
||||
password: jump.password,
|
||||
logPrefix: `[Chain] Hop ${i + 1}/${totalHops}`,
|
||||
}));
|
||||
});
|
||||
conn.on('keyboard-interactive', (name, instructions, lang, prompts, finish) => {
|
||||
if (prompts && prompts.length > 0) {
|
||||
sendProgress(i + 1, totalHops + 1, hopLabel, 'auth-attempt', 'waiting for user input...');
|
||||
}
|
||||
const wrappedFinish = (...args) => {
|
||||
sendProgress(i + 1, totalHops + 1, hopLabel, 'auth-attempt', 'user responded');
|
||||
finish(...args);
|
||||
};
|
||||
chainKiHandler(name, instructions, lang, prompts, wrappedFinish);
|
||||
});
|
||||
console.log(`[Chain] Hop ${i + 1}/${totalHops}: Connecting to ${hopLabel}...`);
|
||||
conn.connect(connOpts);
|
||||
});
|
||||
@@ -497,6 +631,10 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
sendProgress
|
||||
};
|
||||
} catch (err) {
|
||||
if (options?._tunnelRef) {
|
||||
options._tunnelRef.pendingConn = null;
|
||||
options._tunnelRef.chainConnections = connections;
|
||||
}
|
||||
// Cleanup on error
|
||||
for (const conn of connections) {
|
||||
try { conn.end(); } catch { }
|
||||
@@ -590,6 +728,41 @@ async function startSSHSession(event, options) {
|
||||
}
|
||||
}
|
||||
|
||||
// Read identity files from local paths (e.g. from SSH config IdentityFile)
|
||||
// Only if no explicit key was already configured
|
||||
if (!connectOpts.privateKey && !connectOpts.agent && options.identityFilePaths?.length > 0) {
|
||||
for (const keyPath of options.identityFilePaths) {
|
||||
try {
|
||||
const resolvedPath = keyPath.startsWith("~/")
|
||||
? path.join(os.homedir(), keyPath.slice(2))
|
||||
: keyPath;
|
||||
const keyContent = await fs.promises.readFile(resolvedPath, "utf8");
|
||||
connectOpts.privateKey = keyContent;
|
||||
// Check if key is encrypted — if so, prompt for passphrase
|
||||
if (isKeyEncrypted(keyContent)) {
|
||||
log("Identity file is encrypted, requesting passphrase", { keyPath: resolvedPath });
|
||||
const result = await passphraseHandler.requestPassphrase(
|
||||
sender,
|
||||
resolvedPath,
|
||||
path.basename(resolvedPath),
|
||||
options.hostname
|
||||
);
|
||||
if (result?.passphrase) {
|
||||
connectOpts.passphrase = result.passphrase;
|
||||
} else {
|
||||
// Cancelled/skipped/timeout — clear encrypted key, try next file
|
||||
delete connectOpts.privateKey;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
log("Loaded identity file", { keyPath: resolvedPath, encrypted: isKeyEncrypted(keyContent) });
|
||||
break; // Use the first successfully loaded key
|
||||
} catch (err) {
|
||||
log("Failed to read identity file", { keyPath, error: err.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options.password && typeof options.password === "string" && options.password.trim().length > 0) {
|
||||
connectOpts.password = options.password;
|
||||
}
|
||||
@@ -668,7 +841,7 @@ async function startSSHSession(event, options) {
|
||||
let lastTriedMethod = null;
|
||||
|
||||
if (authAgent) {
|
||||
const order = ["agent"];
|
||||
const order = ["none", "agent"];
|
||||
if (connectOpts.password) order.push("password");
|
||||
// Add default key fallback if available and no user key configured
|
||||
// Must also set connectOpts.privateKey for ssh2 to actually try publickey auth
|
||||
@@ -746,8 +919,9 @@ async function startSSHSession(event, options) {
|
||||
}
|
||||
}
|
||||
|
||||
// Use dynamic authHandler if we have multiple auth options
|
||||
if (authMethods.length > 1) {
|
||||
// Always use dynamic authHandler to ensure consistent "none" probing
|
||||
// and auth method logging regardless of how many methods are configured
|
||||
if (authMethods.length >= 1) {
|
||||
let authIndex = 0;
|
||||
// Track methods that have been attempted (to avoid re-trying on failure)
|
||||
// This prevents reusing the same key when server requires multiple publickey auth steps
|
||||
@@ -761,6 +935,22 @@ async function startSSHSession(event, options) {
|
||||
connectOpts.authHandler = (methodsLeft, partialSuccess, callback) => {
|
||||
log("authHandler called", { methodsLeft, partialSuccess, authIndex, attemptedMethodIds: Array.from(attemptedMethodIds) });
|
||||
|
||||
// Log rejection of previous method
|
||||
if (lastTriedMethod && !partialSuccess) {
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', `${lastTriedMethod} rejected`);
|
||||
}
|
||||
|
||||
// On the very first call (methodsLeft === null), try "none" auth.
|
||||
// Per RFC 4252, the "none" request is how the client discovers which
|
||||
// methods the server supports. It also allows passwordless login on
|
||||
// embedded devices. This matches the behavior of OpenSSH and Tabby.
|
||||
if (methodsLeft === null && !attemptedMethodIds.has("none")) {
|
||||
attemptedMethodIds.add("none");
|
||||
lastTriedMethod = "none";
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', 'none (no credentials)');
|
||||
return callback("none");
|
||||
}
|
||||
|
||||
// methodsLeft can be null on first call (before server responds with available methods)
|
||||
// Include "agent" for SSH agent-based auth (used with agentForwarding)
|
||||
const availableMethods = methodsLeft || ["publickey", "password", "keyboard-interactive", "agent"];
|
||||
@@ -897,6 +1087,7 @@ async function startSSHSession(event, options) {
|
||||
}
|
||||
|
||||
log("All auth methods exhausted");
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', 'all methods exhausted');
|
||||
return callback(false);
|
||||
};
|
||||
|
||||
@@ -1008,6 +1199,9 @@ async function startSSHSession(event, options) {
|
||||
hostname: options.host || options.hostname || '',
|
||||
username: options.username || '',
|
||||
label: options.label || '',
|
||||
lastIdlePrompt: '',
|
||||
lastIdlePromptAt: 0,
|
||||
_promptTrackTail: '',
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
@@ -1055,6 +1249,7 @@ async function startSSHSession(event, options) {
|
||||
stream.on("data", (data) => {
|
||||
const decoder = getSessionDecoder(sessionId, "stdout");
|
||||
const decoded = decoder.write(data);
|
||||
trackSessionIdlePrompt(session, decoded);
|
||||
bufferData(decoded);
|
||||
sessionLogStreamManager.appendData(sessionId, decoded);
|
||||
});
|
||||
@@ -1080,17 +1275,29 @@ async function startSSHSession(event, options) {
|
||||
});
|
||||
|
||||
stream.on("close", () => {
|
||||
// Flush any remaining data before close
|
||||
// Always flush buffered data regardless of session state
|
||||
if (flushTimeout) {
|
||||
clearTimeout(flushTimeout);
|
||||
}
|
||||
flushBuffer();
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
const contents = event.sender;
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: streamExitCode, reason: streamExited ? "exited" : "closed" });
|
||||
sessions.delete(sessionId);
|
||||
sessionEncodings.delete(sessionId);
|
||||
sessionDecoders.delete(sessionId);
|
||||
|
||||
// Only send exit if session hasn't already been cleaned up by
|
||||
// conn.once("close") — which fires before stream.on("close")
|
||||
// in ssh2 when the transport drops.
|
||||
if (sessions.has(sessionId)) {
|
||||
const contents = event.sender;
|
||||
const session = sessions.get(sessionId);
|
||||
const transportError = session?._transportError;
|
||||
if (transportError) {
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: transportError, reason: "error" });
|
||||
} else {
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: streamExitCode, reason: streamExited ? "exited" : "closed" });
|
||||
}
|
||||
sessions.delete(sessionId);
|
||||
sessionEncodings.delete(sessionId);
|
||||
sessionDecoders.delete(sessionId);
|
||||
}
|
||||
conn.end();
|
||||
for (const c of chainConnections) {
|
||||
try { c.end(); } catch { }
|
||||
@@ -1116,6 +1323,22 @@ async function startSSHSession(event, options) {
|
||||
});
|
||||
|
||||
conn.on("error", (err) => {
|
||||
// After the promise is settled, we can't reject again. But if the
|
||||
// session was already established (resolved), we still need to notify
|
||||
// the renderer about transport errors so the session shows as failed
|
||||
// rather than silently closing.
|
||||
// Don't send netcatty:exit here — the stream close handler will flush
|
||||
// any buffered data first and then send exit with this error info.
|
||||
if (settled) {
|
||||
console.warn(`${logPrefix} ${options.hostname} post-settle error:`, err.message);
|
||||
// Store the error so the close handler can include it in the exit event
|
||||
if (sessions.has(sessionId)) {
|
||||
const session = sessions.get(sessionId);
|
||||
if (session) session._transportError = err.message;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const contents = event.sender;
|
||||
|
||||
const isAuthError = err.message?.toLowerCase().includes('authentication') ||
|
||||
@@ -1145,6 +1368,9 @@ async function startSSHSession(event, options) {
|
||||
for (const c of chainConnections) {
|
||||
try { c.end(); } catch { }
|
||||
}
|
||||
// Destroy the connection to prevent further socket errors from leaking
|
||||
// as uncaught exceptions (e.g. ECONNRESET on embedded devices).
|
||||
try { conn.destroy(); } catch { }
|
||||
settled = true;
|
||||
reject(err);
|
||||
});
|
||||
@@ -1162,6 +1388,7 @@ async function startSSHSession(event, options) {
|
||||
for (const c of chainConnections) {
|
||||
try { c.end(); } catch { }
|
||||
}
|
||||
try { conn.destroy(); } catch { }
|
||||
settled = true;
|
||||
reject(err);
|
||||
});
|
||||
@@ -1171,7 +1398,19 @@ async function startSSHSession(event, options) {
|
||||
if (!settled) {
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'error', `Connection to ${options.hostname} closed unexpectedly`);
|
||||
}
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 0, reason: "closed" });
|
||||
// Only send exit if the session hasn't already been cleaned up by the
|
||||
// error handler (avoids sending a misleading exitCode:0 "closed" after
|
||||
// a real transport error was already reported).
|
||||
if (sessions.has(sessionId)) {
|
||||
const session = sessions.get(sessionId);
|
||||
const transportError = session?._transportError;
|
||||
if (transportError) {
|
||||
// A transport error was recorded — report it as an error exit
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: transportError, reason: "error" });
|
||||
} else {
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 0, reason: "closed" });
|
||||
}
|
||||
}
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
sessionEncodings.delete(sessionId);
|
||||
@@ -1201,12 +1440,15 @@ async function startSSHSession(event, options) {
|
||||
return;
|
||||
}
|
||||
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', 'waiting for user input...');
|
||||
|
||||
// Forward ALL prompts to user - no auto-fill to avoid semantic detection issues
|
||||
// (Prompt text is admin-customizable and may not contain expected keywords)
|
||||
const requestId = keyboardInteractiveHandler.generateRequestId('ssh');
|
||||
|
||||
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
|
||||
console.log(`${logPrefix} Received user responses, finishing keyboard-interactive`);
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', 'user responded');
|
||||
finish(userResponses);
|
||||
}, sender.id, sessionId);
|
||||
|
||||
@@ -1322,10 +1564,11 @@ async function execCommand(event, payload) {
|
||||
});
|
||||
});
|
||||
})
|
||||
.once("error", (err) => {
|
||||
.on("error", (err) => {
|
||||
if (settled) return;
|
||||
clearTimeout(timer);
|
||||
settled = true;
|
||||
conn.end();
|
||||
reject(err);
|
||||
})
|
||||
.once("end", () => {
|
||||
@@ -1507,7 +1750,11 @@ async function startSSHSessionWrapper(event, options) {
|
||||
authError.isAuthError = true;
|
||||
throw authError;
|
||||
}
|
||||
throw retryErr;
|
||||
// Wrap non-auth retry errors as connection errors to prevent crash
|
||||
const connError = new Error(retryErr.message);
|
||||
connError.level = retryErr.level || 'client-socket';
|
||||
connError.code = retryErr.code;
|
||||
throw connError;
|
||||
}
|
||||
} else {
|
||||
console.log('[SSH] User did not unlock any keys, not retrying');
|
||||
@@ -1522,7 +1769,15 @@ async function startSSHSessionWrapper(event, options) {
|
||||
authError.isAuthError = true;
|
||||
throw authError;
|
||||
}
|
||||
throw err;
|
||||
|
||||
// Non-auth errors (e.g. ECONNRESET, ETIMEDOUT) — wrap in a clean Error
|
||||
// so Electron's ipcMain.handle can serialize it back to the renderer
|
||||
// instead of it becoming an uncaught exception that crashes the app.
|
||||
// See: https://github.com/nicely-gg/netcatty/issues/482
|
||||
const connError = new Error(err.message);
|
||||
connError.level = err.level || 'client-socket';
|
||||
connError.code = err.code;
|
||||
throw connError;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1578,6 +1833,141 @@ async function getSessionPwd(event, payload) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List directory contents on remote machine for path autocomplete.
|
||||
* Uses a separate exec channel — does not touch the interactive shell.
|
||||
*/
|
||||
async function listSessionDir(_event, payload) {
|
||||
const {
|
||||
sessionId,
|
||||
path: dirPath,
|
||||
foldersOnly,
|
||||
filterPrefix = "",
|
||||
limit = 100,
|
||||
} = payload || {};
|
||||
const session = sessions.get(sessionId);
|
||||
|
||||
if (!session || !session.conn) {
|
||||
return { success: false, entries: [], error: 'Session not found' };
|
||||
}
|
||||
|
||||
if (typeof dirPath !== "string" || dirPath.length === 0) {
|
||||
return { success: false, entries: [], error: 'Invalid directory path' };
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
let streamRef = null;
|
||||
const resolveOnce = (result) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve(result);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
streamRef?.close?.();
|
||||
streamRef?.destroy?.();
|
||||
} catch {}
|
||||
resolveOnce({ success: false, entries: [], error: 'Timeout listing directory' });
|
||||
}, 3000);
|
||||
|
||||
// Emit a NUL-delimited stream from plain POSIX shell/find so we don't depend on
|
||||
// Python/Perl, while still preserving whitespace and newline characters in filenames.
|
||||
const safePath = dirPath.replace(/'/g, "'\\''");
|
||||
const tildePathSuffix = dirPath.startsWith("~/")
|
||||
? dirPath.slice(2).replace(/(["\\$`])/g, "\\$1")
|
||||
: "";
|
||||
const normalizedPrefix = typeof filterPrefix === "string" ? filterPrefix.toLowerCase() : "";
|
||||
const safePrefix = normalizedPrefix.replace(/'/g, "'\\''");
|
||||
const maxEntries = Number.isFinite(limit) ? Math.min(Math.max(1, Math.floor(limit)), 200) : 100;
|
||||
const pathExpr = dirPath === "~"
|
||||
? '"$HOME"'
|
||||
: dirPath.startsWith("~/")
|
||||
? `"$HOME/${tildePathSuffix}"`
|
||||
: `'${safePath}'`;
|
||||
const cmd = `find ${pathExpr} -mindepth 1 -maxdepth 1 -exec sh -c '
|
||||
prefix="$1"
|
||||
folders_only="$2"
|
||||
limit="$3"
|
||||
shift 3
|
||||
count=0
|
||||
for path do
|
||||
name=\${path##*/}
|
||||
lower_name=$(printf "%s" "$name" | tr "[:upper:]" "[:lower:]")
|
||||
if [ -n "$prefix" ]; then
|
||||
case "$lower_name" in
|
||||
"$prefix"*) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
fi
|
||||
if [ "$folders_only" -eq 1 ] && [ ! -d "$path" ]; then
|
||||
continue
|
||||
fi
|
||||
if [ -L "$path" ]; then
|
||||
type="symlink"
|
||||
elif [ -d "$path" ]; then
|
||||
type="directory"
|
||||
else
|
||||
type="file"
|
||||
fi
|
||||
printf "%s\\0%s\\0" "$name" "$type"
|
||||
count=$((count + 1))
|
||||
if [ "$count" -ge "$limit" ]; then
|
||||
break
|
||||
fi
|
||||
done
|
||||
' sh '${safePrefix}' ${foldersOnly ? 1 : 0} ${maxEntries} {} + 2>/dev/null`;
|
||||
|
||||
session.conn.exec(cmd, (err, stream) => {
|
||||
if (err) {
|
||||
resolveOnce({ success: false, entries: [], error: err.message });
|
||||
return;
|
||||
}
|
||||
streamRef = stream;
|
||||
const chunks = [];
|
||||
let errOut = '';
|
||||
stream.on('data', (d) => { chunks.push(Buffer.from(d)); });
|
||||
stream.stderr?.on('data', (d) => { errOut += d.toString(); });
|
||||
stream.on('close', () => {
|
||||
if (settled) return;
|
||||
try {
|
||||
const output = Buffer.concat(chunks);
|
||||
const entries = [];
|
||||
let fieldStart = 0;
|
||||
let pendingName = null;
|
||||
|
||||
for (let i = 0; i < output.length; i++) {
|
||||
if (output[i] !== 0) continue;
|
||||
const field = output.toString('utf8', fieldStart, i);
|
||||
fieldStart = i + 1;
|
||||
if (pendingName === null) {
|
||||
pendingName = field;
|
||||
} else {
|
||||
entries.push({ name: pendingName, type: field });
|
||||
pendingName = null;
|
||||
if (entries.length >= maxEntries) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingName !== null) {
|
||||
resolveOnce({ success: false, entries: [], error: 'Invalid directory listing response' });
|
||||
return;
|
||||
}
|
||||
|
||||
resolveOnce({ success: true, entries });
|
||||
} catch {
|
||||
resolveOnce({
|
||||
success: false,
|
||||
entries: [],
|
||||
error: errOut.trim() || 'Failed to parse directory listing',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get server stats (CPU, Memory, Disk) from an active SSH session
|
||||
* Only works for Linux servers
|
||||
@@ -1592,11 +1982,41 @@ async function getServerStats(event, payload) {
|
||||
|
||||
const conn = session.conn;
|
||||
|
||||
// macOS stats command: uses sysctl, vm_stat, top, ps, df, netstat
|
||||
// CPU reported as direct percentage (top computes delta internally)
|
||||
// cpuPerCore not available on macOS without sudo
|
||||
const macosStatsCommand = [
|
||||
`cores=$(sysctl -n hw.logicalcpu 2>/dev/null || echo "1")`,
|
||||
`pagesize=$(sysctl -n hw.pagesize 2>/dev/null || echo "4096")`,
|
||||
`memsize=$(sysctl -n hw.memsize 2>/dev/null || echo "0")`,
|
||||
// CPU usage: top -l 1 gives one logging sample, parse idle%
|
||||
`cpuline=$(top -l 1 -s 0 -n 0 2>/dev/null | grep "CPU usage:" | head -1)`,
|
||||
`cpupct=$(echo "$cpuline" | awk '{for(i=1;i<=NF;i++){if($(i+1)~/^idle/){v=$i;gsub(/%/,"",v);idle=v+0;found=1}};if(found)printf "%.0f",100-idle}')`,
|
||||
// Memory: single vm_stat pipe → awk extracts all page counts (strip trailing dots with gsub)
|
||||
// Outputs: "memfree memcached" in MB
|
||||
`vmmem=$(vm_stat 2>/dev/null | awk -v ps="$pagesize" '/^Pages free:/{gsub(/[^0-9]/,"",$NF);free=$NF+0} /^Pages speculative:/{gsub(/[^0-9]/,"",$NF);spec=$NF+0} /^Pages inactive:/{gsub(/[^0-9]/,"",$NF);inact=$NF+0} /^Pages purgeable:/{gsub(/[^0-9]/,"",$NF);purg=$NF+0} END{mfree=int((free+spec)*ps/1024/1024);mcached=int((inact+purg)*ps/1024/1024);printf "%d %d",mfree,mcached}')`,
|
||||
`memtotal=$(echo "$memsize" | awk '{printf "%d",$1/1024/1024}')`,
|
||||
`memfree=$(echo "$vmmem" | awk '{print $1}')`,
|
||||
`memcached=$(echo "$vmmem" | awk '{print $2}')`,
|
||||
// Swap
|
||||
`swapraw=$(sysctl vm.swapusage 2>/dev/null)`,
|
||||
`swaptotal=$(echo "$swapraw" | awk '{for(i=1;i<=NF;i++){if($i=="total"&&$(i+1)=="="){v=$(i+2);m=1;if(v~/G/)m=1024;gsub(/[MmGg]/,"",v);st=v*m}};printf "%.0f",st+0}')`,
|
||||
`swapused=$(echo "$swapraw" | awk '{for(i=1;i<=NF;i++){if($i=="used"&&$(i+1)=="="){v=$(i+2);m=1;if(v~/G/)m=1024;gsub(/[MmGg]/,"",v);su=v*m}};printf "%.0f",su+0}')`,
|
||||
`swapfree=$(echo "$swaptotal $swapused" | awk '{printf "%.0f",$1-$2}')`,
|
||||
// Top processes by memory%
|
||||
`procs=$(ps -A -o pid=,%mem=,comm= 2>/dev/null | sort -k2 -rn | head -10 | awk '{gsub(/;/,"_",$3);printf "%s;%.1f;%s,",$1,$2,$3}' | sed 's/,$//')`,
|
||||
// Disk: only show root "/" and external volumes "/Volumes/*", skip system APFS snapshots
|
||||
`disks=$(df -k 2>/dev/null | awk 'NR>1&&index($1,"/dev/")==1&&NF>=9&&($NF=="/"||index($NF,"/Volumes/")==1){u=$3/1048576;t=$2/1048576;p=$5;gsub(/%/,"",p);printf "%s:%.0f:%.0f:%s,",$NF,u,t,p}' | sed 's/,$//')`,
|
||||
// Network: Link# lines only, exclude loopback, detect column shift (no MAC addr → cols shift left)
|
||||
`net=$(netstat -ib 2>/dev/null | awk '/^[a-z]/&&$3~/Link/&&$1!~/^lo/{if($4~/:/){rx=$7;tx=$10}else{rx=$6;tx=$9};if((rx+0)>0){gsub(/[*]/,"",$1);printf "%s:%s:%s,",$1,rx,tx}}' | sed 's/,$//')`,
|
||||
`echo "CPU:$cpupct|CORES:$cores|MEMINFO:$memtotal $memfree 0 $memcached $swaptotal $swapfree|PROCS:$procs|DISKS:$disks|NET:$net"`,
|
||||
].join('; ');
|
||||
|
||||
// Command to get CPU (overall + per-core), Memory, Disk, and Network stats
|
||||
// This command is designed to work across most Linux distributions
|
||||
// Note: Using semicolons and avoiding comments for single-line execution
|
||||
// CPU: Output raw values (total and idle) instead of percentage - we calculate delta on backend
|
||||
const statsCommand = [
|
||||
const linuxStatsCommand = [
|
||||
// Get number of CPU cores
|
||||
`cores=$(nproc 2>/dev/null || grep -c "^processor" /proc/cpuinfo 2>/dev/null || echo "1")`,
|
||||
// Get raw CPU values from /proc/stat: "total idle" for overall CPU
|
||||
@@ -1620,6 +2040,8 @@ async function getServerStats(event, payload) {
|
||||
`echo "CPURAW:$cpuraw|CORES:$cores|PERCORERAW:$percoreraw|MEMINFO:$meminfo|PROCS:$procs|DISKS:$disks|NET:$net"`
|
||||
].join('; ');
|
||||
|
||||
// Auto-detect OS via uname — only Linux and macOS are supported
|
||||
const statsCommand = `ostype=$(uname -s 2>/dev/null || echo "Unknown"); if [ "$ostype" = "Darwin" ]; then ${macosStatsCommand}; elif [ "$ostype" = "Linux" ]; then ${linuxStatsCommand}; else echo "UNSUPPORTED_OS:$ostype"; fi`;
|
||||
return new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
resolve({ success: false, error: 'Timeout getting server stats' });
|
||||
@@ -1648,8 +2070,16 @@ async function getServerStats(event, payload) {
|
||||
|
||||
// Parse the output
|
||||
const output = stdout.trim();
|
||||
|
||||
// Unsupported OS — stop polling this session
|
||||
if (output.startsWith('UNSUPPORTED_OS:')) {
|
||||
resolve({ success: false, error: `Server stats not supported on this OS (${output.substring(15)})` });
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = output.split('|');
|
||||
|
||||
let cpuDirect = null; // macOS: direct CPU percentage from top
|
||||
let cpuRawTotal = null;
|
||||
let cpuRawIdle = null;
|
||||
let cpuPerCoreRaw = []; // Array of { total, idle }
|
||||
@@ -1666,7 +2096,11 @@ async function getServerStats(event, payload) {
|
||||
let networkInterfaces = []; // Array of { name, rxBytes, txBytes }
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.startsWith('CPURAW:')) {
|
||||
if (part.startsWith('CPU:')) {
|
||||
// macOS: top reports CPU% directly (no delta needed)
|
||||
const val = parseFloat(part.substring(4).trim());
|
||||
if (!isNaN(val)) cpuDirect = Math.min(100, Math.max(0, Math.round(val)));
|
||||
} else if (part.startsWith('CPURAW:')) {
|
||||
const rawParts = part.substring(7).trim().split(/\s+/);
|
||||
if (rawParts.length >= 2) {
|
||||
cpuRawTotal = parseInt(rawParts[0], 10);
|
||||
@@ -1843,6 +2277,11 @@ async function getServerStats(event, payload) {
|
||||
}
|
||||
}
|
||||
|
||||
// macOS: use direct percentage from top (no delta needed)
|
||||
if (cpu === null && cpuDirect !== null) {
|
||||
cpu = cpuDirect;
|
||||
}
|
||||
|
||||
// Calculate per-core CPU usage from deltas
|
||||
if (cpuPerCoreRaw.length > 0 && prevCpu.perCore.length > 0) {
|
||||
cpuPerCore = cpuPerCoreRaw.map((core, index) => {
|
||||
@@ -1878,6 +2317,12 @@ async function getServerStats(event, payload) {
|
||||
const diskUsed = rootDisk ? rootDisk.used : null;
|
||||
const diskTotal = rootDisk ? rootDisk.total : null;
|
||||
|
||||
// If no meaningful data was parsed, treat as failure to stop futile polling
|
||||
if (cpu === null && memTotal === null && cpuCores === null) {
|
||||
resolve({ success: false, error: 'Unable to parse server stats (unsupported OS or shell)' });
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({
|
||||
success: true,
|
||||
stats: {
|
||||
@@ -1931,6 +2376,7 @@ function registerHandlers(ipcMain) {
|
||||
ipcMain.handle("netcatty:start", startSSHSessionWrapper);
|
||||
ipcMain.handle("netcatty:ssh:exec", execCommand);
|
||||
ipcMain.handle("netcatty:ssh:pwd", getSessionPwd);
|
||||
ipcMain.handle("netcatty:ssh:listdir", listSessionDir);
|
||||
ipcMain.handle("netcatty:ssh:stats", getServerStats);
|
||||
ipcMain.handle("netcatty:key:generate", generateKeyPair);
|
||||
ipcMain.handle("netcatty:ssh:setEncoding", setSessionEncoding);
|
||||
@@ -1940,14 +2386,17 @@ function registerHandlers(ipcMain) {
|
||||
ipcMain.handle("netcatty:ssh:get-default-keys", async () => {
|
||||
const sshDir = path.join(os.homedir(), ".ssh");
|
||||
const keys = [];
|
||||
for (const name of DEFAULT_KEY_NAMES) {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
try {
|
||||
await fs.promises.access(keyPath, fs.constants.F_OK);
|
||||
keys.push({ name, path: keyPath });
|
||||
} catch {
|
||||
// ignore missing keys
|
||||
try {
|
||||
const entries = await fs.promises.readdir(sshDir);
|
||||
const names = entries.filter(f => SSH_KEY_PATTERN.test(f));
|
||||
// Preferred first, then rest alphabetically
|
||||
const preferred = PREFERRED_KEY_NAMES.filter(n => names.includes(n));
|
||||
const rest = names.filter(n => !PREFERRED_KEY_NAMES.includes(n)).sort();
|
||||
for (const name of [...preferred, ...rest]) {
|
||||
keys.push({ name, path: path.join(sshDir, name) });
|
||||
}
|
||||
} catch {
|
||||
// ~/.ssh doesn't exist
|
||||
}
|
||||
return keys;
|
||||
});
|
||||
@@ -1960,16 +2409,5 @@ function registerHandlers(ipcMain) {
|
||||
module.exports = {
|
||||
init,
|
||||
registerHandlers,
|
||||
createProxySocket,
|
||||
startSSHSession,
|
||||
execCommand,
|
||||
getSessionPwd,
|
||||
getServerStats,
|
||||
generateKeyPair,
|
||||
checkWindowsSshAgent,
|
||||
findDefaultPrivateKey,
|
||||
findAllDefaultPrivateKeys,
|
||||
isKeyEncrypted,
|
||||
findAllDefaultPrivateKeys,
|
||||
isKeyEncrypted,
|
||||
connectThroughChain,
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ const { SerialPort } = require("serialport");
|
||||
|
||||
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
|
||||
const { detectShellKind } = require("./ai/ptyExec.cjs");
|
||||
const { trackSessionIdlePrompt } = require("./ai/shellUtils.cjs");
|
||||
|
||||
// Shared references
|
||||
let sessions = null;
|
||||
@@ -52,6 +53,51 @@ function init(deps) {
|
||||
electronModule = deps.electronModule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an 8ms/16KB PTY data buffer for reduced IPC overhead.
|
||||
* Mirrors the SSH stream buffering strategy in sshBridge.cjs.
|
||||
* @param {Function} sendFn - called with the accumulated string to deliver
|
||||
* @returns {{ bufferData: (data: string) => void, flush: () => void }}
|
||||
*/
|
||||
function createPtyBuffer(sendFn) {
|
||||
const FLUSH_INTERVAL = 8; // ms - flush every 8ms (~120fps equivalent)
|
||||
const MAX_BUFFER_SIZE = 16384; // 16KB - flush immediately if buffer grows too large
|
||||
|
||||
let dataBuffer = '';
|
||||
let flushTimeout = null;
|
||||
|
||||
const flushBuffer = () => {
|
||||
if (dataBuffer.length > 0) {
|
||||
sendFn(dataBuffer);
|
||||
dataBuffer = '';
|
||||
}
|
||||
flushTimeout = null;
|
||||
};
|
||||
|
||||
const flush = () => {
|
||||
if (flushTimeout) {
|
||||
clearTimeout(flushTimeout);
|
||||
flushTimeout = null;
|
||||
}
|
||||
flushBuffer();
|
||||
};
|
||||
|
||||
const bufferData = (data) => {
|
||||
dataBuffer += data;
|
||||
if (dataBuffer.length >= MAX_BUFFER_SIZE) {
|
||||
if (flushTimeout) {
|
||||
clearTimeout(flushTimeout);
|
||||
flushTimeout = null;
|
||||
}
|
||||
flushBuffer();
|
||||
} else if (!flushTimeout) {
|
||||
flushTimeout = setTimeout(flushBuffer, FLUSH_INTERVAL);
|
||||
}
|
||||
};
|
||||
|
||||
return { bufferData, flush };
|
||||
}
|
||||
|
||||
/**
|
||||
* Find executable path on Windows
|
||||
*/
|
||||
@@ -245,6 +291,10 @@ function startLocalSession(event, payload) {
|
||||
label: "Local Terminal",
|
||||
shellExecutable: shell,
|
||||
shellKind,
|
||||
flushPendingData: null,
|
||||
lastIdlePrompt: "",
|
||||
lastIdlePromptAt: 0,
|
||||
_promptTrackTail: "",
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
@@ -259,13 +309,20 @@ function startLocalSession(event, payload) {
|
||||
});
|
||||
}
|
||||
|
||||
proc.onData((data) => {
|
||||
const { bufferData: bufferLocalData, flush: flushLocal } = createPtyBuffer((data) => {
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:data", { sessionId, data });
|
||||
});
|
||||
session.flushPendingData = flushLocal;
|
||||
|
||||
proc.onData((data) => {
|
||||
trackSessionIdlePrompt(session, data);
|
||||
bufferLocalData(data);
|
||||
sessionLogStreamManager.appendData(sessionId, data);
|
||||
});
|
||||
|
||||
proc.onExit((evt) => {
|
||||
flushLocal();
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
@@ -434,7 +491,12 @@ async function startTelnetSession(event, options) {
|
||||
webContentsId: event.sender.id,
|
||||
cols,
|
||||
rows,
|
||||
flushPendingData: null,
|
||||
lastIdlePrompt: "",
|
||||
lastIdlePromptAt: 0,
|
||||
_promptTrackTail: "",
|
||||
};
|
||||
session.flushPendingData = flushTelnet;
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
// Start real-time session log stream if configured
|
||||
@@ -463,6 +525,12 @@ async function startTelnetSession(event, options) {
|
||||
|
||||
const telnetDecoder = new StringDecoder(charsetToNodeEncoding(options.charset));
|
||||
|
||||
const telnetWebContentsId = event.sender.id;
|
||||
const { bufferData: bufferTelnetData, flush: flushTelnet } = createPtyBuffer((data) => {
|
||||
const contents = electronModule.webContents.fromId(telnetWebContentsId);
|
||||
contents?.send("netcatty:data", { sessionId, data });
|
||||
});
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
@@ -472,8 +540,8 @@ async function startTelnetSession(event, options) {
|
||||
if (cleanData.length > 0) {
|
||||
const decoded = telnetDecoder.write(cleanData);
|
||||
if (decoded) {
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:data", { sessionId, data: decoded });
|
||||
trackSessionIdlePrompt(session, decoded);
|
||||
bufferTelnetData(decoded);
|
||||
sessionLogStreamManager.appendData(sessionId, decoded);
|
||||
}
|
||||
}
|
||||
@@ -486,6 +554,7 @@ async function startTelnetSession(event, options) {
|
||||
if (!connected) {
|
||||
reject(new Error(`Failed to connect: ${err.message}`));
|
||||
} else {
|
||||
flushTelnet();
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
const session = sessions.get(sessionId);
|
||||
if (session) {
|
||||
@@ -500,6 +569,7 @@ async function startTelnetSession(event, options) {
|
||||
console.log(`[Telnet] Connection closed${hadError ? ' with error' : ''}`);
|
||||
clearTimeout(connectTimeout);
|
||||
|
||||
flushTelnet();
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
const session = sessions.get(sessionId);
|
||||
if (session) {
|
||||
@@ -584,6 +654,10 @@ async function startMoshSession(event, options) {
|
||||
label: options.label || options.hostname || 'Mosh Session',
|
||||
shellKind: 'posix',
|
||||
shellExecutable: 'remote-shell',
|
||||
flushPendingData: null,
|
||||
lastIdlePrompt: "",
|
||||
lastIdlePromptAt: 0,
|
||||
_promptTrackTail: "",
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
@@ -598,13 +672,20 @@ async function startMoshSession(event, options) {
|
||||
});
|
||||
}
|
||||
|
||||
proc.onData((data) => {
|
||||
const { bufferData: bufferMoshData, flush: flushMosh } = createPtyBuffer((data) => {
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:data", { sessionId, data });
|
||||
});
|
||||
session.flushPendingData = flushMosh;
|
||||
|
||||
proc.onData((data) => {
|
||||
trackSessionIdlePrompt(session, data);
|
||||
bufferMoshData(data);
|
||||
sessionLogStreamManager.appendData(sessionId, data);
|
||||
});
|
||||
|
||||
proc.onExit((evt) => {
|
||||
flushMosh();
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
@@ -684,6 +765,8 @@ async function startSerialSession(event, options) {
|
||||
const session = {
|
||||
serialPort,
|
||||
type: 'serial',
|
||||
protocol: 'serial',
|
||||
shellKind: 'raw',
|
||||
webContentsId: event.sender.id,
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
@@ -798,6 +881,7 @@ function closeSession(event, payload) {
|
||||
if (!session) return;
|
||||
|
||||
try {
|
||||
session.flushPendingData?.();
|
||||
if (session.stream) {
|
||||
session.stream.close();
|
||||
session.conn?.end();
|
||||
|
||||
@@ -5,7 +5,12 @@
|
||||
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const globalShortcutBridge = require("./globalShortcutBridge.cjs");
|
||||
|
||||
const V8_CACHE_OPTIONS = "bypassHeatCheck";
|
||||
|
||||
function getGlobalShortcutBridge() {
|
||||
return require("./globalShortcutBridge.cjs");
|
||||
}
|
||||
|
||||
// Theme colors configuration
|
||||
const THEME_COLORS = {
|
||||
@@ -443,6 +448,7 @@ function createAppWindowOpenHandler(shell, { backgroundColor, appIcon }) {
|
||||
nodeIntegration: false,
|
||||
// Sandboxed because this window renders remote content and does not need a preload bridge.
|
||||
sandbox: true,
|
||||
v8CacheOptions: V8_CACHE_OPTIONS,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -531,27 +537,6 @@ function attachOAuthLoadingOverlay(win) {
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForRootPaint(win, { timeoutMs = 400, intervalMs = 30 } = {}) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
if (win.isDestroyed()) return false;
|
||||
const count = await win.webContents.executeJavaScript(
|
||||
`(() => {
|
||||
const root = document.getElementById("root");
|
||||
return root ? root.children.length : 0;
|
||||
})()`,
|
||||
true,
|
||||
);
|
||||
if (Number(count) > 0) return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function setupDeferredShow(win, { timeoutMs = 3000, waitForRendererReady = true } = {}) {
|
||||
const webContentsId = (() => {
|
||||
try {
|
||||
@@ -599,13 +584,10 @@ function setupDeferredShow(win, { timeoutMs = 3000, waitForRendererReady = true
|
||||
tryShow();
|
||||
});
|
||||
|
||||
win.webContents.once("did-finish-load", () => {
|
||||
void (async () => {
|
||||
// If the renderer mounts shortly after load, wait briefly to avoid showing a blank root.
|
||||
const painted = await waitForRootPaint(win, { timeoutMs: 800, intervalMs: 50 });
|
||||
if (painted) markRendererReady();
|
||||
})();
|
||||
});
|
||||
// Renderer calls netcattyBridge.rendererReady() after React mount,
|
||||
// which sends IPC "netcatty:renderer:ready" → markRendererReady().
|
||||
// The timeout fallback (timeoutMs) ensures the window is shown even if
|
||||
// the signal is never received.
|
||||
|
||||
// Dev/edge-case fallback: don't keep the window hidden forever.
|
||||
if (Number(timeoutMs) > 0) {
|
||||
@@ -687,6 +669,7 @@ async function createWindow(electronModule, options) {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: false,
|
||||
v8CacheOptions: V8_CACHE_OPTIONS,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -782,12 +765,12 @@ async function createWindow(electronModule, options) {
|
||||
// Save state when window is about to close
|
||||
win.on("close", (event) => {
|
||||
// Check if close-to-tray is enabled
|
||||
if (!isQuitting && globalShortcutBridge.handleWindowClose(event, win)) {
|
||||
if (!isQuitting && getGlobalShortcutBridge().handleWindowClose(event, win)) {
|
||||
// Window was hidden to tray - save state before returning
|
||||
if (saveStateTimer) clearTimeout(saveStateTimer);
|
||||
const state = getWindowBoundsState(win, lastNormalBounds);
|
||||
if (state) saveWindowStateSync(state);
|
||||
closeSettingsWindow();
|
||||
hideSettingsWindow();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -903,12 +886,13 @@ async function createWindow(electronModule, options) {
|
||||
/**
|
||||
* Create or focus the settings window
|
||||
*/
|
||||
async function openSettingsWindow(electronModule, options) {
|
||||
async function openSettingsWindow(electronModule, options, { showOnLoad = true } = {}) {
|
||||
const { BrowserWindow, shell } = electronModule;
|
||||
const { preload, devServerUrl, isDev, appIcon, isMac, electronDir } = options;
|
||||
|
||||
// If settings window already exists, just focus it
|
||||
// If settings window already exists, show and focus it
|
||||
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
||||
settingsWindow.show();
|
||||
settingsWindow.focus();
|
||||
return settingsWindow;
|
||||
}
|
||||
@@ -955,6 +939,7 @@ async function openSettingsWindow(electronModule, options) {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: false,
|
||||
v8CacheOptions: V8_CACHE_OPTIONS,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1039,10 +1024,20 @@ async function openSettingsWindow(electronModule, options) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Defer show until renderer is ready; use fallback timeout to avoid keeping window hidden forever.
|
||||
setupDeferredShow(win, { timeoutMs: isDev ? 1200 : 600, waitForRendererReady: false });
|
||||
// Hide instead of close so the window can be reused instantly.
|
||||
// When the app is quitting, allow normal close/destroy.
|
||||
win.on('close', (event) => {
|
||||
if (!isQuitting) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
win.hide();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up reference when closed
|
||||
// Clean up reference when actually destroyed
|
||||
win.on('closed', () => {
|
||||
settingsWindow = null;
|
||||
});
|
||||
@@ -1054,6 +1049,7 @@ async function openSettingsWindow(electronModule, options) {
|
||||
try {
|
||||
const baseUrl = getDevRendererBaseUrl(devServerUrl);
|
||||
await win.loadURL(`${baseUrl}${settingsPath}`);
|
||||
if (showOnLoad) { win.show(); win.focus(); }
|
||||
return win;
|
||||
} catch (e) {
|
||||
console.warn("Dev server not reachable for settings window", e);
|
||||
@@ -1062,20 +1058,51 @@ async function openSettingsWindow(electronModule, options) {
|
||||
|
||||
// Production mode - load via custom protocol.
|
||||
await win.loadURL("app://netcatty/index.html#/settings");
|
||||
if (showOnLoad) { win.show(); win.focus(); }
|
||||
|
||||
return win;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the settings window
|
||||
* Destroy the settings window (used when the app is quitting).
|
||||
*/
|
||||
function closeSettingsWindow() {
|
||||
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
||||
settingsWindow.close();
|
||||
try {
|
||||
settingsWindow.destroy();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
settingsWindow = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the settings window without destroying it (used when main window hides to tray).
|
||||
*/
|
||||
function hideSettingsWindow() {
|
||||
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
||||
try {
|
||||
settingsWindow.hide();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-warm the settings window in the background so that opening it later is instant.
|
||||
* The window is created hidden and fully loaded; `openSettingsWindow` will simply show it.
|
||||
*/
|
||||
async function prewarmSettingsWindow(electronModule, options) {
|
||||
if (settingsWindow && !settingsWindow.isDestroyed()) return;
|
||||
try {
|
||||
await openSettingsWindow(electronModule, options, { showOnLoad: false });
|
||||
} catch (err) {
|
||||
debugLog("Failed to pre-warm settings window", { error: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register window control IPC handlers (only once)
|
||||
*/
|
||||
@@ -1176,13 +1203,13 @@ function registerWindowHandlers(ipcMain, nativeTheme) {
|
||||
|
||||
// Settings window close handler
|
||||
ipcMain.handle("netcatty:settings:close", (event) => {
|
||||
// Prefer closing the tracked settings window (if any).
|
||||
// Prefer hiding the tracked settings window (reused on next open).
|
||||
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
||||
debugLog("settings:close (tracked)", {
|
||||
senderId: event?.sender?.id,
|
||||
settingsId: settingsWindow.webContents?.id,
|
||||
});
|
||||
closeSettingsWindow();
|
||||
hideSettingsWindow();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1312,6 +1339,7 @@ module.exports = {
|
||||
createWindow,
|
||||
openSettingsWindow,
|
||||
closeSettingsWindow,
|
||||
prewarmSettingsWindow,
|
||||
buildAppMenu,
|
||||
getMainWindow,
|
||||
getSettingsWindow,
|
||||
|
||||
@@ -21,13 +21,52 @@ if (process.env.ELECTRON_RUN_AS_NODE) {
|
||||
// Load crash log bridge early so process-level error handlers can use it
|
||||
const crashLogBridge = require("./bridges/crashLogBridge.cjs");
|
||||
|
||||
// Handle uncaught exceptions for EPIPE errors
|
||||
// SSH / network errors that must never crash the process.
|
||||
// ssh2 can emit multiple 'error' events per connection (e.g. ECONNRESET followed
|
||||
// by "Connection lost before handshake"). If a listener is consumed after the first
|
||||
// event, the second becomes an uncaught exception. These are non-fatal for the app.
|
||||
function isNonFatalNetworkError(err) {
|
||||
if (!err) return false;
|
||||
// Any error with an ssh2 `level` property is a connection/auth-level error,
|
||||
// never a reason to kill the entire multi-session app.
|
||||
if (err.level) return true;
|
||||
const code = err.code;
|
||||
// Common TCP/DNS/routing errors that can surface from Node.js sockets
|
||||
// without an ssh2 `level` (e.g. proxy sockets, raw net.connect calls).
|
||||
switch (code) {
|
||||
case 'ECONNRESET':
|
||||
case 'ECONNREFUSED':
|
||||
case 'ECONNABORTED':
|
||||
case 'ETIMEDOUT':
|
||||
case 'ENOTFOUND':
|
||||
case 'EHOSTUNREACH':
|
||||
case 'EHOSTDOWN':
|
||||
case 'ENETUNREACH':
|
||||
case 'ENETDOWN':
|
||||
case 'EADDRNOTAVAIL':
|
||||
case 'EPROTO':
|
||||
case 'EPERM':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle uncaught exceptions — log all, only re-throw truly fatal ones
|
||||
process.on('uncaughtException', (err) => {
|
||||
// Skip benign stream teardown errors — don't pollute crash logs with false positives
|
||||
if (err.code === 'EPIPE' || err.code === 'ERR_STREAM_DESTROYED') {
|
||||
console.warn('Ignored stream error:', err.code);
|
||||
return;
|
||||
}
|
||||
// Non-fatal SSH/network errors: log but do NOT crash the process
|
||||
if (isNonFatalNetworkError(err)) {
|
||||
if (!err.__fromUnhandledRejection) {
|
||||
try { crashLogBridge.captureError('uncaughtException', err); } catch {}
|
||||
}
|
||||
console.warn('Non-fatal uncaught exception (suppressed):', err.message);
|
||||
return;
|
||||
}
|
||||
// Skip logging if already captured by unhandledRejection handler
|
||||
if (!err.__fromUnhandledRejection) {
|
||||
try { crashLogBridge.captureError('uncaughtException', err); } catch {}
|
||||
@@ -40,6 +79,12 @@ process.on('unhandledRejection', (reason) => {
|
||||
// Skip benign stream teardown errors
|
||||
const code = reason?.code;
|
||||
if (code === 'EPIPE' || code === 'ERR_STREAM_DESTROYED') return;
|
||||
// Non-fatal SSH/network errors: log but do NOT re-throw
|
||||
if (isNonFatalNetworkError(reason)) {
|
||||
try { crashLogBridge.captureError('unhandledRejection', reason); } catch {}
|
||||
console.warn('Non-fatal unhandled rejection (suppressed):', reason?.message || reason);
|
||||
return;
|
||||
}
|
||||
try { crashLogBridge.captureError('unhandledRejection', reason); } catch {}
|
||||
console.error('Unhandled rejection:', reason);
|
||||
// Re-throw to preserve fatal semantics. Mark so uncaughtException handler
|
||||
@@ -85,6 +130,16 @@ try {
|
||||
|
||||
// Apply ssh2 protocol patch needed for OpenSSH sk-* signature layouts.
|
||||
|
||||
function createLazyModule(modulePath) {
|
||||
let cachedModule = null;
|
||||
return () => {
|
||||
if (!cachedModule) {
|
||||
cachedModule = require(modulePath);
|
||||
}
|
||||
return cachedModule;
|
||||
};
|
||||
}
|
||||
|
||||
// Import bridge modules
|
||||
const sshBridge = require("./bridges/sshBridge.cjs");
|
||||
const sftpBridge = require("./bridges/sftpBridge.cjs");
|
||||
@@ -92,22 +147,22 @@ const localFsBridge = require("./bridges/localFsBridge.cjs");
|
||||
const transferBridge = require("./bridges/transferBridge.cjs");
|
||||
const portForwardingBridge = require("./bridges/portForwardingBridge.cjs");
|
||||
const terminalBridge = require("./bridges/terminalBridge.cjs");
|
||||
const oauthBridge = require("./bridges/oauthBridge.cjs");
|
||||
const githubAuthBridge = require("./bridges/githubAuthBridge.cjs");
|
||||
const googleAuthBridge = require("./bridges/googleAuthBridge.cjs");
|
||||
const onedriveAuthBridge = require("./bridges/onedriveAuthBridge.cjs");
|
||||
const cloudSyncBridge = require("./bridges/cloudSyncBridge.cjs");
|
||||
const fileWatcherBridge = require("./bridges/fileWatcherBridge.cjs");
|
||||
const tempDirBridge = require("./bridges/tempDirBridge.cjs");
|
||||
const sessionLogsBridge = require("./bridges/sessionLogsBridge.cjs");
|
||||
const sessionLogStreamManager = require("./bridges/sessionLogStreamManager.cjs");
|
||||
const compressUploadBridge = require("./bridges/compressUploadBridge.cjs");
|
||||
const globalShortcutBridge = require("./bridges/globalShortcutBridge.cjs");
|
||||
const credentialBridge = require("./bridges/credentialBridge.cjs");
|
||||
const autoUpdateBridge = require("./bridges/autoUpdateBridge.cjs");
|
||||
const aiBridge = require("./bridges/aiBridge.cjs");
|
||||
// crashLogBridge is required at the top of the file (before error handlers)
|
||||
const windowManager = require("./bridges/windowManager.cjs");
|
||||
const getOauthBridge = createLazyModule("./bridges/oauthBridge.cjs");
|
||||
const getGithubAuthBridge = createLazyModule("./bridges/githubAuthBridge.cjs");
|
||||
const getGoogleAuthBridge = createLazyModule("./bridges/googleAuthBridge.cjs");
|
||||
const getOnedriveAuthBridge = createLazyModule("./bridges/onedriveAuthBridge.cjs");
|
||||
const getCloudSyncBridge = createLazyModule("./bridges/cloudSyncBridge.cjs");
|
||||
const getFileWatcherBridge = createLazyModule("./bridges/fileWatcherBridge.cjs");
|
||||
const getTempDirBridge = createLazyModule("./bridges/tempDirBridge.cjs");
|
||||
const getSessionLogsBridge = createLazyModule("./bridges/sessionLogsBridge.cjs");
|
||||
const getCompressUploadBridge = createLazyModule("./bridges/compressUploadBridge.cjs");
|
||||
const getGlobalShortcutBridge = createLazyModule("./bridges/globalShortcutBridge.cjs");
|
||||
const getCredentialBridge = createLazyModule("./bridges/credentialBridge.cjs");
|
||||
const getAutoUpdateBridge = createLazyModule("./bridges/autoUpdateBridge.cjs");
|
||||
const getAiBridge = createLazyModule("./bridges/aiBridge.cjs");
|
||||
const getWindowManager = createLazyModule("./bridges/windowManager.cjs");
|
||||
|
||||
// GPU settings
|
||||
// NOTE: Do not disable Chromium sandbox by default.
|
||||
@@ -339,6 +394,19 @@ const registerBridges = (win) => {
|
||||
|
||||
const { ipcMain } = electronModule;
|
||||
const { safeStorage } = electronModule;
|
||||
const oauthBridge = getOauthBridge();
|
||||
const githubAuthBridge = getGithubAuthBridge();
|
||||
const googleAuthBridge = getGoogleAuthBridge();
|
||||
const onedriveAuthBridge = getOnedriveAuthBridge();
|
||||
const cloudSyncBridge = getCloudSyncBridge();
|
||||
const fileWatcherBridge = getFileWatcherBridge();
|
||||
const tempDirBridge = getTempDirBridge();
|
||||
const sessionLogsBridge = getSessionLogsBridge();
|
||||
const compressUploadBridge = getCompressUploadBridge();
|
||||
const globalShortcutBridge = getGlobalShortcutBridge();
|
||||
const credentialBridge = getCredentialBridge();
|
||||
const autoUpdateBridge = getAutoUpdateBridge();
|
||||
const aiBridge = getAiBridge();
|
||||
|
||||
const getCloudSyncPasswordPath = () => {
|
||||
try {
|
||||
@@ -437,10 +505,107 @@ const registerBridges = (win) => {
|
||||
aiBridge.registerHandlers(ipcMain);
|
||||
crashLogBridge.registerHandlers(ipcMain);
|
||||
|
||||
// Fig autocomplete spec loader — uses dynamic import() since @withfig/autocomplete is ESM
|
||||
ipcMain.handle("netcatty:figspec:list", async () => {
|
||||
try {
|
||||
const fs = require("fs");
|
||||
const mod = await import("@withfig/autocomplete");
|
||||
const figSpecs = mod.default || [];
|
||||
// Merge local specs (covers commands missing from @withfig/autocomplete)
|
||||
const localSpecDir = path.join(electronDir, "specs");
|
||||
let localNames = [];
|
||||
try {
|
||||
localNames = fs.readdirSync(localSpecDir)
|
||||
.filter(f => f.endsWith(".js"))
|
||||
.map(f => f.slice(0, -3));
|
||||
} catch { /* no local specs dir */ }
|
||||
const merged = [...new Set([...figSpecs, ...localNames])];
|
||||
return merged;
|
||||
} catch (err) {
|
||||
console.warn("[Main] Failed to load fig spec list:", err?.message || err);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
ipcMain.handle("netcatty:figspec:load", async (_event, commandName) => {
|
||||
try {
|
||||
// Sanitize: reject absolute paths, path traversal, and non-spec characters
|
||||
if (!commandName || commandName.startsWith("/") || commandName.startsWith("\\") ||
|
||||
commandName.includes("..") || !/^[@a-zA-Z0-9._/+-]+$/.test(commandName)) return null;
|
||||
const { pathToFileURL } = require("url");
|
||||
const fs = require("fs");
|
||||
|
||||
// Try local specs first (covers commands missing from @withfig/autocomplete)
|
||||
const localSpec = path.join(electronDir, "specs", `${commandName}.js`);
|
||||
if (fs.existsSync(localSpec)) {
|
||||
const mod = await import(pathToFileURL(localSpec).href);
|
||||
const spec = mod.default?.default ?? mod.default ?? null;
|
||||
return spec ? JSON.parse(JSON.stringify(spec)) : null;
|
||||
}
|
||||
|
||||
// Fall back to @withfig/autocomplete
|
||||
// Can't use `import("@withfig/autocomplete/build/...")` because the package's
|
||||
// "exports" field restricts allowed import paths. Use file URL to bypass.
|
||||
const specFile = path.join(electronDir, "..", "node_modules", "@withfig", "autocomplete", "build", `${commandName}.js`);
|
||||
const mod = await import(pathToFileURL(specFile).href);
|
||||
const spec = mod.default?.default ?? mod.default ?? null;
|
||||
// IPC requires serializable data — JSON round-trip strips functions/symbols
|
||||
return spec ? JSON.parse(JSON.stringify(spec)) : null;
|
||||
} catch (err) {
|
||||
console.warn("[Main] Failed to load fig spec:", commandName, err?.message);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
// Local directory listing for autocomplete (local terminal sessions)
|
||||
ipcMain.handle("netcatty:local:listdir", async (_event, payload) => {
|
||||
try {
|
||||
const {
|
||||
path: dirPath,
|
||||
foldersOnly,
|
||||
filterPrefix = "",
|
||||
limit = 100,
|
||||
} = payload || {};
|
||||
if (typeof dirPath !== "string" || dirPath.length === 0) {
|
||||
return { success: false, entries: [], error: "Invalid directory path" };
|
||||
}
|
||||
const resolvedPath = dirPath.startsWith("~")
|
||||
? dirPath.replace(/^~/, require("os").homedir())
|
||||
: dirPath;
|
||||
const normalizedPrefix = typeof filterPrefix === "string" ? filterPrefix.toLowerCase() : "";
|
||||
const maxEntries = Number.isFinite(limit) ? Math.min(Math.max(1, Math.floor(limit)), 200) : 100;
|
||||
const entries = await fs.promises.readdir(resolvedPath, { withFileTypes: true });
|
||||
const result = [];
|
||||
for (const entry of entries) {
|
||||
if (result.length >= maxEntries) break;
|
||||
if (entry.name === "." || entry.name === "..") continue;
|
||||
if (normalizedPrefix && !entry.name.toLowerCase().startsWith(normalizedPrefix)) continue;
|
||||
let type = entry.isDirectory() ? "directory" : entry.isSymbolicLink() ? "symlink" : "file";
|
||||
if (foldersOnly) {
|
||||
if (type === "directory") {
|
||||
// keep
|
||||
} else if (type === "symlink") {
|
||||
try {
|
||||
const stat = await fs.promises.stat(path.join(resolvedPath, entry.name));
|
||||
if (!stat.isDirectory()) continue;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
result.push({ name: entry.name, type });
|
||||
}
|
||||
return { success: true, entries: result };
|
||||
} catch {
|
||||
return { success: false, entries: [] };
|
||||
}
|
||||
});
|
||||
|
||||
// Settings window handler
|
||||
ipcMain.handle("netcatty:settings:open", async () => {
|
||||
try {
|
||||
await windowManager.openSettingsWindow(electronModule, {
|
||||
await getWindowManager().openSettingsWindow(electronModule, {
|
||||
preload,
|
||||
devServerUrl: effectiveDevServerUrl,
|
||||
isDev,
|
||||
@@ -630,6 +795,24 @@ const registerBridges = (win) => {
|
||||
return result.filePath;
|
||||
});
|
||||
|
||||
// Select a file and return the selected path
|
||||
ipcMain.handle("netcatty:selectFile", async (_event, { title, defaultPath, filters }) => {
|
||||
const { dialog } = electronModule;
|
||||
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: title || "Select File",
|
||||
defaultPath: defaultPath || os.homedir(),
|
||||
filters: filters || [{ name: "All Files", extensions: ["*"] }],
|
||||
properties: ["openFile", "showHiddenFiles"],
|
||||
});
|
||||
|
||||
if (result.canceled || !result.filePaths.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.filePaths[0];
|
||||
});
|
||||
|
||||
// Select a directory and return the selected path
|
||||
ipcMain.handle("netcatty:selectDirectory", async (_event, { title, defaultPath }) => {
|
||||
const { dialog } = electronModule;
|
||||
@@ -656,7 +839,7 @@ const registerBridges = (win) => {
|
||||
|
||||
const client = require("./bridges/sftpBridge.cjs");
|
||||
// Use tempDirBridge for dedicated Netcatty temp directory
|
||||
const localPath = await tempDirBridge.getTempFilePath(fileName);
|
||||
const localPath = await getTempDirBridge().getTempFilePath(fileName);
|
||||
|
||||
console.log(`[Main] Local temp path: ${localPath}`);
|
||||
|
||||
@@ -695,7 +878,7 @@ const registerBridges = (win) => {
|
||||
// only carries the resolved temp path. Cancellation is NOT an error here —
|
||||
// the UI already transitions the task to "cancelled" via the dedicated event.
|
||||
ipcMain.handle("netcatty:sftp:downloadToTempWithProgress", async (event, { sftpId, remotePath, fileName, encoding, transferId }) => {
|
||||
const localPath = await tempDirBridge.getTempFilePath(fileName);
|
||||
const localPath = await getTempDirBridge().getTempFilePath(fileName);
|
||||
const cleanupPartialDownload = async () => {
|
||||
try {
|
||||
await fs.promises.rm(localPath, { force: true });
|
||||
@@ -736,7 +919,7 @@ const registerBridges = (win) => {
|
||||
ipcMain.handle("netcatty:deleteTempFile", async (_event, { filePath }) => {
|
||||
try {
|
||||
// Only allow deleting files in Netcatty temp directory for security
|
||||
const netcattyTempDir = path.resolve(tempDirBridge.getTempDir());
|
||||
const netcattyTempDir = path.resolve(getTempDirBridge().getTempDir());
|
||||
const resolvedPath = path.resolve(String(filePath || ""));
|
||||
if (!isPathInside(netcattyTempDir, resolvedPath)) {
|
||||
console.warn(`[Main] Refused to delete file outside Netcatty temp dir: ${filePath}`);
|
||||
@@ -760,7 +943,7 @@ const registerBridges = (win) => {
|
||||
* Create the main application window
|
||||
*/
|
||||
async function createWindow() {
|
||||
const win = await windowManager.createWindow(electronModule, {
|
||||
const win = await getWindowManager().createWindow(electronModule, {
|
||||
preload,
|
||||
devServerUrl: effectiveDevServerUrl,
|
||||
isDev,
|
||||
@@ -819,11 +1002,12 @@ if (!gotLock) {
|
||||
}
|
||||
|
||||
// Build and set application menu
|
||||
const menu = windowManager.buildAppMenu(Menu, app, isMac);
|
||||
const menu = getWindowManager().buildAppMenu(Menu, app, isMac);
|
||||
Menu.setApplicationMenu(menu);
|
||||
|
||||
app.on("browser-window-created", (_event, win) => {
|
||||
try {
|
||||
const windowManager = getWindowManager();
|
||||
const mainWin = windowManager.getMainWindow();
|
||||
const settingsWin = windowManager.getSettingsWindow();
|
||||
const isPrimary = win === mainWin || win === settingsWin;
|
||||
@@ -842,7 +1026,20 @@ if (!gotLock) {
|
||||
void createWindow().then(() => {
|
||||
// Trigger auto-update check 5 s after window creation.
|
||||
// startAutoCheck() is a no-op on unsupported platforms (Linux deb/rpm/snap).
|
||||
autoUpdateBridge.startAutoCheck(5000);
|
||||
getAutoUpdateBridge().startAutoCheck(5000);
|
||||
|
||||
// Pre-warm the settings window in the background so it opens instantly.
|
||||
// Delay slightly to avoid competing with main window first-paint resources.
|
||||
setTimeout(() => {
|
||||
getWindowManager().prewarmSettingsWindow(electronModule, {
|
||||
preload,
|
||||
devServerUrl: effectiveDevServerUrl,
|
||||
isDev,
|
||||
appIcon,
|
||||
isMac,
|
||||
electronDir,
|
||||
});
|
||||
}, 3000);
|
||||
}).catch((err) => {
|
||||
console.error("[Main] Failed to create main window:", err);
|
||||
showStartupError(err);
|
||||
@@ -856,7 +1053,7 @@ if (!gotLock) {
|
||||
// If the main window was hidden (e.g. "close to tray"), clicking the Dock icon
|
||||
// should bring it back. Fallback to creating a new window if none exists.
|
||||
try {
|
||||
const mainWin = windowManager.getMainWindow?.();
|
||||
const mainWin = getWindowManager().getMainWindow?.();
|
||||
if (mainWin && !mainWin.isDestroyed?.()) {
|
||||
if (mainWin.isMinimized?.()) mainWin.restore();
|
||||
mainWin.show?.();
|
||||
@@ -886,7 +1083,7 @@ if (!gotLock) {
|
||||
});
|
||||
|
||||
app.on("before-quit", () => {
|
||||
windowManager.setIsQuitting(true);
|
||||
getWindowManager().setIsQuitting(true);
|
||||
});
|
||||
|
||||
// Cleanup all PTY sessions and port forwarding tunnels before quitting
|
||||
@@ -907,12 +1104,12 @@ if (!gotLock) {
|
||||
console.warn("Error during port forwarding cleanup:", err);
|
||||
}
|
||||
try {
|
||||
globalShortcutBridge.cleanup();
|
||||
getGlobalShortcutBridge().cleanup();
|
||||
} catch (err) {
|
||||
console.warn("Error during global shortcut cleanup:", err);
|
||||
}
|
||||
try {
|
||||
aiBridge.cleanup();
|
||||
getAiBridge().cleanup();
|
||||
} catch (err) {
|
||||
console.warn("Error during AI bridge cleanup:", err);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* Spawned by codex-acp (or other ACP agents) as a child process.
|
||||
* Communicates with the Netcatty main process via TCP (JSON-RPC over newline-delimited JSON).
|
||||
* Exposes Netcatty terminal and SFTP tools so ACP agents can operate on scoped sessions.
|
||||
* Exposes Netcatty terminal tools so ACP agents can operate on scoped sessions.
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
@@ -39,7 +39,6 @@ const CHAT_SESSION_ID = process.env.NETCATTY_MCP_CHAT_SESSION_ID || null;
|
||||
|
||||
// Permission mode: 'observer' | 'confirm' | 'autonomous' (defense-in-depth, TCP bridge also checks)
|
||||
const PERMISSION_MODE = process.env.NETCATTY_MCP_PERMISSION_MODE || "confirm";
|
||||
const ENABLE_SFTP_TOOLS = process.env.NETCATTY_MCP_ENABLE_SFTP !== "0";
|
||||
|
||||
// Default command blocklist (defense-in-depth, TCP bridge also checks)
|
||||
// NOTE: Keep in sync with DEFAULT_COMMAND_BLOCKLIST in infrastructure/ai/types.ts
|
||||
@@ -76,12 +75,14 @@ function checkCommandSafety(command) {
|
||||
return { blocked: false };
|
||||
}
|
||||
|
||||
/** Guard for write tools: blocks in observer mode, checks command safety for commands. */
|
||||
function guardWriteOperation(command) {
|
||||
/** Guard for write tools: blocks in observer mode, optionally checks command safety. */
|
||||
function guardWriteOperation(command, { skipBlocklist = false } = {}) {
|
||||
if (PERMISSION_MODE === "observer") {
|
||||
return 'Operation denied: permission mode is "observer" (read-only). Change to "confirm" or "autonomous" in Settings → AI → Safety to allow this action.';
|
||||
}
|
||||
if (command) {
|
||||
// When skipBlocklist is true, the caller relies on the TCP bridge layer for
|
||||
// session-aware blocklist checks (e.g. serial sessions skip shell patterns).
|
||||
if (!skipBlocklist && command) {
|
||||
const safety = checkCommandSafety(command);
|
||||
if (safety.blocked) {
|
||||
return `Command blocked by safety policy. Pattern: ${safety.matchedPattern}`;
|
||||
@@ -197,7 +198,7 @@ server.resource(
|
||||
// Tool: get_environment
|
||||
server.tool(
|
||||
"get_environment",
|
||||
"Get information about the current Netcatty scope: all terminal sessions exposed by Netcatty, their session IDs, OS, shell hints, and connection status. Sessions may be remote hosts, a local terminal, or Mosh-backed shells. Call this first before executing commands.",
|
||||
"Get information about the current Netcatty scope: all terminal sessions exposed by Netcatty, their session IDs, OS, shell hints, and connection status. Sessions may be remote hosts, a local terminal, Mosh-backed shells, or serial port connections (network devices, embedded systems). Serial sessions have protocol 'serial' and shellType 'raw'. Call this first before executing commands.",
|
||||
{},
|
||||
async () => {
|
||||
process.stderr.write(`[netcatty-mcp] get_environment called, SCOPED_SESSION_IDS: ${JSON.stringify(SCOPED_SESSION_IDS)}, CHAT_SESSION_ID: ${CHAT_SESSION_ID}\n`);
|
||||
@@ -206,10 +207,7 @@ server.tool(
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
...ctx,
|
||||
sftpAvailable: ENABLE_SFTP_TOOLS,
|
||||
}, null, 2),
|
||||
text: JSON.stringify(ctx, null, 2),
|
||||
}],
|
||||
};
|
||||
},
|
||||
@@ -218,13 +216,14 @@ server.tool(
|
||||
// Tool: terminal_execute
|
||||
server.tool(
|
||||
"terminal_execute",
|
||||
"Execute a shell command on a Netcatty terminal session. The command runs in that session's shell and output (stdout/stderr) is returned when complete.",
|
||||
"Execute a command on a Netcatty terminal session. For shell sessions, the command runs in the session's shell. For serial/raw sessions (network devices), the command is sent as-is without shell wrapping and exit codes are unavailable.",
|
||||
{
|
||||
sessionId: z.string().describe("The terminal session ID (from get_environment) to execute on."),
|
||||
command: z.string().describe("The shell command to execute in the target session."),
|
||||
command: z.string().describe("The command to execute in the target session."),
|
||||
},
|
||||
async ({ sessionId, command }) => {
|
||||
const guardErr = guardWriteOperation(command);
|
||||
// skipBlocklist: bridge layer does session-aware blocklist (serial sessions skip shell patterns)
|
||||
const guardErr = guardWriteOperation(command, { skipBlocklist: true });
|
||||
if (guardErr) {
|
||||
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
|
||||
}
|
||||
@@ -235,196 +234,14 @@ server.tool(
|
||||
const parts = [];
|
||||
if (result.stdout) parts.push(result.stdout);
|
||||
if (result.stderr) parts.push(`[stderr] ${result.stderr}`);
|
||||
parts.push(`[exit code: ${result.exitCode ?? -1}]`);
|
||||
// Serial/raw sessions return null exitCode (vendor CLIs have no exit codes)
|
||||
if (result.exitCode != null) {
|
||||
parts.push(`[exit code: ${result.exitCode}]`);
|
||||
}
|
||||
return { content: [{ type: "text", text: parts.join("\n") }] };
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: terminal_send_input
|
||||
server.tool(
|
||||
"terminal_send_input",
|
||||
"Send raw input to a Netcatty terminal session. Use only for interactive programs that are already running: y/n prompts, passwords, ctrl+c (\\x03), ctrl+d (\\x04), or pressing enter (\\n). This tool does not return the updated terminal output. For normal commands, use terminal_execute.",
|
||||
{
|
||||
sessionId: z.string().describe("The terminal session ID to send input to."),
|
||||
input: z.string().describe("The raw input string. Use escape sequences for special keys (e.g. \\x03 for ctrl+c, \\n for enter)."),
|
||||
},
|
||||
async ({ sessionId, input }) => {
|
||||
const guardErr = guardWriteOperation(input);
|
||||
if (guardErr) {
|
||||
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
|
||||
}
|
||||
const result = await rpcCall("netcatty/terminalWrite", { ...scopeParams, sessionId, input });
|
||||
if (!result.ok) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
return { content: [{ type: "text", text: `Sent input to session ${sessionId}` }] };
|
||||
},
|
||||
);
|
||||
|
||||
if (ENABLE_SFTP_TOOLS) {
|
||||
// Tool: sftp_list_directory
|
||||
server.tool(
|
||||
"sftp_list_directory",
|
||||
"List the contents of a directory on the remote host. Returns file names, sizes, types, and modification timestamps.",
|
||||
{
|
||||
sessionId: z.string().describe("The terminal session ID for the remote host."),
|
||||
path: z.string().describe("The absolute path of the remote directory to list."),
|
||||
},
|
||||
async ({ sessionId, path }) => {
|
||||
const result = await rpcCall("netcatty/sftpList", { ...scopeParams, sessionId, path });
|
||||
if (result.error) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
return { content: [{ type: "text", text: JSON.stringify(result.files || result.output, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: sftp_read_file
|
||||
server.tool(
|
||||
"sftp_read_file",
|
||||
"Read the content of a file on the remote host. Returns file content as text, truncated if the file is large.",
|
||||
{
|
||||
sessionId: z.string().describe("The terminal session ID for the remote host."),
|
||||
path: z.string().describe("The absolute path of the remote file to read."),
|
||||
maxBytes: z.number().optional().default(10000).describe("Maximum bytes to read. Defaults to 10000."),
|
||||
},
|
||||
async ({ sessionId, path, maxBytes }) => {
|
||||
const safeMaxBytes = Math.max(1, Math.min(10 * 1024 * 1024, Number(maxBytes) || 10000));
|
||||
const result = await rpcCall("netcatty/sftpRead", { ...scopeParams, sessionId, path, maxBytes: safeMaxBytes });
|
||||
if (result.error) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
return { content: [{ type: "text", text: result.content || "(empty file)" }] };
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: sftp_write_file
|
||||
server.tool(
|
||||
"sftp_write_file",
|
||||
"Write content to a file on the remote host. Creates the file if it does not exist, or overwrites it.",
|
||||
{
|
||||
sessionId: z.string().describe("The terminal session ID for the remote host."),
|
||||
path: z.string().describe("The absolute path of the remote file to write."),
|
||||
content: z.string().describe("The text content to write to the file."),
|
||||
},
|
||||
async ({ sessionId, path, content }) => {
|
||||
const guardErr = guardWriteOperation(path);
|
||||
if (guardErr) {
|
||||
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
|
||||
}
|
||||
const result = await rpcCall("netcatty/sftpWrite", { ...scopeParams, sessionId, path, content });
|
||||
if (result.error) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
return { content: [{ type: "text", text: `Written: ${path}` }] };
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: sftp_mkdir
|
||||
server.tool(
|
||||
"sftp_mkdir",
|
||||
"Create a directory on the remote host. Creates parent directories if they don't exist.",
|
||||
{
|
||||
sessionId: z.string().describe("The terminal session ID for the remote host."),
|
||||
path: z.string().describe("The absolute path of the directory to create."),
|
||||
},
|
||||
async ({ sessionId, path }) => {
|
||||
const guardErr = guardWriteOperation();
|
||||
if (guardErr) {
|
||||
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
|
||||
}
|
||||
const result = await rpcCall("netcatty/sftpMkdir", { ...scopeParams, sessionId, path });
|
||||
if (result.error) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
return { content: [{ type: "text", text: `Created directory: ${path}` }] };
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: sftp_remove
|
||||
server.tool(
|
||||
"sftp_remove",
|
||||
"Delete a file or directory on the remote host. Directories are removed recursively.",
|
||||
{
|
||||
sessionId: z.string().describe("The terminal session ID for the remote host."),
|
||||
path: z.string().describe("The absolute path of the file or directory to delete."),
|
||||
},
|
||||
async ({ sessionId, path }) => {
|
||||
const guardErr = guardWriteOperation();
|
||||
if (guardErr) {
|
||||
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
|
||||
}
|
||||
const result = await rpcCall("netcatty/sftpRemove", { ...scopeParams, sessionId, path });
|
||||
if (result.error) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
return { content: [{ type: "text", text: `Removed: ${path}` }] };
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: sftp_rename
|
||||
server.tool(
|
||||
"sftp_rename",
|
||||
"Rename or move a file/directory on the remote host.",
|
||||
{
|
||||
sessionId: z.string().describe("The terminal session ID for the remote host."),
|
||||
oldPath: z.string().describe("The current absolute path."),
|
||||
newPath: z.string().describe("The new absolute path."),
|
||||
},
|
||||
async ({ sessionId, oldPath, newPath }) => {
|
||||
const guardErr = guardWriteOperation();
|
||||
if (guardErr) {
|
||||
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
|
||||
}
|
||||
const result = await rpcCall("netcatty/sftpRename", { ...scopeParams, sessionId, oldPath, newPath });
|
||||
if (result.error) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
return { content: [{ type: "text", text: `Renamed: ${oldPath} → ${newPath}` }] };
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: sftp_stat
|
||||
server.tool(
|
||||
"sftp_stat",
|
||||
"Get file/directory metadata on the remote host: type, size, permissions, and modification time.",
|
||||
{
|
||||
sessionId: z.string().describe("The terminal session ID for the remote host."),
|
||||
path: z.string().describe("The absolute path to stat."),
|
||||
},
|
||||
async ({ sessionId, path }) => {
|
||||
const result = await rpcCall("netcatty/sftpStat", { ...scopeParams, sessionId, path });
|
||||
if (result.error) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Tool: multi_host_execute
|
||||
server.tool(
|
||||
"multi_host_execute",
|
||||
"Execute a command on multiple Netcatty terminal sessions simultaneously or sequentially. Useful for fleet-wide operations, or to compare local and remote environments.",
|
||||
{
|
||||
sessionIds: z.array(z.string()).describe("Array of session IDs to execute on."),
|
||||
command: z.string().describe("The shell command to execute on each host."),
|
||||
mode: z.enum(["parallel", "sequential"]).optional().default("parallel").describe("Execution mode. Defaults to parallel."),
|
||||
stopOnError: z.boolean().optional().default(false).describe("In sequential mode, stop on first failure."),
|
||||
},
|
||||
async ({ sessionIds, command, mode, stopOnError }) => {
|
||||
const guardErr = guardWriteOperation(command);
|
||||
if (guardErr) {
|
||||
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
|
||||
}
|
||||
const result = await rpcCall("netcatty/multiExec", { ...scopeParams, sessionIds, command, mode, stopOnError });
|
||||
if (result.error) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
return { content: [{ type: "text", text: JSON.stringify(result.results, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
// ── Start ──
|
||||
|
||||
async function main() {
|
||||
|
||||
@@ -8,6 +8,7 @@ const transferCompleteListeners = new Map();
|
||||
const transferErrorListeners = new Map();
|
||||
const transferCancelledListeners = new Map();
|
||||
const chainProgressListeners = new Map();
|
||||
const sftpConnectionProgressListeners = new Set();
|
||||
const authFailedListeners = new Map();
|
||||
const languageChangeListeners = new Set();
|
||||
const fullscreenChangeListeners = new Set();
|
||||
@@ -34,6 +35,7 @@ function cleanupTransferListeners(transferId) {
|
||||
// chunk, then filter complete lines that contain the marker.
|
||||
|
||||
const _mcpLineBufs = new Map(); // sessionId -> trailing fragment string
|
||||
const _mcpFlushTimers = new Map(); // sessionId -> delayed-flush timer
|
||||
|
||||
// Returns true if `s` ends with a non-empty prefix of "__NCMCP_"
|
||||
// (i.e. the next chunk might complete it into a marker-containing line).
|
||||
@@ -46,6 +48,13 @@ function _endsWithMarkerPrefix(s) {
|
||||
}
|
||||
|
||||
function filterMcpChunk(sessionId, chunk) {
|
||||
// Cancel any pending delayed flush — new data arrived
|
||||
const pendingTimer = _mcpFlushTimers.get(sessionId);
|
||||
if (pendingTimer) {
|
||||
clearTimeout(pendingTimer);
|
||||
_mcpFlushTimers.delete(sessionId);
|
||||
}
|
||||
|
||||
// Prepend any buffered fragment from the previous chunk
|
||||
const held = _mcpLineBufs.get(sessionId) || "";
|
||||
const data = held + chunk;
|
||||
@@ -58,14 +67,18 @@ function filterMcpChunk(sessionId, chunk) {
|
||||
|
||||
// Slow path: scan line by line
|
||||
let result = "";
|
||||
let droppedAny = false;
|
||||
let pos = 0;
|
||||
while (pos < data.length) {
|
||||
const nlIdx = data.indexOf("\n", pos);
|
||||
if (nlIdx === -1) {
|
||||
// Incomplete trailing line — no newline yet
|
||||
// Incomplete trailing line — no newline yet.
|
||||
// If we dropped any marker line in this chunk, or the tail itself
|
||||
// looks like it could contain a marker, buffer it. Long command
|
||||
// echoes can wrap across PTY lines; wrapped fragments that don't
|
||||
// contain __NCMCP_ would otherwise leak through as garbage.
|
||||
const tail = data.slice(pos);
|
||||
if (tail.includes("__NCMCP_") || _endsWithMarkerPrefix(tail)) {
|
||||
// Hold it; next chunk might complete or confirm the marker
|
||||
if (droppedAny || tail.includes("__NCMCP_") || _endsWithMarkerPrefix(tail)) {
|
||||
_mcpLineBufs.set(sessionId, tail);
|
||||
} else {
|
||||
result += tail; // safe to display immediately
|
||||
@@ -75,34 +88,52 @@ function filterMcpChunk(sessionId, chunk) {
|
||||
const line = data.slice(pos, nlIdx + 1); // includes the \n
|
||||
if (!line.includes("__NCMCP_")) {
|
||||
result += line;
|
||||
} else {
|
||||
droppedAny = true;
|
||||
}
|
||||
// else: drop it — it's a wrapper marker line (or echo of one)
|
||||
pos = nlIdx + 1;
|
||||
}
|
||||
|
||||
// Also strip Posix pager prefix and Fish env lines that have no __NCMCP_
|
||||
if (result) {
|
||||
result = result
|
||||
.replace(/PAGER=cat SYSTEMD_PAGER= GIT_PAGER=cat LESS= /g, "")
|
||||
.replace(/^set -gx (?:PAGER|SYSTEMD_PAGER|GIT_PAGER|LESS) [^\r\n]*[\r\n]*/gm, "")
|
||||
.replace(/^set "(?:PAGER|SYSTEMD_PAGER|GIT_PAGER|LESS)=[^"]*"[\r\n]*/gm, "");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver data to session listeners. Used both by the normal data path
|
||||
* and by the delayed-flush timer.
|
||||
*/
|
||||
function _deliverToListeners(sessionId, data) {
|
||||
const set = dataListeners.get(sessionId);
|
||||
if (!set || !data) return;
|
||||
set.forEach((cb) => {
|
||||
try { cb(data); } catch (err) { console.error("Data callback failed", err); }
|
||||
});
|
||||
}
|
||||
|
||||
ipcRenderer.on("netcatty:data", (_event, payload) => {
|
||||
const set = dataListeners.get(payload.sessionId);
|
||||
if (!set) return;
|
||||
const data = filterMcpChunk(payload.sessionId, payload.data);
|
||||
if (!data) return;
|
||||
set.forEach((cb) => {
|
||||
try {
|
||||
cb(data);
|
||||
} catch (err) {
|
||||
console.error("Data callback failed", err);
|
||||
}
|
||||
});
|
||||
if (data) {
|
||||
set.forEach((cb) => {
|
||||
try {
|
||||
cb(data);
|
||||
} catch (err) {
|
||||
console.error("Data callback failed", err);
|
||||
}
|
||||
});
|
||||
}
|
||||
// If there is buffered content waiting for more data (e.g. a prompt
|
||||
// right after a dropped marker line), schedule a delayed flush so it
|
||||
// appears after a short pause instead of staying hidden forever.
|
||||
if (_mcpLineBufs.has(payload.sessionId)) {
|
||||
const sid = payload.sessionId;
|
||||
_mcpFlushTimers.set(sid, setTimeout(() => {
|
||||
const held = _mcpLineBufs.get(sid);
|
||||
_mcpLineBufs.delete(sid);
|
||||
_mcpFlushTimers.delete(sid);
|
||||
if (held) _deliverToListeners(sid, held);
|
||||
}, 80));
|
||||
}
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:exit", (_event, payload) => {
|
||||
@@ -118,6 +149,11 @@ ipcRenderer.on("netcatty:exit", (_event, payload) => {
|
||||
}
|
||||
dataListeners.delete(payload.sessionId);
|
||||
exitListeners.delete(payload.sessionId);
|
||||
const pendingTimer = _mcpFlushTimers.get(payload.sessionId);
|
||||
if (pendingTimer) {
|
||||
clearTimeout(pendingTimer);
|
||||
_mcpFlushTimers.delete(payload.sessionId);
|
||||
}
|
||||
_mcpLineBufs.delete(payload.sessionId); // clean up any held fragment
|
||||
});
|
||||
|
||||
@@ -134,6 +170,17 @@ ipcRenderer.on("netcatty:chain:progress", (_event, payload) => {
|
||||
});
|
||||
});
|
||||
|
||||
// SFTP connection progress events (auth method logs)
|
||||
ipcRenderer.on("netcatty:sftp:connection-progress", (_event, payload) => {
|
||||
sftpConnectionProgressListeners.forEach((cb) => {
|
||||
try {
|
||||
cb(payload.sessionId, payload.label, payload.status, payload.detail);
|
||||
} catch (err) {
|
||||
console.error("SFTP connection progress callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:languageChanged", (_event, language) => {
|
||||
languageChangeListeners.forEach((cb) => {
|
||||
try {
|
||||
@@ -808,6 +855,13 @@ const api = {
|
||||
chainProgressListeners.delete(id);
|
||||
};
|
||||
},
|
||||
// SFTP connection progress listener (auth method logs)
|
||||
onSftpConnectionProgress: (cb) => {
|
||||
sftpConnectionProgressListeners.add(cb);
|
||||
return () => {
|
||||
sftpConnectionProgressListeners.delete(cb);
|
||||
};
|
||||
},
|
||||
|
||||
// OAuth callback server
|
||||
startOAuthCallback: (expectedState) => ipcRenderer.invoke("oauth:startCallback", expectedState),
|
||||
@@ -878,6 +932,8 @@ const api = {
|
||||
ipcRenderer.invoke("netcatty:showSaveDialog", { defaultPath, filters }),
|
||||
selectDirectory: (title, defaultPath) =>
|
||||
ipcRenderer.invoke("netcatty:selectDirectory", { title, defaultPath }),
|
||||
selectFile: (title, defaultPath, filters) =>
|
||||
ipcRenderer.invoke("netcatty:selectFile", { title, defaultPath, filters }),
|
||||
|
||||
// File watcher for auto-sync feature
|
||||
startFileWatch: (localPath, remotePath, sftpId, encoding) =>
|
||||
@@ -1070,11 +1126,11 @@ const api = {
|
||||
aiAllowlistAddHost: async (baseURL) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:allowlist:add-host", { baseURL });
|
||||
},
|
||||
aiExec: async (sessionId, command) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:exec", { sessionId, command });
|
||||
aiExec: async (sessionId, command, chatSessionId) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:exec", { sessionId, command, chatSessionId });
|
||||
},
|
||||
aiTerminalWrite: async (sessionId, data) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:terminal:write", { sessionId, data });
|
||||
aiCattyCancelExec: async (chatSessionId) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:catty:cancel", { chatSessionId });
|
||||
},
|
||||
aiDiscoverAgents: async () => {
|
||||
return ipcRenderer.invoke("netcatty:ai:agents:discover");
|
||||
@@ -1215,6 +1271,25 @@ const api = {
|
||||
},
|
||||
};
|
||||
|
||||
// Fig autocomplete spec loading via main process
|
||||
const figSpecApi = {
|
||||
listFigSpecs: () => ipcRenderer.invoke("netcatty:figspec:list"),
|
||||
loadFigSpec: (commandName) => ipcRenderer.invoke("netcatty:figspec:load", commandName),
|
||||
listAutocompleteRemoteDir: (sessionId, dirPath, foldersOnly, filterPrefix, limit) => ipcRenderer.invoke("netcatty:ssh:listdir", {
|
||||
sessionId,
|
||||
path: dirPath,
|
||||
foldersOnly,
|
||||
filterPrefix,
|
||||
limit,
|
||||
}),
|
||||
listAutocompleteLocalDir: (dirPath, foldersOnly, filterPrefix, limit) => ipcRenderer.invoke("netcatty:local:listdir", {
|
||||
path: dirPath,
|
||||
foldersOnly,
|
||||
filterPrefix,
|
||||
limit,
|
||||
}),
|
||||
};
|
||||
|
||||
// Merge with existing netcatty (if any) to avoid stale objects on hot reload
|
||||
const existing = (typeof window !== "undefined" && window.netcatty) ? window.netcatty : {};
|
||||
|
||||
@@ -1255,7 +1330,7 @@ function isTrustedRendererLocation(allowedOrigins) {
|
||||
|
||||
const allowedOrigins = getAllowedRendererOrigins();
|
||||
if (isTrustedRendererLocation(allowedOrigins)) {
|
||||
contextBridge.exposeInMainWorld("netcatty", { ...existing, ...api });
|
||||
contextBridge.exposeInMainWorld("netcatty", { ...existing, ...api, ...figSpecApi });
|
||||
} else {
|
||||
// If a window navigates to an untrusted origin, do NOT expose the bridge.
|
||||
try {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user