Compare commits
76 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6734b9ef9 | ||
|
|
fb443541aa | ||
|
|
7622c43c38 | ||
|
|
a4a5c703b1 | ||
|
|
2063a5ccfe | ||
|
|
1fcf77ef4d | ||
|
|
8296c2c780 | ||
|
|
d1e6857f76 | ||
|
|
eccb9f2cfc | ||
|
|
74d56cdcb8 | ||
|
|
cd04b0b33c | ||
|
|
a29953f831 | ||
|
|
c941038e68 | ||
|
|
b1ab4d7105 | ||
|
|
08e566adb0 | ||
|
|
df25d6c4b0 | ||
|
|
324301e61a | ||
|
|
2c3a8e7fb8 | ||
|
|
bd2642be74 | ||
|
|
23151c9db8 | ||
|
|
8215dfe6a1 | ||
|
|
a1866747a5 | ||
|
|
78fc4628b9 | ||
|
|
c721591466 | ||
|
|
8514c75301 | ||
|
|
c30d872852 | ||
|
|
c58f018d24 | ||
|
|
dd1d97ffff | ||
|
|
3c6d888ca9 | ||
|
|
73b27ad7c4 | ||
|
|
4090483738 | ||
|
|
9bf4aed44f | ||
|
|
a5b5f15343 | ||
|
|
5b26a4a447 | ||
|
|
6565e984b4 | ||
|
|
587071cfea | ||
|
|
08f00ed143 | ||
|
|
b9e9a0d59c | ||
|
|
d02e91a14d | ||
|
|
f38afd8bfc | ||
|
|
c3dabbfef2 | ||
|
|
d5c937b7a9 | ||
|
|
c32a8e603f | ||
|
|
0108390d4f | ||
|
|
e992d51fa6 | ||
|
|
7c55381f39 | ||
|
|
d582baaf53 | ||
|
|
8c1657f1ba | ||
|
|
999ad916e3 | ||
|
|
8ca09b1616 | ||
|
|
70b05bfaaf | ||
|
|
e6ab69b516 | ||
|
|
c6d4d3ec16 | ||
|
|
487b7adf3e | ||
|
|
309996bf3c | ||
|
|
071c95ab5c | ||
|
|
ec99875dec | ||
|
|
51a6b7efaa | ||
|
|
30f5346035 | ||
|
|
e0302e5f34 | ||
|
|
0425841032 | ||
|
|
156550f7eb | ||
|
|
a1648adf12 | ||
|
|
8182bd6b3c | ||
|
|
484ac5f463 | ||
|
|
98e3a6b952 | ||
|
|
f6f3147afb | ||
|
|
54b26511a1 | ||
|
|
8ef91e1266 | ||
|
|
b2689f96a4 | ||
|
|
1b23bdcf15 | ||
|
|
2e63848e0e | ||
|
|
3a748aa1aa | ||
|
|
4574f1e2b2 | ||
|
|
081b167172 | ||
|
|
a818a7004f |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -55,6 +55,9 @@ coverage
|
||||
# Serena MCP project config (local only)
|
||||
/.serena/
|
||||
|
||||
# Git worktrees (local isolated workspaces)
|
||||
/.worktrees/
|
||||
|
||||
# Windows VS Build environment scripts (local dev only)
|
||||
Directory.Build.props
|
||||
Directory.Build.targets
|
||||
|
||||
388
App.tsx
388
App.tsx
@@ -1,5 +1,5 @@
|
||||
import React, { Suspense, lazy, useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react';
|
||||
import { activeTabStore, useActiveTabId, useIsSftpActive, useIsTerminalLayerVisible, useIsVaultActive } from './application/state/activeTabStore';
|
||||
import { activeTabStore, useActiveTabId, useIsSftpActive, useIsTerminalLayerVisible, useIsVaultActive, toEditorTabId, fromEditorTabId, isEditorTabId } from './application/state/activeTabStore';
|
||||
import { useAutoSync } from './application/state/useAutoSync';
|
||||
import { useImmersiveMode } from './application/state/useImmersiveMode';
|
||||
import { useManagedSourceSync } from './application/state/useManagedSourceSync';
|
||||
@@ -10,6 +10,7 @@ import { useSettingsState } from './application/state/useSettingsState';
|
||||
import { useUpdateCheck } from './application/state/useUpdateCheck';
|
||||
import { useVaultState } from './application/state/useVaultState';
|
||||
import { useWindowControls } from './application/state/useWindowControls';
|
||||
import { useEditorTabs, editorTabStore } from './application/state/editorTabStore';
|
||||
import { initializeFonts } from './application/state/fontStore';
|
||||
import { initializeUIFonts } from './application/state/uiFontStore';
|
||||
import { I18nProvider, useI18n } from './application/i18n/I18nProvider';
|
||||
@@ -18,6 +19,8 @@ import { resolveGroupDefaults, applyGroupDefaults } from './domain/groupConfig';
|
||||
import { resolveHostAuth } from './domain/sshAuth';
|
||||
import { resolveHostTerminalThemeId } from './domain/terminalAppearance';
|
||||
import { collectSessionIds } from './domain/workspace';
|
||||
import { resolveCloseIntent } from './application/state/resolveCloseIntent';
|
||||
import { resolveSnippetsShortcutIntent } from './application/state/resolveSnippetsShortcutIntent';
|
||||
import { TERMINAL_THEMES } from './infrastructure/config/terminalThemes';
|
||||
import { useCustomThemes } from './application/state/customThemeStore';
|
||||
import type { SyncPayload } from './domain/sync';
|
||||
@@ -43,6 +46,7 @@ import { Label } from './components/ui/label';
|
||||
import { ToastProvider, toast } from './components/ui/toast';
|
||||
import { VaultView, VaultSection } from './components/VaultView';
|
||||
import { QuickAddSnippetDialog } from './components/QuickAddSnippetDialog';
|
||||
import { AddToWorkspaceDialog } from './components/workspace/AddToWorkspaceDialog';
|
||||
import { KeyboardInteractiveModal, KeyboardInteractiveRequest } from './components/KeyboardInteractiveModal';
|
||||
import { PassphraseModal, PassphraseRequest } from './components/PassphraseModal';
|
||||
import { cn } from './lib/utils';
|
||||
@@ -52,6 +56,9 @@ import { ConnectionLog, Host, HostProtocol, SerialConfig, TerminalSession, Termi
|
||||
import { LogView as LogViewType } from './application/state/useSessionState';
|
||||
import type { SftpView as SftpViewComponent } from './components/SftpView';
|
||||
import type { TerminalLayer as TerminalLayerComponent } from './components/TerminalLayer';
|
||||
import { TextEditorTabView } from './components/editor/TextEditorTabView';
|
||||
import { UnsavedChangesProvider } from './components/editor/UnsavedChangesDialog';
|
||||
import { editorSftpWrite } from './application/state/editorSftpBridge';
|
||||
|
||||
// Initialize fonts eagerly at app startup
|
||||
initializeFonts();
|
||||
@@ -177,6 +184,15 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
|
||||
const [isQuickSwitcherOpen, setIsQuickSwitcherOpen] = useState(false);
|
||||
const [isCreateWorkspaceOpen, setIsCreateWorkspaceOpen] = useState(false);
|
||||
// Combined state for the AddToWorkspaceDialog. null = closed; mode
|
||||
// determines whether picking targets appends them to an existing
|
||||
// workspace (focus sidebar "+") or spins up a brand-new workspace
|
||||
// tab (QuickSwitcher's New Workspace button).
|
||||
const [addToWorkspaceDialog, setAddToWorkspaceDialog] = useState<
|
||||
| { mode: 'append'; workspaceId: string }
|
||||
| { mode: 'create' }
|
||||
| null
|
||||
>(null);
|
||||
const [quickSearch, setQuickSearch] = useState('');
|
||||
// Protocol selection dialog state for QuickSwitcher
|
||||
const [protocolSelectHost, setProtocolSelectHost] = useState<Host | null>(null);
|
||||
@@ -291,6 +307,9 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
createWorkspaceWithHosts,
|
||||
createWorkspaceFromSessions,
|
||||
addSessionToWorkspace,
|
||||
appendHostToWorkspace,
|
||||
appendLocalTerminalToWorkspace,
|
||||
createWorkspaceFromTargets,
|
||||
updateSplitSizes,
|
||||
splitSession,
|
||||
toggleWorkspaceViewMode,
|
||||
@@ -316,6 +335,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
// ---------------------------------------------------------------------------
|
||||
const activeTabId = useActiveTabId();
|
||||
const customThemes = useCustomThemes();
|
||||
const editorTabs = useEditorTabs();
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings.showSftpTab && activeTabId === 'sftp') {
|
||||
@@ -855,6 +875,36 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Quit guard: block app exit while any editor tab has unsaved changes.
|
||||
// Main process sends "app:query-dirty-editors"; we respond with the result.
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onCheckDirtyEditors) return;
|
||||
const unsub = bridge.onCheckDirtyEditors(() => {
|
||||
// Always report SOMETHING so the main process doesn't time out for
|
||||
// 5 s on an unhandled exception. If we can't determine the state,
|
||||
// fail open — losing unsaved work is bad, but stranding the user
|
||||
// on a slow quit and then quitting anyway after the timeout is
|
||||
// exactly the same outcome.
|
||||
let hasDirty = false;
|
||||
try {
|
||||
hasDirty = editorTabStore.getTabs().some((tab) => tab.content !== tab.baselineContent);
|
||||
if (hasDirty) toast.warning(t('sftp.editor.quitBlockedByDirty'), 'SFTP');
|
||||
} catch (err) {
|
||||
console.error('[App] dirty-editors check failed:', err);
|
||||
}
|
||||
try {
|
||||
bridge.reportDirtyEditorsResult?.(hasDirty);
|
||||
} catch (err) {
|
||||
// Reporting itself shouldn't throw, but if the IPC bridge is in a
|
||||
// bad state we'd rather log than bubble out of the listener and
|
||||
// disable the quit guard for the rest of the session.
|
||||
console.error('[App] reportDirtyEditorsResult failed:', err);
|
||||
}
|
||||
});
|
||||
return unsub;
|
||||
}, [t]);
|
||||
|
||||
// Keyboard-interactive authentication (2FA/MFA) event listener
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -992,6 +1042,15 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const addConnectionLogRef = useRef(addConnectionLog);
|
||||
addConnectionLogRef.current = addConnectionLog;
|
||||
|
||||
const closeSidePanelRef = useRef<(() => void) | null>(null);
|
||||
const toggleScriptsSidePanelRef = useRef<(() => void) | null>(null);
|
||||
const activeSidePanelTabRef = useRef<string | null>(null);
|
||||
const closeTabInFlightRef = useRef(false);
|
||||
// Populated by UnsavedChangesProvider render-prop below so that the hotkey
|
||||
// dispatcher (defined outside that scope) can still reach the dirty-confirm
|
||||
// close flow.
|
||||
const handleRequestCloseEditorTabRef = useRef<(id: string) => void>(() => {});
|
||||
|
||||
const createLocalTerminalWithCurrentShell = useCallback(() => {
|
||||
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
|
||||
const matchedShell = discoveredShells.find(s => s.id === terminalSettings.localShell);
|
||||
@@ -1025,15 +1084,97 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
return hotkeyScheme === 'mac' ? closeTabBinding.mac : closeTabBinding.pc;
|
||||
}, [hotkeyScheme, keyBindings]);
|
||||
|
||||
const confirmIfBusyLocalTerminal = useCallback(
|
||||
async (sessionIds: string[]): Promise<boolean> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
const localIds = sessionIds.filter((id) => {
|
||||
const s = sessions.find((x) => x.id === id);
|
||||
return s?.protocol === 'local';
|
||||
});
|
||||
const busyCommands: string[] = [];
|
||||
for (const id of localIds) {
|
||||
const children = (await bridge?.ptyGetChildProcesses?.(id)) ?? [];
|
||||
if (children.length > 0) {
|
||||
busyCommands.push(children[0].command);
|
||||
}
|
||||
}
|
||||
if (busyCommands.length === 0) return true;
|
||||
|
||||
const primary = busyCommands[0];
|
||||
const extraCount = busyCommands.length - 1;
|
||||
const message =
|
||||
extraCount > 0
|
||||
? t('confirm.closeBusyTerminal.messageWithMore', {
|
||||
command: primary,
|
||||
count: extraCount,
|
||||
})
|
||||
: t('confirm.closeBusyTerminal.message', { command: primary });
|
||||
|
||||
const ok = await bridge?.confirmCloseBusy?.({
|
||||
command: primary,
|
||||
title: t('confirm.closeBusyTerminal.title'),
|
||||
message,
|
||||
cancelLabel: t('confirm.closeBusyTerminal.cancel'),
|
||||
closeLabel: t('confirm.closeBusyTerminal.close'),
|
||||
});
|
||||
return ok === true;
|
||||
},
|
||||
[sessions, t],
|
||||
);
|
||||
|
||||
const closeTabsInFlightRef = useRef(false);
|
||||
|
||||
// Close many tabs at once with a single batched busy-shell confirmation.
|
||||
// Used by the "Close all / Close others / Close to the right" context-menu
|
||||
// actions on tabs (#748).
|
||||
const closeTabsBatch = useCallback(
|
||||
async (targetIds: string[]) => {
|
||||
if (targetIds.length === 0) return;
|
||||
if (closeTabsInFlightRef.current) return;
|
||||
|
||||
// Expand workspace ids into their constituent session ids so the busy
|
||||
// probe sees every local shell that's about to be killed.
|
||||
const sessionIdsToProbe: string[] = [];
|
||||
for (const tabId of targetIds) {
|
||||
const ws = workspaces.find((w) => w.id === tabId);
|
||||
if (ws) {
|
||||
for (const s of sessions) {
|
||||
if (s.workspaceId === tabId) sessionIdsToProbe.push(s.id);
|
||||
}
|
||||
} else if (sessions.find((s) => s.id === tabId)) {
|
||||
sessionIdsToProbe.push(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
closeTabsInFlightRef.current = true;
|
||||
try {
|
||||
const ok = await confirmIfBusyLocalTerminal(sessionIdsToProbe);
|
||||
if (!ok) return;
|
||||
for (const tabId of targetIds) {
|
||||
if (workspaces.find((w) => w.id === tabId)) {
|
||||
closeWorkspace(tabId);
|
||||
} else if (sessions.find((s) => s.id === tabId)) {
|
||||
closeSession(tabId);
|
||||
} else if (logViews.find((lv) => lv.id === tabId)) {
|
||||
closeLogView(tabId);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
closeTabsInFlightRef.current = false;
|
||||
}
|
||||
},
|
||||
[workspaces, sessions, logViews, confirmIfBusyLocalTerminal, closeWorkspace, closeSession, closeLogView],
|
||||
);
|
||||
|
||||
// Shared hotkey action handler - used by both global handler and terminal callback
|
||||
const executeHotkeyAction = useCallback((action: string, e: KeyboardEvent) => {
|
||||
// Build complete tab list: vault + (sftp when visible) + sessions/workspaces.
|
||||
// Build complete tab list: vault + (sftp when visible) + sessions/workspaces + editor tabs.
|
||||
// Hiding the SFTP tab must also remove it from keyboard cycling so nextTab
|
||||
// doesn't land on a hidden tab (which would get redirected back) and so
|
||||
// number shortcuts don't shift.
|
||||
const allTabs = settings.showSftpTab
|
||||
? ['vault', 'sftp', ...orderedTabs]
|
||||
: ['vault', ...orderedTabs];
|
||||
? ['vault', 'sftp', ...orderedTabs, ...editorTabs.map((t) => toEditorTabId(t.id))]
|
||||
: ['vault', ...orderedTabs, ...editorTabs.map((t) => toEditorTabId(t.id))];
|
||||
switch (action) {
|
||||
case 'switchToTab': {
|
||||
// Get the number key pressed (1-9)
|
||||
@@ -1069,18 +1210,59 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}
|
||||
case 'closeTab': {
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
if (currentId !== 'vault' && currentId !== 'sftp') {
|
||||
// Find if it's a session or workspace
|
||||
const session = sessions.find(s => s.id === currentId);
|
||||
if (session) {
|
||||
closeSession(currentId);
|
||||
} else {
|
||||
const workspace = workspaces.find(w => w.id === currentId);
|
||||
if (workspace) {
|
||||
closeWorkspace(currentId);
|
||||
}
|
||||
}
|
||||
if (!currentId || currentId === 'vault' || currentId === 'sftp') break;
|
||||
if (closeTabInFlightRef.current) break;
|
||||
|
||||
// Editor tabs route through their own dirty-confirm close flow.
|
||||
if (isEditorTabId(currentId)) {
|
||||
const editorId = fromEditorTabId(currentId);
|
||||
if (editorId) handleRequestCloseEditorTabRef.current(editorId);
|
||||
break;
|
||||
}
|
||||
|
||||
const session = sessions.find((s) => s.id === currentId) ?? null;
|
||||
const workspace = workspaces.find((w) => w.id === currentId) ?? null;
|
||||
|
||||
const focusIsInsideTerminal = !!document.activeElement?.closest('[data-session-id]');
|
||||
const activeSidePanel = activeSidePanelTabRef.current;
|
||||
|
||||
const intent = resolveCloseIntent({
|
||||
activeTabId: currentId,
|
||||
workspace: workspace ? { id: workspace.id, focusedSessionId: workspace.focusedSessionId } : null,
|
||||
sessionForTab: session,
|
||||
activeSidePanelTab: activeSidePanel,
|
||||
focusIsInsideTerminal,
|
||||
});
|
||||
|
||||
closeTabInFlightRef.current = true;
|
||||
(async () => {
|
||||
try {
|
||||
switch (intent.kind) {
|
||||
case 'closeTerminal':
|
||||
case 'closeSingleTab': {
|
||||
const ok = await confirmIfBusyLocalTerminal([intent.sessionId]);
|
||||
if (ok) closeSession(intent.sessionId);
|
||||
return;
|
||||
}
|
||||
case 'closeSidePanel': {
|
||||
closeSidePanelRef.current?.();
|
||||
return;
|
||||
}
|
||||
case 'closeWorkspace': {
|
||||
const ids = sessions.filter((s) => s.workspaceId === intent.workspaceId).map((s) => s.id);
|
||||
const ok = await confirmIfBusyLocalTerminal(ids);
|
||||
if (ok) closeWorkspace(intent.workspaceId);
|
||||
return;
|
||||
}
|
||||
case 'noop':
|
||||
default:
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
closeTabInFlightRef.current = false;
|
||||
}
|
||||
})();
|
||||
|
||||
break;
|
||||
}
|
||||
case 'newTab':
|
||||
@@ -1111,15 +1293,35 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
case 'commandPalette':
|
||||
setIsQuickSwitcherOpen(true);
|
||||
break;
|
||||
case 'newWorkspace':
|
||||
// Dedicated shortcut to launch the AddToWorkspaceDialog in
|
||||
// create mode — same entry as QuickSwitcher's "New Workspace"
|
||||
// button, but without having to open QS first.
|
||||
setAddToWorkspaceDialog({ mode: 'create' });
|
||||
break;
|
||||
case 'portForwarding':
|
||||
// Navigate to vault and open port forwarding section
|
||||
setActiveTabId('vault');
|
||||
setNavigateToSection('port');
|
||||
break;
|
||||
case 'snippets':
|
||||
// Navigate to vault and open snippets section
|
||||
setActiveTabId('vault');
|
||||
setNavigateToSection('snippets');
|
||||
{
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
const intent = resolveSnippetsShortcutIntent({
|
||||
activeTabId: currentId,
|
||||
sessionForTab: sessions.find((s) => s.id === currentId) ?? null,
|
||||
workspaceForTab: workspaces.find((w) => w.id === currentId) ?? null,
|
||||
terminalScriptsToggleAvailable: !!toggleScriptsSidePanelRef.current,
|
||||
});
|
||||
|
||||
if (intent.kind === 'toggleTerminalScripts') {
|
||||
toggleScriptsSidePanelRef.current();
|
||||
break;
|
||||
}
|
||||
|
||||
setActiveTabId('vault');
|
||||
setNavigateToSection('snippets');
|
||||
}
|
||||
break;
|
||||
case 'broadcast': {
|
||||
// Toggle broadcast mode for the active workspace
|
||||
@@ -1193,7 +1395,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [orderedTabs, sessions, workspaces, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast, settings.showSftpTab]);
|
||||
}, [orderedTabs, editorTabs, sessions, workspaces, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast, settings.showSftpTab, confirmIfBusyLocalTerminal]);
|
||||
|
||||
// Callback for terminal to invoke app-level hotkey actions
|
||||
const handleHotkeyAction = useCallback((action: string, e: KeyboardEvent) => {
|
||||
@@ -1502,6 +1704,19 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
};
|
||||
}, [handleOpenSettings, t]);
|
||||
|
||||
// Delete-from-sidepanel plumbing: ScriptsSidePanel's right-click menu
|
||||
// dispatches `netcatty:snippets:delete` with the snippet id. Handled here
|
||||
// (rather than in QuickAddSnippetDialog) because delete needs no UI.
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const id = (e as CustomEvent<{ id?: string }>).detail?.id;
|
||||
if (!id) return;
|
||||
updateSnippets(snippets.filter((s) => s.id !== id));
|
||||
};
|
||||
window.addEventListener('netcatty:snippets:delete', handler);
|
||||
return () => window.removeEventListener('netcatty:snippets:delete', handler);
|
||||
}, [snippets, updateSnippets]);
|
||||
|
||||
const handleEndSessionDrag = useCallback(() => {
|
||||
setDraggingSessionId(null);
|
||||
}, [setDraggingSessionId]);
|
||||
@@ -1534,7 +1749,59 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
// Combined ordered tab list including editor tab ids (for TopTabs scrollable area)
|
||||
const orderedTabsWithEditors = useMemo(
|
||||
() => [...orderedTabs, ...editorTabs.map((t) => toEditorTabId(t.id))],
|
||||
[orderedTabs, editorTabs],
|
||||
);
|
||||
|
||||
return (
|
||||
<UnsavedChangesProvider>
|
||||
{({ prompt }) => {
|
||||
// Helper: close an editor tab and activate the neighbor (left-preference), or vault.
|
||||
const closeEditorAndActivateNeighbor = (id: string) => {
|
||||
const closingTabId = toEditorTabId(id);
|
||||
const list = orderedTabsWithEditors;
|
||||
const idx = list.indexOf(closingTabId);
|
||||
editorTabStore.close(id);
|
||||
if (activeTabStore.getActiveTabId() !== closingTabId) return;
|
||||
const next = list[idx - 1] ?? list[idx + 1] ?? 'vault';
|
||||
activeTabStore.setActiveTabId(next === closingTabId ? 'vault' : next);
|
||||
};
|
||||
|
||||
// Real dirty-confirm close handler.
|
||||
const handleRequestCloseEditorTab = async (id: string) => {
|
||||
const tab = editorTabStore.getTab(id);
|
||||
if (!tab) return;
|
||||
const dirty = tab.content !== tab.baselineContent;
|
||||
if (!dirty) {
|
||||
closeEditorAndActivateNeighbor(id);
|
||||
return;
|
||||
}
|
||||
const choice = await prompt(tab.fileName);
|
||||
if (choice === 'cancel') return;
|
||||
if (choice === 'discard') {
|
||||
closeEditorAndActivateNeighbor(id);
|
||||
return;
|
||||
}
|
||||
if (choice === 'save') {
|
||||
try {
|
||||
editorTabStore.setSavingState(id, 'saving');
|
||||
await editorSftpWrite(tab.sessionId, tab.hostId, tab.remotePath, tab.content);
|
||||
editorTabStore.markSaved(id, tab.content);
|
||||
closeEditorAndActivateNeighbor(id);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'Save failed';
|
||||
editorTabStore.setSavingState(id, 'error', msg);
|
||||
toast.error(msg, 'SFTP');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Expose to the hotkey dispatcher (Cmd/Ctrl+W).
|
||||
handleRequestCloseEditorTabRef.current = handleRequestCloseEditorTab;
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-screen text-foreground font-sans netcatty-shell", activeTerminalTheme && "immersive-transition")} onContextMenu={handleRootContextMenu}>
|
||||
<TopTabs
|
||||
theme={resolvedTheme}
|
||||
@@ -1544,7 +1811,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
orphanSessions={orphanSessions}
|
||||
workspaces={workspaces}
|
||||
logViews={logViews}
|
||||
orderedTabs={orderedTabs}
|
||||
orderedTabs={orderedTabsWithEditors}
|
||||
draggingSessionId={draggingSessionId}
|
||||
isMacClient={isMacClient}
|
||||
onCloseSession={closeSession}
|
||||
@@ -1553,6 +1820,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
onRenameWorkspace={startWorkspaceRename}
|
||||
onCloseWorkspace={closeWorkspace}
|
||||
onCloseLogView={closeLogView}
|
||||
onCloseTabsBatch={closeTabsBatch}
|
||||
onOpenQuickSwitcher={handleOpenQuickSwitcher}
|
||||
onToggleTheme={handleToggleTheme}
|
||||
onOpenSettings={handleOpenSettings}
|
||||
@@ -1562,6 +1830,9 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
onEndSessionDrag={handleEndSessionDrag}
|
||||
onReorderTabs={reorderTabs}
|
||||
showSftpTab={settings.showSftpTab}
|
||||
editorTabs={editorTabs}
|
||||
onRequestCloseEditorTab={handleRequestCloseEditorTab}
|
||||
hostById={hostById}
|
||||
/>
|
||||
|
||||
<div className="flex-1 relative min-h-0">
|
||||
@@ -1665,6 +1936,9 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
onTerminalDataCapture={handleTerminalDataCapture}
|
||||
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
|
||||
onAddSessionToWorkspace={addSessionToWorkspace}
|
||||
onRequestAddToWorkspace={(workspaceId) =>
|
||||
setAddToWorkspaceDialog({ mode: 'append', workspaceId })
|
||||
}
|
||||
onUpdateSplitSizes={updateSplitSizes}
|
||||
onSetDraggingSessionId={setDraggingSessionId}
|
||||
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
|
||||
@@ -1684,6 +1958,9 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
sessionLogsEnabled={sessionLogsEnabled}
|
||||
sessionLogsDir={sessionLogsDir}
|
||||
sessionLogsFormat={sessionLogsFormat}
|
||||
closeSidePanelRef={closeSidePanelRef}
|
||||
toggleScriptsSidePanelRef={toggleScriptsSidePanelRef}
|
||||
activeSidePanelTabRef={activeSidePanelTabRef}
|
||||
/>
|
||||
|
||||
{/* Log Views - readonly terminal replays */}
|
||||
@@ -1701,19 +1978,80 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Editor Tabs — kept mounted for Monaco instance persistence; visibility toggled via CSS */}
|
||||
{editorTabs.map((tab) => (
|
||||
<TextEditorTabView
|
||||
key={tab.id}
|
||||
tabId={tab.id}
|
||||
isVisible={activeTabId === toEditorTabId(tab.id)}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
hostById={hostById}
|
||||
onRequestClose={(id) => handleRequestCloseEditorTabRef.current(id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Global "quick add snippet" dialog, triggered by the
|
||||
netcatty:snippets:add window event (from ScriptsSidePanel "+"). */}
|
||||
{/* Global "quick add / edit snippet" dialog, triggered by the
|
||||
netcatty:snippets:add and :edit window events (from ScriptsSidePanel
|
||||
"+" button and right-click menu). Delete is handled by a sibling
|
||||
useEffect above — it does not need a dialog. */}
|
||||
<QuickAddSnippetDialog
|
||||
snippets={snippets}
|
||||
packages={snippetPackages}
|
||||
onCreateSnippet={(snippet) => updateSnippets([...snippets, snippet])}
|
||||
onUpdateSnippet={(snippet) =>
|
||||
updateSnippets(snippets.map((s) => (s.id === snippet.id ? snippet : s)))
|
||||
}
|
||||
onCreatePackage={(pkg) =>
|
||||
updateSnippetPackages(Array.from(new Set([...snippetPackages, pkg])))
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Root-mounted AddToWorkspaceDialog — triggered by the focus-mode
|
||||
"+" button (mode='append') or QuickSwitcher's "New Workspace"
|
||||
button (mode='create'). Single instance so dialog state and
|
||||
styling stay consistent across entry points. */}
|
||||
{addToWorkspaceDialog && (
|
||||
<AddToWorkspaceDialog
|
||||
open
|
||||
onOpenChange={(open) => { if (!open) setAddToWorkspaceDialog(null); }}
|
||||
// Filter serial hosts only in append mode — appendHostToWorkspace
|
||||
// has no serial code path. Create mode goes through
|
||||
// createWorkspaceFromTargets, which builds a SerialConfig-backed
|
||||
// session for serial hosts, so those should remain pickable.
|
||||
hosts={addToWorkspaceDialog.mode === 'append'
|
||||
? hosts.filter((h) => h.protocol !== 'serial')
|
||||
: hosts}
|
||||
workspaceTitle={
|
||||
addToWorkspaceDialog.mode === 'append'
|
||||
? workspaces.find((w) => w.id === addToWorkspaceDialog.workspaceId)?.title
|
||||
: 'New Workspace'
|
||||
}
|
||||
onAdd={(targets) => {
|
||||
if (addToWorkspaceDialog.mode === 'append') {
|
||||
// Match the workspace root's current split direction so
|
||||
// the new panes peer the existing siblings instead of
|
||||
// wrapping the whole tree into one side of a fresh split
|
||||
// (which would happen if we always passed the helper's
|
||||
// default 'vertical').
|
||||
const ws = workspaces.find((w) => w.id === addToWorkspaceDialog.workspaceId);
|
||||
const rootDir = ws && ws.root.type === 'split' ? ws.root.direction : 'vertical';
|
||||
for (const target of targets) {
|
||||
if (target.kind === 'local') {
|
||||
appendLocalTerminalToWorkspace(addToWorkspaceDialog.workspaceId, undefined, rootDir);
|
||||
} else {
|
||||
appendHostToWorkspace(addToWorkspaceDialog.workspaceId, target.host, rootDir);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
createWorkspaceFromTargets(targets);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isQuickSwitcherOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<LazyQuickSwitcher
|
||||
@@ -1737,7 +2075,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}}
|
||||
onCreateWorkspace={() => {
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setIsCreateWorkspaceOpen(true);
|
||||
setQuickSearch('');
|
||||
setAddToWorkspaceDialog({ mode: 'create' });
|
||||
}}
|
||||
onClose={() => {
|
||||
setIsQuickSwitcherOpen(false);
|
||||
@@ -1898,6 +2237,9 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</UnsavedChangesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
45
README.md
45
README.md
@@ -40,7 +40,8 @@
|
||||
|
||||
---
|
||||
|
||||
[](screenshots/main-window-dark.png)
|
||||
<img width="2868" height="1784" alt="netcatty SSH (Window) 2026-04-23 11:19 PM" src="https://github.com/user-attachments/assets/d6df734f-9ebc-452a-8b7d-e8a0fdc9463a" />
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -48,11 +49,6 @@
|
||||
# 🔥 Catty Agent — Your IT Ops AI Partner
|
||||
|
||||
> 🚀 **Boost your IT ops daily work with AI power.** Catty Agent is the built-in AI assistant that understands your servers, executes commands, and handles complex multi-host operations — all through natural conversation.
|
||||
|
||||
<p align="center">
|
||||
<img src="screenshots/ai-feature.png" alt="Catty Agent Interface" width="800">
|
||||
</p>
|
||||
|
||||
### 🔥 What can Catty Agent do?
|
||||
|
||||
- 🚀 **Natural language server management** — just tell it what you need, no more memorizing commands
|
||||
@@ -68,7 +64,10 @@
|
||||
Ask Catty Agent to check a server's health, and it runs the right commands, analyzes the output, and gives you a clear summary — all in seconds.
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/eecf08f1-80bd-49db-886d-b36e93388865
|
||||
|
||||
https://github.com/user-attachments/assets/f819a1b6-8cba-4910-8017-97dfc080b477
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -78,8 +77,9 @@ https://github.com/user-attachments/assets/eecf08f1-80bd-49db-886d-b36e93388865
|
||||
Watch Catty Agent orchestrate a Docker Swarm cluster across two servers in one conversation. It handles the init, token exchange, and node joining — you just tell it what you want.
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/52fd30b8-9f02-43d4-a3b2-142691e8e3ec
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/282027aa-5c9e-4bb1-b2c3-5eea9df2b203
|
||||
|
||||
|
||||
|
||||
@@ -160,21 +160,27 @@ Video previews (stored in `screenshots/gifs/`), rendered inline on GitHub:
|
||||
### Vault views: grid / list / tree
|
||||
Switch between different Vault views to match your workflow: overview in grid, dense scanning in list, and hierarchical navigation in tree.
|
||||
|
||||
https://github.com/user-attachments/assets/e2742987-3131-404d-bd4b-06423e5bfd99
|
||||
|
||||
https://github.com/user-attachments/assets/1ff1f3f1-e5ae-40ea-b35a-0e5148c3afeb
|
||||
|
||||
|
||||
|
||||
### Split terminals + session management
|
||||
Work in multiple sessions at once with split panes. Keep related tasks side-by-side and reduce context switching.
|
||||
|
||||
https://github.com/user-attachments/assets/377d0c46-cc5a-4382-aa31-5acfd412ce62
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/9c24b519-4b4b-4910-a22a-590d04c9af31
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
### SFTP: drag & drop + built-in editor
|
||||
Move files with drag & drop, then edit quickly using the built-in editor without leaving the app.
|
||||
|
||||
https://github.com/user-attachments/assets/c6e06af4-b0d5-461c-b0c7-9d6f655af6c7
|
||||
|
||||
https://github.com/user-attachments/assets/f3afdb36-399d-4330-b9f3-4678f178f6db
|
||||
|
||||
|
||||
|
||||
@@ -182,7 +188,11 @@ https://github.com/user-attachments/assets/c6e06af4-b0d5-461c-b0c7-9d6f655af6c7
|
||||
### Drag file upload
|
||||
Drop files into the app to kick off uploads without hunting through dialogs.
|
||||
|
||||
https://github.com/user-attachments/assets/c8e0c4ff-f020-4e18-9b09-681ec97b003f
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/e1e26f7a-3489-41cc-975e-8dccba56ea85
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -190,7 +200,10 @@ https://github.com/user-attachments/assets/c8e0c4ff-f020-4e18-9b09-681ec97b003f
|
||||
### Custom themes
|
||||
Make Netcatty yours: customize themes and UI appearance.
|
||||
|
||||
https://github.com/user-attachments/assets/77e2a693-4ef2-4823-8ca1-9bcbf14ed98b
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/1a6049aa-9a4c-4d52-a13d-0b007a791b00
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -198,7 +211,11 @@ https://github.com/user-attachments/assets/77e2a693-4ef2-4823-8ca1-9bcbf14ed98b
|
||||
### Keyword highlighting
|
||||
Highlight important terminal output so errors, warnings, and key events stand out at a glance.
|
||||
|
||||
https://github.com/user-attachments/assets/e6516993-ad66-4594-8c28-57426082339b
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/1a1db7bd-948b-4f3c-97cd-8fd0cbe7cce7
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -56,6 +56,11 @@ const en: Messages = {
|
||||
'confirm.deleteHost': 'Delete Host "{name}"?',
|
||||
'confirm.deleteIdentity': 'Delete Identity "{name}"?',
|
||||
'confirm.removeProvider': 'Remove provider "{name}"?',
|
||||
'confirm.closeBusyTerminal.title': 'Confirm close',
|
||||
'confirm.closeBusyTerminal.message': 'Process "{command}" is still running and will be terminated.',
|
||||
'confirm.closeBusyTerminal.messageWithMore': 'Process "{command}" and {count} other running process(es) will be terminated.',
|
||||
'confirm.closeBusyTerminal.cancel': 'Cancel',
|
||||
'confirm.closeBusyTerminal.close': 'Close',
|
||||
'dialog.createWorkspace.title': 'Create Workspace',
|
||||
'dialog.renameWorkspace.title': 'Rename workspace',
|
||||
'dialog.renameSession.title': 'Rename session',
|
||||
@@ -301,6 +306,12 @@ const en: Messages = {
|
||||
'settings.terminal.behavior.bracketedPaste': 'Bracketed paste mode',
|
||||
'settings.terminal.behavior.bracketedPaste.desc':
|
||||
'Wrap pasted text with escape sequences so the shell can distinguish paste from typed input. Disable if you see ^[[200~ artifacts.',
|
||||
'settings.terminal.behavior.clearWipesScrollback': '`clear` wipes scrollback',
|
||||
'settings.terminal.behavior.clearWipesScrollback.desc':
|
||||
'Make `clear` also wipe the scrollback buffer (POSIX default). Disable to keep history visible after `clear`.',
|
||||
'settings.terminal.behavior.preserveSelectionOnInput': 'Keep selection while typing',
|
||||
'settings.terminal.behavior.preserveSelectionOnInput.desc':
|
||||
'Don\'t clear mouse-selected text when typing — useful for selecting a path then pasting it after a command prefix like `sz `.',
|
||||
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 clipboard',
|
||||
'settings.terminal.behavior.osc52Clipboard.desc':
|
||||
'Allow remote programs (tmux, vim, etc.) to access the local clipboard via OSC-52 escape sequences.',
|
||||
@@ -364,6 +375,21 @@ const en: Messages = {
|
||||
'settings.terminal.section.connection': 'Connection',
|
||||
'settings.terminal.connection.keepaliveInterval': 'Keepalive Interval',
|
||||
'settings.terminal.connection.keepaliveInterval.desc': 'How often (in seconds) to send SSH-level keepalive packets to server. Set to 0 to disable.',
|
||||
'settings.terminal.connection.x11Display': 'X11 display',
|
||||
'settings.terminal.connection.x11Display.desc': 'Optional local display address for X11 forwarding. Leave empty to use the system default.',
|
||||
'settings.terminal.connection.x11Display.placeholder': 'Auto (:0 or DISPLAY)',
|
||||
'settings.terminal.mosh.client': 'Mosh client path',
|
||||
'settings.terminal.mosh.client.desc': 'Absolute path to the local mosh executable. Leave empty to auto-detect on PATH and common install locations (Homebrew, MacPorts, ~/.nix-profile, ~/.cargo, ~/.local).',
|
||||
'settings.terminal.mosh.client.placeholder': 'Auto-detect',
|
||||
'settings.terminal.mosh.client.notFound': 'File not found at that path.',
|
||||
'settings.terminal.mosh.client.isDirectory': 'Path points to a directory, not an executable.',
|
||||
'settings.terminal.mosh.client.notExecutable': 'File exists but is not executable. Run `chmod +x` on it or pick another binary.',
|
||||
'settings.terminal.mosh.client.notAbsolute': 'Path must be absolute. Use Browse… to pick the binary, leave the field empty to auto-detect, or enter a full path.',
|
||||
'settings.terminal.mosh.detect': 'Detect',
|
||||
'settings.terminal.mosh.browse': 'Browse…',
|
||||
'settings.terminal.mosh.autoDetected': 'Auto-detected',
|
||||
'settings.terminal.mosh.detected': 'Detected at',
|
||||
'settings.terminal.mosh.notDetected': 'Mosh not found in:',
|
||||
'settings.terminal.section.serverStats': 'Server Stats (Linux)',
|
||||
'settings.terminal.serverStats.show': 'Show Server Stats',
|
||||
'settings.terminal.serverStats.show.desc': 'Display CPU, memory, and disk usage in the terminal statusbar (Linux servers only).',
|
||||
@@ -404,6 +430,7 @@ const en: Messages = {
|
||||
'settings.shortcuts.resetAll': 'Reset All',
|
||||
'settings.shortcuts.recording': 'Press keys...',
|
||||
'settings.shortcuts.none': 'None',
|
||||
'settings.shortcuts.setDisabled': 'Set to disabled',
|
||||
'settings.shortcuts.category.tabs': 'Tabs',
|
||||
'settings.shortcuts.category.terminal': 'Terminal',
|
||||
'settings.shortcuts.category.navigation': 'Navigation',
|
||||
@@ -467,6 +494,30 @@ const en: Messages = {
|
||||
'sync.autoSync.emptyVaultConflict.keepEmpty': 'Keep Empty',
|
||||
'sync.autoSync.emptyVaultConflict.keepEmptyDesc': 'Start fresh with an empty vault',
|
||||
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} hosts, {keys} keys, {snippets} snippets',
|
||||
'sync.autoSync.emptyVaultManual': 'Cannot sync: the local vault is empty. Restore from a local backup or enable Force Push in the sync panel first.',
|
||||
|
||||
'sync.blocked.title': 'Sync paused',
|
||||
'sync.blocked.reason.bulkShrink': 'Would delete {lost} of {baseCount} {entityType} from cloud ({percent}% reduction).',
|
||||
'sync.blocked.reason.largeShrink': 'Would delete {lost} {entityType} from cloud.',
|
||||
'sync.blocked.detail': 'This is usually caused by a degraded local state (keychain failure, partial data load). Restore from a local backup, or force-push if you truly meant to remove these entries.',
|
||||
'sync.blocked.restoreButton': 'Restore from local backup',
|
||||
'sync.blocked.forcePushButton': 'Force push anyway',
|
||||
|
||||
'sync.forcePush.title': 'Confirm force push',
|
||||
'sync.forcePush.body': 'You are about to remove {lost} {entityType} from the cloud. This cannot be undone. Proceed?',
|
||||
'sync.forcePush.confirm': 'Yes, push anyway',
|
||||
'sync.forcePush.cancel': 'Cancel',
|
||||
|
||||
'sync.entityType.hosts': 'hosts',
|
||||
'sync.entityType.keys': 'keys',
|
||||
'sync.entityType.identities': 'identities',
|
||||
'sync.entityType.snippets': 'snippets',
|
||||
'sync.entityType.customGroups': 'groups',
|
||||
'sync.entityType.snippetPackages': 'snippet packages',
|
||||
'sync.entityType.knownHosts': 'known-host entries',
|
||||
'sync.entityType.portForwardingRules': 'port-forwarding rules',
|
||||
'sync.entityType.groupConfigs': 'group configs',
|
||||
|
||||
'sync.credentialsUnavailable': 'This device cannot decrypt some saved credentials. Re-enter credentials locally before syncing.',
|
||||
'time.never': 'Never',
|
||||
'time.justNow': 'Just now',
|
||||
@@ -1041,6 +1092,9 @@ 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.x11Forwarding': 'Forward X11 apps',
|
||||
'hostDetails.x11Forwarding.desc': 'Show remote graphical apps on your local desktop when a local X server is running.',
|
||||
'hostDetails.section.x11Forwarding': 'X11 Forwarding',
|
||||
'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.',
|
||||
@@ -1157,6 +1211,7 @@ const en: Messages = {
|
||||
'terminal.toolbar.openSftp': 'Open SFTP',
|
||||
'terminal.toolbar.availableAfterConnect': 'Available after connect',
|
||||
'terminal.toolbar.sftp': 'SFTP',
|
||||
'terminal.toolbar.more': 'More actions',
|
||||
'terminal.toolbar.scripts': 'Scripts',
|
||||
'terminal.toolbar.library': 'Library',
|
||||
'terminal.toolbar.noSnippets': 'No snippets available',
|
||||
@@ -1481,6 +1536,7 @@ const en: Messages = {
|
||||
'cloudSync.conflict.keepLocal': 'Overwrite cloud (keep local)',
|
||||
'cloudSync.conflict.useCloud': 'Download cloud (overwrite local)',
|
||||
'cloudSync.connect.browserContinue': 'Complete authorization in browser',
|
||||
'cloudSync.connect.browserCancelled': 'Previous browser authorization was cancelled',
|
||||
'cloudSync.connect.github.success': 'GitHub connected successfully',
|
||||
'cloudSync.connect.github.failedTitle': 'GitHub connection failed',
|
||||
'cloudSync.connect.github.timeout': 'GitHub connection timed out. Check your network or proxy settings.',
|
||||
@@ -1604,6 +1660,9 @@ const en: Messages = {
|
||||
'tabs.logPrefix': 'Log:',
|
||||
'tabs.logLocal': 'Local',
|
||||
'tabs.copyTab': 'Copy Tab',
|
||||
'tabs.closeOthers': 'Close Others',
|
||||
'tabs.closeToRight': 'Close Tabs to the Right',
|
||||
'tabs.closeAll': 'Close All',
|
||||
'keychain.edit.labelRequired': 'Label *',
|
||||
'keychain.edit.keyLabelPlaceholder': 'Key label',
|
||||
'keychain.edit.privateKeyRequired': 'Private key *',
|
||||
@@ -1643,6 +1702,8 @@ const en: Messages = {
|
||||
'snippets.breadcrumb.separator': '›',
|
||||
'snippets.empty.title': 'Create snippet',
|
||||
'snippets.empty.desc': 'Save your most used commands as snippets to reuse them in one click.',
|
||||
'snippets.search.noResults.title': 'No matches',
|
||||
'snippets.search.noResults.desc': 'No snippets or packages match "{query}". Try a different search term or clear the search to browse.',
|
||||
'snippets.section.packages': 'Packages',
|
||||
'snippets.section.snippets': 'Snippets',
|
||||
'snippets.package.count': '{count} snippet(s)',
|
||||
@@ -1738,6 +1799,12 @@ const en: Messages = {
|
||||
|
||||
// Text Editor
|
||||
'sftp.editor.wordWrap': 'Word Wrap',
|
||||
'sftp.editor.maximize': 'Maximize',
|
||||
'sftp.editor.unsavedTitle': 'Unsaved changes',
|
||||
'sftp.editor.unsavedMessage': '{fileName} has unsaved changes. Save before closing?',
|
||||
'sftp.editor.discardChanges': 'Discard',
|
||||
'sftp.editor.saveAndClose': 'Save and close',
|
||||
'sftp.editor.quitBlockedByDirty': 'Unsaved editors — please save or discard before quitting',
|
||||
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent Settings',
|
||||
|
||||
@@ -43,6 +43,11 @@ const zhCN: Messages = {
|
||||
'confirm.deleteHost': '删除主机 "{name}"?',
|
||||
'confirm.deleteIdentity': '删除身份 "{name}"?',
|
||||
'confirm.removeProvider': '移除提供商 "{name}"?',
|
||||
'confirm.closeBusyTerminal.title': '确认关闭',
|
||||
'confirm.closeBusyTerminal.message': '进程 "{command}" 仍在运行,关闭后会被终止。',
|
||||
'confirm.closeBusyTerminal.messageWithMore': '进程 "{command}" 及其他 {count} 个正在运行的进程将被终止。',
|
||||
'confirm.closeBusyTerminal.cancel': '取消',
|
||||
'confirm.closeBusyTerminal.close': '关闭',
|
||||
'dialog.renameWorkspace.title': '重命名工作区',
|
||||
'dialog.renameSession.title': '重命名会话',
|
||||
'field.name': '名称',
|
||||
@@ -286,6 +291,30 @@ const zhCN: Messages = {
|
||||
'sync.autoSync.emptyVaultConflict.keepEmpty': '保持为空',
|
||||
'sync.autoSync.emptyVaultConflict.keepEmptyDesc': '从头开始,使用空的主机库',
|
||||
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} 台主机,{keys} 个密钥,{snippets} 个代码片段',
|
||||
'sync.autoSync.emptyVaultManual': '无法同步:本地 vault 为空。请先从本地备份恢复,或在同步面板里使用"强制推送"。',
|
||||
|
||||
'sync.blocked.title': '同步已暂停',
|
||||
'sync.blocked.reason.bulkShrink': '即将从云端删除 {baseCount} 条 {entityType} 中的 {lost} 条(缩减 {percent}%)。',
|
||||
'sync.blocked.reason.largeShrink': '即将从云端删除 {lost} 条 {entityType}。',
|
||||
'sync.blocked.detail': '通常是本地状态异常(钥匙串故障、数据加载不全)导致。请从本地备份恢复,如果确实要删这些条目请使用强制推送。',
|
||||
'sync.blocked.restoreButton': '从本地备份恢复',
|
||||
'sync.blocked.forcePushButton': '强制推送',
|
||||
|
||||
'sync.forcePush.title': '确认强制推送',
|
||||
'sync.forcePush.body': '你将从云端移除 {lost} 条 {entityType},此操作不可撤销。继续?',
|
||||
'sync.forcePush.confirm': '确认推送',
|
||||
'sync.forcePush.cancel': '取消',
|
||||
|
||||
'sync.entityType.hosts': '主机',
|
||||
'sync.entityType.keys': '密钥',
|
||||
'sync.entityType.identities': '身份',
|
||||
'sync.entityType.snippets': '代码片段',
|
||||
'sync.entityType.customGroups': '分组',
|
||||
'sync.entityType.snippetPackages': '片段包',
|
||||
'sync.entityType.knownHosts': '主机密钥记录',
|
||||
'sync.entityType.portForwardingRules': '端口转发规则',
|
||||
'sync.entityType.groupConfigs': '分组配置',
|
||||
|
||||
'sync.credentialsUnavailable': '当前设备无法解密部分已保存凭据。请先在本地重新输入凭据后再同步。',
|
||||
'time.never': '从未',
|
||||
'time.justNow': '刚刚',
|
||||
@@ -683,6 +712,9 @@ 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.x11Forwarding': '转发 X11 图形应用',
|
||||
'hostDetails.x11Forwarding.desc': '本机运行 X 服务时,让远程图形程序显示在本地桌面。',
|
||||
'hostDetails.section.x11Forwarding': 'X11 转发',
|
||||
'hostDetails.section.deviceType': '设备类型',
|
||||
'hostDetails.deviceType': '网络设备模式',
|
||||
'hostDetails.deviceType.desc': '适用于通过 SSH 连接的网络设备(交换机、路由器、防火墙)。命令将原样发送,不进行 Shell 包装,兼容华为 VRP、Cisco IOS 等厂商 CLI。',
|
||||
@@ -770,6 +802,7 @@ const zhCN: Messages = {
|
||||
'terminal.toolbar.openSftp': '打开 SFTP',
|
||||
'terminal.toolbar.availableAfterConnect': '连接后可用',
|
||||
'terminal.toolbar.sftp': 'SFTP',
|
||||
'terminal.toolbar.more': '更多操作',
|
||||
'terminal.toolbar.scripts': '脚本',
|
||||
'terminal.toolbar.library': '库',
|
||||
'terminal.toolbar.noSnippets': '暂无代码片段',
|
||||
@@ -1093,6 +1126,7 @@ const zhCN: Messages = {
|
||||
'cloudSync.conflict.keepLocal': '覆盖云端(保留本地)',
|
||||
'cloudSync.conflict.useCloud': '下载云端(覆盖本地)',
|
||||
'cloudSync.connect.browserContinue': '请在浏览器中完成授权',
|
||||
'cloudSync.connect.browserCancelled': '已取消上一个浏览器授权流程',
|
||||
'cloudSync.connect.github.success': 'GitHub 已连接',
|
||||
'cloudSync.connect.github.failedTitle': 'GitHub 连接失败',
|
||||
'cloudSync.connect.github.timeout': '连接 GitHub 超时,请检查网络或代理设置。',
|
||||
@@ -1360,6 +1394,12 @@ const zhCN: Messages = {
|
||||
'settings.terminal.behavior.bracketedPaste': '括号粘贴模式',
|
||||
'settings.terminal.behavior.bracketedPaste.desc':
|
||||
'粘贴文本时使用转义序列包裹,以便终端区分粘贴和键入。如果出现 ^[[200~ 字样请关闭此选项。',
|
||||
'settings.terminal.behavior.clearWipesScrollback': '`clear` 同时清空回滚历史',
|
||||
'settings.terminal.behavior.clearWipesScrollback.desc':
|
||||
'`clear` 命令同时清空回滚历史(POSIX 默认行为)。关闭则保留历史。',
|
||||
'settings.terminal.behavior.preserveSelectionOnInput': '输入时保留选区',
|
||||
'settings.terminal.behavior.preserveSelectionOnInput.desc':
|
||||
'键盘输入时不清除鼠标选中的文本,方便选中路径后输入 `sz ` 之类命令再粘贴。',
|
||||
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 剪贴板',
|
||||
'settings.terminal.behavior.osc52Clipboard.desc':
|
||||
'允许远程程序(tmux、vim 等)通过 OSC-52 转义序列访问本地剪贴板。',
|
||||
@@ -1419,6 +1459,21 @@ const zhCN: Messages = {
|
||||
'settings.terminal.section.connection': '连接',
|
||||
'settings.terminal.connection.keepaliveInterval': '会话保持间隔',
|
||||
'settings.terminal.connection.keepaliveInterval.desc': '向服务器发送 SSH 级别保活数据包的频率(秒)。设为 0 表示禁用。',
|
||||
'settings.terminal.connection.x11Display': 'X11 显示地址',
|
||||
'settings.terminal.connection.x11Display.desc': '可选的本机 X11 显示地址。留空则使用系统默认值。',
|
||||
'settings.terminal.connection.x11Display.placeholder': '自动(:0 或 DISPLAY)',
|
||||
'settings.terminal.mosh.client': 'Mosh 客户端路径',
|
||||
'settings.terminal.mosh.client.desc': '本机 mosh 可执行文件的绝对路径。留空则自动从 PATH 与常见安装目录中查找(Homebrew、MacPorts、~/.nix-profile、~/.cargo、~/.local)。',
|
||||
'settings.terminal.mosh.client.placeholder': '自动探测',
|
||||
'settings.terminal.mosh.client.notFound': '该路径下未找到文件。',
|
||||
'settings.terminal.mosh.client.isDirectory': '该路径指向目录而非可执行文件。',
|
||||
'settings.terminal.mosh.client.notExecutable': '文件存在但不可执行。请对其执行 `chmod +x`,或选择其它二进制文件。',
|
||||
'settings.terminal.mosh.client.notAbsolute': '路径必须为绝对路径。请使用 浏览… 选择二进制、留空以自动探测,或输入完整路径。',
|
||||
'settings.terminal.mosh.detect': '探测',
|
||||
'settings.terminal.mosh.browse': '浏览…',
|
||||
'settings.terminal.mosh.autoDetected': '自动检测到',
|
||||
'settings.terminal.mosh.detected': '已找到',
|
||||
'settings.terminal.mosh.notDetected': '在以下位置未找到 mosh:',
|
||||
'settings.terminal.section.serverStats': '服务器状态(Linux)',
|
||||
'settings.terminal.serverStats.show': '显示服务器状态',
|
||||
'settings.terminal.serverStats.show.desc': '在终端状态栏显示 CPU、内存和磁盘使用情况(仅限 Linux 服务器)。',
|
||||
@@ -1452,6 +1507,7 @@ const zhCN: Messages = {
|
||||
'settings.shortcuts.resetAll': '全部重置',
|
||||
'settings.shortcuts.recording': '请按键...',
|
||||
'settings.shortcuts.none': '无',
|
||||
'settings.shortcuts.setDisabled': '设为禁用',
|
||||
'settings.shortcuts.category.tabs': '标签页',
|
||||
'settings.shortcuts.category.terminal': '终端',
|
||||
'settings.shortcuts.category.navigation': '导航',
|
||||
@@ -1476,6 +1532,7 @@ const zhCN: Messages = {
|
||||
'settings.shortcuts.binding.port-forwarding': '打开端口转发',
|
||||
'settings.shortcuts.binding.command-palette': '打开命令面板',
|
||||
'settings.shortcuts.binding.quick-switch': '快速切换',
|
||||
'settings.shortcuts.binding.new-workspace': '新建工作区',
|
||||
'settings.shortcuts.binding.snippets': '打开代码片段',
|
||||
'settings.shortcuts.binding.broadcast': '切换广播模式',
|
||||
'settings.shortcuts.binding.sftp-copy': '复制文件',
|
||||
@@ -1612,6 +1669,9 @@ const zhCN: Messages = {
|
||||
'tabs.logPrefix': '日志:',
|
||||
'tabs.logLocal': '本地',
|
||||
'tabs.copyTab': '复制标签页',
|
||||
'tabs.closeOthers': '关闭其他标签',
|
||||
'tabs.closeToRight': '关闭右侧标签',
|
||||
'tabs.closeAll': '关闭所有标签',
|
||||
'keychain.edit.labelRequired': 'Label *',
|
||||
'keychain.edit.keyLabelPlaceholder': '密钥 Label',
|
||||
'keychain.edit.privateKeyRequired': '私钥 *',
|
||||
@@ -1651,6 +1711,8 @@ const zhCN: Messages = {
|
||||
'snippets.breadcrumb.separator': '›',
|
||||
'snippets.empty.title': '创建代码片段',
|
||||
'snippets.empty.desc': '将常用命令保存为代码片段,一键复用。',
|
||||
'snippets.search.noResults.title': '无匹配结果',
|
||||
'snippets.search.noResults.desc': '没有代码片段或代码包与"{query}"匹配。换一个关键字,或清除搜索进行浏览。',
|
||||
'snippets.section.packages': '代码包',
|
||||
'snippets.section.snippets': '代码片段',
|
||||
'snippets.package.count': '{count} 个代码片段',
|
||||
@@ -1746,6 +1808,12 @@ const zhCN: Messages = {
|
||||
|
||||
// Text Editor
|
||||
'sftp.editor.wordWrap': '自动换行',
|
||||
'sftp.editor.maximize': '最大化',
|
||||
'sftp.editor.unsavedTitle': '未保存的修改',
|
||||
'sftp.editor.unsavedMessage': '{fileName} 有未保存的修改,是否保存后关闭?',
|
||||
'sftp.editor.discardChanges': '不保存',
|
||||
'sftp.editor.saveAndClose': '保存并关闭',
|
||||
'sftp.editor.quitBlockedByDirty': '存在未保存的编辑器,请先处理后再退出',
|
||||
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent 设置',
|
||||
|
||||
@@ -6,15 +6,38 @@ import {
|
||||
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
|
||||
} from '../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
|
||||
import { getCloudSyncManager } from '../infrastructure/services/CloudSyncManager';
|
||||
import { netcattyBridge } from '../infrastructure/services/netcattyBridge';
|
||||
import { hasMeaningfulSyncData } from './syncPayload';
|
||||
|
||||
/**
|
||||
* Snapshot the current sync data version (the integer that increments
|
||||
* on each successful cloud sync). Returns undefined when the value is
|
||||
* 0 (never synced) or unavailable, so the UI can fall back to timestamp.
|
||||
*/
|
||||
function captureCurrentSyncDataVersion(): number | undefined {
|
||||
try {
|
||||
const state = getCloudSyncManager().getState();
|
||||
const v = state.localVersion;
|
||||
return typeof v === 'number' && v > 0 ? v : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export type LocalVaultBackupReason = 'app_version_change' | 'before_restore';
|
||||
|
||||
export interface LocalVaultBackupPreview {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
reason: LocalVaultBackupReason;
|
||||
/** Sync-data version at the time the snapshot was taken (the integer
|
||||
* that the CloudSyncManager increments on each successful cloud sync).
|
||||
* Undefined when the user had never synced yet, or for legacy backups
|
||||
* persisted before this field was added. */
|
||||
syncDataVersion?: number;
|
||||
/** App version transition fields, only for `app_version_change` records.
|
||||
* Kept for backward compatibility with already-persisted backups. */
|
||||
sourceAppVersion?: string;
|
||||
targetAppVersion?: string;
|
||||
fingerprint: string;
|
||||
@@ -94,6 +117,7 @@ export async function createLocalVaultBackup(
|
||||
payload: SyncPayload,
|
||||
options: {
|
||||
reason: LocalVaultBackupReason;
|
||||
syncDataVersion?: number;
|
||||
sourceAppVersion?: string;
|
||||
targetAppVersion?: string;
|
||||
maxCount?: number;
|
||||
@@ -118,6 +142,10 @@ export async function createLocalVaultBackup(
|
||||
const result = await bridge.createVaultBackup({
|
||||
payload,
|
||||
reason: options.reason,
|
||||
// Default to the live cloud-sync version so every new backup carries
|
||||
// it even when the caller didn't pass one explicitly. Bridge sanitizer
|
||||
// drops invalid values (non-positive / non-finite), so this is safe.
|
||||
syncDataVersion: options.syncDataVersion ?? captureCurrentSyncDataVersion(),
|
||||
sourceAppVersion: options.sourceAppVersion,
|
||||
targetAppVersion: options.targetAppVersion,
|
||||
maxCount: options.maxCount ?? getLocalVaultBackupMaxCount(),
|
||||
|
||||
@@ -3,6 +3,18 @@ import { useCallback,useSyncExternalStore } from 'react';
|
||||
// Simple store for active tab that allows fine-grained subscriptions
|
||||
type Listener = () => void;
|
||||
|
||||
// ----- Editor tab id helpers -----
|
||||
export const EDITOR_PREFIX = 'editor:';
|
||||
|
||||
/** Returns true when `id` is an editor tab id (starts with "editor:"). */
|
||||
export const isEditorTabId = (id: string): boolean => id.startsWith(EDITOR_PREFIX);
|
||||
|
||||
/** Convert an editorTab's internal id to a top-tab id understood by the tab bar. */
|
||||
export const toEditorTabId = (editorId: string): string => `${EDITOR_PREFIX}${editorId}`;
|
||||
|
||||
/** Strip the "editor:" prefix to recover the internal editorTab id. */
|
||||
export const fromEditorTabId = (tabId: string): string => tabId.slice(EDITOR_PREFIX.length);
|
||||
|
||||
class ActiveTabStore {
|
||||
private activeTabId: string = 'vault';
|
||||
private listeners = new Set<Listener>();
|
||||
@@ -70,9 +82,17 @@ export const useIsSftpActive = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// Check if a specific editor tab is currently active
|
||||
export const useIsEditorTabActive = (tabId: string): boolean => {
|
||||
const editorTopId = toEditorTabId(tabId);
|
||||
const getSnapshot = useCallback(() => activeTabStore.getActiveTabId() === editorTopId, [editorTopId]);
|
||||
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot);
|
||||
};
|
||||
|
||||
// Check if terminal layer should be visible
|
||||
// Editor tabs are NOT terminal tabs, so exclude them from the visibility condition.
|
||||
export const useIsTerminalLayerVisible = (draggingSessionId: string | null) => {
|
||||
const activeTabId = useActiveTabId();
|
||||
const isTerminalTab = activeTabId !== 'vault' && activeTabId !== 'sftp';
|
||||
const isTerminalTab = activeTabId !== 'vault' && activeTabId !== 'sftp' && !isEditorTabId(activeTabId);
|
||||
return isTerminalTab || !!draggingSessionId;
|
||||
};
|
||||
|
||||
@@ -3,12 +3,17 @@ import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
activateDraftView,
|
||||
bumpDraftMutationVersionState,
|
||||
bumpDraftUploadGenerationState,
|
||||
clearScopeDraftState,
|
||||
createEmptyDraft,
|
||||
ensureDraftForScopeState,
|
||||
getDraftMutationVersionState,
|
||||
getDraftUploadGenerationState,
|
||||
pruneTerminalScopeState,
|
||||
pruneTerminalTransientState,
|
||||
resolvePanelView,
|
||||
selectDraftForAgentSwitch,
|
||||
setDraftView,
|
||||
setSessionView,
|
||||
updateDraftForScope,
|
||||
@@ -168,6 +173,69 @@ test("ensureDraftForScopeState returns the original ref when the scope already e
|
||||
assert.equal(next, draftsByScope);
|
||||
});
|
||||
|
||||
test("selectDraftForAgentSwitch preserves hidden draft content when leaving a populated chat session", () => {
|
||||
const currentDraft = {
|
||||
...createEmptyDraft("agent-alpha"),
|
||||
text: "keep me only if I was already drafting",
|
||||
attachments: [{ id: "file-1", filename: "note.txt", dataUrl: "", base64Data: "", mediaType: "text/plain" }],
|
||||
selectedUserSkillSlugs: ["skill-a"],
|
||||
};
|
||||
|
||||
const next = selectDraftForAgentSwitch(currentDraft, "agent-beta", true);
|
||||
|
||||
assert.equal(next.agentId, "agent-beta");
|
||||
assert.equal(next.text, "keep me only if I was already drafting");
|
||||
assert.deepEqual(next.attachments, currentDraft.attachments);
|
||||
assert.deepEqual(next.selectedUserSkillSlugs, ["skill-a"]);
|
||||
});
|
||||
|
||||
test("selectDraftForAgentSwitch resets to an empty draft when leaving a populated chat session without pending draft content", () => {
|
||||
const currentDraft = createEmptyDraft("agent-alpha");
|
||||
|
||||
const next = selectDraftForAgentSwitch(currentDraft, "agent-beta", true);
|
||||
|
||||
assert.equal(next.agentId, "agent-beta");
|
||||
assert.equal(next.text, "");
|
||||
assert.deepEqual(next.attachments, []);
|
||||
assert.deepEqual(next.selectedUserSkillSlugs, []);
|
||||
});
|
||||
|
||||
test("selectDraftForAgentSwitch preserves an existing draft while only changing agent", () => {
|
||||
const currentDraft = {
|
||||
...createEmptyDraft("agent-alpha"),
|
||||
text: "unfinished prompt",
|
||||
selectedUserSkillSlugs: ["skill-a"],
|
||||
};
|
||||
|
||||
const next = selectDraftForAgentSwitch(currentDraft, "agent-beta", false);
|
||||
|
||||
assert.equal(next.agentId, "agent-beta");
|
||||
assert.equal(next.text, "unfinished prompt");
|
||||
assert.deepEqual(next.selectedUserSkillSlugs, ["skill-a"]);
|
||||
});
|
||||
|
||||
test("draft mutation version increments on every mutation for the same scope", () => {
|
||||
const scopeKey = "terminal:1";
|
||||
const initialVersion = getDraftMutationVersionState({}, scopeKey);
|
||||
const nextVersions = bumpDraftMutationVersionState({}, scopeKey);
|
||||
const finalVersions = bumpDraftMutationVersionState(nextVersions, scopeKey);
|
||||
|
||||
assert.equal(initialVersion, 0);
|
||||
assert.equal(getDraftMutationVersionState(nextVersions, scopeKey), 1);
|
||||
assert.equal(getDraftMutationVersionState(finalVersions, scopeKey), 2);
|
||||
});
|
||||
|
||||
test("draft upload generation only increments when the draft lifecycle rolls over", () => {
|
||||
const scopeKey = "terminal:1";
|
||||
const initialGeneration = getDraftUploadGenerationState({}, scopeKey);
|
||||
const nextGenerations = bumpDraftUploadGenerationState({}, scopeKey);
|
||||
const finalGenerations = bumpDraftUploadGenerationState(nextGenerations, scopeKey);
|
||||
|
||||
assert.equal(initialGeneration, 0);
|
||||
assert.equal(getDraftUploadGenerationState(nextGenerations, scopeKey), 1);
|
||||
assert.equal(getDraftUploadGenerationState(finalGenerations, scopeKey), 2);
|
||||
});
|
||||
|
||||
test("pruneTerminalScopeState removes closed terminal drafts and views only", () => {
|
||||
const draftsByScope = {
|
||||
"terminal:closed": createEmptyDraft("agent-alpha"),
|
||||
|
||||
@@ -6,6 +6,8 @@ import type {
|
||||
type DraftsByScope = Partial<Record<string, AIDraft>>;
|
||||
type PanelViewByScope = Partial<Record<string, AIPanelView>>;
|
||||
type ActiveSessionIdMap = Record<string, string | null>;
|
||||
type DraftMutationVersionByScope = Record<string, number>;
|
||||
type DraftUploadGenerationByScope = Record<string, number>;
|
||||
|
||||
const DEFAULT_PANEL_VIEW: AIPanelView = { mode: 'draft' };
|
||||
|
||||
@@ -19,6 +21,40 @@ export function createEmptyDraft(agentId: string): AIDraft {
|
||||
};
|
||||
}
|
||||
|
||||
export function getDraftMutationVersionState(
|
||||
versionsByScope: DraftMutationVersionByScope,
|
||||
scopeKey: string,
|
||||
): number {
|
||||
return versionsByScope[scopeKey] ?? 0;
|
||||
}
|
||||
|
||||
export function bumpDraftMutationVersionState(
|
||||
versionsByScope: DraftMutationVersionByScope,
|
||||
scopeKey: string,
|
||||
): DraftMutationVersionByScope {
|
||||
return {
|
||||
...versionsByScope,
|
||||
[scopeKey]: getDraftMutationVersionState(versionsByScope, scopeKey) + 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function getDraftUploadGenerationState(
|
||||
generationsByScope: DraftUploadGenerationByScope,
|
||||
scopeKey: string,
|
||||
): number {
|
||||
return generationsByScope[scopeKey] ?? 0;
|
||||
}
|
||||
|
||||
export function bumpDraftUploadGenerationState(
|
||||
generationsByScope: DraftUploadGenerationByScope,
|
||||
scopeKey: string,
|
||||
): DraftUploadGenerationByScope {
|
||||
return {
|
||||
...generationsByScope,
|
||||
[scopeKey]: getDraftUploadGenerationState(generationsByScope, scopeKey) + 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePanelView(
|
||||
panelViewByScope: PanelViewByScope,
|
||||
scopeKey: string,
|
||||
@@ -109,6 +145,31 @@ export function ensureDraftForScopeState(
|
||||
};
|
||||
}
|
||||
|
||||
export function selectDraftForAgentSwitch(
|
||||
currentDraft: AIDraft | null | undefined,
|
||||
agentId: string,
|
||||
startFresh: boolean,
|
||||
): AIDraft {
|
||||
const hasPendingDraftContent = Boolean(
|
||||
currentDraft
|
||||
&& (
|
||||
currentDraft.text.length > 0
|
||||
|| currentDraft.attachments.length > 0
|
||||
|| currentDraft.selectedUserSkillSlugs.length > 0
|
||||
),
|
||||
);
|
||||
|
||||
if (startFresh && !hasPendingDraftContent) {
|
||||
return createEmptyDraft(agentId);
|
||||
}
|
||||
|
||||
const baseDraft = currentDraft ?? createEmptyDraft(agentId);
|
||||
return {
|
||||
...baseDraft,
|
||||
agentId,
|
||||
};
|
||||
}
|
||||
|
||||
export function clearScopeDraftState(
|
||||
draftsByScope: DraftsByScope,
|
||||
panelViewByScope: PanelViewByScope,
|
||||
|
||||
@@ -65,7 +65,7 @@ test("pruneInactiveScopedTransientState removes closed workspace and terminal sc
|
||||
});
|
||||
});
|
||||
|
||||
test("pruneInactiveScopedSessions removes non-restorable terminal chats and closed workspaces", () => {
|
||||
test("pruneInactiveScopedSessions preserves restorable terminal ACP ids across reconnects", () => {
|
||||
const sessions = [
|
||||
createSession("terminal-restorable", {
|
||||
type: "terminal",
|
||||
@@ -99,10 +99,7 @@ test("pruneInactiveScopedSessions removes non-restorable terminal chats and clos
|
||||
"workspace-closed",
|
||||
]);
|
||||
assert.deepEqual(next.sessions, [
|
||||
{
|
||||
...sessions[0],
|
||||
externalSessionId: undefined,
|
||||
},
|
||||
sessions[0],
|
||||
sessions[3],
|
||||
]);
|
||||
});
|
||||
@@ -129,3 +126,35 @@ test("pruneInactiveScopedSessions preserves original sessions when orphaned rest
|
||||
assert.deepEqual(next.orphanedSessionIds, ["terminal-restorable"]);
|
||||
assert.equal(next.sessions, sessions);
|
||||
});
|
||||
|
||||
test("pruneInactiveScopedSessions treats sessions displayed elsewhere as in-use, not orphaned", () => {
|
||||
// terminal-restorable's original scope (terminal-closed-A) is gone, but
|
||||
// the user resumed it into terminal-open-B from history. The session's
|
||||
// externalSessionId must be preserved and it must not appear in the
|
||||
// orphaned list, otherwise the active chat loses ACP continuity.
|
||||
const resumedElsewhere = createSession("terminal-restorable", {
|
||||
type: "terminal",
|
||||
targetId: "terminal-closed-A",
|
||||
hostIds: ["host-1"],
|
||||
}, "ext-resumed");
|
||||
|
||||
const trulyOrphaned = createSession("terminal-stale", {
|
||||
type: "terminal",
|
||||
targetId: "terminal-closed-C",
|
||||
hostIds: ["host-2"],
|
||||
}, "ext-stale");
|
||||
|
||||
const sessions = [resumedElsewhere, trulyOrphaned];
|
||||
|
||||
const next = pruneInactiveScopedSessions(
|
||||
sessions,
|
||||
new Set(["terminal-open-B"]),
|
||||
new Set(["terminal-restorable"]),
|
||||
);
|
||||
|
||||
// Only the one not being displayed anywhere should show up as orphaned.
|
||||
assert.deepEqual(next.orphanedSessionIds, ["terminal-stale"]);
|
||||
// The resumed session must retain its externalSessionId.
|
||||
const resumedNext = next.sessions.find((s) => s.id === "terminal-restorable");
|
||||
assert.equal(resumedNext?.externalSessionId, "ext-resumed");
|
||||
});
|
||||
|
||||
@@ -99,12 +99,21 @@ function isRestorableTerminalSession(session: AISession): boolean {
|
||||
export function pruneInactiveScopedSessions(
|
||||
sessions: AISession[],
|
||||
activeTargetIds: Set<string>,
|
||||
/**
|
||||
* Session ids currently displayed by any live scope. A session whose
|
||||
* `scope.targetId` is inactive but whose id is still in use somewhere
|
||||
* (e.g. resumed from history into a different terminal) must not be
|
||||
* treated as orphaned — deleting it outright would break the chat the
|
||||
* user is actively continuing.
|
||||
*/
|
||||
activeSessionIds: Set<string> = new Set(),
|
||||
): {
|
||||
sessions: AISession[];
|
||||
orphanedSessionIds: string[];
|
||||
} {
|
||||
const orphanedSessionIds = sessions
|
||||
.filter((session) => session.scope.targetId && !activeTargetIds.has(session.scope.targetId))
|
||||
.filter((session) => !activeSessionIds.has(session.id))
|
||||
.map((session) => session.id);
|
||||
|
||||
if (orphanedSessionIds.length === 0) {
|
||||
@@ -126,15 +135,7 @@ export function pruneInactiveScopedSessions(
|
||||
sessionsChanged = true;
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!session.externalSessionId) {
|
||||
return [session];
|
||||
}
|
||||
|
||||
sessionsChanged = true;
|
||||
return [
|
||||
{ ...session, externalSessionId: undefined },
|
||||
];
|
||||
return [session];
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
69
application/state/editorSftpBridge.ts
Normal file
69
application/state/editorSftpBridge.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { SftpFilenameEncoding } from "../../types";
|
||||
|
||||
export interface EditorSftpWrite {
|
||||
(
|
||||
connectionId: string,
|
||||
expectedHostId: string,
|
||||
filePath: string,
|
||||
content: string,
|
||||
filenameEncoding?: SftpFilenameEncoding,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
// `useSftpState` is instantiated in at least two places (the top-level SftpView
|
||||
// and the per-terminal SftpSidePanel), each owning its own pane registry. An
|
||||
// editor tab opened from either path must be saved via the matching instance,
|
||||
// so the bridge tracks all currently-mounted writers and dispatches by
|
||||
// attempting each in turn until one succeeds.
|
||||
//
|
||||
// Each writer throws synchronously (or rejects) if the connectionId isn't in
|
||||
// its pane registry; we use "connection no longer available" text as the
|
||||
// signal to fall through to the next writer. Any other error is re-thrown
|
||||
// immediately because it represents a real save failure the user must see.
|
||||
const writers = new Set<EditorSftpWrite>();
|
||||
|
||||
const NOT_MY_CONNECTION_RE = /SFTP connection is no longer available/i;
|
||||
|
||||
export const registerEditorSftpWriter = (fn: EditorSftpWrite | null) => {
|
||||
// Pass `null` on cleanup — but cleanup also needs to know WHICH writer to
|
||||
// remove. Callers who register once per mount should instead use
|
||||
// `registerEditorSftpWriterScoped` below, which returns an unregister fn.
|
||||
// This legacy signature is preserved for callers that prefer the
|
||||
// register/unregister-with-null pattern: we clear ALL writers on null.
|
||||
if (fn === null) {
|
||||
writers.clear();
|
||||
return;
|
||||
}
|
||||
writers.add(fn);
|
||||
};
|
||||
|
||||
export const registerEditorSftpWriterScoped = (fn: EditorSftpWrite): (() => void) => {
|
||||
writers.add(fn);
|
||||
return () => {
|
||||
writers.delete(fn);
|
||||
};
|
||||
};
|
||||
|
||||
export const editorSftpWrite: EditorSftpWrite = async (...args) => {
|
||||
if (writers.size === 0) {
|
||||
throw new Error("SFTP editor bridge not registered — cannot save (no SFTP view mounted)");
|
||||
}
|
||||
let lastNotMine: Error | null = null;
|
||||
for (const fn of writers) {
|
||||
try {
|
||||
await fn(...args);
|
||||
return;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (NOT_MY_CONNECTION_RE.test(msg)) {
|
||||
// This writer doesn't own the connectionId — try the next one.
|
||||
lastNotMine = err instanceof Error ? err : new Error(msg);
|
||||
continue;
|
||||
}
|
||||
// Real save error — surface it.
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
// No writer owned the connectionId.
|
||||
throw lastNotMine ?? new Error("SFTP connection is no longer available");
|
||||
};
|
||||
198
application/state/editorTabStore.test.ts
Normal file
198
application/state/editorTabStore.test.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { EditorTabStore, type EditorTab } from "./editorTabStore.ts";
|
||||
|
||||
const makeTab = (overrides: Partial<EditorTab> = {}): EditorTab => ({
|
||||
id: "edt_1",
|
||||
kind: "editor",
|
||||
sessionId: "conn_1",
|
||||
hostId: "host_1",
|
||||
remotePath: "/etc/nginx/nginx.conf",
|
||||
fileName: "nginx.conf",
|
||||
languageId: "ini",
|
||||
content: "worker_processes auto;",
|
||||
baselineContent: "worker_processes auto;",
|
||||
wordWrap: false,
|
||||
viewState: null,
|
||||
savingState: "idle",
|
||||
saveError: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("updateContent stores content and viewState; dirty flag derives from baseline", () => {
|
||||
const store = new EditorTabStore();
|
||||
store._debugInsert(makeTab());
|
||||
store.updateContent("edt_1", "worker_processes 4;", null);
|
||||
const tab = store.getTab("edt_1")!;
|
||||
assert.equal(tab.content, "worker_processes 4;");
|
||||
assert.equal(store.isDirty("edt_1"), true);
|
||||
});
|
||||
|
||||
test("markSaved moves baseline to current content and clears dirty", () => {
|
||||
const store = new EditorTabStore();
|
||||
store._debugInsert(makeTab({ content: "changed", baselineContent: "orig" }));
|
||||
assert.equal(store.isDirty("edt_1"), true);
|
||||
store.markSaved("edt_1", "changed");
|
||||
assert.equal(store.isDirty("edt_1"), false);
|
||||
assert.equal(store.getTab("edt_1")!.baselineContent, "changed");
|
||||
});
|
||||
|
||||
test("setWordWrap updates only that tab", () => {
|
||||
const store = new EditorTabStore();
|
||||
store._debugInsert(makeTab({ id: "edt_1" }));
|
||||
store._debugInsert(makeTab({ id: "edt_2", remotePath: "/b.txt", fileName: "b.txt" }));
|
||||
store.setWordWrap("edt_1", true);
|
||||
assert.equal(store.getTab("edt_1")!.wordWrap, true);
|
||||
assert.equal(store.getTab("edt_2")!.wordWrap, false);
|
||||
});
|
||||
|
||||
test("setSavingState transitions and clears error on idle", () => {
|
||||
const store = new EditorTabStore();
|
||||
store._debugInsert(makeTab());
|
||||
store.setSavingState("edt_1", "saving");
|
||||
assert.equal(store.getTab("edt_1")!.savingState, "saving");
|
||||
store.setSavingState("edt_1", "error", "EACCES");
|
||||
assert.equal(store.getTab("edt_1")!.saveError, "EACCES");
|
||||
store.setSavingState("edt_1", "idle");
|
||||
assert.equal(store.getTab("edt_1")!.saveError, null);
|
||||
});
|
||||
|
||||
test("close removes the tab and returns remaining ids in order", () => {
|
||||
const store = new EditorTabStore();
|
||||
store._debugInsert(makeTab({ id: "edt_1" }));
|
||||
store._debugInsert(makeTab({ id: "edt_2", remotePath: "/b.txt", fileName: "b.txt" }));
|
||||
store.close("edt_1");
|
||||
assert.equal(store.getTab("edt_1"), undefined);
|
||||
assert.deepEqual(store.getTabs().map((t) => t.id), ["edt_2"]);
|
||||
});
|
||||
|
||||
test("subscribers fire on change and not on read", () => {
|
||||
const store = new EditorTabStore();
|
||||
store._debugInsert(makeTab());
|
||||
let count = 0;
|
||||
const unsub = store.subscribe(() => { count++; });
|
||||
store.getTab("edt_1");
|
||||
store.getTabs();
|
||||
assert.equal(count, 0);
|
||||
store.updateContent("edt_1", "x", null);
|
||||
// notifications are microtask-deferred, flush via awaiting a resolved promise
|
||||
return Promise.resolve().then(() => {
|
||||
assert.equal(count, 1);
|
||||
unsub();
|
||||
});
|
||||
});
|
||||
|
||||
test("promoteFromModal creates a new tab and returns its id", () => {
|
||||
const store = new EditorTabStore();
|
||||
const id = store.promoteFromModal({
|
||||
sessionId: "conn_1",
|
||||
hostId: "host_1",
|
||||
remotePath: "/etc/nginx/nginx.conf",
|
||||
fileName: "nginx.conf",
|
||||
languageId: "ini",
|
||||
content: "x",
|
||||
baselineContent: "x",
|
||||
wordWrap: false,
|
||||
viewState: null,
|
||||
});
|
||||
const tab = store.getTab(id)!;
|
||||
assert.equal(tab.remotePath, "/etc/nginx/nginx.conf");
|
||||
assert.equal(tab.fileName, "nginx.conf");
|
||||
assert.equal(tab.kind, "editor");
|
||||
});
|
||||
|
||||
test("promoteFromModal focuses existing tab for same sessionId+normalized path and overrides content", () => {
|
||||
const store = new EditorTabStore();
|
||||
const first = store.promoteFromModal({
|
||||
sessionId: "conn_1",
|
||||
hostId: "host_1",
|
||||
remotePath: "/etc/nginx/./nginx.conf",
|
||||
fileName: "nginx.conf",
|
||||
languageId: "ini",
|
||||
content: "v1",
|
||||
baselineContent: "v1",
|
||||
wordWrap: false,
|
||||
viewState: null,
|
||||
});
|
||||
const second = store.promoteFromModal({
|
||||
sessionId: "conn_1",
|
||||
hostId: "host_1",
|
||||
remotePath: "/etc/nginx/nginx.conf",
|
||||
fileName: "nginx.conf",
|
||||
languageId: "ini",
|
||||
content: "v2",
|
||||
baselineContent: "v1",
|
||||
wordWrap: false,
|
||||
viewState: null,
|
||||
});
|
||||
assert.equal(second, first);
|
||||
assert.equal(store.getTab(first)!.content, "v2");
|
||||
assert.equal(store.getTabs().length, 1);
|
||||
});
|
||||
|
||||
test("dedup scope is per-sessionId — same path on different sessions are distinct tabs", () => {
|
||||
const store = new EditorTabStore();
|
||||
const a = store.promoteFromModal({
|
||||
sessionId: "conn_A",
|
||||
hostId: "host_1",
|
||||
remotePath: "/etc/hosts",
|
||||
fileName: "hosts",
|
||||
languageId: "plaintext",
|
||||
content: "", baselineContent: "", wordWrap: false, viewState: null,
|
||||
});
|
||||
const b = store.promoteFromModal({
|
||||
sessionId: "conn_B",
|
||||
hostId: "host_2",
|
||||
remotePath: "/etc/hosts",
|
||||
fileName: "hosts",
|
||||
languageId: "plaintext",
|
||||
content: "", baselineContent: "", wordWrap: false, viewState: null,
|
||||
});
|
||||
assert.notEqual(a, b);
|
||||
assert.equal(store.getTabs().length, 2);
|
||||
});
|
||||
|
||||
test("confirmCloseBySession returns true when no tabs match", async () => {
|
||||
const store = new EditorTabStore();
|
||||
store._debugInsert(makeTab());
|
||||
const ok = await store.confirmCloseBySession("other_conn", async () => "discard");
|
||||
assert.equal(ok, true);
|
||||
assert.equal(store.getTabs().length, 1);
|
||||
});
|
||||
|
||||
test("confirmCloseBySession discards all dirty matching tabs when prompt returns 'discard'", async () => {
|
||||
const store = new EditorTabStore();
|
||||
store._debugInsert(makeTab({ id: "edt_1", content: "x", baselineContent: "y" }));
|
||||
store._debugInsert(makeTab({ id: "edt_2", remotePath: "/b.txt", fileName: "b.txt", content: "x", baselineContent: "y" }));
|
||||
const ok = await store.confirmCloseBySession("conn_1", async () => "discard");
|
||||
assert.equal(ok, true);
|
||||
assert.equal(store.getTabs().length, 0);
|
||||
});
|
||||
|
||||
test("confirmCloseBySession closes clean tabs without prompting; aborts on cancel", async () => {
|
||||
const store = new EditorTabStore();
|
||||
store._debugInsert(makeTab({ id: "edt_clean" })); // content == baseline
|
||||
store._debugInsert(makeTab({ id: "edt_dirty", remotePath: "/b.txt", fileName: "b.txt", content: "x", baselineContent: "y" }));
|
||||
let prompts = 0;
|
||||
const ok = await store.confirmCloseBySession("conn_1", async () => { prompts++; return "cancel"; });
|
||||
assert.equal(ok, false);
|
||||
assert.equal(prompts, 1, "prompt fires only for dirty tab");
|
||||
// clean tab was closed before the dirty cancel aborted the batch
|
||||
assert.equal(store.getTab("edt_clean"), undefined);
|
||||
assert.ok(store.getTab("edt_dirty"));
|
||||
});
|
||||
|
||||
test("confirmCloseBySession invokes save callback for 'save' choice and only closes on save success", async () => {
|
||||
const store = new EditorTabStore();
|
||||
store._debugInsert(makeTab({ id: "edt_1", content: "new", baselineContent: "old" }));
|
||||
let saved = false;
|
||||
const ok = await store.confirmCloseBySession("conn_1", async () => "save", async (id) => {
|
||||
assert.equal(id, "edt_1");
|
||||
saved = true;
|
||||
store.markSaved(id, "new");
|
||||
});
|
||||
assert.equal(saved, true);
|
||||
assert.equal(ok, true);
|
||||
assert.equal(store.getTab("edt_1"), undefined);
|
||||
});
|
||||
252
application/state/editorTabStore.ts
Normal file
252
application/state/editorTabStore.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
import type * as Monaco from "monaco-editor";
|
||||
|
||||
import { activeTabStore, fromEditorTabId, isEditorTabId } from "./activeTabStore";
|
||||
|
||||
// POSIX-style normalization: collapse "/./" and duplicate slashes, not ".." (remote paths
|
||||
// may contain semantic ".." segments we don't want to resolve client-side).
|
||||
const normalizePath = (p: string): string => {
|
||||
const collapsed = p.replace(/\/+/g, "/").replace(/\/\.(?=\/|$)/g, "");
|
||||
return collapsed.length > 1 && collapsed.endsWith("/") ? collapsed.slice(0, -1) : collapsed;
|
||||
};
|
||||
|
||||
export type EditorTabId = string;
|
||||
|
||||
export type EditorSavingState = "idle" | "saving" | "error";
|
||||
|
||||
export interface EditorTab {
|
||||
id: EditorTabId;
|
||||
kind: "editor";
|
||||
/** SFTP connection id (matches SftpConnection.id). Session lookup key. */
|
||||
sessionId: string;
|
||||
/** Stable endpoint id; used to verify the session is still the one we opened against. */
|
||||
hostId: string;
|
||||
remotePath: string;
|
||||
fileName: string;
|
||||
languageId: string;
|
||||
content: string;
|
||||
baselineContent: string;
|
||||
wordWrap: boolean;
|
||||
viewState: Monaco.editor.ICodeEditorViewState | null;
|
||||
savingState: EditorSavingState;
|
||||
saveError: string | null;
|
||||
}
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
let idCounter = 0;
|
||||
const genId = (): EditorTabId => `edt_${Date.now().toString(36)}_${(++idCounter).toString(36)}`;
|
||||
|
||||
export class EditorTabStore {
|
||||
private tabs: EditorTab[] = [];
|
||||
private listeners = new Set<Listener>();
|
||||
private pendingNotify = false;
|
||||
|
||||
getTabs = (): readonly EditorTab[] => this.tabs;
|
||||
getTab = (id: EditorTabId): EditorTab | undefined => this.tabs.find((t) => t.id === id);
|
||||
isDirty = (id: EditorTabId): boolean => {
|
||||
const t = this.getTab(id);
|
||||
return !!t && t.content !== t.baselineContent;
|
||||
};
|
||||
|
||||
updateContent = (
|
||||
id: EditorTabId,
|
||||
content: string,
|
||||
viewState: Monaco.editor.ICodeEditorViewState | null,
|
||||
) => {
|
||||
this.patch(id, { content, viewState });
|
||||
};
|
||||
|
||||
markSaved = (id: EditorTabId, newBaseline: string) => {
|
||||
this.patch(id, { baselineContent: newBaseline, savingState: "idle", saveError: null });
|
||||
};
|
||||
|
||||
setWordWrap = (id: EditorTabId, value: boolean) => {
|
||||
this.patch(id, { wordWrap: value });
|
||||
};
|
||||
|
||||
setLanguage = (id: EditorTabId, languageId: string) => {
|
||||
this.patch(id, { languageId });
|
||||
};
|
||||
|
||||
setSavingState = (id: EditorTabId, state: EditorSavingState, error: string | null = null) => {
|
||||
const patch: Partial<EditorTab> = { savingState: state };
|
||||
if (state === "idle") patch.saveError = null;
|
||||
else if (state === "error") patch.saveError = error;
|
||||
this.patch(id, patch);
|
||||
};
|
||||
|
||||
close = (id: EditorTabId) => {
|
||||
const next = this.tabs.filter((t) => t.id !== id);
|
||||
if (next.length !== this.tabs.length) {
|
||||
this.tabs = next;
|
||||
this.notify();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Force-close every tab bound to any of the given sessionIds, with no dirty
|
||||
* prompt. Intended for cases where the owning SFTP instance has gone away
|
||||
* entirely (e.g. the hosting terminal tab was closed) and there is no
|
||||
* realistic save channel anyway. Returns the closed tab ids.
|
||||
*/
|
||||
forceCloseBySessions = (sessionIds: readonly string[]): EditorTabId[] => {
|
||||
if (sessionIds.length === 0) return [];
|
||||
const idSet = new Set(sessionIds);
|
||||
const removed = this.tabs.filter((t) => idSet.has(t.sessionId)).map((t) => t.id);
|
||||
if (removed.length === 0) return [];
|
||||
this.tabs = this.tabs.filter((t) => !idSet.has(t.sessionId));
|
||||
this.notify();
|
||||
|
||||
// If the current active tab was one of the editor tabs we just removed,
|
||||
// fall back to 'vault' so the user doesn't end up on a stale id (empty
|
||||
// chrome + no content). Any better neighbor choice would need the full
|
||||
// orderedTabs list, which isn't available here; 'vault' is always valid.
|
||||
const activeId = activeTabStore.getActiveTabId();
|
||||
if (isEditorTabId(activeId)) {
|
||||
const activeEditorId = fromEditorTabId(activeId);
|
||||
if (activeEditorId && removed.includes(activeEditorId)) {
|
||||
activeTabStore.setActiveTabId('vault');
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
};
|
||||
|
||||
promoteFromModal = (snapshot: {
|
||||
sessionId: string;
|
||||
hostId: string;
|
||||
remotePath: string;
|
||||
fileName: string;
|
||||
languageId: string;
|
||||
content: string;
|
||||
baselineContent: string;
|
||||
wordWrap: boolean;
|
||||
viewState: Monaco.editor.ICodeEditorViewState | null;
|
||||
}): EditorTabId => {
|
||||
const normalized = normalizePath(snapshot.remotePath);
|
||||
const existing = this.tabs.find(
|
||||
(t) => t.sessionId === snapshot.sessionId && normalizePath(t.remotePath) === normalized,
|
||||
);
|
||||
if (existing) {
|
||||
this.patch(existing.id, {
|
||||
content: snapshot.content,
|
||||
baselineContent: snapshot.baselineContent,
|
||||
wordWrap: snapshot.wordWrap,
|
||||
viewState: snapshot.viewState,
|
||||
// keep languageId/hostId/fileName stable; they shouldn't change for the same path
|
||||
});
|
||||
return existing.id;
|
||||
}
|
||||
const tab: EditorTab = {
|
||||
id: this.makeId(),
|
||||
kind: "editor",
|
||||
sessionId: snapshot.sessionId,
|
||||
hostId: snapshot.hostId,
|
||||
remotePath: snapshot.remotePath,
|
||||
fileName: snapshot.fileName,
|
||||
languageId: snapshot.languageId,
|
||||
content: snapshot.content,
|
||||
baselineContent: snapshot.baselineContent,
|
||||
wordWrap: snapshot.wordWrap,
|
||||
viewState: snapshot.viewState,
|
||||
savingState: "idle",
|
||||
saveError: null,
|
||||
};
|
||||
this.tabs = [...this.tabs, tab];
|
||||
this.notify();
|
||||
return tab.id;
|
||||
};
|
||||
|
||||
/**
|
||||
* Walk all editor tabs bound to `sessionId`. Clean tabs close silently; dirty tabs
|
||||
* prompt via `promptChoice`. 'save' invokes `saveTab` and closes only on its success.
|
||||
* Any 'cancel' aborts the batch (subsequent dirty tabs are preserved) and returns false.
|
||||
*/
|
||||
confirmCloseBySession = async (
|
||||
sessionId: string,
|
||||
promptChoice: (tab: EditorTab) => Promise<"save" | "discard" | "cancel">,
|
||||
saveTab?: (tabId: EditorTabId) => Promise<void>,
|
||||
): Promise<boolean> => {
|
||||
const matching = this.tabs.filter((t) => t.sessionId === sessionId);
|
||||
for (const tab of matching) {
|
||||
const dirty = tab.content !== tab.baselineContent;
|
||||
if (!dirty) {
|
||||
this.close(tab.id);
|
||||
continue;
|
||||
}
|
||||
const choice = await promptChoice(tab);
|
||||
if (choice === "cancel") return false;
|
||||
if (choice === "discard") { this.close(tab.id); continue; }
|
||||
if (choice === "save") {
|
||||
if (!saveTab) throw new Error("saveTab callback required when 'save' choice is possible");
|
||||
try {
|
||||
await saveTab(tab.id);
|
||||
} catch {
|
||||
// Save failed — treat like cancel (keep tab open, abort batch so the user sees the error)
|
||||
return false;
|
||||
}
|
||||
this.close(tab.id);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
subscribe = (listener: Listener): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => { this.listeners.delete(listener); };
|
||||
};
|
||||
|
||||
/** TEST-ONLY: seed a tab without going through promote/openOrFocus. */
|
||||
_debugInsert = (tab: EditorTab) => {
|
||||
this.tabs = [...this.tabs, tab];
|
||||
this.notify();
|
||||
};
|
||||
|
||||
protected makeId = genId;
|
||||
|
||||
protected patch = (id: EditorTabId, patch: Partial<EditorTab>) => {
|
||||
let changed = false;
|
||||
this.tabs = this.tabs.map((t) => {
|
||||
if (t.id !== id) return t;
|
||||
changed = true;
|
||||
return { ...t, ...patch };
|
||||
});
|
||||
if (changed) this.notify();
|
||||
};
|
||||
|
||||
protected notify = () => {
|
||||
if (this.pendingNotify) return;
|
||||
this.pendingNotify = true;
|
||||
Promise.resolve().then(() => {
|
||||
this.pendingNotify = false;
|
||||
this.listeners.forEach((l) => l());
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export const editorTabStore = new EditorTabStore();
|
||||
|
||||
// Hooks
|
||||
const getTabsSnapshot = () => editorTabStore.getTabs();
|
||||
|
||||
export const useEditorTabs = (): readonly EditorTab[] =>
|
||||
useSyncExternalStore(editorTabStore.subscribe, getTabsSnapshot);
|
||||
|
||||
export const useEditorTab = (id: EditorTabId): EditorTab | undefined => {
|
||||
const getSnapshot = useCallback(() => editorTabStore.getTab(id), [id]);
|
||||
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot);
|
||||
};
|
||||
|
||||
export const useEditorDirty = (id: EditorTabId): boolean => {
|
||||
const getSnapshot = useCallback(() => editorTabStore.isDirty(id), [id]);
|
||||
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot);
|
||||
};
|
||||
|
||||
export const useAnyEditorDirty = (): boolean => {
|
||||
const getSnapshot = useCallback(
|
||||
() => editorTabStore.getTabs().some((t) => t.content !== t.baselineContent),
|
||||
[],
|
||||
);
|
||||
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot);
|
||||
};
|
||||
110
application/state/resolveCloseIntent.test.ts
Normal file
110
application/state/resolveCloseIntent.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { resolveCloseIntent } from "./resolveCloseIntent.ts";
|
||||
|
||||
const baseWorkspace = {
|
||||
id: "w1",
|
||||
focusedSessionId: "s1",
|
||||
};
|
||||
|
||||
const baseSession = { id: "s1" };
|
||||
|
||||
test("non-workspace tab → closeSingleTab with session id", () => {
|
||||
const result = resolveCloseIntent({
|
||||
activeTabId: "s1",
|
||||
workspace: null,
|
||||
sessionForTab: baseSession,
|
||||
activeSidePanelTab: null,
|
||||
focusIsInsideTerminal: true,
|
||||
});
|
||||
assert.deepEqual(result, { kind: "closeSingleTab", sessionId: "s1" });
|
||||
});
|
||||
|
||||
test("non-workspace session tab + sidebar open → closeSidePanel (sidebar beats session close)", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "s1",
|
||||
workspace: null,
|
||||
sessionForTab: { id: "s1" },
|
||||
activeSidePanelTab: "ai",
|
||||
focusIsInsideTerminal: true, // focus IS in terminal, but sidebar wins
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeSidePanel" });
|
||||
});
|
||||
|
||||
test("vault/sftp tab → noop", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "vault",
|
||||
workspace: null,
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: null,
|
||||
focusIsInsideTerminal: false,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "noop" });
|
||||
});
|
||||
|
||||
test("workspace + focus in terminal + sidebar open → closeSidePanel wins (sidebar beats focus)", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: baseWorkspace,
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: "ai",
|
||||
focusIsInsideTerminal: true,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeSidePanel" });
|
||||
});
|
||||
|
||||
test("workspace + focus NOT in terminal + sidebar open → closeSidePanel", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: baseWorkspace,
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: "sftp",
|
||||
focusIsInsideTerminal: false,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeSidePanel" });
|
||||
});
|
||||
|
||||
test("workspace + sidebar closed + focus in terminal → closeTerminal", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: baseWorkspace,
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: null,
|
||||
focusIsInsideTerminal: true,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeTerminal", sessionId: "s1" });
|
||||
});
|
||||
|
||||
test("workspace + sidebar closed + focus NOT in terminal → closeWorkspace", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: baseWorkspace,
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: null,
|
||||
focusIsInsideTerminal: false,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeWorkspace", workspaceId: "w1" });
|
||||
});
|
||||
|
||||
test("workspace with no focused session + sidebar closed → closeWorkspace", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: { id: "w1", focusedSessionId: undefined },
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: null,
|
||||
focusIsInsideTerminal: true, // even if flag true, no focused id → cannot closeTerminal
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeWorkspace", workspaceId: "w1" });
|
||||
});
|
||||
|
||||
test("workspace with no focused session + sidebar open → closeSidePanel", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: { id: "w1", focusedSessionId: undefined },
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: "ai",
|
||||
focusIsInsideTerminal: false,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeSidePanel" });
|
||||
});
|
||||
43
application/state/resolveCloseIntent.ts
Normal file
43
application/state/resolveCloseIntent.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export type CloseIntent =
|
||||
| { kind: 'closeTerminal'; sessionId: string }
|
||||
| { kind: 'closeSidePanel' }
|
||||
| { kind: 'closeWorkspace'; workspaceId: string }
|
||||
| { kind: 'closeSingleTab'; sessionId: string }
|
||||
| { kind: 'noop' };
|
||||
|
||||
export interface ResolveCloseInput {
|
||||
activeTabId: string | null;
|
||||
workspace: { id: string; focusedSessionId?: string } | null;
|
||||
sessionForTab: { id: string } | null;
|
||||
activeSidePanelTab: string | null;
|
||||
focusIsInsideTerminal: boolean;
|
||||
}
|
||||
|
||||
export function resolveCloseIntent(input: ResolveCloseInput): CloseIntent {
|
||||
const { activeTabId, workspace, sessionForTab, activeSidePanelTab, focusIsInsideTerminal } = input;
|
||||
|
||||
if (!activeTabId) return { kind: 'noop' };
|
||||
|
||||
// Sidebar always wins — applies to any tab type (workspace, single-session, etc.).
|
||||
// Modals take priority over this but are intercepted upstream in App.tsx before the
|
||||
// hotkey reaches resolveCloseIntent.
|
||||
if (activeSidePanelTab !== null) {
|
||||
return { kind: 'closeSidePanel' };
|
||||
}
|
||||
|
||||
if (sessionForTab && !workspace) {
|
||||
return { kind: 'closeSingleTab', sessionId: sessionForTab.id };
|
||||
}
|
||||
|
||||
if (!workspace) {
|
||||
// e.g. 'vault', 'sftp', or any non-closable pinned tab
|
||||
return { kind: 'noop' };
|
||||
}
|
||||
|
||||
const focusedSessionId = workspace.focusedSessionId;
|
||||
if (focusedSessionId && focusIsInsideTerminal) {
|
||||
return { kind: 'closeTerminal', sessionId: focusedSessionId };
|
||||
}
|
||||
|
||||
return { kind: 'closeWorkspace', workspaceId: workspace.id };
|
||||
}
|
||||
64
application/state/resolveSnippetsShortcutIntent.test.ts
Normal file
64
application/state/resolveSnippetsShortcutIntent.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
resolveScriptsSidePanelShortcutIntent,
|
||||
resolveSnippetsShortcutIntent,
|
||||
} from "./resolveSnippetsShortcutIntent.ts";
|
||||
|
||||
test("active single terminal tab toggles the terminal scripts panel", () => {
|
||||
const result = resolveSnippetsShortcutIntent({
|
||||
activeTabId: "s1",
|
||||
sessionForTab: { id: "s1" },
|
||||
workspaceForTab: null,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { kind: "toggleTerminalScripts" });
|
||||
});
|
||||
|
||||
test("active workspace tab toggles the terminal scripts panel", () => {
|
||||
const result = resolveSnippetsShortcutIntent({
|
||||
activeTabId: "w1",
|
||||
sessionForTab: null,
|
||||
workspaceForTab: { id: "w1" },
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { kind: "toggleTerminalScripts" });
|
||||
});
|
||||
|
||||
test("non-terminal tabs navigate to the vault snippets section", () => {
|
||||
for (const activeTabId of ["vault", "sftp", "editor:notes", "log1", null]) {
|
||||
const result = resolveSnippetsShortcutIntent({
|
||||
activeTabId,
|
||||
sessionForTab: null,
|
||||
workspaceForTab: null,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { kind: "openVaultSnippets" });
|
||||
}
|
||||
});
|
||||
|
||||
test("terminal tabs fall back to vault snippets when terminal toggle is unavailable", () => {
|
||||
const result = resolveSnippetsShortcutIntent({
|
||||
activeTabId: "s1",
|
||||
sessionForTab: { id: "s1" },
|
||||
workspaceForTab: null,
|
||||
terminalScriptsToggleAvailable: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { kind: "openVaultSnippets" });
|
||||
});
|
||||
|
||||
test("scripts panel shortcut closes when scripts is already open", () => {
|
||||
const result = resolveScriptsSidePanelShortcutIntent("scripts");
|
||||
|
||||
assert.deepEqual(result, { kind: "closeTerminalSidePanel" });
|
||||
});
|
||||
|
||||
test("scripts panel shortcut opens scripts from closed or other panel states", () => {
|
||||
for (const activePanel of [null, "sftp", "theme", "ai"]) {
|
||||
const result = resolveScriptsSidePanelShortcutIntent(activePanel);
|
||||
|
||||
assert.deepEqual(result, { kind: "openTerminalScripts" });
|
||||
}
|
||||
});
|
||||
42
application/state/resolveSnippetsShortcutIntent.ts
Normal file
42
application/state/resolveSnippetsShortcutIntent.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export type SnippetsShortcutIntent =
|
||||
| { kind: 'toggleTerminalScripts' }
|
||||
| { kind: 'openVaultSnippets' };
|
||||
|
||||
export type ScriptsSidePanelShortcutIntent =
|
||||
| { kind: 'closeTerminalSidePanel' }
|
||||
| { kind: 'openTerminalScripts' };
|
||||
|
||||
export interface ResolveSnippetsShortcutIntentInput {
|
||||
activeTabId: string | null;
|
||||
sessionForTab: { id: string } | null;
|
||||
workspaceForTab: { id: string } | null;
|
||||
terminalScriptsToggleAvailable?: boolean;
|
||||
}
|
||||
|
||||
export function resolveSnippetsShortcutIntent(
|
||||
input: ResolveSnippetsShortcutIntentInput,
|
||||
): SnippetsShortcutIntent {
|
||||
const {
|
||||
activeTabId,
|
||||
sessionForTab,
|
||||
workspaceForTab,
|
||||
terminalScriptsToggleAvailable = true,
|
||||
} = input;
|
||||
if (!activeTabId) return { kind: 'openVaultSnippets' };
|
||||
|
||||
if ((sessionForTab || workspaceForTab) && terminalScriptsToggleAvailable) {
|
||||
return { kind: 'toggleTerminalScripts' };
|
||||
}
|
||||
|
||||
return { kind: 'openVaultSnippets' };
|
||||
}
|
||||
|
||||
export function resolveScriptsSidePanelShortcutIntent(
|
||||
activePanel: string | null,
|
||||
): ScriptsSidePanelShortcutIntent {
|
||||
if (activePanel === 'scripts') {
|
||||
return { kind: 'closeTerminalSidePanel' };
|
||||
}
|
||||
|
||||
return { kind: 'openTerminalScripts' };
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useRef, useMemo } from "react";
|
||||
import { TransferTask, TransferStatus } from "../../../domain/models";
|
||||
import { TransferTask, TransferStatus, SftpFilenameEncoding } from "../../../domain/models";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { SftpPane } from "./types";
|
||||
@@ -20,6 +20,7 @@ export type { UploadResult };
|
||||
|
||||
interface UseSftpExternalOperationsParams {
|
||||
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||||
getPaneByConnectionId: (connectionId: string) => SftpPane | null;
|
||||
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
|
||||
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
|
||||
connectionCacheKeyMapRef: React.MutableRefObject<Map<string, string>>;
|
||||
@@ -35,6 +36,13 @@ interface SftpExternalOperationsResult {
|
||||
readTextFile: (side: "left" | "right", filePath: string) => Promise<string>;
|
||||
readBinaryFile: (side: "left" | "right", filePath: string) => Promise<ArrayBuffer>;
|
||||
writeTextFile: (side: "left" | "right", filePath: string, content: string) => Promise<void>;
|
||||
writeTextFileByConnection: (
|
||||
connectionId: string,
|
||||
expectedHostId: string,
|
||||
filePath: string,
|
||||
content: string,
|
||||
filenameEncoding?: SftpFilenameEncoding,
|
||||
) => Promise<void>;
|
||||
downloadToTempAndOpen: (
|
||||
side: "left" | "right",
|
||||
remotePath: string,
|
||||
@@ -62,6 +70,7 @@ export const useSftpExternalOperations = (
|
||||
): SftpExternalOperationsResult => {
|
||||
const {
|
||||
getActivePane,
|
||||
getPaneByConnectionId,
|
||||
refresh,
|
||||
sftpSessionsRef,
|
||||
connectionCacheKeyMapRef,
|
||||
@@ -173,6 +182,41 @@ export const useSftpExternalOperations = (
|
||||
[getActivePane, sftpSessionsRef],
|
||||
);
|
||||
|
||||
const writeTextFileByConnection = useCallback(
|
||||
async (
|
||||
connectionId: string,
|
||||
expectedHostId: string,
|
||||
filePath: string,
|
||||
content: string,
|
||||
filenameEncoding?: SftpFilenameEncoding,
|
||||
): Promise<void> => {
|
||||
const pane = getPaneByConnectionId(connectionId);
|
||||
if (!pane?.connection) {
|
||||
throw new Error("SFTP connection is no longer available");
|
||||
}
|
||||
if (pane.connection.hostId !== expectedHostId) {
|
||||
throw new Error("SFTP connection changed while editing — file not saved to prevent writing to wrong host");
|
||||
}
|
||||
|
||||
if (pane.connection.isLocal) {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.writeLocalFile) throw new Error("Local file writing not supported");
|
||||
const data = new TextEncoder().encode(content);
|
||||
await bridge.writeLocalFile(filePath, data.buffer);
|
||||
return;
|
||||
}
|
||||
|
||||
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||||
if (!sftpId) throw new Error("SFTP session not found");
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge) throw new Error("Bridge not available");
|
||||
|
||||
await bridge.writeSftp(sftpId, filePath, content, filenameEncoding ?? pane.filenameEncoding);
|
||||
},
|
||||
[getPaneByConnectionId, sftpSessionsRef],
|
||||
);
|
||||
|
||||
const downloadToTempAndOpen = useCallback(
|
||||
async (
|
||||
side: "left" | "right",
|
||||
@@ -693,6 +737,7 @@ export const useSftpExternalOperations = (
|
||||
readTextFile,
|
||||
readBinaryFile,
|
||||
writeTextFile,
|
||||
writeTextFileByConnection,
|
||||
downloadToTempAndOpen,
|
||||
uploadExternalFiles,
|
||||
uploadExternalEntries,
|
||||
|
||||
@@ -33,8 +33,11 @@ import type {
|
||||
import { DEFAULT_COMMAND_BLOCKLIST } from '../../infrastructure/ai/types';
|
||||
import {
|
||||
activateDraftView,
|
||||
bumpDraftMutationVersionState,
|
||||
bumpDraftUploadGenerationState,
|
||||
clearScopeDraftState,
|
||||
ensureDraftForScopeState,
|
||||
getDraftUploadGenerationState,
|
||||
setSessionView,
|
||||
updateDraftForScope,
|
||||
} from './aiDraftState';
|
||||
@@ -91,9 +94,25 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
const currentSessions = latestAISessionsSnapshot
|
||||
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
||||
?? [];
|
||||
|
||||
// Sessions shown by a still-live scope must be protected from cleanup
|
||||
// even when their own `scope.targetId` points at a closed terminal —
|
||||
// history can be resumed into a different terminal and we must not
|
||||
// delete it outright while it's actively being used.
|
||||
const preCleanupActiveSessionMap = latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {};
|
||||
const activeSessionIds = new Set<string>();
|
||||
for (const [scopeKey, sessionId] of Object.entries(preCleanupActiveSessionMap)) {
|
||||
if (!sessionId) continue;
|
||||
if (!isScopeKeyActive(scopeKey, activeTargetIds)) continue;
|
||||
activeSessionIds.add(sessionId);
|
||||
}
|
||||
|
||||
const nextSessionCleanup = pruneInactiveScopedSessions(
|
||||
currentSessions,
|
||||
activeTargetIds,
|
||||
activeSessionIds,
|
||||
);
|
||||
|
||||
if (nextSessionCleanup.orphanedSessionIds.length > 0) {
|
||||
@@ -109,9 +128,7 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
|
||||
}
|
||||
|
||||
const activeSessionIdMap = latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {};
|
||||
const activeSessionIdMap = preCleanupActiveSessionMap;
|
||||
let activeSessionMapChanged = false;
|
||||
const nextActiveSessionIdMap = { ...activeSessionIdMap };
|
||||
|
||||
@@ -152,6 +169,7 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
for (const scopeKey of Object.keys(currentDraftsByScope)) {
|
||||
if (scopeKey in prunedScopedTransientState.draftsByScope) continue;
|
||||
bumpDraftMutationVersion(scopeKey);
|
||||
bumpDraftUploadGeneration(scopeKey);
|
||||
}
|
||||
setLatestAIDraftsByScopeSnapshot(prunedScopedTransientState.draftsByScope);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
|
||||
@@ -198,6 +216,7 @@ let latestAIActiveSessionMapSnapshot: Record<string, string | null> | null = nul
|
||||
let latestAIDraftsByScopeSnapshot: DraftsByScope | null = null;
|
||||
let latestAIPanelViewByScopeSnapshot: PanelViewByScope | null = null;
|
||||
let latestAIDraftMutationVersionByScopeSnapshot: Record<string, number> = {};
|
||||
let latestAIDraftUploadGenerationByScopeSnapshot: Record<string, number> = {};
|
||||
|
||||
function setLatestAISessionsSnapshot(sessions: AISession[]) {
|
||||
latestAISessionsSnapshot = sessions;
|
||||
@@ -207,19 +226,6 @@ 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));
|
||||
}
|
||||
|
||||
function setLatestAIDraftsByScopeSnapshot(draftsByScope: DraftsByScope) {
|
||||
latestAIDraftsByScopeSnapshot = draftsByScope;
|
||||
}
|
||||
@@ -228,15 +234,25 @@ function setLatestAIPanelViewByScopeSnapshot(panelViewByScope: PanelViewByScope)
|
||||
latestAIPanelViewByScopeSnapshot = panelViewByScope;
|
||||
}
|
||||
|
||||
function getDraftMutationVersion(scopeKey: string) {
|
||||
return latestAIDraftMutationVersionByScopeSnapshot[scopeKey] ?? 0;
|
||||
function bumpDraftMutationVersion(scopeKey: string) {
|
||||
latestAIDraftMutationVersionByScopeSnapshot = bumpDraftMutationVersionState(
|
||||
latestAIDraftMutationVersionByScopeSnapshot,
|
||||
scopeKey,
|
||||
);
|
||||
}
|
||||
|
||||
function bumpDraftMutationVersion(scopeKey: string) {
|
||||
latestAIDraftMutationVersionByScopeSnapshot = {
|
||||
...latestAIDraftMutationVersionByScopeSnapshot,
|
||||
[scopeKey]: getDraftMutationVersion(scopeKey) + 1,
|
||||
};
|
||||
function getDraftUploadGeneration(scopeKey: string) {
|
||||
return getDraftUploadGenerationState(
|
||||
latestAIDraftUploadGenerationByScopeSnapshot,
|
||||
scopeKey,
|
||||
);
|
||||
}
|
||||
|
||||
function bumpDraftUploadGeneration(scopeKey: string) {
|
||||
latestAIDraftUploadGenerationByScopeSnapshot = bumpDraftUploadGenerationState(
|
||||
latestAIDraftUploadGenerationByScopeSnapshot,
|
||||
scopeKey,
|
||||
);
|
||||
}
|
||||
|
||||
export function useAIState() {
|
||||
@@ -788,60 +804,6 @@ 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;
|
||||
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;
|
||||
|
||||
@@ -947,12 +909,15 @@ export function useAIState() {
|
||||
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
|
||||
return next;
|
||||
});
|
||||
bumpDraftMutationVersion(scopeKey);
|
||||
}, []);
|
||||
|
||||
const updateDraftIfPresent = useCallback((
|
||||
scopeKey: string,
|
||||
updater: (draft: AIDraft) => AIDraft,
|
||||
): void => {
|
||||
let updated = false;
|
||||
|
||||
setDraftsByScopeRaw((prev) => {
|
||||
const currentDraft = prev[scopeKey];
|
||||
if (!currentDraft) return prev;
|
||||
@@ -965,14 +930,19 @@ export function useAIState() {
|
||||
...prev,
|
||||
[scopeKey]: nextDraft,
|
||||
};
|
||||
updated = true;
|
||||
setLatestAIDraftsByScopeSnapshot(next);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
|
||||
return next;
|
||||
});
|
||||
|
||||
if (updated) {
|
||||
bumpDraftMutationVersion(scopeKey);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const showDraftView = useCallback((scopeKey: string) => {
|
||||
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? panelViewByScope;
|
||||
const currentPanelViewByScope = panelViewByScope;
|
||||
let nextActiveSessionIdMap: Record<string, string | null> | null = null;
|
||||
let nextPanelViewByScope: PanelViewByScope | null = null;
|
||||
let activeSessionMapChanged = false;
|
||||
@@ -1009,7 +979,7 @@ export function useAIState() {
|
||||
}, [setPanelViewByScope]);
|
||||
|
||||
const clearDraftForScope = useCallback((scopeKey: string) => {
|
||||
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? panelViewByScope;
|
||||
const currentPanelViewByScope = panelViewByScope;
|
||||
let nextDraftsByScope: DraftsByScope | null = null;
|
||||
let nextPanelViewByScope: PanelViewByScope | null = null;
|
||||
let draftsChanged = false;
|
||||
@@ -1031,6 +1001,7 @@ export function useAIState() {
|
||||
if (!draftsChanged && !panelViewChanged) return;
|
||||
|
||||
bumpDraftMutationVersion(scopeKey);
|
||||
bumpDraftUploadGeneration(scopeKey);
|
||||
|
||||
if (draftsChanged && nextDraftsByScope) {
|
||||
setLatestAIDraftsByScopeSnapshot(nextDraftsByScope);
|
||||
@@ -1050,11 +1021,11 @@ export function useAIState() {
|
||||
inputFiles: File[],
|
||||
) => {
|
||||
ensureDraftForScope(scopeKey, fallbackAgentId);
|
||||
const initialMutationVersion = getDraftMutationVersion(scopeKey);
|
||||
const initialUploadGeneration = getDraftUploadGeneration(scopeKey);
|
||||
const uploads = await convertFilesToUploads(inputFiles);
|
||||
if (uploads.length === 0) return;
|
||||
|
||||
if (getDraftMutationVersion(scopeKey) !== initialMutationVersion) {
|
||||
if (getDraftUploadGeneration(scopeKey) !== initialUploadGeneration) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1175,7 +1146,6 @@ export function useAIState() {
|
||||
deleteSessionsByTarget,
|
||||
updateSessionTitle,
|
||||
updateSessionExternalSessionId,
|
||||
retargetSessionScope,
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
updateMessageById,
|
||||
|
||||
@@ -51,10 +51,35 @@ const AUTO_SYNC_PROVIDER_ORDER: CloudProvider[] = ['github', 'google', 'onedrive
|
||||
|
||||
// Cross-window restore barrier: stored as an epoch-ms deadline. Any value
|
||||
// in the future means a restore is applying in some window and auto-sync
|
||||
// must not push concurrently.
|
||||
// must not push concurrently. The writer (`withRestoreBarrier`) heartbeats
|
||||
// the deadline to keep it alive; a crashed window naturally expires within
|
||||
// ~RESTORE_BARRIER_HOLD_MS. We still defend against two degenerate cases:
|
||||
// (1) a stale deadline sitting in the past — harmless but pollutes debug
|
||||
// state, so we opportunistically clear it; (2) a deadline absurdly far
|
||||
// in the future (clock skew between windows, pathological holdMs, or a
|
||||
// tampered value) — would otherwise lock auto-sync indefinitely, so we
|
||||
// clear it and treat the barrier as inactive.
|
||||
const RESTORE_BARRIER_SANITY_MAX_MS = 10 * 60 * 1000; // 10 minutes
|
||||
const isRestoreInProgress = (): boolean => {
|
||||
const raw = localStorageAdapter.readNumber(STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL);
|
||||
return typeof raw === 'number' && raw > Date.now();
|
||||
if (typeof raw !== 'number' || raw <= 0) return false;
|
||||
const now = Date.now();
|
||||
if (raw <= now) {
|
||||
// Deadline is in the past — either a clean finish that failed to
|
||||
// overwrite the key, or a crashed heartbeat. Clear so subsequent
|
||||
// reads are cheap and the key doesn't linger forever.
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL, 0);
|
||||
return false;
|
||||
}
|
||||
if (raw - now > RESTORE_BARRIER_SANITY_MAX_MS) {
|
||||
console.warn(
|
||||
'[useAutoSync] Restore barrier deadline is absurdly far in the future; treating as corrupt and clearing.',
|
||||
{ deadline: raw, now },
|
||||
);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL, 0);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
type SyncTrigger = 'auto' | 'manual';
|
||||
@@ -247,19 +272,23 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
throw new Error(t('sync.credentialsUnavailable'));
|
||||
}
|
||||
|
||||
// Prevent pushing an empty vault to cloud. This is almost always
|
||||
// Refuse to push an empty vault to cloud. This is almost always
|
||||
// a sign that the local state was lost (update, import failure,
|
||||
// storage corruption) rather than a deliberate "delete everything".
|
||||
// We only block auto-sync — manual trigger from Settings can still
|
||||
// push if the user explicitly wants to.
|
||||
// Both auto and manual triggers are blocked; the user can still
|
||||
// use Force Push from the SyncBlocked banner if they genuinely
|
||||
// want to wipe the cloud.
|
||||
//
|
||||
// This pairs with the inspect-failure "fail open" behavior in
|
||||
// checkRemoteVersion below: if inspect transiently errors we still
|
||||
// let auto-sync run, trusting this guard to refuse if local is
|
||||
// truly empty rather than letting an empty state clobber remote.
|
||||
if (!hasMeaningfulSyncData(payload) && trigger === 'auto') {
|
||||
console.warn('[AutoSync] Blocked: refusing to auto-sync an empty vault to cloud');
|
||||
return;
|
||||
if (!hasMeaningfulSyncData(payload)) {
|
||||
if (trigger === 'auto') {
|
||||
console.warn('[AutoSync] Blocked: refusing to auto-sync an empty vault to cloud');
|
||||
return;
|
||||
}
|
||||
throw new Error(t('sync.autoSync.emptyVaultManual'));
|
||||
}
|
||||
|
||||
const results = await sync.syncNow(payload);
|
||||
@@ -479,7 +508,20 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// that only approximated the correct ordering.
|
||||
if (mergeResult.payload) {
|
||||
try {
|
||||
await manager.syncAllProviders(mergeResult.payload);
|
||||
const roundTripResults = await manager.syncAllProviders(mergeResult.payload);
|
||||
const wasShrinkBlocked = Array.from(roundTripResults.values()).some(
|
||||
(r) => r.shrinkBlocked === true,
|
||||
);
|
||||
if (wasShrinkBlocked) {
|
||||
// The merged payload is already applied locally and is the source of truth
|
||||
// for THIS device. The blocking only prevents pushing it to cloud, which
|
||||
// is acceptable here — the next user-edit-triggered sync will re-check
|
||||
// (and the user can also force-push from the Settings banner if they
|
||||
// navigate there). Reset syncState so we don't leave the manager wedged
|
||||
// in BLOCKED with no banner visible.
|
||||
console.warn('[AutoSync] Post-merge round-trip was shrink-blocked; merged data applied locally, reset syncState to IDLE for next attempt.');
|
||||
manager.clearShrinkBlockedState();
|
||||
}
|
||||
// Suppress the debounced follow-up tick that otherwise fires
|
||||
// once React commits the applied state, since we've just
|
||||
// already pushed that exact payload upstream.
|
||||
|
||||
@@ -26,7 +26,9 @@ import {
|
||||
import {
|
||||
getCloudSyncManager,
|
||||
type SyncManagerState,
|
||||
type SyncEventCallback,
|
||||
} from '../../infrastructure/services/CloudSyncManager';
|
||||
import type { ShrinkFinding } from '../../domain/syncGuards';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import type { DeviceFlowState } from '../../infrastructure/services/adapters/GitHubAdapter';
|
||||
|
||||
@@ -51,11 +53,12 @@ export interface CloudSyncHook {
|
||||
remoteVersion: number;
|
||||
remoteUpdatedAt: number;
|
||||
syncHistory: SyncHistoryEntry[];
|
||||
pendingBrowserAuthProvider: 'google' | 'onedrive' | null;
|
||||
|
||||
// Computed
|
||||
hasAnyConnectedProvider: boolean;
|
||||
connectedProviderCount: number;
|
||||
overallSyncStatus: 'none' | 'synced' | 'syncing' | 'error' | 'conflict';
|
||||
overallSyncStatus: 'none' | 'synced' | 'syncing' | 'error' | 'conflict' | 'blocked';
|
||||
|
||||
// Master Key Actions
|
||||
setupMasterKey: (password: string, confirmPassword: string) => Promise<void>;
|
||||
@@ -70,7 +73,9 @@ export interface CloudSyncHook {
|
||||
deviceCode: string,
|
||||
interval: number,
|
||||
expiresAt: number,
|
||||
onPending?: () => void
|
||||
onPending?: () => void,
|
||||
signal?: AbortSignal,
|
||||
authAttemptId?: number
|
||||
) => Promise<void>;
|
||||
connectGoogle: () => Promise<string>;
|
||||
connectOneDrive: () => Promise<string>;
|
||||
@@ -86,8 +91,8 @@ export interface CloudSyncHook {
|
||||
resetProviderStatus: (provider: CloudProvider) => void;
|
||||
|
||||
// Sync Actions
|
||||
syncNow: (payload: SyncPayload) => Promise<Map<CloudProvider, SyncResult>>;
|
||||
syncToProvider: (provider: CloudProvider, payload: SyncPayload) => Promise<SyncResult>;
|
||||
syncNow: (payload: SyncPayload, opts?: { overrideShrink?: boolean }) => Promise<Map<CloudProvider, SyncResult>>;
|
||||
syncToProvider: (provider: CloudProvider, payload: SyncPayload, opts?: { overrideShrink?: boolean }) => Promise<SyncResult>;
|
||||
downloadFromProvider: (provider: CloudProvider) => Promise<SyncPayload | null>;
|
||||
resolveConflict: (resolution: ConflictResolution) => Promise<SyncPayload | null>;
|
||||
|
||||
@@ -116,8 +121,55 @@ export interface CloudSyncHook {
|
||||
formatLastSync: (timestamp?: number) => string;
|
||||
getProviderDotColor: (provider: CloudProvider) => string;
|
||||
refresh: () => void;
|
||||
|
||||
// Event subscription (for non-state events like SYNC_BLOCKED_SHRINK)
|
||||
subscribeToEvents: (callback: SyncEventCallback) => () => void;
|
||||
|
||||
// Shrink-block state query (for banner hydration on mount)
|
||||
getShrinkBlockedFinding: () => Extract<ShrinkFinding, { suspicious: true }> | null;
|
||||
}
|
||||
|
||||
type PendingBrowserAuthState = {
|
||||
provider: 'google' | 'onedrive';
|
||||
sessionId: string;
|
||||
authAttemptId?: number;
|
||||
} | null;
|
||||
|
||||
let pendingBrowserAuthState: PendingBrowserAuthState = null;
|
||||
const pendingBrowserAuthListeners = new Set<() => void>();
|
||||
let activeOAuthBrowserHandoff:
|
||||
| { sessionId: string; cancel: () => void }
|
||||
| null = null;
|
||||
const cancelledOAuthSessionIds = new Set<string>();
|
||||
|
||||
const getPendingBrowserAuthState = (): PendingBrowserAuthState => pendingBrowserAuthState;
|
||||
|
||||
const subscribePendingBrowserAuthState = (callback: () => void) => {
|
||||
pendingBrowserAuthListeners.add(callback);
|
||||
return () => pendingBrowserAuthListeners.delete(callback);
|
||||
};
|
||||
|
||||
const setPendingBrowserAuthState = (next: PendingBrowserAuthState) => {
|
||||
pendingBrowserAuthState = next;
|
||||
pendingBrowserAuthListeners.forEach((callback) => callback());
|
||||
};
|
||||
|
||||
const clearPendingBrowserAuthState = (
|
||||
match?: { provider: 'google' | 'onedrive'; sessionId: string; authAttemptId?: number }
|
||||
) => {
|
||||
if (!match) {
|
||||
setPendingBrowserAuthState(null);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
pendingBrowserAuthState &&
|
||||
pendingBrowserAuthState.provider === match.provider &&
|
||||
pendingBrowserAuthState.sessionId === match.sessionId
|
||||
) {
|
||||
setPendingBrowserAuthState(null);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Hook Implementation
|
||||
// ============================================================================
|
||||
@@ -138,6 +190,15 @@ const getSnapshot = (): SyncManagerState => {
|
||||
export const useCloudSync = (): CloudSyncHook => {
|
||||
// Use useSyncExternalStore for real-time state sync across all components
|
||||
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
const pendingBrowserAuth = useSyncExternalStore(
|
||||
subscribePendingBrowserAuthState,
|
||||
getPendingBrowserAuthState,
|
||||
getPendingBrowserAuthState
|
||||
);
|
||||
const activeOAuthSessionIdRef = useRef<string | null>(null);
|
||||
const activeOAuthProviderRef = useRef<'google' | 'onedrive' | null>(null);
|
||||
const activeGitHubAuthAbortRef = useRef<AbortController | null>(null);
|
||||
const activeGitHubAuthAttemptIdRef = useRef<number | null>(null);
|
||||
|
||||
// Auto-unlock: if a master key exists, retrieve the persisted password (Electron safeStorage)
|
||||
// and unlock silently so users don't have to manage a LOCKED state in the UI.
|
||||
@@ -190,7 +251,8 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
).length;
|
||||
}, [state.providers]);
|
||||
|
||||
const overallSyncStatus = useMemo((): 'none' | 'synced' | 'syncing' | 'error' | 'conflict' => {
|
||||
const overallSyncStatus = useMemo((): 'none' | 'synced' | 'syncing' | 'error' | 'conflict' | 'blocked' => {
|
||||
if (state.syncState === 'BLOCKED') return 'blocked';
|
||||
if (state.syncState === 'CONFLICT') return 'conflict';
|
||||
if (state.syncState === 'ERROR') return 'error';
|
||||
if (state.syncState === 'SYNCING') return 'syncing';
|
||||
@@ -253,107 +315,277 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
if (result.type !== 'device_code') {
|
||||
throw new Error('Unexpected auth type');
|
||||
}
|
||||
return result.data as DeviceFlowState;
|
||||
activeGitHubAuthAttemptIdRef.current = result.data.authAttemptId ?? null;
|
||||
return result.data;
|
||||
}, []);
|
||||
|
||||
const completeGitHubAuth = useCallback(async (
|
||||
deviceCode: string,
|
||||
interval: number,
|
||||
expiresAt: number,
|
||||
onPending?: () => void
|
||||
onPending?: () => void,
|
||||
signal?: AbortSignal,
|
||||
authAttemptId?: number
|
||||
): Promise<void> => {
|
||||
await manager.completeGitHubAuth(deviceCode, interval, expiresAt, onPending);
|
||||
}, []);
|
||||
|
||||
const connectGoogle = useCallback(async (): Promise<string> => {
|
||||
const result = await manager.startProviderAuth('google');
|
||||
if (result.type !== 'url') {
|
||||
throw new Error('Unexpected auth type');
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort();
|
||||
|
||||
if (signal?.aborted) {
|
||||
abort();
|
||||
} else if (signal) {
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
}
|
||||
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;
|
||||
if (startCallback) {
|
||||
// Get state from adapter for CSRF protection
|
||||
const adapter = manager.getAdapter('google') as { getPKCEState?: () => string | null } | undefined;
|
||||
const expectedState = adapter?.getPKCEState?.() || undefined;
|
||||
activeGitHubAuthAbortRef.current = controller;
|
||||
|
||||
// Start callback server and open system browser
|
||||
const callbackPromise = startCallback(expectedState);
|
||||
|
||||
// Use system browser to avoid white-screen issues in popup windows (#563)
|
||||
// Race: if browser launch fails, surface the error immediately
|
||||
let openTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const browserPromise = new Promise<never>((_resolve, reject) => {
|
||||
openTimer = setTimeout(async () => {
|
||||
try {
|
||||
await bridge?.openExternal(data.url);
|
||||
} catch (err) {
|
||||
bridge?.cancelOAuthCallback?.();
|
||||
reject(err instanceof Error ? err : new Error('Failed to open browser for authentication'));
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
try {
|
||||
const { code } = await Promise.race([callbackPromise, browserPromise]);
|
||||
|
||||
// Complete auth with the received code
|
||||
await manager.completePKCEAuth('google', code, data.redirectUri);
|
||||
} finally {
|
||||
if (openTimer) clearTimeout(openTimer);
|
||||
try {
|
||||
await manager.completeGitHubAuth(
|
||||
deviceCode,
|
||||
interval,
|
||||
expiresAt,
|
||||
onPending,
|
||||
controller.signal,
|
||||
authAttemptId
|
||||
);
|
||||
} finally {
|
||||
if (signal) {
|
||||
signal.removeEventListener('abort', abort);
|
||||
}
|
||||
if (activeGitHubAuthAbortRef.current === controller) {
|
||||
activeGitHubAuthAbortRef.current = null;
|
||||
}
|
||||
if (activeGitHubAuthAttemptIdRef.current === (authAttemptId ?? null)) {
|
||||
activeGitHubAuthAttemptIdRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
return data.url;
|
||||
}, []);
|
||||
|
||||
const cancelActivePKCEAuth = useCallback(async () => {
|
||||
const pending = getPendingBrowserAuthState();
|
||||
const sessionId = pending?.sessionId ?? activeOAuthSessionIdRef.current;
|
||||
const provider = pending?.provider ?? activeOAuthProviderRef.current;
|
||||
const authAttemptId = pending?.authAttemptId;
|
||||
if (!sessionId || !provider) return;
|
||||
|
||||
cancelledOAuthSessionIds.add(sessionId);
|
||||
if (activeOAuthBrowserHandoff?.sessionId === sessionId) {
|
||||
activeOAuthBrowserHandoff.cancel();
|
||||
activeOAuthBrowserHandoff = null;
|
||||
}
|
||||
manager.cancelProviderAuthAttempt(provider, authAttemptId);
|
||||
activeOAuthSessionIdRef.current = null;
|
||||
activeOAuthProviderRef.current = null;
|
||||
clearPendingBrowserAuthState(
|
||||
pending
|
||||
? {
|
||||
provider: pending.provider,
|
||||
sessionId: pending.sessionId,
|
||||
authAttemptId: pending.authAttemptId,
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
|
||||
try {
|
||||
await netcattyBridge.get()?.cancelOAuthCallback?.(sessionId);
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
}, []);
|
||||
|
||||
const runPKCEAuth = useCallback(
|
||||
async (provider: 'google' | 'onedrive'): Promise<string> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
const prepare = bridge?.prepareOAuthCallback;
|
||||
const awaitCallback = bridge?.awaitOAuthCallback;
|
||||
const openExternal = bridge?.openExternal;
|
||||
if (!prepare || !awaitCallback || !openExternal) {
|
||||
throw new Error('OAuth bridge is unavailable');
|
||||
}
|
||||
|
||||
// Only one loopback OAuth flow can be active at a time. If the user
|
||||
// starts another provider while a previous browser hop is still pending,
|
||||
// cancel the stale one first so the new attempt owns the callback port.
|
||||
await cancelActivePKCEAuth();
|
||||
|
||||
// Bind the loopback callback server first so we know which port to put
|
||||
// in the provider's redirect_uri (#823: 45678 may be in use).
|
||||
const { redirectUri, sessionId } = await prepare();
|
||||
activeOAuthSessionIdRef.current = sessionId;
|
||||
activeOAuthProviderRef.current = provider;
|
||||
setPendingBrowserAuthState({ provider, sessionId });
|
||||
|
||||
try {
|
||||
const result = await manager.startProviderAuth(provider, redirectUri);
|
||||
if (result.type !== 'url') {
|
||||
throw new Error('Unexpected auth type');
|
||||
}
|
||||
const data = result.data;
|
||||
|
||||
if (cancelledOAuthSessionIds.has(sessionId)) {
|
||||
throw new Error('OAuth flow cancelled');
|
||||
}
|
||||
|
||||
const adapter = manager.getAdapter(provider) as
|
||||
| { getPKCEState?: () => string | null }
|
||||
| undefined;
|
||||
const expectedState = adapter?.getPKCEState?.() || undefined;
|
||||
|
||||
const callbackPromise = awaitCallback(expectedState, sessionId);
|
||||
|
||||
// Use system browser to avoid white-screen issues in popup windows (#563).
|
||||
// Once the browser has opened, let the rest of the PKCE handshake
|
||||
// continue in the background so closing the browser later does not
|
||||
// leave the whole settings page locked waiting on a timeout.
|
||||
let openTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let browserOpened = false;
|
||||
let rejectBrowserPromise: ((error: Error) => void) | null = null;
|
||||
const browserPromise = new Promise<void>((resolve, reject) => {
|
||||
rejectBrowserPromise = reject;
|
||||
openTimer = setTimeout(async () => {
|
||||
try {
|
||||
await openExternal(data.url);
|
||||
browserOpened = true;
|
||||
resolve();
|
||||
} catch (err) {
|
||||
bridge?.cancelOAuthCallback?.(sessionId);
|
||||
reject(
|
||||
err instanceof Error
|
||||
? err
|
||||
: new Error('Failed to open browser for authentication')
|
||||
);
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
activeOAuthBrowserHandoff = {
|
||||
sessionId,
|
||||
cancel: () => {
|
||||
if (openTimer) {
|
||||
clearTimeout(openTimer);
|
||||
openTimer = null;
|
||||
}
|
||||
if (rejectBrowserPromise) {
|
||||
rejectBrowserPromise(new Error('OAuth flow cancelled'));
|
||||
rejectBrowserPromise = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
browserPromise,
|
||||
callbackPromise.then(
|
||||
() => {
|
||||
throw new Error('OAuth callback completed before browser handoff');
|
||||
},
|
||||
(error) => {
|
||||
if (browserOpened) {
|
||||
return new Promise<void>(() => {});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
),
|
||||
]);
|
||||
} finally {
|
||||
if (openTimer) clearTimeout(openTimer);
|
||||
if (activeOAuthBrowserHandoff?.sessionId === sessionId) {
|
||||
activeOAuthBrowserHandoff = null;
|
||||
}
|
||||
}
|
||||
setPendingBrowserAuthState({
|
||||
provider,
|
||||
sessionId,
|
||||
authAttemptId: data.authAttemptId,
|
||||
});
|
||||
|
||||
const completionPromise = (async () => {
|
||||
try {
|
||||
const { code } = await callbackPromise;
|
||||
await manager.completePKCEAuth(provider, code, data.redirectUri, data.authAttemptId);
|
||||
} catch (error) {
|
||||
const ownsActiveSession =
|
||||
activeOAuthSessionIdRef.current === sessionId &&
|
||||
activeOAuthProviderRef.current === provider;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const cancelledOrSuperseded =
|
||||
message.includes('cancelled') || message.includes('auth superseded');
|
||||
const timedOut = message.toLowerCase().includes('timeout');
|
||||
if (ownsActiveSession && (cancelledOrSuperseded || timedOut)) {
|
||||
activeOAuthSessionIdRef.current = null;
|
||||
activeOAuthProviderRef.current = null;
|
||||
cancelledOAuthSessionIds.delete(sessionId);
|
||||
clearPendingBrowserAuthState({
|
||||
provider,
|
||||
sessionId,
|
||||
authAttemptId: data.authAttemptId,
|
||||
});
|
||||
manager.resetProviderStatus(provider);
|
||||
} else if (ownsActiveSession) {
|
||||
activeOAuthSessionIdRef.current = null;
|
||||
activeOAuthProviderRef.current = null;
|
||||
cancelledOAuthSessionIds.delete(sessionId);
|
||||
clearPendingBrowserAuthState({
|
||||
provider,
|
||||
sessionId,
|
||||
authAttemptId: data.authAttemptId,
|
||||
});
|
||||
manager.setProviderError(provider, message);
|
||||
}
|
||||
} finally {
|
||||
if (
|
||||
activeOAuthSessionIdRef.current === sessionId &&
|
||||
activeOAuthProviderRef.current === provider
|
||||
) {
|
||||
activeOAuthSessionIdRef.current = null;
|
||||
activeOAuthProviderRef.current = null;
|
||||
}
|
||||
cancelledOAuthSessionIds.delete(sessionId);
|
||||
clearPendingBrowserAuthState({
|
||||
provider,
|
||||
sessionId,
|
||||
authAttemptId: data.authAttemptId,
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
// Release the transient "connecting" UI once the browser handoff has
|
||||
// happened. The callback session remains active in the background and
|
||||
// will mark the provider connected when the redirect completes.
|
||||
// Do NOT use resetProviderStatus here — it would restore from the
|
||||
// auth snapshot and delete the adapter we just created, making the
|
||||
// eventual completePKCEAuth call fail with "adapter not initialized".
|
||||
manager.clearConnectingStatus(provider);
|
||||
manager.clearProviderError(provider);
|
||||
void completionPromise;
|
||||
return data.url;
|
||||
} catch (err) {
|
||||
const ownsActiveSession =
|
||||
activeOAuthSessionIdRef.current === sessionId &&
|
||||
activeOAuthProviderRef.current === provider;
|
||||
try {
|
||||
await bridge?.cancelOAuthCallback?.(sessionId);
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
if (ownsActiveSession) {
|
||||
activeOAuthSessionIdRef.current = null;
|
||||
activeOAuthProviderRef.current = null;
|
||||
manager.cancelProviderAuthAttempt(provider);
|
||||
manager.resetProviderStatus(provider);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[cancelActivePKCEAuth]
|
||||
);
|
||||
|
||||
const connectGoogle = useCallback(async (): Promise<string> => {
|
||||
return runPKCEAuth('google');
|
||||
}, [runPKCEAuth]);
|
||||
|
||||
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 };
|
||||
return runPKCEAuth('onedrive');
|
||||
}, [runPKCEAuth]);
|
||||
|
||||
// Start OAuth callback server in Electron and wait for authorization
|
||||
const bridge = netcattyBridge.get();
|
||||
const startCallback = bridge?.startOAuthCallback;
|
||||
if (startCallback) {
|
||||
// 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 system browser
|
||||
const callbackPromise = startCallback(expectedState);
|
||||
|
||||
// Use system browser to avoid white-screen issues in popup windows (#563)
|
||||
let openTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const browserPromise = new Promise<never>((_resolve, reject) => {
|
||||
openTimer = setTimeout(async () => {
|
||||
try {
|
||||
await bridge?.openExternal(data.url);
|
||||
} catch (err) {
|
||||
bridge?.cancelOAuthCallback?.();
|
||||
reject(err instanceof Error ? err : new Error('Failed to open browser for authentication'));
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
try {
|
||||
const { code } = await Promise.race([callbackPromise, browserPromise]);
|
||||
|
||||
// Complete auth with the received code
|
||||
await manager.completePKCEAuth('onedrive', code, data.redirectUri);
|
||||
} finally {
|
||||
if (openTimer) clearTimeout(openTimer);
|
||||
}
|
||||
}
|
||||
|
||||
return data.url;
|
||||
}, []);
|
||||
|
||||
const completePKCEAuth = useCallback(async (
|
||||
provider: 'google' | 'onedrive',
|
||||
code: string,
|
||||
@@ -379,9 +611,16 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
}, []);
|
||||
|
||||
const cancelOAuthConnect = useCallback(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.cancelOAuthCallback?.();
|
||||
}, []);
|
||||
const githubAbort = activeGitHubAuthAbortRef.current;
|
||||
if (githubAbort) {
|
||||
manager.cancelProviderAuthAttempt('github', activeGitHubAuthAttemptIdRef.current ?? undefined);
|
||||
activeGitHubAuthAttemptIdRef.current = null;
|
||||
githubAbort.abort();
|
||||
return;
|
||||
}
|
||||
|
||||
void cancelActivePKCEAuth();
|
||||
}, [cancelActivePKCEAuth]);
|
||||
|
||||
// ========== Settings ==========
|
||||
|
||||
@@ -422,14 +661,14 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
throw new Error('Vault is locked');
|
||||
}, []);
|
||||
|
||||
const syncNowWithUnlock = useCallback(async (payload: SyncPayload) => {
|
||||
const syncNowWithUnlock = useCallback(async (payload: SyncPayload, opts?: { overrideShrink?: boolean }) => {
|
||||
await ensureUnlocked();
|
||||
return await manager.syncAllProviders(payload);
|
||||
return await manager.syncAllProviders(payload, opts);
|
||||
}, [ensureUnlocked]);
|
||||
|
||||
const syncToProviderWithUnlock = useCallback(async (provider: CloudProvider, payload: SyncPayload) => {
|
||||
const syncToProviderWithUnlock = useCallback(async (provider: CloudProvider, payload: SyncPayload, opts?: { overrideShrink?: boolean }) => {
|
||||
await ensureUnlocked();
|
||||
return await manager.syncToProvider(provider, payload);
|
||||
return await manager.syncToProvider(provider, payload, opts);
|
||||
}, [ensureUnlocked]);
|
||||
|
||||
const downloadFromProviderWithUnlock = useCallback(async (provider: CloudProvider) => {
|
||||
@@ -437,6 +676,16 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
return await manager.downloadFromProvider(provider);
|
||||
}, [ensureUnlocked]);
|
||||
|
||||
const subscribeToEvents = useCallback(
|
||||
(callback: SyncEventCallback) => manager.subscribe(callback),
|
||||
[],
|
||||
);
|
||||
|
||||
const getShrinkBlockedFinding = useCallback(
|
||||
() => manager.getShrinkBlockedFinding(),
|
||||
[],
|
||||
);
|
||||
|
||||
const resolveConflictWithUnlock = useCallback(async (resolution: ConflictResolution) => {
|
||||
await ensureUnlocked();
|
||||
return await manager.resolveConflict(resolution);
|
||||
@@ -459,6 +708,7 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
remoteVersion: state.remoteVersion,
|
||||
remoteUpdatedAt: state.remoteUpdatedAt,
|
||||
syncHistory: state.syncHistory,
|
||||
pendingBrowserAuthProvider: pendingBrowserAuth?.provider ?? null,
|
||||
|
||||
// Computed
|
||||
hasAnyConnectedProvider,
|
||||
@@ -505,6 +755,12 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
formatLastSync,
|
||||
getProviderDotColor,
|
||||
refresh,
|
||||
|
||||
// Event subscription
|
||||
subscribeToEvents,
|
||||
|
||||
// Shrink-block state query
|
||||
getShrinkBlockedFinding,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ interface HotkeyActions {
|
||||
openHosts: () => void;
|
||||
openSftp: () => void;
|
||||
quickSwitch: () => void;
|
||||
newWorkspace: () => void;
|
||||
commandPalette: () => void;
|
||||
portForwarding: () => void;
|
||||
snippets: () => void;
|
||||
@@ -61,6 +62,7 @@ export const getAppLevelActions = (): Set<string> => {
|
||||
'openHosts',
|
||||
'openSftp',
|
||||
'quickSwitch',
|
||||
'newWorkspace',
|
||||
'commandPalette',
|
||||
'portForwarding',
|
||||
'snippets',
|
||||
@@ -168,6 +170,9 @@ export const useGlobalHotkeys = ({
|
||||
case 'quickSwitch':
|
||||
currentActions.quickSwitch?.();
|
||||
break;
|
||||
case 'newWorkspace':
|
||||
currentActions.newWorkspace?.();
|
||||
break;
|
||||
case 'commandPalette':
|
||||
currentActions.commandPalette?.();
|
||||
break;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MouseEvent,useCallback,useMemo,useState } from 'react';
|
||||
import { MouseEvent,useCallback,useMemo,useRef,useState } from 'react';
|
||||
import { ConnectionLog,Host,SerialConfig,Snippet,TerminalSession,Workspace,WorkspaceViewMode } from '../../domain/models';
|
||||
import {
|
||||
appendPaneToWorkspaceRoot,
|
||||
collectSessionIds,
|
||||
createWorkspaceFromSessions as createWorkspaceEntity,
|
||||
createWorkspaceFromSessionIds,
|
||||
@@ -24,6 +25,12 @@ export interface LogView {
|
||||
export const useSessionState = () => {
|
||||
const [sessions, setSessions] = useState<TerminalSession[]>([]);
|
||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
||||
// Latest workspaces snapshot for synchronous existence checks outside
|
||||
// setWorkspaces updaters — React doesn't guarantee updaters run
|
||||
// synchronously, so relying on a flag flipped inside them to decide
|
||||
// whether to also call setSessions is racy and can leave orphan panes.
|
||||
const workspacesRef = useRef(workspaces);
|
||||
workspacesRef.current = workspaces;
|
||||
// activeTabId is now managed by external store - components subscribe directly
|
||||
const setActiveTabId = activeTabStore.setActiveTabId;
|
||||
const [draggingSessionId, setDraggingSessionId] = useState<string | null>(null);
|
||||
@@ -141,19 +148,48 @@ export const useSessionState = () => {
|
||||
setSessions(prev => prev.map(s => s.id === sessionId ? { ...s, status } : s));
|
||||
}, []);
|
||||
|
||||
const closeWorkspace = useCallback((workspaceId: string) => {
|
||||
setWorkspaces(prevWorkspaces => {
|
||||
const remainingWorkspaces = prevWorkspaces.filter(w => w.id !== workspaceId);
|
||||
|
||||
setSessions(prevSessions => prevSessions.filter(s => s.workspaceId !== workspaceId));
|
||||
|
||||
const currentActiveTabId = activeTabStore.getActiveTabId();
|
||||
if (currentActiveTabId === workspaceId) {
|
||||
if (remainingWorkspaces.length > 0) {
|
||||
setActiveTabId(remainingWorkspaces[remainingWorkspaces.length - 1].id);
|
||||
} else {
|
||||
setActiveTabId('vault');
|
||||
}
|
||||
}
|
||||
|
||||
return remainingWorkspaces;
|
||||
});
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const closeSession = useCallback((sessionId: string, e?: MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
|
||||
|
||||
// Pre-compute outside the setSessions updater so we don't depend on React
|
||||
// having run the updater by the time we queue the microtask. React 18+ does
|
||||
// not guarantee updater execution timing under concurrent scheduling.
|
||||
const sessionBeingClosed = sessions.find(s => s.id === sessionId);
|
||||
const workspaceIdToMaybeClose =
|
||||
sessionBeingClosed?.workspaceId &&
|
||||
sessions.every(s => s.id === sessionId || s.workspaceId !== sessionBeingClosed.workspaceId)
|
||||
? sessionBeingClosed.workspaceId
|
||||
: undefined;
|
||||
|
||||
setSessions(prevSessions => {
|
||||
const targetSession = prevSessions.find(s => s.id === sessionId);
|
||||
const wsId = targetSession?.workspaceId;
|
||||
|
||||
|
||||
setWorkspaces(prevWorkspaces => {
|
||||
let removedWorkspaceId: string | null = null;
|
||||
let nextWorkspaces = prevWorkspaces;
|
||||
let dissolvedWorkspaceId: string | null = null;
|
||||
let lastRemainingSessionId: string | null = null;
|
||||
|
||||
|
||||
if (wsId) {
|
||||
nextWorkspaces = prevWorkspaces
|
||||
.map(ws => {
|
||||
@@ -163,7 +199,7 @@ export const useSessionState = () => {
|
||||
removedWorkspaceId = ws.id;
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// Check if only 1 session remains - dissolve workspace
|
||||
const remainingSessionIds = collectSessionIds(pruned);
|
||||
if (remainingSessionIds.length === 1) {
|
||||
@@ -171,12 +207,12 @@ export const useSessionState = () => {
|
||||
lastRemainingSessionId = remainingSessionIds[0];
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
return { ...ws, root: pruned };
|
||||
})
|
||||
.filter((ws): ws is Workspace => Boolean(ws));
|
||||
}
|
||||
|
||||
|
||||
const remainingSessions = prevSessions.filter(s => s.id !== sessionId);
|
||||
const fallbackWorkspace = nextWorkspaces[nextWorkspaces.length - 1];
|
||||
const fallbackSolo = remainingSessions.filter(s => !s.workspaceId).slice(-1)[0];
|
||||
@@ -198,10 +234,10 @@ export const useSessionState = () => {
|
||||
} else if (wsId && currentActiveTabId === wsId && !nextWorkspaces.find(w => w.id === wsId)) {
|
||||
setActiveTabId(getFallback());
|
||||
}
|
||||
|
||||
|
||||
return nextWorkspaces;
|
||||
});
|
||||
|
||||
|
||||
// Check if we need to dissolve a workspace (convert remaining session to orphan)
|
||||
if (targetSession?.workspaceId) {
|
||||
const ws = workspaces.find(w => w.id === targetSession.workspaceId);
|
||||
@@ -218,29 +254,14 @@ export const useSessionState = () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return prevSessions.filter(s => s.id !== sessionId);
|
||||
});
|
||||
}, [workspaces, setActiveTabId]);
|
||||
|
||||
const closeWorkspace = useCallback((workspaceId: string) => {
|
||||
setWorkspaces(prevWorkspaces => {
|
||||
const remainingWorkspaces = prevWorkspaces.filter(w => w.id !== workspaceId);
|
||||
|
||||
setSessions(prevSessions => prevSessions.filter(s => s.workspaceId !== workspaceId));
|
||||
|
||||
const currentActiveTabId = activeTabStore.getActiveTabId();
|
||||
if (currentActiveTabId === workspaceId) {
|
||||
if (remainingWorkspaces.length > 0) {
|
||||
setActiveTabId(remainingWorkspaces[remainingWorkspaces.length - 1].id);
|
||||
} else {
|
||||
setActiveTabId('vault');
|
||||
}
|
||||
}
|
||||
|
||||
return remainingWorkspaces;
|
||||
});
|
||||
}, [setActiveTabId]);
|
||||
return prevSessions.filter(s => s.id !== sessionId);
|
||||
});
|
||||
|
||||
if (workspaceIdToMaybeClose) {
|
||||
queueMicrotask(() => closeWorkspace(workspaceIdToMaybeClose!));
|
||||
}
|
||||
}, [sessions, workspaces, setActiveTabId, closeWorkspace]);
|
||||
|
||||
const startSessionRename = useCallback((sessionId: string) => {
|
||||
setSessions(prevSessions => {
|
||||
@@ -369,6 +390,89 @@ export const useSessionState = () => {
|
||||
setActiveTabId(workspace.id);
|
||||
}, [setActiveTabId]);
|
||||
|
||||
// Like createWorkspaceWithHosts but supports mixed targets — each
|
||||
// entry is either an SSH host or a local terminal. Used by the
|
||||
// "New Workspace" flow in QuickSwitcher.
|
||||
type WorkspaceTarget =
|
||||
| { kind: 'local'; shellType?: TerminalSession['shellType']; shell?: string; shellArgs?: string[]; shellName?: string; shellIcon?: string }
|
||||
| { kind: 'host'; host: Host };
|
||||
|
||||
const createWorkspaceFromTargets = useCallback((targets: WorkspaceTarget[], name: string = 'Workspace'): string | null => {
|
||||
if (targets.length === 0) return null;
|
||||
|
||||
const newSessions: TerminalSession[] = targets.map((target) => {
|
||||
if (target.kind === 'local') {
|
||||
const sessionId = crypto.randomUUID();
|
||||
return {
|
||||
id: sessionId,
|
||||
hostId: `local-${sessionId}`,
|
||||
hostLabel: target.shellName || 'Local Terminal',
|
||||
hostname: 'localhost',
|
||||
username: 'local',
|
||||
status: 'connecting',
|
||||
protocol: 'local',
|
||||
shellType: target.shellType,
|
||||
localShell: target.shell,
|
||||
localShellArgs: target.shellArgs,
|
||||
localShellName: target.shellName,
|
||||
localShellIcon: target.shellIcon,
|
||||
};
|
||||
}
|
||||
const host = target.host;
|
||||
if (host.protocol === 'serial') {
|
||||
const serialConfig: SerialConfig = host.serialConfig || {
|
||||
path: host.hostname,
|
||||
baudRate: host.port || 115200,
|
||||
dataBits: 8,
|
||||
stopBits: 1,
|
||||
parity: 'none',
|
||||
flowControl: 'none',
|
||||
localEcho: false,
|
||||
lineMode: false,
|
||||
};
|
||||
const portName = serialConfig.path.split('/').pop() || serialConfig.path;
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
hostId: host.id,
|
||||
hostLabel: host.label || `Serial: ${portName}`,
|
||||
hostname: serialConfig.path,
|
||||
username: '',
|
||||
status: 'connecting',
|
||||
protocol: 'serial',
|
||||
serialConfig,
|
||||
charset: host.charset,
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
hostId: host.id,
|
||||
hostLabel: host.label,
|
||||
hostname: host.hostname,
|
||||
username: host.username,
|
||||
status: 'connecting',
|
||||
protocol: host.protocol,
|
||||
port: host.port,
|
||||
moshEnabled: host.moshEnabled,
|
||||
charset: host.charset,
|
||||
};
|
||||
});
|
||||
|
||||
const sessionIds = newSessions.map((s) => s.id);
|
||||
// Default to focus-mode (sidebar layout) regardless of target
|
||||
// count — matches the intent behind the QuickSwitcher "New
|
||||
// Workspace" flow, which the user expects to land in focus view.
|
||||
const workspace = createWorkspaceFromSessionIds(sessionIds, {
|
||||
title: name,
|
||||
viewMode: 'focus',
|
||||
});
|
||||
const sessionsWithWorkspace = newSessions.map((s) => ({ ...s, workspaceId: workspace.id }));
|
||||
|
||||
setSessions((prev) => [...prev, ...sessionsWithWorkspace]);
|
||||
setWorkspaces((prev) => [...prev, workspace]);
|
||||
setActiveTabId(workspace.id);
|
||||
return workspace.id;
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const createWorkspaceFromSessions = useCallback((
|
||||
baseSessionId: string,
|
||||
joiningSessionId: string,
|
||||
@@ -420,6 +524,118 @@ export const useSessionState = () => {
|
||||
});
|
||||
}, [setActiveTabId]);
|
||||
|
||||
// Add a host into an existing workspace by creating a new session for
|
||||
// that host and appending it as the last pane at the workspace root.
|
||||
// Sibling sizes are rebalanced equally by appendPaneToWorkspaceRoot.
|
||||
// Unlike addSessionToWorkspace (which takes a pre-created orphan
|
||||
// session and a SplitHint), this is atomic — the new session is born
|
||||
// already bound to the target workspace and focused.
|
||||
const appendHostToWorkspace = useCallback((
|
||||
workspaceId: string,
|
||||
host: Host,
|
||||
direction: SplitDirection = 'vertical',
|
||||
): string | null => {
|
||||
// Serial hosts use a different session constructor; they currently
|
||||
// only enter workspaces via createSerialSession + drag, so reject
|
||||
// them here to avoid a partially-constructed session.
|
||||
if (host.protocol === 'serial') return null;
|
||||
|
||||
// Cheap early-exit using the ref when the workspace is clearly
|
||||
// absent. The authoritative check lives inside the setWorkspaces
|
||||
// updater below so we also cover the concurrent-close race.
|
||||
if (!workspacesRef.current.some(w => w.id === workspaceId)) return null;
|
||||
|
||||
const newSessionId = crypto.randomUUID();
|
||||
const newSession: TerminalSession = {
|
||||
id: newSessionId,
|
||||
hostId: host.id,
|
||||
hostLabel: host.label,
|
||||
hostname: host.hostname,
|
||||
username: host.username,
|
||||
status: 'connecting',
|
||||
protocol: host.protocol,
|
||||
port: host.port,
|
||||
moshEnabled: host.moshEnabled,
|
||||
charset: host.charset,
|
||||
workspaceId,
|
||||
};
|
||||
|
||||
// Nest setSessions + setActiveTabId inside the setWorkspaces updater
|
||||
// so we only commit the session when the workspace update actually
|
||||
// matched — otherwise a concurrent closeWorkspace between the ref
|
||||
// check and the updater firing would leave an orphan session with a
|
||||
// workspaceId pointing at nothing, and active tab would jump to a
|
||||
// closed id. The inner setSessions is idempotent (id dedupe) so
|
||||
// StrictMode's dev-time double-invoke does not duplicate the row.
|
||||
setWorkspaces(prev => {
|
||||
const target = prev.find(w => w.id === workspaceId);
|
||||
if (!target) return prev;
|
||||
setSessions(s => s.some(x => x.id === newSessionId) ? s : [...s, newSession]);
|
||||
setActiveTabId(workspaceId);
|
||||
return prev.map(ws => {
|
||||
if (ws.id !== workspaceId) return ws;
|
||||
return {
|
||||
...ws,
|
||||
root: appendPaneToWorkspaceRoot(ws.root, newSessionId, direction),
|
||||
focusedSessionId: newSessionId,
|
||||
};
|
||||
});
|
||||
});
|
||||
return newSessionId;
|
||||
}, [setActiveTabId]);
|
||||
|
||||
// Atomic "append a local terminal pane" — mirror of appendHostToWorkspace
|
||||
// but constructs a local-protocol session instead of an SSH one.
|
||||
const appendLocalTerminalToWorkspace = useCallback((
|
||||
workspaceId: string,
|
||||
options?: {
|
||||
shellType?: TerminalSession['shellType'];
|
||||
shell?: string;
|
||||
shellArgs?: string[];
|
||||
shellName?: string;
|
||||
shellIcon?: string;
|
||||
},
|
||||
direction: SplitDirection = 'vertical',
|
||||
): string | null => {
|
||||
// Same pattern as appendHostToWorkspace — ref guard + authoritative
|
||||
// inside-updater match to cover concurrent closeWorkspace.
|
||||
if (!workspacesRef.current.some(w => w.id === workspaceId)) return null;
|
||||
|
||||
const newSessionId = crypto.randomUUID();
|
||||
const localHostId = `local-${newSessionId}`;
|
||||
const newSession: TerminalSession = {
|
||||
id: newSessionId,
|
||||
hostId: localHostId,
|
||||
hostLabel: options?.shellName || 'Local Terminal',
|
||||
hostname: 'localhost',
|
||||
username: 'local',
|
||||
status: 'connecting',
|
||||
protocol: 'local',
|
||||
shellType: options?.shellType,
|
||||
localShell: options?.shell,
|
||||
localShellArgs: options?.shellArgs,
|
||||
localShellName: options?.shellName,
|
||||
localShellIcon: options?.shellIcon,
|
||||
workspaceId,
|
||||
};
|
||||
|
||||
setWorkspaces(prev => {
|
||||
const target = prev.find(w => w.id === workspaceId);
|
||||
if (!target) return prev;
|
||||
setSessions(s => s.some(x => x.id === newSessionId) ? s : [...s, newSession]);
|
||||
setActiveTabId(workspaceId);
|
||||
return prev.map(ws => {
|
||||
if (ws.id !== workspaceId) return ws;
|
||||
return {
|
||||
...ws,
|
||||
root: appendPaneToWorkspaceRoot(ws.root, newSessionId, direction),
|
||||
focusedSessionId: newSessionId,
|
||||
};
|
||||
});
|
||||
});
|
||||
return newSessionId;
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const updateSplitSizes = useCallback((workspaceId: string, splitId: string, sizes: number[]) => {
|
||||
setWorkspaces(prev => prev.map(ws => {
|
||||
if (ws.id !== workspaceId) return ws;
|
||||
@@ -654,16 +870,22 @@ export const useSessionState = () => {
|
||||
const copySession = useCallback((sessionId: string, options?: {
|
||||
localShellType?: TerminalSession['shellType'];
|
||||
}) => {
|
||||
// Pre-allocate the new id outside the updater so StrictMode's
|
||||
// double-invocation of the functional updater doesn't mint two ids.
|
||||
const newSessionId = crypto.randomUUID();
|
||||
|
||||
setSessions(prevSessions => {
|
||||
const session = prevSessions.find(s => s.id === sessionId);
|
||||
// Source may have been closed between the user's action and this
|
||||
// update running; in that case skip entirely — do NOT switch the
|
||||
// active tab or insert into tabOrder, which would leave dangling ids.
|
||||
if (!session) return prevSessions;
|
||||
const nextShellType = session.protocol === 'local'
|
||||
? options?.localShellType
|
||||
: session.shellType;
|
||||
|
||||
// Create a new session with the same connection info
|
||||
const newSession: TerminalSession = {
|
||||
id: crypto.randomUUID(),
|
||||
id: newSessionId,
|
||||
hostId: session.hostId,
|
||||
hostLabel: session.hostLabel,
|
||||
hostname: session.hostname,
|
||||
@@ -681,10 +903,40 @@ export const useSessionState = () => {
|
||||
localShellIcon: session.localShellIcon,
|
||||
};
|
||||
|
||||
setActiveTabId(newSession.id);
|
||||
// Schedule the activeTab + tabOrder updates only when creation
|
||||
// actually happens. These nested setStates are idempotent, so
|
||||
// StrictMode's double-invocation is harmless.
|
||||
setActiveTabId(newSessionId);
|
||||
setTabOrder(prevTabOrder => {
|
||||
// Fast path: source is already tracked in tabOrder — splice directly.
|
||||
const directIdx = prevTabOrder.indexOf(sessionId);
|
||||
if (directIdx !== -1) {
|
||||
const next = [...prevTabOrder];
|
||||
next.splice(directIdx + 1, 0, newSessionId);
|
||||
return next;
|
||||
}
|
||||
// Fallback: source is only in the derived tab collections. Rebuild the
|
||||
// effective order (same pattern as reorderTabs) to locate its position.
|
||||
const allTabIds = [
|
||||
...orphanSessions.map(s => s.id),
|
||||
...workspaces.map(w => w.id),
|
||||
...logViews.map(lv => lv.id),
|
||||
];
|
||||
const allTabIdSet = new Set(allTabIds);
|
||||
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 sourceIdx = currentOrder.indexOf(sessionId);
|
||||
if (sourceIdx === -1) return [...prevTabOrder, newSessionId];
|
||||
const next = [...currentOrder];
|
||||
next.splice(sourceIdx + 1, 0, newSessionId);
|
||||
return next;
|
||||
});
|
||||
|
||||
return [...prevSessions, newSession];
|
||||
});
|
||||
}, [setActiveTabId]);
|
||||
}, [orphanSessions, workspaces, logViews, setActiveTabId]);
|
||||
|
||||
// Toggle broadcast mode for a workspace
|
||||
const toggleBroadcast = useCallback((workspaceId: string) => {
|
||||
@@ -788,8 +1040,11 @@ export const useSessionState = () => {
|
||||
closeWorkspace,
|
||||
updateSessionStatus,
|
||||
createWorkspaceWithHosts,
|
||||
createWorkspaceFromTargets,
|
||||
createWorkspaceFromSessions,
|
||||
addSessionToWorkspace,
|
||||
appendHostToWorkspace,
|
||||
appendLocalTerminalToWorkspace,
|
||||
updateSplitSizes,
|
||||
splitSession,
|
||||
toggleWorkspaceViewMode,
|
||||
|
||||
@@ -40,6 +40,15 @@ import {
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
|
||||
import {
|
||||
areCustomKeyBindingsEqual,
|
||||
nextCustomKeyBindingsSyncVersion,
|
||||
parseCustomKeyBindingsStorageRecord,
|
||||
resetCustomKeyBinding,
|
||||
serializeCustomKeyBindingsStorageRecord,
|
||||
shouldApplyIncomingCustomKeyBindingsRecord,
|
||||
updateCustomKeyBinding as updateCustomKeyBindingRecord,
|
||||
} from '../../domain/customKeyBindings';
|
||||
import { getTerminalThemeForUiTheme } from '../../domain/terminalAppearance';
|
||||
import { customThemeStore, useCustomThemes } from '../state/customThemeStore';
|
||||
import { DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
|
||||
@@ -124,6 +133,14 @@ const serializeTerminalSettings = (settings: TerminalSettings): string =>
|
||||
const areTerminalSettingsEqual = (a: TerminalSettings, b: TerminalSettings): boolean =>
|
||||
serializeTerminalSettings(a) === serializeTerminalSettings(b);
|
||||
|
||||
const createCustomKeyBindingsSyncOrigin = (): string => {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
};
|
||||
|
||||
const applyThemeTokens = (
|
||||
themeSource: 'light' | 'dark' | 'system',
|
||||
resolvedTheme: 'light' | 'dark',
|
||||
@@ -169,6 +186,8 @@ const applyThemeTokens = (
|
||||
};
|
||||
|
||||
export const useSettingsState = () => {
|
||||
const initialCustomKeyBindingsRecord =
|
||||
parseCustomKeyBindingsStorageRecord(localStorageAdapter.readString(STORAGE_KEY_CUSTOM_KEY_BINDINGS));
|
||||
const uiFontsLoaded = useUIFontsLoaded();
|
||||
const [theme, setTheme] = useState<'dark' | 'light' | 'system'>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_THEME);
|
||||
@@ -231,8 +250,8 @@ export const useSettingsState = () => {
|
||||
}
|
||||
return DEFAULT_HOTKEY_SCHEME;
|
||||
});
|
||||
const [customKeyBindings, setCustomKeyBindings] = useState<CustomKeyBindings>(() =>
|
||||
localStorageAdapter.read<CustomKeyBindings>(STORAGE_KEY_CUSTOM_KEY_BINDINGS) || {}
|
||||
const [customKeyBindings, setCustomKeyBindingsState] = useState<CustomKeyBindings>(() =>
|
||||
initialCustomKeyBindingsRecord?.bindings || {}
|
||||
);
|
||||
const [isHotkeyRecording, setIsHotkeyRecordingState] = useState(false);
|
||||
const [customCSS, setCustomCSS] = useState<string>(() =>
|
||||
@@ -330,6 +349,10 @@ export const useSettingsState = () => {
|
||||
const incomingTerminalSettingsSignatureRef = useRef<string | null>(null);
|
||||
const localTerminalSettingsVersionRef = useRef(0);
|
||||
const broadcastedLocalTerminalSettingsVersionRef = useRef(0);
|
||||
const customKeyBindingsVersionRef = useRef(initialCustomKeyBindingsRecord?.version || 0);
|
||||
const customKeyBindingsOriginRef = useRef(initialCustomKeyBindingsRecord?.origin || 'legacy');
|
||||
const customKeyBindingsLocalOriginRef = useRef(createCustomKeyBindingsSyncOrigin());
|
||||
const customKeyBindingsMutationSourceRef = useRef<'local' | 'incoming'>('local');
|
||||
|
||||
// Fix 1: Mount guard — skip redundant IPC broadcasts & localStorage writes on initial mount.
|
||||
// Set to true by the LAST useEffect declaration; all persist effects see false on first render.
|
||||
@@ -361,6 +384,51 @@ export const useSettingsState = () => {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setCustomKeyBindings = useCallback((nextValue: SetStateAction<CustomKeyBindings>) => {
|
||||
setCustomKeyBindingsState((prev) => {
|
||||
const candidate = typeof nextValue === 'function'
|
||||
? (nextValue as (prevState: CustomKeyBindings) => CustomKeyBindings)(prev)
|
||||
: nextValue;
|
||||
if (areCustomKeyBindingsEqual(prev, candidate)) {
|
||||
return prev;
|
||||
}
|
||||
customKeyBindingsVersionRef.current = nextCustomKeyBindingsSyncVersion(
|
||||
customKeyBindingsVersionRef.current,
|
||||
);
|
||||
customKeyBindingsOriginRef.current = customKeyBindingsLocalOriginRef.current;
|
||||
customKeyBindingsMutationSourceRef.current = 'local';
|
||||
return candidate;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const applyIncomingCustomKeyBindings = useCallback((incoming: {
|
||||
bindings: CustomKeyBindings;
|
||||
version: number;
|
||||
origin: string;
|
||||
}) => {
|
||||
setCustomKeyBindingsState((prev) => {
|
||||
if (!shouldApplyIncomingCustomKeyBindingsRecord(
|
||||
{
|
||||
version: customKeyBindingsVersionRef.current,
|
||||
origin: customKeyBindingsOriginRef.current,
|
||||
},
|
||||
{
|
||||
version: incoming.version,
|
||||
origin: incoming.origin,
|
||||
},
|
||||
)) {
|
||||
return prev;
|
||||
}
|
||||
customKeyBindingsVersionRef.current = incoming.version;
|
||||
customKeyBindingsOriginRef.current = incoming.origin;
|
||||
customKeyBindingsMutationSourceRef.current = 'incoming';
|
||||
if (areCustomKeyBindingsEqual(prev, incoming.bindings)) {
|
||||
return prev;
|
||||
}
|
||||
return incoming.bindings;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Helper to notify other windows about settings changes via IPC
|
||||
const notifySettingsChanged = useCallback((key: string, value: unknown) => {
|
||||
try {
|
||||
@@ -456,11 +524,11 @@ export const useSettingsState = () => {
|
||||
}
|
||||
|
||||
// Keyboard
|
||||
const storedKb = readStoredString(STORAGE_KEY_CUSTOM_KEY_BINDINGS);
|
||||
const storedKb = parseCustomKeyBindingsStorageRecord(
|
||||
localStorageAdapter.readString(STORAGE_KEY_CUSTOM_KEY_BINDINGS),
|
||||
);
|
||||
if (storedKb) {
|
||||
try {
|
||||
setCustomKeyBindings(JSON.parse(storedKb));
|
||||
} catch { /* ignore */ }
|
||||
applyIncomingCustomKeyBindings(storedKb);
|
||||
}
|
||||
|
||||
// Editor
|
||||
@@ -493,7 +561,7 @@ export const useSettingsState = () => {
|
||||
|
||||
// Custom terminal themes
|
||||
customThemeStore.loadFromStorage();
|
||||
}, [syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings]);
|
||||
}, [applyIncomingCustomKeyBindings, syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
|
||||
@@ -616,14 +684,9 @@ export const useSettingsState = () => {
|
||||
setHotkeyScheme(value);
|
||||
}
|
||||
if (key === STORAGE_KEY_CUSTOM_KEY_BINDINGS) {
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
setCustomKeyBindings(JSON.parse(value) as CustomKeyBindings);
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
} else if (value && typeof value === 'object') {
|
||||
setCustomKeyBindings(value as CustomKeyBindings);
|
||||
const parsed = parseCustomKeyBindingsStorageRecord(value);
|
||||
if (parsed) {
|
||||
applyIncomingCustomKeyBindings(parsed);
|
||||
}
|
||||
}
|
||||
if (key === STORAGE_KEY_HOTKEY_RECORDING && typeof value === 'boolean') {
|
||||
@@ -657,7 +720,7 @@ export const useSettingsState = () => {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
}, [mergeIncomingTerminalSettings, syncAppearanceFromStorage, syncCustomCssFromStorage]);
|
||||
}, [applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings, syncAppearanceFromStorage, syncCustomCssFromStorage]);
|
||||
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -752,11 +815,9 @@ export const useSettingsState = () => {
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_CUSTOM_KEY_BINDINGS && e.newValue) {
|
||||
try {
|
||||
const newBindings = JSON.parse(e.newValue) as CustomKeyBindings;
|
||||
setCustomKeyBindings(newBindings);
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
const parsed = parseCustomKeyBindingsStorageRecord(e.newValue);
|
||||
if (parsed) {
|
||||
applyIncomingCustomKeyBindings(parsed);
|
||||
}
|
||||
}
|
||||
// Sync terminal settings from other windows
|
||||
@@ -908,7 +969,7 @@ export const useSettingsState = () => {
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
return () => window.removeEventListener('storage', handleStorageChange);
|
||||
}, [mergeIncomingTerminalSettings]); // Fix 4: stable deps only — state comparisons use settingsSnapshotRef
|
||||
}, [applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings]); // Fix 4: stable deps only — state comparisons use settingsSnapshotRef
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
|
||||
@@ -956,9 +1017,21 @@ export const useSettingsState = () => {
|
||||
}, [hotkeyScheme, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.write(STORAGE_KEY_CUSTOM_KEY_BINDINGS, customKeyBindings);
|
||||
const payload = serializeCustomKeyBindingsStorageRecord({
|
||||
version: customKeyBindingsVersionRef.current,
|
||||
origin: customKeyBindingsOriginRef.current,
|
||||
bindings: customKeyBindings,
|
||||
});
|
||||
if (localStorageAdapter.readString(STORAGE_KEY_CUSTOM_KEY_BINDINGS) !== payload) {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_KEY_BINDINGS, payload);
|
||||
}
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_CUSTOM_KEY_BINDINGS, customKeyBindings);
|
||||
if (customKeyBindingsMutationSourceRef.current === 'incoming') return;
|
||||
notifySettingsChanged(STORAGE_KEY_CUSTOM_KEY_BINDINGS, {
|
||||
version: customKeyBindingsVersionRef.current,
|
||||
origin: customKeyBindingsOriginRef.current,
|
||||
bindings: customKeyBindings,
|
||||
});
|
||||
}, [customKeyBindings, notifySettingsChanged]);
|
||||
|
||||
const setIsHotkeyRecording = useCallback((isRecording: boolean) => {
|
||||
@@ -1170,37 +1243,18 @@ export const useSettingsState = () => {
|
||||
|
||||
// Update a single key binding
|
||||
const updateKeyBinding = useCallback((bindingId: string, scheme: 'mac' | 'pc', newKey: string) => {
|
||||
setCustomKeyBindings(prev => ({
|
||||
...prev,
|
||||
[bindingId]: {
|
||||
...prev[bindingId],
|
||||
[scheme]: newKey,
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
setCustomKeyBindings(prev => updateCustomKeyBindingRecord(prev, bindingId, scheme, newKey));
|
||||
}, [setCustomKeyBindings]);
|
||||
|
||||
// Reset a key binding to default
|
||||
const resetKeyBinding = useCallback((bindingId: string, scheme?: 'mac' | 'pc') => {
|
||||
setCustomKeyBindings(prev => {
|
||||
const next = { ...prev };
|
||||
if (scheme) {
|
||||
if (next[bindingId]) {
|
||||
delete next[bindingId][scheme];
|
||||
if (Object.keys(next[bindingId]).length === 0) {
|
||||
delete next[bindingId];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
delete next[bindingId];
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
setCustomKeyBindings(prev => resetCustomKeyBinding(prev, bindingId, scheme));
|
||||
}, [setCustomKeyBindings]);
|
||||
|
||||
// Reset all key bindings to defaults
|
||||
const resetAllKeyBindings = useCallback(() => {
|
||||
setCustomKeyBindings({});
|
||||
}, []);
|
||||
}, [setCustomKeyBindings]);
|
||||
|
||||
const updateSyncConfig = useCallback((config: SyncConfig | null) => {
|
||||
setSyncConfig(config);
|
||||
|
||||
@@ -301,6 +301,7 @@ export const useSftpState = (
|
||||
readTextFile,
|
||||
readBinaryFile,
|
||||
writeTextFile,
|
||||
writeTextFileByConnection,
|
||||
downloadToTempAndOpen,
|
||||
uploadExternalFiles,
|
||||
uploadExternalEntries,
|
||||
@@ -309,6 +310,7 @@ export const useSftpState = (
|
||||
activeFileWatchCountRef,
|
||||
} = useSftpExternalOperations({
|
||||
getActivePane,
|
||||
getPaneByConnectionId,
|
||||
refresh,
|
||||
sftpSessionsRef,
|
||||
connectionCacheKeyMapRef,
|
||||
@@ -359,6 +361,7 @@ export const useSftpState = (
|
||||
readTextFile,
|
||||
readBinaryFile,
|
||||
writeTextFile,
|
||||
writeTextFileByConnection,
|
||||
downloadToTempAndOpen,
|
||||
uploadExternalFiles,
|
||||
uploadExternalEntries,
|
||||
@@ -413,6 +416,7 @@ export const useSftpState = (
|
||||
readTextFile,
|
||||
readBinaryFile,
|
||||
writeTextFile,
|
||||
writeTextFileByConnection,
|
||||
downloadToTempAndOpen,
|
||||
uploadExternalFiles,
|
||||
uploadExternalEntries,
|
||||
@@ -476,6 +480,8 @@ export const useSftpState = (
|
||||
readTextFile: (...args: Parameters<typeof readTextFile>) => methodsRef.current.readTextFile(...args),
|
||||
readBinaryFile: (...args: Parameters<typeof readBinaryFile>) => methodsRef.current.readBinaryFile(...args),
|
||||
writeTextFile: (...args: Parameters<typeof writeTextFile>) => methodsRef.current.writeTextFile(...args),
|
||||
writeTextFileByConnection: (...args: Parameters<typeof writeTextFileByConnection>) =>
|
||||
methodsRef.current.writeTextFileByConnection(...args),
|
||||
downloadToTempAndOpen: (...args: Parameters<typeof downloadToTempAndOpen>) => methodsRef.current.downloadToTempAndOpen(...args),
|
||||
uploadExternalFiles: (...args: Parameters<typeof uploadExternalFiles>) => methodsRef.current.uploadExternalFiles(...args),
|
||||
uploadExternalEntries: (...args: Parameters<typeof uploadExternalEntries>) =>
|
||||
|
||||
@@ -18,6 +18,11 @@ import type {
|
||||
SSHKey,
|
||||
} from '../domain/models';
|
||||
import type { SyncPayload } from '../domain/sync';
|
||||
import {
|
||||
nextCustomKeyBindingsSyncVersion,
|
||||
parseCustomKeyBindingsStorageRecord,
|
||||
serializeCustomKeyBindingsStorageRecord,
|
||||
} from '../domain/customKeyBindings';
|
||||
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
|
||||
import { rehydrateGlobalBookmarks } from '../components/sftp/hooks/useGlobalSftpBookmarks';
|
||||
import {
|
||||
@@ -51,6 +56,8 @@ import {
|
||||
// Input types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CUSTOM_KEY_BINDINGS_SYNC_PAYLOAD_ORIGIN = 'sync-payload';
|
||||
|
||||
/** All vault-owned data that participates in cloud sync. */
|
||||
export interface SyncableVaultData {
|
||||
hosts: Host[];
|
||||
@@ -108,7 +115,8 @@ const SYNCABLE_TERMINAL_KEYS = [
|
||||
'smoothScrolling',
|
||||
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
|
||||
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
|
||||
'keepaliveInterval', 'disableBracketedPaste', 'osc52Clipboard',
|
||||
'keepaliveInterval', 'disableBracketedPaste', 'clearWipesScrollback',
|
||||
'preserveSelectionOnInput', 'osc52Clipboard',
|
||||
'autocompleteEnabled', 'autocompleteGhostText', 'autocompletePopupMenu',
|
||||
'autocompleteDebounceMs', 'autocompleteMinChars', 'autocompleteMaxSuggestions',
|
||||
] as const;
|
||||
@@ -170,9 +178,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
// Keyboard
|
||||
const kb = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_KEY_BINDINGS);
|
||||
if (kb) {
|
||||
try {
|
||||
settings.customKeyBindings = JSON.parse(kb);
|
||||
} catch { /* ignore */ }
|
||||
const parsed = parseCustomKeyBindingsStorageRecord(kb);
|
||||
if (parsed) settings.customKeyBindings = parsed.bindings;
|
||||
}
|
||||
|
||||
// Editor
|
||||
@@ -249,7 +256,17 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
|
||||
|
||||
// Keyboard
|
||||
if (settings.customKeyBindings != null) {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_KEY_BINDINGS, JSON.stringify(settings.customKeyBindings));
|
||||
const previous = parseCustomKeyBindingsStorageRecord(
|
||||
localStorageAdapter.readString(STORAGE_KEY_CUSTOM_KEY_BINDINGS),
|
||||
);
|
||||
localStorageAdapter.writeString(
|
||||
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
|
||||
serializeCustomKeyBindingsStorageRecord({
|
||||
version: nextCustomKeyBindingsSyncVersion(previous?.version || 0),
|
||||
origin: CUSTOM_KEY_BINDINGS_SYNC_PAYLOAD_ORIGIN,
|
||||
bindings: settings.customKeyBindings,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Editor
|
||||
|
||||
@@ -47,17 +47,25 @@ import {
|
||||
type UserSkillOption,
|
||||
} from './ai/userSkillsState';
|
||||
import {
|
||||
applyDraftEntrySelection,
|
||||
applyHistorySessionSelection,
|
||||
resolveDisplayedPanelView,
|
||||
resolveDisplayedSession,
|
||||
shouldRetargetSessionForScope,
|
||||
} from './ai/aiPanelViewState';
|
||||
import {
|
||||
endDraftSend,
|
||||
tryBeginDraftSend,
|
||||
} from './ai/draftSendGate';
|
||||
import { getSessionScopeMatchRank } from './ai/sessionScopeMatch';
|
||||
import { SESSION_HISTORY_ROW_CLASSNAMES } from './ai/sessionHistoryLayout';
|
||||
import { selectDraftForAgentSwitch } from '../application/state/aiDraftState';
|
||||
import type { CodexIntegrationStatus } from './settings/tabs/ai/types';
|
||||
import {
|
||||
useAIChatStreaming,
|
||||
getNetcattyBridge,
|
||||
type DefaultTargetSessionHint,
|
||||
} from './ai/hooks/useAIChatStreaming';
|
||||
import { buildAcpHistoryMessagesForBridge } from './ai/acpHistory';
|
||||
import { clearAllPendingApprovals } from '../infrastructure/ai/shared/approvalGate';
|
||||
import { useConversationExport } from './ai/hooks/useConversationExport';
|
||||
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
|
||||
@@ -102,7 +110,6 @@ 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,
|
||||
@@ -172,56 +179,6 @@ function generateId(): string {
|
||||
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function buildAcpHistoryMessages(messages: ChatMessage[]): Array<{ role: 'user' | 'assistant'; content: string }> {
|
||||
return messages.flatMap((message): Array<{ role: 'user' | 'assistant'; content: string }> => {
|
||||
if (message.role === 'system') return [];
|
||||
|
||||
if (message.role === 'user') {
|
||||
return message.content ? [{ role: 'user', content: message.content }] : [];
|
||||
}
|
||||
|
||||
if (message.role === 'assistant') {
|
||||
const parts: string[] = [];
|
||||
if (message.content) parts.push(message.content);
|
||||
if (message.toolCalls?.length) {
|
||||
parts.push(...message.toolCalls.map((tc) => `Tool call: ${tc.name}(${JSON.stringify(tc.arguments ?? {})})`));
|
||||
}
|
||||
if (!parts.length) return [];
|
||||
return [{ role: 'assistant', content: parts.join('\n\n') }];
|
||||
}
|
||||
|
||||
if (message.role === 'tool' && message.toolResults?.length) {
|
||||
return message.toolResults.map((tr) => ({
|
||||
role: 'assistant',
|
||||
content: `Tool result:\n${tr.content}`,
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
// -------------------------------------------------------------------
|
||||
@@ -243,7 +200,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
deleteSession,
|
||||
updateSessionTitle,
|
||||
updateSessionExternalSessionId,
|
||||
retargetSessionScope,
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
updateMessageById,
|
||||
@@ -302,36 +258,42 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
setActiveSessionIdForScope(scopeKey, id);
|
||||
}, [scopeKey, setActiveSessionIdForScope]);
|
||||
|
||||
const activeTerminalTargetIds = useMemo(() => {
|
||||
const targetIds = new Set<string>();
|
||||
for (const [sessionScopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
|
||||
const activeTerminalSessionIds = useMemo(() => {
|
||||
const sessionIds = new Set<string>();
|
||||
const entries = Object.entries(activeSessionIdMap) as Array<[string, string | null]>;
|
||||
for (const [sessionScopeKey, sessionId] of entries) {
|
||||
if (!sessionScopeKey.startsWith('terminal:') || !sessionId) continue;
|
||||
const targetId = sessionScopeKey.slice('terminal:'.length);
|
||||
if (!targetId || targetId === scopeTargetId) continue;
|
||||
targetIds.add(targetId);
|
||||
if (sessionScopeKey === scopeKey) continue;
|
||||
sessionIds.add(sessionId);
|
||||
}
|
||||
return targetIds;
|
||||
}, [activeSessionIdMap, scopeTargetId]);
|
||||
return sessionIds;
|
||||
}, [activeSessionIdMap, scopeKey]);
|
||||
|
||||
const historySessions = useMemo(
|
||||
() =>
|
||||
sessions
|
||||
.map((session) => ({
|
||||
session,
|
||||
matchRank: getSessionScopeMatchRank(session, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds),
|
||||
matchRank: getSessionScopeMatchRank(
|
||||
session,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeHostIds,
|
||||
activeTerminalSessionIds,
|
||||
),
|
||||
}))
|
||||
.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],
|
||||
[sessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalSessionIds],
|
||||
);
|
||||
|
||||
const explicitPanelView = panelViewByScope[scopeKey];
|
||||
const currentDraft = draftsByScope[scopeKey] ?? null;
|
||||
const persistedSessionId = activeSessionIdMap[scopeKey] ?? null;
|
||||
const normalizedPanelView = useMemo<AIPanelView>(
|
||||
() => resolveDisplayedPanelView(explicitPanelView, currentDraft != null, historySessions, persistedSessionId),
|
||||
[explicitPanelView, currentDraft, historySessions, persistedSessionId],
|
||||
() => resolveDisplayedPanelView(explicitPanelView, currentDraft != null, historySessions, persistedSessionId, scopeType),
|
||||
[explicitPanelView, currentDraft, historySessions, persistedSessionId, scopeType],
|
||||
);
|
||||
const activeSession = useMemo(
|
||||
() => resolveDisplayedSession(normalizedPanelView, historySessions),
|
||||
@@ -348,6 +310,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
currentDraftRef.current = currentDraft;
|
||||
const activeSessionRef = useRef(activeSession);
|
||||
activeSessionRef.current = activeSession;
|
||||
const draftSendInFlightRef = useRef(false);
|
||||
|
||||
const defaultTargetSession = useMemo<DefaultTargetSessionHint | undefined>(() => {
|
||||
const connectedSessions = terminalSessions.filter((session) => session.connected !== false);
|
||||
@@ -385,40 +348,9 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
showDraftView(scopeKey);
|
||||
}, [normalizedPanelView, explicitPanelView, scopeKey, showDraftView]);
|
||||
|
||||
const shouldRetargetActiveSession = useMemo(() => {
|
||||
return shouldRetargetSessionForScope(
|
||||
activeSession,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeHostIds,
|
||||
activeTerminalTargetIds,
|
||||
);
|
||||
}, [activeSession, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSession) return;
|
||||
|
||||
if (shouldRetargetActiveSession && isVisible) {
|
||||
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 && activeSessionIdMap[scopeKey] !== activeSession.id) {
|
||||
setActiveSessionId(activeSession.id);
|
||||
}
|
||||
@@ -426,18 +358,22 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
activeSession,
|
||||
activeSessionIdMap,
|
||||
scopeKey,
|
||||
retargetSessionScope,
|
||||
isVisible,
|
||||
scopeHostIds,
|
||||
scopeTargetId,
|
||||
scopeType,
|
||||
setActiveSessionId,
|
||||
setStreamingForScope,
|
||||
shouldRetargetActiveSession,
|
||||
streamingSessionIds,
|
||||
abortControllersRef,
|
||||
]);
|
||||
|
||||
// When the resolved view is draft but activeSessionIdMap still points at a
|
||||
// previously-shown session, clear that stale entry. Otherwise
|
||||
// activeTerminalTargetIds keeps claiming ownership of the old session's
|
||||
// target and getSessionScopeMatchRank suppresses matching history from
|
||||
// other terminals until another action rewrites the map.
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
if (normalizedPanelView.mode !== 'draft') return;
|
||||
if (persistedSessionId == null) return;
|
||||
setActiveSessionId(null);
|
||||
}, [isVisible, normalizedPanelView.mode, persistedSessionId, setActiveSessionId]);
|
||||
|
||||
const ensureScopeDraft = useCallback((agentId: string) => {
|
||||
ensureDraftForScope(scopeKey, agentId);
|
||||
}, [ensureDraftForScope, scopeKey]);
|
||||
@@ -461,16 +397,26 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
clearDraftForScope(scopeKey);
|
||||
}, [clearDraftForScope, scopeKey]);
|
||||
|
||||
const enterScopeDraftMode = useCallback((agentId: string, preserveSessionView = false) => {
|
||||
applyDraftEntrySelection({
|
||||
ensureDraft: () => ensureScopeDraft(agentId),
|
||||
showDraftView: showScopeDraftView,
|
||||
preserveSessionView,
|
||||
});
|
||||
}, [ensureScopeDraft, showScopeDraftView]);
|
||||
|
||||
const setInputValue = useCallback((value: string) => {
|
||||
enterScopeDraftMode(currentAgentId, panelViewRef.current.mode === 'session');
|
||||
updateScopeDraft(currentAgentId, (draft) => ({
|
||||
...draft,
|
||||
text: value,
|
||||
}));
|
||||
}, [currentAgentId, updateScopeDraft]);
|
||||
}, [currentAgentId, enterScopeDraftMode, updateScopeDraft]);
|
||||
|
||||
const addFiles = useCallback(async (inputFiles: File[]) => {
|
||||
enterScopeDraftMode(currentAgentId, panelViewRef.current.mode === 'session');
|
||||
await addDraftFiles(scopeKey, currentAgentId, inputFiles);
|
||||
}, [addDraftFiles, scopeKey, currentAgentId]);
|
||||
}, [addDraftFiles, currentAgentId, enterScopeDraftMode, scopeKey]);
|
||||
|
||||
const removeFile = useCallback((fileId: string) => {
|
||||
removeDraftFile(scopeKey, currentAgentId, fileId);
|
||||
@@ -810,6 +756,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
const addSelectedUserSkill = useCallback((slug: string) => {
|
||||
const normalizedSlug = String(slug || '').trim().toLowerCase();
|
||||
if (!normalizedSlug) return;
|
||||
enterScopeDraftMode(currentAgentId, panelViewRef.current.mode === 'session');
|
||||
updateScopeDraft(currentAgentId, (draft) => {
|
||||
if (draft.selectedUserSkillSlugs.includes(normalizedSlug)) {
|
||||
return draft;
|
||||
@@ -819,11 +766,12 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
selectedUserSkillSlugs: [...draft.selectedUserSkillSlugs, normalizedSlug],
|
||||
};
|
||||
});
|
||||
}, [currentAgentId, updateScopeDraft]);
|
||||
}, [currentAgentId, enterScopeDraftMode, updateScopeDraft]);
|
||||
|
||||
const removeSelectedUserSkill = useCallback((slug: string) => {
|
||||
const normalizedSlug = String(slug || '').trim().toLowerCase();
|
||||
if (!normalizedSlug) return;
|
||||
enterScopeDraftMode(currentAgentId, panelViewRef.current.mode === 'session');
|
||||
updateScopeDraft(currentAgentId, (draft) => {
|
||||
const nextSelectedUserSkillSlugs = draft.selectedUserSkillSlugs.filter(
|
||||
(entry) => entry !== normalizedSlug,
|
||||
@@ -836,7 +784,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
selectedUserSkillSlugs: nextSelectedUserSkillSlugs,
|
||||
};
|
||||
});
|
||||
}, [currentAgentId, updateScopeDraft]);
|
||||
}, [currentAgentId, enterScopeDraftMode, updateScopeDraft]);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Main send handler (thin orchestrator)
|
||||
@@ -859,110 +807,121 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
filename: file.filename,
|
||||
filePath: file.filePath,
|
||||
}));
|
||||
const isDraftMode = currentPanelView.mode === 'draft';
|
||||
|
||||
let sessionId = currentSessionView?.id ?? null;
|
||||
let currentSession = currentSessionView ?? null;
|
||||
let sendAgentId = currentSessionView?.agentId ?? draft?.agentId ?? currentAgentId;
|
||||
|
||||
if (currentPanelView.mode === 'draft') {
|
||||
const scope: AISessionScope = { type: scopeType, targetId: scopeTargetId, hostIds: scopeHostIds };
|
||||
const createdSession = createSession(scope, sendAgentId);
|
||||
sessionId = createdSession.id;
|
||||
currentSession = createdSession;
|
||||
clearScopeDraft();
|
||||
showScopeSessionView(createdSession.id);
|
||||
setActiveSessionId(createdSession.id);
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
if (isDraftMode && !tryBeginDraftSend(draftSendInFlightRef)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isExternalAgent = sendAgentId !== 'catty';
|
||||
try {
|
||||
let sessionId = currentSessionView?.id ?? null;
|
||||
let currentSession = currentSessionView ?? null;
|
||||
const sendAgentId = currentSessionView?.agentId ?? draft?.agentId ?? currentAgentId;
|
||||
|
||||
// No provider configured for built-in agent
|
||||
if (!isExternalAgent && !activeProvider) {
|
||||
addMessageToSession(sessionId, { id: generateId(), role: 'user', content: trimmed, timestamp: Date.now() });
|
||||
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProvider'), timestamp: Date.now() });
|
||||
if (currentPanelView.mode === 'session') {
|
||||
if (isDraftMode) {
|
||||
const scope: AISessionScope = { type: scopeType, targetId: scopeTargetId, hostIds: scopeHostIds };
|
||||
const createdSession = createSession(scope, sendAgentId);
|
||||
sessionId = createdSession.id;
|
||||
currentSession = createdSession;
|
||||
clearScopeDraft();
|
||||
showScopeSessionView(sessionId);
|
||||
showScopeSessionView(createdSession.id);
|
||||
setActiveSessionId(createdSession.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Add user message
|
||||
addMessageToSession(sessionId, {
|
||||
id: generateId(), role: 'user', content: trimmed,
|
||||
...(attachments.length > 0 ? { attachments } : {}),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
clearScopeDraft();
|
||||
showScopeSessionView(sessionId);
|
||||
setActiveSessionId(sessionId);
|
||||
setStreamingForScope(sessionId, true);
|
||||
|
||||
// Create assistant message placeholder with a tracked ID
|
||||
const agentConfig = isExternalAgent ? externalAgents.find((agent) => agent.id === sendAgentId) : undefined;
|
||||
const assistantMsgId = generateId();
|
||||
addMessageToSession(sessionId, {
|
||||
id: assistantMsgId, role: 'assistant', content: '', timestamp: Date.now(),
|
||||
model: isExternalAgent
|
||||
? (selectedAgentModel || agentConfig?.name || 'external')
|
||||
: (activeModelId || activeProvider?.defaultModel || ''),
|
||||
providerId: isExternalAgent ? undefined : activeProvider?.providerId,
|
||||
});
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortControllersRef.current.set(sessionId, abortController);
|
||||
currentSession = currentSession ?? sessionsRef.current.find((session) => session.id === sessionId) ?? null;
|
||||
|
||||
if (isExternalAgent) {
|
||||
if (!agentConfig) {
|
||||
updateMessageById(sessionId, assistantMsgId, msg => ({ ...msg, content: 'External agent not found. Please check settings.', executionStatus: 'failed' }));
|
||||
setStreamingForScope(sessionId, false);
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await sendToExternalAgent(sessionId, trimmed, agentConfig, abortController, attachments, {
|
||||
existingSessionId: currentSession?.externalSessionId,
|
||||
updateExternalSessionId: updateSessionExternalSessionId,
|
||||
historyMessages: buildAcpHistoryMessages(currentSession?.messages ?? []),
|
||||
terminalSessions,
|
||||
defaultTargetSession,
|
||||
providers,
|
||||
selectedAgentModel,
|
||||
toolIntegrationMode,
|
||||
selectedUserSkillSlugs: selectedSkillSlugs,
|
||||
});
|
||||
} catch (err) {
|
||||
reportStreamError(sessionId, abortController.signal, err);
|
||||
|
||||
const isExternalAgent = sendAgentId !== 'catty';
|
||||
|
||||
// No provider configured for built-in agent
|
||||
if (!isExternalAgent && !activeProvider) {
|
||||
addMessageToSession(sessionId, { id: generateId(), role: 'user', content: trimmed, timestamp: Date.now() });
|
||||
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProvider'), timestamp: Date.now() });
|
||||
if (currentPanelView.mode === 'session') {
|
||||
clearScopeDraft();
|
||||
showScopeSessionView(sessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Add user message
|
||||
addMessageToSession(sessionId, {
|
||||
id: generateId(), role: 'user', content: trimmed,
|
||||
...(attachments.length > 0 ? { attachments } : {}),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
clearScopeDraft();
|
||||
showScopeSessionView(sessionId);
|
||||
setActiveSessionId(sessionId);
|
||||
setStreamingForScope(sessionId, true);
|
||||
|
||||
// Create assistant message placeholder with a tracked ID
|
||||
const agentConfig = isExternalAgent ? externalAgents.find((agent) => agent.id === sendAgentId) : undefined;
|
||||
const assistantMsgId = generateId();
|
||||
addMessageToSession(sessionId, {
|
||||
id: assistantMsgId, role: 'assistant', content: '', timestamp: Date.now(),
|
||||
model: isExternalAgent
|
||||
? (selectedAgentModel || agentConfig?.name || 'external')
|
||||
: (activeModelId || activeProvider?.defaultModel || ''),
|
||||
providerId: isExternalAgent ? undefined : activeProvider?.providerId,
|
||||
});
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortControllersRef.current.set(sessionId, abortController);
|
||||
currentSession = currentSession ?? sessionsRef.current.find((session) => session.id === sessionId) ?? null;
|
||||
|
||||
if (isExternalAgent) {
|
||||
if (!agentConfig) {
|
||||
updateMessageById(sessionId, assistantMsgId, msg => ({ ...msg, content: 'External agent not found. Please check settings.', executionStatus: 'failed' }));
|
||||
setStreamingForScope(sessionId, false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const existingExternalSessionId = currentSession?.externalSessionId;
|
||||
await sendToExternalAgent(sessionId, trimmed, agentConfig, abortController, attachments, {
|
||||
existingSessionId: existingExternalSessionId,
|
||||
updateExternalSessionId: updateSessionExternalSessionId,
|
||||
historyMessages: buildAcpHistoryMessagesForBridge(currentSession?.messages ?? [], existingExternalSessionId),
|
||||
terminalSessions,
|
||||
defaultTargetSession,
|
||||
providers,
|
||||
selectedAgentModel,
|
||||
toolIntegrationMode,
|
||||
selectedUserSkillSlugs: selectedSkillSlugs,
|
||||
});
|
||||
} catch (err) {
|
||||
reportStreamError(sessionId, abortController.signal, err);
|
||||
}
|
||||
updateLastMessage(sessionId, msg => msg.statusText ? { ...msg, statusText: '' } : msg);
|
||||
setStreamingForScope(sessionId, false);
|
||||
abortControllersRef.current.delete(sessionId);
|
||||
autoTitleSession(sessionId, trimmed);
|
||||
} else {
|
||||
const toolScope = {
|
||||
type: scopeType,
|
||||
targetId: scopeTargetId,
|
||||
label: scopeLabel,
|
||||
} as const;
|
||||
await sendToCattyAgent(sessionId, sendScopeKey, trimmed, abortController, currentSession ?? undefined, assistantMsgId, {
|
||||
activeProvider,
|
||||
activeModelId,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeLabel,
|
||||
globalPermissionMode,
|
||||
commandBlocklist,
|
||||
terminalSessions,
|
||||
webSearchConfig,
|
||||
getExecutorContext: () => buildExecutorContextForScope(toolScope),
|
||||
autoTitleSession,
|
||||
selectedUserSkillSlugs: selectedSkillSlugs,
|
||||
}, attachments.length > 0 ? attachments : undefined);
|
||||
}
|
||||
} finally {
|
||||
if (isDraftMode) {
|
||||
endDraftSend(draftSendInFlightRef);
|
||||
}
|
||||
// Clear any lingering statusText when the external agent stream finishes
|
||||
updateLastMessage(sessionId, msg => msg.statusText ? { ...msg, statusText: '' } : msg);
|
||||
setStreamingForScope(sessionId, false);
|
||||
abortControllersRef.current.delete(sessionId);
|
||||
autoTitleSession(sessionId, trimmed);
|
||||
} else {
|
||||
const toolScope = {
|
||||
type: scopeType,
|
||||
targetId: scopeTargetId,
|
||||
label: scopeLabel,
|
||||
} as const;
|
||||
await sendToCattyAgent(sessionId, sendScopeKey, trimmed, abortController, currentSession ?? undefined, assistantMsgId, {
|
||||
activeProvider,
|
||||
activeModelId,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeLabel,
|
||||
globalPermissionMode,
|
||||
commandBlocklist,
|
||||
terminalSessions,
|
||||
webSearchConfig,
|
||||
getExecutorContext: () => buildExecutorContextForScope(toolScope),
|
||||
autoTitleSession,
|
||||
selectedUserSkillSlugs: selectedSkillSlugs,
|
||||
}, attachments.length > 0 ? attachments : undefined);
|
||||
}
|
||||
}, [
|
||||
isStreaming, activeProvider, scopeKey, currentAgentId,
|
||||
@@ -1017,12 +976,15 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
);
|
||||
|
||||
const handleAgentChange = useCallback((agentId: string) => {
|
||||
showScopeDraftView();
|
||||
ensureScopeDraft(agentId);
|
||||
updateScopeDraft(agentId, (draft) => ({
|
||||
...draft,
|
||||
agentId,
|
||||
...selectDraftForAgentSwitch(
|
||||
draft,
|
||||
agentId,
|
||||
Boolean(activeSessionRef.current?.messages.length),
|
||||
),
|
||||
}));
|
||||
showScopeDraftView();
|
||||
setShowHistory(false);
|
||||
}, [ensureScopeDraft, showScopeDraftView, updateScopeDraft]);
|
||||
|
||||
@@ -1203,20 +1165,20 @@ const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
||||
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',
|
||||
SESSION_HISTORY_ROW_CLASSNAMES.row,
|
||||
isActive ? 'text-foreground' : 'text-foreground/70 hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<span className="text-[13px] truncate pr-3 flex-1 min-w-0">
|
||||
<span className={SESSION_HISTORY_ROW_CLASSNAMES.title}>
|
||||
{session.title || t('ai.chat.untitled')}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-[12px] text-muted-foreground/50">
|
||||
<div className={SESSION_HISTORY_ROW_CLASSNAMES.meta}>
|
||||
<span className={SESSION_HISTORY_ROW_CLASSNAMES.time}>
|
||||
{timeStr}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => onDelete(e, session.id)}
|
||||
className="opacity-0 group-hover:opacity-100 p-0.5 hover:text-destructive transition-all cursor-pointer"
|
||||
className={SESSION_HISTORY_ROW_CLASSNAMES.deleteButton}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
|
||||
@@ -1,38 +1,58 @@
|
||||
import React from 'react';
|
||||
|
||||
interface AppLogoProps {
|
||||
className?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* App logo component that dynamically uses the accent color (--primary CSS variable).
|
||||
* The original logo.svg file remains unchanged; this component renders an inline SVG
|
||||
* with colors bound to the current theme's accent color.
|
||||
*/
|
||||
export const AppLogo: React.FC<AppLogoProps> = ({ className }) => (
|
||||
<svg viewBox="0 0 64 64" className={className}>
|
||||
{/* Main background - uses accent color */}
|
||||
<rect x="4" y="4" width="56" height="56" rx="12" fill="hsl(var(--primary))" />
|
||||
{/* Terminal window */}
|
||||
<rect x="14" y="17" width="36" height="24" rx="4" fill="white" />
|
||||
{/* Title bar - light accent tint */}
|
||||
<rect x="14" y="17" width="36" height="5" rx="4" fill="hsl(var(--primary) / 0.15)" />
|
||||
{/* Window buttons */}
|
||||
<circle cx="18" cy="19.5" r="1" fill="hsl(var(--primary))" />
|
||||
<circle cx="22" cy="19.5" r="1" fill="hsl(var(--primary))" opacity="0.7" />
|
||||
<circle cx="26" cy="19.5" r="1" fill="hsl(var(--primary))" opacity="0.5" />
|
||||
{/* Terminal prompt arrow */}
|
||||
<path d="M20 32 L24 30 L20 28" stroke="hsl(var(--primary))" fill="none" strokeWidth="1.6" />
|
||||
{/* Cursor line */}
|
||||
<path d="M28 34 H34" stroke="hsl(var(--primary))" strokeWidth="1.6" />
|
||||
{/* Cat ears */}
|
||||
<path d="M24 17 L26 12 L28 17Z" fill="white" />
|
||||
<path d="M36 17 L38 12 L40 17Z" fill="white" />
|
||||
{/* Cat tail */}
|
||||
<path d="M40 37 C44 40,46 42,46 46 C46 49,44 51,41 51" stroke="white" fill="none" strokeWidth="3.2" />
|
||||
{/* Connector/plug */}
|
||||
<rect x="38" y="48" width="6" height="5" rx="1" fill="white" stroke="hsl(var(--primary))" />
|
||||
</svg>
|
||||
<svg
|
||||
viewBox="0 0 1024 1024"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<rect
|
||||
x="0"
|
||||
y="0"
|
||||
width="1024"
|
||||
height="1024"
|
||||
rx="192"
|
||||
ry="192"
|
||||
fill="hsl(var(--primary))"
|
||||
/>
|
||||
<g transform="translate(85.64 85.64) scale(0.68)">
|
||||
<g><path style={{opacity:1}} fill="#f9f9f9" d="M 618.5,240.5 C 647.925,240.677 677.258,242.344 706.5,245.5C 753.323,252.113 798.49,265.113 842,284.5C 870.064,257.538 902.23,236.704 938.5,222C 966.969,211.263 988.469,219.096 1003,245.5C 1011.08,263.079 1016.75,281.412 1020,300.5C 1022.13,320.204 1024.29,339.871 1026.5,359.5C 1026.17,379.674 1026.5,399.674 1027.5,419.5C 1072.74,473.648 1102.74,535.314 1117.5,604.5C 1117.29,607.495 1117.96,610.162 1119.5,612.5C 1126.08,656.83 1126.08,701.163 1119.5,745.5C 1118.23,747.905 1117.57,750.572 1117.5,753.5C 1107.38,802.706 1088.05,847.872 1059.5,889C 1053.04,888.572 1046.71,887.405 1040.5,885.5C 1036.79,883.864 1032.79,883.198 1028.5,883.5C 1011.79,881.938 995.122,882.271 978.5,884.5C 975.572,884.565 972.905,885.232 970.5,886.5C 928.686,895.489 896.519,918.156 874,954.5C 864.791,970.962 859.958,988.628 859.5,1007.5C 793.269,1029.39 725.269,1041.72 655.5,1044.5C 633.833,1044.5 612.167,1044.5 590.5,1044.5C 524.821,1041.8 460.821,1029.63 398.5,1008C 396.254,996.177 393.421,984.344 390,972.5C 387.524,964.881 384.024,957.881 379.5,951.5C 363.815,925.334 341.815,906.667 313.5,895.5C 297.343,888.573 280.343,884.406 262.5,883C 248.055,882.038 233.722,882.538 219.5,884.5C 216.572,884.565 213.905,885.232 211.5,886.5C 211.167,886.5 210.833,886.5 210.5,886.5C 207.848,886.41 205.515,887.076 203.5,888.5C 200.823,889.614 198.156,889.614 195.5,888.5C 149.432,819.968 128.098,744.301 131.5,661.5C 131.502,654.48 131.835,647.48 132.5,640.5C 133.461,638.735 133.795,636.735 133.5,634.5C 135.136,630.79 135.802,626.79 135.5,622.5C 137.764,609.333 140.431,596.333 143.5,583.5C 144.924,581.485 145.59,579.152 145.5,576.5C 156.228,537.714 172.395,501.381 194,467.5C 204.685,451.452 215.852,435.786 227.5,420.5C 228.042,388.62 229.375,356.62 231.5,324.5C 234.549,300.253 240.382,276.586 249,253.5C 253.868,241.906 261.035,232.073 270.5,224C 279.336,218.042 289.002,216.042 299.5,218C 314.655,220.607 328.988,225.607 342.5,233C 368.29,247.23 391.957,264.396 413.5,284.5C 478.68,255.797 547.014,241.13 618.5,240.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#1f2657" d="M 706.5,245.5 C 677.258,242.344 647.925,240.677 618.5,240.5C 649.662,238.284 680.995,239.784 712.5,245C 710.527,245.495 708.527,245.662 706.5,245.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#18214c" d="M 231.5,324.5 C 229.375,356.62 228.042,388.62 227.5,420.5C 226.104,392.965 226.604,365.298 229,337.5C 229.17,331.677 230.003,327.344 231.5,324.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#0c1943" d="M 1026.5,359.5 C 1027.92,371.971 1028.59,384.637 1028.5,397.5C 1028.5,405.008 1028.17,412.341 1027.5,419.5C 1026.5,399.674 1026.17,379.674 1026.5,359.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#505c83" d="M 817.5,544.5 C 815.162,546.04 812.495,546.706 809.5,546.5C 811.905,545.232 814.572,544.565 817.5,544.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#919ab0" d="M 445.5,545.5 C 448.152,545.41 450.485,546.076 452.5,547.5C 449.848,547.59 447.515,546.924 445.5,545.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#022551" d="M 445.5,545.5 C 447.515,546.924 449.848,547.59 452.5,547.5C 479.103,555.885 499.269,572.218 513,596.5C 515.435,607.525 511.268,614.191 500.5,616.5C 497.302,616.378 494.302,615.545 491.5,614C 485.302,604.13 477.969,595.13 469.5,587C 459.207,579.735 447.873,574.902 435.5,572.5C 415.88,568.656 398.213,573.156 382.5,586C 380.905,585.383 379.572,585.716 378.5,587C 378.957,587.414 379.291,587.914 379.5,588.5C 376.839,591.423 374.005,593.423 371,594.5C 369.606,600.126 366.772,603.96 362.5,606C 363.517,607.049 363.684,608.216 363,609.5C 355.276,616.472 347.943,616.139 341,608.5C 339.805,603.4 340.638,598.733 343.5,594.5C 344.086,594.709 344.586,595.043 345,595.5C 344.718,590.888 346.551,587.055 350.5,584C 351.515,582.627 351.515,581.46 350.5,580.5C 375.329,550.884 406.995,539.218 445.5,545.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#032551" d="M 817.5,544.5 C 862.791,541.392 895.958,559.726 917,599.5C 917.138,612.028 910.971,617.528 898.5,616C 897.167,615.333 895.833,614.667 894.5,614C 884.255,595.245 869.255,582.078 849.5,574.5C 843.812,571.54 837.645,570.207 831,570.5C 822.066,570.919 813.233,572.086 804.5,574C 798.217,577.721 792.05,581.554 786,585.5C 785.667,585.167 785.333,584.833 785,584.5C 782.92,587.065 781.087,589.732 779.5,592.5C 774.384,597.792 770.218,603.792 767,610.5C 759.55,618.016 751.883,618.349 744,611.5C 742.878,609.593 742.045,607.593 741.5,605.5C 741.508,602.455 741.841,599.455 742.5,596.5C 757.037,569.397 779.371,552.73 809.5,546.5C 812.495,546.706 815.162,546.04 817.5,544.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#0c1a4d" d="M 849.5,574.5 C 822.908,568.314 799.574,574.314 779.5,592.5C 781.087,589.732 782.92,587.065 785,584.5C 785.333,584.833 785.667,585.167 786,585.5C 792.05,581.554 798.217,577.721 804.5,574C 813.233,572.086 822.066,570.919 831,570.5C 837.645,570.207 843.812,571.54 849.5,574.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#98a2bf" d="M 423.5,572.5 C 419.684,573.482 415.684,574.149 411.5,574.5C 415.183,572.75 419.183,572.083 423.5,572.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#9ea6be" d="M 145.5,576.5 C 145.59,579.152 144.924,581.485 143.5,583.5C 143.41,580.848 144.076,578.515 145.5,576.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#132152" d="M 435.5,572.5 C 431.5,572.5 427.5,572.5 423.5,572.5C 419.183,572.083 415.183,572.75 411.5,574.5C 389.242,579.57 372.909,592.403 362.5,613C 356.408,617.241 350.075,617.574 343.5,614C 337.996,608.137 337.163,601.637 341,594.5C 343.929,589.631 347.096,584.965 350.5,580.5C 351.515,581.46 351.515,582.627 350.5,584C 346.551,587.055 344.718,590.888 345,595.5C 344.586,595.043 344.086,594.709 343.5,594.5C 340.638,598.733 339.805,603.4 341,608.5C 347.943,616.139 355.276,616.472 363,609.5C 363.684,608.216 363.517,607.049 362.5,606C 366.772,603.96 369.606,600.126 371,594.5C 374.005,593.423 376.839,591.423 379.5,588.5C 379.291,587.914 378.957,587.414 378.5,587C 379.572,585.716 380.905,585.383 382.5,586C 398.213,573.156 415.88,568.656 435.5,572.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#6c7794" d="M 742.5,596.5 C 741.841,599.455 741.508,602.455 741.5,605.5C 740.848,604.551 740.514,603.385 740.5,602C 740.393,599.779 741.06,597.946 742.5,596.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#6f7b97" d="M 1117.5,604.5 C 1118.77,606.905 1119.43,609.572 1119.5,612.5C 1117.96,610.162 1117.29,607.495 1117.5,604.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#a8aec5" d="M 135.5,622.5 C 135.802,626.79 135.136,630.79 133.5,634.5C 133.717,630.295 134.383,626.295 135.5,622.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#677393" d="M 653.5,662.5 C 634.473,662.218 615.473,662.551 596.5,663.5C 597.263,662.732 598.263,662.232 599.5,662C 617.671,661.171 635.671,661.338 653.5,662.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#032551" d="M 653.5,662.5 C 664.536,665.228 669.036,672.228 667,683.5C 665.861,687.112 664.194,690.446 662,693.5C 656.35,700.317 650.184,706.65 643.5,712.5C 643.058,737.755 654.725,754.922 678.5,764C 709.272,768.521 729.105,756.021 738,726.5C 747.413,717.842 755.746,718.842 763,729.5C 759.409,758.463 743.909,778.297 716.5,789C 713.111,789.776 709.778,790.609 706.5,791.5C 697.533,792.383 688.533,792.716 679.5,792.5C 657.328,788.994 639.828,777.994 627,759.5C 607.084,786.202 580.584,797.035 547.5,792C 516.901,784.235 497.901,765.068 490.5,734.5C 493.257,721.955 500.59,718.121 512.5,723C 517.164,727.124 519.998,732.291 521,738.5C 533.515,761.003 552.348,769.17 577.5,763C 599.78,754.048 610.947,737.548 611,713.5C 604.698,706.197 598.032,699.197 591,692.5C 586.824,686.46 585.491,679.794 587,672.5C 589.072,668.26 592.238,665.26 596.5,663.5C 615.473,662.551 634.473,662.218 653.5,662.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#01103f" d="M 132.5,640.5 C 131.835,647.48 131.502,654.48 131.5,661.5C 130.669,675.994 130.169,690.661 130,705.5C 128.188,682.722 128.854,660.055 132,637.5C 132.483,638.448 132.649,639.448 132.5,640.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#7c869d" d="M 1119.5,745.5 C 1119.71,748.495 1119.04,751.162 1117.5,753.5C 1117.57,750.572 1118.23,747.905 1119.5,745.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#7581a0" d="M 706.5,791.5 C 705.737,792.268 704.737,792.768 703.5,793C 695.323,793.823 687.323,793.656 679.5,792.5C 688.533,792.716 697.533,792.383 706.5,791.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#a7aec3" d="M 1028.5,883.5 C 1032.79,883.198 1036.79,883.864 1040.5,885.5C 1036.29,885.283 1032.29,884.617 1028.5,883.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#f9f9f9" d="M 233.5,904.5 C 242.833,904.5 252.167,904.5 261.5,904.5C 263.833,904.5 266.167,904.5 268.5,904.5C 304.989,908.827 334.489,925.494 357,954.5C 374.323,977.781 379.323,1003.45 372,1031.5C 365.153,1050.01 351.986,1060.85 332.5,1064C 324.173,1064.5 315.84,1064.67 307.5,1064.5C 307.947,1050.43 307.447,1036.43 306,1022.5C 296.93,1011.58 288.263,1011.91 280,1023.5C 279.833,1038.51 279.333,1053.51 278.5,1068.5C 271.841,1075.83 263.508,1080 253.5,1081C 248.845,1081.5 244.179,1081.67 239.5,1081.5C 237.485,1080.08 235.152,1079.41 232.5,1079.5C 225.481,1077.32 219.315,1073.66 214,1068.5C 213.667,1053.5 213.333,1038.5 213,1023.5C 208.464,1016.16 201.964,1013.66 193.5,1016C 190.333,1017.83 187.833,1020.33 186,1023.5C 185.5,1037.83 185.333,1052.16 185.5,1066.5C 160.376,1072.2 140.21,1064.86 125,1044.5C 120.792,1037.38 118.292,1029.71 117.5,1021.5C 117.482,1013.15 117.815,1004.82 118.5,996.5C 129.171,955.493 154.504,927.826 194.5,913.5C 200.166,912.61 205.5,910.943 210.5,908.5C 211.568,907.566 212.901,907.232 214.5,907.5C 221.111,907.453 227.444,906.453 233.5,904.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#f8f8f9" d="M 1133.5,985.5 C 1133.41,988.152 1134.08,990.485 1135.5,992.5C 1136.26,1002.48 1136.59,1012.48 1136.5,1022.5C 1133.68,1047.82 1119.68,1062.66 1094.5,1067C 1086.48,1067.61 1078.48,1067.44 1070.5,1066.5C 1070.67,1052.83 1070.5,1039.16 1070,1025.5C 1066.12,1016.96 1059.62,1013.79 1050.5,1016C 1047.33,1017.83 1044.83,1020.33 1043,1023.5C 1042.67,1038.17 1042.33,1052.83 1042,1067.5C 1035.97,1075.1 1028.14,1079.43 1018.5,1080.5C 1013.2,1081.27 1007.87,1081.61 1002.5,1081.5C 991.789,1080.39 982.955,1075.73 976,1067.5C 975.667,1052.83 975.333,1038.17 975,1023.5C 971.569,1017.53 966.402,1014.87 959.5,1015.5C 953.942,1016.72 950.275,1020.06 948.5,1025.5C 947.505,1037.99 947.171,1050.66 947.5,1063.5C 946.209,1063.26 945.209,1063.6 944.5,1064.5C 903.542,1067.19 882.208,1048.02 880.5,1007C 880.658,1002.81 880.991,998.641 881.5,994.5C 883.277,991.495 884.277,988.162 884.5,984.5C 894.73,953.43 914.73,930.93 944.5,917C 978.246,903.385 1012.91,900.718 1048.5,909C 1082.5,918.575 1108.67,938.409 1127,968.5C 1129.86,973.928 1132.03,979.595 1133.5,985.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#adb2c9" d="M 233.5,904.5 C 227.444,906.453 221.111,907.453 214.5,907.5C 220.536,905.419 226.869,904.419 233.5,904.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#bec4d7" d="M 210.5,908.5 C 205.5,910.943 200.166,912.61 194.5,913.5C 199.5,911.057 204.834,909.39 210.5,908.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#9ba0b8" d="M 884.5,984.5 C 884.277,988.162 883.277,991.495 881.5,994.5C 881.723,990.838 882.723,987.505 884.5,984.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#9aa5bc" d="M 1133.5,985.5 C 1134.92,987.515 1135.59,989.848 1135.5,992.5C 1134.08,990.485 1133.41,988.152 1133.5,985.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#adb1c6" d="M 118.5,996.5 C 117.815,1004.82 117.482,1013.15 117.5,1021.5C 116.835,1018.69 116.502,1015.69 116.5,1012.5C 116.429,1006.93 117.096,1001.6 118.5,996.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#c9d0dc" d="M 1135.5,992.5 C 1136.96,998.434 1137.63,1004.6 1137.5,1011C 1137.5,1015.02 1137.17,1018.85 1136.5,1022.5C 1136.59,1012.48 1136.26,1002.48 1135.5,992.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#b5bfcb" d="M 948.5,1025.5 C 948.5,1038.5 948.5,1051.5 948.5,1064.5C 947.167,1064.5 945.833,1064.5 944.5,1064.5C 945.209,1063.6 946.209,1063.26 947.5,1063.5C 947.171,1050.66 947.505,1037.99 948.5,1025.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#8193aa" d="M 232.5,1079.5 C 235.152,1079.41 237.485,1080.08 239.5,1081.5C 236.848,1081.59 234.515,1080.92 232.5,1079.5 Z"/></g>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default AppLogo;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* - Sync status and conflict resolution
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
@@ -43,7 +43,9 @@ import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import {
|
||||
findSyncPayloadEncryptedCredentialPaths,
|
||||
} from '../domain/credentials';
|
||||
import { isProviderReadyForSync, type CloudProvider, type ConflictInfo, type SyncPayload, type WebDAVAuthType, type WebDAVConfig, type S3Config } from '../domain/sync';
|
||||
import { isProviderReadyForSync, type CloudProvider, type ConflictInfo, type SyncPayload, type SyncResult, type WebDAVAuthType, type WebDAVConfig, type S3Config } from '../domain/sync';
|
||||
import type { ShrinkFinding } from '../domain/syncGuards';
|
||||
import { SyncBlockedBanner } from './sync/SyncBlockedBanner';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Button } from './ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
@@ -431,7 +433,7 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onCancelConnect}
|
||||
className="gap-1"
|
||||
className="gap-1 min-w-[136px] justify-center"
|
||||
>
|
||||
<X size={14} />
|
||||
{t('common.cancel')}
|
||||
@@ -440,7 +442,7 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => { onConnect(); }}
|
||||
className="gap-1"
|
||||
className="gap-1 min-w-[136px] justify-center"
|
||||
disabled={disabled || isConnecting}
|
||||
>
|
||||
{isConnecting ? <Loader2 size={14} className="animate-spin" /> : <Cloud size={14} />}
|
||||
@@ -897,20 +899,29 @@ const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
className="flex items-center gap-3 rounded-lg border border-border/60 p-3"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium">
|
||||
{getReasonLabel(backup.reason)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatTimestamp(backup.createdAt)}
|
||||
</span>
|
||||
<div className="text-sm font-medium">
|
||||
{backup.syncDataVersion
|
||||
? `v${backup.syncDataVersion}`
|
||||
: formatTimestamp(backup.createdAt)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1 flex items-center gap-1 flex-wrap">
|
||||
<span>{getReasonLabel(backup.reason)}</span>
|
||||
{backup.syncDataVersion && (
|
||||
<>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{formatTimestamp(backup.createdAt)}</span>
|
||||
</>
|
||||
)}
|
||||
{backup.sourceAppVersion && backup.targetAppVersion && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('cloudSync.localBackups.versionChange', {
|
||||
from: backup.sourceAppVersion,
|
||||
to: backup.targetAppVersion,
|
||||
})}
|
||||
</span>
|
||||
<>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>
|
||||
{t('cloudSync.localBackups.versionChange', {
|
||||
from: backup.sourceAppVersion,
|
||||
to: backup.targetAppVersion,
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
@@ -974,13 +985,30 @@ const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
</DialogHeader>
|
||||
{pendingRestoreBackup && (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/30 p-3 text-xs space-y-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium">
|
||||
{getReasonLabel(pendingRestoreBackup.reason)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{formatTimestamp(pendingRestoreBackup.createdAt)}
|
||||
</span>
|
||||
<div className="font-medium">
|
||||
{pendingRestoreBackup.syncDataVersion
|
||||
? `v${pendingRestoreBackup.syncDataVersion}`
|
||||
: formatTimestamp(pendingRestoreBackup.createdAt)}
|
||||
</div>
|
||||
<div className="text-muted-foreground flex items-center gap-1 flex-wrap">
|
||||
<span>{getReasonLabel(pendingRestoreBackup.reason)}</span>
|
||||
{pendingRestoreBackup.syncDataVersion && (
|
||||
<>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{formatTimestamp(pendingRestoreBackup.createdAt)}</span>
|
||||
</>
|
||||
)}
|
||||
{pendingRestoreBackup.sourceAppVersion && pendingRestoreBackup.targetAppVersion && (
|
||||
<>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>
|
||||
{t('cloudSync.localBackups.versionChange', {
|
||||
from: pendingRestoreBackup.sourceAppVersion,
|
||||
to: pendingRestoreBackup.targetAppVersion,
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{t('cloudSync.localBackups.counts', {
|
||||
@@ -1093,6 +1121,10 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
};
|
||||
|
||||
const disconnectOtherProviders = async (current: CloudProvider) => {
|
||||
if (sync.pendingBrowserAuthProvider && sync.pendingBrowserAuthProvider !== current) {
|
||||
toast.info(t('cloudSync.connect.browserCancelled'));
|
||||
}
|
||||
sync.cancelOAuthConnect();
|
||||
const providers: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
|
||||
for (const provider of providers) {
|
||||
if (provider === current) continue;
|
||||
@@ -1107,6 +1139,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
const [gitHubUserCode, setGitHubUserCode] = useState('');
|
||||
const [gitHubVerificationUri, setGitHubVerificationUri] = useState('');
|
||||
const [isPollingGitHub, setIsPollingGitHub] = useState(false);
|
||||
const activeGitHubAttemptIdRef = useRef<number | null>(null);
|
||||
|
||||
// Conflict modal
|
||||
const [showConflictModal, setShowConflictModal] = useState(false);
|
||||
@@ -1124,6 +1157,40 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
} | null>(null);
|
||||
const [historyPreviewLoading, setHistoryPreviewLoading] = useState(false);
|
||||
const [historyError, setHistoryError] = useState<string | null>(null);
|
||||
const [pendingConnectProvider, setPendingConnectProvider] = useState<CloudProvider | null>(null);
|
||||
const pendingConnectProviderRef = useRef<CloudProvider | null>(null);
|
||||
|
||||
const hasConnectingProvider = (Object.values(sync.providers) as Array<{ status: string }>).some(
|
||||
(provider) => provider.status === 'connecting'
|
||||
);
|
||||
|
||||
const isConnectDisabled = (provider: CloudProvider): boolean => {
|
||||
if (pendingConnectProvider && pendingConnectProvider !== provider) {
|
||||
return true;
|
||||
}
|
||||
if (pendingConnectProvider === provider) {
|
||||
return true;
|
||||
}
|
||||
if (hasConnectingProvider && sync.providers[provider].status !== 'connecting') {
|
||||
return true;
|
||||
}
|
||||
return sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers[provider]);
|
||||
};
|
||||
|
||||
const beginPendingConnect = (provider: CloudProvider): boolean => {
|
||||
if (pendingConnectProviderRef.current) {
|
||||
return false;
|
||||
}
|
||||
pendingConnectProviderRef.current = provider;
|
||||
setPendingConnectProvider(provider);
|
||||
return true;
|
||||
};
|
||||
|
||||
const endPendingConnect = (provider: CloudProvider) => {
|
||||
if (pendingConnectProviderRef.current !== provider) return;
|
||||
pendingConnectProviderRef.current = null;
|
||||
setPendingConnectProvider((current) => (current === provider ? null : current));
|
||||
};
|
||||
|
||||
// Change master key dialog
|
||||
const [showChangeKeyDialog, setShowChangeKeyDialog] = useState(false);
|
||||
@@ -1172,6 +1239,17 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
// Clear local data dialog
|
||||
const [showClearLocalDialog, setShowClearLocalDialog] = useState(false);
|
||||
|
||||
// Sync-blocked banner (Task 7) + force-push confirmation modal (Task 8)
|
||||
const [blockedFinding, setBlockedFinding] = useState<Extract<ShrinkFinding, { suspicious: true }> | null>(null);
|
||||
const [showForcePushConfirm, setShowForcePushConfirm] = useState(false);
|
||||
|
||||
// Ref for scrolling to LocalBackupsPanel when the banner's Restore button is clicked
|
||||
const localBackupsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Active tab state — lets the banner's "Restore" button switch to the
|
||||
// local-backups tab without a separate DOM query.
|
||||
const [activeTab, setActiveTab] = useState<'providers' | 'status'>('providers');
|
||||
|
||||
const ensureSyncablePayload = useCallback(
|
||||
(payload: SyncPayload): boolean => {
|
||||
const encryptedCredentialPaths = findSyncPayloadEncryptedCredentialPaths(payload);
|
||||
@@ -1190,6 +1268,35 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
}
|
||||
}, [sync.currentConflict]);
|
||||
|
||||
// Subscribe to sync events to show/clear the blocked-shrink banner.
|
||||
// Destructure the stable useCallback reference so the effect runs once on
|
||||
// mount rather than re-subscribing on every render when `sync` object ref changes.
|
||||
const { subscribeToEvents, getShrinkBlockedFinding } = sync;
|
||||
|
||||
// Hydrate from current manager state in case a shrink-block happened
|
||||
// before this component mounted (e.g., auto-sync ran while the user
|
||||
// was on a different tab). Without this, the banner only shows
|
||||
// blocks that occur after Settings is open.
|
||||
useEffect(() => {
|
||||
const existing = getShrinkBlockedFinding();
|
||||
if (existing) {
|
||||
setBlockedFinding(existing);
|
||||
}
|
||||
}, [getShrinkBlockedFinding]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = subscribeToEvents((event) => {
|
||||
if (event.type === 'SYNC_BLOCKED_SHRINK') {
|
||||
if (event.finding.suspicious) {
|
||||
setBlockedFinding(event.finding);
|
||||
}
|
||||
} else if (event.type === 'SYNC_BLOCKED_CLEARED') {
|
||||
setBlockedFinding(null);
|
||||
}
|
||||
});
|
||||
return unsub;
|
||||
}, [subscribeToEvents]);
|
||||
|
||||
// If we have a master key but we're still locked (e.g. older installs),
|
||||
// prompt once and persist the password via safeStorage.
|
||||
useEffect(() => {
|
||||
@@ -1207,9 +1314,14 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
|
||||
// Connect GitHub (disconnect others first - single provider only)
|
||||
const handleConnectGitHub = async () => {
|
||||
if (!beginPendingConnect('github')) return;
|
||||
const cancelController = new AbortController();
|
||||
let authAttemptId: number | null = null;
|
||||
try {
|
||||
await disconnectOtherProviders('github');
|
||||
const deviceFlow = await sync.connectGitHub();
|
||||
authAttemptId = deviceFlow.authAttemptId ?? null;
|
||||
activeGitHubAttemptIdRef.current = authAttemptId;
|
||||
setGitHubUserCode(deviceFlow.userCode);
|
||||
setGitHubVerificationUri(deviceFlow.verificationUri);
|
||||
setShowGitHubModal(true);
|
||||
@@ -1219,59 +1331,78 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
deviceFlow.deviceCode,
|
||||
deviceFlow.interval,
|
||||
deviceFlow.expiresAt,
|
||||
() => { } // onPending callback
|
||||
() => { }, // onPending callback
|
||||
cancelController.signal,
|
||||
authAttemptId ?? undefined
|
||||
);
|
||||
|
||||
setIsPollingGitHub(false);
|
||||
setShowGitHubModal(false);
|
||||
if (activeGitHubAttemptIdRef.current === authAttemptId) {
|
||||
activeGitHubAttemptIdRef.current = null;
|
||||
setIsPollingGitHub(false);
|
||||
setShowGitHubModal(false);
|
||||
}
|
||||
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');
|
||||
if (activeGitHubAttemptIdRef.current === authAttemptId) {
|
||||
activeGitHubAttemptIdRef.current = null;
|
||||
setIsPollingGitHub(false);
|
||||
setShowGitHubModal(false);
|
||||
}
|
||||
const message = getNetworkErrorMessage(error, t('common.unknownError'));
|
||||
toast.error(message, t('cloudSync.connect.github.failedTitle'));
|
||||
if (!message.toLowerCase().includes('cancelled')) {
|
||||
toast.error(message, t('cloudSync.connect.github.failedTitle'));
|
||||
}
|
||||
} finally {
|
||||
cancelController.abort();
|
||||
if (activeGitHubAttemptIdRef.current == null) {
|
||||
endPendingConnect('github');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Connect Google (disconnect others first - single provider only)
|
||||
const handleConnectGoogle = async () => {
|
||||
if (!beginPendingConnect('google')) return;
|
||||
try {
|
||||
await disconnectOtherProviders('google');
|
||||
await sync.connectGoogle();
|
||||
// Note: Auth flow is handled automatically by oauthBridge
|
||||
toast.info(t('cloudSync.connect.browserContinue'));
|
||||
} catch (error) {
|
||||
// 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'));
|
||||
}
|
||||
} finally {
|
||||
endPendingConnect('google');
|
||||
}
|
||||
};
|
||||
|
||||
// Connect OneDrive (disconnect others first - single provider only)
|
||||
const handleConnectOneDrive = async () => {
|
||||
if (!beginPendingConnect('onedrive')) return;
|
||||
try {
|
||||
await disconnectOtherProviders('onedrive');
|
||||
await sync.connectOneDrive();
|
||||
// Note: Auth flow is handled automatically by oauthBridge
|
||||
toast.info(t('cloudSync.connect.browserContinue'));
|
||||
} catch (error) {
|
||||
// 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'));
|
||||
}
|
||||
} finally {
|
||||
endPendingConnect('onedrive');
|
||||
}
|
||||
};
|
||||
|
||||
const openWebdavDialog = () => {
|
||||
if (sync.pendingBrowserAuthProvider) {
|
||||
toast.info(t('cloudSync.connect.browserCancelled'));
|
||||
}
|
||||
sync.cancelOAuthConnect();
|
||||
const config = sync.providers.webdav.config as WebDAVConfig | undefined;
|
||||
setWebdavEndpoint(config?.endpoint || '');
|
||||
setWebdavAuthType(config?.authType || 'basic');
|
||||
@@ -1286,6 +1417,10 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
};
|
||||
|
||||
const openS3Dialog = () => {
|
||||
if (sync.pendingBrowserAuthProvider) {
|
||||
toast.info(t('cloudSync.connect.browserCancelled'));
|
||||
}
|
||||
sync.cancelOAuthConnect();
|
||||
const config = sync.providers.s3.config as S3Config | undefined;
|
||||
setS3Endpoint(config?.endpoint || '');
|
||||
setS3Region(config?.region || '');
|
||||
@@ -1441,9 +1576,30 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
// window's push, not ours.
|
||||
const localPayload = onBuildPayload();
|
||||
if (!ensureSyncablePayload(localPayload)) return;
|
||||
|
||||
let results: Map<CloudProvider, SyncResult> | null = null;
|
||||
await withRestoreBarrier(async () => {
|
||||
await sync.syncNow(localPayload);
|
||||
results = await sync.syncNow(localPayload, { overrideShrink: true });
|
||||
});
|
||||
|
||||
if (results) {
|
||||
// Apply any merged payload BEFORE closing the modal so local state
|
||||
// reflects what's now on cloud (in case remote changed during the merge).
|
||||
for (const result of (results as Map<CloudProvider, SyncResult>).values()) {
|
||||
if (result.mergedPayload) {
|
||||
await Promise.resolve(onApplyPayload(result.mergedPayload));
|
||||
break;
|
||||
}
|
||||
}
|
||||
const allOk = Array.from((results as Map<CloudProvider, SyncResult>).values()).every((r) => r.success);
|
||||
if (!allOk) {
|
||||
const firstError = Array.from((results as Map<CloudProvider, SyncResult>).values())
|
||||
.find((r) => !r.success)?.error
|
||||
?? t('common.unknownError');
|
||||
toast.error(firstError, t('cloudSync.resolve.failedTitle'));
|
||||
return; // KEEP the modal open so user can retry / pick USE_REMOTE
|
||||
}
|
||||
}
|
||||
toast.success(t('cloudSync.resolve.uploaded'));
|
||||
}
|
||||
setShowConflictModal(false);
|
||||
@@ -1554,7 +1710,20 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="providers" className="space-y-4">
|
||||
{blockedFinding && (
|
||||
<SyncBlockedBanner
|
||||
finding={blockedFinding}
|
||||
onRestore={() => {
|
||||
setActiveTab('status');
|
||||
requestAnimationFrame(() => {
|
||||
localBackupsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
});
|
||||
}}
|
||||
onForcePush={() => setShowForcePushConfirm(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'providers' | 'status')} className="space-y-4">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="providers">{t('cloudSync.providers.title')}</TabsTrigger>
|
||||
<TabsTrigger value="status">{t('cloudSync.status.title')}</TabsTrigger>
|
||||
@@ -1571,7 +1740,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
account={sync.providers.github.account}
|
||||
lastSync={sync.providers.github.lastSync}
|
||||
error={sync.providers.github.error}
|
||||
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.github)}
|
||||
disabled={isConnectDisabled('github')}
|
||||
onConnect={handleConnectGitHub}
|
||||
onDisconnect={() => sync.disconnectProvider('github')}
|
||||
onSync={() => handleSync('github')}
|
||||
@@ -1591,11 +1760,14 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
icon={<GoogleDriveIcon className="w-6 h-6" />}
|
||||
isConnected={isProviderReadyForSync(sync.providers.google)}
|
||||
isSyncing={sync.providers.google.status === 'syncing'}
|
||||
isConnecting={sync.providers.google.status === 'connecting'}
|
||||
isConnecting={
|
||||
sync.providers.google.status === 'connecting' ||
|
||||
sync.pendingBrowserAuthProvider === 'google'
|
||||
}
|
||||
account={sync.providers.google.account}
|
||||
lastSync={sync.providers.google.lastSync}
|
||||
error={sync.providers.google.error}
|
||||
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.google)}
|
||||
disabled={isConnectDisabled('google')}
|
||||
onConnect={handleConnectGoogle}
|
||||
onCancelConnect={sync.cancelOAuthConnect}
|
||||
onDisconnect={() => sync.disconnectProvider('google')}
|
||||
@@ -1608,11 +1780,14 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
icon={<OneDriveIcon className="w-6 h-6" />}
|
||||
isConnected={isProviderReadyForSync(sync.providers.onedrive)}
|
||||
isSyncing={sync.providers.onedrive.status === 'syncing'}
|
||||
isConnecting={sync.providers.onedrive.status === 'connecting'}
|
||||
isConnecting={
|
||||
sync.providers.onedrive.status === 'connecting' ||
|
||||
sync.pendingBrowserAuthProvider === 'onedrive'
|
||||
}
|
||||
account={sync.providers.onedrive.account}
|
||||
lastSync={sync.providers.onedrive.lastSync}
|
||||
error={sync.providers.onedrive.error}
|
||||
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.onedrive)}
|
||||
disabled={isConnectDisabled('onedrive')}
|
||||
onConnect={handleConnectOneDrive}
|
||||
onCancelConnect={sync.cancelOAuthConnect}
|
||||
onDisconnect={() => sync.disconnectProvider('onedrive')}
|
||||
@@ -1629,7 +1804,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
account={sync.providers.webdav.account}
|
||||
lastSync={sync.providers.webdav.lastSync}
|
||||
error={sync.providers.webdav.error}
|
||||
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.webdav)}
|
||||
disabled={isConnectDisabled('webdav')}
|
||||
onEdit={openWebdavDialog}
|
||||
onConnect={openWebdavDialog}
|
||||
onDisconnect={() => sync.disconnectProvider('webdav')}
|
||||
@@ -1646,7 +1821,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
account={sync.providers.s3.account}
|
||||
lastSync={sync.providers.s3.lastSync}
|
||||
error={sync.providers.s3.error}
|
||||
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.s3)}
|
||||
disabled={isConnectDisabled('s3')}
|
||||
onEdit={openS3Dialog}
|
||||
onConnect={openS3Dialog}
|
||||
onDisconnect={() => sync.disconnectProvider('s3')}
|
||||
@@ -1739,9 +1914,11 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LocalBackupsPanel
|
||||
onApplyPayload={onApplyPayload}
|
||||
/>
|
||||
<div ref={localBackupsRef}>
|
||||
<LocalBackupsPanel
|
||||
onApplyPayload={onApplyPayload}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Clear Local Data */}
|
||||
<div className="p-4 rounded-lg border border-destructive/30 bg-destructive/5">
|
||||
@@ -1772,11 +1949,11 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
verificationUri={gitHubVerificationUri}
|
||||
isPolling={isPollingGitHub}
|
||||
onClose={() => {
|
||||
activeGitHubAttemptIdRef.current = null;
|
||||
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');
|
||||
endPendingConnect('github');
|
||||
sync.cancelOAuthConnect();
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -2361,6 +2538,69 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Force-push confirmation modal (Task 8) */}
|
||||
{showForcePushConfirm && blockedFinding && (
|
||||
<Dialog open onOpenChange={(open) => !open && setShowForcePushConfirm(false)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('sync.forcePush.title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm">
|
||||
{t('sync.forcePush.body', {
|
||||
lost: blockedFinding.lost,
|
||||
entityType: t(`sync.entityType.${blockedFinding.entityType}`),
|
||||
})}
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowForcePushConfirm(false)}>
|
||||
{t('sync.forcePush.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
const localPayload = onBuildPayload();
|
||||
if (!ensureSyncablePayload(localPayload)) {
|
||||
setShowForcePushConfirm(false);
|
||||
return;
|
||||
}
|
||||
setShowForcePushConfirm(false);
|
||||
try {
|
||||
const results = await sync.syncNow(localPayload, { overrideShrink: true });
|
||||
|
||||
// Apply any merged payload BEFORE clearing the banner. If a merge happened
|
||||
// during force-push (remote changed), the merged result is what the cloud
|
||||
// now has — applying it to local state prevents the next sync from
|
||||
// re-deleting the remote additions we just merged in.
|
||||
for (const result of results.values()) {
|
||||
if (result.mergedPayload) {
|
||||
await Promise.resolve(onApplyPayload(result.mergedPayload));
|
||||
break; // All providers share the same merged payload
|
||||
}
|
||||
}
|
||||
|
||||
const allOk = Array.from(results.values()).every((r) => r.success);
|
||||
if (allOk) {
|
||||
setBlockedFinding(null);
|
||||
} else {
|
||||
// Surface the failure but KEEP the banner so the user can retry or
|
||||
// restore. Find the first error string to display.
|
||||
const firstError = Array.from(results.values())
|
||||
.find((r) => !r.success)
|
||||
?.error ?? t('sync.toast.errorTitle');
|
||||
toast.error(firstError, t('sync.toast.errorTitle'));
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(String(err), t('sync.toast.errorTitle'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('sync.forcePush.confirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -408,6 +408,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
} else if (preserveLegacyFontSize && cleaned.fontSize == null) {
|
||||
cleaned.fontSize = initialData?.fontSize;
|
||||
}
|
||||
|
||||
if ((cleaned.protocol && cleaned.protocol !== "ssh") || cleaned.moshEnabled) {
|
||||
delete cleaned.x11Forwarding;
|
||||
}
|
||||
onSave(cleaned);
|
||||
};
|
||||
|
||||
@@ -1551,11 +1555,15 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
enabled={!!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 }));
|
||||
if (enabling) {
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
moshEnabled: true,
|
||||
deviceType: prev.deviceType === 'network' ? undefined : prev.deviceType,
|
||||
x11Forwarding: undefined,
|
||||
}));
|
||||
} else {
|
||||
update("moshEnabled", enabling);
|
||||
update("moshEnabled", false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -1590,6 +1598,24 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* X11 Forwarding */}
|
||||
{(!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">
|
||||
<TerminalSquare size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">{t("hostDetails.section.x11Forwarding")}</p>
|
||||
</div>
|
||||
<ToggleRow
|
||||
label={t("hostDetails.x11Forwarding")}
|
||||
enabled={!!form.x11Forwarding}
|
||||
onToggle={() => update("x11Forwarding", !form.x11Forwarding)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.x11Forwarding.desc")}
|
||||
</p>
|
||||
</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">
|
||||
|
||||
@@ -520,7 +520,7 @@ echo $3 >> "$FILE"`);
|
||||
)}
|
||||
>
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-wrap items-center gap-3 bg-secondary/60 border-b border-border/70 px-3 py-1.5 shrink-0">
|
||||
<div className="h-14 px-4 py-2 flex items-center gap-3 bg-secondary/80 supports-[backdrop-filter]:backdrop-blur-sm border-b border-border/50 shrink-0">
|
||||
{/* Filter Tabs */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* KEY button with split interaction: left=switch view, right=dropdown */}
|
||||
@@ -528,16 +528,15 @@ echo $3 >> "$FILE"`);
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center rounded-md transition-colors",
|
||||
activeFilter === "key" ? "bg-primary/15" : "hover:bg-accent",
|
||||
activeFilter === "key"
|
||||
? "bg-foreground/10 text-foreground hover:bg-foreground/15"
|
||||
: "bg-foreground/5 text-foreground hover:bg-foreground/10",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-8 px-3 gap-2 rounded-r-none hover:bg-transparent",
|
||||
activeFilter === "key" && "text-primary",
|
||||
)}
|
||||
className="h-10 px-3 gap-2 rounded-r-none hover:bg-transparent text-inherit"
|
||||
onClick={() => setActiveFilter("key")}
|
||||
>
|
||||
<Key size={14} />
|
||||
@@ -547,10 +546,7 @@ echo $3 >> "$FILE"`);
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-8 px-1.5 rounded-l-none hover:bg-transparent",
|
||||
activeFilter === "key" && "text-primary",
|
||||
)}
|
||||
className="h-10 px-1.5 rounded-l-none hover:bg-transparent text-inherit"
|
||||
>
|
||||
<ChevronDown size={12} />
|
||||
</Button>
|
||||
@@ -589,33 +585,24 @@ echo $3 >> "$FILE"`);
|
||||
className={cn(
|
||||
"flex items-center rounded-md transition-colors",
|
||||
activeFilter === "certificate"
|
||||
? "bg-primary/15"
|
||||
: "hover:bg-accent",
|
||||
? "bg-foreground/10 text-foreground hover:bg-foreground/15"
|
||||
: "bg-foreground/5 text-foreground hover:bg-foreground/10",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-8 px-3 gap-2 rounded-r-none hover:bg-transparent",
|
||||
activeFilter === "certificate" && "text-primary",
|
||||
)}
|
||||
className="h-10 px-3 gap-2 rounded-r-none hover:bg-transparent text-inherit"
|
||||
onClick={() => setActiveFilter("certificate")}
|
||||
>
|
||||
<BadgeCheck size={14} />
|
||||
{t("keychain.filter.certificate")}
|
||||
<span className="text-[10px] px-1.5 rounded-full bg-muted text-muted-foreground">
|
||||
{keys.filter((k) => k.certificate).length}
|
||||
</span>
|
||||
</Button>
|
||||
<DropdownTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-8 px-1.5 rounded-l-none hover:bg-transparent",
|
||||
activeFilter === "certificate" && "text-primary",
|
||||
)}
|
||||
className="h-10 px-1.5 rounded-l-none hover:bg-transparent text-inherit"
|
||||
>
|
||||
<ChevronDown size={12} />
|
||||
</Button>
|
||||
@@ -645,7 +632,7 @@ echo $3 >> "$FILE"`);
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("common.searchPlaceholder")}
|
||||
className="h-9 pl-8 w-full"
|
||||
className="h-10 pl-9 w-full bg-secondary border-border/60 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -654,7 +641,7 @@ echo $3 >> "$FILE"`);
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 flex-shrink-0"
|
||||
className="h-10 w-10 flex-shrink-0"
|
||||
>
|
||||
{viewMode === "grid" ? (
|
||||
<LayoutGrid size={16} />
|
||||
|
||||
@@ -455,7 +455,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-border/50 bg-secondary/50">
|
||||
<div className="h-14 px-4 py-2 flex items-center gap-3 border-b border-border/50 bg-secondary/80 supports-[backdrop-filter]:backdrop-blur-sm">
|
||||
<div className="flex-1 min-w-0 flex items-center gap-2">
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<Search
|
||||
@@ -464,7 +464,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
/>
|
||||
<Input
|
||||
placeholder={t("knownHosts.search.placeholder")}
|
||||
className="pl-9 h-9 bg-background border-border/60 text-sm"
|
||||
className="pl-9 h-10 bg-secondary border-border/60 text-sm"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
@@ -474,7 +474,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
{/* View Mode Toggle */}
|
||||
<Dropdown>
|
||||
<DropdownTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-9 w-9">
|
||||
<Button variant="ghost" size="icon" className="h-10 w-10">
|
||||
{viewMode === "grid" ? (
|
||||
<LayoutGrid size={16} />
|
||||
) : (
|
||||
@@ -505,15 +505,14 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
<SortDropdown
|
||||
value={sortMode}
|
||||
onChange={setSortMode}
|
||||
className="h-9 w-9"
|
||||
className="h-10 w-10"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-px h-5 bg-border/50" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9 px-3 text-xs"
|
||||
variant="secondary"
|
||||
className="h-10 px-3 bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40"
|
||||
onClick={() => handleScanSystem()}
|
||||
disabled={isScanning}
|
||||
>
|
||||
@@ -532,8 +531,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-9 px-3 text-xs"
|
||||
className="h-10 px-3 bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40"
|
||||
onClick={openFilePicker}
|
||||
>
|
||||
<Import size={14} className="mr-2" />
|
||||
|
||||
@@ -567,10 +567,13 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
)}
|
||||
>
|
||||
{/* Toolbar */}
|
||||
<div className="h-14 px-4 flex items-center gap-3 bg-secondary/60 border-b border-border/60 relative z-20">
|
||||
<div className="h-14 px-4 py-2 flex items-center gap-3 bg-secondary/80 supports-[backdrop-filter]:backdrop-blur-sm border-b border-border/50 relative z-20">
|
||||
<Dropdown open={showNewMenu} onOpenChange={setShowNewMenu}>
|
||||
<DropdownTrigger asChild>
|
||||
<Button variant="secondary" className="h-9 px-3 gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="h-10 px-3 gap-2 bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40"
|
||||
>
|
||||
<Zap size={14} />
|
||||
{t("pf.action.newForwarding")}
|
||||
<ChevronDown
|
||||
@@ -618,7 +621,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
/>
|
||||
<Input
|
||||
placeholder={t("common.searchPlaceholder")}
|
||||
className="h-9 pl-8 w-44"
|
||||
className="h-10 pl-9 w-44 bg-secondary border-border/60 text-sm"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
@@ -627,7 +630,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
{/* View mode toggle */}
|
||||
<Dropdown>
|
||||
<DropdownTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-9 w-9">
|
||||
<Button variant="ghost" size="icon" className="h-10 w-10">
|
||||
{viewMode === "grid" ? (
|
||||
<LayoutGrid size={16} />
|
||||
) : (
|
||||
@@ -664,7 +667,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
<SortDropdown
|
||||
value={sortMode}
|
||||
onChange={setSortMode}
|
||||
className="h-9 w-9"
|
||||
className="h-10 w-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface QuickAddSnippetDialogProps {
|
||||
snippets: Snippet[];
|
||||
packages: string[];
|
||||
onCreateSnippet: (snippet: Snippet) => void;
|
||||
onUpdateSnippet?: (snippet: Snippet) => void;
|
||||
onCreatePackage?: (packagePath: string) => void;
|
||||
}
|
||||
|
||||
@@ -37,6 +38,7 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
snippets,
|
||||
packages,
|
||||
onCreateSnippet,
|
||||
onUpdateSnippet,
|
||||
onCreatePackage,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
@@ -44,6 +46,7 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
const [label, setLabel] = useState('');
|
||||
const [command, setCommand] = useState('');
|
||||
const [packagePath, setPackagePath] = useState('');
|
||||
const [editing, setEditing] = useState<Snippet | null>(null);
|
||||
const labelInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Listen for the global "add snippet" request dispatched by the
|
||||
@@ -51,6 +54,7 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
// every open so stale input from a previous cancel does not leak.
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
setEditing(null);
|
||||
setLabel('');
|
||||
setCommand('');
|
||||
setPackagePath('');
|
||||
@@ -60,6 +64,23 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
return () => window.removeEventListener('netcatty:snippets:add', handler);
|
||||
}, []);
|
||||
|
||||
// Sibling event for editing an existing snippet from the ScriptsSidePanel
|
||||
// context menu. Prefills the form and flips the dialog into update mode.
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent<{ snippet?: Snippet }>).detail;
|
||||
const snippet = detail?.snippet;
|
||||
if (!snippet) return;
|
||||
setEditing(snippet);
|
||||
setLabel(snippet.label ?? '');
|
||||
setCommand(snippet.command ?? '');
|
||||
setPackagePath(snippet.package ?? '');
|
||||
setOpen(true);
|
||||
};
|
||||
window.addEventListener('netcatty:snippets:edit', handler);
|
||||
return () => window.removeEventListener('netcatty:snippets:edit', handler);
|
||||
}, []);
|
||||
|
||||
// Auto-focus the label input once the dialog renders, so the user can
|
||||
// start typing immediately after clicking the + button.
|
||||
useEffect(() => {
|
||||
@@ -92,16 +113,27 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
if (trimmedPackage && !packages.includes(trimmedPackage)) {
|
||||
onCreatePackage?.(trimmedPackage);
|
||||
}
|
||||
onCreateSnippet({
|
||||
id: crypto.randomUUID(),
|
||||
label: label.trim(),
|
||||
command, // preserve whitespace in multi-line commands
|
||||
tags: [],
|
||||
package: trimmedPackage || '',
|
||||
targets: [],
|
||||
});
|
||||
if (editing && onUpdateSnippet) {
|
||||
// Preserve tags/targets/shortkey/noAutoRun etc. that this lightweight
|
||||
// dialog does not expose — only the three quick-edit fields change.
|
||||
onUpdateSnippet({
|
||||
...editing,
|
||||
label: label.trim(),
|
||||
command,
|
||||
package: trimmedPackage || '',
|
||||
});
|
||||
} else {
|
||||
onCreateSnippet({
|
||||
id: crypto.randomUUID(),
|
||||
label: label.trim(),
|
||||
command, // preserve whitespace in multi-line commands
|
||||
tags: [],
|
||||
package: trimmedPackage || '',
|
||||
targets: [],
|
||||
});
|
||||
}
|
||||
setOpen(false);
|
||||
}, [canSave, packagePath, packages, onCreatePackage, onCreateSnippet, label, command]);
|
||||
}, [canSave, packagePath, packages, onCreatePackage, onCreateSnippet, onUpdateSnippet, editing, label, command]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
@@ -118,7 +150,9 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-md" onKeyDown={handleKeyDown}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('snippets.panel.newTitle')}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{t(editing ? 'snippets.panel.editTitle' : 'snippets.panel.newTitle')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('snippets.empty.desc')}
|
||||
</DialogDescription>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import {
|
||||
Folder,
|
||||
LayoutGrid,
|
||||
Search,
|
||||
FolderLock,
|
||||
LayoutGrid,
|
||||
Plus,
|
||||
Search,
|
||||
Terminal,
|
||||
TerminalSquare,
|
||||
} from "lucide-react";
|
||||
@@ -68,7 +69,7 @@ interface QuickSwitcherProps {
|
||||
onSelectTab: (tabId: string) => void;
|
||||
onClose: () => void;
|
||||
onCreateLocalTerminal?: (shell?: { command: string; args?: string[]; name?: string; icon?: string }) => void;
|
||||
// onCreateWorkspace removed - feature not currently used
|
||||
onCreateWorkspace?: () => void;
|
||||
keyBindings?: KeyBinding[];
|
||||
showSftpTab: boolean;
|
||||
}
|
||||
@@ -84,6 +85,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
onSelectTab,
|
||||
onClose,
|
||||
onCreateLocalTerminal,
|
||||
onCreateWorkspace,
|
||||
keyBindings,
|
||||
showSftpTab,
|
||||
}) => {
|
||||
@@ -280,7 +282,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
<ScrollArea className="flex-1 h-full">
|
||||
{/* Categorized view: Hosts/Tabs/Quick connect */}
|
||||
<div>
|
||||
{/* Jump To hint */}
|
||||
{/* Jump To hint + New Workspace action */}
|
||||
<div className="px-4 py-2 flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">{t("qs.jumpTo")}</span>
|
||||
{quickSwitchKey && (
|
||||
@@ -288,6 +290,20 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
{quickSwitchKey.replace(/ \+ /g, '+')}
|
||||
</kbd>
|
||||
)}
|
||||
{onCreateWorkspace && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onCreateWorkspace();
|
||||
onClose();
|
||||
}}
|
||||
className="ml-auto inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground border border-border rounded px-1.5 py-0.5 transition-colors hover:bg-muted/50"
|
||||
title="New Workspace"
|
||||
>
|
||||
<Plus size={11} />
|
||||
<span>New Workspace</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hosts section */}
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
/**
|
||||
* ScriptsSidePanel - Lightweight scripts browser for the terminal side panel
|
||||
*
|
||||
* Shows snippets organized by package hierarchy with breadcrumb navigation.
|
||||
* Clicking a snippet executes it in the focused terminal session.
|
||||
* Shows snippets organized by package hierarchy as a single tree view.
|
||||
* Packages expand / collapse via a chevron; clicking a snippet executes it
|
||||
* in the focused terminal session. Typing in the search box flattens to a
|
||||
* list of matching snippets regardless of package nesting.
|
||||
*/
|
||||
|
||||
import { ChevronRight, Package, Plus, Search, Zap } from 'lucide-react';
|
||||
import React, { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { ChevronRight, Edit2, FileCode, Package, Plus, Search, Trash2, Zap } from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Snippet } from '../types';
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from './ui/context-menu';
|
||||
import { Input } from './ui/input';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
|
||||
|
||||
interface ScriptsSidePanelProps {
|
||||
snippets: Snippet[];
|
||||
@@ -20,6 +29,33 @@ interface ScriptsSidePanelProps {
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
type TreeRow =
|
||||
| {
|
||||
type: 'package';
|
||||
id: string;
|
||||
path: string;
|
||||
name: string;
|
||||
depth: number;
|
||||
count: number;
|
||||
hasChildren: boolean;
|
||||
isExpanded: boolean;
|
||||
}
|
||||
| {
|
||||
type: 'snippet';
|
||||
id: string;
|
||||
depth: number;
|
||||
snippet: Snippet;
|
||||
packagePath: string;
|
||||
};
|
||||
|
||||
const pkgDisplayName = (path: string) => {
|
||||
const clean = path.startsWith('/') ? path.slice(1) : path;
|
||||
const last = clean.split('/').filter(Boolean).pop() ?? clean;
|
||||
// Preserve the leading slash on absolute root packages so they stay
|
||||
// distinguishable from relative ones (matches the previous breadcrumb UI).
|
||||
return path.startsWith('/') && !clean.includes('/') ? `/${last}` : last;
|
||||
};
|
||||
|
||||
const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
snippets,
|
||||
packages,
|
||||
@@ -27,97 +63,151 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
isVisible = true,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [selectedPackage, setSelectedPackage] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
|
||||
|
||||
const displayedPackages = useMemo(() => {
|
||||
if (!selectedPackage) {
|
||||
const absolutePaths = packages.filter(p => p.startsWith('/'));
|
||||
const relativePaths = packages.filter(p => !p.startsWith('/'));
|
||||
// Normalize the package list + derive ancestor packages implied by each path
|
||||
// (e.g. package "a/b/c" implies roots "a" and "a/b" even when not listed).
|
||||
const normalizedPackages = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
const addWithAncestors = (raw: string) => {
|
||||
const path = raw.trim();
|
||||
if (!path) return;
|
||||
const isAbs = path.startsWith('/');
|
||||
const body = isAbs ? path.slice(1) : path;
|
||||
const parts = body.split('/').filter(Boolean);
|
||||
for (let i = 1; i <= parts.length; i++) {
|
||||
const sub = parts.slice(0, i).join('/');
|
||||
set.add(isAbs ? `/${sub}` : sub);
|
||||
}
|
||||
};
|
||||
packages.forEach(addWithAncestors);
|
||||
// A snippet may reference a package path that's not in `packages` yet.
|
||||
snippets.forEach((s) => {
|
||||
if (s.package) addWithAncestors(s.package);
|
||||
});
|
||||
return set;
|
||||
}, [packages, snippets]);
|
||||
|
||||
const results: { name: string; path: string; count: number }[] = [];
|
||||
// Track every package we've ever observed so we can tell "new" from
|
||||
// "previously-seen-but-user-collapsed". Without this, any unrelated refresh
|
||||
// that reduced prev.size (because the user collapsed a row) would
|
||||
// incorrectly trip a bulk re-expand.
|
||||
const seenPackagesRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const relativeRoots = relativePaths
|
||||
.map((p) => p.split('/')[0])
|
||||
.filter((name): name is string => Boolean(name) && name.length > 0);
|
||||
// Default: auto-expand packages the first time they appear, so the user sees
|
||||
// everything without drilling in. After that, respect the user's collapse
|
||||
// choices across unrelated refreshes.
|
||||
useEffect(() => {
|
||||
const seen = seenPackagesRef.current;
|
||||
const newlySeen: string[] = [];
|
||||
normalizedPackages.forEach((p) => {
|
||||
if (!seen.has(p)) {
|
||||
seen.add(p);
|
||||
newlySeen.push(p);
|
||||
}
|
||||
});
|
||||
if (newlySeen.length === 0) return;
|
||||
setExpandedPaths((prev) => {
|
||||
const next = new Set(prev);
|
||||
newlySeen.forEach((p) => next.add(p));
|
||||
return next;
|
||||
});
|
||||
}, [normalizedPackages]);
|
||||
|
||||
Array.from(new Set(relativeRoots)).forEach((name: string) => {
|
||||
const path: string = name;
|
||||
const count = snippets.filter((s) => {
|
||||
const pkg = s.package || '';
|
||||
return pkg === path || pkg.startsWith(path + '/');
|
||||
}).length;
|
||||
results.push({ name, path, count });
|
||||
});
|
||||
const togglePackage = useCallback((path: string) => {
|
||||
setExpandedPaths((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(path)) next.delete(path);
|
||||
else next.add(path);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const absoluteRoots = absolutePaths
|
||||
.map((p) => {
|
||||
const cleanPath = p.substring(1);
|
||||
return cleanPath.split('/')[0];
|
||||
// When search is active, flatten everything (no tree, no packages).
|
||||
const searchMatches = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return null;
|
||||
return snippets.filter(
|
||||
(s) =>
|
||||
s.label.toLowerCase().includes(q) ||
|
||||
s.command.toLowerCase().includes(q),
|
||||
);
|
||||
}, [snippets, search]);
|
||||
|
||||
const rows = useMemo<TreeRow[]>(() => {
|
||||
if (searchMatches !== null) return [];
|
||||
|
||||
const out: TreeRow[] = [];
|
||||
const paths: string[] = [];
|
||||
normalizedPackages.forEach((p) => paths.push(p));
|
||||
|
||||
const childPackagesOf = (parent: string | null): string[] => {
|
||||
const prefix = parent === null ? '' : parent + '/';
|
||||
return paths
|
||||
.filter((p) => {
|
||||
if (parent === null) {
|
||||
// Root-level: no "/" inside the body
|
||||
const body = p.startsWith('/') ? p.slice(1) : p;
|
||||
return !body.includes('/');
|
||||
}
|
||||
if (!p.startsWith(prefix)) return false;
|
||||
const rest = p.slice(prefix.length);
|
||||
return rest.length > 0 && !rest.includes('/');
|
||||
})
|
||||
.filter((name): name is string => Boolean(name) && name.length > 0);
|
||||
.sort((a, b) => pkgDisplayName(a).localeCompare(pkgDisplayName(b)));
|
||||
};
|
||||
|
||||
Array.from(new Set(absoluteRoots)).forEach((name: string) => {
|
||||
const path: string = `/${name}`;
|
||||
const displayName: string = `/${name}`;
|
||||
const count = snippets.filter((s) => {
|
||||
const pkg = s.package || '';
|
||||
return pkg === path || pkg.startsWith(path + '/');
|
||||
}).length;
|
||||
results.push({ name: displayName, path, count });
|
||||
const snippetsIn = (pkg: string | null): Snippet[] =>
|
||||
snippets
|
||||
.filter((s) => (s.package || '') === (pkg ?? ''))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
|
||||
const countDescendants = (pkg: string): number =>
|
||||
snippets.filter((s) => {
|
||||
const sp = s.package || '';
|
||||
return sp === pkg || sp.startsWith(pkg + '/');
|
||||
}).length;
|
||||
|
||||
const walk = (pkg: string, depth: number) => {
|
||||
const children = childPackagesOf(pkg);
|
||||
const localSnippets = snippetsIn(pkg);
|
||||
const hasChildren = children.length > 0 || localSnippets.length > 0;
|
||||
const isExpanded = expandedPaths.has(pkg);
|
||||
|
||||
out.push({
|
||||
type: 'package',
|
||||
id: pkg,
|
||||
path: pkg,
|
||||
name: pkgDisplayName(pkg),
|
||||
depth,
|
||||
count: countDescendants(pkg),
|
||||
hasChildren,
|
||||
isExpanded,
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
const prefix = selectedPackage + '/';
|
||||
const children = packages
|
||||
.filter((p) => p.startsWith(prefix))
|
||||
.map((p) => p.replace(prefix, '').split('/')[0])
|
||||
.filter((name): name is string => Boolean(name) && name.length > 0);
|
||||
return Array.from(new Set(children)).map((name) => {
|
||||
const path = `${selectedPackage}/${name}`;
|
||||
const count = snippets.filter((s) => {
|
||||
const pkg = s.package || '';
|
||||
return pkg === path || pkg.startsWith(path + '/');
|
||||
}).length;
|
||||
return { name, path, count };
|
||||
});
|
||||
}, [packages, selectedPackage, snippets]);
|
||||
|
||||
const displayedSnippets = useMemo(() => {
|
||||
let result = snippets.filter((s) => (s.package || '') === (selectedPackage || ''));
|
||||
if (search.trim()) {
|
||||
const s = search.toLowerCase();
|
||||
result = result.filter(sn =>
|
||||
sn.label.toLowerCase().includes(s) ||
|
||||
sn.command.toLowerCase().includes(s)
|
||||
if (!isExpanded) return;
|
||||
children.forEach((c) => walk(c, depth + 1));
|
||||
localSnippets.forEach((s) =>
|
||||
out.push({ type: 'snippet', id: s.id, depth: depth + 1, snippet: s, packagePath: pkg }),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}, [snippets, selectedPackage, search]);
|
||||
};
|
||||
|
||||
// Also filter packages by search when at root level
|
||||
const filteredPackages = useMemo(() => {
|
||||
if (!search.trim()) return displayedPackages;
|
||||
const s = search.toLowerCase();
|
||||
return displayedPackages.filter(pkg => pkg.name.toLowerCase().includes(s));
|
||||
}, [displayedPackages, search]);
|
||||
// Orphan / uncategorized snippets first (package === '')
|
||||
snippetsIn(null).forEach((s) =>
|
||||
out.push({ type: 'snippet', id: s.id, depth: 0, snippet: s, packagePath: '' }),
|
||||
);
|
||||
childPackagesOf(null).forEach((root) => walk(root, 0));
|
||||
|
||||
const breadcrumb = useMemo(() => {
|
||||
if (!selectedPackage) return [];
|
||||
const isAbsolute = selectedPackage.startsWith('/');
|
||||
const parts = selectedPackage.split('/').filter(Boolean);
|
||||
return parts.map((name, idx) => {
|
||||
const pathSegments = parts.slice(0, idx + 1);
|
||||
const path = isAbsolute ? `/${pathSegments.join('/')}` : pathSegments.join('/');
|
||||
return { name, path };
|
||||
});
|
||||
}, [selectedPackage]);
|
||||
return out;
|
||||
}, [normalizedPackages, snippets, expandedPaths, searchMatches]);
|
||||
|
||||
const handleSnippetClick = useCallback((command: string, noAutoRun?: boolean) => {
|
||||
onSnippetClick(command, noAutoRun);
|
||||
}, [onSnippetClick]);
|
||||
const handleSnippetClick = useCallback(
|
||||
(command: string, noAutoRun?: boolean) => {
|
||||
onSnippetClick(command, noAutoRun);
|
||||
},
|
||||
[onSnippetClick],
|
||||
);
|
||||
|
||||
const handleAddSnippet = useCallback(() => {
|
||||
// Let the App shell listen and navigate to the Snippets section with
|
||||
@@ -126,11 +216,24 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
window.dispatchEvent(new CustomEvent('netcatty:snippets:add'));
|
||||
}, []);
|
||||
|
||||
const handleEditSnippet = useCallback((snippet: Snippet) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('netcatty:snippets:edit', { detail: { snippet } }),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleDeleteSnippet = useCallback((id: string) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('netcatty:snippets:delete', { detail: { id } }),
|
||||
);
|
||||
}, []);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
const hasAnyContent = snippets.length > 0 || packages.length > 0;
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div
|
||||
className="h-full flex flex-col bg-background overflow-hidden"
|
||||
data-section="snippets-panel"
|
||||
@@ -157,30 +260,6 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<div className="shrink-0 flex items-center gap-1 px-3 py-1.5 text-[11px] border-b border-border/30 min-h-[28px]">
|
||||
<button
|
||||
className={cn(
|
||||
"hover:text-primary transition-colors truncate",
|
||||
!selectedPackage ? "text-foreground font-medium" : "text-muted-foreground"
|
||||
)}
|
||||
onClick={() => setSelectedPackage(null)}
|
||||
>
|
||||
{t('terminal.toolbar.library')}
|
||||
</button>
|
||||
{breadcrumb.map((b) => (
|
||||
<React.Fragment key={b.path}>
|
||||
<ChevronRight size={10} className="text-muted-foreground shrink-0" />
|
||||
<button
|
||||
className="text-muted-foreground hover:text-primary transition-colors truncate"
|
||||
onClick={() => setSelectedPackage(b.path)}
|
||||
>
|
||||
{b.name}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="py-1">
|
||||
@@ -191,41 +270,47 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Packages */}
|
||||
{filteredPackages.map((pkg) => (
|
||||
<button
|
||||
key={pkg.path}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-left hover:bg-accent/50 transition-colors"
|
||||
onClick={() => { setSelectedPackage(pkg.path); setSearch(''); }}
|
||||
>
|
||||
<div className="w-6 h-6 rounded-md bg-primary/10 text-primary flex items-center justify-center shrink-0">
|
||||
<Package size={12} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium truncate">{pkg.name}</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{t('snippets.package.count', { count: pkg.count })}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight size={12} className="text-muted-foreground shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
{/* Search flat list */}
|
||||
{searchMatches !== null && searchMatches.length > 0 &&
|
||||
searchMatches.map((s) => (
|
||||
<SnippetRow
|
||||
key={s.id}
|
||||
snippet={s}
|
||||
depth={0}
|
||||
subtitle={s.package || t('terminal.toolbar.library')}
|
||||
onClick={() => handleSnippetClick(s.command, s.noAutoRun)}
|
||||
onEdit={() => handleEditSnippet(s)}
|
||||
onDelete={() => handleDeleteSnippet(s.id)}
|
||||
editLabel={t('action.edit')}
|
||||
deleteLabel={t('action.delete')}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Snippets */}
|
||||
{displayedSnippets.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => handleSnippetClick(s.command, s.noAutoRun)}
|
||||
className="w-full text-left px-3 py-2 hover:bg-accent/50 transition-colors flex flex-col gap-0.5"
|
||||
>
|
||||
<span className="text-xs font-medium truncate">{s.label}</span>
|
||||
<span className="text-muted-foreground truncate font-mono text-[10px] max-w-full">
|
||||
{s.command}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{/* Tree */}
|
||||
{searchMatches === null &&
|
||||
rows.map((row) =>
|
||||
row.type === 'package' ? (
|
||||
<PackageRow
|
||||
key={`pkg:${row.id}`}
|
||||
row={row}
|
||||
countLabel={t('snippets.package.count', { count: row.count })}
|
||||
onToggle={() => togglePackage(row.path)}
|
||||
/>
|
||||
) : (
|
||||
<SnippetRow
|
||||
key={`snip:${row.id}`}
|
||||
snippet={row.snippet}
|
||||
depth={row.depth}
|
||||
onClick={() => handleSnippetClick(row.snippet.command, row.snippet.noAutoRun)}
|
||||
onEdit={() => handleEditSnippet(row.snippet)}
|
||||
onDelete={() => handleDeleteSnippet(row.snippet.id)}
|
||||
editLabel={t('action.edit')}
|
||||
deleteLabel={t('action.delete')}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
||||
{hasAnyContent && displayedSnippets.length === 0 && filteredPackages.length === 0 && search.trim() && (
|
||||
{hasAnyContent && searchMatches !== null && searchMatches.length === 0 && (
|
||||
<div className="px-3 py-4 text-xs text-muted-foreground italic text-center">
|
||||
{t('common.noResultsFound')}
|
||||
</div>
|
||||
@@ -233,8 +318,100 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
interface PackageRowProps {
|
||||
row: Extract<TreeRow, { type: 'package' }>;
|
||||
countLabel: string;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
const PackageRow: React.FC<PackageRowProps> = ({ row, countLabel, onToggle }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="w-full flex items-center gap-1.5 pr-3 py-1.5 text-left hover:bg-accent/50 transition-colors"
|
||||
style={{ paddingLeft: 8 + row.depth * 14 }}
|
||||
>
|
||||
<ChevronRight
|
||||
size={12}
|
||||
className={cn(
|
||||
'shrink-0 text-muted-foreground transition-transform',
|
||||
row.isExpanded && 'rotate-90',
|
||||
!row.hasChildren && 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
<Package size={12} className="shrink-0 text-primary/80" />
|
||||
<span className="flex-1 min-w-0 truncate text-xs font-medium">{row.name}</span>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground tabular-nums">{countLabel}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
interface SnippetRowProps {
|
||||
snippet: Snippet;
|
||||
depth: number;
|
||||
subtitle?: string;
|
||||
onClick: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
editLabel: string;
|
||||
deleteLabel: string;
|
||||
}
|
||||
|
||||
const SnippetRow: React.FC<SnippetRowProps> = ({
|
||||
snippet,
|
||||
depth,
|
||||
subtitle,
|
||||
onClick,
|
||||
onEdit,
|
||||
onDelete,
|
||||
editLabel,
|
||||
deleteLabel,
|
||||
}) => (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="w-full flex items-center gap-1.5 pr-3 py-1.5 text-left hover:bg-accent/50 transition-colors overflow-hidden"
|
||||
style={{ paddingLeft: 8 + depth * 14 }}
|
||||
>
|
||||
{/* Hidden chevron column mirrors PackageRow's layout so the
|
||||
snippet icon lines up exactly with the package icon above. */}
|
||||
<ChevronRight size={12} className="shrink-0 opacity-0" aria-hidden />
|
||||
<FileCode size={12} className="shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 min-w-0 truncate text-xs font-medium">{snippet.label}</span>
|
||||
{subtitle && (
|
||||
<span className="shrink-0 max-w-[40%] truncate text-[10px] text-muted-foreground">
|
||||
{subtitle}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" align="start" className="max-w-[480px]">
|
||||
<div className="font-medium text-xs mb-1 break-all">{snippet.label}</div>
|
||||
<pre className="font-mono text-[11px] whitespace-pre-wrap break-all leading-snug opacity-90">
|
||||
{snippet.command}
|
||||
</pre>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={onEdit}>
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {editLabel}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem className="text-destructive" onClick={onDelete}>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> {deleteLabel}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
export const ScriptsSidePanel = memo(ScriptsSidePanelInner);
|
||||
ScriptsSidePanel.displayName = 'ScriptsSidePanel';
|
||||
|
||||
@@ -152,7 +152,14 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
|
||||
<div className="flex items-center gap-4">
|
||||
<AppLogo className="w-16 h-16" />
|
||||
<div>
|
||||
<div className="text-3xl font-semibold leading-none">{appInfo.name}</div>
|
||||
{/* Match the Vault sidebar wordmark so the Netcatty brand
|
||||
reads consistently across surfaces — same italic heavy
|
||||
cut, just scaled up for the Settings hero area and
|
||||
using the branded mixed-case "Netcatty" instead of
|
||||
the lowercase electron app name. */}
|
||||
<div className="text-3xl font-black italic tracking-tight leading-none text-foreground">
|
||||
Netcatty
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{appInfo.version ? appInfo.version : " "}
|
||||
|
||||
@@ -14,6 +14,8 @@ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "
|
||||
import { formatHostPort } from "../domain/host";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useSftpState } from "../application/state/useSftpState";
|
||||
import { registerEditorSftpWriterScoped } from "../application/state/editorSftpBridge";
|
||||
import { editorTabStore } from "../application/state/editorTabStore";
|
||||
import { useSftpBackend } from "../application/state/useSftpBackend";
|
||||
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
|
||||
import { getParentPath } from "../application/state/sftp/utils";
|
||||
@@ -45,6 +47,7 @@ interface SftpSidePanelProps {
|
||||
/** The host to connect to (follows focused terminal) */
|
||||
activeHost: Host | null;
|
||||
initialLocation?: { hostId: string; path: string } | null;
|
||||
onInitialLocationApplied?: (location: { hostId: string; path: string }) => void;
|
||||
showWorkspaceHostHeader?: boolean;
|
||||
isVisible?: boolean;
|
||||
renderOverlays?: boolean;
|
||||
@@ -65,6 +68,7 @@ interface SftpSidePanelProps {
|
||||
editorWordWrap: boolean;
|
||||
setEditorWordWrap: (value: boolean) => void;
|
||||
onGetTerminalCwd?: () => Promise<string | null>;
|
||||
onRequestTerminalFocus?: () => void;
|
||||
}
|
||||
|
||||
const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
@@ -75,6 +79,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
sftpDefaultViewMode,
|
||||
activeHost,
|
||||
initialLocation,
|
||||
onInitialLocationApplied,
|
||||
showWorkspaceHostHeader = false,
|
||||
isVisible = true,
|
||||
renderOverlays = true,
|
||||
@@ -89,6 +94,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
onGetTerminalCwd,
|
||||
onRequestTerminalFocus,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -125,6 +131,46 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
const sftpRef = useRef(sftp);
|
||||
sftpRef.current = sftp;
|
||||
|
||||
// Register this instance's writeTextFileByConnection with the editor bridge
|
||||
// so editor tabs promoted from SFTP files opened in a terminal side panel
|
||||
// can still route saves through this useSftpState.
|
||||
//
|
||||
// Intentionally no deps — go through sftpRef so SFTP state churn (transfers,
|
||||
// tab switches, listings) doesn't make this unregister+reregister on every
|
||||
// re-render.
|
||||
useEffect(() => {
|
||||
return registerEditorSftpWriterScoped((connectionId, expectedHostId, filePath, content, encoding) =>
|
||||
sftpRef.current.writeTextFileByConnection(connectionId, expectedHostId, filePath, content, encoding),
|
||||
);
|
||||
}, []);
|
||||
|
||||
// When this side panel unmounts (its hosting terminal tab was closed) we
|
||||
// force-close any editor tabs bound to connections this panel owned — the
|
||||
// save channel is gone with the SFTP session and there's no way to recover
|
||||
// it. Dirty state is dropped intentionally; the user closed the terminal
|
||||
// knowing the file was open.
|
||||
//
|
||||
// Collect every connection id across all left/right tabs — the panel can
|
||||
// host multiple SFTP tabs per side, and an editor tab promoted from an
|
||||
// inactive-pane tab would otherwise be stranded by the unmount.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const s = sftpRef.current;
|
||||
if (!s) return;
|
||||
const owned = new Set<string>();
|
||||
for (const tab of s.leftTabs?.tabs ?? []) {
|
||||
const id = tab.connection?.id;
|
||||
if (id) owned.add(id);
|
||||
}
|
||||
for (const tab of s.rightTabs?.tabs ?? []) {
|
||||
const id = tab.connection?.id;
|
||||
if (id) owned.add(id);
|
||||
}
|
||||
if (owned.size === 0) return;
|
||||
editorTabStore.forceCloseBySessions([...owned]);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const behaviorRef = useRef(sftpDoubleClickBehavior);
|
||||
behaviorRef.current = sftpDoubleClickBehavior;
|
||||
|
||||
@@ -224,6 +270,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
fileOpenerTarget,
|
||||
setFileOpenerTarget,
|
||||
handleSaveTextFile,
|
||||
onPromoteToTab,
|
||||
handleFileOpenerSelect,
|
||||
handleSelectSystemApp,
|
||||
} = useSftpViewPaneCallbacks({
|
||||
@@ -422,16 +469,18 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
const locationKey = `${connectedKeyRef.current}:${initialLocation.path}`;
|
||||
if (lastAppliedInitialLocationKeyRef.current === locationKey) return;
|
||||
|
||||
lastAppliedInitialLocationKeyRef.current = locationKey;
|
||||
onInitialLocationApplied?.(initialLocation);
|
||||
|
||||
if (connection.currentPath === initialLocation.path) {
|
||||
lastAppliedInitialLocationKeyRef.current = locationKey;
|
||||
return;
|
||||
}
|
||||
|
||||
lastAppliedInitialLocationKeyRef.current = locationKey;
|
||||
sftpRef.current.navigateTo("left", initialLocation.path);
|
||||
}, [
|
||||
activeHost,
|
||||
initialLocation,
|
||||
onInitialLocationApplied,
|
||||
sftp.leftPane,
|
||||
]);
|
||||
|
||||
@@ -679,6 +728,8 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
setFileOpenerTarget={setFileOpenerTarget}
|
||||
handleFileOpenerSelect={handleFileOpenerSelect}
|
||||
handleSelectSystemApp={handleSelectSystemApp}
|
||||
onPromoteToTab={onPromoteToTab}
|
||||
onRequestTerminalFocus={onRequestTerminalFocus}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
@@ -707,6 +758,7 @@ const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps):
|
||||
prev.editorWordWrap === next.editorWordWrap &&
|
||||
prev.setEditorWordWrap === next.setEditorWordWrap &&
|
||||
prev.onGetTerminalCwd === next.onGetTerminalCwd &&
|
||||
prev.onRequestTerminalFocus === next.onRequestTerminalFocus &&
|
||||
prev.initialLocation?.hostId === next.initialLocation?.hostId &&
|
||||
prev.initialLocation?.path === next.initialLocation?.path;
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* - components/sftp/SftpHostPicker.tsx - Host selection dialog
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback, useLayoutEffect, useMemo, useRef } from "react";
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useIsSftpActive } from "../application/state/activeTabStore";
|
||||
import { useSftpState } from "../application/state/useSftpState";
|
||||
@@ -27,6 +27,7 @@ import { useInstantThemeSwitch } from "../lib/useInstantThemeSwitch";
|
||||
import { Host, Identity, SSHKey } from "../types";
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
|
||||
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
|
||||
import { registerEditorSftpWriterScoped } from "../application/state/editorSftpBridge";
|
||||
import { toast } from "./ui/toast";
|
||||
|
||||
// Import extracted components
|
||||
@@ -135,6 +136,23 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
const sftpRef = useRef(sftp);
|
||||
sftpRef.current = sftp;
|
||||
|
||||
// Register this useSftpState's writeTextFileByConnection with the bridge so
|
||||
// the editor tab's save path can reach the active SFTP session. The bridge
|
||||
// supports multiple simultaneous writers (SftpSidePanel inside terminals
|
||||
// also registers its own instance) and dispatches by trying each until one
|
||||
// owns the target connectionId.
|
||||
//
|
||||
// Intentionally no deps: `sftp` identity churns on every SFTP state change
|
||||
// (transfers, pane updates, tab switches), which would make this effect
|
||||
// unregister+reregister constantly. Route through sftpRef so the closure
|
||||
// always reads the latest writeTextFileByConnection; that method is stable
|
||||
// across sftp re-renders (it's a methodsRef-backed dispatcher).
|
||||
useEffect(() => {
|
||||
return registerEditorSftpWriterScoped((connectionId, expectedHostId, filePath, content, encoding) =>
|
||||
sftpRef.current.writeTextFileByConnection(connectionId, expectedHostId, filePath, content, encoding),
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Store behavior setting in ref for stable callbacks
|
||||
const behaviorRef = useRef(sftpDoubleClickBehavior);
|
||||
behaviorRef.current = sftpDoubleClickBehavior;
|
||||
@@ -219,6 +237,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
fileOpenerTarget,
|
||||
setFileOpenerTarget,
|
||||
handleSaveTextFile,
|
||||
onPromoteToTab,
|
||||
handleFileOpenerSelect,
|
||||
handleSelectSystemApp,
|
||||
} = useSftpViewPaneCallbacks({
|
||||
@@ -475,6 +494,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
setFileOpenerTarget={setFileOpenerTarget}
|
||||
handleFileOpenerSelect={handleFileOpenerSelect}
|
||||
handleSelectSystemApp={handleSelectSystemApp}
|
||||
onPromoteToTab={onPromoteToTab}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -402,9 +402,15 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
}, [packages, selectedPackage, snippets]);
|
||||
|
||||
const displayedSnippets = useMemo(() => {
|
||||
let result = snippets.filter((s) => (s.package || '') === (selectedPackage || ''));
|
||||
// Apply search filter
|
||||
if (search.trim()) {
|
||||
// Search spans all packages (#777): when the user types in the search
|
||||
// box we drop the current-package scoping so cross-package matches are
|
||||
// reachable without navigating into each one. Otherwise the user is
|
||||
// browsing and we keep the package scope.
|
||||
const hasSearch = search.trim().length > 0;
|
||||
let result = hasSearch
|
||||
? snippets
|
||||
: snippets.filter((s) => (s.package || '') === (selectedPackage || ''));
|
||||
if (hasSearch) {
|
||||
const s = search.toLowerCase();
|
||||
result = result.filter(sn =>
|
||||
sn.label.toLowerCase().includes(s) ||
|
||||
@@ -734,16 +740,35 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
title={editingSnippet.id ? t('snippets.panel.editTitle') : t('snippets.panel.newTitle')}
|
||||
layout="inline"
|
||||
actions={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={handleSubmit}
|
||||
disabled={!editingSnippet.label || !editingSnippet.command}
|
||||
aria-label={t('common.save')}
|
||||
>
|
||||
<Check size={16} />
|
||||
</Button>
|
||||
<>
|
||||
{editingSnippet.id && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={() => {
|
||||
const id = editingSnippet.id;
|
||||
if (!id) return;
|
||||
onDelete(id);
|
||||
handleClosePanel();
|
||||
}}
|
||||
aria-label={t('common.delete')}
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={handleSubmit}
|
||||
disabled={!editingSnippet.label || !editingSnippet.command}
|
||||
aria-label={t('common.save')}
|
||||
>
|
||||
<Check size={16} />
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<AsidePanelContent>
|
||||
@@ -958,8 +983,8 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="h-full min-h-0 flex relative">
|
||||
<div className="flex-1 flex flex-col min-h-0 min-w-0 overflow-hidden">
|
||||
<header className="border-b border-border/50 bg-secondary/80 backdrop-blur">
|
||||
<div className="h-14 px-4 py-2 flex items-center gap-2">
|
||||
<header className="border-b border-border/50 bg-secondary/80 supports-[backdrop-filter]:backdrop-blur-sm">
|
||||
<div className="h-14 px-4 py-2 flex items-center gap-3">
|
||||
{/* Search box */}
|
||||
<div className="relative w-64">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
@@ -980,7 +1005,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
}}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-10 gap-2"
|
||||
className="h-10 gap-2 bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40"
|
||||
>
|
||||
<FolderPlus size={14} className="mr-1" /> {t('snippets.action.newPackage')}
|
||||
</Button>
|
||||
@@ -1049,7 +1074,10 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
)}
|
||||
|
||||
<div className="flex-1 space-y-3 overflow-y-auto px-4 pb-4">
|
||||
{displayedPackages.length > 0 && (
|
||||
{/* Hide the sub-package grid while searching (#777) — search spans
|
||||
all packages, so showing the package tiles alongside a flat
|
||||
cross-package snippet list is noisy. */}
|
||||
{displayedPackages.length > 0 && !search.trim() && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">{t('snippets.section.packages')}</h3>
|
||||
@@ -1196,6 +1224,29 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search-with-no-results feedback (#777 codex follow-up). Package
|
||||
tiles are already hidden during search, so the only visible
|
||||
surface is the flat snippet list — if that's empty the content
|
||||
area would be blank without this fallback. The gate intentionally
|
||||
excludes the fully-empty workspace (snippets.length === 0 AND
|
||||
displayedPackages.length === 0), which the global "Create
|
||||
snippet" empty state renders instead — avoids stacking two
|
||||
empty states. Package-only workspaces (no snippets yet) still
|
||||
get this feedback when searching. */}
|
||||
{search.trim() && displayedSnippets.length === 0 && (snippets.length > 0 || displayedPackages.length > 0) && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||
<div className="h-14 w-14 rounded-2xl bg-secondary/80 flex items-center justify-center mb-3">
|
||||
<Search size={24} className="opacity-60" />
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-foreground mb-1">
|
||||
{t('snippets.search.noResults.title')}
|
||||
</h3>
|
||||
<p className="text-xs text-center max-w-sm">
|
||||
{t('snippets.search.noResults.desc', { query: search.trim() })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -136,7 +136,13 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
|
||||
// Determine overall status for the button indicator
|
||||
const getOverallStatus = (): StatusIndicatorProps['status'] => {
|
||||
if (sync.overallSyncStatus === 'syncing') return 'syncing';
|
||||
if (sync.overallSyncStatus === 'error' || sync.overallSyncStatus === 'conflict') return 'error';
|
||||
if (
|
||||
sync.overallSyncStatus === 'error' ||
|
||||
sync.overallSyncStatus === 'conflict' ||
|
||||
sync.overallSyncStatus === 'blocked'
|
||||
) {
|
||||
return 'error';
|
||||
}
|
||||
if (sync.overallSyncStatus === 'synced') return 'synced';
|
||||
return 'none';
|
||||
};
|
||||
|
||||
@@ -49,6 +49,8 @@ import { ZmodemProgressIndicator } from "./terminal/ZmodemProgressIndicator";
|
||||
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
|
||||
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
|
||||
import { createXTermRuntime, primaryFontFamily, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
|
||||
import { applyUserCursorPreference } from "./terminal/runtime/cursorPreference";
|
||||
import { shouldPreserveTerminalFocusOnMouseDown } from "./terminal/toolbarFocus";
|
||||
import { preserveTerminalViewportInScrollback } from "./terminal/clearTerminalViewport";
|
||||
import { XTERM_PERFORMANCE_CONFIG } from "../infrastructure/config/xtermPerformance";
|
||||
import { useTerminalSearch } from "./terminal/hooks/useTerminalSearch";
|
||||
@@ -183,6 +185,29 @@ function formatNetSpeed(bytesPerSec: number): string {
|
||||
}
|
||||
}
|
||||
|
||||
type XTermWithPrivateRenderService = XTerm & {
|
||||
_core?: {
|
||||
_renderService?: {
|
||||
_renderRows?: (start: number, end: number) => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
function forceSyncRenderAfterResize(term: XTerm): void {
|
||||
const renderService = (term as XTermWithPrivateRenderService)._core?._renderService;
|
||||
const renderRows = renderService?._renderRows;
|
||||
if (typeof renderRows !== "function") return;
|
||||
|
||||
const endRow = term.rows - 1;
|
||||
if (endRow < 0) return;
|
||||
|
||||
try {
|
||||
renderRows.call(renderService, 0, endRow);
|
||||
} catch (err) {
|
||||
logger.warn("Sync render after resize failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
host,
|
||||
keys,
|
||||
@@ -373,6 +398,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
});
|
||||
const terminalEncodingRef = useRef(terminalEncoding);
|
||||
terminalEncodingRef.current = terminalEncoding;
|
||||
// True only after the user actively picks an encoding from the toolbar.
|
||||
// onSessionAttached uses this to decide whether to override the backend's
|
||||
// initial charset for telnet/serial reconnects — on a first attach we
|
||||
// must not overwrite arbitrary host.charset values (latin1/shift_jis/...)
|
||||
// that the UI's two-value state can't represent.
|
||||
const userPickedEncodingRef = useRef(false);
|
||||
|
||||
const terminalSearch = useTerminalSearch({ searchAddonRef, termRef });
|
||||
const {
|
||||
@@ -620,6 +651,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
termRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleTopOverlayMouseDownCapture = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.button !== 0) return;
|
||||
if (!shouldPreserveTerminalFocusOnMouseDown(e.target)) return;
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
// Subscribe to custom theme changes so editing triggers re-render
|
||||
const customThemes = useCustomThemes();
|
||||
const hasFontSizeOverride = host.fontSizeOverride === true || (host.fontSizeOverride === undefined && host.fontSize != null);
|
||||
@@ -733,10 +770,27 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
setChainProgress,
|
||||
t,
|
||||
onSessionAttached: (id: string) => {
|
||||
// Sync terminal encoding to SSH backend before first data arrives
|
||||
const isSSH = host.protocol !== 'local' && host.protocol !== 'serial' && host.protocol !== 'telnet' && host.protocol !== 'mosh' && !host.moshEnabled && !host.id?.startsWith('local-') && !host.id?.startsWith('serial-') && host.hostname !== 'localhost';
|
||||
// SSH: always sync. Its backend starts in utf-8 regardless of
|
||||
// host.charset, so the push is what keeps the UI state aligned
|
||||
// across reconnects — including localhost SSH targets, hence
|
||||
// hostname isn't in the gate.
|
||||
const isLocal = host.protocol === 'local' || host.id?.startsWith('local-');
|
||||
const isSerial = host.protocol === 'serial' || host.id?.startsWith('serial-');
|
||||
const isTelnet = host.protocol === 'telnet';
|
||||
const isMosh = host.protocol === 'mosh' || host.moshEnabled;
|
||||
const isSSH = !isLocal && !isSerial && !isTelnet && !isMosh;
|
||||
if (isSSH) {
|
||||
setSessionEncoding(id, terminalEncodingRef.current);
|
||||
return;
|
||||
}
|
||||
// Telnet / serial: the backend already applied host.charset
|
||||
// (including arbitrary iconv labels like latin1 / shift_jis that
|
||||
// the UI's two-value state can't represent) through start*Session
|
||||
// options, so don't clobber it on first attach. Only re-sync once
|
||||
// the user has explicitly picked from the toolbar menu — that's
|
||||
// the signal they want the UI choice to win on reconnect.
|
||||
if ((isTelnet || isSerial) && userPickedEncodingRef.current) {
|
||||
setSessionEncoding(id, terminalEncodingRef.current);
|
||||
}
|
||||
},
|
||||
onSessionExit,
|
||||
@@ -793,6 +847,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// Autocomplete integration
|
||||
onAutocompleteKeyEvent: (e: KeyboardEvent) => autocompleteKeyEventRef.current?.(e) ?? true,
|
||||
onAutocompleteInput: (data: string) => autocompleteInputRef.current?.(data),
|
||||
isRestoringSelectionRef,
|
||||
});
|
||||
|
||||
xtermRuntimeRef.current = runtime;
|
||||
@@ -951,8 +1006,21 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
const runFit = () => {
|
||||
try {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
|
||||
const dimensions = fitAddon.proposeDimensions();
|
||||
if (!dimensions || Number.isNaN(dimensions.cols) || Number.isNaN(dimensions.rows)) return;
|
||||
|
||||
lastFittedSizeRef.current = { width, height };
|
||||
fitAddon.fit();
|
||||
// addon-fit 0.11 clears the renderer before resizing, which can show
|
||||
// as a one-frame WebGL blink during layout changes. Resize directly
|
||||
// using the proposed dimensions to preserve the existing behavior
|
||||
// without forcing a blank intermediate frame.
|
||||
if (term.cols !== dimensions.cols || term.rows !== dimensions.rows) {
|
||||
term.resize(dimensions.cols, dimensions.rows);
|
||||
forceSyncRenderAfterResize(term);
|
||||
}
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
requestAnimationFrame(() => {
|
||||
autocompleteRepositionRef.current?.();
|
||||
@@ -994,8 +1062,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
termRef.current.options.fontFamily = resolvedFontFamily;
|
||||
|
||||
if (terminalSettings) {
|
||||
termRef.current.options.cursorStyle = terminalSettings.cursorShape;
|
||||
termRef.current.options.cursorBlink = terminalSettings.cursorBlink;
|
||||
applyUserCursorPreference(termRef.current, terminalSettings);
|
||||
termRef.current.options.scrollback = terminalSettings.scrollback === 0 ? 999999 : terminalSettings.scrollback;
|
||||
termRef.current.options.fontWeight = effectiveFontWeight as
|
||||
| 100
|
||||
@@ -1230,7 +1297,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const hasText = !!selection && selection.length > 0;
|
||||
setHasSelection(hasText);
|
||||
|
||||
if (hasText && terminalSettings?.copyOnSelect) {
|
||||
if (hasText && terminalSettings?.copyOnSelect && !isRestoringSelectionRef.current) {
|
||||
navigator.clipboard.writeText(selection).catch((err) => {
|
||||
logger.warn("Copy on select failed:", err);
|
||||
});
|
||||
@@ -1321,6 +1388,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const disableBracketedPasteRef = useRef(terminalSettings?.disableBracketedPaste ?? false);
|
||||
disableBracketedPasteRef.current = terminalSettings?.disableBracketedPaste ?? false;
|
||||
|
||||
// True only while createXTermRuntime is programmatically restoring the
|
||||
// selection right after a keystroke (preserveSelectionOnInput). Lets
|
||||
// copy-on-select skip a redundant clipboard write that would otherwise
|
||||
// clobber whatever the user copied elsewhere in the meantime.
|
||||
const isRestoringSelectionRef = useRef(false);
|
||||
|
||||
const scrollOnPasteRef = useRef(terminalSettings?.scrollOnPaste ?? true);
|
||||
scrollOnPasteRef.current = terminalSettings?.scrollOnPaste ?? true;
|
||||
|
||||
@@ -1373,6 +1446,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
const handleSetTerminalEncoding = (encoding: 'utf-8' | 'gb18030') => {
|
||||
setTerminalEncoding(encoding);
|
||||
userPickedEncodingRef.current = true;
|
||||
if (sessionRef.current) {
|
||||
setSessionEncoding(sessionRef.current, encoding);
|
||||
}
|
||||
@@ -1706,6 +1780,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
<div className="absolute left-0 right-0 top-0 z-20 pointer-events-none">
|
||||
<div
|
||||
className="flex items-center gap-1 px-2 py-0.5 backdrop-blur-md pointer-events-auto min-w-0"
|
||||
onMouseDownCapture={handleTopOverlayMouseDownCapture}
|
||||
style={{
|
||||
backgroundColor: 'var(--terminal-ui-bg)',
|
||||
color: 'var(--terminal-ui-fg)',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Circle, FolderTree, LayoutGrid, MessageSquare, PanelLeft, PanelRight, Palette, Server, X, Zap } from 'lucide-react';
|
||||
import { Circle, Columns2, FolderTree, MessageSquare, PanelLeft, PanelRight, Palette, Plus, Search, Server, X, Zap } from 'lucide-react';
|
||||
import React, { createContext, memo, startTransition, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useActiveTabId } from '../application/state/activeTabStore';
|
||||
import {
|
||||
@@ -29,7 +29,10 @@ 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 {
|
||||
STORAGE_KEY_SIDE_PANEL_WIDTH,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_SIDEBAR_WIDTH,
|
||||
} from '../infrastructure/config/storageKeys';
|
||||
import { buildCacheKey } from '../application/state/sftp/sharedRemoteHostCache';
|
||||
import type { DropEntry } from '../lib/sftpFileUtils';
|
||||
import { GroupConfig, Host, Identity, KnownHost, SSHKey, Snippet, TerminalSession, TerminalTheme, Workspace, WorkspaceNode } from '../types';
|
||||
@@ -40,14 +43,18 @@ import Terminal from './Terminal';
|
||||
import { SftpSidePanel } from './SftpSidePanel';
|
||||
import { ScriptsSidePanel } from './ScriptsSidePanel';
|
||||
import { ThemeSidePanel } from './terminal/ThemeSidePanel';
|
||||
import { focusTerminalSessionInput } from './terminal/focusTerminalSession';
|
||||
import { AIChatSidePanel } from './AIChatSidePanel';
|
||||
import { useAIState } from '../application/state/useAIState';
|
||||
import { TerminalComposeBar } from './terminal/TerminalComposeBar';
|
||||
import { TERMINAL_THEMES } from '../infrastructure/config/terminalThemes';
|
||||
import { useCustomThemes } from '../application/state/customThemeStore';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { RippleButton } from './ui/ripple';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { setupMcpApprovalBridge } from '../infrastructure/ai/shared/approvalGate';
|
||||
import { resolveScriptsSidePanelShortcutIntent } from '../application/state/resolveSnippetsShortcutIntent';
|
||||
|
||||
type SidePanelTab = 'sftp' | 'scripts' | 'theme' | 'ai';
|
||||
|
||||
@@ -340,7 +347,6 @@ 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}
|
||||
@@ -408,6 +414,7 @@ interface TerminalLayerProps {
|
||||
onTerminalDataCapture?: (sessionId: string, data: string) => void;
|
||||
onCreateWorkspaceFromSessions: (baseSessionId: string, joiningSessionId: string, hint: Exclude<SplitHint, null>) => void;
|
||||
onAddSessionToWorkspace: (workspaceId: string, sessionId: string, hint: Exclude<SplitHint, null>) => void;
|
||||
onRequestAddToWorkspace?: (workspaceId: string) => void;
|
||||
onUpdateSplitSizes: (workspaceId: string, splitId: string, sizes: number[]) => void;
|
||||
onSetDraggingSessionId: (id: string | null) => void;
|
||||
onToggleWorkspaceViewMode?: (workspaceId: string) => void;
|
||||
@@ -430,6 +437,9 @@ interface TerminalLayerProps {
|
||||
sessionLogsEnabled?: boolean;
|
||||
sessionLogsDir?: string;
|
||||
sessionLogsFormat?: string;
|
||||
closeSidePanelRef?: React.MutableRefObject<(() => void) | null>;
|
||||
toggleScriptsSidePanelRef?: React.MutableRefObject<(() => void) | null>;
|
||||
activeSidePanelTabRef?: React.MutableRefObject<string | null>;
|
||||
}
|
||||
|
||||
const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
@@ -464,6 +474,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onTerminalDataCapture,
|
||||
onCreateWorkspaceFromSessions,
|
||||
onAddSessionToWorkspace,
|
||||
onRequestAddToWorkspace,
|
||||
onUpdateSplitSizes,
|
||||
onSetDraggingSessionId,
|
||||
onToggleWorkspaceViewMode,
|
||||
@@ -483,6 +494,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
sessionLogsEnabled,
|
||||
sessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
closeSidePanelRef,
|
||||
toggleScriptsSidePanelRef,
|
||||
activeSidePanelTabRef,
|
||||
}) => {
|
||||
// Subscribe to activeTabId from external store
|
||||
const activeTabId = useActiveTabId();
|
||||
@@ -597,6 +611,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const workspaceInnerRef = useRef<HTMLDivElement>(null);
|
||||
const workspaceOverlayRef = useRef<HTMLDivElement>(null);
|
||||
const [dropHint, setDropHint] = useState<SplitHint>(null);
|
||||
// Focus-mode sidebar: client-side filter for the terminal list.
|
||||
const [focusSidebarSearch, setFocusSidebarSearch] = useState('');
|
||||
const [themePreview, setThemePreview] = useState<{ targetSessionId: string | null; themeId: string | null }>({
|
||||
targetSessionId: null,
|
||||
themeId: null,
|
||||
@@ -651,6 +667,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const [sidePanelWidth, setSidePanelWidth, persistSidePanelWidth] = useStoredNumber(
|
||||
STORAGE_KEY_SIDE_PANEL_WIDTH, 420, { min: 280, max: 800 },
|
||||
);
|
||||
const [focusSidebarWidth, setFocusSidebarWidth, persistFocusSidebarWidth] = useStoredNumber(
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_SIDEBAR_WIDTH, 224, { min: 160, max: 480 },
|
||||
);
|
||||
const [sidePanelPosition, setSidePanelPosition] = useStoredString<'left' | 'right'>(
|
||||
'netcatty_side_panel_position',
|
||||
'left',
|
||||
@@ -663,6 +682,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
// Whether side panel is open for the currently active tab and which sub-panel
|
||||
const isSidePanelOpenForCurrentTab = activeTabId ? sidePanelOpenTabs.has(activeTabId) : false;
|
||||
const activeSidePanelTab = activeTabId ? sidePanelOpenTabs.get(activeTabId) ?? null : null;
|
||||
if (activeSidePanelTabRef) {
|
||||
activeSidePanelTabRef.current = activeSidePanelTab;
|
||||
}
|
||||
|
||||
// Legacy compatibility helpers for SFTP-specific logic
|
||||
const isSftpOpenForCurrentTab = activeSidePanelTab === 'sftp';
|
||||
@@ -775,6 +797,47 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSftpInitialLocationApplied = useCallback((tabId: string, location: { hostId: string; path: string }) => {
|
||||
setSftpInitialLocationForTab(prev => {
|
||||
const current = prev.get(tabId);
|
||||
if (!current || current.hostId !== location.hostId || current.path !== location.path) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.delete(tabId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Focus-mode workspace sidebar resize handler. The sidebar is always
|
||||
// anchored to the left of the workspace area, so a rightward drag grows it.
|
||||
const handleFocusSidebarResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const startWidth = focusSidebarWidth;
|
||||
|
||||
let lastWidth = startWidth;
|
||||
let rafId: number | null = null;
|
||||
const onMouseMove = (ev: MouseEvent) => {
|
||||
const delta = ev.clientX - startX;
|
||||
lastWidth = Math.max(160, Math.min(480, startWidth + delta));
|
||||
if (rafId !== null) return;
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null;
|
||||
setFocusSidebarWidth(lastWidth);
|
||||
});
|
||||
};
|
||||
const onMouseUp = () => {
|
||||
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||
setFocusSidebarWidth(lastWidth);
|
||||
persistFocusSidebarWidth(lastWidth);
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
}, [focusSidebarWidth, setFocusSidebarWidth, persistFocusSidebarWidth]);
|
||||
|
||||
// Side panel resize handler
|
||||
const handleSidePanelResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -1247,9 +1310,26 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
[sidePanelOpenTabs],
|
||||
);
|
||||
|
||||
const getActiveTerminalSessionId = useCallback((): string | null => {
|
||||
if (!activeWorkspace) return activeSession?.id ?? null;
|
||||
|
||||
const workspaceSessionIdSet = new Set(collectSessionIds(activeWorkspace.root));
|
||||
const focusedSessionId = activeWorkspace.focusedSessionId;
|
||||
if (focusedSessionId && workspaceSessionIdSet.has(focusedSessionId) && sessions.some((session) => session.id === focusedSessionId)) {
|
||||
return focusedSessionId;
|
||||
}
|
||||
|
||||
return sessions.find((session) => workspaceSessionIdSet.has(session.id))?.id ?? null;
|
||||
}, [activeWorkspace, activeSession?.id, sessions]);
|
||||
|
||||
const syncWorkspaceFocusIfNeeded = useCallback((sessionId: string | null) => {
|
||||
if (!activeWorkspace || !sessionId || activeWorkspace.focusedSessionId === sessionId) return;
|
||||
onSetWorkspaceFocusedSession?.(activeWorkspace.id, sessionId);
|
||||
}, [activeWorkspace, onSetWorkspaceFocusedSession]);
|
||||
|
||||
// Get the focused terminal's current working directory
|
||||
const getTerminalCwd = useCallback(async (): Promise<string | null> => {
|
||||
const sessionId = activeWorkspace?.focusedSessionId ?? activeSession?.id;
|
||||
const sessionId = getActiveTerminalSessionId();
|
||||
if (!sessionId) return null;
|
||||
try {
|
||||
const result = await terminalBackend.getSessionPwd(sessionId);
|
||||
@@ -1257,11 +1337,23 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [activeWorkspace?.focusedSessionId, activeSession?.id, terminalBackend]);
|
||||
}, [getActiveTerminalSessionId, terminalBackend]);
|
||||
|
||||
const refocusTerminalSession = useCallback((sessionId?: string | null) => {
|
||||
focusTerminalSessionInput(sessionId);
|
||||
}, []);
|
||||
|
||||
const refocusActiveTerminalSession = useCallback(() => {
|
||||
const sessionId = getActiveTerminalSessionId();
|
||||
syncWorkspaceFocusIfNeeded(sessionId);
|
||||
refocusTerminalSession(sessionId);
|
||||
}, [getActiveTerminalSessionId, refocusTerminalSession, syncWorkspaceFocusIfNeeded]);
|
||||
|
||||
// Close the entire side panel for the current tab
|
||||
const handleCloseSidePanel = useCallback(() => {
|
||||
if (!activeTabId) return;
|
||||
const sessionIdToRefocus = getActiveTerminalSessionId();
|
||||
syncWorkspaceFocusIfNeeded(sessionIdToRefocus);
|
||||
setSidePanelOpenTabs(prev => {
|
||||
const next = new Map(prev);
|
||||
next.delete(activeTabId);
|
||||
@@ -1284,7 +1376,16 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
next.delete(activeTabId);
|
||||
return next;
|
||||
});
|
||||
}, [activeTabId]);
|
||||
refocusTerminalSession(sessionIdToRefocus);
|
||||
}, [activeTabId, getActiveTerminalSessionId, refocusTerminalSession, syncWorkspaceFocusIfNeeded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!closeSidePanelRef) return;
|
||||
closeSidePanelRef.current = handleCloseSidePanel;
|
||||
return () => {
|
||||
closeSidePanelRef.current = null;
|
||||
};
|
||||
}, [closeSidePanelRef, handleCloseSidePanel]);
|
||||
|
||||
// Switch side panel to a specific tab (or toggle if already on that tab)
|
||||
const handleSwitchSidePanelTab = useCallback((tab: SidePanelTab) => {
|
||||
@@ -1331,6 +1432,34 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
handleSwitchSidePanelTab('scripts');
|
||||
}, [handleSwitchSidePanelTab]);
|
||||
|
||||
const handleToggleScriptsSidePanel = useCallback(() => {
|
||||
const tabId = activeTabIdRef.current;
|
||||
if (!tabId) return;
|
||||
|
||||
const intent = resolveScriptsSidePanelShortcutIntent(
|
||||
sidePanelOpenTabsRef.current.get(tabId) ?? null,
|
||||
);
|
||||
|
||||
if (intent.kind === 'closeTerminalSidePanel') {
|
||||
handleCloseSidePanel();
|
||||
return;
|
||||
}
|
||||
|
||||
setSidePanelOpenTabs(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(tabId, 'scripts');
|
||||
return next;
|
||||
});
|
||||
}, [handleCloseSidePanel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!toggleScriptsSidePanelRef) return;
|
||||
toggleScriptsSidePanelRef.current = handleToggleScriptsSidePanel;
|
||||
return () => {
|
||||
toggleScriptsSidePanelRef.current = null;
|
||||
};
|
||||
}, [toggleScriptsSidePanelRef, handleToggleScriptsSidePanel]);
|
||||
|
||||
// Open theme side panel (called from Terminal toolbar)
|
||||
const handleOpenTheme = useCallback(() => {
|
||||
handleSwitchSidePanelTab('theme');
|
||||
@@ -1878,31 +2007,97 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const renderFocusModeSidebar = () => {
|
||||
if (!activeWorkspace || !isFocusMode) return null;
|
||||
|
||||
// Use terminal-theme colors for every surface in here so the sidebar
|
||||
// stays readable when the app theme and terminal theme diverge
|
||||
// (e.g. followAppTerminalTheme=off, light app + dark terminal).
|
||||
// Tailwind's bg-foreground/* / text-foreground classes bind to app
|
||||
// theme vars, so we derive row colors from the terminal theme
|
||||
// directly with color-mix.
|
||||
const termBg = resolvedPreviewTheme.colors.background;
|
||||
const termFg = resolvedPreviewTheme.colors.foreground;
|
||||
const selectedBg = `color-mix(in srgb, ${termFg} 10%, transparent)`;
|
||||
const selectedHoverBg = `color-mix(in srgb, ${termFg} 15%, transparent)`;
|
||||
const unselectedHoverBg = `color-mix(in srgb, ${termFg} 10%, transparent)`;
|
||||
const unselectedFg = `color-mix(in srgb, ${termFg} 75%, ${termBg} 25%)`;
|
||||
const mutedFg = `color-mix(in srgb, ${termFg} 55%, ${termBg} 45%)`;
|
||||
const separator = `color-mix(in srgb, ${termFg} 10%, ${termBg} 90%)`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-56 flex-shrink-0 bg-secondary/50 border-r border-border/50 flex flex-col"
|
||||
className="flex-shrink-0 flex flex-col relative"
|
||||
style={{
|
||||
width: focusSidebarWidth,
|
||||
// Paint the sidebar with the terminal's theme background so it
|
||||
// reads as one continuous surface with the focused terminal
|
||||
// (instead of a distinct tinted panel sitting next to it).
|
||||
backgroundColor: termBg,
|
||||
color: termFg,
|
||||
borderRight: `1px solid ${separator}`,
|
||||
}}
|
||||
data-section="terminal-workspace-sidebar"
|
||||
>
|
||||
{/* Header with view toggle */}
|
||||
<div className="h-10 flex items-center justify-between px-3 border-b border-border/50">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Terminals · {workspaceSessions.length}
|
||||
</span>
|
||||
{/* Resize handle sitting on the right edge of the sidebar. */}
|
||||
<div
|
||||
className="absolute top-0 right-[-3px] h-full w-2 cursor-ew-resize z-30"
|
||||
onMouseDown={handleFocusSidebarResizeStart}
|
||||
/>
|
||||
{/* Header — search box + actions (matches Vault-sidebar search
|
||||
style but skinned to the terminal theme so it blends with the
|
||||
sidebar's bg). */}
|
||||
<div
|
||||
className="h-11 flex items-center gap-1.5 px-2"
|
||||
style={{ borderBottom: `1px solid ${separator}` }}
|
||||
>
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<Search
|
||||
size={12}
|
||||
className="absolute left-1 top-1/2 -translate-y-1/2 pointer-events-none"
|
||||
style={{ color: mutedFg }}
|
||||
/>
|
||||
<Input
|
||||
value={focusSidebarSearch}
|
||||
onChange={(e) => setFocusSidebarSearch(e.target.value)}
|
||||
placeholder="Search terminals..."
|
||||
className="h-7 pl-6 pr-0 text-xs bg-transparent border-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
style={{ color: termFg }}
|
||||
/>
|
||||
</div>
|
||||
{onRequestAddToWorkspace && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 flex-shrink-0 hover:text-inherit"
|
||||
style={{ color: mutedFg }}
|
||||
onClick={() => onRequestAddToWorkspace(activeWorkspace.id)}
|
||||
title="Add Terminal"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
className="h-7 w-7 p-0 flex-shrink-0 hover:text-inherit"
|
||||
style={{ color: mutedFg }}
|
||||
onClick={() => onToggleWorkspaceViewMode?.(activeWorkspace.id)}
|
||||
title="Switch to Split View"
|
||||
>
|
||||
<LayoutGrid size={14} />
|
||||
<Columns2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Session list */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2 space-y-1">
|
||||
{workspaceSessions.map(session => {
|
||||
{workspaceSessions.filter((session) => {
|
||||
const term = focusSidebarSearch.trim().toLowerCase();
|
||||
if (!term) return true;
|
||||
return (
|
||||
session.hostLabel?.toLowerCase().includes(term)
|
||||
|| session.hostname?.toLowerCase().includes(term)
|
||||
|| session.username?.toLowerCase().includes(term)
|
||||
);
|
||||
}).map(session => {
|
||||
const host = sessionHostsMap.get(session.id);
|
||||
const isSelected = session.id === focusedSessionId;
|
||||
const statusColor = session.status === 'connected'
|
||||
@@ -1911,35 +2106,49 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
? 'text-amber-500'
|
||||
: 'text-red-500';
|
||||
|
||||
const restBg = isSelected ? selectedBg : 'transparent';
|
||||
const hoverBg = isSelected ? selectedHoverBg : unselectedHoverBg;
|
||||
const rowFg = isSelected ? termFg : unselectedFg;
|
||||
|
||||
return (
|
||||
<div
|
||||
<RippleButton
|
||||
key={session.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer transition-colors",
|
||||
isSelected
|
||||
? "bg-primary/15 border border-primary/30"
|
||||
: "hover:bg-secondary/80 border border-transparent"
|
||||
)}
|
||||
variant="ghost"
|
||||
// Row colors are terminal-theme derived (see renderFocusModeSidebar
|
||||
// top). `hover:text-inherit` pins text against ghost variant's
|
||||
// hover:text-accent-foreground default; hover bg is swapped
|
||||
// via inline style so we stay on terminal-theme alpha rather
|
||||
// than Tailwind's app-theme foreground color.
|
||||
className="w-full h-auto justify-start gap-2 px-2 py-1.5 font-normal hover:text-inherit"
|
||||
style={{ backgroundColor: restBg, color: rowFg }}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = hoverBg;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = restBg;
|
||||
}}
|
||||
onClick={() => onSetWorkspaceFocusedSession?.(activeWorkspace.id, session.id)}
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="relative flex-shrink-0">
|
||||
{host ? (
|
||||
<DistroAvatar host={host} fallback={session.hostLabel} size="sm" />
|
||||
) : (
|
||||
<Server size={16} className="text-muted-foreground" />
|
||||
<Server size={16} style={{ color: mutedFg }} />
|
||||
)}
|
||||
<Circle
|
||||
size={6}
|
||||
className={cn("absolute -bottom-0.5 -right-0.5 fill-current", statusColor)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium truncate">{session.hostLabel}</div>
|
||||
<div className="text-[10px] text-muted-foreground truncate">
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
<div className={cn("text-xs truncate", isSelected ? "font-semibold" : "font-medium")}>
|
||||
{session.hostLabel}
|
||||
</div>
|
||||
<div className="text-[10px] truncate" style={{ color: mutedFg }}>
|
||||
{session.username}@{session.hostname}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</RippleButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -1961,14 +2170,18 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
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) */}
|
||||
<div className="flex-1 flex min-h-0 relative">
|
||||
{/* Side panel with tab header + content (SFTP / Scripts / Theme).
|
||||
Uses `order-last` instead of flex-row-reverse on the parent so the
|
||||
workspace focus-mode sidebar and terminal area below stay in source
|
||||
order (sidebar on the left) regardless of the side panel's side. */}
|
||||
{(isSidePanelOpenForCurrentTab || mountedSftpTabIds.length > 0 || mountedAiTabIds.length > 0) && (
|
||||
<>
|
||||
<div
|
||||
style={{ width: isSidePanelOpenForCurrentTab ? sidePanelWidth : 0 }}
|
||||
className={cn(
|
||||
"flex-shrink-0 h-full relative z-20",
|
||||
sidePanelPosition === 'right' && "order-last",
|
||||
)}
|
||||
>
|
||||
{isSidePanelOpenForCurrentTab && (
|
||||
@@ -2115,6 +2328,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
? (sftpInitialLocationForTab.get(tabId) ?? null)
|
||||
: null
|
||||
}
|
||||
onInitialLocationApplied={(location) => handleSftpInitialLocationApplied(tabId, location)}
|
||||
showWorkspaceHostHeader={isVisibleSftpPanel && !!activeWorkspace}
|
||||
isVisible={isVisibleSftpPanel}
|
||||
renderOverlays={isVisibleSftpPanel}
|
||||
@@ -2129,6 +2343,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
editorWordWrap={editorWordWrap}
|
||||
setEditorWordWrap={setEditorWordWrap}
|
||||
onGetTerminalCwd={getTerminalCwd}
|
||||
onRequestTerminalFocus={refocusActiveTerminalSession}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -2189,6 +2404,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
{/* Focus mode sidebar */}
|
||||
{isFocusMode && renderFocusModeSidebar()}
|
||||
|
||||
|
||||
<div ref={workspaceInnerRef} className="overflow-hidden relative flex-1">
|
||||
{draggingSessionId && !isFocusMode && (
|
||||
<div
|
||||
@@ -2403,14 +2619,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onSend={handleComposeSend}
|
||||
onClose={() => {
|
||||
setIsComposeBarOpen(false);
|
||||
// Refocus the terminal pane (matching solo-session behavior)
|
||||
if (focusedSessionId) {
|
||||
requestAnimationFrame(() => {
|
||||
const pane = document.querySelector(`[data-session-id="${focusedSessionId}"]`);
|
||||
const textarea = pane?.querySelector('textarea.xterm-helper-textarea') as HTMLTextAreaElement | null;
|
||||
textarea?.focus();
|
||||
});
|
||||
}
|
||||
refocusTerminalSession(focusedSessionId);
|
||||
}}
|
||||
isBroadcastEnabled={isBroadcastEnabled?.(activeWorkspace.id)}
|
||||
themeColors={composeBarThemeColors}
|
||||
@@ -2449,6 +2658,7 @@ const terminalLayerAreEqual = (prev: TerminalLayerProps, next: TerminalLayerProp
|
||||
prev.onToggleWorkspaceViewMode === next.onToggleWorkspaceViewMode &&
|
||||
prev.onSetWorkspaceFocusedSession === next.onSetWorkspaceFocusedSession &&
|
||||
prev.onSplitSession === next.onSplitSession &&
|
||||
prev.toggleScriptsSidePanelRef === next.toggleScriptsSidePanelRef &&
|
||||
prev.identities === next.identities
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,31 +1,32 @@
|
||||
/**
|
||||
* TextEditorModal - Modal for editing text files in SFTP with syntax highlighting
|
||||
* TextEditorModal - Dialog shell for editing text files in SFTP.
|
||||
* Delegates all editor chrome to TextEditorPane.
|
||||
*/
|
||||
import {
|
||||
CloudUpload,
|
||||
Loader2,
|
||||
Search,
|
||||
WrapText,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import Editor, { type OnMount, loader, useMonaco } from '@monaco-editor/react';
|
||||
import type * as Monaco from 'monaco-editor';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
// Configure Monaco to use local files instead of CDN
|
||||
const monacoBasePath = import.meta.env.DEV
|
||||
? './node_modules/monaco-editor/min/vs'
|
||||
: `${import.meta.env.BASE_URL}monaco/vs`;
|
||||
loader.config({ paths: { vs: monacoBasePath } });
|
||||
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useClipboardBackend } from '../application/state/useClipboardBackend';
|
||||
import { HotkeyScheme, KeyBinding, matchesKeyBinding } from '../domain/models';
|
||||
import { getLanguageId, getLanguageName, getSupportedLanguages } from '../lib/sftpFileUtils';
|
||||
import { Button } from './ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
import { Combobox } from './ui/combobox';
|
||||
import { getLanguageId } from '../lib/sftpFileUtils';
|
||||
import { Dialog, DialogContent, DialogTitle } from './ui/dialog';
|
||||
import { toast } from './ui/toast';
|
||||
import { TextEditorPane } from './editor/TextEditorPane';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import type { HotkeyScheme, KeyBinding } from '../domain/models';
|
||||
|
||||
/** Snapshot passed to `onPromoteToTab` when the user clicks the maximize button. */
|
||||
export interface TextEditorModalSnapshot {
|
||||
/** The file name at the time of promotion (modal's fileName prop). */
|
||||
fileName: string;
|
||||
/** The clean baseline content at the time the modal was opened. */
|
||||
baselineContent: string;
|
||||
/** The current (possibly-dirty) editor content. */
|
||||
content: string;
|
||||
/** The current language ID selected by the user (may differ from file-detected default). */
|
||||
languageId: string;
|
||||
/** The current word-wrap state (carried over so the tab opens with the same setting). */
|
||||
wordWrap: boolean;
|
||||
/** The latest Monaco view state (scroll position, cursor, etc.) — may be null before first edit. */
|
||||
viewState: Monaco.editor.ICodeEditorViewState | null;
|
||||
}
|
||||
|
||||
interface TextEditorModalProps {
|
||||
open: boolean;
|
||||
@@ -37,128 +38,10 @@ interface TextEditorModalProps {
|
||||
onToggleWordWrap: () => void;
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
keyBindings: KeyBinding[];
|
||||
/** If provided, a maximize button is shown in the Pane header. */
|
||||
onPromoteToTab?: (snapshot: TextEditorModalSnapshot) => void;
|
||||
}
|
||||
|
||||
// Map our language IDs to Monaco language IDs
|
||||
const languageIdToMonaco = (langId: string): string => {
|
||||
const mapping: Record<string, string> = {
|
||||
'javascript': 'javascript',
|
||||
'typescript': 'typescript',
|
||||
'python': 'python',
|
||||
'shell': 'shell',
|
||||
'batch': 'bat',
|
||||
'powershell': 'powershell',
|
||||
'c': 'c',
|
||||
'cpp': 'cpp',
|
||||
'java': 'java',
|
||||
'kotlin': 'kotlin',
|
||||
'go': 'go',
|
||||
'rust': 'rust',
|
||||
'ruby': 'ruby',
|
||||
'php': 'php',
|
||||
'perl': 'perl',
|
||||
'lua': 'lua',
|
||||
'r': 'r',
|
||||
'swift': 'swift',
|
||||
'dart': 'dart',
|
||||
'csharp': 'csharp',
|
||||
'fsharp': 'fsharp',
|
||||
'vb': 'vb',
|
||||
'html': 'html',
|
||||
'css': 'css',
|
||||
'scss': 'scss',
|
||||
'sass': 'sass',
|
||||
'less': 'less',
|
||||
'json': 'json',
|
||||
'jsonc': 'json',
|
||||
'json5': 'json',
|
||||
'xml': 'xml',
|
||||
'yaml': 'yaml',
|
||||
'toml': 'ini',
|
||||
'ini': 'ini',
|
||||
'sql': 'sql',
|
||||
'graphql': 'graphql',
|
||||
'markdown': 'markdown',
|
||||
'plaintext': 'plaintext',
|
||||
'vue': 'html',
|
||||
'svelte': 'html',
|
||||
'dockerfile': 'dockerfile',
|
||||
'makefile': 'makefile',
|
||||
'diff': 'diff',
|
||||
};
|
||||
return mapping[langId] || 'plaintext';
|
||||
};
|
||||
|
||||
// Convert HSL string "h s% l%" to hex color
|
||||
const hslToHex = (hslString: string): string => {
|
||||
const parts = hslString.trim().split(/\s+/);
|
||||
if (parts.length < 3) return '#1e1e1e';
|
||||
const h = parseFloat(parts[0]) / 360;
|
||||
const s = parseFloat(parts[1].replace('%', '')) / 100;
|
||||
const l = parseFloat(parts[2].replace('%', '')) / 100;
|
||||
|
||||
const hue2rgb = (p: number, q: number, t: number) => {
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
|
||||
let r: number, g: number, b: number;
|
||||
if (s === 0) {
|
||||
r = g = b = l;
|
||||
} else {
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
r = hue2rgb(p, q, h + 1 / 3);
|
||||
g = hue2rgb(p, q, h);
|
||||
b = hue2rgb(p, q, h - 1 / 3);
|
||||
}
|
||||
|
||||
const toHex = (x: number) => {
|
||||
const hex = Math.round(x * 255).toString(16);
|
||||
return hex.length === 1 ? '0' + hex : hex;
|
||||
};
|
||||
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||
};
|
||||
|
||||
// Read a CSS custom-property and convert from HSL to hex
|
||||
const getCssColor = (varName: string, fallback: string): string => {
|
||||
const value = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(varName)
|
||||
.trim();
|
||||
return value ? hslToHex(value) : fallback;
|
||||
};
|
||||
|
||||
interface EditorColors {
|
||||
bg: string;
|
||||
fg: string;
|
||||
primary: string;
|
||||
card: string;
|
||||
mutedFg: string;
|
||||
border: string;
|
||||
}
|
||||
|
||||
/** Read all UI CSS variables that matter for the Monaco theme. */
|
||||
const getEditorColors = (isDark: boolean): EditorColors => ({
|
||||
bg: getCssColor('--background', isDark ? '#1e1e1e' : '#ffffff'),
|
||||
fg: getCssColor('--foreground', isDark ? '#d4d4d4' : '#1e1e1e'),
|
||||
primary: getCssColor('--primary', isDark ? '#569cd6' : '#0078d4'),
|
||||
card: getCssColor('--card', isDark ? '#252526' : '#f3f3f3'),
|
||||
mutedFg: getCssColor('--muted-foreground', isDark ? '#858585' : '#858585'),
|
||||
border: getCssColor('--border', isDark ? '#3c3c3c' : '#d4d4d4'),
|
||||
});
|
||||
|
||||
/** Build a fingerprint string so we can detect immersive-mode color changes cheaply. */
|
||||
const getThemeSignal = (): string => {
|
||||
const root = document.documentElement;
|
||||
return root.dataset.immersiveTheme
|
||||
?? getComputedStyle(root).getPropertyValue('--background').trim();
|
||||
};
|
||||
|
||||
export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
@@ -169,182 +52,45 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
onToggleWordWrap,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
onPromoteToTab,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { readClipboardText: readClipboardTextFromBridge } = useClipboardBackend();
|
||||
const monaco = useMonaco();
|
||||
|
||||
const [content, setContent] = useState(initialContent);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [languageId, setLanguageId] = useState(() => getLanguageId(fileName));
|
||||
const editorRef = useRef<Monaco.editor.IStandaloneCodeEditor | null>(null);
|
||||
|
||||
// Ref to store the latest save function to avoid stale closure in keyboard shortcut
|
||||
const handleSaveRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
||||
const handlePasteRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
||||
const readClipboardTextRef = useRef<() => Promise<string | null>>(() => Promise.resolve(null));
|
||||
// Latest view state captured from Pane's onContentChange — used by handlePromote
|
||||
const viewStateRef = useRef<Monaco.editor.ICodeEditorViewState | null>(null);
|
||||
|
||||
// Track theme from document.documentElement class (syncs with app theme)
|
||||
const [isDarkTheme, setIsDarkTheme] = useState(() =>
|
||||
document.documentElement.classList.contains('dark')
|
||||
);
|
||||
// Derived: whether the current content differs from the clean baseline
|
||||
const hasChanges = content !== initialContent;
|
||||
|
||||
// Track a signal that changes whenever immersive-mode or base theme colors change
|
||||
const [themeSignal, setThemeSignal] = useState(() => getThemeSignal());
|
||||
|
||||
// Custom theme name
|
||||
const customThemeName = isDarkTheme ? 'netcatty-dark' : 'netcatty-light';
|
||||
|
||||
// Define and update custom Monaco themes — syncs with immersive-mode / base UI colors
|
||||
useEffect(() => {
|
||||
if (!monaco) return;
|
||||
|
||||
const colors = getEditorColors(isDarkTheme);
|
||||
|
||||
const themeColors: Record<string, string> = {
|
||||
'editor.background': colors.bg,
|
||||
'editor.foreground': colors.fg,
|
||||
'editorCursor.foreground': colors.primary,
|
||||
'editor.selectionBackground': colors.primary + '40',
|
||||
'editor.inactiveSelectionBackground': colors.primary + '25',
|
||||
'editorLineNumber.foreground': colors.mutedFg,
|
||||
'editorLineNumber.activeForeground': colors.fg,
|
||||
'editor.lineHighlightBackground': colors.fg + '08',
|
||||
'editorWidget.background': colors.card,
|
||||
'editorWidget.foreground': colors.fg,
|
||||
'editorWidget.border': colors.border,
|
||||
'input.background': colors.card,
|
||||
'input.foreground': colors.fg,
|
||||
'input.border': colors.border,
|
||||
};
|
||||
|
||||
monaco.editor.defineTheme('netcatty-dark', {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: themeColors,
|
||||
});
|
||||
|
||||
monaco.editor.defineTheme('netcatty-light', {
|
||||
base: 'vs',
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: themeColors,
|
||||
});
|
||||
|
||||
monaco.editor.setTheme(customThemeName);
|
||||
}, [monaco, isDarkTheme, themeSignal, customThemeName]);
|
||||
|
||||
// Listen for theme changes via MutationObserver on <html> class, style, and immersive data attr
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
const updateTheme = () => {
|
||||
setIsDarkTheme(root.classList.contains('dark'));
|
||||
setThemeSignal(getThemeSignal());
|
||||
};
|
||||
const observer = new MutationObserver(updateTheme);
|
||||
observer.observe(root, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'style', 'data-immersive-theme'],
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Reset content when file changes
|
||||
// Reset all state when a new file is opened
|
||||
useEffect(() => {
|
||||
setContent(initialContent);
|
||||
setHasChanges(false);
|
||||
setSaveError(null);
|
||||
setLanguageId(getLanguageId(fileName));
|
||||
viewStateRef.current = null;
|
||||
}, [initialContent, fileName]);
|
||||
|
||||
// Track changes
|
||||
useEffect(() => {
|
||||
setHasChanges(content !== initialContent);
|
||||
}, [content, initialContent]);
|
||||
|
||||
const closeTabBinding = useMemo(
|
||||
() => keyBindings.find((binding) => binding.action === 'closeTab'),
|
||||
[keyBindings],
|
||||
);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
await onSave(content);
|
||||
setHasChanges(false);
|
||||
toast.success(t('sftp.editor.saved'), 'SFTP');
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t('sftp.editor.saveFailed'),
|
||||
'SFTP'
|
||||
);
|
||||
const msg = e instanceof Error ? e.message : t('sftp.editor.saveFailed');
|
||||
setSaveError(msg);
|
||||
toast.error(msg, 'SFTP');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [content, onSave, saving, t]);
|
||||
|
||||
// Keep the ref updated with the latest handleSave function
|
||||
useEffect(() => {
|
||||
handleSaveRef.current = handleSave;
|
||||
}, [handleSave]);
|
||||
|
||||
const readClipboardText = useCallback(async (): Promise<string | null> => {
|
||||
try {
|
||||
if (navigator.clipboard?.readText) {
|
||||
return await navigator.clipboard.readText();
|
||||
}
|
||||
} catch {
|
||||
// Fall through to Electron bridge
|
||||
}
|
||||
|
||||
try {
|
||||
return await readClipboardTextFromBridge();
|
||||
} catch {
|
||||
// Both clipboard APIs unavailable; signal failure so caller can fall back.
|
||||
return null;
|
||||
}
|
||||
}, [readClipboardTextFromBridge]);
|
||||
|
||||
useEffect(() => {
|
||||
readClipboardTextRef.current = readClipboardText;
|
||||
}, [readClipboardText]);
|
||||
|
||||
const handlePaste = useCallback(async () => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
|
||||
const text = await readClipboardText();
|
||||
if (text === null) {
|
||||
// Clipboard read unavailable; fall back to Monaco's native paste.
|
||||
editor.trigger('keyboard', 'editor.action.clipboardPasteAction', null);
|
||||
return;
|
||||
}
|
||||
if (!text) return;
|
||||
|
||||
const selections = editor.getSelections();
|
||||
if (!selections || selections.length === 0) return;
|
||||
|
||||
// Match Monaco's default multicursorPaste:'spread' behavior:
|
||||
// distribute one line per cursor when line count equals cursor count.
|
||||
const lines = text.split(/\r\n|\n/);
|
||||
const distribute = selections.length > 1 && lines.length === selections.length;
|
||||
|
||||
editor.executeEdits(
|
||||
'netcatty-paste',
|
||||
selections.map((selection, i) => ({
|
||||
range: selection,
|
||||
text: distribute ? lines[i] : text,
|
||||
forceMoveMarkers: true,
|
||||
})),
|
||||
);
|
||||
editor.focus();
|
||||
}, [readClipboardText]);
|
||||
|
||||
useEffect(() => {
|
||||
handlePasteRef.current = handlePaste;
|
||||
}, [handlePaste]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (hasChanges) {
|
||||
const confirmed = confirm(t('sftp.editor.unsavedChanges'));
|
||||
@@ -353,222 +99,53 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
onClose();
|
||||
}, [hasChanges, onClose, t]);
|
||||
|
||||
const handleEditorChange = useCallback((value: string | undefined) => {
|
||||
setContent(value || '');
|
||||
}, []);
|
||||
|
||||
const handleEditorMount: OnMount = useCallback((editor, monaco) => {
|
||||
editorRef.current = editor;
|
||||
|
||||
// Add save shortcut - use ref to avoid stale closure
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
|
||||
handleSaveRef.current();
|
||||
});
|
||||
|
||||
// Add find shortcut (Ctrl+F / Cmd+F)
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyF, () => {
|
||||
// Trigger Monaco's built-in find widget
|
||||
editor.trigger('keyboard', 'actions.find', null);
|
||||
});
|
||||
|
||||
// Fallback paste path for Electron environments where Monaco paste can fail.
|
||||
// Skip custom paste when focus is inside the find/replace widget so that
|
||||
// its input fields receive the pasted text via default browser behavior.
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyV, () => {
|
||||
const active = document.activeElement;
|
||||
if (active?.closest('.find-widget')) {
|
||||
// Read clipboard and insert into the find/replace input field.
|
||||
void (async () => {
|
||||
try {
|
||||
const text = await readClipboardTextRef.current();
|
||||
if (!text) return;
|
||||
// Monaco find widget inputs are <textarea> elements inside .monaco-inputbox
|
||||
if (active instanceof HTMLTextAreaElement || active instanceof HTMLInputElement) {
|
||||
const start = active.selectionStart ?? active.value.length;
|
||||
const end = active.selectionEnd ?? active.value.length;
|
||||
active.focus();
|
||||
active.setSelectionRange(start, end);
|
||||
document.execCommand('insertText', false, text);
|
||||
}
|
||||
} catch {
|
||||
// Ignore – paste simply won't work
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
void handlePasteRef.current();
|
||||
});
|
||||
|
||||
editor.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
editorRef.current?.focus();
|
||||
});
|
||||
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [open]);
|
||||
|
||||
const handleDialogKeyDownCapture = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (hotkeyScheme === 'disabled' || !closeTabBinding) return;
|
||||
|
||||
const isMac = hotkeyScheme === 'mac';
|
||||
const keyStr = isMac ? closeTabBinding.mac : closeTabBinding.pc;
|
||||
if (!matchesKeyBinding(e.nativeEvent, keyStr, isMac)) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.nativeEvent.stopPropagation();
|
||||
handleClose();
|
||||
}, [closeTabBinding, handleClose, hotkeyScheme]);
|
||||
|
||||
// Trigger search dialog
|
||||
const handleSearch = useCallback(() => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.trigger('keyboard', 'actions.find', null);
|
||||
editorRef.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const supportedLanguages = useMemo(() => getSupportedLanguages(), []);
|
||||
const monacoLanguage = useMemo(() => languageIdToMonaco(languageId), [languageId]);
|
||||
const languageOptions = useMemo(
|
||||
() => supportedLanguages.map((lang) => ({ value: lang.id, label: lang.name })),
|
||||
[supportedLanguages],
|
||||
const handleContentChange = useCallback(
|
||||
(nextContent: string, viewState: Monaco.editor.ICodeEditorViewState | null) => {
|
||||
setContent(nextContent);
|
||||
viewStateRef.current = viewState;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleLanguageChange = useCallback((nextValue: string) => {
|
||||
setLanguageId(nextValue || 'plaintext');
|
||||
}, []);
|
||||
const handlePromote = useCallback(() => {
|
||||
if (!onPromoteToTab) return;
|
||||
onPromoteToTab({
|
||||
fileName,
|
||||
baselineContent: initialContent,
|
||||
content,
|
||||
languageId,
|
||||
wordWrap: editorWordWrap,
|
||||
viewState: viewStateRef.current,
|
||||
});
|
||||
}, [onPromoteToTab, fileName, initialContent, content, languageId, editorWordWrap]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
|
||||
<DialogContent
|
||||
className="max-w-5xl h-[85vh] flex flex-col p-0 gap-0"
|
||||
hideCloseButton
|
||||
data-hotkey-close-tab="true"
|
||||
onKeyDownCapture={handleDialogKeyDownCapture}
|
||||
>
|
||||
{/* Header */}
|
||||
<DialogHeader className="px-4 py-3 border-b border-border/60 flex-shrink-0">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<DialogTitle className="text-sm font-semibold truncate">
|
||||
{fileName}
|
||||
{hasChanges && <span className="text-primary ml-1">*</span>}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{/* Search button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleSearch}
|
||||
title={t('common.search')}
|
||||
>
|
||||
<Search size={14} />
|
||||
</Button>
|
||||
|
||||
{/* Word wrap toggle */}
|
||||
<Button
|
||||
variant={editorWordWrap ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onToggleWordWrap}
|
||||
title={t('sftp.editor.wordWrap')}
|
||||
>
|
||||
<WrapText size={14} />
|
||||
</Button>
|
||||
|
||||
{/* Language selector */}
|
||||
<Combobox
|
||||
options={languageOptions}
|
||||
value={languageId}
|
||||
onValueChange={handleLanguageChange}
|
||||
placeholder={t('sftp.editor.syntaxHighlight')}
|
||||
triggerClassName="h-7 max-w-[180px] min-w-[120px] text-xs"
|
||||
/>
|
||||
|
||||
{/* Save button */}
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !hasChanges}
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 size={14} className="mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<CloudUpload size={14} className="mr-1.5" />
|
||||
)}
|
||||
{saving ? t('sftp.editor.saving') : t('sftp.editor.save')}
|
||||
</Button>
|
||||
|
||||
{/* Close button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Monaco Editor */}
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
<Editor
|
||||
height="100%"
|
||||
language={monacoLanguage}
|
||||
value={content}
|
||||
onChange={handleEditorChange}
|
||||
onMount={handleEditorMount}
|
||||
theme={customThemeName}
|
||||
loading={
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background">
|
||||
<Loader2 size={32} className="animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
}
|
||||
options={{
|
||||
// Prefer native context menu in Electron so right-click Paste uses OS clipboard path.
|
||||
contextmenu: false,
|
||||
minimap: { enabled: true },
|
||||
fontSize: 14,
|
||||
lineNumbers: 'on',
|
||||
roundedSelection: false,
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
tabSize: 2,
|
||||
insertSpaces: true,
|
||||
wordWrap: editorWordWrap ? 'on' : 'off',
|
||||
folding: true,
|
||||
renderWhitespace: 'selection',
|
||||
bracketPairColorization: { enabled: true },
|
||||
find: {
|
||||
addExtraSpaceOnTop: false,
|
||||
autoFindInSelection: 'never',
|
||||
seedSearchStringFromSelection: 'selection',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-4 py-2 border-t border-border/60 flex items-center justify-between text-xs text-muted-foreground bg-muted/30 flex-shrink-0">
|
||||
<span>
|
||||
{getLanguageName(languageId)}
|
||||
</span>
|
||||
<span>
|
||||
{content.split('\n').length} lines • {content.length} characters
|
||||
</span>
|
||||
</div>
|
||||
{/* Radix requires a DialogTitle inside every DialogContent for a11y.
|
||||
The Pane's own header already shows the filename visually, so we
|
||||
mirror it here inside an sr-only DialogTitle for screen readers. */}
|
||||
<DialogTitle className="sr-only">{fileName}</DialogTitle>
|
||||
<TextEditorPane
|
||||
chrome="modal"
|
||||
fileName={`${fileName}${hasChanges ? ' *' : ''}`}
|
||||
content={content}
|
||||
languageId={languageId}
|
||||
wordWrap={editorWordWrap}
|
||||
saving={saving}
|
||||
saveError={saveError}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
onContentChange={handleContentChange}
|
||||
onLanguageChange={setLanguageId}
|
||||
onToggleWordWrap={onToggleWordWrap}
|
||||
onSave={handleSave}
|
||||
onRequestClose={handleClose}
|
||||
onPromoteToTab={onPromoteToTab ? handlePromote : undefined}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Bell, Copy, FileText, Folder, FolderLock, LayoutGrid, Minus, Moon, MoreHorizontal, Plus, Server, Sparkles, Square, Sun, TerminalSquare, Usb, X } from 'lucide-react';
|
||||
import { Bell, Copy, FileCode, 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 { activeTabStore, fromEditorTabId, isEditorTabId, useActiveTabId } from '../application/state/activeTabStore';
|
||||
import type { EditorTab } from '../application/state/editorTabStore';
|
||||
import { buildWorkspaceActivityMap } from '../application/state/sessionActivity';
|
||||
import { useSessionActivityMap } from '../application/state/sessionActivityStore';
|
||||
import { LogView } from '../application/state/useSessionState';
|
||||
@@ -12,13 +13,16 @@ import { Host, TerminalSession, Workspace } from '../types';
|
||||
import { DISTRO_LOGOS, DISTRO_COLORS } from './DistroAvatar';
|
||||
import { getShellIconPath, isMonochromeShellIcon } from '../lib/useDiscoveredShells';
|
||||
import { Button } from './ui/button';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from './ui/context-menu';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from './ui/context-menu';
|
||||
import { SyncStatusButton } from './SyncStatusButton';
|
||||
|
||||
// Helper styles for Electron drag regions (use type assertion to include non-standard WebkitAppRegion)
|
||||
const dragRegionStyle = { WebkitAppRegion: 'drag' } as React.CSSProperties;
|
||||
const dragRegionNoSelect = { WebkitAppRegion: 'drag', userSelect: 'none' } as React.CSSProperties;
|
||||
|
||||
// File extensions that render the code-file icon instead of the plain text icon.
|
||||
const CODE_EXTENSIONS_RE = /\.(js|jsx|ts|tsx|py|rb|go|rs|c|cpp|cs|java|php|sh|bash|zsh|fish|lua|r|scala|swift|kt|html|css|scss|less|json|yaml|yml|toml|xml|sql|graphql|gql|md|mdx|conf|ini|env|tf|hcl|dockerfile)$/i;
|
||||
|
||||
interface TopTabsProps {
|
||||
theme: 'dark' | 'light';
|
||||
followAppTerminalTheme?: boolean;
|
||||
@@ -36,6 +40,7 @@ interface TopTabsProps {
|
||||
onRenameWorkspace: (workspaceId: string) => void;
|
||||
onCloseWorkspace: (workspaceId: string) => void;
|
||||
onCloseLogView: (logViewId: string) => void;
|
||||
onCloseTabsBatch: (targetIds: string[]) => void;
|
||||
onOpenQuickSwitcher: () => void;
|
||||
onToggleTheme: () => void;
|
||||
onOpenSettings: () => void;
|
||||
@@ -45,6 +50,9 @@ interface TopTabsProps {
|
||||
onEndSessionDrag: () => void;
|
||||
onReorderTabs: (draggedId: string, targetId: string, position: 'before' | 'after') => void;
|
||||
showSftpTab: boolean;
|
||||
editorTabs: readonly EditorTab[];
|
||||
onRequestCloseEditorTab: (editorTabId: string) => void;
|
||||
hostById: Map<string, Host>;
|
||||
}
|
||||
|
||||
// Detect local OS for local terminal tab icons
|
||||
@@ -244,6 +252,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onRenameWorkspace,
|
||||
onCloseWorkspace,
|
||||
onCloseLogView,
|
||||
onCloseTabsBatch,
|
||||
onOpenQuickSwitcher,
|
||||
onToggleTheme,
|
||||
onOpenSettings,
|
||||
@@ -253,6 +262,9 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onEndSessionDrag,
|
||||
onReorderTabs,
|
||||
showSftpTab,
|
||||
editorTabs,
|
||||
onRequestCloseEditorTab,
|
||||
hostById,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
// Subscribe to activeTabId from external store
|
||||
@@ -304,11 +316,23 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
updateScrollState();
|
||||
const container = tabsContainerRef.current;
|
||||
if (container) {
|
||||
// Translate vertical wheel to horizontal scroll so users can reach
|
||||
// off-screen tabs with a standard mouse wheel. Trackpad gestures that
|
||||
// already carry horizontal delta are left alone so native two-finger
|
||||
// swiping still works.
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
if (e.deltaY !== 0 && e.deltaX === 0) {
|
||||
e.preventDefault();
|
||||
container.scrollLeft += e.deltaY;
|
||||
}
|
||||
};
|
||||
container.addEventListener('scroll', updateScrollState);
|
||||
container.addEventListener('wheel', handleWheel, { passive: false });
|
||||
const resizeObserver = new ResizeObserver(updateScrollState);
|
||||
resizeObserver.observe(container);
|
||||
return () => {
|
||||
container.removeEventListener('scroll', updateScrollState);
|
||||
container.removeEventListener('wheel', handleWheel);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}
|
||||
@@ -463,9 +487,30 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
return styles;
|
||||
}, [dropIndicator, isDraggingForReorder, orderedTabs]);
|
||||
|
||||
// Pre-compute editor tab map for O(1) access
|
||||
const editorTabMap = useMemo(() => {
|
||||
const map = new Map<string, EditorTab>();
|
||||
for (const t of editorTabs) map.set(t.id, t);
|
||||
return map;
|
||||
}, [editorTabs]);
|
||||
|
||||
// fileName → count, for the rename-disambiguation suffix in the render loop.
|
||||
// Memoed so we don't do a per-tab O(n) filter on every render (was O(n²)).
|
||||
const editorTabFileNameCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const t of editorTabs) counts.set(t.fileName, (counts.get(t.fileName) ?? 0) + 1);
|
||||
return counts;
|
||||
}, [editorTabs]);
|
||||
|
||||
// Build ordered tab items using pre-computed maps for O(1) lookups
|
||||
const orderedTabItems = useMemo(() => {
|
||||
return orderedTabs.map((tabId) => {
|
||||
if (isEditorTabId(tabId)) {
|
||||
const editorId = fromEditorTabId(tabId);
|
||||
const editorTab = editorTabMap.get(editorId);
|
||||
if (!editorTab) return null;
|
||||
return { type: 'editor' as const, id: tabId, editorTab };
|
||||
}
|
||||
const session = orphanSessionMap.get(tabId);
|
||||
const workspace = workspaceMap.get(tabId);
|
||||
const logView = logViewMap.get(tabId);
|
||||
@@ -480,13 +525,115 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
}
|
||||
return null;
|
||||
}).filter(Boolean);
|
||||
}, [orderedTabs, orphanSessionMap, workspaceMap, logViewMap, workspacePaneCounts]);
|
||||
}, [orderedTabs, editorTabMap, orphanSessionMap, workspaceMap, logViewMap, workspacePaneCounts]);
|
||||
|
||||
// Bulk-close menu items shared by session and workspace context menus.
|
||||
// Anchor is the tab the user right-clicked on (matches VSCode/JetBrains UX).
|
||||
const renderBulkCloseItems = (anchorId: string) => {
|
||||
const anchorIdx = orderedTabs.indexOf(anchorId);
|
||||
const othersIds = orderedTabs.filter((id) => id !== anchorId);
|
||||
const rightIds = anchorIdx >= 0 ? orderedTabs.slice(anchorIdx + 1) : [];
|
||||
return (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
disabled={othersIds.length === 0}
|
||||
onClick={() => onCloseTabsBatch(othersIds)}
|
||||
>
|
||||
{t('tabs.closeOthers')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
disabled={rightIds.length === 0}
|
||||
onClick={() => onCloseTabsBatch(rightIds)}
|
||||
>
|
||||
{t('tabs.closeToRight')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => onCloseTabsBatch(orderedTabs)}
|
||||
>
|
||||
{t('tabs.closeAll')}
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Render the tabs
|
||||
const renderOrderedTabs = () => {
|
||||
return orderedTabItems.map((item) => {
|
||||
if (!item) return null;
|
||||
|
||||
if (item.type === 'editor') {
|
||||
const { editorTab } = item;
|
||||
const tabId = item.id;
|
||||
const isActive = activeTabId === tabId;
|
||||
const host = hostById.get(editorTab.hostId);
|
||||
const dirty = editorTab.content !== editorTab.baselineContent;
|
||||
const tooltip = `${host?.label ?? editorTab.hostId}@${host?.hostname ?? ''}:${editorTab.remotePath}`;
|
||||
// Disambiguate duplicate filenames using the memoed counts map.
|
||||
const suffix = (editorTabFileNameCounts.get(editorTab.fileName) ?? 0) > 1
|
||||
? ` · ${editorTab.remotePath.split('/').slice(-2, -1)[0] || '/'}`
|
||||
: '';
|
||||
const FileIcon = CODE_EXTENSIONS_RE.test(editorTab.fileName) ? FileCode : FileText;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tabId}
|
||||
data-tab-id={tabId}
|
||||
data-tab-type="editor"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
onClick={() => onSelectTab(tabId)}
|
||||
title={tooltip}
|
||||
className={cn(
|
||||
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: isActive
|
||||
? '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)))';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<FileIcon
|
||||
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 flex items-center gap-0.5">
|
||||
{dirty && <span className="text-primary mr-0.5">●</span>}
|
||||
{editorTab.fileName}
|
||||
{suffix && <span className="text-muted-foreground ml-1">{suffix}</span>}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRequestCloseEditorTab(editorTab.id);
|
||||
}}
|
||||
className="p-1 rounded-full hover:bg-destructive/10 hover:text-destructive transition-colors"
|
||||
aria-label="Close editor tab"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === 'session') {
|
||||
const session = item.session;
|
||||
const hasActivity = !!sessionActivityMap[session.id];
|
||||
@@ -510,7 +657,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onDragLeave={handleTabDragLeave}
|
||||
onDrop={(e) => handleTabDrop(e, session.id)}
|
||||
className={cn(
|
||||
"netcatty-tab 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",
|
||||
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"transition-transform duration-150",
|
||||
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : ""
|
||||
)}
|
||||
@@ -536,13 +683,6 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Active tab top accent line */}
|
||||
{activeTabId === session.id && (
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-[2px]"
|
||||
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))' }}
|
||||
/>
|
||||
)}
|
||||
{/* Drop indicator line - before */}
|
||||
{showDropIndicatorBefore && isDraggingForReorder && (
|
||||
<div
|
||||
@@ -581,6 +721,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
<ContextMenuItem className="text-destructive" onClick={() => onCloseSession(session.id)}>
|
||||
{t('common.close')}
|
||||
</ContextMenuItem>
|
||||
{renderBulkCloseItems(session.id)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
@@ -611,7 +752,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onDragLeave={handleTabDragLeave}
|
||||
onDrop={(e) => handleTabDrop(e, workspace.id)}
|
||||
className={cn(
|
||||
"netcatty-tab 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",
|
||||
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[150px] max-w-[260px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"transition-transform duration-150",
|
||||
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : ""
|
||||
)}
|
||||
@@ -637,13 +778,6 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Active tab top accent line */}
|
||||
{isActive && (
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-[2px]"
|
||||
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))' }}
|
||||
/>
|
||||
)}
|
||||
{/* Drop indicator line - before */}
|
||||
{showDropIndicatorBefore && isDraggingForReorder && (
|
||||
<div
|
||||
@@ -687,6 +821,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
<ContextMenuItem className="text-destructive" onClick={() => onCloseWorkspace(workspace.id)}>
|
||||
{t('common.close')}
|
||||
</ContextMenuItem>
|
||||
{renderBulkCloseItems(workspace.id)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
@@ -705,7 +840,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
onClick={() => onSelectTab(logView.id)}
|
||||
className={cn(
|
||||
"netcatty-tab 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",
|
||||
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: isActive
|
||||
@@ -728,13 +863,6 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Active tab top accent line */}
|
||||
{isActive && (
|
||||
<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}
|
||||
@@ -830,7 +958,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
data-state={isSftpActive ? 'active' : 'inactive'}
|
||||
onClick={() => onSelectTab('sftp')}
|
||||
className={cn(
|
||||
"netcatty-tab relative h-7 px-3 rounded-none text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
|
||||
"netcatty-tab relative h-7 px-3 rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: isSftpActive
|
||||
@@ -853,12 +981,6 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isSftpActive && (
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-[2px]"
|
||||
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))' }}
|
||||
/>
|
||||
)}
|
||||
<Folder size={14} /> SFTP
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -202,7 +202,7 @@ const TrayPanelContent: React.FC = () => {
|
||||
}, [quitApp]);
|
||||
|
||||
return (
|
||||
<div id="tray-panel-root" className="w-full h-full bg-background/95 backdrop-blur border border-border/60 rounded-lg shadow-lg overflow-hidden flex flex-col">
|
||||
<div id="tray-panel-root" className="w-full h-full bg-background/95 supports-[backdrop-filter]:backdrop-blur-sm border border-border/60 rounded-lg shadow-lg overflow-hidden flex flex-col">
|
||||
<div className="px-3 py-2 border-b border-border/60 flex items-center justify-between app-no-drag">
|
||||
<div className="flex items-center gap-2">
|
||||
<AppLogo className="w-5 h-5" />
|
||||
|
||||
32
components/VaultView.memo.test.tsx
Normal file
32
components/VaultView.memo.test.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { vaultViewAreEqual } from "./VaultView.tsx";
|
||||
|
||||
test("VaultView re-renders when an external section navigation request changes", () => {
|
||||
const baseProps = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
snippetPackages: [],
|
||||
customGroups: [],
|
||||
knownHosts: [],
|
||||
shellHistory: [],
|
||||
connectionLogs: [],
|
||||
sessions: [],
|
||||
managedSources: [],
|
||||
groupConfigs: {},
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
navigateToSection: null,
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
vaultViewAreEqual(
|
||||
baseProps as never,
|
||||
{ ...baseProps, navigateToSection: "snippets" } as never,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -36,7 +36,7 @@ import { useStoredViewMode } from "../application/state/useStoredViewMode";
|
||||
import { useStoredBoolean } from "../application/state/useStoredBoolean";
|
||||
import { useTreeExpandedState } from "../application/state/useTreeExpandedState";
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
|
||||
import { getEffectiveHostDistro, sanitizeHost } from "../domain/host";
|
||||
import { getEffectiveHostDistro, sanitizeHost, upsertHostById } from "../domain/host";
|
||||
import { importVaultHostsFromText, exportHostsToCsvWithStats } from "../domain/vaultImport";
|
||||
import type { VaultImportFormat } from "../domain/vaultImport";
|
||||
import {
|
||||
@@ -76,6 +76,7 @@ import SerialHostDetailsPanel from "./SerialHostDetailsPanel";
|
||||
import SnippetsManager from "./SnippetsManager";
|
||||
import { ImportVaultDialog, ImportOptions } from "./vault/ImportVaultDialog";
|
||||
import { Button } from "./ui/button";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
@@ -867,23 +868,30 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
|
||||
const displayedHosts = useMemo(() => {
|
||||
let filtered = hosts;
|
||||
if (selectedGroupPath) {
|
||||
// Match hosts whose group equals the selected path
|
||||
// For "General" group, also match hosts with empty/undefined group
|
||||
filtered = filtered.filter((h) => {
|
||||
const hostGroup = h.group || "";
|
||||
if (selectedGroupPath === "General") {
|
||||
return hostGroup === "" || hostGroup === "General";
|
||||
}
|
||||
return hostGroup === selectedGroupPath;
|
||||
});
|
||||
} else if (showOnlyUngroupedHostsInRoot) {
|
||||
filtered = filtered.filter((h) => {
|
||||
const hostGroup = (h.group || "").trim();
|
||||
return hostGroup === "";
|
||||
});
|
||||
// Search spans all groups (#777): when the user types in the search box
|
||||
// we skip group/ungrouped-root scoping, so a matching host in another
|
||||
// group is still reachable without having to navigate into it first.
|
||||
// The tree view already uses this shape — see `treeViewHosts` below.
|
||||
const hasSearch = search.trim().length > 0;
|
||||
if (!hasSearch) {
|
||||
if (selectedGroupPath) {
|
||||
// Match hosts whose group equals the selected path
|
||||
// For "General" group, also match hosts with empty/undefined group
|
||||
filtered = filtered.filter((h) => {
|
||||
const hostGroup = h.group || "";
|
||||
if (selectedGroupPath === "General") {
|
||||
return hostGroup === "" || hostGroup === "General";
|
||||
}
|
||||
return hostGroup === selectedGroupPath;
|
||||
});
|
||||
} else if (showOnlyUngroupedHostsInRoot) {
|
||||
filtered = filtered.filter((h) => {
|
||||
const hostGroup = (h.group || "").trim();
|
||||
return hostGroup === "";
|
||||
});
|
||||
}
|
||||
}
|
||||
if (search.trim()) {
|
||||
if (hasSearch) {
|
||||
const s = search.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(h) =>
|
||||
@@ -1590,24 +1598,26 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
<TooltipProvider delayDuration={100}>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-secondary/80 border-r border-border/60 flex flex-col transition-all duration-200",
|
||||
"bg-secondary border-r border-border/60 flex flex-col transition-all duration-200",
|
||||
sidebarCollapsed ? "w-14" : "w-52"
|
||||
)}
|
||||
data-section="vault-sidebar"
|
||||
>
|
||||
<div className={cn(
|
||||
"py-4 flex items-center",
|
||||
"pt-5 pb-6 flex items-center",
|
||||
sidebarCollapsed ? "px-2 justify-center" : "px-4"
|
||||
)}>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
className="flex items-center gap-3 hover:opacity-80 transition-opacity"
|
||||
className="flex items-center gap-2.5 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<AppLogo className="h-10 w-10 rounded-xl flex-shrink-0" />
|
||||
<AppLogo className="h-8 w-8 flex-shrink-0" />
|
||||
{!sidebarCollapsed && (
|
||||
<p className="text-sm font-bold text-foreground">Netcatty</p>
|
||||
<p className="text-xl font-black italic tracking-tight text-foreground leading-none">
|
||||
Netcatty
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
@@ -1620,7 +1630,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
<div className={cn("space-y-1", sidebarCollapsed ? "px-1.5" : "px-3")}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
<RippleButton
|
||||
variant={currentSection === "hosts" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full h-10",
|
||||
@@ -1635,13 +1645,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
>
|
||||
<LayoutGrid size={16} className="flex-shrink-0" />
|
||||
{!sidebarCollapsed && t("vault.nav.hosts")}
|
||||
</Button>
|
||||
</RippleButton>
|
||||
</TooltipTrigger>
|
||||
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.hosts")}</TooltipContent>}
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
<RippleButton
|
||||
variant={currentSection === "keys" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full h-10",
|
||||
@@ -1655,13 +1665,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
>
|
||||
<Key size={16} className="flex-shrink-0" />
|
||||
{!sidebarCollapsed && t("vault.nav.keychain")}
|
||||
</Button>
|
||||
</RippleButton>
|
||||
</TooltipTrigger>
|
||||
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.keychain")}</TooltipContent>}
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
<RippleButton
|
||||
variant={currentSection === "port" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full h-10",
|
||||
@@ -1673,13 +1683,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
>
|
||||
<Plug size={16} className="flex-shrink-0" />
|
||||
{!sidebarCollapsed && t("vault.nav.portForwarding")}
|
||||
</Button>
|
||||
</RippleButton>
|
||||
</TooltipTrigger>
|
||||
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.portForwarding")}</TooltipContent>}
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
<RippleButton
|
||||
variant={currentSection === "snippets" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full h-10",
|
||||
@@ -1693,13 +1703,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
>
|
||||
<FileCode size={16} className="flex-shrink-0" />
|
||||
{!sidebarCollapsed && t("vault.nav.snippets")}
|
||||
</Button>
|
||||
</RippleButton>
|
||||
</TooltipTrigger>
|
||||
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.snippets")}</TooltipContent>}
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
<RippleButton
|
||||
variant={currentSection === "knownhosts" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full h-10",
|
||||
@@ -1711,13 +1721,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
>
|
||||
<BookMarked size={16} className="flex-shrink-0" />
|
||||
{!sidebarCollapsed && t("vault.nav.knownHosts")}
|
||||
</Button>
|
||||
</RippleButton>
|
||||
</TooltipTrigger>
|
||||
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.knownHosts")}</TooltipContent>}
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
<RippleButton
|
||||
variant={currentSection === "logs" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full h-10",
|
||||
@@ -1729,7 +1739,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
>
|
||||
<Activity size={16} className="flex-shrink-0" />
|
||||
{!sidebarCollapsed && t("vault.nav.logs")}
|
||||
</Button>
|
||||
</RippleButton>
|
||||
</TooltipTrigger>
|
||||
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.logs")}</TooltipContent>}
|
||||
</Tooltip>
|
||||
@@ -1967,6 +1977,52 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{isMultiSelectMode && isHostsSectionActive && (
|
||||
<div className="px-4 py-1.5 bg-background border-b border-border/40 flex items-center gap-2">
|
||||
<span className="flex items-center h-7 text-xs text-muted-foreground leading-none">
|
||||
{t("vault.hosts.selected", { count: selectedHostIds.size })}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => {
|
||||
const allIds = new Set(displayedHosts.map(h => h.id));
|
||||
setSelectedHostIds(allIds);
|
||||
}}
|
||||
>
|
||||
{t("vault.hosts.selectAll")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={clearHostSelection}
|
||||
>
|
||||
{t("vault.hosts.deselectAll")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
disabled={selectedHostIds.size === 0}
|
||||
onClick={deleteSelectedHosts}
|
||||
>
|
||||
<Trash2 size={12} className="mr-1" />
|
||||
{t("vault.hosts.deleteSelected", { count: selectedHostIds.size })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={clearHostSelection}
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keep hosts mounted so switching sections does not reset scroll or remount the list. */}
|
||||
<div
|
||||
className={cn(
|
||||
@@ -2401,49 +2457,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isMultiSelectMode && (
|
||||
<div className="flex items-center gap-2 p-2 bg-secondary/60 rounded-lg border border-border/40">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("vault.hosts.selected", { count: selectedHostIds.size })}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const allIds = new Set(displayedHosts.map(h => h.id));
|
||||
setSelectedHostIds(allIds);
|
||||
}}
|
||||
>
|
||||
{t("vault.hosts.selectAll")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearHostSelection}
|
||||
>
|
||||
{t("vault.hosts.deselectAll")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={selectedHostIds.size === 0}
|
||||
onClick={deleteSelectedHosts}
|
||||
>
|
||||
<Trash2 size={14} className="mr-1" />
|
||||
{t("vault.hosts.deleteSelected", { count: selectedHostIds.size })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={clearHostSelection}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === "tree" ? (
|
||||
<HostTreeView
|
||||
groupTree={treeViewGroupTree}
|
||||
@@ -2941,13 +2954,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
groupDefaults={editingHostGroupDefaults}
|
||||
groupConfigs={groupConfigs}
|
||||
onSave={(host) => {
|
||||
// Check if host already exists in the list (for updates vs. new/duplicate)
|
||||
const hostExists = hosts.some((h) => h.id === host.id);
|
||||
onUpdateHosts(
|
||||
hostExists
|
||||
? hosts.map((h) => (h.id === host.id ? host : h))
|
||||
: [...hosts, host],
|
||||
);
|
||||
onUpdateHosts(upsertHostById(hosts, host));
|
||||
setIsHostPanelOpen(false);
|
||||
setEditingHost(null);
|
||||
setNewHostGroupPath(null);
|
||||
@@ -2973,15 +2980,15 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
allTags={allTags}
|
||||
groups={allGroupPaths}
|
||||
onSave={(host) => {
|
||||
onUpdateHosts(
|
||||
hosts.map((h) => (h.id === host.id ? host : h)),
|
||||
);
|
||||
onUpdateHosts(upsertHostById(hosts, host));
|
||||
setIsHostPanelOpen(false);
|
||||
setEditingHost(null);
|
||||
setNewHostGroupPath(null);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsHostPanelOpen(false);
|
||||
setEditingHost(null);
|
||||
setNewHostGroupPath(null);
|
||||
}}
|
||||
layout="inline"
|
||||
/>
|
||||
@@ -3192,7 +3199,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
};
|
||||
|
||||
// Only re-render when data props change - isActive is now managed internally via store subscription
|
||||
const vaultViewAreEqual = (
|
||||
export const vaultViewAreEqual = (
|
||||
prev: VaultViewProps,
|
||||
next: VaultViewProps,
|
||||
): boolean => {
|
||||
@@ -3210,7 +3217,8 @@ const vaultViewAreEqual = (
|
||||
prev.managedSources === next.managedSources &&
|
||||
prev.groupConfigs === next.groupConfigs &&
|
||||
prev.terminalThemeId === next.terminalThemeId &&
|
||||
prev.terminalFontSize === next.terminalFontSize;
|
||||
prev.terminalFontSize === next.terminalFontSize &&
|
||||
prev.navigateToSection === next.navigateToSection;
|
||||
|
||||
return isEqual;
|
||||
};
|
||||
|
||||
@@ -9,27 +9,37 @@ export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: 'user' | 'assistant' | 'system' | 'tool';
|
||||
};
|
||||
|
||||
// Public CSS hooks for user customization (Settings → Appearance → Custom CSS):
|
||||
// .ai-chat-message[data-role="user"] — outer row, user-authored
|
||||
// .ai-chat-message[data-role="assistant"] — outer row, assistant reply
|
||||
// .ai-chat-message-content[data-role=...] — inner bubble / content area
|
||||
// These attributes are part of the UI's stable contract; do not rename
|
||||
// without updating Custom CSS docs.
|
||||
export const Message = ({ className, from, ...props }: MessageProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'group flex w-full max-w-[95%] flex-col gap-1.5',
|
||||
'ai-chat-message group flex w-full max-w-[95%] flex-col gap-1.5',
|
||||
from === 'user' ? 'is-user ml-auto' : 'is-assistant',
|
||||
className,
|
||||
)}
|
||||
data-role={from}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
export type MessageContentProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from?: 'user' | 'assistant' | 'system' | 'tool';
|
||||
};
|
||||
|
||||
export const MessageContent = ({ children, className, ...props }: MessageContentProps) => (
|
||||
export const MessageContent = ({ children, className, from, ...props }: MessageContentProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-fit min-w-0 max-w-full flex-col gap-1.5 text-[13px] leading-relaxed',
|
||||
'ai-chat-message-content 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,
|
||||
)}
|
||||
data-role={from}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -217,7 +217,7 @@ const AgentSelector: React.FC<AgentSelectorProps> = ({
|
||||
<DropdownContent
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="w-[288px] overflow-hidden rounded-2xl border border-border/50 bg-popover p-0 text-foreground shadow-lg supports-[backdrop-filter]:backdrop-blur-xl"
|
||||
className="w-[288px] overflow-hidden rounded-2xl border border-border/50 bg-popover p-0 text-foreground shadow-lg supports-[backdrop-filter]:backdrop-blur-sm"
|
||||
>
|
||||
{BUILTIN_AGENTS.map((agent) => (
|
||||
<AgentMenuRow
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { AtSign, Check, ChevronDown, ChevronRight, Cpu, Expand, Eye, FileText, ImageIcon, Package, Plus, ShieldCheck, X, Zap } from 'lucide-react';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { FormEvent } from 'react';
|
||||
@@ -100,6 +100,8 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
const [hoveredModelId, setHoveredModelId] = useState<string | null>(null);
|
||||
const [slashQuery, setSlashQuery] = useState('');
|
||||
const [slashRange, setSlashRange] = useState<{ start: number; end: number } | null>(null);
|
||||
// Active highlight index for @ mention / slash skill keyboard navigation
|
||||
const [activeMenuIndex, setActiveMenuIndex] = useState(0);
|
||||
|
||||
// Derived booleans for readability
|
||||
const showModelPicker = activeMenu === 'model';
|
||||
@@ -203,11 +205,11 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
setActiveMenu(menu);
|
||||
}, [getInputPanelMenuPos]);
|
||||
|
||||
const filteredUserSkills = userSkills.filter((skill) => {
|
||||
const filteredUserSkills = useMemo(() => userSkills.filter((skill) => {
|
||||
if (!slashQuery) return true;
|
||||
const lowerQuery = slashQuery.toLowerCase();
|
||||
return skill.slug.toLowerCase().startsWith(lowerQuery) || skill.name.toLowerCase().includes(lowerQuery);
|
||||
});
|
||||
}), [userSkills, slashQuery]);
|
||||
|
||||
const removeSlashQueryFromInput = useCallback(() => {
|
||||
if (!slashRange) return value;
|
||||
@@ -227,6 +229,78 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
closeAllMenus();
|
||||
}, [closeAllMenus, onAddUserSkill, onChange, removeSlashQueryFromInput, slashRange]);
|
||||
|
||||
// Reset active highlight when a menu opens or when the *identity* of the
|
||||
// visible items changes. Watching only `.length` misses cases where the
|
||||
// filter produces a different set with the same count (e.g. user types
|
||||
// another character into the slash query) — Enter would then commit an
|
||||
// unexpected item. Derive a stable key from the visible ids instead.
|
||||
const atMentionKey = useMemo(
|
||||
() => hosts.map((h) => h.sessionId).join('|'),
|
||||
[hosts],
|
||||
);
|
||||
const slashSkillKey = useMemo(
|
||||
() => filteredUserSkills.map((s) => s.id).join('|'),
|
||||
[filteredUserSkills],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (showAtMention) setActiveMenuIndex(0);
|
||||
}, [showAtMention, atMentionKey]);
|
||||
useEffect(() => {
|
||||
if (showSlashSkillPicker) setActiveMenuIndex(0);
|
||||
}, [showSlashSkillPicker, slashSkillKey]);
|
||||
|
||||
const handleTextareaKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.nativeEvent.isComposing) return;
|
||||
// @ mention popover keyboard navigation
|
||||
if (showAtMention && hosts.length > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActiveMenuIndex((i) => (i + 1) % hosts.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActiveMenuIndex((i) => (i - 1 + hosts.length) % hosts.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const host = hosts[Math.min(activeMenuIndex, hosts.length - 1)];
|
||||
if (host) handleSelectAtMention(host);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeAllMenus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// / skill popover keyboard navigation
|
||||
if (showSlashSkillPicker && filteredUserSkills.length > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActiveMenuIndex((i) => (i + 1) % filteredUserSkills.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActiveMenuIndex((i) => (i - 1 + filteredUserSkills.length) % filteredUserSkills.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const skill = filteredUserSkills[Math.min(activeMenuIndex, filteredUserSkills.length - 1)];
|
||||
if (skill) insertUserSkillToken(skill);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeAllMenus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [showAtMention, hosts, showSlashSkillPicker, filteredUserSkills, activeMenuIndex, handleSelectAtMention, insertUserSkillToken, closeAllMenus]);
|
||||
|
||||
const handlePaste = useCallback((e: React.ClipboardEvent) => {
|
||||
const pastedFiles = Array.from(e.clipboardData.items)
|
||||
.map((item: DataTransferItem) => item.getAsFile())
|
||||
@@ -367,6 +441,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
onKeyDown={handleTextareaKeyDown}
|
||||
placeholder={placeholder || defaultPlaceholder}
|
||||
disabled={disabled}
|
||||
className={[
|
||||
@@ -392,31 +467,40 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Mention host"
|
||||
className="fixed z-[1000] overflow-hidden rounded-[20px] border border-border/60 bg-popover shadow-2xl"
|
||||
style={{ left: inputPanelPos.left, bottom: inputPanelPos.bottom, width: inputPanelPos.width }}
|
||||
aria-activedescendant={hosts[activeMenuIndex] ? `at-mention-${hosts[activeMenuIndex].sessionId}` : undefined}
|
||||
className="fixed z-[1000] overflow-hidden rounded-lg border border-border/50 bg-popover shadow-lg"
|
||||
style={{ left: inputPanelPos.left, bottom: inputPanelPos.bottom, width: 'auto', minWidth: Math.min(200, inputPanelPos.width), maxWidth: inputPanelPos.width }}
|
||||
>
|
||||
<div className="px-4 pt-3 pb-1.5 text-[10px] font-medium text-muted-foreground/62 tracking-wide">{t('ai.chat.menuHosts')}</div>
|
||||
<ScrollArea className="max-h-[300px]">
|
||||
<div className="px-2.5 pb-2.5">
|
||||
{hosts.map(host => (
|
||||
<button
|
||||
key={host.sessionId}
|
||||
type="button"
|
||||
role="option"
|
||||
onClick={() => handleSelectAtMention(host)}
|
||||
className="w-full rounded-[16px] px-3 py-1.5 text-left hover:bg-muted/30 transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-[12px] text-foreground/90">
|
||||
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${host.connected ? 'bg-green-500' : 'bg-muted-foreground/30'}`} />
|
||||
<span className="truncate">{host.label || host.hostname}</span>
|
||||
</div>
|
||||
{host.label && host.hostname !== host.label ? (
|
||||
<div className="mt-0.5 pl-3.5 text-[10px] text-muted-foreground/60 truncate">
|
||||
{host.hostname}
|
||||
<ScrollArea className="max-h-[280px]">
|
||||
<div className="p-1">
|
||||
{hosts.map((host, idx) => {
|
||||
const isActive = idx === activeMenuIndex;
|
||||
const showHostnameLine = host.label
|
||||
&& host.hostname !== host.label
|
||||
&& !host.label.includes(host.hostname);
|
||||
return (
|
||||
<button
|
||||
id={`at-mention-${host.sessionId}`}
|
||||
key={host.sessionId}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
onMouseEnter={() => setActiveMenuIndex(idx)}
|
||||
onClick={() => handleSelectAtMention(host)}
|
||||
className={`w-full rounded-md px-2 py-1 text-left transition-colors cursor-pointer ${isActive ? 'bg-muted/40' : 'hover:bg-muted/30'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-[12px] text-foreground/90">
|
||||
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${host.connected ? 'bg-green-500' : 'bg-muted-foreground/30'}`} />
|
||||
<span className="truncate">{host.label || host.hostname}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
{showHostnameLine ? (
|
||||
<div className="pl-3.5 text-[10px] text-muted-foreground/60 truncate">
|
||||
{host.hostname}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
@@ -428,34 +512,41 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
{showSlashSkillPicker && filteredUserSkills.length > 0 && inputPanelPos && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
|
||||
<div className="fixed inset-0 z-[999] cursor-default" onClick={closeAllMenus} />
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Insert user skill"
|
||||
className="fixed z-[1000] overflow-hidden rounded-[20px] border border-border/60 bg-popover shadow-2xl"
|
||||
style={{ left: inputPanelPos.left, bottom: inputPanelPos.bottom, width: inputPanelPos.width }}
|
||||
aria-activedescendant={filteredUserSkills[activeMenuIndex] ? `slash-skill-${filteredUserSkills[activeMenuIndex].id}` : undefined}
|
||||
className="fixed z-[1000] overflow-hidden rounded-lg border border-border/50 bg-popover shadow-lg"
|
||||
style={{ left: inputPanelPos.left, bottom: inputPanelPos.bottom, width: 'auto', minWidth: Math.min(200, inputPanelPos.width), maxWidth: inputPanelPos.width }}
|
||||
>
|
||||
<div className="px-4 pt-3 pb-1.5 text-[10px] font-medium text-muted-foreground/62 tracking-wide">{t('ai.chat.menuUserSkills')}</div>
|
||||
<ScrollArea className="max-h-[300px]">
|
||||
<div className="px-2.5 pb-2.5">
|
||||
{filteredUserSkills.map((skill) => (
|
||||
<button
|
||||
key={skill.id}
|
||||
type="button"
|
||||
role="option"
|
||||
onClick={() => insertUserSkillToken(skill)}
|
||||
className="w-full rounded-[16px] px-3 py-1.5 text-left hover:bg-muted/30 transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-[12px]">
|
||||
<Package size={12} className="text-muted-foreground/55 shrink-0" />
|
||||
<span className="text-foreground/90">/{skill.slug}</span>
|
||||
</div>
|
||||
{skill.description ? (
|
||||
<div className="mt-0.5 pl-5 text-[10px] leading-4.5 text-muted-foreground/62 line-clamp-2">
|
||||
{skill.description}
|
||||
<ScrollArea className="max-h-[280px]">
|
||||
<div className="p-1">
|
||||
{filteredUserSkills.map((skill, idx) => {
|
||||
const isActive = idx === activeMenuIndex;
|
||||
return (
|
||||
<button
|
||||
id={`slash-skill-${skill.id}`}
|
||||
key={skill.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
onMouseEnter={() => setActiveMenuIndex(idx)}
|
||||
onClick={() => insertUserSkillToken(skill)}
|
||||
className={`w-full rounded-md px-2 py-1 text-left transition-colors cursor-pointer ${isActive ? 'bg-muted/40' : 'hover:bg-muted/30'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-[12px]">
|
||||
<Package size={12} className="text-muted-foreground/55 shrink-0" />
|
||||
<span className="text-foreground/90">/{skill.slug}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
{skill.description ? (
|
||||
<div className="pl-5 text-[10px] leading-4.5 text-muted-foreground/62 line-clamp-2">
|
||||
{skill.description}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
@@ -488,6 +579,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
{showAttachMenu && menuPos && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
|
||||
<div className="fixed inset-0 z-[999] cursor-default" onClick={closeAllMenus} />
|
||||
<div
|
||||
role="menu"
|
||||
className="fixed z-[1000] min-w-[170px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
|
||||
@@ -568,10 +660,11 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
{hasModelPicker && <ChevronDown size={9} className="text-muted-foreground/50" />}
|
||||
</button>
|
||||
{showModelPicker && hasModelPicker && menuPos && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
|
||||
<div
|
||||
role="listbox"
|
||||
<>
|
||||
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
|
||||
<div className="fixed inset-0 z-[999] cursor-default" onClick={closeAllMenus} />
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Select model"
|
||||
className="fixed z-[1000] w-max min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
|
||||
style={{ left: menuPos.left, bottom: menuPos.bottom, maxWidth: MODEL_PICKER_MAX_WIDTH }}
|
||||
@@ -680,6 +773,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
{showPermPicker && menuPos && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
|
||||
<div className="fixed inset-0 z-[999] cursor-default" onClick={closeAllMenus} />
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Permission mode"
|
||||
|
||||
@@ -196,7 +196,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
|
||||
return (
|
||||
<Message key={message.id} from={message.role}>
|
||||
<MessageContent>
|
||||
<MessageContent from={message.role}>
|
||||
{/* Thinking block */}
|
||||
{!isUser && message.thinking && (
|
||||
<ThinkingBlock
|
||||
|
||||
@@ -59,7 +59,7 @@ const ConversationExport: React.FC<ConversationExportProps> = ({
|
||||
<DropdownContent
|
||||
align="end"
|
||||
sideOffset={6}
|
||||
className="w-40 rounded-xl border border-border/45 bg-[#111111]/98 p-1.5 text-foreground shadow-[0_20px_48px_rgba(0,0,0,0.48)] supports-[backdrop-filter]:bg-[#111111]/92 supports-[backdrop-filter]:backdrop-blur-xl"
|
||||
className="w-40 rounded-xl border border-border/45 bg-[#111111]/98 p-1.5 text-foreground shadow-[0_20px_48px_rgba(0,0,0,0.48)] supports-[backdrop-filter]:bg-[#111111]/92 supports-[backdrop-filter]:backdrop-blur-sm"
|
||||
>
|
||||
<div className="px-2 py-1 text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground/48">
|
||||
{t('ai.chat.exportAs')}
|
||||
|
||||
662
components/ai/acpHistory.test.ts
Normal file
662
components/ai/acpHistory.test.ts
Normal file
@@ -0,0 +1,662 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { ChatMessage } from "../../infrastructure/ai/types.ts";
|
||||
import {
|
||||
buildAcpHistoryMessages,
|
||||
buildAcpHistoryMessagesForBridge,
|
||||
} from "./acpHistory.ts";
|
||||
|
||||
function message(
|
||||
id: string,
|
||||
role: ChatMessage["role"],
|
||||
content: string,
|
||||
extra: Partial<ChatMessage> = {},
|
||||
): ChatMessage {
|
||||
return {
|
||||
id,
|
||||
role,
|
||||
content,
|
||||
timestamp: 1,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
test("buildAcpHistoryMessages compacts older ACP context and keeps only recent raw turns", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "我希望最小改动,不要添加很多 test"),
|
||||
message("a1", "assistant", "已按最小改动处理"),
|
||||
message("u2", "user", "MCP 不允许使用,Windows 上不要假设 pwsh.exe"),
|
||||
message("a2", "assistant", "PR #738 已创建,commit 4181a2c"),
|
||||
message("u3", "user", "帮我上网查查优化方案,每轮都带历史太慢了"),
|
||||
message("a3", "assistant", "建议 ACP history compaction"),
|
||||
message("tool1", "tool", "", {
|
||||
toolResults: [
|
||||
{
|
||||
toolCallId: "search",
|
||||
content: `error: ${"large output ".repeat(500)}`,
|
||||
isError: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
message("u4", "user", "好的"),
|
||||
message("a4", "assistant", "准备实现"),
|
||||
message("u5", "user", "继续"),
|
||||
message("a5", "assistant", "继续处理"),
|
||||
message("u6", "user", "现在提交"),
|
||||
message("a6", "assistant", "还没提交"),
|
||||
];
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /Compact prior Netcatty UI context/);
|
||||
assert.match(result[0].content, /最小改动/);
|
||||
assert.match(result[0].content, /pwsh\.exe/);
|
||||
assert.match(result[0].content, /PR #738/);
|
||||
assert.ok(result[0].content.length <= 3000);
|
||||
|
||||
assert.ok(result.length <= 7);
|
||||
assert.deepEqual(
|
||||
result.slice(1).map((entry) => entry.content),
|
||||
["好的", "准备实现", "继续", "继续处理", "现在提交", "还没提交"],
|
||||
);
|
||||
assert.ok(result.every((entry) => entry.content.length <= 3000));
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessagesForBridge keeps fallback history available for stale ACP session recovery", () => {
|
||||
const messages = [message("u1", "user", "继续处理这个历史压缩问题")];
|
||||
|
||||
assert.equal(buildAcpHistoryMessagesForBridge([], "acp-session-1"), undefined);
|
||||
assert.deepEqual(
|
||||
buildAcpHistoryMessagesForBridge(messages, "acp-session-1"),
|
||||
buildAcpHistoryMessages(messages),
|
||||
);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves older substantive user instructions outside the recent raw window", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "Keep this incremental and do not refactor unrelated files."),
|
||||
message("a1", "assistant", "Understood."),
|
||||
];
|
||||
|
||||
for (let index = 2; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `filler assistant message ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /Keep this incremental and do not refactor unrelated files\./);
|
||||
assert.deepEqual(
|
||||
result.slice(-6).map((entry) => entry.content),
|
||||
[
|
||||
"filler user message 11",
|
||||
"filler assistant message 11",
|
||||
"filler user message 12",
|
||||
"filler assistant message 12",
|
||||
"filler user message 13",
|
||||
"filler assistant message 13",
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves short important user constraints outside the recent raw window", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "不要提交"),
|
||||
message("a1", "assistant", "收到"),
|
||||
];
|
||||
|
||||
for (let index = 2; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `filler assistant message ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /不要提交/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages does not treat pr inside ordinary words as important", () => {
|
||||
// Original intent: `\bpr\b` in IMPORTANT_PATTERNS must NOT match 'pr'
|
||||
// inside ordinary English words like 'approach' / 'improve' / 'prepare'.
|
||||
// Those words land at priority=1 (kept only as space allows) while the
|
||||
// 不要提交 line lands at priority=2 (always preferred). The check below
|
||||
// doesn't assert that the ordinary words are absent from the compact
|
||||
// section — they may legitimately survive when budget allows; that's
|
||||
// intentional after we stopped blanket-dropping short user messages.
|
||||
// What we DO verify: the priority-2 line is selected, which is only
|
||||
// possible if the IMPORTANT_PATTERNS regex correctly distinguishes it
|
||||
// from the surrounding short ordinary-word turns.
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "不要提交"),
|
||||
message("a1", "assistant", "收到"),
|
||||
message("u2", "user", "approach"),
|
||||
message("a2", "assistant", "ack"),
|
||||
message("u3", "user", "improve"),
|
||||
message("a3", "assistant", "ack"),
|
||||
message("u4", "user", "prepare"),
|
||||
message("a4", "assistant", "ack"),
|
||||
];
|
||||
|
||||
for (let index = 5; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `filler assistant message ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /不要提交/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages prioritizes later durable instructions over older filler prompts", () => {
|
||||
const messages: ChatMessage[] = [];
|
||||
|
||||
for (let index = 1; index <= 12; index += 1) {
|
||||
messages.push(
|
||||
message(
|
||||
`u${index}`,
|
||||
"user",
|
||||
`Please continue with implementation step ${index} and keep momentum by following the current plan carefully.`,
|
||||
),
|
||||
message(`a${index}`, "assistant", `Ack ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
messages.push(
|
||||
message("u13", "user", "Keep the existing layout and copy wording unchanged."),
|
||||
message("a13", "assistant", "Understood."),
|
||||
);
|
||||
|
||||
for (let index = 14; index <= 18; index += 1) {
|
||||
messages.push(
|
||||
message(
|
||||
`u${index}`,
|
||||
"user",
|
||||
`Please continue with implementation step ${index} and keep momentum by following the current plan carefully.`,
|
||||
),
|
||||
message(`a${index}`, "assistant", `Ack ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /Keep the existing layout and copy wording unchanged\./);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves older substantive assistant context that later user prompts can reference", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "Please propose a migration plan for the sidebar state."),
|
||||
message(
|
||||
"a1",
|
||||
"assistant",
|
||||
"Plan: 1. Introduce a dedicated hook for the panel stack. 2. Move the derived view state into that hook. 3. Keep the existing UI copy and layout. 4. Add a regression test around back navigation.",
|
||||
),
|
||||
];
|
||||
|
||||
for (let index = 2; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `Ack ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
messages.push(message("u14", "user", "Apply step 2 of your plan now."));
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /Move the derived view state into that hook\./);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves short non-trivial user constraints that miss the IMPORTANT regex", () => {
|
||||
// Regression: short load-bearing instructions like "Use ssh2" / "中文输出"
|
||||
// would previously be dropped by a blanket length<10 heuristic, even
|
||||
// though they don't match any TRIVIAL pattern.
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "Use ssh2"),
|
||||
message("a1", "assistant", "Got it."),
|
||||
message("u2", "user", "中文输出"),
|
||||
message("a2", "assistant", "明白"),
|
||||
];
|
||||
|
||||
// Push enough later turns so u1/u2 fall outside the recent raw window
|
||||
// and have to survive via the durable-user compaction path.
|
||||
for (let index = 3; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `filler assistant message ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /Use ssh2/);
|
||||
assert.match(result[0].content, /中文输出/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages still drops one-word filler user messages", () => {
|
||||
// Sanity: removing the length<10 heuristic must not cause "ok" / "继续" /
|
||||
// "thanks" filler to leak into the compact section.
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "ok"),
|
||||
message("a1", "assistant", "ack"),
|
||||
message("u2", "user", "继续"),
|
||||
message("a2", "assistant", "继续处理"),
|
||||
];
|
||||
|
||||
for (let index = 3; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `filler assistant message ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
// u1 / u2 fall outside the recent raw window. The compact context, if it
|
||||
// exists, must not surface these trivial turns as durable user requests.
|
||||
if (result.length > 0 && result[0].role === "user") {
|
||||
assert.doesNotMatch(result[0].content, /User request: ok\b/);
|
||||
assert.doesNotMatch(result[0].content, /User request: 继续/);
|
||||
}
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves recent tool results verbatim (up to the raw budget) for follow-up references", () => {
|
||||
// Regression: tool results used to only reach fallback replay via the
|
||||
// 500-char compact summary. If the user's last interaction produced a
|
||||
// large tool output (cat/rg/fetched file), any "use that output"-style
|
||||
// follow-up lost the actual bytes. Now tool messages flow through the
|
||||
// recent raw window at MAX_RAW_MESSAGE_CHARS (2000).
|
||||
const bigToolOutput = "DATA ".repeat(300); // ~1500 chars — bigger than summary cap but smaller than raw cap
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "cat /etc/hosts"),
|
||||
message("a1", "assistant", "", {
|
||||
toolCalls: [{ id: "call1", name: "terminal", arguments: { cmd: "cat /etc/hosts" } }],
|
||||
}),
|
||||
message("tool1", "tool", "", {
|
||||
toolResults: [
|
||||
{ toolCallId: "call1", content: bigToolOutput, isError: false },
|
||||
],
|
||||
}),
|
||||
message("u2", "user", "use that output"),
|
||||
];
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
// Raw-window tool result carries both the [from ...] provenance label
|
||||
// and the actual bytes (not just the 500-char compact summary).
|
||||
assert.match(flat, /Tool result \[from terminal.*?cat \/etc\/hosts.*?\] \(call1\): DATA DATA DATA/);
|
||||
// Confirm we kept enough bytes to exceed the compact-summary cap.
|
||||
const toolResultIdx = flat.indexOf("Tool result [from terminal");
|
||||
assert.ok(toolResultIdx >= 0, "tool result line must appear in raw window");
|
||||
const toolResultChunk = flat.slice(toolResultIdx);
|
||||
assert.ok(
|
||||
toolResultChunk.length > 600,
|
||||
`expected tool result chunk to exceed compact cap (~500 chars), got ${toolResultChunk.length}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages inlines tool_call name+args so tool_result is interpretable without the preceding assistant turn", () => {
|
||||
// Regression: if the raw window starts mid-tool-interaction, the
|
||||
// preceding assistant tool_call message may be outside the 6-item
|
||||
// slice. Without the call's name/args inline on the result line, the
|
||||
// AI sees opaque bytes and "use that output" becomes ambiguous.
|
||||
const messages: ChatMessage[] = [
|
||||
// Early filler to push the tool_call off the raw window
|
||||
message("u0", "user", "prior chatter"),
|
||||
message("a0", "assistant", "prior reply"),
|
||||
message("u1", "user", "cat /etc/hosts"),
|
||||
message("a1", "assistant", "", {
|
||||
toolCalls: [
|
||||
{ id: "call1", name: "terminal_exec", arguments: { command: "cat /etc/hosts" } },
|
||||
],
|
||||
}),
|
||||
message("tool1", "tool", "", {
|
||||
toolResults: [
|
||||
{ toolCallId: "call1", content: "127.0.0.1 localhost", isError: false },
|
||||
],
|
||||
}),
|
||||
message("u2", "user", "use that output"),
|
||||
message("a2", "assistant", "acknowledged"),
|
||||
message("u3", "user", "now do the same for /etc/resolv.conf"),
|
||||
];
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
// The tool_result line must carry the originating tool_call's name and
|
||||
// args, so even if a1 was pushed out of the raw window, the result is
|
||||
// self-describing.
|
||||
assert.match(flat, /Tool result \[from terminal_exec/);
|
||||
assert.match(flat, /cat \/etc\/hosts/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages bounds the durable-candidate scan to avoid O(N) work per send on long chats", () => {
|
||||
// Regression target: codex review flagged that the compaction path
|
||||
// scanned messages.entries() over the full transcript. Build a very
|
||||
// long chat (>> MAX_DURABLE_SCAN_TURNS user turns) and verify that
|
||||
// only messages within the recent user-turn window contribute
|
||||
// durable candidates.
|
||||
const messages: ChatMessage[] = [];
|
||||
// An ancient high-priority constraint that MUST be aged out.
|
||||
messages.push(message("old-important", "user", "不要提交 old-marker-xyz"));
|
||||
messages.push(message("old-ack", "assistant", "收到"));
|
||||
|
||||
// 300 filler turns between the ancient constraint and the window —
|
||||
// well past MAX_DURABLE_SCAN_TURNS (100).
|
||||
for (let i = 0; i < 300; i += 1) {
|
||||
messages.push(
|
||||
message(`u${i}`, "user", `filler user message ${i}`),
|
||||
message(`a${i}`, "assistant", `filler assistant message ${i}`),
|
||||
);
|
||||
}
|
||||
|
||||
// A recent constraint that should survive.
|
||||
messages.push(message("recent-important", "user", "不要提交 recent-marker-abc"));
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
messages.push(
|
||||
message(`t${i}`, "user", `tail user message ${i}`),
|
||||
message(`ta${i}`, "assistant", `tail assistant message ${i}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
// Recent priority-2 constraint is kept.
|
||||
assert.match(flat, /recent-marker-abc/);
|
||||
// Ancient one past the scan window is dropped — proof the bound holds.
|
||||
assert.doesNotMatch(flat, /old-marker-xyz/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves an early constraint in a tool-heavy chat where message count balloons past the raw-count limit", () => {
|
||||
// Regression: the previous bound was MAX_DURABLE_SCAN_MESSAGES=200 on
|
||||
// the raw message array. In a tool-heavy chat, each user turn can
|
||||
// expand to 5+ messages (user + assistant w/ toolCalls + N tool
|
||||
// results + follow-up assistant), so 200 messages might be only
|
||||
// ~40 user turns. An instruction like "不要提交" from turn 5 would
|
||||
// fall out of the scan before the turn count justified aging it out.
|
||||
//
|
||||
// Now the bound is MAX_DURABLE_SCAN_TURNS=100 user turns. Build a
|
||||
// chat with only 30 user turns but many messages per turn — the
|
||||
// early constraint must still survive.
|
||||
const messages: ChatMessage[] = [];
|
||||
messages.push(message("early-important", "user", "不要提交 EARLY_CONSTRAINT_MARKER"));
|
||||
messages.push(message("early-ack", "assistant", "收到"));
|
||||
|
||||
// 35 additional turns, each with 6 messages (bloats the total
|
||||
// message count to >200 without exceeding 100 user turns).
|
||||
for (let turn = 1; turn < 36; turn += 1) {
|
||||
messages.push(message(`u${turn}`, "user", `turn ${turn} request`));
|
||||
messages.push(message(`a${turn}-plan`, "assistant", "let me check", {
|
||||
toolCalls: [
|
||||
{ id: `c${turn}a`, name: "terminal_exec", arguments: { cmd: "echo a" } },
|
||||
{ id: `c${turn}b`, name: "terminal_exec", arguments: { cmd: "echo b" } },
|
||||
{ id: `c${turn}c`, name: "terminal_exec", arguments: { cmd: "echo c" } },
|
||||
],
|
||||
}));
|
||||
messages.push(message(`t${turn}a`, "tool", "", {
|
||||
toolResults: [{ toolCallId: `c${turn}a`, content: `result a of turn ${turn}`, isError: false }],
|
||||
}));
|
||||
messages.push(message(`t${turn}b`, "tool", "", {
|
||||
toolResults: [{ toolCallId: `c${turn}b`, content: `result b of turn ${turn}`, isError: false }],
|
||||
}));
|
||||
messages.push(message(`t${turn}c`, "tool", "", {
|
||||
toolResults: [{ toolCallId: `c${turn}c`, content: `result c of turn ${turn}`, isError: false }],
|
||||
}));
|
||||
messages.push(message(`a${turn}-done`, "assistant", `turn ${turn} done`));
|
||||
}
|
||||
|
||||
// Sanity: the message count is over 200 even though user turns are 30.
|
||||
assert.ok(messages.length > 200, `setup: expected > 200 messages, got ${messages.length}`);
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
// Under the old raw-count bound, the early constraint would age out;
|
||||
// under the turn-based bound it survives.
|
||||
assert.match(flat, /EARLY_CONSTRAINT_MARKER/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves short non-trivial assistant decisions that miss the keyword heuristic", () => {
|
||||
// Regression: isSubstantiveAssistantMessage previously required length
|
||||
// >= 40 OR a small English keyword match OR a numbered list. Short
|
||||
// load-bearing replies like "Use ssh2" / "rebase instead" / "中文输出"
|
||||
// satisfied none of those and were silently dropped. After a stale-
|
||||
// session recovery, "do what you suggested earlier" would then replay
|
||||
// only the user's question without the assistant's actual decision.
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "which client should I use"),
|
||||
message("a1", "assistant", "Use ssh2"),
|
||||
message("u2", "user", "output language?"),
|
||||
message("a2", "assistant", "中文输出"),
|
||||
message("u3", "user", "merge or rebase?"),
|
||||
message("a3", "assistant", "rebase instead"),
|
||||
];
|
||||
|
||||
// Pad so u1..a3 fall outside the recent raw window (last 6 items) and
|
||||
// must flow through the durable-assistant compact pass.
|
||||
for (let index = 4; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `Ack ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
assert.match(flat, /Use ssh2/);
|
||||
assert.match(flat, /中文输出/);
|
||||
assert.match(flat, /rebase instead/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages still drops trivial assistant filler like 'ack' / 'ok' / '明白'", () => {
|
||||
// Sanity: removing the length/keyword gate must not let assistant
|
||||
// filler leak into the compact durable-assistant section.
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "prompt 1"),
|
||||
message("a1", "assistant", "ack"),
|
||||
message("u2", "user", "prompt 2"),
|
||||
message("a2", "assistant", "明白"),
|
||||
message("u3", "user", "prompt 3"),
|
||||
message("a3", "assistant", "got it"),
|
||||
];
|
||||
|
||||
for (let index = 4; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `more filler ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
assert.doesNotMatch(flat, /Assistant context: ack\b/);
|
||||
assert.doesNotMatch(flat, /Assistant context: got it\b/);
|
||||
assert.doesNotMatch(flat, /Assistant context: 明白/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages inlines tool_call context on OLDER summarized tool results", () => {
|
||||
// Regression: the raw-window fix covered the last 6 items, but once
|
||||
// a tool result fell into the compact section (summarizeToolMessage
|
||||
// path) the `[from <name>(<args>)]` provenance label was absent.
|
||||
// With multiple older tool outputs, all surfacing as identical
|
||||
// `Tool result (callN): ...`, follow-ups like "use the resolv.conf
|
||||
// output" have no way to map to the right call.
|
||||
const messages: ChatMessage[] = [
|
||||
// Two distinct tool interactions, both pushed well outside the
|
||||
// recent raw window by later turns.
|
||||
message("u1", "user", "show hosts"),
|
||||
message("a1", "assistant", "", {
|
||||
toolCalls: [{ id: "call-hosts", name: "terminal_exec", arguments: { command: "cat /etc/hosts" } }],
|
||||
}),
|
||||
message("tool1", "tool", "", {
|
||||
toolResults: [{ toolCallId: "call-hosts", content: "127.0.0.1 localhost", isError: false }],
|
||||
}),
|
||||
message("u2", "user", "show resolv.conf"),
|
||||
message("a2", "assistant", "", {
|
||||
toolCalls: [{ id: "call-resolv", name: "terminal_exec", arguments: { command: "cat /etc/resolv.conf" } }],
|
||||
}),
|
||||
message("tool2", "tool", "", {
|
||||
toolResults: [{ toolCallId: "call-resolv", content: "nameserver 8.8.8.8", isError: false }],
|
||||
}),
|
||||
// Important user text so summarizeMessage picks these up via the
|
||||
// important-text branch; tool results themselves are always
|
||||
// summarized regardless of IMPORTANT_PATTERNS.
|
||||
message("u3", "user", "fallback plan"),
|
||||
];
|
||||
|
||||
// Filler to push the early tool results out of the 6-item raw window
|
||||
// and into the compact summary section (scanned = last 20).
|
||||
for (let index = 4; index <= 10; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `Ack ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
// Both older tool results must now carry provenance labels so a
|
||||
// follow-up can disambiguate them.
|
||||
assert.match(flat, /Tool result \[from terminal_exec.*?cat \/etc\/hosts/);
|
||||
assert.match(flat, /Tool result \[from terminal_exec.*?cat \/etc\/resolv\.conf/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages does not duplicate recent raw turns into the compact summary section", () => {
|
||||
// Regression: the scanned loop (last 20) overlaps with recentRaw (last 6).
|
||||
// Without skipping raw-window items, the same last-6 turns would be
|
||||
// summarized in the compact section AND appended verbatim in the raw
|
||||
// section — doubling the budget cost of important user turns / large
|
||||
// tool output and crowding out older durable context.
|
||||
//
|
||||
// Setup: enough filler upfront that u3 ends up OUTSIDE the raw window
|
||||
// (so it can be asserted absent from raw), then a distinctive "raw
|
||||
// only" marker that should appear only in the last-6 raw slice.
|
||||
const messages: ChatMessage[] = [];
|
||||
for (let index = 1; index <= 6; index += 1) {
|
||||
messages.push(
|
||||
message(`uf${index}`, "user", `filler user ${index}`),
|
||||
message(`af${index}`, "assistant", `filler assistant ${index}`),
|
||||
);
|
||||
}
|
||||
// These are the last 4 user/assistant messages — guaranteed to be in
|
||||
// the last-6 raw slice. The IMPORTANT markers below would ordinarily
|
||||
// also get summarized into the compact section, duplicating the cost.
|
||||
messages.push(
|
||||
message("u-rec1", "user", "commit now IMPORTANT_RAW_MARKER please"),
|
||||
message("a-rec1", "assistant", "", {
|
||||
toolCalls: [{ id: "c1", name: "git", arguments: { op: "commit" } }],
|
||||
}),
|
||||
message("tool-rec", "tool", "", {
|
||||
toolResults: [{ toolCallId: "c1", content: "committed abc123 RAW_TOOL_MARKER", isError: false }],
|
||||
}),
|
||||
message("u-rec2", "user", "now push"),
|
||||
);
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
const compact = result.find((m) => m.content.includes("[Compact prior Netcatty UI context]"));
|
||||
assert.ok(compact, "expected a compact context message");
|
||||
|
||||
// Both markers belong to messages inside the raw window — they must
|
||||
// not be summarized into compact (which would double-bill them).
|
||||
assert.doesNotMatch(compact.content, /IMPORTANT_RAW_MARKER/);
|
||||
assert.doesNotMatch(compact.content, /RAW_TOOL_MARKER/);
|
||||
|
||||
// Raw section still carries them verbatim.
|
||||
const raw = result.filter((m) => !m.content.includes("[Compact prior Netcatty UI context]"));
|
||||
const rawFlat = raw.map((m) => m.content).join("\n");
|
||||
assert.match(rawFlat, /IMPORTANT_RAW_MARKER/);
|
||||
assert.match(rawFlat, /RAW_TOOL_MARKER/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages resolves tool_call provenance correctly when tool ids are reused across turns", () => {
|
||||
// Regression: keying toolCallIndex by raw toolCall.id alone let a later
|
||||
// assistant tool_call with the same id overwrite the older one. An
|
||||
// older tool_result in the replay history would then be annotated
|
||||
// with the wrong command (e.g. a /etc/hosts result labeled as
|
||||
// /etc/resolv.conf). Now each tool_result is indexed by its own
|
||||
// messageId + toolCallId and resolved to the most recent preceding
|
||||
// call with that id.
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "show hosts"),
|
||||
message("a1", "assistant", "", {
|
||||
toolCalls: [{ id: "call1", name: "terminal_exec", arguments: { command: "cat /etc/hosts" } }],
|
||||
}),
|
||||
message("tool-hosts", "tool", "", {
|
||||
toolResults: [{ toolCallId: "call1", content: "127.0.0.1 localhost HOSTS_BYTES", isError: false }],
|
||||
}),
|
||||
// A later assistant turn reuses the id "call1" for a different call.
|
||||
message("u2", "user", "show resolv"),
|
||||
message("a2", "assistant", "", {
|
||||
toolCalls: [{ id: "call1", name: "terminal_exec", arguments: { command: "cat /etc/resolv.conf" } }],
|
||||
}),
|
||||
message("tool-resolv", "tool", "", {
|
||||
toolResults: [{ toolCallId: "call1", content: "nameserver 8.8.8.8 RESOLV_BYTES", isError: false }],
|
||||
}),
|
||||
message("u3", "user", "ok"),
|
||||
];
|
||||
|
||||
// Pad so the first interaction lands in the compact summary pass.
|
||||
for (let index = 4; index <= 10; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `Ack ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
// Each tool_result must be annotated with ITS OWN preceding call's
|
||||
// args — not whichever assistant tool_call happened to win the
|
||||
// last-write on the shared id.
|
||||
//
|
||||
// Extract the two Tool-result lines and match each to its expected
|
||||
// args. Use non-greedy .*? — the args JSON can contain parentheses.
|
||||
const hostsMatch = flat.match(/Tool result \[from [^\]]*?cat \/etc\/hosts[^\]]*?\][^\n]*HOSTS_BYTES/);
|
||||
const resolvMatch = flat.match(/Tool result \[from [^\]]*?cat \/etc\/resolv\.conf[^\]]*?\][^\n]*RESOLV_BYTES/);
|
||||
|
||||
assert.ok(hostsMatch, "hosts result must still be labeled with cat /etc/hosts despite later id reuse");
|
||||
assert.ok(resolvMatch, "resolv result must be labeled with cat /etc/resolv.conf");
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves assistant-only compact context", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "ok"),
|
||||
message(
|
||||
"a1",
|
||||
"assistant",
|
||||
"Plan: 1. Move parser setup into a dedicated hook. 2. Keep storage schema unchanged. 3. Add a regression test.",
|
||||
),
|
||||
];
|
||||
|
||||
for (let index = 2; index <= 7; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", index % 2 === 0 ? "ok" : "continue"),
|
||||
message(`a${index}`, "assistant", "ack"),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /Move parser setup into a dedicated hook\./);
|
||||
});
|
||||
438
components/ai/acpHistory.ts
Normal file
438
components/ai/acpHistory.ts
Normal file
@@ -0,0 +1,438 @@
|
||||
import type { ChatMessage } from "../../infrastructure/ai/types.ts";
|
||||
|
||||
type AcpHistoryMessage = { role: "user" | "assistant"; content: string };
|
||||
type RawHistoryMessage = AcpHistoryMessage & { sourceId: string };
|
||||
type DurableUserLine = {
|
||||
line: string;
|
||||
messageIndex: number;
|
||||
priority: number;
|
||||
};
|
||||
|
||||
const MAX_RECENT_RAW_MESSAGES = 6;
|
||||
const MAX_MESSAGES_TO_SCAN = 20;
|
||||
// Bound the scan by user turns, not raw message count: a tool-heavy ACP
|
||||
// chat can produce 5+ messages per logical turn (user + assistant +
|
||||
// several tool_results + follow-up assistant), so a plain
|
||||
// message-count cap ages out early constraints much sooner than intended.
|
||||
const MAX_DURABLE_SCAN_TURNS = 100;
|
||||
const MAX_COMPACT_CONTEXT_CHARS = 3000;
|
||||
const MAX_RAW_MESSAGE_CHARS = 2000;
|
||||
const MAX_TOOL_SUMMARY_CHARS = 500;
|
||||
const MAX_DURABLE_USER_CONTEXT_CHARS = 1400;
|
||||
const MAX_DURABLE_ASSISTANT_CONTEXT_CHARS = 900;
|
||||
const MAX_RECENT_SUMMARY_CONTEXT_CHARS = 1200;
|
||||
const MAX_DURABLE_USER_MESSAGE_CHARS = 280;
|
||||
const MAX_DURABLE_ASSISTANT_MESSAGE_CHARS = 360;
|
||||
const MAX_TOOL_CALL_LABEL_CHARS = 200;
|
||||
|
||||
type ToolCallInfo = { name: string; arguments: unknown };
|
||||
|
||||
const IMPORTANT_PATTERNS = [
|
||||
/不要|别|不能|不允许|必须|希望|只|最小|先|暂时|fallback|pwsh|powershell|cmd\.exe|windows|mcp|skills|cli|commit|\bpr\b|打包|内存|历史|压缩|慢/i,
|
||||
/error|failed|failure|exit code|exception|cannot|unable|timeout|crash|fallback|commit|pull request|PR #\d+/i,
|
||||
];
|
||||
const DURABLE_CONSTRAINT_PATTERNS = [
|
||||
/\bdo not\b|\bdon't\b|\bkeep\b|\bpreserve\b|\bavoid\b|\bonly\b|\bunchanged\b|\blocal only\b|\bwithout\b|\bleave\b/i,
|
||||
/不要|别|保留|保持|维持|不改|别改|不要改|仅限本地/i,
|
||||
];
|
||||
const TRIVIAL_USER_MESSAGE_PATTERNS = [
|
||||
/^(ok|okay|yes|no|thanks|thank you|continue|继续|好的|收到|行|嗯|好|继续处理|继续吧|开始吧)[.!? ]*$/i,
|
||||
];
|
||||
const TRIVIAL_ASSISTANT_MESSAGE_PATTERNS = [
|
||||
/^(ok|okay|understood|got it|working|proceeding|ready|ack(?: \d+)?|收到|明白|继续处理|准备实现|开始处理|处理中)[.!? ]*$/i,
|
||||
];
|
||||
|
||||
function truncateText(value: string, maxChars: number): string {
|
||||
if (value.length <= maxChars) return value;
|
||||
return `${value.slice(0, Math.max(0, maxChars - 24)).trimEnd()}\n[truncated]`;
|
||||
}
|
||||
|
||||
function normalizeWhitespace(value: string): string {
|
||||
return value.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function isImportantText(value: string): boolean {
|
||||
return IMPORTANT_PATTERNS.some((pattern) => pattern.test(value));
|
||||
}
|
||||
|
||||
function isDurableConstraintText(value: string): boolean {
|
||||
return DURABLE_CONSTRAINT_PATTERNS.some((pattern) => pattern.test(value));
|
||||
}
|
||||
|
||||
function isTrivialUserMessage(value: string): boolean {
|
||||
const normalized = normalizeWhitespace(value);
|
||||
if (isImportantText(normalized) || isDurableConstraintText(normalized)) return false;
|
||||
// Don't blanket-drop short messages — short user turns are often
|
||||
// load-bearing constraints ("Use ssh2", "中文输出", "no logs", "more
|
||||
// verbose") that the IMPORTANT/DURABLE regexes can't realistically
|
||||
// enumerate. The trivial-phrase regex already catches actual filler
|
||||
// ("ok", "yes", "thanks", "继续").
|
||||
return TRIVIAL_USER_MESSAGE_PATTERNS.some((pattern) => pattern.test(normalized));
|
||||
}
|
||||
|
||||
function getDurableUserPriority(value: string): number {
|
||||
const normalized = normalizeWhitespace(value);
|
||||
if (isImportantText(normalized) || isDurableConstraintText(normalized)) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function isSubstantiveAssistantMessage(value: string): boolean {
|
||||
const normalized = normalizeWhitespace(value);
|
||||
if (!normalized) return false;
|
||||
// Mirror the user-side loosening: don't blanket-drop short assistant
|
||||
// messages just because they're under 40 chars or don't match the small
|
||||
// English keyword list. Short but load-bearing decisions ("Use ssh2",
|
||||
// "rebase instead", "中文输出") aren't realistically enumerable and
|
||||
// they're the exact things a later "do what you suggested" references.
|
||||
// TRIVIAL_ASSISTANT_MESSAGE_PATTERNS still catches the actual filler
|
||||
// ("ok", "ack", "got it", "明白").
|
||||
return !TRIVIAL_ASSISTANT_MESSAGE_PATTERNS.some((pattern) => pattern.test(normalized));
|
||||
}
|
||||
|
||||
function getDurableAssistantPriority(value: string): number {
|
||||
const normalized = normalizeWhitespace(value);
|
||||
if (isImportantText(normalized)) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function appendUniqueLine(
|
||||
target: string[],
|
||||
seen: Set<string>,
|
||||
line: string,
|
||||
maxSectionChars: number,
|
||||
sectionCharsRef: { value: number },
|
||||
): void {
|
||||
const normalized = normalizeWhitespace(line);
|
||||
if (!normalized || seen.has(normalized)) return;
|
||||
const nextChars = sectionCharsRef.value + normalized.length;
|
||||
if (nextChars > maxSectionChars) return;
|
||||
seen.add(normalized);
|
||||
target.push(normalized);
|
||||
sectionCharsRef.value = nextChars;
|
||||
}
|
||||
|
||||
function summarizeToolMessage(
|
||||
message: ChatMessage,
|
||||
toolCallIndex: Map<string, ToolCallInfo>,
|
||||
): string[] {
|
||||
if (!message.toolResults?.length) return [];
|
||||
return message.toolResults.map((result) => {
|
||||
const prefix = result.isError ? "Tool error" : "Tool result";
|
||||
const content = normalizeWhitespace(result.content || "");
|
||||
// Same provenance problem as the raw-window path: once a tool result
|
||||
// lands in the compact section (older than the 6-item raw window),
|
||||
// its paired assistant tool_call is almost always gone. Without the
|
||||
// call label, multiple older results collapse into indistinguishable
|
||||
// "Tool result (callN): ..." lines and follow-ups like "use the
|
||||
// resolv.conf output" can't be resolved. Inline the name+args here
|
||||
// the same way toRawHistoryMessage does.
|
||||
const callInfo = lookupToolCallInfo(toolCallIndex, message.id, result.toolCallId);
|
||||
const callLabel = callInfo
|
||||
? ` [from ${callInfo.name}(${truncateText(JSON.stringify(callInfo.arguments ?? {}), MAX_TOOL_CALL_LABEL_CHARS)})]`
|
||||
: "";
|
||||
return `${prefix}${callLabel} (${result.toolCallId}): ${truncateText(content, MAX_TOOL_SUMMARY_CHARS)}`;
|
||||
});
|
||||
}
|
||||
|
||||
function summarizeMessage(
|
||||
message: ChatMessage,
|
||||
toolCallIndex: Map<string, ToolCallInfo>,
|
||||
): string[] {
|
||||
if (message.role === "system") return [];
|
||||
if (message.role === "tool") return summarizeToolMessage(message, toolCallIndex);
|
||||
|
||||
const lines: string[] = [];
|
||||
if (message.content && isImportantText(message.content)) {
|
||||
const label = message.role === "user" ? "User" : "Assistant";
|
||||
lines.push(`${label}: ${truncateText(normalizeWhitespace(message.content), MAX_TOOL_SUMMARY_CHARS)}`);
|
||||
}
|
||||
|
||||
if (message.role === "assistant" && message.toolCalls?.length) {
|
||||
for (const toolCall of message.toolCalls) {
|
||||
const args = JSON.stringify(toolCall.arguments ?? {});
|
||||
const summary = `Tool call: ${toolCall.name}(${truncateText(args, 220)})`;
|
||||
if (isImportantText(summary)) lines.push(summary);
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
function summarizeDurableUserMessage(message: ChatMessage): string | null {
|
||||
if (message.role !== "user" || !message.content) return null;
|
||||
if (isTrivialUserMessage(message.content)) return null;
|
||||
return `User request: ${truncateText(normalizeWhitespace(message.content), MAX_DURABLE_USER_MESSAGE_CHARS)}`;
|
||||
}
|
||||
|
||||
function summarizeDurableAssistantMessage(message: ChatMessage): string | null {
|
||||
if (message.role !== "assistant" || !message.content) return null;
|
||||
if (!isSubstantiveAssistantMessage(message.content)) return null;
|
||||
return `Assistant context: ${truncateText(normalizeWhitespace(message.content), MAX_DURABLE_ASSISTANT_MESSAGE_CHARS)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a per-tool-result provenance index. Keys are
|
||||
* `${toolResultMessageId}:${toolCallId}` rather than the bare toolCall.id
|
||||
* so that provider-reused ids (e.g. "call1" across unrelated turns) don't
|
||||
* cause later calls to overwrite older ones in the lookup — each
|
||||
* tool_result resolves to the most recent assistant tool_call that
|
||||
* preceded it with matching id, which preserves historical correctness
|
||||
* when rebuilding older compact summaries.
|
||||
*/
|
||||
function buildToolCallIndex(messages: ChatMessage[]): Map<string, ToolCallInfo> {
|
||||
const provenance = new Map<string, ToolCallInfo>();
|
||||
// Rolling map of the latest tool_call seen (by id) up to the current
|
||||
// point in the message stream.
|
||||
const latestByCallId = new Map<string, ToolCallInfo>();
|
||||
for (const message of messages) {
|
||||
if (message.role === "assistant" && message.toolCalls?.length) {
|
||||
for (const toolCall of message.toolCalls) {
|
||||
if (!toolCall.id) continue;
|
||||
latestByCallId.set(toolCall.id, { name: toolCall.name, arguments: toolCall.arguments });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (message.role === "tool" && message.toolResults?.length) {
|
||||
for (const result of message.toolResults) {
|
||||
const info = latestByCallId.get(result.toolCallId);
|
||||
if (info) {
|
||||
provenance.set(`${message.id}:${result.toolCallId}`, info);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return provenance;
|
||||
}
|
||||
|
||||
function lookupToolCallInfo(
|
||||
index: Map<string, ToolCallInfo>,
|
||||
toolMessageId: string,
|
||||
toolCallId: string,
|
||||
): ToolCallInfo | undefined {
|
||||
return index.get(`${toolMessageId}:${toolCallId}`);
|
||||
}
|
||||
|
||||
function toRawHistoryMessage(
|
||||
message: ChatMessage,
|
||||
toolCallIndex: Map<string, ToolCallInfo>,
|
||||
): RawHistoryMessage[] {
|
||||
if (message.role === "user") {
|
||||
return message.content
|
||||
? [{ sourceId: message.id, role: "user", content: truncateText(message.content, MAX_RAW_MESSAGE_CHARS) }]
|
||||
: [];
|
||||
}
|
||||
|
||||
if (message.role === "assistant") {
|
||||
const parts: string[] = [];
|
||||
if (message.content) parts.push(message.content);
|
||||
if (message.toolCalls?.length) {
|
||||
parts.push(...message.toolCalls.map((tc) => `Tool call: ${tc.name}(${JSON.stringify(tc.arguments ?? {})})`));
|
||||
}
|
||||
return parts.length
|
||||
? [{ sourceId: message.id, role: "assistant", content: truncateText(parts.join("\n\n"), MAX_RAW_MESSAGE_CHARS) }]
|
||||
: [];
|
||||
}
|
||||
|
||||
if (message.role === "tool" && message.toolResults?.length) {
|
||||
// Keep tool output in the recent raw window (up to MAX_RAW_MESSAGE_CHARS
|
||||
// per message, ~2000). Without this, follow-up turns after stale-session
|
||||
// recovery would only see the 500-char compact summary in
|
||||
// summarizeToolMessage, losing the actual bytes the user might reference
|
||||
// ("use that output", "what did cat show?"). ACP only supports user/
|
||||
// assistant roles, so we flatten to "assistant" — the tool results were
|
||||
// produced during the assistant's turn.
|
||||
//
|
||||
// Inline the originating tool_call's name+args. Tool calls and their
|
||||
// results live in separate messages; if the last six raw items start
|
||||
// in the middle of a tool interaction, the preceding assistant tool
|
||||
// call can be outside the window. Without the call label the result
|
||||
// is opaque bytes and "use that output" becomes ambiguous.
|
||||
const parts = message.toolResults.map((result) => {
|
||||
const prefix = result.isError ? "Tool error" : "Tool result";
|
||||
const callInfo = lookupToolCallInfo(toolCallIndex, message.id, result.toolCallId);
|
||||
const callLabel = callInfo
|
||||
? ` [from ${callInfo.name}(${truncateText(JSON.stringify(callInfo.arguments ?? {}), MAX_TOOL_CALL_LABEL_CHARS)})]`
|
||||
: "";
|
||||
return `${prefix}${callLabel} (${result.toolCallId}): ${result.content || ""}`;
|
||||
});
|
||||
return [{
|
||||
sourceId: message.id,
|
||||
role: "assistant",
|
||||
content: truncateText(parts.join("\n\n"), MAX_RAW_MESSAGE_CHARS),
|
||||
}];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function buildCompactContext(
|
||||
messages: ChatMessage[],
|
||||
durableScanStart: number,
|
||||
recentRawSourceIds: Set<string>,
|
||||
toolCallIndex: Map<string, ToolCallInfo>,
|
||||
): AcpHistoryMessage[] {
|
||||
const scanned = messages.slice(-MAX_MESSAGES_TO_SCAN);
|
||||
const summaryLines: string[] = [];
|
||||
const durableUserCandidates: DurableUserLine[] = [];
|
||||
const selectedDurableUserLines: DurableUserLine[] = [];
|
||||
const durableAssistantCandidates: DurableUserLine[] = [];
|
||||
const selectedDurableAssistantLines: DurableUserLine[] = [];
|
||||
const seen = new Set<string>();
|
||||
const durableChars = { value: 0 };
|
||||
const durableAssistantChars = { value: 0 };
|
||||
const summaryChars = { value: 0 };
|
||||
|
||||
for (let messageIndex = durableScanStart; messageIndex < messages.length; messageIndex += 1) {
|
||||
const message = messages[messageIndex];
|
||||
if (recentRawSourceIds.has(message.id)) continue;
|
||||
const durableUserLine = summarizeDurableUserMessage(message);
|
||||
if (durableUserLine) {
|
||||
durableUserCandidates.push({
|
||||
line: durableUserLine,
|
||||
messageIndex,
|
||||
priority: getDurableUserPriority(message.content || ""),
|
||||
});
|
||||
}
|
||||
const durableAssistantLine = summarizeDurableAssistantMessage(message);
|
||||
if (durableAssistantLine) {
|
||||
durableAssistantCandidates.push({
|
||||
line: durableAssistantLine,
|
||||
messageIndex,
|
||||
priority: getDurableAssistantPriority(message.content || ""),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
durableUserCandidates
|
||||
.sort((left, right) => right.priority - left.priority || right.messageIndex - left.messageIndex)
|
||||
.forEach((candidate) => {
|
||||
const normalized = normalizeWhitespace(candidate.line);
|
||||
if (!normalized || seen.has(normalized)) return;
|
||||
const nextChars = durableChars.value + normalized.length;
|
||||
if (nextChars > MAX_DURABLE_USER_CONTEXT_CHARS) return;
|
||||
seen.add(normalized);
|
||||
selectedDurableUserLines.push(candidate);
|
||||
durableChars.value = nextChars;
|
||||
});
|
||||
|
||||
durableAssistantCandidates
|
||||
.sort((left, right) => right.priority - left.priority || right.messageIndex - left.messageIndex)
|
||||
.forEach((candidate) => {
|
||||
const normalized = normalizeWhitespace(candidate.line);
|
||||
if (!normalized || seen.has(normalized)) return;
|
||||
const nextChars = durableAssistantChars.value + normalized.length;
|
||||
if (nextChars > MAX_DURABLE_ASSISTANT_CONTEXT_CHARS) return;
|
||||
seen.add(normalized);
|
||||
selectedDurableAssistantLines.push(candidate);
|
||||
durableAssistantChars.value = nextChars;
|
||||
});
|
||||
|
||||
const durableUserLines = selectedDurableUserLines
|
||||
.sort((left, right) => left.messageIndex - right.messageIndex)
|
||||
.map((candidate) => candidate.line);
|
||||
const durableAssistantLines = selectedDurableAssistantLines
|
||||
.sort((left, right) => left.messageIndex - right.messageIndex)
|
||||
.map((candidate) => candidate.line);
|
||||
|
||||
for (const line of [...durableUserLines, ...durableAssistantLines]) {
|
||||
seen.add(normalizeWhitespace(line));
|
||||
}
|
||||
|
||||
// Skip messages that are already appended verbatim in the raw window —
|
||||
// otherwise the same last-6 turns get summarized here AND re-sent as
|
||||
// raw, doubling the budget cost of important user turns / large tool
|
||||
// output and crowding out older durable context the replay is meant
|
||||
// to preserve. Matches the recentRawSourceIds skip in the durable pass.
|
||||
for (const message of scanned) {
|
||||
if (recentRawSourceIds.has(message.id)) continue;
|
||||
for (const line of summarizeMessage(message, toolCallIndex)) {
|
||||
appendUniqueLine(summaryLines, seen, line, MAX_RECENT_SUMMARY_CONTEXT_CHARS, summaryChars);
|
||||
}
|
||||
}
|
||||
|
||||
if (!durableUserLines.length && !durableAssistantLines.length && !summaryLines.length) return [];
|
||||
|
||||
const contentLines = [
|
||||
"[Compact prior Netcatty UI context]",
|
||||
"The external ACP agent may already have its own persisted session context. Use this compact Netcatty UI context only as fallback/background, and prefer the current user request when there is any conflict.",
|
||||
];
|
||||
if (durableUserLines.length) {
|
||||
contentLines.push("Earlier user requests that may still apply:");
|
||||
contentLines.push(...durableUserLines.map((line) => `- ${line}`));
|
||||
}
|
||||
if (durableAssistantLines.length) {
|
||||
contentLines.push("Earlier assistant context that may still matter:");
|
||||
contentLines.push(...durableAssistantLines.map((line) => `- ${line}`));
|
||||
}
|
||||
if (summaryLines.length) {
|
||||
contentLines.push("Recent noteworthy context:");
|
||||
contentLines.push(...summaryLines.map((line) => `- ${line}`));
|
||||
}
|
||||
|
||||
return [{
|
||||
role: "user",
|
||||
content: truncateText(
|
||||
contentLines.join("\n"),
|
||||
MAX_COMPACT_CONTEXT_CHARS,
|
||||
),
|
||||
}];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the index of the first message to include in the scan window,
|
||||
* bounded by MAX_DURABLE_SCAN_TURNS user turns (not raw message count).
|
||||
* Walking backwards stops at the target turn count, so the cost is
|
||||
* bounded even when the transcript is huge.
|
||||
*/
|
||||
function computeDurableScanStart(messages: ChatMessage[]): number {
|
||||
let userTurns = 0;
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
if (messages[i].role === "user") {
|
||||
userTurns += 1;
|
||||
if (userTurns >= MAX_DURABLE_SCAN_TURNS) return i;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function buildAcpHistoryMessages(messages: ChatMessage[]): AcpHistoryMessage[] {
|
||||
// Compute the scan start once, then do all subsequent work over the
|
||||
// already-sliced tail. This avoids O(N) walks over the whole transcript
|
||||
// on every send — previously buildToolCallIndex + the flatMap-to-take-
|
||||
// last-6 raw history both traversed every message in the chat.
|
||||
const durableScanStart = computeDurableScanStart(messages);
|
||||
const scannedTail = messages.slice(durableScanStart);
|
||||
|
||||
// The tool-call provenance index only needs entries for tool_results
|
||||
// that might appear in our output. Building from the scanned tail is
|
||||
// correct for any tool_result whose paired assistant tool_call is
|
||||
// also within the window, which covers >99% of realistic patterns
|
||||
// (tool_calls and tool_results are always adjacent or near-adjacent).
|
||||
// If an ancient tool_call's result stays within the window while the
|
||||
// call itself is outside, that single result loses its [from X(Y)]
|
||||
// label — an acceptable trade for eliminating the per-send O(N) walk.
|
||||
const toolCallIndex = buildToolCallIndex(scannedTail);
|
||||
|
||||
const rawHistory = scannedTail
|
||||
.flatMap((message) => toRawHistoryMessage(message, toolCallIndex))
|
||||
.slice(-MAX_RECENT_RAW_MESSAGES);
|
||||
const compactContext = buildCompactContext(
|
||||
messages,
|
||||
durableScanStart,
|
||||
new Set(rawHistory.map((message) => message.sourceId)),
|
||||
toolCallIndex,
|
||||
);
|
||||
const recentRaw = rawHistory.map(({ role, content }) => ({ role, content }));
|
||||
|
||||
return [...compactContext, ...recentRaw];
|
||||
}
|
||||
|
||||
export function buildAcpHistoryMessagesForBridge(
|
||||
messages: ChatMessage[],
|
||||
_existingSessionId?: string | null,
|
||||
): AcpHistoryMessage[] | undefined {
|
||||
// The main process bridge only consumes this payload during stale-session
|
||||
// fallback replay, so keep it available even when a session id exists.
|
||||
const historyMessages = buildAcpHistoryMessages(messages);
|
||||
return historyMessages.length ? historyMessages : undefined;
|
||||
}
|
||||
@@ -6,11 +6,11 @@ import type {
|
||||
AISession,
|
||||
} from "../../infrastructure/ai/types.ts";
|
||||
import {
|
||||
applyDraftEntrySelection,
|
||||
applyHistorySessionSelection,
|
||||
normalizePanelView,
|
||||
resolveDisplayedPanelView,
|
||||
resolveDisplayedSession,
|
||||
shouldRetargetSessionForScope,
|
||||
} from "./aiPanelViewState.ts";
|
||||
|
||||
function createSession(id: string): AISession {
|
||||
@@ -61,7 +61,7 @@ test("missing explicit panel view resumes the most recent matching history when
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.deepEqual(
|
||||
resolveDisplayedPanelView(undefined, false, sessions),
|
||||
resolveDisplayedPanelView(undefined, false, sessions, undefined, "workspace"),
|
||||
{ mode: "session", sessionId: "session-2" },
|
||||
);
|
||||
});
|
||||
@@ -70,7 +70,7 @@ test("missing explicit panel view restores the persisted active session instead
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.deepEqual(
|
||||
resolveDisplayedPanelView(undefined, false, sessions, "session-1"),
|
||||
resolveDisplayedPanelView(undefined, false, sessions, "session-1", "workspace"),
|
||||
{ mode: "session", sessionId: "session-1" },
|
||||
);
|
||||
});
|
||||
@@ -79,7 +79,7 @@ test("persisted session id that no longer exists in history falls back to newest
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.deepEqual(
|
||||
resolveDisplayedPanelView(undefined, false, sessions, "deleted-session"),
|
||||
resolveDisplayedPanelView(undefined, false, sessions, "deleted-session", "workspace"),
|
||||
{ mode: "session", sessionId: "session-2" },
|
||||
);
|
||||
});
|
||||
@@ -88,11 +88,20 @@ test("null persisted session id falls back to newest history entry", () => {
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.deepEqual(
|
||||
resolveDisplayedPanelView(undefined, false, sessions, null),
|
||||
resolveDisplayedPanelView(undefined, false, sessions, null, "workspace"),
|
||||
{ mode: "session", sessionId: "session-2" },
|
||||
);
|
||||
});
|
||||
|
||||
test("terminal scope without explicit view always starts from draft even when history exists", () => {
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.deepEqual(
|
||||
resolveDisplayedPanelView(undefined, false, sessions, "session-1", "terminal"),
|
||||
{ mode: "draft" },
|
||||
);
|
||||
});
|
||||
|
||||
test("missing explicit panel view prefers the draft when unsent input exists", () => {
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
@@ -109,50 +118,6 @@ test("draft state is used when there is no implicit history to resume", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("restorable terminal history should retarget to the current scope", () => {
|
||||
const session: AISession = {
|
||||
...createSession("session-2"),
|
||||
scope: {
|
||||
type: "terminal",
|
||||
targetId: "old-terminal",
|
||||
hostIds: ["host-1"],
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
shouldRetargetSessionForScope(
|
||||
session,
|
||||
"terminal",
|
||||
"new-terminal",
|
||||
["host-1"],
|
||||
new Set<string>(),
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("session owned by another active terminal should not retarget", () => {
|
||||
const session: AISession = {
|
||||
...createSession("session-2"),
|
||||
scope: {
|
||||
type: "terminal",
|
||||
targetId: "other-active-terminal",
|
||||
hostIds: ["host-1"],
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
shouldRetargetSessionForScope(
|
||||
session,
|
||||
"terminal",
|
||||
"new-terminal",
|
||||
["host-1"],
|
||||
new Set<string>(["other-active-terminal"]),
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("history selection switches to the chosen session without touching draft state", () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
@@ -174,3 +139,39 @@ test("history selection switches to the chosen session without touching draft st
|
||||
"close-history",
|
||||
]);
|
||||
});
|
||||
|
||||
test("draft entry ensures a draft exists before switching the panel to draft mode", () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
applyDraftEntrySelection({
|
||||
ensureDraft: () => {
|
||||
calls.push("ensure-draft");
|
||||
},
|
||||
showDraftView: () => {
|
||||
calls.push("show-draft");
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
"ensure-draft",
|
||||
"show-draft",
|
||||
]);
|
||||
});
|
||||
|
||||
test("draft entry can preserve the current session view while ensuring draft state", () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
applyDraftEntrySelection({
|
||||
ensureDraft: () => {
|
||||
calls.push("ensure-draft");
|
||||
},
|
||||
showDraftView: () => {
|
||||
calls.push("show-draft");
|
||||
},
|
||||
preserveSessionView: true,
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
"ensure-draft",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -11,11 +11,18 @@ interface HistorySessionSelectionActions {
|
||||
closeHistory?: () => void;
|
||||
}
|
||||
|
||||
interface DraftEntrySelectionActions {
|
||||
ensureDraft: () => void;
|
||||
showDraftView: () => void;
|
||||
preserveSessionView?: boolean;
|
||||
}
|
||||
|
||||
export function resolveDisplayedPanelView(
|
||||
panelView: AIPanelView | undefined,
|
||||
hasDraft: boolean,
|
||||
sessions: AISession[],
|
||||
persistedSessionId?: string | null,
|
||||
scopeType: "terminal" | "workspace" = "workspace",
|
||||
): AIPanelView {
|
||||
if (panelView) {
|
||||
return normalizePanelView(panelView, sessions);
|
||||
@@ -25,6 +32,12 @@ export function resolveDisplayedPanelView(
|
||||
return DEFAULT_PANEL_VIEW;
|
||||
}
|
||||
|
||||
// New terminal sessions should always start from a blank draft. History is
|
||||
// still available in the drawer, but never auto-resumed into a fresh SSH tab.
|
||||
if (scopeType === "terminal") {
|
||||
return DEFAULT_PANEL_VIEW;
|
||||
}
|
||||
|
||||
// Honour the persisted active-session selection (survives cold mount)
|
||||
// before falling back to the newest history entry.
|
||||
if (persistedSessionId && sessions.some((s) => s.id === persistedSessionId)) {
|
||||
@@ -62,28 +75,6 @@ export function resolveDisplayedSession(
|
||||
return sessions.find((session) => session.id === panelView.sessionId) ?? null;
|
||||
}
|
||||
|
||||
export function shouldRetargetSessionForScope(
|
||||
session: AISession | null,
|
||||
scopeType: "terminal" | "workspace",
|
||||
scopeTargetId?: string,
|
||||
scopeHostIds?: string[],
|
||||
activeTerminalTargetIds?: Set<string>,
|
||||
): boolean {
|
||||
if (!session || scopeType !== "terminal" || !scopeTargetId || !scopeHostIds?.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (session.scope.type !== scopeType || session.scope.targetId === scopeTargetId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (session.scope.targetId && activeTerminalTargetIds?.has(session.scope.targetId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return session.scope.hostIds?.some((hostId) => scopeHostIds.includes(hostId)) ?? false;
|
||||
}
|
||||
|
||||
export function applyHistorySessionSelection(
|
||||
sessionId: string,
|
||||
actions: HistorySessionSelectionActions,
|
||||
@@ -92,3 +83,12 @@ export function applyHistorySessionSelection(
|
||||
actions.setActiveSessionId(sessionId);
|
||||
actions.closeHistory?.();
|
||||
}
|
||||
|
||||
export function applyDraftEntrySelection(
|
||||
actions: DraftEntrySelectionActions,
|
||||
): void {
|
||||
actions.ensureDraft();
|
||||
if (!actions.preserveSessionView) {
|
||||
actions.showDraftView();
|
||||
}
|
||||
}
|
||||
|
||||
18
components/ai/draftSendGate.test.ts
Normal file
18
components/ai/draftSendGate.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
endDraftSend,
|
||||
tryBeginDraftSend,
|
||||
} from "./draftSendGate.ts";
|
||||
|
||||
test("draft send gate allows only one in-flight draft send at a time", () => {
|
||||
const gate = { current: false };
|
||||
|
||||
assert.equal(tryBeginDraftSend(gate), true);
|
||||
assert.equal(tryBeginDraftSend(gate), false);
|
||||
|
||||
endDraftSend(gate);
|
||||
|
||||
assert.equal(tryBeginDraftSend(gate), true);
|
||||
});
|
||||
12
components/ai/draftSendGate.ts
Normal file
12
components/ai/draftSendGate.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export function tryBeginDraftSend(gate: { current: boolean }): boolean {
|
||||
if (gate.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
gate.current = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function endDraftSend(gate: { current: boolean }): void {
|
||||
gate.current = false;
|
||||
}
|
||||
@@ -355,14 +355,13 @@ export function useAIChatStreaming({
|
||||
err: unknown,
|
||||
) => {
|
||||
if (abortSignal.aborted) return;
|
||||
let errorStr: string;
|
||||
if (err instanceof Error) errorStr = err.message;
|
||||
else if (typeof err === 'object' && err !== null && 'message' in err) errorStr = String((err as { message: unknown }).message);
|
||||
else if (typeof err === 'string') errorStr = err;
|
||||
else { try { errorStr = JSON.stringify(err) ?? 'Unknown error'; } catch { errorStr = 'Unknown error'; } }
|
||||
// Log the full unsanitized error for debugging
|
||||
console.error('[AIChatSidePanel] Stream error (full):', errorStr);
|
||||
const errorInfo = classifyError(errorStr);
|
||||
console.error('[AIChatSidePanel] Stream error (full):', err);
|
||||
// Pass the raw error to classifyError so it can inspect structured
|
||||
// fields (statusCode, responseBody) from APICallError and friends;
|
||||
// string-coercing here would strip the metadata we need to detect
|
||||
// 413 / HTML-error-page / parse-failure scenarios.
|
||||
const errorInfo = classifyError(err);
|
||||
updateLastMessage(sessionId, msg => ({
|
||||
...msg,
|
||||
statusText: '',
|
||||
@@ -560,11 +559,10 @@ export function useAIChatStreaming({
|
||||
id: generateId(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
errorInfo: classifyError(
|
||||
typedChunk.error instanceof Error ? typedChunk.error.message
|
||||
: typeof typedChunk.error === 'string' ? typedChunk.error
|
||||
: (() => { try { return JSON.stringify(typedChunk.error) ?? 'Unknown error'; } catch { return 'Unknown error'; } })(),
|
||||
),
|
||||
// Pass the raw error so classifyError can detect 413 / HTML /
|
||||
// schema-parse scenarios via structured fields (statusCode,
|
||||
// responseBody) instead of lossy string conversion.
|
||||
errorInfo: classifyError(typedChunk.error),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
break;
|
||||
|
||||
15
components/ai/sessionHistoryLayout.test.ts
Normal file
15
components/ai/sessionHistoryLayout.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
SESSION_HISTORY_ROW_CLASSNAMES,
|
||||
} from "./sessionHistoryLayout.ts";
|
||||
|
||||
test("session history row keeps metadata pinned to the end while title truncates", () => {
|
||||
assert.match(SESSION_HISTORY_ROW_CLASSNAMES.row, /\bgrid\b/);
|
||||
assert.ok(SESSION_HISTORY_ROW_CLASSNAMES.row.includes('grid-cols-[minmax(0,1fr)_auto]'));
|
||||
assert.match(SESSION_HISTORY_ROW_CLASSNAMES.title, /\btruncate\b/);
|
||||
assert.match(SESSION_HISTORY_ROW_CLASSNAMES.title, /\bmin-w-0\b/);
|
||||
assert.match(SESSION_HISTORY_ROW_CLASSNAMES.meta, /\bjustify-self-end\b/);
|
||||
assert.match(SESSION_HISTORY_ROW_CLASSNAMES.meta, /\bshrink-0\b/);
|
||||
});
|
||||
7
components/ai/sessionHistoryLayout.ts
Normal file
7
components/ai/sessionHistoryLayout.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const SESSION_HISTORY_ROW_CLASSNAMES = {
|
||||
row: 'w-full grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 py-2.5 border-b border-border/20 text-left transition-colors cursor-pointer group',
|
||||
title: 'text-[13px] truncate min-w-0',
|
||||
meta: 'flex items-center gap-2 justify-self-end shrink-0',
|
||||
time: 'text-[12px] text-muted-foreground/50 whitespace-nowrap',
|
||||
deleteButton: 'opacity-0 group-hover:opacity-100 p-0.5 hover:text-destructive transition-all cursor-pointer shrink-0',
|
||||
} as const;
|
||||
101
components/ai/sessionScopeMatch.test.ts
Normal file
101
components/ai/sessionScopeMatch.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { AISession } from "../../infrastructure/ai/types.ts";
|
||||
import { getSessionScopeMatchRank } from "./sessionScopeMatch.ts";
|
||||
|
||||
function createSession(id: string, targetId: string, hostIds: string[]): AISession {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
messages: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
agentId: "catty",
|
||||
scope: {
|
||||
type: "terminal",
|
||||
targetId,
|
||||
hostIds,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("host-matched terminal session is excluded when another active terminal already displays it", () => {
|
||||
const session = createSession("session-1", "terminal-other", ["host-a"]);
|
||||
|
||||
assert.equal(
|
||||
getSessionScopeMatchRank(
|
||||
session,
|
||||
"terminal",
|
||||
"terminal-current",
|
||||
["host-a"],
|
||||
new Set(["session-1"]),
|
||||
),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test("host-matched terminal session remains resumable when no terminal is displaying it", () => {
|
||||
const session = createSession("session-1", "terminal-closed", ["host-a"]);
|
||||
|
||||
assert.equal(
|
||||
getSessionScopeMatchRank(
|
||||
session,
|
||||
"terminal",
|
||||
"terminal-current",
|
||||
["host-a"],
|
||||
new Set(["session-other"]),
|
||||
),
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test("ownership is tracked by session id, not scope.targetId", () => {
|
||||
// Session was created in terminal-A but a different terminal (B) is now
|
||||
// displaying it after the user resumed it from history. Opening a third
|
||||
// terminal (C) should not see this session as owned, because the new
|
||||
// ownership check is keyed on session id, not the stale targetId.
|
||||
const session = createSession("session-1", "terminal-A", ["host-a"]);
|
||||
|
||||
assert.equal(
|
||||
getSessionScopeMatchRank(
|
||||
session,
|
||||
"terminal",
|
||||
"terminal-C",
|
||||
["host-a"],
|
||||
// terminal-B is displaying session-1; pass session-1 as an
|
||||
// active-id so C sees it as in-use
|
||||
new Set(["session-1"]),
|
||||
),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test("session targeting the current scope is an exact match (rank 2)", () => {
|
||||
const session = createSession("session-1", "terminal-current", ["host-a"]);
|
||||
|
||||
assert.equal(
|
||||
getSessionScopeMatchRank(
|
||||
session,
|
||||
"terminal",
|
||||
"terminal-current",
|
||||
["host-a"],
|
||||
new Set(),
|
||||
),
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
test("scope type mismatch returns 0 regardless of target or hosts", () => {
|
||||
const session = createSession("session-1", "terminal-current", ["host-a"]);
|
||||
|
||||
assert.equal(
|
||||
getSessionScopeMatchRank(
|
||||
session,
|
||||
"workspace",
|
||||
"terminal-current",
|
||||
["host-a"],
|
||||
),
|
||||
0,
|
||||
);
|
||||
});
|
||||
28
components/ai/sessionScopeMatch.ts
Normal file
28
components/ai/sessionScopeMatch.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { AISession } from "../../infrastructure/ai/types";
|
||||
|
||||
export function getSessionScopeMatchRank(
|
||||
session: AISession,
|
||||
scopeType: "terminal" | "workspace",
|
||||
scopeTargetId?: string,
|
||||
scopeHostIds?: string[],
|
||||
/**
|
||||
* Session ids currently displayed by other terminal scopes. Tracked by
|
||||
* session id rather than `scope.targetId` so that a host-matched session
|
||||
* resumed from a different terminal is still recognised as in-use and
|
||||
* not offered (or cleaned) as if it were orphaned.
|
||||
*/
|
||||
activeTerminalSessionIds?: 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 (activeTerminalSessionIds?.has(session.id)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return session.scope.hostIds.some((hostId) => scopeHostIds.includes(hostId)) ? 1 : 0;
|
||||
}
|
||||
584
components/editor/TextEditorPane.tsx
Normal file
584
components/editor/TextEditorPane.tsx
Normal file
@@ -0,0 +1,584 @@
|
||||
/**
|
||||
* TextEditorPane — pure Monaco editor body + toolbar.
|
||||
* Extracted from TextEditorModal.tsx. Contains no Dialog shell.
|
||||
* Parents (modal or tab) own content state, saving state, and toast calls.
|
||||
*/
|
||||
import {
|
||||
CloudUpload,
|
||||
Loader2,
|
||||
Maximize2,
|
||||
Search,
|
||||
WrapText,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import Editor, { type OnMount, loader, useMonaco } from '@monaco-editor/react';
|
||||
import type * as Monaco from 'monaco-editor';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
// Configure Monaco to use local files instead of CDN
|
||||
const monacoBasePath = import.meta.env.DEV
|
||||
? './node_modules/monaco-editor/min/vs'
|
||||
: `${import.meta.env.BASE_URL}monaco/vs`;
|
||||
loader.config({ paths: { vs: monacoBasePath } });
|
||||
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { useClipboardBackend } from '../../application/state/useClipboardBackend';
|
||||
import { HotkeyScheme, KeyBinding, matchesKeyBinding } from '../../domain/models';
|
||||
import { getLanguageName, getSupportedLanguages } from '../../lib/sftpFileUtils';
|
||||
import { Button } from '../ui/button';
|
||||
import { Combobox } from '../ui/combobox';
|
||||
|
||||
// Map our language IDs to Monaco language IDs
|
||||
const languageIdToMonaco = (langId: string): string => {
|
||||
const mapping: Record<string, string> = {
|
||||
'javascript': 'javascript',
|
||||
'typescript': 'typescript',
|
||||
'python': 'python',
|
||||
'shell': 'shell',
|
||||
'batch': 'bat',
|
||||
'powershell': 'powershell',
|
||||
'c': 'c',
|
||||
'cpp': 'cpp',
|
||||
'java': 'java',
|
||||
'kotlin': 'kotlin',
|
||||
'go': 'go',
|
||||
'rust': 'rust',
|
||||
'ruby': 'ruby',
|
||||
'php': 'php',
|
||||
'perl': 'perl',
|
||||
'lua': 'lua',
|
||||
'r': 'r',
|
||||
'swift': 'swift',
|
||||
'dart': 'dart',
|
||||
'csharp': 'csharp',
|
||||
'fsharp': 'fsharp',
|
||||
'vb': 'vb',
|
||||
'html': 'html',
|
||||
'css': 'css',
|
||||
'scss': 'scss',
|
||||
'sass': 'sass',
|
||||
'less': 'less',
|
||||
'json': 'json',
|
||||
'jsonc': 'json',
|
||||
'json5': 'json',
|
||||
'xml': 'xml',
|
||||
'yaml': 'yaml',
|
||||
'toml': 'ini',
|
||||
'ini': 'ini',
|
||||
'sql': 'sql',
|
||||
'graphql': 'graphql',
|
||||
'markdown': 'markdown',
|
||||
'plaintext': 'plaintext',
|
||||
'vue': 'html',
|
||||
'svelte': 'html',
|
||||
'dockerfile': 'dockerfile',
|
||||
'makefile': 'makefile',
|
||||
'diff': 'diff',
|
||||
};
|
||||
return mapping[langId] || 'plaintext';
|
||||
};
|
||||
|
||||
// Convert HSL string "h s% l%" to hex color
|
||||
const hslToHex = (hslString: string): string => {
|
||||
const parts = hslString.trim().split(/\s+/);
|
||||
if (parts.length < 3) return '#1e1e1e';
|
||||
const h = parseFloat(parts[0]) / 360;
|
||||
const s = parseFloat(parts[1].replace('%', '')) / 100;
|
||||
const l = parseFloat(parts[2].replace('%', '')) / 100;
|
||||
|
||||
const hue2rgb = (p: number, q: number, t: number) => {
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
|
||||
let r: number, g: number, b: number;
|
||||
if (s === 0) {
|
||||
r = g = b = l;
|
||||
} else {
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
r = hue2rgb(p, q, h + 1 / 3);
|
||||
g = hue2rgb(p, q, h);
|
||||
b = hue2rgb(p, q, h - 1 / 3);
|
||||
}
|
||||
|
||||
const toHex = (x: number) => {
|
||||
const hex = Math.round(x * 255).toString(16);
|
||||
return hex.length === 1 ? '0' + hex : hex;
|
||||
};
|
||||
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||
};
|
||||
|
||||
// Read a CSS custom-property and convert from HSL to hex
|
||||
const getCssColor = (varName: string, fallback: string): string => {
|
||||
const value = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(varName)
|
||||
.trim();
|
||||
return value ? hslToHex(value) : fallback;
|
||||
};
|
||||
|
||||
interface EditorColors {
|
||||
bg: string;
|
||||
fg: string;
|
||||
primary: string;
|
||||
card: string;
|
||||
mutedFg: string;
|
||||
border: string;
|
||||
}
|
||||
|
||||
/** Read all UI CSS variables that matter for the Monaco theme. */
|
||||
const getEditorColors = (isDark: boolean): EditorColors => ({
|
||||
bg: getCssColor('--background', isDark ? '#1e1e1e' : '#ffffff'),
|
||||
fg: getCssColor('--foreground', isDark ? '#d4d4d4' : '#1e1e1e'),
|
||||
primary: getCssColor('--primary', isDark ? '#569cd6' : '#0078d4'),
|
||||
card: getCssColor('--card', isDark ? '#252526' : '#f3f3f3'),
|
||||
mutedFg: getCssColor('--muted-foreground', isDark ? '#858585' : '#858585'),
|
||||
border: getCssColor('--border', isDark ? '#3c3c3c' : '#d4d4d4'),
|
||||
});
|
||||
|
||||
/** Build a fingerprint string so we can detect immersive-mode color changes cheaply. */
|
||||
const getThemeSignal = (): string => {
|
||||
const root = document.documentElement;
|
||||
return root.dataset.immersiveTheme
|
||||
?? getComputedStyle(root).getPropertyValue('--background').trim();
|
||||
};
|
||||
|
||||
export interface TextEditorPaneProps {
|
||||
fileName: string;
|
||||
content: string;
|
||||
languageId: string;
|
||||
wordWrap: boolean;
|
||||
saving: boolean;
|
||||
saveError: string | null;
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
keyBindings: KeyBinding[];
|
||||
/** Layout mode — affects header chrome (modal shows close+maximize; tab-form only shows content controls since tab has its own close). */
|
||||
chrome: 'modal' | 'tab';
|
||||
/** Optional secondary label shown next to the filename in muted text — used by the tab form to display `host:remotePath`. */
|
||||
subtitle?: string;
|
||||
onContentChange: (content: string, viewState: Monaco.editor.ICodeEditorViewState | null) => void;
|
||||
onLanguageChange: (nextLanguageId: string) => void;
|
||||
onToggleWordWrap: () => void;
|
||||
onSave: () => void;
|
||||
onRequestClose?: () => void; // modal only
|
||||
onPromoteToTab?: () => void; // modal only — omit to hide the maximize button
|
||||
initialViewState?: Monaco.editor.ICodeEditorViewState | null;
|
||||
}
|
||||
|
||||
export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
|
||||
fileName,
|
||||
content,
|
||||
languageId,
|
||||
wordWrap,
|
||||
saving,
|
||||
saveError,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
chrome,
|
||||
subtitle,
|
||||
onContentChange,
|
||||
onLanguageChange,
|
||||
onToggleWordWrap,
|
||||
onSave,
|
||||
onRequestClose,
|
||||
onPromoteToTab,
|
||||
initialViewState,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { readClipboardText: readClipboardTextFromBridge } = useClipboardBackend();
|
||||
const monaco = useMonaco();
|
||||
const editorRef = useRef<Monaco.editor.IStandaloneCodeEditor | null>(null);
|
||||
|
||||
// Ref to store the latest save function to avoid stale closure in keyboard shortcut
|
||||
const handleSaveRef = useRef<() => void>(() => {});
|
||||
const handleCloseRef = useRef<(() => void) | null>(null);
|
||||
const handlePasteRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
||||
const readClipboardTextRef = useRef<() => Promise<string | null>>(() => Promise.resolve(null));
|
||||
|
||||
// Track theme from document.documentElement class (syncs with app theme)
|
||||
const [isDarkTheme, setIsDarkTheme] = useState(() =>
|
||||
document.documentElement.classList.contains('dark')
|
||||
);
|
||||
|
||||
// Track a signal that changes whenever immersive-mode or base theme colors change
|
||||
const [themeSignal, setThemeSignal] = useState(() => getThemeSignal());
|
||||
|
||||
// Custom theme name
|
||||
const customThemeName = isDarkTheme ? 'netcatty-dark' : 'netcatty-light';
|
||||
|
||||
// Define and update custom Monaco themes — syncs with immersive-mode / base UI colors
|
||||
useEffect(() => {
|
||||
if (!monaco) return;
|
||||
|
||||
const colors = getEditorColors(isDarkTheme);
|
||||
|
||||
const themeColors: Record<string, string> = {
|
||||
'editor.background': colors.bg,
|
||||
'editor.foreground': colors.fg,
|
||||
'editorCursor.foreground': colors.primary,
|
||||
'editor.selectionBackground': colors.primary + '40',
|
||||
'editor.inactiveSelectionBackground': colors.primary + '25',
|
||||
'editorLineNumber.foreground': colors.mutedFg,
|
||||
'editorLineNumber.activeForeground': colors.fg,
|
||||
'editor.lineHighlightBackground': colors.fg + '08',
|
||||
'editorWidget.background': colors.card,
|
||||
'editorWidget.foreground': colors.fg,
|
||||
'editorWidget.border': colors.border,
|
||||
'input.background': colors.card,
|
||||
'input.foreground': colors.fg,
|
||||
'input.border': colors.border,
|
||||
};
|
||||
|
||||
monaco.editor.defineTheme('netcatty-dark', {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: themeColors,
|
||||
});
|
||||
|
||||
monaco.editor.defineTheme('netcatty-light', {
|
||||
base: 'vs',
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: themeColors,
|
||||
});
|
||||
|
||||
monaco.editor.setTheme(customThemeName);
|
||||
}, [monaco, isDarkTheme, themeSignal, customThemeName]);
|
||||
|
||||
// Listen for theme changes via MutationObserver on <html> class, style, and immersive data attr
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
const updateTheme = () => {
|
||||
setIsDarkTheme(root.classList.contains('dark'));
|
||||
setThemeSignal(getThemeSignal());
|
||||
};
|
||||
const observer = new MutationObserver(updateTheme);
|
||||
observer.observe(root, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'style', 'data-immersive-theme'],
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const closeTabBinding = useMemo(
|
||||
() => keyBindings.find((binding) => binding.action === 'closeTab'),
|
||||
[keyBindings],
|
||||
);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (saving) return;
|
||||
onSave();
|
||||
}, [saving, onSave]);
|
||||
|
||||
// Keep the ref updated with the latest handleSave function
|
||||
useEffect(() => {
|
||||
handleSaveRef.current = handleSave;
|
||||
}, [handleSave]);
|
||||
|
||||
// Keep the close ref fresh so the Monaco Cmd/Ctrl+W command invokes the
|
||||
// latest onRequestClose handler without re-binding the Monaco command.
|
||||
useEffect(() => {
|
||||
handleCloseRef.current = onRequestClose ?? null;
|
||||
}, [onRequestClose]);
|
||||
|
||||
const readClipboardText = useCallback(async (): Promise<string | null> => {
|
||||
try {
|
||||
if (navigator.clipboard?.readText) {
|
||||
return await navigator.clipboard.readText();
|
||||
}
|
||||
} catch {
|
||||
// Fall through to Electron bridge
|
||||
}
|
||||
|
||||
try {
|
||||
return await readClipboardTextFromBridge();
|
||||
} catch {
|
||||
// Both clipboard APIs unavailable; signal failure so caller can fall back.
|
||||
return null;
|
||||
}
|
||||
}, [readClipboardTextFromBridge]);
|
||||
|
||||
useEffect(() => {
|
||||
readClipboardTextRef.current = readClipboardText;
|
||||
}, [readClipboardText]);
|
||||
|
||||
const handlePaste = useCallback(async () => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
|
||||
const text = await readClipboardText();
|
||||
if (text === null) {
|
||||
// Clipboard read unavailable; fall back to Monaco's native paste.
|
||||
editor.trigger('keyboard', 'editor.action.clipboardPasteAction', null);
|
||||
return;
|
||||
}
|
||||
if (!text) return;
|
||||
|
||||
const selections = editor.getSelections();
|
||||
if (!selections || selections.length === 0) return;
|
||||
|
||||
// Match Monaco's default multicursorPaste:'spread' behavior:
|
||||
// distribute one line per cursor when line count equals cursor count.
|
||||
const lines = text.split(/\r\n|\n/);
|
||||
const distribute = selections.length > 1 && lines.length === selections.length;
|
||||
|
||||
editor.executeEdits(
|
||||
'netcatty-paste',
|
||||
selections.map((selection, i) => ({
|
||||
range: selection,
|
||||
text: distribute ? lines[i] : text,
|
||||
forceMoveMarkers: true,
|
||||
})),
|
||||
);
|
||||
editor.focus();
|
||||
}, [readClipboardText]);
|
||||
|
||||
useEffect(() => {
|
||||
handlePasteRef.current = handlePaste;
|
||||
}, [handlePaste]);
|
||||
|
||||
const handleEditorChange = useCallback((value: string | undefined) => {
|
||||
const editor = editorRef.current;
|
||||
onContentChange(value ?? '', editor ? editor.saveViewState() : null);
|
||||
}, [onContentChange]);
|
||||
|
||||
const handleEditorMount: OnMount = useCallback((editor, monaco) => {
|
||||
editorRef.current = editor;
|
||||
|
||||
if (initialViewState) editor.restoreViewState(initialViewState);
|
||||
|
||||
// Add save shortcut - use ref to avoid stale closure
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
|
||||
handleSaveRef.current();
|
||||
});
|
||||
|
||||
// Close-tab shortcut inside Monaco. The capture-phase keydown on the
|
||||
// Pane's root div also tries to handle this, but Monaco's internal
|
||||
// key-event dispatcher fires first for focused editor keystrokes, so
|
||||
// registering the command here is the reliable path.
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyW, () => {
|
||||
handleCloseRef.current?.();
|
||||
});
|
||||
|
||||
// Add find shortcut (Ctrl+F / Cmd+F)
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyF, () => {
|
||||
// Trigger Monaco's built-in find widget
|
||||
editor.trigger('keyboard', 'actions.find', null);
|
||||
});
|
||||
|
||||
// Fallback paste path for Electron environments where Monaco paste can fail.
|
||||
// Skip custom paste when focus is inside the find/replace widget so that
|
||||
// its input fields receive the pasted text via default browser behavior.
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyV, () => {
|
||||
const active = document.activeElement;
|
||||
if (active?.closest('.find-widget')) {
|
||||
// Read clipboard and insert into the find/replace input field.
|
||||
void (async () => {
|
||||
try {
|
||||
const text = await readClipboardTextRef.current();
|
||||
if (!text) return;
|
||||
// Monaco find widget inputs are <textarea> elements inside .monaco-inputbox
|
||||
if (active instanceof HTMLTextAreaElement || active instanceof HTMLInputElement) {
|
||||
const start = active.selectionStart ?? active.value.length;
|
||||
const end = active.selectionEnd ?? active.value.length;
|
||||
active.focus();
|
||||
active.setSelectionRange(start, end);
|
||||
document.execCommand('insertText', false, text);
|
||||
}
|
||||
} catch {
|
||||
// Ignore – paste simply won't work
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
void handlePasteRef.current();
|
||||
});
|
||||
|
||||
editor.focus();
|
||||
}, [initialViewState]);
|
||||
|
||||
// Capture-phase close-tab hotkey handler. Runs in both modal and tab chrome
|
||||
// so Cmd/Ctrl+W works even when focus is inside Monaco (which otherwise
|
||||
// swallows the event). Requires an `onRequestClose` prop from the parent.
|
||||
const handleDialogKeyDownCapture = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (hotkeyScheme === 'disabled' || !closeTabBinding || !onRequestClose) return;
|
||||
|
||||
const isMac = hotkeyScheme === 'mac';
|
||||
const keyStr = isMac ? closeTabBinding.mac : closeTabBinding.pc;
|
||||
if (!matchesKeyBinding(e.nativeEvent, keyStr, isMac)) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.nativeEvent.stopPropagation();
|
||||
onRequestClose();
|
||||
}, [closeTabBinding, hotkeyScheme, onRequestClose]);
|
||||
|
||||
// Trigger search dialog
|
||||
const handleSearch = useCallback(() => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.trigger('keyboard', 'actions.find', null);
|
||||
editorRef.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const supportedLanguages = useMemo(() => getSupportedLanguages(), []);
|
||||
const monacoLanguage = useMemo(() => languageIdToMonaco(languageId), [languageId]);
|
||||
const languageOptions = useMemo(
|
||||
() => supportedLanguages.map((lang) => ({ value: lang.id, label: lang.name })),
|
||||
[supportedLanguages],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full flex flex-col"
|
||||
onKeyDownCapture={handleDialogKeyDownCapture}
|
||||
data-hotkey-close-tab={chrome === 'modal' ? 'true' : undefined}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-border/60 flex-shrink-0">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-baseline gap-2 flex-1 min-w-0">
|
||||
<span className="text-sm font-semibold truncate flex-shrink-0">
|
||||
{fileName}
|
||||
</span>
|
||||
{subtitle && (
|
||||
<span className="text-xs text-muted-foreground truncate" title={subtitle}>
|
||||
{subtitle}
|
||||
</span>
|
||||
)}
|
||||
{saveError && <span className="text-xs text-destructive truncate">{saveError}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{/* Search button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleSearch}
|
||||
title={t('common.search')}
|
||||
>
|
||||
<Search size={14} />
|
||||
</Button>
|
||||
|
||||
{/* Word wrap toggle */}
|
||||
<Button
|
||||
variant={wordWrap ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onToggleWordWrap}
|
||||
title={t('sftp.editor.wordWrap')}
|
||||
>
|
||||
<WrapText size={14} />
|
||||
</Button>
|
||||
|
||||
{/* Language selector */}
|
||||
<Combobox
|
||||
options={languageOptions}
|
||||
value={languageId}
|
||||
onValueChange={(v) => onLanguageChange(v || 'plaintext')}
|
||||
placeholder={t('sftp.editor.syntaxHighlight')}
|
||||
triggerClassName="h-7 max-w-[180px] min-w-[120px] text-xs"
|
||||
/>
|
||||
|
||||
{/* Save button */}
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 size={14} className="mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<CloudUpload size={14} className="mr-1.5" />
|
||||
)}
|
||||
{saving ? t('sftp.editor.saving') : t('sftp.editor.save')}
|
||||
</Button>
|
||||
|
||||
{/* Maximize button — modal chrome only, when onPromoteToTab is provided */}
|
||||
{chrome === 'modal' && onPromoteToTab && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onPromoteToTab}
|
||||
title={t('sftp.editor.maximize')}
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Close button — modal chrome only */}
|
||||
{chrome === 'modal' && onRequestClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onRequestClose}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Monaco Editor */}
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
<Editor
|
||||
height="100%"
|
||||
language={monacoLanguage}
|
||||
value={content}
|
||||
onChange={handleEditorChange}
|
||||
onMount={handleEditorMount}
|
||||
theme={customThemeName}
|
||||
loading={
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background">
|
||||
<Loader2 size={32} className="animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
}
|
||||
options={{
|
||||
// Prefer native context menu in Electron so right-click Paste uses OS clipboard path.
|
||||
contextmenu: false,
|
||||
minimap: { enabled: true },
|
||||
fontSize: 14,
|
||||
lineNumbers: 'on',
|
||||
roundedSelection: false,
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
tabSize: 2,
|
||||
insertSpaces: true,
|
||||
wordWrap: wordWrap ? 'on' : 'off',
|
||||
folding: true,
|
||||
renderWhitespace: 'selection',
|
||||
bracketPairColorization: { enabled: true },
|
||||
find: {
|
||||
addExtraSpaceOnTop: false,
|
||||
autoFindInSelection: 'never',
|
||||
seedSearchStringFromSelection: 'selection',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-4 py-2 border-t border-border/60 flex items-center justify-between text-xs text-muted-foreground bg-muted/30 flex-shrink-0">
|
||||
<span>
|
||||
{getLanguageName(languageId)}
|
||||
</span>
|
||||
<span>
|
||||
{content.split('\n').length} lines • {content.length} characters
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TextEditorPane;
|
||||
128
components/editor/TextEditorTabView.tsx
Normal file
128
components/editor/TextEditorTabView.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* TextEditorTabView — thin wrapper that binds an editorTab entry to TextEditorPane.
|
||||
*
|
||||
* Each tab has its own instance (keyed by tabId), so Monaco is never torn down
|
||||
* on tab-switch — we just toggle CSS visibility via the `isVisible` prop.
|
||||
*/
|
||||
import type * as Monaco from 'monaco-editor';
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { editorSftpWrite } from '../../application/state/editorSftpBridge';
|
||||
import { editorTabStore, useEditorTab, type EditorTabId } from '../../application/state/editorTabStore';
|
||||
import type { HotkeyScheme, KeyBinding } from '../../domain/models';
|
||||
import type { Host } from '../../types';
|
||||
import { toast } from '../ui/toast';
|
||||
import { TextEditorPane } from './TextEditorPane';
|
||||
|
||||
export interface TextEditorTabViewProps {
|
||||
tabId: EditorTabId;
|
||||
/** When false the view is hidden via display:none so the Monaco instance persists. */
|
||||
isVisible: boolean;
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
keyBindings: KeyBinding[];
|
||||
/** Host lookup for building the `host:remotePath` subtitle next to the filename. */
|
||||
hostById: Map<string, Host>;
|
||||
/** Routed into Monaco's Cmd/Ctrl+W command so closing the editor tab works
|
||||
* even when focus is inside the editor (Monaco otherwise swallows the event). */
|
||||
onRequestClose: (tabId: EditorTabId) => void;
|
||||
}
|
||||
|
||||
export const TextEditorTabView: React.FC<TextEditorTabViewProps> = ({
|
||||
tabId,
|
||||
isVisible,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
hostById,
|
||||
onRequestClose,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const tab = useEditorTab(tabId);
|
||||
|
||||
const handleContentChange = useCallback(
|
||||
(content: string, viewState: Monaco.editor.ICodeEditorViewState | null) => {
|
||||
editorTabStore.updateContent(tabId, content, viewState);
|
||||
},
|
||||
[tabId],
|
||||
);
|
||||
|
||||
const handleLanguageChange = useCallback(
|
||||
(lang: string) => {
|
||||
editorTabStore.setLanguage(tabId, lang);
|
||||
},
|
||||
[tabId],
|
||||
);
|
||||
|
||||
const handleToggleWordWrap = useCallback(() => {
|
||||
const current = editorTabStore.getTab(tabId);
|
||||
if (!current) return;
|
||||
editorTabStore.setWordWrap(tabId, !current.wordWrap);
|
||||
}, [tabId]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
// Read live store state at call time — React state snapshot lags the store
|
||||
// by one microtask, so a keystroke between onChange and this save would
|
||||
// otherwise leave us writing stale content and marking a stale baseline.
|
||||
const current = editorTabStore.getTab(tabId);
|
||||
if (!current) return;
|
||||
if (current.savingState === 'saving') return;
|
||||
|
||||
editorTabStore.setSavingState(tabId, 'saving');
|
||||
try {
|
||||
await editorSftpWrite(current.sessionId, current.hostId, current.remotePath, current.content);
|
||||
editorTabStore.markSaved(tabId, current.content);
|
||||
toast.success(t('sftp.editor.saved'), 'SFTP');
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : t('sftp.editor.saveFailed');
|
||||
editorTabStore.setSavingState(tabId, 'error', msg);
|
||||
toast.error(msg, 'SFTP');
|
||||
}
|
||||
}, [tabId, t]);
|
||||
|
||||
// Tab has been closed — render nothing (parent should remove this instance,
|
||||
// but guard here in case of a transient render before unmount).
|
||||
if (!tab) return null;
|
||||
|
||||
const isDirty = tab.content !== tab.baselineContent;
|
||||
// Subtitle shown next to the filename in the Pane header, e.g.
|
||||
// "Rainyun-114.66.26.174:/root/hello-server.go". Falls back to hostId when
|
||||
// we don't have a Host record (session may have been removed).
|
||||
const host = hostById.get(tab.hostId);
|
||||
const hostLabel = host?.label ?? tab.hostId;
|
||||
const subtitle = `${hostLabel}:${tab.remotePath}`;
|
||||
|
||||
return (
|
||||
// Sibling tab panels (VaultView, SftpView, TerminalLayerMount, LogView)
|
||||
// all fill their flex-1 parent via `absolute inset-0`. Match that here so
|
||||
// an inactive editor tab doesn't collapse to zero height in normal flow,
|
||||
// and an active one fills the viewport instead of stacking beneath others.
|
||||
// z-index high enough to stay above the TerminalLayer's inner `z-10` panels
|
||||
// (TerminalLayer root is visibility:hidden when editor tabs are active, but
|
||||
// its children's stacking contexts can still overlap without an explicit z.)
|
||||
<div
|
||||
style={{ display: isVisible ? undefined : 'none', zIndex: 20 }}
|
||||
className="absolute inset-0 min-h-0 flex flex-col bg-background"
|
||||
>
|
||||
<TextEditorPane
|
||||
chrome="tab"
|
||||
fileName={`${tab.fileName}${isDirty ? ' *' : ''}`}
|
||||
subtitle={subtitle}
|
||||
onRequestClose={() => onRequestClose(tabId)}
|
||||
content={tab.content}
|
||||
languageId={tab.languageId}
|
||||
wordWrap={tab.wordWrap}
|
||||
saving={tab.savingState === 'saving'}
|
||||
saveError={tab.saveError}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
onContentChange={handleContentChange}
|
||||
onLanguageChange={handleLanguageChange}
|
||||
onToggleWordWrap={handleToggleWordWrap}
|
||||
onSave={handleSave}
|
||||
initialViewState={tab.viewState}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TextEditorTabView;
|
||||
104
components/editor/UnsavedChangesDialog.tsx
Normal file
104
components/editor/UnsavedChangesDialog.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../ui/dialog";
|
||||
|
||||
export type UnsavedChoice = "save" | "discard" | "cancel";
|
||||
|
||||
interface Pending {
|
||||
fileName: string;
|
||||
resolve: (choice: UnsavedChoice) => void;
|
||||
}
|
||||
|
||||
interface UnsavedChangesAPI {
|
||||
prompt: (fileName: string) => Promise<UnsavedChoice>;
|
||||
}
|
||||
|
||||
export const UnsavedChangesProvider: React.FC<{
|
||||
children: (api: UnsavedChangesAPI) => React.ReactNode;
|
||||
}> = ({ children }) => {
|
||||
const { t } = useI18n();
|
||||
const [pending, setPending] = useState<Pending | null>(null);
|
||||
const pendingRef = useRef<Pending | null>(null);
|
||||
pendingRef.current = pending;
|
||||
|
||||
const prompt = useCallback(
|
||||
(fileName: string) =>
|
||||
new Promise<UnsavedChoice>((resolve) => {
|
||||
// Re-entrance: if a prior prompt is still pending, cancel it so its caller
|
||||
// doesn't hang forever waiting for a resolve that now belongs to a new prompt.
|
||||
const prior = pendingRef.current;
|
||||
if (prior) prior.resolve("cancel");
|
||||
setPending({ fileName, resolve });
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
// Register the prompt function as the module-level singleton so it can be
|
||||
// called from outside the React tree (e.g. useSftpViewPaneActions).
|
||||
useEffect(() => {
|
||||
promptSingleton = prompt;
|
||||
return () => { promptSingleton = null; };
|
||||
}, [prompt]);
|
||||
|
||||
// On unmount, resolve any in-flight prompt as "cancel" so awaiting callers don't leak.
|
||||
useEffect(() => () => {
|
||||
const prior = pendingRef.current;
|
||||
if (prior) {
|
||||
prior.resolve("cancel");
|
||||
pendingRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resolveWith = useCallback((choice: UnsavedChoice) => {
|
||||
if (!pending) return;
|
||||
pending.resolve(choice);
|
||||
setPending(null);
|
||||
}, [pending]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{children({ prompt })}
|
||||
<Dialog open={!!pending} onOpenChange={(o) => { if (!o) resolveWith("cancel"); }}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("sftp.editor.unsavedTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("sftp.editor.unsavedMessage", { fileName: pending?.fileName ?? "" })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="ghost" onClick={() => resolveWith("cancel")}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => resolveWith("discard")}>
|
||||
{t("sftp.editor.discardChanges")}
|
||||
</Button>
|
||||
<Button variant="default" onClick={() => resolveWith("save")}>
|
||||
{t("sftp.editor.saveAndClose")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module-level singleton — lets non-React code call the dialog without
|
||||
// prop-drilling. Registered/unregistered by UnsavedChangesProvider above.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let promptSingleton: ((fileName: string) => Promise<UnsavedChoice>) | null = null;
|
||||
|
||||
export const promptUnsavedChanges = (fileName: string): Promise<UnsavedChoice> => {
|
||||
if (!promptSingleton) return Promise.resolve("cancel");
|
||||
return promptSingleton(fileName);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import { Ban, RotateCcw } from "lucide-react";
|
||||
import type { HotkeyScheme, KeyBinding } from "../../../domain/models";
|
||||
import { keyEventToString } from "../../../domain/models";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
@@ -221,7 +221,18 @@ export default function SettingsShortcutsTab(props: {
|
||||
>
|
||||
{isRecordingThis
|
||||
? t("settings.shortcuts.recording")
|
||||
: currentKey || t("settings.shortcuts.scheme.disabled")}
|
||||
: currentKey === "Disabled"
|
||||
? t("settings.shortcuts.scheme.disabled")
|
||||
: currentKey || t("settings.shortcuts.scheme.disabled")}
|
||||
</button>
|
||||
)}
|
||||
{!isSpecialBinding && (
|
||||
<button
|
||||
onClick={() => updateKeyBinding?.(binding.id, scheme, "Disabled")}
|
||||
className="p-1 hover:bg-muted rounded"
|
||||
title={t("settings.shortcuts.setDisabled")}
|
||||
>
|
||||
<Ban size={12} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
|
||||
@@ -300,6 +300,16 @@ export default function SettingsTerminalTab(props: {
|
||||
const [shellValidation, setShellValidation] = useState<{ valid: boolean; message?: string } | null>(null);
|
||||
const [dirValidation, setDirValidation] = useState<{ valid: boolean; message?: string } | null>(null);
|
||||
|
||||
// Mosh settings state
|
||||
const [moshValidation, setMoshValidation] = useState<{ valid: boolean; message?: string } | null>(null);
|
||||
const [moshDetectStatus, setMoshDetectStatus] = useState<
|
||||
| { kind: "idle" }
|
||||
| { kind: "running" }
|
||||
| { kind: "found"; path: string }
|
||||
| { kind: "not-found"; searchedPaths: string[] }
|
||||
>({ kind: "idle" });
|
||||
const [autoDetectedMoshPath, setAutoDetectedMoshPath] = useState<string | null>(null);
|
||||
|
||||
const discoveredShells = useDiscoveredShells();
|
||||
const [showCustomShellInput, setShowCustomShellInput] = useState(() => {
|
||||
if (!terminalSettings.localShell) return false;
|
||||
@@ -455,6 +465,109 @@ export default function SettingsTerminalTab(props: {
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [terminalSettings.localShell, discoveredShells, t]);
|
||||
|
||||
// Validate mosh client path when it changes (debounced)
|
||||
useEffect(() => {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
const moshPath = terminalSettings.moshClientPath;
|
||||
if (!moshPath) {
|
||||
setMoshValidation(null);
|
||||
return;
|
||||
}
|
||||
// The shared validatePath bridge resolves bare names through PATH (good
|
||||
// for localShell where "powershell.exe" is a valid choice), but
|
||||
// startMoshSession treats moshClientPath as a literal filesystem path —
|
||||
// so any non-absolute entry would look valid here yet fail at connect
|
||||
// time. Gate on absolute paths first; accept ~ since the main process
|
||||
// will expand it. Tolerant across platforms so e.g. a user pasting a
|
||||
// Windows-style absolute path on macOS still gets a real error
|
||||
// downstream rather than a misleading "not absolute".
|
||||
const looksAbsolute =
|
||||
moshPath.startsWith("/") ||
|
||||
moshPath.startsWith("~") ||
|
||||
/^[a-zA-Z]:[\\/]/.test(moshPath) ||
|
||||
moshPath.startsWith("\\\\");
|
||||
if (!looksAbsolute) {
|
||||
setMoshValidation({ valid: false, message: t("settings.terminal.mosh.client.notAbsolute") });
|
||||
return;
|
||||
}
|
||||
if (!bridge?.validatePath) {
|
||||
setMoshValidation(null);
|
||||
return;
|
||||
}
|
||||
const timeoutId = setTimeout(() => {
|
||||
bridge.validatePath(moshPath, "file").then((result) => {
|
||||
if (result.exists && result.isFile && !result.isExecutable) {
|
||||
// Stays consistent with startMoshSession's isExecutableFile check —
|
||||
// a regular file without the execute bit can't actually launch.
|
||||
setMoshValidation({ valid: false, message: t("settings.terminal.mosh.client.notExecutable") });
|
||||
} else if (result.exists && result.isFile) {
|
||||
setMoshValidation({ valid: true });
|
||||
} else if (result.exists && result.isDirectory) {
|
||||
setMoshValidation({ valid: false, message: t("settings.terminal.mosh.client.isDirectory") });
|
||||
} else {
|
||||
setMoshValidation({ valid: false, message: t("settings.terminal.mosh.client.notFound") });
|
||||
}
|
||||
}).catch(() => {
|
||||
setMoshValidation(null);
|
||||
});
|
||||
}, 300);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [terminalSettings.moshClientPath, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
if (!bridge?.detectMoshClient) return;
|
||||
let canceled = false;
|
||||
bridge.detectMoshClient()
|
||||
.then((result) => {
|
||||
if (!canceled) {
|
||||
setAutoDetectedMoshPath(result.found && result.path ? result.path : null);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!canceled) setAutoDetectedMoshPath(null);
|
||||
});
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDetectMosh = useCallback(async () => {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
if (!bridge?.detectMoshClient) return;
|
||||
setMoshDetectStatus({ kind: "running" });
|
||||
try {
|
||||
const result = await bridge.detectMoshClient();
|
||||
if (result.found && result.path) {
|
||||
setMoshDetectStatus({ kind: "found", path: result.path });
|
||||
// Auto-fill the input only when it is empty so we don't override
|
||||
// a value the user is in the middle of editing.
|
||||
if (!terminalSettings.moshClientPath) {
|
||||
updateTerminalSetting("moshClientPath", result.path);
|
||||
}
|
||||
} else {
|
||||
setMoshDetectStatus({ kind: "not-found", searchedPaths: result.searchedPaths });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Settings] detectMoshClient failed:", err);
|
||||
setMoshDetectStatus({ kind: "not-found", searchedPaths: [] });
|
||||
}
|
||||
}, [terminalSettings.moshClientPath, updateTerminalSetting]);
|
||||
|
||||
const handleBrowseMosh = useCallback(async () => {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
if (!bridge?.pickMoshClient) return;
|
||||
try {
|
||||
const result = await bridge.pickMoshClient();
|
||||
if (!result.canceled && result.filePath) {
|
||||
updateTerminalSetting("moshClientPath", result.filePath);
|
||||
setMoshDetectStatus({ kind: "idle" });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Settings] pickMoshClient failed:", err);
|
||||
}
|
||||
}, [updateTerminalSetting]);
|
||||
|
||||
// Validate directory path when it changes
|
||||
useEffect(() => {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
@@ -815,6 +928,20 @@ export default function SettingsTerminalTab(props: {
|
||||
<Toggle checked={!terminalSettings.disableBracketedPaste} onChange={(v) => updateTerminalSetting("disableBracketedPaste", !v)} />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.terminal.behavior.clearWipesScrollback")}
|
||||
description={t("settings.terminal.behavior.clearWipesScrollback.desc")}
|
||||
>
|
||||
<Toggle checked={terminalSettings.clearWipesScrollback ?? true} onChange={(v) => updateTerminalSetting("clearWipesScrollback", v)} />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.terminal.behavior.preserveSelectionOnInput")}
|
||||
description={t("settings.terminal.behavior.preserveSelectionOnInput.desc")}
|
||||
>
|
||||
<Toggle checked={terminalSettings.preserveSelectionOnInput ?? false} onChange={(v) => updateTerminalSetting("preserveSelectionOnInput", v)} />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.terminal.behavior.osc52Clipboard")}
|
||||
description={t("settings.terminal.behavior.osc52Clipboard.desc")}
|
||||
@@ -1020,6 +1147,85 @@ export default function SettingsTerminalTab(props: {
|
||||
className="w-24"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("settings.terminal.connection.x11Display")}
|
||||
description={t("settings.terminal.connection.x11Display.desc")}
|
||||
>
|
||||
<Input
|
||||
value={terminalSettings.x11Display}
|
||||
onChange={(e) => updateTerminalSetting("x11Display", e.target.value)}
|
||||
placeholder={t("settings.terminal.connection.x11Display.placeholder")}
|
||||
className="w-48"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("settings.terminal.mosh.client")}
|
||||
description={t("settings.terminal.mosh.client.desc")}
|
||||
>
|
||||
<div className="flex max-w-full flex-col gap-1.5" style={{ width: "min(420px, 100%)" }}>
|
||||
<div className="grid grid-cols-[minmax(220px,1fr)_auto_auto] gap-2">
|
||||
<Input
|
||||
value={terminalSettings.moshClientPath}
|
||||
placeholder={t("settings.terminal.mosh.client.placeholder")}
|
||||
onChange={(e) => updateTerminalSetting("moshClientPath", e.target.value)}
|
||||
className={cn(
|
||||
"flex-1",
|
||||
moshValidation && !moshValidation.valid && "border-destructive focus-visible:ring-destructive",
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDetectMosh}
|
||||
disabled={moshDetectStatus.kind === "running"}
|
||||
>
|
||||
{t("settings.terminal.mosh.detect")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleBrowseMosh}
|
||||
>
|
||||
{t("settings.terminal.mosh.browse")}
|
||||
</Button>
|
||||
</div>
|
||||
{!terminalSettings.moshClientPath && autoDetectedMoshPath && moshDetectStatus.kind !== "found" && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("settings.terminal.mosh.autoDetected")}: <span className="break-all font-mono">{autoDetectedMoshPath}</span>
|
||||
</span>
|
||||
)}
|
||||
{moshValidation && !moshValidation.valid && moshValidation.message && (
|
||||
<span className="text-xs text-destructive flex items-center gap-1">
|
||||
<AlertCircle size={12} />
|
||||
{moshValidation.message}
|
||||
</span>
|
||||
)}
|
||||
{moshDetectStatus.kind === "found" && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("settings.terminal.mosh.detected")}: <span className="break-all font-mono">{moshDetectStatus.path}</span>
|
||||
</span>
|
||||
)}
|
||||
{moshDetectStatus.kind === "not-found" && (
|
||||
<span className="text-xs text-destructive flex items-start gap-1">
|
||||
<AlertCircle size={12} className="mt-0.5 shrink-0" />
|
||||
<span>
|
||||
{t("settings.terminal.mosh.notDetected")}
|
||||
{moshDetectStatus.searchedPaths.length > 0 && (
|
||||
<>
|
||||
{" "}
|
||||
<span className="text-muted-foreground">
|
||||
({moshDetectStatus.searchedPaths.slice(0, 4).join(", ")}
|
||||
{moshDetectStatus.searchedPaths.length > 4 ? "…" : ""})
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<SectionHeader title={t("settings.terminal.section.serverStats")} />
|
||||
|
||||
@@ -20,7 +20,10 @@ export interface SftpTransferSource {
|
||||
// Types for the context
|
||||
export interface SftpPaneCallbacks {
|
||||
onConnect: (host: Host | "local") => void;
|
||||
onDisconnect: () => void;
|
||||
/** Resolves true if disconnect completed, false if the user canceled the
|
||||
* dirty-editor prompt. Callers that follow up with a replacement connect
|
||||
* must gate on the result. */
|
||||
onDisconnect: () => Promise<boolean>;
|
||||
onPrepareSelection: () => void;
|
||||
onNavigateTo: (path: string) => void;
|
||||
onNavigateUp: () => void;
|
||||
@@ -49,6 +52,7 @@ export interface SftpPaneCallbacks {
|
||||
onOpenFile?: (entry: SftpFileEntry, fullPath?: string) => void;
|
||||
onOpenFileWith?: (entry: SftpFileEntry, fullPath?: string) => void; // Always show opener dialog
|
||||
onDownloadFile?: (entry: SftpFileEntry, fullPath?: string) => void; // Download to local filesystem
|
||||
onDownloadFiles?: (entries: SftpFileEntry[]) => void; // Batch download — picks one target directory for remote panes
|
||||
// External file upload (supports folders via DataTransfer)
|
||||
onUploadExternalFiles?: (dataTransfer: DataTransfer, targetPath?: string) => Promise<void>;
|
||||
onListDirectory: (path: string) => Promise<SftpFileEntry[]>;
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { useSftpState } from "../../application/state/useSftpState";
|
||||
import type { HotkeyScheme, KeyBinding } from "../../domain/models";
|
||||
import FileOpenerDialog from "../FileOpenerDialog";
|
||||
import TextEditorModal from "../TextEditorModal";
|
||||
import type { TextEditorModalSnapshot } from "../TextEditorModal";
|
||||
import { SftpConflictDialog, SftpHostPicker, SftpPermissionsDialog } from "./index";
|
||||
import { SftpTransferQueue } from "./SftpTransferQueue";
|
||||
|
||||
@@ -44,6 +45,8 @@ interface SftpOverlaysProps {
|
||||
setFileOpenerTarget: (target: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null) => void;
|
||||
handleFileOpenerSelect: (openerType: FileOpenerType, setAsDefault: boolean, systemApp?: SystemAppInfo) => void;
|
||||
handleSelectSystemApp: (systemApp: { path: string; name: string }) => void;
|
||||
onPromoteToTab?: (snapshot: TextEditorModalSnapshot) => void;
|
||||
onRequestTerminalFocus?: () => void;
|
||||
}
|
||||
|
||||
export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
|
||||
@@ -80,6 +83,8 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
|
||||
setFileOpenerTarget,
|
||||
handleFileOpenerSelect,
|
||||
handleSelectSystemApp,
|
||||
onPromoteToTab,
|
||||
onRequestTerminalFocus,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
@@ -138,6 +143,7 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
|
||||
setShowTextEditor(false);
|
||||
setTextEditorTarget(null);
|
||||
setTextEditorContent("");
|
||||
onRequestTerminalFocus?.();
|
||||
}}
|
||||
fileName={textEditorTarget?.file.name || ""}
|
||||
initialContent={textEditorContent}
|
||||
@@ -146,6 +152,7 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
|
||||
onToggleWordWrap={() => setEditorWordWrap(!editorWordWrap)}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
onPromoteToTab={onPromoteToTab}
|
||||
/>
|
||||
|
||||
{/* File Opener Dialog */}
|
||||
|
||||
@@ -61,7 +61,7 @@ interface SftpPaneDialogsProps {
|
||||
hostSearch: string;
|
||||
setHostSearch: (value: string) => void;
|
||||
onConnect: (host: Host | "local") => void;
|
||||
onDisconnect: () => void;
|
||||
onDisconnect: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
const HostHint: React.FC<{ label?: string }> = ({ label }) =>
|
||||
@@ -357,13 +357,16 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
|
||||
side={side}
|
||||
hostSearch={hostSearch}
|
||||
onHostSearchChange={setHostSearch}
|
||||
onSelectLocal={() => {
|
||||
onDisconnect();
|
||||
onConnect("local");
|
||||
onSelectLocal={async () => {
|
||||
// Only connect to the new target if the disconnect actually happened.
|
||||
// A cancel on the dirty-editor prompt must keep the user on the
|
||||
// current host instead of silently switching and stranding tabs.
|
||||
const ok = await onDisconnect();
|
||||
if (ok) onConnect("local");
|
||||
}}
|
||||
onSelectHost={(host) => {
|
||||
onDisconnect();
|
||||
onConnect(host);
|
||||
onSelectHost={async (host) => {
|
||||
const ok = await onDisconnect();
|
||||
if (ok) onConnect(host);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -58,6 +58,7 @@ interface SftpPaneFileListProps {
|
||||
onOpenFileWith?: (entry: SftpFileEntry) => void;
|
||||
onEditFile?: (entry: SftpFileEntry) => void;
|
||||
onDownloadFile?: (entry: SftpFileEntry) => void;
|
||||
onDownloadFiles?: (entries: SftpFileEntry[]) => void;
|
||||
onEditPermissions?: (entry: SftpFileEntry) => void;
|
||||
openRenameDialog: (name: string) => void;
|
||||
openDeleteConfirm: (targets: string[]) => void;
|
||||
@@ -143,6 +144,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
||||
onOpenFileWith,
|
||||
onEditFile,
|
||||
onDownloadFile,
|
||||
onDownloadFiles,
|
||||
onEditPermissions,
|
||||
openRenameDialog,
|
||||
openDeleteConfirm,
|
||||
@@ -243,7 +245,23 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
||||
)}
|
||||
{onDownloadFile &&
|
||||
(!isNavigableDirectory(entry) || !pane.connection?.isLocal) && (
|
||||
<ContextMenuItem onClick={() => onDownloadFile(entry)}>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
const currentSelected = selectedFilesRef.current;
|
||||
if (
|
||||
onDownloadFiles &&
|
||||
currentSelected.has(entry.name) &&
|
||||
currentSelected.size > 1
|
||||
) {
|
||||
const entries = Array.from(currentSelected)
|
||||
.map((name) => filesByName.get(String(name)))
|
||||
.filter((f): f is SftpFileEntry => !!f);
|
||||
onDownloadFiles(entries);
|
||||
} else {
|
||||
onDownloadFile(entry);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Download size={14} className="mr-2" />{" "}
|
||||
{t("sftp.context.download")}
|
||||
</ContextMenuItem>
|
||||
@@ -349,6 +367,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
||||
onCopyToOtherPane,
|
||||
onMoveEntriesToPath,
|
||||
onDownloadFile,
|
||||
onDownloadFiles,
|
||||
onDragEnd,
|
||||
onEditFile,
|
||||
onEditPermissions,
|
||||
|
||||
@@ -570,6 +570,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
onOpenFileWith={callbacks.onOpenFileWith}
|
||||
onEditFile={callbacks.onEditFile}
|
||||
onDownloadFile={callbacks.onDownloadFile}
|
||||
onDownloadFiles={callbacks.onDownloadFiles}
|
||||
onEditPermissions={callbacks.onEditPermissions}
|
||||
openRenameDialog={openRenameDialog}
|
||||
openDeleteConfirm={openDeleteConfirm}
|
||||
|
||||
@@ -270,7 +270,7 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="border-t border-border/40 bg-background/60 px-3 py-2.5 backdrop-blur-sm">
|
||||
<div className="border-t border-border/40 bg-background/60 px-3 py-2.5 supports-[backdrop-filter]:backdrop-blur-sm">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex h-5 w-5 items-center justify-center shrink-0 -translate-y-px">
|
||||
{statusIcon}
|
||||
|
||||
@@ -325,7 +325,7 @@ export const SftpTransferQueue: React.FC<SftpTransferQueueProps> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="border-t border-border/70 bg-secondary/80 backdrop-blur-sm shrink-0"
|
||||
className="border-t border-border/70 bg-secondary/80 supports-[backdrop-filter]:backdrop-blur-sm shrink-0"
|
||||
style={{ height: clampPanelHeight(panelHeight) }}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -5,8 +5,11 @@ import { getParentPath, joinPath as joinFsPath } from "../../../application/stat
|
||||
import type { SftpStateApi } from "../../../application/state/useSftpState";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { toast } from "../../ui/toast";
|
||||
import { getFileExtension, FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
|
||||
import { getFileExtension, getLanguageId, FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
|
||||
import { isNavigableDirectory } from "../index";
|
||||
import { editorTabStore } from "../../../application/state/editorTabStore";
|
||||
import { toEditorTabId, activeTabStore } from "../../../application/state/activeTabStore";
|
||||
import type { TextEditorModalSnapshot } from "../../TextEditorModal";
|
||||
|
||||
interface UseSftpViewFileOpsParams {
|
||||
sftpRef: MutableRefObject<SftpStateApi>;
|
||||
@@ -80,6 +83,7 @@ interface UseSftpViewFileOpsResult {
|
||||
} | null>
|
||||
>;
|
||||
handleSaveTextFile: (content: string) => Promise<void>;
|
||||
onPromoteToTab: (snapshot: TextEditorModalSnapshot) => void;
|
||||
handleFileOpenerSelect: (
|
||||
openerType: FileOpenerType,
|
||||
setAsDefault: boolean,
|
||||
@@ -98,6 +102,8 @@ interface UseSftpViewFileOpsResult {
|
||||
onOpenFileWithRight: (file: SftpFileEntry, fullPath?: string) => void;
|
||||
onDownloadFileLeft: (file: SftpFileEntry, fullPath?: string) => void;
|
||||
onDownloadFileRight: (file: SftpFileEntry, fullPath?: string) => void;
|
||||
onDownloadFilesLeft: (files: SftpFileEntry[]) => void;
|
||||
onDownloadFilesRight: (files: SftpFileEntry[]) => void;
|
||||
onUploadExternalFilesLeft: (dataTransfer: DataTransfer, targetPath?: string) => void;
|
||||
onUploadExternalFilesRight: (dataTransfer: DataTransfer, targetPath?: string) => void;
|
||||
}
|
||||
@@ -298,6 +304,31 @@ export const useSftpViewFileOps = ({
|
||||
[sftpRef],
|
||||
);
|
||||
|
||||
const handlePromoteToTab = useCallback((snapshot: TextEditorModalSnapshot) => {
|
||||
const target = textEditorTargetRef.current;
|
||||
if (!target) return;
|
||||
const pane = target.side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
|
||||
const connection = pane.connection;
|
||||
if (!connection || !target.hostId) return;
|
||||
|
||||
const editorId = editorTabStore.promoteFromModal({
|
||||
sessionId: connection.id,
|
||||
hostId: target.hostId,
|
||||
remotePath: target.fullPath,
|
||||
fileName: target.file.name,
|
||||
languageId: snapshot.languageId || getLanguageId(target.file.name),
|
||||
content: snapshot.content,
|
||||
baselineContent: snapshot.baselineContent,
|
||||
wordWrap: snapshot.wordWrap,
|
||||
viewState: snapshot.viewState,
|
||||
});
|
||||
activeTabStore.setActiveTabId(toEditorTabId(editorId));
|
||||
// Close the modal
|
||||
setShowTextEditor(false);
|
||||
setTextEditorTarget(null);
|
||||
setTextEditorContent("");
|
||||
}, [sftpRef]);
|
||||
|
||||
const onEditFileLeft = useCallback(
|
||||
(file: SftpFileEntry, fullPath?: string) => handleEditFileForSide("left", file, fullPath),
|
||||
[handleEditFileForSide],
|
||||
@@ -589,6 +620,177 @@ export const useSftpViewFileOps = ({
|
||||
[handleDownloadFileForSide],
|
||||
);
|
||||
|
||||
// Multi-file download. For local panes, each file auto-downloads as a blob
|
||||
// (no prompt). For remote panes, prompts for a target directory once and
|
||||
// streams all selected entries into it — avoids the per-file save dialog
|
||||
// that would otherwise appear N times.
|
||||
const handleDownloadFilesForSide = useCallback(
|
||||
async (side: "left" | "right", files: SftpFileEntry[]) => {
|
||||
if (files.length === 0) return;
|
||||
if (files.length === 1) {
|
||||
await handleDownloadFileForSide(side, files[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
|
||||
if (!pane.connection) return;
|
||||
|
||||
if (pane.connection.isLocal) {
|
||||
for (const file of files) {
|
||||
await handleDownloadFileForSide(side, file);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectDirectory || !startStreamTransfer || !getSftpIdForConnection) {
|
||||
toast.error(t("sftp.error.downloadFailed"), "SFTP");
|
||||
return;
|
||||
}
|
||||
|
||||
const sftpId = getSftpIdForConnection(pane.connection.id);
|
||||
if (!sftpId) {
|
||||
toast.error(t("sftp.error.downloadFailed"), "SFTP");
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedDirectory = await selectDirectory(t("sftp.context.download"));
|
||||
if (!selectedDirectory) return;
|
||||
|
||||
for (const file of files) {
|
||||
const sourcePath = sftpRef.current.joinPath(pane.connection.currentPath, file.name);
|
||||
const targetPath = joinFsPath(selectedDirectory, file.name);
|
||||
const isDirectory = isNavigableDirectory(file);
|
||||
|
||||
try {
|
||||
if (isDirectory) {
|
||||
const status = await sftpRef.current.downloadToLocal({
|
||||
fileName: file.name,
|
||||
sourcePath,
|
||||
targetPath,
|
||||
sftpId,
|
||||
connectionId: pane.connection.id,
|
||||
sourceEncoding: pane.filenameEncoding,
|
||||
isDirectory: true,
|
||||
});
|
||||
if (status === "completed") {
|
||||
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
|
||||
} else if (status === "failed") {
|
||||
toast.error(`${t("sftp.error.downloadFailed")}: ${file.name}`, "SFTP");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const transferId = `download-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const fileSize = typeof file.size === "string" ? parseInt(file.size, 10) || 0 : (file.size || 0);
|
||||
|
||||
sftpRef.current.addExternalUpload({
|
||||
id: transferId,
|
||||
fileName: file.name,
|
||||
sourcePath,
|
||||
targetPath,
|
||||
sourceConnectionId: pane.connection.id,
|
||||
targetConnectionId: "local",
|
||||
direction: "download",
|
||||
status: "transferring",
|
||||
totalBytes: fileSize,
|
||||
transferredBytes: 0,
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
isDirectory: false,
|
||||
});
|
||||
|
||||
let errorHandled = false;
|
||||
|
||||
const result = await startStreamTransfer(
|
||||
{
|
||||
transferId,
|
||||
sourcePath,
|
||||
targetPath,
|
||||
sourceType: "sftp",
|
||||
targetType: "local",
|
||||
sourceSftpId: sftpId,
|
||||
totalBytes: fileSize,
|
||||
sourceEncoding: pane.filenameEncoding,
|
||||
},
|
||||
(transferred, total, speed) => {
|
||||
sftpRef.current.updateExternalUpload(transferId, {
|
||||
transferredBytes: transferred,
|
||||
totalBytes: total,
|
||||
speed,
|
||||
});
|
||||
},
|
||||
() => {
|
||||
sftpRef.current.updateExternalUpload(transferId, {
|
||||
status: "completed",
|
||||
transferredBytes: fileSize,
|
||||
endTime: Date.now(),
|
||||
});
|
||||
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
|
||||
},
|
||||
(error) => {
|
||||
errorHandled = true;
|
||||
const isCancelError = error.includes("cancelled") || error.includes("canceled");
|
||||
sftpRef.current.updateExternalUpload(transferId, {
|
||||
status: isCancelError ? "cancelled" : "failed",
|
||||
error: isCancelError ? undefined : error,
|
||||
endTime: Date.now(),
|
||||
});
|
||||
if (!isCancelError) {
|
||||
toast.error(error, "SFTP");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (result === undefined) {
|
||||
sftpRef.current.updateExternalUpload(transferId, {
|
||||
status: "failed",
|
||||
error: t("sftp.error.downloadFailed"),
|
||||
endTime: Date.now(),
|
||||
});
|
||||
toast.error(t("sftp.error.downloadFailed"), "SFTP");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result?.error && !errorHandled) {
|
||||
const isCancelError = result.error.includes("cancelled") || result.error.includes("canceled");
|
||||
sftpRef.current.updateExternalUpload(transferId, {
|
||||
status: isCancelError ? "cancelled" : "failed",
|
||||
error: isCancelError ? undefined : result.error,
|
||||
endTime: Date.now(),
|
||||
});
|
||||
if (!isCancelError) {
|
||||
toast.error(result.error, "SFTP");
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error("[SftpView] Failed to download file:", e);
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.downloadFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
sftpRef,
|
||||
t,
|
||||
selectDirectory,
|
||||
startStreamTransfer,
|
||||
getSftpIdForConnection,
|
||||
handleDownloadFileForSide,
|
||||
],
|
||||
);
|
||||
|
||||
const onDownloadFilesLeft = useCallback(
|
||||
(files: SftpFileEntry[]) => handleDownloadFilesForSide("left", files),
|
||||
[handleDownloadFilesForSide],
|
||||
);
|
||||
|
||||
const onDownloadFilesRight = useCallback(
|
||||
(files: SftpFileEntry[]) => handleDownloadFilesForSide("right", files),
|
||||
[handleDownloadFilesForSide],
|
||||
);
|
||||
|
||||
const onOpenEntryLeft = useCallback(
|
||||
(entry: SftpFileEntry, fullPath?: string) => {
|
||||
const pane = sftpRef.current.leftPane;
|
||||
@@ -664,6 +866,7 @@ export const useSftpViewFileOps = ({
|
||||
fileOpenerTarget,
|
||||
setFileOpenerTarget,
|
||||
handleSaveTextFile,
|
||||
onPromoteToTab: handlePromoteToTab,
|
||||
handleFileOpenerSelect,
|
||||
handleSelectSystemApp,
|
||||
onEditPermissionsLeft,
|
||||
@@ -678,6 +881,8 @@ export const useSftpViewFileOps = ({
|
||||
onOpenFileWithRight,
|
||||
onDownloadFileLeft,
|
||||
onDownloadFileRight,
|
||||
onDownloadFilesLeft,
|
||||
onDownloadFilesRight,
|
||||
onUploadExternalFilesLeft,
|
||||
onUploadExternalFilesRight,
|
||||
};
|
||||
|
||||
@@ -3,6 +3,9 @@ import type { MutableRefObject } from "react";
|
||||
import type { SftpStateApi } from "../../../application/state/useSftpState";
|
||||
import type { SftpDragCallbacks, SftpTransferSource } from "../SftpContext";
|
||||
import { keepOnlyActivePaneSelections } from "./selectionScope";
|
||||
import { editorTabStore } from "../../../application/state/editorTabStore";
|
||||
import type { EditorTab, EditorTabId } from "../../../application/state/editorTabStore";
|
||||
import { promptUnsavedChanges } from "../../editor/UnsavedChangesDialog";
|
||||
|
||||
interface UseSftpViewPaneActionsParams {
|
||||
sftpRef: MutableRefObject<SftpStateApi>;
|
||||
@@ -13,8 +16,8 @@ interface UseSftpViewPaneActionsResult {
|
||||
draggedFiles: (SftpTransferSource & { side: "left" | "right" })[] | null;
|
||||
onConnectLeft: (host: Parameters<SftpStateApi["connect"]>[1]) => void;
|
||||
onConnectRight: (host: Parameters<SftpStateApi["connect"]>[1]) => void;
|
||||
onDisconnectLeft: () => void;
|
||||
onDisconnectRight: () => void;
|
||||
onDisconnectLeft: () => Promise<boolean>;
|
||||
onDisconnectRight: () => Promise<boolean>;
|
||||
onPrepareSelectionLeft: () => void;
|
||||
onPrepareSelectionRight: () => void;
|
||||
onNavigateToLeft: (path: string) => void;
|
||||
@@ -127,8 +130,42 @@ export const useSftpViewPaneActions = ({
|
||||
(host: Parameters<SftpStateApi["connect"]>[1]) => sftpRef.current.connect("right", host),
|
||||
[sftpRef],
|
||||
);
|
||||
const onDisconnectLeft = useCallback(() => sftpRef.current.disconnect("left"), [sftpRef]);
|
||||
const onDisconnectRight = useCallback(() => sftpRef.current.disconnect("right"), [sftpRef]);
|
||||
// Returns `true` if the disconnect actually happened, `false` if the user
|
||||
// canceled the dirty-editor prompt. Callers that kick off a replacement
|
||||
// connect (e.g. the host picker) MUST gate their follow-up on this result
|
||||
// so a canceled prompt doesn't silently drop the user onto a new host.
|
||||
const onDisconnectLeft = useCallback(async (): Promise<boolean> => {
|
||||
const connectionId = sftpRef.current.getActivePane("left")?.connection?.id;
|
||||
if (connectionId) {
|
||||
const choice = (tab: EditorTab) => promptUnsavedChanges(tab.fileName);
|
||||
const saveTab = async (id: EditorTabId) => {
|
||||
const tab = editorTabStore.getTab(id);
|
||||
if (!tab) return;
|
||||
await sftpRef.current.writeTextFileByConnection(tab.sessionId, tab.hostId, tab.remotePath, tab.content);
|
||||
editorTabStore.markSaved(id, tab.content);
|
||||
};
|
||||
const ok = await editorTabStore.confirmCloseBySession(connectionId, choice, saveTab);
|
||||
if (!ok) return false;
|
||||
}
|
||||
sftpRef.current.disconnect("left");
|
||||
return true;
|
||||
}, [sftpRef]);
|
||||
const onDisconnectRight = useCallback(async (): Promise<boolean> => {
|
||||
const connectionId = sftpRef.current.getActivePane("right")?.connection?.id;
|
||||
if (connectionId) {
|
||||
const choice = (tab: EditorTab) => promptUnsavedChanges(tab.fileName);
|
||||
const saveTab = async (id: EditorTabId) => {
|
||||
const tab = editorTabStore.getTab(id);
|
||||
if (!tab) return;
|
||||
await sftpRef.current.writeTextFileByConnection(tab.sessionId, tab.hostId, tab.remotePath, tab.content);
|
||||
editorTabStore.markSaved(id, tab.content);
|
||||
};
|
||||
const ok = await editorTabStore.confirmCloseBySession(connectionId, choice, saveTab);
|
||||
if (!ok) return false;
|
||||
}
|
||||
sftpRef.current.disconnect("right");
|
||||
return true;
|
||||
}, [sftpRef]);
|
||||
const onPrepareSelectionLeft = useCallback(() => {
|
||||
keepOnlyActivePaneSelections(sftpRef.current, "left");
|
||||
}, [sftpRef]);
|
||||
|
||||
@@ -169,6 +169,7 @@ export const useSftpViewPaneCallbacks = ({
|
||||
onOpenFile: fileOps.onOpenFileLeft,
|
||||
onOpenFileWith: fileOps.onOpenFileWithLeft,
|
||||
onDownloadFile: fileOps.onDownloadFileLeft,
|
||||
onDownloadFiles: fileOps.onDownloadFilesLeft,
|
||||
onUploadExternalFiles: fileOps.onUploadExternalFilesLeft,
|
||||
onListDirectory: makeListDirectory("left", () => sftpRef.current.leftPane),
|
||||
}),
|
||||
@@ -206,6 +207,7 @@ export const useSftpViewPaneCallbacks = ({
|
||||
onOpenFile: fileOps.onOpenFileRight,
|
||||
onOpenFileWith: fileOps.onOpenFileWithRight,
|
||||
onDownloadFile: fileOps.onDownloadFileRight,
|
||||
onDownloadFiles: fileOps.onDownloadFilesRight,
|
||||
onUploadExternalFiles: fileOps.onUploadExternalFilesRight,
|
||||
onListDirectory: makeListDirectory("right", () => sftpRef.current.rightPane),
|
||||
}),
|
||||
@@ -232,6 +234,7 @@ export const useSftpViewPaneCallbacks = ({
|
||||
fileOpenerTarget: fileOps.fileOpenerTarget,
|
||||
setFileOpenerTarget: fileOps.setFileOpenerTarget,
|
||||
handleSaveTextFile: fileOps.handleSaveTextFile,
|
||||
onPromoteToTab: fileOps.onPromoteToTab,
|
||||
handleFileOpenerSelect: fileOps.handleFileOpenerSelect,
|
||||
handleSelectSystemApp: fileOps.handleSelectSystemApp,
|
||||
};
|
||||
|
||||
51
components/sync/SyncBlockedBanner.tsx
Normal file
51
components/sync/SyncBlockedBanner.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import type { ShrinkFinding } from '../../domain/syncGuards';
|
||||
import { Button } from '../ui/button';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
|
||||
interface Props {
|
||||
finding: Extract<ShrinkFinding, { suspicious: true }>;
|
||||
onRestore: () => void;
|
||||
onForcePush: () => void;
|
||||
}
|
||||
|
||||
export const SyncBlockedBanner: React.FC<Props> = ({ finding, onRestore, onForcePush }) => {
|
||||
const { t } = useI18n();
|
||||
const entityLabel = t(`sync.entityType.${finding.entityType}`);
|
||||
const percent = finding.baseCount > 0 ? Math.round((finding.lost / finding.baseCount) * 100) : 0;
|
||||
|
||||
const reasonText = finding.reason === 'bulk-shrink'
|
||||
? t('sync.blocked.reason.bulkShrink', {
|
||||
lost: finding.lost,
|
||||
baseCount: finding.baseCount,
|
||||
entityType: entityLabel,
|
||||
percent,
|
||||
})
|
||||
: t('sync.blocked.reason.largeShrink', {
|
||||
lost: finding.lost,
|
||||
entityType: entityLabel,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex flex-col gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 p-4"
|
||||
>
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
||||
<span>{t('sync.blocked.title')}</span>
|
||||
</div>
|
||||
<p className="text-sm">{reasonText}</p>
|
||||
<p className="text-xs opacity-70">{t('sync.blocked.detail')}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="default" size="sm" onClick={onRestore}>
|
||||
{t('sync.blocked.restoreButton')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onForcePush}>
|
||||
{t('sync.blocked.forcePushButton')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -148,10 +148,10 @@ export const CustomThemeModal: React.FC<CustomThemeModalProps> = ({
|
||||
className="fixed inset-0 z-[300] flex items-center justify-center"
|
||||
>
|
||||
{/* Backdrop — clicking it dismisses the modal */}
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onCancel} />
|
||||
<div className="absolute inset-0 bg-black/60 supports-[backdrop-filter]:backdrop-blur-sm" onClick={onCancel} />
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative z-10 bg-popover/95 backdrop-blur-xl rounded-xl shadow-2xl border border-border/50 flex flex-col"
|
||||
<div className="relative z-10 bg-popover/95 supports-[backdrop-filter]:backdrop-blur-sm rounded-xl shadow-2xl border border-border/50 flex flex-col"
|
||||
style={{ width: 'min(820px, 90vw)', height: 'min(600px, 85vh)' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
|
||||
22
components/terminal/GhostSuggestionPolicy.test.ts
Normal file
22
components/terminal/GhostSuggestionPolicy.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { decideGhostSuggestion } from "./autocomplete/ghostSuggestionPolicy.ts";
|
||||
|
||||
test("keeps the active ghost suggestion while input still fits it", () => {
|
||||
const decision = decideGhostSuggestion("docker ps -a", "doc", "docker compose ls");
|
||||
|
||||
assert.deepEqual(decision, { type: "keep" });
|
||||
});
|
||||
|
||||
test("switches to a new suggestion once the active one no longer matches", () => {
|
||||
const decision = decideGhostSuggestion("docker ps -a", "dog", "dogstatsd");
|
||||
|
||||
assert.deepEqual(decision, { type: "show", suggestion: "dogstatsd" });
|
||||
});
|
||||
|
||||
test("hides the ghost when neither the active nor next suggestion matches", () => {
|
||||
const decision = decideGhostSuggestion("docker ps -a", "dog", null);
|
||||
|
||||
assert.deepEqual(decision, { type: "hide" });
|
||||
});
|
||||
325
components/terminal/GhostTextAddon.test.ts
Normal file
325
components/terminal/GhostTextAddon.test.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { GhostTextAddon } from "./autocomplete/GhostTextAddon.ts";
|
||||
|
||||
type RenderListener = () => void;
|
||||
type ResizeListener = () => void;
|
||||
|
||||
class FakeElement {
|
||||
public readonly style: Record<string, string> = {};
|
||||
public textContent = "";
|
||||
public className = "";
|
||||
public children: FakeElement[] = [];
|
||||
|
||||
appendChild(child: FakeElement): FakeElement {
|
||||
this.children.push(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
insertBefore(child: FakeElement, referenceNode: FakeElement | null): FakeElement {
|
||||
if (!referenceNode) {
|
||||
this.children.push(child);
|
||||
return child;
|
||||
}
|
||||
const index = this.children.indexOf(referenceNode);
|
||||
if (index < 0) {
|
||||
this.children.push(child);
|
||||
return child;
|
||||
}
|
||||
this.children.splice(index, 0, child);
|
||||
return child;
|
||||
}
|
||||
|
||||
remove(): void {
|
||||
// No-op for tests.
|
||||
}
|
||||
|
||||
querySelector(selector: string): FakeElement | null {
|
||||
if (selector === ".xterm-screen") {
|
||||
return this.children.find((child) => child.className === "xterm-screen") ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function installFakeDocument(): () => void {
|
||||
const previousDocument = globalThis.document;
|
||||
const fakeDocument = {
|
||||
createElement() {
|
||||
return new FakeElement();
|
||||
},
|
||||
} as unknown as Document;
|
||||
Object.defineProperty(globalThis, "document", {
|
||||
configurable: true,
|
||||
value: fakeDocument,
|
||||
});
|
||||
return () => {
|
||||
if (previousDocument === undefined) {
|
||||
delete (globalThis as { document?: Document }).document;
|
||||
return;
|
||||
}
|
||||
Object.defineProperty(globalThis, "document", {
|
||||
configurable: true,
|
||||
value: previousDocument,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function createFakeTerm() {
|
||||
const renderListeners: RenderListener[] = [];
|
||||
const resizeListeners: ResizeListener[] = [];
|
||||
const element = new FakeElement();
|
||||
const screen = new FakeElement();
|
||||
screen.className = "xterm-screen";
|
||||
element.appendChild(screen);
|
||||
|
||||
const term = {
|
||||
element,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
options: {
|
||||
fontSize: 14,
|
||||
fontFamily: "monospace",
|
||||
},
|
||||
buffer: {
|
||||
active: {
|
||||
cursorX: 2,
|
||||
cursorY: 0,
|
||||
},
|
||||
},
|
||||
_core: {
|
||||
_renderService: {
|
||||
dimensions: {
|
||||
css: {
|
||||
cell: {
|
||||
width: 9,
|
||||
height: 18,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
onRender(listener: RenderListener) {
|
||||
renderListeners.push(listener);
|
||||
return {
|
||||
dispose() {
|
||||
const index = renderListeners.indexOf(listener);
|
||||
if (index >= 0) renderListeners.splice(index, 1);
|
||||
},
|
||||
};
|
||||
},
|
||||
onResize(listener: ResizeListener) {
|
||||
resizeListeners.push(listener);
|
||||
return {
|
||||
dispose() {
|
||||
const index = resizeListeners.indexOf(listener);
|
||||
if (index >= 0) resizeListeners.splice(index, 1);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
term,
|
||||
ghostElement: () => screen.children[0]?.children[0] ?? null,
|
||||
fireRender() {
|
||||
for (const listener of [...renderListeners]) listener();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("shifts ghost to predicted cursor column as matching input is typed", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
addon.activate(term as never);
|
||||
addon.show("docker", "do");
|
||||
|
||||
const ghost = ghostElement();
|
||||
assert.ok(ghost);
|
||||
assert.equal(ghost.style.display, "block");
|
||||
assert.equal(ghost.textContent, "cker");
|
||||
// show() anchored at cursorX=2, cell width=9 → left=18.
|
||||
assert.equal(ghost.style.left, "18px");
|
||||
|
||||
addon.adjustToInput("doc");
|
||||
|
||||
// After one matching char, the ghost predicts the cursor has moved
|
||||
// to column 3 and trims "c" from the tail so the next char starts
|
||||
// where the echo will land. Not waiting for xterm's render keeps
|
||||
// ghost + real input aligned across SSH echo latency.
|
||||
assert.equal(ghost.style.display, "block");
|
||||
assert.equal(ghost.textContent, "ker");
|
||||
assert.equal(ghost.style.left, "27px");
|
||||
assert.equal(addon.getGhostText(), "ker");
|
||||
} finally {
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("walks the anchor column backwards on backspace so the ghost re-aligns", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
addon.activate(term as never);
|
||||
addon.show("docker", "do");
|
||||
|
||||
const ghost = ghostElement();
|
||||
assert.ok(ghost);
|
||||
|
||||
addon.adjustToInput("doc");
|
||||
assert.equal(ghost.textContent, "ker");
|
||||
assert.equal(ghost.style.left, "27px");
|
||||
|
||||
// Backspace below the anchor input — the ghost should shift *left*,
|
||||
// not stay pinned at the show-time anchor column. Pinning would
|
||||
// leave a visual gap between the real cursor and the ghost.
|
||||
addon.adjustToInput("d");
|
||||
assert.equal(ghost.textContent, "ocker");
|
||||
// anchor was cursorX=2 captured at show(); "d" is 1 char below
|
||||
// anchorInputLength=2 → predicted cursor column = 1.
|
||||
assert.equal(ghost.style.left, "9px");
|
||||
|
||||
// Backspace past the anchor back to empty: left is clamped at 0.
|
||||
addon.adjustToInput("");
|
||||
assert.equal(ghost.textContent, "docker");
|
||||
assert.equal(ghost.style.left, "0px");
|
||||
} finally {
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("advances the anchor by two cells when a CJK glyph is typed", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
addon.activate(term as never);
|
||||
// Suggestion starts with a CJK char so the prefix-match survives
|
||||
// the next keystroke.
|
||||
addon.show("你好世界", "");
|
||||
const ghost = ghostElement();
|
||||
assert.ok(ghost);
|
||||
// show() anchored at cursorX=2. Input length 0 → delta 0 → left=18.
|
||||
assert.equal(ghost.style.left, "18px");
|
||||
|
||||
addon.adjustToInput("你");
|
||||
|
||||
// One CJK char = 2 cells. Predicted col = 2 + 2 = 4 → left 36px.
|
||||
assert.equal(ghost.textContent, "好世界");
|
||||
assert.equal(ghost.style.left, "36px");
|
||||
} finally {
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("wraps the ghost to the next row when the predicted column crosses cols", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
// Shrink the terminal to 10 cols to keep the math obvious. Anchor at
|
||||
// col 8 with 5 ASCII chars to type → predicted col = 13, which should
|
||||
// wrap to col 3 of row 1.
|
||||
term.cols = 10;
|
||||
term.buffer.active.cursorX = 8;
|
||||
addon.activate(term as never);
|
||||
addon.show("abcdefghij", "ab");
|
||||
const ghost = ghostElement();
|
||||
assert.ok(ghost);
|
||||
assert.equal(ghost.style.top, "0px");
|
||||
|
||||
addon.adjustToInput("abcde");
|
||||
|
||||
// Predicted col = 8 + (5-2) = 11 → wraps to col 1 on next row.
|
||||
// cellWidth=9, cellHeight=18.
|
||||
assert.equal(ghost.textContent, "fghij");
|
||||
assert.equal(ghost.style.left, "9px");
|
||||
assert.equal(ghost.style.top, "18px");
|
||||
} finally {
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("self-heals a stale anchor on render while no adjustToInput has fired", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement, fireRender } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
addon.activate(term as never);
|
||||
// show() captures cursorX=2 — simulate this firing during the
|
||||
// keystroke→echo gap by later advancing the live cursor and
|
||||
// verifying the ghost anchor snaps to the echoed position.
|
||||
addon.show("docker", "do");
|
||||
const ghost = ghostElement();
|
||||
assert.ok(ghost);
|
||||
assert.equal(ghost.style.left, "18px");
|
||||
|
||||
term.buffer.active.cursorX = 5;
|
||||
fireRender();
|
||||
|
||||
// Input hasn't moved from the show-time baseline, so updatePosition
|
||||
// re-reads live cursor: new left = 5 * 9 = 45px.
|
||||
assert.equal(ghost.style.left, "45px");
|
||||
} finally {
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("wraps the ghost to the previous row when deletion crosses a row boundary", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
term.cols = 10;
|
||||
term.buffer.active.cursorX = 1;
|
||||
term.buffer.active.cursorY = 1;
|
||||
addon.activate(term as never);
|
||||
// Anchored at row 1 col 1 with 5 chars already typed.
|
||||
addon.show("abcdefghij", "abcde");
|
||||
const ghost = ghostElement();
|
||||
assert.ok(ghost);
|
||||
|
||||
// Backspace back to 2 chars — delta = -3 across a row boundary.
|
||||
addon.adjustToInput("ab");
|
||||
|
||||
// targetCol = 1 - 3 = -2 → col = 8 (wrapped) on row 0.
|
||||
assert.equal(ghost.textContent, "cdefghij");
|
||||
assert.equal(ghost.style.left, "72px");
|
||||
assert.equal(ghost.style.top, "0px");
|
||||
} finally {
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("hides ghost immediately when input no longer matches suggestion", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
addon.activate(term as never);
|
||||
addon.show("docker", "do");
|
||||
|
||||
const ghost = ghostElement();
|
||||
assert.ok(ghost);
|
||||
assert.equal(ghost.style.display, "block");
|
||||
|
||||
addon.adjustToInput("dox");
|
||||
|
||||
assert.equal(ghost.style.display, "none");
|
||||
assert.equal(ghost.textContent, "");
|
||||
assert.equal(addon.isActive(), false);
|
||||
} finally {
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
49
components/terminal/PromptDetector.test.ts
Normal file
49
components/terminal/PromptDetector.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { getAlignedPrompt } from "./autocomplete/promptDetector.ts";
|
||||
|
||||
function createFakeTerm(lineText: string, cursorX: number) {
|
||||
return {
|
||||
buffer: {
|
||||
active: {
|
||||
cursorX,
|
||||
cursorY: 0,
|
||||
baseY: 0,
|
||||
getLine(line: number) {
|
||||
if (line !== 0) return undefined;
|
||||
return {
|
||||
isWrapped: false,
|
||||
translateToString() {
|
||||
return lineText;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("prefers the typed buffer when shell echo is still one character behind", () => {
|
||||
const term = createFakeTerm("$ do", 4);
|
||||
|
||||
const result = getAlignedPrompt(term as never, "doc", true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true);
|
||||
assert.equal(result.prompt.promptText, "$ ");
|
||||
assert.equal(result.prompt.userInput, "doc");
|
||||
assert.equal(result.prompt.cursorOffset, 3);
|
||||
assert.equal(result.alignedTyped, "doc");
|
||||
});
|
||||
|
||||
test("still trims prompt decorations out of the detected input", () => {
|
||||
const term = createFakeTerm("➜ ~ do", 7);
|
||||
|
||||
const result = getAlignedPrompt(term as never, "do", true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true);
|
||||
assert.equal(result.prompt.promptText, "➜ ~ ");
|
||||
assert.equal(result.prompt.userInput, "do");
|
||||
assert.equal(result.prompt.cursorOffset, 2);
|
||||
assert.equal(result.alignedTyped, "do");
|
||||
});
|
||||
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* Terminal Compose Bar
|
||||
* A modern text input bar for composing commands before sending them.
|
||||
* Supports pre-reviewing passwords/commands and broadcasting to multiple sessions.
|
||||
* An immersive, borderless prompt bar that blends into the terminal's
|
||||
* background — like the Claude Code compose area. Enter sends, Escape
|
||||
* closes, Shift+Enter inserts a newline. The only visible chrome is a
|
||||
* hair-line top border separating it from the terminal output.
|
||||
*/
|
||||
import { Radio, Send, X } from 'lucide-react';
|
||||
import { Radio, X } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { cn } from '../../lib/utils';
|
||||
@@ -73,10 +75,9 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
|
||||
<div
|
||||
className="flex-shrink-0"
|
||||
style={{
|
||||
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',
|
||||
backgroundColor: resolvedBg,
|
||||
borderTop: `1px solid color-mix(in srgb, ${resolvedFg} 8%, ${resolvedBg} 92%)`,
|
||||
padding: '8px 12px',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -90,77 +91,48 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input field */}
|
||||
{/* Borderless input — lives flush on the terminal bg so the
|
||||
bar feels like part of the terminal rather than a panel. */}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className={cn(
|
||||
"flex-1 min-w-0 resize-none rounded-md px-3 py-1.5 text-xs font-mono leading-relaxed",
|
||||
"outline-none transition-all duration-200",
|
||||
"placeholder:opacity-40",
|
||||
"flex-1 min-w-0 resize-none bg-transparent border-none px-0 py-0",
|
||||
"text-xs font-mono leading-relaxed outline-none",
|
||||
"placeholder:opacity-70",
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: `color-mix(in srgb, ${resolvedFg} 6%, ${resolvedBg} 94%)`,
|
||||
color: resolvedFg,
|
||||
border: `1px solid color-mix(in srgb, ${resolvedFg} 25%, ${resolvedBg} 75%)`,
|
||||
minHeight: '28px',
|
||||
minHeight: '20px',
|
||||
maxHeight: '120px',
|
||||
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, ${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, ${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; }}
|
||||
/>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
className="h-7 w-7 flex items-center justify-center rounded-md transition-colors duration-150"
|
||||
style={{
|
||||
color: resolvedFg,
|
||||
background: `color-mix(in srgb, ${resolvedFg} 20%, ${resolvedBg} 80%)`,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = `color-mix(in srgb, ${resolvedFg} 30%, ${resolvedBg} 70%)`;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = `color-mix(in srgb, ${resolvedFg} 20%, ${resolvedBg} 80%)`;
|
||||
}}
|
||||
onClick={handleSend}
|
||||
title={t("terminal.composeBar.send")}
|
||||
>
|
||||
<Send size={13} />
|
||||
</button>
|
||||
<button
|
||||
className="h-7 w-7 flex items-center justify-center rounded-md transition-colors duration-150"
|
||||
style={{
|
||||
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, ${resolvedFg} 22%, ${resolvedBg} 78%)`;
|
||||
e.currentTarget.style.color = resolvedFg;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
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")}
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
{/* Minimal close button — no filled bg, hover only. */}
|
||||
<button
|
||||
className="h-6 w-6 flex items-center justify-center rounded-md transition-colors duration-150 flex-shrink-0"
|
||||
style={{
|
||||
color: `color-mix(in srgb, ${resolvedFg} 50%, ${resolvedBg} 50%)`,
|
||||
background: 'transparent',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = `color-mix(in srgb, ${resolvedFg} 10%, ${resolvedBg} 90%)`;
|
||||
e.currentTarget.style.color = resolvedFg;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.color = `color-mix(in srgb, ${resolvedFg} 50%, ${resolvedBg} 50%)`;
|
||||
}}
|
||||
onClick={onClose}
|
||||
title={t("terminal.composeBar.close")}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
Terminal as TerminalIcon,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { KeyBinding, RightClickBehavior } from '../../domain/models';
|
||||
import {
|
||||
@@ -59,6 +59,17 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isMac = hotkeyScheme === 'mac';
|
||||
// Tracks the .workspace-pane whose context menu is currently open so we can
|
||||
// keep its `:focus-within`-driven opacity stable while focus is in the
|
||||
// menu portal (otherwise the pane dims for the menu's lifetime).
|
||||
const markedPaneRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const handleOpenChange = useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
markedPaneRef.current?.removeAttribute('data-menu-open');
|
||||
markedPaneRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Helper to get shortcut from keyBindings and format for display
|
||||
const getShortcut = (bindingId: string): string => {
|
||||
@@ -91,7 +102,15 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
}
|
||||
|
||||
// Shift+Right-Click or context-menu mode: let Radix open the menu
|
||||
if (e.shiftKey || rightClickBehavior === 'context-menu') return;
|
||||
if (e.shiftKey || rightClickBehavior === 'context-menu') {
|
||||
const pane = (e.target as HTMLElement | null)?.closest<HTMLElement>('.workspace-pane');
|
||||
if (pane) {
|
||||
markedPaneRef.current?.removeAttribute('data-menu-open');
|
||||
pane.setAttribute('data-menu-open', '');
|
||||
markedPaneRef.current = pane;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Paste / select-word: intercept and prevent the context menu
|
||||
e.preventDefault();
|
||||
@@ -107,7 +126,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
// Always use ContextMenu wrapper to maintain consistent React tree structure
|
||||
// This prevents terminal from unmounting when rightClickBehavior changes
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenu onOpenChange={handleOpenChange}>
|
||||
<ContextMenuTrigger
|
||||
asChild
|
||||
onContextMenu={handleRightClick}
|
||||
|
||||
54
components/terminal/TerminalToolbar.test.ts
Normal file
54
components/terminal/TerminalToolbar.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
|
||||
import type { Host } from "../../types.ts";
|
||||
import { TerminalToolbar } from "./TerminalToolbar.tsx";
|
||||
|
||||
const sshHost: Host = {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
hostname: "example.com",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
protocol: "ssh",
|
||||
};
|
||||
|
||||
const renderToolbar = (host: Host, status: "connecting" | "connected" | "disconnected" = "connected") =>
|
||||
renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(TerminalToolbar, {
|
||||
status,
|
||||
host,
|
||||
onOpenSFTP: () => {},
|
||||
onOpenScripts: () => {},
|
||||
onOpenTheme: () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
test("keeps SFTP visible before the terminal overflow menu for SSH sessions", () => {
|
||||
const markup = renderToolbar(sshHost);
|
||||
|
||||
const sftpIndex = markup.indexOf('aria-label="Open SFTP"');
|
||||
const moreIndex = markup.indexOf('aria-label="More actions"');
|
||||
|
||||
assert.notEqual(sftpIndex, -1);
|
||||
assert.notEqual(moreIndex, -1);
|
||||
assert.ok(sftpIndex < moreIndex);
|
||||
});
|
||||
|
||||
test("hides SFTP for local terminal sessions", () => {
|
||||
const markup = renderToolbar({
|
||||
...sshHost,
|
||||
id: "local-1",
|
||||
protocol: "local",
|
||||
});
|
||||
|
||||
assert.equal(markup.includes('aria-label="Open SFTP"'), false);
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Terminal Toolbar
|
||||
* Displays SFTP, Scripts, Theme, Highlight, Search buttons and close button in terminal status bar
|
||||
* Displays high-frequency terminal actions and close button in the terminal status bar.
|
||||
*/
|
||||
import { Check, FolderInput, Languages, X, Zap, Palette, Search, TextCursorInput } from 'lucide-react';
|
||||
import { Check, ChevronRight, FolderInput, Languages, MoreVertical, X, Zap, Palette, Search, TextCursorInput } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Host } from '../../types';
|
||||
@@ -50,25 +50,48 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [highlightPopoverOpen, setHighlightPopoverOpen] = useState(false);
|
||||
// Overflow popover + encoding submenu are both controlled so that
|
||||
// picking an encoding closes the whole chain, and so the parent popover
|
||||
// can ignore clicks that land in the submenu portal (otherwise the
|
||||
// submenu click would read as "outside" and dismiss the parent).
|
||||
const [overflowOpen, setOverflowOpen] = useState(false);
|
||||
const [encodingSubmenuOpen, setEncodingSubmenuOpen] = useState(false);
|
||||
const buttonBase = "h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)] bg-transparent hover:bg-transparent";
|
||||
|
||||
const isLocalTerminal = host?.protocol === 'local' || host?.id?.startsWith('local-');
|
||||
const isSerialTerminal = host?.protocol === 'serial' || host?.id?.startsWith('serial-');
|
||||
const isSSHSession = !isLocalTerminal && !isSerialTerminal && host?.protocol !== 'telnet' && host?.protocol !== 'mosh' && !host?.moshEnabled && host?.hostname !== 'localhost';
|
||||
const isMoshSession = host?.protocol === 'mosh' || host?.moshEnabled;
|
||||
// Local PTY inherits the OS locale and mosh always uses its own UTF-8
|
||||
// framing, so the quick-switch menu only makes sense for sessions whose
|
||||
// backend decoder we actually control (SSH, telnet, serial). Hostname
|
||||
// isn't part of the gate — telnet/SSH targets pointed at localhost
|
||||
// (test daemons, forwarded endpoints) still have a real backend
|
||||
// decoder we can drive.
|
||||
const encodingSwitchSupported = !isLocalTerminal && !isMoshSession;
|
||||
const hidesSftp = isLocalTerminal || isSerialTerminal;
|
||||
|
||||
const menuItemClass = "w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors";
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={500} skipDelayDuration={100} disableHoverableContent>
|
||||
<HostKeywordHighlightPopover
|
||||
host={host}
|
||||
onUpdateHost={onUpdateHost}
|
||||
isOpen={highlightPopoverOpen}
|
||||
setIsOpen={setHighlightPopoverOpen}
|
||||
buttonClassName={buttonBase}
|
||||
/>
|
||||
|
||||
{!hidesSftp && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className={buttonBase}
|
||||
disabled={status !== 'connected'}
|
||||
aria-label={t("terminal.toolbar.openSftp")}
|
||||
className={cn(buttonBase, status !== 'connected' && "opacity-50")}
|
||||
aria-label={status === 'connected' ? t("terminal.toolbar.openSftp") : t("terminal.toolbar.availableAfterConnect")}
|
||||
onClick={onOpenSFTP}
|
||||
disabled={status !== 'connected'}
|
||||
>
|
||||
<FolderInput size={12} />
|
||||
</Button>
|
||||
@@ -79,86 +102,6 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{isSSHSession && onSetTerminalEncoding && (
|
||||
<Popover>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className={buttonBase}
|
||||
aria-label={t("terminal.toolbar.encoding")}
|
||||
>
|
||||
<Languages size={12} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("terminal.toolbar.encoding")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent className="w-36 p-1" align="start">
|
||||
{(["utf-8", "gb18030"] as const).map((enc) => (
|
||||
<PopoverClose asChild key={enc}>
|
||||
<button
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors",
|
||||
terminalEncoding === enc && "font-medium"
|
||||
)}
|
||||
onClick={() => onSetTerminalEncoding(enc)}
|
||||
>
|
||||
<Check
|
||||
size={12}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
terminalEncoding === enc ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{t(`terminal.toolbar.encoding.${enc === "utf-8" ? "utf8" : enc}`)}
|
||||
</button>
|
||||
</PopoverClose>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className={buttonBase}
|
||||
aria-label={t("terminal.toolbar.scripts")}
|
||||
onClick={onOpenScripts}
|
||||
>
|
||||
<Zap size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("terminal.toolbar.scripts")}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className={buttonBase}
|
||||
aria-label={t("terminal.toolbar.terminalSettings")}
|
||||
onClick={onOpenTheme}
|
||||
>
|
||||
<Palette size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("terminal.toolbar.terminalSettings")}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<HostKeywordHighlightPopover
|
||||
host={host}
|
||||
onUpdateHost={onUpdateHost}
|
||||
isOpen={highlightPopoverOpen}
|
||||
setIsOpen={setHighlightPopoverOpen}
|
||||
buttonClassName={buttonBase}
|
||||
/>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -191,6 +134,112 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
<TooltipContent>{t("terminal.toolbar.searchTerminal")}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Overflow menu — keeps lower-frequency opener-style actions
|
||||
(Encoding / Scripts / Terminal Settings) behind a single
|
||||
trigger so the toolbar doesn't feel crowded.
|
||||
Highlight / Compose / Search stay visible because they
|
||||
are toggled mid-session, not just once. */}
|
||||
<Popover
|
||||
open={overflowOpen}
|
||||
onOpenChange={(open) => {
|
||||
setOverflowOpen(open);
|
||||
if (!open) setEncodingSubmenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className={buttonBase}
|
||||
aria-label={t("terminal.toolbar.more")}
|
||||
>
|
||||
<MoreVertical size={14} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("terminal.toolbar.more")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent
|
||||
className="w-48 p-1"
|
||||
align="end"
|
||||
onInteractOutside={(e) => {
|
||||
// Radix treats the submenu's portalled content as
|
||||
// "outside" this popover; without this guard a click
|
||||
// in the submenu would dismiss the parent.
|
||||
const target = e.target as Element | null;
|
||||
if (target?.closest('[data-encoding-submenu="true"]')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverClose asChild>
|
||||
<button type="button" className={menuItemClass} onClick={onOpenScripts}>
|
||||
<Zap size={12} className="shrink-0" />
|
||||
<span className="flex-1 text-left truncate">{t("terminal.toolbar.scripts")}</span>
|
||||
</button>
|
||||
</PopoverClose>
|
||||
<PopoverClose asChild>
|
||||
<button type="button" className={menuItemClass} onClick={onOpenTheme}>
|
||||
<Palette size={12} className="shrink-0" />
|
||||
<span className="flex-1 text-left truncate">{t("terminal.toolbar.terminalSettings")}</span>
|
||||
</button>
|
||||
</PopoverClose>
|
||||
{encodingSwitchSupported && onSetTerminalEncoding && (
|
||||
<Popover open={encodingSubmenuOpen} onOpenChange={setEncodingSubmenuOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={menuItemClass}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={encodingSubmenuOpen}
|
||||
>
|
||||
<Languages size={12} className="shrink-0" />
|
||||
<span className="flex-1 text-left truncate">{t("terminal.toolbar.encoding")}</span>
|
||||
<ChevronRight size={12} className="shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
data-encoding-submenu="true"
|
||||
className="w-40 p-1"
|
||||
side="right"
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
>
|
||||
{(["utf-8", "gb18030"] as const).map((enc) => {
|
||||
const isActive = terminalEncoding === enc;
|
||||
return (
|
||||
<button
|
||||
key={enc}
|
||||
type="button"
|
||||
className={cn(menuItemClass, isActive && "font-medium")}
|
||||
onClick={() => {
|
||||
onSetTerminalEncoding(enc);
|
||||
setEncodingSubmenuOpen(false);
|
||||
setOverflowOpen(false);
|
||||
}}
|
||||
>
|
||||
<Languages size={12} className="shrink-0" />
|
||||
<span className="flex-1 text-left truncate">
|
||||
{t(`terminal.toolbar.encoding.${enc === "utf-8" ? "utf8" : enc}`)}
|
||||
</span>
|
||||
<Check
|
||||
size={12}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
isActive ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{showClose && onClose && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -10,12 +10,56 @@
|
||||
import type { Terminal as XTerm, IDisposable } from "@xterm/xterm";
|
||||
import { getXTermCellDimensions, invalidateCellDimensionCache } from "./xtermUtils";
|
||||
|
||||
/**
|
||||
* Minimal East-Asian-Width-style classifier: returns 2 for wide glyphs
|
||||
* (CJK ideographs, fullwidth forms, most emoji, hangul syllables) and
|
||||
* 1 otherwise. Not full wcwidth — just enough to keep the predicted
|
||||
* ghost column from drifting by one cell per CJK char typed.
|
||||
*/
|
||||
function codePointCellWidth(cp: number): number {
|
||||
if (
|
||||
(cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
|
||||
(cp >= 0x2e80 && cp <= 0x303e) || // CJK Radicals, Kangxi
|
||||
(cp >= 0x3041 && cp <= 0x33ff) || // Hiragana, Katakana, CJK Compat
|
||||
(cp >= 0x3400 && cp <= 0x4dbf) || // CJK Extension A
|
||||
(cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified Ideographs
|
||||
(cp >= 0xa000 && cp <= 0xa4cf) || // Yi
|
||||
(cp >= 0xac00 && cp <= 0xd7a3) || // Hangul Syllables
|
||||
(cp >= 0xf900 && cp <= 0xfaff) || // CJK Compat Ideographs
|
||||
(cp >= 0xfe30 && cp <= 0xfe4f) || // CJK Compat Forms
|
||||
(cp >= 0xff00 && cp <= 0xff60) || // Fullwidth forms
|
||||
(cp >= 0xffe0 && cp <= 0xffe6) || // Fullwidth signs
|
||||
(cp >= 0x1f300 && cp <= 0x1faff) || // Emoji blocks
|
||||
(cp >= 0x20000 && cp <= 0x3fffd) // CJK Extension B-F, G
|
||||
) {
|
||||
return 2;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
function stringCellWidth(s: string): number {
|
||||
let w = 0;
|
||||
for (const ch of s) {
|
||||
const cp = ch.codePointAt(0) ?? 0;
|
||||
w += codePointCellWidth(cp);
|
||||
}
|
||||
return w;
|
||||
}
|
||||
|
||||
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 = "";
|
||||
/** Cursor column captured at show() time — the anchor the ghost was painted from. */
|
||||
private anchorCursorX = 0;
|
||||
/** Cursor row captured at show() time. */
|
||||
private anchorCursorY = 0;
|
||||
/** Length of currentInput at show() time — lets adjustToInput shift left
|
||||
* by (newInput.length - anchorInputLength) cells without having to
|
||||
* re-read xterm's cursorX (which hasn't advanced yet at keystroke time). */
|
||||
private anchorInputLength = 0;
|
||||
private disposed = false;
|
||||
private disposables: IDisposable[] = [];
|
||||
private lastLeft = -1;
|
||||
@@ -37,6 +81,9 @@ export class GhostTextAddon implements IDisposable {
|
||||
height: "100%",
|
||||
pointerEvents: "none",
|
||||
overflow: "hidden",
|
||||
// Sit above xterm's canvas — xterm's default renderer paints its
|
||||
// theme.background across every cell including empty ones, so a
|
||||
// ghost placed beneath the canvas would be completely occluded.
|
||||
zIndex: "1",
|
||||
});
|
||||
|
||||
@@ -63,17 +110,25 @@ export class GhostTextAddon implements IDisposable {
|
||||
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();
|
||||
if (this.isVisible()) {
|
||||
this.updatePosition();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Invalidate cell dimension cache on resize so measurements stay accurate
|
||||
// Invalidate cell dimension cache on resize so measurements stay
|
||||
// accurate, and force a pixel-coord recompute on the next render —
|
||||
// otherwise the lastLeft/lastTop short-circuit in updatePosition
|
||||
// would keep the ghost at stale pixel coordinates until the user
|
||||
// typed again.
|
||||
this.disposables.push(
|
||||
term.onResize(() => {
|
||||
invalidateCellDimensionCache();
|
||||
this.lastLeft = -1;
|
||||
this.lastTop = -1;
|
||||
if (this.isVisible()) this.updatePosition();
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -97,6 +152,12 @@ export class GhostTextAddon implements IDisposable {
|
||||
|
||||
this.currentSuggestion = fullSuggestion;
|
||||
this.currentInput = currentInput;
|
||||
this.anchorCursorX = this.term.buffer.active.cursorX;
|
||||
this.anchorCursorY = this.term.buffer.active.cursorY;
|
||||
this.anchorInputLength = currentInput.length;
|
||||
// Force position recalc since the text also changed.
|
||||
this.lastLeft = -1;
|
||||
this.lastTop = -1;
|
||||
|
||||
this.updatePosition();
|
||||
this.ghostElement.textContent = ghostText;
|
||||
@@ -113,6 +174,43 @@ export class GhostTextAddon implements IDisposable {
|
||||
}
|
||||
this.currentSuggestion = "";
|
||||
this.currentInput = "";
|
||||
this.anchorInputLength = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-align the ghost against a freshly-updated user input synchronously.
|
||||
* Called from handleInput on every keystroke that mutates the typed
|
||||
* buffer so ghost text never falls out of sync with what the user has
|
||||
* actually typed.
|
||||
*
|
||||
* Implementation relies on the predict-anchor-shift trick rather than
|
||||
* re-reading xterm's live cursorX: xterm hasn't echoed the triggering
|
||||
* keystroke yet at this point, so cursorX still points at the
|
||||
* pre-keystroke column. Instead we track the cursor column captured
|
||||
* at show() time and advance the ghost's left by the number of chars
|
||||
* typed since — so the tail aligns with where the real cursor *will*
|
||||
* land once the echo arrives, even across SSH round-trip latency.
|
||||
*/
|
||||
adjustToInput(newInput: string): void {
|
||||
if (this.disposed || !this.ghostElement || !this.currentSuggestion) return;
|
||||
if (!this.currentSuggestion.startsWith(newInput)) {
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
this.currentInput = newInput;
|
||||
const ghostText = this.currentSuggestion.substring(newInput.length);
|
||||
if (!ghostText) {
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
// Force position recomputation — updatePosition skips DOM writes
|
||||
// when the left/top cache hasn't changed, but we also need the new
|
||||
// textContent to flush.
|
||||
this.lastLeft = -1;
|
||||
this.lastTop = -1;
|
||||
this.ghostElement.textContent = ghostText;
|
||||
this.updatePosition();
|
||||
this.ghostElement.style.display = "block";
|
||||
}
|
||||
|
||||
getSuggestion(): string {
|
||||
@@ -124,6 +222,17 @@ export class GhostTextAddon implements IDisposable {
|
||||
this.currentSuggestion);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the ghost has a live suggestion even if it's momentarily
|
||||
* shown underneath the real text while the user keeps typing within
|
||||
* the prediction. Accept-path gates should use this instead of
|
||||
* isVisible() so the suggestion remains available even while its
|
||||
* leading characters are fully covered by real glyphs.
|
||||
*/
|
||||
isActive(): boolean {
|
||||
return !this.disposed && !!this.currentSuggestion;
|
||||
}
|
||||
|
||||
getGhostText(): string {
|
||||
if (!this.currentSuggestion || !this.currentInput) return "";
|
||||
return this.currentSuggestion.startsWith(this.currentInput)
|
||||
@@ -151,11 +260,47 @@ export class GhostTextAddon implements IDisposable {
|
||||
private updatePosition(): void {
|
||||
if (!this.term || !this.ghostElement) return;
|
||||
|
||||
// Self-heal a stale anchor: when show() fires during the SSH
|
||||
// keystroke→echo gap, cursorX captured there is still the
|
||||
// pre-echo column. While no adjustToInput has moved us from the
|
||||
// show-time baseline, re-read live cursor on each render tick so
|
||||
// the anchor snaps to the echoed position once it arrives.
|
||||
if (this.currentInput.length === this.anchorInputLength) {
|
||||
this.anchorCursorX = this.term.buffer.active.cursorX;
|
||||
this.anchorCursorY = this.term.buffer.active.cursorY;
|
||||
}
|
||||
|
||||
const dims = getXTermCellDimensions(this.term);
|
||||
|
||||
const buffer = this.term.buffer.active;
|
||||
const left = buffer.cursorX * dims.width;
|
||||
const top = buffer.cursorY * dims.height;
|
||||
// Advance (or walk back) the anchor column by the cell width of
|
||||
// whatever the user has typed since show() was called. Using cell
|
||||
// width (not code-unit length) lets CJK / emoji / fullwidth glyphs
|
||||
// advance by 2 cells instead of 1. Backspace / Ctrl-W produces a
|
||||
// negative delta by shrinking currentInput below anchorInputLength.
|
||||
const cellDelta = this.currentInput.length >= this.anchorInputLength
|
||||
? stringCellWidth(this.currentInput.slice(this.anchorInputLength))
|
||||
: -stringCellWidth(
|
||||
// currentSuggestion[0..anchorInputLength] equals what was typed
|
||||
// when show() fired (prefix-match invariant), so its slice gives
|
||||
// the correct cell widths for the deleted glyphs.
|
||||
this.currentSuggestion.slice(this.currentInput.length, this.anchorInputLength),
|
||||
);
|
||||
const cols = Math.max(1, this.term.cols);
|
||||
const targetCol = this.anchorCursorX + cellDelta;
|
||||
// Wrap the predicted cursor position across line boundaries in both
|
||||
// directions — the real xterm cursor wraps to the next row once it
|
||||
// crosses cols forward, and to the previous row when a deletion
|
||||
// crosses back past column 0. JS `%` returns negative for negative
|
||||
// dividends, so normalize both col and rowOffset explicitly.
|
||||
let col = targetCol % cols;
|
||||
let rowOffset = Math.floor(targetCol / cols);
|
||||
if (col < 0) {
|
||||
col += cols;
|
||||
}
|
||||
// Clamp to the visible top row so a runaway negative delta (e.g.
|
||||
// deleted past the prompt) doesn't render above the terminal.
|
||||
const top = Math.max(0, this.anchorCursorY + rowOffset) * dims.height;
|
||||
const left = col * dims.width;
|
||||
|
||||
// Skip DOM writes if position hasn't changed (avoids unnecessary style recalc)
|
||||
if (left === this.lastLeft && top === this.lastTop) return;
|
||||
|
||||
@@ -66,6 +66,14 @@ export interface CompletionContext {
|
||||
isOptionArg: boolean;
|
||||
}
|
||||
|
||||
export function shellEscape(name: string): string {
|
||||
if (!name) return name;
|
||||
if (/[\\$'"|!<>;#~` ]/.test(name)) {
|
||||
return `'${name.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a command line string into tokens, handling quoting.
|
||||
*/
|
||||
@@ -241,9 +249,9 @@ export async function getCompletions(
|
||||
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(" ")
|
||||
const insertName = isQuotedPath || !/[\\$'"|!<>;#~` ]/.test(entry.name)
|
||||
? entry.name
|
||||
: entry.name.replace(/ /g, "\\ ");
|
||||
: shellEscape(entry.name);
|
||||
const suffix = entry.type === "directory" ? "/" : "";
|
||||
const fullPath = pathPrefix + insertName + suffix + quoteSuffix;
|
||||
const suggestion = {
|
||||
|
||||
24
components/terminal/autocomplete/ghostSuggestionPolicy.ts
Normal file
24
components/terminal/autocomplete/ghostSuggestionPolicy.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export type GhostSuggestionDecision =
|
||||
| { type: "keep" }
|
||||
| { type: "show"; suggestion: string }
|
||||
| { type: "hide" };
|
||||
|
||||
/**
|
||||
* Prefer a stable ghost suggestion while the user's typed input still
|
||||
* falls within the currently shown prediction. This avoids a "jitter"
|
||||
* effect where freshly fetched suggestions keep replacing the same
|
||||
* visual prediction one character at a time.
|
||||
*/
|
||||
export function decideGhostSuggestion(
|
||||
activeSuggestion: string | null,
|
||||
input: string,
|
||||
nextSuggestion: string | null,
|
||||
): GhostSuggestionDecision {
|
||||
if (activeSuggestion && activeSuggestion.startsWith(input)) {
|
||||
return { type: "keep" };
|
||||
}
|
||||
if (nextSuggestion && nextSuggestion.startsWith(input)) {
|
||||
return { type: "show", suggestion: nextSuggestion };
|
||||
}
|
||||
return { type: "hide" };
|
||||
}
|
||||
@@ -3,3 +3,4 @@ export type { AutocompleteSettings, AutocompleteState, TerminalAutocompleteHandl
|
||||
export { default as AutocompletePopup } from "./AutocompletePopup";
|
||||
export type { CompletionSuggestion, SuggestionSource } from "./completionEngine";
|
||||
export { recordCommand, clearHistory, deleteHistoryEntry, getHistoryCount } from "./commandHistoryStore";
|
||||
export { shellEscape } from "./completionEngine";
|
||||
|
||||
@@ -38,6 +38,30 @@ const NO_PROMPT: PromptDetectionResult = {
|
||||
isAtPrompt: false, promptText: "", userInput: "", cursorOffset: 0,
|
||||
};
|
||||
|
||||
export interface AlignedPromptResult {
|
||||
/** The prompt view every consumer should use for parsing / suggestion lookup / line rewrites. */
|
||||
prompt: PromptDetectionResult;
|
||||
/**
|
||||
* The keystroke buffer, but only when it's both marked reliable AND
|
||||
* actually matches the tail of the raw detected userInput. Returns
|
||||
* null otherwise — the single signal downstream uses to decide
|
||||
* whether to record it as the executed command.
|
||||
*/
|
||||
alignedTyped: string | null;
|
||||
}
|
||||
|
||||
function replacePromptUserInput(
|
||||
prompt: PromptDetectionResult,
|
||||
userInput: string,
|
||||
): PromptDetectionResult {
|
||||
return {
|
||||
isAtPrompt: true,
|
||||
promptText: prompt.promptText,
|
||||
userInput,
|
||||
cursorOffset: userInput.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether the terminal cursor is at a shell prompt and extract the current user input.
|
||||
*/
|
||||
@@ -205,6 +229,92 @@ function findPromptBoundary(lineText: string): number {
|
||||
return lastBoundary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile a buffer-parsed prompt with the user's own keystroke history.
|
||||
*
|
||||
* findPromptBoundary stops at the first `PROMPT_CHAR + space` it sees, so
|
||||
* themes that render additional content after the prompt char — e.g.
|
||||
* oh-my-zsh's robbyrussell prints "➜ ~ " where `~` is the cwd — get
|
||||
* parsed as prompt="➜ " + userInput="~ lo". Every consumer downstream
|
||||
* (history recording, suggestion matching, insertion) then treats the
|
||||
* theme's cwd marker as part of the user's command, which pollutes
|
||||
* history with entries like "~ sudo id" and makes Tab insertions prepend
|
||||
* a phantom "~ " to the typed command (issue #806).
|
||||
*
|
||||
* Whenever we have an independent record of what the user actually typed
|
||||
* since the last Enter (keystroke buffer), we can detect this case: the
|
||||
* real input is always a suffix of the over-captured userInput. When it
|
||||
* is, reattribute the leading garbage back to promptText so the rest of
|
||||
* the pipeline sees the clean split.
|
||||
*/
|
||||
export function reconcilePromptWithTypedInput(
|
||||
prompt: PromptDetectionResult,
|
||||
typedInput: string,
|
||||
): PromptDetectionResult {
|
||||
if (!prompt.isAtPrompt) return prompt;
|
||||
if (!typedInput) return prompt;
|
||||
if (prompt.userInput === typedInput) return prompt;
|
||||
if (
|
||||
prompt.userInput.length > typedInput.length &&
|
||||
prompt.userInput.endsWith(typedInput)
|
||||
) {
|
||||
const extra = prompt.userInput.slice(0, prompt.userInput.length - typedInput.length);
|
||||
return {
|
||||
isAtPrompt: true,
|
||||
promptText: prompt.promptText + extra,
|
||||
userInput: typedInput,
|
||||
cursorOffset: typedInput.length,
|
||||
};
|
||||
}
|
||||
return prompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified entry point for any autocomplete code path that needs a prompt
|
||||
* view. Every consumer (fetchSuggestions, insertSuggestion,
|
||||
* handleSubDirSelect, Enter-record) goes through this one helper so the
|
||||
* alignment policy lives in exactly one place — if another out-of-band
|
||||
* line-rewrite path gets added later and forgets to notify the keystroke
|
||||
* buffer, the worst that happens is reconcile no-ops and we degrade to
|
||||
* pre-#806 behavior, not a worse pollution.
|
||||
*
|
||||
* Alignment rule: the keystroke buffer is usable only when it's marked
|
||||
* reliable AND the raw detected prompt still looks like the same shell
|
||||
* line. When the raw buffer has either over-captured prompt chrome
|
||||
* (`raw.userInput.endsWith(typedBuffer)`) or under-captured because the
|
||||
* shell echo/render is lagging behind local keystrokes
|
||||
* (`typedBuffer.startsWith(raw.userInput)`), prefer the typed buffer.
|
||||
* Otherwise the buffer is ignored and the raw detector result passes
|
||||
* through.
|
||||
*/
|
||||
export function getAlignedPrompt(
|
||||
term: XTerm | null,
|
||||
typedBuffer: string,
|
||||
typedReliable: boolean,
|
||||
): AlignedPromptResult {
|
||||
if (!term) return { prompt: NO_PROMPT, alignedTyped: null };
|
||||
const raw = detectPrompt(term);
|
||||
if (!typedReliable || typedBuffer.length === 0 || !raw.isAtPrompt) {
|
||||
return { prompt: raw, alignedTyped: null };
|
||||
}
|
||||
if (raw.userInput === typedBuffer) {
|
||||
return { prompt: raw, alignedTyped: typedBuffer };
|
||||
}
|
||||
if (raw.userInput.length > typedBuffer.length && raw.userInput.endsWith(typedBuffer)) {
|
||||
return {
|
||||
prompt: reconcilePromptWithTypedInput(raw, typedBuffer),
|
||||
alignedTyped: typedBuffer,
|
||||
};
|
||||
}
|
||||
if (typedBuffer.length > raw.userInput.length && typedBuffer.startsWith(raw.userInput)) {
|
||||
return {
|
||||
prompt: replacePromptUserInput(raw, typedBuffer),
|
||||
alignedTyped: typedBuffer,
|
||||
};
|
||||
}
|
||||
return { prompt: raw, alignedTyped: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplified prompt detection: just check if we're likely at a prompt.
|
||||
*/
|
||||
|
||||
@@ -11,12 +11,14 @@
|
||||
import { startTransition, useCallback, useEffect, useRef, useState, type RefObject } from "react";
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import { GhostTextAddon } from "./GhostTextAddon";
|
||||
import { detectPrompt, type PromptDetectionResult } from "./promptDetector";
|
||||
import { getAlignedPrompt, type PromptDetectionResult } from "./promptDetector";
|
||||
import { getCompletions, parseCommandLine, type CompletionSuggestion } from "./completionEngine";
|
||||
import { recordCommand } from "./commandHistoryStore";
|
||||
import { shellEscape } from "./completionEngine";
|
||||
import { preloadCommonSpecs } from "./figSpecLoader";
|
||||
import { getXTermCellDimensions } from "./xtermUtils";
|
||||
import { listDirectoryEntries, normalizePathTokenForLookup } from "./remotePathCompleter";
|
||||
import { decideGhostSuggestion } from "./ghostSuggestionPolicy";
|
||||
|
||||
export interface AutocompleteSettings {
|
||||
enabled: boolean;
|
||||
@@ -64,6 +66,8 @@ export interface SubDirPanel {
|
||||
dirPath: string;
|
||||
}
|
||||
|
||||
|
||||
|
||||
export interface AutocompleteState {
|
||||
suggestions: CompletionSuggestion[];
|
||||
selectedIndex: number;
|
||||
@@ -111,6 +115,13 @@ export function useTerminalAutocomplete(
|
||||
...DEFAULT_AUTOCOMPLETE_SETTINGS,
|
||||
...userSettings,
|
||||
};
|
||||
// Mutual-exclusivity guard matching the repo-wide contract:
|
||||
// - SettingsTerminalTab toggles one off when the other is enabled.
|
||||
// - domain/models.ts normalizes stored settings so popup wins.
|
||||
// Keep the guard here too so callers that pass DEFAULT_AUTOCOMPLETE_SETTINGS
|
||||
// directly (e.g. tests or future embedders) don't end up rendering both
|
||||
// systems at once. In the normal Terminal.tsx → store path only one of
|
||||
// the two arrives as true, so this is defensive, not load-bearing.
|
||||
const settings: AutocompleteSettings = {
|
||||
...rawSettings,
|
||||
showGhostText: rawSettings.showPopupMenu ? false : rawSettings.showGhostText,
|
||||
@@ -149,6 +160,31 @@ export function useTerminalAutocomplete(
|
||||
const lastAcceptedCommandRef = useRef<string | null>(null);
|
||||
/** Monotonic counter to invalidate stale async sub-dir fetches */
|
||||
const subDirFetchVersionRef = useRef(0);
|
||||
/**
|
||||
* Keystroke buffer mirroring what the user has typed since the last
|
||||
* prompt boundary (Enter / Ctrl-C / Ctrl-U / cursor movement).
|
||||
*
|
||||
* detectPrompt parses the xterm buffer and can misattribute theme
|
||||
* content — e.g. oh-my-zsh robbyrussell's "➜ ~ " — as user input.
|
||||
* Keeping an independent keystroke log lets getAlignedPrompt snap the
|
||||
* detected userInput back to what was actually typed (and only when
|
||||
* the buffer matches the live line's tail), which in turn keeps
|
||||
* history recording and Tab insertion honest (#806).
|
||||
*/
|
||||
const typedInputBufferRef = useRef<string>("");
|
||||
/**
|
||||
* Whether typedInputBufferRef can be trusted as the full tail of the
|
||||
* current command line. Cleared after any event this append-only buffer
|
||||
* can't follow (history recall via ↑/Ctrl-P, cursor moves, reverse
|
||||
* search, etc.). Reset to true on clean line boundaries — Enter,
|
||||
* Ctrl-C, Ctrl-U — and after we explicitly re-align via
|
||||
* insertSuggestion or a ghost-text accept.
|
||||
*
|
||||
* Without this flag, an Up-arrow-recall workflow would leave the buffer
|
||||
* holding only the post-navigation suffix, and Enter would record that
|
||||
* suffix as a command (pollutes history, misleads future completions).
|
||||
*/
|
||||
const typedBufferReliableRef = useRef<boolean>(true);
|
||||
|
||||
// Preload common specs on first mount (only if enabled)
|
||||
useEffect(() => {
|
||||
@@ -203,6 +239,17 @@ export function useTerminalAutocomplete(
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessionId, settings.enabled]);
|
||||
|
||||
// Hide any active ghost when the user turns showGhostText off mid-
|
||||
// session. The fetchSuggestions branch (~L531) already gates new
|
||||
// shows on the flag, but a ghost that was already on screen at toggle
|
||||
// time would otherwise keep sliding around under a disabled setting
|
||||
// until something unrelated called clearState (Codex #815 P2).
|
||||
useEffect(() => {
|
||||
if (!settings.showGhostText) {
|
||||
ghostAddonRef.current?.hide();
|
||||
}
|
||||
}, [settings.showGhostText]);
|
||||
|
||||
/**
|
||||
* Write accepted text to the terminal via callback (no CustomEvent).
|
||||
*/
|
||||
@@ -246,8 +293,12 @@ export function useTerminalAutocomplete(
|
||||
return;
|
||||
}
|
||||
const term = termRef.current;
|
||||
const livePrompt = term ? detectPrompt(term) : null;
|
||||
const activePrompt = livePrompt?.isAtPrompt ? livePrompt : lastPromptRef.current;
|
||||
const { prompt: livePrompt } = getAlignedPrompt(
|
||||
term,
|
||||
typedInputBufferRef.current,
|
||||
typedBufferReliableRef.current,
|
||||
);
|
||||
const activePrompt = livePrompt.isAtPrompt ? livePrompt : lastPromptRef.current;
|
||||
const activeWord = activePrompt?.isAtPrompt
|
||||
? parseCommandLine(activePrompt.userInput).currentWord
|
||||
: parseCommandLine(item.text).currentWord;
|
||||
@@ -396,8 +447,10 @@ export function useTerminalAutocomplete(
|
||||
const panel = s.subDirPanels[level];
|
||||
if (!panel) return;
|
||||
|
||||
// Get current prompt to know what command prefix to keep (e.g., "cd ")
|
||||
const prompt = detectPrompt(term);
|
||||
// Get current prompt to know what command prefix to keep (e.g., "cd ").
|
||||
// getAlignedPrompt handles robbyrussell-style themes by trimming the
|
||||
// cwd marker out of userInput when the typed buffer is aligned (#806).
|
||||
const { prompt } = getAlignedPrompt(term, typedInputBufferRef.current, typedBufferReliableRef.current);
|
||||
if (!prompt.isAtPrompt) return;
|
||||
|
||||
// Find the command part (everything before the path argument)
|
||||
@@ -412,9 +465,9 @@ export function useTerminalAutocomplete(
|
||||
: "";
|
||||
const quoteSuffix = quotePrefix && currentToken.endsWith(quotePrefix) ? quotePrefix : "";
|
||||
const suffix = entry.type === "directory" ? "/" : "";
|
||||
const entryName = quotePrefix || !entry.name.includes(" ")
|
||||
const entryName = quotePrefix || !/[\\$'"|!<>;#~` ]/.test(entry.name)
|
||||
? entry.name
|
||||
: entry.name.replace(/ /g, "\\ ");
|
||||
: shellEscape(entry.name);
|
||||
const fullPath = panel.dirPath + entryName + suffix;
|
||||
const replacementPath = `${quotePrefix}${fullPath}${quoteSuffix}`;
|
||||
|
||||
@@ -423,7 +476,13 @@ export function useTerminalAutocomplete(
|
||||
const clearSeq = isWindows
|
||||
? "\b".repeat(prompt.userInput.length)
|
||||
: "\x15";
|
||||
writeToTerminal(clearSeq + cmdPrefix + replacementPath);
|
||||
const newCommand = cmdPrefix + replacementPath;
|
||||
writeToTerminal(clearSeq + newCommand);
|
||||
// Sub-dir selection rewrote the whole command line; re-align the
|
||||
// keystroke buffer so the next Enter records the executed command
|
||||
// instead of whatever partial input we had before (P2 from #814).
|
||||
typedInputBufferRef.current = newCommand;
|
||||
typedBufferReliableRef.current = true;
|
||||
clearState();
|
||||
|
||||
if (entry.type === "directory") {
|
||||
@@ -444,7 +503,7 @@ export function useTerminalAutocomplete(
|
||||
// Capture version at start — if it changes during async work, discard results
|
||||
const version = ++fetchVersionRef.current;
|
||||
|
||||
const prompt = detectPrompt(term);
|
||||
const { prompt } = getAlignedPrompt(term, typedInputBufferRef.current, typedBufferReliableRef.current);
|
||||
lastPromptRef.current = prompt;
|
||||
|
||||
if (!prompt.isAtPrompt || prompt.userInput.length < settingsRef.current.minChars) {
|
||||
@@ -485,16 +544,24 @@ export function useTerminalAutocomplete(
|
||||
|
||||
// Discard stale results: if the user kept typing while getCompletions was running,
|
||||
// the current prompt input will have changed. Re-detect and compare.
|
||||
const currentPrompt = detectPrompt(term);
|
||||
const { prompt: currentPrompt } = getAlignedPrompt(term, typedInputBufferRef.current, typedBufferReliableRef.current);
|
||||
if (!currentPrompt.isAtPrompt || currentPrompt.userInput !== input) {
|
||||
return; // Input changed — these completions are stale
|
||||
}
|
||||
|
||||
// Ghost text: use the best suggestion
|
||||
if (settingsRef.current.showGhostText && completions.length > 0) {
|
||||
ghostAddonRef.current?.show(completions[0].text, input);
|
||||
} else {
|
||||
ghostAddonRef.current?.hide();
|
||||
// Ghost text: keep the active prediction stable while the user's
|
||||
// input still fits within it. Only swap to a fresh prediction once
|
||||
// the current one no longer matches the typed prefix.
|
||||
if (settingsRef.current.showGhostText) {
|
||||
const ghost = ghostAddonRef.current;
|
||||
const activeSuggestion = ghost?.isActive() ? ghost.getSuggestion() : null;
|
||||
const nextSuggestion = completions.length > 0 ? completions[0].text : null;
|
||||
const ghostDecision = decideGhostSuggestion(activeSuggestion, input, nextSuggestion);
|
||||
if (ghostDecision.type === "show") {
|
||||
ghost?.show(ghostDecision.suggestion, input);
|
||||
} else if (ghostDecision.type === "hide") {
|
||||
ghost?.hide();
|
||||
}
|
||||
}
|
||||
|
||||
// Popup
|
||||
@@ -568,23 +635,136 @@ export function useTerminalAutocomplete(
|
||||
if (lastAcceptedCommandRef.current) {
|
||||
recordCommand(lastAcceptedCommandRef.current, hostIdRef.current, hostOsRef.current);
|
||||
} else {
|
||||
// Try real-time detection; fall back to cached prompt
|
||||
const livePrompt = termRef.current ? detectPrompt(termRef.current) : null;
|
||||
const prompt = (livePrompt?.isAtPrompt && livePrompt.userInput.trim())
|
||||
? livePrompt
|
||||
: lastPromptRef.current;
|
||||
if (prompt?.isAtPrompt && prompt.userInput.trim()) {
|
||||
recordCommand(prompt.userInput.trim(), hostIdRef.current, hostOsRef.current);
|
||||
// Require a live prompt before trusting either keystroke buffer
|
||||
// or buffer-based detection — otherwise sudo password Enter
|
||||
// would record the typed password as a command.
|
||||
const { prompt: livePrompt, alignedTyped } = getAlignedPrompt(
|
||||
termRef.current,
|
||||
typedInputBufferRef.current,
|
||||
typedBufferReliableRef.current,
|
||||
);
|
||||
if (livePrompt.isAtPrompt) {
|
||||
// alignedTyped is only non-null when the buffer is reliable
|
||||
// AND matches the live line's tail — that single signal
|
||||
// covers both the robbyrussell "~ " case (#806) and the
|
||||
// stale-buffer cases from out-of-band pastes / history
|
||||
// recall (#814 P1/P2). When it's null we fall back to the
|
||||
// reconciled livePrompt.userInput, which for paste-bypass
|
||||
// scenarios lands on pre-PR behavior (no regression).
|
||||
if (alignedTyped && alignedTyped.trim()) {
|
||||
recordCommand(alignedTyped.trim(), hostIdRef.current, hostOsRef.current);
|
||||
} else if (livePrompt.userInput.trim()) {
|
||||
recordCommand(livePrompt.userInput.trim(), hostIdRef.current, hostOsRef.current);
|
||||
}
|
||||
} else if (lastPromptRef.current?.isAtPrompt && lastPromptRef.current.userInput.trim()) {
|
||||
// Only fall back to the cached prompt when we have no live
|
||||
// reading at all — guards against recording during interactive
|
||||
// prompts where detectPrompt correctly bails out.
|
||||
recordCommand(lastPromptRef.current.userInput.trim(), hostIdRef.current, hostOsRef.current);
|
||||
}
|
||||
}
|
||||
lastAcceptedCommandRef.current = null;
|
||||
}
|
||||
typedInputBufferRef.current = "";
|
||||
typedBufferReliableRef.current = true;
|
||||
clearState();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+C, Ctrl+U — clear
|
||||
// Ctrl+C, Ctrl+U — clear. These kill the zle line entirely, so the
|
||||
// buffer is once again a true reflection of the (empty) line.
|
||||
if (data === "\x03" || data === "\x15") {
|
||||
typedInputBufferRef.current = "";
|
||||
typedBufferReliableRef.current = true;
|
||||
// Same rationale as the ctrl/escape early returns below: any
|
||||
// previously-accepted suggestion is gone from the line too, so
|
||||
// accept → Ctrl-C → type "foo" → Enter must not log the stale
|
||||
// accepted command via the Enter fast path.
|
||||
lastAcceptedCommandRef.current = null;
|
||||
clearState();
|
||||
return;
|
||||
}
|
||||
|
||||
// Backspace / DEL: drop the last typed char so the buffer stays aligned
|
||||
// with what the shell actually holds.
|
||||
if (data === "\x7f" || data === "\b") {
|
||||
typedInputBufferRef.current = typedInputBufferRef.current.slice(0, -1);
|
||||
} else if (data === "\x17") {
|
||||
// Ctrl+W: word-erase — kill the trailing whitespace + word.
|
||||
typedInputBufferRef.current = typedInputBufferRef.current.replace(/\s*\S+\s*$/, "");
|
||||
} else if (data.startsWith("\x1b[200~")) {
|
||||
// Bracketed paste: "\x1b[200~...\x1b[201~". The inner bytes are
|
||||
// literal input, so newlines stay on the zle line instead of
|
||||
// executing each segment — meaning we must preserve the whole
|
||||
// content in the buffer, not just the post-final-newline tail
|
||||
// (Codex #814 P2).
|
||||
//
|
||||
// Reliability is *inherited*, not reset: if the buffer was
|
||||
// already aligned with the line (reliable=true), appending this
|
||||
// paste keeps it aligned; if the buffer was unreliable (e.g.
|
||||
// after ↑ recalled a history command so line ≠ buffer), the
|
||||
// paste only extends the tail but the head is still whatever
|
||||
// the shell had, so the buffer stays unreliable. Without this,
|
||||
// a paste-after-recall flow would flip reliability back on and
|
||||
// Enter would record just the pasted suffix as the command
|
||||
// (Codex #814 P1 follow-up).
|
||||
const endIdx = data.indexOf("\x1b[201~");
|
||||
const content = endIdx >= 0
|
||||
? data.slice("\x1b[200~".length, endIdx)
|
||||
: data.slice("\x1b[200~".length);
|
||||
typedInputBufferRef.current += content;
|
||||
// Paste extends the line past whatever was accepted, so the
|
||||
// Enter fast-path must not record the pre-paste accepted
|
||||
// command — mirrors the non-bracketed paste branch below.
|
||||
lastAcceptedCommandRef.current = null;
|
||||
clearState();
|
||||
return;
|
||||
} else if (data.startsWith("\x1b") && data !== "\x1b") {
|
||||
// Cursor-movement / function keys — we lose track of where the
|
||||
// cursor sits relative to our append-only buffer. Mark the
|
||||
// buffer unreliable and drop it; detectPrompt takes over until
|
||||
// the next Enter / Ctrl-C / Ctrl-U.
|
||||
typedInputBufferRef.current = "";
|
||||
typedBufferReliableRef.current = false;
|
||||
} else if (data.length === 1 && data.charCodeAt(0) >= 32) {
|
||||
typedInputBufferRef.current += data;
|
||||
} else if (data.length > 1 && !data.startsWith("\x1b")) {
|
||||
// Paste chunk. Any \r / \n inside executes the preceding text as
|
||||
// a command in the shell, so keeping the pre-newline portion in
|
||||
// our buffer would leave stale content that a later Enter could
|
||||
// record (Codex #814 P2). Drop everything up to and including
|
||||
// the last terminator and keep only the tail as new content.
|
||||
// Intermediate executed lines aren't synthesized back into
|
||||
// recordCommand here — the onCommandExecuted path in
|
||||
// createXTermRuntime still captures them independently.
|
||||
const lastCR = data.lastIndexOf("\r");
|
||||
const lastLF = data.lastIndexOf("\n");
|
||||
const nlIdx = Math.max(lastCR, lastLF);
|
||||
if (nlIdx >= 0) {
|
||||
typedInputBufferRef.current = data.slice(nlIdx + 1);
|
||||
typedBufferReliableRef.current = true;
|
||||
// The embedded newline flushed any previously-accepted
|
||||
// suggestion too — clearing the cache here prevents the next
|
||||
// Enter from falling into the lastAcceptedCommandRef fast path
|
||||
// and recording that stale command.
|
||||
lastAcceptedCommandRef.current = null;
|
||||
clearState();
|
||||
return;
|
||||
}
|
||||
typedInputBufferRef.current += data;
|
||||
} else if (data.length === 1 && data.charCodeAt(0) < 32) {
|
||||
// Any other single control char (Ctrl-A, Ctrl-E, Ctrl-B, Ctrl-F,
|
||||
// Ctrl-R, Ctrl-P, Ctrl-N, ...) moves the cursor or swaps the
|
||||
// line in ways this append-only buffer can't follow. Same story
|
||||
// as escape sequences above — and hide the ghost too, so the
|
||||
// unreliable-accept fallback doesn't pull a stale tail onto a
|
||||
// recalled line (Codex #815 follow-up).
|
||||
typedInputBufferRef.current = "";
|
||||
typedBufferReliableRef.current = false;
|
||||
// Null the fast-path accepted-command cache: accept-then-Ctrl-R
|
||||
// should not let an old accepted command sneak back in via the
|
||||
// Enter fast path after reverse-search picks a different one.
|
||||
lastAcceptedCommandRef.current = null;
|
||||
clearState();
|
||||
return;
|
||||
}
|
||||
@@ -593,6 +773,10 @@ export function useTerminalAutocomplete(
|
||||
// since cursor position may have changed, making current suggestions invalid.
|
||||
// Up/Down/Right/Tab are handled by handleKeyEvent; other sequences land here.
|
||||
if (data.startsWith("\x1b") && data !== "\x1b") {
|
||||
// Same fast-path reset as the single-byte ctrl-char branch above —
|
||||
// accept-then-↑/↓ must not record the stale accepted command if
|
||||
// the user then presses Enter on a different recalled line.
|
||||
lastAcceptedCommandRef.current = null;
|
||||
clearState();
|
||||
return;
|
||||
}
|
||||
@@ -601,6 +785,20 @@ export function useTerminalAutocomplete(
|
||||
// command is being edited further (e.g., accepted "git status" then added " --short")
|
||||
lastAcceptedCommandRef.current = null;
|
||||
|
||||
// Re-align any visible ghost text to the freshly-updated buffer
|
||||
// immediately. Without this the ghost keeps the tail it captured at
|
||||
// show() time; a fast "type + press →" sequence then pastes the
|
||||
// pre-update tail on top of the new input ("doc" + "cker ls" →
|
||||
// "doccker ls"). Only safe to call when the buffer is reliable —
|
||||
// otherwise its content doesn't correspond to the live line and
|
||||
// adjustToInput would make the ghost lie. Also skip when the user
|
||||
// has turned showGhostText off mid-session: otherwise a ghost that
|
||||
// was active before the toggle would keep moving around under a
|
||||
// setting the user just said to disable (Codex #815 P2).
|
||||
if (typedBufferReliableRef.current && settingsRef.current.showGhostText) {
|
||||
ghostAddonRef.current?.adjustToInput(typedInputBufferRef.current);
|
||||
}
|
||||
|
||||
// Fast typing suppression: if typing faster than threshold, skip this debounce cycle
|
||||
const isFastTyping = timeSinceLastKeystroke < settingsRef.current.fastTypingThresholdMs;
|
||||
|
||||
@@ -654,15 +852,46 @@ export function useTerminalAutocomplete(
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Otherwise: accept ghost text
|
||||
if (ghost?.isVisible()) {
|
||||
// Otherwise: accept ghost text. Use isActive(), not isVisible(),
|
||||
// so a fast "type + →" that lands in the hide-until-render gap
|
||||
// still hits this branch and accepts the pending ghost.
|
||||
if (ghost?.isActive()) {
|
||||
e.preventDefault();
|
||||
const ghostText = ghost.getGhostText();
|
||||
const fullSuggestion = ghost.getSuggestion();
|
||||
// When the keystroke buffer is reliable, recompute the tail
|
||||
// against the *live* buffer so a fast "type + →" in the
|
||||
// hide-until-render gap still writes the correct tail. When
|
||||
// it's not reliable (post history-recall / Ctrl-R), we can't
|
||||
// treat empty buffer as "nothing typed" — the line actually
|
||||
// has content we're not tracking — so fall back to the
|
||||
// ghost's own cached tail instead of writing the entire
|
||||
// suggestion onto an already-populated line.
|
||||
let ghostText: string;
|
||||
let newBuffer: string | null;
|
||||
if (typedBufferReliableRef.current) {
|
||||
const live = typedInputBufferRef.current;
|
||||
if (fullSuggestion && fullSuggestion.startsWith(live)) {
|
||||
ghostText = fullSuggestion.substring(live.length);
|
||||
newBuffer = fullSuggestion;
|
||||
} else {
|
||||
ghostText = "";
|
||||
newBuffer = null;
|
||||
}
|
||||
} else {
|
||||
ghostText = ghost.getGhostText();
|
||||
newBuffer = null; // buffer is unreliable; don't flip it back on
|
||||
}
|
||||
if (ghostText) {
|
||||
writeToTerminal(ghostText);
|
||||
lastAcceptedCommandRef.current = ghost.getSuggestion();
|
||||
lastAcceptedCommandRef.current = fullSuggestion;
|
||||
if (newBuffer !== null) {
|
||||
typedInputBufferRef.current = newBuffer;
|
||||
typedBufferReliableRef.current = true;
|
||||
}
|
||||
ghost.hide();
|
||||
clearState();
|
||||
} else {
|
||||
ghost.hide();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -670,18 +899,45 @@ export function useTerminalAutocomplete(
|
||||
|
||||
// Ctrl+Right / Alt+Right (Mac): accept next word
|
||||
if (e.key === "ArrowRight" && (e.ctrlKey || e.altKey) && !e.metaKey && !e.shiftKey) {
|
||||
if (ghost?.isVisible()) {
|
||||
if (ghost?.isActive()) {
|
||||
e.preventDefault();
|
||||
const fullSuggestion = ghost.getSuggestion();
|
||||
if (!fullSuggestion) {
|
||||
ghost.hide();
|
||||
return false;
|
||||
}
|
||||
// Determine the baseline the next word should extend. Reliable
|
||||
// buffer: resync the ghost to the live buffer so getNextWord
|
||||
// operates on the up-to-date tail. Unreliable buffer (post
|
||||
// history-recall / Ctrl-R): don't reanchor to "" — that would
|
||||
// make getNextWord hand back the very first word and the shell
|
||||
// would duplicate leading tokens on top of the recalled line.
|
||||
// Fall back to the ghost's existing cached input instead.
|
||||
if (typedBufferReliableRef.current) {
|
||||
const live = typedInputBufferRef.current;
|
||||
if (fullSuggestion.startsWith(live)) {
|
||||
ghost.show(fullSuggestion, live);
|
||||
} else {
|
||||
ghost.hide();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const base = ghost.getGhostText().length > 0
|
||||
? fullSuggestion.substring(0, fullSuggestion.length - ghost.getGhostText().length)
|
||||
: fullSuggestion;
|
||||
const nextWord = ghost.getNextWord();
|
||||
if (nextWord) {
|
||||
writeToTerminal(nextWord);
|
||||
// Update ghost text to show remaining
|
||||
const fullSuggestion = ghost.getSuggestion();
|
||||
const currentInput = ghost.getGhostText().substring(nextWord.length);
|
||||
if (currentInput && fullSuggestion) {
|
||||
// Rebuild: the new input is old input + nextWord
|
||||
const oldInput = fullSuggestion.substring(0, fullSuggestion.length - ghost.getGhostText().length);
|
||||
ghost.show(fullSuggestion, oldInput + nextWord);
|
||||
// Only extend the buffer if it was already aligned with the
|
||||
// line — otherwise we'd end up with just the appended word,
|
||||
// which the next Enter would then record as the command.
|
||||
if (typedBufferReliableRef.current) {
|
||||
typedInputBufferRef.current += nextWord;
|
||||
}
|
||||
// Shrink the ghost to reflect what's left after the accept.
|
||||
const newInput = base + nextWord;
|
||||
if (fullSuggestion.startsWith(newInput) && fullSuggestion.length > newInput.length) {
|
||||
ghost.show(fullSuggestion, newInput);
|
||||
} else {
|
||||
ghost.hide();
|
||||
}
|
||||
@@ -690,7 +946,9 @@ export function useTerminalAutocomplete(
|
||||
}
|
||||
}
|
||||
|
||||
// Tab: accept selected popup suggestion, or accept ghost text
|
||||
// Tab: accept selected popup suggestion. Ghost text is accepted via → only —
|
||||
// letting Tab pass through lets the shell's native completion (bash/zsh) run,
|
||||
// which is otherwise shadowed by our single-Tab ghost accept.
|
||||
if (e.key === "Tab" && !e.ctrlKey && !e.metaKey && !e.altKey && s.subDirFocusLevel < 0) {
|
||||
if (s.popupVisible && s.suggestions.length > 0) {
|
||||
e.preventDefault();
|
||||
@@ -698,16 +956,10 @@ export function useTerminalAutocomplete(
|
||||
if (selected) insertSuggestion(selected, false);
|
||||
return false;
|
||||
}
|
||||
if (ghost?.isVisible()) {
|
||||
e.preventDefault();
|
||||
const ghostText = ghost.getGhostText();
|
||||
if (ghostText) {
|
||||
writeToTerminal(ghostText);
|
||||
lastAcceptedCommandRef.current = ghost.getSuggestion();
|
||||
ghost.hide();
|
||||
clearState();
|
||||
}
|
||||
return false;
|
||||
// Hide stale ghost text before Tab reaches the shell — the shell's
|
||||
// completion will rewrite the line and the old ghost would mislead.
|
||||
if (ghost?.isActive()) {
|
||||
ghost.hide();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -850,8 +1102,8 @@ export function useTerminalAutocomplete(
|
||||
if (!term) return;
|
||||
|
||||
// Always use real-time prompt detection — lastPromptRef may be stale
|
||||
// if the user typed more characters after suggestions were fetched
|
||||
const prompt = detectPrompt(term);
|
||||
// if the user typed more characters after suggestions were fetched.
|
||||
const { prompt } = getAlignedPrompt(term, typedInputBufferRef.current, typedBufferReliableRef.current);
|
||||
if (!prompt.isAtPrompt) return;
|
||||
|
||||
// If suggestion starts with the current input, insert only the remaining part.
|
||||
@@ -875,6 +1127,18 @@ export function useTerminalAutocomplete(
|
||||
writeToTerminal(payload);
|
||||
}
|
||||
|
||||
// Keystroke buffer now reflects the accepted text (either extended by
|
||||
// the insertion suffix, or wholesale replaced by the fuzzy-match path
|
||||
// that emits Ctrl-U first). Re-aligning it here keeps the subsequent
|
||||
// Enter-record honest, and flips reliability back on since we know
|
||||
// the line content exactly.
|
||||
if (execute) {
|
||||
typedInputBufferRef.current = "";
|
||||
} else {
|
||||
typedInputBufferRef.current = suggestion.text;
|
||||
}
|
||||
typedBufferReliableRef.current = true;
|
||||
|
||||
// Track accepted command for accurate history recording on fast Enter
|
||||
if (!execute) {
|
||||
lastAcceptedCommandRef.current = suggestion.text;
|
||||
|
||||
61
components/terminal/focusTerminalSession.test.ts
Normal file
61
components/terminal/focusTerminalSession.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { focusTerminalSessionInput } from "./focusTerminalSession";
|
||||
|
||||
test("focusTerminalSessionInput focuses the xterm helper textarea immediately and after scheduled retries", () => {
|
||||
const focusCalls: string[] = [];
|
||||
const textarea = {
|
||||
focus: () => focusCalls.push("focus"),
|
||||
};
|
||||
const pane = {
|
||||
querySelector: (selector: string) => {
|
||||
assert.equal(selector, "textarea.xterm-helper-textarea");
|
||||
return textarea;
|
||||
},
|
||||
};
|
||||
const queriedSelectors: string[] = [];
|
||||
const doc = {
|
||||
querySelector: (selector: string) => {
|
||||
queriedSelectors.push(selector);
|
||||
return pane;
|
||||
},
|
||||
};
|
||||
const timeouts: number[] = [];
|
||||
|
||||
focusTerminalSessionInput("session-1", {
|
||||
document: doc,
|
||||
requestAnimationFrame: (callback) => {
|
||||
callback();
|
||||
return 1;
|
||||
},
|
||||
setTimeout: (callback, delay) => {
|
||||
timeouts.push(delay);
|
||||
callback();
|
||||
return delay;
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(queriedSelectors, [
|
||||
'[data-session-id="session-1"]',
|
||||
'[data-session-id="session-1"]',
|
||||
]);
|
||||
assert.deepEqual(timeouts, [50]);
|
||||
assert.deepEqual(focusCalls, ["focus", "focus"]);
|
||||
});
|
||||
|
||||
test("focusTerminalSessionInput ignores empty or unavailable targets", () => {
|
||||
assert.doesNotThrow(() => {
|
||||
focusTerminalSessionInput(null, {
|
||||
document: undefined,
|
||||
requestAnimationFrame: (callback) => {
|
||||
callback();
|
||||
return 1;
|
||||
},
|
||||
setTimeout: (callback, delay) => {
|
||||
callback();
|
||||
return delay;
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
57
components/terminal/focusTerminalSession.ts
Normal file
57
components/terminal/focusTerminalSession.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
type QueryTarget = {
|
||||
querySelector: (selector: string) => QueryTarget | FocusableTarget | null;
|
||||
};
|
||||
|
||||
type FocusableTarget = {
|
||||
focus?: () => void;
|
||||
};
|
||||
|
||||
interface FocusTerminalSessionInputOptions {
|
||||
document?: QueryTarget | null;
|
||||
requestAnimationFrame?: (callback: () => void) => unknown;
|
||||
setTimeout?: (callback: () => void, delay: number) => unknown;
|
||||
retryDelays?: readonly number[];
|
||||
}
|
||||
|
||||
const escapeAttributeValue = (value: string): string =>
|
||||
value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
|
||||
export const focusTerminalSessionInput = (
|
||||
sessionId: string | null | undefined,
|
||||
options: FocusTerminalSessionInputOptions = {},
|
||||
): void => {
|
||||
if (!sessionId) return;
|
||||
|
||||
const doc = options.document ?? (typeof document !== "undefined" ? document : null);
|
||||
if (!doc) return;
|
||||
|
||||
const raf = options.requestAnimationFrame
|
||||
?? (typeof requestAnimationFrame !== "undefined"
|
||||
? requestAnimationFrame
|
||||
: (callback: () => void) => {
|
||||
callback();
|
||||
return undefined;
|
||||
});
|
||||
const scheduleTimeout = options.setTimeout
|
||||
?? (typeof setTimeout !== "undefined"
|
||||
? setTimeout
|
||||
: (callback: () => void) => {
|
||||
callback();
|
||||
return undefined;
|
||||
});
|
||||
const retryDelays = options.retryDelays ?? [50];
|
||||
const paneSelector = `[data-session-id="${escapeAttributeValue(sessionId)}"]`;
|
||||
|
||||
const focusTarget = () => {
|
||||
const pane = doc.querySelector(paneSelector) as QueryTarget | null;
|
||||
const textarea = pane?.querySelector("textarea.xterm-helper-textarea") as FocusableTarget | null;
|
||||
textarea?.focus?.();
|
||||
};
|
||||
|
||||
raf(() => {
|
||||
focusTarget();
|
||||
retryDelays.forEach((delay) => {
|
||||
scheduleTimeout(focusTarget, delay);
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -569,6 +569,8 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
? (effectivePassphrase || sanitizeCredentialValue(attempt.key.passphrase))
|
||||
: undefined,
|
||||
agentForwarding: ctx.host.agentForwarding,
|
||||
x11Forwarding: ctx.host.x11Forwarding,
|
||||
x11Display: ctx.terminalSettings?.x11Display,
|
||||
legacyAlgorithms: ctx.host.legacyAlgorithms,
|
||||
cols: term.cols,
|
||||
rows: term.rows,
|
||||
@@ -759,6 +761,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
username: ctx.host.username || "root",
|
||||
port: ctx.host.port || 22,
|
||||
moshServerPath: ctx.host.moshServerPath,
|
||||
moshClientPath: ctx.terminalSettings?.moshClientPath || undefined,
|
||||
agentForwarding: ctx.host.agentForwarding,
|
||||
cols: term.cols,
|
||||
rows: term.rows,
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
isEraseScrollbackSequence,
|
||||
preserveTerminalViewportInScrollback,
|
||||
} from "../clearTerminalViewport";
|
||||
import { installUserCursorPreferenceGuard } from "./cursorPreference";
|
||||
import type {
|
||||
Host,
|
||||
KeyBinding,
|
||||
@@ -114,6 +115,10 @@ export type CreateXTermRuntimeContext = {
|
||||
onAutocompleteKeyEvent?: (e: KeyboardEvent) => boolean;
|
||||
// Autocomplete input handler — called on every character input
|
||||
onAutocompleteInput?: (data: string) => void;
|
||||
|
||||
// Set to true while we're programmatically restoring a selection so that
|
||||
// copy-on-select listeners can suppress redundant clipboard writes.
|
||||
isRestoringSelectionRef?: RefObject<boolean>;
|
||||
};
|
||||
|
||||
const detectPlatform = (): XTermPlatform => {
|
||||
@@ -415,6 +420,48 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
};
|
||||
|
||||
term.attachCustomKeyEventHandler((e: KeyboardEvent) => {
|
||||
// Preserve mouse selection across keystrokes when enabled. xterm.js
|
||||
// unconditionally clears the selection on user input
|
||||
// (SelectionService.ts: coreService.onUserInput → clearSelection).
|
||||
// Capture the selection here, then re-apply it after xterm has
|
||||
// processed the key + cleared. The microtask runs after both
|
||||
// synchronous listeners, so by then either the selection is gone (and
|
||||
// we restore) or it's still there (we no-op).
|
||||
//
|
||||
// Both keydown AND keypress must be hooked: xterm routes Space
|
||||
// (keyCode 32 fails Keyboard.ts: `ev.keyCode >= 48`) and A–Z
|
||||
// (CoreBrowserTerminal.ts:_keyDown A–Z IME HACK) through the
|
||||
// `keypress` event, calling triggerDataEvent in _keyPress rather
|
||||
// than _keyDown. For those keys, keydown's microtask drains before
|
||||
// keypress fires, so hasSelection is still true → no-op. Attaching
|
||||
// to keypress gives us a second microtask that drains after
|
||||
// _keyPress clears the selection, so the restore runs.
|
||||
if (
|
||||
(e.type === "keydown" || e.type === "keypress") &&
|
||||
ctx.terminalSettingsRef.current?.preserveSelectionOnInput &&
|
||||
term.hasSelection()
|
||||
) {
|
||||
const sel = term.getSelectionPosition();
|
||||
if (sel) {
|
||||
const length =
|
||||
(sel.end.y - sel.start.y) * term.cols + (sel.end.x - sel.start.x);
|
||||
const savedStartX = sel.start.x;
|
||||
const savedStartY = sel.start.y;
|
||||
queueMicrotask(() => {
|
||||
if (term.hasSelection()) return;
|
||||
// Bail out if scrollback trim invalidated the row index.
|
||||
if (savedStartY >= term.buffer.active.length) return;
|
||||
const restoreFlag = ctx.isRestoringSelectionRef;
|
||||
if (restoreFlag) restoreFlag.current = true;
|
||||
try {
|
||||
term.select(savedStartX, savedStartY, length);
|
||||
} finally {
|
||||
if (restoreFlag) restoreFlag.current = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (e.type !== "keydown") {
|
||||
return true;
|
||||
}
|
||||
@@ -449,6 +496,11 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
// Wrap for this terminal only, after broadcasting
|
||||
const snippetIsMultiLine = snippetData.includes("\n");
|
||||
if (snippetIsMultiLine && term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste) snippetData = wrapBracketedPaste(snippetData);
|
||||
// Notify autocomplete with the final (possibly bracket-wrapped)
|
||||
// bytes so its keystroke buffer can tell literal multi-line
|
||||
// paste ("\x1b[200~...\x1b[201~") from the non-bracketed path
|
||||
// where each \n executes an intermediate command (#814 P2).
|
||||
ctx.onAutocompleteInput?.(snippetData);
|
||||
ctx.terminalBackend.writeToSession(id, snippetData);
|
||||
if (!snippet.noAutoRun && ctx.onCommandExecuted) {
|
||||
const cmd = snippet.command.trim();
|
||||
@@ -489,8 +541,13 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
navigator.clipboard.readText().then((text) => {
|
||||
const id = ctx.sessionRef.current;
|
||||
if (id) {
|
||||
let data = normalizeLineEndings(text);
|
||||
if (term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste) data = wrapBracketedPaste(data);
|
||||
const rawData = normalizeLineEndings(text);
|
||||
const data = term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste
|
||||
? wrapBracketedPaste(rawData)
|
||||
: rawData;
|
||||
// Notify autocomplete with the final bytes so bracketed
|
||||
// pastes preserve their inner newlines as literal input.
|
||||
ctx.onAutocompleteInput?.(data);
|
||||
ctx.terminalBackend.writeToSession(id, data);
|
||||
scrollToBottomAfterPaste();
|
||||
}
|
||||
@@ -501,8 +558,11 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
const selection = term.getSelection();
|
||||
const id = ctx.sessionRef.current;
|
||||
if (selection && id) {
|
||||
let data = normalizeLineEndings(selection);
|
||||
if (term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste) data = wrapBracketedPaste(data);
|
||||
const rawData = normalizeLineEndings(selection);
|
||||
const data = term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste
|
||||
? wrapBracketedPaste(rawData)
|
||||
: rawData;
|
||||
ctx.onAutocompleteInput?.(data);
|
||||
ctx.terminalBackend.writeToSession(id, data);
|
||||
scrollToBottomAfterPaste();
|
||||
}
|
||||
@@ -536,8 +596,11 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (text && ctx.sessionRef.current) {
|
||||
let data = normalizeLineEndings(text);
|
||||
if (term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste) data = wrapBracketedPaste(data);
|
||||
const rawData = normalizeLineEndings(text);
|
||||
const data = term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste
|
||||
? wrapBracketedPaste(rawData)
|
||||
: rawData;
|
||||
ctx.onAutocompleteInput?.(data);
|
||||
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, data);
|
||||
scrollToBottomAfterPaste();
|
||||
}
|
||||
@@ -664,7 +727,10 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
if (!isEraseScrollbackSequence(params)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
// CSI 3 J — POSIX/ncurses default `clear` emits this to wipe scrollback.
|
||||
// Honor it unless the user opts into the legacy "preserve history" behavior.
|
||||
const wipeAllowed = ctx.terminalSettingsRef.current?.clearWipesScrollback ?? true;
|
||||
return !wipeAllowed;
|
||||
});
|
||||
|
||||
// Register OSC 7 handler using xterm.js parser
|
||||
@@ -765,6 +831,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
return true;
|
||||
});
|
||||
|
||||
const cursorPreferenceDisposable = installUserCursorPreferenceGuard(term, ctx.terminalSettingsRef);
|
||||
|
||||
let resizeTimeout: NodeJS.Timeout | null = null;
|
||||
const resizeDebounceMs = XTERM_PERFORMANCE_CONFIG.resize.debounceMs;
|
||||
term.onResize(({ cols, rows }) => {
|
||||
@@ -792,6 +860,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
eraseScrollbackDisposable.dispose();
|
||||
osc7Disposable.dispose();
|
||||
osc52Disposable.dispose();
|
||||
cursorPreferenceDisposable?.dispose();
|
||||
try {
|
||||
term.dispose();
|
||||
} catch (err) {
|
||||
|
||||
160
components/terminal/runtime/cursorPreference.test.ts
Normal file
160
components/terminal/runtime/cursorPreference.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
applyUserCursorBlinkPreference,
|
||||
applyUserCursorPreference,
|
||||
installUserCursorPreferenceGuard,
|
||||
resolveUserCursorPreference,
|
||||
} from "./cursorPreference";
|
||||
|
||||
test("resolveUserCursorPreference defaults to a blinking block cursor", () => {
|
||||
assert.deepEqual(resolveUserCursorPreference(undefined), {
|
||||
cursorShape: "block",
|
||||
cursorBlink: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("applyUserCursorPreference clears terminal-side cursor overrides before applying user settings", () => {
|
||||
const term = {
|
||||
options: {
|
||||
cursorStyle: "block" as const,
|
||||
cursorBlink: false,
|
||||
},
|
||||
_core: {
|
||||
coreService: {
|
||||
decPrivateModes: {
|
||||
cursorStyle: "bar" as const,
|
||||
cursorBlink: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
applyUserCursorPreference(term, {
|
||||
cursorShape: "underline",
|
||||
cursorBlink: true,
|
||||
});
|
||||
|
||||
assert.equal(term.options.cursorStyle, "underline");
|
||||
assert.equal(term.options.cursorBlink, true);
|
||||
assert.equal(term._core.coreService.decPrivateModes.cursorStyle, undefined);
|
||||
assert.equal(term._core.coreService.decPrivateModes.cursorBlink, undefined);
|
||||
});
|
||||
|
||||
test("applyUserCursorBlinkPreference keeps remote cursor shape overrides intact", () => {
|
||||
const term = {
|
||||
options: {
|
||||
cursorStyle: "block" as const,
|
||||
cursorBlink: false,
|
||||
},
|
||||
_core: {
|
||||
coreService: {
|
||||
decPrivateModes: {
|
||||
cursorStyle: "bar" as const,
|
||||
cursorBlink: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
applyUserCursorBlinkPreference(term, {
|
||||
cursorShape: "underline",
|
||||
cursorBlink: true,
|
||||
});
|
||||
|
||||
assert.equal(term.options.cursorStyle, "block");
|
||||
assert.equal(term.options.cursorBlink, true);
|
||||
assert.equal(term._core.coreService.decPrivateModes.cursorStyle, "bar");
|
||||
assert.equal(term._core.coreService.decPrivateModes.cursorBlink, undefined);
|
||||
});
|
||||
|
||||
test("installUserCursorPreferenceGuard restores blink without consuming cursor-style overrides", async () => {
|
||||
const handlers = new Map<string, (params: readonly (number | number[])[]) => boolean>();
|
||||
const parser = {
|
||||
registerCsiHandler(this: typeof parser, id: { prefix?: string; intermediates?: string; final: string }, callback: (params: readonly (number | number[])[]) => boolean) {
|
||||
assert.equal(this, parser);
|
||||
handlers.set(`${id.prefix ?? ""}|${id.intermediates ?? ""}|${id.final}`, callback);
|
||||
return { dispose: () => undefined };
|
||||
},
|
||||
};
|
||||
const term = {
|
||||
options: {
|
||||
cursorStyle: "block" as const,
|
||||
cursorBlink: false,
|
||||
},
|
||||
parser,
|
||||
_core: {
|
||||
coreService: {
|
||||
decPrivateModes: {
|
||||
cursorStyle: "block" as const,
|
||||
cursorBlink: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const settingsRef = {
|
||||
current: {
|
||||
cursorShape: "bar",
|
||||
cursorBlink: true,
|
||||
},
|
||||
};
|
||||
|
||||
installUserCursorPreferenceGuard(term, settingsRef);
|
||||
const handled = handlers.get("| |q")?.([2]);
|
||||
|
||||
assert.equal(handled, false);
|
||||
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
|
||||
assert.equal(term.options.cursorStyle, "block");
|
||||
assert.equal(term.options.cursorBlink, true);
|
||||
assert.equal(term._core.coreService.decPrivateModes.cursorStyle, "block");
|
||||
assert.equal(term._core.coreService.decPrivateModes.cursorBlink, undefined);
|
||||
});
|
||||
|
||||
test("installUserCursorPreferenceGuard restores cursor blink after private mode changes", async () => {
|
||||
const handlers = new Map<string, (params: readonly (number | number[])[]) => boolean>();
|
||||
const term = {
|
||||
options: {
|
||||
cursorStyle: "block" as const,
|
||||
cursorBlink: false,
|
||||
},
|
||||
parser: {
|
||||
registerCsiHandler: (id: { prefix?: string; intermediates?: string; final: string }, callback: (params: readonly (number | number[])[]) => boolean) => {
|
||||
handlers.set(`${id.prefix ?? ""}|${id.intermediates ?? ""}|${id.final}`, callback);
|
||||
return { dispose: () => undefined };
|
||||
},
|
||||
},
|
||||
_core: {
|
||||
coreService: {
|
||||
decPrivateModes: {
|
||||
cursorStyle: "block" as const,
|
||||
cursorBlink: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const settingsRef = {
|
||||
current: {
|
||||
cursorShape: "underline",
|
||||
cursorBlink: true,
|
||||
},
|
||||
};
|
||||
|
||||
installUserCursorPreferenceGuard(term, settingsRef);
|
||||
const handled = handlers.get("?||l")?.([12]);
|
||||
|
||||
assert.equal(handled, false);
|
||||
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
|
||||
assert.equal(term.options.cursorStyle, "block");
|
||||
assert.equal(term.options.cursorBlink, true);
|
||||
assert.equal(term._core.coreService.decPrivateModes.cursorStyle, "block");
|
||||
assert.equal(term._core.coreService.decPrivateModes.cursorBlink, undefined);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user