Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecadc1fc2d | ||
|
|
79ccf47655 | ||
|
|
6ef0a4ad6b | ||
|
|
88142d2a92 | ||
|
|
f5c3302329 | ||
|
|
bb02f8e162 | ||
|
|
d57dd664a2 | ||
|
|
74ec6678bb | ||
|
|
b9e88cd99d | ||
|
|
32afade4f9 | ||
|
|
66de2db912 | ||
|
|
0a38da8867 | ||
|
|
5e739f8293 | ||
|
|
6f64245d10 | ||
|
|
d48ca65a1e | ||
|
|
285fcd55a9 | ||
|
|
05b713ab18 | ||
|
|
293b15f67a | ||
|
|
83aec35f2f | ||
|
|
910ef72205 | ||
|
|
550a37b379 | ||
|
|
2b396c14e3 | ||
|
|
36724a3abd | ||
|
|
4459aa4ef3 | ||
|
|
64a6986d01 | ||
|
|
a301ecb2ca | ||
|
|
f16429e30f | ||
|
|
46b9bf6ccb | ||
|
|
17c8f11194 | ||
|
|
4d1a7ea55a | ||
|
|
babe06a944 | ||
|
|
9e31d53bdd | ||
|
|
ea24841939 | ||
|
|
bf9f557e42 | ||
|
|
106e748a9b | ||
|
|
94fff62f9b | ||
|
|
324253f23a | ||
|
|
e9a2e44a91 | ||
|
|
7b4f046001 |
11
App.tsx
11
App.tsx
@@ -28,6 +28,7 @@ import { resolveHostAuth } from './domain/sshAuth';
|
||||
import { isEncryptedCredentialPlaceholder } from './domain/credentials';
|
||||
import {
|
||||
mergeTerminalHostUpdate,
|
||||
type TerminalHostUpdate,
|
||||
} from './domain/terminalAppearance';
|
||||
import { selectConnectionLogForTerminalDataCapture } from './domain/connectionLog';
|
||||
import { collectSessionIds } from './domain/workspace';
|
||||
@@ -193,7 +194,6 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const keysRef = useRef(keys);
|
||||
keysRef.current = keys;
|
||||
const knownHostsRef = useRef(knownHosts);
|
||||
knownHostsRef.current = knownHosts;
|
||||
// Bridge the gap while useVaultState hydrates: its async init awaits
|
||||
// hosts/keys/identities/proxyProfiles decryption before reading knownHosts,
|
||||
// so the state is briefly [] at boot even when localStorage has entries.
|
||||
@@ -204,6 +204,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
() => getEffectiveKnownHosts(knownHosts) ?? [],
|
||||
[knownHosts],
|
||||
);
|
||||
knownHostsRef.current = effectiveKnownHosts;
|
||||
|
||||
const {
|
||||
sessions,
|
||||
@@ -215,6 +216,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
sessionRenameValue,
|
||||
setSessionRenameValue,
|
||||
startSessionRename,
|
||||
renameSessionInline,
|
||||
submitSessionRename,
|
||||
resetSessionRename,
|
||||
workspaceRenameTarget,
|
||||
@@ -234,6 +236,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
createWorkspaceWithHosts,
|
||||
createWorkspaceFromSessions,
|
||||
addSessionToWorkspace,
|
||||
removeSessionFromWorkspace,
|
||||
appendHostToWorkspace,
|
||||
appendLocalTerminalToWorkspace,
|
||||
createWorkspaceFromTargets,
|
||||
@@ -727,7 +730,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
);
|
||||
|
||||
// Shared hotkey action handler - used by both global handler and terminal callback
|
||||
const executeHotkeyAction = useCallback((action: string, e: KeyboardEvent) => { return executeHotkeyActionImpl(() => ({ IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, action, activeTabStore, addConnectionLogRef, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, e, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces }), action, e); }, [orderedTabs, editorTabs, sessions, workspaces, isQuickSwitcherOpen, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast, settings, confirmIfBusyLocalTerminal]);
|
||||
const executeHotkeyAction = useCallback((action: string, e: KeyboardEvent) => { return executeHotkeyActionImpl(() => ({ IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, action, activeTabStore, addConnectionLogRef, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, e, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, workspaces }), action, e); }, [orderedTabs, editorTabs, sessions, workspaces, isQuickSwitcherOpen, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast, toggleWorkspaceViewMode, settings, confirmIfBusyLocalTerminal]);
|
||||
|
||||
const handleWindowCommandCloseRequest = useCallback(async () => {
|
||||
const openDialogs = Array.from(document.querySelectorAll<HTMLElement>('[role="dialog"][data-state="open"]'));
|
||||
@@ -874,7 +877,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}
|
||||
}, [updateSessionStatus, updateHostLastConnected]);
|
||||
|
||||
const handleUpdateHostFromTerminal = useCallback((host: Host) => {
|
||||
const handleUpdateHostFromTerminal = useCallback((host: TerminalHostUpdate) => {
|
||||
updateHosts(hosts.map((h) => (
|
||||
h.id === host.id ? mergeTerminalHostUpdate(h, host) : h
|
||||
)));
|
||||
@@ -987,7 +990,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
logViews={logViews}
|
||||
t={t}
|
||||
/>
|
||||
<AppView ctx={{ accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, clearSessionFontSizeOverride, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, copySessionToNewWindowWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost, handleEndSessionDrag, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateSessionFontSize, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />
|
||||
<AppView ctx={{ accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, clearSessionFontSizeOverride, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, copySessionToNewWindowWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost, handleEndSessionDrag, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, renameSessionInline, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateSessionFontSize, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -440,7 +440,7 @@ export async function closeTabsBatchImpl(getCtx: AppContextGetter, targetIds: st
|
||||
}
|
||||
|
||||
export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string, e: KeyboardEvent) {
|
||||
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces } = getCtx();
|
||||
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, workspaces } = getCtx();
|
||||
{
|
||||
// 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
|
||||
@@ -539,6 +539,40 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
|
||||
break;
|
||||
}
|
||||
case 'closeSession': {
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
if (!currentId || currentId === 'vault' || currentId === 'sftp') break;
|
||||
if (closeTabInFlightRef.current) break;
|
||||
|
||||
const session = sessions.find((s) => s.id === currentId) ?? null;
|
||||
const workspace = workspaces.find((w) => w.id === currentId) ?? null;
|
||||
|
||||
closeTabInFlightRef.current = true;
|
||||
(async () => {
|
||||
try {
|
||||
// If active tab is a workspace, close the focused session (pane)
|
||||
if (workspace) {
|
||||
// Validate focusedSessionId is still valid — it can become stale
|
||||
// if the previously focused session was already closed
|
||||
const aliveIds = collectSessionIds(workspace.root);
|
||||
const focusedId = aliveIds.includes(workspace.focusedSessionId)
|
||||
? workspace.focusedSessionId
|
||||
: aliveIds[0];
|
||||
if (focusedId) {
|
||||
const ok = await confirmIfBusyLocalTerminal([focusedId]);
|
||||
if (ok) closeSession(focusedId);
|
||||
}
|
||||
} else if (session) {
|
||||
// Standalone session tab — close the session
|
||||
const ok = await confirmIfBusyLocalTerminal([session.id]);
|
||||
if (ok) closeSession(session.id);
|
||||
}
|
||||
} finally {
|
||||
closeTabInFlightRef.current = false;
|
||||
}
|
||||
})();
|
||||
break;
|
||||
}
|
||||
case 'newTab':
|
||||
case 'openLocal':
|
||||
// Add connection log for local terminal
|
||||
@@ -644,6 +678,15 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'togglePaneZoom': {
|
||||
// Toggle workspace between split and focus (zoom) mode
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
const activeWs = workspaces.find(w => w.id === currentId);
|
||||
if (activeWs) {
|
||||
toggleWorkspaceViewMode(activeWs.id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'moveFocus': {
|
||||
// Debounce to prevent double-triggering when focus switches between terminals
|
||||
const now = Date.now();
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { GroupConfig, Host, TerminalSession, TerminalTheme, Workspace } fro
|
||||
import {
|
||||
isHostTreeWorkTabSurface,
|
||||
resolveWorkTabActiveHostId,
|
||||
resolveWorkTabHostTreeTheme,
|
||||
} from './workTabSurface';
|
||||
|
||||
interface AppHostTreeLayerProps {
|
||||
@@ -20,7 +21,12 @@ interface AppHostTreeLayerProps {
|
||||
editorTabs: readonly EditorTab[];
|
||||
logViews: readonly LogView[];
|
||||
orderedTabs: readonly string[];
|
||||
resolvedPreviewTheme: TerminalTheme;
|
||||
accentMode: 'theme' | 'custom';
|
||||
currentTerminalTheme: TerminalTheme;
|
||||
customAccent: string;
|
||||
followAppTerminalTheme: boolean;
|
||||
hostById: ReadonlyMap<string, Host>;
|
||||
themeById: ReadonlyMap<string, TerminalTheme>;
|
||||
onConnect: (host: Host) => void;
|
||||
onCreateLocalTerminal?: () => void;
|
||||
}
|
||||
@@ -43,7 +49,12 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
|
||||
editorTabs,
|
||||
logViews,
|
||||
orderedTabs,
|
||||
resolvedPreviewTheme,
|
||||
accentMode,
|
||||
currentTerminalTheme,
|
||||
customAccent,
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
themeById,
|
||||
onConnect,
|
||||
onCreateLocalTerminal,
|
||||
}) => {
|
||||
@@ -67,6 +78,24 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
|
||||
workspaces,
|
||||
}), [activeTabId, editorTabs, sessions, workspaces]);
|
||||
|
||||
const hostTreeTheme = useMemo(() => resolveWorkTabHostTreeTheme({
|
||||
activeHostId,
|
||||
accentMode,
|
||||
currentTerminalTheme,
|
||||
customAccent,
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
themeById,
|
||||
}), [
|
||||
activeHostId,
|
||||
accentMode,
|
||||
currentTerminalTheme,
|
||||
customAccent,
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
themeById,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 flex min-h-0"
|
||||
@@ -79,7 +108,7 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
|
||||
hosts={hosts}
|
||||
customGroups={customGroups}
|
||||
groupConfigs={groupConfigs}
|
||||
resolvedPreviewTheme={resolvedPreviewTheme}
|
||||
resolvedPreviewTheme={hostTreeTheme}
|
||||
activeHostId={activeHostId}
|
||||
onConnect={onConnect}
|
||||
onCreateLocalTerminal={onCreateLocalTerminal}
|
||||
|
||||
@@ -42,13 +42,13 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal,
|
||||
hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
|
||||
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions,
|
||||
passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename,
|
||||
passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename,
|
||||
resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
|
||||
sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen,
|
||||
setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, updateSessionFontSize, clearSessionFontSizeOverride,
|
||||
setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
|
||||
sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename,
|
||||
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId,
|
||||
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById,
|
||||
toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog,
|
||||
updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources,
|
||||
updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces,
|
||||
@@ -134,6 +134,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
onStartSessionDrag={setDraggingSessionId}
|
||||
onEndSessionDrag={handleEndSessionDrag}
|
||||
onReorderTabs={reorderWorkTabs}
|
||||
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
|
||||
showSftpTab={settings.showSftpTab}
|
||||
showHostTreeSidebar={settings.showHostTreeSidebar}
|
||||
editorTabs={editorTabs}
|
||||
@@ -152,7 +153,12 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
editorTabs={editorTabs}
|
||||
logViews={logViews}
|
||||
orderedTabs={orderedTabsWithEditors}
|
||||
resolvedPreviewTheme={currentTerminalTheme}
|
||||
accentMode={accentMode}
|
||||
currentTerminalTheme={currentTerminalTheme}
|
||||
customAccent={customAccent}
|
||||
followAppTerminalTheme={followAppTerminalTheme}
|
||||
hostById={hostById}
|
||||
themeById={themeById}
|
||||
onConnect={handleConnectToHost}
|
||||
onCreateLocalTerminal={handleCreateLocalTerminal}
|
||||
/>
|
||||
@@ -214,9 +220,11 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
hosts={hosts}
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
knownHosts={effectiveKnownHosts}
|
||||
proxyProfiles={proxyProfiles}
|
||||
groupConfigs={groupConfigs}
|
||||
updateHosts={updateHosts}
|
||||
onAddKnownHost={handleAddKnownHost}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
|
||||
sftpAutoSync={sftpAutoSync}
|
||||
@@ -250,6 +258,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
terminalFontFamilyId={terminalFontFamilyId}
|
||||
fontSize={terminalFontSize}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
disableTerminalFontZoom={settings.disableTerminalFontZoom}
|
||||
keyBindings={keyBindings}
|
||||
onHotkeyAction={handleHotkeyAction}
|
||||
onUpdateTerminalThemeId={setTerminalThemeId}
|
||||
@@ -278,6 +287,9 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
|
||||
onSetWorkspaceFocusedSession={setWorkspaceFocusedSession}
|
||||
onReorderWorkspaceSessions={reorderWorkspaceSessions}
|
||||
onReorderTabs={reorderWorkTabs}
|
||||
onCopySession={copySessionWithCurrentShell}
|
||||
onCopySessionToNewWindow={copySessionToNewWindowWithCurrentShell}
|
||||
onSplitSession={splitSessionWithCurrentShell}
|
||||
onConnectToHost={handleConnectToHost}
|
||||
onCreateLocalTerminal={handleCreateLocalTerminal}
|
||||
@@ -304,6 +316,9 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
showHostTreeSidebar={settings.showHostTreeSidebar}
|
||||
toggleScriptsSidePanelRef={toggleScriptsSidePanelRef}
|
||||
toggleSidePanelRef={toggleSidePanelRef}
|
||||
onStartSessionRename={startSessionRename}
|
||||
onSubmitSessionRename={submitSessionRename}
|
||||
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
|
||||
/>
|
||||
|
||||
{/* Log Views - readonly terminal replays */}
|
||||
|
||||
@@ -39,7 +39,7 @@ const baseInput = {
|
||||
workspaceById: new Map<string, Workspace>(),
|
||||
};
|
||||
|
||||
test("editor tabs use the theme from their owning host", () => {
|
||||
test("editor tabs use the owning host terminal theme when follow-app terminal theme is off", () => {
|
||||
const editorTab = {
|
||||
id: "editor-1",
|
||||
hostId: "host-1",
|
||||
@@ -58,6 +58,26 @@ test("editor tabs use the theme from their owning host", () => {
|
||||
assert.equal(resolved?.id, hostTheme.id);
|
||||
});
|
||||
|
||||
test("editor tabs use the followed terminal theme when follow-app terminal theme is on", () => {
|
||||
const editorTab = {
|
||||
id: "editor-1",
|
||||
hostId: "host-1",
|
||||
sessionId: "sftp-1",
|
||||
};
|
||||
|
||||
const resolved = resolveActiveChromeTheme({
|
||||
...baseInput,
|
||||
activeTabId: toEditorTabId(editorTab.id),
|
||||
editorTabs: [editorTab as unknown as EditorTab],
|
||||
followAppTerminalTheme: true,
|
||||
hostById: new Map([
|
||||
["host-1", { id: "host-1", theme: hostTheme.id } as unknown as Host],
|
||||
]),
|
||||
});
|
||||
|
||||
assert.equal(resolved?.id, currentTheme.id);
|
||||
});
|
||||
|
||||
test("log tabs use the saved log theme when available", () => {
|
||||
const resolved = resolveActiveChromeTheme({
|
||||
...baseInput,
|
||||
|
||||
@@ -54,22 +54,21 @@ export function resolveActiveChromeTheme({
|
||||
}: ResolveActiveChromeThemeInput): TerminalTheme | null {
|
||||
if (activeTabId === "vault" || activeTabId === "sftp") return null;
|
||||
|
||||
const resolveSessionTheme = (session: TerminalSession): TerminalTheme => {
|
||||
const resolveHostTheme = (hostId: string): TerminalTheme => {
|
||||
if (followAppTerminalTheme) return currentTerminalTheme;
|
||||
const host = hostById.get(session.hostId) ?? null;
|
||||
const host = hostById.get(hostId) ?? null;
|
||||
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
|
||||
const baseTheme = themeById.get(themeId) ?? currentTerminalTheme;
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
};
|
||||
|
||||
const resolveSessionTheme = (session: TerminalSession): TerminalTheme => resolveHostTheme(session.hostId);
|
||||
|
||||
if (isEditorTabId(activeTabId)) {
|
||||
const editorTabId = fromEditorTabId(activeTabId);
|
||||
const editorTab = editorTabs.find((tab) => tab.id === editorTabId);
|
||||
if (!editorTab) return null;
|
||||
const host = hostById.get(editorTab.hostId) ?? null;
|
||||
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
|
||||
const baseTheme = themeById.get(themeId) ?? currentTerminalTheme;
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
return resolveHostTheme(editorTab.hostId);
|
||||
}
|
||||
|
||||
const logView = logViews.find((item) => item.id === activeTabId);
|
||||
|
||||
@@ -6,10 +6,40 @@ import {
|
||||
isHostTreeWorkTabSurface,
|
||||
isRootPageTabId,
|
||||
isTerminalContentTabSurface,
|
||||
reorderWorkTabIds,
|
||||
resolveWorkTabActiveHostId,
|
||||
resolveWorkTabHostTreeTheme,
|
||||
} from './workTabSurface';
|
||||
import type { EditorTab } from '../state/editorTabStore';
|
||||
import type { TerminalSession, Workspace } from '../../types';
|
||||
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
|
||||
|
||||
const makeTheme = (id: string, type: TerminalTheme['type'], background: string): TerminalTheme => ({
|
||||
id,
|
||||
name: id,
|
||||
type,
|
||||
colors: {
|
||||
background,
|
||||
foreground: type === 'dark' ? '#ffffff' : '#000000',
|
||||
cursor: '#888888',
|
||||
selection: '#555555',
|
||||
black: '#000000',
|
||||
red: '#ff0000',
|
||||
green: '#00ff00',
|
||||
yellow: '#ffff00',
|
||||
blue: '#0000ff',
|
||||
magenta: '#ff00ff',
|
||||
cyan: '#00ffff',
|
||||
white: '#ffffff',
|
||||
brightBlack: '#444444',
|
||||
brightRed: '#ff5555',
|
||||
brightGreen: '#55ff55',
|
||||
brightYellow: '#ffff55',
|
||||
brightBlue: '#5555ff',
|
||||
brightMagenta: '#ff55ff',
|
||||
brightCyan: '#55ffff',
|
||||
brightWhite: '#ffffff',
|
||||
},
|
||||
});
|
||||
|
||||
test('work tab order keeps custom positions and appends new tabs', () => {
|
||||
assert.deepEqual(
|
||||
@@ -18,6 +48,29 @@ test('work tab order keeps custom positions and appends new tabs', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('work tab order removes duplicate ids before rendering', () => {
|
||||
assert.deepEqual(
|
||||
buildOrderedWorkTabIds(
|
||||
['session-2', 'session-1', 'session-2', 'session-1'],
|
||||
['session-1', 'session-2', 'session-3', 'session-3'],
|
||||
),
|
||||
['session-2', 'session-1', 'session-3'],
|
||||
);
|
||||
});
|
||||
|
||||
test('work tab order reorders with newly materialized tabs', () => {
|
||||
assert.deepEqual(
|
||||
reorderWorkTabIds(
|
||||
['session-1', 'session-2', 'session-3'],
|
||||
['session-1', 'session-2', 'session-3'],
|
||||
'session-1',
|
||||
'session-3',
|
||||
'after',
|
||||
),
|
||||
['session-2', 'session-3', 'session-1'],
|
||||
);
|
||||
});
|
||||
|
||||
test('root pages are not work tab surfaces', () => {
|
||||
assert.equal(isRootPageTabId('vault'), true);
|
||||
assert.equal(isRootPageTabId('sftp'), true);
|
||||
@@ -80,3 +133,73 @@ test('shared host tree resolves active host ids across work tab types', () => {
|
||||
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'editor:file-1', sessions, workspaces, editorTabs }), 'host-3');
|
||||
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'log-1', sessions, workspaces, editorTabs }), null);
|
||||
});
|
||||
|
||||
test('shared host tree uses the active host theme when follow-app terminal theme is off', () => {
|
||||
const currentTheme = makeTheme('app-dark', 'dark', '#111111');
|
||||
const hostTheme = makeTheme('host-light', 'light', '#fafafa');
|
||||
const host = {
|
||||
id: 'host-1',
|
||||
label: 'Host',
|
||||
hostname: 'host.local',
|
||||
username: 'root',
|
||||
tags: [],
|
||||
os: 'linux',
|
||||
theme: hostTheme.id,
|
||||
themeOverride: true,
|
||||
} as Host;
|
||||
|
||||
const resolved = resolveWorkTabHostTreeTheme({
|
||||
activeHostId: host.id,
|
||||
accentMode: 'theme',
|
||||
currentTerminalTheme: currentTheme,
|
||||
customAccent: '#8b5cf6',
|
||||
followAppTerminalTheme: false,
|
||||
hostById: new Map([[host.id, host]]),
|
||||
themeById: new Map([[currentTheme.id, currentTheme], [hostTheme.id, hostTheme]]),
|
||||
});
|
||||
|
||||
assert.equal(resolved.id, hostTheme.id);
|
||||
});
|
||||
|
||||
test('shared host tree uses the followed terminal theme when follow-app terminal theme is on', () => {
|
||||
const currentTheme = makeTheme('app-light', 'light', '#ffffff');
|
||||
const hostTheme = makeTheme('host-dark', 'dark', '#050505');
|
||||
const host = {
|
||||
id: 'host-1',
|
||||
label: 'Host',
|
||||
hostname: 'host.local',
|
||||
username: 'root',
|
||||
tags: [],
|
||||
os: 'linux',
|
||||
theme: hostTheme.id,
|
||||
themeOverride: true,
|
||||
} as Host;
|
||||
|
||||
const resolved = resolveWorkTabHostTreeTheme({
|
||||
activeHostId: host.id,
|
||||
accentMode: 'theme',
|
||||
currentTerminalTheme: currentTheme,
|
||||
customAccent: '#8b5cf6',
|
||||
followAppTerminalTheme: true,
|
||||
hostById: new Map([[host.id, host]]),
|
||||
themeById: new Map([[currentTheme.id, currentTheme], [hostTheme.id, hostTheme]]),
|
||||
});
|
||||
|
||||
assert.equal(resolved.id, currentTheme.id);
|
||||
});
|
||||
|
||||
test('shared host tree falls back to the current terminal theme without an active host', () => {
|
||||
const currentTheme = makeTheme('app-dark', 'dark', '#111111');
|
||||
|
||||
const resolved = resolveWorkTabHostTreeTheme({
|
||||
activeHostId: null,
|
||||
accentMode: 'theme',
|
||||
currentTerminalTheme: currentTheme,
|
||||
customAccent: '#8b5cf6',
|
||||
followAppTerminalTheme: false,
|
||||
hostById: new Map(),
|
||||
themeById: new Map([[currentTheme.id, currentTheme]]),
|
||||
});
|
||||
|
||||
assert.equal(resolved.id, currentTheme.id);
|
||||
});
|
||||
|
||||
@@ -2,8 +2,20 @@ import {
|
||||
fromEditorTabId,
|
||||
isEditorTabId,
|
||||
} from '../state/activeTabStore';
|
||||
import { applyCustomAccentToTerminalTheme, resolveHostTerminalThemeId } from '../../domain/terminalAppearance';
|
||||
import type { EditorTab } from '../state/editorTabStore';
|
||||
import type { TerminalSession, Workspace } from '../../types';
|
||||
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
|
||||
|
||||
function uniqueTabIds(tabIds: readonly string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const uniqueIds: string[] = [];
|
||||
for (const tabId of tabIds) {
|
||||
if (!tabId || seen.has(tabId)) continue;
|
||||
seen.add(tabId);
|
||||
uniqueIds.push(tabId);
|
||||
}
|
||||
return uniqueIds;
|
||||
}
|
||||
|
||||
export function isRootPageTabId(activeTabId: string): boolean {
|
||||
return activeTabId === 'vault' || activeTabId === 'sftp';
|
||||
@@ -13,13 +25,42 @@ export function buildOrderedWorkTabIds(
|
||||
tabOrder: readonly string[],
|
||||
allTabIds: readonly string[],
|
||||
): string[] {
|
||||
const allTabIdSet = new Set(allTabIds);
|
||||
const orderedIds = tabOrder.filter((id) => allTabIdSet.has(id));
|
||||
const uniqueAllTabIds = uniqueTabIds(allTabIds);
|
||||
const allTabIdSet = new Set(uniqueAllTabIds);
|
||||
const orderedIds = uniqueTabIds(tabOrder.filter((id) => allTabIdSet.has(id)));
|
||||
const orderedIdSet = new Set(orderedIds);
|
||||
const newIds = allTabIds.filter((id) => !orderedIdSet.has(id));
|
||||
const newIds = uniqueAllTabIds.filter((id) => !orderedIdSet.has(id));
|
||||
return [...orderedIds, ...newIds];
|
||||
}
|
||||
|
||||
export function reorderWorkTabIds(
|
||||
tabOrder: readonly string[],
|
||||
allTabIds: readonly string[],
|
||||
draggedId: string,
|
||||
targetId: string,
|
||||
position: 'before' | 'after' = 'before',
|
||||
): string[] {
|
||||
if (draggedId === targetId) return buildOrderedWorkTabIds(tabOrder, allTabIds);
|
||||
|
||||
const currentOrder = buildOrderedWorkTabIds(tabOrder, allTabIds);
|
||||
const draggedIndex = currentOrder.indexOf(draggedId);
|
||||
const targetIndex = currentOrder.indexOf(targetId);
|
||||
if (draggedIndex === -1 || targetIndex === -1) return [...tabOrder];
|
||||
|
||||
currentOrder.splice(draggedIndex, 1);
|
||||
|
||||
let nextTargetIndex = targetIndex;
|
||||
if (draggedIndex < targetIndex) {
|
||||
nextTargetIndex -= 1;
|
||||
}
|
||||
if (position === 'after') {
|
||||
nextTargetIndex += 1;
|
||||
}
|
||||
|
||||
currentOrder.splice(nextTargetIndex, 0, draggedId);
|
||||
return currentOrder;
|
||||
}
|
||||
|
||||
export function isHostTreeWorkTabSurface({
|
||||
enabled,
|
||||
activeTabId,
|
||||
@@ -85,3 +126,28 @@ export function resolveWorkTabActiveHostId({
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveWorkTabHostTreeTheme({
|
||||
activeHostId,
|
||||
accentMode,
|
||||
currentTerminalTheme,
|
||||
customAccent,
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
themeById,
|
||||
}: {
|
||||
activeHostId: string | null;
|
||||
accentMode: 'theme' | 'custom';
|
||||
currentTerminalTheme: TerminalTheme;
|
||||
customAccent: string;
|
||||
followAppTerminalTheme: boolean;
|
||||
hostById: ReadonlyMap<string, Host>;
|
||||
themeById: ReadonlyMap<string, TerminalTheme>;
|
||||
}): TerminalTheme {
|
||||
if (!activeHostId || followAppTerminalTheme) return currentTerminalTheme;
|
||||
|
||||
const host = hostById.get(activeHostId) ?? null;
|
||||
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
|
||||
const baseTheme = themeById.get(themeId) ?? currentTerminalTheme;
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ import type { Messages } from '../types';
|
||||
export const enAiMessages: Messages = {
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent Settings',
|
||||
'ai.chat.preparing': 'Preparing…',
|
||||
'ai.title': 'AI',
|
||||
'ai.description': 'Configure AI providers, agents, and safety settings',
|
||||
'ai.providers': 'Providers',
|
||||
'ai.agents': 'Agents',
|
||||
'ai.providers.empty': 'No providers configured. Add a provider to get started.',
|
||||
'ai.providers.add': 'Add Provider',
|
||||
'ai.providers.active': 'Active',
|
||||
@@ -265,6 +267,11 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.slashNoResults': 'No matching commands',
|
||||
'ai.chat.slashEmptyHint': 'Add prompts in Settings → AI → Quick Messages.',
|
||||
|
||||
// AI Chat Shortcuts
|
||||
'ai.chatShortcuts.title': 'Chat Shortcuts',
|
||||
'ai.chatShortcuts.selectionAction': 'Show Add to Conversation when selecting terminal text',
|
||||
'ai.chatShortcuts.selectionAction.description': 'Show a small AI button next to selected terminal text.',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Codex main-process handlers are not loaded yet. Fully restart Netcatty, or restart the Electron dev process, then try again.',
|
||||
|
||||
|
||||
@@ -312,6 +312,15 @@ export const enCoreMessages: Messages = {
|
||||
'settings.terminal.font.size.desc': 'Terminal text size',
|
||||
'settings.terminal.font.weight': 'Font weight',
|
||||
'settings.terminal.font.weight.desc': 'Weight for regular text (100-900)',
|
||||
'settings.terminal.font.weight.thin': 'Thin',
|
||||
'settings.terminal.font.weight.extraLight': 'Extra Light',
|
||||
'settings.terminal.font.weight.light': 'Light',
|
||||
'settings.terminal.font.weight.normal': 'Normal',
|
||||
'settings.terminal.font.weight.medium': 'Medium',
|
||||
'settings.terminal.font.weight.semiBold': 'Semi Bold',
|
||||
'settings.terminal.font.weight.bold': 'Bold',
|
||||
'settings.terminal.font.weight.extraBold': 'Extra Bold',
|
||||
'settings.terminal.font.weight.black': 'Black',
|
||||
'settings.terminal.font.weightBold': 'Bold font weight',
|
||||
'settings.terminal.font.weightBold.desc': 'Weight for bold text (100-900)',
|
||||
'settings.terminal.font.linePadding': 'Line padding',
|
||||
@@ -341,6 +350,11 @@ export const enCoreMessages: Messages = {
|
||||
'settings.terminal.behavior.middleClickPaste': 'Middle-click paste',
|
||||
'settings.terminal.behavior.middleClickPaste.desc':
|
||||
'Paste clipboard content on middle-click',
|
||||
'settings.terminal.behavior.middleClick': 'Middle-click behavior',
|
||||
'settings.terminal.behavior.middleClick.desc': 'Action when middle-clicking in terminal',
|
||||
'settings.terminal.behavior.middleClick.menu': 'Show menu',
|
||||
'settings.terminal.behavior.middleClick.paste': 'Paste',
|
||||
'settings.terminal.behavior.middleClick.disabled': 'Do nothing',
|
||||
'settings.terminal.behavior.bracketedPaste': 'Bracketed paste mode',
|
||||
'settings.terminal.behavior.bracketedPaste.desc':
|
||||
'Wrap pasted text with escape sequences so the shell can distinguish paste from typed input. Disable if you see ^[[200~ artifacts.',
|
||||
@@ -476,6 +490,8 @@ export const enCoreMessages: Messages = {
|
||||
'settings.shortcuts.scheme.disabled': 'Disabled',
|
||||
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
|
||||
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
|
||||
'settings.shortcuts.disableTerminalFontZoom.label': 'Disable terminal zoom',
|
||||
'settings.shortcuts.disableTerminalFontZoom.desc': 'Turn off terminal font zoom shortcuts, including Cmd/Ctrl + mouse wheel.',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': 'Number keys skip pinned tabs',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': 'When enabled, Cmd/Ctrl+[1...9] switches only work tabs (terminals, workspaces, editors), not the pinned Vault or SFTP tabs.',
|
||||
'settings.shortcuts.section.custom': 'Custom Shortcuts',
|
||||
|
||||
@@ -44,6 +44,8 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.processes.elapsed': 'Elapsed',
|
||||
'systemManager.processes.stat': 'State',
|
||||
'systemManager.processes.meta': '{{count}} process(es)',
|
||||
'systemManager.processes.loading': 'Loading processes…',
|
||||
'systemManager.processes.loadingMore': 'Loading more processes…',
|
||||
'systemManager.processes.state.running': 'Running',
|
||||
'systemManager.processes.state.sleeping': 'Sleeping',
|
||||
'systemManager.processes.state.stopped': 'Stopped',
|
||||
@@ -55,6 +57,10 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.processes.sort.user': 'User',
|
||||
|
||||
'systemManager.common.dismiss': 'Dismiss',
|
||||
'systemManager.common.checkingAvailability': 'Checking availability…',
|
||||
'systemManager.common.loading': 'Loading…',
|
||||
'systemManager.common.loadingDetails': 'Loading details…',
|
||||
'systemManager.common.loadingStats': 'Loading stats…',
|
||||
|
||||
'systemManager.tmux.new': 'New',
|
||||
'systemManager.tmux.search': 'Search sessions…',
|
||||
|
||||
@@ -6,6 +6,7 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.toolbar.openSftp': 'Open SFTP',
|
||||
'terminal.toolbar.availableAfterConnect': 'Available after connect',
|
||||
'terminal.toolbar.sendYmodem': 'Send with YMODEM',
|
||||
'terminal.toolbar.receiveYmodem': 'Receive with YMODEM',
|
||||
'terminal.toolbar.sftp': 'SFTP',
|
||||
'terminal.toolbar.more': 'More actions',
|
||||
'terminal.toolbar.scripts': 'Scripts',
|
||||
@@ -30,6 +31,8 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.toolbar.terminalSettings': 'Terminal settings',
|
||||
'terminal.toolbar.searchTerminal': 'Search terminal',
|
||||
'terminal.toolbar.search': 'Search',
|
||||
'terminal.toolbar.timestampsEnable': 'Show timestamps',
|
||||
'terminal.toolbar.timestampsDisable': 'Hide timestamps',
|
||||
'terminal.toolbar.broadcast': 'Broadcast',
|
||||
'terminal.toolbar.broadcastEnable': 'Enable Broadcast Mode',
|
||||
'terminal.toolbar.broadcastDisable': 'Disable Broadcast Mode',
|
||||
@@ -48,6 +51,7 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.composeBar.snippetClickHint': 'Click to insert · Shift+Click to send',
|
||||
'terminal.toolbar.focus': 'Focus',
|
||||
'terminal.toolbar.focusMode': 'Focus Mode',
|
||||
'terminal.toolbar.detach': 'Detach to standalone tab',
|
||||
'terminal.toolbar.encoding': 'Terminal Encoding',
|
||||
'terminal.toolbar.encoding.utf8': 'UTF-8',
|
||||
'terminal.toolbar.encoding.gb18030': 'GB18030',
|
||||
@@ -86,7 +90,9 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.dragDrop.localTitle': 'Drop to Insert Paths',
|
||||
'terminal.dragDrop.localMessage': 'File paths will be inserted into the terminal',
|
||||
'terminal.dragDrop.remoteTitle': 'Drop to Upload Files',
|
||||
'terminal.dragDrop.remoteMessage': 'Files will be uploaded via SFTP',
|
||||
'terminal.dragDrop.remoteZmodemMessage': 'Files will be uploaded via ZMODEM (PTY)',
|
||||
'terminal.dragDrop.remoteSftpMessage': 'Files will be uploaded via SFTP',
|
||||
'terminal.dragDrop.noFiles': 'No files to upload',
|
||||
'terminal.dragDrop.notConnected': 'Cannot drop files - terminal is not connected',
|
||||
'terminal.dragDrop.errorTitle': 'Drop Error',
|
||||
'terminal.dragDrop.errorMessage': 'Failed to process dropped files',
|
||||
@@ -101,15 +107,25 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.menu.selectAll': 'Select All',
|
||||
'terminal.menu.reconnect': 'Reconnect',
|
||||
'terminal.menu.sendYmodem': 'Send with YMODEM',
|
||||
'terminal.menu.receiveYmodem': 'Receive with YMODEM',
|
||||
'terminal.menu.splitHorizontal': 'Split Horizontal',
|
||||
'terminal.menu.splitVertical': 'Split Vertical',
|
||||
'terminal.menu.clearBuffer': 'Clear Buffer',
|
||||
'terminal.menu.closeTerminal': 'Close terminal',
|
||||
'terminal.menu.rename': 'Rename',
|
||||
'terminal.menu.detach': 'Detach from workspace',
|
||||
'terminal.menu.detachSession': 'Detach {name}',
|
||||
'terminal.ymodem.selectFile': 'Select file to send',
|
||||
'terminal.ymodem.allFiles': 'All files',
|
||||
'terminal.ymodem.started': 'YMODEM sending {fileName}',
|
||||
'terminal.ymodem.complete': 'YMODEM sent {fileName}',
|
||||
'terminal.ymodem.failed': 'YMODEM send failed',
|
||||
'terminal.ymodem.selectReceiveDirectory': 'Select folder to save received files',
|
||||
'terminal.ymodem.receiveStarted': 'YMODEM receiving...',
|
||||
'terminal.ymodem.receiveComplete': 'YMODEM received {fileName}',
|
||||
'terminal.ymodem.receiveCompleteMultiple': 'YMODEM received {count} files',
|
||||
'terminal.ymodem.receiveEmpty': 'No YMODEM files received',
|
||||
'terminal.ymodem.receiveFailed': 'YMODEM receive failed',
|
||||
'terminal.ymodem.unavailable': 'YMODEM is unavailable',
|
||||
'terminal.selection.addToAI': 'Add to Conversation',
|
||||
'terminal.selection.addToAIDesc': 'Attach selected terminal output to the AI draft',
|
||||
|
||||
@@ -258,6 +258,8 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.tabs.addTab': 'Add new tab',
|
||||
'sftp.tabs.closeTab': 'Close tab',
|
||||
'sftp.tabs.newTab': 'New Tab',
|
||||
'sftp.tabs.copyDefaultPath': 'Copy tab (default path)',
|
||||
'sftp.tabs.copyCurrentPath': 'Copy and go to current path',
|
||||
'sftp.conflict.title': 'File Conflict',
|
||||
'sftp.conflict.desc': 'A file with the same name already exists at the destination',
|
||||
'sftp.conflict.alreadyExistsSuffix': 'already exists',
|
||||
@@ -483,6 +485,7 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
|
||||
'hostDetails.distro.option.almalinux': 'AlmaLinux',
|
||||
'hostDetails.distro.option.alinux': 'Alibaba Cloud Linux',
|
||||
'hostDetails.distro.option.openeuler': 'openEuler',
|
||||
'hostDetails.distro.option.oracle': 'Oracle Linux',
|
||||
'hostDetails.distro.option.kali': 'Kali Linux',
|
||||
'hostDetails.distro.option.cisco': 'Cisco',
|
||||
@@ -530,8 +533,8 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.deviceType.warning': 'AI agent commands will be sent directly without exit code tracking. Only enable for devices that do not run a standard shell.',
|
||||
'hostDetails.section.sshAlgorithms': 'SSH Algorithms',
|
||||
'hostDetails.section.terminalBehavior': 'Terminal Behavior',
|
||||
'hostDetails.lineTimestamps': 'Prefix output with timestamps',
|
||||
'hostDetails.lineTimestamps.desc': 'Add local time before visible output lines for this host. Disable it for prompts that render incorrectly when output is prefixed.',
|
||||
'hostDetails.lineTimestamps': 'Show output timestamps',
|
||||
'hostDetails.lineTimestamps.desc': 'Show local time beside visible output lines for this host without changing terminal text.',
|
||||
'hostDetails.legacyAlgorithms': 'Allow Legacy Algorithms',
|
||||
'hostDetails.legacyAlgorithms.desc': 'Enable deprecated SSH algorithms (diffie-hellman-group1, ssh-dss, 3des-cbc, etc.) for connecting to older network equipment.',
|
||||
'hostDetails.legacyAlgorithms.warning': 'These algorithms have known security weaknesses. Only enable for legacy devices that do not support modern cryptography.',
|
||||
|
||||
@@ -3,9 +3,11 @@ import type { Messages } from '../types';
|
||||
export const ruAiMessages: Messages = {
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Настройки агента',
|
||||
'ai.chat.preparing': 'Подготовка…',
|
||||
'ai.title': 'AI',
|
||||
'ai.description': 'Настройка AI-провайдеров, агентов и параметров безопасности',
|
||||
'ai.providers': 'Провайдеры',
|
||||
'ai.agents': 'Агенты',
|
||||
'ai.providers.empty': 'Провайдеры не настроены. Добавьте провайдера, чтобы начать.',
|
||||
'ai.providers.add': 'Добавить провайдера',
|
||||
'ai.providers.active': 'Активен',
|
||||
@@ -265,6 +267,11 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.slashNoResults': 'Нет подходящих команд',
|
||||
'ai.chat.slashEmptyHint': 'Добавьте подсказки в Настройки → AI → Быстрые сообщения.',
|
||||
|
||||
// AI Chat Shortcuts
|
||||
'ai.chatShortcuts.title': 'Быстрые действия чата',
|
||||
'ai.chatShortcuts.selectionAction': 'Показывать «Добавить в чат» при выделении в терминале',
|
||||
'ai.chatShortcuts.selectionAction.description': 'Показывать небольшую кнопку AI рядом с выделенным текстом терминала.',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Обработчики главного процесса Codex ещё не загружены. Полностью перезапустите Netcatty или dev-процесс Electron и попробуйте снова.',
|
||||
|
||||
|
||||
@@ -312,6 +312,15 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.font.size.desc': 'Размер текста терминала',
|
||||
'settings.terminal.font.weight': 'Толщина шрифта',
|
||||
'settings.terminal.font.weight.desc': 'Толщина обычного текста (100-900)',
|
||||
'settings.terminal.font.weight.thin': 'Тонкий',
|
||||
'settings.terminal.font.weight.extraLight': 'Очень светлый',
|
||||
'settings.terminal.font.weight.light': 'Светлый',
|
||||
'settings.terminal.font.weight.normal': 'Обычный',
|
||||
'settings.terminal.font.weight.medium': 'Средний',
|
||||
'settings.terminal.font.weight.semiBold': 'Полужирный',
|
||||
'settings.terminal.font.weight.bold': 'Жирный',
|
||||
'settings.terminal.font.weight.extraBold': 'Очень жирный',
|
||||
'settings.terminal.font.weight.black': 'Максимально жирный',
|
||||
'settings.terminal.font.weightBold': 'Толщина жирного шрифта',
|
||||
'settings.terminal.font.weightBold.desc': 'Толщина жирного текста (100-900)',
|
||||
'settings.terminal.font.linePadding': 'Межстрочный отступ',
|
||||
@@ -341,6 +350,11 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.behavior.middleClickPaste': 'Вставка средней кнопкой мыши',
|
||||
'settings.terminal.behavior.middleClickPaste.desc':
|
||||
'Вставлять содержимое буфера обмена по щелчку средней кнопкой',
|
||||
'settings.terminal.behavior.middleClick': 'Поведение средней кнопки мыши',
|
||||
'settings.terminal.behavior.middleClick.desc': 'Действие при щелчке средней кнопкой в терминале',
|
||||
'settings.terminal.behavior.middleClick.menu': 'Показать меню',
|
||||
'settings.terminal.behavior.middleClick.paste': 'Вставить',
|
||||
'settings.terminal.behavior.middleClick.disabled': 'Ничего не делать',
|
||||
'settings.terminal.behavior.bracketedPaste': 'Режим bracketed paste',
|
||||
'settings.terminal.behavior.bracketedPaste.desc':
|
||||
'Оборачивать вставляемый текст escape-последовательностями, чтобы оболочка отличала вставку от обычного ввода. Отключите, если видите артефакты вида ^[[200~.',
|
||||
@@ -476,6 +490,8 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.shortcuts.scheme.disabled': 'Отключено',
|
||||
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
|
||||
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
|
||||
'settings.shortcuts.disableTerminalFontZoom.label': 'Отключить масштаб терминала',
|
||||
'settings.shortcuts.disableTerminalFontZoom.desc': 'Отключает быстрый масштаб текста в терминале, включая Cmd/Ctrl + колесо мыши.',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': 'Цифры без закреплённых вкладок',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': 'Если включено, Cmd/Ctrl+[1...9] переключает только рабочие вкладки (терминалы, рабочие области, редакторы), а не закреплённые Vault и SFTP.',
|
||||
'settings.shortcuts.section.custom': 'Пользовательские сочетания',
|
||||
@@ -493,6 +509,7 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.shortcuts.binding.next-tab': 'Следующая вкладка',
|
||||
'settings.shortcuts.binding.prev-tab': 'Предыдущая вкладка',
|
||||
'settings.shortcuts.binding.close-tab': 'Закрыть вкладку',
|
||||
'settings.shortcuts.binding.close-session': 'Закрыть панель сессии',
|
||||
'settings.shortcuts.binding.new-tab': 'Новая локальная вкладка',
|
||||
'settings.shortcuts.binding.copy': 'Копировать из терминала',
|
||||
'settings.shortcuts.binding.paste': 'Вставить в терминал',
|
||||
@@ -500,9 +517,13 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.shortcuts.binding.select-all': 'Выделить всё содержимое терминала',
|
||||
'settings.shortcuts.binding.clear-buffer': 'Очистить буфер терминала',
|
||||
'settings.shortcuts.binding.search-terminal': 'Открыть поиск по терминалу',
|
||||
'settings.shortcuts.binding.increase-terminal-font-size': 'Увеличить размер шрифта терминала',
|
||||
'settings.shortcuts.binding.decrease-terminal-font-size': 'Уменьшить размер шрифта терминала',
|
||||
'settings.shortcuts.binding.reset-terminal-font-size': 'Сбросить размер шрифта терминала',
|
||||
'settings.shortcuts.binding.move-focus': 'Переместить фокус между разделёнными окнами',
|
||||
'settings.shortcuts.binding.split-horizontal': 'Горизонтальное разделение',
|
||||
'settings.shortcuts.binding.split-vertical': 'Вертикальное разделение',
|
||||
'settings.shortcuts.binding.toggle-pane-zoom': 'Переключить масштаб панели',
|
||||
'settings.shortcuts.binding.open-hosts': 'Открыть список хостов',
|
||||
'settings.shortcuts.binding.open-local': 'Открыть локальный терминал',
|
||||
'settings.shortcuts.binding.open-sftp': 'Открыть SFTP',
|
||||
|
||||
@@ -44,6 +44,8 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.processes.elapsed': 'Время работы',
|
||||
'systemManager.processes.stat': 'Состояние',
|
||||
'systemManager.processes.meta': '{{count}} проц.',
|
||||
'systemManager.processes.loading': 'Загрузка процессов…',
|
||||
'systemManager.processes.loadingMore': 'Загрузка следующих процессов…',
|
||||
'systemManager.processes.state.running': 'Активен',
|
||||
'systemManager.processes.state.sleeping': 'Сон',
|
||||
'systemManager.processes.state.stopped': 'Остановлен',
|
||||
@@ -55,6 +57,10 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.processes.sort.user': 'Пользователь',
|
||||
|
||||
'systemManager.common.dismiss': 'Закрыть',
|
||||
'systemManager.common.checkingAvailability': 'Проверка доступности…',
|
||||
'systemManager.common.loading': 'Загрузка…',
|
||||
'systemManager.common.loadingDetails': 'Загрузка деталей…',
|
||||
'systemManager.common.loadingStats': 'Загрузка статистики…',
|
||||
|
||||
'systemManager.tmux.new': 'Создать',
|
||||
'systemManager.tmux.search': 'Поиск сессий…',
|
||||
|
||||
@@ -27,6 +27,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.toolbar.openSftp': 'Открыть SFTP',
|
||||
'terminal.toolbar.availableAfterConnect': 'Доступно после подключения',
|
||||
'terminal.toolbar.sendYmodem': 'Отправить через YMODEM',
|
||||
'terminal.toolbar.receiveYmodem': 'Получить через YMODEM',
|
||||
'terminal.toolbar.sftp': 'SFTP',
|
||||
'terminal.toolbar.more': 'Другие действия',
|
||||
'terminal.toolbar.scripts': 'Скрипты',
|
||||
@@ -51,6 +52,8 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.toolbar.terminalSettings': 'Настройки терминала',
|
||||
'terminal.toolbar.searchTerminal': 'Поиск по терминалу',
|
||||
'terminal.toolbar.search': 'Поиск',
|
||||
'terminal.toolbar.timestampsEnable': 'Показать время',
|
||||
'terminal.toolbar.timestampsDisable': 'Скрыть время',
|
||||
'terminal.toolbar.broadcast': 'Трансляция',
|
||||
'terminal.toolbar.broadcastEnable': 'Включить режим трансляции',
|
||||
'terminal.toolbar.broadcastDisable': 'Отключить режим трансляции',
|
||||
@@ -69,6 +72,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.composeBar.snippetClickHint': 'Клик — вставить · Shift+клик — отправить',
|
||||
'terminal.toolbar.focus': 'Фокус',
|
||||
'terminal.toolbar.focusMode': 'Режим фокуса',
|
||||
'terminal.toolbar.detach': 'Открепить в отдельную вкладку',
|
||||
'terminal.toolbar.encoding': 'Кодировка терминала',
|
||||
'terminal.toolbar.encoding.utf8': 'UTF-8',
|
||||
'terminal.toolbar.encoding.gb18030': 'GB18030',
|
||||
@@ -107,7 +111,9 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.dragDrop.localTitle': 'Перетащите для вставки путей',
|
||||
'terminal.dragDrop.localMessage': 'Пути к файлам будут вставлены в терминал',
|
||||
'terminal.dragDrop.remoteTitle': 'Перетащите для загрузки файлов',
|
||||
'terminal.dragDrop.remoteMessage': 'Файлы будут загружены через SFTP',
|
||||
'terminal.dragDrop.remoteZmodemMessage': 'Файлы будут загружены через ZMODEM (PTY)',
|
||||
'terminal.dragDrop.remoteSftpMessage': 'Файлы будут загружены через SFTP',
|
||||
'terminal.dragDrop.noFiles': 'Нет файлов для загрузки',
|
||||
'terminal.dragDrop.notConnected': 'Нельзя перетащить файлы — терминал не подключён',
|
||||
'terminal.dragDrop.errorTitle': 'Ошибка перетаскивания',
|
||||
'terminal.dragDrop.errorMessage': 'Не удалось обработать перетащенные файлы',
|
||||
@@ -122,15 +128,25 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.menu.selectAll': 'Выбрать всё',
|
||||
'terminal.menu.reconnect': 'Переподключиться',
|
||||
'terminal.menu.sendYmodem': 'Отправить через YMODEM',
|
||||
'terminal.menu.receiveYmodem': 'Получить через YMODEM',
|
||||
'terminal.menu.splitHorizontal': 'Разделить по горизонтали',
|
||||
'terminal.menu.splitVertical': 'Разделить по вертикали',
|
||||
'terminal.menu.clearBuffer': 'Очистить буфер',
|
||||
'terminal.menu.closeTerminal': 'Закрыть терминал',
|
||||
'terminal.menu.rename': 'Переименовать',
|
||||
'terminal.menu.detach': 'Открепить из рабочей области',
|
||||
'terminal.menu.detachSession': 'Открепить {name}',
|
||||
'terminal.ymodem.selectFile': 'Выберите файл для отправки',
|
||||
'terminal.ymodem.allFiles': 'Все файлы',
|
||||
'terminal.ymodem.started': 'YMODEM отправляет {fileName}',
|
||||
'terminal.ymodem.complete': 'YMODEM отправил {fileName}',
|
||||
'terminal.ymodem.failed': 'Не удалось отправить через YMODEM',
|
||||
'terminal.ymodem.selectReceiveDirectory': 'Выберите папку для полученных файлов',
|
||||
'terminal.ymodem.receiveStarted': 'YMODEM получает...',
|
||||
'terminal.ymodem.receiveComplete': 'YMODEM получил {fileName}',
|
||||
'terminal.ymodem.receiveCompleteMultiple': 'YMODEM получил файлов: {count}',
|
||||
'terminal.ymodem.receiveEmpty': 'Файлы YMODEM не получены',
|
||||
'terminal.ymodem.receiveFailed': 'Не удалось получить через YMODEM',
|
||||
'terminal.ymodem.unavailable': 'YMODEM недоступен',
|
||||
'terminal.selection.addToAI': 'Добавить в чат',
|
||||
'terminal.selection.addToAIDesc': 'Прикрепить выбранный вывод терминала к черновику AI',
|
||||
|
||||
@@ -293,6 +293,8 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.tabs.addTab': 'Добавить новую вкладку',
|
||||
'sftp.tabs.closeTab': 'Закрыть вкладку',
|
||||
'sftp.tabs.newTab': 'Новая вкладка',
|
||||
'sftp.tabs.copyDefaultPath': 'Копировать вкладку (путь по умолчанию)',
|
||||
'sftp.tabs.copyCurrentPath': 'Копировать и перейти к текущему пути',
|
||||
'sftp.conflict.title': 'Конфликт файлов',
|
||||
'sftp.conflict.desc': 'В месте назначения уже существует файл с таким именем',
|
||||
'sftp.conflict.alreadyExistsSuffix': 'уже существует',
|
||||
@@ -518,6 +520,7 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
|
||||
'hostDetails.distro.option.almalinux': 'AlmaLinux',
|
||||
'hostDetails.distro.option.alinux': 'Alibaba Cloud Linux',
|
||||
'hostDetails.distro.option.openeuler': 'openEuler',
|
||||
'hostDetails.distro.option.oracle': 'Oracle Linux',
|
||||
'hostDetails.distro.option.kali': 'Kali Linux',
|
||||
'hostDetails.distro.option.cisco': 'Cisco',
|
||||
@@ -562,8 +565,8 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.deviceType.warning': 'Команды AI-агента будут отправляться напрямую без отслеживания кода выхода. Включайте только для устройств, на которых нет стандартной оболочки.',
|
||||
'hostDetails.section.sshAlgorithms': 'SSH-алгоритмы',
|
||||
'hostDetails.section.terminalBehavior': 'Поведение терминала',
|
||||
'hostDetails.lineTimestamps': 'Добавлять время к выводу',
|
||||
'hostDetails.lineTimestamps.desc': 'Добавлять локальное время перед видимыми строками вывода только для этого хоста. Отключите, если из-за этого некорректно отображается приглашение.',
|
||||
'hostDetails.lineTimestamps': 'Показывать время вывода',
|
||||
'hostDetails.lineTimestamps.desc': 'Показывать локальное время рядом с видимыми строками вывода для этого хоста, не изменяя текст терминала.',
|
||||
'hostDetails.legacyAlgorithms': 'Разрешить устаревшие алгоритмы',
|
||||
'hostDetails.legacyAlgorithms.desc': 'Включить устаревшие SSH-алгоритмы (diffie-hellman-group1, ssh-dss, 3des-cbc и т. д.) для подключения к старому сетевому оборудованию.',
|
||||
'hostDetails.legacyAlgorithms.warning': 'У этих алгоритмов есть известные слабые места безопасности. Включайте только для устаревших устройств, которые не поддерживают современную криптографию.',
|
||||
|
||||
55
application/i18n/locales/settingsLocales.test.ts
Normal file
55
application/i18n/locales/settingsLocales.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { DEFAULT_KEY_BINDINGS } from "../../../domain/models/keyBindings.ts";
|
||||
import zhCN from "./zh-CN.ts";
|
||||
import ru from "./ru.ts";
|
||||
|
||||
const LOCALIZED_SETTINGS_LOCALES = [
|
||||
{ name: "zh-CN", messages: zhCN },
|
||||
{ name: "ru", messages: ru },
|
||||
];
|
||||
|
||||
test("localized settings include names for every default shortcut", () => {
|
||||
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
|
||||
const missing = DEFAULT_KEY_BINDINGS
|
||||
.map((binding) => `settings.shortcuts.binding.${binding.id}`)
|
||||
.filter((key) => !locale.messages[key]);
|
||||
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing shortcut labels`);
|
||||
}
|
||||
});
|
||||
|
||||
test("localized settings include workspace focus indicator labels", () => {
|
||||
const keys = [
|
||||
"settings.terminal.section.workspaceFocus",
|
||||
"settings.terminal.workspaceFocus.style",
|
||||
"settings.terminal.workspaceFocus.style.desc",
|
||||
"settings.terminal.workspaceFocus.dim",
|
||||
"settings.terminal.workspaceFocus.border",
|
||||
];
|
||||
|
||||
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
|
||||
const missing = keys.filter((key) => !locale.messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing workspace focus labels`);
|
||||
}
|
||||
});
|
||||
|
||||
test("localized settings include terminal font weight option labels", () => {
|
||||
const keys = [
|
||||
"settings.terminal.font.weight.thin",
|
||||
"settings.terminal.font.weight.extraLight",
|
||||
"settings.terminal.font.weight.light",
|
||||
"settings.terminal.font.weight.normal",
|
||||
"settings.terminal.font.weight.medium",
|
||||
"settings.terminal.font.weight.semiBold",
|
||||
"settings.terminal.font.weight.bold",
|
||||
"settings.terminal.font.weight.extraBold",
|
||||
"settings.terminal.font.weight.black",
|
||||
];
|
||||
|
||||
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
|
||||
const missing = keys.filter((key) => !locale.messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing font weight labels`);
|
||||
}
|
||||
});
|
||||
@@ -3,9 +3,11 @@ import type { Messages } from '../types';
|
||||
export const zhCNAiMessages: Messages = {
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent 设置',
|
||||
'ai.chat.preparing': '准备中…',
|
||||
'ai.title': 'AI',
|
||||
'ai.description': '配置 AI 提供商、Agent 和安全设置',
|
||||
'ai.providers': '提供商',
|
||||
'ai.agents': 'Agent',
|
||||
'ai.providers.empty': '尚未配置提供商。添加一个提供商以开始使用。',
|
||||
'ai.providers.add': '添加提供商',
|
||||
'ai.providers.active': '活跃',
|
||||
@@ -265,6 +267,11 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.slashNoResults': '没有匹配的命令',
|
||||
'ai.chat.slashEmptyHint': '可在 设置 → AI → 快捷消息 中添加常用提示词。',
|
||||
|
||||
// AI 聊天快捷入口
|
||||
'ai.chatShortcuts.title': '聊天快捷入口',
|
||||
'ai.chatShortcuts.selectionAction': '选中终端内容时显示“添加到对话”',
|
||||
'ai.chatShortcuts.selectionAction.description': '在终端里选中文本后显示 AI 快捷按钮。',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Codex 主进程处理器尚未加载。请完全重启 Netcatty 或重启 Electron 开发进程,然后重试。',
|
||||
|
||||
|
||||
@@ -44,6 +44,8 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.processes.elapsed': '运行时长',
|
||||
'systemManager.processes.stat': '状态',
|
||||
'systemManager.processes.meta': '{{count}} 个进程',
|
||||
'systemManager.processes.loading': '正在加载进程…',
|
||||
'systemManager.processes.loadingMore': '正在显示更多进程…',
|
||||
'systemManager.processes.state.running': '运行中',
|
||||
'systemManager.processes.state.sleeping': '睡眠',
|
||||
'systemManager.processes.state.stopped': '已暂停',
|
||||
@@ -55,6 +57,10 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.processes.sort.user': '用户',
|
||||
|
||||
'systemManager.common.dismiss': '关闭',
|
||||
'systemManager.common.checkingAvailability': '正在检查可用状态…',
|
||||
'systemManager.common.loading': '正在加载…',
|
||||
'systemManager.common.loadingDetails': '正在加载详情…',
|
||||
'systemManager.common.loadingStats': '正在加载性能数据…',
|
||||
|
||||
'systemManager.tmux.new': '新建',
|
||||
'systemManager.tmux.search': '搜索会话…',
|
||||
|
||||
@@ -2,6 +2,11 @@ import type { Messages } from '../types';
|
||||
|
||||
export const zhCNTerminalMessages: Messages = {
|
||||
'terminal.sudoHint.pressEnter': '按 Enter 粘贴 sudo 密码',
|
||||
'terminal.menu.rename': '重命名',
|
||||
'terminal.toolbar.detach': '移出到独立标签',
|
||||
'terminal.menu.detach': '从工作区移出',
|
||||
'terminal.toolbar.timestampsEnable': '显示时间戳',
|
||||
'terminal.toolbar.timestampsDisable': '隐藏时间戳',
|
||||
'terminal.connection.protocol.et': 'EternalTerminal',
|
||||
'terminal.et.proxyUnsupported': 'EternalTerminal 目前不支持 Netcatty 的代理设置。请改用 SSH,或移除该主机的代理。',
|
||||
'terminal.et.multiJumpUnsupported': 'EternalTerminal 目前在 Netcatty 中最多支持一个跳板机。',
|
||||
@@ -185,6 +190,15 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.font.size.desc': '终端文字大小',
|
||||
'settings.terminal.font.weight': '字重',
|
||||
'settings.terminal.font.weight.desc': '常规文本字重 (100-900)',
|
||||
'settings.terminal.font.weight.thin': '极细',
|
||||
'settings.terminal.font.weight.extraLight': '特细',
|
||||
'settings.terminal.font.weight.light': '细',
|
||||
'settings.terminal.font.weight.normal': '常规',
|
||||
'settings.terminal.font.weight.medium': '中等',
|
||||
'settings.terminal.font.weight.semiBold': '半粗',
|
||||
'settings.terminal.font.weight.bold': '粗',
|
||||
'settings.terminal.font.weight.extraBold': '特粗',
|
||||
'settings.terminal.font.weight.black': '黑体',
|
||||
'settings.terminal.font.weightBold': '粗体字重',
|
||||
'settings.terminal.font.weightBold.desc': '粗体文本字重 (100-900)',
|
||||
'settings.terminal.font.linePadding': '行间距',
|
||||
@@ -210,6 +224,11 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.behavior.copyOnSelect.desc': '自动复制选中的文本。在 tmux/vim 鼠标模式下,macOS 按住 Option,Windows/Linux 按住 Shift 拖选即可选中文本',
|
||||
'settings.terminal.behavior.middleClickPaste': '中键粘贴',
|
||||
'settings.terminal.behavior.middleClickPaste.desc': '中键点击时粘贴剪贴板内容',
|
||||
'settings.terminal.behavior.middleClick': '中键行为',
|
||||
'settings.terminal.behavior.middleClick.desc': '在终端中点击鼠标中键时执行的操作',
|
||||
'settings.terminal.behavior.middleClick.menu': '显示菜单',
|
||||
'settings.terminal.behavior.middleClick.paste': '粘贴',
|
||||
'settings.terminal.behavior.middleClick.disabled': '无动作',
|
||||
'settings.terminal.behavior.bracketedPaste': '括号粘贴模式',
|
||||
'settings.terminal.behavior.bracketedPaste.desc':
|
||||
'粘贴文本时使用转义序列包裹,以便终端区分粘贴和键入。如果出现 ^[[200~ 字样请关闭此选项。',
|
||||
@@ -318,6 +337,13 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.rendering.renderer.desc': '选择终端渲染技术。自动模式会在低内存设备上使用 DOM 渲染。更改将在新终端会话中生效。',
|
||||
'settings.terminal.rendering.auto': '自动',
|
||||
|
||||
// Settings > Terminal > Workspace Focus Indicator
|
||||
'settings.terminal.section.workspaceFocus': '工作区焦点提示',
|
||||
'settings.terminal.workspaceFocus.style': '焦点提示样式',
|
||||
'settings.terminal.workspaceFocus.style.desc': '在分屏视图中如何标识当前聚焦的窗格。',
|
||||
'settings.terminal.workspaceFocus.dim': '淡化未聚焦窗格',
|
||||
'settings.terminal.workspaceFocus.border': '为聚焦窗格显示边框',
|
||||
|
||||
// Settings > Terminal > Autocomplete
|
||||
'settings.terminal.section.autocomplete': '自动补全',
|
||||
'settings.terminal.autocomplete.enabled': '启用自动补全',
|
||||
@@ -334,6 +360,8 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.shortcuts.scheme.disabled': '禁用',
|
||||
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
|
||||
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
|
||||
'settings.shortcuts.disableTerminalFontZoom.label': '禁用终端缩放',
|
||||
'settings.shortcuts.disableTerminalFontZoom.desc': '关闭终端文字缩放快捷操作,包括 Cmd/Ctrl 加滚轮。',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': '数字键跳过固定标签',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': '开启后,Cmd/Ctrl+[1...9] 仅在终端、工作区、编辑器等可关闭标签页之间切换,不包括固定的 Vault 和 SFTP 标签页。',
|
||||
'settings.shortcuts.section.custom': '自定义快捷键',
|
||||
@@ -350,18 +378,25 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.shortcuts.binding.next-tab': '下一个标签页',
|
||||
'settings.shortcuts.binding.prev-tab': '上一个标签页',
|
||||
'settings.shortcuts.binding.close-tab': '关闭标签页',
|
||||
'settings.shortcuts.binding.close-session': '关闭会话窗格',
|
||||
'settings.shortcuts.binding.new-tab': '新建本地标签页',
|
||||
'settings.shortcuts.binding.copy': '从终端复制',
|
||||
'settings.shortcuts.binding.paste': '粘贴到终端',
|
||||
'settings.shortcuts.binding.paste-selection': '将选区粘贴到终端',
|
||||
'settings.shortcuts.binding.select-all': '全选终端内容',
|
||||
'settings.shortcuts.binding.clear-buffer': '清空终端缓冲区',
|
||||
'settings.shortcuts.binding.search-terminal': '打开终端搜索',
|
||||
'settings.shortcuts.binding.increase-terminal-font-size': '增大终端字号',
|
||||
'settings.shortcuts.binding.decrease-terminal-font-size': '减小终端字号',
|
||||
'settings.shortcuts.binding.reset-terminal-font-size': '重置终端字号',
|
||||
'settings.shortcuts.binding.move-focus': '在分屏间移动焦点',
|
||||
'settings.shortcuts.binding.split-horizontal': '水平分屏',
|
||||
'settings.shortcuts.binding.split-vertical': '垂直分屏',
|
||||
'settings.shortcuts.binding.toggle-pane-zoom': '切换窗格缩放',
|
||||
'settings.shortcuts.binding.open-hosts': '打开主机列表',
|
||||
'settings.shortcuts.binding.open-local': '打开本地终端',
|
||||
'settings.shortcuts.binding.open-sftp': '打开 SFTP',
|
||||
'settings.shortcuts.binding.open-settings': '打开设置',
|
||||
'settings.shortcuts.binding.port-forwarding': '打开端口转发',
|
||||
'settings.shortcuts.binding.command-palette': '打开命令面板',
|
||||
'settings.shortcuts.binding.quick-switch': '快速切换',
|
||||
@@ -377,6 +412,9 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.shortcuts.binding.sftp-delete': '删除文件',
|
||||
'settings.shortcuts.binding.sftp-refresh': '刷新',
|
||||
'settings.shortcuts.binding.sftp-new-folder': '新建文件夹',
|
||||
'settings.shortcuts.binding.sftp-open': '打开文件 / 进入目录',
|
||||
'settings.shortcuts.binding.sftp-go-parent': '转到上级目录',
|
||||
'settings.shortcuts.binding.sftp-navigate-to': '转到选中的目录',
|
||||
|
||||
// Host Details (sub-panels)
|
||||
'hostDetails.proxyPanel.title': '通过 HTTP/SOCKS5/命令代理',
|
||||
|
||||
@@ -66,6 +66,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
|
||||
'hostDetails.distro.option.almalinux': 'AlmaLinux',
|
||||
'hostDetails.distro.option.alinux': '阿里云 Linux',
|
||||
'hostDetails.distro.option.openeuler': 'openEuler',
|
||||
'hostDetails.distro.option.oracle': 'Oracle Linux',
|
||||
'hostDetails.distro.option.kali': 'Kali Linux',
|
||||
'hostDetails.distro.option.cisco': '思科',
|
||||
@@ -113,8 +114,8 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.deviceType.warning': 'AI 代理命令将直接发送,无法获取退出码。仅建议在设备不运行标准 Shell 时启用。',
|
||||
'hostDetails.section.sshAlgorithms': 'SSH 算法',
|
||||
'hostDetails.section.terminalBehavior': '终端行为',
|
||||
'hostDetails.lineTimestamps': '给输出加时间戳',
|
||||
'hostDetails.lineTimestamps.desc': '仅为这个主机的终端输出行添加本地时间。如果提示符因此渲染异常,请关闭。',
|
||||
'hostDetails.lineTimestamps': '显示输出时间',
|
||||
'hostDetails.lineTimestamps.desc': '在终端输出行旁边显示本地时间,不改变终端文本内容。',
|
||||
'hostDetails.legacyAlgorithms': '允许旧版算法',
|
||||
'hostDetails.legacyAlgorithms.desc': '启用已弃用的 SSH 算法(diffie-hellman-group1、ssh-dss、3des-cbc 等)以连接老旧网络设备。',
|
||||
'hostDetails.legacyAlgorithms.warning': '这些算法存在已知安全漏洞,仅建议在老旧设备不支持现代加密时启用。',
|
||||
@@ -216,6 +217,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.toolbar.openSftp': '打开 SFTP',
|
||||
'terminal.toolbar.availableAfterConnect': '连接后可用',
|
||||
'terminal.toolbar.sendYmodem': 'YMODEM 发送',
|
||||
'terminal.toolbar.receiveYmodem': 'YMODEM 接收',
|
||||
'terminal.toolbar.sftp': 'SFTP',
|
||||
'terminal.toolbar.more': '更多操作',
|
||||
'terminal.toolbar.scripts': '脚本',
|
||||
@@ -243,6 +245,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.composeBar.snippetClickHint': '单击插入 · Shift+单击直接发送',
|
||||
'terminal.toolbar.focus': '聚焦',
|
||||
'terminal.toolbar.focusMode': '聚焦模式',
|
||||
'terminal.toolbar.detach': '移出到独立标签',
|
||||
'terminal.toolbar.encoding': '终端编码',
|
||||
'terminal.toolbar.encoding.utf8': 'UTF-8',
|
||||
'terminal.toolbar.encoding.gb18030': 'GB18030',
|
||||
@@ -281,7 +284,9 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.dragDrop.localTitle': '拖放以插入路径',
|
||||
'terminal.dragDrop.localMessage': '文件路径将被插入到终端',
|
||||
'terminal.dragDrop.remoteTitle': '拖放以上传文件',
|
||||
'terminal.dragDrop.remoteMessage': '文件将通过 SFTP 上传',
|
||||
'terminal.dragDrop.remoteZmodemMessage': '文件将通过 ZMODEM(PTY)上传',
|
||||
'terminal.dragDrop.remoteSftpMessage': '文件将通过 SFTP 上传',
|
||||
'terminal.dragDrop.noFiles': '没有可上传的文件',
|
||||
'terminal.dragDrop.notConnected': '无法拖放文件 - 终端未连接',
|
||||
'terminal.dragDrop.errorTitle': '拖放错误',
|
||||
'terminal.dragDrop.errorMessage': '处理拖放文件失败',
|
||||
@@ -296,15 +301,25 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.menu.selectAll': '全选',
|
||||
'terminal.menu.reconnect': '重新连接',
|
||||
'terminal.menu.sendYmodem': 'YMODEM 发送',
|
||||
'terminal.menu.receiveYmodem': 'YMODEM 接收',
|
||||
'terminal.menu.splitHorizontal': '水平分屏',
|
||||
'terminal.menu.splitVertical': '垂直分屏',
|
||||
'terminal.menu.clearBuffer': '清空缓冲区',
|
||||
'terminal.menu.closeTerminal': '关闭终端',
|
||||
'terminal.menu.rename': '重命名',
|
||||
'terminal.menu.detach': '从工作区移出',
|
||||
'terminal.menu.detachSession': '移出 {name}',
|
||||
'terminal.ymodem.selectFile': '选择要发送的文件',
|
||||
'terminal.ymodem.allFiles': '所有文件',
|
||||
'terminal.ymodem.started': '正在通过 YMODEM 发送 {fileName}',
|
||||
'terminal.ymodem.complete': 'YMODEM 已发送 {fileName}',
|
||||
'terminal.ymodem.failed': 'YMODEM 发送失败',
|
||||
'terminal.ymodem.selectReceiveDirectory': '选择接收文件保存位置',
|
||||
'terminal.ymodem.receiveStarted': '正在通过 YMODEM 接收...',
|
||||
'terminal.ymodem.receiveComplete': 'YMODEM 已接收 {fileName}',
|
||||
'terminal.ymodem.receiveCompleteMultiple': 'YMODEM 已接收 {count} 个文件',
|
||||
'terminal.ymodem.receiveEmpty': '没有接收到 YMODEM 文件',
|
||||
'terminal.ymodem.receiveFailed': 'YMODEM 接收失败',
|
||||
'terminal.ymodem.unavailable': 'YMODEM 当前不可用',
|
||||
'terminal.selection.addToAI': '添加到对话',
|
||||
'terminal.selection.addToAIDesc': '将选中的终端输出作为附件加入 AI 草稿',
|
||||
@@ -676,6 +691,8 @@ export const zhCNVaultMessages: Messages = {
|
||||
'sftp.tabs.addTab': '新建标签页',
|
||||
'sftp.tabs.closeTab': '关闭标签页',
|
||||
'sftp.tabs.newTab': '新标签页',
|
||||
'sftp.tabs.copyDefaultPath': '复制标签页(默认路径)',
|
||||
'sftp.tabs.copyCurrentPath': '复制并跳转到当前路径',
|
||||
'sftp.conflict.title': '文件冲突',
|
||||
'sftp.conflict.desc': '目标位置已存在同名文件',
|
||||
'sftp.conflict.alreadyExistsSuffix': '已存在',
|
||||
|
||||
@@ -196,6 +196,21 @@ export function setLatestAIActiveSessionMapSnapshot(activeSessionIdMap: Record<s
|
||||
latestAIActiveSessionMapSnapshot = activeSessionIdMap;
|
||||
}
|
||||
|
||||
export function prewarmAIStateStorageSnapshots() {
|
||||
try {
|
||||
if (latestAISessionsSnapshot === null) {
|
||||
latestAISessionsSnapshot =
|
||||
localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? [];
|
||||
}
|
||||
if (latestAIActiveSessionMapSnapshot === null) {
|
||||
latestAIActiveSessionMapSnapshot =
|
||||
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {};
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[AIState] Failed to prewarm AI state storage snapshots:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function setLatestAIDraftsByScopeSnapshot(draftsByScope: DraftsByScope) {
|
||||
latestAIDraftsByScopeSnapshot = draftsByScope;
|
||||
}
|
||||
|
||||
@@ -1,31 +1,45 @@
|
||||
import type { SessionCapabilities } from '../../domain/systemManager/types';
|
||||
|
||||
/** Internal entry: capabilities plus computed expiry timestamp. */
|
||||
interface StoreEntry {
|
||||
capabilities: SessionCapabilities;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
const capabilitiesBySessionId = new Map<string, SessionCapabilities>();
|
||||
const capabilitiesBySessionId = new Map<string, StoreEntry>();
|
||||
const listenersBySessionId = new Map<string, Set<Listener>>();
|
||||
|
||||
function isExpired(entry: StoreEntry): boolean {
|
||||
return Date.now() > entry.expiresAt;
|
||||
}
|
||||
|
||||
function notifySession(sessionId: string) {
|
||||
listenersBySessionId.get(sessionId)?.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
export const sessionCapabilitiesStore = {
|
||||
get(sessionId: string): SessionCapabilities | undefined {
|
||||
return capabilitiesBySessionId.get(sessionId);
|
||||
const entry = capabilitiesBySessionId.get(sessionId);
|
||||
if (!entry) return undefined;
|
||||
if (isExpired(entry)) {
|
||||
capabilitiesBySessionId.delete(sessionId);
|
||||
notifySession(sessionId);
|
||||
return undefined;
|
||||
}
|
||||
return entry.capabilities;
|
||||
},
|
||||
|
||||
set(sessionId: string, capabilities: SessionCapabilities) {
|
||||
const prev = capabilitiesBySessionId.get(sessionId);
|
||||
if (
|
||||
prev
|
||||
&& prev.targetOs === capabilities.targetOs
|
||||
&& prev.hasTmux === capabilities.hasTmux
|
||||
&& prev.hasDocker === capabilities.hasDocker
|
||||
&& prev.probedAt === capabilities.probedAt
|
||||
) {
|
||||
return;
|
||||
}
|
||||
capabilitiesBySessionId.set(sessionId, capabilities);
|
||||
set(sessionId: string, capabilities: SessionCapabilities, ttlMs: number) {
|
||||
const entry: StoreEntry = {
|
||||
capabilities: {
|
||||
...capabilities,
|
||||
probedAt: Date.now(),
|
||||
},
|
||||
expiresAt: Date.now() + ttlMs,
|
||||
};
|
||||
capabilitiesBySessionId.set(sessionId, entry);
|
||||
notifySession(sessionId);
|
||||
},
|
||||
|
||||
|
||||
123
application/state/sessionWorkspaceDetach.test.ts
Normal file
123
application/state/sessionWorkspaceDetach.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { TerminalSession, Workspace } from "../../domain/models";
|
||||
import {
|
||||
closeSessionWorkspaceLayoutState,
|
||||
detachSessionFromWorkspaceState,
|
||||
replaceDissolvedWorkspaceTabOrder,
|
||||
} from "./sessionWorkspaceDetach";
|
||||
|
||||
const session = (id: string, workspaceId = "ws-1"): TerminalSession => ({
|
||||
id,
|
||||
hostId: id,
|
||||
hostLabel: id,
|
||||
status: "connected",
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const workspace = (sessionIds: string[]): Workspace => ({
|
||||
id: "ws-1",
|
||||
title: "Workspace",
|
||||
focusedSessionId: sessionIds[0],
|
||||
focusSessionOrder: sessionIds,
|
||||
root: sessionIds.length === 1
|
||||
? { id: "pane-1", type: "pane", sessionId: sessionIds[0] }
|
||||
: {
|
||||
id: "split-1",
|
||||
type: "split",
|
||||
direction: "vertical",
|
||||
children: sessionIds.map((sessionId, index) => ({
|
||||
id: `pane-${index + 1}`,
|
||||
type: "pane" as const,
|
||||
sessionId,
|
||||
})),
|
||||
sizes: sessionIds.map(() => 1),
|
||||
},
|
||||
});
|
||||
|
||||
test("detach dissolves the original workspace when one session remains", () => {
|
||||
const result = detachSessionFromWorkspaceState({
|
||||
sessions: [session("s1"), session("s2")],
|
||||
workspaces: [workspace(["s1", "s2"])],
|
||||
sessionId: "s1",
|
||||
});
|
||||
|
||||
assert.equal(result.changed, true);
|
||||
assert.equal(result.activeTabId, "s1");
|
||||
assert.deepEqual(result.sessions.map((s) => [s.id, s.workspaceId]), [
|
||||
["s1", undefined],
|
||||
["s2", undefined],
|
||||
]);
|
||||
assert.equal(result.workspaces.length, 0);
|
||||
assert.equal(result.dissolvedWorkspaceId, "ws-1");
|
||||
assert.deepEqual(result.replacementTabIds, ["s1", "s2"]);
|
||||
});
|
||||
|
||||
test("detach preserves the other sessions in a multi-pane workspace", () => {
|
||||
const result = detachSessionFromWorkspaceState({
|
||||
sessions: [session("s1"), session("s2"), session("s3")],
|
||||
workspaces: [workspace(["s1", "s2", "s3"])],
|
||||
sessionId: "s2",
|
||||
});
|
||||
|
||||
assert.equal(result.changed, true);
|
||||
assert.deepEqual(result.sessions.map((s) => [s.id, s.workspaceId]), [
|
||||
["s1", "ws-1"],
|
||||
["s2", undefined],
|
||||
["s3", "ws-1"],
|
||||
]);
|
||||
assert.deepEqual(result.workspaces[0].focusSessionOrder, ["s1", "s3"]);
|
||||
assert.equal(result.workspaces[0].focusedSessionId, "s1");
|
||||
assert.deepEqual(
|
||||
result.workspaces[0].root.type === "split"
|
||||
? result.workspaces[0].root.children.map((child) => child.type === "pane" ? child.sessionId : null)
|
||||
: [],
|
||||
["s1", "s3"],
|
||||
);
|
||||
});
|
||||
|
||||
test("dissolved workspace replacement preserves its tab position", () => {
|
||||
assert.deepEqual(
|
||||
replaceDissolvedWorkspaceTabOrder(["log-1", "ws-1", "session-3"], "ws-1", ["s1", "s2"]),
|
||||
["log-1", "s1", "s2", "session-3"],
|
||||
);
|
||||
});
|
||||
|
||||
test("dissolved workspace replacement removes duplicate replacement ids", () => {
|
||||
assert.deepEqual(
|
||||
replaceDissolvedWorkspaceTabOrder(["s1", "ws-1", "session-3"], "ws-1", ["s1", "s2"]),
|
||||
["s1", "s2", "session-3"],
|
||||
);
|
||||
});
|
||||
|
||||
test("dissolved workspace replacement is idempotent", () => {
|
||||
const once = replaceDissolvedWorkspaceTabOrder(["log-1", "ws-1", "session-3"], "ws-1", ["s1", "s2"]);
|
||||
|
||||
assert.deepEqual(
|
||||
replaceDissolvedWorkspaceTabOrder(once, "ws-1", ["s1", "s2"]),
|
||||
once,
|
||||
);
|
||||
});
|
||||
|
||||
test("single remaining session preserves dissolved workspace tab position", () => {
|
||||
assert.deepEqual(
|
||||
replaceDissolvedWorkspaceTabOrder(["log-1", "ws-1", "session-3"], "ws-1", ["s2"]),
|
||||
["log-1", "s2", "session-3"],
|
||||
);
|
||||
});
|
||||
|
||||
test("closing a workspace session dissolves the workspace when one terminal remains", () => {
|
||||
const result = closeSessionWorkspaceLayoutState([workspace(["s1", "s2"])], "ws-1", "s1");
|
||||
|
||||
assert.equal(result.dissolvedWorkspaceId, "ws-1");
|
||||
assert.equal(result.lastRemainingSessionId, "s2");
|
||||
assert.deepEqual(result.workspaces, []);
|
||||
assert.deepEqual(
|
||||
replaceDissolvedWorkspaceTabOrder(
|
||||
["log-1", result.dissolvedWorkspaceId!, "session-3"],
|
||||
result.dissolvedWorkspaceId,
|
||||
result.lastRemainingSessionId ? [result.lastRemainingSessionId] : undefined,
|
||||
),
|
||||
["log-1", "s2", "session-3"],
|
||||
);
|
||||
});
|
||||
182
application/state/sessionWorkspaceDetach.ts
Normal file
182
application/state/sessionWorkspaceDetach.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import type { TerminalSession, Workspace } from "../../domain/models";
|
||||
import { collectSessionIds, pruneWorkspaceNode } from "../../domain/workspace";
|
||||
|
||||
export type DetachSessionFromWorkspaceStateResult = {
|
||||
changed: boolean;
|
||||
sessions: TerminalSession[];
|
||||
workspaces: Workspace[];
|
||||
activeTabId?: string;
|
||||
dissolvedWorkspaceId?: string;
|
||||
replacementTabIds?: string[];
|
||||
};
|
||||
|
||||
export type CloseSessionWorkspaceLayoutResult = {
|
||||
workspaces: Workspace[];
|
||||
removedWorkspaceId?: string;
|
||||
dissolvedWorkspaceId?: string;
|
||||
lastRemainingSessionId?: string;
|
||||
};
|
||||
|
||||
type DetachSessionFromWorkspaceStateOptions = {
|
||||
sessions: TerminalSession[];
|
||||
workspaces: Workspace[];
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
export function replaceDissolvedWorkspaceTabOrder(
|
||||
tabOrder: readonly string[],
|
||||
workspaceId: string | undefined,
|
||||
replacementTabIds: readonly string[] | undefined,
|
||||
): string[] {
|
||||
if (!workspaceId || !replacementTabIds?.length) return [...tabOrder];
|
||||
|
||||
const uniqueReplacementIds = replacementTabIds.filter((tabId, index, list) => (
|
||||
tabId && list.indexOf(tabId) === index
|
||||
));
|
||||
if (uniqueReplacementIds.length === 0) return [...tabOrder];
|
||||
|
||||
if (!tabOrder.includes(workspaceId)) {
|
||||
const hasAllReplacementIds = uniqueReplacementIds.every((tabId) => tabOrder.includes(tabId));
|
||||
return hasAllReplacementIds ? [...tabOrder] : [
|
||||
...tabOrder,
|
||||
...uniqueReplacementIds.filter((tabId) => !tabOrder.includes(tabId)),
|
||||
];
|
||||
}
|
||||
|
||||
const replacementIdSet = new Set(uniqueReplacementIds);
|
||||
let inserted = false;
|
||||
const nextOrder: string[] = [];
|
||||
|
||||
for (const tabId of tabOrder) {
|
||||
if (tabId === workspaceId) {
|
||||
if (!inserted) {
|
||||
nextOrder.push(...uniqueReplacementIds);
|
||||
inserted = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!replacementIdSet.has(tabId)) {
|
||||
nextOrder.push(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
return nextOrder;
|
||||
}
|
||||
|
||||
export function closeSessionWorkspaceLayoutState(
|
||||
workspaces: readonly Workspace[],
|
||||
workspaceId: string | undefined,
|
||||
sessionId: string,
|
||||
): CloseSessionWorkspaceLayoutResult {
|
||||
if (!workspaceId) return { workspaces: [...workspaces] };
|
||||
|
||||
let removedWorkspaceId: string | undefined;
|
||||
let dissolvedWorkspaceId: string | undefined;
|
||||
let lastRemainingSessionId: string | undefined;
|
||||
const nextWorkspaces = workspaces
|
||||
.map((workspace) => {
|
||||
if (workspace.id !== workspaceId) return workspace;
|
||||
const prunedRoot = pruneWorkspaceNode(workspace.root, sessionId);
|
||||
if (!prunedRoot) {
|
||||
removedWorkspaceId = workspace.id;
|
||||
return null;
|
||||
}
|
||||
|
||||
const remainingSessionIds = collectSessionIds(prunedRoot);
|
||||
if (remainingSessionIds.length === 1) {
|
||||
dissolvedWorkspaceId = workspace.id;
|
||||
lastRemainingSessionId = remainingSessionIds[0];
|
||||
return null;
|
||||
}
|
||||
|
||||
return { ...workspace, root: prunedRoot };
|
||||
})
|
||||
.filter((workspace): workspace is Workspace => Boolean(workspace));
|
||||
|
||||
return {
|
||||
workspaces: nextWorkspaces,
|
||||
removedWorkspaceId,
|
||||
dissolvedWorkspaceId,
|
||||
lastRemainingSessionId,
|
||||
};
|
||||
}
|
||||
|
||||
export function detachSessionFromWorkspaceState({
|
||||
sessions,
|
||||
workspaces,
|
||||
sessionId,
|
||||
}: DetachSessionFromWorkspaceStateOptions): DetachSessionFromWorkspaceStateResult {
|
||||
const session = sessions.find((candidate) => candidate.id === sessionId);
|
||||
if (!session?.workspaceId) {
|
||||
return { changed: false, sessions, workspaces };
|
||||
}
|
||||
|
||||
const workspaceId = session.workspaceId;
|
||||
const targetWorkspace = workspaces.find((workspace) => workspace.id === workspaceId);
|
||||
if (!targetWorkspace) {
|
||||
return { changed: false, sessions, workspaces };
|
||||
}
|
||||
|
||||
const prunedRoot = pruneWorkspaceNode(targetWorkspace.root, sessionId);
|
||||
let nextSessions = sessions.map((candidate) => (
|
||||
candidate.id === sessionId ? { ...candidate, workspaceId: undefined } : candidate
|
||||
));
|
||||
|
||||
if (!prunedRoot) {
|
||||
return {
|
||||
changed: true,
|
||||
sessions: nextSessions,
|
||||
workspaces: workspaces.filter((workspace) => workspace.id !== workspaceId),
|
||||
activeTabId: sessionId,
|
||||
dissolvedWorkspaceId: workspaceId,
|
||||
replacementTabIds: [sessionId],
|
||||
};
|
||||
}
|
||||
|
||||
const remainingSessionIds = collectSessionIds(prunedRoot);
|
||||
if (remainingSessionIds.length === 1) {
|
||||
nextSessions = nextSessions.map((candidate) => (
|
||||
candidate.id === remainingSessionIds[0] ? { ...candidate, workspaceId: undefined } : candidate
|
||||
));
|
||||
|
||||
return {
|
||||
changed: true,
|
||||
sessions: nextSessions,
|
||||
workspaces: workspaces.filter((workspace) => workspace.id !== workspaceId),
|
||||
activeTabId: sessionId,
|
||||
dissolvedWorkspaceId: workspaceId,
|
||||
replacementTabIds: [sessionId, ...remainingSessionIds],
|
||||
};
|
||||
}
|
||||
|
||||
const nextFocusedSessionId = remainingSessionIds.includes(targetWorkspace.focusedSessionId)
|
||||
? targetWorkspace.focusedSessionId
|
||||
: remainingSessionIds[0];
|
||||
const nextFocusSessionOrder = (targetWorkspace.focusSessionOrder ?? [])
|
||||
.filter((candidateId, index, list) => (
|
||||
candidateId !== sessionId &&
|
||||
remainingSessionIds.includes(candidateId) &&
|
||||
list.indexOf(candidateId) === index
|
||||
));
|
||||
for (const remainingSessionId of remainingSessionIds) {
|
||||
if (!nextFocusSessionOrder.includes(remainingSessionId)) {
|
||||
nextFocusSessionOrder.push(remainingSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
changed: true,
|
||||
sessions: nextSessions,
|
||||
workspaces: workspaces.map((workspace) => (
|
||||
workspace.id === workspaceId
|
||||
? {
|
||||
...workspace,
|
||||
root: prunedRoot,
|
||||
focusedSessionId: nextFocusedSessionId,
|
||||
focusSessionOrder: nextFocusSessionOrder,
|
||||
}
|
||||
: workspace
|
||||
)),
|
||||
activeTabId: sessionId,
|
||||
};
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
STORAGE_KEY_HOTKEY_RECORDING,
|
||||
STORAGE_KEY_HOTKEY_SCHEME,
|
||||
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
|
||||
STORAGE_KEY_SESSION_LOGS_DIR,
|
||||
STORAGE_KEY_SESSION_LOGS_ENABLED,
|
||||
STORAGE_KEY_SESSION_LOGS_FORMAT,
|
||||
@@ -73,6 +74,7 @@ interface UseSettingsIpcSyncParams {
|
||||
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
|
||||
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
|
||||
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
|
||||
setDisableTerminalFontZoomState: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpTransferConcurrencyState: Dispatch<SetStateAction<number>>;
|
||||
}
|
||||
|
||||
@@ -105,6 +107,7 @@ export function useSettingsIpcSync({
|
||||
setSftpDefaultViewMode,
|
||||
setWorkspaceFocusStyleState,
|
||||
setShowHostTreeSidebarState,
|
||||
setDisableTerminalFontZoomState,
|
||||
setSftpTransferConcurrencyState,
|
||||
}: UseSettingsIpcSyncParams) {
|
||||
// Listen for settings changes from other windows via IPC
|
||||
@@ -228,6 +231,9 @@ export function useSettingsIpcSync({
|
||||
if (key === STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR && typeof value === 'boolean') {
|
||||
setShowHostTreeSidebarState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM && typeof value === 'boolean') {
|
||||
setDisableTerminalFontZoomState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY && typeof value === 'number') {
|
||||
setSftpTransferConcurrencyState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
@@ -258,6 +264,7 @@ export function useSettingsIpcSync({
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
setShowHostTreeSidebarState,
|
||||
setDisableTerminalFontZoomState,
|
||||
setSftpTransferConcurrencyState,
|
||||
setTerminalFontFamilyId,
|
||||
setTerminalFontSize,
|
||||
|
||||
@@ -65,6 +65,7 @@ export const DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT = false;
|
||||
export const DEFAULT_SHOW_SFTP_TAB = true;
|
||||
export const DEFAULT_SHOW_HOST_TREE_SIDEBAR = true;
|
||||
export const DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS = false;
|
||||
export const DEFAULT_DISABLE_TERMINAL_FONT_ZOOM = false;
|
||||
|
||||
// Editor defaults
|
||||
export const DEFAULT_EDITOR_WORD_WRAP = false;
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
|
||||
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_FONT_SIZE,
|
||||
@@ -79,6 +80,7 @@ interface UseSettingsStorageSyncParams {
|
||||
showSftpTab: boolean;
|
||||
showHostTreeSidebar: boolean;
|
||||
shellOnlyTabNumberShortcuts: boolean;
|
||||
disableTerminalFontZoom: boolean;
|
||||
editorWordWrap: boolean;
|
||||
sessionLogsEnabled: boolean;
|
||||
sessionLogsDir: string;
|
||||
@@ -115,6 +117,7 @@ interface UseSettingsStorageSyncParams {
|
||||
setShowSftpTabState: Dispatch<SetStateAction<boolean>>;
|
||||
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
|
||||
setShellOnlyTabNumberShortcutsState: Dispatch<SetStateAction<boolean>>;
|
||||
setDisableTerminalFontZoomState: Dispatch<SetStateAction<boolean>>;
|
||||
setEditorWordWrapState: Dispatch<SetStateAction<boolean>>;
|
||||
setSessionLogsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSessionLogsDir: Dispatch<SetStateAction<string>>;
|
||||
@@ -136,7 +139,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
|
||||
@@ -145,7 +148,7 @@ export function useSettingsStorageSync({
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setShellOnlyTabNumberShortcutsState,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
|
||||
@@ -159,7 +162,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
});
|
||||
@@ -169,7 +172,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
};
|
||||
@@ -389,6 +392,12 @@ export function useSettingsStorageSync({
|
||||
setShellOnlyTabNumberShortcutsState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.disableTerminalFontZoom) {
|
||||
setDisableTerminalFontZoomState(newValue);
|
||||
}
|
||||
}
|
||||
// Sync global hotkey enabled setting from other windows
|
||||
if (e.key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
@@ -458,6 +467,7 @@ export function useSettingsStorageSync({
|
||||
setShowRecentHostsState,
|
||||
setShowSftpTabState,
|
||||
setShellOnlyTabNumberShortcutsState,
|
||||
setDisableTerminalFontZoomState,
|
||||
setTerminalFontFamilyId,
|
||||
setTerminalFontSize,
|
||||
setTerminalThemeDarkId,
|
||||
|
||||
56
application/state/sftp/sftpConnectStartPath.test.ts
Normal file
56
application/state/sftp/sftpConnectStartPath.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type { RemoteSftpStartCache } from "./sftpConnectStartPath.ts";
|
||||
import {
|
||||
normalizeSftpInitialPath,
|
||||
resolveRemoteSftpStartState,
|
||||
} from "./sftpConnectStartPath.ts";
|
||||
|
||||
const cached: RemoteSftpStartCache = {
|
||||
path: "/var/cache",
|
||||
homeDir: "/home/deploy",
|
||||
files: [],
|
||||
filenameEncoding: "auto",
|
||||
};
|
||||
|
||||
test("remote SFTP default-path duplication ignores the shared host cache", () => {
|
||||
const state = resolveRemoteSftpStartState({
|
||||
filenameEncoding: "auto",
|
||||
ignoreSharedCache: true,
|
||||
sharedHostCacheCandidate: cached,
|
||||
});
|
||||
|
||||
assert.equal(state.initialPath, undefined);
|
||||
assert.equal(state.sharedHostCache, null);
|
||||
assert.equal(state.cachedStartPath, "/");
|
||||
});
|
||||
|
||||
test("remote SFTP current-path duplication uses the requested path instead of stale cache", () => {
|
||||
const state = resolveRemoteSftpStartState({
|
||||
filenameEncoding: "auto",
|
||||
initialPath: "/var/www/app",
|
||||
sharedHostCacheCandidate: cached,
|
||||
});
|
||||
|
||||
assert.equal(state.initialPath, "/var/www/app");
|
||||
assert.equal(state.sharedHostCache, null);
|
||||
assert.equal(state.cachedStartPath, "/var/www/app");
|
||||
});
|
||||
|
||||
test("remote SFTP initial paths preserve meaningful whitespace", () => {
|
||||
assert.equal(normalizeSftpInitialPath("/var/www/app "), "/var/www/app ");
|
||||
|
||||
const state = resolveRemoteSftpStartState({
|
||||
filenameEncoding: "auto",
|
||||
initialPath: "/var/www/app ",
|
||||
sharedHostCacheCandidate: {
|
||||
...cached,
|
||||
path: "/var/www/app",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(state.initialPath, "/var/www/app ");
|
||||
assert.equal(state.sharedHostCache, null);
|
||||
assert.equal(state.cachedStartPath, "/var/www/app ");
|
||||
});
|
||||
44
application/state/sftp/sftpConnectStartPath.ts
Normal file
44
application/state/sftp/sftpConnectStartPath.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
|
||||
|
||||
export interface RemoteSftpStartCache {
|
||||
path: string;
|
||||
homeDir: string;
|
||||
files: SftpFileEntry[];
|
||||
filenameEncoding: SftpFilenameEncoding;
|
||||
}
|
||||
|
||||
interface ResolveRemoteSftpStartStateParams {
|
||||
filenameEncoding: SftpFilenameEncoding;
|
||||
ignoreSharedCache?: boolean;
|
||||
initialPath?: string;
|
||||
sharedHostCacheCandidate: RemoteSftpStartCache | null;
|
||||
}
|
||||
|
||||
export function normalizeSftpInitialPath(initialPath?: string): string | undefined {
|
||||
return initialPath === undefined || initialPath.length === 0 ? undefined : initialPath;
|
||||
}
|
||||
|
||||
export function resolveRemoteSftpStartState({
|
||||
filenameEncoding,
|
||||
ignoreSharedCache,
|
||||
initialPath,
|
||||
sharedHostCacheCandidate,
|
||||
}: ResolveRemoteSftpStartStateParams): {
|
||||
initialPath: string | undefined;
|
||||
sharedHostCache: RemoteSftpStartCache | null;
|
||||
cachedStartPath: string;
|
||||
} {
|
||||
const requestedInitialPath = normalizeSftpInitialPath(initialPath);
|
||||
const sharedHostCache =
|
||||
!ignoreSharedCache
|
||||
&& sharedHostCacheCandidate?.filenameEncoding === filenameEncoding
|
||||
&& (!requestedInitialPath || sharedHostCacheCandidate.path === requestedInitialPath)
|
||||
? sharedHostCacheCandidate
|
||||
: null;
|
||||
|
||||
return {
|
||||
initialPath: requestedInitialPath,
|
||||
sharedHostCache,
|
||||
cachedStartPath: requestedInitialPath ?? sharedHostCache?.path ?? "/",
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
|
||||
|
||||
interface SharedRemoteHostCacheEntry {
|
||||
export interface SharedRemoteHostCacheEntry {
|
||||
path: string;
|
||||
homeDir: string;
|
||||
files: SftpFileEntry[];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SftpConnection, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
|
||||
import { KnownHost, SftpConnection, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
|
||||
|
||||
export interface SftpPane {
|
||||
id: string;
|
||||
@@ -15,6 +15,22 @@ export interface SftpPane {
|
||||
transferMutationToken: number;
|
||||
}
|
||||
|
||||
export interface SftpHostKeyInfo {
|
||||
hostname: string;
|
||||
port: number;
|
||||
keyType: string;
|
||||
fingerprint: string;
|
||||
publicKey?: string;
|
||||
status?: "unknown" | "changed";
|
||||
knownHostId?: string;
|
||||
knownFingerprint?: string;
|
||||
}
|
||||
|
||||
export interface SftpHostKeyVerificationState {
|
||||
hostKeyInfo: SftpHostKeyInfo;
|
||||
progressLogs: string[];
|
||||
}
|
||||
|
||||
// Multi-tab state for left and right sides
|
||||
export interface SftpSideTabs {
|
||||
tabs: SftpPane[];
|
||||
@@ -70,4 +86,6 @@ export interface SftpStateOptions {
|
||||
* is honored for SFTP browsing too (not just the terminal session).
|
||||
*/
|
||||
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
|
||||
knownHosts?: KnownHost[];
|
||||
onAddKnownHost?: (knownHost: KnownHost) => void;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import React, { useCallback, useEffect, useRef } from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import type { Host, Identity, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";
|
||||
import type { SftpPane } from "./types";
|
||||
import type { Host, Identity, KnownHost, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";
|
||||
import type { SftpHostKeyInfo, SftpHostKeyVerificationState, SftpPane } from "./types";
|
||||
import { useSftpDirectoryListing } from "./useSftpDirectoryListing";
|
||||
import { useSftpHostCredentials } from "./useSftpHostCredentials";
|
||||
import { buildCacheKey, getSharedRemoteHostCache, setSharedRemoteHostCache } from "./sharedRemoteHostCache";
|
||||
import { resolveRemoteSftpStartState } from "./sftpConnectStartPath";
|
||||
|
||||
interface UseSftpConnectionsParams {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
knownHosts?: KnownHost[];
|
||||
onAddKnownHost?: (knownHost: KnownHost) => void;
|
||||
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
|
||||
leftTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
|
||||
rightTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
|
||||
@@ -34,17 +37,61 @@ interface UseSftpConnectionsParams {
|
||||
autoConnectLocalOnMount?: boolean;
|
||||
}
|
||||
|
||||
export interface SftpConnectOptions {
|
||||
forceNewTab?: boolean;
|
||||
ignoreSharedCache?: boolean;
|
||||
initialPath?: string;
|
||||
onTabCreated?: (tabId: string) => void;
|
||||
sourceSessionId?: string;
|
||||
}
|
||||
|
||||
interface UseSftpConnectionsResult {
|
||||
connect: (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean; onTabCreated?: (tabId: string) => void }) => Promise<void>;
|
||||
connect: (side: "left" | "right", host: Host | "local", options?: SftpConnectOptions) => Promise<void>;
|
||||
disconnect: (side: "left" | "right") => Promise<void>;
|
||||
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
|
||||
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
|
||||
hostKeyVerification: SftpHostKeyVerificationState | null;
|
||||
rejectHostKeyVerification: () => void;
|
||||
acceptHostKeyVerification: () => void;
|
||||
acceptAndSaveHostKeyVerification: () => void;
|
||||
}
|
||||
|
||||
type HostKeyVerificationRequest = SftpHostKeyInfo & {
|
||||
requestId: string;
|
||||
sessionId?: string;
|
||||
};
|
||||
|
||||
const toSftpHostKeyInfo = (request: HostKeyVerificationRequest): SftpHostKeyInfo => ({
|
||||
hostname: request.hostname,
|
||||
port: request.port || 22,
|
||||
keyType: request.keyType,
|
||||
fingerprint: request.fingerprint,
|
||||
publicKey: request.publicKey,
|
||||
status: request.status,
|
||||
knownHostId: request.knownHostId,
|
||||
knownFingerprint: request.knownFingerprint,
|
||||
});
|
||||
|
||||
const createKnownHostFromSftpHostKeyInfo = (
|
||||
hostKeyInfo: SftpHostKeyInfo,
|
||||
now = Date.now(),
|
||||
idSuffix = Math.random().toString(36).slice(2, 11),
|
||||
): KnownHost => ({
|
||||
id: hostKeyInfo.knownHostId || `kh-${now}-${idSuffix}`,
|
||||
hostname: hostKeyInfo.hostname,
|
||||
port: hostKeyInfo.port || 22,
|
||||
keyType: hostKeyInfo.keyType,
|
||||
publicKey: hostKeyInfo.publicKey || `SHA256:${hostKeyInfo.fingerprint}`,
|
||||
fingerprint: hostKeyInfo.fingerprint,
|
||||
discoveredAt: now,
|
||||
});
|
||||
|
||||
export const useSftpConnections = ({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
knownHosts,
|
||||
onAddKnownHost,
|
||||
terminalSettings,
|
||||
leftTabsRef,
|
||||
rightTabsRef,
|
||||
@@ -67,11 +114,79 @@ export const useSftpConnections = ({
|
||||
createEmptyPane,
|
||||
autoConnectLocalOnMount = true,
|
||||
}: UseSftpConnectionsParams): UseSftpConnectionsResult => {
|
||||
const getHostCredentials = useSftpHostCredentials({ hosts, keys, identities, terminalSettings });
|
||||
const getHostCredentials = useSftpHostCredentials({ hosts, keys, identities, knownHosts, terminalSettings });
|
||||
const { listLocalFiles, listRemoteFiles } = useSftpDirectoryListing();
|
||||
const [hostKeyVerification, setHostKeyVerification] = useState<SftpHostKeyVerificationState | null>(null);
|
||||
const hostKeyVerificationRef = useRef<(SftpHostKeyVerificationState & { requestId: string; sessionId: string }) | null>(null);
|
||||
const activeHostKeySessionsRef = useRef<Map<string, { side: "left" | "right"; tabId: string }>>(new Map());
|
||||
|
||||
const setPendingHostKeyVerification = useCallback((
|
||||
next: (SftpHostKeyVerificationState & { requestId: string; sessionId: string }) | null,
|
||||
) => {
|
||||
hostKeyVerificationRef.current = next;
|
||||
setHostKeyVerification(next ? {
|
||||
hostKeyInfo: next.hostKeyInfo,
|
||||
progressLogs: next.progressLogs,
|
||||
} : null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const dispose = netcattyBridge.get()?.onHostKeyVerification?.((request: HostKeyVerificationRequest) => {
|
||||
const sessionId = request.sessionId;
|
||||
if (!sessionId) return;
|
||||
const activeSession = activeHostKeySessionsRef.current.get(sessionId);
|
||||
if (!activeSession) return;
|
||||
|
||||
const hostKeyInfo = toSftpHostKeyInfo(request);
|
||||
const logLine = request.status === "changed"
|
||||
? `Host key changed for ${request.hostname}. Waiting for confirmation...`
|
||||
: `Host key verification required for ${request.hostname}.`;
|
||||
|
||||
updateTab(activeSession.side, activeSession.tabId, (prev) => ({
|
||||
...prev,
|
||||
connectionLogs: [...prev.connectionLogs, logLine],
|
||||
}));
|
||||
setPendingHostKeyVerification({
|
||||
requestId: request.requestId,
|
||||
sessionId,
|
||||
hostKeyInfo,
|
||||
progressLogs: [logLine],
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
dispose?.();
|
||||
};
|
||||
}, [setPendingHostKeyVerification, updateTab]);
|
||||
|
||||
const respondToHostKeyVerification = useCallback((accept: boolean, addToKnownHosts = false) => {
|
||||
const pending = hostKeyVerificationRef.current;
|
||||
if (!pending) return;
|
||||
if (accept && addToKnownHosts) {
|
||||
onAddKnownHost?.(createKnownHostFromSftpHostKeyInfo(pending.hostKeyInfo));
|
||||
}
|
||||
void netcattyBridge.get()?.respondHostKeyVerification?.(
|
||||
pending.requestId,
|
||||
accept,
|
||||
addToKnownHosts,
|
||||
);
|
||||
setPendingHostKeyVerification(null);
|
||||
}, [onAddKnownHost, setPendingHostKeyVerification]);
|
||||
|
||||
const rejectHostKeyVerification = useCallback(() => {
|
||||
respondToHostKeyVerification(false);
|
||||
}, [respondToHostKeyVerification]);
|
||||
|
||||
const acceptHostKeyVerification = useCallback(() => {
|
||||
respondToHostKeyVerification(true, false);
|
||||
}, [respondToHostKeyVerification]);
|
||||
|
||||
const acceptAndSaveHostKeyVerification = useCallback(() => {
|
||||
respondToHostKeyVerification(true, true);
|
||||
}, [respondToHostKeyVerification]);
|
||||
|
||||
const connect = useCallback(
|
||||
async (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean; onTabCreated?: (tabId: string) => void; sourceSessionId?: string }) => {
|
||||
async (side: "left" | "right", host: Host | "local", options?: SftpConnectOptions) => {
|
||||
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
|
||||
|
||||
let activeTabId: string | null = null;
|
||||
@@ -101,6 +216,33 @@ export const useSftpConnections = ({
|
||||
|
||||
navSeqRef.current[side] += 1;
|
||||
const connectRequestId = navSeqRef.current[side];
|
||||
const getTargetPane = () => {
|
||||
const tabs = side === "left" ? leftTabsRef.current.tabs : rightTabsRef.current.tabs;
|
||||
return tabs.find((tab) => tab.id === activeTabId) ?? null;
|
||||
};
|
||||
const isTargetConnectionCurrent = () => {
|
||||
const pane = getTargetPane();
|
||||
if (!pane) return false;
|
||||
if (pane.connection?.id === connectionId) return true;
|
||||
return !pane.connection && navSeqRef.current[side] === connectRequestId;
|
||||
};
|
||||
const isTargetConnectionAtPath = (path: string) => {
|
||||
const connection = getTargetPane()?.connection;
|
||||
if (!connection) return navSeqRef.current[side] === connectRequestId;
|
||||
return connection?.id === connectionId && connection.currentPath === path;
|
||||
};
|
||||
const closeSftpSessionForConnection = async () => {
|
||||
const sftpId = sftpSessionsRef.current.get(connectionId);
|
||||
sftpSessionsRef.current.delete(connectionId);
|
||||
connectionCacheKeyMapRef.current.delete(connectionId);
|
||||
clearCacheForConnection(connectionId);
|
||||
if (!sftpId) return;
|
||||
try {
|
||||
await netcattyBridge.get()?.closeSftp(sftpId);
|
||||
} catch {
|
||||
// Ignore errors when closing stale SFTP sessions
|
||||
}
|
||||
};
|
||||
|
||||
lastConnectedHostRef.current[side] = host;
|
||||
// Store the cache key for this connection so pane actions can look it up
|
||||
@@ -147,13 +289,15 @@ export const useSftpConnections = ({
|
||||
homeDir = isWindows ? "C:\\Users\\damao" : "/Users/damao";
|
||||
}
|
||||
|
||||
const startPath = options?.initialPath || homeDir;
|
||||
|
||||
const connection: SftpConnection = {
|
||||
id: connectionId,
|
||||
hostId: "local",
|
||||
hostLabel: "Local",
|
||||
isLocal: true,
|
||||
status: "connected",
|
||||
currentPath: homeDir,
|
||||
currentPath: startPath,
|
||||
homeDir,
|
||||
};
|
||||
|
||||
@@ -168,9 +312,9 @@ export const useSftpConnections = ({
|
||||
}));
|
||||
|
||||
try {
|
||||
const files = await listLocalFiles(homeDir);
|
||||
if (navSeqRef.current[side] !== connectRequestId) return;
|
||||
dirCacheRef.current.set(makeCacheKey(connectionId, homeDir, filenameEncoding), {
|
||||
const files = await listLocalFiles(startPath);
|
||||
if (!isTargetConnectionAtPath(startPath)) return;
|
||||
dirCacheRef.current.set(makeCacheKey(connectionId, startPath, filenameEncoding), {
|
||||
files,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
@@ -182,7 +326,7 @@ export const useSftpConnections = ({
|
||||
reconnecting: false,
|
||||
}));
|
||||
} catch (err) {
|
||||
if (navSeqRef.current[side] !== connectRequestId) return;
|
||||
if (!isTargetConnectionAtPath(startPath)) return;
|
||||
reconnectingRef.current[side] = false;
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
@@ -193,12 +337,15 @@ export const useSftpConnections = ({
|
||||
}
|
||||
} else {
|
||||
const hostCacheKey = buildCacheKey(host.id, host.hostname, host.port, host.protocol, host.sftpSudo, host.username);
|
||||
const sharedHostCacheCandidate = getSharedRemoteHostCache(hostCacheKey);
|
||||
const sharedHostCache =
|
||||
sharedHostCacheCandidate?.filenameEncoding === filenameEncoding
|
||||
? sharedHostCacheCandidate
|
||||
: null;
|
||||
const cachedStartPath = sharedHostCache?.path ?? "/";
|
||||
const sharedHostCacheCandidate = options?.ignoreSharedCache
|
||||
? null
|
||||
: getSharedRemoteHostCache(hostCacheKey);
|
||||
const { initialPath, sharedHostCache, cachedStartPath } = resolveRemoteSftpStartState({
|
||||
filenameEncoding,
|
||||
ignoreSharedCache: options?.ignoreSharedCache,
|
||||
initialPath: options?.initialPath,
|
||||
sharedHostCacheCandidate,
|
||||
});
|
||||
|
||||
const connection: SftpConnection = {
|
||||
id: connectionId,
|
||||
@@ -230,6 +377,7 @@ export const useSftpConnections = ({
|
||||
|
||||
// Subscribe to SFTP connection progress events for auth logging
|
||||
const sftpSessionId = `sftp-${connectionId}`;
|
||||
activeHostKeySessionsRef.current.set(sftpSessionId, { side, tabId: activeTabId });
|
||||
let unsubSftpProgress: (() => void) | undefined;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.onSftpConnectionProgress) {
|
||||
@@ -264,7 +412,7 @@ export const useSftpConnections = ({
|
||||
logLine = `${label} - ${status}${detail ? `: ${detail}` : ''}`;
|
||||
}
|
||||
// Only update if this is still the active request (avoids stale logs leaking)
|
||||
if (navSeqRef.current[side] !== connectRequestId) return;
|
||||
if (!isTargetConnectionCurrent()) return;
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
connectionLogs: [...prev.connectionLogs, logLine],
|
||||
@@ -295,7 +443,7 @@ export const useSftpConnections = ({
|
||||
if (hasKey) {
|
||||
try {
|
||||
const keyFirstCredentials = {
|
||||
sessionId: `sftp-${connectionId}`,
|
||||
sessionId: sftpSessionId,
|
||||
...credentials,
|
||||
sourceSessionId: options?.sourceSessionId,
|
||||
};
|
||||
@@ -306,7 +454,7 @@ export const useSftpConnections = ({
|
||||
} catch (err) {
|
||||
if (hasPassword && isAuthError(err)) {
|
||||
sftpId = await openSftp({
|
||||
sessionId: `sftp-${connectionId}`,
|
||||
sessionId: sftpSessionId,
|
||||
...credentials,
|
||||
sourceSessionId: options?.sourceSessionId,
|
||||
privateKey: undefined,
|
||||
@@ -322,7 +470,7 @@ export const useSftpConnections = ({
|
||||
}
|
||||
} else {
|
||||
sftpId = await openSftp({
|
||||
sessionId: `sftp-${connectionId}`,
|
||||
sessionId: sftpSessionId,
|
||||
...credentials,
|
||||
sourceSessionId: options?.sourceSessionId,
|
||||
});
|
||||
@@ -331,6 +479,10 @@ export const useSftpConnections = ({
|
||||
if (!sftpId) throw new Error("Failed to open SFTP session");
|
||||
|
||||
sftpSessionsRef.current.set(connectionId, sftpId);
|
||||
if (!isTargetConnectionCurrent()) {
|
||||
await closeSftpSessionForConnection();
|
||||
return;
|
||||
}
|
||||
|
||||
let startPath = sharedHostCache?.path ?? "/";
|
||||
let homeDir = sharedHostCache?.homeDir ?? startPath;
|
||||
@@ -395,6 +547,10 @@ export const useSftpConnections = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (initialPath) {
|
||||
startPath = initialPath;
|
||||
}
|
||||
|
||||
const provisionalCacheKey = sharedHostCache
|
||||
? makeCacheKey(connectionId, startPath, filenameEncoding)
|
||||
: null;
|
||||
@@ -438,7 +594,10 @@ export const useSftpConnections = ({
|
||||
throw new Error("Cannot list any remote directory");
|
||||
}
|
||||
}
|
||||
if (navSeqRef.current[side] !== connectRequestId) return;
|
||||
if (!isTargetConnectionCurrent()) {
|
||||
await closeSftpSessionForConnection();
|
||||
return;
|
||||
}
|
||||
dirCacheRef.current.set(makeCacheKey(connectionId, startPath, filenameEncoding), {
|
||||
files,
|
||||
timestamp: Date.now(),
|
||||
@@ -469,7 +628,10 @@ export const useSftpConnections = ({
|
||||
connectionLogs: [], // Clear after successful connect to avoid replay during navigation
|
||||
}));
|
||||
} catch (err) {
|
||||
if (navSeqRef.current[side] !== connectRequestId) return;
|
||||
if (!isTargetConnectionCurrent()) {
|
||||
await closeSftpSessionForConnection();
|
||||
return;
|
||||
}
|
||||
reconnectingRef.current[side] = false;
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
@@ -489,6 +651,10 @@ export const useSftpConnections = ({
|
||||
reconnecting: false,
|
||||
}));
|
||||
} finally {
|
||||
activeHostKeySessionsRef.current.delete(sftpSessionId);
|
||||
if (hostKeyVerificationRef.current?.sessionId === sftpSessionId) {
|
||||
setPendingHostKeyVerification(null);
|
||||
}
|
||||
unsubSftpProgress?.();
|
||||
}
|
||||
}
|
||||
@@ -503,6 +669,7 @@ export const useSftpConnections = ({
|
||||
makeCacheKey,
|
||||
listLocalFiles,
|
||||
listRemoteFiles,
|
||||
setPendingHostKeyVerification,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -588,5 +755,9 @@ export const useSftpConnections = ({
|
||||
disconnect,
|
||||
listLocalFiles,
|
||||
listRemoteFiles,
|
||||
hostKeyVerification,
|
||||
rejectHostKeyVerification,
|
||||
acceptHostKeyVerification,
|
||||
acceptAndSaveHostKeyVerification,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useRef, useMemo, useState } from "react";
|
||||
import { FileConflict, FileConflictAction, TransferStatus, SftpFilenameEncoding } from "../../../domain/models";
|
||||
import { getSftpConflictTypeKey } from "../../../domain/sftpConflict";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { notify } from "../../notification";
|
||||
@@ -501,7 +502,7 @@ export const useSftpExternalOperations = (
|
||||
newModified: number;
|
||||
applyToAllCount: number;
|
||||
}): Promise<FileConflictAction> => {
|
||||
const conflictType = conflict.isDirectory ? "directory" : "file";
|
||||
const conflictType = getSftpConflictTypeKey(conflict.isDirectory, conflict.existingType);
|
||||
const defaultAction = conflictDefaults.get(conflictType);
|
||||
if (defaultAction) return defaultAction;
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { buildSftpHostCredentials } from "./useSftpHostCredentials.ts";
|
||||
import type { Host, SSHKey } from "../../../domain/models.ts";
|
||||
import type { Host, KnownHost, SSHKey } from "../../../domain/models.ts";
|
||||
|
||||
const host = (overrides: Partial<Host> = {}): Host => ({
|
||||
id: "host-1",
|
||||
@@ -102,6 +102,28 @@ test("buildSftpHostCredentials passes reference keys as identity file paths", ()
|
||||
assert.equal(credentials.passphrase, "saved-passphrase");
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials forwards known hosts for SFTP host-key checks", () => {
|
||||
const knownHosts: KnownHost[] = [{
|
||||
id: "kh-1",
|
||||
hostname: "example.com",
|
||||
port: 22,
|
||||
keyType: "ssh-ed25519",
|
||||
publicKey: "SHA256:abc",
|
||||
fingerprint: "abc",
|
||||
discoveredAt: 1,
|
||||
}];
|
||||
|
||||
const credentials = buildSftpHostCredentials({
|
||||
host: host(),
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
knownHosts,
|
||||
});
|
||||
|
||||
assert.equal(credentials.knownHosts, knownHosts);
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials passes jump host reference keys as identity file paths", () => {
|
||||
const key: SSHKey = {
|
||||
id: "jump-key",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback } from "react";
|
||||
import type { Host, Identity, SSHKey, TerminalSettings } from "../../../domain/models";
|
||||
import type { Host, Identity, KnownHost, SSHKey, TerminalSettings } from "../../../domain/models";
|
||||
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "../../../domain/credentials";
|
||||
import { resolveBridgeKeyAuth, resolveHostAuth } from "../../../domain/sshAuth";
|
||||
import { resolveHostKeepalive } from "../../../domain/host";
|
||||
@@ -14,6 +14,7 @@ interface UseSftpHostCredentialsParams {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
knownHosts?: KnownHost[];
|
||||
terminalSettings?: Pick<TerminalSettings, 'keepaliveInterval' | 'keepaliveCountMax'>;
|
||||
}
|
||||
|
||||
@@ -22,6 +23,7 @@ export const buildSftpHostCredentials = ({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
knownHosts,
|
||||
terminalSettings,
|
||||
}: UseSftpHostCredentialsParams & { host: Host }): NetcattySSHOptions => {
|
||||
const globalKeepalive = terminalSettings ?? FALLBACK_KEEPALIVE;
|
||||
@@ -165,6 +167,7 @@ export const buildSftpHostCredentials = ({
|
||||
identityFilePaths: keyAuth.identityFilePaths,
|
||||
keepaliveInterval: targetKeepalive.interval,
|
||||
keepaliveCountMax: targetKeepalive.countMax,
|
||||
knownHosts,
|
||||
// Algorithm settings — must reach the SFTP bridge or hosts that need
|
||||
// legacy mode / the ECDSA skip / advanced overrides would still hit
|
||||
// the original negotiation failure when opening their SFTP pane,
|
||||
@@ -179,9 +182,10 @@ export const useSftpHostCredentials = ({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
knownHosts,
|
||||
terminalSettings,
|
||||
}: UseSftpHostCredentialsParams) =>
|
||||
useCallback(
|
||||
(host: Host): NetcattySSHOptions => buildSftpHostCredentials({ host, hosts, keys, identities, terminalSettings }),
|
||||
[hosts, identities, keys, terminalSettings],
|
||||
(host: Host): NetcattySSHOptions => buildSftpHostCredentials({ host, hosts, keys, identities, knownHosts, terminalSettings }),
|
||||
[hosts, identities, keys, knownHosts, terminalSettings],
|
||||
);
|
||||
|
||||
@@ -7,6 +7,12 @@ import {
|
||||
TransferStatus,
|
||||
TransferTask,
|
||||
} from "../../../domain/models";
|
||||
import {
|
||||
canReplaceSftpConflict,
|
||||
describeSftpExistingKind,
|
||||
describeSftpIncomingKind,
|
||||
getSftpConflictTypeKey,
|
||||
} from "../../../domain/sftpConflict";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { SftpPane } from "./types";
|
||||
@@ -69,8 +75,14 @@ export const useSftpTransfers = ({
|
||||
);
|
||||
|
||||
const conflictDefaultKey = useCallback(
|
||||
(batchId: string | undefined, isDirectory: boolean) =>
|
||||
`${batchId ?? "global"}:${isDirectory ? "directory" : "file"}`,
|
||||
(batchId: string | undefined, isDirectory: boolean, existingType?: "file" | "directory" | "symlink") =>
|
||||
`${batchId ?? "global"}:${getSftpConflictTypeKey(isDirectory, existingType)}`,
|
||||
[],
|
||||
);
|
||||
|
||||
const buildReplaceTypeMismatchError = useCallback(
|
||||
(isDirectory: boolean, existingType: "file" | "directory" | "symlink" | undefined, targetPath: string) =>
|
||||
`Cannot replace existing ${describeSftpExistingKind(existingType)} with ${describeSftpIncomingKind(isDirectory)}: ${targetPath}`,
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -233,6 +245,33 @@ export const useSftpTransfers = ({
|
||||
const existingStat = await statTargetPath(targetPane, targetSftpId, task.targetPath, targetEncoding);
|
||||
|
||||
if (existingStat) {
|
||||
const applyToAllCount = task.batchId
|
||||
? await (async () => {
|
||||
const candidates = transfersRef.current.filter((candidate) =>
|
||||
candidate.batchId === task.batchId &&
|
||||
candidate.isDirectory === task.isDirectory &&
|
||||
!candidate.parentTaskId &&
|
||||
candidate.status !== "completed" &&
|
||||
candidate.status !== "cancelled",
|
||||
);
|
||||
const matches = await Promise.all(candidates.map(async (candidate) => {
|
||||
if (candidate.id === task.id) return true;
|
||||
try {
|
||||
const candidateStat = await statTargetPath(
|
||||
targetPane,
|
||||
targetSftpId,
|
||||
candidate.targetPath,
|
||||
targetEncoding,
|
||||
);
|
||||
return candidateStat?.type === existingStat.type;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}));
|
||||
return Math.max(1, matches.filter(Boolean).length);
|
||||
})()
|
||||
: 1;
|
||||
|
||||
return {
|
||||
transferId: task.id,
|
||||
batchId: task.batchId,
|
||||
@@ -241,15 +280,7 @@ export const useSftpTransfers = ({
|
||||
targetPath: task.targetPath,
|
||||
isDirectory: task.isDirectory,
|
||||
existingType: existingStat.type,
|
||||
applyToAllCount: task.batchId
|
||||
? transfersRef.current.filter((candidate) =>
|
||||
candidate.batchId === task.batchId &&
|
||||
candidate.isDirectory === task.isDirectory &&
|
||||
!candidate.parentTaskId &&
|
||||
candidate.status !== "completed" &&
|
||||
candidate.status !== "cancelled",
|
||||
).length
|
||||
: 1,
|
||||
applyToAllCount,
|
||||
existingSize: existingStat.size,
|
||||
newSize: sourceStat?.size || task.totalBytes || 0,
|
||||
existingModified: existingStat.mtime,
|
||||
@@ -271,7 +302,9 @@ export const useSftpTransfers = ({
|
||||
const conflict = await conflictCheckPromise;
|
||||
|
||||
if (conflict) {
|
||||
const defaultAction = conflictDefaultsRef.current.get(conflictDefaultKey(task.batchId, task.isDirectory));
|
||||
const defaultAction = conflictDefaultsRef.current.get(
|
||||
conflictDefaultKey(task.batchId, task.isDirectory, conflict.existingType),
|
||||
);
|
||||
if (defaultAction) {
|
||||
if (defaultAction === "stop") {
|
||||
await markBatchStopped(task);
|
||||
@@ -285,6 +318,16 @@ export const useSftpTransfers = ({
|
||||
return "cancelled";
|
||||
}
|
||||
|
||||
if (defaultAction === "replace" && !canReplaceSftpConflict(task.isDirectory, conflict.existingType)) {
|
||||
updateTask({
|
||||
status: "failed",
|
||||
endTime: Date.now(),
|
||||
error: buildReplaceTypeMismatchError(task.isDirectory, conflict.existingType, task.targetPath),
|
||||
retryable: false,
|
||||
});
|
||||
return "failed";
|
||||
}
|
||||
|
||||
const duplicateTarget = defaultAction === "duplicate"
|
||||
? await getDuplicateTarget(task, targetPane, targetSftpId, targetEncoding)
|
||||
: null;
|
||||
@@ -728,16 +771,19 @@ export const useSftpTransfers = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedConflictKey = conflictDefaultKey(task.batchId, task.isDirectory);
|
||||
const selectedConflictKey = conflictDefaultKey(conflict.batchId, conflict.isDirectory, conflict.existingType);
|
||||
const affectedConflicts = applyToAll
|
||||
? conflictsRef.current.filter((candidate) =>
|
||||
conflictDefaultKey(candidate.batchId, candidate.isDirectory) === selectedConflictKey,
|
||||
conflictDefaultKey(candidate.batchId, candidate.isDirectory, candidate.existingType) === selectedConflictKey,
|
||||
)
|
||||
: [conflict];
|
||||
const affectedConflictIds = new Set(affectedConflicts.map((candidate) => candidate.transferId));
|
||||
const affectedTasks = affectedConflicts
|
||||
.map((candidate) => transfersRef.current.find((transfer) => transfer.id === candidate.transferId))
|
||||
.filter((candidate): candidate is TransferTask => Boolean(candidate));
|
||||
const affectedConflictById = new Map<string, FileConflict>(
|
||||
affectedConflicts.map((candidate): [string, FileConflict] => [candidate.transferId, candidate]),
|
||||
);
|
||||
|
||||
if (applyToAll) {
|
||||
conflictDefaultsRef.current.set(selectedConflictKey, action);
|
||||
@@ -771,9 +817,11 @@ export const useSftpTransfers = ({
|
||||
}
|
||||
|
||||
const updatedTasks: TransferTask[] = [];
|
||||
const blockedReplaceTasks: Array<{ task: TransferTask; conflict: FileConflict }> = [];
|
||||
|
||||
for (const affectedTask of affectedTasks) {
|
||||
let updatedTask = { ...affectedTask };
|
||||
const affectedConflict = affectedConflictById.get(affectedTask.id);
|
||||
|
||||
if (action === "duplicate") {
|
||||
const endpoints = resolveTaskEndpoints(affectedTask);
|
||||
@@ -792,6 +840,13 @@ export const useSftpTransfers = ({
|
||||
skipConflictCheck: true,
|
||||
};
|
||||
} else if (action === "replace") {
|
||||
if (
|
||||
affectedConflict &&
|
||||
!canReplaceSftpConflict(affectedTask.isDirectory, affectedConflict.existingType)
|
||||
) {
|
||||
blockedReplaceTasks.push({ task: affectedTask, conflict: affectedConflict });
|
||||
continue;
|
||||
}
|
||||
updatedTask = {
|
||||
...affectedTask,
|
||||
skipConflictCheck: true,
|
||||
@@ -808,6 +863,28 @@ export const useSftpTransfers = ({
|
||||
updatedTasks.push(updatedTask);
|
||||
}
|
||||
|
||||
if (blockedReplaceTasks.length > 0) {
|
||||
const blockedTaskIds = new Set(blockedReplaceTasks.map(({ task }) => task.id));
|
||||
const blockedErrors = new Map(
|
||||
blockedReplaceTasks.map(({ task, conflict }) => [
|
||||
task.id,
|
||||
buildReplaceTypeMismatchError(task.isDirectory, conflict.existingType, task.targetPath),
|
||||
]),
|
||||
);
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) => blockedTaskIds.has(t.id)
|
||||
? {
|
||||
...t,
|
||||
status: "failed" as TransferStatus,
|
||||
endTime: Date.now(),
|
||||
error: blockedErrors.get(t.id),
|
||||
retryable: false,
|
||||
}
|
||||
: t,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const updatedTaskMap = new Map(updatedTasks.map((updatedTask) => [updatedTask.id, updatedTask]));
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) => {
|
||||
|
||||
53
application/state/shellHistoryPersistence.test.ts
Normal file
53
application/state/shellHistoryPersistence.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { buildDockerLogsCommand } from '../../domain/systemManager/dockerShell.ts';
|
||||
import { loadSanitizedShellHistory } from './shellHistoryPersistence.ts';
|
||||
import type { ShellHistoryEntry } from '../../domain/models.ts';
|
||||
|
||||
const entry = (id: string, command: string): ShellHistoryEntry => ({
|
||||
id,
|
||||
command,
|
||||
hostId: 'host-1',
|
||||
hostLabel: 'Host',
|
||||
sessionId: 'session-1',
|
||||
timestamp: 1000,
|
||||
});
|
||||
|
||||
test('loadSanitizedShellHistory removes persisted managed startup commands and writes back cleaned history', () => {
|
||||
const stored = [
|
||||
entry('managed', buildDockerLogsCommand('587abcdef123')),
|
||||
entry('user', 'docker ps -a'),
|
||||
];
|
||||
let written: ShellHistoryEntry[] | null = null;
|
||||
|
||||
const loaded = loadSanitizedShellHistory({
|
||||
read: () => stored,
|
||||
write: (_key, value) => {
|
||||
written = value;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
loaded?.map((item) => item.command),
|
||||
['docker ps -a'],
|
||||
);
|
||||
assert.deepEqual(written, loaded);
|
||||
});
|
||||
|
||||
test('loadSanitizedShellHistory does not write when persisted history is already clean', () => {
|
||||
const stored = [entry('user', 'docker ps -a')];
|
||||
let writeCount = 0;
|
||||
|
||||
const loaded = loadSanitizedShellHistory({
|
||||
read: () => stored,
|
||||
write: () => {
|
||||
writeCount += 1;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(loaded, stored);
|
||||
assert.equal(writeCount, 0);
|
||||
});
|
||||
23
application/state/shellHistoryPersistence.ts
Normal file
23
application/state/shellHistoryPersistence.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { ShellHistoryEntry } from '../../domain/models';
|
||||
import { sanitizeGlobalHistoryEntries } from '../../domain/globalHistory';
|
||||
import { STORAGE_KEY_SHELL_HISTORY } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
|
||||
type ShellHistoryStorage = {
|
||||
read<T>(key: string): T | null;
|
||||
write<T>(key: string, value: T): boolean;
|
||||
};
|
||||
|
||||
export function loadSanitizedShellHistory(
|
||||
storage: ShellHistoryStorage = localStorageAdapter,
|
||||
storageKey = STORAGE_KEY_SHELL_HISTORY,
|
||||
): ShellHistoryEntry[] | null {
|
||||
const savedShellHistory = storage.read<ShellHistoryEntry[]>(storageKey);
|
||||
if (!savedShellHistory) return null;
|
||||
|
||||
const cleanedShellHistory = sanitizeGlobalHistoryEntries(savedShellHistory);
|
||||
if (cleanedShellHistory.length !== savedShellHistory.length) {
|
||||
storage.write(storageKey, cleanedShellHistory);
|
||||
}
|
||||
return cleanedShellHistory;
|
||||
}
|
||||
62
application/state/terminalDragData.ts
Normal file
62
application/state/terminalDragData.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
export const WORKSPACE_SESSION_DRAG_TYPE = 'application/x-netcatty-workspace-session';
|
||||
|
||||
type DataTransferLike = {
|
||||
types: DOMStringList | readonly string[];
|
||||
getData: (format: string) => string;
|
||||
};
|
||||
|
||||
export function dataTransferHasType(dataTransfer: Pick<DataTransferLike, 'types'>, type: string): boolean {
|
||||
return Array.prototype.includes.call(dataTransfer.types, type);
|
||||
}
|
||||
|
||||
export function hasWorkspaceSessionDrag(dataTransfer: Pick<DataTransferLike, 'types'>): boolean {
|
||||
return dataTransferHasType(dataTransfer, WORKSPACE_SESSION_DRAG_TYPE);
|
||||
}
|
||||
|
||||
export function getWorkspaceSessionDragId(dataTransfer: DataTransferLike): string {
|
||||
return dataTransfer.getData(WORKSPACE_SESSION_DRAG_TYPE) || dataTransfer.getData('session-id');
|
||||
}
|
||||
|
||||
export function isPointInsideRect(
|
||||
point: { clientX: number; clientY: number },
|
||||
rect: Pick<DOMRect, 'left' | 'right' | 'top' | 'bottom'>,
|
||||
): boolean {
|
||||
return point.clientX >= rect.left
|
||||
&& point.clientX <= rect.right
|
||||
&& point.clientY >= rect.top
|
||||
&& point.clientY <= rect.bottom;
|
||||
}
|
||||
|
||||
export type TopTabInsertionTarget = {
|
||||
tabId: string;
|
||||
position: 'before' | 'after';
|
||||
};
|
||||
|
||||
export function getTopTabInsertionTarget(
|
||||
point: { clientX: number; clientY: number },
|
||||
topTabsRoot: HTMLElement | null,
|
||||
): TopTabInsertionTarget | null {
|
||||
if (!topTabsRoot || !isPointInsideRect(point, topTabsRoot.getBoundingClientRect())) return null;
|
||||
|
||||
const tabs = Array.from(topTabsRoot.querySelectorAll<HTMLElement>('[data-tab-id]'))
|
||||
.filter((tab) => tab.dataset.tabType !== 'root');
|
||||
|
||||
if (tabs.length === 0) return null;
|
||||
|
||||
for (const tab of tabs) {
|
||||
const rect = tab.getBoundingClientRect();
|
||||
const midpoint = rect.left + rect.width / 2;
|
||||
const tabId = tab.dataset.tabId;
|
||||
if (!tabId) continue;
|
||||
if (point.clientX <= midpoint) {
|
||||
return { tabId, position: 'before' };
|
||||
}
|
||||
if (point.clientX <= rect.right) {
|
||||
return { tabId, position: 'after' };
|
||||
}
|
||||
}
|
||||
|
||||
const lastTab = tabs[tabs.length - 1];
|
||||
const lastTabId = lastTab?.dataset.tabId;
|
||||
return lastTabId ? { tabId: lastTabId, position: 'after' } : null;
|
||||
}
|
||||
@@ -74,3 +74,56 @@ test("runThemeTransition uses view transition API when available", async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(finished, true);
|
||||
});
|
||||
|
||||
test("runThemeTransition handles skipped view transitions", async () => {
|
||||
const root = createRoot();
|
||||
let applied = false;
|
||||
let rejectFinished!: (reason: unknown) => void;
|
||||
const doc = {
|
||||
startViewTransition: (callback: () => void) => {
|
||||
callback();
|
||||
return {
|
||||
finished: new Promise<void>((_, reject) => {
|
||||
rejectFinished = reject;
|
||||
}),
|
||||
skipTransition: () => {},
|
||||
};
|
||||
},
|
||||
};
|
||||
(root as { ownerDocument: typeof doc }).ownerDocument = doc;
|
||||
|
||||
runThemeTransition(() => {
|
||||
applied = true;
|
||||
}, root);
|
||||
|
||||
rejectFinished(new DOMException("Transition was skipped", "AbortError"));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(applied, true);
|
||||
assert.equal(root.getAttribute(THEME_TRANSITION_ATTR), null);
|
||||
});
|
||||
|
||||
test("runThemeTransition can apply without animation for heavy tab switches", () => {
|
||||
const root = createRoot();
|
||||
let applied = false;
|
||||
let startViewTransitionCalled = false;
|
||||
const doc = {
|
||||
startViewTransition: (callback: () => void) => {
|
||||
startViewTransitionCalled = true;
|
||||
callback();
|
||||
return {
|
||||
finished: Promise.resolve(),
|
||||
skipTransition: () => {},
|
||||
};
|
||||
},
|
||||
};
|
||||
(root as { ownerDocument: typeof doc }).ownerDocument = doc;
|
||||
|
||||
runThemeTransition(() => {
|
||||
applied = true;
|
||||
}, { root, mode: "instant" });
|
||||
|
||||
assert.equal(applied, true);
|
||||
assert.equal(startViewTransitionCalled, false);
|
||||
assert.equal(root.getAttribute(THEME_TRANSITION_ATTR), null);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { TERMINAL_HOST_TREE_ANIMATION_MS } from './terminalHostTreeAnimation';
|
||||
|
||||
export const THEME_TRANSITION_ATTR = 'data-theme-transition';
|
||||
export const THEME_TRANSITION_MS = TERMINAL_HOST_TREE_ANIMATION_MS;
|
||||
export type ThemeTransitionMode = 'view' | 'css' | 'instant';
|
||||
|
||||
type DocumentWithViewTransition = Document & {
|
||||
startViewTransition?: (callback: () => void | Promise<void>) => {
|
||||
@@ -10,12 +11,57 @@ type DocumentWithViewTransition = Document & {
|
||||
};
|
||||
};
|
||||
|
||||
type ThemeTransitionOptions = {
|
||||
root?: HTMLElement;
|
||||
mode?: ThemeTransitionMode;
|
||||
};
|
||||
|
||||
let cancelThemeTransitionReset: (() => void) | null = null;
|
||||
|
||||
function resolveOptions(rootOrOptions?: HTMLElement | ThemeTransitionOptions): Required<ThemeTransitionOptions> {
|
||||
if (
|
||||
rootOrOptions
|
||||
&& (
|
||||
Object.prototype.hasOwnProperty.call(rootOrOptions, 'root')
|
||||
|| Object.prototype.hasOwnProperty.call(rootOrOptions, 'mode')
|
||||
)
|
||||
) {
|
||||
const options = rootOrOptions as ThemeTransitionOptions;
|
||||
return {
|
||||
root: options.root ?? document.documentElement,
|
||||
mode: options.mode ?? 'view',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
root: rootOrOptions as HTMLElement | undefined ?? document.documentElement,
|
||||
mode: 'view',
|
||||
};
|
||||
}
|
||||
|
||||
function runCssThemeTransition(apply: () => void, root: HTMLElement, cleanup: () => void): void {
|
||||
root.setAttribute(THEME_TRANSITION_ATTR, 'true');
|
||||
apply();
|
||||
const timer = globalThis.setTimeout(cleanup, THEME_TRANSITION_MS + 40);
|
||||
cancelThemeTransitionReset = () => {
|
||||
globalThis.clearTimeout(timer);
|
||||
cleanup();
|
||||
};
|
||||
}
|
||||
|
||||
function skipViewTransition(transition: ReturnType<NonNullable<DocumentWithViewTransition['startViewTransition']>>): void {
|
||||
try {
|
||||
transition.skipTransition();
|
||||
} catch {
|
||||
// Already finished or skipped by the browser.
|
||||
}
|
||||
}
|
||||
|
||||
export function runThemeTransition(
|
||||
apply: () => void,
|
||||
root: HTMLElement = document.documentElement,
|
||||
rootOrOptions?: HTMLElement | ThemeTransitionOptions,
|
||||
): void {
|
||||
const { root, mode } = resolveOptions(rootOrOptions);
|
||||
cancelThemeTransitionReset?.();
|
||||
|
||||
const cleanup = () => {
|
||||
@@ -23,6 +69,17 @@ export function runThemeTransition(
|
||||
cancelThemeTransitionReset = null;
|
||||
};
|
||||
|
||||
if (mode === 'instant') {
|
||||
apply();
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'css') {
|
||||
runCssThemeTransition(apply, root, cleanup);
|
||||
return;
|
||||
}
|
||||
|
||||
const doc = root.ownerDocument as DocumentWithViewTransition | null;
|
||||
const startViewTransition = doc?.startViewTransition?.bind(doc);
|
||||
|
||||
@@ -33,29 +90,19 @@ export function runThemeTransition(
|
||||
apply();
|
||||
});
|
||||
} catch {
|
||||
root.setAttribute(THEME_TRANSITION_ATTR, 'true');
|
||||
apply();
|
||||
const timer = globalThis.setTimeout(cleanup, THEME_TRANSITION_MS + 40);
|
||||
cancelThemeTransitionReset = () => {
|
||||
globalThis.clearTimeout(timer);
|
||||
cleanup();
|
||||
};
|
||||
runCssThemeTransition(apply, root, cleanup);
|
||||
return;
|
||||
}
|
||||
|
||||
cancelThemeTransitionReset = () => {
|
||||
transition?.skipTransition();
|
||||
if (transition) {
|
||||
skipViewTransition(transition);
|
||||
}
|
||||
cleanup();
|
||||
};
|
||||
void transition.finished.finally(cleanup);
|
||||
void transition.finished.then(cleanup, cleanup);
|
||||
return;
|
||||
}
|
||||
|
||||
root.setAttribute(THEME_TRANSITION_ATTR, 'true');
|
||||
apply();
|
||||
const timer = globalThis.setTimeout(cleanup, THEME_TRANSITION_MS + 40);
|
||||
cancelThemeTransitionReset = () => {
|
||||
globalThis.clearTimeout(timer);
|
||||
cleanup();
|
||||
};
|
||||
runCssThemeTransition(apply, root, cleanup);
|
||||
}
|
||||
|
||||
@@ -139,6 +139,86 @@ test("uploads picked folder files with their relative directory structure", asyn
|
||||
]);
|
||||
});
|
||||
|
||||
test("does not replace an existing directory when uploading a same-named file", async () => {
|
||||
const file = new File(["local"], "dddd", { lastModified: 1234 });
|
||||
const deletedPaths: string[] = [];
|
||||
const uploadedPaths: string[] = [];
|
||||
|
||||
const results = await uploadFromFileList(
|
||||
[file],
|
||||
{
|
||||
targetPath: "/target",
|
||||
sftpId: "sftp-1",
|
||||
isLocal: false,
|
||||
bridge: {
|
||||
mkdirSftp: async () => {},
|
||||
statSftp: async (_sftpId, path) =>
|
||||
path === "/target/dddd"
|
||||
? { type: "directory", size: 0, lastModified: 1000 }
|
||||
: null,
|
||||
deleteSftp: async (_sftpId, path) => {
|
||||
deletedPaths.push(path);
|
||||
},
|
||||
writeSftpBinary: async (_sftpId, path) => {
|
||||
uploadedPaths.push(path);
|
||||
},
|
||||
},
|
||||
joinPath: (base, name) => `${base}/${name}`,
|
||||
resolveConflict: async () => "replace",
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(deletedPaths, []);
|
||||
assert.deepEqual(uploadedPaths, []);
|
||||
assert.equal(results.length, 1);
|
||||
assert.equal(results[0].fileName, "dddd");
|
||||
assert.equal(results[0].success, false);
|
||||
assert.match(results[0].error ?? "", /directory/i);
|
||||
});
|
||||
|
||||
test("counts apply-to-all upload conflicts by incoming and existing type", async () => {
|
||||
const files = [
|
||||
new File(["local"], "existing-file", { lastModified: 1234 }),
|
||||
new File(["local"], "existing-directory", { lastModified: 1234 }),
|
||||
];
|
||||
const conflictCounts: number[] = [];
|
||||
|
||||
const results = await uploadFromFileList(
|
||||
files,
|
||||
{
|
||||
targetPath: "/target",
|
||||
sftpId: "sftp-1",
|
||||
isLocal: false,
|
||||
bridge: {
|
||||
mkdirSftp: async () => {},
|
||||
statSftp: async (_sftpId, path) => {
|
||||
if (path === "/target/existing-file") {
|
||||
return { type: "file", size: 2, lastModified: 1000 };
|
||||
}
|
||||
if (path === "/target/existing-directory") {
|
||||
return { type: "directory", size: 0, lastModified: 1000 };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
writeSftpBinary: async () => {
|
||||
throw new Error("skipped conflicts should not upload");
|
||||
},
|
||||
},
|
||||
joinPath: (base, name) => `${base}/${name}`,
|
||||
resolveConflict: async (conflict) => {
|
||||
conflictCounts.push(conflict.applyToAllCount);
|
||||
return "skip";
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(conflictCounts, [1, 1]);
|
||||
assert.deepEqual(results, [
|
||||
{ fileName: "existing-file", success: false, cancelled: true },
|
||||
{ fileName: "existing-directory", success: false, cancelled: true },
|
||||
]);
|
||||
});
|
||||
|
||||
test("uploads path-backed clipboard files through stream transfer", async () => {
|
||||
const transfers: Array<{ sourcePath: string; targetPath: string; totalBytes?: number }> = [];
|
||||
const taskTotals: number[] = [];
|
||||
|
||||
350
application/state/useAISettingsState.ts
Normal file
350
application/state/useAISettingsState.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import {
|
||||
STORAGE_KEY_AI_PROVIDERS,
|
||||
STORAGE_KEY_AI_ACTIVE_PROVIDER,
|
||||
STORAGE_KEY_AI_ACTIVE_MODEL,
|
||||
STORAGE_KEY_AI_PERMISSION_MODE,
|
||||
STORAGE_KEY_AI_TOOL_INTEGRATION_MODE,
|
||||
STORAGE_KEY_AI_EXTERNAL_AGENTS,
|
||||
STORAGE_KEY_AI_DEFAULT_AGENT,
|
||||
STORAGE_KEY_AI_COMMAND_BLOCKLIST,
|
||||
STORAGE_KEY_AI_COMMAND_TIMEOUT,
|
||||
STORAGE_KEY_AI_MAX_ITERATIONS,
|
||||
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
||||
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
STORAGE_KEY_AI_QUICK_MESSAGES,
|
||||
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import type { AIQuickMessage } from '../../infrastructure/ai/quickMessages';
|
||||
import { sanitizeQuickMessages } from '../../infrastructure/ai/quickMessages';
|
||||
import type {
|
||||
AIPermissionMode,
|
||||
AIToolIntegrationMode,
|
||||
ExternalAgentConfig,
|
||||
ProviderConfig,
|
||||
WebSearchConfig,
|
||||
} from '../../infrastructure/ai/types';
|
||||
import { DEFAULT_COMMAND_BLOCKLIST } from '../../infrastructure/ai/types';
|
||||
import { removeProviderReferences } from './aiProviderCleanup';
|
||||
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
|
||||
import { getAIBridge } from './aiStateSnapshots';
|
||||
import { useStoredBoolean } from './useStoredBoolean';
|
||||
|
||||
function readPermissionMode(): AIPermissionMode {
|
||||
const stored = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
|
||||
if (stored === 'observer' || stored === 'confirm' || stored === 'autonomous') return stored;
|
||||
return 'confirm';
|
||||
}
|
||||
|
||||
function readToolIntegrationMode(): AIToolIntegrationMode {
|
||||
return localStorageAdapter.readString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE) === 'skills'
|
||||
? 'skills'
|
||||
: 'mcp';
|
||||
}
|
||||
|
||||
export function useAISettingsState() {
|
||||
const [providers, setProvidersRaw] = useState<ProviderConfig[]>(() =>
|
||||
localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS) ?? []
|
||||
);
|
||||
const [activeProviderId, setActiveProviderIdRaw] = useState<string>(() =>
|
||||
localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_PROVIDER) ?? ''
|
||||
);
|
||||
const [activeModelId, setActiveModelIdRaw] = useState<string>(() =>
|
||||
localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_MODEL) ?? ''
|
||||
);
|
||||
const [globalPermissionMode, setGlobalPermissionModeRaw] = useState<AIPermissionMode>(readPermissionMode);
|
||||
const [toolIntegrationMode, setToolIntegrationModeRaw] = useState<AIToolIntegrationMode>(readToolIntegrationMode);
|
||||
const [externalAgents, setExternalAgentsRaw] = useState<ExternalAgentConfig[]>(() =>
|
||||
localStorageAdapter.read<ExternalAgentConfig[]>(STORAGE_KEY_AI_EXTERNAL_AGENTS) ?? []
|
||||
);
|
||||
const [defaultAgentId, setDefaultAgentIdRaw] = useState<string>(() =>
|
||||
localStorageAdapter.readString(STORAGE_KEY_AI_DEFAULT_AGENT) ?? 'catty'
|
||||
);
|
||||
const [commandBlocklist, setCommandBlocklistRaw] = useState<string[]>(() =>
|
||||
localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST) ?? [...DEFAULT_COMMAND_BLOCKLIST]
|
||||
);
|
||||
const [commandTimeout, setCommandTimeoutRaw] = useState<number>(() =>
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? 60
|
||||
);
|
||||
const [maxIterations, setMaxIterationsRaw] = useState<number>(() =>
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20
|
||||
);
|
||||
const [webSearchConfig, setWebSearchConfigRaw] = useState<WebSearchConfig | null>(() =>
|
||||
localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null
|
||||
);
|
||||
const [quickMessages, setQuickMessagesRaw] = useState<AIQuickMessage[]>(() =>
|
||||
sanitizeQuickMessages(localStorageAdapter.read<unknown>(STORAGE_KEY_AI_QUICK_MESSAGES)),
|
||||
);
|
||||
const [showTerminalSelectionAIAction, setShowTerminalSelectionAIAction] = useStoredBoolean(
|
||||
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
|
||||
true,
|
||||
);
|
||||
|
||||
const setProviders = useCallback((value: ProviderConfig[] | ((prev: ProviderConfig[]) => ProviderConfig[])) => {
|
||||
setProvidersRaw((prev) => {
|
||||
const next = typeof value === 'function' ? value(prev) : value;
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PROVIDERS, next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const addProvider = useCallback((provider: ProviderConfig) => {
|
||||
setProviders((prev) => [...prev, provider]);
|
||||
}, [setProviders]);
|
||||
|
||||
const updateProvider = useCallback((id: string, updates: Partial<ProviderConfig>) => {
|
||||
setProviders((prev) => prev.map((provider) => (
|
||||
provider.id === id ? { ...provider, ...updates } : provider
|
||||
)));
|
||||
}, [setProviders]);
|
||||
|
||||
const removeProvider = useCallback((id: string) => {
|
||||
setProviders((prev) => prev.filter((provider) => provider.id !== id));
|
||||
setActiveProviderIdRaw((prevId) => {
|
||||
if (prevId !== id) return prevId;
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_PROVIDER, '');
|
||||
return '';
|
||||
});
|
||||
|
||||
const agentProviderMap =
|
||||
localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_PROVIDER_MAP) ?? {};
|
||||
const agentModelMap =
|
||||
localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP) ?? {};
|
||||
const cleanup = removeProviderReferences(id, agentProviderMap, agentModelMap);
|
||||
if (cleanup.providerMapChanged) {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_PROVIDER_MAP, cleanup.agentProviderMap);
|
||||
}
|
||||
if (cleanup.modelMapChanged) {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_MODEL_MAP, cleanup.agentModelMap);
|
||||
}
|
||||
}, [setProviders]);
|
||||
|
||||
const setActiveProviderId = useCallback((id: string) => {
|
||||
setActiveProviderIdRaw(id);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_PROVIDER, id);
|
||||
}, []);
|
||||
|
||||
const setActiveModelId = useCallback((id: string) => {
|
||||
setActiveModelIdRaw(id);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_MODEL, id);
|
||||
}, []);
|
||||
|
||||
const setGlobalPermissionMode = useCallback((mode: AIPermissionMode) => {
|
||||
setGlobalPermissionModeRaw(mode);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_PERMISSION_MODE, mode);
|
||||
getAIBridge()?.aiMcpSetPermissionMode?.(mode);
|
||||
}, []);
|
||||
|
||||
const setToolIntegrationMode = useCallback((mode: AIToolIntegrationMode) => {
|
||||
setToolIntegrationModeRaw(mode);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE, mode);
|
||||
getAIBridge()?.aiMcpSetToolIntegrationMode?.(mode);
|
||||
}, []);
|
||||
|
||||
const setExternalAgents = useCallback((value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => {
|
||||
setExternalAgentsRaw((prev) => {
|
||||
const next = typeof value === 'function' ? value(prev) : value;
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_EXTERNAL_AGENTS, next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setDefaultAgentId = useCallback((id: string) => {
|
||||
setDefaultAgentIdRaw(id);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_DEFAULT_AGENT, id);
|
||||
}, []);
|
||||
|
||||
const setCommandBlocklist = useCallback((value: string[]) => {
|
||||
setCommandBlocklistRaw(value);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_COMMAND_BLOCKLIST, value);
|
||||
getAIBridge()?.aiMcpSetCommandBlocklist?.(value);
|
||||
}, []);
|
||||
|
||||
const setCommandTimeout = useCallback((value: number) => {
|
||||
setCommandTimeoutRaw(value);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT, value);
|
||||
getAIBridge()?.aiMcpSetCommandTimeout?.(value);
|
||||
}, []);
|
||||
|
||||
const setMaxIterations = useCallback((value: number) => {
|
||||
setMaxIterationsRaw(value);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_MAX_ITERATIONS, value);
|
||||
getAIBridge()?.aiMcpSetMaxIterations?.(value);
|
||||
}, []);
|
||||
|
||||
const setWebSearchConfig = useCallback((config: WebSearchConfig | null) => {
|
||||
setWebSearchConfigRaw(config);
|
||||
if (config) {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_WEB_SEARCH, config);
|
||||
} else {
|
||||
localStorageAdapter.remove(STORAGE_KEY_AI_WEB_SEARCH);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setQuickMessages = useCallback((value: AIQuickMessage[] | ((prev: AIQuickMessage[]) => AIQuickMessage[])) => {
|
||||
setQuickMessagesRaw((prev) => {
|
||||
const nextRaw = typeof value === 'function' ? value(prev) : value;
|
||||
const next = sanitizeQuickMessages(nextRaw);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_QUICK_MESSAGES, next);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_QUICK_MESSAGES);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const syncFromStorageKey = (key: string | null) => {
|
||||
try {
|
||||
switch (key) {
|
||||
case STORAGE_KEY_AI_PROVIDERS: {
|
||||
const parsed = localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS);
|
||||
if (parsed != null && !Array.isArray(parsed)) break;
|
||||
setProvidersRaw(parsed ?? []);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_ACTIVE_PROVIDER:
|
||||
setActiveProviderIdRaw(localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_PROVIDER) ?? '');
|
||||
break;
|
||||
case STORAGE_KEY_AI_ACTIVE_MODEL:
|
||||
setActiveModelIdRaw(localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_MODEL) ?? '');
|
||||
break;
|
||||
case STORAGE_KEY_AI_PERMISSION_MODE:
|
||||
setGlobalPermissionModeRaw(readPermissionMode());
|
||||
getAIBridge()?.aiMcpSetPermissionMode?.(readPermissionMode());
|
||||
break;
|
||||
case STORAGE_KEY_AI_TOOL_INTEGRATION_MODE:
|
||||
setToolIntegrationModeRaw(readToolIntegrationMode());
|
||||
getAIBridge()?.aiMcpSetToolIntegrationMode?.(readToolIntegrationMode());
|
||||
break;
|
||||
case STORAGE_KEY_AI_EXTERNAL_AGENTS: {
|
||||
const agents = localStorageAdapter.read<ExternalAgentConfig[]>(STORAGE_KEY_AI_EXTERNAL_AGENTS);
|
||||
if (agents != null && !Array.isArray(agents)) break;
|
||||
setExternalAgentsRaw(agents ?? []);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_DEFAULT_AGENT:
|
||||
setDefaultAgentIdRaw(localStorageAdapter.readString(STORAGE_KEY_AI_DEFAULT_AGENT) ?? 'catty');
|
||||
break;
|
||||
case STORAGE_KEY_AI_COMMAND_BLOCKLIST: {
|
||||
const list = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
|
||||
if (list != null && !Array.isArray(list)) break;
|
||||
const blocklist = list ?? [...DEFAULT_COMMAND_BLOCKLIST];
|
||||
setCommandBlocklistRaw(blocklist);
|
||||
getAIBridge()?.aiMcpSetCommandBlocklist?.(blocklist);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_COMMAND_TIMEOUT: {
|
||||
const timeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? 60;
|
||||
if (!Number.isFinite(timeout)) break;
|
||||
setCommandTimeoutRaw(timeout);
|
||||
getAIBridge()?.aiMcpSetCommandTimeout?.(timeout);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_MAX_ITERATIONS: {
|
||||
const iters = localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20;
|
||||
if (!Number.isFinite(iters)) break;
|
||||
setMaxIterationsRaw(iters);
|
||||
getAIBridge()?.aiMcpSetMaxIterations?.(iters);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_WEB_SEARCH:
|
||||
setWebSearchConfigRaw(localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null);
|
||||
break;
|
||||
case STORAGE_KEY_AI_QUICK_MESSAGES:
|
||||
setQuickMessagesRaw(sanitizeQuickMessages(localStorageAdapter.read<unknown>(STORAGE_KEY_AI_QUICK_MESSAGES)));
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[useAISettingsState] Failed to process AI settings storage change', key, err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStorage = (event: StorageEvent) => syncFromStorageKey(event.key);
|
||||
const handleLocalStateChanged = (event: Event) => {
|
||||
syncFromStorageKey((event as CustomEvent<{ key?: string }>).detail?.key ?? null);
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorage);
|
||||
window.addEventListener(AI_STATE_CHANGED_EVENT, handleLocalStateChanged);
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorage);
|
||||
window.removeEventListener(AI_STATE_CHANGED_EVENT, handleLocalStateChanged);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const bridge = getAIBridge();
|
||||
bridge?.aiMcpSetCommandBlocklist?.(commandBlocklist);
|
||||
bridge?.aiMcpSetCommandTimeout?.(commandTimeout);
|
||||
bridge?.aiMcpSetMaxIterations?.(maxIterations);
|
||||
bridge?.aiMcpSetPermissionMode?.(globalPermissionMode);
|
||||
bridge?.aiMcpSetToolIntegrationMode?.(toolIntegrationMode);
|
||||
}, [commandBlocklist, commandTimeout, globalPermissionMode, maxIterations, toolIntegrationMode]);
|
||||
|
||||
const activeProvider = providers.find((provider) => provider.id === activeProviderId) ?? null;
|
||||
|
||||
return useMemo(() => ({
|
||||
providers,
|
||||
setProviders,
|
||||
addProvider,
|
||||
updateProvider,
|
||||
removeProvider,
|
||||
activeProviderId,
|
||||
setActiveProviderId,
|
||||
activeModelId,
|
||||
setActiveModelId,
|
||||
activeProvider,
|
||||
globalPermissionMode,
|
||||
setGlobalPermissionMode,
|
||||
toolIntegrationMode,
|
||||
setToolIntegrationMode,
|
||||
externalAgents,
|
||||
setExternalAgents,
|
||||
defaultAgentId,
|
||||
setDefaultAgentId,
|
||||
commandBlocklist,
|
||||
setCommandBlocklist,
|
||||
commandTimeout,
|
||||
setCommandTimeout,
|
||||
maxIterations,
|
||||
setMaxIterations,
|
||||
webSearchConfig,
|
||||
setWebSearchConfig,
|
||||
quickMessages,
|
||||
setQuickMessages,
|
||||
showTerminalSelectionAIAction,
|
||||
setShowTerminalSelectionAIAction,
|
||||
}), [
|
||||
providers,
|
||||
setProviders,
|
||||
addProvider,
|
||||
updateProvider,
|
||||
removeProvider,
|
||||
activeProviderId,
|
||||
setActiveProviderId,
|
||||
activeModelId,
|
||||
setActiveModelId,
|
||||
activeProvider,
|
||||
globalPermissionMode,
|
||||
setGlobalPermissionMode,
|
||||
toolIntegrationMode,
|
||||
setToolIntegrationMode,
|
||||
externalAgents,
|
||||
setExternalAgents,
|
||||
defaultAgentId,
|
||||
setDefaultAgentId,
|
||||
commandBlocklist,
|
||||
setCommandBlocklist,
|
||||
commandTimeout,
|
||||
setCommandTimeout,
|
||||
maxIterations,
|
||||
setMaxIterations,
|
||||
webSearchConfig,
|
||||
setWebSearchConfig,
|
||||
quickMessages,
|
||||
setQuickMessages,
|
||||
showTerminalSelectionAIAction,
|
||||
setShowTerminalSelectionAIAction,
|
||||
]);
|
||||
}
|
||||
@@ -115,7 +115,9 @@ export function useAIState() {
|
||||
|
||||
// ── Sessions ──
|
||||
const [sessions, setSessionsRaw] = useState<AISession[]>(() =>
|
||||
localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? []
|
||||
latestAISessionsSnapshot
|
||||
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
||||
?? []
|
||||
);
|
||||
// Ref that always holds the latest sessions for use inside debounced callbacks
|
||||
const sessionsRef = useRef(sessions);
|
||||
@@ -124,7 +126,9 @@ export function useAIState() {
|
||||
}, [sessions]);
|
||||
// Per-scope active session: keyed by `${scopeType}:${scopeTargetId}`
|
||||
const [activeSessionIdMap, setActiveSessionIdMapRaw] = useState<Record<string, string | null>>(() =>
|
||||
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {}
|
||||
latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {}
|
||||
);
|
||||
// Per-scope draft/view state is intentionally memory-only so a relaunch
|
||||
// does not restore stale composer input or panel intent against new history.
|
||||
@@ -185,7 +189,7 @@ export function useAIState() {
|
||||
}, [panelViewByScope]);
|
||||
|
||||
useEffect(() => {
|
||||
const validSessionIds = new Set(sessions.map((session) => session.id));
|
||||
const validSessionIds = new Set<string>(sessions.map((session) => session.id));
|
||||
let changed = false;
|
||||
const nextActiveSessionIdMap: Record<string, string | null> = {};
|
||||
|
||||
|
||||
@@ -3,7 +3,19 @@ import test from "node:test";
|
||||
|
||||
import {
|
||||
scheduleChromeLayoutAnimation,
|
||||
syncActiveChromeTheme,
|
||||
themeFingerprint,
|
||||
} from "./useActiveChromeTheme.ts";
|
||||
import { TERMINAL_THEMES } from "../../infrastructure/config/terminalThemes.ts";
|
||||
|
||||
function createInlineStyle() {
|
||||
const values = new Map<string, string>();
|
||||
return {
|
||||
getPropertyValue: (name: string) => values.get(name) ?? "",
|
||||
setProperty: (name: string, value: string) => values.set(name, value),
|
||||
removeProperty: (name: string) => values.delete(name),
|
||||
};
|
||||
}
|
||||
|
||||
function createRafRoot() {
|
||||
const callbacks = new Map<number, FrameRequestCallback>();
|
||||
@@ -47,3 +59,37 @@ test("chrome layout animations wait until theme settle frames complete", () => {
|
||||
assert.equal(ran, true);
|
||||
cancel();
|
||||
});
|
||||
|
||||
test("syncActiveChromeTheme refreshes top tabs when the active theme fingerprint is unchanged", () => {
|
||||
const globalWithDocument = globalThis as typeof globalThis & { document?: Document };
|
||||
const originalDocument = globalWithDocument.document;
|
||||
const theme = TERMINAL_THEMES[0];
|
||||
assert.ok(theme);
|
||||
const topTabsRoot = {
|
||||
style: createInlineStyle(),
|
||||
};
|
||||
const documentElement = {
|
||||
dataset: { activeChromeTheme: themeFingerprint(theme) },
|
||||
};
|
||||
const fakeDocument = {
|
||||
documentElement,
|
||||
querySelector: (selector: string) => selector === "[data-top-tabs-root]" ? topTabsRoot : null,
|
||||
};
|
||||
globalWithDocument.document = fakeDocument as unknown as Document;
|
||||
|
||||
try {
|
||||
syncActiveChromeTheme(theme, () => {
|
||||
throw new Error("app theme should not be restored for an unchanged active chrome theme");
|
||||
});
|
||||
|
||||
assert.notEqual(topTabsRoot.style.getPropertyValue("--top-tabs-bg"), "");
|
||||
assert.notEqual(topTabsRoot.style.getPropertyValue("--top-tabs-active-bg"), "");
|
||||
assert.notEqual(topTabsRoot.style.getPropertyValue("--top-tabs-accent"), "");
|
||||
} finally {
|
||||
if (originalDocument) {
|
||||
globalWithDocument.document = originalDocument;
|
||||
} else {
|
||||
delete globalWithDocument.document;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -208,10 +208,17 @@ function applyActiveChromeTheme(theme: TerminalTheme) {
|
||||
}
|
||||
style.textContent = getChromeCss(theme);
|
||||
root.dataset.activeChromeTheme = themeFingerprint(theme);
|
||||
refreshActiveChromeThemeSurfaces(theme);
|
||||
}, { mode: "instant" });
|
||||
}
|
||||
|
||||
function refreshActiveChromeThemeSurfaces(theme: TerminalTheme) {
|
||||
const targetClass = theme.type === "dark" ? "dark" : "light";
|
||||
if (typeof window !== "undefined") {
|
||||
netcattyBridge.get()?.setTheme?.(targetClass);
|
||||
netcattyBridge.get()?.setBackgroundColor?.(theme.colors.background);
|
||||
applyTopTabsChromeThemeVars(theme);
|
||||
});
|
||||
}
|
||||
applyTopTabsChromeThemeVars(theme);
|
||||
}
|
||||
|
||||
export function syncActiveChromeTheme(
|
||||
@@ -220,7 +227,14 @@ export function syncActiveChromeTheme(
|
||||
): void {
|
||||
const nextFingerprint = activeTheme ? themeFingerprint(activeTheme) : null;
|
||||
const appliedFingerprint = getAppliedChromeFingerprint();
|
||||
if (nextFingerprint === appliedFingerprint) return;
|
||||
if (nextFingerprint === appliedFingerprint) {
|
||||
if (activeTheme) {
|
||||
refreshActiveChromeThemeSurfaces(activeTheme);
|
||||
} else {
|
||||
clearTopTabsChromeThemeVars();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeTheme) {
|
||||
applyActiveChromeTheme(activeTheme);
|
||||
@@ -231,7 +245,7 @@ export function syncActiveChromeTheme(
|
||||
runThemeTransition(() => {
|
||||
removeActiveChromeTheme();
|
||||
applyAppTheme();
|
||||
});
|
||||
}, { mode: "instant" });
|
||||
}
|
||||
|
||||
export function useActiveChromeTheme({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { startTransition, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { DiscoveredAgent, ExternalAgentConfig } from '../../infrastructure/ai/types';
|
||||
import { getExternalAgentSdkBackend } from '../../infrastructure/ai/managedAgents';
|
||||
|
||||
@@ -10,6 +10,15 @@ function getBridge(): NetcattyBridge | undefined {
|
||||
return (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
}
|
||||
|
||||
const AGENT_DISCOVERY_CACHE_TTL_MS = 60_000;
|
||||
let agentDiscoveryCache: {
|
||||
agents: DiscoveredAgent[];
|
||||
apiKeyPresent: boolean;
|
||||
updatedAt: number;
|
||||
} | null = null;
|
||||
const agentDiscoveryPromises = new Map<string, Promise<DiscoveredAgent[]>>();
|
||||
let agentDiscoveryWriteGeneration = 0;
|
||||
|
||||
export function useAgentDiscovery(
|
||||
externalAgents: ExternalAgentConfig[],
|
||||
setExternalAgents?: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void,
|
||||
@@ -18,29 +27,87 @@ export function useAgentDiscovery(
|
||||
const enabled = options?.enabled ?? true;
|
||||
const [discoveredAgents, setDiscoveredAgents] = useState<DiscoveredAgent[]>([]);
|
||||
const [isDiscovering, setIsDiscovering] = useState(false);
|
||||
const discoverSeqRef = useRef(0);
|
||||
const mountedRef = useRef(true);
|
||||
const enabledRef = useRef(enabled);
|
||||
|
||||
enabledRef.current = enabled;
|
||||
|
||||
useEffect(() => () => {
|
||||
mountedRef.current = false;
|
||||
discoverSeqRef.current += 1;
|
||||
}, []);
|
||||
|
||||
const cursorApiKeyPresent = externalAgents.some(
|
||||
(agent) => agent.id === "discovered_cursor" && Boolean(agent.apiKey),
|
||||
);
|
||||
|
||||
const discover = useCallback(async (discoverOptions?: { refreshShellEnv?: boolean }) => {
|
||||
if (!enabledRef.current) return;
|
||||
const bridge = getBridge();
|
||||
if (!bridge) return;
|
||||
|
||||
const forceRefresh = discoverOptions?.refreshShellEnv === true;
|
||||
const cacheFresh =
|
||||
agentDiscoveryCache
|
||||
&& agentDiscoveryCache.apiKeyPresent === cursorApiKeyPresent
|
||||
&& Date.now() - agentDiscoveryCache.updatedAt < AGENT_DISCOVERY_CACHE_TTL_MS;
|
||||
|
||||
if (!forceRefresh && cacheFresh) {
|
||||
startTransition(() => setDiscoveredAgents(agentDiscoveryCache?.agents ?? []));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDiscovering(true);
|
||||
const discoverSeq = ++discoverSeqRef.current;
|
||||
const writeGeneration = ++agentDiscoveryWriteGeneration;
|
||||
const promiseKey = JSON.stringify({
|
||||
apiKeyPresent: cursorApiKeyPresent,
|
||||
refreshShellEnv: forceRefresh,
|
||||
});
|
||||
try {
|
||||
const agents = await bridge.aiDiscoverAgents({
|
||||
...discoverOptions,
|
||||
let discoveryPromise = agentDiscoveryPromises.get(promiseKey) ?? null;
|
||||
if (!discoveryPromise) {
|
||||
const sharedPromise = bridge.aiDiscoverAgents({
|
||||
...discoverOptions,
|
||||
apiKeyPresent: cursorApiKeyPresent,
|
||||
}).finally(() => {
|
||||
if (agentDiscoveryPromises.get(promiseKey) === sharedPromise) {
|
||||
agentDiscoveryPromises.delete(promiseKey);
|
||||
}
|
||||
});
|
||||
agentDiscoveryPromises.set(promiseKey, sharedPromise);
|
||||
discoveryPromise = sharedPromise;
|
||||
}
|
||||
const agents = await discoveryPromise;
|
||||
if (
|
||||
!mountedRef.current
|
||||
|| !enabledRef.current
|
||||
|| discoverSeq !== discoverSeqRef.current
|
||||
|| writeGeneration !== agentDiscoveryWriteGeneration
|
||||
) return;
|
||||
agentDiscoveryCache = {
|
||||
agents,
|
||||
apiKeyPresent: cursorApiKeyPresent,
|
||||
});
|
||||
setDiscoveredAgents(agents);
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
startTransition(() => setDiscoveredAgents(agents));
|
||||
} catch (err) {
|
||||
console.error('Agent discovery failed:', err);
|
||||
} finally {
|
||||
setIsDiscovering(false);
|
||||
if (mountedRef.current && discoverSeq === discoverSeqRef.current) {
|
||||
setIsDiscovering(false);
|
||||
}
|
||||
}
|
||||
}, [cursorApiKeyPresent]);
|
||||
|
||||
useEffect(() => {
|
||||
discoverSeqRef.current += 1;
|
||||
if (!enabled) {
|
||||
setIsDiscovering(false);
|
||||
}
|
||||
}, [cursorApiKeyPresent, enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
@@ -68,6 +135,7 @@ export function useAgentDiscovery(
|
||||
// the canonical args from discovery change (e.g. after an app update).
|
||||
useEffect(() => {
|
||||
if (!setExternalAgents || discoveredAgents.length === 0) return;
|
||||
if (!enabled) return;
|
||||
|
||||
setExternalAgents((prev) => {
|
||||
let changed = false;
|
||||
@@ -102,7 +170,7 @@ export function useAgentDiscovery(
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [discoveredAgents, setExternalAgents]);
|
||||
}, [discoveredAgents, enabled, setExternalAgents]);
|
||||
|
||||
// Filter out agents that are already configured as external agents
|
||||
const unconfiguredAgents = discoveredAgents.filter(
|
||||
|
||||
@@ -23,6 +23,7 @@ export const getAppLevelActions = (): Set<string> => {
|
||||
'nextTab',
|
||||
'prevTab',
|
||||
'closeTab',
|
||||
'closeSession',
|
||||
'newTab',
|
||||
'openHosts',
|
||||
'openSftp',
|
||||
@@ -35,6 +36,7 @@ export const getAppLevelActions = (): Set<string> => {
|
||||
'splitVertical',
|
||||
'moveFocus',
|
||||
'broadcast',
|
||||
'togglePaneZoom',
|
||||
'openLocal',
|
||||
'openSettings',
|
||||
]);
|
||||
|
||||
@@ -17,8 +17,13 @@ SplitHint,
|
||||
updateWorkspaceSplitSizes,
|
||||
} from '../../domain/workspace';
|
||||
import { clearSessionFontSizeOverride as clearSessionFontSizeOverrideFields } from '../../domain/terminalAppearance';
|
||||
import { buildOrderedWorkTabIds } from '../app/workTabSurface';
|
||||
import { buildOrderedWorkTabIds, reorderWorkTabIds } from '../app/workTabSurface';
|
||||
import { activeTabStore } from './activeTabStore';
|
||||
import {
|
||||
closeSessionWorkspaceLayoutState,
|
||||
detachSessionFromWorkspaceState,
|
||||
replaceDissolvedWorkspaceTabOrder,
|
||||
} from './sessionWorkspaceDetach';
|
||||
import {
|
||||
createCopiedTerminalSessionClone,
|
||||
createSplitTerminalSessionClone,
|
||||
@@ -122,33 +127,12 @@ export const useSessionState = () => {
|
||||
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 => {
|
||||
if (ws.id !== wsId) return ws;
|
||||
const pruned = pruneWorkspaceNode(ws.root, sessionId);
|
||||
if (!pruned) {
|
||||
removedWorkspaceId = ws.id;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if only 1 session remains - dissolve workspace
|
||||
const remainingSessionIds = collectSessionIds(pruned);
|
||||
if (remainingSessionIds.length === 1) {
|
||||
dissolvedWorkspaceId = ws.id;
|
||||
lastRemainingSessionId = remainingSessionIds[0];
|
||||
return null;
|
||||
}
|
||||
|
||||
return { ...ws, root: pruned };
|
||||
})
|
||||
.filter((ws): ws is Workspace => Boolean(ws));
|
||||
}
|
||||
const {
|
||||
workspaces: nextWorkspaces,
|
||||
removedWorkspaceId,
|
||||
dissolvedWorkspaceId,
|
||||
lastRemainingSessionId,
|
||||
} = closeSessionWorkspaceLayoutState(prevWorkspaces, wsId, sessionId);
|
||||
|
||||
const remainingSessions = prevSessions.filter(s => s.id !== sessionId);
|
||||
const fallbackWorkspace = nextWorkspaces[nextWorkspaces.length - 1];
|
||||
@@ -162,6 +146,14 @@ export const useSessionState = () => {
|
||||
return 'vault';
|
||||
};
|
||||
|
||||
if (dissolvedWorkspaceId && lastRemainingSessionId) {
|
||||
setTabOrder(prevTabOrder => replaceDissolvedWorkspaceTabOrder(
|
||||
prevTabOrder,
|
||||
dissolvedWorkspaceId,
|
||||
[lastRemainingSessionId],
|
||||
));
|
||||
}
|
||||
|
||||
if (dissolvedWorkspaceId && currentActiveTabId === dissolvedWorkspaceId) {
|
||||
setActiveTabId(getFallback());
|
||||
} else if (currentActiveTabId === sessionId) {
|
||||
@@ -205,20 +197,39 @@ export const useSessionState = () => {
|
||||
const target = prevSessions.find(s => s.id === sessionId);
|
||||
if (target) {
|
||||
setSessionRenameTarget(target);
|
||||
setSessionRenameValue(target.hostLabel);
|
||||
setSessionRenameValue(target.customName || target.hostLabel);
|
||||
}
|
||||
return prevSessions;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const submitSessionRename = useCallback(() => {
|
||||
const renameSessionInline = useCallback((sessionId: string, name: string) => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
setSessions(prev => prev.map(s => (
|
||||
s.id === sessionId ? { ...s, customName: trimmed, hostLabel: trimmed } : s
|
||||
)));
|
||||
}, []);
|
||||
|
||||
const submitSessionRename = useCallback((sessionId?: string, name?: string) => {
|
||||
if (sessionId !== undefined && name !== undefined) {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
setSessions(prev => prev.map(s => (
|
||||
s.id === sessionId ? { ...s, customName: trimmed, hostLabel: trimmed } : s
|
||||
)));
|
||||
return;
|
||||
}
|
||||
|
||||
setSessionRenameValue(prevValue => {
|
||||
const name = prevValue.trim();
|
||||
if (!name) return prevValue;
|
||||
const trimmed = prevValue.trim();
|
||||
if (!trimmed) return prevValue;
|
||||
|
||||
setSessionRenameTarget(prevTarget => {
|
||||
if (!prevTarget) return prevTarget;
|
||||
setSessions(prev => prev.map(s => s.id === prevTarget.id ? { ...s, hostLabel: name } : s));
|
||||
setSessions(prev => prev.map(s => (
|
||||
s.id === prevTarget.id ? { ...s, customName: trimmed, hostLabel: trimmed } : s
|
||||
)));
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -888,6 +899,50 @@ export const useSessionState = () => {
|
||||
[getOrderedWorkTabs],
|
||||
);
|
||||
|
||||
const removeSessionFromWorkspace = useCallback((
|
||||
sessionId: string,
|
||||
tabInsertionTarget?: {
|
||||
tabId: string;
|
||||
position: 'before' | 'after';
|
||||
additionalTabIds?: readonly string[];
|
||||
},
|
||||
) => {
|
||||
setSessions(prevSessions => {
|
||||
const result = detachSessionFromWorkspaceState({
|
||||
sessions: prevSessions,
|
||||
workspaces: workspacesRef.current,
|
||||
sessionId,
|
||||
});
|
||||
|
||||
if (!result.changed) return prevSessions;
|
||||
setWorkspaces(result.workspaces);
|
||||
setTabOrder(prevTabOrder => {
|
||||
const replacedOrder = replaceDissolvedWorkspaceTabOrder(
|
||||
prevTabOrder,
|
||||
result.dissolvedWorkspaceId,
|
||||
result.replacementTabIds,
|
||||
);
|
||||
if (!tabInsertionTarget) return replacedOrder;
|
||||
|
||||
const allTabIds = [
|
||||
...result.sessions.filter(s => !s.workspaceId).map(s => s.id),
|
||||
...result.workspaces.map(w => w.id),
|
||||
...logViews.map(lv => lv.id),
|
||||
...(tabInsertionTarget.additionalTabIds ?? []),
|
||||
];
|
||||
return reorderWorkTabIds(
|
||||
replacedOrder,
|
||||
allTabIds,
|
||||
sessionId,
|
||||
tabInsertionTarget.tabId,
|
||||
tabInsertionTarget.position,
|
||||
);
|
||||
});
|
||||
if (result.activeTabId) setActiveTabId(result.activeTabId);
|
||||
return result.sessions;
|
||||
});
|
||||
}, [logViews, setActiveTabId]);
|
||||
|
||||
const reorderTabs = useCallback((
|
||||
draggedId: string,
|
||||
targetId: string,
|
||||
@@ -896,39 +951,13 @@ export const useSessionState = () => {
|
||||
) => {
|
||||
if (draggedId === targetId) return;
|
||||
|
||||
setTabOrder(prevTabOrder => {
|
||||
const allTabIds = [...baseWorkTabIds, ...additionalTabIds];
|
||||
const allTabIdSet = new Set(allTabIds);
|
||||
|
||||
// Build current effective order: existing order + new tabs at end
|
||||
const orderedIds = prevTabOrder.filter(id => allTabIdSet.has(id));
|
||||
const orderedIdSet = new Set(orderedIds);
|
||||
const newIds = allTabIds.filter(id => !orderedIdSet.has(id));
|
||||
const currentOrder = [...orderedIds, ...newIds];
|
||||
|
||||
const draggedIndex = currentOrder.indexOf(draggedId);
|
||||
const targetIndex = currentOrder.indexOf(targetId);
|
||||
|
||||
if (draggedIndex === -1 || targetIndex === -1) return prevTabOrder;
|
||||
|
||||
// Remove dragged item first
|
||||
currentOrder.splice(draggedIndex, 1);
|
||||
|
||||
// Calculate new target index (adjusted after removal)
|
||||
let newTargetIndex = targetIndex;
|
||||
if (draggedIndex < targetIndex) {
|
||||
newTargetIndex -= 1;
|
||||
}
|
||||
|
||||
// Insert at the correct position
|
||||
if (position === 'after') {
|
||||
newTargetIndex += 1;
|
||||
}
|
||||
|
||||
currentOrder.splice(newTargetIndex, 0, draggedId);
|
||||
|
||||
return currentOrder;
|
||||
});
|
||||
setTabOrder(prevTabOrder => reorderWorkTabIds(
|
||||
prevTabOrder,
|
||||
[...baseWorkTabIds, ...additionalTabIds],
|
||||
draggedId,
|
||||
targetId,
|
||||
position,
|
||||
));
|
||||
}, [baseWorkTabIds]);
|
||||
|
||||
return {
|
||||
@@ -942,6 +971,7 @@ export const useSessionState = () => {
|
||||
sessionRenameValue,
|
||||
setSessionRenameValue,
|
||||
startSessionRename,
|
||||
renameSessionInline,
|
||||
submitSessionRename,
|
||||
resetSessionRename,
|
||||
workspaceRenameTarget,
|
||||
@@ -962,6 +992,7 @@ export const useSessionState = () => {
|
||||
createWorkspaceFromTargets,
|
||||
createWorkspaceFromSessions,
|
||||
addSessionToWorkspace,
|
||||
removeSessionFromWorkspace,
|
||||
appendHostToWorkspace,
|
||||
appendLocalTerminalToWorkspace,
|
||||
updateSplitSizes,
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||
import {
|
||||
@@ -89,6 +90,7 @@ import {
|
||||
DEFAULT_SHOW_SFTP_TAB,
|
||||
DEFAULT_SHOW_HOST_TREE_SIDEBAR,
|
||||
DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
DEFAULT_DISABLE_TERMINAL_FONT_ZOOM,
|
||||
DEFAULT_SSH_DEBUG_LOGS_ENABLED,
|
||||
DEFAULT_TERMINAL_THEME,
|
||||
DEFAULT_THEME,
|
||||
@@ -244,6 +246,10 @@ export const useSettingsState = () => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
|
||||
return stored ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS;
|
||||
});
|
||||
const [disableTerminalFontZoom, setDisableTerminalFontZoomState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM);
|
||||
return stored ?? DEFAULT_DISABLE_TERMINAL_FONT_ZOOM;
|
||||
});
|
||||
const [sftpTransferConcurrency, setSftpTransferConcurrencyState] = useState<number>(() => {
|
||||
const stored = localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY);
|
||||
return stored != null && stored >= 1 && stored <= 16 ? stored : 4;
|
||||
@@ -343,7 +349,14 @@ export const useSettingsState = () => {
|
||||
|
||||
const mergeIncomingTerminalSettings = useCallback((incoming: Partial<TerminalSettings>) => {
|
||||
setTerminalSettingsState((prev) => {
|
||||
const next = normalizeTerminalSettings({ ...prev, ...incoming });
|
||||
const merged: Partial<TerminalSettings> = { ...prev, ...incoming };
|
||||
if (
|
||||
!Object.prototype.hasOwnProperty.call(incoming, 'middleClickBehavior') &&
|
||||
Object.prototype.hasOwnProperty.call(incoming, 'middleClickPaste')
|
||||
) {
|
||||
delete merged.middleClickBehavior;
|
||||
}
|
||||
const next = normalizeTerminalSettings(merged);
|
||||
if (areTerminalSettingsEqual(prev, next)) {
|
||||
return prev;
|
||||
}
|
||||
@@ -544,6 +557,8 @@ export const useSettingsState = () => {
|
||||
setShowHostTreeSidebarState(storedShowHostTreeSidebar ?? DEFAULT_SHOW_HOST_TREE_SIDEBAR);
|
||||
const storedShellOnlyTabNumberShortcuts = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
|
||||
setShellOnlyTabNumberShortcutsState(storedShellOnlyTabNumberShortcuts ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
|
||||
const storedDisableTerminalFontZoom = localStorageAdapter.readBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM);
|
||||
setDisableTerminalFontZoomState(storedDisableTerminalFontZoom ?? DEFAULT_DISABLE_TERMINAL_FONT_ZOOM);
|
||||
|
||||
// Workspace focus style
|
||||
const storedFocusStyle = readStoredString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
|
||||
@@ -635,6 +650,7 @@ export const useSettingsState = () => {
|
||||
setSftpDefaultViewMode,
|
||||
setWorkspaceFocusStyleState,
|
||||
setShowHostTreeSidebarState,
|
||||
setDisableTerminalFontZoomState,
|
||||
setSftpTransferConcurrencyState,
|
||||
});
|
||||
|
||||
@@ -661,7 +677,7 @@ export const useSettingsState = () => {
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
|
||||
@@ -670,7 +686,7 @@ export const useSettingsState = () => {
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setShellOnlyTabNumberShortcutsState,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
|
||||
@@ -791,6 +807,13 @@ export const useSettingsState = () => {
|
||||
notifySettingsChanged(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setDisableTerminalFontZoom = useCallback((enabled: boolean) => {
|
||||
setDisableTerminalFontZoomState(enabled);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM, enabled);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
// Apply and persist custom CSS
|
||||
useEffect(() => {
|
||||
applyCustomCssToDocument(customCSS);
|
||||
@@ -1031,6 +1054,8 @@ export const useSettingsState = () => {
|
||||
setShowHostTreeSidebar,
|
||||
shellOnlyTabNumberShortcuts,
|
||||
setShellOnlyTabNumberShortcuts,
|
||||
disableTerminalFontZoom,
|
||||
setDisableTerminalFontZoom,
|
||||
sftpTransferConcurrency,
|
||||
setSftpTransferConcurrency,
|
||||
// Editor Settings
|
||||
@@ -1075,7 +1100,7 @@ export const useSettingsState = () => {
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
||||
customKeyBindings, editorWordWrap,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
|
||||
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
]),
|
||||
};
|
||||
|
||||
@@ -170,10 +170,21 @@ export const useSftpState = (
|
||||
useSftpSessionCleanup(sftpSessionsRef);
|
||||
useSftpFileWatch(options);
|
||||
|
||||
const { connect, disconnect, listLocalFiles, listRemoteFiles } = useSftpConnections({
|
||||
const {
|
||||
connect,
|
||||
disconnect,
|
||||
listLocalFiles,
|
||||
listRemoteFiles,
|
||||
hostKeyVerification,
|
||||
rejectHostKeyVerification,
|
||||
acceptHostKeyVerification,
|
||||
acceptAndSaveHostKeyVerification,
|
||||
} = useSftpConnections({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
knownHosts: options?.knownHosts,
|
||||
onAddKnownHost: options?.onAddKnownHost,
|
||||
terminalSettings: options?.terminalSettings,
|
||||
leftTabsRef,
|
||||
rightTabsRef,
|
||||
@@ -402,6 +413,9 @@ export const useSftpState = (
|
||||
resolveConflict: resolveAnyConflict,
|
||||
getSftpIdForConnection,
|
||||
reportSessionError: handleSessionError,
|
||||
rejectHostKeyVerification,
|
||||
acceptHostKeyVerification,
|
||||
acceptAndSaveHostKeyVerification,
|
||||
});
|
||||
methodsRef.current = {
|
||||
getFilteredFiles,
|
||||
@@ -460,6 +474,9 @@ export const useSftpState = (
|
||||
resolveConflict: resolveAnyConflict,
|
||||
getSftpIdForConnection,
|
||||
reportSessionError: handleSessionError,
|
||||
rejectHostKeyVerification,
|
||||
acceptHostKeyVerification,
|
||||
acceptAndSaveHostKeyVerification,
|
||||
};
|
||||
|
||||
// Create stable method wrappers that call through methodsRef
|
||||
@@ -532,6 +549,9 @@ export const useSftpState = (
|
||||
resolveConflict: (...args: Parameters<typeof resolveAnyConflict>) => methodsRef.current.resolveConflict(...args),
|
||||
getSftpIdForConnection: (...args: Parameters<typeof getSftpIdForConnection>) => methodsRef.current.getSftpIdForConnection(...args),
|
||||
reportSessionError: (...args: Parameters<typeof handleSessionError>) => methodsRef.current.reportSessionError(...args),
|
||||
rejectHostKeyVerification: () => methodsRef.current.rejectHostKeyVerification(),
|
||||
acceptHostKeyVerification: () => methodsRef.current.acceptHostKeyVerification(),
|
||||
acceptAndSaveHostKeyVerification: () => methodsRef.current.acceptAndSaveHostKeyVerification(),
|
||||
activeFileWatchCountRef,
|
||||
}), [activeFileWatchCountRef]); // activeFileWatchCountRef is a stable ref
|
||||
|
||||
@@ -546,6 +566,7 @@ export const useSftpState = (
|
||||
transfers,
|
||||
activeTransfersCount,
|
||||
conflicts,
|
||||
hostKeyVerification,
|
||||
|
||||
// Stable methods - never change reference
|
||||
...stableMethods,
|
||||
@@ -566,6 +587,7 @@ export const useSftpState = (
|
||||
transfers,
|
||||
activeTransfersCount,
|
||||
conflicts,
|
||||
hostKeyVerification,
|
||||
stableMethods,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -180,17 +180,33 @@ export const useTerminalBackend = () => {
|
||||
return !!bridge?.sendSerialYmodem;
|
||||
}, []);
|
||||
|
||||
const serialYmodemReceiveAvailable = useCallback(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return !!bridge?.receiveSerialYmodem;
|
||||
}, []);
|
||||
|
||||
const selectFileAvailable = useCallback(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return !!bridge?.selectFile;
|
||||
}, []);
|
||||
|
||||
const selectDirectoryAvailable = useCallback(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return !!bridge?.selectDirectory;
|
||||
}, []);
|
||||
|
||||
const sendSerialYmodem = useCallback(async (sessionId: string, filePath: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.sendSerialYmodem) return { success: false, error: 'sendSerialYmodem unavailable' };
|
||||
return bridge.sendSerialYmodem(sessionId, filePath);
|
||||
}, []);
|
||||
|
||||
const receiveSerialYmodem = useCallback(async (sessionId: string, destinationDir: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.receiveSerialYmodem) return { success: false, error: 'receiveSerialYmodem unavailable' };
|
||||
return bridge.receiveSerialYmodem(sessionId, destinationDir);
|
||||
}, []);
|
||||
|
||||
const selectFile = useCallback(async (
|
||||
title?: string,
|
||||
defaultPath?: string,
|
||||
@@ -201,6 +217,42 @@ export const useTerminalBackend = () => {
|
||||
return bridge.selectFile(title, defaultPath, filters);
|
||||
}, []);
|
||||
|
||||
const selectDirectory = useCallback(async (title?: string, defaultPath?: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.selectDirectory) return null;
|
||||
return bridge.selectDirectory(title, defaultPath);
|
||||
}, []);
|
||||
|
||||
const startZmodemDragDropUpload = useCallback(async (
|
||||
sessionId: string,
|
||||
files: Array<{
|
||||
path?: string;
|
||||
name: string;
|
||||
remoteName: string;
|
||||
data?: ArrayBuffer;
|
||||
}>,
|
||||
uploadCommand?: string,
|
||||
) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.startZmodemDragDropUpload) {
|
||||
return { success: false, error: "startZmodemDragDropUpload unavailable" };
|
||||
}
|
||||
return bridge.startZmodemDragDropUpload(sessionId, files, uploadCommand);
|
||||
}, []);
|
||||
|
||||
const cancelZmodem = useCallback((sessionId: string, options?: { interrupt?: boolean }) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.cancelZmodem?.(sessionId, options);
|
||||
}, []);
|
||||
|
||||
const onZmodemEvent = useCallback((
|
||||
sessionId: string,
|
||||
cb: Parameters<NonNullable<NetcattyBridge["onZmodemEvent"]>>[1],
|
||||
) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onZmodemEvent?.(sessionId, cb) ?? (() => {});
|
||||
}, []);
|
||||
|
||||
const getSessionPwd = useCallback(async (sessionId: string, options?: { allowHomeFallback?: boolean }) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getSessionPwd) return { success: false, error: 'getSessionPwd unavailable' };
|
||||
@@ -256,9 +308,16 @@ export const useTerminalBackend = () => {
|
||||
startSerialSession,
|
||||
listSerialPorts,
|
||||
serialYmodemAvailable,
|
||||
serialYmodemReceiveAvailable,
|
||||
selectFileAvailable,
|
||||
selectDirectoryAvailable,
|
||||
sendSerialYmodem,
|
||||
receiveSerialYmodem,
|
||||
selectFile,
|
||||
selectDirectory,
|
||||
startZmodemDragDropUpload,
|
||||
cancelZmodem,
|
||||
onZmodemEvent,
|
||||
execCommand,
|
||||
getSessionPwd,
|
||||
getSessionRemoteInfo,
|
||||
@@ -297,9 +356,16 @@ export const useTerminalBackend = () => {
|
||||
startSerialSession,
|
||||
listSerialPorts,
|
||||
serialYmodemAvailable,
|
||||
serialYmodemReceiveAvailable,
|
||||
selectFileAvailable,
|
||||
selectDirectoryAvailable,
|
||||
sendSerialYmodem,
|
||||
receiveSerialYmodem,
|
||||
selectFile,
|
||||
selectDirectory,
|
||||
startZmodemDragDropUpload,
|
||||
cancelZmodem,
|
||||
onZmodemEvent,
|
||||
execCommand,
|
||||
getSessionPwd,
|
||||
getSessionRemoteInfo,
|
||||
|
||||
@@ -36,8 +36,9 @@ import {
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
} from "../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import { mergeGlobalHistoryOnAppend } from "../../domain/globalHistory";
|
||||
import { mergeGlobalHistoryOnAppend, sanitizeGlobalHistoryEntries } from "../../domain/globalHistory";
|
||||
import { getNextVaultOrder, normalizeVaultOrder } from "../../domain/vaultOrder";
|
||||
import { loadSanitizedShellHistory } from "./shellHistoryPersistence";
|
||||
import {
|
||||
decryptGroupConfigs,
|
||||
decryptHosts,
|
||||
@@ -598,10 +599,10 @@ export const useVaultState = () => {
|
||||
}
|
||||
|
||||
// Load shell history
|
||||
const savedShellHistory = localStorageAdapter.read<ShellHistoryEntry[]>(
|
||||
STORAGE_KEY_SHELL_HISTORY,
|
||||
);
|
||||
if (savedShellHistory) setShellHistory(savedShellHistory);
|
||||
const savedShellHistory = loadSanitizedShellHistory();
|
||||
if (savedShellHistory) {
|
||||
setShellHistory(savedShellHistory);
|
||||
}
|
||||
|
||||
// Load connection logs
|
||||
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
|
||||
@@ -729,7 +730,9 @@ export const useVaultState = () => {
|
||||
}
|
||||
|
||||
if (key === STORAGE_KEY_SHELL_HISTORY) {
|
||||
const next = safeParse<ShellHistoryEntry[]>(event.newValue) ?? [];
|
||||
const next = sanitizeGlobalHistoryEntries(
|
||||
safeParse<ShellHistoryEntry[]>(event.newValue) ?? [],
|
||||
);
|
||||
setShellHistory(next);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ const {
|
||||
hasCloudSyncEntityData,
|
||||
hasMeaningfulCloudSyncData,
|
||||
shouldPromptCloudVaultRecovery,
|
||||
SYNCABLE_SETTING_STORAGE_KEYS,
|
||||
} = await import("./syncPayload.ts");
|
||||
const storageKeys = await import("../infrastructure/config/storageKeys.ts");
|
||||
|
||||
@@ -124,6 +125,7 @@ test("buildSyncPayload includes AI configuration settings", () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP, JSON.stringify({ codex: "gpt-test" }));
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP, JSON.stringify({ catty: "openai-main" }));
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify(webSearch));
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION, "false");
|
||||
|
||||
const payload = buildSyncPayload(vault([]));
|
||||
|
||||
@@ -140,9 +142,18 @@ test("buildSyncPayload includes AI configuration settings", () => {
|
||||
agentModelMap: { codex: "gpt-test" },
|
||||
agentProviderMap: { catty: "openai-main" },
|
||||
webSearchConfig: webSearch,
|
||||
showTerminalSelectionAction: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("terminal selection AI preference is syncable for auto-sync detection", () => {
|
||||
assert.ok(
|
||||
(SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(
|
||||
storageKeys.STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("buildSyncPayload includes host tree sidebar visibility setting", () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, "false");
|
||||
|
||||
@@ -215,6 +226,7 @@ test("applySyncPayload restores AI configuration settings", async () => {
|
||||
agentModelMap: { claude: "claude-test" },
|
||||
agentProviderMap: { catty: "anthropic-main" },
|
||||
webSearchConfig: webSearch,
|
||||
showTerminalSelectionAction: false,
|
||||
},
|
||||
},
|
||||
syncedAt: 1,
|
||||
@@ -234,6 +246,7 @@ test("applySyncPayload restores AI configuration settings", async () => {
|
||||
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP)!), { claude: "claude-test" });
|
||||
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP)!), { catty: "anthropic-main" });
|
||||
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH)!), webSearch);
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION), "false");
|
||||
});
|
||||
|
||||
test("applySyncPayload restores host tree sidebar visibility setting", async () => {
|
||||
@@ -529,6 +542,7 @@ test("buildSyncPayload includes syncable terminal options from settings", () =>
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_TERM_SETTINGS, JSON.stringify({
|
||||
terminalEmulationType: "vt100",
|
||||
altAsMeta: true,
|
||||
middleClickBehavior: "context-menu",
|
||||
showServerStats: false,
|
||||
serverStatsRefreshInterval: 12,
|
||||
rendererType: "dom",
|
||||
@@ -541,6 +555,7 @@ test("buildSyncPayload includes syncable terminal options from settings", () =>
|
||||
assert.deepEqual(payload.settings?.terminalSettings, {
|
||||
terminalEmulationType: "vt100",
|
||||
altAsMeta: true,
|
||||
middleClickBehavior: "context-menu",
|
||||
showServerStats: false,
|
||||
serverStatsRefreshInterval: 12,
|
||||
rendererType: "dom",
|
||||
@@ -805,6 +820,42 @@ test("applySyncPayload writes incoming fallbackFont into local TERM_SETTINGS", a
|
||||
assert.equal(parsed.fallbackFont, "Sarasa Mono SC");
|
||||
});
|
||||
|
||||
test("applySyncPayload lets legacy middle-click paste update the new middle-click behavior", async () => {
|
||||
localStorage.setItem(
|
||||
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({
|
||||
scrollback: 2000,
|
||||
middleClickBehavior: "paste",
|
||||
middleClickPaste: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const payload: SyncPayload = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
settings: {
|
||||
terminalSettings: {
|
||||
middleClickPaste: false,
|
||||
},
|
||||
},
|
||||
} as SyncPayload;
|
||||
|
||||
await applySyncPayload(payload, {
|
||||
importVaultData: () => {},
|
||||
});
|
||||
|
||||
const raw = localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS);
|
||||
assert.ok(raw, "TERM_SETTINGS should be written");
|
||||
const parsed = JSON.parse(raw!);
|
||||
assert.equal(parsed.scrollback, 2000);
|
||||
assert.equal(parsed.middleClickBehavior, "disabled");
|
||||
assert.equal(parsed.middleClickPaste, false);
|
||||
});
|
||||
|
||||
test("applySyncPayload from legacy client (no fallbackFont) preserves local value", async () => {
|
||||
localStorage.setItem(
|
||||
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
||||
|
||||
@@ -67,6 +67,7 @@ import {
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_AI_PROVIDERS,
|
||||
STORAGE_KEY_AI_ACTIVE_PROVIDER,
|
||||
@@ -82,6 +83,7 @@ import {
|
||||
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
STORAGE_KEY_AI_QUICK_MESSAGES,
|
||||
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
} from '../infrastructure/config/storageKeys';
|
||||
|
||||
@@ -193,7 +195,7 @@ const SYNCABLE_TERMINAL_KEYS = [
|
||||
'linePadding', 'cursorShape', 'cursorBlink', 'minimumContrastRatio',
|
||||
'altAsMeta', 'optionArrowWordJump', 'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
|
||||
'smoothScrolling',
|
||||
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
|
||||
'rightClickBehavior', 'middleClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
|
||||
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
|
||||
'keepaliveInterval', 'keepaliveCountMax', 'disableBracketedPaste', 'clearWipesScrollback',
|
||||
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'showServerStats',
|
||||
@@ -251,6 +253,7 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
|
||||
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
STORAGE_KEY_AI_QUICK_MESSAGES,
|
||||
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
|
||||
] as const;
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
@@ -416,6 +419,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (showSftpTab != null) settings.showSftpTab = showSftpTab;
|
||||
const shellOnlyTabNumberShortcuts = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
|
||||
if (shellOnlyTabNumberShortcuts != null) settings.shellOnlyTabNumberShortcuts = shellOnlyTabNumberShortcuts;
|
||||
const disableTerminalFontZoom = localStorageAdapter.readBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM);
|
||||
if (disableTerminalFontZoom != null) settings.disableTerminalFontZoom = disableTerminalFontZoom;
|
||||
const showHostTreeSidebar = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
|
||||
if (showHostTreeSidebar != null) settings.showHostTreeSidebar = showHostTreeSidebar;
|
||||
const workspaceFocusStyle = localStorageAdapter.readString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
|
||||
@@ -457,6 +462,10 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (webSearchConfig) ai.webSearchConfig = stripDeviceBoundApiKey(webSearchConfig);
|
||||
const quickMessages = readArraySetting(STORAGE_KEY_AI_QUICK_MESSAGES);
|
||||
if (quickMessages) ai.quickMessages = sanitizeQuickMessages(quickMessages);
|
||||
const showTerminalSelectionAction = localStorageAdapter.readBoolean(STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION);
|
||||
if (showTerminalSelectionAction != null) {
|
||||
ai.showTerminalSelectionAction = showTerminalSelectionAction;
|
||||
}
|
||||
if (Object.keys(ai).length > 0) settings.ai = ai;
|
||||
|
||||
return Object.keys(settings).length > 0 ? settings : undefined;
|
||||
@@ -495,11 +504,27 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
|
||||
try { existing = JSON.parse(raw); } catch { /* ignore */ }
|
||||
}
|
||||
const merged = { ...existing };
|
||||
const hasIncomingMiddleClickBehavior = 'middleClickBehavior' in settings.terminalSettings;
|
||||
const hasIncomingMiddleClickPaste = 'middleClickPaste' in settings.terminalSettings;
|
||||
for (const key of SYNCABLE_TERMINAL_KEYS) {
|
||||
if (key in settings.terminalSettings) {
|
||||
merged[key] = settings.terminalSettings[key];
|
||||
}
|
||||
}
|
||||
if (hasIncomingMiddleClickBehavior) {
|
||||
const behavior = settings.terminalSettings.middleClickBehavior;
|
||||
if (
|
||||
behavior === 'context-menu' ||
|
||||
behavior === 'paste' ||
|
||||
behavior === 'disabled'
|
||||
) {
|
||||
merged.middleClickPaste = behavior === 'paste';
|
||||
}
|
||||
} else if (hasIncomingMiddleClickPaste) {
|
||||
merged.middleClickBehavior = settings.terminalSettings.middleClickPaste === false
|
||||
? 'disabled'
|
||||
: 'paste';
|
||||
}
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_SETTINGS, JSON.stringify(merged));
|
||||
}
|
||||
|
||||
@@ -553,6 +578,9 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
|
||||
if (settings.shellOnlyTabNumberShortcuts != null) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, settings.shellOnlyTabNumberShortcuts);
|
||||
}
|
||||
if (settings.disableTerminalFontZoom != null) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM, settings.disableTerminalFontZoom);
|
||||
}
|
||||
if (settings.showHostTreeSidebar != null) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, settings.showHostTreeSidebar);
|
||||
}
|
||||
@@ -594,6 +622,12 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
|
||||
if (ai.quickMessages != null) {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_QUICK_MESSAGES, sanitizeQuickMessages(ai.quickMessages));
|
||||
}
|
||||
if (ai.showTerminalSelectionAction != null) {
|
||||
localStorageAdapter.writeBoolean(
|
||||
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
|
||||
ai.showTerminalSelectionAction,
|
||||
);
|
||||
}
|
||||
// After all AI writes, reconcile per-agent bindings against the final
|
||||
// provider list. Sync payloads can land with a new `providers` set but
|
||||
// no `agentProviderMap`, or with a stale `agentProviderMap` that
|
||||
@@ -635,6 +669,9 @@ function notifyAIStateAfterSync(ai: NonNullable<SyncPayload['settings']>['ai']):
|
||||
}
|
||||
if (ai.webSearchConfig !== undefined) touched.push(STORAGE_KEY_AI_WEB_SEARCH);
|
||||
if (ai.quickMessages != null) touched.push(STORAGE_KEY_AI_QUICK_MESSAGES);
|
||||
if (ai.showTerminalSelectionAction != null) {
|
||||
touched.push(STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION);
|
||||
}
|
||||
for (const key of touched) {
|
||||
emitAIStateChanged(key);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
||||
|
||||
import type { AIDraft, AISession } from '../infrastructure/ai/types';
|
||||
import {
|
||||
aiChatSidePanelPropsAreEqual,
|
||||
hasAIChatSidePanelRetainedContent,
|
||||
shouldKeepAIChatSidePanelMounted,
|
||||
} from './AIChatSidePanel.tsx';
|
||||
@@ -100,3 +101,17 @@ test('hidden AI side panel is retained when it has session messages', () => {
|
||||
test('visible AI side panel is always mounted even when empty', () => {
|
||||
assert.equal(shouldKeepAIChatSidePanelMounted(baseProps({ isVisible: true })), true);
|
||||
});
|
||||
|
||||
test('AI side panel re-renders when retained content becomes visible again', () => {
|
||||
const hiddenProps = baseProps({
|
||||
isVisible: false,
|
||||
draftsByScope: {
|
||||
'terminal:terminal-1': draft({ text: 'hello' }),
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(aiChatSidePanelPropsAreEqual(
|
||||
hiddenProps,
|
||||
{ ...hiddenProps, isVisible: true },
|
||||
), false);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
|
||||
|
||||
import React, { useCallback, useEffect, useDeferredValue, useMemo, useRef, useState } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useWindowControls } from '../application/state/useWindowControls';
|
||||
import type {
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
getNextSelectedUserSkillSlugsMap,
|
||||
type UserSkillOption,
|
||||
} from './ai/userSkillsState';
|
||||
import { subscribeUserSkillsStatusChanged } from './ai/userSkillsStatusEvents';
|
||||
import {
|
||||
applyDraftEntrySelection,
|
||||
applyHistorySessionSelection,
|
||||
@@ -55,6 +57,77 @@ import {
|
||||
profileAIPanelCalculation,
|
||||
} from './ai/aiPanelDiagnostics';
|
||||
|
||||
type UserSkillsStatusResult = { ok: boolean; skills?: Array<{
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
status: 'ready' | 'warning';
|
||||
}> } | null;
|
||||
type UserSkillsStatusLoadResult = UserSkillsStatusResult | undefined;
|
||||
|
||||
const USER_SKILLS_STATUS_CACHE_TTL_MS = 60_000;
|
||||
let userSkillsStatusCache: {
|
||||
version: number;
|
||||
result: UserSkillsStatusResult;
|
||||
updatedAt: number;
|
||||
} | null = null;
|
||||
let userSkillsStatusPromise: {
|
||||
version: number;
|
||||
promise: Promise<UserSkillsStatusLoadResult>;
|
||||
} | null = null;
|
||||
let userSkillsStatusCacheVersion = 0;
|
||||
|
||||
function invalidateUserSkillsStatusCache() {
|
||||
userSkillsStatusCacheVersion += 1;
|
||||
userSkillsStatusCache = null;
|
||||
userSkillsStatusPromise = null;
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
subscribeUserSkillsStatusChanged(invalidateUserSkillsStatusCache);
|
||||
}
|
||||
|
||||
function loadUserSkillsStatus(
|
||||
bridge: ReturnType<typeof getNetcattyBridge>,
|
||||
): Promise<UserSkillsStatusLoadResult> {
|
||||
const requestVersion = userSkillsStatusCacheVersion;
|
||||
if (!bridge?.aiUserSkillsGetStatus) {
|
||||
userSkillsStatusCache = { version: requestVersion, result: null, updatedAt: Date.now() };
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
if (
|
||||
userSkillsStatusCache
|
||||
&& userSkillsStatusCache.version === requestVersion
|
||||
&& Date.now() - userSkillsStatusCache.updatedAt < USER_SKILLS_STATUS_CACHE_TTL_MS
|
||||
) {
|
||||
return Promise.resolve(userSkillsStatusCache.result);
|
||||
}
|
||||
|
||||
if (!userSkillsStatusPromise || userSkillsStatusPromise.version !== requestVersion) {
|
||||
const promise = bridge.aiUserSkillsGetStatus()
|
||||
.then((result) => {
|
||||
if (userSkillsStatusCacheVersion !== requestVersion) return undefined;
|
||||
userSkillsStatusCache = { version: requestVersion, result, updatedAt: Date.now() };
|
||||
return result;
|
||||
})
|
||||
.catch(() => {
|
||||
if (userSkillsStatusCacheVersion !== requestVersion) return undefined;
|
||||
userSkillsStatusCache = { version: requestVersion, result: null, updatedAt: Date.now() };
|
||||
return null;
|
||||
})
|
||||
.finally(() => {
|
||||
if (userSkillsStatusPromise?.version === requestVersion) {
|
||||
userSkillsStatusPromise = null;
|
||||
}
|
||||
});
|
||||
userSkillsStatusPromise = { version: requestVersion, promise };
|
||||
}
|
||||
|
||||
return userSkillsStatusPromise.promise;
|
||||
}
|
||||
|
||||
export function hasAIChatSidePanelRetainedContent(props: Pick<
|
||||
AIChatSidePanelProps,
|
||||
'activeSessionIdMap' | 'draftsByScope' | 'sessions' | 'scopeTargetId' | 'scopeType'
|
||||
@@ -90,6 +163,49 @@ export function shouldKeepAIChatSidePanelMounted(props: AIChatSidePanelProps): b
|
||||
return isAIChatSessionStreaming(sessionId);
|
||||
}
|
||||
|
||||
function shouldDelayAIChatSidePanelActivation(props: AIChatSidePanelProps): boolean {
|
||||
if (!(props.isVisible ?? true)) return false;
|
||||
const scopeKey = `${props.scopeType}:${props.scopeTargetId ?? ''}`;
|
||||
const sessionId = props.activeSessionIdMap[scopeKey] ?? null;
|
||||
if (isAIChatSessionStreaming(sessionId)) return false;
|
||||
return !hasAIChatSidePanelRetainedContent(props);
|
||||
}
|
||||
|
||||
function schedulePanelActivation(callback: () => void): () => void {
|
||||
let timeoutId: number | null = null;
|
||||
if (typeof requestAnimationFrame === 'function') {
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
timeoutId = window.setTimeout(callback, 0);
|
||||
});
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
if (timeoutId !== null) window.clearTimeout(timeoutId);
|
||||
};
|
||||
}
|
||||
|
||||
timeoutId = window.setTimeout(callback, 0);
|
||||
return () => {
|
||||
if (timeoutId !== null) window.clearTimeout(timeoutId);
|
||||
};
|
||||
}
|
||||
|
||||
const AIChatSidePanelPreparing = React.memo(function AIChatSidePanelPreparing() {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-background" data-section="ai-chat-panel-preparing">
|
||||
<div className="shrink-0 border-b border-border/50 px-2.5 py-1.5">
|
||||
<div className="h-8 w-36 rounded-md bg-muted/45" />
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
{t('ai.chat.preparing')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
sessions,
|
||||
activeSessionIdMap,
|
||||
@@ -141,6 +257,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, AgentModelPreset[]>>({});
|
||||
const [userSkillOptions, setUserSkillOptions] = useState<UserSkillOption[]>([]);
|
||||
const [userSkillsStatusVersion, setUserSkillsStatusVersion] = useState(0);
|
||||
const { openSettingsWindow } = useWindowControls();
|
||||
const terminalSessionsRef = useRef(terminalSessions);
|
||||
terminalSessionsRef.current = terminalSessions;
|
||||
@@ -367,25 +484,25 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
};
|
||||
|
||||
const bridge = getNetcattyBridge();
|
||||
if (!bridge?.aiUserSkillsGetStatus) {
|
||||
applyUserSkillsStatus(null);
|
||||
return;
|
||||
}
|
||||
|
||||
void bridge.aiUserSkillsGetStatus()
|
||||
void loadUserSkillsStatus(bridge)
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
if (result === undefined) return;
|
||||
applyUserSkillsStatus(result);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
applyUserSkillsStatus(null);
|
||||
});
|
||||
.catch(() => {});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isVisible, scopeKey, toolIntegrationMode, updateScopeDraft]);
|
||||
}, [isVisible, scopeKey, toolIntegrationMode, updateScopeDraft, userSkillsStatusVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleUserSkillsChanged = () => {
|
||||
setUserSkillsStatusVersion((version) => version + 1);
|
||||
};
|
||||
return subscribeUserSkillsStatusChanged(handleUserSkillsChanged);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
@@ -1034,7 +1151,7 @@ const AI_CHAT_SIDE_PANEL_AI_STATE_KEYS = [
|
||||
'quickMessages',
|
||||
] as const satisfies readonly (keyof AIChatSidePanelProps)[];
|
||||
|
||||
function aiChatSidePanelPropsAreEqual(
|
||||
export function aiChatSidePanelPropsAreEqual(
|
||||
prev: AIChatSidePanelProps,
|
||||
next: AIChatSidePanelProps,
|
||||
): boolean {
|
||||
@@ -1050,6 +1167,7 @@ function aiChatSidePanelPropsAreEqual(
|
||||
if (prev.scopeType !== next.scopeType) return false;
|
||||
if (prev.scopeTargetId !== next.scopeTargetId) return false;
|
||||
if (prev.scopeLabel !== next.scopeLabel) return false;
|
||||
if ((prev.isVisible ?? true) !== (next.isVisible ?? true)) return false;
|
||||
if (prev.scopeHostIds !== next.scopeHostIds) return false;
|
||||
if (prev.terminalSessions !== next.terminalSessions) return false;
|
||||
if (prev.resolveExecutorContext !== next.resolveExecutorContext) return false;
|
||||
@@ -1061,7 +1179,25 @@ function aiChatSidePanelPropsAreEqual(
|
||||
}
|
||||
|
||||
const AIChatSidePanel = React.memo(function AIChatSidePanel(props: AIChatSidePanelProps) {
|
||||
if (!shouldKeepAIChatSidePanelMounted(props)) return null;
|
||||
const shouldKeepMounted = shouldKeepAIChatSidePanelMounted(props);
|
||||
const shouldDelayActivation = shouldKeepMounted && shouldDelayAIChatSidePanelActivation(props);
|
||||
const activationKey = `${props.scopeType}:${props.scopeTargetId ?? ''}`;
|
||||
const [activationReady, setActivationReady] = useState(!shouldDelayActivation);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldDelayActivation) {
|
||||
setActivationReady(true);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
setActivationReady(false);
|
||||
return schedulePanelActivation(() => setActivationReady(true));
|
||||
}, [activationKey, shouldDelayActivation]);
|
||||
|
||||
if (!shouldKeepMounted) return null;
|
||||
if (shouldDelayActivation && !activationReady) {
|
||||
return <AIChatSidePanelPreparing />;
|
||||
}
|
||||
// Keep hidden panels alive only when they contain real work (messages, draft
|
||||
// content, or an active stream). Empty hidden panels can drop their heavy
|
||||
// input/agent-picker subtree and remount cheaply when shown again.
|
||||
|
||||
@@ -22,7 +22,9 @@ export function isCopilotAgentConfig(agent?: ExternalAgentConfig): boolean {
|
||||
getExternalAgentSdkBackend(agent),
|
||||
]
|
||||
.filter((value): value is string => typeof value === 'string' && value.length > 0)
|
||||
.map((value) => value.split('/').pop()?.toLowerCase() ?? value.toLowerCase());
|
||||
// Split on both separators so Windows command paths (e.g. "...\\copilot.exe")
|
||||
// reduce to their basename rather than staying as the full path.
|
||||
.map((value) => value.split(/[\\/]/).pop()?.toLowerCase() ?? value.toLowerCase());
|
||||
return tokens.some((token) => token.includes('copilot'));
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ export const DISTRO_LOGOS: Record<string, string> = {
|
||||
kali: "/distro/kali.svg",
|
||||
almalinux: "/distro/almalinux.svg",
|
||||
alinux: "/distro/alinux.svg",
|
||||
openeuler: "/distro/openeuler.svg",
|
||||
// OS-level logos (used by local terminal tab icons)
|
||||
macos: "/distro/macos.svg",
|
||||
windows: "/distro/windows.svg",
|
||||
@@ -50,6 +51,7 @@ export const DISTRO_COLORS: Record<string, string> = {
|
||||
kali: "bg-[#0F6DB3]",
|
||||
almalinux: "bg-[#173B66]",
|
||||
alinux: "bg-[#FF6A00]",
|
||||
openeuler: "bg-[#002FA7]",
|
||||
// OS-level colors
|
||||
macos: "bg-[#333333]",
|
||||
windows: "bg-[#0078D4]",
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
import { AppWindow, Cloud, FileType, HardDrive, Keyboard, Palette, Sparkles, TerminalSquare, X } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useSettingsState } from "../application/state/useSettingsState";
|
||||
import { useAISettingsState } from "../application/state/useAISettingsState";
|
||||
import { useAvailableFonts } from "../application/state/fontStore";
|
||||
import { usePortForwardingState } from "../application/state/usePortForwardingState";
|
||||
import { useVaultState } from "../application/state/useVaultState";
|
||||
import { useWindowControls } from "../application/state/useWindowControls";
|
||||
import { useUpdateCheck } from "../application/state/useUpdateCheck";
|
||||
import { useAIState } from "../application/state/useAIState";
|
||||
import { I18nProvider, useI18n } from "../application/i18n/I18nProvider";
|
||||
import { sanitizePortForwardingRulesForSync } from "../application/syncPayload";
|
||||
import { toast } from "./ui/toast";
|
||||
@@ -126,7 +126,7 @@ const SettingsTerminalTabContainer = React.memo<TerminalTabSettingsProps>(functi
|
||||
});
|
||||
|
||||
const SettingsAITabContainer: React.FC = () => {
|
||||
const aiState = useAIState();
|
||||
const aiState = useAISettingsState();
|
||||
|
||||
return (
|
||||
<AITabErrorBoundary>
|
||||
@@ -157,6 +157,8 @@ const SettingsAITabContainer: React.FC = () => {
|
||||
setWebSearchConfig={aiState.setWebSearchConfig}
|
||||
quickMessages={aiState.quickMessages}
|
||||
setQuickMessages={aiState.setQuickMessages}
|
||||
showTerminalSelectionAIAction={aiState.showTerminalSelectionAIAction}
|
||||
setShowTerminalSelectionAIAction={aiState.setShowTerminalSelectionAIAction}
|
||||
/>
|
||||
</AITabErrorBoundary>
|
||||
);
|
||||
@@ -401,6 +403,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
setHotkeyScheme={settings.setHotkeyScheme}
|
||||
shellOnlyTabNumberShortcuts={settings.shellOnlyTabNumberShortcuts}
|
||||
setShellOnlyTabNumberShortcuts={settings.setShellOnlyTabNumberShortcuts}
|
||||
disableTerminalFontZoom={settings.disableTerminalFontZoom}
|
||||
setDisableTerminalFontZoom={settings.setDisableTerminalFontZoom}
|
||||
keyBindings={settings.keyBindings}
|
||||
updateKeyBinding={settings.updateKeyBinding}
|
||||
resetKeyBinding={settings.resetKeyBinding}
|
||||
|
||||
@@ -24,7 +24,7 @@ import { getParentPath, isConcreteTransferTargetPath } from "../application/stat
|
||||
import { buildCacheKey } from "../application/state/sftp/sharedRemoteHostCache";
|
||||
import { logger } from "../lib/logger";
|
||||
import type { DropEntry } from "../lib/sftpFileUtils";
|
||||
import { Host, Identity, SSHKey } from "../types";
|
||||
import { Host, Identity, KnownHost, SSHKey } from "../types";
|
||||
import type { TransferTask } from "../types";
|
||||
import { toast } from "./ui/toast";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
@@ -47,7 +47,9 @@ interface SftpSidePanelProps {
|
||||
writableHosts?: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
knownHosts?: KnownHost[];
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
onAddKnownHost?: (knownHost: KnownHost) => void;
|
||||
sftpDefaultViewMode: "list" | "tree";
|
||||
/** The host to connect to (follows focused terminal) */
|
||||
activeHost: Host | null;
|
||||
@@ -87,7 +89,9 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
writableHosts,
|
||||
keys,
|
||||
identities,
|
||||
knownHosts = [],
|
||||
updateHosts,
|
||||
onAddKnownHost,
|
||||
sftpDefaultViewMode,
|
||||
activeHost,
|
||||
activeSessionId,
|
||||
@@ -134,7 +138,9 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
defaultShowHiddenFiles: sftpShowHiddenFiles,
|
||||
autoConnectLocalOnMount: false,
|
||||
terminalSettings,
|
||||
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles, terminalSettings]);
|
||||
knownHosts,
|
||||
onAddKnownHost,
|
||||
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles, terminalSettings, knownHosts, onAddKnownHost]);
|
||||
|
||||
const sftp = useSftpState(hosts, keys, identities, sftpOptions);
|
||||
const {
|
||||
@@ -964,7 +970,9 @@ const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps):
|
||||
prev.writableHosts === next.writableHosts &&
|
||||
prev.keys === next.keys &&
|
||||
prev.identities === next.identities &&
|
||||
prev.knownHosts === next.knownHosts &&
|
||||
prev.updateHosts === next.updateHosts &&
|
||||
prev.onAddKnownHost === next.onAddKnownHost &&
|
||||
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
|
||||
prev.activeHost === next.activeHost &&
|
||||
prev.activeSessionId === next.activeSessionId &&
|
||||
|
||||
@@ -24,7 +24,7 @@ import { HotkeyScheme, KeyBinding } from "../domain/models";
|
||||
import { logger } from "../lib/logger";
|
||||
import { useRenderTracker } from "../lib/useRenderTracker";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Host, Identity, ProxyProfile, SSHKey, TransferTask } from "../types";
|
||||
import { Host, Identity, KnownHost, ProxyProfile, SSHKey, TransferTask } from "../types";
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
|
||||
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
|
||||
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
|
||||
@@ -54,9 +54,11 @@ interface SftpViewProps {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
knownHosts?: KnownHost[];
|
||||
groupConfigs?: import('../domain/models').GroupConfig[];
|
||||
proxyProfiles?: ProxyProfile[];
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
onAddKnownHost?: (knownHost: KnownHost) => void;
|
||||
sftpDefaultViewMode: "list" | "tree";
|
||||
sftpDoubleClickBehavior: "open" | "transfer";
|
||||
sftpAutoSync: boolean;
|
||||
@@ -73,9 +75,11 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
knownHosts = [],
|
||||
groupConfigs = [],
|
||||
proxyProfiles = [],
|
||||
updateHosts,
|
||||
onAddKnownHost,
|
||||
sftpDefaultViewMode,
|
||||
sftpDoubleClickBehavior,
|
||||
sftpAutoSync,
|
||||
@@ -110,7 +114,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
useCompressedUpload: sftpUseCompressedUpload,
|
||||
defaultShowHiddenFiles: sftpShowHiddenFiles,
|
||||
terminalSettings,
|
||||
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles, terminalSettings]);
|
||||
knownHosts,
|
||||
onAddKnownHost,
|
||||
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles, terminalSettings, knownHosts, onAddKnownHost]);
|
||||
|
||||
// Pre-resolve group defaults so SFTP connections inherit group config
|
||||
const effectiveHosts = useMemo(() => {
|
||||
@@ -374,9 +380,11 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
handleReorderTabsRight,
|
||||
handleMoveTabFromLeftToRight,
|
||||
handleMoveTabFromRightToLeft,
|
||||
handleDuplicateTabLeft,
|
||||
handleDuplicateTabRight,
|
||||
handleHostSelectLeft,
|
||||
handleHostSelectRight,
|
||||
} = useSftpViewTabs({ sftp, sftpRef });
|
||||
} = useSftpViewTabs({ sftp, sftpRef, hosts: effectiveHosts });
|
||||
|
||||
const handleAddTabLeftWithFocus = useCallback(() => {
|
||||
const tabId = handleAddTabLeft();
|
||||
@@ -398,6 +406,26 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
handlePaneFocus("right", tabId);
|
||||
}, [handlePaneFocus, handleSelectTabRight]);
|
||||
|
||||
const handleDuplicateTabLeftWithFocus = useCallback(
|
||||
async (...args: Parameters<typeof handleDuplicateTabLeft>) => {
|
||||
const tabId = await handleDuplicateTabLeft(...args);
|
||||
if (tabId) {
|
||||
handlePaneFocus("left", tabId);
|
||||
}
|
||||
},
|
||||
[handleDuplicateTabLeft, handlePaneFocus],
|
||||
);
|
||||
|
||||
const handleDuplicateTabRightWithFocus = useCallback(
|
||||
async (...args: Parameters<typeof handleDuplicateTabRight>) => {
|
||||
const tabId = await handleDuplicateTabRight(...args);
|
||||
if (tabId) {
|
||||
handlePaneFocus("right", tabId);
|
||||
}
|
||||
},
|
||||
[handleDuplicateTabRight, handlePaneFocus],
|
||||
);
|
||||
|
||||
return (
|
||||
<SftpContextProvider
|
||||
hosts={effectiveHosts}
|
||||
@@ -444,6 +472,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
onAddTab={handleAddTabLeftWithFocus}
|
||||
onReorderTabs={handleReorderTabsLeft}
|
||||
onMoveTabToOtherSide={handleMoveTabFromRightToLeft}
|
||||
onDuplicateTab={handleDuplicateTabLeftWithFocus}
|
||||
/>
|
||||
)}
|
||||
<div className="relative flex-1 min-h-0">
|
||||
@@ -504,6 +533,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
onAddTab={handleAddTabRightWithFocus}
|
||||
onReorderTabs={handleReorderTabsRight}
|
||||
onMoveTabToOtherSide={handleMoveTabFromLeftToRight}
|
||||
onDuplicateTab={handleDuplicateTabRightWithFocus}
|
||||
/>
|
||||
)}
|
||||
<div className="relative flex-1 min-h-0">
|
||||
@@ -588,9 +618,11 @@ const sftpViewAreEqual = (prev: SftpViewProps, next: SftpViewProps): boolean =>
|
||||
prev.hosts === next.hosts &&
|
||||
prev.keys === next.keys &&
|
||||
prev.identities === next.identities &&
|
||||
prev.knownHosts === next.knownHosts &&
|
||||
prev.groupConfigs === next.groupConfigs &&
|
||||
prev.proxyProfiles === next.proxyProfiles &&
|
||||
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
|
||||
prev.onAddKnownHost === next.onAddKnownHost &&
|
||||
prev.sftpDoubleClickBehavior === next.sftpDoubleClickBehavior &&
|
||||
prev.sftpAutoSync === next.sftpAutoSync &&
|
||||
prev.sftpShowHiddenFiles === next.sftpShowHiddenFiles &&
|
||||
|
||||
@@ -3,7 +3,7 @@ import { FitAddon } from "@xterm/addon-fit";
|
||||
import { SerializeAddon } from "@xterm/addon-serialize";
|
||||
import { SearchAddon } from "@xterm/addon-search";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { Activity, Cpu, Copy, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine, Sparkles } from "lucide-react";
|
||||
import { Activity, Cpu, Clock3, Copy, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine, Sparkles, SquareArrowOutUpRight } from "lucide-react";
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { detectLocalOs } from "../lib/localShell";
|
||||
@@ -22,8 +22,10 @@ import {
|
||||
import {
|
||||
applyCustomAccentToTerminalTheme,
|
||||
resolveHostTerminalThemeId,
|
||||
type TerminalHostUpdate,
|
||||
} from "../domain/terminalAppearance";
|
||||
import { classifyDistroId, shouldProbeSessionCwd } from "../domain/host";
|
||||
import { supportsZmodemTerminalDragDrop } from "../lib/zmodemDragDrop";
|
||||
import { resolveHostAuth } from "../domain/sshAuth";
|
||||
import { useTerminalBackend } from "../application/state/useTerminalBackend";
|
||||
import { useTerminalLayoutSuppressActive } from "../application/state/terminalLayoutSuppressStore";
|
||||
@@ -93,6 +95,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
snippets,
|
||||
snippetPackages = [],
|
||||
compactToolbar = false,
|
||||
lineTimestampsAvailable = true,
|
||||
chainHosts = [],
|
||||
themePreviewId,
|
||||
knownHosts = [],
|
||||
@@ -115,6 +118,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
reuseConnectionFromSessionId,
|
||||
serialConfig,
|
||||
hotkeyScheme = "disabled",
|
||||
disableTerminalFontZoom = false,
|
||||
keyBindings = [],
|
||||
onHotkeyAction,
|
||||
onTerminalFontSizeChange,
|
||||
@@ -145,7 +149,16 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
sessionLog,
|
||||
sshDebugLogEnabled,
|
||||
sudoAutofillPassword,
|
||||
showSelectionAIAction = true,
|
||||
onAddSelectionToAI,
|
||||
sessionDisplayName,
|
||||
onRename,
|
||||
onDetach,
|
||||
onStartSessionDrag,
|
||||
onEndSessionDrag,
|
||||
onDetachPointerDown,
|
||||
onDetachDragStart,
|
||||
onDetachDragEnd,
|
||||
}) => {
|
||||
const layoutSuppressActive = useTerminalLayoutSuppressActive();
|
||||
const deferTerminalResize = isResizing || layoutSuppressActive;
|
||||
@@ -187,6 +200,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
const terminalSettingsRef = useRef(terminalSettings);
|
||||
terminalSettingsRef.current = terminalSettings;
|
||||
const handleUpdateHostFromTerminal = useCallback((hostUpdate: TerminalHostUpdate) => {
|
||||
onUpdateHost?.(hostUpdate as Host);
|
||||
}, [onUpdateHost]);
|
||||
onTerminalDataCaptureRef.current = onTerminalDataCapture;
|
||||
const isVisibleRef = useRef(isVisible);
|
||||
isVisibleRef.current = isVisible;
|
||||
@@ -215,9 +231,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}, [captureTerminalLogData]);
|
||||
|
||||
const hotkeySchemeRef = useRef(hotkeyScheme);
|
||||
const disableTerminalFontZoomRef = useRef(disableTerminalFontZoom);
|
||||
const keyBindingsRef = useRef(keyBindings);
|
||||
const onHotkeyActionRef = useRef(onHotkeyAction);
|
||||
hotkeySchemeRef.current = hotkeyScheme;
|
||||
disableTerminalFontZoomRef.current = disableTerminalFontZoom;
|
||||
keyBindingsRef.current = keyBindings;
|
||||
onHotkeyActionRef.current = onHotkeyAction;
|
||||
|
||||
@@ -242,10 +260,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const terminalBackend = useTerminalBackend();
|
||||
const {
|
||||
resizeSession,
|
||||
receiveSerialYmodem,
|
||||
selectDirectory,
|
||||
selectDirectoryAvailable,
|
||||
selectFile,
|
||||
selectFileAvailable,
|
||||
sendSerialYmodem,
|
||||
serialYmodemAvailable,
|
||||
serialYmodemReceiveAvailable,
|
||||
setSessionEncoding,
|
||||
} = terminalBackend;
|
||||
|
||||
@@ -457,6 +479,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const detectedDeviceClass = classifyDistroId(host.distro);
|
||||
const isNetworkDevice =
|
||||
host.deviceType === 'network' || detectedDeviceClass === 'network-device';
|
||||
const remoteDragDropUsesZmodem = supportsZmodemTerminalDragDrop(host, isNetworkDevice);
|
||||
|
||||
// Check if this is a local or serial connection (doesn't need connection dialog during connecting)
|
||||
const isLocalConnection = host.protocol === "local";
|
||||
@@ -500,7 +523,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
host,
|
||||
pendingAuthRef,
|
||||
termRef,
|
||||
onUpdateHost,
|
||||
onUpdateHost: handleUpdateHostFromTerminal,
|
||||
onStartSession: (term) => {
|
||||
const starters = sessionStartersRef.current;
|
||||
if (!starters) return;
|
||||
@@ -956,6 +979,43 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
}, [isSerialConnection, selectFile, selectFileAvailable, sendSerialYmodem, serialYmodemAvailable, sessionId, t]);
|
||||
|
||||
const handleReceiveYmodem = useCallback(async () => {
|
||||
if (!isSerialConnection || statusRef.current !== "connected") return;
|
||||
if (!selectDirectoryAvailable() || !serialYmodemReceiveAvailable()) {
|
||||
toast.error(t("terminal.ymodem.unavailable"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const destinationDir = await selectDirectory(t("terminal.ymodem.selectReceiveDirectory"));
|
||||
if (!destinationDir) return;
|
||||
|
||||
toast.info(t("terminal.ymodem.receiveStarted"));
|
||||
const result = await receiveSerialYmodem(sessionRef.current || sessionId, destinationDir);
|
||||
if (result.success) {
|
||||
if (result.fileCount && result.fileCount > 1) {
|
||||
toast.success(t("terminal.ymodem.receiveCompleteMultiple", { count: result.fileCount }));
|
||||
} else if (result.fileName) {
|
||||
toast.success(t("terminal.ymodem.receiveComplete", { fileName: result.fileName }));
|
||||
} else {
|
||||
toast.success(t("terminal.ymodem.receiveEmpty"));
|
||||
}
|
||||
} else {
|
||||
toast.error(t("terminal.ymodem.receiveFailed"));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("terminal.ymodem.receiveFailed"));
|
||||
}
|
||||
}, [
|
||||
isSerialConnection,
|
||||
receiveSerialYmodem,
|
||||
selectDirectory,
|
||||
selectDirectoryAvailable,
|
||||
serialYmodemReceiveAvailable,
|
||||
sessionId,
|
||||
t,
|
||||
]);
|
||||
|
||||
const handleCancelConnect = () => {
|
||||
if (pendingHostKeyRequestId) {
|
||||
void terminalBackend.respondHostKeyVerification(pendingHostKeyRequestId, false);
|
||||
@@ -1112,6 +1172,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
} = useTerminalDragDrop({
|
||||
host,
|
||||
isLocalConnection,
|
||||
isNetworkDevice,
|
||||
onOpenSftp,
|
||||
resolveSftpInitialPath,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
@@ -1147,10 +1208,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onSnippetClick={(snippet) => { void executeSnippet(snippet); }}
|
||||
onOpenSFTP={handleOpenSFTP}
|
||||
onSendYmodem={isSerialConnection ? handleSendYmodem : undefined}
|
||||
onReceiveYmodem={isSerialConnection ? handleReceiveYmodem : undefined}
|
||||
onOpenScripts={onOpenScripts ?? (() => {})}
|
||||
onOpenHistory={onOpenHistory}
|
||||
onOpenTheme={onOpenTheme ?? (() => {})}
|
||||
onUpdateHost={onUpdateHost}
|
||||
onUpdateHost={handleUpdateHostFromTerminal}
|
||||
showClose={opts?.showClose}
|
||||
onClose={() => onCloseSession?.(sessionId)}
|
||||
isSearchOpen={isSearchOpen}
|
||||
@@ -1164,6 +1226,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
compactToolbar,
|
||||
executeSnippet,
|
||||
handleOpenSFTP,
|
||||
handleReceiveYmodem,
|
||||
handleSendYmodem,
|
||||
handleSetTerminalEncoding,
|
||||
handleToggleSearch,
|
||||
@@ -1178,7 +1241,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onOpenHistory,
|
||||
onOpenTheme,
|
||||
onToggleComposeBar,
|
||||
onUpdateHost,
|
||||
handleUpdateHostFromTerminal,
|
||||
sessionId,
|
||||
snippetPackages,
|
||||
snippets,
|
||||
@@ -1203,9 +1266,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
const effectiveComposeBarOpen = inWorkspace ? !!isWorkspaceComposeBarOpen : isComposeBarOpen;
|
||||
|
||||
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef });
|
||||
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef });
|
||||
|
||||
return <TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, containerRef, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onExpandToFocus, onOpenSystem, onSplitHorizontal, onSplitVertical, onToggleBroadcast, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, scrollToBottomAfterProgrammaticInput, searchMatchCount, selectionOverlayPosition, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, snippets, status, statusDotTone, sudoHintRef, sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />;
|
||||
return <TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, remoteDragDropUsesZmodem, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, scrollToBottomAfterProgrammaticInput, searchMatchCount, selectionOverlayPosition, sessionDisplayName, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, showSelectionAIAction, snippets, status, statusDotTone, sudoHintRef, sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />;
|
||||
};
|
||||
|
||||
const Terminal = memo(TerminalComponent, terminalPropsAreEqual);
|
||||
|
||||
@@ -3,6 +3,7 @@ import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef,
|
||||
import { activeTabStore } from '../application/state/activeTabStore';
|
||||
import { canReuseTerminalConnection } from '../application/state/terminalConnectionReuse';
|
||||
import { resolveTerminalSessionExitIntent, type TerminalSessionExitEvent } from '../application/state/resolveTerminalSessionExitIntent';
|
||||
import { prewarmAIStateStorageSnapshots } from '../application/state/aiStateSnapshots';
|
||||
import {
|
||||
getSessionActivityIdsToClear,
|
||||
getValidSessionActivityIds,
|
||||
@@ -98,6 +99,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
terminalFontFamilyId,
|
||||
fontSize = 14,
|
||||
hotkeyScheme = 'disabled',
|
||||
disableTerminalFontZoom = false,
|
||||
keyBindings = [],
|
||||
onHotkeyAction,
|
||||
onUpdateTerminalThemeId,
|
||||
@@ -122,6 +124,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onToggleWorkspaceViewMode,
|
||||
onSetWorkspaceFocusedSession,
|
||||
onReorderWorkspaceSessions,
|
||||
onReorderTabs,
|
||||
onCopySession,
|
||||
onCopySessionToNewWindow,
|
||||
onSplitSession,
|
||||
onConnectToHost,
|
||||
onCreateLocalTerminal,
|
||||
@@ -148,6 +153,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
showHostTreeSidebar = true,
|
||||
toggleScriptsSidePanelRef,
|
||||
toggleSidePanelRef,
|
||||
// Session rename props
|
||||
onStartSessionRename,
|
||||
onSubmitSessionRename,
|
||||
onRemoveSessionFromWorkspace,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const terminalRendererCwdBySessionRef = useRef<Map<string, string>>(new Map());
|
||||
@@ -161,9 +170,23 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const cwdProbeCancelersRef = useRef<Map<string, () => void>>(new Map());
|
||||
const cwdProbeGenerationRef = useRef<Map<string, number>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
const runPrewarm = () => prewarmAIStateStorageSnapshots();
|
||||
if (typeof window.requestIdleCallback === 'function') {
|
||||
const idleId = window.requestIdleCallback(runPrewarm, { timeout: 2500 });
|
||||
return () => window.cancelIdleCallback(idleId);
|
||||
}
|
||||
const timeoutId = window.setTimeout(runPrewarm, 500);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, []);
|
||||
|
||||
const handleTerminalCwdChange = useCallback((sessionId: string, cwd: string | null) => {
|
||||
if (cwd && cwd.trim().length > 0) {
|
||||
terminalRendererCwdBySessionRef.current.set(sessionId, cwd);
|
||||
const currentCwd = terminalRendererCwdBySessionRef.current.get(sessionId) ?? null;
|
||||
const nextCwd = cwd && cwd.trim().length > 0 ? cwd : null;
|
||||
if (currentCwd === nextCwd) return;
|
||||
|
||||
if (nextCwd) {
|
||||
terminalRendererCwdBySessionRef.current.set(sessionId, nextCwd);
|
||||
} else {
|
||||
terminalRendererCwdBySessionRef.current.delete(sessionId);
|
||||
}
|
||||
@@ -1103,6 +1126,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
hosts,
|
||||
hostsRef,
|
||||
hotkeyScheme,
|
||||
disableTerminalFontZoom,
|
||||
identities,
|
||||
isBroadcastEnabled,
|
||||
isComposeBarOpen,
|
||||
@@ -1121,10 +1145,18 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onCreateWorkspaceFromSessions,
|
||||
onHotkeyAction,
|
||||
onReorderWorkspaceSessions,
|
||||
onReorderTabs,
|
||||
onCopySession,
|
||||
onCopySessionToNewWindow,
|
||||
onRequestAddToWorkspace,
|
||||
onSessionData,
|
||||
onSetDraggingSessionId,
|
||||
onSetWorkspaceFocusedSession,
|
||||
onStartSessionRename,
|
||||
onSubmitSessionRename,
|
||||
onRemoveSessionFromWorkspace,
|
||||
onStartSessionDrag: onSetDraggingSessionId,
|
||||
onEndSessionDrag: () => onSetDraggingSessionId(null),
|
||||
onSplitSession,
|
||||
onSplitSessionRef,
|
||||
onToggleBroadcastRef,
|
||||
|
||||
@@ -7,9 +7,11 @@ import { useTerminalPopupWindow } from '../application/state/useTerminalPopupWin
|
||||
import { useVaultState } from '../application/state/useVaultState';
|
||||
import { useWindowControls } from '../application/state/useWindowControls';
|
||||
import { shouldCloseTerminalPopupOnExit } from '../application/state/resolveTerminalSessionExitIntent';
|
||||
import { upsertKnownHost } from '../domain/knownHosts';
|
||||
import type { TerminalPopupPayload } from '../domain/systemManager/types';
|
||||
import type { TerminalTheme } from '../domain/models';
|
||||
import type { Host } from '../types';
|
||||
import type { Host, KnownHost } from '../types';
|
||||
import { getEffectiveKnownHosts } from '../infrastructure/syncHelpers';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
const Terminal = lazy(() => import('./Terminal'));
|
||||
@@ -195,11 +197,22 @@ function TerminalPopupPageInner() {
|
||||
const { close, setWindowTitle, onPopupConfig } = useTerminalPopupWindow();
|
||||
const { notifyRendererReady, onWindowCommandCloseRequested } = useWindowControls();
|
||||
const settings = useSettingsState();
|
||||
const { isInitialized: vaultInitialized, hosts, keys, identities, knownHosts, snippets, snippetPackages } = useVaultState();
|
||||
const { isInitialized: vaultInitialized, hosts, keys, identities, knownHosts, snippets, snippetPackages, updateKnownHosts } = useVaultState();
|
||||
const [config, setConfig] = useState<TerminalPopupPayload | null>(null);
|
||||
const [terminalReady, setTerminalReady] = useState(false);
|
||||
const [startupError, setStartupError] = useState<string | null>(null);
|
||||
const sessionId = useMemo(() => crypto.randomUUID(), []);
|
||||
const knownHostsRef = React.useRef(knownHosts);
|
||||
const effectiveKnownHosts = useMemo(
|
||||
() => getEffectiveKnownHosts(knownHosts) ?? [],
|
||||
[knownHosts],
|
||||
);
|
||||
knownHostsRef.current = effectiveKnownHosts;
|
||||
const handleAddKnownHost = useCallback((knownHost: KnownHost) => {
|
||||
const nextKnownHosts = upsertKnownHost(knownHostsRef.current, knownHost);
|
||||
knownHostsRef.current = nextKnownHosts;
|
||||
updateKnownHosts(nextKnownHosts);
|
||||
}, [updateKnownHosts]);
|
||||
const popupThemeVars = useMemo(
|
||||
() => buildPopupThemeVars(settings.currentTerminalTheme),
|
||||
[settings.currentTerminalTheme],
|
||||
@@ -306,7 +319,9 @@ function TerminalPopupPageInner() {
|
||||
snippets={snippets}
|
||||
snippetPackages={snippetPackages}
|
||||
compactToolbar
|
||||
knownHosts={knownHosts}
|
||||
lineTimestampsAvailable={false}
|
||||
knownHosts={effectiveKnownHosts}
|
||||
onAddKnownHost={handleAddKnownHost}
|
||||
isVisible
|
||||
isFocused
|
||||
fontFamilyId={settings.terminalFontFamilyId}
|
||||
@@ -316,6 +331,7 @@ function TerminalPopupPageInner() {
|
||||
accentMode={settings.accentMode}
|
||||
customAccent={settings.customAccent}
|
||||
terminalSettings={settings.terminalSettings}
|
||||
disableTerminalFontZoom={settings.disableTerminalFontZoom}
|
||||
sessionId={sessionId}
|
||||
startupCommand={config.startupCommand}
|
||||
reuseConnectionFromSessionId={reuseId}
|
||||
|
||||
@@ -18,13 +18,23 @@ Object.defineProperty(globalThis, "requestAnimationFrame", {
|
||||
|
||||
const {
|
||||
computeHostTreeTabGutter,
|
||||
resolveWorkspaceSessionTabDropTarget,
|
||||
shouldKeepHostTreeToggleSurface,
|
||||
shouldShowHostTreeToggle,
|
||||
} = await import("./TopTabs.tsx");
|
||||
const {
|
||||
WORKSPACE_SESSION_DRAG_TYPE,
|
||||
dataTransferHasType,
|
||||
getTopTabInsertionTarget,
|
||||
getWorkspaceSessionDragId,
|
||||
hasWorkspaceSessionDrag,
|
||||
isPointInsideRect,
|
||||
} = await import("../application/state/terminalDragData.ts");
|
||||
const { activateLogViewTab } = await import("./top-tabs/TopTabItems.tsx");
|
||||
const { activeTabStore } = await import("../application/state/activeTabStore.ts");
|
||||
const indexCss = readFileSync(new URL("../index.css", import.meta.url), "utf8");
|
||||
const topTabsSource = readFileSync(new URL("./TopTabs.tsx", import.meta.url), "utf8");
|
||||
const terminalViewSource = readFileSync(new URL("./terminal/TerminalView.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("host tree tab gutter fills the remaining sidebar width", () => {
|
||||
assert.equal(computeHostTreeTabGutter(280, 120), 160);
|
||||
@@ -93,6 +103,152 @@ test("quick switcher plus button exposes a custom CSS hook", () => {
|
||||
assert.match(topTabsSource, /data-section="top-tabs-quick-switcher-toggle"/);
|
||||
});
|
||||
|
||||
test("workspace session drag data is recognized with a dedicated drag type", () => {
|
||||
const data = new Map([
|
||||
[WORKSPACE_SESSION_DRAG_TYPE, "session-1"],
|
||||
["session-id", "fallback-session"],
|
||||
]);
|
||||
const transfer = {
|
||||
types: [WORKSPACE_SESSION_DRAG_TYPE, "text/plain"],
|
||||
getData: (format: string) => data.get(format) ?? "",
|
||||
};
|
||||
|
||||
assert.equal(hasWorkspaceSessionDrag(transfer), true);
|
||||
assert.equal(getWorkspaceSessionDragId(transfer), "session-1");
|
||||
});
|
||||
|
||||
test("workspace session drag id falls back to the legacy session id", () => {
|
||||
const transfer = {
|
||||
types: ["session-id"],
|
||||
getData: (format: string) => (format === "session-id" ? "session-2" : ""),
|
||||
};
|
||||
|
||||
assert.equal(dataTransferHasType(transfer, "session-id"), true);
|
||||
assert.equal(hasWorkspaceSessionDrag(transfer), false);
|
||||
assert.equal(getWorkspaceSessionDragId(transfer), "session-2");
|
||||
});
|
||||
|
||||
test("point-in-rect detects pointer release inside the top tab bar", () => {
|
||||
const rect = { left: 10, right: 110, top: 20, bottom: 60 };
|
||||
|
||||
assert.equal(isPointInsideRect({ clientX: 10, clientY: 20 }, rect), true);
|
||||
assert.equal(isPointInsideRect({ clientX: 70, clientY: 40 }, rect), true);
|
||||
assert.equal(isPointInsideRect({ clientX: 111, clientY: 40 }, rect), false);
|
||||
assert.equal(isPointInsideRect({ clientX: 70, clientY: 61 }, rect), false);
|
||||
});
|
||||
|
||||
test("top tab insertion target ignores fixed root tabs", () => {
|
||||
const makeTab = (id: string, type: string, left: number, right: number) => ({
|
||||
dataset: { tabId: id, tabType: type },
|
||||
getBoundingClientRect: () => ({ left, right, top: 20, bottom: 60, width: right - left, height: 40 }),
|
||||
});
|
||||
const root = {
|
||||
getBoundingClientRect: () => ({ left: 0, right: 400, top: 0, bottom: 80, width: 400, height: 80 }),
|
||||
querySelectorAll: () => [
|
||||
makeTab("vault", "root", 0, 80),
|
||||
makeTab("workspace-1", "workspace", 90, 210),
|
||||
makeTab("session-1", "session", 210, 330),
|
||||
],
|
||||
} as unknown as HTMLElement;
|
||||
|
||||
assert.deepEqual(getTopTabInsertionTarget({ clientX: 20, clientY: 40 }, root), {
|
||||
tabId: "workspace-1",
|
||||
position: "before",
|
||||
});
|
||||
assert.deepEqual(getTopTabInsertionTarget({ clientX: 180, clientY: 40 }, root), {
|
||||
tabId: "workspace-1",
|
||||
position: "after",
|
||||
});
|
||||
assert.deepEqual(getTopTabInsertionTarget({ clientX: 380, clientY: 40 }, root), {
|
||||
tabId: "session-1",
|
||||
position: "after",
|
||||
});
|
||||
assert.equal(getTopTabInsertionTarget({ clientX: 180, clientY: 120 }, root), null);
|
||||
});
|
||||
|
||||
test("workspace session tab drop forwards the requested insertion target", () => {
|
||||
assert.deepEqual(resolveWorkspaceSessionTabDropTarget({
|
||||
targetTabId: "session-3",
|
||||
position: "after",
|
||||
draggedSessionId: "session-1",
|
||||
draggedWorkspaceId: "workspace-1",
|
||||
workspaces: [],
|
||||
}), {
|
||||
tabId: "session-3",
|
||||
position: "after",
|
||||
additionalTabIds: ["session-1", "session-3"],
|
||||
});
|
||||
});
|
||||
|
||||
test("workspace session tab drop targets the remaining terminal when its workspace dissolves", () => {
|
||||
assert.deepEqual(resolveWorkspaceSessionTabDropTarget({
|
||||
targetTabId: "workspace-1",
|
||||
position: "before",
|
||||
draggedSessionId: "session-1",
|
||||
draggedWorkspaceId: "workspace-1",
|
||||
workspaces: [{
|
||||
id: "workspace-1",
|
||||
title: "Workspace",
|
||||
focusedSessionId: "session-1",
|
||||
root: {
|
||||
id: "split-1",
|
||||
type: "split",
|
||||
direction: "horizontal",
|
||||
children: [
|
||||
{ id: "pane-1", type: "pane", sessionId: "session-1" },
|
||||
{ id: "pane-2", type: "pane", sessionId: "session-2" },
|
||||
],
|
||||
sizes: [1, 1],
|
||||
},
|
||||
}],
|
||||
}), {
|
||||
tabId: "session-2",
|
||||
position: "before",
|
||||
additionalTabIds: ["session-1", "session-2"],
|
||||
});
|
||||
});
|
||||
|
||||
test("workspace session tab-bar blank drop inserts after the last work tab", () => {
|
||||
const makeTab = (id: string, type: string, left: number, right: number) => ({
|
||||
dataset: { tabId: id, tabType: type },
|
||||
getBoundingClientRect: () => ({ left, right, top: 20, bottom: 60, width: right - left, height: 40 }),
|
||||
});
|
||||
const root = {
|
||||
getBoundingClientRect: () => ({ left: 0, right: 500, top: 0, bottom: 80, width: 500, height: 80 }),
|
||||
querySelectorAll: () => [
|
||||
makeTab("vault", "root", 0, 80),
|
||||
makeTab("workspace-1", "workspace", 90, 210),
|
||||
makeTab("session-3", "session", 210, 330),
|
||||
],
|
||||
} as unknown as HTMLElement;
|
||||
const insertionTarget = getTopTabInsertionTarget({ clientX: 460, clientY: 40 }, root);
|
||||
|
||||
assert.deepEqual(insertionTarget, { tabId: "session-3", position: "after" });
|
||||
assert.deepEqual(resolveWorkspaceSessionTabDropTarget({
|
||||
targetTabId: insertionTarget!.tabId,
|
||||
position: insertionTarget!.position,
|
||||
draggedSessionId: "session-1",
|
||||
draggedWorkspaceId: "workspace-1",
|
||||
workspaces: [],
|
||||
}), {
|
||||
tabId: "session-3",
|
||||
position: "after",
|
||||
additionalTabIds: ["session-1", "session-3"],
|
||||
});
|
||||
});
|
||||
|
||||
test("terminal top bar hides server stats before they crowd the host title", () => {
|
||||
assert.match(indexCss, /\.terminal-topbar\s*\{[\s\S]*container-type: inline-size/);
|
||||
assert.match(indexCss, /@container \(max-width: 760px\) \{[\s\S]*\.terminal-server-stats\s*\{[\s\S]*display: none/);
|
||||
assert.match(terminalViewSource, /terminal-topbar/);
|
||||
assert.match(terminalViewSource, /terminal-title-cluster/);
|
||||
assert.match(terminalViewSource, /onPointerDown=\{onDetachPointerDown\}/);
|
||||
});
|
||||
|
||||
test("workspace session drag no longer uses a full tab-bar drop zone", () => {
|
||||
assert.doesNotMatch(topTabsSource, /top-tabs-workspace-detach-drop-zone/);
|
||||
});
|
||||
|
||||
test("host tree chrome enters after theme switch settles so root labels can animate", () => {
|
||||
assert.match(topTabsSource, /hostTreeChromeReady/);
|
||||
assert.match(topTabsSource, /scheduleAfterInstantThemeSwitch\(\(\) => \{\s*cancelHostTreeChromeReadyRef\.current = null;\s*setHostTreeChromeReady\(true\);/);
|
||||
|
||||
@@ -4,7 +4,9 @@ import { fromEditorTabId, isEditorTabId, useActiveTabId } from '../application/s
|
||||
import { isHostTreeWorkTabSurface } from '../application/app/workTabSurface';
|
||||
import type { EditorTab } from '../application/state/editorTabStore';
|
||||
import { buildWorkspaceActivityMap } from '../application/state/sessionActivity';
|
||||
import { collectSessionIds } from '../domain/workspace';
|
||||
import { useSessionActivityMap } from '../application/state/sessionActivityStore';
|
||||
import { getTopTabInsertionTarget, getWorkspaceSessionDragId, hasWorkspaceSessionDrag } from '../application/state/terminalDragData';
|
||||
import {
|
||||
useTerminalHostTreeLayoutWidth,
|
||||
useTerminalHostTreeOpen,
|
||||
@@ -82,6 +84,34 @@ export function shouldKeepHostTreeToggleSurface({
|
||||
return enabled && activeWorkTabCount > 0;
|
||||
}
|
||||
|
||||
export function resolveWorkspaceSessionTabDropTarget({
|
||||
targetTabId,
|
||||
position,
|
||||
draggedSessionId,
|
||||
draggedWorkspaceId,
|
||||
workspaces,
|
||||
}: {
|
||||
targetTabId: string;
|
||||
position: 'before' | 'after';
|
||||
draggedSessionId: string;
|
||||
draggedWorkspaceId: string;
|
||||
workspaces: readonly Workspace[];
|
||||
}): { tabId: string; position: 'before' | 'after'; additionalTabIds: readonly string[] } {
|
||||
const sourceWorkspace = workspaces.find((workspace) => workspace.id === draggedWorkspaceId);
|
||||
const remainingSessionIds = sourceWorkspace
|
||||
? collectSessionIds(sourceWorkspace.root).filter((sessionId) => sessionId !== draggedSessionId)
|
||||
: [];
|
||||
const stableTargetTabId = targetTabId === draggedWorkspaceId && remainingSessionIds.length === 1
|
||||
? remainingSessionIds[0]
|
||||
: targetTabId;
|
||||
|
||||
return {
|
||||
tabId: stableTargetTabId,
|
||||
position,
|
||||
additionalTabIds: [draggedSessionId, stableTargetTabId],
|
||||
};
|
||||
}
|
||||
|
||||
interface TopTabsProps {
|
||||
theme: 'dark' | 'light';
|
||||
hosts: Host[];
|
||||
@@ -109,6 +139,10 @@ interface TopTabsProps {
|
||||
onStartSessionDrag: (sessionId: string) => void;
|
||||
onEndSessionDrag: () => void;
|
||||
onReorderTabs: (draggedId: string, targetId: string, position: 'before' | 'after') => void;
|
||||
onRemoveSessionFromWorkspace: (
|
||||
sessionId: string,
|
||||
tabInsertionTarget?: { tabId: string; position: 'before' | 'after'; additionalTabIds?: readonly string[] },
|
||||
) => void;
|
||||
showSftpTab: boolean;
|
||||
showHostTreeSidebar: boolean;
|
||||
editorTabs: readonly EditorTab[];
|
||||
@@ -143,6 +177,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onStartSessionDrag,
|
||||
onEndSessionDrag,
|
||||
onReorderTabs,
|
||||
onRemoveSessionFromWorkspace,
|
||||
showSftpTab,
|
||||
showHostTreeSidebar,
|
||||
editorTabs,
|
||||
@@ -386,7 +421,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const syncGutter = () => updateHostTreeTabGutterRef.current();
|
||||
syncGutter({ deferClose: true });
|
||||
updateHostTreeTabGutterRef.current({ deferClose: true });
|
||||
const rafId = window.requestAnimationFrame(() => syncGutter());
|
||||
const settleTimer = window.setTimeout(syncGutter, 320);
|
||||
const root = tabsContainerRef.current?.closest('[data-top-tabs-root]') as HTMLElement | null;
|
||||
@@ -442,6 +477,11 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
|
||||
if (hasWorkspaceSessionDrag(e.dataTransfer)) {
|
||||
setDropIndicator(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!draggedTabIdRef.current || draggedTabIdRef.current === tabId) {
|
||||
return;
|
||||
}
|
||||
@@ -463,6 +503,26 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
|
||||
const handleTabDrop = useCallback((e: React.DragEvent, targetTabId: string) => {
|
||||
e.preventDefault();
|
||||
if (hasWorkspaceSessionDrag(e.dataTransfer)) {
|
||||
const draggedSessionId = getWorkspaceSessionDragId(e.dataTransfer);
|
||||
const draggedSession = sessions.find((s) => s.id === draggedSessionId);
|
||||
if (draggedSession?.workspaceId) {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const position: 'before' | 'after' = e.clientX < rect.left + rect.width / 2 ? 'before' : 'after';
|
||||
onRemoveSessionFromWorkspace(draggedSessionId, resolveWorkspaceSessionTabDropTarget({
|
||||
targetTabId,
|
||||
position,
|
||||
draggedSessionId,
|
||||
draggedWorkspaceId: draggedSession.workspaceId,
|
||||
workspaces,
|
||||
}));
|
||||
setDropIndicator(null);
|
||||
setIsDraggingForReorder(false);
|
||||
onEndSessionDrag();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const draggedId = e.dataTransfer.getData('tab-reorder-id') || draggedTabIdRef.current;
|
||||
|
||||
if (draggedId && draggedId !== targetTabId && dropIndicator) {
|
||||
@@ -471,7 +531,33 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
|
||||
setDropIndicator(null);
|
||||
setIsDraggingForReorder(false);
|
||||
}, [dropIndicator, onReorderTabs]);
|
||||
}, [dropIndicator, onEndSessionDrag, onRemoveSessionFromWorkspace, onReorderTabs, sessions, workspaces]);
|
||||
|
||||
const handleTabBarDrop = useCallback((e: React.DragEvent) => {
|
||||
if (!hasWorkspaceSessionDrag(e.dataTransfer)) return;
|
||||
const draggedSessionId = getWorkspaceSessionDragId(e.dataTransfer);
|
||||
if (!draggedSessionId) return;
|
||||
const draggedSession = sessions.find((s) => s.id === draggedSessionId);
|
||||
if (!draggedSession?.workspaceId) return;
|
||||
e.preventDefault();
|
||||
const root = e.currentTarget.closest('[data-top-tabs-root]') as HTMLElement | null;
|
||||
const insertionTarget = getTopTabInsertionTarget(e, root);
|
||||
onRemoveSessionFromWorkspace(
|
||||
draggedSessionId,
|
||||
insertionTarget
|
||||
? resolveWorkspaceSessionTabDropTarget({
|
||||
targetTabId: insertionTarget.tabId,
|
||||
position: insertionTarget.position,
|
||||
draggedSessionId,
|
||||
draggedWorkspaceId: draggedSession.workspaceId,
|
||||
workspaces,
|
||||
})
|
||||
: undefined,
|
||||
);
|
||||
setDropIndicator(null);
|
||||
setIsDraggingForReorder(false);
|
||||
onEndSessionDrag();
|
||||
}, [onEndSessionDrag, onRemoveSessionFromWorkspace, sessions, workspaces]);
|
||||
|
||||
const handleScrollableTabClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const target = e.target as HTMLElement;
|
||||
@@ -682,6 +768,14 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
const shiftStyle = tabShiftStyles[workspace.id] || emptyTabStyle;
|
||||
const showDropIndicatorBefore = dropIndicator?.tabId === workspace.id && dropIndicator.position === 'before';
|
||||
const showDropIndicatorAfter = dropIndicator?.tabId === workspace.id && dropIndicator.position === 'after';
|
||||
const workspaceSessionIds = collectSessionIds(workspace.root);
|
||||
const workspaceSessionLabels: Record<string, string> = {};
|
||||
for (const sessionId of workspaceSessionIds) {
|
||||
const wsSession = sessions.find((s) => s.id === sessionId);
|
||||
if (wsSession) {
|
||||
workspaceSessionLabels[sessionId] = wsSession.customName || wsSession.hostLabel;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspaceTopTab
|
||||
@@ -701,6 +795,8 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onTabDrop={handleTabDrop}
|
||||
onRenameWorkspace={onRenameWorkspace}
|
||||
onCloseWorkspace={onCloseWorkspace}
|
||||
onDetachSessionFromWorkspace={(_workspaceId, sessionId) => onRemoveSessionFromWorkspace(sessionId)}
|
||||
workspaceSessionLabels={workspaceSessionLabels}
|
||||
renderBulkCloseItems={renderBulkCloseItems}
|
||||
t={t}
|
||||
tabAnimationClass={getTabAnimationClass(workspace.id)}
|
||||
@@ -801,12 +897,18 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
style={dragRegionStyle}
|
||||
// Add container-level drag handlers to prevent indicator loss
|
||||
onDragOver={(e) => {
|
||||
if (hasWorkspaceSessionDrag(e.dataTransfer)) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
return;
|
||||
}
|
||||
// Keep drop indicator active while dragging over the container
|
||||
if (draggedTabIdRef.current && isDraggingForReorder && !dropIndicator) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
}}
|
||||
onDrop={handleTabBarDrop}
|
||||
>
|
||||
{hasHostTreeToggleSurface && (
|
||||
<div
|
||||
@@ -871,6 +973,13 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
className="flex items-end gap-0 overflow-x-auto scrollbar-none app-drag max-w-full"
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
onClick={handleScrollableTabClick}
|
||||
onDragOver={(e) => {
|
||||
if (hasWorkspaceSessionDrag(e.dataTransfer)) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
}}
|
||||
onDrop={handleTabBarDrop}
|
||||
>
|
||||
{renderOrderedTabs()}
|
||||
{/* Add new tab button - follows last tab when not overflowing */}
|
||||
|
||||
38
components/ai/userSkillsStatusEvents.ts
Normal file
38
components/ai/userSkillsStatusEvents.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export const USER_SKILLS_STATUS_CHANGED_EVENT = 'netcatty:user-skills-status-changed';
|
||||
const USER_SKILLS_STATUS_CHANGED_KEY = 'ai:user-skills-status-changed';
|
||||
|
||||
type SettingsBridge = {
|
||||
notifySettingsChanged?: (payload: { key: string; value: unknown }) => void;
|
||||
onSettingsChanged?: (callback: (payload: { key: string; value: unknown }) => void) => () => void;
|
||||
};
|
||||
|
||||
function getSettingsBridge(): SettingsBridge | undefined {
|
||||
return (window as unknown as { netcatty?: SettingsBridge }).netcatty;
|
||||
}
|
||||
|
||||
export function notifyUserSkillsStatusChanged() {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.dispatchEvent(new Event(USER_SKILLS_STATUS_CHANGED_EVENT));
|
||||
getSettingsBridge()?.notifySettingsChanged?.({
|
||||
key: USER_SKILLS_STATUS_CHANGED_KEY,
|
||||
value: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
export function subscribeUserSkillsStatusChanged(callback: () => void): () => void {
|
||||
if (typeof window === 'undefined') return () => {};
|
||||
|
||||
const handleLocalEvent = () => callback();
|
||||
window.addEventListener(USER_SKILLS_STATUS_CHANGED_EVENT, handleLocalEvent);
|
||||
|
||||
const unsubscribeSettings = getSettingsBridge()?.onSettingsChanged?.((payload) => {
|
||||
if (payload.key === USER_SKILLS_STATUS_CHANGED_KEY) {
|
||||
callback();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(USER_SKILLS_STATUS_CHANGED_EVENT, handleLocalEvent);
|
||||
unsubscribeSettings?.();
|
||||
};
|
||||
}
|
||||
18
components/settings/TerminalBehaviorSettings.test.tsx
Normal file
18
components/settings/TerminalBehaviorSettings.test.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import * as terminalBehaviorSettings from "./tabs/TerminalBehaviorSettings.tsx";
|
||||
|
||||
const middleClickBehaviorOptions = (
|
||||
terminalBehaviorSettings as {
|
||||
MIDDLE_CLICK_BEHAVIOR_OPTIONS?: Array<{ value: string; labelKey: string }>;
|
||||
}
|
||||
).MIDDLE_CLICK_BEHAVIOR_OPTIONS;
|
||||
|
||||
test("middle-click settings expose only supported behaviors", () => {
|
||||
assert.ok(Array.isArray(middleClickBehaviorOptions));
|
||||
assert.deepEqual(
|
||||
middleClickBehaviorOptions.map((option) => option.value),
|
||||
["context-menu", "paste", "disabled"],
|
||||
);
|
||||
});
|
||||
@@ -8,13 +8,15 @@ interface ToggleProps {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export const Toggle: React.FC<ToggleProps> = ({ checked, onChange, disabled }) => (
|
||||
export const Toggle: React.FC<ToggleProps> = ({ checked, onChange, disabled, ariaLabel }) => (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={ariaLabel}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={cn(
|
||||
@@ -69,7 +71,7 @@ export const Select: React.FC<SelectProps> = ({
|
||||
</SelectPrimitive.Trigger>
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
className="z-[200000] max-h-80 w-max max-w-[var(--radix-select-content-available-width)] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1"
|
||||
className="z-[200000] max-h-80 w-max max-w-[min(24rem,var(--radix-select-content-available-width))] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1"
|
||||
position="popper"
|
||||
sideOffset={4}
|
||||
style={{ minWidth: "max(12rem, var(--radix-select-trigger-width))" }}
|
||||
@@ -82,7 +84,7 @@ export const Select: React.FC<SelectProps> = ({
|
||||
<SelectPrimitive.Item
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
className="relative flex w-full min-w-max cursor-default select-none items-center whitespace-nowrap rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
|
||||
className="relative flex w-full min-w-0 cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
@@ -90,7 +92,7 @@ export const Select: React.FC<SelectProps> = ({
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>
|
||||
<span className="flex items-center gap-2 whitespace-nowrap">
|
||||
<span className="flex min-w-0 items-center gap-2 whitespace-normal break-words leading-snug">
|
||||
{opt.icon}
|
||||
{opt.label}
|
||||
</span>
|
||||
|
||||
@@ -21,9 +21,11 @@ import type { ManagedAgentKey } from "../../../infrastructure/ai/managedAgents";
|
||||
import { PROVIDER_PRESETS } from "../../../infrastructure/ai/types";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../ui/button";
|
||||
import { Select, SettingCard, SettingsSection, SettingsTabContent, SettingRow } from "../settings-ui";
|
||||
import { Select, SettingCard, SettingsSection, SettingsTabContent, SettingRow, Toggle } from "../settings-ui";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../ui/tabs";
|
||||
import { AgentIconBadge } from "../../ai/AgentIconBadge";
|
||||
import { canSendWithAgent } from "../../ai/agentSendEligibility";
|
||||
import { notifyUserSkillsStatusChanged } from "../../ai/userSkillsStatusEvents";
|
||||
|
||||
import type {
|
||||
AgentPathInfo,
|
||||
@@ -56,6 +58,57 @@ import {
|
||||
import { splitClaudeEnv, buildClaudeEnv } from "./ai/claudeConfigEnv";
|
||||
import { splitCodebuddyEnv } from "./ai/codebuddyConfigEnv";
|
||||
|
||||
type IdleWindow = Window & {
|
||||
requestIdleCallback?: (callback: IdleRequestCallback, options?: IdleRequestOptions) => number;
|
||||
cancelIdleCallback?: (handle: number) => void;
|
||||
};
|
||||
|
||||
function scheduleAfterFirstPaint(callback: () => void, delayMs = 0): () => void {
|
||||
let cancelled = false;
|
||||
let idleHandle: number | null = null;
|
||||
const timeoutHandle = window.setTimeout(() => {
|
||||
if (cancelled) return;
|
||||
const idleWindow = window as IdleWindow;
|
||||
if (typeof idleWindow.requestIdleCallback === "function") {
|
||||
idleHandle = idleWindow.requestIdleCallback(() => {
|
||||
if (!cancelled) callback();
|
||||
}, { timeout: 1200 });
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
}, delayMs);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timeoutHandle);
|
||||
if (idleHandle !== null) {
|
||||
(window as IdleWindow).cancelIdleCallback?.(idleHandle);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
type AISettingsSubTab = "providers" | "agents" | "tools" | "search" | "safety";
|
||||
|
||||
function getSavedManagedAgentPathInfo(
|
||||
agents: ExternalAgentConfig[],
|
||||
agentKey: ManagedAgentKey,
|
||||
): AgentPathInfo | null {
|
||||
const managed = agents.find((agent) => agent.id === `discovered_${agentKey}`);
|
||||
const command = typeof managed?.command === "string" ? managed.command.trim() : "";
|
||||
if (!managed || !command) return null;
|
||||
const savedAvailable = managed.available === true || managed.enabled === true;
|
||||
|
||||
return {
|
||||
path: command,
|
||||
binPath: command,
|
||||
version: null,
|
||||
available: savedAvailable,
|
||||
installed: true,
|
||||
authenticated: undefined,
|
||||
authSource: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Props
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -87,6 +140,8 @@ interface SettingsAITabProps {
|
||||
setWebSearchConfig: (config: WebSearchConfig | null) => void;
|
||||
quickMessages: AIQuickMessage[];
|
||||
setQuickMessages: (value: AIQuickMessage[] | ((prev: AIQuickMessage[]) => AIQuickMessage[])) => void;
|
||||
showTerminalSelectionAIAction: boolean;
|
||||
setShowTerminalSelectionAIAction: (value: boolean | ((prev: boolean) => boolean)) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -120,6 +175,8 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
setWebSearchConfig,
|
||||
quickMessages,
|
||||
setQuickMessages,
|
||||
showTerminalSelectionAIAction,
|
||||
setShowTerminalSelectionAIAction,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [editingProviderId, setEditingProviderId] = useState<string | null>(null);
|
||||
@@ -127,14 +184,29 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
const [codexLoginSession, setCodexLoginSession] = useState<CodexLoginSession | null>(null);
|
||||
const [isCodexLoading, setIsCodexLoading] = useState(false);
|
||||
const [codexError, setCodexError] = useState<string | null>(null);
|
||||
const initialManagedPathsRef = useRef<{
|
||||
codex: string;
|
||||
claude: string;
|
||||
copilot: string;
|
||||
cursor: string;
|
||||
codebuddy: string;
|
||||
} | null>(null);
|
||||
if (!initialManagedPathsRef.current) {
|
||||
initialManagedPathsRef.current = getInitialManagedAgentPaths(externalAgents);
|
||||
}
|
||||
|
||||
// Path detection state
|
||||
const [codexPathInfo, setCodexPathInfo] = useState<AgentPathInfo | null>(null);
|
||||
const [codexCustomPath, setCodexCustomPath] = useState("");
|
||||
const [codexPathInfo, setCodexPathInfo] = useState<AgentPathInfo | null>(
|
||||
() => getSavedManagedAgentPathInfo(externalAgents, "codex"),
|
||||
);
|
||||
const [codexCustomPath, setCodexCustomPath] = useState(() => initialManagedPathsRef.current?.codex ?? "");
|
||||
const [isResolvingCodex, setIsResolvingCodex] = useState(false);
|
||||
const [activeSubTab, setActiveSubTab] = useState<AISettingsSubTab>("providers");
|
||||
|
||||
const [claudePathInfo, setClaudePathInfo] = useState<AgentPathInfo | null>(null);
|
||||
const [claudeCustomPath, setClaudeCustomPath] = useState("");
|
||||
const [claudePathInfo, setClaudePathInfo] = useState<AgentPathInfo | null>(
|
||||
() => getSavedManagedAgentPathInfo(externalAgents, "claude"),
|
||||
);
|
||||
const [claudeCustomPath, setClaudeCustomPath] = useState(() => initialManagedPathsRef.current?.claude ?? "");
|
||||
const [isResolvingClaude, setIsResolvingClaude] = useState(false);
|
||||
|
||||
const claudeManagedEnv = useMemo(
|
||||
@@ -160,26 +232,21 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
[setExternalAgents],
|
||||
);
|
||||
|
||||
const initialManagedPathsRef = useRef<{
|
||||
codex: string;
|
||||
claude: string;
|
||||
copilot: string;
|
||||
cursor: string;
|
||||
codebuddy: string;
|
||||
} | null>(null);
|
||||
if (!initialManagedPathsRef.current) {
|
||||
initialManagedPathsRef.current = getInitialManagedAgentPaths(externalAgents);
|
||||
}
|
||||
|
||||
const [copilotPathInfo, setCopilotPathInfo] = useState<AgentPathInfo | null>(null);
|
||||
const [copilotCustomPath, setCopilotCustomPath] = useState("");
|
||||
const [copilotPathInfo, setCopilotPathInfo] = useState<AgentPathInfo | null>(
|
||||
() => getSavedManagedAgentPathInfo(externalAgents, "copilot"),
|
||||
);
|
||||
const [copilotCustomPath, setCopilotCustomPath] = useState(() => initialManagedPathsRef.current?.copilot ?? "");
|
||||
const [isResolvingCopilot, setIsResolvingCopilot] = useState(false);
|
||||
|
||||
const [cursorPathInfo, setCursorPathInfo] = useState<AgentPathInfo | null>(null);
|
||||
const [cursorPathInfo, setCursorPathInfo] = useState<AgentPathInfo | null>(
|
||||
() => getSavedManagedAgentPathInfo(externalAgents, "cursor"),
|
||||
);
|
||||
const [isResolvingCursor, setIsResolvingCursor] = useState(false);
|
||||
|
||||
const [codebuddyPathInfo, setCodebuddyPathInfo] = useState<AgentPathInfo | null>(null);
|
||||
const [codebuddyCustomPath, setCodebuddyCustomPath] = useState("");
|
||||
const [codebuddyPathInfo, setCodebuddyPathInfo] = useState<AgentPathInfo | null>(
|
||||
() => getSavedManagedAgentPathInfo(externalAgents, "codebuddy"),
|
||||
);
|
||||
const [codebuddyCustomPath, setCodebuddyCustomPath] = useState(() => initialManagedPathsRef.current?.codebuddy ?? "");
|
||||
const [isResolvingCodebuddy, setIsResolvingCodebuddy] = useState(false);
|
||||
|
||||
const codebuddyManagedEnv = useMemo(
|
||||
@@ -209,15 +276,22 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
// Ref to read current defaultAgentId without adding it as a dependency.
|
||||
const defaultAgentIdRef = useRef(defaultAgentId);
|
||||
defaultAgentIdRef.current = defaultAgentId;
|
||||
const autoResolvedAgentStateRef = useRef<Partial<Record<ManagedAgentKey, "pending" | "done">>>({});
|
||||
const codexIntegrationLoadedRef = useRef(false);
|
||||
const userSkillsLoadedRef = useRef(false);
|
||||
const mountedRef = useRef(true);
|
||||
const agentPathRequestIdRef = useRef<Partial<Record<ManagedAgentKey, number>>>({});
|
||||
const codexRequestIdRef = useRef(0);
|
||||
|
||||
const resolveAgentPath = useCallback(async (
|
||||
agentKey: ManagedAgentKey,
|
||||
customPath = "",
|
||||
options?: { apiKeyPresent?: boolean },
|
||||
) => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge?.aiResolveCli) return null;
|
||||
useEffect(() => () => {
|
||||
mountedRef.current = false;
|
||||
codexRequestIdRef.current += 1;
|
||||
for (const key of ["codex", "claude", "copilot", "cursor", "codebuddy"] as ManagedAgentKey[]) {
|
||||
agentPathRequestIdRef.current[key] = (agentPathRequestIdRef.current[key] ?? 0) + 1;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const applyResolvedAgentPath = useCallback((agentKey: ManagedAgentKey, result: AgentPathInfo | null) => {
|
||||
const setInfo = agentKey === "codex"
|
||||
? setCodexPathInfo
|
||||
: agentKey === "claude"
|
||||
@@ -227,6 +301,31 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
: agentKey === "cursor"
|
||||
? setCursorPathInfo
|
||||
: setCodebuddyPathInfo;
|
||||
|
||||
setInfo(result);
|
||||
|
||||
let nextDefaultId: string | null = null;
|
||||
setExternalAgents((prev) => {
|
||||
const state = buildManagedAgentState(prev, defaultAgentIdRef.current, agentKey, result);
|
||||
if (state.defaultAgentId !== defaultAgentIdRef.current) {
|
||||
nextDefaultId = state.defaultAgentId;
|
||||
defaultAgentIdRef.current = state.defaultAgentId;
|
||||
}
|
||||
return areExternalAgentListsEqual(prev, state.agents) ? prev : state.agents;
|
||||
});
|
||||
if (nextDefaultId !== null) {
|
||||
setDefaultAgentId(nextDefaultId);
|
||||
}
|
||||
}, [setDefaultAgentId, setExternalAgents]);
|
||||
|
||||
const resolveAgentPath = useCallback(async (
|
||||
agentKey: ManagedAgentKey,
|
||||
customPath = "",
|
||||
options?: { apiKeyPresent?: boolean; refreshShellEnv?: boolean },
|
||||
) => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge?.aiResolveCli) return null;
|
||||
|
||||
const setResolving = agentKey === "codex"
|
||||
? setIsResolvingCodex
|
||||
: agentKey === "claude"
|
||||
@@ -238,49 +337,66 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
: setIsResolvingCodebuddy;
|
||||
|
||||
setResolving(true);
|
||||
const requestId = (agentPathRequestIdRef.current[agentKey] ?? 0) + 1;
|
||||
agentPathRequestIdRef.current[agentKey] = requestId;
|
||||
const isCurrentRequest = () => (
|
||||
mountedRef.current
|
||||
&& agentPathRequestIdRef.current[agentKey] === requestId
|
||||
);
|
||||
try {
|
||||
const result = await bridge.aiResolveCli({
|
||||
command: agentKey,
|
||||
customPath: customPath.trim(),
|
||||
refreshShellEnv: agentKey === "cursor",
|
||||
refreshShellEnv: Boolean(options?.refreshShellEnv),
|
||||
...(agentKey === "cursor" ? { apiKeyPresent: Boolean(options?.apiKeyPresent ?? cursorApiKeyEncrypted) } : {}),
|
||||
});
|
||||
setInfo(result);
|
||||
|
||||
// Consolidate managed agent entries using the callback form of
|
||||
// setExternalAgents so we never depend on externalAgents directly.
|
||||
// All three agents resolve concurrently on mount — React runs
|
||||
// state updater callbacks sequentially, so updating the ref inside
|
||||
// ensures later calls see earlier defaultAgentId changes.
|
||||
let nextDefaultId: string | null = null;
|
||||
setExternalAgents((prev) => {
|
||||
const state = buildManagedAgentState(prev, defaultAgentIdRef.current, agentKey, result);
|
||||
if (state.defaultAgentId !== defaultAgentIdRef.current) {
|
||||
nextDefaultId = state.defaultAgentId;
|
||||
defaultAgentIdRef.current = state.defaultAgentId;
|
||||
}
|
||||
return areExternalAgentListsEqual(prev, state.agents) ? prev : state.agents;
|
||||
});
|
||||
if (nextDefaultId !== null) {
|
||||
setDefaultAgentId(nextDefaultId);
|
||||
}
|
||||
if (!isCurrentRequest()) return null;
|
||||
applyResolvedAgentPath(agentKey, result);
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error("Path resolution failed:", err);
|
||||
return null;
|
||||
} finally {
|
||||
setResolving(false);
|
||||
if (isCurrentRequest()) {
|
||||
setResolving(false);
|
||||
}
|
||||
}
|
||||
}, [cursorApiKeyEncrypted, setExternalAgents, setDefaultAgentId]);
|
||||
}, [applyResolvedAgentPath, cursorApiKeyEncrypted]);
|
||||
|
||||
useEffect(() => {
|
||||
void resolveAgentPath("codex", initialManagedPathsRef.current?.codex ?? "");
|
||||
void resolveAgentPath("claude", initialManagedPathsRef.current?.claude ?? "");
|
||||
void resolveAgentPath("copilot", initialManagedPathsRef.current?.copilot ?? "");
|
||||
void resolveAgentPath("cursor", initialManagedPathsRef.current?.cursor ?? "", { apiKeyPresent: Boolean(cursorApiKeyEncrypted) });
|
||||
void resolveAgentPath("codebuddy", initialManagedPathsRef.current?.codebuddy ?? "");
|
||||
}, [cursorApiKeyEncrypted, resolveAgentPath]);
|
||||
if (activeSubTab !== "agents") return;
|
||||
|
||||
const initialPaths = initialManagedPathsRef.current;
|
||||
const tasks: Array<{
|
||||
key: ManagedAgentKey;
|
||||
delayMs: number;
|
||||
path: string;
|
||||
options?: { apiKeyPresent?: boolean };
|
||||
}> = [
|
||||
{ key: "codex", delayMs: 160, path: initialPaths?.codex ?? "" },
|
||||
{ key: "claude", delayMs: 440, path: initialPaths?.claude ?? "" },
|
||||
{ key: "copilot", delayMs: 720, path: initialPaths?.copilot ?? "" },
|
||||
{
|
||||
key: "cursor",
|
||||
delayMs: 1000,
|
||||
path: initialPaths?.cursor ?? "",
|
||||
options: { apiKeyPresent: Boolean(cursorApiKeyEncrypted) },
|
||||
},
|
||||
{ key: "codebuddy", delayMs: 1280, path: initialPaths?.codebuddy ?? "" },
|
||||
];
|
||||
const cancelTasks = tasks
|
||||
.filter((task) => !autoResolvedAgentStateRef.current[task.key])
|
||||
.map((task) => scheduleAfterFirstPaint(() => {
|
||||
autoResolvedAgentStateRef.current[task.key] = "pending";
|
||||
void resolveAgentPath(task.key, task.path, task.options).finally(() => {
|
||||
autoResolvedAgentStateRef.current[task.key] = "done";
|
||||
});
|
||||
}, task.delayMs));
|
||||
return () => {
|
||||
for (const cancel of cancelTasks) cancel();
|
||||
};
|
||||
}, [activeSubTab, cursorApiKeyEncrypted, resolveAgentPath]);
|
||||
|
||||
// Validate a custom path for an agent
|
||||
const handleCheckCustomPath = useCallback(async (agentKey: ManagedAgentKey) => {
|
||||
@@ -293,7 +409,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
: agentKey === "codebuddy"
|
||||
? codebuddyCustomPath
|
||||
: "";
|
||||
await resolveAgentPath(agentKey, customPath);
|
||||
await resolveAgentPath(agentKey, customPath, { refreshShellEnv: true });
|
||||
}, [claudeCustomPath, codexCustomPath, copilotCustomPath, codebuddyCustomPath, resolveAgentPath]);
|
||||
|
||||
const handleSaveCursorApiKey = useCallback(async (apiKey: string) => {
|
||||
@@ -373,25 +489,46 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
}
|
||||
}, [agentOptions, defaultAgentId, setDefaultAgentId]);
|
||||
|
||||
const refreshCodexIntegration = useCallback(async (opts?: { refreshShellEnv?: boolean }) => {
|
||||
useEffect(() => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge?.aiPrewarmShellEnv) return;
|
||||
return scheduleAfterFirstPaint(() => {
|
||||
void bridge.aiPrewarmShellEnv?.();
|
||||
}, 900);
|
||||
}, []);
|
||||
|
||||
const refreshCodexIntegration = useCallback(async (opts?: { refreshShellEnv?: boolean; validateChatGptAuth?: boolean }) => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge?.aiCodexGetIntegration) return;
|
||||
|
||||
const requestId = codexRequestIdRef.current + 1;
|
||||
codexRequestIdRef.current = requestId;
|
||||
const isCurrentRequest = () => mountedRef.current && codexRequestIdRef.current === requestId;
|
||||
setIsCodexLoading(true);
|
||||
setCodexError(null);
|
||||
try {
|
||||
const integration = await bridge.aiCodexGetIntegration(opts);
|
||||
if (!isCurrentRequest()) return;
|
||||
setCodexIntegration(integration);
|
||||
} catch (err) {
|
||||
setCodexError(normalizeCodexBridgeError(err));
|
||||
if (isCurrentRequest()) {
|
||||
setCodexError(normalizeCodexBridgeError(err));
|
||||
}
|
||||
} finally {
|
||||
setIsCodexLoading(false);
|
||||
if (isCurrentRequest()) {
|
||||
setIsCodexLoading(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshCodexIntegration();
|
||||
}, [refreshCodexIntegration]);
|
||||
if (activeSubTab !== "agents") return;
|
||||
if (codexIntegrationLoadedRef.current) return;
|
||||
return scheduleAfterFirstPaint(() => {
|
||||
codexIntegrationLoadedRef.current = true;
|
||||
void refreshCodexIntegration();
|
||||
}, 620);
|
||||
}, [activeSubTab, refreshCodexIntegration]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!codexLoginSession || codexLoginSession.state !== "running") {
|
||||
@@ -411,7 +548,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
setCodexLoginSession(result.session);
|
||||
if (result.session.state !== "running") {
|
||||
if (result.session.state === "success") {
|
||||
void refreshCodexIntegration();
|
||||
void refreshCodexIntegration({ validateChatGptAuth: true });
|
||||
}
|
||||
}
|
||||
}).catch((err) => {
|
||||
@@ -431,18 +568,26 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
const bridge = getBridge();
|
||||
if (!bridge?.aiCodexStartLogin) return;
|
||||
|
||||
const requestId = codexRequestIdRef.current + 1;
|
||||
codexRequestIdRef.current = requestId;
|
||||
const isCurrentRequest = () => mountedRef.current && codexRequestIdRef.current === requestId;
|
||||
setCodexError(null);
|
||||
setIsCodexLoading(true);
|
||||
try {
|
||||
const result = await bridge.aiCodexStartLogin();
|
||||
if (!isCurrentRequest()) return;
|
||||
if (!result.ok || !result.session) {
|
||||
throw new Error(result.error || "Failed to start Codex login");
|
||||
}
|
||||
setCodexLoginSession(result.session);
|
||||
} catch (err) {
|
||||
setCodexError(normalizeCodexBridgeError(err));
|
||||
if (isCurrentRequest()) {
|
||||
setCodexError(normalizeCodexBridgeError(err));
|
||||
}
|
||||
} finally {
|
||||
setIsCodexLoading(false);
|
||||
if (isCurrentRequest()) {
|
||||
setIsCodexLoading(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -474,19 +619,27 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
const bridge = getBridge();
|
||||
if (!bridge?.aiCodexLogout) return;
|
||||
|
||||
const requestId = codexRequestIdRef.current + 1;
|
||||
codexRequestIdRef.current = requestId;
|
||||
const isCurrentRequest = () => mountedRef.current && codexRequestIdRef.current === requestId;
|
||||
setCodexError(null);
|
||||
setIsCodexLoading(true);
|
||||
try {
|
||||
const result = await bridge.aiCodexLogout();
|
||||
if (!isCurrentRequest()) return;
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error || "Failed to log out from Codex");
|
||||
}
|
||||
setCodexLoginSession(null);
|
||||
await refreshCodexIntegration();
|
||||
await refreshCodexIntegration({ refreshShellEnv: true, validateChatGptAuth: true });
|
||||
} catch (err) {
|
||||
setCodexError(normalizeCodexBridgeError(err));
|
||||
if (isCurrentRequest()) {
|
||||
setCodexError(normalizeCodexBridgeError(err));
|
||||
}
|
||||
} finally {
|
||||
setIsCodexLoading(false);
|
||||
if (isCurrentRequest()) {
|
||||
setIsCodexLoading(false);
|
||||
}
|
||||
}
|
||||
}, [refreshCodexIntegration]);
|
||||
|
||||
@@ -504,6 +657,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
try {
|
||||
const result = await bridge.aiUserSkillsGetStatus();
|
||||
setUserSkillsStatus(result);
|
||||
notifyUserSkillsStatusChanged();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setUserSkillsStatus({ ok: false, error: message });
|
||||
@@ -513,14 +667,13 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void refreshUserSkillsStatus().then(() => {
|
||||
if (cancelled) return;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [refreshUserSkillsStatus]);
|
||||
if (activeSubTab !== "tools") return;
|
||||
if (userSkillsLoadedRef.current) return;
|
||||
return scheduleAfterFirstPaint(() => {
|
||||
userSkillsLoadedRef.current = true;
|
||||
void refreshUserSkillsStatus();
|
||||
}, 520);
|
||||
}, [activeSubTab, refreshUserSkillsStatus]);
|
||||
|
||||
const reservedUserSkillSlugs = useMemo(
|
||||
() => (userSkillsStatus?.ok && userSkillsStatus.skills
|
||||
@@ -539,6 +692,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
try {
|
||||
const result = await bridge.aiUserSkillsOpenFolder();
|
||||
setUserSkillsStatus(result);
|
||||
notifyUserSkillsStatusChanged();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setUserSkillsStatus({ ok: false, error: message });
|
||||
@@ -549,6 +703,16 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
|
||||
return (
|
||||
<SettingsTabContent value="ai">
|
||||
<Tabs value={activeSubTab} onValueChange={(value) => setActiveSubTab(value as AISettingsSubTab)} className="space-y-5">
|
||||
<TabsList className="h-auto flex-wrap justify-start bg-muted/50">
|
||||
<TabsTrigger value="providers">{t('ai.providers')}</TabsTrigger>
|
||||
<TabsTrigger value="agents">{t('ai.agents')}</TabsTrigger>
|
||||
<TabsTrigger value="tools">{t('ai.toolAccess.title')}</TabsTrigger>
|
||||
<TabsTrigger value="search">{t("ai.webSearch.title")}</TabsTrigger>
|
||||
<TabsTrigger value="safety">{t('ai.safety.title')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="providers" className="m-0 space-y-6">
|
||||
<SettingsSection
|
||||
title={t('ai.providers')}
|
||||
actions={<AddProviderDropdown onAdd={handleAddProvider} />}
|
||||
@@ -569,7 +733,6 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
isActive={provider.id === activeProviderId}
|
||||
onToggleEnabled={(enabled) => {
|
||||
if (enabled) {
|
||||
// Activate this provider, deactivate all others
|
||||
setActiveProviderId(provider.id);
|
||||
if (provider.defaultModel) {
|
||||
setActiveModelId(provider.defaultModel);
|
||||
@@ -577,12 +740,11 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
for (const p of providers) {
|
||||
if (p.id === provider.id) {
|
||||
if (!p.enabled) updateProvider(p.id, { enabled: true });
|
||||
} else {
|
||||
if (p.enabled) updateProvider(p.id, { enabled: false });
|
||||
} else if (p.enabled) {
|
||||
updateProvider(p.id, { enabled: false });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Deactivate this provider
|
||||
if (activeProviderId === provider.id) {
|
||||
setActiveProviderId("");
|
||||
setActiveModelId("");
|
||||
@@ -598,7 +760,6 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
onRemove={() => handleRemoveProvider(provider.id)}
|
||||
onUpdate={(updates) => {
|
||||
updateProvider(provider.id, updates);
|
||||
// If this is the active provider and model changed, update activeModelId
|
||||
if (provider.id === activeProviderId && updates.defaultModel !== undefined) {
|
||||
setActiveModelId(updates.defaultModel || "");
|
||||
}
|
||||
@@ -610,7 +771,9 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="agents" className="m-0 space-y-6">
|
||||
<SettingsSection
|
||||
title={t('ai.codex')}
|
||||
leading={<AgentIconBadge agent={{ id: "codex", icon: "openai", name: "Codex CLI" }} variant="plain" className="h-5 w-5 text-muted-foreground/90" />}
|
||||
@@ -625,7 +788,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
loginSession={codexLoginSession}
|
||||
isLoading={isCodexLoading}
|
||||
error={codexError}
|
||||
onRefresh={() => void refreshCodexIntegration({ refreshShellEnv: true })}
|
||||
onRefresh={() => void refreshCodexIntegration({ refreshShellEnv: true, validateChatGptAuth: true })}
|
||||
onConnect={() => void handleStartCodexLogin()}
|
||||
onCancel={() => void handleCancelCodexLogin()}
|
||||
onOpenUrl={handleOpenCodexLoginUrl}
|
||||
@@ -709,6 +872,23 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
</SettingCard>
|
||||
</SettingsSection>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tools" className="m-0 space-y-6">
|
||||
<SettingsSection title={t('ai.chatShortcuts.title')}>
|
||||
<SettingCard divided>
|
||||
<SettingRow
|
||||
label={t('ai.chatShortcuts.selectionAction')}
|
||||
description={t('ai.chatShortcuts.selectionAction.description')}
|
||||
>
|
||||
<Toggle
|
||||
checked={showTerminalSelectionAIAction}
|
||||
onChange={setShowTerminalSelectionAIAction}
|
||||
ariaLabel={t('ai.chatShortcuts.selectionAction')}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title={t('ai.toolAccess.title')}>
|
||||
<SettingCard>
|
||||
@@ -778,10 +958,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
{userSkillsStatus?.ok && userSkillsStatus.skills && userSkillsStatus.skills.length > 0 ? (
|
||||
<div className="border-t border-border/60 divide-y divide-border/60">
|
||||
{userSkillsStatus.skills.map((skill) => (
|
||||
<div
|
||||
key={skill.id}
|
||||
className="py-3"
|
||||
>
|
||||
<div key={skill.id} className="py-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="text-sm font-medium">{skill.name}</div>
|
||||
@@ -828,13 +1005,16 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
setQuickMessages={setQuickMessages}
|
||||
reservedUserSkillSlugs={reservedUserSkillSlugs}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="search" className="m-0 space-y-6">
|
||||
<WebSearchSettings
|
||||
webSearchConfig={webSearchConfig}
|
||||
setWebSearchConfig={setWebSearchConfig}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* -- Safety Section -- */}
|
||||
<TabsContent value="safety" className="m-0 space-y-6">
|
||||
<SafetySettings
|
||||
globalPermissionMode={globalPermissionMode}
|
||||
setGlobalPermissionMode={setGlobalPermissionMode}
|
||||
@@ -845,6 +1025,8 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
maxIterations={maxIterations}
|
||||
setMaxIterations={setMaxIterations}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</SettingsTabContent>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,6 +12,8 @@ export default function SettingsShortcutsTab(props: {
|
||||
setHotkeyScheme: (scheme: HotkeyScheme) => void;
|
||||
shellOnlyTabNumberShortcuts: boolean;
|
||||
setShellOnlyTabNumberShortcuts: (enabled: boolean) => void;
|
||||
disableTerminalFontZoom: boolean;
|
||||
setDisableTerminalFontZoom: (enabled: boolean) => void;
|
||||
keyBindings: KeyBinding[];
|
||||
updateKeyBinding?: (bindingId: string, scheme: "mac" | "pc", newKey: string) => void;
|
||||
resetKeyBinding?: (bindingId: string, scheme?: "mac" | "pc") => void;
|
||||
@@ -23,6 +25,8 @@ export default function SettingsShortcutsTab(props: {
|
||||
setHotkeyScheme,
|
||||
shellOnlyTabNumberShortcuts,
|
||||
setShellOnlyTabNumberShortcuts,
|
||||
disableTerminalFontZoom,
|
||||
setDisableTerminalFontZoom,
|
||||
keyBindings,
|
||||
updateKeyBinding,
|
||||
resetKeyBinding,
|
||||
@@ -140,6 +144,15 @@ export default function SettingsShortcutsTab(props: {
|
||||
className="w-32"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("settings.shortcuts.disableTerminalFontZoom.label")}
|
||||
description={t("settings.shortcuts.disableTerminalFontZoom.desc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={disableTerminalFontZoom}
|
||||
onChange={setDisableTerminalFontZoom}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("settings.shortcuts.shellOnlyTabNumberShortcuts.label")}
|
||||
description={t("settings.shortcuts.shellOnlyTabNumberShortcuts.desc")}
|
||||
|
||||
@@ -27,6 +27,19 @@ import { resolveFollowedTerminalThemeId, TERMINAL_THEME_AUTO } from "../../../do
|
||||
|
||||
import { KeywordHighlightRulesEditor, ThemePreviewButton } from "./SettingsTerminalTabControls";
|
||||
import { TerminalBehaviorSettings } from "./TerminalBehaviorSettings";
|
||||
|
||||
const FONT_WEIGHT_OPTIONS = [
|
||||
{ value: "100", labelKey: "settings.terminal.font.weight.thin" },
|
||||
{ value: "200", labelKey: "settings.terminal.font.weight.extraLight" },
|
||||
{ value: "300", labelKey: "settings.terminal.font.weight.light" },
|
||||
{ value: "400", labelKey: "settings.terminal.font.weight.normal" },
|
||||
{ value: "500", labelKey: "settings.terminal.font.weight.medium" },
|
||||
{ value: "600", labelKey: "settings.terminal.font.weight.semiBold" },
|
||||
{ value: "700", labelKey: "settings.terminal.font.weight.bold" },
|
||||
{ value: "800", labelKey: "settings.terminal.font.weight.extraBold" },
|
||||
{ value: "900", labelKey: "settings.terminal.font.weight.black" },
|
||||
];
|
||||
|
||||
function SettingsTerminalTab(props: {
|
||||
terminalThemeId: string;
|
||||
setTerminalThemeId: (id: string) => void;
|
||||
@@ -146,6 +159,13 @@ function SettingsTerminalTab(props: {
|
||||
|| TERMINAL_THEMES[0];
|
||||
}, [terminalThemeDarkId, terminalThemeLightId, lightUiThemeId, darkUiThemeId, terminalThemeId, customThemes]);
|
||||
|
||||
const fontWeightOptions = useMemo(() => (
|
||||
FONT_WEIGHT_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: `${option.value} - ${t(option.labelKey)}`,
|
||||
}))
|
||||
), [t]);
|
||||
|
||||
const handleAutocompleteGhostTextChange = useCallback((enabled: boolean) => {
|
||||
updateTerminalSetting("autocompleteGhostText", enabled);
|
||||
if (enabled) {
|
||||
@@ -516,17 +536,7 @@ function SettingsTerminalTab(props: {
|
||||
>
|
||||
<Select
|
||||
value={String(terminalSettings.fontWeight)}
|
||||
options={[
|
||||
{ value: "100", label: "100 - Thin" },
|
||||
{ value: "200", label: "200 - Extra Light" },
|
||||
{ value: "300", label: "300 - Light" },
|
||||
{ value: "400", label: "400 - Normal" },
|
||||
{ value: "500", label: "500 - Medium" },
|
||||
{ value: "600", label: "600 - Semi Bold" },
|
||||
{ value: "700", label: "700 - Bold" },
|
||||
{ value: "800", label: "800 - Extra Bold" },
|
||||
{ value: "900", label: "900 - Black" },
|
||||
]}
|
||||
options={fontWeightOptions}
|
||||
onChange={(v) => updateTerminalSetting("fontWeight", parseInt(v))}
|
||||
className="w-40"
|
||||
/>
|
||||
@@ -538,17 +548,7 @@ function SettingsTerminalTab(props: {
|
||||
>
|
||||
<Select
|
||||
value={String(terminalSettings.fontWeightBold)}
|
||||
options={[
|
||||
{ value: "100", label: "100 - Thin" },
|
||||
{ value: "200", label: "200 - Extra Light" },
|
||||
{ value: "300", label: "300 - Light" },
|
||||
{ value: "400", label: "400 - Normal" },
|
||||
{ value: "500", label: "500 - Medium" },
|
||||
{ value: "600", label: "600 - Semi Bold" },
|
||||
{ value: "700", label: "700 - Bold" },
|
||||
{ value: "800", label: "800 - Extra Bold" },
|
||||
{ value: "900", label: "900 - Black" },
|
||||
]}
|
||||
options={fontWeightOptions}
|
||||
onChange={(v) => updateTerminalSetting("fontWeightBold", parseInt(v))}
|
||||
className="w-40"
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import type { LinkModifier, RightClickBehavior, TerminalSettings } from "../../../domain/models";
|
||||
import type { LinkModifier, MiddleClickBehavior, RightClickBehavior, TerminalSettings } from "../../../domain/models";
|
||||
import { Input } from "../../ui/input";
|
||||
import { Label } from "../../ui/label";
|
||||
import { SectionHeader, Select, SettingRow, Toggle } from "../settings-ui";
|
||||
@@ -12,6 +12,15 @@ interface TerminalBehaviorSettingsProps {
|
||||
updateTerminalSetting: <K extends keyof TerminalSettings>(key: K, value: TerminalSettings[K]) => void;
|
||||
}
|
||||
|
||||
export const MIDDLE_CLICK_BEHAVIOR_OPTIONS: Array<{
|
||||
value: MiddleClickBehavior;
|
||||
labelKey: string;
|
||||
}> = [
|
||||
{ value: "context-menu", labelKey: "settings.terminal.behavior.middleClick.menu" },
|
||||
{ value: "paste", labelKey: "settings.terminal.behavior.middleClick.paste" },
|
||||
{ value: "disabled", labelKey: "settings.terminal.behavior.middleClick.disabled" },
|
||||
];
|
||||
|
||||
export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> = ({
|
||||
t,
|
||||
terminalSettings,
|
||||
@@ -44,10 +53,18 @@ export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> =
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.terminal.behavior.middleClickPaste")}
|
||||
description={t("settings.terminal.behavior.middleClickPaste.desc")}
|
||||
label={t("settings.terminal.behavior.middleClick")}
|
||||
description={t("settings.terminal.behavior.middleClick.desc")}
|
||||
>
|
||||
<Toggle checked={terminalSettings.middleClickPaste} onChange={(v) => updateTerminalSetting("middleClickPaste", v)} />
|
||||
<Select
|
||||
value={terminalSettings.middleClickBehavior}
|
||||
options={MIDDLE_CLICK_BEHAVIOR_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: t(option.labelKey),
|
||||
}))}
|
||||
onChange={(v) => updateTerminalSetting("middleClickBehavior", v as MiddleClickBehavior)}
|
||||
className="w-36"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
|
||||
@@ -103,7 +103,9 @@ export interface FetchBridge {
|
||||
}
|
||||
|
||||
export interface NetcattyAiBridge {
|
||||
aiCodexGetIntegration?: (options?: { refreshShellEnv?: boolean }) => Promise<CodexIntegrationStatus>;
|
||||
aiDiscoverAgents?: (options?: { refreshShellEnv?: boolean; apiKeyPresent?: boolean }) => Promise<Array<AgentPathInfo & { command: string }>>;
|
||||
aiPrewarmShellEnv?: () => Promise<{ ok: boolean; error?: string }>;
|
||||
aiCodexGetIntegration?: (options?: { refreshShellEnv?: boolean; validateChatGptAuth?: boolean }) => Promise<CodexIntegrationStatus>;
|
||||
aiCodexStartLogin?: () => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
|
||||
aiCodexGetLoginSession?: (sessionId: string) => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
|
||||
aiCodexCancelLogin?: (sessionId: string) => Promise<{ ok: boolean; found?: boolean; session?: CodexLoginSession; error?: string }>;
|
||||
|
||||
25
components/sftp/SftpConflictDialog.test.ts
Normal file
25
components/sftp/SftpConflictDialog.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { canReplaceConflict } from "./SftpConflictDialog.tsx";
|
||||
|
||||
test("does not offer replace when a file upload conflicts with an existing directory", () => {
|
||||
assert.equal(canReplaceConflict({
|
||||
isDirectory: false,
|
||||
existingType: "directory",
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("does not offer replace when a directory upload conflicts with an existing file", () => {
|
||||
assert.equal(canReplaceConflict({
|
||||
isDirectory: true,
|
||||
existingType: "file",
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("offers replace when a file upload conflicts with an existing file", () => {
|
||||
assert.equal(canReplaceConflict({
|
||||
isDirectory: false,
|
||||
existingType: "file",
|
||||
}), true);
|
||||
});
|
||||
@@ -5,6 +5,7 @@
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import React, { memo, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { canReplaceSftpConflict, getSftpConflictTypeKey } from '../../domain/sftpConflict';
|
||||
import { Button } from '../ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
import type { FileConflictAction } from '../../domain/models';
|
||||
@@ -23,12 +24,53 @@ interface ConflictItem {
|
||||
newModified: number;
|
||||
}
|
||||
|
||||
export const canReplaceConflict = (conflict: Pick<ConflictItem, 'isDirectory' | 'existingType'>): boolean => {
|
||||
return canReplaceSftpConflict(conflict.isDirectory, conflict.existingType);
|
||||
};
|
||||
|
||||
const getConflictTypeKey = (conflict: Pick<ConflictItem, 'isDirectory' | 'existingType'>): string =>
|
||||
getSftpConflictTypeKey(conflict.isDirectory, conflict.existingType);
|
||||
|
||||
interface SftpConflictDialogProps {
|
||||
conflicts: ConflictItem[];
|
||||
onResolve: (conflictId: string, action: FileConflictAction, applyToAll?: boolean) => void;
|
||||
formatFileSize: (size: number) => string;
|
||||
}
|
||||
|
||||
interface ConflictFileSummaryProps {
|
||||
title: string;
|
||||
sizeLabel: string;
|
||||
modifiedLabel: string;
|
||||
size: string;
|
||||
modified: string;
|
||||
}
|
||||
|
||||
const ConflictFileSummary: React.FC<ConflictFileSummaryProps> = ({
|
||||
title,
|
||||
sizeLabel,
|
||||
modifiedLabel,
|
||||
size,
|
||||
modified,
|
||||
}) => (
|
||||
<div className="rounded-md border border-border/60 bg-secondary/25 px-4 py-3">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
<dl className="space-y-2 text-sm">
|
||||
<div className="grid grid-cols-[5.5rem_minmax(0,1fr)] gap-3">
|
||||
<dt className="text-muted-foreground">{sizeLabel}</dt>
|
||||
<dd className="min-w-0 text-foreground">{size}</dd>
|
||||
</div>
|
||||
<div className="grid grid-cols-[5.5rem_minmax(0,1fr)] gap-3">
|
||||
<dt className="text-muted-foreground">{modifiedLabel}</dt>
|
||||
<dd className="min-w-0 break-words leading-relaxed text-foreground">{modified}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts, onResolve, formatFileSize }) => {
|
||||
const { t } = useI18n();
|
||||
const [applyToAll, setApplyToAll] = useState(false);
|
||||
@@ -42,9 +84,10 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
|
||||
|
||||
const sameTypeConflictCount = Math.max(
|
||||
conflict.applyToAllCount ?? 1,
|
||||
conflicts.filter((item) => item.isDirectory === conflict.isDirectory).length,
|
||||
conflicts.filter((item) => getConflictTypeKey(item) === getConflictTypeKey(conflict)).length,
|
||||
);
|
||||
const canMerge = conflict.isDirectory && conflict.existingType === 'directory';
|
||||
const canReplace = canReplaceConflict(conflict);
|
||||
|
||||
const handleAction = (action: FileConflictAction) => {
|
||||
onResolve(conflict.transferId, action, applyToAll);
|
||||
@@ -53,55 +96,46 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
|
||||
|
||||
return (
|
||||
<Dialog open={!!conflict} onOpenChange={() => handleAction('skip')}>
|
||||
<DialogContent className="sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-yellow-500" />
|
||||
<DialogContent className="gap-5 p-5 sm:max-w-[520px] sm:p-6">
|
||||
<DialogHeader className="space-y-2 pr-8">
|
||||
<DialogTitle className="flex items-center gap-3 text-xl leading-tight">
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-border/70 text-muted-foreground">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
</span>
|
||||
{t('sftp.conflict.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<DialogDescription className="text-[15px] leading-6">
|
||||
{t('sftp.conflict.desc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="text-sm">
|
||||
<span className="font-medium">{conflict.fileName}</span>
|
||||
<span className="text-muted-foreground ml-1">{t('sftp.conflict.alreadyExistsSuffix')}</span>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border border-border/60 bg-muted/25 px-4 py-3 text-sm leading-6">
|
||||
<div className="min-w-0 break-words">
|
||||
<span className="font-medium text-foreground">{conflict.fileName}</span>
|
||||
<span className="ml-1 text-muted-foreground">{t('sftp.conflict.alreadyExistsSuffix')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-xs">
|
||||
<div className="p-3 rounded-lg bg-secondary/50 border border-border/60">
|
||||
<div className="font-medium mb-2 text-muted-foreground">{t('sftp.conflict.existingFile')}</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('sftp.conflict.size')}</span>
|
||||
<span>{formatFileSize(conflict.existingSize)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('sftp.conflict.modified')}</span>
|
||||
<span>{formatDate(conflict.existingModified)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 rounded-lg bg-primary/10 border border-primary/30">
|
||||
<div className="font-medium mb-2 text-primary">{t('sftp.conflict.newFile')}</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('sftp.conflict.size')}</span>
|
||||
<span>{formatFileSize(conflict.newSize)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('sftp.conflict.modified')}</span>
|
||||
<span>{formatDate(conflict.newModified)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<ConflictFileSummary
|
||||
title={t('sftp.conflict.existingFile')}
|
||||
sizeLabel={t('sftp.conflict.size')}
|
||||
modifiedLabel={t('sftp.conflict.modified')}
|
||||
size={formatFileSize(conflict.existingSize)}
|
||||
modified={formatDate(conflict.existingModified)}
|
||||
/>
|
||||
<ConflictFileSummary
|
||||
title={t('sftp.conflict.newFile')}
|
||||
sizeLabel={t('sftp.conflict.size')}
|
||||
modifiedLabel={t('sftp.conflict.modified')}
|
||||
size={formatFileSize(conflict.newSize)}
|
||||
modified={formatDate(conflict.newModified)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{sameTypeConflictCount > 1 && (
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<label className="flex cursor-pointer items-center gap-2 rounded-md border border-border/60 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={applyToAll}
|
||||
@@ -113,25 +147,25 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex flex-wrap gap-2 sm:justify-end sm:space-x-0">
|
||||
<DialogFooter className="flex flex-wrap gap-2 sm:items-center sm:justify-end sm:space-x-0">
|
||||
<Button
|
||||
variant="destructive"
|
||||
variant="outline"
|
||||
onClick={() => handleAction('stop')}
|
||||
className="flex-1"
|
||||
className="min-w-24 border-border/70 text-muted-foreground hover:text-destructive sm:mr-auto"
|
||||
>
|
||||
{t('sftp.conflict.action.stop')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleAction('skip')}
|
||||
className="flex-1"
|
||||
className="min-w-24"
|
||||
>
|
||||
{t('sftp.conflict.action.skip')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleAction('duplicate')}
|
||||
className="flex-1"
|
||||
className="min-w-24"
|
||||
>
|
||||
{t('sftp.conflict.action.duplicate')}
|
||||
</Button>
|
||||
@@ -140,18 +174,20 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
|
||||
variant="outline"
|
||||
onClick={() => handleAction('merge')}
|
||||
disabled={!canMerge}
|
||||
className="flex-1"
|
||||
className="min-w-24"
|
||||
>
|
||||
{t('sftp.conflict.action.merge')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => handleAction('replace')}
|
||||
className="flex-1"
|
||||
>
|
||||
{t('sftp.conflict.action.replace')}
|
||||
</Button>
|
||||
{canReplace && (
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => handleAction('replace')}
|
||||
className="min-w-28"
|
||||
>
|
||||
{t('sftp.conflict.action.replace')}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -7,6 +7,8 @@ import type { TransferTask } from "../../types";
|
||||
import FileOpenerDialog from "../FileOpenerDialog";
|
||||
import TextEditorModal from "../TextEditorModal";
|
||||
import type { TextEditorModalSnapshot } from "../TextEditorModal";
|
||||
import { TerminalHostKeyVerification } from "../terminal/TerminalHostKeyVerification";
|
||||
import { Dialog, DialogContent, DialogTitle } from "../ui/dialog";
|
||||
import { SftpConflictDialog } from "./SftpConflictDialog";
|
||||
import { SftpHostPicker } from "./SftpHostPicker";
|
||||
import { SftpPermissionsDialog } from "./SftpPermissionsDialog";
|
||||
@@ -139,6 +141,27 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
|
||||
formatFileSize={sftp.formatFileSize}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
open={!!sftp.hostKeyVerification}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) sftp.rejectHostKeyVerification();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-lg" hideCloseButton>
|
||||
<DialogTitle className="sr-only">Confirm host key</DialogTitle>
|
||||
{sftp.hostKeyVerification && (
|
||||
<TerminalHostKeyVerification
|
||||
hostKeyInfo={sftp.hostKeyVerification.hostKeyInfo}
|
||||
showLogs={sftp.hostKeyVerification.progressLogs.length > 0}
|
||||
progressLogs={sftp.hostKeyVerification.progressLogs}
|
||||
onClose={sftp.rejectHostKeyVerification}
|
||||
onContinue={sftp.acceptHostKeyVerification}
|
||||
onAddAndContinue={sftp.acceptAndSaveHostKeyVerification}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<SftpPermissionsDialog
|
||||
open={!!permissionsState}
|
||||
onOpenChange={(open) => !open && setPermissionsState(null)}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* - Drag-and-drop reordering of tabs
|
||||
*/
|
||||
|
||||
import { HardDrive, Monitor, Plus, X } from "lucide-react";
|
||||
import { Copy, HardDrive, Monitor, Plus, X } from "lucide-react";
|
||||
import React, {
|
||||
memo,
|
||||
useCallback,
|
||||
@@ -25,12 +25,27 @@ import { useRenderTracker } from "../../lib/useRenderTracker";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { useActiveTabId } from "./SftpContext";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "../ui/context-menu";
|
||||
import {
|
||||
canDuplicateSftpTab,
|
||||
isSftpTabKeyboardContextMenuShortcut,
|
||||
isSftpTabKeyboardSelectShortcut,
|
||||
shouldHandleSftpTabKeyboardEvent,
|
||||
SFTP_TAB_DUPLICATE_MENU_ITEMS,
|
||||
type SftpTabDuplicateMode,
|
||||
} from "./sftpTabDuplication";
|
||||
|
||||
export interface SftpTab {
|
||||
id: string;
|
||||
label: string;
|
||||
isLocal: boolean;
|
||||
hostId: string | null;
|
||||
canDuplicate?: boolean;
|
||||
}
|
||||
|
||||
interface SftpTabBarProps {
|
||||
@@ -46,6 +61,10 @@ interface SftpTabBarProps {
|
||||
) => void;
|
||||
/** Called when a tab is dragged to the other side */
|
||||
onMoveTabToOtherSide?: (tabId: string) => void;
|
||||
onDuplicateTab?: (
|
||||
tabId: string,
|
||||
mode: SftpTabDuplicateMode,
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
|
||||
@@ -56,6 +75,7 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
|
||||
onAddTab,
|
||||
onReorderTabs,
|
||||
onMoveTabToOtherSide,
|
||||
onDuplicateTab,
|
||||
}) => {
|
||||
// Subscribe to activeTabId from store (isolated subscription)
|
||||
const activeTabId = useActiveTabId(side);
|
||||
@@ -232,6 +252,35 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
|
||||
[onAddTab],
|
||||
);
|
||||
|
||||
const handleTabKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLDivElement>, tabId: string) => {
|
||||
if (!shouldHandleSftpTabKeyboardEvent(e.target, e.currentTarget)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSftpTabKeyboardSelectShortcut(e.key)) {
|
||||
e.preventDefault();
|
||||
onSelectTab(tabId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSftpTabKeyboardContextMenuShortcut(e.key, e.shiftKey)) {
|
||||
e.preventDefault();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
e.currentTarget.dispatchEvent(
|
||||
new MouseEvent("contextmenu", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 2,
|
||||
clientX: rect.left + Math.min(rect.width / 2, 24),
|
||||
clientY: rect.bottom,
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
[onSelectTab],
|
||||
);
|
||||
|
||||
// Cross-pane drag handlers
|
||||
const handleCrossPaneDragOver = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
@@ -307,6 +356,7 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = activeTabId === tab.id;
|
||||
const canDuplicateTab = canDuplicateSftpTab(tab, !!onDuplicateTab);
|
||||
const isBeingDragged =
|
||||
isDragging && draggedTabIdRef.current === tab.id;
|
||||
const showDropIndicatorBefore =
|
||||
@@ -317,71 +367,92 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
|
||||
dropIndicator.position === "after";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
data-tab-id={tab.id}
|
||||
data-tab-type="sftp"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
onClick={(e) => handleSelectTabClick(e, tab.id)}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseTab(tab.id))}
|
||||
draggable
|
||||
onDragStart={(e) => handleTabDragStart(e, tab.id)}
|
||||
onDragEnd={handleTabDragEnd}
|
||||
onDragOver={(e) => handleTabDragOver(e, tab.id)}
|
||||
onDrop={(e) => handleTabDrop(e, tab.id)}
|
||||
className={cn(
|
||||
"netcatty-tab relative px-3 min-w-[100px] max-w-[180px] text-xs font-medium cursor-pointer flex items-center justify-between gap-2 flex-shrink-0 border-r border-border/40",
|
||||
"transition-[color,opacity,transform] duration-100 ease-out",
|
||||
isActive
|
||||
? "text-foreground border-b-2"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
isBeingDragged && "opacity-50",
|
||||
)}
|
||||
style={
|
||||
isActive
|
||||
? { borderBottomColor: "hsl(var(--accent))" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{/* Drop indicator line - before */}
|
||||
{showDropIndicatorBefore && isDragging && (
|
||||
<div className="absolute left-0 top-1 bottom-1 w-0.5 bg-primary shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
|
||||
)}
|
||||
{/* Drop indicator line - after */}
|
||||
{showDropIndicatorAfter && isDragging && (
|
||||
<div className="absolute right-0 top-1 bottom-1 w-0.5 bg-primary shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
|
||||
)}
|
||||
<ContextMenu key={tab.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
data-tab-id={tab.id}
|
||||
data-tab-type="sftp"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
tabIndex={0}
|
||||
aria-haspopup="menu"
|
||||
aria-label={tab.label}
|
||||
onClick={(e) => handleSelectTabClick(e, tab.id)}
|
||||
onKeyDown={(e) => handleTabKeyDown(e, tab.id)}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseTab(tab.id))}
|
||||
draggable
|
||||
onDragStart={(e) => handleTabDragStart(e, tab.id)}
|
||||
onDragEnd={handleTabDragEnd}
|
||||
onDragOver={(e) => handleTabDragOver(e, tab.id)}
|
||||
onDrop={(e) => handleTabDrop(e, tab.id)}
|
||||
className={cn(
|
||||
"netcatty-tab relative px-3 min-w-[100px] max-w-[180px] text-xs font-medium cursor-pointer flex items-center justify-between gap-2 flex-shrink-0 border-r border-border/40",
|
||||
"transition-[color,opacity,transform] duration-100 ease-out focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 focus-visible:ring-inset",
|
||||
isActive
|
||||
? "text-foreground border-b-2"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
isBeingDragged && "opacity-50",
|
||||
)}
|
||||
style={
|
||||
isActive
|
||||
? { borderBottomColor: "hsl(var(--accent))" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{/* Drop indicator line - before */}
|
||||
{showDropIndicatorBefore && isDragging && (
|
||||
<div className="absolute left-0 top-1 bottom-1 w-0.5 bg-primary shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
|
||||
)}
|
||||
{/* Drop indicator line - after */}
|
||||
{showDropIndicatorAfter && isDragging && (
|
||||
<div className="absolute right-0 top-1 bottom-1 w-0.5 bg-primary shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
{tab.isLocal ? (
|
||||
<Monitor
|
||||
size={12}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
isActive ? "text-primary" : "text-muted-foreground",
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
{tab.isLocal ? (
|
||||
<Monitor
|
||||
size={12}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
isActive ? "text-primary" : "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<HardDrive
|
||||
size={12}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
isActive ? "text-primary" : "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<HardDrive
|
||||
size={12}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
isActive ? "text-primary" : "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span className="truncate">{tab.label}</span>
|
||||
</div>
|
||||
<span className="truncate">{tab.label}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={(e) => handleCloseTab(e, tab.id)}
|
||||
className="p-0.5 hover:bg-destructive/10 hover:text-destructive transition-colors shrink-0"
|
||||
aria-label={t("common.close")}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => handleCloseTab(e, tab.id)}
|
||||
className="p-0.5 hover:bg-destructive/10 hover:text-destructive transition-colors shrink-0"
|
||||
aria-label={t("common.close")}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
{SFTP_TAB_DUPLICATE_MENU_ITEMS.map((item) => (
|
||||
<ContextMenuItem
|
||||
key={item.mode}
|
||||
disabled={!canDuplicateTab}
|
||||
onClick={() => {
|
||||
void onDuplicateTab?.(tab.id, item.mode);
|
||||
}}
|
||||
>
|
||||
<Copy size={14} className="mr-2" />
|
||||
{t(item.labelKey)}
|
||||
</ContextMenuItem>
|
||||
))}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -432,7 +503,8 @@ const sftpTabBarAreEqual = (
|
||||
prevTab.id !== nextTab.id ||
|
||||
prevTab.label !== nextTab.label ||
|
||||
prevTab.isLocal !== nextTab.isLocal ||
|
||||
prevTab.hostId !== nextTab.hostId
|
||||
prevTab.hostId !== nextTab.hostId ||
|
||||
prevTab.canDuplicate !== nextTab.canDuplicate
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -6,17 +6,22 @@ import { editorTabStore } from "../../../application/state/editorTabStore";
|
||||
import type { EditorTab, EditorTabId } from "../../../application/state/editorTabStore";
|
||||
import { releaseEditorTabSaveCoordinator, saveEditorTab } from "../../../application/state/editorTabSave";
|
||||
import { promptUnsavedChanges } from "../../editor/UnsavedChangesDialog";
|
||||
import {
|
||||
getSftpTabDuplicateRequest,
|
||||
type SftpTabDuplicateMode,
|
||||
} from "../sftpTabDuplication";
|
||||
|
||||
interface UseSftpViewTabsParams {
|
||||
sftp: SftpStateApi;
|
||||
sftpRef: MutableRefObject<SftpStateApi>;
|
||||
hosts?: Host[];
|
||||
}
|
||||
|
||||
interface UseSftpViewTabsResult {
|
||||
leftPanes: SftpStateApi["leftPane"][];
|
||||
rightPanes: SftpStateApi["rightPane"][];
|
||||
leftTabsInfo: { id: string; label: string; isLocal: boolean; hostId: string | null }[];
|
||||
rightTabsInfo: { id: string; label: string; isLocal: boolean; hostId: string | null }[];
|
||||
leftTabsInfo: { id: string; label: string; isLocal: boolean; hostId: string | null; canDuplicate: boolean }[];
|
||||
rightTabsInfo: { id: string; label: string; isLocal: boolean; hostId: string | null; canDuplicate: boolean }[];
|
||||
showHostPickerLeft: boolean;
|
||||
showHostPickerRight: boolean;
|
||||
hostSearchLeft: string;
|
||||
@@ -35,15 +40,19 @@ interface UseSftpViewTabsResult {
|
||||
handleReorderTabsRight: (draggedId: string, targetId: string, position: "before" | "after") => void;
|
||||
handleMoveTabFromLeftToRight: (tabId: string) => void;
|
||||
handleMoveTabFromRightToLeft: (tabId: string) => void;
|
||||
handleDuplicateTabLeft: (tabId: string, mode: SftpTabDuplicateMode) => Promise<string | null>;
|
||||
handleDuplicateTabRight: (tabId: string, mode: SftpTabDuplicateMode) => Promise<string | null>;
|
||||
handleHostSelectLeft: (host: Host | "local") => void;
|
||||
handleHostSelectRight: (host: Host | "local") => void;
|
||||
}
|
||||
|
||||
export const useSftpViewTabs = ({ sftp, sftpRef }: UseSftpViewTabsParams): UseSftpViewTabsResult => {
|
||||
export const useSftpViewTabs = ({ sftp, sftpRef, hosts = [] }: UseSftpViewTabsParams): UseSftpViewTabsResult => {
|
||||
const [showHostPickerLeft, setShowHostPickerLeft] = useState(false);
|
||||
const [showHostPickerRight, setShowHostPickerRight] = useState(false);
|
||||
const [hostSearchLeft, setHostSearchLeft] = useState("");
|
||||
const [hostSearchRight, setHostSearchRight] = useState("");
|
||||
const hostsRef = React.useRef(hosts);
|
||||
hostsRef.current = hosts;
|
||||
|
||||
const handleAddTabLeft = useCallback(() => {
|
||||
const tabId = sftpRef.current.addTab("left");
|
||||
@@ -132,6 +141,43 @@ export const useSftpViewTabs = ({ sftp, sftpRef }: UseSftpViewTabsParams): UseSf
|
||||
sftpRef.current.moveTabToOtherSide("right", tabId);
|
||||
}, [sftpRef]);
|
||||
|
||||
const handleDuplicateTab = useCallback(
|
||||
async (side: "left" | "right", tabId: string, mode: SftpTabDuplicateMode) => {
|
||||
const sideTabs = side === "left" ? sftpRef.current.leftTabs : sftpRef.current.rightTabs;
|
||||
const pane = sideTabs.tabs.find((tab) => tab.id === tabId);
|
||||
const request = getSftpTabDuplicateRequest(pane, mode);
|
||||
if (!request) return null;
|
||||
|
||||
const host = request.kind === "local"
|
||||
? "local"
|
||||
: hostsRef.current.find((item) => item.id === request.hostId);
|
||||
if (!host) return null;
|
||||
|
||||
let duplicatedTabId: string | null = null;
|
||||
await sftpRef.current.connect(side, host, {
|
||||
forceNewTab: true,
|
||||
ignoreSharedCache: mode === "defaultPath",
|
||||
initialPath: request.path,
|
||||
onTabCreated: (createdTabId) => {
|
||||
duplicatedTabId = createdTabId;
|
||||
},
|
||||
});
|
||||
|
||||
return duplicatedTabId;
|
||||
},
|
||||
[sftpRef],
|
||||
);
|
||||
|
||||
const handleDuplicateTabLeft = useCallback(
|
||||
(tabId: string, mode: SftpTabDuplicateMode) => handleDuplicateTab("left", tabId, mode),
|
||||
[handleDuplicateTab],
|
||||
);
|
||||
|
||||
const handleDuplicateTabRight = useCallback(
|
||||
(tabId: string, mode: SftpTabDuplicateMode) => handleDuplicateTab("right", tabId, mode),
|
||||
[handleDuplicateTab],
|
||||
);
|
||||
|
||||
const handleHostSelectLeft = useCallback((host: Host | "local") => {
|
||||
sftpRef.current.connect("left", host);
|
||||
setShowHostPickerLeft(false);
|
||||
@@ -149,6 +195,7 @@ export const useSftpViewTabs = ({ sftp, sftpRef }: UseSftpViewTabsParams): UseSf
|
||||
label: pane.connection?.hostLabel || "New Tab",
|
||||
isLocal: pane.connection?.isLocal || false,
|
||||
hostId: pane.connection?.hostId || null,
|
||||
canDuplicate: pane.connection?.status === "connected",
|
||||
})),
|
||||
[sftp.leftTabs.tabs],
|
||||
);
|
||||
@@ -160,6 +207,7 @@ export const useSftpViewTabs = ({ sftp, sftpRef }: UseSftpViewTabsParams): UseSf
|
||||
label: pane.connection?.hostLabel || "New Tab",
|
||||
isLocal: pane.connection?.isLocal || false,
|
||||
hostId: pane.connection?.hostId || null,
|
||||
canDuplicate: pane.connection?.status === "connected",
|
||||
})),
|
||||
[sftp.rightTabs.tabs],
|
||||
);
|
||||
@@ -187,6 +235,8 @@ export const useSftpViewTabs = ({ sftp, sftpRef }: UseSftpViewTabsParams): UseSf
|
||||
handleReorderTabsRight,
|
||||
handleMoveTabFromLeftToRight,
|
||||
handleMoveTabFromRightToLeft,
|
||||
handleDuplicateTabLeft,
|
||||
handleDuplicateTabRight,
|
||||
handleHostSelectLeft,
|
||||
handleHostSelectRight,
|
||||
};
|
||||
|
||||
114
components/sftp/sftpTabDuplication.test.ts
Normal file
114
components/sftp/sftpTabDuplication.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type { SftpPane } from "../../application/state/sftp/types.ts";
|
||||
import {
|
||||
canDuplicateSftpTab,
|
||||
getSftpTabDuplicateRequest,
|
||||
isSftpTabKeyboardContextMenuShortcut,
|
||||
isSftpTabKeyboardSelectShortcut,
|
||||
shouldHandleSftpTabKeyboardEvent,
|
||||
SFTP_TAB_DUPLICATE_MENU_ITEMS,
|
||||
} from "./sftpTabDuplication.ts";
|
||||
|
||||
const connectedPane = (overrides: Partial<NonNullable<SftpPane["connection"]>> = {}): SftpPane => ({
|
||||
id: "tab-1",
|
||||
connection: {
|
||||
id: "conn-1",
|
||||
hostId: "host-1",
|
||||
hostLabel: "Prod",
|
||||
isLocal: false,
|
||||
status: "connected",
|
||||
currentPath: "/var/www/app",
|
||||
homeDir: "/home/deploy",
|
||||
...overrides,
|
||||
},
|
||||
files: [],
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
error: null,
|
||||
connectionLogs: [],
|
||||
selectedFiles: new Set(),
|
||||
filter: "",
|
||||
filenameEncoding: "auto",
|
||||
showHiddenFiles: false,
|
||||
transferMutationToken: 0,
|
||||
});
|
||||
|
||||
test("default-path SFTP tab duplication keeps only the remote host identity", () => {
|
||||
assert.deepEqual(getSftpTabDuplicateRequest(connectedPane(), "defaultPath"), {
|
||||
kind: "remote",
|
||||
hostId: "host-1",
|
||||
});
|
||||
});
|
||||
|
||||
test("current-path SFTP tab duplication carries the active directory", () => {
|
||||
assert.deepEqual(getSftpTabDuplicateRequest(connectedPane(), "currentPath"), {
|
||||
kind: "remote",
|
||||
hostId: "host-1",
|
||||
path: "/var/www/app",
|
||||
});
|
||||
});
|
||||
|
||||
test("local SFTP tab duplication targets the local filesystem", () => {
|
||||
assert.deepEqual(
|
||||
getSftpTabDuplicateRequest(
|
||||
connectedPane({
|
||||
hostId: "local",
|
||||
hostLabel: "Local",
|
||||
isLocal: true,
|
||||
currentPath: "/Users/damao/projects",
|
||||
homeDir: "/Users/damao",
|
||||
}),
|
||||
"currentPath",
|
||||
),
|
||||
{
|
||||
kind: "local",
|
||||
path: "/Users/damao/projects",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("SFTP tab duplication is unavailable before a tab is connected", () => {
|
||||
assert.equal(getSftpTabDuplicateRequest({ ...connectedPane(), connection: null }, "defaultPath"), null);
|
||||
assert.equal(
|
||||
getSftpTabDuplicateRequest(connectedPane({ status: "connecting" }), "currentPath"),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("SFTP tab duplicate menu exposes separate default and current path actions", () => {
|
||||
assert.deepEqual(
|
||||
SFTP_TAB_DUPLICATE_MENU_ITEMS.map((item) => item.mode),
|
||||
["defaultPath", "currentPath"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
SFTP_TAB_DUPLICATE_MENU_ITEMS.map((item) => item.labelKey),
|
||||
["sftp.tabs.copyDefaultPath", "sftp.tabs.copyCurrentPath"],
|
||||
);
|
||||
});
|
||||
|
||||
test("SFTP tab duplicate menu is disabled without a connected tab and handler", () => {
|
||||
assert.equal(canDuplicateSftpTab({ canDuplicate: true }, true), true);
|
||||
assert.equal(canDuplicateSftpTab({ canDuplicate: true }, false), false);
|
||||
assert.equal(canDuplicateSftpTab({ canDuplicate: false }, true), false);
|
||||
assert.equal(canDuplicateSftpTab(connectedPane(), true), true);
|
||||
assert.equal(canDuplicateSftpTab(connectedPane({ status: "connecting" }), true), false);
|
||||
});
|
||||
|
||||
test("SFTP tab duplicate menu has keyboard shortcuts for selection and menu access", () => {
|
||||
assert.equal(isSftpTabKeyboardSelectShortcut("Enter"), true);
|
||||
assert.equal(isSftpTabKeyboardSelectShortcut(" "), true);
|
||||
assert.equal(isSftpTabKeyboardSelectShortcut("Escape"), false);
|
||||
assert.equal(isSftpTabKeyboardContextMenuShortcut("ContextMenu"), true);
|
||||
assert.equal(isSftpTabKeyboardContextMenuShortcut("F10", true), true);
|
||||
assert.equal(isSftpTabKeyboardContextMenuShortcut("F10", false), false);
|
||||
});
|
||||
|
||||
test("SFTP tab keyboard shortcuts do not intercept nested close button events", () => {
|
||||
const tab = new EventTarget();
|
||||
const closeButton = new EventTarget();
|
||||
|
||||
assert.equal(shouldHandleSftpTabKeyboardEvent(tab, tab), true);
|
||||
assert.equal(shouldHandleSftpTabKeyboardEvent(closeButton, tab), false);
|
||||
});
|
||||
73
components/sftp/sftpTabDuplication.ts
Normal file
73
components/sftp/sftpTabDuplication.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { SftpPane } from "../../application/state/sftp/types";
|
||||
|
||||
export type SftpTabDuplicateMode = "defaultPath" | "currentPath";
|
||||
|
||||
export type SftpTabDuplicateRequest =
|
||||
| { kind: "local"; path?: string }
|
||||
| { kind: "remote"; hostId: string; path?: string };
|
||||
|
||||
export const SFTP_TAB_DUPLICATE_MENU_ITEMS: ReadonlyArray<{
|
||||
mode: SftpTabDuplicateMode;
|
||||
labelKey: "sftp.tabs.copyDefaultPath" | "sftp.tabs.copyCurrentPath";
|
||||
}> = Object.freeze([
|
||||
{ mode: "defaultPath", labelKey: "sftp.tabs.copyDefaultPath" },
|
||||
{ mode: "currentPath", labelKey: "sftp.tabs.copyCurrentPath" },
|
||||
]);
|
||||
|
||||
export function canDuplicateSftpTab(
|
||||
tab: Pick<SftpPane, "connection"> | { canDuplicate?: boolean } | null | undefined,
|
||||
hasDuplicateHandler: boolean,
|
||||
): boolean {
|
||||
if (!hasDuplicateHandler || !tab) return false;
|
||||
if ("connection" in tab) return tab.connection?.status === "connected";
|
||||
return !!tab.canDuplicate;
|
||||
}
|
||||
|
||||
export function isSftpTabKeyboardContextMenuShortcut(
|
||||
key: string,
|
||||
shiftKey = false,
|
||||
): boolean {
|
||||
return key === "ContextMenu" || (shiftKey && key === "F10");
|
||||
}
|
||||
|
||||
export function isSftpTabKeyboardSelectShortcut(key: string): boolean {
|
||||
return key === "Enter" || key === " ";
|
||||
}
|
||||
|
||||
export function shouldHandleSftpTabKeyboardEvent(
|
||||
target: EventTarget | null,
|
||||
currentTarget: EventTarget | null,
|
||||
): boolean {
|
||||
return target === currentTarget;
|
||||
}
|
||||
|
||||
export function getSftpTabDuplicateRequest(
|
||||
pane: Pick<SftpPane, "connection"> | null | undefined,
|
||||
mode: SftpTabDuplicateMode,
|
||||
): SftpTabDuplicateRequest | null {
|
||||
const connection = pane?.connection;
|
||||
if (!connection || connection.status !== "connected") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const path = mode === "currentPath" && connection.currentPath
|
||||
? { path: connection.currentPath }
|
||||
: {};
|
||||
|
||||
if (connection.isLocal) {
|
||||
return {
|
||||
kind: "local",
|
||||
...path,
|
||||
};
|
||||
}
|
||||
|
||||
if (!connection.hostId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "remote",
|
||||
hostId: connection.hostId,
|
||||
...path,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Pause, Pencil, Play, Trash2, Zap } from 'lucide-react';
|
||||
import React, { memo, useCallback, useState } from 'react';
|
||||
import { Loader2, Pause, Pencil, Play, Trash2, Zap } from 'lucide-react';
|
||||
import React, { memo, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
|
||||
import type { DockerContainerAction, DockerContainerInfo, DockerStatInfo } from '../../domain/systemManager/types';
|
||||
import { getContainerFlags } from '../../domain/systemManager/containerState';
|
||||
import { DockerInspectView } from './DockerInspectView';
|
||||
@@ -9,18 +8,17 @@ import { ResourceBar } from './ResourceBar';
|
||||
import {
|
||||
SystemPanelActionChip,
|
||||
SystemPanelDetailStrip,
|
||||
SystemPanelInlineError,
|
||||
} from './SystemPanelUi';
|
||||
import { SystemPanelPromptDialog } from './SystemPanelPromptDialog';
|
||||
import { usePolling } from './hooks/useSystemManager';
|
||||
|
||||
type Backend = ReturnType<typeof useSystemManagerBackend>;
|
||||
|
||||
interface DockerContainerDetailProps {
|
||||
container: DockerContainerInfo;
|
||||
sessionId: string;
|
||||
backend: Backend;
|
||||
statsRefreshIntervalSec: number;
|
||||
inspect: Record<string, unknown> | null;
|
||||
inspectError?: string | null;
|
||||
inspectLoading?: boolean;
|
||||
stat?: DockerStatInfo | null;
|
||||
statsLoading?: boolean;
|
||||
pendingAction: DockerContainerAction | null;
|
||||
onCloseInspect: () => void;
|
||||
onRunAction: (containerId: string, action: DockerContainerAction, newName?: string) => Promise<void>;
|
||||
@@ -28,10 +26,11 @@ interface DockerContainerDetailProps {
|
||||
|
||||
export const DockerContainerDetail = memo(function DockerContainerDetail({
|
||||
container,
|
||||
sessionId,
|
||||
backend,
|
||||
statsRefreshIntervalSec,
|
||||
inspect,
|
||||
inspectError = null,
|
||||
inspectLoading = false,
|
||||
stat = null,
|
||||
statsLoading = false,
|
||||
pendingAction,
|
||||
onCloseInspect,
|
||||
onRunAction,
|
||||
@@ -40,20 +39,6 @@ export const DockerContainerDetail = memo(function DockerContainerDetail({
|
||||
const shortId = container.id.slice(0, 12);
|
||||
const { isRunning, isPaused } = getContainerFlags(container);
|
||||
|
||||
const statsFetcher = useCallback(async () => {
|
||||
const result = await backend.getDockerStats({ sessionId, ids: [container.id] });
|
||||
if (!result.success || !result.stats) {
|
||||
throw new Error(result.error || t('systemManager.errors.loadDockerStats'));
|
||||
}
|
||||
return result.stats;
|
||||
}, [backend, container.id, sessionId, t]);
|
||||
|
||||
const statsIntervalMs = Math.max(2, statsRefreshIntervalSec) * 1000;
|
||||
// docker stats still reports paused containers, so keep polling them.
|
||||
const { data: stats } = usePolling<DockerStatInfo[]>(statsFetcher, statsIntervalMs, isRunning || isPaused);
|
||||
|
||||
const stat = stats?.find((s) => s.id === container.id || s.id.startsWith(shortId)) ?? stats?.[0];
|
||||
|
||||
const [renameOpen, setRenameOpen] = useState(false);
|
||||
const actionBusy = pendingAction !== null;
|
||||
|
||||
@@ -61,7 +46,7 @@ export const DockerContainerDetail = memo(function DockerContainerDetail({
|
||||
<>
|
||||
<SystemPanelDetailStrip>
|
||||
{container.ports && (
|
||||
<div className="text-[10px] text-muted-foreground mb-2 truncate">{container.ports}</div>
|
||||
<div className="text-[10px] text-muted-foreground mb-2 break-all">{container.ports}</div>
|
||||
)}
|
||||
{stat && (
|
||||
<div className="space-y-1 mb-2">
|
||||
@@ -70,6 +55,12 @@ export const DockerContainerDetail = memo(function DockerContainerDetail({
|
||||
<div className="text-[10px] text-muted-foreground">{stat.netIO} · {stat.memUsage}</div>
|
||||
</div>
|
||||
)}
|
||||
{!stat && statsLoading && (isRunning || isPaused) && (
|
||||
<div className="mb-2 flex items-center gap-1.5 text-[10px] text-muted-foreground">
|
||||
<Loader2 size={11} className="animate-spin" />
|
||||
{t('systemManager.common.loadingStats')}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-0.5">
|
||||
<SystemPanelActionChip title={t('systemManager.docker.renamePrompt')} disabled={actionBusy} onClick={() => setRenameOpen(true)}>
|
||||
<Pencil size={11} /> {t('common.rename')}
|
||||
@@ -94,6 +85,15 @@ export const DockerContainerDetail = memo(function DockerContainerDetail({
|
||||
</SystemPanelActionChip>
|
||||
</div>
|
||||
</SystemPanelDetailStrip>
|
||||
{inspectLoading && !inspect && (
|
||||
<div className="flex items-center gap-1.5 border-b border-border/40 bg-muted/20 px-3 py-2 text-[10px] text-muted-foreground">
|
||||
<Loader2 size={11} className="animate-spin" />
|
||||
{t('systemManager.common.loadingDetails')}
|
||||
</div>
|
||||
)}
|
||||
{inspectError && !inspect && (
|
||||
<SystemPanelInlineError message={inspectError} />
|
||||
)}
|
||||
{inspect && (
|
||||
<DockerInspectView
|
||||
kind="container"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Box, FileText, Play, RotateCcw, Square, Terminal } from 'lucide-react';
|
||||
import React, { memo, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import React, { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
|
||||
import { writeSystemManagerDiagnostic } from '../../application/state/systemManagerDiagnostics';
|
||||
import type { TerminalSession } from '../../types';
|
||||
import type { DockerContainerAction, DockerContainerInfo, TerminalPopupIcon } from '../../domain/systemManager/types';
|
||||
import type { DockerContainerAction, DockerContainerInfo, DockerStatInfo, TerminalPopupIcon } from '../../domain/systemManager/types';
|
||||
import { dockerContainerInfoEqual } from '../../domain/systemManager/pollEquals';
|
||||
import { getContainerFlags, getContainerTone } from '../../domain/systemManager/containerState';
|
||||
import { buildDockerExecShellCommand, buildDockerLogsCommand } from '../../domain/systemManager/dockerShell';
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
SystemPanelEmpty,
|
||||
SystemPanelError,
|
||||
SystemPanelList,
|
||||
SystemPanelLoading,
|
||||
SystemPanelMetaBar,
|
||||
SystemPanelRefreshButton,
|
||||
SystemPanelRoundButton,
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
SystemPanelStatusBadge,
|
||||
SystemPanelToolbar,
|
||||
} from './SystemPanelUi';
|
||||
import { useAsyncRecordCache } from './hooks/useAsyncRecordCache';
|
||||
import { usePolling, useStableTranslate } from './hooks/useSystemManager';
|
||||
import { openInteractiveTerminal } from './openInteractiveTerminal';
|
||||
import { showSystemManagerError } from './systemManagerToast';
|
||||
@@ -53,6 +55,7 @@ interface DockerContainersPanelProps {
|
||||
sessionId: string;
|
||||
parentSession: TerminalSession;
|
||||
isVisible: boolean;
|
||||
warmupEnabled?: boolean;
|
||||
backend: Backend;
|
||||
listRefreshIntervalSec: number;
|
||||
statsRefreshIntervalSec: number;
|
||||
@@ -150,6 +153,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
sessionId,
|
||||
parentSession,
|
||||
isVisible,
|
||||
warmupEnabled = false,
|
||||
backend,
|
||||
listRefreshIntervalSec,
|
||||
statsRefreshIntervalSec,
|
||||
@@ -159,10 +163,6 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
const [query, setQuery] = useState('');
|
||||
const [filter, setFilter] = useState<ContainerFilter>('all');
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [inspect, setInspect] = useState<Record<string, unknown> | null>(null);
|
||||
// Invalidates in-flight inspect fetches when the selection changes —
|
||||
// a slow response for container A must not render under container B.
|
||||
const inspectSeqRef = useRef(0);
|
||||
// Spinner feedback while a container action (stop/restart/…) runs;
|
||||
// cleared only after the follow-up list refresh lands.
|
||||
const [pendingAction, setPendingAction] = useState<{ id: string; action: DockerContainerAction } | null>(null);
|
||||
@@ -179,13 +179,15 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
const { data: containers, error, loading, refresh } = usePolling<DockerContainerInfo[]>(
|
||||
containersFetcher,
|
||||
listIntervalMs,
|
||||
isVisible,
|
||||
isVisible || warmupEnabled,
|
||||
(prev, next) => mergePollListByKey(prev, next, (c) => c.id, dockerContainerInfoEqual),
|
||||
{ poll: isVisible, resetKey: sessionId },
|
||||
);
|
||||
|
||||
const matched = useMemo(() => {
|
||||
const matched = useMemo<DockerContainerInfo[]>(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return (containers ?? []).filter((container) => {
|
||||
const containerList = containers ?? [];
|
||||
return containerList.filter((container) => {
|
||||
const { isRunning, isPaused } = getContainerFlags(container);
|
||||
if (filter === 'running' && !isRunning) return false;
|
||||
if (filter === 'stopped' && (isRunning || isPaused)) return false;
|
||||
@@ -202,7 +204,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
(a: DockerContainerInfo, b: DockerContainerInfo) => a.name.localeCompare(b.name),
|
||||
[],
|
||||
);
|
||||
const displayList = useStableListOrder(
|
||||
const displayList = useStableListOrder<DockerContainerInfo, string>(
|
||||
matched,
|
||||
(c) => c.id,
|
||||
`${filter}|${query}`,
|
||||
@@ -214,6 +216,69 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
[displayList, selectedId],
|
||||
);
|
||||
|
||||
const statContainerIds = useMemo(
|
||||
() => {
|
||||
if (!selectedContainer) return [];
|
||||
const { isRunning, isPaused } = getContainerFlags(selectedContainer);
|
||||
return isRunning || isPaused ? [selectedContainer.id] : [];
|
||||
},
|
||||
[selectedContainer],
|
||||
);
|
||||
const statsFetcher = useCallback(async () => {
|
||||
if (statContainerIds.length === 0) return [];
|
||||
const result = await backend.getDockerStats({ sessionId, ids: statContainerIds });
|
||||
if (!result.success || !result.stats) {
|
||||
throw new Error(result.error || stableT('systemManager.errors.loadDockerStats'));
|
||||
}
|
||||
return result.stats;
|
||||
}, [backend, sessionId, stableT, statContainerIds]);
|
||||
|
||||
const statsIntervalMs = Math.max(2, statsRefreshIntervalSec) * 1000;
|
||||
const { data: stats, loading: statsLoading } = usePolling<DockerStatInfo[]>(
|
||||
statsFetcher,
|
||||
statsIntervalMs,
|
||||
isVisible && statContainerIds.length > 0,
|
||||
undefined,
|
||||
{ poll: isVisible, resetKey: `${sessionId}:${statContainerIds.join(',')}` },
|
||||
);
|
||||
|
||||
const statsByContainerId = useMemo(() => {
|
||||
const map = new Map<string, DockerStatInfo>();
|
||||
for (const stat of stats ?? []) {
|
||||
map.set(stat.id, stat);
|
||||
map.set(stat.id.slice(0, 12), stat);
|
||||
}
|
||||
return map;
|
||||
}, [stats]);
|
||||
|
||||
const getContainerInspectKey = useCallback((container: DockerContainerInfo) => (
|
||||
`${sessionId}:${container.id}`
|
||||
), [sessionId]);
|
||||
const fetchContainerInspect = useCallback(async (container: DockerContainerInfo) => {
|
||||
const result = await backend.dockerInspect({
|
||||
sessionId,
|
||||
containerId: container.id.slice(0, 12),
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || stableT('systemManager.errors.actionFailed'));
|
||||
}
|
||||
return result.inspect ?? null;
|
||||
}, [backend, sessionId, stableT]);
|
||||
const {
|
||||
records: inspectByContainerId,
|
||||
loadRecord: loadContainerInspect,
|
||||
refreshRecord: refreshContainerInspect,
|
||||
invalidateMatching: invalidateContainerInspectMatching,
|
||||
} = useAsyncRecordCache<DockerContainerInfo, Record<string, unknown>>({
|
||||
items: containers ?? [],
|
||||
enabled: isVisible && (containers?.length ?? 0) > 0,
|
||||
getKey: getContainerInspectKey,
|
||||
fetchRecord: fetchContainerInspect,
|
||||
prefetchLimit: 24,
|
||||
prefetchDelayMs: 40,
|
||||
staleTimeMs: 20_000,
|
||||
});
|
||||
|
||||
const runAction = useCallback(async (
|
||||
containerId: string,
|
||||
action: DockerContainerAction,
|
||||
@@ -234,34 +299,42 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
|
||||
return;
|
||||
}
|
||||
const affectedContainer = (containers ?? []).find((container) => (
|
||||
container.id === containerId || container.id.startsWith(containerId)
|
||||
));
|
||||
invalidateContainerInspectMatching((key) => (
|
||||
key === `${sessionId}:${containerId}` || key.startsWith(`${sessionId}:${containerId}`)
|
||||
));
|
||||
if (action === 'rm') {
|
||||
setSelectedId(null);
|
||||
setInspect(null);
|
||||
inspectSeqRef.current += 1;
|
||||
}
|
||||
await refresh();
|
||||
if (affectedContainer && action !== 'rm') {
|
||||
void refreshContainerInspect(affectedContainer);
|
||||
}
|
||||
} finally {
|
||||
setPendingAction(null);
|
||||
}
|
||||
}, [backend, refresh, sessionId, t]);
|
||||
}, [
|
||||
backend,
|
||||
containers,
|
||||
invalidateContainerInspectMatching,
|
||||
refresh,
|
||||
refreshContainerInspect,
|
||||
sessionId,
|
||||
t,
|
||||
]);
|
||||
|
||||
const handleRowAction = useCallback((container: DockerContainerInfo, action: DockerContainerAction) => {
|
||||
void runAction(container.id.slice(0, 12), action);
|
||||
}, [runAction]);
|
||||
|
||||
const selectContainer = useCallback(async (container: DockerContainerInfo) => {
|
||||
const selectContainer = useCallback((container: DockerContainerInfo) => {
|
||||
const next = selectedId === container.id ? null : container.id;
|
||||
setSelectedId(next);
|
||||
setInspect(null);
|
||||
const seq = ++inspectSeqRef.current;
|
||||
if (!next) return;
|
||||
const result = await backend.dockerInspect({
|
||||
sessionId,
|
||||
containerId: container.id.slice(0, 12),
|
||||
});
|
||||
if (inspectSeqRef.current !== seq) return;
|
||||
setInspect(result.success ? (result.inspect ?? null) : null);
|
||||
}, [backend, selectedId, sessionId]);
|
||||
void loadContainerInspect(container, { force: true, urgent: true });
|
||||
}, [loadContainerInspect, selectedId]);
|
||||
|
||||
const openShell = useCallback(async (container: DockerContainerInfo) => {
|
||||
const id = container.id.slice(0, 12);
|
||||
@@ -354,6 +427,9 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
{error && (
|
||||
<SystemPanelError message={error} onRetry={() => void refresh()} retryLabel={t('history.action.retry')} loading={loading} />
|
||||
)}
|
||||
{!error && displayList.length === 0 && loading && (
|
||||
<SystemPanelLoading message={t('systemManager.common.loading')} />
|
||||
)}
|
||||
{!error && displayList.length === 0 && !loading && (
|
||||
<SystemPanelEmpty icon={Box} message={t('systemManager.docker.empty')} />
|
||||
)}
|
||||
@@ -363,6 +439,8 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
const rowPending = pendingAction && pendingAction.id === container.id.slice(0, 12)
|
||||
? pendingAction.action
|
||||
: null;
|
||||
const selectedInspectKey = selectedContainer ? getContainerInspectKey(selectedContainer) : null;
|
||||
const selectedInspectRecord = selectedInspectKey ? inspectByContainerId[selectedInspectKey] : undefined;
|
||||
return (
|
||||
<React.Fragment key={container.id}>
|
||||
<DockerContainerRow
|
||||
@@ -378,12 +456,13 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
{selectedContainer && (
|
||||
<DockerContainerDetail
|
||||
container={selectedContainer}
|
||||
sessionId={sessionId}
|
||||
backend={backend}
|
||||
statsRefreshIntervalSec={statsRefreshIntervalSec}
|
||||
inspect={inspect}
|
||||
inspect={selectedInspectRecord?.data ?? null}
|
||||
inspectError={selectedInspectRecord?.error ?? null}
|
||||
inspectLoading={selectedInspectRecord?.loading ?? false}
|
||||
stat={statsByContainerId.get(selectedContainer.id) ?? statsByContainerId.get(selectedContainer.id.slice(0, 12)) ?? null}
|
||||
statsLoading={statsLoading}
|
||||
pendingAction={rowPending}
|
||||
onCloseInspect={() => { setSelectedId(null); setInspect(null); }}
|
||||
onCloseInspect={() => { setSelectedId(null); }}
|
||||
onRunAction={runAction}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Layers, Tag, Trash2 } from 'lucide-react';
|
||||
import React, { memo, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { Layers, Loader2, Tag, Trash2 } from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
|
||||
import { dockerImageRowKey, type DockerImageInfo } from '../../domain/systemManager/types';
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
SystemPanelCollapsible,
|
||||
SystemPanelEmpty,
|
||||
SystemPanelError,
|
||||
SystemPanelInlineError,
|
||||
SystemPanelList,
|
||||
SystemPanelLoading,
|
||||
SystemPanelMetaBar,
|
||||
SystemPanelRefreshButton,
|
||||
SystemPanelRoundButton,
|
||||
@@ -20,6 +22,7 @@ import {
|
||||
SystemPanelToolbar,
|
||||
} from './SystemPanelUi';
|
||||
import { SystemPanelPromptDialog } from './SystemPanelPromptDialog';
|
||||
import { useAsyncRecordCache } from './hooks/useAsyncRecordCache';
|
||||
import { usePolling, useStableTranslate } from './hooks/useSystemManager';
|
||||
import { showSystemManagerError } from './systemManagerToast';
|
||||
|
||||
@@ -28,6 +31,7 @@ type Backend = ReturnType<typeof useSystemManagerBackend>;
|
||||
interface DockerImagesPanelProps {
|
||||
sessionId: string;
|
||||
isVisible: boolean;
|
||||
warmupEnabled?: boolean;
|
||||
backend: Backend;
|
||||
listRefreshIntervalSec: number;
|
||||
}
|
||||
@@ -81,6 +85,7 @@ const DockerImageRow = memo(function DockerImageRow({
|
||||
export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
sessionId,
|
||||
isVisible,
|
||||
warmupEnabled = false,
|
||||
backend,
|
||||
listRefreshIntervalSec,
|
||||
}: DockerImagesPanelProps) {
|
||||
@@ -88,8 +93,12 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
const stableT = useStableTranslate();
|
||||
const [query, setQuery] = useState('');
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [inspect, setInspect] = useState<Record<string, unknown> | null>(null);
|
||||
const inspectSeqRef = useRef(0);
|
||||
const [tagTarget, setTagTarget] = useState<DockerImageInfo | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedId(null);
|
||||
setTagTarget(null);
|
||||
}, [sessionId]);
|
||||
|
||||
const imagesFetcher = useCallback(async () => {
|
||||
const result = await backend.listDockerImages(sessionId);
|
||||
@@ -103,8 +112,9 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
const { data: images, error, loading, refresh } = usePolling<DockerImageInfo[]>(
|
||||
imagesFetcher,
|
||||
listIntervalMs,
|
||||
isVisible,
|
||||
isVisible || warmupEnabled,
|
||||
(prev, next) => mergePollListByKey(prev, next, dockerImageRowKey, dockerImageInfoEqual),
|
||||
{ poll: isVisible, resetKey: sessionId },
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
@@ -130,6 +140,33 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
);
|
||||
const displayList = useStableListOrder(filtered, dockerImageRowKey, query, compareImages);
|
||||
|
||||
const getImageInspectKey = useCallback((image: DockerImageInfo) => (
|
||||
`${sessionId}:${dockerImageRowKey(image)}`
|
||||
), [sessionId]);
|
||||
const fetchImageInspect = useCallback(async (image: DockerImageInfo) => {
|
||||
const result = await backend.dockerImageInspect({
|
||||
sessionId,
|
||||
imageId: image.id.slice(0, 12),
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || stableT('systemManager.errors.actionFailed'));
|
||||
}
|
||||
return result.inspect ?? null;
|
||||
}, [backend, sessionId, stableT]);
|
||||
const {
|
||||
records: inspectByImageKey,
|
||||
loadRecord: loadImageInspect,
|
||||
invalidateRecord: invalidateImageInspect,
|
||||
} = useAsyncRecordCache<DockerImageInfo, Record<string, unknown>>({
|
||||
items: images ?? [],
|
||||
enabled: isVisible && (images?.length ?? 0) > 0,
|
||||
getKey: getImageInspectKey,
|
||||
fetchRecord: fetchImageInspect,
|
||||
prefetchLimit: 24,
|
||||
prefetchDelayMs: 40,
|
||||
staleTimeMs: 20_000,
|
||||
});
|
||||
|
||||
const handleRemove = useCallback(async (image: DockerImageInfo) => {
|
||||
const label = image.name || image.id.slice(0, 12);
|
||||
const ok = window.confirm(t('systemManager.docker.confirmRemoveImage', { name: label }));
|
||||
@@ -146,11 +183,10 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
}
|
||||
if (selectedId === dockerImageRowKey(image)) {
|
||||
setSelectedId(null);
|
||||
setInspect(null);
|
||||
inspectSeqRef.current += 1;
|
||||
}
|
||||
invalidateImageInspect(getImageInspectKey(image));
|
||||
await refresh();
|
||||
}, [backend, refresh, selectedId, sessionId, t]);
|
||||
}, [backend, getImageInspectKey, invalidateImageInspect, refresh, selectedId, sessionId, t]);
|
||||
|
||||
const handlePrune = async (all: boolean) => {
|
||||
const ok = window.confirm(all
|
||||
@@ -165,8 +201,6 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
await refresh();
|
||||
};
|
||||
|
||||
const [tagTarget, setTagTarget] = useState<DockerImageInfo | null>(null);
|
||||
|
||||
const handleTagSubmit = async (image: DockerImageInfo, repository: string, tag: string) => {
|
||||
const result = await backend.dockerImageAction({
|
||||
sessionId,
|
||||
@@ -182,20 +216,13 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
await refresh();
|
||||
};
|
||||
|
||||
const selectImage = useCallback(async (image: DockerImageInfo) => {
|
||||
const selectImage = useCallback((image: DockerImageInfo) => {
|
||||
const rowKey = dockerImageRowKey(image);
|
||||
const next = selectedId === rowKey ? null : rowKey;
|
||||
setSelectedId(next);
|
||||
setInspect(null);
|
||||
const seq = ++inspectSeqRef.current;
|
||||
if (!next) return;
|
||||
const result = await backend.dockerImageInspect({
|
||||
sessionId,
|
||||
imageId: image.id.slice(0, 12),
|
||||
});
|
||||
if (inspectSeqRef.current !== seq) return;
|
||||
setInspect(result.success ? (result.inspect ?? null) : null);
|
||||
}, [backend, selectedId, sessionId]);
|
||||
void loadImageInspect(image, { force: true, urgent: true });
|
||||
}, [loadImageInspect, selectedId]);
|
||||
|
||||
const openTagDialog = useCallback((image: DockerImageInfo) => {
|
||||
setTagTarget(image);
|
||||
@@ -243,12 +270,16 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
{error && (
|
||||
<SystemPanelError message={error} onRetry={() => void refresh()} retryLabel={t('history.action.retry')} loading={loading} />
|
||||
)}
|
||||
{!error && displayList.length === 0 && loading && (
|
||||
<SystemPanelLoading message={t('systemManager.common.loading')} />
|
||||
)}
|
||||
{!error && displayList.length === 0 && !loading && (
|
||||
<SystemPanelEmpty icon={Layers} message={t('systemManager.docker.imagesEmpty')} />
|
||||
)}
|
||||
|
||||
{displayList.map((image) => {
|
||||
const rowKey = dockerImageRowKey(image);
|
||||
const inspectKey = getImageInspectKey(image);
|
||||
const shortId = image.id.slice(0, 12);
|
||||
const displayName = image.repository && image.tag
|
||||
? `${image.repository}:${image.tag}`
|
||||
@@ -266,11 +297,20 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
<SystemPanelCollapsible open={selected}>
|
||||
{inspect && (
|
||||
{inspectByImageKey[inspectKey]?.loading && !inspectByImageKey[inspectKey]?.data && (
|
||||
<div className="flex items-center gap-1.5 border-b border-border/40 bg-muted/20 px-3 py-2 text-[10px] text-muted-foreground">
|
||||
<Loader2 size={11} className="animate-spin" />
|
||||
{t('systemManager.common.loadingDetails')}
|
||||
</div>
|
||||
)}
|
||||
{inspectByImageKey[inspectKey]?.error && !inspectByImageKey[inspectKey]?.data && (
|
||||
<SystemPanelInlineError message={inspectByImageKey[inspectKey].error} />
|
||||
)}
|
||||
{inspectByImageKey[inspectKey]?.data && (
|
||||
<DockerInspectView
|
||||
kind="image"
|
||||
data={inspect}
|
||||
onClose={() => { setSelectedId(null); setInspect(null); }}
|
||||
data={inspectByImageKey[inspectKey].data}
|
||||
onClose={() => { setSelectedId(null); }}
|
||||
/>
|
||||
)}
|
||||
</SystemPanelCollapsible>
|
||||
|
||||
@@ -23,7 +23,7 @@ function InspectList({ label, items }: { label: string; items: string[] }) {
|
||||
return (
|
||||
<div className="text-[10px] leading-relaxed">
|
||||
<div className="text-muted-foreground mb-0.5">{label}</div>
|
||||
<div className="space-y-0.5 max-h-28 overflow-y-auto font-mono">
|
||||
<div className="space-y-0.5 font-mono">
|
||||
{items.map((item, index) => (
|
||||
<div key={index} className="break-all text-foreground/90">{item}</div>
|
||||
))}
|
||||
|
||||
@@ -15,6 +15,7 @@ interface DockerManagerTabProps {
|
||||
sessionId: string;
|
||||
parentSession: TerminalSession;
|
||||
isVisible: boolean;
|
||||
warmupEnabled?: boolean;
|
||||
backend: Backend;
|
||||
listRefreshIntervalSec: number;
|
||||
statsRefreshIntervalSec: number;
|
||||
@@ -24,6 +25,7 @@ export const DockerManagerTab = memo(function DockerManagerTab({
|
||||
sessionId,
|
||||
parentSession,
|
||||
isVisible,
|
||||
warmupEnabled = false,
|
||||
backend,
|
||||
listRefreshIntervalSec,
|
||||
statsRefreshIntervalSec,
|
||||
@@ -58,23 +60,26 @@ export const DockerManagerTab = memo(function DockerManagerTab({
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{subTab === 'containers' ? (
|
||||
<div className={cn('flex-1 min-h-0 flex flex-col', subTab !== 'containers' && 'hidden')}>
|
||||
<DockerContainersPanel
|
||||
sessionId={sessionId}
|
||||
parentSession={parentSession}
|
||||
isVisible={isVisible}
|
||||
isVisible={isVisible && subTab === 'containers'}
|
||||
warmupEnabled={warmupEnabled || (isVisible && subTab !== 'containers')}
|
||||
backend={backend}
|
||||
listRefreshIntervalSec={listRefreshIntervalSec}
|
||||
statsRefreshIntervalSec={statsRefreshIntervalSec}
|
||||
/>
|
||||
) : (
|
||||
</div>
|
||||
<div className={cn('flex-1 min-h-0 flex flex-col', subTab !== 'images' && 'hidden')}>
|
||||
<DockerImagesPanel
|
||||
sessionId={sessionId}
|
||||
isVisible={isVisible}
|
||||
isVisible={isVisible && subTab === 'images'}
|
||||
warmupEnabled={warmupEnabled || (isVisible && subTab !== 'images')}
|
||||
backend={backend}
|
||||
listRefreshIntervalSec={listRefreshIntervalSec}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SystemPanelShell>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
Gauge, LayoutList, Pause, Play, Skull, XCircle,
|
||||
Gauge, LayoutList, Loader2, Pause, Play, Skull, XCircle,
|
||||
} from 'lucide-react';
|
||||
import React, { memo, useCallback, useMemo, useState } from 'react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
|
||||
import {
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import type { SystemProcessInfo } from '../../domain/systemManager/types';
|
||||
import { systemProcessInfoEqual } from '../../domain/systemManager/pollEquals';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { VariableSizeVirtualList } from '../ui/VariableSizeVirtualList';
|
||||
import { ResourceBar } from './ResourceBar';
|
||||
import { useStableListOrder, mergePollListByKey } from './listStable';
|
||||
import {
|
||||
@@ -27,7 +28,6 @@ import {
|
||||
SystemPanelSearch,
|
||||
SystemPanelSegmented,
|
||||
SystemPanelShell,
|
||||
SystemPanelCollapsible,
|
||||
SystemPanelStatusBadge,
|
||||
SystemPanelToolbar,
|
||||
} from './SystemPanelUi';
|
||||
@@ -38,6 +38,16 @@ type Backend = ReturnType<typeof useSystemManagerBackend>;
|
||||
type SortKey = 'cpuPercent' | 'memPercent' | 'pid' | 'command' | 'user';
|
||||
type ProcessFilter = 'all' | 'running';
|
||||
|
||||
const PROCESS_CACHE_TTL_MS = 30_000;
|
||||
const PROCESS_ROW_HEIGHT = 56;
|
||||
const PROCESS_DETAIL_HEIGHT = 112;
|
||||
const PROCESS_OVERSCAN_ROWS = 8;
|
||||
|
||||
const processListCache = new Map<string, {
|
||||
processes: SystemProcessInfo[];
|
||||
updatedAt: number;
|
||||
}>();
|
||||
|
||||
const SORT_OPTIONS: Array<{ key: SortKey; labelKey: string }> = [
|
||||
{ key: 'cpuPercent', labelKey: 'systemManager.processes.sort.cpu' },
|
||||
{ key: 'memPercent', labelKey: 'systemManager.processes.sort.mem' },
|
||||
@@ -61,6 +71,29 @@ const mergeProcesses = (
|
||||
next: SystemProcessInfo[],
|
||||
) => mergePollListByKey(prev, next, (p) => p.pid, systemProcessInfoEqual);
|
||||
|
||||
function getCachedProcesses(sessionId: string): SystemProcessInfo[] | null {
|
||||
const cached = processListCache.get(sessionId);
|
||||
if (!cached) return null;
|
||||
if (Date.now() - cached.updatedAt > PROCESS_CACHE_TTL_MS) {
|
||||
processListCache.delete(sessionId);
|
||||
return null;
|
||||
}
|
||||
return cached.processes;
|
||||
}
|
||||
|
||||
const ProcessListLoading = memo(function ProcessListLoading({
|
||||
message,
|
||||
}: {
|
||||
message: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-[180px] flex-col items-center justify-center px-4 py-10 text-center text-xs text-muted-foreground">
|
||||
<Loader2 size={18} className="mb-2 animate-spin opacity-70" />
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface ProcessRowProps {
|
||||
proc: SystemProcessInfo;
|
||||
selected: boolean;
|
||||
@@ -79,72 +112,125 @@ const ProcessRow = memo(function ProcessRow({
|
||||
const { t } = useI18n();
|
||||
const { isStopped, isZombie } = getProcessFlags(proc);
|
||||
|
||||
const actions = (
|
||||
<div className="flex w-[112px] shrink-0 items-center justify-end gap-1">
|
||||
{!isStopped && !isZombie && (
|
||||
<SystemPanelRoundButton
|
||||
title={t('systemManager.processes.stop')}
|
||||
onClick={() => onSignal(proc.pid, 'STOP')}
|
||||
>
|
||||
<Pause size={12} />
|
||||
</SystemPanelRoundButton>
|
||||
)}
|
||||
{isStopped && !isZombie && (
|
||||
<SystemPanelRoundButton
|
||||
title={t('systemManager.processes.cont')}
|
||||
onClick={() => onSignal(proc.pid, 'CONT')}
|
||||
>
|
||||
<Play size={12} />
|
||||
</SystemPanelRoundButton>
|
||||
)}
|
||||
<SystemPanelRoundButton
|
||||
title={t('systemManager.processes.term')}
|
||||
onClick={() => onSignal(proc.pid, 'TERM')}
|
||||
>
|
||||
<XCircle size={12} />
|
||||
</SystemPanelRoundButton>
|
||||
<SystemPanelRoundButton
|
||||
title={t('systemManager.processes.kill')}
|
||||
destructive
|
||||
onClick={() => onSignal(proc.pid, 'KILL')}
|
||||
>
|
||||
<Skull size={12} />
|
||||
</SystemPanelRoundButton>
|
||||
<SystemPanelRoundButton
|
||||
title={t('systemManager.processes.renice')}
|
||||
onClick={() => onRenice(proc.pid)}
|
||||
>
|
||||
<Gauge size={12} />
|
||||
</SystemPanelRoundButton>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full overflow-hidden">
|
||||
<SystemPanelRow
|
||||
selected={selected}
|
||||
onClick={() => onToggle(proc.pid)}
|
||||
title={proc.command}
|
||||
subtitle={`${proc.user || '—'} · PID ${proc.pid}`}
|
||||
className="h-14"
|
||||
trailing={(
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<div className="flex w-[88px] shrink-0 items-center justify-end">
|
||||
<SystemPanelStatusBadge tone={getProcessTone(proc)}>
|
||||
{t(getProcessStatusLabelKey(proc))}
|
||||
</SystemPanelStatusBadge>
|
||||
{!isStopped && !isZombie && (
|
||||
<SystemPanelRoundButton
|
||||
title={t('systemManager.processes.stop')}
|
||||
onClick={() => onSignal(proc.pid, 'STOP')}
|
||||
>
|
||||
<Pause size={12} />
|
||||
</SystemPanelRoundButton>
|
||||
)}
|
||||
{isStopped && !isZombie && (
|
||||
<SystemPanelRoundButton
|
||||
title={t('systemManager.processes.cont')}
|
||||
onClick={() => onSignal(proc.pid, 'CONT')}
|
||||
>
|
||||
<Play size={12} />
|
||||
</SystemPanelRoundButton>
|
||||
)}
|
||||
<SystemPanelRoundButton
|
||||
title={t('systemManager.processes.term')}
|
||||
onClick={() => onSignal(proc.pid, 'TERM')}
|
||||
>
|
||||
<XCircle size={12} />
|
||||
</SystemPanelRoundButton>
|
||||
<SystemPanelRoundButton
|
||||
title={t('systemManager.processes.kill')}
|
||||
destructive
|
||||
onClick={() => onSignal(proc.pid, 'KILL')}
|
||||
>
|
||||
<Skull size={12} />
|
||||
</SystemPanelRoundButton>
|
||||
<SystemPanelRoundButton
|
||||
title={t('systemManager.processes.renice')}
|
||||
onClick={() => onRenice(proc.pid)}
|
||||
>
|
||||
<Gauge size={12} />
|
||||
</SystemPanelRoundButton>
|
||||
</div>
|
||||
)}
|
||||
actions={actions}
|
||||
/>
|
||||
<SystemPanelCollapsible open={selected}>
|
||||
<SystemPanelDetailStrip>
|
||||
{selected && (
|
||||
<SystemPanelDetailStrip className="h-28 overflow-hidden">
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-1 text-[10px] text-muted-foreground mb-2">
|
||||
<span>{t('systemManager.processes.ppid')}: {proc.ppid}</span>
|
||||
<span>{t('systemManager.processes.stat')}: {proc.stat}</span>
|
||||
<span>{t('systemManager.processes.elapsed')}: {proc.elapsed || '—'}</span>
|
||||
<span>{t('systemManager.processes.rss')}: {formatKb(proc.rssKb)}</span>
|
||||
<span className="col-span-2">{t('systemManager.processes.vsz')}: {formatKb(proc.vszKb)}</span>
|
||||
<span className="min-w-0 truncate">{t('systemManager.processes.ppid')}: {proc.ppid}</span>
|
||||
<span className="min-w-0 truncate">{t('systemManager.processes.stat')}: {proc.stat}</span>
|
||||
<span className="min-w-0 truncate">{t('systemManager.processes.elapsed')}: {proc.elapsed || '—'}</span>
|
||||
<span className="min-w-0 truncate">{t('systemManager.processes.rss')}: {formatKb(proc.rssKb)}</span>
|
||||
<span className="col-span-2 min-w-0 truncate">{t('systemManager.processes.vsz')}: {formatKb(proc.vszKb)}</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<ResourceBar label="CPU" value={proc.cpuPercent} />
|
||||
<ResourceBar label="MEM" value={proc.memPercent} />
|
||||
</div>
|
||||
</SystemPanelDetailStrip>
|
||||
</SystemPanelCollapsible>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface ProcessVirtualListProps {
|
||||
processes: SystemProcessInfo[];
|
||||
selectedPid: number | null;
|
||||
onToggle: (pid: number) => void;
|
||||
onSignal: (pid: number, signal: string) => void;
|
||||
onRenice: (pid: number) => void;
|
||||
}
|
||||
|
||||
const ProcessVirtualList = memo(function ProcessVirtualList({
|
||||
processes,
|
||||
selectedPid,
|
||||
onToggle,
|
||||
onSignal,
|
||||
onRenice,
|
||||
}: ProcessVirtualListProps) {
|
||||
const getItemHeight = useCallback(
|
||||
(proc: SystemProcessInfo) => (
|
||||
proc.pid === selectedPid
|
||||
? PROCESS_ROW_HEIGHT + PROCESS_DETAIL_HEIGHT
|
||||
: PROCESS_ROW_HEIGHT
|
||||
),
|
||||
[selectedPid],
|
||||
);
|
||||
|
||||
const renderItem = useCallback((proc: SystemProcessInfo) => (
|
||||
<ProcessRow
|
||||
proc={proc}
|
||||
selected={selectedPid === proc.pid}
|
||||
onToggle={onToggle}
|
||||
onSignal={onSignal}
|
||||
onRenice={onRenice}
|
||||
/>
|
||||
), [onRenice, onSignal, onToggle, selectedPid]);
|
||||
|
||||
return (
|
||||
<VariableSizeVirtualList<SystemProcessInfo>
|
||||
items={processes}
|
||||
getItemHeight={getItemHeight}
|
||||
className="flex-1 min-h-0"
|
||||
overscan={PROCESS_OVERSCAN_ROWS}
|
||||
getItemKey={(proc) => String(proc.pid)}
|
||||
renderItem={renderItem}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -170,14 +256,52 @@ export const ProcessManagerTab = memo(function ProcessManagerTab({
|
||||
const [selectedPid, setSelectedPid] = useState<number | null>(null);
|
||||
const [reniceTarget, setReniceTarget] = useState<number | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [cachedProcesses, setCachedProcesses] = useState<SystemProcessInfo[] | null>(() => getCachedProcesses(sessionId));
|
||||
const [cachedProcessesSessionId, setCachedProcessesSessionId] = useState(sessionId);
|
||||
const [processListPending, setProcessListPending] = useState(false);
|
||||
const processFetchGenerationRef = useRef(0);
|
||||
const currentSessionIdRef = useRef(sessionId);
|
||||
|
||||
if (currentSessionIdRef.current !== sessionId) {
|
||||
currentSessionIdRef.current = sessionId;
|
||||
processFetchGenerationRef.current += 1;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
processFetchGenerationRef.current += 1;
|
||||
setCachedProcesses(getCachedProcesses(sessionId));
|
||||
setCachedProcessesSessionId(sessionId);
|
||||
setProcessListPending(false);
|
||||
}, [sessionId]);
|
||||
|
||||
useEffect(() => () => {
|
||||
processFetchGenerationRef.current += 1;
|
||||
}, []);
|
||||
|
||||
const fetcher = useCallback(async () => {
|
||||
const result = await backend.listSystemProcesses(sessionId);
|
||||
if (result.pending) return null;
|
||||
if (!result.success || !result.processes) {
|
||||
throw new Error(result.error || stableT('systemManager.errors.loadProcesses'));
|
||||
const fetchGeneration = processFetchGenerationRef.current;
|
||||
const fetchSessionId = sessionId;
|
||||
const isCurrentFetch = () => (
|
||||
processFetchGenerationRef.current === fetchGeneration
|
||||
&& currentSessionIdRef.current === fetchSessionId
|
||||
);
|
||||
try {
|
||||
const result = await backend.listSystemProcesses(sessionId);
|
||||
if (!isCurrentFetch()) return null;
|
||||
if (result.pending) {
|
||||
setProcessListPending(true);
|
||||
return null;
|
||||
}
|
||||
setProcessListPending(false);
|
||||
if (!result.success || !result.processes) {
|
||||
throw new Error(result.error || stableT('systemManager.errors.loadProcesses'));
|
||||
}
|
||||
return result.processes;
|
||||
} catch (err) {
|
||||
if (!isCurrentFetch()) return null;
|
||||
setProcessListPending(false);
|
||||
throw err;
|
||||
}
|
||||
return result.processes;
|
||||
}, [backend, sessionId, stableT]);
|
||||
|
||||
const intervalMs = Math.max(2, refreshIntervalSec) * 1000;
|
||||
@@ -186,10 +310,24 @@ export const ProcessManagerTab = memo(function ProcessManagerTab({
|
||||
intervalMs,
|
||||
isVisible,
|
||||
mergeProcesses,
|
||||
{ resetKey: sessionId },
|
||||
);
|
||||
|
||||
const matched = useMemo(() => {
|
||||
const list = processes ?? [];
|
||||
useEffect(() => {
|
||||
if (!processes) return;
|
||||
processListCache.set(sessionId, { processes, updatedAt: Date.now() });
|
||||
setCachedProcesses(processes);
|
||||
setCachedProcessesSessionId(sessionId);
|
||||
}, [processes, sessionId]);
|
||||
|
||||
const sessionCachedProcesses = cachedProcessesSessionId === sessionId
|
||||
? cachedProcesses
|
||||
: getCachedProcesses(sessionId);
|
||||
const visibleProcesses = processes ?? sessionCachedProcesses;
|
||||
const showingCachedProcesses = processes === null && sessionCachedProcesses !== null;
|
||||
|
||||
const matched = useMemo<SystemProcessInfo[]>(() => {
|
||||
const list = visibleProcesses ?? [];
|
||||
const q = query.trim().toLowerCase();
|
||||
return list.filter((p) => {
|
||||
if (filter === 'running' && !isProcessRunning(p.stat)) return false;
|
||||
@@ -199,7 +337,7 @@ export const ProcessManagerTab = memo(function ProcessManagerTab({
|
||||
|| p.user.toLowerCase().includes(q)
|
||||
|| p.command.toLowerCase().includes(q);
|
||||
});
|
||||
}, [processes, query, filter]);
|
||||
}, [visibleProcesses, query, filter]);
|
||||
|
||||
const compareProcesses = useCallback((a: SystemProcessInfo, b: SystemProcessInfo) => {
|
||||
let cmp = 0;
|
||||
@@ -216,7 +354,16 @@ export const ProcessManagerTab = memo(function ProcessManagerTab({
|
||||
}, [sortAsc, sortKey]);
|
||||
|
||||
const sortToken = `${sortKey}|${sortAsc}|${filter}|${query}`;
|
||||
const displayList = useStableListOrder(matched, (p) => p.pid, sortToken, compareProcesses);
|
||||
const displayList = useStableListOrder<SystemProcessInfo, number>(
|
||||
matched,
|
||||
(p) => p.pid,
|
||||
sortToken,
|
||||
compareProcesses,
|
||||
);
|
||||
const isProcessRefreshActive = loading || processListPending;
|
||||
const showInitialLoading = isProcessRefreshActive && displayList.length === 0;
|
||||
const showBlockingError = Boolean(error && !isProcessRefreshActive && displayList.length === 0);
|
||||
const showInlineRefreshError = Boolean(error && !isProcessRefreshActive && displayList.length > 0);
|
||||
|
||||
const cycleSort = (key: SortKey) => {
|
||||
if (sortKey === key) setSortAsc((v) => !v);
|
||||
@@ -265,7 +412,7 @@ export const ProcessManagerTab = memo(function ProcessManagerTab({
|
||||
trailing={(
|
||||
<SystemPanelRefreshButton
|
||||
title={t('history.action.refresh')}
|
||||
loading={loading}
|
||||
loading={isProcessRefreshActive}
|
||||
onClick={() => void refresh()}
|
||||
/>
|
||||
)}
|
||||
@@ -305,29 +452,36 @@ export const ProcessManagerTab = memo(function ProcessManagerTab({
|
||||
))}
|
||||
</div>
|
||||
)}>
|
||||
{t('systemManager.processes.meta', { count: String(displayList.length) })}
|
||||
<span className={cn(showingCachedProcesses && isProcessRefreshActive && 'inline-flex items-center gap-1.5')}>
|
||||
{showingCachedProcesses && isProcessRefreshActive && <Loader2 size={10} className="animate-spin" />}
|
||||
{t('systemManager.processes.meta', { count: String(displayList.length) })}
|
||||
</span>
|
||||
</SystemPanelMetaBar>
|
||||
|
||||
{actionError && <SystemPanelInlineError message={actionError} />}
|
||||
{showInlineRefreshError && error && <SystemPanelInlineError message={error} />}
|
||||
|
||||
<SystemPanelList>
|
||||
{error && (
|
||||
<SystemPanelError message={error} onRetry={() => void refresh()} retryLabel={t('history.action.retry')} loading={loading} />
|
||||
)}
|
||||
{!error && displayList.length === 0 && !loading && (
|
||||
<SystemPanelEmpty icon={LayoutList} message={t('systemManager.empty')} />
|
||||
)}
|
||||
{displayList.map((proc) => (
|
||||
<ProcessRow
|
||||
key={proc.pid}
|
||||
proc={proc}
|
||||
selected={selectedPid === proc.pid}
|
||||
onToggle={togglePid}
|
||||
onSignal={signalProcess}
|
||||
onRenice={openRenicePrompt}
|
||||
/>
|
||||
))}
|
||||
</SystemPanelList>
|
||||
{(showBlockingError || showInitialLoading || (!error && displayList.length === 0 && !loading && !showInitialLoading)) ? (
|
||||
<SystemPanelList>
|
||||
{showBlockingError && error && (
|
||||
<SystemPanelError message={error} onRetry={() => void refresh()} retryLabel={t('history.action.retry')} loading={loading} />
|
||||
)}
|
||||
{showInitialLoading && (
|
||||
<ProcessListLoading message={t('systemManager.processes.loading')} />
|
||||
)}
|
||||
{!error && displayList.length === 0 && !loading && !showInitialLoading && (
|
||||
<SystemPanelEmpty icon={LayoutList} message={t('systemManager.empty')} />
|
||||
)}
|
||||
</SystemPanelList>
|
||||
) : (
|
||||
<ProcessVirtualList
|
||||
processes={displayList}
|
||||
selectedPid={selectedPid}
|
||||
onToggle={togglePid}
|
||||
onSignal={signalProcess}
|
||||
onRenice={openRenicePrompt}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SystemPanelPromptDialog
|
||||
open={reniceTarget !== null}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Activity, Box, LayoutList, TerminalSquare } from 'lucide-react';
|
||||
import { Activity, Box, LayoutList, Loader2, TerminalSquare } from 'lucide-react';
|
||||
import React, { memo, useMemo, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
|
||||
import type { TerminalSettings } from '../../domain/models';
|
||||
import type { Host } from '../../domain/models/connection';
|
||||
import type { SystemManagerSubTab } from '../../domain/systemManager/types';
|
||||
import { resolveCapabilityPanelState } from '../../domain/systemManagerPanelState';
|
||||
import { buildSystemManagerTabs } from '../../domain/systemManager/systemTarget';
|
||||
import type { Snippet, TerminalSession } from '../../types';
|
||||
import { cn } from '../../lib/utils';
|
||||
@@ -15,6 +16,19 @@ import { WorkspaceSidebarHostHeader } from '../terminalLayer/WorkspaceSidebarHos
|
||||
import { SystemPanelEmpty, SystemPanelShell } from './SystemPanelUi';
|
||||
import { useSessionCapabilities } from './hooks/useSystemManager';
|
||||
|
||||
const SystemPanelChecking = memo(function SystemPanelChecking({
|
||||
message,
|
||||
}: {
|
||||
message: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full min-h-[180px] flex-col items-center justify-center px-4 py-10 text-center text-xs text-muted-foreground">
|
||||
<Loader2 size={18} className="mb-2 animate-spin opacity-70" />
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface SystemManagerSidePanelProps {
|
||||
session: TerminalSession | null;
|
||||
sessionHost: Host | null;
|
||||
@@ -37,7 +51,9 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
const sessionId = session?.id ?? null;
|
||||
const isConnected = session?.status === 'connected';
|
||||
|
||||
const { capabilities, probing } = useSessionCapabilities(sessionId, isConnected, backend, isVisible);
|
||||
const capabilitiesTtlMs = terminalSettings.systemManagerProcessRefreshInterval * 1000;
|
||||
|
||||
const { capabilities, refreshCapabilities } = useSessionCapabilities(sessionId, isConnected, backend, isVisible, capabilitiesTtlMs);
|
||||
|
||||
const availableTabs = useMemo(
|
||||
() => buildSystemManagerTabs(sessionHost, capabilities, session),
|
||||
@@ -47,6 +63,69 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
const [activeTab, setActiveTab] = useState<SystemManagerSubTab>('processes');
|
||||
const resolvedTab = availableTabs.includes(activeTab) ? activeTab : 'processes';
|
||||
|
||||
// Must be defined before early returns to comply with React rules of hooks.
|
||||
const prevTabRef = React.useRef(resolvedTab);
|
||||
const probingRef = React.useRef(false);
|
||||
React.useEffect(() => {
|
||||
const prev = prevTabRef.current;
|
||||
prevTabRef.current = resolvedTab;
|
||||
if (prev === resolvedTab) return;
|
||||
if (resolvedTab === 'docker' && capabilities?.hasDocker !== true) {
|
||||
if (!probingRef.current) {
|
||||
probingRef.current = true;
|
||||
refreshCapabilities().finally(() => { probingRef.current = false; });
|
||||
}
|
||||
} else if (resolvedTab === 'tmux' && capabilities?.hasTmux !== true) {
|
||||
void refreshCapabilities();
|
||||
}
|
||||
}, [resolvedTab, capabilities, refreshCapabilities]);
|
||||
|
||||
// Auto-poll for Docker capabilities while Docker tab is active and Docker not yet detected.
|
||||
// Use setTimeout recursion so the next probe only starts after the previous one finishes,
|
||||
// avoiding overlapping probes (e.g. SSH timeout 8s vs user-configured interval 2s).
|
||||
// First poll is delayed by one interval to avoid overlapping with the tab-switch probe above.
|
||||
//
|
||||
// Use a ref to store refreshCapabilities so that if its reference changes on every render,
|
||||
// the useEffect below is NOT re-run (which would cancel the timer and bypass the interval).
|
||||
const refreshRef = React.useRef(refreshCapabilities);
|
||||
refreshRef.current = refreshCapabilities;
|
||||
|
||||
// Auto-poll for Docker capabilities while Docker tab is active and Docker not yet detected.
|
||||
// Each effect generation gets its own cancelled flag and timerId via closure,
|
||||
// preventing stale probes from surviving cleanup (unlike cancelledRef which is shared).
|
||||
// First poll is delayed by one interval to avoid overlapping with the tab-switch probe.
|
||||
React.useEffect(() => {
|
||||
if (!isVisible || resolvedTab !== 'docker' || capabilities?.hasDocker === true) return;
|
||||
|
||||
let cancelled = false;
|
||||
let timerId: ReturnType<typeof setTimeout>;
|
||||
|
||||
const pollOnce = async () => {
|
||||
if (cancelled) return;
|
||||
if (probingRef.current) {
|
||||
// probe is in-flight, reschedule for next cycle
|
||||
timerId = setTimeout(pollOnce, capabilitiesTtlMs);
|
||||
return;
|
||||
}
|
||||
probingRef.current = true;
|
||||
try {
|
||||
await refreshRef.current();
|
||||
} catch {
|
||||
// Transient error - keep polling next round
|
||||
}
|
||||
probingRef.current = false;
|
||||
if (cancelled) return;
|
||||
timerId = setTimeout(pollOnce, capabilitiesTtlMs);
|
||||
};
|
||||
|
||||
timerId = setTimeout(pollOnce, capabilitiesTtlMs);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timerId) clearTimeout(timerId);
|
||||
};
|
||||
}, [isVisible, resolvedTab, capabilities?.hasDocker, capabilitiesTtlMs]);
|
||||
|
||||
const workspaceHostHeader = showWorkspaceHostHeader && sessionHost ? (
|
||||
<WorkspaceSidebarHostHeader
|
||||
host={sessionHost}
|
||||
@@ -80,8 +159,16 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
|
||||
const tmuxReady = capabilities?.hasTmux === true;
|
||||
const dockerReady = capabilities?.hasDocker === true;
|
||||
const tmuxUnavailable = !probing && capabilities !== undefined && !tmuxReady;
|
||||
const dockerUnavailable = !probing && capabilities !== undefined && !dockerReady;
|
||||
const tmuxPanelState = resolveCapabilityPanelState({
|
||||
isActive: resolvedTab === 'tmux',
|
||||
ready: tmuxReady,
|
||||
capabilitiesKnown: capabilities !== undefined,
|
||||
});
|
||||
const dockerPanelState = resolveCapabilityPanelState({
|
||||
isActive: resolvedTab === 'docker',
|
||||
ready: dockerReady,
|
||||
capabilitiesKnown: capabilities !== undefined,
|
||||
});
|
||||
|
||||
return (
|
||||
<SystemPanelShell section="system-manager-panel">
|
||||
@@ -106,42 +193,56 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{resolvedTab === 'processes' && (
|
||||
<div className={cn('flex-1 min-h-0 flex flex-col', resolvedTab !== 'processes' && 'hidden')}>
|
||||
<ProcessManagerTab
|
||||
sessionId={sessionId}
|
||||
isVisible={isVisible}
|
||||
isVisible={isVisible && resolvedTab === 'processes'}
|
||||
backend={backend}
|
||||
refreshIntervalSec={terminalSettings.systemManagerProcessRefreshInterval}
|
||||
/>
|
||||
)}
|
||||
{resolvedTab === 'tmux' && (
|
||||
tmuxUnavailable ? (
|
||||
</div>
|
||||
{tmuxPanelState === 'unavailable' ? (
|
||||
<div className="flex-1 min-h-0">
|
||||
<SystemPanelEmpty icon={TerminalSquare} message={t('systemManager.tmux.unavailable')} />
|
||||
) : (
|
||||
</div>
|
||||
) : tmuxPanelState === 'checking' ? (
|
||||
<div className="flex-1 min-h-0">
|
||||
<SystemPanelChecking message={t('systemManager.common.checkingAvailability')} />
|
||||
</div>
|
||||
) : tmuxPanelState === 'ready' ? (
|
||||
<div className={cn('flex-1 min-h-0 flex flex-col', resolvedTab !== 'tmux' && 'hidden')}>
|
||||
<TmuxManagerTab
|
||||
sessionId={sessionId}
|
||||
parentSession={session}
|
||||
isVisible={isVisible && tmuxReady}
|
||||
isVisible={isVisible && resolvedTab === 'tmux'}
|
||||
warmupEnabled={isVisible && resolvedTab !== 'tmux'}
|
||||
backend={backend}
|
||||
refreshIntervalSec={terminalSettings.systemManagerTmuxRefreshInterval}
|
||||
snippets={snippets}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{resolvedTab === 'docker' && (
|
||||
dockerUnavailable ? (
|
||||
</div>
|
||||
) : null}
|
||||
{dockerPanelState === 'unavailable' ? (
|
||||
<div className="flex-1 min-h-0">
|
||||
<SystemPanelEmpty icon={Box} message={t('systemManager.docker.unavailable')} />
|
||||
) : (
|
||||
</div>
|
||||
) : dockerPanelState === 'checking' ? (
|
||||
<div className="flex-1 min-h-0">
|
||||
<SystemPanelChecking message={t('systemManager.common.checkingAvailability')} />
|
||||
</div>
|
||||
) : dockerPanelState === 'ready' ? (
|
||||
<div className={cn('flex-1 min-h-0 flex flex-col', resolvedTab !== 'docker' && 'hidden')}>
|
||||
<DockerManagerTab
|
||||
sessionId={sessionId}
|
||||
parentSession={session}
|
||||
isVisible={isVisible && dockerReady}
|
||||
isVisible={isVisible && resolvedTab === 'docker'}
|
||||
warmupEnabled={isVisible && resolvedTab !== 'docker'}
|
||||
backend={backend}
|
||||
listRefreshIntervalSec={terminalSettings.systemManagerDockerListRefreshInterval}
|
||||
statsRefreshIntervalSec={terminalSettings.systemManagerDockerStatsRefreshInterval}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</SystemPanelShell>
|
||||
);
|
||||
|
||||
@@ -203,6 +203,19 @@ export const SystemPanelEmpty = memo(function SystemPanelEmpty({
|
||||
);
|
||||
});
|
||||
|
||||
export const SystemPanelLoading = memo(function SystemPanelLoading({
|
||||
message,
|
||||
}: {
|
||||
message: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-[180px] flex-col items-center justify-center px-4 py-10 text-center text-xs text-muted-foreground">
|
||||
<Loader2 size={18} className="mb-2 animate-spin opacity-70" />
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const SystemPanelError = memo(function SystemPanelError({
|
||||
message,
|
||||
onRetry,
|
||||
@@ -270,6 +283,7 @@ export const SystemPanelRow = memo(function SystemPanelRow({
|
||||
subtitle,
|
||||
trailing,
|
||||
actions,
|
||||
className,
|
||||
}: {
|
||||
selected?: boolean;
|
||||
onClick?: () => void;
|
||||
@@ -279,6 +293,7 @@ export const SystemPanelRow = memo(function SystemPanelRow({
|
||||
subtitle?: ReactNode;
|
||||
trailing?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const content = (
|
||||
<>
|
||||
@@ -292,7 +307,7 @@ export const SystemPanelRow = memo(function SystemPanelRow({
|
||||
{trailing}
|
||||
{actions && (
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-end gap-0.5 invisible group-hover:visible group-focus-within:visible"
|
||||
className="flex shrink-0 items-center justify-end gap-0.5"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{actions}
|
||||
@@ -301,10 +316,11 @@ export const SystemPanelRow = memo(function SystemPanelRow({
|
||||
</>
|
||||
);
|
||||
|
||||
const className = cn(
|
||||
const rowClassName = cn(
|
||||
'group flex items-center gap-2.5 pr-2.5 py-2.5 min-h-[44px] border-b border-border/30',
|
||||
selected && 'bg-accent/30',
|
||||
onClick && 'cursor-pointer hover:bg-accent/50',
|
||||
className,
|
||||
);
|
||||
const style = { paddingLeft: 12 + depth * 14 };
|
||||
|
||||
@@ -315,7 +331,7 @@ export const SystemPanelRow = memo(function SystemPanelRow({
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={cn('w-full text-left', className)}
|
||||
className={cn('w-full text-left', rowClassName)}
|
||||
style={style}
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => {
|
||||
@@ -332,7 +348,7 @@ export const SystemPanelRow = memo(function SystemPanelRow({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className} style={style}>
|
||||
<div className={rowClassName} style={style}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import { Plus, TerminalSquare } from 'lucide-react';
|
||||
import React, { memo, useCallback, useMemo, useState } from 'react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
|
||||
import type { Snippet, TerminalSession } from '../../types';
|
||||
import type { TmuxSessionInfo } from '../../domain/systemManager/types';
|
||||
import type { TmuxClientInfo, TmuxSessionInfo, TmuxWindowInfo } from '../../domain/systemManager/types';
|
||||
import { tmuxSessionInfoEqual } from '../../domain/systemManager/pollEquals';
|
||||
import {
|
||||
SystemPanelEmpty,
|
||||
SystemPanelError,
|
||||
SystemPanelIconButton,
|
||||
SystemPanelList,
|
||||
SystemPanelLoading,
|
||||
SystemPanelMetaBar,
|
||||
SystemPanelRefreshButton,
|
||||
SystemPanelSearch,
|
||||
SystemPanelShell,
|
||||
SystemPanelToolbar,
|
||||
} from './SystemPanelUi';
|
||||
import { useAsyncRecordCache } from './hooks/useAsyncRecordCache';
|
||||
import { usePolling, useStableTranslate } from './hooks/useSystemManager';
|
||||
import { TmuxNewSessionModal } from './TmuxNewSessionModal';
|
||||
import { TmuxSessionCard } from './TmuxSessionCard';
|
||||
@@ -23,10 +25,16 @@ import { useStableListOrder, mergePollListByKey } from './listStable';
|
||||
|
||||
type Backend = ReturnType<typeof useSystemManagerBackend>;
|
||||
|
||||
export interface TmuxSessionDetails {
|
||||
windows: TmuxWindowInfo[];
|
||||
clients: TmuxClientInfo[];
|
||||
}
|
||||
|
||||
interface TmuxManagerTabProps {
|
||||
sessionId: string;
|
||||
parentSession: TerminalSession;
|
||||
isVisible: boolean;
|
||||
warmupEnabled?: boolean;
|
||||
backend: Backend;
|
||||
refreshIntervalSec: number;
|
||||
snippets: Snippet[];
|
||||
@@ -36,6 +44,7 @@ export const TmuxManagerTab = memo(function TmuxManagerTab({
|
||||
sessionId,
|
||||
parentSession,
|
||||
isVisible,
|
||||
warmupEnabled = false,
|
||||
backend,
|
||||
refreshIntervalSec,
|
||||
snippets,
|
||||
@@ -48,11 +57,20 @@ export const TmuxManagerTab = memo(function TmuxManagerTab({
|
||||
const [modalError, setModalError] = useState<string | null>(null);
|
||||
|
||||
const [tmuxVersion, setTmuxVersion] = useState<string | null>(null);
|
||||
const currentSessionIdRef = useRef(sessionId);
|
||||
currentSessionIdRef.current = sessionId;
|
||||
|
||||
useEffect(() => {
|
||||
setTmuxVersion(null);
|
||||
}, [sessionId]);
|
||||
|
||||
const fetcher = useCallback(async () => {
|
||||
const fetchSessionId = sessionId;
|
||||
const result = await backend.listTmuxSessions(sessionId);
|
||||
const version = result.tmuxVersion ?? null;
|
||||
setTmuxVersion((prev) => (prev === version ? prev : version));
|
||||
if (currentSessionIdRef.current === fetchSessionId) {
|
||||
setTmuxVersion((prev) => (prev === version ? prev : version));
|
||||
}
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || stableT('systemManager.errors.loadTmux'));
|
||||
}
|
||||
@@ -63,11 +81,12 @@ export const TmuxManagerTab = memo(function TmuxManagerTab({
|
||||
const { data: sessions, error, loading, refresh } = usePolling<TmuxSessionInfo[]>(
|
||||
fetcher,
|
||||
intervalMs,
|
||||
isVisible,
|
||||
isVisible || warmupEnabled,
|
||||
(prev, next) => mergePollListByKey(prev, next, (s) => s.name, tmuxSessionInfoEqual),
|
||||
{ poll: isVisible, resetKey: sessionId },
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const filtered = useMemo<TmuxSessionInfo[]>(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
const list = sessions ?? [];
|
||||
if (!q) return list;
|
||||
@@ -78,13 +97,69 @@ export const TmuxManagerTab = memo(function TmuxManagerTab({
|
||||
(a: TmuxSessionInfo, b: TmuxSessionInfo) => a.name.localeCompare(b.name),
|
||||
[],
|
||||
);
|
||||
const displaySessions = useStableListOrder(
|
||||
const displaySessions = useStableListOrder<TmuxSessionInfo, string>(
|
||||
filtered,
|
||||
(s) => s.name,
|
||||
query,
|
||||
compareSessions,
|
||||
);
|
||||
|
||||
const formatTmuxLoadError = useCallback((
|
||||
message: string,
|
||||
debug?: { lastOutput?: string; tried?: string[] },
|
||||
) => {
|
||||
const parts = [message];
|
||||
if (debug?.lastOutput) parts.push(debug.lastOutput);
|
||||
if (debug?.tried?.length) {
|
||||
parts.push(t('systemManager.tmux.lastCommand', { command: debug.tried[debug.tried.length - 1] ?? '' }));
|
||||
}
|
||||
return parts.filter(Boolean).join(' · ');
|
||||
}, [t]);
|
||||
|
||||
const getTmuxDetailsKey = useCallback((session: TmuxSessionInfo) => (
|
||||
`${sessionId}:${session.name}:${session.created}`
|
||||
), [sessionId]);
|
||||
const fetchTmuxDetails = useCallback(async (session: TmuxSessionInfo): Promise<TmuxSessionDetails> => {
|
||||
const [windowsResult, clientsResult] = await Promise.all([
|
||||
backend.listTmuxWindows({ sessionId, sessionName: session.name }),
|
||||
backend.listTmuxClients({ sessionId, sessionName: session.name }),
|
||||
]);
|
||||
if (!windowsResult.success) {
|
||||
throw new Error(formatTmuxLoadError(
|
||||
windowsResult.error || stableT('systemManager.errors.loadTmuxWindows'),
|
||||
windowsResult.debug,
|
||||
));
|
||||
}
|
||||
if (!clientsResult.success) {
|
||||
throw new Error(clientsResult.error || stableT('systemManager.errors.loadTmuxClients'));
|
||||
}
|
||||
const freshWindows = windowsResult.windows ?? [];
|
||||
if (freshWindows.length === 0 && session.windows > 0) {
|
||||
throw new Error(formatTmuxLoadError(
|
||||
stableT('systemManager.tmux.windowsMismatch', { count: String(session.windows) }),
|
||||
windowsResult.debug,
|
||||
));
|
||||
}
|
||||
return {
|
||||
windows: freshWindows,
|
||||
clients: clientsResult.clients ?? [],
|
||||
};
|
||||
}, [backend, formatTmuxLoadError, sessionId, stableT]);
|
||||
|
||||
const {
|
||||
records: tmuxDetailsByName,
|
||||
loadRecord: loadTmuxDetails,
|
||||
refreshRecord: refreshTmuxDetails,
|
||||
} = useAsyncRecordCache<TmuxSessionInfo, TmuxSessionDetails>({
|
||||
items: sessions ?? [],
|
||||
enabled: isVisible && (sessions?.length ?? 0) > 0,
|
||||
getKey: getTmuxDetailsKey,
|
||||
fetchRecord: fetchTmuxDetails,
|
||||
prefetchLimit: 16,
|
||||
prefetchDelayMs: 40,
|
||||
staleTimeMs: 20_000,
|
||||
});
|
||||
|
||||
const handleCreate = useCallback(async (name: string, command: string) => {
|
||||
setCreating(true);
|
||||
setModalError(null);
|
||||
@@ -140,6 +215,9 @@ export const TmuxManagerTab = memo(function TmuxManagerTab({
|
||||
</SystemPanelMetaBar>
|
||||
|
||||
<SystemPanelList>
|
||||
{!error && displaySessions.length === 0 && loading && (
|
||||
<SystemPanelLoading message={t('systemManager.common.loading')} />
|
||||
)}
|
||||
{!error && displaySessions.length === 0 && !loading && (
|
||||
<SystemPanelEmpty icon={TerminalSquare} message={t('systemManager.tmux.empty')} />
|
||||
)}
|
||||
@@ -148,11 +226,14 @@ export const TmuxManagerTab = memo(function TmuxManagerTab({
|
||||
)}
|
||||
{displaySessions.map((session) => (
|
||||
<TmuxSessionCard
|
||||
key={session.name}
|
||||
key={`${session.name}:${session.created}`}
|
||||
session={session}
|
||||
sessionId={sessionId}
|
||||
parentSession={parentSession}
|
||||
backend={backend}
|
||||
detailsRecord={tmuxDetailsByName[getTmuxDetailsKey(session)]}
|
||||
onLoadDetails={loadTmuxDetails}
|
||||
onRefreshDetails={refreshTmuxDetails}
|
||||
onSessionsChanged={refresh}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import {
|
||||
Loader2, MonitorPlay, Pencil, Plus, Trash2, Unplug,
|
||||
} from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useState } from 'react';
|
||||
import React, { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
|
||||
import { buildTmuxAttachCommand } from '../../domain/systemManager/tmuxShell';
|
||||
import type {
|
||||
TmuxClientInfo,
|
||||
TmuxManageAction,
|
||||
TmuxSessionInfo,
|
||||
TmuxWindowInfo,
|
||||
} from '../../domain/systemManager/types';
|
||||
import type { TerminalSession } from '../../types';
|
||||
import type { AsyncRecordState } from './hooks/useAsyncRecordCache';
|
||||
import type { TmuxSessionDetails } from './TmuxManagerTab';
|
||||
import {
|
||||
SystemPanelCollapsible,
|
||||
SystemPanelDetailStrip,
|
||||
@@ -46,6 +46,9 @@ interface TmuxSessionCardProps {
|
||||
sessionId: string;
|
||||
parentSession: TerminalSession;
|
||||
backend: Backend;
|
||||
detailsRecord?: AsyncRecordState<TmuxSessionDetails>;
|
||||
onLoadDetails: (session: TmuxSessionInfo, options?: { force?: boolean; urgent?: boolean }) => Promise<void>;
|
||||
onRefreshDetails: (session: TmuxSessionInfo) => Promise<void>;
|
||||
onSessionsChanged: () => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -54,74 +57,42 @@ export const TmuxSessionCard = memo(function TmuxSessionCard({
|
||||
sessionId,
|
||||
parentSession,
|
||||
backend,
|
||||
detailsRecord,
|
||||
onLoadDetails,
|
||||
onRefreshDetails,
|
||||
onSessionsChanged,
|
||||
}: TmuxSessionCardProps) {
|
||||
const { t } = useI18n();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [loadingDetails, setLoadingDetails] = useState(false);
|
||||
const [windows, setWindows] = useState<TmuxWindowInfo[]>([]);
|
||||
const [clients, setClients] = useState<TmuxClientInfo[]>([]);
|
||||
const [renamePrompt, setRenamePrompt] = useState<RenamePromptTarget | null>(null);
|
||||
const [newWindowOpen, setNewWindowOpen] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [windowsLoadDetail, setWindowsLoadDetail] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [pending, setPending] = useState<PendingTarget | null>(null);
|
||||
|
||||
const formatTmuxLoadError = useCallback((
|
||||
message: string,
|
||||
debug?: { lastOutput?: string; tried?: string[] },
|
||||
) => {
|
||||
const parts = [message];
|
||||
if (debug?.lastOutput) parts.push(debug.lastOutput);
|
||||
if (debug?.tried?.length) {
|
||||
parts.push(t('systemManager.tmux.lastCommand', { command: debug.tried[debug.tried.length - 1] ?? '' }));
|
||||
}
|
||||
return parts.filter(Boolean).join(' · ');
|
||||
}, [t]);
|
||||
|
||||
const loadDetails = useCallback(async (): Promise<TmuxWindowInfo[] | null> => {
|
||||
setLoadingDetails(true);
|
||||
setActionError(null);
|
||||
setWindowsLoadDetail(null);
|
||||
try {
|
||||
const [windowsResult, clientsResult] = await Promise.all([
|
||||
backend.listTmuxWindows({ sessionId, sessionName: session.name }),
|
||||
backend.listTmuxClients({ sessionId, sessionName: session.name }),
|
||||
]);
|
||||
if (!windowsResult.success) {
|
||||
const detail = formatTmuxLoadError(
|
||||
windowsResult.error || t('systemManager.errors.loadTmuxWindows'),
|
||||
windowsResult.debug,
|
||||
);
|
||||
setWindowsLoadDetail(detail);
|
||||
throw new Error(detail);
|
||||
}
|
||||
if (!clientsResult.success) throw new Error(clientsResult.error || t('systemManager.errors.loadTmuxClients'));
|
||||
const freshWindows = windowsResult.windows ?? [];
|
||||
if (freshWindows.length === 0 && session.windows > 0) {
|
||||
const detail = formatTmuxLoadError(
|
||||
t('systemManager.tmux.windowsMismatch', { count: String(session.windows) }),
|
||||
windowsResult.debug,
|
||||
);
|
||||
setWindowsLoadDetail(detail);
|
||||
throw new Error(detail);
|
||||
}
|
||||
setWindows(freshWindows);
|
||||
setClients(clientsResult.clients ?? []);
|
||||
return freshWindows;
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : t('systemManager.errors.actionFailed'));
|
||||
setWindows([]);
|
||||
return null;
|
||||
} finally {
|
||||
setLoadingDetails(false);
|
||||
}
|
||||
}, [backend, formatTmuxLoadError, session.name, session.windows, sessionId, t]);
|
||||
const windows = detailsRecord?.data?.windows ?? [];
|
||||
const clients = detailsRecord?.data?.clients ?? [];
|
||||
const loadingDetails = detailsRecord?.loading ?? false;
|
||||
const windowsLoadDetail = detailsRecord?.error ?? null;
|
||||
const summaryKey = useMemo(
|
||||
() => `${session.name}|${session.created}|${session.windows}|${session.attached}|${session.activity ?? ''}`,
|
||||
[session.activity, session.attached, session.created, session.name, session.windows],
|
||||
);
|
||||
const lastExpandedSummaryKeyRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (expanded) void loadDetails();
|
||||
}, [expanded, loadDetails]);
|
||||
if (!expanded) {
|
||||
lastExpandedSummaryKeyRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (lastExpandedSummaryKeyRef.current === null) {
|
||||
lastExpandedSummaryKeyRef.current = summaryKey;
|
||||
return;
|
||||
}
|
||||
if (lastExpandedSummaryKeyRef.current === summaryKey) return;
|
||||
lastExpandedSummaryKeyRef.current = summaryKey;
|
||||
void onRefreshDetails(session);
|
||||
}, [expanded, onRefreshDetails, session, summaryKey]);
|
||||
|
||||
const runAction = async (action: TmuxManageAction) => {
|
||||
setBusy(true);
|
||||
@@ -135,7 +106,7 @@ export const TmuxSessionCard = memo(function TmuxSessionCard({
|
||||
if (!result.success) throw new Error(result.error || t('systemManager.errors.actionFailed'));
|
||||
const cardWillRemount = action.action === 'killSession' || action.action === 'renameSession';
|
||||
if (!cardWillRemount && expanded) {
|
||||
await loadDetails();
|
||||
await onRefreshDetails(session);
|
||||
}
|
||||
await onSessionsChanged();
|
||||
} catch (err) {
|
||||
@@ -170,7 +141,13 @@ export const TmuxSessionCard = memo(function TmuxSessionCard({
|
||||
<>
|
||||
<SystemPanelRow
|
||||
selected={expanded}
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
onClick={() => {
|
||||
const nextExpanded = !expanded;
|
||||
setExpanded(nextExpanded);
|
||||
if (nextExpanded) {
|
||||
void onLoadDetails(session, { force: true, urgent: true });
|
||||
}
|
||||
}}
|
||||
title={session.name}
|
||||
subtitle={t('systemManager.tmux.windows', { count: String(session.windows) })}
|
||||
trailing={(
|
||||
|
||||
269
components/systemManager/hooks/useAsyncRecordCache.ts
Normal file
269
components/systemManager/hooks/useAsyncRecordCache.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import { startTransition, useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
export interface AsyncRecordState<T> {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
updatedAt: number | null;
|
||||
}
|
||||
|
||||
type RecordMap<T> = Record<string, AsyncRecordState<T>>;
|
||||
|
||||
interface UseAsyncRecordCacheOptions<TItem, TValue> {
|
||||
items: TItem[];
|
||||
enabled: boolean;
|
||||
getKey: (item: TItem) => string;
|
||||
fetchRecord: (item: TItem) => Promise<TValue | null>;
|
||||
prefetchLimit?: number;
|
||||
prefetchDelayMs?: number;
|
||||
staleTimeMs?: number;
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function scheduleIdleTask(callback: () => void): () => void {
|
||||
if (typeof window.requestIdleCallback === 'function') {
|
||||
const id = window.requestIdleCallback(callback, { timeout: 1200 });
|
||||
return () => window.cancelIdleCallback(id);
|
||||
}
|
||||
|
||||
const id = window.setTimeout(callback, 80);
|
||||
return () => window.clearTimeout(id);
|
||||
}
|
||||
|
||||
function normalizeRecordError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error || 'Unknown error');
|
||||
}
|
||||
|
||||
function isRecordFresh<TValue>(record: AsyncRecordState<TValue> | undefined, staleTimeMs: number): boolean {
|
||||
if (!record || record.error || record.updatedAt === null) return false;
|
||||
if (!Number.isFinite(staleTimeMs)) return true;
|
||||
return Date.now() - record.updatedAt < staleTimeMs;
|
||||
}
|
||||
|
||||
const EMPTY_RECORDS = {};
|
||||
|
||||
export function useAsyncRecordCache<TItem, TValue>({
|
||||
items,
|
||||
enabled,
|
||||
getKey,
|
||||
fetchRecord,
|
||||
prefetchLimit = 64,
|
||||
prefetchDelayMs = 16,
|
||||
staleTimeMs = 30_000,
|
||||
}: UseAsyncRecordCacheOptions<TItem, TValue>) {
|
||||
const [records, setRecords] = useState<RecordMap<TValue>>(() => EMPTY_RECORDS);
|
||||
const recordsRef = useRef<RecordMap<TValue>>(records);
|
||||
const enabledRef = useRef(enabled);
|
||||
const inflightRef = useRef(new Set<string>());
|
||||
const requestVersionRef = useRef(new Map<string, number>());
|
||||
const queuedForceRef = useRef(new Set<string>());
|
||||
const loadRecordRef = useRef<(
|
||||
item: TItem,
|
||||
options?: { force?: boolean; urgent?: boolean },
|
||||
) => Promise<void>>(async () => {});
|
||||
|
||||
recordsRef.current = records;
|
||||
enabledRef.current = enabled;
|
||||
|
||||
const commitRecords = useCallback((
|
||||
updater: (prev: RecordMap<TValue>) => RecordMap<TValue>,
|
||||
urgent = false,
|
||||
) => {
|
||||
const apply = () => {
|
||||
setRecords((prev) => {
|
||||
const next = updater(prev);
|
||||
recordsRef.current = next;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (urgent) {
|
||||
apply();
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(apply);
|
||||
}, []);
|
||||
|
||||
const loadRecord = useCallback(async (
|
||||
item: TItem,
|
||||
options?: { force?: boolean; urgent?: boolean },
|
||||
) => {
|
||||
if (!enabledRef.current) return;
|
||||
const key = getKey(item);
|
||||
if (!key) return;
|
||||
if (inflightRef.current.has(key)) {
|
||||
if (options?.force) {
|
||||
queuedForceRef.current.add(key);
|
||||
requestVersionRef.current.set(key, (requestVersionRef.current.get(key) ?? 0) + 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = recordsRef.current[key];
|
||||
if (!options?.force && isRecordFresh(existing, staleTimeMs)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestVersion = (requestVersionRef.current.get(key) ?? 0) + 1;
|
||||
requestVersionRef.current.set(key, requestVersion);
|
||||
inflightRef.current.add(key);
|
||||
commitRecords((prev) => ({
|
||||
...prev,
|
||||
[key]: {
|
||||
data: prev[key]?.data ?? null,
|
||||
loading: true,
|
||||
error: null,
|
||||
updatedAt: prev[key]?.updatedAt ?? null,
|
||||
},
|
||||
}), options?.urgent);
|
||||
|
||||
try {
|
||||
const data = await fetchRecord(item);
|
||||
if (requestVersionRef.current.get(key) !== requestVersion) return;
|
||||
commitRecords((prev) => ({
|
||||
...prev,
|
||||
[key]: {
|
||||
data,
|
||||
loading: false,
|
||||
error: null,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
if (requestVersionRef.current.get(key) !== requestVersion) return;
|
||||
commitRecords((prev) => ({
|
||||
...prev,
|
||||
[key]: {
|
||||
data: prev[key]?.data ?? null,
|
||||
loading: false,
|
||||
error: normalizeRecordError(error),
|
||||
updatedAt: prev[key]?.updatedAt ?? null,
|
||||
},
|
||||
}));
|
||||
} finally {
|
||||
inflightRef.current.delete(key);
|
||||
if (queuedForceRef.current.has(key)) {
|
||||
if (enabledRef.current) {
|
||||
queuedForceRef.current.delete(key);
|
||||
void loadRecordRef.current(item, { force: true, urgent: options?.urgent });
|
||||
} else {
|
||||
commitRecords((prev) => {
|
||||
const current = prev[key];
|
||||
if (!current?.loading) return prev;
|
||||
return {
|
||||
...prev,
|
||||
[key]: {
|
||||
...current,
|
||||
loading: false,
|
||||
},
|
||||
};
|
||||
}, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [commitRecords, fetchRecord, getKey, staleTimeMs]);
|
||||
|
||||
loadRecordRef.current = loadRecord;
|
||||
|
||||
useEffect(() => {
|
||||
const itemKeys = new Set(items.map(getKey).filter(Boolean));
|
||||
for (const key of queuedForceRef.current) {
|
||||
if (!itemKeys.has(key)) {
|
||||
queuedForceRef.current.delete(key);
|
||||
}
|
||||
}
|
||||
commitRecords((prev) => {
|
||||
let changed = false;
|
||||
const next: RecordMap<TValue> = {};
|
||||
for (const [key, value] of Object.entries(prev) as Array<[string, AsyncRecordState<TValue>]>) {
|
||||
if (!itemKeys.has(key)) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
next[key] = value;
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [commitRecords, getKey, items]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || queuedForceRef.current.size === 0) return;
|
||||
for (const item of items) {
|
||||
const key = getKey(item);
|
||||
if (!key || !queuedForceRef.current.has(key)) continue;
|
||||
queuedForceRef.current.delete(key);
|
||||
void loadRecord(item, { force: true, urgent: true });
|
||||
}
|
||||
}, [enabled, getKey, items, loadRecord]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || items.length === 0 || prefetchLimit <= 0) return undefined;
|
||||
|
||||
let cancelled = false;
|
||||
const candidates = items.slice(0, prefetchLimit);
|
||||
const cancelIdleTask = scheduleIdleTask(() => {
|
||||
void (async () => {
|
||||
for (const item of candidates) {
|
||||
if (cancelled) return;
|
||||
await loadRecord(item);
|
||||
if (prefetchDelayMs > 0) {
|
||||
await delay(prefetchDelayMs);
|
||||
}
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelIdleTask();
|
||||
};
|
||||
}, [enabled, items, loadRecord, prefetchDelayMs, prefetchLimit]);
|
||||
|
||||
const invalidateRecord = useCallback((key: string) => {
|
||||
requestVersionRef.current.set(key, (requestVersionRef.current.get(key) ?? 0) + 1);
|
||||
queuedForceRef.current.delete(key);
|
||||
commitRecords((prev) => {
|
||||
if (!(key in prev)) return prev;
|
||||
const { [key]: _removed, ...next } = prev;
|
||||
return next;
|
||||
}, true);
|
||||
}, [commitRecords]);
|
||||
|
||||
const invalidateMatching = useCallback((matches: (key: string) => boolean) => {
|
||||
for (const key of requestVersionRef.current.keys()) {
|
||||
if (matches(key)) {
|
||||
requestVersionRef.current.set(key, (requestVersionRef.current.get(key) ?? 0) + 1);
|
||||
queuedForceRef.current.delete(key);
|
||||
}
|
||||
}
|
||||
commitRecords((prev) => {
|
||||
let changed = false;
|
||||
const next: RecordMap<TValue> = {};
|
||||
for (const [key, value] of Object.entries(prev) as Array<[string, AsyncRecordState<TValue>]>) {
|
||||
if (matches(key)) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
next[key] = value;
|
||||
}
|
||||
return changed ? next : prev;
|
||||
}, true);
|
||||
}, [commitRecords]);
|
||||
|
||||
const refreshRecord = useCallback(
|
||||
(item: TItem) => loadRecord(item, { force: true, urgent: true }),
|
||||
[loadRecord],
|
||||
);
|
||||
|
||||
return {
|
||||
records,
|
||||
loadRecord,
|
||||
refreshRecord,
|
||||
invalidateRecord,
|
||||
invalidateMatching,
|
||||
};
|
||||
}
|
||||
@@ -37,7 +37,11 @@ export function useSessionCapabilities(
|
||||
isConnected: boolean,
|
||||
backend: Backend,
|
||||
enabled: boolean,
|
||||
capabilitiesTtlMs: number,
|
||||
) {
|
||||
const ttlMsRef = useRef(capabilitiesTtlMs);
|
||||
ttlMsRef.current = capabilitiesTtlMs;
|
||||
|
||||
const [capabilities, setCapabilities] = useState<SessionCapabilities | undefined>(
|
||||
() => (sessionId ? sessionCapabilitiesStore.get(sessionId) : undefined),
|
||||
);
|
||||
@@ -63,7 +67,7 @@ export function useSessionCapabilities(
|
||||
try {
|
||||
const result = await backend.probeSystemCapabilities(sessionId);
|
||||
if (result.success && result.capabilities) {
|
||||
sessionCapabilitiesStore.set(sessionId, result.capabilities);
|
||||
sessionCapabilitiesStore.set(sessionId, result.capabilities, ttlMsRef.current);
|
||||
}
|
||||
} finally {
|
||||
setProbing(false);
|
||||
@@ -84,10 +88,13 @@ export function useSystemCapabilitiesWarmup(
|
||||
sessionIds: string[],
|
||||
backend: Backend,
|
||||
enabled: boolean,
|
||||
capabilitiesTtlMs: number,
|
||||
) {
|
||||
const backendRef = useRef(backend);
|
||||
backendRef.current = backend;
|
||||
const inflightRef = useRef(new Set<string>());
|
||||
const ttlMsRef = useRef(capabilitiesTtlMs);
|
||||
ttlMsRef.current = capabilitiesTtlMs;
|
||||
|
||||
const sessionKey = enabled ? sessionIds.slice().sort().join(',') : '';
|
||||
|
||||
@@ -100,7 +107,7 @@ export function useSystemCapabilitiesWarmup(
|
||||
void backendRef.current.probeSystemCapabilities(sessionId).then((result) => {
|
||||
inflightRef.current.delete(sessionId);
|
||||
if (result.success && result.capabilities) {
|
||||
sessionCapabilitiesStore.set(sessionId, result.capabilities);
|
||||
sessionCapabilitiesStore.set(sessionId, result.capabilities, ttlMsRef.current);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -113,20 +120,37 @@ export function usePolling<T>(
|
||||
intervalMs: number,
|
||||
enabled: boolean,
|
||||
merge?: (prev: T | null, next: T) => T,
|
||||
options?: { poll?: boolean; resetKey?: string },
|
||||
) {
|
||||
const stableT = useStableTranslate();
|
||||
const resetKey = options?.resetKey ?? '';
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [dataKey, setDataKey] = useState(resetKey);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [errorKey, setErrorKey] = useState(resetKey);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingKey, setLoadingKey] = useState(resetKey);
|
||||
const failuresRef = useRef(0);
|
||||
const hasDataRef = useRef(false);
|
||||
const inflightRef = useRef(false);
|
||||
const enabledRef = useRef(enabled);
|
||||
const generationRef = useRef(0);
|
||||
const runIdRef = useRef(0);
|
||||
const loadingRunIdRef = useRef(0);
|
||||
const inflightRef = useRef<{ generation: number; runId: number } | null>(null);
|
||||
const queuedRunRef = useRef<{
|
||||
options?: { withLoading?: boolean; minLoadingMs?: number };
|
||||
resolve: () => void;
|
||||
} | null>(null);
|
||||
const fetcherRef = useRef(fetcher);
|
||||
const mergeRef = useRef(merge);
|
||||
const pollRef = useRef(options?.poll ?? true);
|
||||
const resetKeyRef = useRef(resetKey);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
enabledRef.current = enabled;
|
||||
fetcherRef.current = fetcher;
|
||||
mergeRef.current = merge;
|
||||
pollRef.current = options?.poll ?? true;
|
||||
|
||||
const clearPollTimer = useCallback(() => {
|
||||
if (timerRef.current !== null) {
|
||||
@@ -140,70 +164,163 @@ export function usePolling<T>(
|
||||
return intervalMs;
|
||||
}, [intervalMs]);
|
||||
|
||||
const resolveQueuedRun = useCallback(() => {
|
||||
queuedRunRef.current?.resolve();
|
||||
queuedRunRef.current = null;
|
||||
}, []);
|
||||
|
||||
const run = useCallback(async (options?: { withLoading?: boolean; minLoadingMs?: number }) => {
|
||||
if (!enabled || inflightRef.current) return;
|
||||
inflightRef.current = true;
|
||||
const generation = generationRef.current;
|
||||
const runResetKey = resetKeyRef.current;
|
||||
if (!enabledRef.current) return;
|
||||
if (inflightRef.current?.generation === generation) {
|
||||
if (options?.withLoading) {
|
||||
queuedRunRef.current?.resolve();
|
||||
loadingRunIdRef.current = 0;
|
||||
setLoadingKey(runResetKey);
|
||||
setLoading(true);
|
||||
return new Promise<void>((resolve) => {
|
||||
queuedRunRef.current = { options, resolve };
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
const runId = ++runIdRef.current;
|
||||
inflightRef.current = { generation, runId };
|
||||
const showLoading = options?.withLoading ?? !hasDataRef.current;
|
||||
const startedAt = Date.now();
|
||||
if (showLoading) setLoading(true);
|
||||
const isCurrent = () => (
|
||||
generationRef.current === generation
|
||||
&& enabledRef.current
|
||||
&& inflightRef.current?.runId === runId
|
||||
&& resetKeyRef.current === runResetKey
|
||||
);
|
||||
if (showLoading) {
|
||||
loadingRunIdRef.current = runId;
|
||||
setLoadingKey(runResetKey);
|
||||
setLoading(true);
|
||||
}
|
||||
try {
|
||||
const result = await fetcherRef.current();
|
||||
if (!isCurrent()) return;
|
||||
if (result !== null) {
|
||||
setDataKey(runResetKey);
|
||||
setData((prev) => {
|
||||
const mergeFn = mergeRef.current;
|
||||
const next = mergeFn ? mergeFn(prev, result) : nextPollData(prev, result);
|
||||
if (next !== prev) hasDataRef.current = true;
|
||||
return next;
|
||||
});
|
||||
setErrorKey(runResetKey);
|
||||
setError(null);
|
||||
failuresRef.current = 0;
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isCurrent()) return;
|
||||
failuresRef.current += 1;
|
||||
setDataKey(runResetKey);
|
||||
setData(null);
|
||||
hasDataRef.current = false;
|
||||
setErrorKey(runResetKey);
|
||||
setError(normalizePollingErrorMessage(err, stableT));
|
||||
} finally {
|
||||
inflightRef.current = false;
|
||||
if (inflightRef.current?.runId === runId) {
|
||||
inflightRef.current = null;
|
||||
}
|
||||
if (showLoading) {
|
||||
const remaining = Math.max(0, (options?.minLoadingMs ?? 0) - (Date.now() - startedAt));
|
||||
if (remaining > 0) await delay(remaining);
|
||||
setLoading(false);
|
||||
if (
|
||||
generationRef.current === generation
|
||||
&& enabledRef.current
|
||||
&& resetKeyRef.current === runResetKey
|
||||
&& loadingRunIdRef.current === runId
|
||||
) {
|
||||
loadingRunIdRef.current = 0;
|
||||
setLoadingKey(runResetKey);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
const queued = queuedRunRef.current;
|
||||
if (
|
||||
queued
|
||||
&& generationRef.current === generation
|
||||
&& enabledRef.current
|
||||
&& resetKeyRef.current === runResetKey
|
||||
) {
|
||||
queuedRunRef.current = null;
|
||||
await run(queued.options);
|
||||
queued.resolve();
|
||||
}
|
||||
}
|
||||
}, [enabled, stableT]);
|
||||
}, [stableT]);
|
||||
|
||||
const scheduleNextPoll = useCallback(() => {
|
||||
clearPollTimer();
|
||||
if (!enabled) return;
|
||||
if (!enabledRef.current || !pollRef.current) return;
|
||||
const generation = generationRef.current;
|
||||
timerRef.current = setTimeout(() => {
|
||||
void run({ withLoading: false }).finally(() => {
|
||||
scheduleNextPoll();
|
||||
if (generationRef.current === generation) {
|
||||
scheduleNextPoll();
|
||||
}
|
||||
});
|
||||
}, pollDelayMs());
|
||||
}, [clearPollTimer, enabled, pollDelayMs, run]);
|
||||
}, [clearPollTimer, pollDelayMs, run]);
|
||||
|
||||
useEffect(() => {
|
||||
const resetChanged = resetKeyRef.current !== resetKey;
|
||||
resetKeyRef.current = resetKey;
|
||||
generationRef.current += 1;
|
||||
inflightRef.current = null;
|
||||
clearPollTimer();
|
||||
if (!enabled) {
|
||||
clearPollTimer();
|
||||
resolveQueuedRun();
|
||||
loadingRunIdRef.current = 0;
|
||||
setLoading(false);
|
||||
setLoadingKey(resetKey);
|
||||
setDataKey(resetKey);
|
||||
setData(null);
|
||||
setErrorKey(resetKey);
|
||||
setError(null);
|
||||
failuresRef.current = 0;
|
||||
hasDataRef.current = false;
|
||||
return undefined;
|
||||
}
|
||||
if (resetChanged) {
|
||||
resolveQueuedRun();
|
||||
loadingRunIdRef.current = 0;
|
||||
setLoading(false);
|
||||
setLoadingKey(resetKey);
|
||||
setDataKey(resetKey);
|
||||
setData(null);
|
||||
setErrorKey(resetKey);
|
||||
setError(null);
|
||||
failuresRef.current = 0;
|
||||
hasDataRef.current = false;
|
||||
}
|
||||
const generation = generationRef.current;
|
||||
void run({ withLoading: true }).finally(() => {
|
||||
scheduleNextPoll();
|
||||
if (generationRef.current === generation && pollRef.current) scheduleNextPoll();
|
||||
});
|
||||
return () => {
|
||||
generationRef.current += 1;
|
||||
resolveQueuedRun();
|
||||
loadingRunIdRef.current = 0;
|
||||
inflightRef.current = null;
|
||||
clearPollTimer();
|
||||
};
|
||||
}, [clearPollTimer, enabled, intervalMs, run, scheduleNextPoll]);
|
||||
}, [clearPollTimer, enabled, intervalMs, options?.poll, resetKey, resolveQueuedRun, run, scheduleNextPoll]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
failuresRef.current = 0;
|
||||
await run({ withLoading: true, minLoadingMs: 450 });
|
||||
}, [run]);
|
||||
|
||||
return { data, error, loading, refresh };
|
||||
return {
|
||||
data: dataKey === resetKey ? data : null,
|
||||
error: errorKey === resetKey ? error : null,
|
||||
loading: loadingKey === resetKey ? loading : enabled,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
|
||||
81
components/terminal/SessionInlineRenameInput.tsx
Normal file
81
components/terminal/SessionInlineRenameInput.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
type SessionInlineRenameInputProps = {
|
||||
initialName: string;
|
||||
onCommit: (name: string) => void;
|
||||
onCancel: () => void;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
export const SessionInlineRenameInput: React.FC<SessionInlineRenameInputProps> = ({
|
||||
initialName,
|
||||
onCommit,
|
||||
onCancel,
|
||||
className,
|
||||
style,
|
||||
}) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [value, setValue] = useState(initialName);
|
||||
const committedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const input = inputRef.current;
|
||||
if (!input) return;
|
||||
input.focus();
|
||||
input.select();
|
||||
}, []);
|
||||
|
||||
const commit = () => {
|
||||
if (committedRef.current) return;
|
||||
committedRef.current = true;
|
||||
onCommit(value);
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
if (committedRef.current) return;
|
||||
committedRef.current = true;
|
||||
onCancel();
|
||||
};
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
data-session-inline-rename="true"
|
||||
value={value}
|
||||
draggable={false}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onBlur={() => {
|
||||
queueMicrotask(() => {
|
||||
commit();
|
||||
});
|
||||
}}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onDragStart={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
commit();
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
cancel();
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'min-w-0 flex-1 truncate select-text rounded-sm border border-primary/50 bg-background/80 px-1 py-0 text-sm font-medium outline-none ring-1 ring-primary/30',
|
||||
className,
|
||||
)}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
|
||||
|
||||
import en from "../../application/i18n/locales/en.ts";
|
||||
import zhCN from "../../application/i18n/locales/zh-CN.ts";
|
||||
import { markMiddleClickContextMenuEvent } from "./runtime/middleClickBehavior.ts";
|
||||
import * as terminalContextMenu from "./TerminalContextMenu.tsx";
|
||||
import { shouldEnableYmodemAction } from "./TerminalView.tsx";
|
||||
|
||||
@@ -22,6 +23,30 @@ const shouldSuppressMouseTrackingContextMenu = (
|
||||
}) => boolean;
|
||||
}
|
||||
).shouldSuppressMouseTrackingContextMenu;
|
||||
const shouldShowAddSelectionToAIContextMenuAction = (
|
||||
terminalContextMenu as {
|
||||
shouldShowAddSelectionToAIContextMenuAction?: (onAddSelectionToAI?: () => void) => boolean;
|
||||
}
|
||||
).shouldShowAddSelectionToAIContextMenuAction;
|
||||
const shouldOpenTerminalContextMenu = (
|
||||
terminalContextMenu as {
|
||||
shouldOpenTerminalContextMenu?: (options: {
|
||||
event: { shiftKey?: boolean; nativeEvent: MouseEvent };
|
||||
rightClickBehavior?: "context-menu" | "paste" | "select-word";
|
||||
isAlternateScreen?: boolean;
|
||||
showReconnectAction?: boolean;
|
||||
}) => boolean;
|
||||
}
|
||||
).shouldOpenTerminalContextMenu;
|
||||
const shouldRenderTerminalContextMenuContent = (
|
||||
terminalContextMenu as {
|
||||
shouldRenderTerminalContextMenuContent?: (options: {
|
||||
isAlternateScreen?: boolean;
|
||||
showReconnectAction?: boolean;
|
||||
allowSuppressedMenuContent?: boolean;
|
||||
}) => boolean;
|
||||
}
|
||||
).shouldRenderTerminalContextMenuContent;
|
||||
|
||||
test("shows reconnect only for reconnectable terminals with a handler", () => {
|
||||
assert.equal(typeof shouldShowReconnectAction, "function");
|
||||
@@ -49,11 +74,23 @@ test("localizes the reconnect context menu label", () => {
|
||||
assert.equal(zhCN["terminal.menu.reconnect"], "重新连接");
|
||||
});
|
||||
|
||||
test("shows add selection to AI context menu action when a handler exists", () => {
|
||||
assert.equal(typeof shouldShowAddSelectionToAIContextMenuAction, "function");
|
||||
if (typeof shouldShowAddSelectionToAIContextMenuAction !== "function") return;
|
||||
|
||||
assert.equal(shouldShowAddSelectionToAIContextMenuAction(() => {}), true);
|
||||
assert.equal(shouldShowAddSelectionToAIContextMenuAction(), false);
|
||||
});
|
||||
|
||||
test("localizes the YMODEM serial send actions", () => {
|
||||
assert.equal(en["terminal.menu.sendYmodem"], "Send with YMODEM");
|
||||
assert.equal(en["terminal.menu.receiveYmodem"], "Receive with YMODEM");
|
||||
assert.equal(en["terminal.toolbar.sendYmodem"], "Send with YMODEM");
|
||||
assert.equal(en["terminal.toolbar.receiveYmodem"], "Receive with YMODEM");
|
||||
assert.equal(zhCN["terminal.menu.sendYmodem"], "YMODEM 发送");
|
||||
assert.equal(zhCN["terminal.menu.receiveYmodem"], "YMODEM 接收");
|
||||
assert.equal(zhCN["terminal.toolbar.sendYmodem"], "YMODEM 发送");
|
||||
assert.equal(zhCN["terminal.toolbar.receiveYmodem"], "YMODEM 接收");
|
||||
});
|
||||
|
||||
test("enables YMODEM action only for connected serial terminals", () => {
|
||||
@@ -64,6 +101,16 @@ test("enables YMODEM action only for connected serial terminals", () => {
|
||||
status: "connected",
|
||||
handleSendYmodem: handler,
|
||||
}), true);
|
||||
assert.equal(shouldEnableYmodemAction({
|
||||
isSerialConnection: true,
|
||||
status: "connected",
|
||||
handleReceiveYmodem: handler,
|
||||
}), true);
|
||||
assert.equal(shouldEnableYmodemAction({
|
||||
isSerialConnection: true,
|
||||
status: "disconnected",
|
||||
handleReceiveYmodem: handler,
|
||||
}), false);
|
||||
assert.equal(shouldEnableYmodemAction({
|
||||
isSerialConnection: true,
|
||||
status: "disconnected",
|
||||
@@ -99,3 +146,83 @@ test("allows reconnect menu while stale mouse tracking is still active", () => {
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("opens a middle-click menu even when right-click is configured to paste", () => {
|
||||
assert.equal(typeof shouldOpenTerminalContextMenu, "function");
|
||||
if (typeof shouldOpenTerminalContextMenu !== "function") return;
|
||||
|
||||
assert.equal(
|
||||
shouldOpenTerminalContextMenu({
|
||||
event: {
|
||||
shiftKey: false,
|
||||
nativeEvent: markMiddleClickContextMenuEvent({} as MouseEvent),
|
||||
},
|
||||
rightClickBehavior: "paste",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
shouldOpenTerminalContextMenu({
|
||||
event: {
|
||||
shiftKey: false,
|
||||
nativeEvent: {} as MouseEvent,
|
||||
},
|
||||
rightClickBehavior: "paste",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("opens and renders middle-click menu while alternate-screen mouse tracking suppresses right-click menus", () => {
|
||||
assert.equal(typeof shouldOpenTerminalContextMenu, "function");
|
||||
assert.equal(typeof shouldRenderTerminalContextMenuContent, "function");
|
||||
if (
|
||||
typeof shouldOpenTerminalContextMenu !== "function" ||
|
||||
typeof shouldRenderTerminalContextMenuContent !== "function"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
shouldOpenTerminalContextMenu({
|
||||
event: {
|
||||
shiftKey: false,
|
||||
nativeEvent: markMiddleClickContextMenuEvent({} as MouseEvent),
|
||||
},
|
||||
rightClickBehavior: "paste",
|
||||
isAlternateScreen: true,
|
||||
showReconnectAction: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldRenderTerminalContextMenuContent({
|
||||
isAlternateScreen: true,
|
||||
showReconnectAction: false,
|
||||
allowSuppressedMenuContent: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
shouldOpenTerminalContextMenu({
|
||||
event: {
|
||||
shiftKey: false,
|
||||
nativeEvent: {} as MouseEvent,
|
||||
},
|
||||
rightClickBehavior: "context-menu",
|
||||
isAlternateScreen: true,
|
||||
showReconnectAction: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldRenderTerminalContextMenuContent({
|
||||
isAlternateScreen: true,
|
||||
showReconnectAction: false,
|
||||
allowSuppressedMenuContent: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -5,15 +5,18 @@
|
||||
import {
|
||||
ClipboardPaste,
|
||||
Copy,
|
||||
Download,
|
||||
Pencil,
|
||||
RefreshCcw,
|
||||
Sparkles,
|
||||
SquareArrowOutUpRight,
|
||||
SplitSquareHorizontal,
|
||||
SplitSquareVertical,
|
||||
Terminal as TerminalIcon,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { KeyBinding, RightClickBehavior } from '../../domain/models';
|
||||
import {
|
||||
@@ -24,6 +27,7 @@ import {
|
||||
ContextMenuShortcut,
|
||||
ContextMenuTrigger,
|
||||
} from '../ui/context-menu';
|
||||
import { isMiddleClickContextMenuEvent } from './runtime/middleClickBehavior';
|
||||
|
||||
export interface TerminalContextMenuProps {
|
||||
children: React.ReactNode;
|
||||
@@ -40,11 +44,14 @@ export interface TerminalContextMenuProps {
|
||||
onSplitHorizontal?: () => void;
|
||||
onSplitVertical?: () => void;
|
||||
onSendYmodem?: () => void;
|
||||
onReceiveYmodem?: () => void;
|
||||
isReconnectable?: boolean;
|
||||
onReconnect?: () => void;
|
||||
onClose?: () => void;
|
||||
onSelectWord?: () => void;
|
||||
onAddSelectionToAI?: () => void;
|
||||
onRename?: () => void;
|
||||
onDetach?: () => void;
|
||||
}
|
||||
|
||||
export const shouldShowReconnectAction = ({
|
||||
@@ -63,6 +70,44 @@ export const shouldSuppressMouseTrackingContextMenu = ({
|
||||
showReconnectAction?: boolean;
|
||||
}): boolean => Boolean(isAlternateScreen && !showReconnectAction);
|
||||
|
||||
export const shouldShowAddSelectionToAIContextMenuAction = (
|
||||
onAddSelectionToAI?: () => void,
|
||||
): boolean => Boolean(onAddSelectionToAI);
|
||||
|
||||
export const shouldRenderTerminalContextMenuContent = ({
|
||||
isAlternateScreen,
|
||||
showReconnectAction,
|
||||
allowSuppressedMenuContent,
|
||||
}: {
|
||||
isAlternateScreen?: boolean;
|
||||
showReconnectAction?: boolean;
|
||||
allowSuppressedMenuContent?: boolean;
|
||||
}): boolean =>
|
||||
allowSuppressedMenuContent ||
|
||||
!shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, showReconnectAction });
|
||||
|
||||
export const shouldOpenTerminalContextMenu = ({
|
||||
event,
|
||||
rightClickBehavior = 'context-menu',
|
||||
isAlternateScreen,
|
||||
showReconnectAction,
|
||||
}: {
|
||||
event: { shiftKey?: boolean; nativeEvent: MouseEvent };
|
||||
rightClickBehavior?: RightClickBehavior;
|
||||
isAlternateScreen?: boolean;
|
||||
showReconnectAction?: boolean;
|
||||
}): boolean => {
|
||||
if (isMiddleClickContextMenuEvent(event.nativeEvent)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, showReconnectAction })) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Boolean(event.shiftKey || rightClickBehavior === 'context-menu');
|
||||
};
|
||||
|
||||
export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
children,
|
||||
hasSelection = false,
|
||||
@@ -78,11 +123,14 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
onSplitHorizontal,
|
||||
onSplitVertical,
|
||||
onSendYmodem,
|
||||
onReceiveYmodem,
|
||||
isReconnectable,
|
||||
onReconnect,
|
||||
onClose,
|
||||
onSelectWord,
|
||||
onAddSelectionToAI,
|
||||
onRename,
|
||||
onDetach,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isMac = hotkeyScheme === 'mac';
|
||||
@@ -90,11 +138,13 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
// keep its `:focus-within`-driven opacity stable while focus is in the
|
||||
// menu portal (otherwise the pane dims for the menu's lifetime).
|
||||
const markedPaneRef = useRef<HTMLElement | null>(null);
|
||||
const [allowSuppressedMenuContent, setAllowSuppressedMenuContent] = useState(false);
|
||||
|
||||
const handleOpenChange = useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
markedPaneRef.current?.removeAttribute('data-menu-open');
|
||||
markedPaneRef.current = null;
|
||||
setAllowSuppressedMenuContent(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -125,19 +175,28 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
// In alternate screen (tmux, vim, etc.), let the terminal application
|
||||
// handle right-click natively to avoid conflicting menus. Reconnect is
|
||||
// still available after disconnect, even if mouse tracking was left on.
|
||||
if (shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, showReconnectAction })) {
|
||||
const shouldOpenMenu = shouldOpenTerminalContextMenu({
|
||||
event: e,
|
||||
rightClickBehavior,
|
||||
isAlternateScreen,
|
||||
showReconnectAction,
|
||||
});
|
||||
const isMiddleClickMenu = isMiddleClickContextMenuEvent(e.nativeEvent);
|
||||
|
||||
if (!shouldOpenMenu && shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, showReconnectAction })) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// Shift+Right-Click or context-menu mode: let Radix open the menu
|
||||
if (e.shiftKey || rightClickBehavior === 'context-menu') {
|
||||
if (shouldOpenMenu) {
|
||||
const pane = (e.target as HTMLElement | null)?.closest<HTMLElement>('.workspace-pane');
|
||||
if (pane) {
|
||||
markedPaneRef.current?.removeAttribute('data-menu-open');
|
||||
pane.setAttribute('data-menu-open', '');
|
||||
markedPaneRef.current = pane;
|
||||
}
|
||||
setAllowSuppressedMenuContent(isMiddleClickMenu);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -162,7 +221,11 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
>
|
||||
{children}
|
||||
</ContextMenuTrigger>
|
||||
{!shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, showReconnectAction }) && (
|
||||
{shouldRenderTerminalContextMenuContent({
|
||||
isAlternateScreen,
|
||||
showReconnectAction,
|
||||
allowSuppressedMenuContent,
|
||||
}) && (
|
||||
<ContextMenuContent className="w-max">
|
||||
<ContextMenuItem onClick={onCopy} disabled={!hasSelection}>
|
||||
<Copy size={14} className="mr-2" />
|
||||
@@ -174,7 +237,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
{t('terminal.menu.paste')}
|
||||
<ContextMenuShortcut>{pasteShortcut}</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
{onAddSelectionToAI && (
|
||||
{shouldShowAddSelectionToAIContextMenuAction(onAddSelectionToAI) && (
|
||||
<ContextMenuItem onClick={onAddSelectionToAI} disabled={!hasSelection}>
|
||||
<Sparkles size={14} className="mr-2" />
|
||||
{t('terminal.menu.addSelectionToAI')}
|
||||
@@ -203,13 +266,21 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{onSendYmodem && (
|
||||
{(onSendYmodem || onReceiveYmodem) && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={onSendYmodem}>
|
||||
<Upload size={14} className="mr-2" />
|
||||
{t('terminal.menu.sendYmodem')}
|
||||
</ContextMenuItem>
|
||||
{onSendYmodem && (
|
||||
<ContextMenuItem onClick={onSendYmodem}>
|
||||
<Upload size={14} className="mr-2" />
|
||||
{t('terminal.menu.sendYmodem')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{onReceiveYmodem && (
|
||||
<ContextMenuItem onClick={onReceiveYmodem}>
|
||||
<Download size={14} className="mr-2" />
|
||||
{t('terminal.menu.receiveYmodem')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -234,6 +305,26 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
<ContextMenuShortcut>{clearShortcut}</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
|
||||
{onRename && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={onRename}>
|
||||
<Pencil size={14} className="mr-2" />
|
||||
{t('terminal.menu.rename')}
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
{onDetach && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={onDetach}>
|
||||
<SquareArrowOutUpRight size={14} className="mr-2" />
|
||||
{t('terminal.menu.detach')}
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
{onClose && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user