Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2aad02a914 | ||
|
|
76baf87c29 | ||
|
|
2a75f863f8 | ||
|
|
262bc57a21 | ||
|
|
9563ae9dcc | ||
|
|
349b215d3d | ||
|
|
7639191c50 | ||
|
|
c3224d30c6 | ||
|
|
40d80fe535 | ||
|
|
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 |
397
App.tsx
397
App.tsx
@@ -1,4 +1,4 @@
|
||||
import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { Suspense, lazy, useCallback, useEffect, useEffectEvent, 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';
|
||||
@@ -19,10 +19,11 @@ 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 './domain/syncPayload';
|
||||
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';
|
||||
@@ -103,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 })),
|
||||
@@ -177,6 +177,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const {
|
||||
setTheme,
|
||||
resolvedTheme,
|
||||
terminalThemeId,
|
||||
setTerminalThemeId,
|
||||
currentTerminalTheme,
|
||||
terminalFontFamilyId,
|
||||
@@ -285,30 +286,48 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const customThemes = useCustomThemes();
|
||||
|
||||
// Resolve the effective TerminalTheme for the currently focused terminal tab
|
||||
const hostById = useMemo(
|
||||
() => new Map(hosts.map((host) => [host.id, host])),
|
||||
[hosts],
|
||||
);
|
||||
const sessionById = useMemo(
|
||||
() => new Map(sessions.map((session) => [session.id, session])),
|
||||
[sessions],
|
||||
);
|
||||
const workspaceById = useMemo(
|
||||
() => new Map(workspaces.map((workspace) => [workspace.id, workspace])),
|
||||
[workspaces],
|
||||
);
|
||||
const themeById = useMemo(
|
||||
() => new Map([...customThemes, ...TERMINAL_THEMES].map((theme) => [theme.id, theme])),
|
||||
[customThemes],
|
||||
);
|
||||
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 host = hostById.get(s.hostId) ?? null;
|
||||
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
|
||||
return TERMINAL_THEMES.find(t => t.id === themeId)
|
||||
|| customThemes.find(t => t.id === themeId)
|
||||
|| currentTerminalTheme;
|
||||
return themeById.get(themeId) || currentTerminalTheme;
|
||||
};
|
||||
|
||||
// Workspace
|
||||
const workspace = workspaces.find(w => w.id === activeTabId);
|
||||
const workspace = workspaceById.get(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));
|
||||
const focused = (workspace.focusedSessionId
|
||||
? sessionById.get(workspace.focusedSessionId)
|
||||
: null)
|
||||
?? wsSessionIds.map((id) => sessionById.get(id)).find(Boolean);
|
||||
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[];
|
||||
const wsSessions = sessionIds
|
||||
.map((id) => sessionById.get(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);
|
||||
@@ -316,10 +335,10 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}
|
||||
|
||||
// Single session tab
|
||||
const session = sessions.find(s => s.id === activeTabId);
|
||||
const session = sessionById.get(activeTabId);
|
||||
if (!session) return null;
|
||||
return resolveTheme(session);
|
||||
}, [activeTabId, sessions, workspaces, hosts, currentTerminalTheme, customThemes]);
|
||||
}, [activeTabId, currentTerminalTheme, hostById, sessionById, themeById, workspaceById]);
|
||||
|
||||
useImmersiveMode({
|
||||
isImmersive: immersiveMode,
|
||||
@@ -373,10 +392,148 @@ 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();
|
||||
const _handleTrayJumpToSession = useEffectEvent((sessionId: string) => {
|
||||
const session = sessions.find((item) => item.id === sessionId);
|
||||
if (session?.workspaceId) {
|
||||
setActiveTabId(session.workspaceId);
|
||||
setWorkspaceFocusedSession(session.workspaceId, sessionId);
|
||||
return;
|
||||
}
|
||||
setActiveTabId(sessionId);
|
||||
});
|
||||
const _handleTrayTogglePortForward = useEffectEvent((ruleId: string, start: boolean) => {
|
||||
const rule = portForwardingRules.find((item) => item.id === ruleId);
|
||||
if (!rule) return;
|
||||
const host = rule.hostId ? hosts.find((item) => item.id === rule.hostId) : undefined;
|
||||
if (!host) {
|
||||
toast.error(t("pf.error.hostNotFound"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (start) {
|
||||
void startTunnel(rule, host, hosts, keys, identities, (status, error) => {
|
||||
if (status === "error" && error) toast.error(error);
|
||||
}, rule.autoStart);
|
||||
return;
|
||||
}
|
||||
|
||||
void stopTunnel(ruleId);
|
||||
});
|
||||
const _handleTrayPanelConnect = useEffectEvent((hostId: string) => {
|
||||
const host = hosts.find((item) => item.id === hostId);
|
||||
if (!host) {
|
||||
toast.error(t("pf.error.hostNotFound"));
|
||||
return;
|
||||
}
|
||||
|
||||
const { username, hostname: localHost } = systemInfoRef.current;
|
||||
if (host.protocol === 'serial') {
|
||||
const portName = host.hostname.split('/').pop() || host.hostname;
|
||||
const sessionId = connectToHost(host);
|
||||
addConnectionLog({
|
||||
sessionId,
|
||||
hostId: host.id,
|
||||
hostLabel: host.label || `Serial: ${portName}`,
|
||||
hostname: host.hostname,
|
||||
username,
|
||||
protocol: 'serial',
|
||||
startTime: Date.now(),
|
||||
localUsername: username,
|
||||
localHostname: localHost,
|
||||
saved: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const protocol = host.moshEnabled ? 'mosh' : (host.protocol || 'ssh');
|
||||
const resolvedAuth = resolveHostAuth({ host, keys, identities });
|
||||
const sessionId = connectToHost(host);
|
||||
addConnectionLog({
|
||||
sessionId,
|
||||
hostId: host.id,
|
||||
hostLabel: host.label,
|
||||
hostname: host.hostname,
|
||||
username: resolvedAuth.username || 'root',
|
||||
protocol: protocol as 'ssh' | 'telnet' | 'local' | 'mosh',
|
||||
startTime: Date.now(),
|
||||
localUsername: username,
|
||||
localHostname: localHost,
|
||||
saved: false,
|
||||
});
|
||||
});
|
||||
const _handleGlobalHotkeyKeyDown = useEffectEvent((e: KeyboardEvent) => {
|
||||
const isMac = hotkeyScheme === 'mac';
|
||||
const target = e.target as HTMLElement;
|
||||
const isFormElement = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
|
||||
const isMonacoElement =
|
||||
target instanceof HTMLElement &&
|
||||
!!target.closest?.('.monaco-editor, .monaco-diff-editor, .monaco-inputbox');
|
||||
const isXtermInput =
|
||||
target instanceof HTMLElement &&
|
||||
!!target.closest?.(".xterm, .xterm-helper-textarea, .xterm-screen, .xterm-viewport");
|
||||
|
||||
if ((isFormElement || isMonacoElement) && !isXtermInput && e.key !== 'Escape') {
|
||||
return;
|
||||
}
|
||||
|
||||
const isTerminalElement =
|
||||
target instanceof HTMLElement &&
|
||||
!!target.closest?.(".xterm, .xterm-helper-textarea, .xterm-screen, .xterm-viewport");
|
||||
const isTerminalInPath = Boolean(
|
||||
e.composedPath?.().some(
|
||||
(node) =>
|
||||
node instanceof HTMLElement &&
|
||||
(node.classList.contains("xterm") ||
|
||||
node.classList.contains("xterm-helper-textarea") ||
|
||||
node.classList.contains("xterm-screen") ||
|
||||
node.classList.contains("xterm-viewport") ||
|
||||
node.hasAttribute("data-session-id")),
|
||||
),
|
||||
);
|
||||
|
||||
for (const binding of keyBindings) {
|
||||
const keyStr = isMac ? binding.mac : binding.pc;
|
||||
if (!matchesKeyBinding(e, keyStr, isMac)) continue;
|
||||
if (HOTKEY_DEBUG) console.log('[Hotkeys] Matched binding:', binding.action, keyStr);
|
||||
if (binding.category === 'sftp') {
|
||||
continue;
|
||||
}
|
||||
const terminalActions = ['copy', 'paste', 'selectAll', 'clearBuffer', 'searchTerminal'];
|
||||
if (terminalActions.includes(binding.action)) {
|
||||
if (isTerminalElement) {
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (HOTKEY_DEBUG) {
|
||||
console.log('[Hotkeys] Global handle', {
|
||||
action: binding.action,
|
||||
key: e.key,
|
||||
meta: e.metaKey,
|
||||
ctrl: e.ctrlKey,
|
||||
alt: e.altKey,
|
||||
shift: e.shiftKey,
|
||||
targetTag: target?.tagName,
|
||||
isTerminalElement,
|
||||
isTerminalInPath,
|
||||
});
|
||||
}
|
||||
executeHotkeyAction(binding.action, e);
|
||||
return;
|
||||
}
|
||||
});
|
||||
const _handleEscapeKeyDown = useEffectEvent((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isQuickSwitcherOpen) {
|
||||
setIsQuickSwitcherOpen(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Show toast notification when update is available (only when auto-download is idle)
|
||||
useEffect(() => {
|
||||
@@ -408,7 +565,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;
|
||||
@@ -431,12 +588,12 @@ 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]);
|
||||
}, [updateState.autoDownloadStatus, updateState.latestRelease?.version, t, installUpdate, openSettingsWindow]);
|
||||
|
||||
// Auto-start port forwarding rules on app launch
|
||||
usePortForwardingAutoStart({
|
||||
@@ -483,110 +640,34 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
if (!bridge?.onTrayFocusSession || !bridge?.onTrayTogglePortForward) return;
|
||||
|
||||
const unsubscribeFocus = bridge.onTrayFocusSession((sessionId) => {
|
||||
// Find the session to check if it belongs to a workspace
|
||||
const session = sessions.find((s) => s.id === sessionId);
|
||||
if (session?.workspaceId) {
|
||||
// Session is in a workspace - navigate to workspace and focus the session
|
||||
setActiveTabId(session.workspaceId);
|
||||
setWorkspaceFocusedSession(session.workspaceId, sessionId);
|
||||
} else {
|
||||
// Standalone session or session not found - just set tab
|
||||
setActiveTabId(sessionId);
|
||||
}
|
||||
_handleTrayJumpToSession(sessionId);
|
||||
});
|
||||
|
||||
const unsubscribeToggle = bridge.onTrayTogglePortForward((ruleId, start) => {
|
||||
const rule = portForwardingRules.find((r) => r.id === ruleId);
|
||||
if (!rule) return;
|
||||
const host = rule.hostId ? hosts.find((h) => h.id === rule.hostId) : undefined;
|
||||
if (!host) {
|
||||
toast.error(t("pf.error.hostNotFound"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (start) {
|
||||
void startTunnel(rule, host, hosts, keys, identities, (status, error) => {
|
||||
if (status === "error" && error) toast.error(error);
|
||||
}, rule.autoStart);
|
||||
} else {
|
||||
void stopTunnel(ruleId);
|
||||
}
|
||||
_handleTrayTogglePortForward(ruleId, start);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeFocus?.();
|
||||
unsubscribeToggle?.();
|
||||
};
|
||||
}, [hosts, identities, keys, portForwardingRules, sessions, setActiveTabId, setWorkspaceFocusedSession, startTunnel, stopTunnel, t]);
|
||||
}, []);
|
||||
|
||||
// Tray panel actions (from main process)
|
||||
useEffect(() => {
|
||||
const handlerJump = (sessionId: string) => {
|
||||
// Find the session to check if it belongs to a workspace
|
||||
const session = sessions.find((s) => s.id === sessionId);
|
||||
if (session?.workspaceId) {
|
||||
// Session is in a workspace - navigate to workspace and focus the session
|
||||
setActiveTabId(session.workspaceId);
|
||||
setWorkspaceFocusedSession(session.workspaceId, sessionId);
|
||||
} else {
|
||||
// Standalone session or session not found - just set tab
|
||||
setActiveTabId(sessionId);
|
||||
}
|
||||
};
|
||||
|
||||
const handlerConnect = (hostId: string) => {
|
||||
const host = hosts.find((h) => h.id === hostId);
|
||||
if (!host) {
|
||||
toast.error(t("pf.error.hostNotFound"));
|
||||
return;
|
||||
}
|
||||
|
||||
const { username, hostname: localHost } = systemInfoRef.current;
|
||||
if (host.protocol === 'serial') {
|
||||
const portName = host.hostname.split('/').pop() || host.hostname;
|
||||
const sessionId = connectToHost(host);
|
||||
addConnectionLog({
|
||||
sessionId,
|
||||
hostId: host.id,
|
||||
hostLabel: host.label || `Serial: ${portName}`,
|
||||
hostname: host.hostname,
|
||||
username,
|
||||
protocol: 'serial',
|
||||
startTime: Date.now(),
|
||||
localUsername: username,
|
||||
localHostname: localHost,
|
||||
saved: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const protocol = host.moshEnabled ? 'mosh' : (host.protocol || 'ssh');
|
||||
const resolvedAuth = resolveHostAuth({ host, keys, identities });
|
||||
const sessionId = connectToHost(host);
|
||||
addConnectionLog({
|
||||
sessionId,
|
||||
hostId: host.id,
|
||||
hostLabel: host.label,
|
||||
hostname: host.hostname,
|
||||
username: resolvedAuth.username || 'root',
|
||||
protocol: protocol as 'ssh' | 'telnet' | 'local' | 'mosh',
|
||||
startTime: Date.now(),
|
||||
localUsername: username,
|
||||
localHostname: localHost,
|
||||
saved: false,
|
||||
});
|
||||
};
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onTrayPanelJumpToSession || !bridge?.onTrayPanelConnectToHost) return;
|
||||
|
||||
const unsubscribeJump = bridge.onTrayPanelJumpToSession(handlerJump);
|
||||
const unsubscribeConnect = bridge.onTrayPanelConnectToHost(handlerConnect);
|
||||
const unsubscribeJump = bridge.onTrayPanelJumpToSession((sessionId) => {
|
||||
_handleTrayJumpToSession(sessionId);
|
||||
});
|
||||
const unsubscribeConnect = bridge.onTrayPanelConnectToHost((hostId) => {
|
||||
_handleTrayPanelConnect(hostId);
|
||||
});
|
||||
return () => {
|
||||
unsubscribeJump?.();
|
||||
unsubscribeConnect?.();
|
||||
};
|
||||
}, [addConnectionLog, connectToHost, hosts, identities, keys, sessions, setActiveTabId, setWorkspaceFocusedSession, t]);
|
||||
}, []);
|
||||
|
||||
// Keyboard-interactive authentication (2FA/MFA) event listener
|
||||
useEffect(() => {
|
||||
@@ -903,96 +984,21 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
useEffect(() => {
|
||||
if (hotkeyScheme === 'disabled' || isHotkeyRecording) return;
|
||||
|
||||
const isMac = hotkeyScheme === 'mac';
|
||||
if (HOTKEY_DEBUG) {
|
||||
console.log('[Hotkeys] Registering global hotkey handler, scheme:', hotkeyScheme, 'bindings count:', keyBindings.length);
|
||||
}
|
||||
|
||||
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
||||
// Don't handle if we're in an input or textarea (except for Escape)
|
||||
// Note: xterm terminal handles its own key interception via attachCustomKeyEventHandler
|
||||
const target = e.target as HTMLElement;
|
||||
const isFormElement = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
|
||||
const isMonacoElement =
|
||||
target instanceof HTMLElement &&
|
||||
!!target.closest?.('.monaco-editor, .monaco-diff-editor, .monaco-inputbox');
|
||||
const isXtermInput =
|
||||
target instanceof HTMLElement &&
|
||||
!!target.closest?.(".xterm, .xterm-helper-textarea, .xterm-screen, .xterm-viewport");
|
||||
|
||||
// Monaco is not always contentEditable/input, so treat it as an editor surface.
|
||||
if ((isFormElement || isMonacoElement) && !isXtermInput && e.key !== 'Escape') {
|
||||
return;
|
||||
}
|
||||
|
||||
const isTerminalElement =
|
||||
target instanceof HTMLElement &&
|
||||
!!target.closest?.(".xterm, .xterm-helper-textarea, .xterm-screen, .xterm-viewport");
|
||||
const isTerminalInPath = Boolean(
|
||||
e.composedPath?.().some(
|
||||
(node) =>
|
||||
node instanceof HTMLElement &&
|
||||
(node.classList.contains("xterm") ||
|
||||
node.classList.contains("xterm-helper-textarea") ||
|
||||
node.classList.contains("xterm-screen") ||
|
||||
node.classList.contains("xterm-viewport") ||
|
||||
node.hasAttribute("data-session-id")),
|
||||
),
|
||||
);
|
||||
|
||||
// Check each key binding
|
||||
for (const binding of keyBindings) {
|
||||
const keyStr = isMac ? binding.mac : binding.pc;
|
||||
if (matchesKeyBinding(e, keyStr, isMac)) {
|
||||
if (HOTKEY_DEBUG) console.log('[Hotkeys] Matched binding:', binding.action, keyStr);
|
||||
// SFTP shortcuts are handled by SFTP-specific hooks.
|
||||
if (binding.category === 'sftp') {
|
||||
continue;
|
||||
}
|
||||
// Terminal-specific actions should be handled by the terminal
|
||||
// Don't handle them at app level
|
||||
const terminalActions = ['copy', 'paste', 'selectAll', 'clearBuffer', 'searchTerminal'];
|
||||
if (terminalActions.includes(binding.action)) {
|
||||
if (isTerminalElement) {
|
||||
return; // Let terminal handle it
|
||||
}
|
||||
continue; // Ignore terminal actions outside terminal
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (HOTKEY_DEBUG) {
|
||||
console.log('[Hotkeys] Global handle', {
|
||||
action: binding.action,
|
||||
key: e.key,
|
||||
meta: e.metaKey,
|
||||
ctrl: e.ctrlKey,
|
||||
alt: e.altKey,
|
||||
shift: e.shiftKey,
|
||||
targetTag: target?.tagName,
|
||||
isTerminalElement,
|
||||
isTerminalInPath,
|
||||
});
|
||||
}
|
||||
executeHotkeyAction(binding.action, e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
_handleGlobalHotkeyKeyDown(e);
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleGlobalKeyDown, true);
|
||||
return () => window.removeEventListener('keydown', handleGlobalKeyDown, true);
|
||||
}, [hotkeyScheme, keyBindings, isHotkeyRecording, executeHotkeyAction]);
|
||||
}, [hotkeyScheme, isHotkeyRecording]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isQuickSwitcherOpen) {
|
||||
setIsQuickSwitcherOpen(false);
|
||||
}
|
||||
_handleEscapeKeyDown(e);
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [isQuickSwitcherOpen]);
|
||||
}, []);
|
||||
|
||||
const quickResults = useMemo(() => {
|
||||
if (!isQuickSwitcherOpen) return [];
|
||||
@@ -1005,7 +1011,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
)
|
||||
: hosts;
|
||||
return filtered;
|
||||
}, [hosts, quickSearch, isQuickSwitcherOpen]);
|
||||
}, [quickSearch, hosts, isQuickSwitcherOpen]);
|
||||
|
||||
const handleDeleteHost = useCallback((hostId: string) => {
|
||||
const target = hosts.find(h => h.id === hostId);
|
||||
@@ -1094,10 +1100,10 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}, [addConnectionLog, connectToHost, identities, keys]);
|
||||
|
||||
// Wrapper to create serial session with logging
|
||||
const handleConnectSerial = useCallback((config: SerialConfig) => {
|
||||
const handleConnectSerial = useCallback((config: SerialConfig, options?: { charset?: string }) => {
|
||||
const { username, hostname } = systemInfoRef.current;
|
||||
const portName = config.path.split('/').pop() || config.path;
|
||||
const sessionId = createSerialSession(config);
|
||||
const sessionId = createSerialSession(config, options);
|
||||
addConnectionLog({
|
||||
sessionId,
|
||||
hostId: '',
|
||||
@@ -1304,6 +1310,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
sessions={sessions}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
terminalThemeId={terminalThemeId}
|
||||
terminalFontSize={terminalFontSize}
|
||||
onOpenSettings={handleOpenSettings}
|
||||
onOpenQuickSwitcher={handleOpenQuickSwitcher}
|
||||
onCreateLocalTerminal={handleCreateLocalTerminal}
|
||||
@@ -1332,7 +1340,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}
|
||||
|
||||
@@ -355,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',
|
||||
@@ -604,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',
|
||||
@@ -914,6 +925,10 @@ const en: Messages = {
|
||||
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent is not available',
|
||||
'hostDetails.agentForwarding.agentNotRunningHint': 'No SSH agent detected. Enable OpenSSH Authentication Agent in Windows Services, or use a compatible agent such as Bitwarden, 1Password, or gpg-agent.',
|
||||
'hostDetails.section.agentForwarding': 'SSH Agent',
|
||||
'hostDetails.section.deviceType': 'Device Type',
|
||||
'hostDetails.deviceType': 'Network Device Mode',
|
||||
'hostDetails.deviceType.desc': 'Enable for network equipment (switches, routers, firewalls) connected via SSH. Commands are sent as-is without shell wrapping, compatible with vendor CLIs like Huawei VRP and Cisco IOS.',
|
||||
'hostDetails.deviceType.warning': 'AI agent commands will be sent directly without exit code tracking. Only enable for devices that do not run a standard shell.',
|
||||
'hostDetails.section.legacyAlgorithms': 'Legacy Algorithms',
|
||||
'hostDetails.legacyAlgorithms': 'Allow Legacy Algorithms',
|
||||
'hostDetails.legacyAlgorithms.desc': 'Enable deprecated SSH algorithms (diffie-hellman-group1, ssh-dss, 3des-cbc, etc.) for connecting to older network equipment.',
|
||||
@@ -1522,6 +1537,7 @@ const en: Messages = {
|
||||
'serial.field.localEchoDesc': 'Echo typed characters locally (for devices without remote echo)',
|
||||
'serial.field.lineMode': 'Line Mode',
|
||||
'serial.field.lineModeDesc': 'Buffer input and send on Enter (instead of character-by-character)',
|
||||
'serial.field.charset': 'Charset',
|
||||
'serial.connectionError': 'Failed to connect to serial port',
|
||||
'serial.field.baudRatePlaceholder': 'Select or enter baud rate...',
|
||||
'serial.field.baudRateEmpty': 'Enter a custom baud rate',
|
||||
@@ -1588,6 +1604,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',
|
||||
|
||||
@@ -428,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': '修改时间',
|
||||
@@ -602,6 +604,10 @@ const zhCN: Messages = {
|
||||
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent 不可用',
|
||||
'hostDetails.agentForwarding.agentNotRunningHint': '未检测到 SSH Agent。请启用 Windows OpenSSH Authentication Agent 服务,或使用兼容的 Agent(如 Bitwarden、1Password、gpg-agent)。',
|
||||
'hostDetails.section.agentForwarding': 'SSH 代理',
|
||||
'hostDetails.section.deviceType': '设备类型',
|
||||
'hostDetails.deviceType': '网络设备模式',
|
||||
'hostDetails.deviceType.desc': '适用于通过 SSH 连接的网络设备(交换机、路由器、防火墙)。命令将原样发送,不进行 Shell 包装,兼容华为 VRP、Cisco IOS 等厂商 CLI。',
|
||||
'hostDetails.deviceType.warning': 'AI 代理命令将直接发送,无法获取退出码。仅建议在设备不运行标准 Shell 时启用。',
|
||||
'hostDetails.section.legacyAlgorithms': '旧版算法',
|
||||
'hostDetails.legacyAlgorithms': '允许旧版算法',
|
||||
'hostDetails.legacyAlgorithms.desc': '启用已弃用的 SSH 算法(diffie-hellman-group1、ssh-dss、3des-cbc 等)以连接老旧网络设备。',
|
||||
@@ -1266,6 +1272,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': '键盘快捷键',
|
||||
@@ -1536,6 +1551,7 @@ const zhCN: Messages = {
|
||||
'serial.field.localEchoDesc': '本地回显输入字符(用于没有远程回显的设备)',
|
||||
'serial.field.lineMode': '行模式',
|
||||
'serial.field.lineModeDesc': '缓冲输入,按回车后发送(而不是逐字符发送)',
|
||||
'serial.field.charset': '字符编码',
|
||||
'serial.connectionError': '连接串口失败',
|
||||
'serial.field.baudRatePlaceholder': '选择或输入波特率...',
|
||||
'serial.field.baudRateEmpty': '输入自定义波特率',
|
||||
@@ -1602,6 +1618,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),
|
||||
};
|
||||
@@ -6,6 +6,7 @@ type Listener = () => void;
|
||||
class ActiveTabStore {
|
||||
private activeTabId: string = 'vault';
|
||||
private listeners = new Set<Listener>();
|
||||
private pendingNotify = false;
|
||||
|
||||
getActiveTabId = () => this.activeTabId;
|
||||
|
||||
@@ -13,7 +14,10 @@ class ActiveTabStore {
|
||||
if (this.activeTabId !== id) {
|
||||
this.activeTabId = id;
|
||||
// Defer listener notification to avoid "setState during render" if called from a render phase
|
||||
if (this.pendingNotify) return;
|
||||
this.pendingNotify = true;
|
||||
Promise.resolve().then(() => {
|
||||
this.pendingNotify = false;
|
||||
this.listeners.forEach(listener => listener());
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
};
|
||||
@@ -496,32 +496,39 @@ export const useSftpConnections = ({
|
||||
!initialConnectDoneRef.current &&
|
||||
leftTabs.tabs.length === 0
|
||||
) {
|
||||
initialConnectDoneRef.current = true;
|
||||
setTimeout(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
initialConnectDoneRef.current = true;
|
||||
connect("left", "local");
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}
|
||||
}, [autoConnectLocalOnMount, connect, leftTabs.tabs.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const attemptReconnect = async (side: "left" | "right") => {
|
||||
const reconnectTimers: number[] = [];
|
||||
|
||||
const scheduleReconnect = (side: "left" | "right") => {
|
||||
const lastHost = lastConnectedHostRef.current[side];
|
||||
if (lastHost && reconnectingRef.current[side]) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
if (reconnectingRef.current[side]) {
|
||||
connect(side, lastHost);
|
||||
}
|
||||
}
|
||||
if (!lastHost || !reconnectingRef.current[side]) return;
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
if (!reconnectingRef.current[side]) return;
|
||||
void connect(side, lastHost);
|
||||
}, 1000);
|
||||
reconnectTimers.push(timer);
|
||||
};
|
||||
|
||||
if (leftPane.reconnecting && reconnectingRef.current.left) {
|
||||
attemptReconnect("left");
|
||||
scheduleReconnect("left");
|
||||
}
|
||||
if (rightPane.reconnecting && reconnectingRef.current.right) {
|
||||
attemptReconnect("right");
|
||||
scheduleReconnect("right");
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [leftPane.reconnecting, rightPane.reconnecting, connect]);
|
||||
|
||||
return () => {
|
||||
reconnectTimers.forEach((timer) => window.clearTimeout(timer));
|
||||
};
|
||||
}, [leftPane.reconnecting, rightPane.reconnecting, connect, lastConnectedHostRef, reconnectingRef]);
|
||||
|
||||
const disconnect = useCallback(
|
||||
async (side: "left" | "right") => {
|
||||
|
||||
@@ -47,27 +47,63 @@ function cleanupAcpSessions(sessionIds: string[]) {
|
||||
}
|
||||
}
|
||||
|
||||
function isScopeKeyActive(scopeKey: string, activeTargetIds: Set<string>) {
|
||||
const separatorIndex = scopeKey.indexOf(':');
|
||||
if (separatorIndex === -1) return true;
|
||||
|
||||
const targetId = scopeKey.slice(separatorIndex + 1);
|
||||
if (!targetId) return true;
|
||||
|
||||
return activeTargetIds.has(targetId);
|
||||
}
|
||||
|
||||
export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
const currentSessions = latestAISessionsSnapshot
|
||||
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
||||
?? [];
|
||||
const removedSessionIds = currentSessions
|
||||
const orphanedSessionIds = currentSessions
|
||||
.filter((session) => session.scope.targetId && !activeTargetIds.has(session.scope.targetId))
|
||||
.map((session) => session.id);
|
||||
|
||||
if (removedSessionIds.length === 0) return;
|
||||
if (orphanedSessionIds.length > 0) {
|
||||
const orphanedSessionIdSet = new Set(orphanedSessionIds);
|
||||
|
||||
cleanupAcpSessions(removedSessionIds);
|
||||
// Determine which sessions can be restored via host-based matching
|
||||
const preservedIds = new Set<string>();
|
||||
for (const session of currentSessions) {
|
||||
if (!orphanedSessionIdSet.has(session.id)) continue;
|
||||
// Only preserve remote terminal sessions with real hostIds
|
||||
const isRestorable = session.scope.type === 'terminal'
|
||||
&& session.scope.hostIds?.length
|
||||
&& session.scope.hostIds.some((id) => !id.startsWith('local-') && !id.startsWith('serial-'));
|
||||
if (isRestorable) {
|
||||
preservedIds.add(session.id);
|
||||
}
|
||||
}
|
||||
|
||||
const removedSessionIdSet = new Set(removedSessionIds);
|
||||
// Cleanup ACP sessions for all orphans (both deleted and preserved).
|
||||
// Preserved sessions will get a new externalSessionId on next use,
|
||||
// so cleaning the old one is safe and prevents subprocess leaks.
|
||||
cleanupAcpSessions(orphanedSessionIds);
|
||||
|
||||
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 nextSessions = currentSessions
|
||||
.filter((session) => !orphanedSessionIdSet.has(session.id) || preservedIds.has(session.id))
|
||||
.map((session) => {
|
||||
if (!preservedIds.has(session.id) || !session.externalSessionId) {
|
||||
return session;
|
||||
}
|
||||
// Drop transient ACP session handles so the next turn starts cleanly.
|
||||
return { ...session, externalSessionId: undefined };
|
||||
});
|
||||
|
||||
const sessionsChanged = nextSessions.length !== currentSessions.length
|
||||
|| nextSessions.some((session, index) => session !== currentSessions[index]);
|
||||
if (sessionsChanged) {
|
||||
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)
|
||||
@@ -75,11 +111,10 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
let activeSessionMapChanged = false;
|
||||
const nextActiveSessionIdMap = { ...activeSessionIdMap };
|
||||
|
||||
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
|
||||
if (sessionId && removedSessionIdSet.has(sessionId)) {
|
||||
nextActiveSessionIdMap[scopeKey] = null;
|
||||
activeSessionMapChanged = true;
|
||||
}
|
||||
for (const scopeKey of Object.keys(activeSessionIdMap)) {
|
||||
if (isScopeKeyActive(scopeKey, activeTargetIds)) continue;
|
||||
delete nextActiveSessionIdMap[scopeKey];
|
||||
activeSessionMapChanged = true;
|
||||
}
|
||||
|
||||
if (activeSessionMapChanged) {
|
||||
@@ -126,6 +161,19 @@ function setLatestAIActiveSessionMapSnapshot(activeSessionIdMap: Record<string,
|
||||
latestAIActiveSessionMapSnapshot = activeSessionIdMap;
|
||||
}
|
||||
|
||||
function buildScopeKey(scope: AISessionScope) {
|
||||
return `${scope.type}:${scope.targetId ?? ''}`;
|
||||
}
|
||||
|
||||
function areHostIdsEqual(left?: string[], right?: string[]) {
|
||||
const leftIds = left ?? [];
|
||||
const rightIds = right ?? [];
|
||||
if (leftIds.length !== rightIds.length) return false;
|
||||
|
||||
const rightSet = new Set(rightIds);
|
||||
return leftIds.every((hostId) => rightSet.has(hostId));
|
||||
}
|
||||
|
||||
export function useAIState() {
|
||||
// ── Provider Config ──
|
||||
const [providers, setProvidersRaw] = useState<ProviderConfig[]>(() =>
|
||||
@@ -598,6 +646,61 @@ export function useAIState() {
|
||||
});
|
||||
}, [debouncedPersistSessions]);
|
||||
|
||||
const retargetSessionScope = useCallback((sessionId: string, scope: AISessionScope) => {
|
||||
const currentSession = sessionsRef.current.find((session) => session.id === sessionId);
|
||||
if (!currentSession) return;
|
||||
|
||||
const currentScope = currentSession.scope;
|
||||
const scopeChanged =
|
||||
currentScope.type !== scope.type
|
||||
|| currentScope.targetId !== scope.targetId
|
||||
|| !areHostIdsEqual(currentScope.hostIds, scope.hostIds);
|
||||
|
||||
const nextScopeKey = buildScopeKey(scope);
|
||||
const currentScopeKey = buildScopeKey(currentScope);
|
||||
|
||||
if (scopeChanged) {
|
||||
setSessionsRaw((prev) => {
|
||||
let changed = false;
|
||||
const next = prev.map((session) => {
|
||||
if (session.id !== sessionId) return session;
|
||||
changed = true;
|
||||
// Clear stale ACP handle — retarget may run before orphan cleanup
|
||||
return { ...session, scope, externalSessionId: undefined };
|
||||
});
|
||||
|
||||
if (!changed) return prev;
|
||||
|
||||
sessionsRef.current = next;
|
||||
setLatestAISessionsSnapshot(next);
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
setActiveSessionIdMapRaw((prev) => {
|
||||
let changed = false;
|
||||
const next = { ...prev };
|
||||
|
||||
if (currentScopeKey !== nextScopeKey && next[currentScopeKey] === sessionId) {
|
||||
delete next[currentScopeKey];
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (next[nextScopeKey] !== sessionId) {
|
||||
next[nextScopeKey] = sessionId;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) return prev;
|
||||
|
||||
setLatestAIActiveSessionMapSnapshot(next);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
return next;
|
||||
});
|
||||
}, [persistSessions]);
|
||||
|
||||
// Maximum messages per session to prevent unbounded memory growth
|
||||
const MAX_MESSAGES_PER_SESSION = 500;
|
||||
|
||||
@@ -750,6 +853,7 @@ export function useAIState() {
|
||||
deleteSessionsByTarget,
|
||||
updateSessionTitle,
|
||||
updateSessionExternalSessionId,
|
||||
retargetSessionScope,
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
updateMessageById,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* Uses useSyncExternalStore for real-time state synchronization across all components.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
|
||||
import {
|
||||
type CloudProvider,
|
||||
type SecurityState,
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
isProviderReadyForSync,
|
||||
} from '../../domain/sync';
|
||||
import {
|
||||
CloudSyncManager,
|
||||
getCloudSyncManager,
|
||||
type SyncManagerState,
|
||||
} from '../../infrastructure/services/CloudSyncManager';
|
||||
@@ -83,7 +82,8 @@ export interface CloudSyncHook {
|
||||
redirectUri: string
|
||||
) => Promise<void>;
|
||||
disconnectProvider: (provider: CloudProvider) => Promise<void>;
|
||||
|
||||
resetProviderStatus: (provider: CloudProvider) => void;
|
||||
|
||||
// Sync Actions
|
||||
syncNow: (payload: SyncPayload) => Promise<Map<CloudProvider, SyncResult>>;
|
||||
syncToProvider: (provider: CloudProvider, payload: SyncPayload) => Promise<SyncResult>;
|
||||
@@ -103,12 +103,6 @@ export interface CloudSyncHook {
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
export interface GitHubAuthState {
|
||||
isAuthenticating: boolean;
|
||||
deviceFlowState: DeviceFlowState | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hook Implementation
|
||||
// ============================================================================
|
||||
@@ -127,17 +121,6 @@ const getSnapshot = (): SyncManagerState => {
|
||||
};
|
||||
|
||||
export const useCloudSync = (): CloudSyncHook => {
|
||||
// Force update mechanism to ensure React re-renders
|
||||
const [, forceUpdate] = useState(0);
|
||||
|
||||
// Subscribe to state changes and force update
|
||||
useEffect(() => {
|
||||
const unsubscribe = manager.subscribeToStateChanges(() => {
|
||||
forceUpdate(n => n + 1);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
// Use useSyncExternalStore for real-time state sync across all components
|
||||
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
|
||||
@@ -273,7 +256,7 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
throw new Error('Unexpected auth type');
|
||||
}
|
||||
const data = result.data as { url: string; redirectUri: string };
|
||||
|
||||
|
||||
// Start OAuth callback server in Electron and wait for authorization
|
||||
const bridge = netcattyBridge.get();
|
||||
const startCallback = bridge?.startOAuthCallback;
|
||||
@@ -281,32 +264,48 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
// Get state from adapter for CSRF protection
|
||||
const adapter = manager.getAdapter('google') as { getPKCEState?: () => string | null } | undefined;
|
||||
const expectedState = adapter?.getPKCEState?.() || undefined;
|
||||
|
||||
|
||||
// Start callback server and open browser
|
||||
const callbackPromise = startCallback(expectedState);
|
||||
|
||||
// Open browser after starting server
|
||||
setTimeout(() => {
|
||||
window.open(data.url, "_blank", "width=600,height=700,noopener,noreferrer");
|
||||
|
||||
// Open browser after starting server — omit noopener/noreferrer so we can track the popup
|
||||
let popup: Window | null = null;
|
||||
let popupPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
const openTimer = setTimeout(() => {
|
||||
popup = window.open(data.url, "_blank", "width=600,height=700");
|
||||
// Poll for popup closure — if user closes it, cancel the OAuth flow
|
||||
if (popup) {
|
||||
popupPollTimer = setInterval(() => {
|
||||
if (popup?.closed) {
|
||||
if (popupPollTimer) clearInterval(popupPollTimer);
|
||||
bridge?.cancelOAuthCallback?.();
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Wait for callback
|
||||
const { code } = await callbackPromise;
|
||||
|
||||
// Complete auth with the received code
|
||||
await manager.completePKCEAuth('google', code, data.redirectUri);
|
||||
|
||||
try {
|
||||
// Wait for callback
|
||||
const { code } = await callbackPromise;
|
||||
|
||||
// Complete auth with the received code
|
||||
await manager.completePKCEAuth('google', code, data.redirectUri);
|
||||
} finally {
|
||||
clearTimeout(openTimer);
|
||||
if (popupPollTimer) clearInterval(popupPollTimer);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return data.url;
|
||||
}, []);
|
||||
|
||||
|
||||
const connectOneDrive = useCallback(async (): Promise<string> => {
|
||||
const result = await manager.startProviderAuth('onedrive');
|
||||
if (result.type !== 'url') {
|
||||
throw new Error('Unexpected auth type');
|
||||
}
|
||||
const data = result.data as { url: string; redirectUri: string };
|
||||
|
||||
|
||||
// Start OAuth callback server in Electron and wait for authorization
|
||||
const bridge = netcattyBridge.get();
|
||||
const startCallback = bridge?.startOAuthCallback;
|
||||
@@ -314,22 +313,38 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
// Get state from adapter for CSRF protection
|
||||
const adapter = manager.getAdapter('onedrive') as { getPKCEState?: () => string | null } | undefined;
|
||||
const expectedState = adapter?.getPKCEState?.() || undefined;
|
||||
|
||||
|
||||
// Start callback server and open browser
|
||||
const callbackPromise = startCallback(expectedState);
|
||||
|
||||
// Open browser after starting server
|
||||
setTimeout(() => {
|
||||
window.open(data.url, "_blank", "width=600,height=700,noopener,noreferrer");
|
||||
|
||||
// Open browser after starting server — omit noopener/noreferrer so we can track the popup
|
||||
let popup: Window | null = null;
|
||||
let popupPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
const openTimer = setTimeout(() => {
|
||||
popup = window.open(data.url, "_blank", "width=600,height=700");
|
||||
// Poll for popup closure — if user closes it, cancel the OAuth flow
|
||||
if (popup) {
|
||||
popupPollTimer = setInterval(() => {
|
||||
if (popup?.closed) {
|
||||
if (popupPollTimer) clearInterval(popupPollTimer);
|
||||
bridge?.cancelOAuthCallback?.();
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Wait for callback
|
||||
const { code } = await callbackPromise;
|
||||
|
||||
// Complete auth with the received code
|
||||
await manager.completePKCEAuth('onedrive', code, data.redirectUri);
|
||||
|
||||
try {
|
||||
// Wait for callback
|
||||
const { code } = await callbackPromise;
|
||||
|
||||
// Complete auth with the received code
|
||||
await manager.completePKCEAuth('onedrive', code, data.redirectUri);
|
||||
} finally {
|
||||
clearTimeout(openTimer);
|
||||
if (popupPollTimer) clearInterval(popupPollTimer);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return data.url;
|
||||
}, []);
|
||||
|
||||
@@ -345,6 +360,10 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
await manager.disconnectProvider(provider);
|
||||
}, []);
|
||||
|
||||
const resetProviderStatus = useCallback((provider: CloudProvider): void => {
|
||||
manager.resetProviderStatus(provider);
|
||||
}, []);
|
||||
|
||||
const connectWebDAV = useCallback(async (config: WebDAVConfig): Promise<void> => {
|
||||
await manager.connectConfigProvider('webdav', config);
|
||||
}, []);
|
||||
@@ -451,7 +470,8 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
connectS3,
|
||||
completePKCEAuth,
|
||||
disconnectProvider,
|
||||
|
||||
resetProviderStatus,
|
||||
|
||||
// Sync Actions
|
||||
syncNow: syncNowWithUnlock,
|
||||
syncToProvider: syncToProviderWithUnlock,
|
||||
@@ -472,60 +492,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;
|
||||
|
||||
@@ -58,7 +58,7 @@ export const useSessionState = () => {
|
||||
return sessionId;
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const createSerialSession = useCallback((config: SerialConfig) => {
|
||||
const createSerialSession = useCallback((config: SerialConfig, options?: { charset?: string }) => {
|
||||
const sessionId = crypto.randomUUID();
|
||||
const serialHostId = `serial-${sessionId}`;
|
||||
const portName = config.path.split('/').pop() || config.path;
|
||||
@@ -71,6 +71,7 @@ export const useSessionState = () => {
|
||||
status: 'connecting',
|
||||
protocol: 'serial',
|
||||
serialConfig: config,
|
||||
charset: options?.charset,
|
||||
};
|
||||
setSessions(prev => [...prev, newSession]);
|
||||
setActiveTabId(sessionId);
|
||||
@@ -103,6 +104,7 @@ export const useSessionState = () => {
|
||||
status: 'connecting',
|
||||
protocol: 'serial',
|
||||
serialConfig: serialConfig,
|
||||
charset: host.charset,
|
||||
};
|
||||
setSessions(prev => [...prev, newSession]);
|
||||
setActiveTabId(sessionId);
|
||||
@@ -120,6 +122,7 @@ export const useSessionState = () => {
|
||||
protocol: host.protocol,
|
||||
port: host.port,
|
||||
moshEnabled: host.moshEnabled,
|
||||
charset: host.charset,
|
||||
};
|
||||
setSessions(prev => [...prev, newSession]);
|
||||
setActiveTabId(newSession.id);
|
||||
@@ -321,6 +324,7 @@ export const useSessionState = () => {
|
||||
status: 'connecting',
|
||||
protocol: 'serial',
|
||||
serialConfig: serialConfig,
|
||||
charset: host.charset,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -334,6 +338,7 @@ export const useSessionState = () => {
|
||||
protocol: host.protocol,
|
||||
port: host.port,
|
||||
moshEnabled: host.moshEnabled,
|
||||
charset: host.charset,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -445,8 +450,9 @@ export const useSessionState = () => {
|
||||
port: session.port,
|
||||
moshEnabled: session.moshEnabled,
|
||||
shellType: nextShellType,
|
||||
charset: session.charset,
|
||||
};
|
||||
|
||||
|
||||
// Add pane to existing workspace
|
||||
const hint: SplitHint = {
|
||||
direction,
|
||||
@@ -476,13 +482,14 @@ export const useSessionState = () => {
|
||||
port: session.port,
|
||||
moshEnabled: session.moshEnabled,
|
||||
shellType: nextShellType,
|
||||
charset: session.charset,
|
||||
};
|
||||
|
||||
|
||||
const hint: SplitHint = {
|
||||
direction,
|
||||
position: direction === 'horizontal' ? 'bottom' : 'right',
|
||||
};
|
||||
|
||||
|
||||
const newWorkspace = createWorkspaceEntity(sessionId, newSession.id, hint);
|
||||
setWorkspaces(prev => [...prev, newWorkspace]);
|
||||
setActiveTabId(newWorkspace.id);
|
||||
@@ -563,6 +570,7 @@ export const useSessionState = () => {
|
||||
hostname: host.hostname,
|
||||
username: host.username,
|
||||
status: 'connecting' as const,
|
||||
charset: host.charset,
|
||||
// workspaceId will be set after workspace is created
|
||||
}));
|
||||
|
||||
@@ -649,6 +657,7 @@ export const useSessionState = () => {
|
||||
port: session.port,
|
||||
moshEnabled: session.moshEnabled,
|
||||
shellType: nextShellType,
|
||||
charset: session.charset,
|
||||
serialConfig: session.serialConfig,
|
||||
};
|
||||
|
||||
@@ -682,9 +691,11 @@ export const useSessionState = () => {
|
||||
...workspaces.map(w => w.id),
|
||||
...logViews.map(lv => lv.id),
|
||||
];
|
||||
const allTabIdSet = new Set(allTabIds);
|
||||
// Filter tabOrder to only include existing tabs, then add any new tabs at the end
|
||||
const orderedIds = tabOrder.filter(id => allTabIds.includes(id));
|
||||
const newIds = allTabIds.filter(id => !orderedIds.includes(id));
|
||||
const orderedIds = tabOrder.filter(id => allTabIdSet.has(id));
|
||||
const orderedIdSet = new Set(orderedIds);
|
||||
const newIds = allTabIds.filter(id => !orderedIdSet.has(id));
|
||||
return [...orderedIds, ...newIds];
|
||||
}, [orphanSessions, workspaces, logViews, tabOrder]);
|
||||
|
||||
@@ -698,10 +709,12 @@ export const useSessionState = () => {
|
||||
...workspaces.map(w => w.id),
|
||||
...logViews.map(lv => lv.id),
|
||||
];
|
||||
const allTabIdSet = new Set(allTabIds);
|
||||
|
||||
// Build current effective order: existing order + new tabs at end
|
||||
const orderedIds = prevTabOrder.filter(id => allTabIds.includes(id));
|
||||
const newIds = allTabIds.filter(id => !orderedIds.includes(id));
|
||||
const orderedIds = prevTabOrder.filter(id => allTabIdSet.has(id));
|
||||
const orderedIdSet = new Set(orderedIds);
|
||||
const newIds = allTabIds.filter(id => !orderedIdSet.has(id));
|
||||
const currentOrder = [...orderedIds, ...newIds];
|
||||
|
||||
const draggedIndex = currentOrder.indexOf(draggedId);
|
||||
|
||||
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,
|
||||
@@ -79,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;
|
||||
|
||||
/**
|
||||
@@ -56,6 +56,7 @@ interface AIChatSidePanelProps {
|
||||
deleteSession: (sessionId: string, scopeKey?: string) => void;
|
||||
updateSessionTitle: (sessionId: string, title: string) => void;
|
||||
updateSessionExternalSessionId: (sessionId: string, externalSessionId: string | undefined) => void;
|
||||
retargetSessionScope: (sessionId: string, scope: AISessionScope) => void;
|
||||
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
|
||||
updateLastMessage: (
|
||||
sessionId: string,
|
||||
@@ -103,6 +104,7 @@ interface AIChatSidePanelProps {
|
||||
username?: string;
|
||||
protocol?: string;
|
||||
shellType?: string;
|
||||
deviceType?: string;
|
||||
connected: boolean;
|
||||
}>;
|
||||
resolveExecutorContext?: (scope: {
|
||||
@@ -152,6 +154,27 @@ function buildAcpHistoryMessages(messages: ChatMessage[]): Array<{ role: 'user'
|
||||
});
|
||||
}
|
||||
|
||||
function getSessionScopeMatchRank(
|
||||
session: AISession,
|
||||
scopeType: 'terminal' | 'workspace',
|
||||
scopeTargetId?: string,
|
||||
scopeHostIds?: string[],
|
||||
activeTerminalTargetIds?: Set<string>,
|
||||
): number {
|
||||
if (session.scope.type !== scopeType) return 0;
|
||||
if (session.scope.targetId === scopeTargetId) return 2;
|
||||
|
||||
if (scopeType !== 'terminal' || !scopeHostIds?.length || !session.scope.hostIds?.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (session.scope.targetId && activeTerminalTargetIds?.has(session.scope.targetId)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return session.scope.hostIds.some((hostId) => scopeHostIds.includes(hostId)) ? 1 : 0;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Component
|
||||
// -------------------------------------------------------------------
|
||||
@@ -164,6 +187,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
deleteSession,
|
||||
updateSessionTitle,
|
||||
updateSessionExternalSessionId,
|
||||
retargetSessionScope,
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
updateMessageById,
|
||||
@@ -227,21 +251,115 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
|
||||
|
||||
// Per-scope active session ID
|
||||
const activeSessionId = activeSessionIdMap[scopeKey] ?? null;
|
||||
const isStreaming = activeSessionId ? streamingSessionIds.has(activeSessionId) : false;
|
||||
const activeSessionIdForScope = activeSessionIdMap[scopeKey] ?? null;
|
||||
const setActiveSessionId = useCallback((id: string | null) => {
|
||||
setActiveSessionIdForScope(scopeKey, id);
|
||||
}, [scopeKey, setActiveSessionIdForScope]);
|
||||
|
||||
// Restore agent selector from active session when scope changes
|
||||
useEffect(() => {
|
||||
if (activeSessionId) {
|
||||
const session = sessions.find((s) => s.id === activeSessionId);
|
||||
if (session) {
|
||||
setCurrentAgentId(session.agentId);
|
||||
const activeTerminalTargetIds = useMemo(() => {
|
||||
const targetIds = new Set<string>();
|
||||
for (const [sessionScopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
|
||||
if (!sessionScopeKey.startsWith('terminal:') || !sessionId) continue;
|
||||
const targetId = sessionScopeKey.slice('terminal:'.length);
|
||||
if (!targetId || targetId === scopeTargetId) continue;
|
||||
targetIds.add(targetId);
|
||||
}
|
||||
return targetIds;
|
||||
}, [activeSessionIdMap, scopeTargetId]);
|
||||
|
||||
const historySessions = useMemo(
|
||||
() =>
|
||||
sessions
|
||||
.map((session) => ({
|
||||
session,
|
||||
matchRank: getSessionScopeMatchRank(session, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds),
|
||||
}))
|
||||
.filter(({ matchRank }) => matchRank > 0)
|
||||
.sort((a, b) => b.matchRank - a.matchRank || b.session.updatedAt - a.session.updatedAt)
|
||||
.map(({ session }) => session),
|
||||
[sessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds],
|
||||
);
|
||||
|
||||
const activeSession = useMemo(() => {
|
||||
if (activeSessionIdForScope) {
|
||||
const session = sessions.find((s) => s.id === activeSessionIdForScope);
|
||||
if (session && getSessionScopeMatchRank(session, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds) > 0) {
|
||||
return session;
|
||||
}
|
||||
}
|
||||
}, [scopeKey, activeSessionId, sessions]);
|
||||
return historySessions[0] ?? null;
|
||||
}, [sessions, activeSessionIdForScope, historySessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds]);
|
||||
|
||||
const activeSessionId = activeSession?.id ?? activeSessionIdForScope;
|
||||
const isStreaming = activeSessionId ? streamingSessionIds.has(activeSessionId) : false;
|
||||
|
||||
const shouldRetargetActiveSession = useMemo(() => {
|
||||
if (!activeSession || scopeType !== 'terminal' || !scopeTargetId || !scopeHostIds?.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (activeSession.scope.type !== scopeType || activeSession.scope.targetId === scopeTargetId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't retarget sessions that are actively owned by another terminal
|
||||
if (activeSession.scope.targetId && activeTerminalTargetIds.has(activeSession.scope.targetId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return activeSession.scope.hostIds?.some((hostId) => scopeHostIds.includes(hostId)) ?? false;
|
||||
}, [activeSession, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSession) return;
|
||||
|
||||
if (shouldRetargetActiveSession && isVisible) {
|
||||
// Full cleanup of any in-flight work — the session came from a disconnected
|
||||
// terminal, so any active response, pending approvals, or exec is dead.
|
||||
if (streamingSessionIds.has(activeSession.id)) {
|
||||
const controller = abortControllersRef.current.get(activeSession.id);
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
abortControllersRef.current.delete(activeSession.id);
|
||||
}
|
||||
setStreamingForScope(activeSession.id, false);
|
||||
clearAllPendingApprovals(activeSession.id);
|
||||
const bridge = getNetcattyBridge();
|
||||
bridge?.aiCattyCancelExec?.(activeSession.id);
|
||||
bridge?.aiAcpCancel?.('', activeSession.id);
|
||||
}
|
||||
retargetSessionScope(activeSession.id, {
|
||||
type: scopeType,
|
||||
targetId: scopeTargetId,
|
||||
hostIds: scopeHostIds,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isVisible && activeSessionIdForScope !== activeSession.id) {
|
||||
setActiveSessionId(activeSession.id);
|
||||
}
|
||||
}, [
|
||||
activeSession,
|
||||
activeSessionIdForScope,
|
||||
retargetSessionScope,
|
||||
isVisible,
|
||||
scopeHostIds,
|
||||
scopeTargetId,
|
||||
scopeType,
|
||||
setActiveSessionId,
|
||||
setStreamingForScope,
|
||||
shouldRetargetActiveSession,
|
||||
streamingSessionIds,
|
||||
abortControllersRef,
|
||||
]);
|
||||
|
||||
// Restore agent selector from active session when scope changes
|
||||
useEffect(() => {
|
||||
if (activeSession) {
|
||||
setCurrentAgentId(activeSession.agentId);
|
||||
}
|
||||
}, [scopeKey, activeSession]);
|
||||
|
||||
// Proactively sync terminal session metadata to main process whenever scope or sessions change
|
||||
useEffect(() => {
|
||||
@@ -294,12 +412,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
[enableAgent, setExternalAgents],
|
||||
);
|
||||
|
||||
// Active session (scoped)
|
||||
const activeSession = useMemo(
|
||||
() => sessions.find((s) => s.id === activeSessionId) ?? null,
|
||||
[sessions, activeSessionId],
|
||||
);
|
||||
|
||||
const messages = activeSession?.messages ?? [];
|
||||
|
||||
// ── Export hook ──
|
||||
@@ -345,15 +457,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
setAgentModel(currentAgentId, modelId);
|
||||
}, [currentAgentId, setAgentModel]);
|
||||
|
||||
// Filtered sessions for history (matching current scope type)
|
||||
const historySessions = useMemo(
|
||||
() =>
|
||||
sessions
|
||||
.filter((s) => s.scope.type === scopeType && s.scope.targetId === scopeTargetId)
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt),
|
||||
[sessions, scopeType, scopeTargetId],
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Handlers
|
||||
// -------------------------------------------------------------------
|
||||
@@ -420,14 +523,34 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
|
||||
/** Ensure a session exists for the current scope and return its ID. */
|
||||
const ensureSession = useCallback((): string => {
|
||||
if (activeSessionId && sessionsRef.current.some((session) => session.id === activeSessionId)) {
|
||||
return activeSessionId;
|
||||
if (activeSession && sessionsRef.current.some((session) => session.id === activeSession.id)) {
|
||||
if (shouldRetargetActiveSession) {
|
||||
retargetSessionScope(activeSession.id, {
|
||||
type: scopeType,
|
||||
targetId: scopeTargetId,
|
||||
hostIds: scopeHostIds,
|
||||
});
|
||||
} else if (activeSessionIdForScope !== activeSession.id) {
|
||||
setActiveSessionId(activeSession.id);
|
||||
}
|
||||
return activeSession.id;
|
||||
}
|
||||
const scope: AISessionScope = { type: scopeType, targetId: scopeTargetId, hostIds: scopeHostIds };
|
||||
const session = createSession(scope, currentAgentId);
|
||||
setActiveSessionId(session.id);
|
||||
return session.id;
|
||||
}, [activeSessionId, scopeType, scopeTargetId, scopeHostIds, currentAgentId, createSession, setActiveSessionId]);
|
||||
}, [
|
||||
activeSession,
|
||||
activeSessionIdForScope,
|
||||
createSession,
|
||||
currentAgentId,
|
||||
retargetSessionScope,
|
||||
scopeHostIds,
|
||||
scopeTargetId,
|
||||
scopeType,
|
||||
setActiveSessionId,
|
||||
shouldRetargetActiveSession,
|
||||
]);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Main send handler (thin orchestrator)
|
||||
@@ -747,9 +870,12 @@ const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
||||
const timeStr = formatRelativeTime(time, t);
|
||||
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
key={session.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect(session.id)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onSelect(session.id); }}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-between py-2.5 border-b border-border/20 text-left transition-colors cursor-pointer group',
|
||||
isActive ? 'text-foreground' : 'text-foreground/70 hover:text-foreground',
|
||||
@@ -770,7 +896,7 @@ const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
@@ -611,7 +611,7 @@ interface SyncDashboardProps {
|
||||
onClearLocalData?: () => void;
|
||||
}
|
||||
|
||||
export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
onBuildPayload,
|
||||
onApplyPayload,
|
||||
onClearLocalData,
|
||||
@@ -800,6 +800,9 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
toast.success(t('cloudSync.connect.github.success'));
|
||||
} catch (error) {
|
||||
setIsPollingGitHub(false);
|
||||
setShowGitHubModal(false);
|
||||
// Reset provider status so button is clickable again (without tearing down existing connections)
|
||||
sync.resetProviderStatus('github');
|
||||
const message = getNetworkErrorMessage(error, t('common.unknownError'));
|
||||
toast.error(message, t('cloudSync.connect.github.failedTitle'));
|
||||
}
|
||||
@@ -813,10 +816,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
// Note: Auth flow is handled automatically by oauthBridge
|
||||
toast.info(t('cloudSync.connect.browserContinue'));
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('common.unknownError'),
|
||||
t('cloudSync.connect.google.failedTitle'),
|
||||
);
|
||||
// Reset provider status so button is clickable again (without tearing down existing connections)
|
||||
sync.resetProviderStatus('google');
|
||||
const msg = error instanceof Error ? error.message : t('common.unknownError');
|
||||
// Don't show toast for user-initiated cancellation (popup closed)
|
||||
if (!msg.includes('cancelled')) {
|
||||
toast.error(msg, t('cloudSync.connect.google.failedTitle'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -828,10 +834,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
// Note: Auth flow is handled automatically by oauthBridge
|
||||
toast.info(t('cloudSync.connect.browserContinue'));
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('common.unknownError'),
|
||||
t('cloudSync.connect.onedrive.failedTitle'),
|
||||
);
|
||||
// Reset provider status so button is clickable again (without tearing down existing connections)
|
||||
sync.resetProviderStatus('onedrive');
|
||||
const msg = error instanceof Error ? error.message : t('common.unknownError');
|
||||
// Don't show toast for user-initiated cancellation (popup closed)
|
||||
if (!msg.includes('cancelled')) {
|
||||
toast.error(msg, t('cloudSync.connect.onedrive.failedTitle'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1250,6 +1259,9 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
onClose={() => {
|
||||
setShowGitHubModal(false);
|
||||
setIsPollingGitHub(false);
|
||||
// Reset provider status so button is clickable again.
|
||||
// The background polling will continue until expiry but is harmless.
|
||||
sync.resetProviderStatus('github');
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -25,12 +25,12 @@ import {
|
||||
Trash2,
|
||||
Variable,
|
||||
Wifi,
|
||||
Router,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
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 {
|
||||
@@ -93,6 +93,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
|
||||
@@ -108,6 +110,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
allTags = [],
|
||||
allHosts = [],
|
||||
defaultGroup,
|
||||
terminalThemeId,
|
||||
terminalFontSize,
|
||||
onSave,
|
||||
onCancel,
|
||||
onCreateGroup,
|
||||
@@ -115,7 +119,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { checkSshAgent } = useApplicationBackend();
|
||||
const { terminalThemeId, terminalFontSize } = useSettingsState();
|
||||
const [form, setForm] = useState<Host>(
|
||||
() =>
|
||||
initialData ||
|
||||
@@ -1513,7 +1516,15 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
<ToggleRow
|
||||
label="Mosh"
|
||||
enabled={!!form.moshEnabled}
|
||||
onToggle={() => update("moshEnabled", !form.moshEnabled)}
|
||||
onToggle={() => {
|
||||
const enabling = !form.moshEnabled;
|
||||
if (enabling && form.deviceType === 'network') {
|
||||
// Network device mode is incompatible with Mosh — clear it
|
||||
setForm(prev => ({ ...prev, moshEnabled: true, deviceType: undefined }));
|
||||
} else {
|
||||
update("moshEnabled", enabling);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -1546,6 +1557,32 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Network Device Mode — only for SSH hosts without Mosh (serial already uses raw mode) */}
|
||||
{(!form.protocol || form.protocol === 'ssh') && !form.moshEnabled && (
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<Router size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">{t("hostDetails.section.deviceType")}</p>
|
||||
</div>
|
||||
<ToggleRow
|
||||
label={t("hostDetails.deviceType")}
|
||||
enabled={form.deviceType === 'network'}
|
||||
onToggle={() => update("deviceType", form.deviceType === 'network' ? undefined : 'network')}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground break-words">
|
||||
{t("hostDetails.deviceType.desc")}
|
||||
</p>
|
||||
{form.deviceType === 'network' && (
|
||||
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
|
||||
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400 break-words">
|
||||
{t("hostDetails.deviceType.warning")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Legacy Algorithms */}
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
Folder,
|
||||
LayoutGrid,
|
||||
Search,
|
||||
Shield,
|
||||
FolderLock,
|
||||
Terminal,
|
||||
TerminalSquare,
|
||||
} from "lucide-react";
|
||||
@@ -98,13 +98,17 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
|
||||
// Reset state when opening
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSelectedIndex(0);
|
||||
// Auto focus the input after a short delay
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 50);
|
||||
}
|
||||
if (!isOpen) return;
|
||||
|
||||
const focusTimer = window.setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 50);
|
||||
|
||||
setSelectedIndex(0);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(focusTimer);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// Handle clicks outside the container
|
||||
@@ -287,7 +291,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
const isSelected = idx === selectedIndex;
|
||||
const icon =
|
||||
tabId === "vault" ? (
|
||||
<Shield size={16} />
|
||||
<FolderLock size={16} />
|
||||
) : (
|
||||
<Folder size={16} />
|
||||
);
|
||||
|
||||
@@ -35,7 +35,7 @@ interface SerialPort {
|
||||
interface SerialConnectModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConnect: (config: SerialConfig) => void;
|
||||
onConnect: (config: SerialConfig, options?: { charset?: string }) => void;
|
||||
onSaveHost?: (host: Host) => void;
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
const [flowControl, setFlowControl] = useState<SerialFlowControl>('none');
|
||||
const [localEcho, setLocalEcho] = useState(false);
|
||||
const [lineMode, setLineMode] = useState(false);
|
||||
const [charset, setCharset] = useState('UTF-8');
|
||||
|
||||
// Save configuration state
|
||||
const [saveConfig, setSaveConfig] = useState(false);
|
||||
@@ -131,12 +132,13 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
tags: ['serial'],
|
||||
protocol: 'serial',
|
||||
createdAt: Date.now(),
|
||||
charset,
|
||||
serialConfig: config, // Store full serial configuration for connection
|
||||
};
|
||||
onSaveHost(host);
|
||||
}
|
||||
|
||||
onConnect(config);
|
||||
onConnect(config, { charset });
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -164,7 +166,7 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogContent className="max-w-md max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Usb size={18} />
|
||||
@@ -175,7 +177,7 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-4 py-2 overflow-y-auto flex-1 min-h-0">
|
||||
{/* Serial Port Selection */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -368,6 +370,20 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Charset */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="serial-charset" className="text-sm font-medium">
|
||||
{t('serial.field.charset')}
|
||||
</Label>
|
||||
<Input
|
||||
id="serial-charset"
|
||||
placeholder={t("hostDetails.charset.placeholder")}
|
||||
value={charset}
|
||||
onChange={(e) => setCharset(e.target.value)}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
@@ -66,6 +66,7 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
|
||||
const [flowControl, setFlowControl] = useState<SerialFlowControl>(initialData.serialConfig?.flowControl || 'none');
|
||||
const [localEcho, setLocalEcho] = useState(initialData.serialConfig?.localEcho || false);
|
||||
const [lineMode, setLineMode] = useState(initialData.serialConfig?.lineMode || false);
|
||||
const [charset, setCharset] = useState(initialData.charset || 'UTF-8');
|
||||
const [tags, setTags] = useState<string[]>(initialData.tags || []);
|
||||
const [group, setGroup] = useState(initialData.group || '');
|
||||
|
||||
@@ -107,6 +108,7 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
|
||||
port: baudRate,
|
||||
tags,
|
||||
group,
|
||||
charset,
|
||||
serialConfig: config,
|
||||
};
|
||||
|
||||
@@ -392,6 +394,20 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Charset */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="serial-charset" className="text-sm font-medium">
|
||||
{t('serial.field.charset')}
|
||||
</Label>
|
||||
<Input
|
||||
id="serial-charset"
|
||||
placeholder={t("hostDetails.charset.placeholder")}
|
||||
value={charset}
|
||||
onChange={(e) => setCharset(e.target.value)}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -149,7 +149,7 @@ 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 { 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;
|
||||
@@ -260,6 +260,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
checkNow={checkNow}
|
||||
openReleasePage={openReleasePage}
|
||||
installUpdate={installUpdate}
|
||||
startDownload={startDownload}
|
||||
isUpdateDemoMode={isUpdateDemoMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -337,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,8 +4,8 @@ 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, normalizeLineEndings, wrapBracketedPaste } from "../lib/utils";
|
||||
@@ -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;
|
||||
@@ -183,7 +183,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
keys,
|
||||
identities,
|
||||
snippets,
|
||||
allHosts = [],
|
||||
chainHosts = [],
|
||||
themePreviewId,
|
||||
knownHosts: _knownHosts = [],
|
||||
isVisible,
|
||||
inWorkspace,
|
||||
@@ -233,11 +234,14 @@ 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);
|
||||
const hasConnectedRef = useRef(false);
|
||||
const hasRunStartupCommandRef = useRef(false);
|
||||
const terminalDataCapturedRef = useRef(false);
|
||||
const onTerminalDataCaptureRef = useRef(onTerminalDataCapture);
|
||||
const commandBufferRef = useRef<string>("");
|
||||
const [hasMouseTracking, setHasMouseTracking] = useState(false);
|
||||
const mouseTrackingRef = useRef(false);
|
||||
@@ -245,6 +249,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
const terminalSettingsRef = useRef(terminalSettings);
|
||||
terminalSettingsRef.current = terminalSettings;
|
||||
onTerminalDataCaptureRef.current = onTerminalDataCapture;
|
||||
const isVisibleRef = useRef(isVisible);
|
||||
isVisibleRef.current = isVisible;
|
||||
const pendingOutputScrollRef = useRef(false);
|
||||
@@ -296,6 +301,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;
|
||||
|
||||
@@ -347,6 +357,135 @@ 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";
|
||||
@@ -358,6 +497,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
refreshInterval: terminalSettings?.serverStatsRefreshInterval ?? 5,
|
||||
isSupportedOs: host.os === 'linux' || host.os === 'macos',
|
||||
isConnected: status === 'connected',
|
||||
isVisible,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -411,27 +551,47 @@ 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);
|
||||
hasConnectedRef.current = next === "connected";
|
||||
onStatusChange?.(sessionId, next);
|
||||
};
|
||||
const handleTerminalDataCaptureOnce = useCallback((capturedSessionId: string, data: string) => {
|
||||
const captureHandler = onTerminalDataCaptureRef.current;
|
||||
if (!captureHandler || terminalDataCapturedRef.current) return;
|
||||
terminalDataCapturedRef.current = true;
|
||||
captureHandler(capturedSessionId, data);
|
||||
}, []);
|
||||
|
||||
const cleanupSession = () => {
|
||||
disposeDataRef.current?.();
|
||||
@@ -499,7 +659,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
},
|
||||
onSessionExit,
|
||||
onTerminalDataCapture,
|
||||
onTerminalDataCapture: handleTerminalDataCaptureOnce,
|
||||
onOsDetected,
|
||||
onCommandExecuted,
|
||||
sessionLog,
|
||||
@@ -508,6 +668,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
terminalDataCapturedRef.current = false;
|
||||
setError(null);
|
||||
hasConnectedRef.current = false;
|
||||
pendingOutputScrollRef.current = false;
|
||||
@@ -544,7 +705,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;
|
||||
@@ -619,11 +786,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (onTerminalDataCapture && serializeAddonRef.current) {
|
||||
if (!terminalDataCapturedRef.current && serializeAddonRef.current) {
|
||||
try {
|
||||
const terminalData = serializeAddonRef.current.serialize();
|
||||
logger.info("[Terminal] Capturing data on unmount", { sessionId, dataLength: terminalData.length });
|
||||
onTerminalDataCapture(sessionId, terminalData);
|
||||
handleTerminalDataCaptureOnce(sessionId, terminalData);
|
||||
} catch (err) {
|
||||
logger.warn("Failed to serialize terminal data on unmount:", err);
|
||||
}
|
||||
@@ -631,7 +798,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
teardown();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Effect only runs on host.id/sessionId change, internal functions are stable
|
||||
}, [host.id, sessionId]);
|
||||
}, [handleTerminalDataCaptureOnce, host.id, sessionId]);
|
||||
|
||||
// Connection timeline and timeout visuals
|
||||
useEffect(() => {
|
||||
@@ -696,6 +863,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
if (!options?.force) {
|
||||
const lastSize = lastFittedSizeRef.current;
|
||||
if (lastSize && lastSize.width === width && lastSize.height === height) {
|
||||
autocompleteRepositionRef.current?.();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -704,6 +872,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);
|
||||
}
|
||||
@@ -719,15 +894,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;
|
||||
@@ -780,27 +960,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;
|
||||
@@ -848,7 +1014,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)
|
||||
@@ -884,7 +1049,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;
|
||||
@@ -1022,6 +1187,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}, [sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
|
||||
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const handler = () => {
|
||||
@@ -1039,7 +1206,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
if (resizeTimeout) clearTimeout(resizeTimeout);
|
||||
window.removeEventListener("resize", handler);
|
||||
};
|
||||
}, []);
|
||||
}, [isVisible]);
|
||||
|
||||
const disableBracketedPasteRef = useRef(terminalSettings?.disableBracketedPaste ?? false);
|
||||
disableBracketedPasteRef.current = terminalSettings?.disableBracketedPaste ?? false;
|
||||
@@ -1189,6 +1356,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
if (!termRef.current) return;
|
||||
cleanupSession();
|
||||
auth.resetForRetry();
|
||||
terminalDataCapturedRef.current = false;
|
||||
hasRunStartupCommandRef.current = false;
|
||||
setIsDisconnectedDialogDismissed(false);
|
||||
setStatus("connecting");
|
||||
@@ -1322,6 +1490,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
|
||||
@@ -1344,6 +1520,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}
|
||||
@@ -1374,14 +1551,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">
|
||||
@@ -1756,7 +1933,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}
|
||||
@@ -1764,10 +1941,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
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { Circle, FolderTree, LayoutGrid, MessageSquare, PanelLeft, PanelRight, Palette, Server, X, Zap } from 'lucide-react';
|
||||
import React, { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { createContext, memo, startTransition, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useActiveTabId } from '../application/state/activeTabStore';
|
||||
import {
|
||||
getSessionActivityIdsToClear,
|
||||
getValidSessionActivityIds,
|
||||
shouldMarkSessionActivity,
|
||||
} from '../application/state/sessionActivity';
|
||||
import { sessionActivityStore } from '../application/state/sessionActivityStore';
|
||||
import { useTerminalBackend } from '../application/state/useTerminalBackend';
|
||||
import { collectSessionIds } from '../domain/workspace';
|
||||
import { SplitDirection } from '../domain/workspace';
|
||||
@@ -19,6 +25,8 @@ import {
|
||||
import { cn, normalizeLineEndings } from '../lib/utils';
|
||||
import { detectLocalOs } from '../lib/localShell';
|
||||
import { useStoredString } from '../application/state/useStoredString';
|
||||
import { useStoredNumber } from '../application/state/useStoredNumber';
|
||||
import { STORAGE_KEY_SIDE_PANEL_WIDTH } from '../infrastructure/config/storageKeys';
|
||||
import { buildCacheKey } from '../application/state/sftp/sharedRemoteHostCache';
|
||||
import type { DropEntry } from '../lib/sftpFileUtils';
|
||||
import { Host, Identity, KnownHost, SSHKey, Snippet, TerminalSession, TerminalTheme, Workspace, WorkspaceNode } from '../types';
|
||||
@@ -67,6 +75,78 @@ type PendingSftpUpload = {
|
||||
|
||||
type SnippetExecutor = (command: string, noAutoRun?: boolean) => void;
|
||||
|
||||
function hexToHslToken(hex: string): string {
|
||||
const normalized = hex.startsWith('#') ? hex : `#${hex}`;
|
||||
const r = parseInt(normalized.slice(1, 3), 16) / 255;
|
||||
const g = parseInt(normalized.slice(3, 5), 16) / 255;
|
||||
const b = parseInt(normalized.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;
|
||||
default:
|
||||
h = ((r - g) / d + 4) / 6;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return `${Math.round(h * 3600) / 10} ${Math.round(s * 1000) / 10}% ${Math.round(l * 1000) / 10}%`;
|
||||
}
|
||||
|
||||
function adjustLightnessToken(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 adjustSaturationToken(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]}`;
|
||||
}
|
||||
|
||||
const clearTerminalPreviewVars = (sessionId: string | null) => {
|
||||
if (!sessionId || typeof document === 'undefined') return;
|
||||
const pane = document.querySelector<HTMLElement>(`[data-session-id="${sessionId}"]`);
|
||||
if (!pane) return;
|
||||
pane.style.removeProperty('--terminal-preview-bg');
|
||||
pane.style.removeProperty('--terminal-preview-fg');
|
||||
pane.style.removeProperty('--terminal-preview-border');
|
||||
pane.style.removeProperty('--terminal-preview-toolbar-btn');
|
||||
pane.style.removeProperty('--terminal-preview-toolbar-btn-hover');
|
||||
pane.style.removeProperty('--terminal-preview-toolbar-btn-active');
|
||||
};
|
||||
|
||||
const clearTopTabsPreviewVars = () => {
|
||||
if (typeof document === 'undefined') return;
|
||||
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
|
||||
if (!tabsRoot) return;
|
||||
tabsRoot.style.removeProperty('--top-tabs-bg');
|
||||
tabsRoot.style.removeProperty('--top-tabs-fg');
|
||||
tabsRoot.style.removeProperty('--top-tabs-muted');
|
||||
tabsRoot.style.removeProperty('--top-tabs-active-bg');
|
||||
tabsRoot.style.removeProperty('--top-tabs-accent');
|
||||
tabsRoot.style.removeProperty('--background');
|
||||
tabsRoot.style.removeProperty('--foreground');
|
||||
tabsRoot.style.removeProperty('--accent');
|
||||
tabsRoot.style.removeProperty('--primary');
|
||||
tabsRoot.style.removeProperty('--secondary');
|
||||
tabsRoot.style.removeProperty('--border');
|
||||
tabsRoot.style.removeProperty('--muted-foreground');
|
||||
};
|
||||
|
||||
const filterTabsMap = <T,>(source: Map<string, T>, validIds: Set<string>): Map<string, T> => {
|
||||
let changed = false;
|
||||
const next = new Map<string, T>();
|
||||
@@ -80,6 +160,41 @@ const filterTabsMap = <T,>(source: Map<string, T>, validIds: Set<string>): Map<s
|
||||
return changed ? next : source;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const TERMINAL_OSC_SEQUENCE_REGEX = new RegExp('\\u001B\\][^\\u0007\\u001B]*(?:\\u0007|\\u001B\\\\)', 'g');
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const TERMINAL_ESCAPE_SEQUENCE_REGEX = new RegExp('\\u001B(?:[@-Z\\\\-_]|\\[[0-?]*[ -/]*[@-~])', 'g');
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const TERMINAL_CONTROL_CHAR_REGEX = new RegExp('[\\u0000-\\u0008\\u000B-\\u001F\\u007F]', 'g');
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const INCOMPLETE_ESCAPE_TAIL_REGEX = new RegExp('\\u001B(?:\\][^\\u0007\\u001B]*(?:\\u001B)?|\\[[0-?]*[ -/]*)?$');
|
||||
|
||||
const stripTerminalControlSequences = (data: string): string => {
|
||||
return data
|
||||
.replace(TERMINAL_OSC_SEQUENCE_REGEX, '')
|
||||
.replace(TERMINAL_ESCAPE_SEQUENCE_REGEX, '')
|
||||
.replace(TERMINAL_CONTROL_CHAR_REGEX, '');
|
||||
};
|
||||
|
||||
class ChunkedEscapeFilter {
|
||||
private pending = '';
|
||||
|
||||
feed(chunk: string): string {
|
||||
const data = this.pending + chunk;
|
||||
const tailMatch = INCOMPLETE_ESCAPE_TAIL_REGEX.exec(data);
|
||||
if (tailMatch) {
|
||||
this.pending = tailMatch[0];
|
||||
return stripTerminalControlSequences(data.slice(0, tailMatch.index));
|
||||
}
|
||||
this.pending = '';
|
||||
return stripTerminalControlSequences(data);
|
||||
}
|
||||
}
|
||||
|
||||
const hasNotifiableTerminalOutput = (filter: ChunkedEscapeFilter, chunk: string): boolean => {
|
||||
return filter.feed(chunk).trim().length > 0;
|
||||
};
|
||||
|
||||
type AITerminalSessionInfo = {
|
||||
sessionId: string;
|
||||
hostId: string;
|
||||
@@ -89,6 +204,7 @@ type AITerminalSessionInfo = {
|
||||
username?: string;
|
||||
protocol?: string;
|
||||
shellType?: string;
|
||||
deviceType?: string;
|
||||
connected: boolean;
|
||||
};
|
||||
|
||||
@@ -120,6 +236,9 @@ const buildAITerminalSessionInfo = (
|
||||
username: host?.username || session?.username,
|
||||
protocol,
|
||||
shellType: session?.shellType && session.shellType !== 'unknown' ? session.shellType : undefined,
|
||||
// Suppress deviceType for Mosh sessions — Mosh requires a shell-backed PTY
|
||||
// and cannot connect to vendor CLIs, so network device mode doesn't apply.
|
||||
deviceType: (session?.moshEnabled || host?.moshEnabled) ? undefined : host?.deviceType,
|
||||
connected: session?.status === 'connected',
|
||||
};
|
||||
};
|
||||
@@ -182,6 +301,7 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
|
||||
deleteSession={aiState.deleteSession}
|
||||
updateSessionTitle={aiState.updateSessionTitle}
|
||||
updateSessionExternalSessionId={aiState.updateSessionExternalSessionId}
|
||||
retargetSessionScope={aiState.retargetSessionScope}
|
||||
addMessageToSession={aiState.addMessageToSession}
|
||||
updateLastMessage={aiState.updateLastMessage}
|
||||
updateMessageById={aiState.updateMessageById}
|
||||
@@ -422,11 +542,18 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
snippetExecutorsRef.current.delete(sessionId);
|
||||
}, []);
|
||||
|
||||
const onSessionData = terminalBackend.onSessionData;
|
||||
|
||||
const [workspaceArea, setWorkspaceArea] = useState<{ width: number; height: number }>({ width: 0, height: 0 });
|
||||
const workspaceOuterRef = useRef<HTMLDivElement>(null);
|
||||
const workspaceInnerRef = useRef<HTMLDivElement>(null);
|
||||
const workspaceOverlayRef = useRef<HTMLDivElement>(null);
|
||||
const [dropHint, setDropHint] = useState<SplitHint>(null);
|
||||
const [themePreview, setThemePreview] = useState<{ targetSessionId: string | null; themeId: string | null }>({
|
||||
targetSessionId: null,
|
||||
themeId: null,
|
||||
});
|
||||
const themeCommitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [resizing, setResizing] = useState<{
|
||||
workspaceId: string;
|
||||
splitId: string;
|
||||
@@ -473,10 +600,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
// Side panel state - per-tab tracking of which sub-panel is active
|
||||
// Maps tab IDs to the active sub-panel type (sftp/scripts/theme), absent = closed
|
||||
const [sidePanelOpenTabs, setSidePanelOpenTabs] = useState<Map<string, SidePanelTab>>(new Map());
|
||||
const [sidePanelWidth, setSidePanelWidth] = useState(() => {
|
||||
const stored = window.localStorage.getItem('netcatty_side_panel_width');
|
||||
return stored ? Math.max(280, Math.min(800, Number(stored))) : 420;
|
||||
});
|
||||
const [sidePanelWidth, setSidePanelWidth, persistSidePanelWidth] = useStoredNumber(
|
||||
STORAGE_KEY_SIDE_PANEL_WIDTH, 420, { min: 280, max: 800 },
|
||||
);
|
||||
const [sidePanelPosition, setSidePanelPosition] = useStoredString<'left' | 'right'>(
|
||||
'netcatty_side_panel_position',
|
||||
'left',
|
||||
@@ -609,20 +735,27 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const startWidth = sidePanelWidth;
|
||||
|
||||
let lastWidth = startWidth;
|
||||
let rafId: number | null = null;
|
||||
const onMouseMove = (ev: MouseEvent) => {
|
||||
const delta = ev.clientX - startX;
|
||||
lastWidth = Math.max(280, Math.min(800, startWidth + (sidePanelPosition === 'left' ? delta : -delta)));
|
||||
setSidePanelWidth(lastWidth);
|
||||
if (rafId !== null) return;
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null;
|
||||
setSidePanelWidth(lastWidth);
|
||||
});
|
||||
};
|
||||
const onMouseUp = () => {
|
||||
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||
setSidePanelWidth(lastWidth);
|
||||
sftpResizingRef.current = false;
|
||||
window.localStorage.setItem('netcatty_side_panel_width', String(lastWidth));
|
||||
persistSidePanelWidth(lastWidth);
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
}, [sidePanelWidth, sidePanelPosition]);
|
||||
}, [sidePanelWidth, sidePanelPosition, setSidePanelWidth, persistSidePanelWidth]);
|
||||
|
||||
// Pre-compute host lookup map for O(1) access
|
||||
const hostMap = useMemo(() => {
|
||||
@@ -637,15 +770,24 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
for (const session of sessions) {
|
||||
const existingHost = hostMap.get(session.hostId);
|
||||
if (existingHost) {
|
||||
// Apply session-time protocol overrides to the host
|
||||
const hostWithOverrides: Host = {
|
||||
...existingHost,
|
||||
// Use session protocol settings if provided (from connection-time selection)
|
||||
protocol: session.protocol ?? existingHost.protocol,
|
||||
port: session.port ?? existingHost.port,
|
||||
moshEnabled: session.moshEnabled ?? existingHost.moshEnabled,
|
||||
};
|
||||
map.set(session.id, hostWithOverrides);
|
||||
const protocol = session.protocol ?? existingHost.protocol;
|
||||
const port = session.port ?? existingHost.port;
|
||||
const moshEnabled = session.moshEnabled ?? existingHost.moshEnabled;
|
||||
|
||||
if (
|
||||
protocol === existingHost.protocol &&
|
||||
port === existingHost.port &&
|
||||
moshEnabled === existingHost.moshEnabled
|
||||
) {
|
||||
map.set(session.id, existingHost);
|
||||
} else {
|
||||
map.set(session.id, {
|
||||
...existingHost,
|
||||
protocol,
|
||||
port,
|
||||
moshEnabled,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Create stable fallback host object
|
||||
map.set(session.id, {
|
||||
@@ -659,11 +801,26 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
tags: [],
|
||||
protocol: session.protocol ?? 'local' as const,
|
||||
moshEnabled: session.moshEnabled,
|
||||
charset: session.charset,
|
||||
});
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [sessions, hostMap]);
|
||||
const sessionChainHostsMap = useMemo(() => {
|
||||
const map = new Map<string, Host[]>();
|
||||
for (const session of sessions) {
|
||||
const host = sessionHostsMap.get(session.id);
|
||||
if (!host?.hostChain?.hostIds?.length) continue;
|
||||
map.set(
|
||||
session.id,
|
||||
host.hostChain.hostIds
|
||||
.map((hostId) => hostMap.get(hostId))
|
||||
.filter((value): value is Host => Boolean(value)),
|
||||
);
|
||||
}
|
||||
return map;
|
||||
}, [sessions, sessionHostsMap, hostMap]);
|
||||
|
||||
const validTerminalTabIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
@@ -672,6 +829,17 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
return ids;
|
||||
}, [sessions, workspaces]);
|
||||
|
||||
const validSessionActivityIds = useMemo(() => {
|
||||
return getValidSessionActivityIds(sessions);
|
||||
}, [sessions]);
|
||||
const activityTrackedSessions = useMemo(
|
||||
() =>
|
||||
sessions.filter(
|
||||
(session) => session.status !== 'disconnected',
|
||||
),
|
||||
[sessions],
|
||||
);
|
||||
|
||||
const onSplitSessionRef = useRef(onSplitSession);
|
||||
onSplitSessionRef.current = onSplitSession;
|
||||
const splitHorizontalHandlersRef = useRef<Map<string, () => void>>(new Map());
|
||||
@@ -746,7 +914,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
setSftpHostForTab(prev => filterTabsMap(prev, validTerminalTabIds));
|
||||
setSftpInitialLocationForTab(prev => filterTabsMap(prev, validTerminalTabIds));
|
||||
setSftpPendingUploadsForTab(prev => filterTabsMap(prev, validTerminalTabIds));
|
||||
}, [validTerminalTabIds]);
|
||||
sessionActivityStore.prune(validSessionActivityIds);
|
||||
}, [validSessionActivityIds, validTerminalTabIds]);
|
||||
|
||||
useEffect(() => {
|
||||
cleanupOrphanedAISessions(validTerminalTabIds);
|
||||
@@ -886,15 +1055,16 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (!resizing) return;
|
||||
const onMove = (e: MouseEvent) => {
|
||||
let rafId: number | null = null;
|
||||
let lastDelta = 0;
|
||||
const applySizes = () => {
|
||||
const dimension = resizing.direction === 'vertical' ? resizing.startArea.w : resizing.startArea.h;
|
||||
if (dimension <= 0) return;
|
||||
const total = resizing.startSizes.reduce((acc, n) => acc + n, 0) || 1;
|
||||
const pxSizes = resizing.startSizes.map(s => (s / total) * dimension);
|
||||
const i = resizing.index;
|
||||
const delta = (resizing.direction === 'vertical' ? e.clientX - resizing.startClient.x : e.clientY - resizing.startClient.y);
|
||||
let a = pxSizes[i] + delta;
|
||||
let b = pxSizes[i + 1] - delta;
|
||||
let a = pxSizes[i] + lastDelta;
|
||||
let b = pxSizes[i + 1] - lastDelta;
|
||||
const minPx = Math.min(120, dimension / 2);
|
||||
if (a < minPx) {
|
||||
const diff = minPx - a;
|
||||
@@ -913,10 +1083,23 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const newSizes = newPxSizes.map(n => n / totalPx);
|
||||
onUpdateSplitSizes(resizing.workspaceId, resizing.splitId, newSizes);
|
||||
};
|
||||
const onUp = () => setResizing(null);
|
||||
const onMove = (e: MouseEvent) => {
|
||||
lastDelta = resizing.direction === 'vertical' ? e.clientX - resizing.startClient.x : e.clientY - resizing.startClient.y;
|
||||
if (rafId !== null) return;
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null;
|
||||
applySizes();
|
||||
});
|
||||
};
|
||||
const onUp = () => {
|
||||
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||
applySizes();
|
||||
setResizing(null);
|
||||
};
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
return () => {
|
||||
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
@@ -1104,6 +1287,38 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
return () => window.removeEventListener('netcatty:toggle-ai-panel', handler);
|
||||
}, [handleOpenAI]);
|
||||
|
||||
useEffect(() => {
|
||||
const sessionIdsToClear = getSessionActivityIdsToClear(activeTabId, sessions);
|
||||
if (sessionIdsToClear.length === 1) {
|
||||
sessionActivityStore.clearTab(sessionIdsToClear[0]);
|
||||
return;
|
||||
}
|
||||
if (sessionIdsToClear.length > 1) {
|
||||
sessionActivityStore.clearTabs(sessionIdsToClear);
|
||||
}
|
||||
}, [activeTabId, sessions]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribers = activityTrackedSessions.map((session) => {
|
||||
const filter = new ChunkedEscapeFilter();
|
||||
return onSessionData(session.id, (chunk) => {
|
||||
if (!hasNotifiableTerminalOutput(filter, chunk)) return;
|
||||
|
||||
if (!shouldMarkSessionActivity(activeTabIdRef.current, session)) {
|
||||
return;
|
||||
}
|
||||
|
||||
sessionActivityStore.setTabActive(session.id, true);
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
for (const unsubscribe of unsubscribers) {
|
||||
unsubscribe();
|
||||
}
|
||||
};
|
||||
}, [activityTrackedSessions, onSessionData]);
|
||||
|
||||
// Execute snippet on the focused terminal session
|
||||
const handleSnippetClickForFocusedSession = useCallback((command: string, noAutoRun?: boolean) => {
|
||||
const sessionId = activeWorkspace?.focusedSessionId ?? activeSession?.id;
|
||||
@@ -1137,51 +1352,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const isFocusedHostLocal = useMemo(() => {
|
||||
return focusedHost?.protocol === 'local' || !!focusedHost?.id?.startsWith('local-');
|
||||
}, [focusedHost]);
|
||||
|
||||
const handleThemeChangeForFocusedSession = useCallback((themeId: string) => {
|
||||
if (isFocusedHostLocal) {
|
||||
onUpdateTerminalThemeId?.(themeId);
|
||||
return;
|
||||
}
|
||||
if (focusedHost) {
|
||||
onUpdateHost({ ...focusedHost, theme: themeId, themeOverride: true });
|
||||
}
|
||||
}, [focusedHost, isFocusedHostLocal, onUpdateTerminalThemeId, onUpdateHost]);
|
||||
|
||||
const handleThemeResetForFocusedSession = useCallback(() => {
|
||||
if (!focusedHost || isFocusedHostLocal) return;
|
||||
onUpdateHost(clearHostThemeOverride(focusedHost));
|
||||
}, [focusedHost, isFocusedHostLocal, onUpdateHost]);
|
||||
|
||||
const handleFontFamilyChangeForFocusedSession = useCallback((fontFamilyId: string) => {
|
||||
if (isFocusedHostLocal) {
|
||||
onUpdateTerminalFontFamilyId?.(fontFamilyId);
|
||||
return;
|
||||
}
|
||||
if (focusedHost) {
|
||||
onUpdateHost({ ...focusedHost, fontFamily: fontFamilyId, fontFamilyOverride: true });
|
||||
}
|
||||
}, [focusedHost, isFocusedHostLocal, onUpdateTerminalFontFamilyId, onUpdateHost]);
|
||||
|
||||
const handleFontFamilyResetForFocusedSession = useCallback(() => {
|
||||
if (!focusedHost || isFocusedHostLocal) return;
|
||||
onUpdateHost(clearHostFontFamilyOverride(focusedHost));
|
||||
}, [focusedHost, isFocusedHostLocal, onUpdateHost]);
|
||||
|
||||
const handleFontSizeChangeForFocusedSession = useCallback((newFontSize: number) => {
|
||||
if (isFocusedHostLocal) {
|
||||
onUpdateTerminalFontSize?.(newFontSize);
|
||||
return;
|
||||
}
|
||||
if (focusedHost) {
|
||||
onUpdateHost({ ...focusedHost, fontSize: newFontSize, fontSizeOverride: true });
|
||||
}
|
||||
}, [focusedHost, isFocusedHostLocal, onUpdateTerminalFontSize, onUpdateHost]);
|
||||
|
||||
const handleFontSizeResetForFocusedSession = useCallback(() => {
|
||||
if (!focusedHost || isFocusedHostLocal) return;
|
||||
onUpdateHost(clearHostFontSizeOverride(focusedHost));
|
||||
}, [focusedHost, isFocusedHostLocal, onUpdateHost]);
|
||||
const previewTargetSessionId = activeWorkspace?.focusedSessionId ?? activeSession?.id ?? null;
|
||||
const activeThemePreviewId = themePreview.targetSessionId === previewTargetSessionId
|
||||
? themePreview.themeId
|
||||
: null;
|
||||
|
||||
// Current theme/font/size for the focused session (for ThemeSidePanel)
|
||||
const focusedThemeId = resolveHostTerminalThemeId(focusedHost, terminalTheme.id);
|
||||
@@ -1190,6 +1364,190 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const focusedThemeOverridden = hasHostThemeOverride(focusedHost);
|
||||
const focusedFontFamilyOverridden = hasHostFontFamilyOverride(focusedHost);
|
||||
const focusedFontSizeOverridden = hasHostFontSizeOverride(focusedHost);
|
||||
const activeTopTabsThemeId = activeSidePanelTab === 'theme' && previewTargetSessionId
|
||||
? (activeThemePreviewId ?? focusedThemeId)
|
||||
: null;
|
||||
const appliedPreviewSessionRef = useRef<string | null>(null);
|
||||
const customThemes = useCustomThemes();
|
||||
const applyTerminalPreviewVars = useCallback((sessionId: string | null, themeId: string | null) => {
|
||||
if (!sessionId || !themeId || typeof document === 'undefined') {
|
||||
clearTerminalPreviewVars(sessionId);
|
||||
return;
|
||||
}
|
||||
const pane = document.querySelector<HTMLElement>(`[data-session-id="${sessionId}"]`);
|
||||
const theme = TERMINAL_THEMES.find((entry) => entry.id === themeId)
|
||||
|| customThemes.find((entry) => entry.id === themeId);
|
||||
if (!pane || !theme) {
|
||||
clearTerminalPreviewVars(sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
pane.style.setProperty('--terminal-preview-bg', theme.colors.background);
|
||||
pane.style.setProperty('--terminal-preview-fg', theme.colors.foreground);
|
||||
pane.style.setProperty('--terminal-preview-border', `color-mix(in srgb, ${theme.colors.foreground} 8%, ${theme.colors.background} 92%)`);
|
||||
pane.style.setProperty('--terminal-preview-toolbar-btn', `color-mix(in srgb, ${theme.colors.background} 88%, ${theme.colors.foreground} 12%)`);
|
||||
pane.style.setProperty('--terminal-preview-toolbar-btn-hover', `color-mix(in srgb, ${theme.colors.background} 78%, ${theme.colors.foreground} 22%)`);
|
||||
pane.style.setProperty('--terminal-preview-toolbar-btn-active', `color-mix(in srgb, ${theme.colors.background} 68%, ${theme.colors.foreground} 32%)`);
|
||||
}, [customThemes]);
|
||||
const applyTopTabsPreviewVars = useCallback((themeId: string | null) => {
|
||||
if (!themeId || typeof document === 'undefined') {
|
||||
clearTopTabsPreviewVars();
|
||||
return;
|
||||
}
|
||||
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
|
||||
const theme = TERMINAL_THEMES.find((entry) => entry.id === themeId)
|
||||
|| customThemes.find((entry) => entry.id === themeId);
|
||||
if (!tabsRoot || !theme) {
|
||||
clearTopTabsPreviewVars();
|
||||
return;
|
||||
}
|
||||
const bg = hexToHslToken(theme.colors.background);
|
||||
const fg = hexToHslToken(theme.colors.foreground);
|
||||
const accent = fg;
|
||||
const isDark = theme.type === 'dark';
|
||||
const secondary = adjustLightnessToken(bg, isDark ? 6 : -5);
|
||||
const border = adjustLightnessToken(bg, isDark ? 12 : -10);
|
||||
const mutedFg = adjustSaturationToken(adjustLightnessToken(fg, isDark ? -20 : 20), 0.5);
|
||||
|
||||
tabsRoot.style.setProperty('--background', bg);
|
||||
tabsRoot.style.setProperty('--foreground', fg);
|
||||
tabsRoot.style.setProperty('--accent', accent);
|
||||
tabsRoot.style.setProperty('--primary', accent);
|
||||
tabsRoot.style.setProperty('--secondary', secondary);
|
||||
tabsRoot.style.setProperty('--border', border);
|
||||
tabsRoot.style.setProperty('--muted-foreground', mutedFg);
|
||||
tabsRoot.style.setProperty('--top-tabs-bg', 'hsl(var(--secondary))');
|
||||
tabsRoot.style.setProperty('--top-tabs-fg', 'hsl(var(--foreground))');
|
||||
tabsRoot.style.setProperty('--top-tabs-muted', 'hsl(var(--muted-foreground))');
|
||||
tabsRoot.style.setProperty('--top-tabs-active-bg', 'hsl(var(--background))');
|
||||
tabsRoot.style.setProperty('--top-tabs-accent', 'hsl(var(--foreground))');
|
||||
}, [customThemes]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (themeCommitTimerRef.current) {
|
||||
clearTimeout(themeCommitTimerRef.current);
|
||||
}
|
||||
clearTerminalPreviewVars(appliedPreviewSessionRef.current);
|
||||
clearTopTabsPreviewVars();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const appliedSessionId = appliedPreviewSessionRef.current;
|
||||
if (
|
||||
appliedSessionId &&
|
||||
(appliedSessionId !== themePreview.targetSessionId || !themePreview.themeId)
|
||||
) {
|
||||
clearTerminalPreviewVars(appliedSessionId);
|
||||
appliedPreviewSessionRef.current = null;
|
||||
}
|
||||
|
||||
if (themePreview.targetSessionId && themePreview.themeId) {
|
||||
applyTerminalPreviewVars(themePreview.targetSessionId, themePreview.themeId);
|
||||
appliedPreviewSessionRef.current = themePreview.targetSessionId;
|
||||
}
|
||||
}, [applyTerminalPreviewVars, themePreview]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTopTabsThemeId) {
|
||||
applyTopTabsPreviewVars(activeTopTabsThemeId);
|
||||
return;
|
||||
}
|
||||
clearTopTabsPreviewVars();
|
||||
}, [activeTopTabsThemeId, applyTopTabsPreviewVars]);
|
||||
|
||||
useEffect(() => {
|
||||
const shouldKeepPreview =
|
||||
activeSidePanelTab === 'theme' &&
|
||||
!!previewTargetSessionId &&
|
||||
!!themePreview.targetSessionId &&
|
||||
!!themePreview.themeId;
|
||||
|
||||
if (shouldKeepPreview) return;
|
||||
|
||||
const appliedSessionId = appliedPreviewSessionRef.current;
|
||||
if (appliedSessionId) {
|
||||
clearTerminalPreviewVars(appliedSessionId);
|
||||
appliedPreviewSessionRef.current = null;
|
||||
}
|
||||
clearTopTabsPreviewVars();
|
||||
|
||||
if (themePreview.targetSessionId || themePreview.themeId) {
|
||||
setThemePreview({ targetSessionId: null, themeId: null });
|
||||
}
|
||||
}, [activeSidePanelTab, previewTargetSessionId, themePreview.targetSessionId, themePreview.themeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
themePreview.targetSessionId === previewTargetSessionId &&
|
||||
themePreview.themeId &&
|
||||
themePreview.themeId === focusedThemeId
|
||||
) {
|
||||
setThemePreview({ targetSessionId: null, themeId: null });
|
||||
}
|
||||
}, [focusedThemeId, previewTargetSessionId, themePreview]);
|
||||
|
||||
const handleThemeChangeForFocusedSession = useCallback((themeId: string) => {
|
||||
if (!focusedHost || themeId === focusedThemeId) return;
|
||||
applyTerminalPreviewVars(previewTargetSessionId, themeId);
|
||||
applyTopTabsPreviewVars(themeId);
|
||||
setThemePreview({ targetSessionId: previewTargetSessionId, themeId });
|
||||
if (themeCommitTimerRef.current) {
|
||||
clearTimeout(themeCommitTimerRef.current);
|
||||
}
|
||||
themeCommitTimerRef.current = setTimeout(() => {
|
||||
startTransition(() => {
|
||||
if (isFocusedHostLocal) {
|
||||
onUpdateTerminalThemeId?.(themeId);
|
||||
return;
|
||||
}
|
||||
onUpdateHost({ ...focusedHost, theme: themeId, themeOverride: true });
|
||||
});
|
||||
}, 160);
|
||||
}, [applyTerminalPreviewVars, applyTopTabsPreviewVars, focusedHost, focusedThemeId, isFocusedHostLocal, onUpdateTerminalThemeId, onUpdateHost, previewTargetSessionId]);
|
||||
|
||||
const handleThemeResetForFocusedSession = useCallback(() => {
|
||||
if (themeCommitTimerRef.current) {
|
||||
clearTimeout(themeCommitTimerRef.current);
|
||||
}
|
||||
clearTerminalPreviewVars(previewTargetSessionId);
|
||||
setThemePreview({ targetSessionId: null, themeId: null });
|
||||
if (!focusedHost || isFocusedHostLocal) return;
|
||||
onUpdateHost(clearHostThemeOverride(focusedHost));
|
||||
}, [focusedHost, isFocusedHostLocal, onUpdateHost, previewTargetSessionId]);
|
||||
|
||||
const handleFontFamilyChangeForFocusedSession = useCallback((fontFamilyId: string) => {
|
||||
if (!focusedHost || fontFamilyId === focusedFontFamilyId) return;
|
||||
startTransition(() => {
|
||||
if (isFocusedHostLocal) {
|
||||
onUpdateTerminalFontFamilyId?.(fontFamilyId);
|
||||
return;
|
||||
}
|
||||
onUpdateHost({ ...focusedHost, fontFamily: fontFamilyId, fontFamilyOverride: true });
|
||||
});
|
||||
}, [focusedHost, focusedFontFamilyId, isFocusedHostLocal, onUpdateTerminalFontFamilyId, onUpdateHost]);
|
||||
|
||||
const handleFontFamilyResetForFocusedSession = useCallback(() => {
|
||||
if (!focusedHost || isFocusedHostLocal) return;
|
||||
onUpdateHost(clearHostFontFamilyOverride(focusedHost));
|
||||
}, [focusedHost, isFocusedHostLocal, onUpdateHost]);
|
||||
|
||||
const handleFontSizeChangeForFocusedSession = useCallback((newFontSize: number) => {
|
||||
if (!focusedHost || newFontSize === focusedFontSize) return;
|
||||
startTransition(() => {
|
||||
if (isFocusedHostLocal) {
|
||||
onUpdateTerminalFontSize?.(newFontSize);
|
||||
return;
|
||||
}
|
||||
onUpdateHost({ ...focusedHost, fontSize: newFontSize, fontSizeOverride: true });
|
||||
});
|
||||
}, [focusedHost, focusedFontSize, isFocusedHostLocal, onUpdateTerminalFontSize, onUpdateHost]);
|
||||
|
||||
const handleFontSizeResetForFocusedSession = useCallback(() => {
|
||||
if (!focusedHost || isFocusedHostLocal) return;
|
||||
onUpdateHost(clearHostFontSizeOverride(focusedHost));
|
||||
}, [focusedHost, isFocusedHostLocal, onUpdateHost]);
|
||||
|
||||
// Keep MCP/ACP approval IPC listener alive for the entire terminal lifecycle.
|
||||
// Must live here (TerminalLayer), not inside the AI panel subtree, so closing
|
||||
@@ -1283,20 +1641,25 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Subscribe to custom theme changes so editing triggers re-render
|
||||
const customThemes = useCustomThemes();
|
||||
const resolvedPreviewTheme = useMemo(() => {
|
||||
const themeId = activeThemePreviewId ?? focusedThemeId;
|
||||
return TERMINAL_THEMES.find((theme) => theme.id === themeId)
|
||||
|| customThemes.find((theme) => theme.id === themeId)
|
||||
|| terminalTheme;
|
||||
}, [activeThemePreviewId, customThemes, focusedThemeId, terminalTheme]);
|
||||
const sessionLogConfig = useMemo(
|
||||
() =>
|
||||
sessionLogsEnabled && sessionLogsDir
|
||||
? { enabled: true as const, directory: sessionLogsDir, format: sessionLogsFormat || 'txt' }
|
||||
: undefined,
|
||||
[sessionLogsDir, sessionLogsEnabled, sessionLogsFormat],
|
||||
);
|
||||
|
||||
// Resolve the effective theme for the compose bar in workspace mode
|
||||
const composeBarThemeColors = useMemo(() => {
|
||||
if (!activeWorkspace || !focusedSessionId) return terminalTheme.colors;
|
||||
const focusedHost = sessionHostsMap.get(focusedSessionId);
|
||||
if (focusedHost?.theme) {
|
||||
const hostTheme = TERMINAL_THEMES.find(t => t.id === focusedHost.theme)
|
||||
|| customThemes.find(t => t.id === focusedHost.theme);
|
||||
if (hostTheme) return hostTheme.colors;
|
||||
}
|
||||
return terminalTheme.colors;
|
||||
}, [activeWorkspace, focusedSessionId, sessionHostsMap, terminalTheme, customThemes]);
|
||||
return resolvedPreviewTheme.colors;
|
||||
}, [activeWorkspace, focusedSessionId, resolvedPreviewTheme, terminalTheme.colors]);
|
||||
|
||||
// Handle compose bar send for workspace mode
|
||||
const handleComposeSend = useCallback((text: string) => {
|
||||
@@ -1334,14 +1697,16 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
// Track previous focusedSessionId to detect changes
|
||||
const prevFocusedSessionIdRef = useRef<string | undefined>(undefined);
|
||||
|
||||
// When focusedSessionId changes in split view, focus the corresponding terminal
|
||||
// When focusedSessionId changes or terminal layer becomes visible,
|
||||
// focus the corresponding terminal to restore :focus-within CSS state
|
||||
useEffect(() => {
|
||||
// Only handle split view mode (not focus mode)
|
||||
if (isFocusMode || !focusedSessionId || !activeWorkspace) return;
|
||||
|
||||
// Only trigger when focusedSessionId actually changes
|
||||
if (prevFocusedSessionIdRef.current === focusedSessionId) return;
|
||||
const prevFocusedId = prevFocusedSessionIdRef.current;
|
||||
// Trigger on focusedSessionId change OR when layer becomes visible again
|
||||
const sessionChanged = prevFocusedSessionIdRef.current !== focusedSessionId;
|
||||
if (!sessionChanged && !isTerminalLayerVisible) return;
|
||||
const prevFocusedId = sessionChanged ? prevFocusedSessionIdRef.current : undefined;
|
||||
prevFocusedSessionIdRef.current = focusedSessionId;
|
||||
|
||||
// First, blur the currently focused terminal immediately
|
||||
@@ -1379,7 +1744,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
clearTimeout(timer2);
|
||||
clearTimeout(timer3);
|
||||
};
|
||||
}, [focusedSessionId, isFocusMode, activeWorkspace]);
|
||||
}, [focusedSessionId, isFocusMode, activeWorkspace, isTerminalLayerVisible]);
|
||||
|
||||
// Get sessions for the active workspace in focus mode
|
||||
const workspaceSessionIds = useMemo(() => {
|
||||
@@ -1388,7 +1753,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
}, [activeWorkspace]);
|
||||
|
||||
const workspaceSessions = useMemo(() => {
|
||||
return sessions.filter(s => workspaceSessionIds.includes(s.id));
|
||||
const idSet = new Set(workspaceSessionIds);
|
||||
return sessions.filter(s => idSet.has(s.id));
|
||||
}, [sessions, workspaceSessionIds]);
|
||||
|
||||
// Render focus mode sidebar
|
||||
@@ -1467,7 +1833,11 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
<div
|
||||
ref={workspaceOuterRef}
|
||||
className="absolute inset-0 bg-background flex flex-col"
|
||||
style={{ display: isTerminalLayerVisible ? 'flex' : 'none', zIndex: isTerminalLayerVisible ? 10 : 0 }}
|
||||
style={{
|
||||
visibility: isTerminalLayerVisible ? 'visible' : 'hidden',
|
||||
pointerEvents: isTerminalLayerVisible ? 'auto' : 'none',
|
||||
zIndex: isTerminalLayerVisible ? 10 : 0,
|
||||
}}
|
||||
>
|
||||
<div className={cn("flex-1 flex min-h-0 relative", sidePanelPosition === 'right' && "flex-row-reverse")}>
|
||||
{/* Side panel with tab header + content (SFTP / Scripts / Theme) */}
|
||||
@@ -1493,19 +1863,32 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
"h-full flex flex-col overflow-hidden",
|
||||
!isSidePanelOpenForCurrentTab && "pointer-events-none",
|
||||
)}
|
||||
>
|
||||
style={{
|
||||
['--terminal-sidepanel-bg' as never]: resolvedPreviewTheme.colors.background,
|
||||
['--terminal-sidepanel-fg' as never]: resolvedPreviewTheme.colors.foreground,
|
||||
['--terminal-sidepanel-muted' as never]: `color-mix(in srgb, ${resolvedPreviewTheme.colors.foreground} 62%, ${resolvedPreviewTheme.colors.background} 38%)`,
|
||||
['--terminal-sidepanel-border' as never]: `color-mix(in srgb, ${resolvedPreviewTheme.colors.foreground} 12%, ${resolvedPreviewTheme.colors.background} 88%)`,
|
||||
backgroundColor: 'var(--terminal-sidepanel-bg)',
|
||||
color: 'var(--terminal-sidepanel-fg)',
|
||||
borderColor: 'var(--terminal-sidepanel-border)',
|
||||
}}
|
||||
>
|
||||
{isSidePanelOpenForCurrentTab && (
|
||||
<div className="flex h-9 items-center px-1.5 py-1 flex-shrink-0 gap-1">
|
||||
<div
|
||||
className="flex h-9 items-center px-1.5 py-1 flex-shrink-0 gap-1"
|
||||
style={{
|
||||
borderBottom: '1px solid var(--terminal-sidepanel-border)',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-7 w-7 rounded-md p-0",
|
||||
activeSidePanelTab === 'sftp'
|
||||
? "text-foreground opacity-100"
|
||||
: "text-muted-foreground opacity-70 hover:opacity-100",
|
||||
"hover:bg-transparent",
|
||||
)}
|
||||
className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
|
||||
style={{
|
||||
color: activeSidePanelTab === 'sftp'
|
||||
? 'var(--terminal-sidepanel-fg)'
|
||||
: 'var(--terminal-sidepanel-muted)',
|
||||
}}
|
||||
onClick={handleToggleSftpFromBar}
|
||||
title="SFTP"
|
||||
>
|
||||
@@ -1514,13 +1897,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-7 w-7 rounded-md p-0",
|
||||
activeSidePanelTab === 'scripts'
|
||||
? "text-foreground opacity-100"
|
||||
: "text-muted-foreground opacity-70 hover:opacity-100",
|
||||
"hover:bg-transparent",
|
||||
)}
|
||||
className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
|
||||
style={{
|
||||
color: activeSidePanelTab === 'scripts'
|
||||
? 'var(--terminal-sidepanel-fg)'
|
||||
: 'var(--terminal-sidepanel-muted)',
|
||||
}}
|
||||
onClick={handleOpenScripts}
|
||||
title="Scripts"
|
||||
>
|
||||
@@ -1529,13 +1911,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-7 w-7 rounded-md p-0",
|
||||
activeSidePanelTab === 'theme'
|
||||
? "text-foreground opacity-100"
|
||||
: "text-muted-foreground opacity-70 hover:opacity-100",
|
||||
"hover:bg-transparent",
|
||||
)}
|
||||
className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
|
||||
style={{
|
||||
color: activeSidePanelTab === 'theme'
|
||||
? 'var(--terminal-sidepanel-fg)'
|
||||
: 'var(--terminal-sidepanel-muted)',
|
||||
}}
|
||||
onClick={handleOpenTheme}
|
||||
title="Theme"
|
||||
>
|
||||
@@ -1544,13 +1925,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-7 w-7 rounded-md p-0",
|
||||
activeSidePanelTab === 'ai'
|
||||
? "text-foreground opacity-100"
|
||||
: "text-muted-foreground opacity-70 hover:opacity-100",
|
||||
"hover:bg-transparent",
|
||||
)}
|
||||
className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
|
||||
style={{
|
||||
color: activeSidePanelTab === 'ai'
|
||||
? 'var(--terminal-sidepanel-fg)'
|
||||
: 'var(--terminal-sidepanel-muted)',
|
||||
}}
|
||||
onClick={handleOpenAI}
|
||||
title="AI Chat"
|
||||
>
|
||||
@@ -1560,10 +1940,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-7 w-7 rounded-md p-0 text-muted-foreground",
|
||||
"hover:bg-transparent hover:text-foreground",
|
||||
)}
|
||||
className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
|
||||
style={{
|
||||
color: 'var(--terminal-sidepanel-muted)',
|
||||
}}
|
||||
onClick={() => setSidePanelPosition(p => p === 'left' ? 'right' : 'left')}
|
||||
title={sidePanelPosition === 'left' ? 'Move panel to right' : 'Move panel to left'}
|
||||
>
|
||||
@@ -1572,10 +1952,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-7 w-7 rounded-md p-0 text-muted-foreground",
|
||||
"hover:bg-transparent hover:text-foreground",
|
||||
)}
|
||||
className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
|
||||
style={{
|
||||
color: 'var(--terminal-sidepanel-muted)',
|
||||
}}
|
||||
onClick={handleCloseSidePanel}
|
||||
title="Close panel"
|
||||
>
|
||||
@@ -1631,7 +2011,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
{activeSidePanelTab === 'theme' && (
|
||||
<div className="absolute inset-0 z-10">
|
||||
<ThemeSidePanel
|
||||
currentThemeId={focusedThemeId}
|
||||
currentThemeId={activeThemePreviewId ?? focusedThemeId}
|
||||
globalThemeId={terminalTheme.id}
|
||||
currentFontFamilyId={focusedFontFamilyId}
|
||||
globalFontFamilyId={terminalFontFamilyId}
|
||||
@@ -1645,6 +2025,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onFontFamilyReset={handleFontFamilyResetForFocusedSession}
|
||||
onFontSizeChange={handleFontSizeChangeForFocusedSession}
|
||||
onFontSizeReset={handleFontSizeResetForFocusedSession}
|
||||
previewColors={resolvedPreviewTheme.colors}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -1731,7 +2112,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const style: React.CSSProperties = { ...layoutStyle };
|
||||
|
||||
if (!isVisible) {
|
||||
style.display = 'none';
|
||||
style.visibility = 'hidden';
|
||||
style.pointerEvents = 'none';
|
||||
// Use absolute offscreen position instead of display:none to preserve
|
||||
// xterm canvas state in memory and avoid full re-render on tab switch.
|
||||
style.left = '-9999px';
|
||||
style.top = '-9999px';
|
||||
}
|
||||
|
||||
// Check if this pane is the focused one in the workspace
|
||||
@@ -1753,7 +2139,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
"absolute bg-background",
|
||||
inActiveWorkspace && "workspace-pane",
|
||||
isVisible && "z-10",
|
||||
isFocusedPane && "ring-1 ring-primary/50 ring-inset"
|
||||
// Focus indicator is handled by CSS .workspace-pane:not(:focus-within)
|
||||
)}
|
||||
style={style}
|
||||
tabIndex={-1}
|
||||
@@ -1769,7 +2155,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
snippets={snippets}
|
||||
allHosts={hosts}
|
||||
chainHosts={sessionChainHostsMap.get(session.id)}
|
||||
themePreviewId={session.id === previewTargetSessionId ? activeThemePreviewId ?? undefined : undefined}
|
||||
knownHosts={knownHosts}
|
||||
isVisible={isVisible}
|
||||
inWorkspace={inActiveWorkspace}
|
||||
@@ -1807,7 +2194,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
isWorkspaceComposeBarOpen={inActiveWorkspace ? isComposeBarOpen : undefined}
|
||||
onBroadcastInput={inActiveWorkspace && activeWorkspace && isBroadcastEnabled?.(activeWorkspace.id) ? handleBroadcastInput : undefined}
|
||||
onSnippetExecutorChange={handleSnippetExecutorChange}
|
||||
sessionLog={sessionLogsEnabled && sessionLogsDir ? { enabled: true, directory: sessionLogsDir, format: sessionLogsFormat || 'txt' } : undefined}
|
||||
sessionLog={sessionLogConfig}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
@@ -55,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') {
|
||||
@@ -82,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>
|
||||
);
|
||||
@@ -109,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)
|
||||
@@ -168,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 ? (
|
||||
@@ -227,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;
|
||||
@@ -330,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>();
|
||||
@@ -453,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';
|
||||
@@ -472,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)}
|
||||
@@ -524,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] || {};
|
||||
@@ -544,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>
|
||||
@@ -597,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>
|
||||
@@ -642,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 */}
|
||||
@@ -658,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>
|
||||
@@ -698,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)' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -715,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"
|
||||
>
|
||||
@@ -729,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>
|
||||
@@ -740,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"
|
||||
>
|
||||
@@ -752,20 +906,22 @@ 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"
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
X,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import React, { Suspense, lazy, memo, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import React, { Suspense, lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useStoredViewMode } from "../application/state/useStoredViewMode";
|
||||
import { useStoredBoolean } from "../application/state/useStoredBoolean";
|
||||
@@ -37,6 +37,7 @@ import { importVaultHostsFromText, exportHostsToCsvWithStats } from "../domain/v
|
||||
import type { VaultImportFormat } from "../domain/vaultImport";
|
||||
import { STORAGE_KEY_VAULT_HOSTS_VIEW_MODE, STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED, STORAGE_KEY_VAULT_SIDEBAR_COLLAPSED } from "../infrastructure/config/storageKeys";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useInstantThemeSwitch } from "../lib/useInstantThemeSwitch";
|
||||
import {
|
||||
ConnectionLog,
|
||||
GroupNode,
|
||||
@@ -109,10 +110,12 @@ interface VaultViewProps {
|
||||
sessions: TerminalSession[];
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
keyBindings: KeyBinding[];
|
||||
terminalThemeId: string;
|
||||
terminalFontSize: number;
|
||||
onOpenSettings: () => void;
|
||||
onOpenQuickSwitcher: () => void;
|
||||
onCreateLocalTerminal: () => void;
|
||||
onConnectSerial?: (config: SerialConfig) => void;
|
||||
onConnectSerial?: (config: SerialConfig, options?: { charset?: string }) => void;
|
||||
onDeleteHost: (id: string) => void;
|
||||
onConnect: (host: Host) => void;
|
||||
onUpdateHosts: (hosts: Host[]) => void;
|
||||
@@ -151,6 +154,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
sessions,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
terminalThemeId,
|
||||
terminalFontSize,
|
||||
onOpenSettings,
|
||||
onOpenQuickSwitcher,
|
||||
onCreateLocalTerminal,
|
||||
@@ -178,6 +183,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
onNavigateToSectionHandled,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const [currentSection, setCurrentSection] = useState<VaultSection>("hosts");
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedGroupPath, setSelectedGroupPath] = useState<string | null>(
|
||||
@@ -196,6 +202,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
const [deleteTargetPath, setDeleteTargetPath] = useState<string | null>(null);
|
||||
const [deleteGroupWithHosts, setDeleteGroupWithHosts] = useState(false);
|
||||
|
||||
useInstantThemeSwitch(rootRef);
|
||||
|
||||
// Sidebar collapsed state with localStorage persistence
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useStoredBoolean(
|
||||
STORAGE_KEY_VAULT_SIDEBAR_COLLAPSED,
|
||||
@@ -1272,7 +1280,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
|
||||
// Component no longer handles visibility - that's done by VaultViewWrapper
|
||||
return (
|
||||
<div className="absolute inset-0 min-h-0 flex">
|
||||
<div ref={rootRef} className="absolute inset-0 min-h-0 flex">
|
||||
{/* Sidebar */}
|
||||
<TooltipProvider delayDuration={100}>
|
||||
<div className={cn(
|
||||
@@ -2302,6 +2310,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
allTags={allTags}
|
||||
allHosts={hosts}
|
||||
defaultGroup={editingHost ? undefined : (newHostGroupPath || selectedGroupPath)}
|
||||
terminalThemeId={terminalThemeId}
|
||||
terminalFontSize={terminalFontSize}
|
||||
onSave={(host) => {
|
||||
// Check if host already exists in the list (for updates vs. new/duplicate)
|
||||
const hostExists = hosts.some((h) => h.id === host.id);
|
||||
@@ -2538,9 +2548,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
<SerialConnectModal
|
||||
open={isSerialModalOpen}
|
||||
onClose={() => setIsSerialModalOpen(false)}
|
||||
onConnect={(config) => {
|
||||
onConnect={(config, options) => {
|
||||
if (onConnectSerial) {
|
||||
onConnectSerial(config);
|
||||
onConnectSerial(config, options);
|
||||
}
|
||||
}}
|
||||
onSaveHost={(host) => {
|
||||
@@ -2567,7 +2577,9 @@ const vaultViewAreEqual = (
|
||||
prev.shellHistory === next.shellHistory &&
|
||||
prev.connectionLogs === next.connectionLogs &&
|
||||
prev.sessions === next.sessions &&
|
||||
prev.managedSources === next.managedSources;
|
||||
prev.managedSources === next.managedSources &&
|
||||
prev.terminalThemeId === next.terminalThemeId &&
|
||||
prev.terminalFontSize === next.terminalFontSize;
|
||||
|
||||
return isEqual;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -25,8 +25,8 @@ export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
export const MessageContent = ({ children, className, ...props }: MessageContentProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-fit min-w-0 max-w-full flex-col gap-1.5 overflow-hidden text-[13px] leading-relaxed',
|
||||
'group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:border group-[.is-user]:border-border/50 group-[.is-user]:bg-muted/50 group-[.is-user]:px-2.5 group-[.is-user]:py-2',
|
||||
'flex w-fit min-w-0 max-w-full flex-col gap-1.5 text-[13px] leading-relaxed',
|
||||
'group-[.is-user]:ml-auto group-[.is-user]:overflow-hidden group-[.is-user]:rounded-lg group-[.is-user]:border group-[.is-user]:border-border/50 group-[.is-user]:bg-muted/50 group-[.is-user]:px-2.5 group-[.is-user]:py-2',
|
||||
'group-[.is-assistant]:w-full group-[.is-assistant]:text-foreground/90',
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -5,6 +5,39 @@ import { Button } from '../ui/button';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
|
||||
/**
|
||||
* Format tool result for display. Extracts stdout/stderr from structured
|
||||
* command results for terminal-like output.
|
||||
*/
|
||||
function formatToolResult(result: unknown): string {
|
||||
let parsed = result;
|
||||
|
||||
if (typeof parsed === 'string') {
|
||||
try {
|
||||
const obj = JSON.parse(parsed);
|
||||
if (obj && typeof obj === 'object') parsed = obj;
|
||||
} catch {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
if (typeof obj.stdout === 'string' || typeof obj.stderr === 'string') {
|
||||
const parts: string[] = [];
|
||||
if (typeof obj.stdout === 'string' && obj.stdout) parts.push(obj.stdout);
|
||||
if (typeof obj.stderr === 'string' && obj.stderr) parts.push(obj.stderr);
|
||||
if (typeof obj.exitCode === 'number' && obj.exitCode !== 0) {
|
||||
parts.push(`exit code: ${obj.exitCode}`);
|
||||
}
|
||||
if (parts.length > 0) return parts.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof parsed === 'string') return parsed;
|
||||
return JSON.stringify(parsed, null, 2);
|
||||
}
|
||||
|
||||
export interface ToolCallProps extends HTMLAttributes<HTMLDivElement> {
|
||||
name: string;
|
||||
args?: Record<string, unknown>;
|
||||
@@ -133,7 +166,7 @@ export const ToolCall = ({
|
||||
{args && Object.keys(args).length > 0 && (
|
||||
<div className="px-3 py-2">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30 mb-1">Arguments</div>
|
||||
<pre className="text-[11px] font-mono text-muted-foreground/50 whitespace-pre-wrap break-all">
|
||||
<pre className="max-h-64 overflow-auto text-[11px] font-mono text-muted-foreground/50 whitespace-pre [overflow-wrap:normal]">
|
||||
{JSON.stringify(args, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -174,10 +207,10 @@ export const ToolCall = ({
|
||||
<div className="px-3 py-2 border-t border-border/20">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30 mb-1">Result</div>
|
||||
<pre className={cn(
|
||||
'text-[11px] font-mono whitespace-pre-wrap break-all',
|
||||
'max-h-64 overflow-auto text-[11px] font-mono whitespace-pre [overflow-wrap:normal]',
|
||||
isError ? 'text-red-400/60' : 'text-muted-foreground/50',
|
||||
)}>
|
||||
{typeof result === 'string' ? result : JSON.stringify(result, null, 2)}
|
||||
{formatToolResult(result)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -229,7 +229,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
value={value}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
placeholder={placeholder || defaultPlaceholder}
|
||||
disabled={disabled || isStreaming}
|
||||
disabled={disabled}
|
||||
className={expanded ? 'max-h-[220px]' : undefined}
|
||||
/>
|
||||
<button
|
||||
|
||||
@@ -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';
|
||||
@@ -125,6 +126,7 @@ export interface TerminalSessionInfo {
|
||||
username?: string;
|
||||
protocol?: string;
|
||||
shellType?: string;
|
||||
deviceType?: string;
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
@@ -186,6 +188,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 +323,7 @@ export function useAIChatStreaming({
|
||||
sdkMessages: Array<ModelMessage>,
|
||||
signal: AbortSignal,
|
||||
currentAssistantMsgId: string,
|
||||
advancedParams?: ProviderAdvancedParams,
|
||||
): Promise<void> => {
|
||||
const result = streamText({
|
||||
model,
|
||||
@@ -328,6 +332,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
|
||||
@@ -680,6 +689,7 @@ export function useAIChatStreaming({
|
||||
username: s.username,
|
||||
protocol: s.protocol,
|
||||
shellType: s.shellType,
|
||||
deviceType: s.deviceType,
|
||||
connected: s.connected,
|
||||
})),
|
||||
permissionMode: context.globalPermissionMode,
|
||||
@@ -804,7 +814,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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { AlertCircle, ArrowDown, ChevronDown, ClipboardCopy, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderPlus, Loader2, Pencil, RefreshCw, Shield, Trash2 } from "lucide-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,
|
||||
@@ -65,20 +65,20 @@ const SftpErrorWithLogs: React.FC<{
|
||||
onRetry: () => void;
|
||||
t: (key: string) => string;
|
||||
}> = ({ error, connectionLogs, onRetry, t }) => {
|
||||
const [showLogs, setShowLogs] = useState(false);
|
||||
const [showLogs, setShowLogs] = useState(connectionLogs.length > 0);
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-2 text-destructive">
|
||||
<AlertCircle size={24} />
|
||||
<span className="text-sm text-center px-4">{t(error)}</span>
|
||||
<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" onClick={onRetry}>
|
||||
<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="text-muted-foreground"
|
||||
className="h-7 text-xs text-muted-foreground"
|
||||
onClick={() => setShowLogs(!showLogs)}
|
||||
>
|
||||
<ChevronDown size={14} className={`mr-1 transition-transform ${showLogs ? 'rotate-180' : ''}`} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -147,7 +147,8 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
|
||||
container.scrollLeft += tabRect.right - containerRect.right + 8;
|
||||
}
|
||||
}
|
||||
setTimeout(updateScrollState, 100);
|
||||
const timer = setTimeout(updateScrollState, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, [activeTabId, updateScrollState]);
|
||||
|
||||
// Drag handlers
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -108,10 +108,10 @@ export const useSftpPaneDragAndSelect = ({
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
const selectedNames = Array.from(selectedFilesRef.current);
|
||||
const files = selectedNames.includes(entry.name)
|
||||
const selectedNames = new Set(selectedFilesRef.current);
|
||||
const files = selectedNames.has(entry.name)
|
||||
? sortedFilesRef.current
|
||||
.filter((f) => selectedNames.includes(f.name))
|
||||
.filter((f) => selectedNames.has(f.name))
|
||||
.map((f) => ({
|
||||
name: f.name,
|
||||
isDirectory: isNavigableDirectory(f),
|
||||
|
||||
@@ -34,24 +34,41 @@ export const useSftpPaneSorting = (): UseSftpPaneSortingResult => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleResizeMove = useCallback((e: MouseEvent) => {
|
||||
const rafIdRef = useRef<number | null>(null);
|
||||
const lastClientXRef = useRef(0);
|
||||
|
||||
const applyColumnWidth = useCallback(() => {
|
||||
if (!resizingRef.current) return;
|
||||
const diff = e.clientX - resizingRef.current.startX;
|
||||
const { field, startX, startWidth } = resizingRef.current;
|
||||
const diff = lastClientXRef.current - startX;
|
||||
const newWidth = Math.max(
|
||||
10,
|
||||
Math.min(60, resizingRef.current.startWidth + diff / 5),
|
||||
Math.min(60, startWidth + diff / 5),
|
||||
);
|
||||
setColumnWidths((prev) => ({
|
||||
...prev,
|
||||
[resizingRef.current!.field]: newWidth,
|
||||
[field]: newWidth,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleResizeMove = useCallback((e: MouseEvent) => {
|
||||
if (!resizingRef.current) return;
|
||||
lastClientXRef.current = e.clientX;
|
||||
if (rafIdRef.current !== null) return;
|
||||
rafIdRef.current = requestAnimationFrame(() => {
|
||||
rafIdRef.current = null;
|
||||
applyColumnWidth();
|
||||
});
|
||||
}, [applyColumnWidth]);
|
||||
|
||||
const handleResizeEnd = useCallback(() => {
|
||||
if (rafIdRef.current !== null) cancelAnimationFrame(rafIdRef.current);
|
||||
applyColumnWidth();
|
||||
rafIdRef.current = null;
|
||||
resizingRef.current = null;
|
||||
document.removeEventListener("mousemove", handleResizeMove);
|
||||
document.removeEventListener("mouseup", handleResizeEnd);
|
||||
}, [handleResizeMove]);
|
||||
}, [applyColumnWidth, handleResizeMove]);
|
||||
|
||||
const handleResizeStart = (
|
||||
field: keyof ColumnWidths,
|
||||
@@ -59,6 +76,7 @@ export const useSftpPaneSorting = (): UseSftpPaneSortingResult => {
|
||||
) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
lastClientXRef.current = e.clientX;
|
||||
resizingRef.current = {
|
||||
field,
|
||||
startX: e.clientX,
|
||||
|
||||
@@ -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 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;
|
||||
}
|
||||
@@ -50,6 +50,7 @@ interface UseServerStatsOptions {
|
||||
refreshInterval: number; // Refresh interval in seconds
|
||||
isSupportedOs: boolean; // Only collect stats for Linux/macOS servers
|
||||
isConnected: boolean; // Only collect when connected
|
||||
isVisible: boolean; // Pause background polling for hidden terminals
|
||||
}
|
||||
|
||||
export function useServerStats({
|
||||
@@ -58,6 +59,7 @@ export function useServerStats({
|
||||
refreshInterval,
|
||||
isSupportedOs,
|
||||
isConnected,
|
||||
isVisible,
|
||||
}: UseServerStatsOptions) {
|
||||
const [stats, setStats] = useState<ServerStats>({
|
||||
cpu: null,
|
||||
@@ -84,9 +86,12 @@ export function useServerStats({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const isMountedRef = useRef(true);
|
||||
const hasFetchedRef = useRef(false);
|
||||
const connectedAtRef = useRef(0);
|
||||
const fetchGenerationRef = useRef(0);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
if (!enabled || !isSupportedOs || !isConnected || !sessionId) {
|
||||
if (!enabled || !isSupportedOs || !isConnected || !isVisible || !sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -95,15 +100,18 @@ export function useServerStats({
|
||||
return;
|
||||
}
|
||||
|
||||
const generation = ++fetchGenerationRef.current;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await bridge.getServerStats(sessionId);
|
||||
|
||||
if (!isMountedRef.current) return;
|
||||
// Discard stale responses from before a hide/show cycle or reconnect
|
||||
if (!isMountedRef.current || generation !== fetchGenerationRef.current) return;
|
||||
|
||||
if (result.success && result.stats) {
|
||||
hasFetchedRef.current = true;
|
||||
setStats({
|
||||
cpu: result.stats.cpu,
|
||||
cpuCores: result.stats.cpuCores,
|
||||
@@ -129,15 +137,15 @@ export function useServerStats({
|
||||
setError(result.error);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isMountedRef.current) {
|
||||
if (isMountedRef.current && generation === fetchGenerationRef.current) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
if (isMountedRef.current && generation === fetchGenerationRef.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, [sessionId, enabled, isSupportedOs, isConnected]);
|
||||
}, [sessionId, enabled, isSupportedOs, isConnected, isVisible]);
|
||||
|
||||
// Initial fetch and periodic refresh
|
||||
useEffect(() => {
|
||||
@@ -150,7 +158,10 @@ export function useServerStats({
|
||||
}
|
||||
|
||||
if (!enabled || !isSupportedOs || !isConnected) {
|
||||
// Reset stats when disabled or not connected
|
||||
// Reset stats and fetch state when disabled or not connected
|
||||
hasFetchedRef.current = false;
|
||||
connectedAtRef.current = 0;
|
||||
|
||||
setStats({
|
||||
cpu: null,
|
||||
cpuCores: null,
|
||||
@@ -175,10 +186,43 @@ export function useServerStats({
|
||||
return;
|
||||
}
|
||||
|
||||
// Initial fetch with a small delay to let the connection stabilize
|
||||
const initialTimer = setTimeout(() => {
|
||||
fetchStats();
|
||||
}, 2000);
|
||||
// Track when the connection became available for delay calculation
|
||||
// (must be before the isVisible check so hidden tabs record connection time)
|
||||
if (connectedAtRef.current === 0) {
|
||||
connectedAtRef.current = Date.now();
|
||||
}
|
||||
|
||||
if (!isVisible) {
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Invalidate any in-flight request from a previous visible/hidden cycle
|
||||
// so stale responses don't overwrite the reset network stats below.
|
||||
fetchGenerationRef.current++;
|
||||
|
||||
// Fetch immediately when resuming from hidden, or with a delay on first connect.
|
||||
// When resuming, reset delta-based network stats (both aggregate and per-interface)
|
||||
// so the first sample doesn't show averaged-over-hidden-interval throughput.
|
||||
if (hasFetchedRef.current) {
|
||||
setStats(prev => ({
|
||||
...prev,
|
||||
netRxSpeed: 0,
|
||||
netTxSpeed: 0,
|
||||
netInterfaces: prev.netInterfaces.map(iface => ({ ...iface, rxSpeed: 0, txSpeed: 0 })),
|
||||
}));
|
||||
}
|
||||
// Skip the warmup delay if the connection has been established long enough
|
||||
// (e.g., tab was hidden while connected and is now becoming visible).
|
||||
const connectionAge = Date.now() - connectedAtRef.current;
|
||||
const needsWarmup = !hasFetchedRef.current && connectionAge < 2000;
|
||||
const initialTimer = setTimeout(fetchStats, needsWarmup ? 2000 : 0);
|
||||
|
||||
// Set up periodic refresh
|
||||
const intervalMs = Math.max(5, refreshInterval) * 1000; // Minimum 5 seconds
|
||||
@@ -192,7 +236,7 @@ export function useServerStats({
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [enabled, isSupportedOs, isConnected, refreshInterval, fetchStats]);
|
||||
}, [enabled, isSupportedOs, isConnected, isVisible, refreshInterval, fetchStats]);
|
||||
|
||||
// Manual refresh function
|
||||
const refresh = useCallback(() => {
|
||||
|
||||
@@ -5,6 +5,22 @@ import type { RefObject } from "react";
|
||||
|
||||
type SearchMatchCount = { current: number; total: number } | null;
|
||||
|
||||
const SEARCH_DECORATIONS = {
|
||||
matchBackground: "#FFFF0044",
|
||||
matchBorder: "#FFFF00",
|
||||
matchOverviewRuler: "#FFFF00",
|
||||
activeMatchBackground: "#FF880088",
|
||||
activeMatchBorder: "#FF8800",
|
||||
activeMatchColorOverviewRuler: "#FF8800",
|
||||
} as const;
|
||||
|
||||
const SEARCH_OPTIONS = {
|
||||
regex: false,
|
||||
caseSensitive: false,
|
||||
wholeWord: false,
|
||||
decorations: SEARCH_DECORATIONS,
|
||||
} as const;
|
||||
|
||||
export const useTerminalSearch = ({
|
||||
searchAddonRef,
|
||||
termRef,
|
||||
@@ -39,19 +55,7 @@ export const useTerminalSearch = ({
|
||||
searchTermRef.current = term;
|
||||
searchAddon.clearDecorations();
|
||||
|
||||
const found = searchAddon.findNext(term, {
|
||||
regex: false,
|
||||
caseSensitive: false,
|
||||
wholeWord: false,
|
||||
decorations: {
|
||||
matchBackground: "#FFFF0044",
|
||||
matchBorder: "#FFFF00",
|
||||
matchOverviewRuler: "#FFFF00",
|
||||
activeMatchBackground: "#FF880088",
|
||||
activeMatchBorder: "#FF8800",
|
||||
activeMatchColorOverviewRuler: "#FF8800",
|
||||
},
|
||||
});
|
||||
const found = searchAddon.findNext(term, SEARCH_OPTIONS);
|
||||
|
||||
if (found) {
|
||||
setSearchMatchCount({ current: 1, total: 1 });
|
||||
@@ -68,38 +72,14 @@ export const useTerminalSearch = ({
|
||||
const searchAddon = searchAddonRef.current;
|
||||
const term = searchTermRef.current;
|
||||
if (!searchAddon || !term) return false;
|
||||
return searchAddon.findNext(term, {
|
||||
regex: false,
|
||||
caseSensitive: false,
|
||||
wholeWord: false,
|
||||
decorations: {
|
||||
matchBackground: "#FFFF0044",
|
||||
matchBorder: "#FFFF00",
|
||||
matchOverviewRuler: "#FFFF00",
|
||||
activeMatchBackground: "#FF880088",
|
||||
activeMatchBorder: "#FF8800",
|
||||
activeMatchColorOverviewRuler: "#FF8800",
|
||||
},
|
||||
});
|
||||
return searchAddon.findNext(term, SEARCH_OPTIONS);
|
||||
}, [searchAddonRef]);
|
||||
|
||||
const handleFindPrevious = useCallback((): boolean => {
|
||||
const searchAddon = searchAddonRef.current;
|
||||
const term = searchTermRef.current;
|
||||
if (!searchAddon || !term) return false;
|
||||
return searchAddon.findPrevious(term, {
|
||||
regex: false,
|
||||
caseSensitive: false,
|
||||
wholeWord: false,
|
||||
decorations: {
|
||||
matchBackground: "#FFFF0044",
|
||||
matchBorder: "#FFFF00",
|
||||
matchOverviewRuler: "#FFFF00",
|
||||
activeMatchBackground: "#FF880088",
|
||||
activeMatchBorder: "#FF8800",
|
||||
activeMatchColorOverviewRuler: "#FF8800",
|
||||
},
|
||||
});
|
||||
return searchAddon.findPrevious(term, SEARCH_OPTIONS);
|
||||
}, [searchAddonRef]);
|
||||
|
||||
const handleCloseSearch = useCallback(() => {
|
||||
|
||||
@@ -854,6 +854,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
stopBits: ctx.serialConfig.stopBits,
|
||||
parity: ctx.serialConfig.parity,
|
||||
flowControl: ctx.serialConfig.flowControl,
|
||||
charset: ctx.host.charset,
|
||||
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { SearchAddon } from "@xterm/addon-search";
|
||||
import { SerializeAddon } from "@xterm/addon-serialize";
|
||||
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { WebglAddon } from "@xterm/addon-webgl";
|
||||
import { Terminal as XTerm } from "@xterm/xterm";
|
||||
@@ -101,6 +102,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 => {
|
||||
@@ -348,6 +354,10 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
});
|
||||
term.loadAddon(webLinksAddon);
|
||||
|
||||
// Enable Unicode 11 for better Nerd Fonts / Powerline / CJK character width handling
|
||||
term.loadAddon(new Unicode11Addon());
|
||||
term.unicode.activeVersion = '11';
|
||||
|
||||
logRenderer();
|
||||
|
||||
const appLevelActions = getAppLevelActions();
|
||||
@@ -375,6 +385,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 +584,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 {
|
||||
@@ -68,6 +69,9 @@ export interface Host {
|
||||
group?: string;
|
||||
tags: string[];
|
||||
os: 'linux' | 'windows' | 'macos';
|
||||
// Device type: 'general' for standard servers, 'network' for switches/routers/firewalls.
|
||||
// Network devices use raw command execution (no shell wrapping) for AI agent compatibility.
|
||||
deviceType?: 'general' | 'network';
|
||||
identityFileId?: string; // Reference to SSHKey
|
||||
protocol?: 'ssh' | 'telnet' | 'local' | 'serial'; // Default/primary protocol
|
||||
password?: string;
|
||||
@@ -445,6 +449,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)';
|
||||
@@ -514,6 +526,9 @@ export const normalizeTerminalSettings = (
|
||||
|
||||
return {
|
||||
...mergedSettings,
|
||||
autocompleteGhostText: mergedSettings.autocompletePopupMenu
|
||||
? false
|
||||
: mergedSettings.autocompleteGhostText,
|
||||
keywordHighlightRules: normalizeKeywordHighlightRules(
|
||||
mergedSettings.keywordHighlightRules,
|
||||
),
|
||||
@@ -553,6 +568,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 {
|
||||
@@ -599,6 +620,7 @@ export interface TerminalSession {
|
||||
port?: number;
|
||||
moshEnabled?: boolean;
|
||||
shellType?: 'posix' | 'fish' | 'powershell' | 'cmd' | 'unknown';
|
||||
charset?: string; // Connection-time charset override (e.g. for quick-connect serial)
|
||||
// Serial-specific connection settings
|
||||
serialConfig?: SerialConfig;
|
||||
}
|
||||
|
||||
@@ -425,9 +425,210 @@ 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,
|
||||
encoding = "utf8", // Callers should pass the session's resolved encoding
|
||||
} = 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) => {
|
||||
// latin1 for serial ports (matches terminalBridge.cjs decoder); utf8 for SSH PTY streams.
|
||||
const chunk = typeof data === "string" ? data : data.toString(encoding);
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -224,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;
|
||||
@@ -891,17 +883,29 @@ 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" };
|
||||
}
|
||||
|
||||
// Look up device type from metadata (set by renderer from Host.deviceType).
|
||||
// Mosh sessions use a shell-backed PTY, so network device mode only applies to SSH/serial.
|
||||
// Prefer session.protocol (runtime truth) over meta.protocol (renderer hint)
|
||||
// because Mosh tabs report as protocol:"ssh" in metadata but "mosh" in session.
|
||||
const meta = mcpServerBridge.getSessionMeta(sessionId, chatSessionId) || {};
|
||||
const sessionProtocol = session.protocol || session.type || meta.protocol || "";
|
||||
const isSshOrSerial = sessionProtocol === "ssh" || sessionProtocol === "serial";
|
||||
const isNetworkDevice = (meta.deviceType === "network" && isSshOrSerial) || sessionProtocol === "serial";
|
||||
|
||||
// Shell blocklist is meaningless on network device CLIs (e.g. "shutdown"
|
||||
// disables an interface on Cisco). Skip for network devices and serial sessions.
|
||||
if (!isNetworkDevice) {
|
||||
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 {
|
||||
@@ -910,8 +914,22 @@ function registerHandlers(ipcMain) {
|
||||
};
|
||||
}
|
||||
|
||||
// Prefer PTY stream (visible in terminal)
|
||||
const ptyStream = session.stream || session.pty || session.proc;
|
||||
|
||||
// Network devices (switches/routers) connected via SSH: use raw execution.
|
||||
// Their vendor CLIs don't run a POSIX shell, so shell-wrapped commands fail.
|
||||
if (isNetworkDevice && ptyStream && typeof ptyStream.write === "function") {
|
||||
const { execViaRawPty } = require("./ai/ptyExec.cjs");
|
||||
const timeoutMs = mcpServerBridge.getCommandTimeoutMs ? mcpServerBridge.getCommandTimeoutMs() : 60000;
|
||||
return execViaRawPty(ptyStream, command, {
|
||||
timeoutMs,
|
||||
trackForCancellation: mcpServerBridge.activePtyExecs,
|
||||
chatSessionId,
|
||||
encoding: "utf8", // SSH PTY streams use UTF-8, not latin1
|
||||
});
|
||||
}
|
||||
|
||||
// Prefer PTY stream (visible in terminal)
|
||||
if (ptyStream && typeof ptyStream.write === "function") {
|
||||
const timeoutMs = mcpServerBridge.getCommandTimeoutMs ? mcpServerBridge.getCommandTimeoutMs() : 60000;
|
||||
return execViaPty(ptyStream, command, {
|
||||
@@ -924,6 +942,11 @@ function registerHandlers(ipcMain) {
|
||||
});
|
||||
}
|
||||
|
||||
// Network devices require an interactive PTY for raw command execution.
|
||||
if (isNetworkDevice) {
|
||||
return { ok: false, error: "Network device session has no writable PTY stream for command execution" };
|
||||
}
|
||||
|
||||
// Fallback: SSH exec channel (invisible to terminal)
|
||||
const sshClient = session.sshClient || session.conn;
|
||||
if (sshClient && typeof sshClient.exec === "function") {
|
||||
@@ -936,6 +959,18 @@ function registerHandlers(ipcMain) {
|
||||
});
|
||||
}
|
||||
|
||||
// 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,
|
||||
encoding: session.serialEncoding || "utf8",
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: false, error: "No terminal stream or SSH client available for this session" };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
@@ -951,45 +986,6 @@ function registerHandlers(ipcMain) {
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// 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)
|
||||
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) };
|
||||
}
|
||||
});
|
||||
|
||||
async function runCommand(command, args, options) {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args || [], {
|
||||
@@ -1936,8 +1932,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 and network device sessions (deviceType: network), commands are sent as-is without shell wrapping and exit codes are unavailable. Use vendor CLI commands directly.]\n\n${prompt}`;
|
||||
|
||||
// Build message content: text + optional attachments
|
||||
// ACP provider only supports image/* and audio/* inline via `type: "file"`.
|
||||
|
||||
@@ -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();
|
||||
@@ -171,7 +161,6 @@ function cancelPtyExecsForSession(chatSessionId) {
|
||||
|
||||
function init(deps) {
|
||||
sessions = deps.sessions;
|
||||
sftpClients = deps.sftpClients;
|
||||
if (deps.commandBlocklist) {
|
||||
commandBlocklist = deps.commandBlocklist;
|
||||
}
|
||||
@@ -247,6 +236,7 @@ function updateSessionMetadata(sessionList, chatSessionId) {
|
||||
username: s.username || "",
|
||||
protocol: s.protocol || "",
|
||||
shellType: s.shellType || "",
|
||||
deviceType: s.deviceType || "",
|
||||
connected: s.connected !== false,
|
||||
});
|
||||
}
|
||||
@@ -290,38 +280,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];
|
||||
@@ -438,12 +399,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",
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -483,37 +438,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}`);
|
||||
}
|
||||
@@ -550,7 +479,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) || {};
|
||||
@@ -562,17 +492,18 @@ 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),
|
||||
deviceType: meta.deviceType || "",
|
||||
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. " +
|
||||
"Network device sessions (deviceType: network) use vendor CLIs (Huawei VRP, Cisco IOS, etc.) — commands are sent as-is without shell wrapping, and exit codes are unavailable. " +
|
||||
"Always prefer these tools over suggesting the user to do things manually.",
|
||||
hosts,
|
||||
hostCount: hosts.length,
|
||||
@@ -588,14 +519,38 @@ 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" };
|
||||
|
||||
// Look up device type from metadata (set by renderer from Host.deviceType).
|
||||
const chatSessionId = params?.chatSessionId || null;
|
||||
const meta = getSessionMeta(sessionId, chatSessionId) || {};
|
||||
// Mosh sessions use a shell-backed PTY and cannot connect to vendor CLIs,
|
||||
// so network device mode only applies to SSH and serial sessions.
|
||||
// Prefer session.protocol (runtime truth) over meta.protocol (renderer hint)
|
||||
// because Mosh tabs report as protocol:"ssh" in metadata but "mosh" in session.
|
||||
const sessionProtocol = session.protocol || session.type || meta.protocol || "";
|
||||
const isSshOrSerial = sessionProtocol === "ssh" || sessionProtocol === "serial";
|
||||
const isNetworkDevice = (meta.deviceType === "network" && isSshOrSerial) || sessionProtocol === "serial";
|
||||
|
||||
// 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 (!isNetworkDevice) {
|
||||
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,
|
||||
@@ -606,6 +561,19 @@ function handleExec(params) {
|
||||
const sshClient = session.conn || session.sshClient;
|
||||
const ptyStream = session.stream || session.pty || session.proc;
|
||||
|
||||
// Network devices (switches/routers) connected via SSH: use raw execution.
|
||||
// Their vendor CLIs (Huawei VRP, Cisco IOS, etc.) don't run a POSIX shell,
|
||||
// so shell-wrapped commands with markers would fail. Raw mode sends commands
|
||||
// as-is with idle-timeout completion detection — same as serial sessions.
|
||||
if (isNetworkDevice && ptyStream && typeof ptyStream.write === "function") {
|
||||
return execViaRawPty(ptyStream, command, {
|
||||
timeoutMs: commandTimeoutMs,
|
||||
trackForCancellation: activePtyExecs,
|
||||
chatSessionId: params?.chatSessionId,
|
||||
encoding: "utf8", // SSH PTY streams use UTF-8, not latin1
|
||||
});
|
||||
}
|
||||
|
||||
// Prefer the interactive PTY so the user sees command/output in-session.
|
||||
if (ptyStream && typeof ptyStream.write === "function") {
|
||||
return execViaPty(ptyStream, command, {
|
||||
@@ -616,288 +584,32 @@ function handleExec(params) {
|
||||
});
|
||||
}
|
||||
|
||||
// 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" };
|
||||
// Network devices require an interactive PTY for raw command execution.
|
||||
// If we got here, ptyStream wasn't writable — there's no usable channel.
|
||||
if (isNetworkDevice) {
|
||||
return { ok: false, error: "Network device session has no writable PTY stream for 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,
|
||||
encoding: session.serialEncoding || "utf8",
|
||||
});
|
||||
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 ──
|
||||
@@ -931,11 +643,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 });
|
||||
|
||||
@@ -984,6 +691,7 @@ module.exports = {
|
||||
activePtyExecs,
|
||||
cancelAllPtyExecs,
|
||||
cancelPtyExecsForSession,
|
||||
getSessionMeta,
|
||||
cleanupScopedMetadata,
|
||||
cleanup,
|
||||
setMainWindowGetter,
|
||||
|
||||
@@ -35,17 +35,7 @@ function isTunnelCancelled(tunnelState) {
|
||||
return Boolean(tunnelState?.cancelled);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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");
|
||||
|
||||
/**
|
||||
* Start a port forwarding tunnel
|
||||
|
||||
@@ -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");
|
||||
@@ -412,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
|
||||
|
||||
@@ -565,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
|
||||
|
||||
@@ -361,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
|
||||
@@ -1840,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
|
||||
@@ -2248,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);
|
||||
@@ -2281,16 +2410,4 @@ module.exports = {
|
||||
init,
|
||||
registerHandlers,
|
||||
connectThroughChain,
|
||||
createProxySocket,
|
||||
startSSHSession,
|
||||
execCommand,
|
||||
getSessionPwd,
|
||||
getServerStats,
|
||||
generateKeyPair,
|
||||
checkWindowsSshAgent,
|
||||
findDefaultPrivateKey,
|
||||
findAllDefaultPrivateKeys,
|
||||
isKeyEncrypted,
|
||||
findAllDefaultPrivateKeys,
|
||||
isKeyEncrypted,
|
||||
};
|
||||
|
||||
@@ -19,6 +19,20 @@ const { trackSessionIdlePrompt } = require("./ai/shellUtils.cjs");
|
||||
let sessions = null;
|
||||
let electronModule = null;
|
||||
|
||||
// Map user-facing charset names to Node.js StringDecoder/Buffer encoding names.
|
||||
// Falls back to utf8 for unrecognized charsets (StringDecoder only supports a
|
||||
// small set; for CJK encodings like GB18030/Big5 we'd need iconv-lite, which
|
||||
// is out of scope for this change — utf8 is still the safer default).
|
||||
function charsetToNodeEncoding(charset) {
|
||||
if (!charset) return 'utf8';
|
||||
const normalized = String(charset).trim().toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
if (['utf8', 'utf-8'].includes(normalized)) return 'utf8';
|
||||
if (['latin1', 'iso88591', 'iso-8859-1', 'binary'].includes(normalized)) return 'latin1';
|
||||
if (normalized === 'ascii') return 'ascii';
|
||||
if (['utf16le', 'ucs2'].includes(normalized)) return 'utf16le';
|
||||
return 'utf8';
|
||||
}
|
||||
|
||||
const DEFAULT_UTF8_LOCALE = "en_US.UTF-8";
|
||||
const LOGIN_SHELLS = new Set(["bash", "zsh", "fish", "ksh"]);
|
||||
const POWERSHELL_SHELLS = new Set(["powershell", "powershell.exe", "pwsh", "pwsh.exe"]);
|
||||
@@ -513,16 +527,6 @@ async function startTelnetSession(event, options) {
|
||||
resolve({ sessionId });
|
||||
});
|
||||
|
||||
const charsetToNodeEncoding = (charset) => {
|
||||
if (!charset) return 'utf8';
|
||||
const normalized = String(charset).trim().toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
if (['utf8', 'utf-8'].includes(normalized)) return 'utf8';
|
||||
if (['latin1', 'iso88591', 'iso-8859-1', 'binary'].includes(normalized)) return 'latin1';
|
||||
if (normalized === 'ascii') return 'ascii';
|
||||
if (['utf16le', 'ucs2'].includes(normalized)) return 'utf16le';
|
||||
return 'utf8';
|
||||
};
|
||||
|
||||
const telnetDecoder = new StringDecoder(charsetToNodeEncoding(options.charset));
|
||||
|
||||
const telnetWebContentsId = event.sender.id;
|
||||
@@ -762,9 +766,15 @@ async function startSerialSession(event, options) {
|
||||
|
||||
console.log(`[Serial] Connected to ${portPath}`);
|
||||
|
||||
const serialEncoding = charsetToNodeEncoding(options.charset);
|
||||
const serialDecoder = new StringDecoder(serialEncoding);
|
||||
|
||||
const session = {
|
||||
serialPort,
|
||||
type: 'serial',
|
||||
protocol: 'serial',
|
||||
shellKind: 'raw',
|
||||
serialEncoding,
|
||||
webContentsId: event.sender.id,
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
@@ -780,8 +790,6 @@ async function startSerialSession(event, options) {
|
||||
});
|
||||
}
|
||||
|
||||
const serialDecoder = new StringDecoder('latin1');
|
||||
|
||||
serialPort.on('data', (data) => {
|
||||
const decoded = serialDecoder.write(data);
|
||||
if (decoded) {
|
||||
|
||||
@@ -505,6 +505,103 @@ 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 {
|
||||
|
||||
@@ -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 and network device 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'. SSH sessions with deviceType 'network' are network equipment (Huawei VRP, Cisco IOS, etc.) using vendor CLIs instead of a standard shell. 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 and network device sessions (deviceType: network), commands are 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 and network device 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() {
|
||||
|
||||
@@ -1132,9 +1132,6 @@ const api = {
|
||||
aiCattyCancelExec: async (chatSessionId) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:catty:cancel", { chatSessionId });
|
||||
},
|
||||
aiTerminalWrite: async (sessionId, data) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:terminal:write", { sessionId, data });
|
||||
},
|
||||
aiDiscoverAgents: async () => {
|
||||
return ipcRenderer.invoke("netcatty:ai:agents:discover");
|
||||
},
|
||||
@@ -1274,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 : {};
|
||||
|
||||
@@ -1314,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 {
|
||||
|
||||
33
electron/specs/awk.js
Normal file
33
electron/specs/awk.js
Normal file
@@ -0,0 +1,33 @@
|
||||
// awk spec — pattern scanning and text processing language
|
||||
const completionSpec = {
|
||||
name: "awk",
|
||||
description: "Pattern scanning and text processing language",
|
||||
args: [
|
||||
{ name: "program", description: "AWK program text (e.g. '{print $1}')" },
|
||||
{ name: "file", description: "Input file(s)", isOptional: true, isVariadic: true, template: "filepaths" },
|
||||
],
|
||||
options: [
|
||||
{ name: "-F", description: "Set field separator", args: { name: "separator", description: "e.g. ',' or '\\t'" } },
|
||||
{ name: "-v", description: "Assign a variable (var=value)", args: { name: "var=value" } },
|
||||
{ name: "-f", description: "Read AWK program from file", args: { name: "progfile", template: "filepaths" } },
|
||||
{ name: "-o", description: "Enable pretty-printed output", args: { name: "file", isOptional: true, template: "filepaths" } },
|
||||
{ name: "-b", description: "Treat all input data as single-byte characters (gawk)" },
|
||||
{ name: "-c", description: "Run in POSIX compatibility mode (gawk)" },
|
||||
{ name: "-C", description: "Print copyright information" },
|
||||
{ name: "-d", description: "Dump variables to file (gawk)", args: { name: "file", isOptional: true, template: "filepaths" } },
|
||||
{ name: "-e", description: "Specify AWK program text", args: { name: "program" } },
|
||||
{ name: "-E", description: "Read AWK program from file (like -f but disables command-line variable assignments)", args: { name: "file", template: "filepaths" } },
|
||||
{ name: "-i", description: "Include AWK source library", args: { name: "source-file", template: "filepaths" } },
|
||||
{ name: "-l", description: "Load dynamic extension (gawk)", args: { name: "ext" } },
|
||||
{ name: "-n", description: "Disable automatic input record splitting (gawk)" },
|
||||
{ name: "-N", description: "Use locale decimal point for parsing input data (gawk)" },
|
||||
{ name: "-p", description: "Profile execution and write to file (gawk)", args: { name: "file", isOptional: true, template: "filepaths" } },
|
||||
{ name: "-P", description: "POSIX compatibility mode (gawk)" },
|
||||
{ name: "-S", description: "Sandbox mode — disable system(), I/O redirection (gawk)" },
|
||||
{ name: "-t", description: "Enable type checking (gawk)" },
|
||||
{ name: "--help", description: "Show help" },
|
||||
{ name: "--version", description: "Print version information" },
|
||||
],
|
||||
};
|
||||
|
||||
export default completionSpec;
|
||||
38
electron/specs/journalctl.js
Normal file
38
electron/specs/journalctl.js
Normal file
@@ -0,0 +1,38 @@
|
||||
// journalctl spec — systemd journal query tool
|
||||
const completionSpec = {
|
||||
name: "journalctl",
|
||||
description: "Query the systemd journal",
|
||||
options: [
|
||||
{ name: ["-f", "--follow"], description: "Follow new journal entries (like tail -f)" },
|
||||
{ name: ["-r", "--reverse"], description: "Show newest entries first" },
|
||||
{ name: ["-e", "--pager-end"], description: "Jump to the end of the journal" },
|
||||
{ name: "--no-pager", description: "Do not pipe output into a pager" },
|
||||
{ name: ["-a", "--all"], description: "Show all fields in full" },
|
||||
{ name: ["-n", "--lines"], description: "Number of journal entries to show", args: { name: "N", description: "Number of lines" } },
|
||||
{ name: ["-o", "--output"], description: "Change journal output mode", args: { name: "format", suggestions: ["short", "short-precise", "short-iso", "short-iso-precise", "short-full", "short-monotonic", "verbose", "export", "json", "json-pretty", "json-sse", "cat"] } },
|
||||
{ name: ["-x", "--catalog"], description: "Augment log lines with explanation texts" },
|
||||
{ name: ["-b", "--boot"], description: "Show messages from a specific boot", args: { name: "ID", isOptional: true } },
|
||||
{ name: ["-k", "--dmesg"], description: "Show kernel messages only" },
|
||||
{ name: ["-u", "--unit"], description: "Show messages for the specified unit", args: { name: "UNIT" } },
|
||||
{ name: "--user-unit", description: "Show messages for the specified user unit", args: { name: "UNIT" } },
|
||||
{ name: ["-t", "--identifier"], description: "Show messages with the specified syslog identifier", args: { name: "SYSLOG_IDENTIFIER" } },
|
||||
{ name: ["-p", "--priority"], description: "Filter by message priority", args: { name: "PRIORITY", suggestions: ["emerg", "alert", "crit", "err", "warning", "notice", "info", "debug", "0", "1", "2", "3", "4", "5", "6", "7"] } },
|
||||
{ name: ["-g", "--grep"], description: "Filter by message content (PCRE2 regex)", args: { name: "PATTERN" } },
|
||||
{ name: ["-S", "--since"], description: "Show entries on or newer than date", args: { name: "DATE", description: "e.g. '2023-01-01', 'yesterday', '1 hour ago'" } },
|
||||
{ name: ["-U", "--until"], description: "Show entries on or older than date", args: { name: "DATE" } },
|
||||
{ name: "--disk-usage", description: "Show total disk usage of all journal files" },
|
||||
{ name: "--vacuum-size", description: "Reduce disk usage below specified size", args: { name: "BYTES" } },
|
||||
{ name: "--vacuum-time", description: "Remove journal files older than specified time", args: { name: "TIME" } },
|
||||
{ name: "--list-boots", description: "Show a list of boots" },
|
||||
{ name: ["-D", "--directory"], description: "Show journal files from directory", args: { name: "DIR", template: "folders" } },
|
||||
{ name: "--file", description: "Operate on a specific journal file", args: { name: "FILE", template: "filepaths" } },
|
||||
{ name: "--no-hostname", description: "Suppress hostname field" },
|
||||
{ name: ["-q", "--quiet"], description: "Suppress info messages and privilege warning" },
|
||||
{ name: "--utc", description: "Express time in UTC" },
|
||||
{ name: "--system", description: "Show the system journal only" },
|
||||
{ name: "--user", description: "Show the user journal for the current user" },
|
||||
{ name: ["-h", "--help"], description: "Show help" },
|
||||
],
|
||||
};
|
||||
|
||||
export default completionSpec;
|
||||
68
electron/specs/yum.js
Normal file
68
electron/specs/yum.js
Normal file
@@ -0,0 +1,68 @@
|
||||
// yum spec — Yellowdog Updater Modified (RPM package manager)
|
||||
const completionSpec = {
|
||||
name: "yum",
|
||||
description: "RPM package manager (RHEL/CentOS)",
|
||||
subcommands: [
|
||||
{ name: "install", description: "Install a package", args: { name: "package", isVariadic: true } },
|
||||
{ name: "remove", description: "Remove a package", args: { name: "package", isVariadic: true } },
|
||||
{ name: "update", description: "Update packages", args: { name: "package", isOptional: true, isVariadic: true } },
|
||||
{ name: "upgrade", description: "Upgrade packages (same as update --obsoletes)", args: { name: "package", isOptional: true, isVariadic: true } },
|
||||
{ name: "downgrade", description: "Downgrade a package", args: { name: "package", isVariadic: true } },
|
||||
{ name: "list", description: "List packages", subcommands: [
|
||||
{ name: "installed", description: "List installed packages" },
|
||||
{ name: "available", description: "List available packages" },
|
||||
{ name: "updates", description: "List packages with updates available" },
|
||||
{ name: "extras", description: "List installed packages not in any repo" },
|
||||
{ name: "obsoletes", description: "List obsoleting packages" },
|
||||
{ name: "all", description: "List all packages" },
|
||||
]},
|
||||
{ name: "search", description: "Search package details for the given string", args: { name: "keyword", isVariadic: true } },
|
||||
{ name: "info", description: "Display details about a package", args: { name: "package", isVariadic: true } },
|
||||
{ name: "provides", description: "Find which package provides a file/feature", args: { name: "feature" } },
|
||||
{ name: "clean", description: "Clean cached data", subcommands: [
|
||||
{ name: "all", description: "Clean all cached data" },
|
||||
{ name: "packages", description: "Clean cached packages" },
|
||||
{ name: "metadata", description: "Clean cached metadata" },
|
||||
{ name: "dbcache", description: "Clean cached db data" },
|
||||
{ name: "expire-cache", description: "Expire the cache" },
|
||||
]},
|
||||
{ name: "makecache", description: "Generate the metadata cache" },
|
||||
{ name: "groupinstall", description: "Install a package group", args: { name: "group" } },
|
||||
{ name: "groupremove", description: "Remove a package group", args: { name: "group" } },
|
||||
{ name: "grouplist", description: "List available package groups" },
|
||||
{ name: "groupinfo", description: "Display details about a package group", args: { name: "group" } },
|
||||
{ name: "check-update", description: "Check for available package updates" },
|
||||
{ name: "reinstall", description: "Reinstall a package", args: { name: "package", isVariadic: true } },
|
||||
{ name: "localinstall", description: "Install a local RPM package", args: { name: "rpm-file", template: "filepaths" } },
|
||||
{ name: "deplist", description: "List package dependencies", args: { name: "package" } },
|
||||
{ name: "repolist", description: "Display configured software repositories", subcommands: [
|
||||
{ name: "all", description: "List all repos" },
|
||||
{ name: "enabled", description: "List enabled repos" },
|
||||
{ name: "disabled", description: "List disabled repos" },
|
||||
]},
|
||||
{ name: "repoinfo", description: "Display repository information" },
|
||||
{ name: "history", description: "View and manage yum transaction history", subcommands: [
|
||||
{ name: "list", description: "List transactions" },
|
||||
{ name: "info", description: "Show transaction details", args: { name: "id" } },
|
||||
{ name: "undo", description: "Undo a transaction", args: { name: "id" } },
|
||||
{ name: "redo", description: "Redo a transaction", args: { name: "id" } },
|
||||
]},
|
||||
{ name: "autoremove", description: "Remove unneeded packages installed as dependencies" },
|
||||
],
|
||||
options: [
|
||||
{ name: "-y", description: "Answer yes to all questions" },
|
||||
{ name: "-q", description: "Quiet operation" },
|
||||
{ name: "-v", description: "Verbose operation" },
|
||||
{ name: "--enablerepo", description: "Enable a repository", args: { name: "repo" } },
|
||||
{ name: "--disablerepo", description: "Disable a repository", args: { name: "repo" } },
|
||||
{ name: "--nogpgcheck", description: "Disable GPG signature checking" },
|
||||
{ name: "--skip-broken", description: "Skip packages with dependency problems" },
|
||||
{ name: "--showduplicates", description: "Show duplicate packages in repos" },
|
||||
{ name: "--installroot", description: "Set install root", args: { name: "path", template: "folders" } },
|
||||
{ name: "-C", description: "Run entirely from system cache" },
|
||||
{ name: "--security", description: "Include only security-related packages" },
|
||||
{ name: ["-h", "--help"], description: "Show help" },
|
||||
],
|
||||
};
|
||||
|
||||
export default completionSpec;
|
||||
13
global.d.ts
vendored
13
global.d.ts
vendored
@@ -1,5 +1,5 @@
|
||||
import type { RemoteFile, SftpFilenameEncoding } from "./types";
|
||||
import type { S3Config, SMBConfig, SyncedFile, WebDAVConfig } from "./domain/sync";
|
||||
import type { S3Config, SyncedFile, WebDAVConfig } from "./domain/sync";
|
||||
|
||||
declare module "*.cjs" {
|
||||
const value: Record<string, unknown>;
|
||||
@@ -185,6 +185,7 @@ declare global {
|
||||
stopBits?: 1 | 1.5 | 2;
|
||||
parity?: 'none' | 'even' | 'odd' | 'mark' | 'space';
|
||||
flowControl?: 'none' | 'xon/xoff' | 'rts/cts';
|
||||
charset?: string;
|
||||
sessionLog?: { enabled: boolean; directory: string; format: string };
|
||||
}): Promise<string>;
|
||||
listSerialPorts?(): Promise<Array<{
|
||||
@@ -440,14 +441,6 @@ declare global {
|
||||
cloudSyncS3Download?(config: S3Config): Promise<{ syncedFile: SyncedFile | null }>;
|
||||
cloudSyncS3Delete?(config: S3Config): Promise<{ ok: true }>;
|
||||
|
||||
cloudSyncSmbInitialize?(config: SMBConfig): Promise<{ resourceId: string | null }>;
|
||||
cloudSyncSmbUpload?(
|
||||
config: SMBConfig,
|
||||
syncedFile: SyncedFile
|
||||
): Promise<{ resourceId: string }>;
|
||||
cloudSyncSmbDownload?(config: SMBConfig): Promise<{ syncedFile: SyncedFile | null }>;
|
||||
cloudSyncSmbDelete?(config: SMBConfig): Promise<{ ok: true }>;
|
||||
|
||||
// Port Forwarding
|
||||
startPortForward?(options: PortForwardOptions): Promise<PortForwardResult>;
|
||||
stopPortForward?(tunnelId: string): Promise<PortForwardResult>;
|
||||
@@ -670,7 +663,6 @@ declare global {
|
||||
aiAllowlistAddHost?(baseURL: string): Promise<{ ok: boolean; error?: string }>;
|
||||
aiExec?(sessionId: string, command: string, chatSessionId?: string): Promise<{ ok: boolean; stdout?: string; stderr?: string; exitCode?: number | null; error?: string }>;
|
||||
aiCattyCancelExec?(chatSessionId: string): Promise<{ ok: boolean; error?: string }>;
|
||||
aiTerminalWrite?(sessionId: string, data: string): Promise<{ ok: boolean; error?: string }>;
|
||||
aiDiscoverAgents?(): Promise<Array<{
|
||||
command: string;
|
||||
name: string;
|
||||
@@ -742,6 +734,7 @@ declare global {
|
||||
username?: string;
|
||||
protocol?: string;
|
||||
shellType?: string;
|
||||
deviceType?: string;
|
||||
connected: boolean;
|
||||
}>, chatSessionId?: string): Promise<{ ok: boolean }>;
|
||||
aiSpawnAgent?(agentId: string, command: string, args?: string[], env?: Record<string, string>, options?: { closeStdin?: boolean }): Promise<{ ok: boolean; pid?: number; error?: string }>;
|
||||
|
||||
48
index.css
48
index.css
@@ -101,6 +101,13 @@ body {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
[data-instant-theme-switch="true"],
|
||||
[data-instant-theme-switch="true"] *,
|
||||
[data-instant-theme-switch="true"] *::before,
|
||||
[data-instant-theme-switch="true"] *::after {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
@@ -195,6 +202,23 @@ body {
|
||||
box-shadow: 0 4px 12px hsl(var(--foreground) / 0.06);
|
||||
}
|
||||
|
||||
@keyframes session-activity-breathe {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
opacity: 0.18;
|
||||
}
|
||||
}
|
||||
|
||||
.session-activity-dot {
|
||||
animation: session-activity-breathe 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.card-highlight {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@@ -311,20 +335,9 @@ body {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.workspace-pane::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 0;
|
||||
border: 2px solid hsl(var(--primary));
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 120ms ease, box-shadow 120ms ease;
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.workspace-pane:focus-within::after {
|
||||
opacity: 1;
|
||||
/* Dim terminal text in unfocused workspace panes */
|
||||
.workspace-pane:not(:focus-within) .xterm-screen {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
/* ── Streamdown code block overrides ── */
|
||||
@@ -378,6 +391,9 @@ body {
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
overflow-x: auto !important;
|
||||
overflow-y: hidden !important;
|
||||
overscroll-behavior-x: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
font-size: 0 !important; /* collapse whitespace text nodes */
|
||||
}
|
||||
|
||||
@@ -386,11 +402,15 @@ body {
|
||||
}
|
||||
|
||||
[data-streamdown="code-block"] pre {
|
||||
display: block !important;
|
||||
margin: 0 !important;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
padding: 0 12px 10px !important;
|
||||
width: max-content !important;
|
||||
min-width: 100% !important;
|
||||
font-size: 12px !important;
|
||||
line-height: 1.5 !important;
|
||||
white-space: pre !important;
|
||||
}
|
||||
|
||||
@@ -1,398 +0,0 @@
|
||||
import type {
|
||||
JsonRpcRequest,
|
||||
JsonRpcResponse,
|
||||
JsonRpcNotification,
|
||||
JsonRpcMessage,
|
||||
InitializeParams,
|
||||
InitializeResult,
|
||||
SessionCreateParams,
|
||||
PromptParams,
|
||||
SessionUpdateParams,
|
||||
PermissionRequestParams,
|
||||
AgentCapabilities,
|
||||
} from './protocol';
|
||||
import { ACP_METHODS } from './protocol';
|
||||
import type { ExternalAgentConfig } from '../types';
|
||||
|
||||
type EventHandler<T = unknown> = (params: T) => void;
|
||||
|
||||
// ── Lightweight runtime type guards ──
|
||||
|
||||
function isRecord(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
function isPermissionRequestParams(v: unknown): v is PermissionRequestParams {
|
||||
if (!isRecord(v)) return false;
|
||||
if (typeof v.sessionId !== 'string') return false;
|
||||
if (!isRecord(v.toolCall)) return false;
|
||||
if (typeof v.toolCall.name !== 'string') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function isSessionUpdateParams(v: unknown): v is SessionUpdateParams {
|
||||
if (!isRecord(v)) return false;
|
||||
if (typeof v.sessionId !== 'string') return false;
|
||||
if (typeof v.type !== 'string') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function isJsonRpcError(v: unknown): v is { code: number; message: string } {
|
||||
if (!isRecord(v)) return false;
|
||||
if (typeof v.code !== 'number') return false;
|
||||
if (typeof v.message !== 'string') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge interface to the Electron main process for agent management
|
||||
*/
|
||||
interface AgentBridge {
|
||||
aiSpawnAgent(agentId: string, command: string, args?: string[], env?: Record<string, string>): Promise<{ ok: boolean; pid?: number; error?: string }>;
|
||||
aiWriteToAgent(agentId: string, data: string): Promise<{ ok: boolean; error?: string }>;
|
||||
aiKillAgent(agentId: string): Promise<{ ok: boolean; error?: string }>;
|
||||
onAiAgentStdout(agentId: string, cb: (data: string) => void): () => void;
|
||||
onAiAgentStderr(agentId: string, cb: (data: string) => void): () => void;
|
||||
onAiAgentExit(agentId: string, cb: (code: number) => void): () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* ACP Client - manages a single external agent connection over JSON-RPC 2.0 / NDJSON stdio.
|
||||
*/
|
||||
export class ACPClient {
|
||||
private agentId: string;
|
||||
private config: ExternalAgentConfig;
|
||||
private bridge: AgentBridge;
|
||||
private nextId = 1;
|
||||
private pendingRequests = new Map<number | string, {
|
||||
resolve: (result: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
}>();
|
||||
private buffer = '';
|
||||
private cleanupFns: (() => void)[] = [];
|
||||
private agentCapabilities: AgentCapabilities | null = null;
|
||||
private _isConnected = false;
|
||||
|
||||
// Event handlers
|
||||
private onSessionUpdate: EventHandler<SessionUpdateParams> | null = null;
|
||||
private onPermissionRequest: EventHandler<PermissionRequestParams> | null = null;
|
||||
private onStderr: EventHandler<string> | null = null;
|
||||
private onExit: EventHandler<number> | null = null;
|
||||
|
||||
constructor(config: ExternalAgentConfig, bridge: AgentBridge) {
|
||||
this.agentId = `acp_${config.id}_${Date.now()}`;
|
||||
this.config = config;
|
||||
this.bridge = bridge;
|
||||
}
|
||||
|
||||
get isConnected() { return this._isConnected; }
|
||||
get capabilities() { return this.agentCapabilities; }
|
||||
|
||||
/** Set event handlers */
|
||||
on(event: 'session_update', handler: EventHandler<SessionUpdateParams>): this;
|
||||
on(event: 'permission_request', handler: EventHandler<PermissionRequestParams>): this;
|
||||
on(event: 'stderr', handler: EventHandler<string>): this;
|
||||
on(event: 'exit', handler: EventHandler<number>): this;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
on(event: string, handler: EventHandler<any>): this {
|
||||
switch (event) {
|
||||
case 'session_update': this.onSessionUpdate = handler as EventHandler<SessionUpdateParams>; break;
|
||||
case 'permission_request': this.onPermissionRequest = handler as EventHandler<PermissionRequestParams>; break;
|
||||
case 'stderr': this.onStderr = handler as EventHandler<string>; break;
|
||||
case 'exit': this.onExit = handler as EventHandler<number>; break;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Start the agent process and perform ACP initialization handshake */
|
||||
async connect(): Promise<InitializeResult> {
|
||||
// Spawn the agent process
|
||||
const result = await this.bridge.aiSpawnAgent(
|
||||
this.agentId,
|
||||
this.config.command,
|
||||
this.config.args,
|
||||
this.config.env,
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
throw new Error(`Failed to spawn agent: ${result.error}`);
|
||||
}
|
||||
|
||||
// Listen for stdout (NDJSON messages)
|
||||
const unsubStdout = this.bridge.onAiAgentStdout(this.agentId, (data) => {
|
||||
this.handleStdoutData(data);
|
||||
});
|
||||
this.cleanupFns.push(unsubStdout);
|
||||
|
||||
// Listen for stderr (logging)
|
||||
const unsubStderr = this.bridge.onAiAgentStderr(this.agentId, (data) => {
|
||||
this.onStderr?.(data);
|
||||
});
|
||||
this.cleanupFns.push(unsubStderr);
|
||||
|
||||
// Listen for exit
|
||||
const unsubExit = this.bridge.onAiAgentExit(this.agentId, (code) => {
|
||||
this._isConnected = false;
|
||||
this.onExit?.(code);
|
||||
// Reject all pending requests
|
||||
for (const [, pending] of this.pendingRequests) {
|
||||
pending.reject(new Error(`Agent exited with code ${code}`));
|
||||
}
|
||||
this.pendingRequests.clear();
|
||||
});
|
||||
this.cleanupFns.push(unsubExit);
|
||||
|
||||
// Send initialize request
|
||||
const initParams: InitializeParams = {
|
||||
clientInfo: { name: 'netcatty', version: '1.0.0' },
|
||||
capabilities: {
|
||||
terminal: { create: true, output: true, waitForExit: true, kill: true },
|
||||
fileSystem: { read: true, write: true },
|
||||
permissions: { requestPermission: true },
|
||||
},
|
||||
};
|
||||
|
||||
const initResult = await this.sendRequest<InitializeResult>(ACP_METHODS.INITIALIZE, initParams);
|
||||
this.agentCapabilities = initResult.capabilities;
|
||||
this._isConnected = true;
|
||||
|
||||
return initResult;
|
||||
}
|
||||
|
||||
/** Create a new session */
|
||||
async createSession(params?: SessionCreateParams): Promise<{ sessionId: string }> {
|
||||
return this.sendRequest(ACP_METHODS.SESSION_CREATE, params || {});
|
||||
}
|
||||
|
||||
/** Send a prompt to the agent */
|
||||
async prompt(params: PromptParams): Promise<void> {
|
||||
return this.sendRequest(ACP_METHODS.SESSION_PROMPT, params);
|
||||
}
|
||||
|
||||
/** Cancel the current operation */
|
||||
async cancel(sessionId: string): Promise<void> {
|
||||
return this.sendRequest(ACP_METHODS.SESSION_CANCEL, { sessionId });
|
||||
}
|
||||
|
||||
/** Respond to a permission request */
|
||||
respondPermission(requestId: number | string, approved: boolean): void {
|
||||
this.sendResponse(requestId, { approved });
|
||||
}
|
||||
|
||||
/** Respond to a terminal create request */
|
||||
respondTerminalCreate(requestId: number | string, terminalId: string): void {
|
||||
this.sendResponse(requestId, { terminalId });
|
||||
}
|
||||
|
||||
/** Respond to a file read request */
|
||||
respondFileRead(requestId: number | string, content: string): void {
|
||||
this.sendResponse(requestId, { content });
|
||||
}
|
||||
|
||||
/** Respond to a file write request */
|
||||
respondFileWrite(requestId: number | string, success: boolean): void {
|
||||
this.sendResponse(requestId, { success });
|
||||
}
|
||||
|
||||
/** Disconnect and kill the agent process */
|
||||
async disconnect(): Promise<void> {
|
||||
this._isConnected = false;
|
||||
for (const cleanup of this.cleanupFns) {
|
||||
try { cleanup(); } catch { /* ignore cleanup errors */ }
|
||||
}
|
||||
this.cleanupFns = [];
|
||||
await this.bridge.aiKillAgent(this.agentId);
|
||||
// Reject all pending requests before clearing
|
||||
for (const [, pending] of this.pendingRequests) {
|
||||
pending.reject(new Error('Agent disconnected'));
|
||||
}
|
||||
this.pendingRequests.clear();
|
||||
}
|
||||
|
||||
// ── Private methods ──
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private async sendRequest<T = unknown>(method: string, params?: Record<string, any>): Promise<T> {
|
||||
const id = this.nextId++;
|
||||
const request: JsonRpcRequest = {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
method,
|
||||
params,
|
||||
};
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
// Track timeout so we can clear it when the request resolves
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (this.pendingRequests.has(id)) {
|
||||
this.pendingRequests.delete(id);
|
||||
reject(new Error(`Request timeout: ${method}`));
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
this.pendingRequests.set(id, {
|
||||
resolve: (result: unknown) => {
|
||||
clearTimeout(timeoutId);
|
||||
(resolve as (result: unknown) => void)(result);
|
||||
},
|
||||
reject: (error: Error) => {
|
||||
clearTimeout(timeoutId);
|
||||
reject(error);
|
||||
},
|
||||
});
|
||||
|
||||
const line = JSON.stringify(request) + '\n';
|
||||
this.bridge.aiWriteToAgent(this.agentId, line).catch((err) => {
|
||||
clearTimeout(timeoutId);
|
||||
this.pendingRequests.delete(id);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private sendResponse(id: number | string, result: unknown): void {
|
||||
const response: JsonRpcResponse = { jsonrpc: '2.0', id, result };
|
||||
const line = JSON.stringify(response) + '\n';
|
||||
this.bridge.aiWriteToAgent(this.agentId, line).catch((err) => {
|
||||
console.error('[ACP] Failed to send response:', err);
|
||||
});
|
||||
}
|
||||
|
||||
private sendErrorResponse(id: number | string, code: number, message: string): void {
|
||||
const response: JsonRpcResponse = {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: { code, message },
|
||||
};
|
||||
const line = JSON.stringify(response) + '\n';
|
||||
this.bridge.aiWriteToAgent(this.agentId, line).catch(() => { /* best-effort */ });
|
||||
}
|
||||
|
||||
/** Max NDJSON buffer size (10 MB) to prevent unbounded memory growth */
|
||||
private static readonly MAX_BUFFER_SIZE = 10 * 1024 * 1024;
|
||||
|
||||
private handleStdoutData(data: string): void {
|
||||
this.buffer += data;
|
||||
|
||||
// Guard against unbounded buffer growth
|
||||
if (this.buffer.length > ACPClient.MAX_BUFFER_SIZE) {
|
||||
console.warn(`[ACP] NDJSON buffer exceeded ${ACPClient.MAX_BUFFER_SIZE} bytes, clearing buffer`);
|
||||
this.buffer = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = this.buffer.split('\n');
|
||||
this.buffer = lines.pop() || ''; // Keep incomplete line in buffer
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
try {
|
||||
const message = JSON.parse(trimmed) as JsonRpcMessage;
|
||||
this.handleMessage(message);
|
||||
} catch {
|
||||
// Skip non-JSON lines (agent may print logs to stdout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessage(message: JsonRpcMessage): void {
|
||||
// Response to our request
|
||||
if ('id' in message && ('result' in message || 'error' in message)) {
|
||||
const response = message as JsonRpcResponse;
|
||||
const pending = this.pendingRequests.get(response.id);
|
||||
if (pending) {
|
||||
this.pendingRequests.delete(response.id);
|
||||
if (response.error) {
|
||||
const errMsg = isJsonRpcError(response.error)
|
||||
? response.error.message
|
||||
: JSON.stringify(response.error);
|
||||
pending.reject(new Error(errMsg));
|
||||
} else {
|
||||
pending.resolve(response.result);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Request from agent (needs our response)
|
||||
if ('id' in message && 'method' in message) {
|
||||
const request = message as JsonRpcRequest;
|
||||
this.handleAgentRequest(request);
|
||||
return;
|
||||
}
|
||||
|
||||
// Notification from agent (no response needed)
|
||||
if ('method' in message && !('id' in message)) {
|
||||
const notification = message as JsonRpcNotification;
|
||||
this.handleAgentNotification(notification);
|
||||
}
|
||||
}
|
||||
|
||||
private handleAgentRequest(request: JsonRpcRequest): void {
|
||||
switch (request.method) {
|
||||
case ACP_METHODS.REQUEST_PERMISSION: {
|
||||
if (!isPermissionRequestParams(request.params)) {
|
||||
this.sendErrorResponse(request.id, -32602, 'Invalid permission request params');
|
||||
break;
|
||||
}
|
||||
if (this.onPermissionRequest) {
|
||||
this.onPermissionRequest({
|
||||
...request.params,
|
||||
// Attach the request ID so the handler can respond via respondPermission()
|
||||
_requestId: request.id,
|
||||
} as PermissionRequestParams & { _requestId: number | string });
|
||||
} else {
|
||||
this.sendErrorResponse(request.id, -32603, 'Permission request handler not configured');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case ACP_METHODS.TERMINAL_CREATE:
|
||||
case ACP_METHODS.TERMINAL_WAIT_EXIT:
|
||||
case ACP_METHODS.TERMINAL_KILL:
|
||||
case ACP_METHODS.FS_READ:
|
||||
case ACP_METHODS.FS_WRITE:
|
||||
// Surface as tool_call so the UI layer can handle and respond
|
||||
this.onSessionUpdate?.({
|
||||
sessionId: String(request.params?.sessionId || ''),
|
||||
type: 'tool_call',
|
||||
toolCall: {
|
||||
id: String(request.id),
|
||||
name: request.method,
|
||||
arguments: (request.params as Record<string, unknown>) || {},
|
||||
},
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
// Unknown method - respond with JSON-RPC method-not-found error
|
||||
this.sendErrorResponse(request.id, -32601, `Method not found: ${request.method}`);
|
||||
}
|
||||
}
|
||||
|
||||
private handleAgentNotification(notification: JsonRpcNotification): void {
|
||||
switch (notification.method) {
|
||||
case ACP_METHODS.SESSION_UPDATE:
|
||||
if (isSessionUpdateParams(notification.params)) {
|
||||
this.onSessionUpdate?.(notification.params);
|
||||
}
|
||||
break;
|
||||
case ACP_METHODS.TERMINAL_OUTPUT:
|
||||
// Surface terminal output as a session update with tool_result type
|
||||
this.onSessionUpdate?.({
|
||||
sessionId: String(notification.params?.sessionId || ''),
|
||||
type: 'tool_result',
|
||||
toolResult: {
|
||||
toolCallId: String(notification.params?.terminalId || ''),
|
||||
content: String(notification.params?.data || ''),
|
||||
},
|
||||
});
|
||||
break;
|
||||
default:
|
||||
// Ignore unknown notifications
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import { ACPClient } from './client';
|
||||
import type { ExternalAgentConfig } from '../types';
|
||||
import type { SessionUpdateParams, PermissionRequestParams, InitializeResult } from './protocol';
|
||||
|
||||
interface AgentBridge {
|
||||
aiSpawnAgent(agentId: string, command: string, args?: string[], env?: Record<string, string>): Promise<{ ok: boolean; pid?: number; error?: string }>;
|
||||
aiWriteToAgent(agentId: string, data: string): Promise<{ ok: boolean; error?: string }>;
|
||||
aiKillAgent(agentId: string): Promise<{ ok: boolean; error?: string }>;
|
||||
onAiAgentStdout(agentId: string, cb: (data: string) => void): () => void;
|
||||
onAiAgentStderr(agentId: string, cb: (data: string) => void): () => void;
|
||||
onAiAgentExit(agentId: string, cb: (code: number) => void): () => void;
|
||||
}
|
||||
|
||||
export interface ACPManagerCallbacks {
|
||||
onSessionUpdate: (agentConfigId: string, params: SessionUpdateParams) => void;
|
||||
onPermissionRequest: (agentConfigId: string, params: PermissionRequestParams) => void;
|
||||
onAgentError: (agentConfigId: string, error: string) => void;
|
||||
onAgentExit: (agentConfigId: string, code: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages multiple ACP agent connections.
|
||||
*/
|
||||
export class ACPManager {
|
||||
private clients = new Map<string, ACPClient>();
|
||||
private bridge: AgentBridge;
|
||||
private callbacks: ACPManagerCallbacks;
|
||||
|
||||
constructor(bridge: AgentBridge, callbacks: ACPManagerCallbacks) {
|
||||
this.bridge = bridge;
|
||||
this.callbacks = callbacks;
|
||||
}
|
||||
|
||||
/** Connect to an external agent */
|
||||
async connect(config: ExternalAgentConfig): Promise<InitializeResult> {
|
||||
if (this.clients.has(config.id)) {
|
||||
await this.disconnect(config.id);
|
||||
}
|
||||
|
||||
const client = new ACPClient(config, this.bridge);
|
||||
|
||||
client
|
||||
.on('session_update', (params) => {
|
||||
this.callbacks.onSessionUpdate(config.id, params);
|
||||
})
|
||||
.on('permission_request', (params) => {
|
||||
this.callbacks.onPermissionRequest(config.id, params);
|
||||
})
|
||||
.on('stderr', (data) => {
|
||||
this.callbacks.onAgentError(config.id, data);
|
||||
})
|
||||
.on('exit', (code) => {
|
||||
this.clients.delete(config.id);
|
||||
this.callbacks.onAgentExit(config.id, code);
|
||||
});
|
||||
|
||||
const result = await client.connect();
|
||||
this.clients.set(config.id, client);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Get a connected client */
|
||||
getClient(configId: string): ACPClient | undefined {
|
||||
return this.clients.get(configId);
|
||||
}
|
||||
|
||||
/** Check if an agent is connected */
|
||||
isConnected(configId: string): boolean {
|
||||
return this.clients.get(configId)?.isConnected ?? false;
|
||||
}
|
||||
|
||||
/** Disconnect a specific agent */
|
||||
async disconnect(configId: string): Promise<void> {
|
||||
const client = this.clients.get(configId);
|
||||
if (client) {
|
||||
await client.disconnect();
|
||||
this.clients.delete(configId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Disconnect all agents */
|
||||
async disconnectAll(): Promise<void> {
|
||||
const promises = Array.from(this.clients.keys()).map(id => this.disconnect(id));
|
||||
await Promise.allSettled(promises);
|
||||
}
|
||||
|
||||
/** Get list of connected agent IDs */
|
||||
getConnectedAgentIds(): string[] {
|
||||
return Array.from(this.clients.entries())
|
||||
.filter(([, client]) => client.isConnected)
|
||||
.map(([id]) => id);
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
// JSON-RPC 2.0 base types
|
||||
export interface JsonRpcRequest {
|
||||
jsonrpc: '2.0';
|
||||
id: number | string;
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface JsonRpcResponse {
|
||||
jsonrpc: '2.0';
|
||||
id: number | string;
|
||||
result?: unknown;
|
||||
error?: { code: number; message: string; data?: unknown };
|
||||
}
|
||||
|
||||
export interface JsonRpcNotification {
|
||||
jsonrpc: '2.0';
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type JsonRpcMessage = JsonRpcRequest | JsonRpcResponse | JsonRpcNotification;
|
||||
|
||||
// ACP-specific types
|
||||
|
||||
/** Capabilities that the client (Netcatty) declares it supports */
|
||||
export interface ClientCapabilities {
|
||||
fileSystem?: { read?: boolean; write?: boolean };
|
||||
terminal?: { create?: boolean; output?: boolean; waitForExit?: boolean; kill?: boolean };
|
||||
permissions?: { requestPermission?: boolean };
|
||||
}
|
||||
|
||||
/** Capabilities that the agent declares it supports */
|
||||
export interface AgentCapabilities {
|
||||
streaming?: boolean;
|
||||
tools?: string[];
|
||||
}
|
||||
|
||||
/** ACP initialize params */
|
||||
export interface InitializeParams {
|
||||
clientInfo: { name: string; version: string };
|
||||
capabilities: ClientCapabilities;
|
||||
}
|
||||
|
||||
/** ACP initialize result */
|
||||
export interface InitializeResult {
|
||||
agentInfo: { name: string; version: string };
|
||||
capabilities: AgentCapabilities;
|
||||
}
|
||||
|
||||
/** ACP session create params */
|
||||
export interface SessionCreateParams {
|
||||
sessionId?: string;
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** ACP prompt params - send a user message */
|
||||
export interface PromptParams {
|
||||
sessionId: string;
|
||||
message: string;
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** ACP session update events (streamed as notifications) */
|
||||
export interface SessionUpdateParams {
|
||||
sessionId: string;
|
||||
type: 'text' | 'tool_call' | 'tool_result' | 'thinking' | 'error' | 'done';
|
||||
content?: string;
|
||||
toolCall?: { id: string; name: string; arguments: Record<string, unknown> };
|
||||
toolResult?: { toolCallId: string; content: string; isError?: boolean };
|
||||
}
|
||||
|
||||
/** ACP permission request */
|
||||
export interface PermissionRequestParams {
|
||||
sessionId: string;
|
||||
toolCall: { name: string; arguments: Record<string, unknown> };
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// ACP method names
|
||||
export const ACP_METHODS = {
|
||||
INITIALIZE: 'initialize',
|
||||
SESSION_CREATE: 'session/create',
|
||||
SESSION_PROMPT: 'session/prompt',
|
||||
SESSION_CANCEL: 'session/cancel',
|
||||
SESSION_UPDATE: 'session/update', // notification from agent
|
||||
REQUEST_PERMISSION: 'session/request_permission', // request from agent
|
||||
TERMINAL_CREATE: 'terminal/create', // request from agent
|
||||
TERMINAL_OUTPUT: 'terminal/output', // notification from agent
|
||||
TERMINAL_WAIT_EXIT: 'terminal/waitForExit', // request from agent
|
||||
TERMINAL_KILL: 'terminal/kill', // request from agent
|
||||
FS_READ: 'fs/readTextFile', // request from agent
|
||||
FS_WRITE: 'fs/writeTextFile', // request from agent
|
||||
} as const;
|
||||
@@ -32,7 +32,7 @@ interface AcpBridge {
|
||||
model?: string,
|
||||
existingSessionId?: string,
|
||||
historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>,
|
||||
images?: ImageAttachment[],
|
||||
images?: FileAttachment[],
|
||||
): Promise<{ ok: boolean; error?: string }>;
|
||||
aiAcpCancel(requestId: string, chatSessionId?: string): Promise<{ ok: boolean }>;
|
||||
onAiAcpEvent(requestId: string, cb: (event: StreamEvent) => void): () => void;
|
||||
@@ -57,9 +57,6 @@ export interface FileAttachment {
|
||||
filePath?: string;
|
||||
}
|
||||
|
||||
/** @deprecated Use FileAttachment instead */
|
||||
export type ImageAttachment = FileAttachment;
|
||||
|
||||
export async function runAcpAgentTurn(
|
||||
bridge: Record<string, (...args: unknown[]) => unknown>,
|
||||
requestId: string,
|
||||
@@ -72,7 +69,7 @@ export async function runAcpAgentTurn(
|
||||
model?: string,
|
||||
existingSessionId?: string,
|
||||
historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>,
|
||||
images?: ImageAttachment[],
|
||||
images?: FileAttachment[],
|
||||
): Promise<void> {
|
||||
const acpBridge = bridge as unknown as AcpBridge;
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface ExecutorContext {
|
||||
username?: string;
|
||||
protocol?: string;
|
||||
shellType?: string;
|
||||
deviceType?: string;
|
||||
connected: boolean;
|
||||
}>;
|
||||
// Workspace info
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface SystemPromptContext {
|
||||
username?: string;
|
||||
protocol?: string;
|
||||
shellType?: string;
|
||||
deviceType?: string;
|
||||
connected: boolean;
|
||||
}>;
|
||||
permissionMode: 'observer' | 'confirm' | 'autonomous';
|
||||
@@ -54,9 +55,11 @@ ${permissionRules}
|
||||
|
||||
8. **Be careful with file operations.** When writing files via shell commands, prefer appending or targeted edits over full file overwrites when possible.
|
||||
|
||||
9. **Fetch URLs when provided.** When the user shares a URL or asks you to read a webpage, use \`url_fetch\` to retrieve its content.${webSearchEnabled ? `
|
||||
9. **Fetch URLs when provided.** When the user shares a URL or asks you to read a webpage, use \`url_fetch\` to retrieve its content.
|
||||
|
||||
10. **Search proactively.** You have access to \`web_search\`. Use it whenever you encounter something you are unsure about, don't fully understand, or need to verify — including unfamiliar commands, tools, error messages, configuration syntax, or any factual claims. Don't guess; search first. Also use it when the user asks about current events or recent information. Cite sources when presenting search results.` : ''}`;
|
||||
10. **Network device sessions.** Sessions with \`protocol: serial\` (shell: raw) or \`deviceType: network\` (SSH-connected network equipment) are connected to network devices or embedded systems. They do NOT run a standard shell (bash/zsh/etc). Commands are sent as-is without shell wrapping. Do not use shell syntax (pipes, redirects, environment variables, subshells). Use the device's native CLI commands (e.g. Cisco IOS, Huawei VRP, Juniper JunOS). Exit codes are unavailable. Consider disabling pagination first (\`screen-length 0 temporary\` for Huawei, \`terminal length 0\` for Cisco). SFTP is not available for serial sessions.${webSearchEnabled ? `
|
||||
|
||||
11. **Search proactively.** You have access to \`web_search\`. Use it whenever you encounter something you are unsure about, don't fully understand, or need to verify — including unfamiliar commands, tools, error messages, configuration syntax, or any factual claims. Don't guess; search first. Also use it when the user asks about current events or recent information. Cite sources when presenting search results.` : ''}`;
|
||||
}
|
||||
|
||||
function buildScopeDescription(
|
||||
@@ -89,6 +92,7 @@ function buildHostList(
|
||||
host.os ? `os: ${host.os}` : null,
|
||||
host.username ? `user: ${host.username}` : null,
|
||||
host.shellType ? `shell: ${host.shellType}` : null,
|
||||
host.deviceType ? `deviceType: ${host.deviceType}` : null,
|
||||
`status: ${status}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
/**
|
||||
* Run an array of async task factories with a concurrency limit.
|
||||
*/
|
||||
export async function limitConcurrency<T>(tasks: (() => Promise<T>)[], limit: number): Promise<T[]> {
|
||||
const results: T[] = [];
|
||||
const errors: Array<{ index: number; error: unknown }> = [];
|
||||
const executing = new Set<Promise<void>>();
|
||||
for (const [i, task] of tasks.entries()) {
|
||||
const p: Promise<void> = task()
|
||||
.then(r => { results[i] = r; })
|
||||
.catch(err => { errors.push({ index: i, error: err }); })
|
||||
.finally(() => executing.delete(p));
|
||||
executing.add(p);
|
||||
if (executing.size >= limit) {
|
||||
await Promise.race(executing);
|
||||
}
|
||||
}
|
||||
await Promise.all(executing);
|
||||
if (errors.length > 0) {
|
||||
const msgs = errors.map(e => `Task ${e.index}: ${e.error instanceof Error ? e.error.message : String(e.error)}`);
|
||||
throw new AggregateError(errors.map(e => e.error), `${errors.length} task(s) failed: ${msgs.join('; ')}`);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user