mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d6ef5f701 | ||
|
|
6e858a7e4e | ||
|
|
1acd80338e | ||
|
|
a10c043705 | ||
|
|
abe0846a65 | ||
|
|
4cedaf482d | ||
|
|
fd545e6577 | ||
|
|
12202a1757 | ||
|
|
4cdcf5dc4c | ||
|
|
780f967177 | ||
|
|
69766dcda1 | ||
|
|
f98ce74a54 | ||
|
|
d9553d8e71 | ||
|
|
9b234f38b3 | ||
|
|
711683a90b | ||
|
|
f7222761a2 | ||
|
|
2e2988cacd | ||
|
|
2b9d000a89 | ||
|
|
5187e7ea66 | ||
|
|
778010a271 | ||
|
|
09d2a18ed3 | ||
|
|
904c59c395 | ||
|
|
d5f4b54ae7 | ||
|
|
25ca616622 | ||
|
|
396fb97191 | ||
|
|
fba5059083 | ||
|
|
8e7791a0fc | ||
|
|
dee922385a | ||
|
|
0c8ac89172 | ||
|
|
7763b1ed20 | ||
|
|
c03e46fced | ||
|
|
4e5f2825c6 | ||
|
|
abf32a23cc | ||
|
|
4a85ae32ff | ||
|
|
177c574ffc | ||
|
|
66ad27b4bf | ||
|
|
2afd36ab33 | ||
|
|
4717a6ec8e | ||
|
|
b941556b1d | ||
|
|
8c7bc0c706 | ||
|
|
7a91b31290 | ||
|
|
885b0c0177 | ||
|
|
a39af9d7b0 | ||
|
|
a9252d5902 | ||
|
|
e44afc3916 | ||
|
|
2b1bfadb45 | ||
|
|
5329a47d23 | ||
|
|
162b5a599f | ||
|
|
c5308a573d | ||
|
|
f8c220dbd1 | ||
|
|
d43885bdbc | ||
|
|
c98e688ad6 | ||
|
|
5c2d60f694 |
No files matched your search
@@ -291,15 +291,11 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
draggingSessionId,
|
||||
setDraggingSessionId,
|
||||
sessionRenameTarget,
|
||||
sessionRenameValue,
|
||||
setSessionRenameValue,
|
||||
startSessionRename,
|
||||
renameSessionInline,
|
||||
submitSessionRename,
|
||||
resetSessionRename,
|
||||
workspaceRenameTarget,
|
||||
workspaceRenameValue,
|
||||
setWorkspaceRenameValue,
|
||||
startWorkspaceRename,
|
||||
submitWorkspaceRename,
|
||||
resetWorkspaceRename,
|
||||
@@ -1945,10 +1941,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
resetWorkspaceRename,
|
||||
resolvedTheme,
|
||||
sessionRenameTarget,
|
||||
sessionRenameValue,
|
||||
setActiveTabId,
|
||||
setSessionRenameValue,
|
||||
setWorkspaceRenameValue,
|
||||
settings,
|
||||
startSessionRename,
|
||||
renameSessionInline,
|
||||
@@ -1960,8 +1953,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
toggleConnectionLogSaved,
|
||||
updateConnectionLog,
|
||||
workspaceRenameTarget,
|
||||
workspaceRenameValue,
|
||||
}), [accentMode, clearUnsavedConnectionLogs, closeLogView, customAccent, deleteConnectionLog, handleEndSessionDrag, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handleSyncNowManual, isMacClient, logViews, openLogView, orderedTabsWithEditors, orphanSessionsForShell, reorderWorkTabs, resetSessionRename, resetWorkspaceRename, resolvedTheme, sessionRenameTarget, sessionRenameValue, setActiveTabId, setSessionRenameValue, setWorkspaceRenameValue, settings, startSessionRename, renameSessionInline, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, themeById, toggleConnectionLogSaved, updateConnectionLog, workspaceRenameTarget, workspaceRenameValue]);
|
||||
}), [accentMode, clearUnsavedConnectionLogs, closeLogView, customAccent, deleteConnectionLog, handleEndSessionDrag, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handleSyncNowManual, isMacClient, logViews, openLogView, orderedTabsWithEditors, orphanSessionsForShell, reorderWorkTabs, resetSessionRename, resetWorkspaceRename, resolvedTheme, sessionRenameTarget, setActiveTabId, settings, startSessionRename, renameSessionInline, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, themeById, toggleConnectionLogSaved, updateConnectionLog, workspaceRenameTarget]);
|
||||
|
||||
const appDialogsDomain = useMemo(() => ({
|
||||
addToWorkspaceDialog,
|
||||
|
||||
@@ -53,3 +53,8 @@ test('shared host tree forwards work-surface host management callbacks', () => {
|
||||
assert.match(hostTreeLayerSource, /onNewHost=\{onNewHost\}/);
|
||||
assert.match(hostTreeLayerSource, /onEditHost=\{onEditHost\}/);
|
||||
});
|
||||
|
||||
test('shared host tree layer is memoized with a custom areEqual', () => {
|
||||
assert.match(hostTreeLayerSource, /memo\(AppHostTreeLayerInner,\s*appHostTreeLayerAreEqual\)/);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { memo, useMemo } from 'react';
|
||||
|
||||
import { useActiveTabId } from '../state/activeTabStore';
|
||||
import type { EditorTabChrome } from '../state/editorTabStore';
|
||||
@@ -47,7 +47,33 @@ export function getAppHostTreeLayerStyle(surfaceVisible: boolean): React.CSSProp
|
||||
};
|
||||
}
|
||||
|
||||
export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
|
||||
function appHostTreeLayerAreEqual(
|
||||
prev: AppHostTreeLayerProps,
|
||||
next: AppHostTreeLayerProps,
|
||||
): boolean {
|
||||
return prev.enabled === next.enabled
|
||||
&& prev.hosts === next.hosts
|
||||
&& prev.customGroups === next.customGroups
|
||||
&& prev.groupConfigs === next.groupConfigs
|
||||
&& prev.sessions === next.sessions
|
||||
&& prev.workspaces === next.workspaces
|
||||
&& prev.editorTabs === next.editorTabs
|
||||
&& prev.logViews === next.logViews
|
||||
&& prev.orderedTabs === next.orderedTabs
|
||||
&& prev.accentMode === next.accentMode
|
||||
&& prev.currentTerminalTheme === next.currentTerminalTheme
|
||||
&& prev.customAccent === next.customAccent
|
||||
&& prev.followAppTerminalTheme === next.followAppTerminalTheme
|
||||
&& prev.hostById === next.hostById
|
||||
&& prev.themeById === next.themeById
|
||||
&& prev.resolveSessionAppearance === next.resolveSessionAppearance
|
||||
&& prev.onConnect === next.onConnect
|
||||
&& prev.onNewHost === next.onNewHost
|
||||
&& prev.onEditHost === next.onEditHost
|
||||
&& prev.onCreateLocalTerminal === next.onCreateLocalTerminal;
|
||||
}
|
||||
|
||||
const AppHostTreeLayerInner: React.FC<AppHostTreeLayerProps> = ({
|
||||
enabled,
|
||||
hosts,
|
||||
customGroups,
|
||||
@@ -154,3 +180,6 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AppHostTreeLayer = memo(AppHostTreeLayerInner, appHostTreeLayerAreEqual);
|
||||
AppHostTreeLayer.displayName = 'AppHostTreeLayer';
|
||||
+159
-112
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React, { Suspense, lazy, memo, useCallback, useEffect, useMemo } from 'react';
|
||||
import React, { Suspense, lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { AlertTriangle, Download, Trash2 } from 'lucide-react';
|
||||
import { activeTabStore, toEditorTabId, useIsEditorTabActive } from '../state/activeTabStore';
|
||||
import { editorTabStore } from '../state/editorTabStore';
|
||||
@@ -12,7 +12,7 @@ import { QuickScriptEditorDialog } from '../../components/scripts/QuickScriptEdi
|
||||
import { AddToWorkspaceDialog } from '../../components/workspace/AddToWorkspaceDialog';
|
||||
import { KeyboardInteractiveModal } from '../../components/KeyboardInteractiveModal';
|
||||
import { PassphraseModal } from '../../components/PassphraseModal';
|
||||
import { UnsavedChangesProvider } from '../../components/editor/UnsavedChangesDialog';
|
||||
import { UnsavedChangesProvider, promptUnsavedChanges } from '../../components/editor/UnsavedChangesDialog';
|
||||
import { SnippetExecutionProvider } from '../../components/SnippetExecutionProvider';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../../components/ui/dialog';
|
||||
@@ -66,6 +66,68 @@ const TextEditorTabFallback = ({ tabId }: { tabId: string }) => {
|
||||
);
|
||||
};
|
||||
|
||||
/** Local draft so keystrokes do not rebuild App chrome domain every character. */
|
||||
function RenameDraftDialog({
|
||||
open,
|
||||
inputId,
|
||||
initialName,
|
||||
title,
|
||||
nameLabel,
|
||||
placeholder,
|
||||
cancelLabel,
|
||||
saveLabel,
|
||||
onCancel,
|
||||
onSave,
|
||||
}: {
|
||||
open: boolean;
|
||||
inputId: string;
|
||||
initialName: string;
|
||||
title: string;
|
||||
nameLabel: string;
|
||||
placeholder: string;
|
||||
cancelLabel: string;
|
||||
saveLabel: string;
|
||||
onCancel: () => void;
|
||||
onSave: (name: string) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(initialName);
|
||||
useEffect(() => {
|
||||
if (open) setDraft(initialName);
|
||||
}, [open, initialName]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) onCancel();
|
||||
}}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2 py-2">
|
||||
<Label htmlFor={inputId}>{nameLabel}</Label>
|
||||
<Input
|
||||
id={inputId}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== 'Enter') return;
|
||||
if (!draft.trim()) return;
|
||||
onSave(draft);
|
||||
}}
|
||||
autoFocus
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onCancel}>{cancelLabel}</Button>
|
||||
<Button onClick={() => onSave(draft)} disabled={!draft.trim()}>{saveLabel}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export type AppViewProps = {
|
||||
domains: AppViewDomains;
|
||||
};
|
||||
@@ -127,14 +189,14 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, noteGroups, notes, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
|
||||
passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
|
||||
resolveEmptyVaultConflict, resolvedTheme, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
|
||||
sessionRenameValue, sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
|
||||
setNavigateToSection, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
|
||||
setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
|
||||
sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
|
||||
setNavigateToSection, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
|
||||
setWorkspaceFocusedSession, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
|
||||
sftpShowHiddenFiles, sftpUseCompressedUpload, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename,
|
||||
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById,
|
||||
toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog,
|
||||
readPersistedHosts, readPersistedManagedSources, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources,
|
||||
updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, vaultFocusRequest, workspaceRenameTarget, workspaceRenameValue, workspaces,
|
||||
updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, vaultFocusRequest, workspaceRenameTarget, workspaces,
|
||||
VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper,
|
||||
} = ctx;
|
||||
|
||||
@@ -211,6 +273,70 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
pluginViewTabStore.close(tabId);
|
||||
}, [orderedTabsWithEditors]);
|
||||
|
||||
const orderedTabsWithEditorsRef = useRef(orderedTabsWithEditors);
|
||||
orderedTabsWithEditorsRef.current = orderedTabsWithEditors;
|
||||
|
||||
// Stable for TopTabs memo: read ordered tabs via ref; prompt via module singleton.
|
||||
const handleRequestCloseEditorTab = useCallback(async (id: string): Promise<boolean> => {
|
||||
const tab = editorTabStore.getTab(id);
|
||||
if (!tab) return false;
|
||||
|
||||
const closeEditorAndActivateNeighbor = () => {
|
||||
const closingTabId = toEditorTabId(id);
|
||||
const list = orderedTabsWithEditorsRef.current;
|
||||
const idx = list.indexOf(closingTabId);
|
||||
releaseEditorTabSaveCoordinator(id);
|
||||
editorTabStore.close(id);
|
||||
if (activeTabStore.getActiveTabId() !== closingTabId) return;
|
||||
const next = list[idx - 1] ?? list[idx + 1] ?? 'vault';
|
||||
activeTabStore.setActiveTabId(next === closingTabId ? 'vault' : next);
|
||||
};
|
||||
|
||||
const dirty = tab.content !== tab.baselineContent;
|
||||
if (!dirty) {
|
||||
closeEditorAndActivateNeighbor();
|
||||
return true;
|
||||
}
|
||||
const choice = await promptUnsavedChanges(tab.fileName);
|
||||
if (choice === 'cancel') return false;
|
||||
if (choice === 'discard') {
|
||||
closeEditorAndActivateNeighbor();
|
||||
return true;
|
||||
}
|
||||
if (choice === 'save') {
|
||||
const ok = await saveEditorTab(id);
|
||||
if (!ok) {
|
||||
const msg = editorTabStore.getTab(id)?.saveError ?? 'Save failed';
|
||||
toast.error(msg, 'SFTP');
|
||||
return false;
|
||||
}
|
||||
const latest = editorTabStore.getTab(id);
|
||||
if (!latest || latest.content !== latest.baselineContent) return false;
|
||||
closeEditorAndActivateNeighbor();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
// Keep the hotkey ref current during render so Cmd/Ctrl+W never sees the
|
||||
// App.tsx stub `() => false` between commit and useEffect.
|
||||
handleRequestCloseEditorTabRef.current = handleRequestCloseEditorTab;
|
||||
|
||||
const handleSaveSessionRename = useCallback((name: string) => {
|
||||
if (!sessionRenameTarget) return;
|
||||
if (!name.trim()) return;
|
||||
submitSessionRename(sessionRenameTarget.id, name);
|
||||
resetSessionRename();
|
||||
}, [resetSessionRename, sessionRenameTarget, submitSessionRename]);
|
||||
|
||||
const handleSaveWorkspaceRename = useCallback((name: string) => {
|
||||
if (!workspaceRenameTarget) return;
|
||||
if (!name.trim()) return;
|
||||
submitWorkspaceRename(workspaceRenameTarget.id, name);
|
||||
resetWorkspaceRename();
|
||||
}, [resetWorkspaceRename, submitWorkspaceRename, workspaceRenameTarget]);
|
||||
|
||||
useEffect(() => {
|
||||
const catalog = buildPluginSettingScopeCatalog({
|
||||
hosts,
|
||||
@@ -224,54 +350,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
return (
|
||||
<SnippetExecutionProvider>
|
||||
<UnsavedChangesProvider>
|
||||
{({ prompt }) => {
|
||||
// Helper: close an editor tab and activate the neighbor (left-preference), or vault.
|
||||
const closeEditorAndActivateNeighbor = (id: string) => {
|
||||
const closingTabId = toEditorTabId(id);
|
||||
const list = orderedTabsWithEditors;
|
||||
const idx = list.indexOf(closingTabId);
|
||||
releaseEditorTabSaveCoordinator(id);
|
||||
editorTabStore.close(id);
|
||||
if (activeTabStore.getActiveTabId() !== closingTabId) return;
|
||||
const next = list[idx - 1] ?? list[idx + 1] ?? 'vault';
|
||||
activeTabStore.setActiveTabId(next === closingTabId ? 'vault' : next);
|
||||
};
|
||||
|
||||
// Real dirty-confirm close handler.
|
||||
const handleRequestCloseEditorTab = async (id: string): Promise<boolean> => {
|
||||
const tab = editorTabStore.getTab(id);
|
||||
if (!tab) return false;
|
||||
const dirty = tab.content !== tab.baselineContent;
|
||||
if (!dirty) {
|
||||
closeEditorAndActivateNeighbor(id);
|
||||
return true;
|
||||
}
|
||||
const choice = await prompt(tab.fileName);
|
||||
if (choice === 'cancel') return false;
|
||||
if (choice === 'discard') {
|
||||
closeEditorAndActivateNeighbor(id);
|
||||
return true;
|
||||
}
|
||||
if (choice === 'save') {
|
||||
const ok = await saveEditorTab(id);
|
||||
if (!ok) {
|
||||
const msg = editorTabStore.getTab(id)?.saveError ?? 'Save failed';
|
||||
toast.error(msg, 'SFTP');
|
||||
return false;
|
||||
}
|
||||
const latest = editorTabStore.getTab(id);
|
||||
if (!latest || latest.content !== latest.baselineContent) return false;
|
||||
closeEditorAndActivateNeighbor(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// Expose to the hotkey dispatcher (Cmd/Ctrl+W).
|
||||
handleRequestCloseEditorTabRef.current = handleRequestCloseEditorTab;
|
||||
|
||||
return (
|
||||
{() => (
|
||||
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" data-terminal-appearance-root onContextMenu={handleRootContextMenu}>
|
||||
<TopTabs
|
||||
theme={resolvedTheme}
|
||||
@@ -464,8 +543,6 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
identities={identities}
|
||||
snippets={snippets}
|
||||
snippetPackages={snippetPackages}
|
||||
notes={notes}
|
||||
noteGroups={noteGroups}
|
||||
sessions={sessions}
|
||||
workspaces={workspaces}
|
||||
knownHosts={effectiveKnownHosts}
|
||||
@@ -529,8 +606,6 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
updateHosts={updateTerminalHosts}
|
||||
updateSnippets={updateSnippets}
|
||||
updateSnippetPackages={updateSnippetPackages}
|
||||
updateNotes={updateNotes}
|
||||
updateNoteGroups={updateNoteGroups}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
|
||||
sftpAutoSync={sftpAutoSync}
|
||||
@@ -715,59 +790,32 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
</LazyLoadBoundary>
|
||||
)}
|
||||
|
||||
<Dialog open={!!sessionRenameTarget} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
resetSessionRename();
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('dialog.renameSession.title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2 py-2">
|
||||
<Label htmlFor="session-name">{t('field.name')}</Label>
|
||||
<Input
|
||||
id="session-name"
|
||||
value={sessionRenameValue}
|
||||
onChange={(e) => setSessionRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') submitSessionRename(); }}
|
||||
autoFocus
|
||||
placeholder={t('placeholder.sessionName')}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={resetSessionRename}>{t('common.cancel')}</Button>
|
||||
<Button onClick={submitSessionRename} disabled={!sessionRenameValue.trim()}>{t('common.save')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<RenameDraftDialog
|
||||
open={!!sessionRenameTarget}
|
||||
inputId="session-name"
|
||||
initialName={sessionRenameTarget ? (sessionRenameTarget.customName || sessionRenameTarget.hostLabel) : ''}
|
||||
title={t('dialog.renameSession.title')}
|
||||
nameLabel={t('field.name')}
|
||||
placeholder={t('placeholder.sessionName')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
saveLabel={t('common.save')}
|
||||
onCancel={resetSessionRename}
|
||||
onSave={handleSaveSessionRename}
|
||||
/>
|
||||
|
||||
<RenameDraftDialog
|
||||
open={!!workspaceRenameTarget}
|
||||
inputId="workspace-name"
|
||||
initialName={workspaceRenameTarget ? workspaceRenameTarget.title : ''}
|
||||
title={t('dialog.renameWorkspace.title')}
|
||||
nameLabel={t('field.name')}
|
||||
placeholder={t('placeholder.workspaceName')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
saveLabel={t('common.save')}
|
||||
onCancel={resetWorkspaceRename}
|
||||
onSave={handleSaveWorkspaceRename}
|
||||
/>
|
||||
|
||||
<Dialog open={!!workspaceRenameTarget} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
resetWorkspaceRename();
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('dialog.renameWorkspace.title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2 py-2">
|
||||
<Label htmlFor="workspace-name">{t('field.name')}</Label>
|
||||
<Input
|
||||
id="workspace-name"
|
||||
value={workspaceRenameValue}
|
||||
onChange={(e) => setWorkspaceRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') submitWorkspaceRename(); }}
|
||||
autoFocus
|
||||
placeholder={t('placeholder.workspaceName')}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={resetWorkspaceRename}>{t('common.cancel')}</Button>
|
||||
<Button onClick={submitWorkspaceRename} disabled={!workspaceRenameValue.trim()}>{t('common.save')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{isCreateWorkspaceOpen && (
|
||||
<LazyLoadBoundary name="Create workspace" resetKey="create-workspace">
|
||||
@@ -871,8 +919,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
)}
|
||||
</UnsavedChangesProvider>
|
||||
</SnippetExecutionProvider>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,10 @@ import { sftpTransferCenterStore } from '../state/sftpTransferCenterStore';
|
||||
import { resumeTransferWithDedicatedSession } from '../state/sftp/dedicatedTransferResume';
|
||||
import { getSftpTransferResourceKeys, globalSftpTransferScheduler } from '../state/sftp/globalTransferScheduler';
|
||||
import { hasNewSourceFingerprint } from '../state/sftp/transferProgressMetadata';
|
||||
import { STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY } from '../../infrastructure/config/storageKeys';
|
||||
import {
|
||||
STORAGE_KEY_AUTO_UPDATE_ENABLED,
|
||||
STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import type { TransferTask } from '../../domain/models';
|
||||
import { isTerminalBootEpochCurrent } from '../../domain/terminalBootEpoch';
|
||||
import {
|
||||
@@ -218,7 +221,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
// Skip "update available" toast if auto-download has already started or completed
|
||||
if (updateState.autoDownloadStatus !== 'idle') return;
|
||||
// Don't show automatic notification when auto-update is disabled
|
||||
if (localStorageAdapter.readString('netcatty_auto_update_enabled_v1') === 'false') return;
|
||||
if (localStorageAdapter.readString(STORAGE_KEY_AUTO_UPDATE_ENABLED) === 'false') return;
|
||||
if (updateState.hasUpdate && updateState.latestRelease) {
|
||||
const version = updateState.latestRelease.version;
|
||||
toast.info(
|
||||
|
||||
@@ -113,8 +113,6 @@ export const enVaultMessages: Messages = {
|
||||
'knownHosts.empty.title': 'No Known Hosts',
|
||||
'knownHosts.empty.desc':
|
||||
"Known hosts are SSH servers you've connected to before. Import from your system's known_hosts file to get started.",
|
||||
'knownHosts.results.showingLimited':
|
||||
'Showing {shown} of {total} hosts. Use search to find specific hosts.',
|
||||
'knownHosts.toast.scanUnavailable': 'System scan is unavailable on this platform.',
|
||||
'knownHosts.toast.scanNoFile': 'No system known_hosts file found.',
|
||||
'knownHosts.toast.scanNoEntries': 'No usable entries found in known_hosts.',
|
||||
@@ -450,13 +448,6 @@ export const enVaultMessages: Messages = {
|
||||
'settings.tab.sftpFileAssociations': 'SFTP',
|
||||
'settings.sftp.transferConcurrency': 'Transfer Concurrency',
|
||||
'settings.sftp.transferConcurrency.desc': 'Maximum number of files transferred in parallel for the same endpoint, including folder contents and separately selected files. Higher values may improve speed but can overwhelm some servers.',
|
||||
'settings.sftp.transferPoolIdleTtl': 'Transfer connection keep-alive',
|
||||
'settings.sftp.transferPoolIdleTtl.desc': 'How long dedicated transfer SFTP connections stay warm after idle. Longer values reduce cold-start delay before the next upload or download. Busy transfers are never closed.',
|
||||
'settings.sftp.transferPoolIdleTtl.1m': '1 minute',
|
||||
'settings.sftp.transferPoolIdleTtl.5m': '5 minutes',
|
||||
'settings.sftp.transferPoolIdleTtl.15m': '15 minutes',
|
||||
'settings.sftp.transferPoolIdleTtl.30m': '30 minutes',
|
||||
'settings.sftp.transferPoolIdleTtl.never': 'Until app quit',
|
||||
'settings.ssh.transportIdleTtl': 'SSH connection keep-alive',
|
||||
'settings.ssh.transportIdleTtl.desc': 'How long a shared SSH connection stays warm after the last terminal, SFTP browse, bulk transfer, or port-forward lease is released. While warm, reconnecting to the same host can skip re-authentication (including MFA). Active sessions and transfers are never closed.',
|
||||
'settings.ssh.transportIdleTtl.1m': '1 minute',
|
||||
@@ -913,30 +904,8 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.plugin.unavailable': 'The configured provider is unavailable. Configuration is preserved until the plugin is restored.',
|
||||
'hostDetails.plugin.useSsh': 'Use SSH instead',
|
||||
|
||||
// Host form (legacy modal)
|
||||
'hostForm.title.edit': 'Edit Host',
|
||||
'hostForm.title.new': 'New Host',
|
||||
'hostForm.desc.edit': 'Update connection details for this host',
|
||||
'hostForm.desc.new': 'Create a new SSH host entry',
|
||||
'hostForm.field.label': 'Label',
|
||||
'hostForm.placeholder.label': 'My Production Server',
|
||||
'hostForm.field.hostname': 'Hostname / IP',
|
||||
'hostForm.placeholder.hostname': '192.168.1.1',
|
||||
'hostForm.field.port': 'Port',
|
||||
'hostForm.field.username': 'Username',
|
||||
'hostForm.field.osType': 'OS Type',
|
||||
'hostForm.placeholder.selectOs': 'Select OS',
|
||||
'hostForm.field.group': 'Group',
|
||||
'hostForm.placeholder.group': 'e.g. AWS, DigitalOcean',
|
||||
'hostForm.field.tags': 'Tags',
|
||||
'hostForm.placeholder.addTag': 'Add a tag...',
|
||||
'hostForm.auth.method': 'Authentication Method',
|
||||
'hostForm.auth.password': 'Password',
|
||||
'hostForm.auth.sshKey': 'SSH Key',
|
||||
// Host form (legacy keys still referenced by TerminalAuthDialog)
|
||||
'hostForm.auth.selectKey': 'Select an SSH Key',
|
||||
'hostForm.auth.noKeys': 'No keys available',
|
||||
'hostForm.auth.noKeysHint': 'No SSH keys found in Keychain. Please create one first.',
|
||||
'hostForm.saveHost': 'Save Host',
|
||||
|
||||
// Connection logs
|
||||
'logs.table.date': 'Date',
|
||||
|
||||
@@ -150,8 +150,6 @@ export const ruVaultMessages: Messages = {
|
||||
'knownHosts.empty.title': 'Нет известных хостов',
|
||||
'knownHosts.empty.desc':
|
||||
'Известные хосты — это SSH-серверы, к которым вы подключались раньше. Импортируйте системный файл known_hosts, чтобы начать.',
|
||||
'knownHosts.results.showingLimited':
|
||||
'Показано {shown} из {total} хостов. Используйте поиск, чтобы найти нужные хосты.',
|
||||
'knownHosts.toast.scanUnavailable': 'Сканирование системы недоступно на этой платформе.',
|
||||
'knownHosts.toast.scanNoFile': 'Системный файл known_hosts не найден.',
|
||||
'knownHosts.toast.scanNoEntries': 'В known_hosts не найдено пригодных записей.',
|
||||
@@ -486,13 +484,6 @@ export const ruVaultMessages: Messages = {
|
||||
'settings.tab.sftpFileAssociations': 'SFTP',
|
||||
'settings.sftp.transferConcurrency': 'Параллелизм передачи',
|
||||
'settings.sftp.transferConcurrency.desc': 'Максимальное число файлов, одновременно передаваемых для одной цели, включая содержимое папок и отдельно выбранные файлы. Большие значения могут ускорить передачу, но перегрузить некоторые серверы.',
|
||||
'settings.sftp.transferPoolIdleTtl': 'Удержание transfer-соединений',
|
||||
'settings.sftp.transferPoolIdleTtl.desc': 'Как долго простаивающие SFTP-соединения пула передачи остаются открытыми. Большее время снижает задержку перед следующей передачей. Активные передачи не закрываются.',
|
||||
'settings.sftp.transferPoolIdleTtl.1m': '1 минута',
|
||||
'settings.sftp.transferPoolIdleTtl.5m': '5 минут',
|
||||
'settings.sftp.transferPoolIdleTtl.15m': '15 минут',
|
||||
'settings.sftp.transferPoolIdleTtl.30m': '30 минут',
|
||||
'settings.sftp.transferPoolIdleTtl.never': 'До выхода из приложения',
|
||||
'settings.ssh.transportIdleTtl': 'Удержание SSH-соединений',
|
||||
'settings.ssh.transportIdleTtl.desc': 'Как долго общее SSH-соединение остаётся открытым после освобождения последнего lease терминала, SFTP-браузера, пакетной передачи или проброса портов. Пока оно тёплое, повторное подключение к тому же хосту может обойти повторную аутентификацию (включая MFA). Активные сессии и передачи не закрываются.',
|
||||
'settings.ssh.transportIdleTtl.1m': '1 минута',
|
||||
@@ -945,30 +936,8 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.plugin.unavailable': 'Настроенный провайдер недоступен. Конфигурация сохранится до восстановления плагина.',
|
||||
'hostDetails.plugin.useSsh': 'Использовать SSH',
|
||||
|
||||
// Host form (legacy modal)
|
||||
'hostForm.title.edit': 'Редактировать хост',
|
||||
'hostForm.title.new': 'Новый хост',
|
||||
'hostForm.desc.edit': 'Обновите параметры подключения для этого хоста',
|
||||
'hostForm.desc.new': 'Создайте новую запись SSH-хоста',
|
||||
'hostForm.field.label': 'Метка',
|
||||
'hostForm.placeholder.label': 'Мой production-сервер',
|
||||
'hostForm.field.hostname': 'Имя хоста / IP',
|
||||
'hostForm.placeholder.hostname': '192.168.1.1',
|
||||
'hostForm.field.port': 'Порт',
|
||||
'hostForm.field.username': 'Имя пользователя',
|
||||
'hostForm.field.osType': 'Тип ОС',
|
||||
'hostForm.placeholder.selectOs': 'Выберите ОС',
|
||||
'hostForm.field.group': 'Группа',
|
||||
'hostForm.placeholder.group': 'например, AWS, DigitalOcean',
|
||||
'hostForm.field.tags': 'Теги',
|
||||
'hostForm.placeholder.addTag': 'Добавить тег...',
|
||||
'hostForm.auth.method': 'Метод аутентификации',
|
||||
'hostForm.auth.password': 'Пароль',
|
||||
'hostForm.auth.sshKey': 'SSH-ключ',
|
||||
// Host form (legacy keys still referenced by TerminalAuthDialog)
|
||||
'hostForm.auth.selectKey': 'Выберите SSH-ключ',
|
||||
'hostForm.auth.noKeys': 'Нет доступных ключей',
|
||||
'hostForm.auth.noKeysHint': 'В связке ключей не найдено SSH-ключей. Сначала создайте один.',
|
||||
'hostForm.saveHost': 'Сохранить хост',
|
||||
'vault.import.plugins.title': 'Импортёры плагинов',
|
||||
'vault.import.plugins.loading': 'Создание безопасного предварительного просмотра…',
|
||||
'vault.import.plugins.progress': 'Обработано {completed} из {total} {message}',
|
||||
|
||||
@@ -732,7 +732,6 @@ export const zhCNCoreMessages: Messages = {
|
||||
'knownHosts.empty.title': '暂无已知主机',
|
||||
'knownHosts.empty.desc':
|
||||
'Known Hosts 是你之前连接过的 SSH server。导入系统的 known_hosts 文件以开始。',
|
||||
'knownHosts.results.showingLimited': '显示 {shown}/{total} 个主机。使用搜索查找特定主机。',
|
||||
'knownHosts.toast.scanUnavailable': '当前平台无法扫描系统 known_hosts。',
|
||||
'knownHosts.toast.scanNoFile': '未找到系统 known_hosts 文件。',
|
||||
'knownHosts.toast.scanNoEntries': 'known_hosts 中没有可用条目。',
|
||||
|
||||
@@ -93,13 +93,6 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.tab.sftpFileAssociations': 'SFTP',
|
||||
'settings.sftp.transferConcurrency': '传输并发数',
|
||||
'settings.sftp.transferConcurrency.desc': '同一目标同时传输的文件数量上限,包括文件夹内文件和单独多选的文件。较高的值可能提高速度,但可能导致某些服务器过载。',
|
||||
'settings.sftp.transferPoolIdleTtl': '传输连接保活时间',
|
||||
'settings.sftp.transferPoolIdleTtl.desc': '专用传输 SFTP 连接空闲多久后关闭。时间更长可减少下次上传/下载的冷启动等待;进行中的传输不会被关闭。',
|
||||
'settings.sftp.transferPoolIdleTtl.1m': '1 分钟',
|
||||
'settings.sftp.transferPoolIdleTtl.5m': '5 分钟',
|
||||
'settings.sftp.transferPoolIdleTtl.15m': '15 分钟',
|
||||
'settings.sftp.transferPoolIdleTtl.30m': '30 分钟',
|
||||
'settings.sftp.transferPoolIdleTtl.never': '直到退出应用',
|
||||
'settings.ssh.transportIdleTtl': 'SSH 连接保活时间',
|
||||
'settings.ssh.transportIdleTtl.desc': '最后一个终端、SFTP 浏览、批量传输或端口转发释放后,共享 SSH 连接再保留多久。保留期间再次打开同主机可跳过重新认证(含 MFA);进行中的会话与传输不会被关闭。',
|
||||
'settings.ssh.transportIdleTtl.1m': '1 分钟',
|
||||
|
||||
@@ -331,29 +331,8 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.plugin.useSsh': '改用 SSH',
|
||||
|
||||
// Host form (legacy modal)
|
||||
'hostForm.title.edit': '编辑主机',
|
||||
'hostForm.title.new': '新建主机',
|
||||
'hostForm.desc.edit': '更新该主机的连接信息',
|
||||
'hostForm.desc.new': '创建一个新的 SSH 主机条目',
|
||||
'hostForm.field.label': '名称',
|
||||
'hostForm.placeholder.label': 'My Production Server',
|
||||
'hostForm.field.hostname': 'Hostname / IP',
|
||||
'hostForm.placeholder.hostname': '192.168.1.1',
|
||||
'hostForm.field.port': '端口',
|
||||
'hostForm.field.username': '用户名',
|
||||
'hostForm.field.osType': '操作系统类型',
|
||||
'hostForm.placeholder.selectOs': '选择操作系统',
|
||||
'hostForm.field.group': '分组',
|
||||
'hostForm.placeholder.group': '例如:AWS、DigitalOcean',
|
||||
'hostForm.field.tags': '标签',
|
||||
'hostForm.placeholder.addTag': '添加标签…',
|
||||
'hostForm.auth.method': '认证方式',
|
||||
'hostForm.auth.password': '密码',
|
||||
'hostForm.auth.sshKey': 'SSH密钥',
|
||||
// Host form (legacy keys still referenced by TerminalAuthDialog)
|
||||
'hostForm.auth.selectKey': '选择 SSH密钥',
|
||||
'hostForm.auth.noKeys': '暂无密钥',
|
||||
'hostForm.auth.noKeysHint': '钥匙串中未找到 SSH密钥,请先创建一个。',
|
||||
'hostForm.saveHost': '保存主机',
|
||||
|
||||
// Connection logs
|
||||
'logs.table.date': '日期',
|
||||
|
||||
@@ -732,7 +732,6 @@ export const zhTWCoreMessages: Messages = {
|
||||
'knownHosts.empty.title': '暫無已知主機',
|
||||
'knownHosts.empty.desc':
|
||||
'Known Hosts 是你之前連線過的 SSH server。匯入系統的 known_hosts 檔案以開始。',
|
||||
'knownHosts.results.showingLimited': '顯示 {shown}/{total} 個主機。使用搜索查詢特定主機。',
|
||||
'knownHosts.toast.scanUnavailable': '目前平台無法掃描系統 known_hosts。',
|
||||
'knownHosts.toast.scanNoFile': '未找到系統 known_hosts 檔案。',
|
||||
'knownHosts.toast.scanNoEntries': 'known_hosts 中沒有可用條目。',
|
||||
|
||||
@@ -93,13 +93,6 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.tab.sftpFileAssociations': 'SFTP',
|
||||
'settings.sftp.transferConcurrency': '傳輸併發數',
|
||||
'settings.sftp.transferConcurrency.desc': '同一目標同時傳輸的檔案數量上限,包括資料夾內檔案和單獨多選的檔案。較高的值可能提高速度,但可能導致某些伺服器過載。',
|
||||
'settings.sftp.transferPoolIdleTtl': '傳輸連線保活時間',
|
||||
'settings.sftp.transferPoolIdleTtl.desc': '專用傳輸 SFTP 連線閒置多久後關閉。時間更長可減少下次上傳/下載的冷啟動等待;進行中的傳輸不會被關閉。',
|
||||
'settings.sftp.transferPoolIdleTtl.1m': '1 分鐘',
|
||||
'settings.sftp.transferPoolIdleTtl.5m': '5 分鐘',
|
||||
'settings.sftp.transferPoolIdleTtl.15m': '15 分鐘',
|
||||
'settings.sftp.transferPoolIdleTtl.30m': '30 分鐘',
|
||||
'settings.sftp.transferPoolIdleTtl.never': '直到結束應用',
|
||||
'settings.ssh.transportIdleTtl': 'SSH 連線保活時間',
|
||||
'settings.ssh.transportIdleTtl.desc': '最後一個終端、SFTP 瀏覽、批次傳輸或連接埠轉發釋放後,共享 SSH 連線再保留多久。保留期間再次開啟同主機可略過重新驗證(含 MFA);進行中的工作階段與傳輸不會被關閉。',
|
||||
'settings.ssh.transportIdleTtl.1m': '1 分鐘',
|
||||
|
||||
@@ -331,29 +331,8 @@ export const zhTWVaultMessages: Messages = {
|
||||
'hostDetails.plugin.useSsh': '改用 SSH',
|
||||
|
||||
// Host form (legacy modal)
|
||||
'hostForm.title.edit': '編輯主機',
|
||||
'hostForm.title.new': '新建主機',
|
||||
'hostForm.desc.edit': '更新該主機的連線資訊',
|
||||
'hostForm.desc.new': '建立一個新的 SSH 主機條目',
|
||||
'hostForm.field.label': '名稱',
|
||||
'hostForm.placeholder.label': 'My Production Server',
|
||||
'hostForm.field.hostname': 'Hostname / IP',
|
||||
'hostForm.placeholder.hostname': '192.168.1.1',
|
||||
'hostForm.field.port': '埠',
|
||||
'hostForm.field.username': '使用者名稱',
|
||||
'hostForm.field.osType': '作業系統型別',
|
||||
'hostForm.placeholder.selectOs': '選擇作業系統',
|
||||
'hostForm.field.group': '分組',
|
||||
'hostForm.placeholder.group': '例如:AWS、DigitalOcean',
|
||||
'hostForm.field.tags': '標籤',
|
||||
'hostForm.placeholder.addTag': '新增標籤…',
|
||||
'hostForm.auth.method': '認證方式',
|
||||
'hostForm.auth.password': '密碼',
|
||||
'hostForm.auth.sshKey': 'SSH金鑰',
|
||||
// Host form (legacy keys still referenced by TerminalAuthDialog)
|
||||
'hostForm.auth.selectKey': '選擇 SSH金鑰',
|
||||
'hostForm.auth.noKeys': '暫無金鑰',
|
||||
'hostForm.auth.noKeysHint': '鑰匙串中未找到 SSH金鑰,請先建立一個。',
|
||||
'hostForm.saveHost': '儲存主機',
|
||||
|
||||
// Connection logs
|
||||
'logs.table.date': '日期',
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import type React from 'react';
|
||||
import {
|
||||
STORAGE_KEY_AI_PANEL_DIAGNOSTIC_HIDE,
|
||||
STORAGE_KEY_AI_PANEL_DIAGNOSTIC_PROFILE,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
|
||||
export const AI_PANEL_DIAGNOSTIC_HIDE_KEY = STORAGE_KEY_AI_PANEL_DIAGNOSTIC_HIDE;
|
||||
export const AI_PANEL_DIAGNOSTIC_PROFILE_KEY = STORAGE_KEY_AI_PANEL_DIAGNOSTIC_PROFILE;
|
||||
export const AI_PANEL_FORCE_HIDE_ALL_CONTENT = false;
|
||||
export const AI_PANEL_FORCE_HIDE_SHELL = false;
|
||||
|
||||
export type AIPanelDiagnosticPart =
|
||||
| 'all'
|
||||
| 'attachments'
|
||||
| 'header'
|
||||
| 'history'
|
||||
| 'input'
|
||||
| 'markdown'
|
||||
| 'messages'
|
||||
| 'recent'
|
||||
| 'toolcalls';
|
||||
|
||||
function readLocalStorageValue(key: string): string {
|
||||
try {
|
||||
return localStorageAdapter.readString(key) ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function getAIPanelDiagnosticHiddenParts(): ReadonlySet<string> {
|
||||
if (AI_PANEL_FORCE_HIDE_ALL_CONTENT) {
|
||||
return new Set(['all']);
|
||||
}
|
||||
const raw = readLocalStorageValue(AI_PANEL_DIAGNOSTIC_HIDE_KEY);
|
||||
return new Set(
|
||||
raw
|
||||
.split(',')
|
||||
.map((part) => part.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
export function isAIPanelDiagnosticPartHidden(
|
||||
part: AIPanelDiagnosticPart,
|
||||
hiddenParts = getAIPanelDiagnosticHiddenParts(),
|
||||
): boolean {
|
||||
return hiddenParts.has('all') || hiddenParts.has(part);
|
||||
}
|
||||
|
||||
export function isAIPanelDiagnosticsProfilingEnabled(): boolean {
|
||||
const raw = readLocalStorageValue(AI_PANEL_DIAGNOSTIC_PROFILE_KEY).trim().toLowerCase();
|
||||
return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
|
||||
}
|
||||
|
||||
export function logAIPanelProfiler(
|
||||
id: string,
|
||||
phase: 'mount' | 'update' | 'nested-update',
|
||||
actualDuration: number,
|
||||
baseDuration: number,
|
||||
): void {
|
||||
if (!isAIPanelDiagnosticsProfilingEnabled()) return;
|
||||
console.info(
|
||||
`[AI panel profile] ${id} ${phase}: actual=${actualDuration.toFixed(1)}ms base=${baseDuration.toFixed(1)}ms`,
|
||||
);
|
||||
}
|
||||
|
||||
export function profileAIPanelCalculation<T>(label: string, calculate: () => T): T {
|
||||
if (!isAIPanelDiagnosticsProfilingEnabled()) return calculate();
|
||||
const startedAt = performance.now();
|
||||
try {
|
||||
return calculate();
|
||||
} finally {
|
||||
const elapsed = performance.now() - startedAt;
|
||||
console.info(`[AI panel profile] ${label}: ${elapsed.toFixed(1)}ms`);
|
||||
}
|
||||
}
|
||||
|
||||
export function getAIPanelProfilerProps(id: string): Pick<React.ProfilerProps, 'id' | 'onRender'> {
|
||||
return {
|
||||
id,
|
||||
onRender: logAIPanelProfiler,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
getAppearanceChromeSnapshot,
|
||||
publishAppearanceChromeSnapshot,
|
||||
subscribeAppearanceChrome,
|
||||
} from './appearanceChromeStore.ts';
|
||||
|
||||
test('appearanceChromeStore notifies subscribers only when accent fields change', () => {
|
||||
const events: string[] = [];
|
||||
const unsubscribe = subscribeAppearanceChrome(() => {
|
||||
const snap = getAppearanceChromeSnapshot();
|
||||
events.push(`${snap.accentMode}:${snap.customAccent}`);
|
||||
});
|
||||
|
||||
publishAppearanceChromeSnapshot({ accentMode: 'custom', customAccent: '#ff0000' });
|
||||
assert.equal(events.at(-1), 'custom:#ff0000');
|
||||
assert.equal(getAppearanceChromeSnapshot().customAccent, '#ff0000');
|
||||
|
||||
publishAppearanceChromeSnapshot({ accentMode: 'custom', customAccent: '#ff0000' });
|
||||
assert.equal(events.length, 1);
|
||||
|
||||
publishAppearanceChromeSnapshot({ accentMode: 'custom', customAccent: '#00ff00' });
|
||||
assert.equal(events.at(-1), 'custom:#00ff00');
|
||||
|
||||
publishAppearanceChromeSnapshot({ accentMode: 'theme', customAccent: '#00ff00' });
|
||||
assert.equal(events.at(-1), 'theme:#00ff00');
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
export type AppearanceChromeSnapshot = {
|
||||
accentMode: 'theme' | 'custom';
|
||||
customAccent: string;
|
||||
};
|
||||
|
||||
const DEFAULT_SNAPSHOT: AppearanceChromeSnapshot = Object.freeze({
|
||||
accentMode: 'theme',
|
||||
customAccent: '',
|
||||
});
|
||||
|
||||
/**
|
||||
* External store for app accent chrome so Terminal leaves can apply custom
|
||||
* accent without TerminalLayer rebuilding on every color-picker drag tick.
|
||||
*/
|
||||
class AppearanceChromeStore {
|
||||
private snapshot: AppearanceChromeSnapshot = DEFAULT_SNAPSHOT;
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
getSnapshot = (): AppearanceChromeSnapshot => this.snapshot;
|
||||
|
||||
subscribe = (listener: Listener): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
setSnapshot(next: AppearanceChromeSnapshot): void {
|
||||
if (
|
||||
this.snapshot.accentMode === next.accentMode
|
||||
&& this.snapshot.customAccent === next.customAccent
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.snapshot = next;
|
||||
for (const listener of this.listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const appearanceChromeStore = new AppearanceChromeStore();
|
||||
|
||||
export function publishAppearanceChromeSnapshot(
|
||||
snapshot: AppearanceChromeSnapshot,
|
||||
): void {
|
||||
appearanceChromeStore.setSnapshot(snapshot);
|
||||
}
|
||||
|
||||
export function getAppearanceChromeSnapshot(): AppearanceChromeSnapshot {
|
||||
return appearanceChromeStore.getSnapshot();
|
||||
}
|
||||
|
||||
export function subscribeAppearanceChrome(listener: Listener): () => void {
|
||||
return appearanceChromeStore.subscribe(listener);
|
||||
}
|
||||
|
||||
export function useAppearanceChromeStore(): AppearanceChromeSnapshot {
|
||||
return useSyncExternalStore(
|
||||
subscribeAppearanceChrome,
|
||||
getAppearanceChromeSnapshot,
|
||||
getAppearanceChromeSnapshot,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
EMPTY_NOTES_SNAPSHOT,
|
||||
getEmptyNotesSnapshot,
|
||||
getNotesActions,
|
||||
getNotesSnapshot,
|
||||
publishNotesSnapshot,
|
||||
registerNotesActions,
|
||||
subscribeNotes,
|
||||
subscribeNotesNoop,
|
||||
} from './notesStore.ts';
|
||||
|
||||
test('notesStore notifies subscribers only when snapshot identity changes', () => {
|
||||
const events: number[] = [];
|
||||
const unsubscribe = subscribeNotes(() => {
|
||||
events.push(getNotesSnapshot().notes.length);
|
||||
});
|
||||
|
||||
const firstNotes = [{
|
||||
id: 'n1',
|
||||
title: 'One',
|
||||
content: 'body',
|
||||
tags: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
order: 1000,
|
||||
}];
|
||||
const firstGroups = ['Ops'];
|
||||
publishNotesSnapshot({ notes: firstNotes, noteGroups: firstGroups });
|
||||
assert.equal(events.at(-1), 1);
|
||||
assert.equal(getNotesSnapshot().notes, firstNotes);
|
||||
assert.equal(getNotesSnapshot().noteGroups, firstGroups);
|
||||
|
||||
publishNotesSnapshot({ notes: firstNotes, noteGroups: firstGroups });
|
||||
assert.equal(events.length, 1);
|
||||
|
||||
const secondNotes = [
|
||||
...firstNotes,
|
||||
{
|
||||
id: 'n2',
|
||||
title: 'Two',
|
||||
content: 'more',
|
||||
tags: [],
|
||||
createdAt: 2,
|
||||
updatedAt: 2,
|
||||
order: 2000,
|
||||
},
|
||||
];
|
||||
publishNotesSnapshot({ notes: secondNotes, noteGroups: firstGroups });
|
||||
assert.equal(events.at(-1), 2);
|
||||
|
||||
publishNotesSnapshot({ notes: secondNotes, noteGroups: ['Ops', 'DB'] });
|
||||
assert.equal(events.length, 3);
|
||||
assert.deepEqual(getNotesSnapshot().noteGroups, ['Ops', 'DB']);
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test('notesStore gated helpers stay empty and never notify', () => {
|
||||
let called = 0;
|
||||
const unsub = subscribeNotesNoop(() => {
|
||||
called += 1;
|
||||
});
|
||||
publishNotesSnapshot({
|
||||
notes: [{
|
||||
id: 'n1',
|
||||
title: 'One',
|
||||
content: 'body',
|
||||
tags: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
order: 1000,
|
||||
}],
|
||||
noteGroups: ['Ops'],
|
||||
});
|
||||
assert.equal(called, 0);
|
||||
assert.equal(getEmptyNotesSnapshot(), EMPTY_NOTES_SNAPSHOT);
|
||||
assert.equal(getEmptyNotesSnapshot().notes.length, 0);
|
||||
unsub();
|
||||
});
|
||||
|
||||
test('registerNotesActions exposes update handlers', () => {
|
||||
const calls: string[] = [];
|
||||
registerNotesActions({
|
||||
updateNotes: () => {
|
||||
calls.push('notes');
|
||||
},
|
||||
updateNoteGroups: () => {
|
||||
calls.push('groups');
|
||||
},
|
||||
});
|
||||
const actions = getNotesActions();
|
||||
assert.ok(actions);
|
||||
actions!.updateNotes([]);
|
||||
actions!.updateNoteGroups([]);
|
||||
assert.deepEqual(calls, ['notes', 'groups']);
|
||||
registerNotesActions(null);
|
||||
assert.equal(getNotesActions(), null);
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
import type { VaultNote } from '../../domain/models';
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
export type NotesSnapshot = {
|
||||
notes: readonly VaultNote[];
|
||||
noteGroups: readonly string[];
|
||||
};
|
||||
|
||||
export type NotesActions = {
|
||||
updateNotes: (notes: VaultNote[]) => void;
|
||||
updateNoteGroups: (groups: string[]) => void;
|
||||
};
|
||||
|
||||
const EMPTY_NOTES: readonly VaultNote[] = Object.freeze([]);
|
||||
const EMPTY_NOTE_GROUPS: readonly string[] = Object.freeze([]);
|
||||
|
||||
export const EMPTY_NOTES_SNAPSHOT: NotesSnapshot = Object.freeze({
|
||||
notes: EMPTY_NOTES,
|
||||
noteGroups: EMPTY_NOTE_GROUPS,
|
||||
});
|
||||
|
||||
/**
|
||||
* External store for vault notes so Notes / AI side-panel consumers can
|
||||
* subscribe without forcing TerminalLayer re-renders on every note edit.
|
||||
*/
|
||||
class NotesStore {
|
||||
private snapshot: NotesSnapshot = EMPTY_NOTES_SNAPSHOT;
|
||||
private actions: NotesActions | null = null;
|
||||
private listeners = new Set<Listener>();
|
||||
private actionListeners = new Set<Listener>();
|
||||
|
||||
getSnapshot = (): NotesSnapshot => this.snapshot;
|
||||
|
||||
subscribe = (listener: Listener): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
setSnapshot(next: NotesSnapshot): void {
|
||||
if (
|
||||
this.snapshot.notes === next.notes
|
||||
&& this.snapshot.noteGroups === next.noteGroups
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.snapshot = next;
|
||||
for (const listener of this.listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
getActions = (): NotesActions | null => this.actions;
|
||||
|
||||
subscribeActions = (listener: Listener): (() => void) => {
|
||||
this.actionListeners.add(listener);
|
||||
return () => {
|
||||
this.actionListeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
setActions(next: NotesActions | null): void {
|
||||
if (this.actions === next) return;
|
||||
this.actions = next;
|
||||
for (const listener of this.actionListeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const notesStore = new NotesStore();
|
||||
|
||||
export function publishNotesSnapshot(snapshot: NotesSnapshot): void {
|
||||
notesStore.setSnapshot(snapshot);
|
||||
}
|
||||
|
||||
export function getNotesSnapshot(): NotesSnapshot {
|
||||
return notesStore.getSnapshot();
|
||||
}
|
||||
|
||||
export function subscribeNotes(listener: Listener): () => void {
|
||||
return notesStore.subscribe(listener);
|
||||
}
|
||||
|
||||
/** No-op subscribe for gated (hidden) panel mounts. */
|
||||
export function subscribeNotesNoop(_listener: Listener): () => void {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
export function getEmptyNotesSnapshot(): NotesSnapshot {
|
||||
return EMPTY_NOTES_SNAPSHOT;
|
||||
}
|
||||
|
||||
export function registerNotesActions(actions: NotesActions | null): void {
|
||||
notesStore.setActions(actions);
|
||||
}
|
||||
|
||||
export function getNotesActions(): NotesActions | null {
|
||||
return notesStore.getActions();
|
||||
}
|
||||
|
||||
export function subscribeNotesActions(listener: Listener): () => void {
|
||||
return notesStore.subscribeActions(listener);
|
||||
}
|
||||
|
||||
const noopUpdateNotes: NotesActions['updateNotes'] = () => {};
|
||||
const noopUpdateNoteGroups: NotesActions['updateNoteGroups'] = () => {};
|
||||
|
||||
/**
|
||||
* Subscribe to notes catalog + vault mutation actions for Notes / AI panels.
|
||||
*/
|
||||
export function useNotesStore(): {
|
||||
notes: VaultNote[];
|
||||
noteGroups: string[];
|
||||
updateNotes: NotesActions['updateNotes'];
|
||||
updateNoteGroups: NotesActions['updateNoteGroups'];
|
||||
} {
|
||||
const snapshot = useSyncExternalStore(
|
||||
subscribeNotes,
|
||||
getNotesSnapshot,
|
||||
getNotesSnapshot,
|
||||
);
|
||||
const actions = useSyncExternalStore(
|
||||
subscribeNotesActions,
|
||||
getNotesActions,
|
||||
getNotesActions,
|
||||
);
|
||||
return {
|
||||
notes: snapshot.notes as VaultNote[],
|
||||
noteGroups: snapshot.noteGroups as string[],
|
||||
updateNotes: actions?.updateNotes ?? noopUpdateNotes,
|
||||
updateNoteGroups: actions?.updateNoteGroups ?? noopUpdateNoteGroups,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export type SortField = "name" | "size" | "modified" | "type";
|
||||
export type SortOrder = "asc" | "desc";
|
||||
|
||||
export interface ColumnWidths {
|
||||
name: number;
|
||||
modified: number;
|
||||
size: number;
|
||||
type: number;
|
||||
}
|
||||
|
||||
export type SftpColumnVisibility = Record<keyof ColumnWidths, boolean>;
|
||||
|
||||
export const DEFAULT_SFTP_COLUMN_VISIBILITY: SftpColumnVisibility = {
|
||||
name: true,
|
||||
modified: true,
|
||||
size: true,
|
||||
type: true,
|
||||
};
|
||||
|
||||
export const normalizeSftpColumnVisibility = (value: unknown): SftpColumnVisibility => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return DEFAULT_SFTP_COLUMN_VISIBILITY;
|
||||
}
|
||||
|
||||
const stored = value as Partial<Record<keyof ColumnWidths, unknown>>;
|
||||
return {
|
||||
name: true,
|
||||
modified: stored.modified !== false,
|
||||
size: stored.size !== false,
|
||||
type: stored.type !== false,
|
||||
};
|
||||
};
|
||||
@@ -1,31 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { resumeCompressedUploadSafely } from "./compressedUploadControl.ts";
|
||||
|
||||
test("live compressed resume failure stays paused instead of starting a second transfer", async () => {
|
||||
const outcome = await resumeCompressedUploadSafely({
|
||||
transferId: "compressed-live",
|
||||
reconnectRequired: false,
|
||||
resume: async () => ({ success: false, reason: "Upload resume is unavailable" }),
|
||||
});
|
||||
assert.deepEqual(outcome, { kind: "failed", reason: "Upload resume is unavailable" });
|
||||
});
|
||||
|
||||
test("restored compressed task may restart when its old worker no longer exists", async () => {
|
||||
const outcome = await resumeCompressedUploadSafely({
|
||||
transferId: "compressed-restored",
|
||||
reconnectRequired: true,
|
||||
resume: async () => ({ success: false, reason: "Compression is not active" }),
|
||||
});
|
||||
assert.deepEqual(outcome, { kind: "restart", reason: "Compression is not active" });
|
||||
});
|
||||
|
||||
test("successful compressed resume rejoins the existing job", async () => {
|
||||
const outcome = await resumeCompressedUploadSafely({
|
||||
transferId: "compressed-live",
|
||||
reconnectRequired: false,
|
||||
resume: async () => ({ success: true }),
|
||||
});
|
||||
assert.deepEqual(outcome, { kind: "resumed" });
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
export type CompressedUploadResumeOutcome =
|
||||
| { kind: "resumed" }
|
||||
| { kind: "restart"; reason?: string }
|
||||
| { kind: "failed"; reason: string };
|
||||
|
||||
export async function resumeCompressedUploadSafely(params: {
|
||||
transferId: string;
|
||||
reconnectRequired: boolean;
|
||||
resume?: (transferId: string) => Promise<{ success: boolean; reason?: string }>;
|
||||
}): Promise<CompressedUploadResumeOutcome> {
|
||||
const result = await (params.resume?.(params.transferId)
|
||||
?? { success: false, reason: "Resume unavailable" });
|
||||
if (result.success) return { kind: "resumed" };
|
||||
if (params.reconnectRequired) return { kind: "restart", reason: result.reason };
|
||||
return { kind: "failed", reason: result.reason ?? "Could not resume the compressed upload." };
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
||||
import type { SftpBookmark } from "../../../domain/models";
|
||||
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
import { STORAGE_KEY_SFTP_LOCAL_BOOKMARKS } from "../../../infrastructure/config/storageKeys";
|
||||
import { createSftpBookmark } from "./bookmarkHelpers";
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
let snapshot: SftpBookmark[] =
|
||||
localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_LOCAL_BOOKMARKS) ?? [];
|
||||
|
||||
export function subscribeLocalSftpBookmarks(listener: Listener) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function getLocalSftpBookmarksSnapshot() {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function rehydrateLocalSftpBookmarks() {
|
||||
snapshot = localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_LOCAL_BOOKMARKS) ?? [];
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
export function setLocalSftpBookmarks(
|
||||
next: SftpBookmark[] | ((prev: SftpBookmark[]) => SftpBookmark[]),
|
||||
) {
|
||||
snapshot = typeof next === "function" ? next(snapshot) : next;
|
||||
localStorageAdapter.write(STORAGE_KEY_SFTP_LOCAL_BOOKMARKS, snapshot);
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("storage", (event) => {
|
||||
if (event.key === STORAGE_KEY_SFTP_LOCAL_BOOKMARKS) {
|
||||
rehydrateLocalSftpBookmarks();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
interface UseLocalSftpBookmarksParams {
|
||||
currentPath: string | undefined;
|
||||
}
|
||||
|
||||
export const useLocalSftpBookmarks = ({
|
||||
currentPath,
|
||||
}: UseLocalSftpBookmarksParams) => {
|
||||
const bookmarks = useSyncExternalStore(
|
||||
subscribeLocalSftpBookmarks,
|
||||
getLocalSftpBookmarksSnapshot,
|
||||
getLocalSftpBookmarksSnapshot,
|
||||
);
|
||||
|
||||
const isCurrentPathBookmarked = useMemo(
|
||||
() => !!currentPath && bookmarks.some((b) => b.path === currentPath),
|
||||
[currentPath, bookmarks],
|
||||
);
|
||||
|
||||
const toggleBookmark = useCallback(() => {
|
||||
if (!currentPath) return;
|
||||
if (isCurrentPathBookmarked) {
|
||||
setLocalSftpBookmarks((prev) => prev.filter((b) => b.path !== currentPath));
|
||||
} else {
|
||||
setLocalSftpBookmarks((prev) => [...prev, createSftpBookmark(currentPath)]);
|
||||
}
|
||||
}, [currentPath, isCurrentPathBookmarked]);
|
||||
|
||||
const deleteBookmark = useCallback((id: string) => {
|
||||
setLocalSftpBookmarks((prev) => prev.filter((b) => b.id !== id));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
bookmarks,
|
||||
isCurrentPathBookmarked,
|
||||
toggleBookmark,
|
||||
deleteBookmark,
|
||||
};
|
||||
};
|
||||
File renamed without changes.
+1
-1
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore, useEffect } from "react";
|
||||
import { sftpFocusStore, SftpFocusedSide } from "./useSftpFocusedPane";
|
||||
import { sftpFocusStore, SftpFocusedSide } from "./sftpFocusStore";
|
||||
|
||||
type SftpDialogActionType = "rename" | "delete" | "newFolder" | "newFile" | null;
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,25 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
DEFAULT_SFTP_TRANSFER_POOL_IDLE_TTL_MS,
|
||||
isTransferPoolIdleReclaimDisabled,
|
||||
resolveSftpTransferPoolIdleTtlMs,
|
||||
} from "../../../infrastructure/config/sftpTransferPool.ts";
|
||||
|
||||
test("default pool idle TTL is five minutes", () => {
|
||||
assert.equal(DEFAULT_SFTP_TRANSFER_POOL_IDLE_TTL_MS, 5 * 60_000);
|
||||
assert.equal(resolveSftpTransferPoolIdleTtlMs(() => null), DEFAULT_SFTP_TRANSFER_POOL_IDLE_TTL_MS);
|
||||
});
|
||||
|
||||
test("accepts preset values including never-reclaim zero", () => {
|
||||
assert.equal(resolveSftpTransferPoolIdleTtlMs(() => 60_000), 60_000);
|
||||
assert.equal(resolveSftpTransferPoolIdleTtlMs(() => 0), 0);
|
||||
assert.equal(resolveSftpTransferPoolIdleTtlMs(() => 123), DEFAULT_SFTP_TRANSFER_POOL_IDLE_TTL_MS);
|
||||
});
|
||||
|
||||
test("transfer pool idle reclaim helper is deprecated and always reclaim-enabled", () => {
|
||||
// Channels close when idle; SSH park is separate. The helper always returns false.
|
||||
assert.equal(isTransferPoolIdleReclaimDisabled(0), false);
|
||||
assert.equal(isTransferPoolIdleReclaimDisabled(300_000), false);
|
||||
});
|
||||
@@ -111,9 +111,4 @@ export interface SftpStateOptions {
|
||||
* This keeps side-panel tab switches on the already-authenticated SSH transport.
|
||||
*/
|
||||
resolveBrowseSourceSessionId?: (hostId: string, host?: Host) => string | undefined;
|
||||
/**
|
||||
* @deprecated Transfer channels no longer park independently. SSH keep-alive
|
||||
* is controlled by sshTransportIdleTtlMs in settings.
|
||||
*/
|
||||
transferPoolIdleTtlMs?: number;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import type { Host, Identity, KnownHost, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";
|
||||
import { createKnownHostFromHostKeyInfo } from "../../../domain/knownHosts";
|
||||
import type { SftpHostKeyInfo, SftpHostKeyVerificationState, SftpPane } from "./types";
|
||||
import { useSftpDirectoryListing } from "./useSftpDirectoryListing";
|
||||
import { useSftpHostCredentials } from "./useSftpHostCredentials";
|
||||
@@ -206,15 +207,7 @@ 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,
|
||||
});
|
||||
): KnownHost => createKnownHostFromHostKeyInfo(hostKeyInfo, { now, idSuffix });
|
||||
|
||||
export const useSftpConnections = ({
|
||||
hosts,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const source = readFileSync(new URL("./useSftpPaneSorting.ts", import.meta.url), "utf8");
|
||||
|
||||
test("useSftpPaneSorting imports column layout from application state", () => {
|
||||
assert.doesNotMatch(source, /from ["'].*components\//);
|
||||
assert.match(source, /from ["']\.\/columnLayout["']/);
|
||||
});
|
||||
+2
-2
@@ -6,13 +6,13 @@ import {
|
||||
type SftpColumnVisibility,
|
||||
type SortField,
|
||||
type SortOrder,
|
||||
} from "../utils";
|
||||
} from "./columnLayout";
|
||||
import { STORAGE_KEY_SFTP_VISIBLE_COLUMNS } from "../../../infrastructure/config/storageKeys";
|
||||
import {
|
||||
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
|
||||
localStorageAdapter,
|
||||
} from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
import { useSftpDirectoriesFirst } from "../../../application/state/sftp/useSftpDirectoriesFirst";
|
||||
import { useSftpDirectoriesFirst } from "./useSftpDirectoriesFirst";
|
||||
|
||||
export interface UseSftpPaneSortingResult {
|
||||
sortField: SortField;
|
||||
@@ -2,25 +2,6 @@ import { useEffect, useLayoutEffect, useRef } from "react";
|
||||
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
|
||||
export function useWarmSftpTransferPool(params: {
|
||||
hostIds: readonly string[];
|
||||
activeHostId?: string;
|
||||
warmTransferPoolForHost?: (hostId: string) => void | Promise<void>;
|
||||
}) {
|
||||
const warmRef = useRef(params.warmTransferPoolForHost);
|
||||
warmRef.current = params.warmTransferPoolForHost;
|
||||
const hostIdsKey = [...new Set([
|
||||
...params.hostIds,
|
||||
...(params.activeHostId ? [params.activeHostId] : []),
|
||||
])].sort().join("\u0000");
|
||||
|
||||
useEffect(() => {
|
||||
for (const hostId of hostIdsKey.split("\u0000").filter(Boolean)) {
|
||||
void warmRef.current?.(hostId);
|
||||
}
|
||||
}, [hostIdsKey]);
|
||||
}
|
||||
|
||||
export function useReportSftpTransferOwnerActivity(params: {
|
||||
ownerId: string;
|
||||
activeTransfersCount: number;
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { SftpFileEntry, TransferTask } from "../../../domain/models";
|
||||
import { formatBytes } from "../../../domain/formatBytes";
|
||||
|
||||
export const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return "--";
|
||||
const units = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
const size = bytes / Math.pow(1024, i);
|
||||
return `${size.toFixed(i === 0 ? 0 : 2)} ${units[i]}`;
|
||||
return formatBytes(bytes, {
|
||||
zeroDisplay: "--",
|
||||
includeTB: true,
|
||||
fractionDigits: 2,
|
||||
byteUnit: "Bytes",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -2318,7 +2318,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
const browserPersistence: StorePersistence | undefined = typeof globalThis.localStorage === "undefined"
|
||||
? undefined
|
||||
: {
|
||||
read: () => globalThis.localStorage.getItem(STORAGE_KEY_SFTP_TRANSFER_CENTER),
|
||||
read: () => localStorageAdapter.readString(STORAGE_KEY_SFTP_TRANSFER_CENTER),
|
||||
write: (value) => { localStorageAdapter.writeString(STORAGE_KEY_SFTP_TRANSFER_CENTER, value); },
|
||||
};
|
||||
|
||||
|
||||
File renamed without changes.
@@ -172,15 +172,6 @@ export const useUIFontsLoaded = (): boolean => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a font ID is valid
|
||||
*/
|
||||
export const isValidUiFontId = (fontId: string): boolean => {
|
||||
// Local fonts are always considered valid (they start with 'local-')
|
||||
if (fontId.startsWith('local-')) return true;
|
||||
return uiFontStore.getAvailableFonts().some(f => f.id === fontId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize UI fonts eagerly
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const source = readFileSync(new URL("./useAIChatStreaming.ts", import.meta.url), "utf8");
|
||||
|
||||
test("useAIChatStreaming imports streaming support from infrastructure", () => {
|
||||
assert.match(source, /from ['"]\.\.\/\.\.\/infrastructure\/ai\/aiChatStreamingSupport['"]/);
|
||||
assert.doesNotMatch(source, /from ['"].*components\//);
|
||||
});
|
||||
+10
-10
@@ -15,26 +15,26 @@ import type {
|
||||
ExternalAgentConfig,
|
||||
ProviderConfig,
|
||||
WebSearchConfig,
|
||||
} from '../../../infrastructure/ai/types';
|
||||
import type { ExecutorContext } from '../../../infrastructure/ai/cattyAgent/executor';
|
||||
import { getAgentRuntime } from '../../../infrastructure/ai/harness/globalAgentRuntime';
|
||||
} from '../../infrastructure/ai/types';
|
||||
import type { ExecutorContext } from '../../infrastructure/ai/cattyAgent/executor';
|
||||
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime';
|
||||
import type {
|
||||
TurnSteerInput,
|
||||
TurnSteerResult,
|
||||
} from '../../../infrastructure/ai/harness/turnDrivers/types';
|
||||
import { classifyError } from '../../../infrastructure/ai/errorClassifier';
|
||||
import { latestAISessionsSnapshot } from '../../../application/state/aiStateSnapshots';
|
||||
} from '../../infrastructure/ai/harness/turnDrivers/types';
|
||||
import { classifyError } from '../../infrastructure/ai/errorClassifier';
|
||||
import { latestAISessionsSnapshot } from './aiStateSnapshots';
|
||||
import {
|
||||
generateId,
|
||||
getNetcattyBridge,
|
||||
type DefaultTargetSessionHint,
|
||||
type TerminalSessionInfo,
|
||||
} from './aiChatStreamingSupport';
|
||||
} from '../../infrastructure/ai/aiChatStreamingSupport';
|
||||
import { useAgentCompactionUi } from './useAgentCompactionUi';
|
||||
|
||||
export { getNetcattyBridge } from './aiChatStreamingSupport';
|
||||
export { getNetcattyBridge } from '../../infrastructure/ai/aiChatStreamingSupport';
|
||||
export type { ActiveCompactionUi } from './useAgentCompactionUi';
|
||||
export type { DefaultTargetSessionHint } from './aiChatStreamingSupport';
|
||||
export type { DefaultTargetSessionHint } from '../../infrastructure/ai/aiChatStreamingSupport';
|
||||
|
||||
const sharedStreamingSessionIds = new Set<string>();
|
||||
const sharedAbortControllers = new Map<string, AbortController>();
|
||||
@@ -62,7 +62,7 @@ export interface UseAIChatStreamingParams {
|
||||
updateMessageById: (sessionId: string, messageId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
|
||||
persistContextCompaction?: (
|
||||
sessionId: string,
|
||||
compaction: import('../../../infrastructure/ai/types').AISessionContextCompaction,
|
||||
compaction: import('../../infrastructure/ai/types').AISessionContextCompaction,
|
||||
) => void;
|
||||
}
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ContextPrepareTrigger } from '../../../infrastructure/ai/harness/types';
|
||||
import { getAgentRuntime } from '../../../infrastructure/ai/harness/globalAgentRuntime';
|
||||
import { CATTY_COMPACTION_STATUS_KEYS } from '../../../infrastructure/ai/harness/compactionStatusKeys';
|
||||
import type { ContextPrepareTrigger } from '../../infrastructure/ai/harness/types';
|
||||
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime';
|
||||
import { CATTY_COMPACTION_STATUS_KEYS } from '../../infrastructure/ai/harness/compactionStatusKeys';
|
||||
|
||||
export interface ActiveCompactionUi {
|
||||
sessionId: string;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { buildAITerminalSessionInfo } from '../../components/terminalLayer/TerminalLayerSupport';
|
||||
import { buildAITerminalSessionInfo } from '../../domain/buildAITerminalSessionInfo';
|
||||
import { detectLocalOs } from '../../lib/localShell';
|
||||
import type { Host, PortForwardingRule, TerminalSession } from '../../types';
|
||||
import { STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED } from '../../infrastructure/config/storageKeys';
|
||||
|
||||
@@ -144,12 +144,26 @@ export function readExternalMcpMode(): ExternalMcpMode {
|
||||
return normalizeExternalMcpMode(localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE));
|
||||
}
|
||||
|
||||
export function writeExternalMcpMode(mode: ExternalMcpMode): ExternalMcpMode {
|
||||
const normalized = normalizeExternalMcpMode(mode);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, normalized);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_MODE);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function readExternalMcpIdleTimeoutMinutes(): number {
|
||||
return normalizeExternalMcpIdleTimeoutMinutes(
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES),
|
||||
);
|
||||
}
|
||||
|
||||
export function writeExternalMcpIdleTimeoutMinutes(minutes: number): number {
|
||||
const normalized = normalizeExternalMcpIdleTimeoutMinutes(minutes);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES, normalized);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** Whether host_open should surface/focus the main window. Defaults to true (existing behavior). */
|
||||
export function readExternalMcpFocusOnHostOpen(): boolean {
|
||||
return localStorageAdapter.readBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_FOCUS_ON_HOST_OPEN) ?? true;
|
||||
@@ -187,6 +201,13 @@ export function readSessionIdleTimeoutMinutes(): number {
|
||||
);
|
||||
}
|
||||
|
||||
export function writeSessionIdleTimeoutMinutes(minutes: number): number {
|
||||
const normalized = normalizeSessionIdleTimeoutMinutes(minutes);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES, normalized);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function shouldStartExternalMcpOnStartup({
|
||||
enabled,
|
||||
mode,
|
||||
|
||||
@@ -782,6 +782,9 @@ export function useOutputTriggers({
|
||||
resetTriggerScanState();
|
||||
},
|
||||
schedule: (callback) => {
|
||||
// Prefer setTimeout over rAF: terminal output can continue while the
|
||||
// Electron renderer is hidden/minimized, when rAF is throttled or paused.
|
||||
// Pending scan buffers are capped, so delayed flushes can drop matches.
|
||||
const timer = globalThis.setTimeout(callback, OUTPUT_TRIGGER_SCAN_DELAY_MS);
|
||||
return () => globalThis.clearTimeout(timer);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const source = readFileSync(new URL("./usePortForwardHostKeyVerification.ts", import.meta.url), "utf8");
|
||||
|
||||
test("usePortForwardHostKeyVerification stays below the UI layer", () => {
|
||||
assert.doesNotMatch(source, /from ["'].*components\//);
|
||||
assert.match(source, /from ["']\.\.\/\.\.\/domain\/portForwardHostKey["']/);
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { KnownHost } from "../../domain/models";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
import type { HostKeyInfo } from "../../components/terminal/TerminalHostKeyVerification";
|
||||
import type { HostKeyInfo } from "../../domain/hostKey";
|
||||
import {
|
||||
createKnownHostFromPortForwardHostKeyInfo,
|
||||
enqueuePortForwardHostKeyVerification,
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
toPendingPortForwardHostKeyVerification,
|
||||
type PendingPortForwardHostKeyVerification,
|
||||
type PortForwardHostKeyRequest,
|
||||
} from "../../components/port-forwarding/hostKeyVerification";
|
||||
} from "../../domain/portForwardHostKey";
|
||||
|
||||
export interface PortForwardHostKeyVerificationState {
|
||||
hostKeyInfo: HostKeyInfo;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { netcattyBridge } from '../../../infrastructure/services/netcattyBridge';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export interface DiskInfo {
|
||||
capacityKey?: string; // Filesystem or shared-pool identity for deduplication
|
||||
@@ -619,17 +619,26 @@ export const useSessionState = ({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const submitWorkspaceRename = useCallback(() => {
|
||||
const submitWorkspaceRename = useCallback((workspaceId?: string, name?: string) => {
|
||||
if (workspaceId !== undefined && name !== undefined) {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
setWorkspaces(prev => prev.map(w => (
|
||||
w.id === workspaceId ? { ...w, title: trimmed, autoTitle: false } : w
|
||||
)));
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkspaceRenameValue(prevValue => {
|
||||
const name = prevValue.trim();
|
||||
if (!name) return prevValue;
|
||||
|
||||
const trimmed = prevValue.trim();
|
||||
if (!trimmed) return prevValue;
|
||||
|
||||
setWorkspaceRenameTarget(prevTarget => {
|
||||
if (!prevTarget) return prevTarget;
|
||||
setWorkspaces(prev => prev.map(w => w.id === prevTarget.id ? { ...w, title: name, autoTitle: false } : w));
|
||||
setWorkspaces(prev => prev.map(w => w.id === prevTarget.id ? { ...w, title: trimmed, autoTitle: false } : w));
|
||||
return null;
|
||||
});
|
||||
|
||||
|
||||
return '';
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
|
||||
|
||||
import { runThemeTransition, type ThemeTransitionMode } from './themeTransition';
|
||||
import { publishAppearanceChromeSnapshot } from './appearanceChromeStore';
|
||||
import { SyncConfig, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } from '../../domain/models';
|
||||
import {
|
||||
DEFAULT_HTTP_NETWORK_PROXY,
|
||||
@@ -967,6 +968,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
const persistedAppearanceChanged = previousAppearance === null
|
||||
|| hasPersistedAppearanceChanged(previousAppearance, appearanceRender);
|
||||
previousAppearanceRenderRef.current = appearanceRender;
|
||||
// Terminal leaves subscribe here so accent drag does not rebuild TerminalLayer.
|
||||
publishAppearanceChromeSnapshot({ accentMode, customAccent });
|
||||
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
|
||||
const apply = () => applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
|
||||
const transitionMode = appearanceTransitionModeRef.current;
|
||||
@@ -1841,7 +1844,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
// Opaque version that changes when any synced setting changes, used by useAutoSync.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
settingsVersion: useMemo(() => Math.random(), [
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode,
|
||||
uiFontFamilyId, uiLanguage, customCSS,
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
||||
customKeyBindings, editorWordWrap,
|
||||
|
||||
@@ -399,14 +399,6 @@ export const useSftpState = (
|
||||
[hosts, identities, keys, openPoolSftpSession, transferKnownHosts, transferPoolKeyCache, transferTerminalSettings],
|
||||
);
|
||||
|
||||
/**
|
||||
* @deprecated No-op. SSH transport idle park keeps connections warm; opening
|
||||
* a background transfer channel is unnecessary and could re-trigger MFA.
|
||||
*/
|
||||
const warmTransferPoolForHost = useCallback(async (_hostId: string) => {
|
||||
// Intentionally empty — unified transport registry owns keep-alive.
|
||||
}, []);
|
||||
|
||||
/** True after browse channels were soft-closed while this owner stayed mounted. */
|
||||
const browseParkedRef = useRef(false);
|
||||
const browseLifecycleGenRef = useRef(0);
|
||||
@@ -824,7 +816,6 @@ export const useSftpState = (
|
||||
rejectHostKeyVerification,
|
||||
acceptHostKeyVerification,
|
||||
acceptAndSaveHostKeyVerification,
|
||||
warmTransferPoolForHost,
|
||||
};
|
||||
const methodsRef = useRef(currentMethods);
|
||||
methodsRef.current = currentMethods;
|
||||
@@ -907,8 +898,6 @@ export const useSftpState = (
|
||||
rejectHostKeyVerification: () => methodsRef.current.rejectHostKeyVerification(),
|
||||
acceptHostKeyVerification: () => methodsRef.current.acceptHostKeyVerification(),
|
||||
acceptAndSaveHostKeyVerification: () => methodsRef.current.acceptAndSaveHostKeyVerification(),
|
||||
warmTransferPoolForHost: (...args: Parameters<typeof warmTransferPoolForHost>) =>
|
||||
methodsRef.current.warmTransferPoolForHost(...args),
|
||||
activeFileWatchCountRef,
|
||||
}), [activeFileWatchCountRef]); // activeFileWatchCountRef is a stable ref
|
||||
|
||||
|
||||
+6
-6
@@ -1,10 +1,10 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../../application/i18n/I18nProvider';
|
||||
import type { I18nContextValue } from '../../../application/i18n/I18nProvider';
|
||||
import { sessionCapabilitiesStore } from '../../../application/state/sessionCapabilitiesStore';
|
||||
import type { SessionCapabilities } from '../../../domain/systemManager/types';
|
||||
import type { useSystemManagerBackend } from '../../../application/state/useSystemManagerBackend';
|
||||
import { nextPollData } from '../listStable';
|
||||
import { useI18n } from '../i18n/I18nProvider';
|
||||
import type { I18nContextValue } from '../i18n/I18nProvider';
|
||||
import { sessionCapabilitiesStore } from './sessionCapabilitiesStore';
|
||||
import type { SessionCapabilities } from '../../domain/systemManager/types';
|
||||
import type { useSystemManagerBackend } from './useSystemManagerBackend';
|
||||
import { nextPollData } from '../../domain/systemManager/pollListStable';
|
||||
|
||||
type Backend = ReturnType<typeof useSystemManagerBackend>;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const source = readFileSync(new URL("./useTerminalAiContexts.ts", import.meta.url), "utf8");
|
||||
|
||||
test("useTerminalAiContexts imports AI context primitives from domain", () => {
|
||||
assert.match(source, /from ['"]\.\.\/\.\.\/domain\/buildAITerminalSessionInfo['"]/);
|
||||
assert.doesNotMatch(source, /from ['"].*components\//);
|
||||
});
|
||||
+4
-1
@@ -2,11 +2,14 @@ import type React from 'react';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
|
||||
import { retainStableAiPanelContexts } from '../../domain/aiPanelContextsEqual';
|
||||
import {
|
||||
buildAITerminalSessionInfo,
|
||||
type AIPanelContext,
|
||||
} from '../../domain/buildAITerminalSessionInfo';
|
||||
import { collectSessionIds } from '../../domain/workspace';
|
||||
import type { TerminalContextReader } from '../../domain/terminalContextRead';
|
||||
import { detectLocalOs } from '../../lib/localShell';
|
||||
import type { Host, PortForwardingRule, TerminalSession, Workspace } from '../../types';
|
||||
import { buildAITerminalSessionInfo, type AIPanelContext } from './TerminalLayerSupport';
|
||||
|
||||
interface UseTerminalAiContextsOptions {
|
||||
hosts: Host[];
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { TerminalTheme } from '../../domain/models';
|
||||
import {
|
||||
@@ -42,7 +42,9 @@ export function useThemeRuntime(settings: ThemeRuntimeSettings) {
|
||||
|
||||
const [userIntent, setUserIntent] = useState<ThemeUserIntent>(idleThemeUserIntent());
|
||||
|
||||
const appearanceSettings = useMemo((): TerminalAppearanceSettings => ({
|
||||
// Theme-id settings without accent — keeps resolveFocusedAppearance identity
|
||||
// stable during color-picker drag (TerminalLayer memo depends on it).
|
||||
const appearanceSettingsBase = useMemo((): Omit<TerminalAppearanceSettings, 'accentMode' | 'customAccent'> => ({
|
||||
terminalThemeId,
|
||||
terminalThemeDarkId,
|
||||
terminalThemeLightId,
|
||||
@@ -50,8 +52,6 @@ export function useThemeRuntime(settings: ThemeRuntimeSettings) {
|
||||
resolvedTheme,
|
||||
lightUiThemeId,
|
||||
darkUiThemeId,
|
||||
accentMode,
|
||||
customAccent,
|
||||
}), [
|
||||
terminalThemeId,
|
||||
terminalThemeDarkId,
|
||||
@@ -60,9 +60,17 @@ export function useThemeRuntime(settings: ThemeRuntimeSettings) {
|
||||
resolvedTheme,
|
||||
lightUiThemeId,
|
||||
darkUiThemeId,
|
||||
]);
|
||||
|
||||
const accentRef = useRef({ accentMode, customAccent });
|
||||
accentRef.current = { accentMode, customAccent };
|
||||
|
||||
// Chrome / injection consumers need live accent on every settings change.
|
||||
const appearanceSettings = useMemo((): TerminalAppearanceSettings => ({
|
||||
...appearanceSettingsBase,
|
||||
accentMode,
|
||||
customAccent,
|
||||
]);
|
||||
}), [appearanceSettingsBase, accentMode, customAccent]);
|
||||
|
||||
const globalAppearance = useMemo(() => resolveGlobalTerminalAppearance({
|
||||
userIntent,
|
||||
@@ -70,14 +78,21 @@ export function useThemeRuntime(settings: ThemeRuntimeSettings) {
|
||||
customThemes,
|
||||
}), [userIntent, appearanceSettings, customThemes]);
|
||||
|
||||
// Read accent from a ref so this callback identity does not churn on drag.
|
||||
// Callers that invoke it after an accent-only update still get the latest
|
||||
// accent; Terminal panes also re-apply appearanceChromeStore at the leaf.
|
||||
const resolveFocusedAppearance = useCallback((hostScope: TerminalAppearanceHostScope): ResolvedAppearance => (
|
||||
resolveTerminalAppearance({
|
||||
userIntent,
|
||||
settings: appearanceSettings,
|
||||
settings: {
|
||||
...appearanceSettingsBase,
|
||||
accentMode: accentRef.current.accentMode,
|
||||
customAccent: accentRef.current.customAccent,
|
||||
},
|
||||
hostScope,
|
||||
customThemes,
|
||||
})
|
||||
), [userIntent, appearanceSettings, customThemes]);
|
||||
), [userIntent, appearanceSettingsBase, customThemes]);
|
||||
|
||||
const applyFollowAppSettingsForPick = useCallback((themeId: string) => {
|
||||
const update = getFollowAppTerminalThemeSelectionUpdate(themeId);
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const source = readFileSync(new URL("./useVaultImportHandlers.ts", import.meta.url), "utf8");
|
||||
|
||||
test("useVaultImportHandlers stays below the UI layer", () => {
|
||||
assert.doesNotMatch(source, /from ["'].*components\//);
|
||||
assert.doesNotMatch(source, /from ["'].*\/toast["']/);
|
||||
assert.match(source, /notify: VaultImportNotifier/);
|
||||
assert.match(source, /from ["']\.\/vaultImportOptions["']/);
|
||||
});
|
||||
+17
-15
@@ -4,7 +4,7 @@ import {
|
||||
readRememberedKeyPassphrases,
|
||||
rememberImportedKeyPassphrase,
|
||||
resolveDefaultKeyPassphraseAliases,
|
||||
} from "../../application/defaultKeyPassphrases";
|
||||
} from "../defaultKeyPassphrases";
|
||||
import {
|
||||
countVaultImportDuplicates,
|
||||
ensureVaultImportPersisted,
|
||||
@@ -15,12 +15,12 @@ import {
|
||||
waitForVaultImportProgressPaint,
|
||||
type VaultHostPersistenceResult,
|
||||
type VaultImportProgress,
|
||||
} from "../../application/state/vaultImportProgress";
|
||||
import { importVaultHostsInWorker } from "../../application/state/vaultImportWorker";
|
||||
} from "./vaultImportProgress";
|
||||
import { importVaultHostsInWorker } from "./vaultImportWorker";
|
||||
import {
|
||||
type VaultLockHandle,
|
||||
withVaultImportLock,
|
||||
} from "../../application/state/vaultManagedImportLock";
|
||||
} from "./vaultManagedImportLock";
|
||||
|
||||
/** Exit the import lock so a concurrent host save can finish, then retry. */
|
||||
const RETRY_VAULT_IMPORT_AFTER_CONCURRENT_EDIT = Symbol("retry-vault-import-after-concurrent-edit");
|
||||
@@ -36,14 +36,14 @@ import {
|
||||
type VaultImportFormat,
|
||||
} from "../../domain/vaultImport";
|
||||
import type { GroupConfig, Host, ManagedSource, SSHKey } from "../../types";
|
||||
import type { ImportOptions } from "./ImportVaultDialog";
|
||||
import { toast } from "../ui/toast";
|
||||
import type { VaultImportNotifier, VaultImportOptions } from "./vaultImportOptions";
|
||||
|
||||
interface UseVaultImportHandlersOptions {
|
||||
customGroups: string[];
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
managedSources: ManagedSource[];
|
||||
notify: VaultImportNotifier;
|
||||
onReadPersistedHosts: () => Promise<Host[]>;
|
||||
onUpdateHosts: (hosts: Host[]) => VaultHostPersistenceResult | Promise<VaultHostPersistenceResult>;
|
||||
onUpdateKeys: (keys: SSHKey[]) => void;
|
||||
@@ -73,6 +73,7 @@ export function useVaultImportHandlers({
|
||||
hosts,
|
||||
keys,
|
||||
managedSources,
|
||||
notify,
|
||||
onReadPersistedHosts,
|
||||
onUpdateHosts,
|
||||
onUpdateKeys,
|
||||
@@ -109,7 +110,7 @@ export function useVaultImportHandlers({
|
||||
}, [setIsImportOpen]);
|
||||
|
||||
const handleImportFileSelected = useCallback(
|
||||
async (format: VaultImportFormat, files: File[], options?: ImportOptions) => {
|
||||
async (format: VaultImportFormat, files: File[], options?: VaultImportOptions) => {
|
||||
const file = files[0];
|
||||
if (!file) return;
|
||||
if (importInFlightRef.current) return;
|
||||
@@ -287,7 +288,7 @@ export function useVaultImportHandlers({
|
||||
stage: "failed",
|
||||
error: message,
|
||||
});
|
||||
toast.error(
|
||||
notify.error(
|
||||
message,
|
||||
t("vault.import.sshConfig.noFilePath"),
|
||||
);
|
||||
@@ -305,7 +306,7 @@ export function useVaultImportHandlers({
|
||||
stage: "failed",
|
||||
error: message,
|
||||
});
|
||||
toast.error(
|
||||
notify.error(
|
||||
message,
|
||||
t("vault.import.sshConfig.alreadyManaged"),
|
||||
);
|
||||
@@ -601,7 +602,7 @@ export function useVaultImportHandlers({
|
||||
stage: "failed",
|
||||
error: message,
|
||||
});
|
||||
toast.error(
|
||||
notify.error(
|
||||
message,
|
||||
t("vault.import.toast.failedTitle"),
|
||||
);
|
||||
@@ -617,7 +618,7 @@ export function useVaultImportHandlers({
|
||||
skipped,
|
||||
duplicates,
|
||||
});
|
||||
toast.warning(
|
||||
notify.warning(
|
||||
t("vault.import.toast.noNewHosts", { format: formatLabel }),
|
||||
t("vault.import.toast.completedTitle"),
|
||||
);
|
||||
@@ -628,7 +629,7 @@ export function useVaultImportHandlers({
|
||||
rollbackSnapshot = null;
|
||||
|
||||
if (isManaged) {
|
||||
toast.success(
|
||||
notify.success(
|
||||
t("vault.import.sshConfig.managedSuccess", { count: totalAffected }),
|
||||
t("vault.import.toast.completedTitle"),
|
||||
);
|
||||
@@ -641,12 +642,12 @@ export function useVaultImportHandlers({
|
||||
|
||||
if (hasWarnings) {
|
||||
const firstIssue = result.issues[0]?.message;
|
||||
toast.warning(
|
||||
notify.warning(
|
||||
firstIssue ? `${details} ${t("vault.import.toast.firstIssue", { issue: firstIssue })}` : details,
|
||||
t("vault.import.toast.completedTitle"),
|
||||
);
|
||||
} else {
|
||||
toast.success(details, t("vault.import.toast.completedTitle"));
|
||||
notify.success(details, t("vault.import.toast.completedTitle"));
|
||||
}
|
||||
}
|
||||
updateProgress({
|
||||
@@ -684,7 +685,7 @@ export function useVaultImportHandlers({
|
||||
stage: "failed",
|
||||
error: message,
|
||||
});
|
||||
toast.error(message, t("vault.import.toast.failedTitle"));
|
||||
notify.error(message, t("vault.import.toast.failedTitle"));
|
||||
} finally {
|
||||
if (activeImportAbortRef.current === abortController) {
|
||||
activeImportAbortRef.current = null;
|
||||
@@ -694,6 +695,7 @@ export function useVaultImportHandlers({
|
||||
}
|
||||
},
|
||||
[
|
||||
notify,
|
||||
onReadPersistedHosts,
|
||||
onCommitVaultImportTransaction,
|
||||
onReadPersistedManagedSources,
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
} from "../../domain/globalHistory";
|
||||
import {
|
||||
buildTerminalDataMapFromLogs,
|
||||
connectionLogTerminalDataMapsEqual,
|
||||
mergeConnectionLogsFromStorage,
|
||||
mergeTerminalDataIntoLogs,
|
||||
mergeTerminalDataMapsForStorage,
|
||||
@@ -65,6 +66,10 @@ import {
|
||||
} from "../../domain/connectionLogTerminalData";
|
||||
import { getNextVaultOrder, normalizeVaultOrder } from "../../domain/vaultOrder";
|
||||
import { loadSanitizedShellHistory } from "./shellHistoryPersistence";
|
||||
import {
|
||||
publishNotesSnapshot,
|
||||
registerNotesActions,
|
||||
} from "./notesStore";
|
||||
import { publishShellHistorySnapshot } from "./shellHistoryStore";
|
||||
import { setVaultInitialized } from "./vaultInitStore";
|
||||
import {
|
||||
@@ -257,7 +262,7 @@ const writeConnectionLogTerminalDataMap = (
|
||||
localMaps,
|
||||
buildPersistedLogIds(logs, usePersistedLogIdsFromDisk),
|
||||
);
|
||||
if (JSON.stringify(existing) === JSON.stringify(pruned)) return true;
|
||||
if (connectionLogTerminalDataMapsEqual(existing, pruned)) return true;
|
||||
return localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA, pruned);
|
||||
};
|
||||
|
||||
@@ -280,9 +285,13 @@ export const useVaultState = () => {
|
||||
const customGroupsRef = useRef<string[]>([]);
|
||||
const managedSourcesRef = useRef<ManagedSource[]>([]);
|
||||
const hostsRef = useRef<Host[]>([]);
|
||||
const notesRef = useRef<VaultNote[]>([]);
|
||||
const noteGroupsRef = useRef<string[]>([]);
|
||||
customGroupsRef.current = customGroups;
|
||||
managedSourcesRef.current = managedSources;
|
||||
hostsRef.current = hosts;
|
||||
notesRef.current = notes;
|
||||
noteGroupsRef.current = noteGroups;
|
||||
|
||||
// Write-version counters prevent out-of-order async writes from overwriting
|
||||
// newer data. Each update bumps the counter; the .then() callback only
|
||||
@@ -591,14 +600,20 @@ export const useVaultState = () => {
|
||||
|
||||
const updateNotes = useCallback((data: Partial<VaultNote>[]) => {
|
||||
const cleaned = normalizeVaultNotes(data);
|
||||
notesRef.current = cleaned;
|
||||
setNotes(cleaned);
|
||||
localStorageAdapter.write(STORAGE_KEY_NOTES, cleaned);
|
||||
// Publish synchronously so Notes→AI handoff does not read a stale catalog
|
||||
// while React state is still committing (useLayoutEffect republishes too).
|
||||
publishNotesSnapshot({ notes: cleaned, noteGroups: noteGroupsRef.current });
|
||||
}, []);
|
||||
|
||||
const updateNoteGroups = useCallback((data: unknown) => {
|
||||
const cleaned = normalizeNoteGroups(data);
|
||||
noteGroupsRef.current = cleaned;
|
||||
setNoteGroups(cleaned);
|
||||
localStorageAdapter.write(STORAGE_KEY_NOTE_GROUPS, cleaned);
|
||||
publishNotesSnapshot({ notes: notesRef.current, noteGroups: cleaned });
|
||||
}, []);
|
||||
|
||||
const updateCustomGroups = useCallback((
|
||||
@@ -828,6 +843,18 @@ export const useVaultState = () => {
|
||||
publishShellHistorySnapshot(shellHistory);
|
||||
}, [shellHistory]);
|
||||
|
||||
// Notes catalog for Notes / AI side panels — keep TerminalLayer off the hot path.
|
||||
useLayoutEffect(() => {
|
||||
publishNotesSnapshot({ notes, noteGroups });
|
||||
}, [notes, noteGroups]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
registerNotesActions({ updateNotes, updateNoteGroups });
|
||||
return () => {
|
||||
registerNotesActions(null);
|
||||
};
|
||||
}, [updateNotes, updateNoteGroups]);
|
||||
|
||||
const addShellHistoryEntry = useCallback(
|
||||
(entry: Omit<ShellHistoryEntry, "id" | "timestamp">) => {
|
||||
setShellHistory((prev) => {
|
||||
@@ -1375,9 +1402,12 @@ export const useVaultState = () => {
|
||||
|
||||
const updateHostLastConnected = useCallback((hostId: string) => {
|
||||
setHosts((prev) => {
|
||||
const next = prev.map((h) =>
|
||||
h.id === hostId ? { ...h, lastConnectedAt: Date.now() } : h,
|
||||
);
|
||||
const idx = prev.findIndex((h) => h.id === hostId);
|
||||
if (idx < 0) return prev;
|
||||
const now = Date.now();
|
||||
if (prev[idx].lastConnectedAt === now) return prev;
|
||||
const next = prev.slice();
|
||||
next[idx] = { ...prev[idx], lastConnectedAt: now };
|
||||
const ver = ++hostsWriteVersion.current;
|
||||
const encryptPromise = encryptHosts(next);
|
||||
hostsEncryptPendingRef.current = encryptPromise.then(() => undefined);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { VaultImportDestination } from "../../domain/vaultImport";
|
||||
import type { VaultImportFileEncoding } from "./vaultImportFile";
|
||||
|
||||
/** Options for a vault host-file import (kept out of UI modules). */
|
||||
export type VaultImportOptions = {
|
||||
managed?: boolean;
|
||||
filePath?: string;
|
||||
encoding?: VaultImportFileEncoding;
|
||||
destination?: VaultImportDestination;
|
||||
};
|
||||
|
||||
/** Notification sink so application/state hooks do not import UI toast. */
|
||||
export type VaultImportNotifier = {
|
||||
success: (message: string, title?: string) => void;
|
||||
error: (message: string, title?: string) => void;
|
||||
warning: (message: string, title?: string) => void;
|
||||
};
|
||||
@@ -49,8 +49,8 @@ interface AIChatPanelContentProps {
|
||||
handleDeleteSession: (event: React.MouseEvent, sessionId: string) => void;
|
||||
messages: ChatMessage[];
|
||||
isStreaming: boolean;
|
||||
activeCompaction?: import('./ai/hooks/useAgentCompactionUi').ActiveCompactionUi | null;
|
||||
contextUsage?: import('./ai/hooks/useAgentCompactionUi').AgentContextUsage | null;
|
||||
activeCompaction?: import('../application/state/useAgentCompactionUi').ActiveCompactionUi | null;
|
||||
contextUsage?: import('../application/state/useAgentCompactionUi').AgentContextUsage | null;
|
||||
inputValue: string;
|
||||
setInputValue: (value: string) => void;
|
||||
handleSend: () => void;
|
||||
|
||||
@@ -252,3 +252,68 @@ test('AI side panel skips re-render when only a sibling scope session object cha
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('AI side panel skips re-render when sibling stream only bumps updatedAt', () => {
|
||||
const own = session({
|
||||
id: 'session-1',
|
||||
scope: { type: 'terminal', targetId: 'terminal-1' },
|
||||
updatedAt: 10,
|
||||
});
|
||||
const siblingA = session({
|
||||
id: 'session-2',
|
||||
scope: { type: 'terminal', targetId: 'terminal-2' },
|
||||
title: 'Sibling',
|
||||
updatedAt: 10,
|
||||
});
|
||||
const siblingStreamed = session({
|
||||
id: 'session-2',
|
||||
scope: { type: 'terminal', targetId: 'terminal-2' },
|
||||
title: 'Sibling',
|
||||
updatedAt: 99,
|
||||
});
|
||||
const prev = baseProps({
|
||||
isVisible: true,
|
||||
scopeType: 'terminal',
|
||||
scopeTargetId: 'terminal-1',
|
||||
sessions: [own, siblingA],
|
||||
});
|
||||
assert.equal(
|
||||
aiChatSidePanelPropsAreEqual(prev, {
|
||||
...prev,
|
||||
sessions: [own, siblingStreamed],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('AI side panel re-renders when sibling history chrome title changes', () => {
|
||||
const own = session({
|
||||
id: 'session-1',
|
||||
scope: { type: 'terminal', targetId: 'terminal-1' },
|
||||
});
|
||||
const siblingA = session({
|
||||
id: 'session-2',
|
||||
scope: { type: 'terminal', targetId: 'terminal-2' },
|
||||
title: 'Old',
|
||||
updatedAt: 10,
|
||||
});
|
||||
const siblingRenamed = session({
|
||||
id: 'session-2',
|
||||
scope: { type: 'terminal', targetId: 'terminal-2' },
|
||||
title: 'New',
|
||||
updatedAt: 10,
|
||||
});
|
||||
const prev = baseProps({
|
||||
isVisible: true,
|
||||
scopeType: 'terminal',
|
||||
scopeTargetId: 'terminal-1',
|
||||
sessions: [own, siblingA],
|
||||
});
|
||||
assert.equal(
|
||||
aiChatSidePanelPropsAreEqual(prev, {
|
||||
...prev,
|
||||
sessions: [own, siblingRenamed],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -49,7 +49,7 @@ import {
|
||||
getNetcattyBridge,
|
||||
isAIChatSessionStreaming,
|
||||
type DefaultTargetSessionHint,
|
||||
} from './ai/hooks/useAIChatStreaming';
|
||||
} from '../application/state/useAIChatStreaming';
|
||||
import { getScopedHistorySessions } from './ai/scopedHistorySessions';
|
||||
import { aiSessionIdSetEqual, exactScopeAISessionsEqual } from '../domain/aiSessionsForScope';
|
||||
import { buildExternalAgentHistoryMessagesForBridge } from './ai/externalAgentHistory';
|
||||
@@ -60,7 +60,7 @@ import { stopAgentTurn } from '../infrastructure/ai/harness/agentStop';
|
||||
import { getAgentRuntime } from '../infrastructure/ai/harness/globalAgentRuntime';
|
||||
import { useAIPermissionGrantsState } from '../application/state/useAIPermissionGrantsState';
|
||||
import { useConversationExport } from './ai/hooks/useConversationExport';
|
||||
import { useAgentContextUsage } from './ai/hooks/useAgentCompactionUi';
|
||||
import { useAgentContextUsage } from '../application/state/useAgentCompactionUi';
|
||||
import type { AIChatSidePanelProps } from './AIChatSidePanel.types';
|
||||
import {
|
||||
buildCursorListModelsAgentEnv,
|
||||
@@ -1599,12 +1599,23 @@ export function aiChatSidePanelPropsAreEqual(
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
// History drawer / recent list need create/delete/title/updatedAt chrome.
|
||||
// Only when the panel is (or becomes) visible so hidden retained panels do
|
||||
// not re-render on every sibling stream that bumps updatedAt.
|
||||
// History drawer / recent list need create/delete/title chrome for the full
|
||||
// list (fuzzy ranking still receives every session). updatedAt is compared
|
||||
// for this panel's exact scope (+ selected resume) and, for workspace
|
||||
// panels, sibling workspace timestamps so history sort/relative time stay
|
||||
// fresh — without letting unrelated terminal stream ticks invalidate.
|
||||
const prevVisible = prev.isVisible ?? true;
|
||||
const nextVisible = next.isVisible ?? true;
|
||||
if ((prevVisible || nextVisible) && !aiSessionIdSetEqual(prev.sessions, next.sessions)) {
|
||||
if (
|
||||
(prevVisible || nextVisible)
|
||||
&& !aiSessionIdSetEqual(
|
||||
prev.sessions,
|
||||
next.sessions,
|
||||
prev.scopeType,
|
||||
prev.scopeTargetId,
|
||||
selectedSessionId,
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { getKnownHostsGridColumnCount } from "./knownHostsGridLayout.ts";
|
||||
|
||||
test("known-host grids use container-width column steps", () => {
|
||||
assert.equal(getKnownHostsGridColumnCount(400), 2);
|
||||
assert.equal(getKnownHostsGridColumnCount(640), 3);
|
||||
assert.equal(getKnownHostsGridColumnCount(1000), 3);
|
||||
assert.equal(getKnownHostsGridColumnCount(1280), 4);
|
||||
assert.equal(getKnownHostsGridColumnCount(1800), 4);
|
||||
});
|
||||
|
||||
test("virtualized and non-virtual known hosts share container column policy", () => {
|
||||
const source = readFileSync(new URL("./KnownHostsManager.tsx", import.meta.url), "utf8");
|
||||
assert.match(source, /getColumnCount=\{\(width, mode\) =>/);
|
||||
assert.match(source, /getKnownHostsGridColumnCount\(width\)/);
|
||||
assert.match(source, /getElementContentWidth\(el\)/);
|
||||
assert.match(source, /gridTemplateColumns: `repeat\(\$\{gridColumns\}, minmax\(0, 1fr\)\)`/);
|
||||
assert.doesNotMatch(source, /sm:grid-cols-3|xl:grid-cols-4/);
|
||||
assert.doesNotMatch(source, /getKnownHostsGridColumnCount\(el\.clientWidth\)/);
|
||||
});
|
||||
|
||||
test("virtualized known-host cards expose a focus target for keyboard nav", () => {
|
||||
const source = readFileSync(new URL("./KnownHostsManager.tsx", import.meta.url), "utf8");
|
||||
// Grid and list HostItem roots both need a focus target for VirtualizedHostCollection.
|
||||
assert.equal([...source.matchAll(/data-vault-focus-target/g)].length, 2);
|
||||
assert.equal([...source.matchAll(/tabIndex=\{0\}/g)].length, 2);
|
||||
});
|
||||
@@ -43,7 +43,6 @@ import {
|
||||
ContextMenuTrigger,
|
||||
} from "./ui/context-menu";
|
||||
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
import { SortDropdown, SortMode } from "./ui/sort-dropdown";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
import { toast } from "./ui/toast";
|
||||
@@ -55,8 +54,13 @@ import {
|
||||
} from "./vault/VaultPageHeader";
|
||||
import { VaultDeleteConfirmDialog } from "./vault/VaultDeleteConfirmDialog";
|
||||
import { VaultEntityIcon, vaultPrimaryIconClass } from "./vault/VaultEntityIcon";
|
||||
import { VirtualizedHostCollection } from "./vault/VirtualizedHostCollection";
|
||||
import { getKnownHostsGridColumnCount } from "./knownHostsGridLayout";
|
||||
import { getElementContentWidth } from "./vault/vaultHostGridLayout";
|
||||
import { useVaultItemReorder } from "./vault/vaultReorderDrag";
|
||||
|
||||
const VIRTUALIZE_THRESHOLD = 30;
|
||||
|
||||
interface KnownHostsManagerProps {
|
||||
knownHosts: KnownHost[];
|
||||
hosts: Host[];
|
||||
@@ -207,6 +211,8 @@ const HostItem = React.memo<HostItemProps>(
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
{...reorderProps}
|
||||
data-vault-focus-target
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
reorderProps && "vault-drop-indicator-row",
|
||||
"group cursor-pointer soft-card elevate rounded-xl h-[68px] px-3 py-2",
|
||||
@@ -256,6 +262,8 @@ const HostItem = React.memo<HostItemProps>(
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
{...reorderProps}
|
||||
data-vault-focus-target
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
reorderProps && "vault-drop-indicator-row",
|
||||
"group flex items-center gap-3 px-3 py-2 h-14 rounded-lg hover:bg-secondary/60 transition-colors cursor-pointer",
|
||||
@@ -328,8 +336,8 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
const [sortMode, setSortMode] = useState<SortMode>("manual");
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const hasScannedRef = React.useRef(false);
|
||||
const listRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const RENDER_LIMIT = 100; // Limit rendered items for performance
|
||||
const scrollRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [gridColumns, setGridColumns] = useState(2);
|
||||
|
||||
// Define handleScanSystem before useEffect that depends on it
|
||||
const handleScanSystem = useCallback(async (silent = false) => {
|
||||
@@ -454,12 +462,25 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
return sortMode === "manual" ? sortByVaultOrder(result) : result;
|
||||
}, [knownHosts, deferredSearch, sortMode]);
|
||||
|
||||
// Limit rendered items for performance
|
||||
const displayedHosts = useMemo(() => {
|
||||
return filteredHosts.slice(0, RENDER_LIMIT);
|
||||
}, [filteredHosts]);
|
||||
const shouldVirtualize = filteredHosts.length >= VIRTUALIZE_THRESHOLD;
|
||||
|
||||
const hasMore = filteredHosts.length > RENDER_LIMIT;
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el || viewMode !== "grid" || typeof ResizeObserver === "undefined") return;
|
||||
const recompute = () => {
|
||||
setGridColumns(getKnownHostsGridColumnCount(getElementContentWidth(el)));
|
||||
};
|
||||
recompute();
|
||||
const observer = new ResizeObserver(recompute);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [viewMode, filteredHosts.length]);
|
||||
const collectionLayoutKey = [
|
||||
viewMode,
|
||||
deferredSearch.trim() ? "search" : "browse",
|
||||
sortMode,
|
||||
filteredHosts.length,
|
||||
].join("|");
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -541,22 +562,22 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
const openFilePicker = useCallback(() => fileInputRef.current?.click(), []);
|
||||
|
||||
const knownHostReorder = useVaultItemReorder({
|
||||
containerRef: listRef,
|
||||
containerRef: scrollRef,
|
||||
viewMode,
|
||||
dragType: "known-host-id",
|
||||
targetAttribute: "data-known-host-id",
|
||||
disabled: deferredSearch.trim().length > 0,
|
||||
// Virtualized grid cards can unmount mid-drag — use indicators, not live reorder.
|
||||
preferDropIndicator: shouldVirtualize,
|
||||
onReorder: (sourceId, targetId, position) => {
|
||||
onReorder(reorderVaultItems(knownHosts, sourceId, targetId, position));
|
||||
setSortMode("manual");
|
||||
},
|
||||
});
|
||||
|
||||
// Memoize the rendered list to prevent re-renders
|
||||
const renderedItems = useMemo(() => {
|
||||
return displayedHosts.map((knownHost) => (
|
||||
const renderKnownHostItem = useCallback(
|
||||
(knownHost: KnownHost) => (
|
||||
<HostItem
|
||||
key={knownHost.id}
|
||||
knownHost={knownHost}
|
||||
converted={convertedMap.get(knownHost.id) || false}
|
||||
viewMode={viewMode}
|
||||
@@ -564,15 +585,15 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
onDelete={handleDelete}
|
||||
onConvertToHost={handleConvertToHost}
|
||||
/>
|
||||
));
|
||||
}, [
|
||||
displayedHosts,
|
||||
convertedMap,
|
||||
viewMode,
|
||||
handleDelete,
|
||||
handleConvertToHost,
|
||||
knownHostReorder,
|
||||
]);
|
||||
),
|
||||
[
|
||||
convertedMap,
|
||||
viewMode,
|
||||
handleDelete,
|
||||
handleConvertToHost,
|
||||
knownHostReorder,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
@@ -657,74 +678,75 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
</VaultPageHeader>
|
||||
|
||||
{/* Content */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div
|
||||
ref={listRef}
|
||||
className={cn(
|
||||
"p-4",
|
||||
viewMode === "grid"
|
||||
? "grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-4 gap-3"
|
||||
: "flex flex-col gap-0",
|
||||
)}
|
||||
onDragOverCapture={knownHostReorder.handleDragOverCapture}
|
||||
onDragOver={knownHostReorder.handleDragOver}
|
||||
onDropCapture={knownHostReorder.handleDropCapture}
|
||||
onDragEndCapture={knownHostReorder.handleDragEndCapture}
|
||||
>
|
||||
{displayedHosts.length === 0 ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center py-16 text-muted-foreground",
|
||||
viewMode === "grid" && "col-span-full",
|
||||
)}
|
||||
>
|
||||
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
|
||||
<Shield size={32} className="opacity-60" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
{t("knownHosts.empty.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-center max-w-sm mb-4">
|
||||
{t("knownHosts.empty.desc")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => handleScanSystem()}
|
||||
disabled={isScanning}
|
||||
>
|
||||
<RefreshCw
|
||||
size={14}
|
||||
className={cn("mr-2", isScanning && "animate-spin")}
|
||||
/>
|
||||
{t("knownHosts.action.scanSystem")}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={openFilePicker}>
|
||||
<FolderOpen size={14} className="mr-2" />
|
||||
{t("knownHosts.action.browseFile")}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-auto p-4"
|
||||
onDragOverCapture={knownHostReorder.handleDragOverCapture}
|
||||
onDragOver={knownHostReorder.handleDragOver}
|
||||
onDropCapture={knownHostReorder.handleDropCapture}
|
||||
onDragEndCapture={knownHostReorder.handleDragEndCapture}
|
||||
>
|
||||
{filteredHosts.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
|
||||
<Shield size={32} className="opacity-60" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{renderedItems}
|
||||
{hasMore && (
|
||||
<div
|
||||
className={cn(
|
||||
"text-center py-4 text-sm text-muted-foreground",
|
||||
viewMode === "grid" && "col-span-full",
|
||||
)}
|
||||
>
|
||||
{t("knownHosts.results.showingLimited", {
|
||||
shown: displayedHosts.length,
|
||||
total: filteredHosts.length,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
{t("knownHosts.empty.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-center max-w-sm mb-4">
|
||||
{t("knownHosts.empty.desc")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => handleScanSystem()}
|
||||
disabled={isScanning}
|
||||
>
|
||||
<RefreshCw
|
||||
size={14}
|
||||
className={cn("mr-2", isScanning && "animate-spin")}
|
||||
/>
|
||||
{t("knownHosts.action.scanSystem")}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={openFilePicker}>
|
||||
<FolderOpen size={14} className="mr-2" />
|
||||
{t("knownHosts.action.browseFile")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : shouldVirtualize ? (
|
||||
<VirtualizedHostCollection
|
||||
items={filteredHosts}
|
||||
itemKey={(knownHost) => knownHost.id}
|
||||
scrollRef={scrollRef}
|
||||
viewMode={viewMode}
|
||||
layoutKey={collectionLayoutKey}
|
||||
ariaLabel={t("vault.nav.knownHosts")}
|
||||
getColumnCount={(width, mode) => (
|
||||
mode === "grid" ? getKnownHostsGridColumnCount(width) : 1
|
||||
)}
|
||||
renderItem={renderKnownHostItem}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
viewMode === "grid"
|
||||
? "grid gap-3"
|
||||
: "flex flex-col gap-0",
|
||||
)}
|
||||
style={viewMode === "grid" ? {
|
||||
gridTemplateColumns: `repeat(${gridColumns}, minmax(0, 1fr))`,
|
||||
} : undefined}
|
||||
>
|
||||
{filteredHosts.map((knownHost) => (
|
||||
<React.Fragment key={knownHost.id}>
|
||||
{renderKnownHostItem(knownHost)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<VaultDeleteConfirmDialog
|
||||
open={Boolean(deleteTargetId)}
|
||||
title={t("vault.deleteConfirm.title", {
|
||||
|
||||
@@ -27,7 +27,6 @@ import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useSftpState } from "../application/state/useSftpState";
|
||||
import {
|
||||
useReportSftpTransferOwnerActivity,
|
||||
useWarmSftpTransferPool,
|
||||
} from "../application/state/sftp/useSftpTransferLifecycle";
|
||||
import { registerEditorSftpWriterScoped } from "../application/state/editorSftpBridge";
|
||||
import {
|
||||
@@ -61,7 +60,7 @@ import { SftpContextProvider } from "./sftp";
|
||||
import { useSftpViewPaneCallbacks } from "./sftp/hooks/useSftpViewPaneCallbacks";
|
||||
import { useSftpViewTabs } from "./sftp/hooks/useSftpViewTabs";
|
||||
import { useSftpKeyboardShortcuts } from "./sftp/hooks/useSftpKeyboardShortcuts";
|
||||
import { sftpFocusStore } from "./sftp/hooks/useSftpFocusedPane";
|
||||
import { sftpFocusStore } from "../application/state/sftp/sftpFocusStore";
|
||||
import { keepOnlyPaneSelections } from "./sftp/hooks/selectionScope";
|
||||
import { KeyBinding, HotkeyScheme } from "../domain/models";
|
||||
import {
|
||||
@@ -300,12 +299,6 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
const sftpRef = useRef(sftp);
|
||||
sftpRef.current = sftp;
|
||||
|
||||
useWarmSftpTransferPool({
|
||||
hostIds: connectedHosts.map((entry) => entry.host.id),
|
||||
activeHostId: activeHost?.protocol === "serial" ? undefined : activeHost?.id,
|
||||
warmTransferPoolForHost: sftp.warmTransferPoolForHost,
|
||||
});
|
||||
|
||||
const { getConnectionCacheKey, leftPane } = sftp;
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -29,7 +29,6 @@ import { Host, Identity, KnownHost, ProxyProfile, SSHKey, TransferTask } from ".
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
|
||||
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
|
||||
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
|
||||
import { useWarmSftpTransferPool } from "../application/state/sftp/useSftpTransferLifecycle";
|
||||
import { registerEditorSftpWriterScoped } from "../application/state/editorSftpBridge";
|
||||
import { toast } from "./ui/toast";
|
||||
|
||||
@@ -44,7 +43,7 @@ import { SftpContextProvider, activeTabStore } from "./sftp";
|
||||
import { useSftpViewPaneCallbacks } from "./sftp/hooks/useSftpViewPaneCallbacks";
|
||||
import { useSftpViewTabs } from "./sftp/hooks/useSftpViewTabs";
|
||||
import { useSftpKeyboardShortcuts } from "./sftp/hooks/useSftpKeyboardShortcuts";
|
||||
import { sftpFocusStore, SftpFocusedSide, useSftpFocusedSide } from "./sftp/hooks/useSftpFocusedPane";
|
||||
import { sftpFocusStore, SftpFocusedSide, useSftpFocusedSide } from "../application/state/sftp/sftpFocusStore";
|
||||
import { keepOnlyActivePaneSelections, keepOnlyPaneSelections } from "./sftp/hooks/selectionScope";
|
||||
|
||||
|
||||
@@ -186,11 +185,6 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
const sftpRef = useRef(sftp);
|
||||
sftpRef.current = sftp;
|
||||
|
||||
useWarmSftpTransferPool({
|
||||
hostIds: connectedHosts.map((entry) => entry.host.id),
|
||||
warmTransferPoolForHost: sftp.warmTransferPoolForHost,
|
||||
});
|
||||
|
||||
// Register this useSftpState's writeTextFileByConnection with the bridge so
|
||||
// the editor tab's save path can reach the active SFTP session. The bridge
|
||||
// supports multiple simultaneous writers (SftpSidePanel inside terminals
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { getSnippetsGridColumnCount } from "./snippetsGridLayout.ts";
|
||||
|
||||
test("full-width snippet grids use container-width column steps", () => {
|
||||
assert.equal(getSnippetsGridColumnCount(500, { hasSidePanel: false }), 1);
|
||||
assert.equal(getSnippetsGridColumnCount(768, { hasSidePanel: false }), 2);
|
||||
assert.equal(getSnippetsGridColumnCount(1279, { hasSidePanel: false }), 2);
|
||||
assert.equal(getSnippetsGridColumnCount(1280, { hasSidePanel: false }), 3);
|
||||
assert.equal(getSnippetsGridColumnCount(2000, { hasSidePanel: false }), 3);
|
||||
});
|
||||
|
||||
test("split-view snippet grids keep the card-width column count", () => {
|
||||
assert.equal(getSnippetsGridColumnCount(219, { hasSidePanel: true }), 1);
|
||||
assert.equal(getSnippetsGridColumnCount(452, { hasSidePanel: true }), 2);
|
||||
assert.equal(getSnippetsGridColumnCount(916, { hasSidePanel: true }), 4);
|
||||
});
|
||||
|
||||
test("virtualized and CSS snippet grids share container column policy", () => {
|
||||
const source = readFileSync(new URL("./SnippetsManager.tsx", import.meta.url), "utf8");
|
||||
assert.match(source, /getColumnCount=\{\(width, mode\) =>/);
|
||||
assert.match(source, /getSnippetsGridColumnCount\(width, \{ hasSidePanel: hasSnippetsSidePanel \}\)/);
|
||||
assert.match(source, /getSnippetsGridColumnCount\(getElementContentWidth\(el\)/);
|
||||
assert.match(source, /snippetGridStyle/);
|
||||
assert.doesNotMatch(source, /md:grid-cols-2|xl:grid-cols-3/);
|
||||
assert.doesNotMatch(source, /getSnippetsGridColumnCount\(el\.clientWidth/);
|
||||
});
|
||||
|
||||
test("virtualized snippet drops reset drag state without waiting for dragend", () => {
|
||||
const source = readFileSync(new URL("./SnippetsManager.tsx", import.meta.url), "utf8");
|
||||
assert.match(source, /handleReorderDrop = useCallback/);
|
||||
assert.match(source, /finally \{\s*\/\/ Virtualized cards[\s\S]*resetSnippetDragState\(\);/);
|
||||
assert.match(source, /resetSnippetDragState,/);
|
||||
});
|
||||
+187
-150
@@ -49,6 +49,9 @@ import {
|
||||
vaultSnippetIconClass,
|
||||
} from './vault/VaultEntityIcon';
|
||||
import { VaultDeleteConfirmDialog } from './vault/VaultDeleteConfirmDialog';
|
||||
import { VirtualizedHostCollection } from './vault/VirtualizedHostCollection';
|
||||
import { getSnippetsGridColumnCount } from './snippetsGridLayout';
|
||||
import { getElementContentWidth } from './vault/vaultHostGridLayout';
|
||||
import {
|
||||
clearVaultDropIndicator as clearSnippetDropIndicator,
|
||||
getVaultDropIntent as getPackageDropIntent,
|
||||
@@ -59,6 +62,8 @@ import {
|
||||
useVaultGridLayoutAnimation,
|
||||
} from './vault/vaultReorderDrag';
|
||||
|
||||
const VIRTUALIZE_THRESHOLD = 30;
|
||||
|
||||
interface SnippetsManagerProps {
|
||||
snippets: Snippet[];
|
||||
packages: string[];
|
||||
@@ -521,22 +526,18 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
const prepareGridLayoutAnimation = useVaultGridLayoutAnimation(listRef);
|
||||
const hasSnippetsSidePanel = rightPanelMode !== 'none';
|
||||
const splitGridColsRef = useRef(2);
|
||||
const splitViewGridStyle = hasSnippetsSidePanel && viewMode === 'grid'
|
||||
const snippetGridStyle = viewMode === 'grid'
|
||||
? { gridTemplateColumns: 'var(--snippets-grid-cols, repeat(2, minmax(0, 1fr)))' }
|
||||
: undefined;
|
||||
|
||||
useEffect(() => {
|
||||
const el = listRef.current;
|
||||
if (!el || typeof ResizeObserver === 'undefined') return;
|
||||
|
||||
const GAP = 12;
|
||||
const MIN_CARD = 220;
|
||||
const PADDING_X = 32;
|
||||
if (!el || viewMode !== 'grid' || typeof ResizeObserver === 'undefined') return;
|
||||
|
||||
const recompute = () => {
|
||||
const usable = el.clientWidth - PADDING_X;
|
||||
if (usable <= 0) return;
|
||||
const next = Math.max(1, Math.floor((usable + GAP) / (MIN_CARD + GAP)));
|
||||
const next = getSnippetsGridColumnCount(getElementContentWidth(el), {
|
||||
hasSidePanel: hasSnippetsSidePanel,
|
||||
});
|
||||
if (next === splitGridColsRef.current) return;
|
||||
splitGridColsRef.current = next;
|
||||
el.style.setProperty('--snippets-grid-cols', `repeat(${next}, minmax(0, 1fr))`);
|
||||
@@ -1015,6 +1016,16 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
}, [snippets, selectedPackage, search, sortMode]);
|
||||
|
||||
const isSearchActive = search.trim().length > 0;
|
||||
const shouldVirtualizeSnippets = displayedSnippets.length >= VIRTUALIZE_THRESHOLD;
|
||||
const snippetCollectionLayoutKey = [
|
||||
viewMode,
|
||||
search.trim() ? 'search' : 'browse',
|
||||
selectedPackage ?? 'root',
|
||||
displayedPackages.length > 0 && !search.trim() ? 'packages' : 'no-packages',
|
||||
hasSnippetsSidePanel ? 'panel' : 'full',
|
||||
sortMode,
|
||||
displayedSnippets.length,
|
||||
].join('|');
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedSnippetIds((prev) => {
|
||||
@@ -1445,6 +1456,11 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
const sourceSnippetId = draggingSnippetIdRef.current || event.dataTransfer.getData('snippet-id');
|
||||
const position = getDropPosition(target, event.clientX, event.clientY, isGrid);
|
||||
if (isGrid && sourceSnippetId && sourceSnippetId !== targetSnippetId) {
|
||||
// Virtualized cards can unmount mid-drag — use drop indicator instead of live reorder.
|
||||
if (shouldVirtualizeSnippets) {
|
||||
markSnippetDropIndicator(target, position, 'x');
|
||||
return;
|
||||
}
|
||||
const targetSnippet = snippets.find((snippet) => snippet.id === targetSnippetId);
|
||||
const sourceSnippet = snippets.find((snippet) => snippet.id === sourceSnippetId);
|
||||
if (!targetSnippet || !sourceSnippet) return;
|
||||
@@ -1513,11 +1529,13 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
packages,
|
||||
parentOfPackage,
|
||||
prepareGridLayoutAnimation,
|
||||
shouldVirtualizeSnippets,
|
||||
snippets,
|
||||
viewMode,
|
||||
]);
|
||||
|
||||
const handleReorderDrop = useCallback((event: React.DragEvent<HTMLDivElement>) => {
|
||||
try {
|
||||
const target = (event.target as Element | null)?.closest('[data-snippet-id], [data-pkg-path]');
|
||||
clearSnippetDropIndicator();
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
@@ -1577,12 +1595,18 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
lastPreviewReorderRef.current = null;
|
||||
setSortMode('manual');
|
||||
}
|
||||
} finally {
|
||||
// Virtualized cards can unmount mid-drag so dragend never fires — always
|
||||
// clear snippet/package drag ids on drop instead of relying on capture end.
|
||||
resetSnippetDragState();
|
||||
}
|
||||
}, [
|
||||
onBulkSave,
|
||||
onPackagesChange,
|
||||
packages,
|
||||
parentOfPackage,
|
||||
prepareGridLayoutAnimation,
|
||||
resetSnippetDragState,
|
||||
snippets,
|
||||
viewMode,
|
||||
]);
|
||||
@@ -1702,6 +1726,129 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
/>
|
||||
);
|
||||
|
||||
const renderSnippetItem = (snippet: Snippet) => {
|
||||
const isSelected = selectedSnippetIds.has(snippet.id);
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
<div
|
||||
className={cn(
|
||||
"vault-drop-indicator-row group cursor-pointer overflow-hidden",
|
||||
isSelected && (viewMode === 'grid' ? "ring-2 ring-primary/45 bg-primary/5" : "bg-primary/5"),
|
||||
viewMode === 'grid'
|
||||
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
|
||||
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors"
|
||||
)}
|
||||
data-snippet-id={isSearchActive ? undefined : snippet.id}
|
||||
data-vault-grid-item={`snippet:${snippet.id}`}
|
||||
data-vault-reorder-grid={viewMode === 'grid' ? 'true' : undefined}
|
||||
data-vault-reorder-dragging={draggingSnippetId === snippet.id ? 'true' : undefined}
|
||||
draggable={!isSearchActive && !isMultiSelectMode}
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('snippet-id', snippet.id);
|
||||
draggingSnippetIdRef.current = snippet.id;
|
||||
setDraggingSnippetId(snippet.id);
|
||||
lastPreviewReorderRef.current = null;
|
||||
}}
|
||||
onClick={() => {
|
||||
if (isMultiSelectMode) {
|
||||
toggleSnippetSelection(snippet.id);
|
||||
} else {
|
||||
handleEdit(snippet);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 h-full min-w-0">
|
||||
{isMultiSelectMode && (
|
||||
<div
|
||||
className="shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleSnippetSelection(snippet.id);
|
||||
}}
|
||||
>
|
||||
{isSelected ? (
|
||||
<CheckSquare size={18} className="text-primary" />
|
||||
) : (
|
||||
<Square size={18} className="text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<VaultEntityIcon
|
||||
className={isScriptSnippet(snippet) ? vaultAutomationScriptIconClass : vaultSnippetIconClass}
|
||||
icon={isScriptSnippet(snippet) ? (
|
||||
<Play size={18} />
|
||||
) : (
|
||||
<Zap size={18} />
|
||||
)}
|
||||
/>
|
||||
<div className="w-0 flex-1">
|
||||
<div className="text-sm font-semibold truncate">{snippet.label}</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="text-[11px] text-muted-foreground font-mono leading-4 truncate">
|
||||
{flattenSnippetCommandPreview(snippet.command) || t('snippets.commandFallback')}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<SnippetCommandTooltipContent
|
||||
command={snippet.command}
|
||||
fallback={t('snippets.commandFallback')}
|
||||
/>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{snippet.shortkey && (
|
||||
<div className="shrink-0 px-2 py-1 text-[10px] font-mono rounded border border-border bg-muted/50 text-muted-foreground">
|
||||
{snippet.shortkey}
|
||||
</div>
|
||||
)}
|
||||
{viewMode === 'list' && !isMultiSelectMode && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
|
||||
onClick={(e) => { e.stopPropagation(); handleEdit(snippet); }}
|
||||
>
|
||||
<Edit2 size={14} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
const runTargets = getRunnableHostsForSnippet(snippet, hosts);
|
||||
if (runTargets.length > 0) {
|
||||
onRunSnippet?.(snippet, runTargets);
|
||||
return;
|
||||
}
|
||||
toast.error(t('scripts.actions.noRunnableHosts'));
|
||||
}}
|
||||
disabled={getRunnableHostsForSnippet(snippet, hosts).length === 0}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" /> {t('action.run')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => handleEdit(snippet)}>
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {t('action.edit')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => handleCopy(snippet.id, snippet.command)}>
|
||||
<Copy className="mr-2 h-4 w-4" /> {t('action.copy')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => exportSingleSnippet(snippet)}>
|
||||
<Download className="mr-2 h-4 w-4" /> {t('snippets.export.snippet')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem className="text-destructive" onClick={() => requestDeleteSnippet(snippet.id)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> {t('action.delete')}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="flex flex-1 min-h-0 min-w-0 relative">
|
||||
@@ -1904,15 +2051,10 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
</div>
|
||||
<div className={cn(
|
||||
viewMode === 'grid'
|
||||
? cn(
|
||||
"grid gap-3",
|
||||
hasSnippetsSidePanel
|
||||
? "grid-cols-1"
|
||||
: "grid-cols-1 md:grid-cols-2 xl:grid-cols-3",
|
||||
)
|
||||
? "grid gap-3"
|
||||
: "flex flex-col gap-0"
|
||||
)}
|
||||
style={splitViewGridStyle}
|
||||
style={snippetGridStyle}
|
||||
>
|
||||
{displayedPackages.map((pkg) => (
|
||||
<ContextMenu key={pkg.path}>
|
||||
@@ -1983,141 +2125,36 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
{displayedSnippets.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className={vaultSectionTitleClass}>{t('snippets.section.snippets')}</h3>
|
||||
<div className={cn(
|
||||
viewMode === 'grid'
|
||||
? cn(
|
||||
"grid gap-3",
|
||||
hasSnippetsSidePanel
|
||||
? "grid-cols-1"
|
||||
: "grid-cols-1 md:grid-cols-2 xl:grid-cols-3",
|
||||
)
|
||||
: "flex flex-col gap-0"
|
||||
{shouldVirtualizeSnippets ? (
|
||||
<VirtualizedHostCollection
|
||||
items={displayedSnippets}
|
||||
itemKey={(snippet) => snippet.id}
|
||||
scrollRef={listRef}
|
||||
viewMode={viewMode}
|
||||
layoutKey={snippetCollectionLayoutKey}
|
||||
ariaLabel={t('vault.nav.snippets')}
|
||||
getColumnCount={(width, mode) => (
|
||||
mode === 'grid'
|
||||
? getSnippetsGridColumnCount(width, { hasSidePanel: hasSnippetsSidePanel })
|
||||
: 1
|
||||
)}
|
||||
renderItem={renderSnippetItem}
|
||||
/>
|
||||
) : (
|
||||
<div className={cn(
|
||||
viewMode === 'grid'
|
||||
? "grid gap-3"
|
||||
: "flex flex-col gap-0"
|
||||
)}
|
||||
style={snippetGridStyle}
|
||||
>
|
||||
{displayedSnippets.map((snippet) => (
|
||||
<React.Fragment key={snippet.id}>
|
||||
{renderSnippetItem(snippet)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
style={splitViewGridStyle}
|
||||
>
|
||||
{displayedSnippets.map((snippet) => {
|
||||
const isSelected = selectedSnippetIds.has(snippet.id);
|
||||
return (
|
||||
<ContextMenu key={snippet.id}>
|
||||
<ContextMenuTrigger>
|
||||
<div
|
||||
className={cn(
|
||||
"vault-drop-indicator-row group cursor-pointer overflow-hidden",
|
||||
isSelected && (viewMode === 'grid' ? "ring-2 ring-primary/45 bg-primary/5" : "bg-primary/5"),
|
||||
viewMode === 'grid'
|
||||
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
|
||||
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors"
|
||||
)}
|
||||
data-snippet-id={isSearchActive ? undefined : snippet.id}
|
||||
data-vault-grid-item={`snippet:${snippet.id}`}
|
||||
data-vault-reorder-grid={viewMode === 'grid' ? 'true' : undefined}
|
||||
data-vault-reorder-dragging={draggingSnippetId === snippet.id ? 'true' : undefined}
|
||||
draggable={!isSearchActive && !isMultiSelectMode}
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('snippet-id', snippet.id);
|
||||
draggingSnippetIdRef.current = snippet.id;
|
||||
setDraggingSnippetId(snippet.id);
|
||||
lastPreviewReorderRef.current = null;
|
||||
}}
|
||||
onClick={() => {
|
||||
if (isMultiSelectMode) {
|
||||
toggleSnippetSelection(snippet.id);
|
||||
} else {
|
||||
handleEdit(snippet);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 h-full min-w-0">
|
||||
{isMultiSelectMode && (
|
||||
<div
|
||||
className="shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleSnippetSelection(snippet.id);
|
||||
}}
|
||||
>
|
||||
{isSelected ? (
|
||||
<CheckSquare size={18} className="text-primary" />
|
||||
) : (
|
||||
<Square size={18} className="text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<VaultEntityIcon
|
||||
className={isScriptSnippet(snippet) ? vaultAutomationScriptIconClass : vaultSnippetIconClass}
|
||||
icon={isScriptSnippet(snippet) ? (
|
||||
<Play size={18} />
|
||||
) : (
|
||||
<Zap size={18} />
|
||||
)}
|
||||
/>
|
||||
<div className="w-0 flex-1">
|
||||
<div className="text-sm font-semibold truncate">{snippet.label}</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="text-[11px] text-muted-foreground font-mono leading-4 truncate">
|
||||
{flattenSnippetCommandPreview(snippet.command) || t('snippets.commandFallback')}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<SnippetCommandTooltipContent
|
||||
command={snippet.command}
|
||||
fallback={t('snippets.commandFallback')}
|
||||
/>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{snippet.shortkey && (
|
||||
<div className="shrink-0 px-2 py-1 text-[10px] font-mono rounded border border-border bg-muted/50 text-muted-foreground">
|
||||
{snippet.shortkey}
|
||||
</div>
|
||||
)}
|
||||
{viewMode === 'list' && !isMultiSelectMode && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
|
||||
onClick={(e) => { e.stopPropagation(); handleEdit(snippet); }}
|
||||
>
|
||||
<Edit2 size={14} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
const runTargets = getRunnableHostsForSnippet(snippet, hosts);
|
||||
if (runTargets.length > 0) {
|
||||
onRunSnippet?.(snippet, runTargets);
|
||||
return;
|
||||
}
|
||||
toast.error(t('scripts.actions.noRunnableHosts'));
|
||||
}}
|
||||
disabled={getRunnableHostsForSnippet(snippet, hosts).length === 0}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" /> {t('action.run')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => handleEdit(snippet)}>
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {t('action.edit')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => handleCopy(snippet.id, snippet.command)}>
|
||||
<Copy className="mr-2 h-4 w-4" /> {t('action.copy')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => exportSingleSnippet(snippet)}>
|
||||
<Download className="mr-2 h-4 w-4" /> {t('snippets.export.snippet')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem className="text-destructive" onClick={() => requestDeleteSnippet(snippet.id)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> {t('action.delete')}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{search.trim() && displayedSnippets.length === 0 && (snippets.length > 0 || displayedPackages.length > 0) && (
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const source = readFileSync(new URL("./Terminal.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("Terminal re-resolves catalog theme before applying store accent", () => {
|
||||
// appearanceTheme may still carry a baked custom accent while TerminalLayer
|
||||
// memo ignores accent churn. Switching accentMode back to `theme` must clear
|
||||
// cursor/selection by starting from the clean catalog theme id.
|
||||
assert.match(source, /useAppearanceChromeStore\(\)/);
|
||||
assert.match(
|
||||
source,
|
||||
/getBuiltinTerminalThemeById\(appearanceTheme\.id\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/applyCustomAccentToTerminalTheme\(resolveBase\(\), accentMode, customAccent\)/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/if \(appearanceTheme\) return appearanceTheme;/,
|
||||
);
|
||||
});
|
||||
+25
-17
@@ -66,6 +66,7 @@ import { useStoredBoolean } from "../application/state/useStoredBoolean";
|
||||
import { readOptionalStoredStringValue, useStoredString } from "../application/state/useStoredString";
|
||||
import { useSessionLogBackend } from "../application/state/useSessionLogBackend";
|
||||
import { useTerminalLayoutSuppressActive } from "../application/state/terminalLayoutSuppressStore";
|
||||
import { useAppearanceChromeStore } from "../application/state/appearanceChromeStore";
|
||||
import {
|
||||
shouldPublishPluginTerminalSessionMountLifecycle,
|
||||
usePluginTerminalSessionLifecycle,
|
||||
@@ -260,8 +261,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
fontSize,
|
||||
terminalTheme,
|
||||
followAppTerminalTheme = false,
|
||||
accentMode = "theme",
|
||||
customAccent = "",
|
||||
terminalSettings,
|
||||
sessionId,
|
||||
workspaceId,
|
||||
@@ -1264,22 +1263,31 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
});
|
||||
}, [availableFonts, fontFamilyId, hasFontFamilyOverride, host.fontFamily, terminalSettings?.fallbackFont]);
|
||||
|
||||
const { accentMode, customAccent } = useAppearanceChromeStore();
|
||||
|
||||
const baseEffectiveTheme = useMemo(() => {
|
||||
if (appearanceTheme) return appearanceTheme;
|
||||
if (followAppTerminalTheme) {
|
||||
return applyCustomAccentToTerminalTheme(terminalTheme, accentMode, customAccent);
|
||||
}
|
||||
const themeId = resolveHostTerminalThemeId(
|
||||
{ theme: host.theme, themeOverride: host.themeOverride } as Pick<Host, 'theme' | 'themeOverride'>,
|
||||
terminalTheme.id,
|
||||
);
|
||||
let baseTheme = terminalTheme;
|
||||
if (themeId) {
|
||||
const hostTheme = getBuiltinTerminalThemeById(themeId)
|
||||
|| customThemes.find((t) => t.id === themeId);
|
||||
if (hostTheme) baseTheme = hostTheme;
|
||||
}
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
// Always re-apply appearanceChromeStore accent. appearanceTheme from the
|
||||
// layer may still carry a previously baked custom accent while TerminalLayer
|
||||
// memo ignores accent churn — re-resolve the catalog theme by id first.
|
||||
const resolveBase = (): typeof terminalTheme => {
|
||||
if (appearanceTheme) {
|
||||
const clean = getBuiltinTerminalThemeById(appearanceTheme.id)
|
||||
|| customThemes.find((t) => t.id === appearanceTheme.id);
|
||||
if (clean) return clean;
|
||||
}
|
||||
if (followAppTerminalTheme) return terminalTheme;
|
||||
const themeId = resolveHostTerminalThemeId(
|
||||
{ theme: host.theme, themeOverride: host.themeOverride } as Pick<Host, 'theme' | 'themeOverride'>,
|
||||
terminalTheme.id,
|
||||
);
|
||||
if (themeId) {
|
||||
const hostTheme = getBuiltinTerminalThemeById(themeId)
|
||||
|| customThemes.find((t) => t.id === themeId);
|
||||
if (hostTheme) return hostTheme;
|
||||
}
|
||||
return terminalTheme;
|
||||
};
|
||||
return applyCustomAccentToTerminalTheme(resolveBase(), accentMode, customAccent);
|
||||
}, [accentMode, appearanceTheme, customAccent, customThemes, followAppTerminalTheme, host.theme, host.themeOverride, terminalTheme]);
|
||||
|
||||
const resolvedChainHosts =
|
||||
|
||||
@@ -217,6 +217,32 @@ test("TerminalLayer ignores shellHistory prop churn (reads shellHistoryStore ins
|
||||
);
|
||||
});
|
||||
|
||||
test("TerminalLayer ignores notes prop churn (reads notesStore instead)", () => {
|
||||
assert.equal(
|
||||
terminalLayerAreEqual(
|
||||
baseProps as never,
|
||||
{
|
||||
...baseProps,
|
||||
notes: [{ id: "n1", title: "One", content: "body", tags: [], createdAt: 1, updatedAt: 1, order: 1000 }],
|
||||
noteGroups: ["Ops"],
|
||||
updateNotes: () => {},
|
||||
updateNoteGroups: () => {},
|
||||
} as never,
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("TerminalLayer ignores customAccent prop churn (reads appearanceChromeStore instead)", () => {
|
||||
assert.equal(
|
||||
terminalLayerAreEqual(
|
||||
baseProps as never,
|
||||
{ ...baseProps, accentMode: "custom", customAccent: "#ff00aa" } as never,
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("TerminalLayer ignores sessions dynamicTitle-only updates", () => {
|
||||
const sessionsA = [{
|
||||
id: "s1",
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useStoredString } from '../application/state/useStoredString';
|
||||
import { useStoredNumber } from '../application/state/useStoredNumber';
|
||||
import { useStoredBoolean } from '../application/state/useStoredBoolean';
|
||||
import {
|
||||
STORAGE_KEY_SIDE_PANEL_POSITION,
|
||||
STORAGE_KEY_SIDE_PANEL_WIDTH,
|
||||
STORAGE_KEY_TERMINAL_COMPOSE_BAR_OPEN,
|
||||
} from '../infrastructure/config/storageKeys';
|
||||
@@ -173,8 +174,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
identities,
|
||||
snippets,
|
||||
snippetPackages,
|
||||
notes,
|
||||
noteGroups,
|
||||
openNoteRequest,
|
||||
onOpenVaultNoteFromChat,
|
||||
onOpenVaultHostFromChat,
|
||||
@@ -237,8 +236,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
updateHosts,
|
||||
updateSnippets,
|
||||
updateSnippetPackages,
|
||||
updateNotes,
|
||||
updateNoteGroups,
|
||||
sftpDefaultViewMode,
|
||||
sftpDoubleClickBehavior,
|
||||
sftpAutoSync,
|
||||
@@ -555,7 +552,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
{ min: TERMINAL_SIDE_PANEL_MIN_WIDTH, max: TERMINAL_SIDE_PANEL_MAX_WIDTH },
|
||||
);
|
||||
const [sidePanelPosition, setSidePanelPosition] = useStoredString<'left' | 'right'>(
|
||||
'netcatty_side_panel_position',
|
||||
STORAGE_KEY_SIDE_PANEL_POSITION,
|
||||
'left',
|
||||
(v): v is 'left' | 'right' => v === 'left' || v === 'right',
|
||||
);
|
||||
@@ -2165,8 +2162,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
programmaticCommandLogRewriteHandlersRef,
|
||||
snippetPackages,
|
||||
snippets,
|
||||
noteGroups,
|
||||
notes,
|
||||
onOpenVaultHostFromChat,
|
||||
onOpenVaultNoteFromChat: handleOpenVaultNoteFromAiPanel,
|
||||
onOpenVaultSectionFromChat,
|
||||
@@ -2189,8 +2184,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
updateHosts,
|
||||
updateNoteGroups,
|
||||
updateNotes,
|
||||
updateSnippetPackages,
|
||||
updateSnippets,
|
||||
X,
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { vaultViewAreEqual } from "./VaultView.tsx";
|
||||
import { syncVaultViewMemoSection, vaultViewAreEqual } from "./VaultView.tsx";
|
||||
|
||||
const baseProps = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
snippets: [],
|
||||
snippetPackages: [],
|
||||
notes: [],
|
||||
noteGroups: [],
|
||||
customGroups: [],
|
||||
knownHosts: [],
|
||||
connectionLogs: [],
|
||||
sessions: [],
|
||||
managedSources: [],
|
||||
groupConfigs: {},
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
navigateToSection: null,
|
||||
};
|
||||
|
||||
test("VaultView re-renders when an external section navigation request changes", () => {
|
||||
const baseProps = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
snippets: [],
|
||||
snippetPackages: [],
|
||||
notes: [],
|
||||
noteGroups: [],
|
||||
customGroups: [],
|
||||
knownHosts: [],
|
||||
connectionLogs: [],
|
||||
sessions: [],
|
||||
managedSources: [],
|
||||
groupConfigs: {},
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
navigateToSection: null,
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
vaultViewAreEqual(
|
||||
baseProps as never,
|
||||
@@ -34,26 +34,6 @@ test("VaultView re-renders when an external section navigation request changes",
|
||||
});
|
||||
|
||||
test("VaultView memo does not depend on shellHistory prop identity", () => {
|
||||
const baseProps = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
snippets: [],
|
||||
snippetPackages: [],
|
||||
notes: [],
|
||||
noteGroups: [],
|
||||
customGroups: [],
|
||||
knownHosts: [],
|
||||
connectionLogs: [],
|
||||
sessions: [],
|
||||
managedSources: [],
|
||||
groupConfigs: {},
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
navigateToSection: null,
|
||||
};
|
||||
// History-only updates must not be part of VaultView equality (store path).
|
||||
assert.equal(
|
||||
vaultViewAreEqual(baseProps as never, { ...baseProps } as never),
|
||||
true,
|
||||
@@ -65,26 +45,6 @@ test("VaultView memo does not depend on shellHistory prop identity", () => {
|
||||
});
|
||||
|
||||
test("VaultView re-renders when proxy profiles change", () => {
|
||||
const baseProps = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
snippets: [],
|
||||
snippetPackages: [],
|
||||
notes: [],
|
||||
noteGroups: [],
|
||||
customGroups: [],
|
||||
knownHosts: [],
|
||||
connectionLogs: [],
|
||||
sessions: [],
|
||||
managedSources: [],
|
||||
groupConfigs: {},
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
navigateToSection: null,
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
vaultViewAreEqual(
|
||||
baseProps as never,
|
||||
@@ -105,38 +65,12 @@ test("VaultView re-renders when proxy profiles change", () => {
|
||||
});
|
||||
|
||||
test("VaultView re-renders when host-key verification setting changes", () => {
|
||||
const baseProps = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
snippets: [],
|
||||
snippetPackages: [],
|
||||
notes: [],
|
||||
noteGroups: [],
|
||||
customGroups: [],
|
||||
knownHosts: [],
|
||||
connectionLogs: [],
|
||||
sessions: [],
|
||||
managedSources: [],
|
||||
groupConfigs: {},
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
navigateToSection: null,
|
||||
terminalSettings: {
|
||||
verifyHostKeys: true,
|
||||
keepaliveInterval: 30,
|
||||
keepaliveCountMax: 10,
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
vaultViewAreEqual(
|
||||
baseProps as never,
|
||||
{
|
||||
...baseProps,
|
||||
terminalSettings: {
|
||||
...baseProps.terminalSettings,
|
||||
verifyHostKeys: false,
|
||||
},
|
||||
} as never,
|
||||
@@ -144,3 +78,35 @@ test("VaultView re-renders when host-key verification setting changes", () => {
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("VaultView ignores connectionLogs identity when not on logs section", () => {
|
||||
syncVaultViewMemoSection("hosts");
|
||||
const nextLogs = [{ id: "log-1" }];
|
||||
assert.equal(
|
||||
vaultViewAreEqual(
|
||||
baseProps as never,
|
||||
{ ...baseProps, connectionLogs: nextLogs } as never,
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("VaultView compares connectionLogs identity when on logs section", () => {
|
||||
syncVaultViewMemoSection("logs");
|
||||
const nextLogs = [{ id: "log-2" }];
|
||||
assert.equal(
|
||||
vaultViewAreEqual(
|
||||
baseProps as never,
|
||||
{ ...baseProps, connectionLogs: nextLogs } as never,
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
vaultViewAreEqual(
|
||||
{ ...baseProps, connectionLogs: nextLogs } as never,
|
||||
{ ...baseProps, connectionLogs: nextLogs } as never,
|
||||
),
|
||||
true,
|
||||
);
|
||||
syncVaultViewMemoSection("hosts");
|
||||
});
|
||||
@@ -147,7 +147,7 @@ import { Badge } from "./ui/badge";
|
||||
import { HotkeyScheme, KeyBinding } from "../domain/models";
|
||||
import { VaultViewLayout } from "./vault/VaultViewLayout";
|
||||
import { useVaultHostCollections } from "./vault/useVaultHostCollections";
|
||||
import { useVaultImportHandlers } from "./vault/useVaultImportHandlers";
|
||||
import { useVaultImportHandlers } from "../application/state/useVaultImportHandlers";
|
||||
import { useVaultGroupDragHandlers } from "./vault/useVaultGroupDragHandlers";
|
||||
|
||||
const LazyProtocolSelectDialog = lazy(() => import("./ProtocolSelectDialog"));
|
||||
@@ -167,6 +167,16 @@ export type VaultSection =
|
||||
| "knownhosts"
|
||||
| "logs";
|
||||
|
||||
/** Memo section tracker: areEqual ignores connectionLogs unless on logs. */
|
||||
let vaultViewMemoSection: VaultSection = "hosts";
|
||||
/** Always stashed from areEqual so skipped updates stay available when opening logs. */
|
||||
let vaultViewLatestConnectionLogs: ConnectionLog[] | null = null;
|
||||
|
||||
/** Test/helper: sync the section used by vaultViewAreEqual. */
|
||||
export function syncVaultViewMemoSection(section: VaultSection): void {
|
||||
vaultViewMemoSection = section;
|
||||
}
|
||||
|
||||
const haveSameHostOrderResult = (previous: Host[], next: Host[]) => {
|
||||
if (previous.length !== next.length) return false;
|
||||
return next.every((host, index) => {
|
||||
@@ -365,6 +375,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
const hostsRef = useRef(hosts);
|
||||
hostsRef.current = hosts;
|
||||
const [currentSection, setCurrentSection] = useState<VaultSection>("hosts");
|
||||
vaultViewMemoSection = currentSection;
|
||||
const resolvedConnectionLogs = vaultViewLatestConnectionLogs ?? connectionLogs;
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedGroupPath, setSelectedGroupPath] = useState<string | null>(
|
||||
null,
|
||||
@@ -888,6 +900,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
hosts,
|
||||
keys,
|
||||
managedSources,
|
||||
notify: toast,
|
||||
onReadPersistedHosts,
|
||||
onUpdateHosts,
|
||||
onUpdateKeys,
|
||||
@@ -1414,7 +1427,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
Clock,
|
||||
cn,
|
||||
commitInlineGroupRename,
|
||||
connectionLogs,
|
||||
connectionLogs: resolvedConnectionLogs,
|
||||
connectSelectedHosts,
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
@@ -1667,6 +1680,11 @@ export const vaultViewAreEqual = (
|
||||
prev: VaultViewProps,
|
||||
next: VaultViewProps,
|
||||
): boolean => {
|
||||
vaultViewLatestConnectionLogs = next.connectionLogs;
|
||||
const connectionLogsEqual =
|
||||
vaultViewMemoSection === "logs"
|
||||
? prev.connectionLogs === next.connectionLogs
|
||||
: true;
|
||||
const isEqual =
|
||||
prev.hosts === next.hosts &&
|
||||
prev.keys === next.keys &&
|
||||
@@ -1679,7 +1697,7 @@ export const vaultViewAreEqual = (
|
||||
prev.customGroups === next.customGroups &&
|
||||
prev.knownHosts === next.knownHosts &&
|
||||
// shellHistory is not a VaultView prop; SnippetsManager reads shellHistoryStore.
|
||||
prev.connectionLogs === next.connectionLogs &&
|
||||
connectionLogsEqual &&
|
||||
prev.sessionCount === next.sessionCount &&
|
||||
prev.managedSources === next.managedSources &&
|
||||
prev.groupConfigs === next.groupConfigs &&
|
||||
|
||||
@@ -37,7 +37,7 @@ import type { AgentModelPreset, AIPermissionMode, ProviderConfig, UploadedFile }
|
||||
import { ProviderIconBadge } from '../settings/tabs/ai/ProviderIconBadge';
|
||||
import { VariableSizeVirtualList, type VariableSizeVirtualListHandle } from '../ui/VariableSizeVirtualList';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import type { AgentContextUsage } from './hooks/useAgentCompactionUi';
|
||||
import type { AgentContextUsage } from '../../application/state/useAgentCompactionUi';
|
||||
import {
|
||||
CHAT_INPUT_DEFAULT_HEIGHT,
|
||||
CHAT_INPUT_MAX_HEIGHT,
|
||||
|
||||
@@ -38,16 +38,24 @@ test("resolved approval state retains only tool calls still present in messages"
|
||||
assert.deepEqual([...next], [["live-call", false]]);
|
||||
});
|
||||
|
||||
test("assistant content stays plain only when markdown is hidden", () => {
|
||||
test("assistant content stays plain when markdown is hidden or streaming", () => {
|
||||
assert.equal(shouldRenderAssistantAsPlainText({
|
||||
hideMarkdown: false,
|
||||
}), false);
|
||||
assert.equal(shouldRenderAssistantAsPlainText({
|
||||
hideMarkdown: true,
|
||||
}), true);
|
||||
assert.equal(shouldRenderAssistantAsPlainText({
|
||||
hideMarkdown: false,
|
||||
isStreaming: true,
|
||||
}), true);
|
||||
assert.equal(shouldRenderAssistantAsPlainText({
|
||||
hideMarkdown: false,
|
||||
isStreaming: false,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("ChatMessageList renders markdown for the streaming assistant message", () => {
|
||||
test("ChatMessageList renders plain text for the streaming assistant message", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{
|
||||
id: "user-1",
|
||||
@@ -75,15 +83,16 @@ test("ChatMessageList renders markdown for the streaming assistant message", ()
|
||||
),
|
||||
);
|
||||
|
||||
assert.match(markup, /data-ai-content="markdown"/);
|
||||
assert.match(markup, /data-ai-content="plain"/);
|
||||
assert.match(markup, /streaming-body/);
|
||||
assert.doesNotMatch(markup, /data-ai-content="plain"/);
|
||||
assert.doesNotMatch(markup, /data-ai-content="markdown"/);
|
||||
});
|
||||
|
||||
test("streaming markdown keeps the renderer in its animating mode", () => {
|
||||
test("streaming assistant content skips Streamdown while the turn is live", () => {
|
||||
const source = readFileSync(new URL("./ChatMessageList.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /LazyMessageResponse isAnimating=\{!!isThisStreaming\}/);
|
||||
assert.match(source, /isStreaming: !!isThisStreaming/);
|
||||
assert.match(source, /shouldRenderAssistantAsPlainText\(\{[\s\S]*?isStreaming/);
|
||||
});
|
||||
|
||||
test("ChatMessageList hydrates markdown after streaming settles", () => {
|
||||
|
||||
@@ -67,7 +67,7 @@ import {
|
||||
compactionStatusText,
|
||||
resolveCompactionStatusText,
|
||||
type ActiveCompactionUi,
|
||||
} from './hooks/useAgentCompactionUi';
|
||||
} from '../../application/state/useAgentCompactionUi';
|
||||
import {
|
||||
getAIPanelDiagnosticHiddenParts,
|
||||
getAIPanelProfilerProps,
|
||||
@@ -113,8 +113,9 @@ export function shouldProvideVaultArtifactNavigation({
|
||||
|
||||
export function shouldRenderAssistantAsPlainText(options: {
|
||||
hideMarkdown: boolean;
|
||||
isStreaming?: boolean;
|
||||
}): boolean {
|
||||
return options.hideMarkdown;
|
||||
return options.hideMarkdown || !!options.isStreaming;
|
||||
}
|
||||
|
||||
const ASSISTANT_PLAIN_TEXT_CLASS = 'whitespace-pre-wrap break-words text-[13px] leading-[1.45]';
|
||||
@@ -702,6 +703,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
? <div className={ASSISTANT_PLAIN_TEXT_CLASS}>{message.content}</div>
|
||||
: shouldRenderAssistantAsPlainText({
|
||||
hideMarkdown,
|
||||
isStreaming: !!isThisStreaming,
|
||||
})
|
||||
? (
|
||||
<div
|
||||
@@ -714,7 +716,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
: (
|
||||
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Markdown')}>
|
||||
<div data-ai-content="markdown">
|
||||
<LazyMessageResponse isAnimating={!!isThisStreaming}>
|
||||
<LazyMessageResponse isAnimating={false}>
|
||||
{message.content}
|
||||
</LazyMessageResponse>
|
||||
</div>
|
||||
|
||||
@@ -2,14 +2,19 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
const storage = new Map<string, string>();
|
||||
const localStorageStub = {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { storage.set(key, value); },
|
||||
removeItem: (key: string) => { storage.delete(key); },
|
||||
};
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: localStorageStub,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
localStorage: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => storage.set(key, value),
|
||||
removeItem: (key: string) => storage.delete(key),
|
||||
},
|
||||
localStorage: localStorageStub,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,81 +1,13 @@
|
||||
import type React from 'react';
|
||||
|
||||
export const AI_PANEL_DIAGNOSTIC_HIDE_KEY = 'netcatty.aiDebug.hide';
|
||||
export const AI_PANEL_DIAGNOSTIC_PROFILE_KEY = 'netcatty.aiDebug.profile';
|
||||
export const AI_PANEL_FORCE_HIDE_ALL_CONTENT = false;
|
||||
export const AI_PANEL_FORCE_HIDE_SHELL = false;
|
||||
|
||||
export type AIPanelDiagnosticPart =
|
||||
| 'all'
|
||||
| 'attachments'
|
||||
| 'header'
|
||||
| 'history'
|
||||
| 'input'
|
||||
| 'markdown'
|
||||
| 'messages'
|
||||
| 'recent'
|
||||
| 'toolcalls';
|
||||
|
||||
function readLocalStorageValue(key: string): string {
|
||||
if (typeof window === 'undefined') return '';
|
||||
try {
|
||||
return window.localStorage.getItem(key) ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function getAIPanelDiagnosticHiddenParts(): ReadonlySet<string> {
|
||||
if (AI_PANEL_FORCE_HIDE_ALL_CONTENT) {
|
||||
return new Set(['all']);
|
||||
}
|
||||
const raw = readLocalStorageValue(AI_PANEL_DIAGNOSTIC_HIDE_KEY);
|
||||
return new Set(
|
||||
raw
|
||||
.split(',')
|
||||
.map((part) => part.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
export function isAIPanelDiagnosticPartHidden(
|
||||
part: AIPanelDiagnosticPart,
|
||||
hiddenParts = getAIPanelDiagnosticHiddenParts(),
|
||||
): boolean {
|
||||
return hiddenParts.has('all') || hiddenParts.has(part);
|
||||
}
|
||||
|
||||
export function isAIPanelDiagnosticsProfilingEnabled(): boolean {
|
||||
const raw = readLocalStorageValue(AI_PANEL_DIAGNOSTIC_PROFILE_KEY).trim().toLowerCase();
|
||||
return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
|
||||
}
|
||||
|
||||
export function logAIPanelProfiler(
|
||||
id: string,
|
||||
phase: 'mount' | 'update' | 'nested-update',
|
||||
actualDuration: number,
|
||||
baseDuration: number,
|
||||
): void {
|
||||
if (!isAIPanelDiagnosticsProfilingEnabled()) return;
|
||||
console.info(
|
||||
`[AI panel profile] ${id} ${phase}: actual=${actualDuration.toFixed(1)}ms base=${baseDuration.toFixed(1)}ms`,
|
||||
);
|
||||
}
|
||||
|
||||
export function profileAIPanelCalculation<T>(label: string, calculate: () => T): T {
|
||||
if (!isAIPanelDiagnosticsProfilingEnabled()) return calculate();
|
||||
const startedAt = performance.now();
|
||||
try {
|
||||
return calculate();
|
||||
} finally {
|
||||
const elapsed = performance.now() - startedAt;
|
||||
console.info(`[AI panel profile] ${label}: ${elapsed.toFixed(1)}ms`);
|
||||
}
|
||||
}
|
||||
|
||||
export function getAIPanelProfilerProps(id: string): Pick<React.ProfilerProps, 'id' | 'onRender'> {
|
||||
return {
|
||||
id,
|
||||
onRender: logAIPanelProfiler,
|
||||
};
|
||||
}
|
||||
export {
|
||||
AI_PANEL_DIAGNOSTIC_HIDE_KEY,
|
||||
AI_PANEL_DIAGNOSTIC_PROFILE_KEY,
|
||||
AI_PANEL_FORCE_HIDE_ALL_CONTENT,
|
||||
AI_PANEL_FORCE_HIDE_SHELL,
|
||||
getAIPanelDiagnosticHiddenParts,
|
||||
getAIPanelProfilerProps,
|
||||
isAIPanelDiagnosticPartHidden,
|
||||
isAIPanelDiagnosticsProfilingEnabled,
|
||||
logAIPanelProfiler,
|
||||
profileAIPanelCalculation,
|
||||
type AIPanelDiagnosticPart,
|
||||
} from '../../application/state/aiPanelDiagnostics';
|
||||
@@ -1,288 +1,2 @@
|
||||
import type { NetcattyBridge } from '../../../infrastructure/ai/cattyAgent/executor';
|
||||
import type {
|
||||
OpenAIChatAssistantFields,
|
||||
ProviderContinuationOptions,
|
||||
ProviderContinuationSource,
|
||||
} from '../../../infrastructure/ai/providerContinuation';
|
||||
|
||||
/** Shape of a text/text-delta chunk from the Vercel AI SDK stream. */
|
||||
export interface TextDeltaChunk {
|
||||
type: 'text' | 'text-delta';
|
||||
text?: string;
|
||||
textDelta?: string;
|
||||
providerMetadata?: unknown;
|
||||
}
|
||||
|
||||
/** Shape of a reasoning chunk from the Vercel AI SDK stream. */
|
||||
export interface ReasoningChunk {
|
||||
type: 'reasoning' | 'reasoning-start' | 'reasoning-delta';
|
||||
text?: string;
|
||||
textDelta?: string;
|
||||
delta?: string;
|
||||
providerMetadata?: unknown;
|
||||
}
|
||||
|
||||
/** Shape of a raw provider chunk from the Vercel AI SDK stream. */
|
||||
export interface RawChunk {
|
||||
type: 'raw';
|
||||
rawValue: unknown;
|
||||
}
|
||||
|
||||
/** Shape of a tool-call chunk from the Vercel AI SDK stream. */
|
||||
export interface ToolCallChunk {
|
||||
type: 'tool-call';
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
input?: unknown;
|
||||
args?: unknown;
|
||||
providerMetadata?: unknown;
|
||||
}
|
||||
|
||||
/** Shape of a tool-result chunk from the Vercel AI SDK stream. */
|
||||
export interface ToolResultChunk {
|
||||
type: 'tool-result';
|
||||
toolCallId: string;
|
||||
output?: unknown;
|
||||
result?: unknown;
|
||||
}
|
||||
|
||||
/** Shape of a tool-error chunk from the Vercel AI SDK stream. */
|
||||
export interface ToolErrorChunk {
|
||||
type: 'tool-error';
|
||||
toolCallId: string;
|
||||
toolName?: string;
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
/** Shape of a tool-output-denied chunk from the Vercel AI SDK stream. */
|
||||
export interface ToolOutputDeniedChunk {
|
||||
type: 'tool-output-denied';
|
||||
toolCallId: string;
|
||||
toolName?: string;
|
||||
}
|
||||
|
||||
/** Nested tool call reference on approval stream chunks. */
|
||||
export interface StreamChunkToolCallRef {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
input?: unknown;
|
||||
args?: unknown;
|
||||
}
|
||||
|
||||
/** Shape of a tool-approval-response chunk from the Vercel AI SDK stream. */
|
||||
export interface ToolApprovalResponseChunk {
|
||||
type: 'tool-approval-response';
|
||||
approvalId?: string;
|
||||
approved?: boolean;
|
||||
reason?: string;
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
toolCall?: StreamChunkToolCallRef;
|
||||
}
|
||||
|
||||
/** Resolve toolCallId from flat or nested approval/tool chunks. */
|
||||
export function resolveStreamChunkToolCallId(chunk: {
|
||||
toolCallId?: string;
|
||||
toolCall?: { toolCallId?: string };
|
||||
}): string | undefined {
|
||||
return chunk.toolCallId ?? chunk.toolCall?.toolCallId;
|
||||
}
|
||||
|
||||
/** Format tool execution failures for model/UI consumption. */
|
||||
export function formatToolErrorContent(error: unknown, fallback = 'Tool execution failed.'): string {
|
||||
if (error instanceof Error) return JSON.stringify({ error: error.message });
|
||||
if (typeof error === 'string') return JSON.stringify({ error });
|
||||
if (error != null && typeof error === 'object' && 'error' in error) {
|
||||
return JSON.stringify(error);
|
||||
}
|
||||
return JSON.stringify({ error: fallback });
|
||||
}
|
||||
|
||||
/** Detect tool results that represent errors/denials (e.g. `{ error: "..." }` or `{ ok: false }`) */
|
||||
export function isToolResultError(output: unknown): boolean {
|
||||
if (output == null) return false;
|
||||
|
||||
if (typeof output === 'object') {
|
||||
const obj = output as Record<string, unknown>;
|
||||
// Check for explicit error objects
|
||||
if ('error' in obj && typeof obj.error === 'string') return true;
|
||||
if ('ok' in obj && obj.ok === false) return true;
|
||||
}
|
||||
|
||||
// Check stringified JSON (common for tool result wrapping)
|
||||
if (typeof output === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(output);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const parsedObj = parsed as Record<string, unknown>;
|
||||
if ('error' in parsedObj && typeof parsedObj.error === 'string') return true;
|
||||
if ('ok' in parsedObj && parsedObj.ok === false) return true;
|
||||
}
|
||||
} catch { /* not JSON, not an error */ }
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Shape of an error chunk from the Vercel AI SDK stream. */
|
||||
export interface ErrorChunk {
|
||||
type: 'error';
|
||||
error: unknown;
|
||||
}
|
||||
|
||||
/** Union of all stream chunk shapes we handle. */
|
||||
export type StreamChunk =
|
||||
| TextDeltaChunk
|
||||
| ReasoningChunk
|
||||
| ToolCallChunk
|
||||
| ToolResultChunk
|
||||
| ToolErrorChunk
|
||||
| ToolOutputDeniedChunk
|
||||
| ToolApprovalResponseChunk
|
||||
| ErrorChunk
|
||||
| RawChunk
|
||||
| { type: 'reasoning-end' | 'text-start' | 'text-end' | 'start' | 'finish' | 'start-step' | 'finish-step' | 'tool-approval-request'; approvalId?: string; toolCallId?: string; toolName?: string; approved?: boolean; toolCall?: StreamChunkToolCallRef; input?: unknown; args?: unknown };
|
||||
|
||||
/** Shape of the netcatty bridge exposed on `window` (panel-specific subset). */
|
||||
export interface PanelBridge extends NetcattyBridge {
|
||||
credentialsDecrypt?: (value: string) => Promise<string>;
|
||||
aiSyncProviders?: (providers: Array<{ id: string; providerId: string; apiKey?: string; baseURL?: string; enabled: boolean }>) => Promise<{ ok: boolean }>;
|
||||
aiSyncWebSearch?: (apiHost: string | null, apiKey: string | null) => Promise<{ ok: boolean }>;
|
||||
aiMcpUpdateSessions?: (sessions: TerminalSessionInfo[], chatSessionId?: string) => Promise<unknown>;
|
||||
aiMcpUpdateAttachments?: (
|
||||
attachments: Array<{ base64Data?: string; mediaType?: string; filename?: string; filePath?: string }>,
|
||||
chatSessionId?: string,
|
||||
) => Promise<unknown>;
|
||||
aiSdkAgentListModels?: (
|
||||
sdkBackend: string,
|
||||
cwd?: string,
|
||||
providerId?: string,
|
||||
chatSessionId?: string,
|
||||
agentEnv?: Record<string, string>,
|
||||
agentCommand?: string,
|
||||
codexRuntime?: 'sdk' | 'app-server',
|
||||
) => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string; thinkingLevels?: string[]; defaultThinkingLevel?: string }>; currentModelId?: string | null; warning?: string; error?: string }>;
|
||||
aiCattyCancelExec?(chatSessionId: string): Promise<unknown>;
|
||||
aiSetChatSessionCancelled?(chatSessionId: string, cancelled?: boolean): Promise<{ ok: boolean; error?: string }>;
|
||||
aiMcpSyncPermissionGrants?(grants: Array<Record<string, unknown>>): Promise<{ ok: boolean; count?: number; error?: string }>;
|
||||
aiSdkAgentCancel?: (requestId: string, chatSessionId?: string) => Promise<{ ok: boolean; error?: string }>;
|
||||
aiSdkAgentSteer?: (
|
||||
requestId: string,
|
||||
chatSessionId: string,
|
||||
prompt: string,
|
||||
images: Array<{ base64Data: string; mediaType: string; filename?: string; filePath?: string }> | undefined,
|
||||
clientUserMessageId: string,
|
||||
) => Promise<{
|
||||
status: 'accepted' | 'not-steerable' | 'busy' | 'inactive' | 'unsupported' | 'cancelled' | 'failed';
|
||||
message?: string;
|
||||
turnKind?: 'review' | 'compact';
|
||||
}>;
|
||||
aiSdkAgentCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
|
||||
aiUserSkillsGetStatus?: () => Promise<{
|
||||
ok: boolean;
|
||||
skills?: Array<{
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
status: 'ready' | 'warning';
|
||||
}>;
|
||||
}>;
|
||||
aiUserSkillsBuildContext?: (prompt: string, selectedSkillSlugs?: string[]) => Promise<{ ok: boolean; context?: string; error?: string }>;
|
||||
[key: string]: ((...args: unknown[]) => unknown) | undefined;
|
||||
}
|
||||
|
||||
/** Terminal session info used throughout the streaming hooks. */
|
||||
export interface TerminalSessionInfo {
|
||||
sessionId: string;
|
||||
hostId: string;
|
||||
hostname: string;
|
||||
label: string;
|
||||
os?: string;
|
||||
username?: string;
|
||||
protocol?: string;
|
||||
shellType?: string;
|
||||
deviceType?: string;
|
||||
connected: boolean;
|
||||
hostChain?: Array<{ hostId: string; label?: string; hostname?: string }>;
|
||||
activePortForwards?: Array<{
|
||||
ruleId: string;
|
||||
label?: string;
|
||||
type?: string;
|
||||
localPort?: number;
|
||||
status?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface DefaultTargetSessionHint extends TerminalSessionInfo {
|
||||
source: 'scope-target' | 'only-connected-in-scope';
|
||||
}
|
||||
|
||||
export interface CattyProviderContinuationContext {
|
||||
source: ProviderContinuationSource;
|
||||
openAIChatAssistantFields: Array<OpenAIChatAssistantFields | undefined>;
|
||||
}
|
||||
|
||||
export type AssistantContentPart =
|
||||
| { type: 'reasoning'; text: string; providerOptions?: ProviderContinuationOptions }
|
||||
| { type: 'text'; text: string; providerOptions?: ProviderContinuationOptions }
|
||||
| { type: 'tool-call'; toolCallId: string; toolName: string; input: unknown; providerOptions?: ProviderContinuationOptions };
|
||||
|
||||
export function toAssistantModelContent(parts: AssistantContentPart[]): string | AssistantContentPart[] {
|
||||
if (parts.length === 1 && parts[0].type === 'text' && !parts[0].providerOptions) {
|
||||
return parts[0].text;
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** Typed accessor for the netcatty bridge on the window object. */
|
||||
export function getNetcattyBridge(): PanelBridge | undefined {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (window as any).netcatty as PanelBridge | undefined;
|
||||
}
|
||||
|
||||
// ApprovalInfo and PendingApprovalContext removed — approval is now handled
|
||||
// inside the tool's execute function via the approvalGate module.
|
||||
|
||||
export function generateId(): string {
|
||||
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
const USER_SKILLS_CONTEXT_TIMEOUT_MS = 500;
|
||||
|
||||
interface UserSkillsContextResult {
|
||||
ok: boolean;
|
||||
context?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function buildExplicitUserSkillsFallback(selectedUserSkillSlugs?: string[]): string {
|
||||
if (!selectedUserSkillSlugs?.length) return '';
|
||||
return `The user explicitly selected these Netcatty user skills for this request: ${selectedUserSkillSlugs.map((slug) => `/${slug}`).join(', ')}. Honor those selections even if their expanded skill content is unavailable.`;
|
||||
}
|
||||
|
||||
export async function resolveUserSkillsContext(
|
||||
bridge: PanelBridge | undefined,
|
||||
prompt: string,
|
||||
selectedUserSkillSlugs?: string[],
|
||||
): Promise<string> {
|
||||
if (!bridge?.aiUserSkillsBuildContext) {
|
||||
return buildExplicitUserSkillsFallback(selectedUserSkillSlugs);
|
||||
}
|
||||
|
||||
const buildContextPromise: Promise<UserSkillsContextResult> = bridge
|
||||
.aiUserSkillsBuildContext(prompt, selectedUserSkillSlugs)
|
||||
.catch(() => ({ ok: false, context: '' }));
|
||||
|
||||
const hasExplicitSelections = (selectedUserSkillSlugs?.length ?? 0) > 0;
|
||||
const result = hasExplicitSelections
|
||||
? await buildContextPromise
|
||||
: await Promise.race([
|
||||
buildContextPromise,
|
||||
new Promise<UserSkillsContextResult>((resolve) =>
|
||||
setTimeout(() => resolve({ ok: false, context: '' }), USER_SKILLS_CONTEXT_TIMEOUT_MS),
|
||||
),
|
||||
]);
|
||||
|
||||
return result.context || buildExplicitUserSkillsFallback(selectedUserSkillSlugs);
|
||||
}
|
||||
/** @deprecated Import from `@/infrastructure/ai/aiChatStreamingSupport` instead. */
|
||||
export * from '../../../infrastructure/ai/aiChatStreamingSupport';
|
||||
@@ -0,0 +1,16 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const source = readFileSync(new URL("./UnsavedChangesDialog.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("unsaved prompt singleton registers during render, not only in useEffect", () => {
|
||||
// AppView close / Cmd+W call promptUnsavedChanges outside the render-prop.
|
||||
// Assigning only in useEffect leaves a first-paint window where the
|
||||
// singleton is null and dirty closes silently resolve to "cancel".
|
||||
assert.match(source, /promptSingleton = prompt;/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/useEffect\(\(\) => \{\s*promptSingleton = prompt;/,
|
||||
);
|
||||
});
|
||||
@@ -41,12 +41,12 @@ export const UnsavedChangesProvider: React.FC<{
|
||||
[],
|
||||
);
|
||||
|
||||
// Register the prompt function as the module-level singleton so it can be
|
||||
// called from outside the React tree (e.g. useSftpViewPaneActions).
|
||||
useEffect(() => {
|
||||
promptSingleton = prompt;
|
||||
return () => { promptSingleton = null; };
|
||||
}, [prompt]);
|
||||
// Keep the singleton current during render so AppView close handlers and
|
||||
// hotkeys never race the post-commit useEffect registration window.
|
||||
promptSingleton = prompt;
|
||||
useEffect(() => () => {
|
||||
promptSingleton = null;
|
||||
}, []);
|
||||
|
||||
// On unmount, resolve any in-flight prompt as "cancel" so awaiting callers don't leak.
|
||||
useEffect(() => () => {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Container-width column policy (mirrors former 2 / sm:3 / xl:4 breakpoints). */
|
||||
const KNOWN_HOSTS_SM_MIN_WIDTH = 640;
|
||||
const KNOWN_HOSTS_XL_MIN_WIDTH = 1280;
|
||||
|
||||
export function getKnownHostsGridColumnCount(width: number): number {
|
||||
if (!Number.isFinite(width) || width <= 0) return 2;
|
||||
if (width >= KNOWN_HOSTS_XL_MIN_WIDTH) return 4;
|
||||
if (width >= KNOWN_HOSTS_SM_MIN_WIDTH) return 3;
|
||||
return 2;
|
||||
}
|
||||
@@ -246,12 +246,28 @@ test("NotesManager shows placeholder label for notes without titles", () => {
|
||||
assert.match(markup, /Note title/);
|
||||
});
|
||||
|
||||
test("NotesManager flushes drafts on pagehide/beforeunload", () => {
|
||||
const source = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
|
||||
assert.match(source, /addEventListener\("pagehide"/);
|
||||
assert.match(source, /addEventListener\("beforeunload"/);
|
||||
assert.match(source, /flushNoteDraft/);
|
||||
});
|
||||
|
||||
test("NotesManager flushes drafts when retained mounts become inactive", () => {
|
||||
const source = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
|
||||
assert.match(source, /isActive\?: boolean/);
|
||||
assert.match(source, /if \(isActive\) return;/);
|
||||
assert.match(source, /useLayoutEffect\(\(\) => \{\s*if \(isActive\) return;/);
|
||||
assert.match(source, /visibilitychange/);
|
||||
});
|
||||
|
||||
test("NotesManager tree rename allows clearing note titles", () => {
|
||||
const source = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
|
||||
const renameBlock = source.match(/onRenameCommit=\{\(name\) => \{[\s\S]*?\}\}/)?.[0] ?? "";
|
||||
|
||||
assert.match(renameBlock, /saveNote\(\{ \.\.\.note, title: name\.trim\(\)/);
|
||||
assert.match(renameBlock, /renameNoteFromTree\(note\.id, name\)/);
|
||||
assert.doesNotMatch(renameBlock, /if \(!title\) return/);
|
||||
assert.match(source, /note\.id === noteId \? \{ \.\.\.note, title: nextTitle, updatedAt \}/);
|
||||
});
|
||||
|
||||
test("NotesManager sidebar mode renders list without editor by default", () => {
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import React, { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import { useApplicationBackend } from "../../application/state/useApplicationBackend";
|
||||
import { useStoredNumber } from "../../application/state/useStoredNumber";
|
||||
@@ -121,6 +121,8 @@ export interface NotesManagerProps {
|
||||
onUpdateNoteGroups: (groups: string[]) => void;
|
||||
onOpenHost?: (host: Host, source?: { noteId: string }) => void;
|
||||
displayMode?: "full" | "sidebar";
|
||||
/** When false (hidden retained mount), flush any pending draft immediately. */
|
||||
isActive?: boolean;
|
||||
openNoteId?: string | null;
|
||||
openNoteRequestId?: number | null;
|
||||
/** Called after a one-shot openNoteId focus request has been applied. */
|
||||
@@ -340,6 +342,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
onUpdateNoteGroups,
|
||||
onOpenHost,
|
||||
displayMode = "full",
|
||||
isActive = true,
|
||||
openNoteId = null,
|
||||
openNoteRequestId = null,
|
||||
onOpenNoteIdHandled,
|
||||
@@ -401,6 +404,97 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
return cleaned;
|
||||
}, [onUpdateNotes]);
|
||||
|
||||
const NOTE_DRAFT_DEBOUNCE_MS = 300;
|
||||
const draftNoteIdRef = useRef<string | null>(null);
|
||||
const draftTitleRef = useRef<string | null>(null);
|
||||
const draftContentRef = useRef<string | null>(null);
|
||||
const draftTimerRef = useRef<number | null>(null);
|
||||
const [draftNoteId, setDraftNoteId] = useState<string | null>(null);
|
||||
const [draftTitle, setDraftTitle] = useState<string | null>(null);
|
||||
const [draftContent, setDraftContent] = useState<string | null>(null);
|
||||
|
||||
const clearDraftTimer = useCallback(() => {
|
||||
if (draftTimerRef.current !== null) {
|
||||
window.clearTimeout(draftTimerRef.current);
|
||||
draftTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const flushNoteDraft = useCallback(() => {
|
||||
clearDraftTimer();
|
||||
const noteId = draftNoteIdRef.current;
|
||||
if (!noteId) return;
|
||||
const title = draftTitleRef.current;
|
||||
const content = draftContentRef.current;
|
||||
draftNoteIdRef.current = null;
|
||||
draftTitleRef.current = null;
|
||||
draftContentRef.current = null;
|
||||
setDraftNoteId(null);
|
||||
setDraftTitle(null);
|
||||
setDraftContent(null);
|
||||
if (title === null && content === null) return;
|
||||
commitNotes(sortedNotesRef.current.map((note) => {
|
||||
if (note.id !== noteId) return note;
|
||||
return {
|
||||
...note,
|
||||
...(title !== null ? { title } : {}),
|
||||
...(content !== null ? { content } : {}),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}));
|
||||
}, [clearDraftTimer, commitNotes]);
|
||||
|
||||
const scheduleNoteDraftFlush = useCallback(() => {
|
||||
clearDraftTimer();
|
||||
draftTimerRef.current = window.setTimeout(() => {
|
||||
draftTimerRef.current = null;
|
||||
flushNoteDraft();
|
||||
}, NOTE_DRAFT_DEBOUNCE_MS);
|
||||
}, [clearDraftTimer, flushNoteDraft]);
|
||||
|
||||
const updateNoteDraft = useCallback((noteId: string, fields: { title?: string; content?: string }) => {
|
||||
if (draftNoteIdRef.current && draftNoteIdRef.current !== noteId) {
|
||||
flushNoteDraft();
|
||||
}
|
||||
draftNoteIdRef.current = noteId;
|
||||
setDraftNoteId(noteId);
|
||||
if (fields.title !== undefined) {
|
||||
draftTitleRef.current = fields.title;
|
||||
setDraftTitle(fields.title);
|
||||
}
|
||||
if (fields.content !== undefined) {
|
||||
draftContentRef.current = fields.content;
|
||||
setDraftContent(fields.content);
|
||||
}
|
||||
scheduleNoteDraftFlush();
|
||||
}, [flushNoteDraft, scheduleNoteDraftFlush]);
|
||||
|
||||
useEffect(() => () => {
|
||||
flushNoteDraft();
|
||||
}, [flushNoteDraft]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isActive) return;
|
||||
flushNoteDraft();
|
||||
}, [flushNoteDraft, isActive]);
|
||||
|
||||
useEffect(() => {
|
||||
const flushOnTeardown = () => {
|
||||
flushNoteDraft();
|
||||
};
|
||||
const flushOnHidden = () => {
|
||||
if (document.visibilityState === "hidden") flushNoteDraft();
|
||||
};
|
||||
window.addEventListener("pagehide", flushOnTeardown);
|
||||
window.addEventListener("beforeunload", flushOnTeardown);
|
||||
document.addEventListener("visibilitychange", flushOnHidden);
|
||||
return () => {
|
||||
window.removeEventListener("pagehide", flushOnTeardown);
|
||||
window.removeEventListener("beforeunload", flushOnTeardown);
|
||||
document.removeEventListener("visibilitychange", flushOnHidden);
|
||||
};
|
||||
}, [flushNoteDraft]);
|
||||
|
||||
const noteTree = useMemo(() => {
|
||||
const tree = buildNoteTree(groups, sortedNotes);
|
||||
return {
|
||||
@@ -410,6 +504,26 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
}, [groupOrderByPath, groups, sortedNotes]);
|
||||
const selectedNote = getSelectedVaultNote(sortedNotes, selectedNoteId);
|
||||
const overlayNote = sortedNotes.find((note) => note.id === overlayNoteId) ?? null;
|
||||
const selectedNoteView = selectedNote && draftNoteId === selectedNote.id
|
||||
? {
|
||||
...selectedNote,
|
||||
...(draftTitle !== null ? { title: draftTitle } : {}),
|
||||
...(draftContent !== null ? { content: draftContent } : {}),
|
||||
}
|
||||
: selectedNote;
|
||||
const overlayNoteView = overlayNote && draftNoteId === overlayNote.id
|
||||
? {
|
||||
...overlayNote,
|
||||
...(draftTitle !== null ? { title: draftTitle } : {}),
|
||||
...(draftContent !== null ? { content: draftContent } : {}),
|
||||
}
|
||||
: overlayNote;
|
||||
|
||||
useEffect(() => {
|
||||
if (!draftNoteIdRef.current) return;
|
||||
if (draftNoteIdRef.current === selectedNoteId || draftNoteIdRef.current === overlayNoteId) return;
|
||||
flushNoteDraft();
|
||||
}, [flushNoteDraft, overlayNoteId, selectedNoteId]);
|
||||
|
||||
useEffect(() => {
|
||||
const urls = activeDownloadUrlsRef.current;
|
||||
@@ -496,8 +610,28 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
const expandAllGroups = () => setExpandedGroups(new Set(allGroupPaths));
|
||||
const collapseAllGroups = () => setExpandedGroups(new Set());
|
||||
|
||||
const saveNote = (nextNote: VaultNote) => {
|
||||
commitNotes(sortedNotes.map((note) => (note.id === nextNote.id ? nextNote : note)));
|
||||
/** Flush pending draft, then mutate from the post-flush ref snapshot. */
|
||||
const commitNotesAfterFlush = useCallback((mutator: (notes: VaultNote[]) => VaultNote[]) => {
|
||||
flushNoteDraft();
|
||||
return commitNotes(mutator(sortedNotesRef.current));
|
||||
}, [commitNotes, flushNoteDraft]);
|
||||
|
||||
/** Rename from the tree row: merge title into the post-flush note so an
|
||||
* unflushed body edit is not discarded by a stale tree-row snapshot. */
|
||||
const renameNoteFromTree = (noteId: string, title: string) => {
|
||||
const nextTitle = title.trim();
|
||||
const updatedAt = Date.now();
|
||||
commitNotesAfterFlush((notes) => notes.map((note) => (
|
||||
note.id === noteId ? { ...note, title: nextTitle, updatedAt } : note
|
||||
)));
|
||||
};
|
||||
|
||||
const saveNoteTitleDraft = (note: VaultNote, title: string) => {
|
||||
updateNoteDraft(note.id, { title });
|
||||
};
|
||||
|
||||
const saveNoteContentDraft = (note: VaultNote, content: string) => {
|
||||
updateNoteDraft(note.id, { content });
|
||||
};
|
||||
|
||||
const handleOpenHostFromNote = useCallback((host: Host, noteId: string) => {
|
||||
@@ -531,10 +665,14 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
};
|
||||
|
||||
const addNoteToGroup = (group: string | null) => {
|
||||
const note = createNote(group, getNextVaultOrder(sortedNotes));
|
||||
commitNotes([...sortedNotes, note]);
|
||||
let created: VaultNote | null = null;
|
||||
commitNotesAfterFlush((notes) => {
|
||||
created = createNote(group, getNextVaultOrder(notes));
|
||||
return [...notes, created];
|
||||
});
|
||||
if (!created) return;
|
||||
if (group) expandPath(group);
|
||||
const nextSelection = getNoteSelectionState(note, isSidebarMode);
|
||||
const nextSelection = getNoteSelectionState(created, isSidebarMode);
|
||||
setSelectedNoteId(nextSelection.selectedNoteId);
|
||||
setSelectedGroup(nextSelection.selectedGroup);
|
||||
setOverlayNoteId(nextSelection.overlayNoteId);
|
||||
@@ -591,7 +729,10 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
|
||||
const result = importMarkdownPayloadsToVaultNotes(
|
||||
payloads,
|
||||
sortedNotesRef.current,
|
||||
(() => {
|
||||
flushNoteDraft();
|
||||
return sortedNotesRef.current;
|
||||
})(),
|
||||
targetGroup,
|
||||
);
|
||||
|
||||
@@ -623,6 +764,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
}, [
|
||||
isSidebarMode,
|
||||
commitNotes,
|
||||
flushNoteDraft,
|
||||
selectedGroup,
|
||||
selectedNote,
|
||||
t,
|
||||
@@ -645,10 +787,12 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
}, []);
|
||||
|
||||
const exportNoteToMarkdown = useCallback((note: VaultNote) => {
|
||||
flushNoteDraft();
|
||||
const latest = sortedNotesRef.current.find((item) => item.id === note.id) ?? note;
|
||||
try {
|
||||
const fileName = `${sanitizeNoteExportFileNamePart(note.title, "note")}.md`;
|
||||
const fileName = `${sanitizeNoteExportFileNamePart(latest.title, "note")}.md`;
|
||||
downloadNotesBlob(
|
||||
new Blob([note.content], { type: "text/markdown;charset=utf-8" }),
|
||||
new Blob([latest.content], { type: "text/markdown;charset=utf-8" }),
|
||||
fileName,
|
||||
);
|
||||
toast.success(t("notes.export.toast.success", { count: 1 }));
|
||||
@@ -656,9 +800,10 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
logger.error("Failed to export note:", err);
|
||||
toast.error(t("notes.export.toast.failed"));
|
||||
}
|
||||
}, [downloadNotesBlob, t]);
|
||||
}, [downloadNotesBlob, flushNoteDraft, t]);
|
||||
|
||||
const exportNotesToZip = useCallback((scope: VaultNotesExportScope, fileNamePart: string) => {
|
||||
flushNoteDraft();
|
||||
try {
|
||||
const files = buildVaultNoteMarkdownExportFiles(sortedNotesRef.current, scope);
|
||||
if (files.length === 0) {
|
||||
@@ -674,7 +819,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
logger.error("Failed to export notes:", err);
|
||||
toast.error(t("notes.export.toast.failed"));
|
||||
}
|
||||
}, [downloadNotesBlob, t]);
|
||||
}, [downloadNotesBlob, flushNoteDraft, t]);
|
||||
|
||||
const exportAllNotes = useCallback(() => {
|
||||
exportNotesToZip({ type: "all" }, "all");
|
||||
@@ -703,7 +848,8 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
);
|
||||
|
||||
const duplicateNoteById = (noteId: string) => {
|
||||
const source = sortedNotes.find((note) => note.id === noteId);
|
||||
flushNoteDraft();
|
||||
const source = sortedNotesRef.current.find((note) => note.id === noteId);
|
||||
if (!source) return;
|
||||
const now = Date.now();
|
||||
const copy: VaultNote = {
|
||||
@@ -712,9 +858,9 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
title: `${source.title} (${t("action.copy")})`,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
order: getNextVaultOrder(sortedNotes),
|
||||
order: getNextVaultOrder(sortedNotesRef.current),
|
||||
};
|
||||
commitNotes([...sortedNotes, copy]);
|
||||
commitNotes([...sortedNotesRef.current, copy]);
|
||||
if (copy.group) expandPath(copy.group);
|
||||
const nextSelection = getNoteSelectionState(copy, isSidebarMode);
|
||||
setSelectedNoteId(nextSelection.selectedNoteId);
|
||||
@@ -723,8 +869,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
};
|
||||
|
||||
const performDeleteNoteById = (noteId: string) => {
|
||||
const next = sortedNotes.filter((note) => note.id !== noteId);
|
||||
commitNotes(next);
|
||||
const next = commitNotesAfterFlush((notes) => notes.filter((note) => note.id !== noteId));
|
||||
if (selectedNoteId === noteId) {
|
||||
const nextSelection = getFallbackNoteSelectionState(next, isSidebarMode);
|
||||
setSelectedNoteId(nextSelection.selectedNoteId);
|
||||
@@ -772,12 +917,11 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
const nextGroups = normalizeNoteGroups(
|
||||
groups.map((item) => replaceNoteGroupPrefix(item, group, nextPath) || ""),
|
||||
);
|
||||
const nextNotes = sortedNotes.map((note) => ({
|
||||
onUpdateNoteGroups(nextGroups);
|
||||
commitNotesAfterFlush((notes) => notes.map((note) => ({
|
||||
...note,
|
||||
group: replaceNoteGroupPrefix(note.group, group, nextPath),
|
||||
}));
|
||||
onUpdateNoteGroups(nextGroups);
|
||||
commitNotes(nextNotes);
|
||||
})));
|
||||
setExpandedGroups((current) => {
|
||||
const next = new Set<string>();
|
||||
current.forEach((item) => {
|
||||
@@ -794,7 +938,9 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
|
||||
const performDeleteGroup = (group: string) => {
|
||||
onUpdateNoteGroups(groups.filter((item) => !isNoteGroupInside(item, group)));
|
||||
commitNotes(sortedNotes.map((note) => isNoteGroupInside(note.group, group) ? { ...note, group: undefined } : note));
|
||||
commitNotesAfterFlush((notes) => notes.map((note) => (
|
||||
isNoteGroupInside(note.group, group) ? { ...note, group: undefined } : note
|
||||
)));
|
||||
if (selectedGroup && isNoteGroupInside(selectedGroup, group)) setSelectedGroup(null);
|
||||
setEditingGroupPath(null);
|
||||
};
|
||||
@@ -844,12 +990,13 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
};
|
||||
|
||||
const moveNoteToGroup = (noteId: string, group: string | null) => {
|
||||
const source = sortedNotes.find((note) => note.id === noteId);
|
||||
flushNoteDraft();
|
||||
const source = sortedNotesRef.current.find((note) => note.id === noteId);
|
||||
if (!source) return;
|
||||
const nextGroup = group || undefined;
|
||||
if ((source.group || undefined) === nextGroup) return;
|
||||
|
||||
commitNotes(sortedNotes.map((note) => (
|
||||
commitNotes(sortedNotesRef.current.map((note) => (
|
||||
note.id === noteId ? { ...note, group: nextGroup, updatedAt: Date.now() } : note
|
||||
)));
|
||||
if (group) expandPath(group);
|
||||
@@ -858,29 +1005,31 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
const reorderNoteToNote = (sourceId: string, targetNote: VaultNote, event: React.DragEvent<HTMLElement>) => {
|
||||
if (!sourceId || sourceId === targetNote.id) return;
|
||||
const position = getVaultDropPosition(event.currentTarget, event.clientX, event.clientY);
|
||||
const movedNotes = sortedNotes.map((note) => (
|
||||
note.id === sourceId
|
||||
? { ...note, group: targetNote.group, updatedAt: Date.now() }
|
||||
: note
|
||||
));
|
||||
commitNotes(reorderVaultItems(movedNotes, sourceId, targetNote.id, position));
|
||||
commitNotesAfterFlush((notes) => {
|
||||
const movedNotes = notes.map((note) => (
|
||||
note.id === sourceId
|
||||
? { ...note, group: targetNote.group, updatedAt: Date.now() }
|
||||
: note
|
||||
));
|
||||
return reorderVaultItems(movedNotes, sourceId, targetNote.id, position);
|
||||
});
|
||||
if (targetNote.group) expandPath(targetNote.group);
|
||||
};
|
||||
|
||||
const moveGroupToParent = (group: string, parent: string | null) => {
|
||||
flushNoteDraft();
|
||||
const knownGroups = normalizeNoteGroups([
|
||||
...groups,
|
||||
...sortedNotes.map((note) => note.group).filter((item): item is string => Boolean(item)),
|
||||
...sortedNotesRef.current.map((note) => note.group).filter((item): item is string => Boolean(item)),
|
||||
]);
|
||||
const nextPath = resolveMovedNoteGroupPath(group, parent, knownGroups);
|
||||
if (!nextPath) return;
|
||||
const nextGroups = normalizeNoteGroups(groups.map((item) => replaceNoteGroupPrefix(item, group, nextPath) || ""));
|
||||
const nextNotes = sortedNotes.map((note) => ({
|
||||
onUpdateNoteGroups(nextGroups);
|
||||
commitNotes(sortedNotesRef.current.map((note) => ({
|
||||
...note,
|
||||
group: replaceNoteGroupPrefix(note.group, group, nextPath),
|
||||
}));
|
||||
onUpdateNoteGroups(nextGroups);
|
||||
commitNotes(nextNotes);
|
||||
})));
|
||||
setExpandedGroups((current) => remapExpandedNoteGroupPaths(current, group, nextPath));
|
||||
if (selectedGroup && isNoteGroupInside(selectedGroup, group)) {
|
||||
setSelectedGroup(replaceNoteGroupPrefix(selectedGroup, group, nextPath) ?? null);
|
||||
@@ -895,10 +1044,11 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
if (!sourceGroup || !targetGroup || sourceGroup === targetGroup) return;
|
||||
if (targetGroup.startsWith(`${sourceGroup}/`)) return;
|
||||
|
||||
flushNoteDraft();
|
||||
const targetParent = getNoteGroupParentPath(targetGroup);
|
||||
const knownGroups = normalizeNoteGroups([
|
||||
...groups,
|
||||
...sortedNotes.map((note) => note.group).filter((item): item is string => Boolean(item)),
|
||||
...sortedNotesRef.current.map((note) => note.group).filter((item): item is string => Boolean(item)),
|
||||
]);
|
||||
const nextSourceGroup = getNoteGroupParentPath(sourceGroup) === targetParent
|
||||
? cleanNoteGroupPath(sourceGroup)
|
||||
@@ -916,16 +1066,13 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
targetGroup,
|
||||
position,
|
||||
);
|
||||
const nextNotes = nextSourceGroup === sourceGroup
|
||||
? sortedNotes
|
||||
: sortedNotes.map((note) => ({
|
||||
...note,
|
||||
group: replaceNoteGroupPrefix(note.group, sourceGroup, nextSourceGroup),
|
||||
}));
|
||||
|
||||
onUpdateNoteGroups(nextGroups);
|
||||
if (nextSourceGroup !== sourceGroup) {
|
||||
commitNotes(nextNotes);
|
||||
commitNotes(sortedNotesRef.current.map((note) => ({
|
||||
...note,
|
||||
group: replaceNoteGroupPrefix(note.group, sourceGroup, nextSourceGroup),
|
||||
})));
|
||||
setExpandedGroups((current) => remapExpandedNoteGroupPaths(current, sourceGroup, nextSourceGroup));
|
||||
if (selectedGroup && isNoteGroupInside(selectedGroup, sourceGroup)) {
|
||||
setSelectedGroup(replaceNoteGroupPrefix(selectedGroup, sourceGroup, nextSourceGroup) ?? null);
|
||||
@@ -1092,7 +1239,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
editingInitialName={note.title}
|
||||
onRenameCommit={(name) => {
|
||||
setEditingNoteId(null);
|
||||
saveNote({ ...note, title: name.trim(), updatedAt: Date.now() });
|
||||
renameNoteFromTree(note.id, name);
|
||||
}}
|
||||
onRenameCancel={() => setEditingNoteId(null)}
|
||||
icon={<FileText size={16} className="mr-2 shrink-0 text-muted-foreground" />}
|
||||
@@ -1515,41 +1662,41 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
|
||||
{!isSidebarMode && (
|
||||
<main className="flex min-w-0 flex-1 flex-col bg-background">
|
||||
{selectedNote ? (
|
||||
{selectedNoteView ? (
|
||||
<>
|
||||
<div className="flex min-h-[54px] shrink-0 items-center gap-3 px-8 pt-6 pb-1" data-note-title-row>
|
||||
<div className="min-w-0 flex-1">
|
||||
<input
|
||||
className="h-8 w-full bg-transparent text-lg font-semibold leading-8 outline-none placeholder:text-muted-foreground"
|
||||
value={selectedNote.title}
|
||||
value={selectedNoteView.title}
|
||||
placeholder={t("notes.title.placeholder")}
|
||||
onChange={(event) => saveNote({
|
||||
...selectedNote,
|
||||
title: event.target.value,
|
||||
updatedAt: Date.now(),
|
||||
})}
|
||||
onChange={(event) => saveNoteTitleDraft(selectedNoteView, event.target.value)}
|
||||
onBlur={() => flushNoteDraft()}
|
||||
/>
|
||||
</div>
|
||||
{renderNoteExportButton(selectedNote)}
|
||||
{renderNoteExportButton(selectedNoteView)}
|
||||
{renderNoteModeToggle()}
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="min-h-full w-full px-8 pt-2 pb-6">
|
||||
<LazyLoadBoundary name="Notes editor" resetKey={selectedNote.id}>
|
||||
<div
|
||||
className="min-h-full w-full px-8 pt-2 pb-6"
|
||||
onBlur={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
||||
flushNoteDraft();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LazyLoadBoundary name="Notes editor" resetKey={selectedNoteView.id}>
|
||||
<Suspense fallback={<InlineMarkdownEditorFallback />}>
|
||||
<InlineMarkdownEditor
|
||||
key={selectedNote.id}
|
||||
value={selectedNote.content}
|
||||
key={selectedNoteView.id}
|
||||
value={selectedNoteView.content}
|
||||
placeholder={t("notes.editor.placeholder")}
|
||||
editorMode={noteEditorMode}
|
||||
previewEmptyLabel={t("notes.preview.empty")}
|
||||
onChange={(content) => saveNote({
|
||||
...selectedNote,
|
||||
content,
|
||||
updatedAt: Date.now(),
|
||||
})}
|
||||
onChange={(content) => saveNoteContentDraft(selectedNoteView, content)}
|
||||
hosts={hosts}
|
||||
onOpenHost={(host) => handleOpenHostFromNote(host, selectedNote.id)}
|
||||
onOpenHost={(host) => handleOpenHostFromNote(host, selectedNoteView.id)}
|
||||
onOpenExternalLink={openExternal}
|
||||
/>
|
||||
</Suspense>
|
||||
@@ -1582,7 +1729,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isSidebarMode && overlayNote && (
|
||||
{isSidebarMode && overlayNoteView && (
|
||||
<div className="absolute inset-0 z-30 flex min-h-0 flex-col bg-background text-foreground">
|
||||
<div className={cn(
|
||||
TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS,
|
||||
@@ -1602,7 +1749,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
<TooltipContent side="bottom">{t("common.back")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="min-w-0 flex-1 truncate px-1 text-xs font-medium leading-4 text-foreground">
|
||||
{overlayNote.title || t("notes.title.placeholder")}
|
||||
{overlayNoteView.title || t("notes.title.placeholder")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-background">
|
||||
@@ -1610,35 +1757,35 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
<div className="min-w-0 flex-1">
|
||||
<input
|
||||
className="h-8 w-full bg-transparent text-lg font-semibold leading-8 outline-none placeholder:text-muted-foreground"
|
||||
value={overlayNote.title}
|
||||
value={overlayNoteView.title}
|
||||
placeholder={t("notes.title.placeholder")}
|
||||
onChange={(event) => saveNote({
|
||||
...overlayNote,
|
||||
title: event.target.value,
|
||||
updatedAt: Date.now(),
|
||||
})}
|
||||
onChange={(event) => saveNoteTitleDraft(overlayNoteView, event.target.value)}
|
||||
onBlur={() => flushNoteDraft()}
|
||||
/>
|
||||
</div>
|
||||
{renderNoteExportButton(overlayNote)}
|
||||
{renderNoteExportButton(overlayNoteView)}
|
||||
{renderNoteModeToggle()}
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="min-h-full w-full px-4 pt-2 pb-6">
|
||||
<LazyLoadBoundary name="Notes editor" resetKey={overlayNote.id}>
|
||||
<div
|
||||
className="min-h-full w-full px-4 pt-2 pb-6"
|
||||
onBlur={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
||||
flushNoteDraft();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LazyLoadBoundary name="Notes editor" resetKey={overlayNoteView.id}>
|
||||
<Suspense fallback={<InlineMarkdownEditorFallback />}>
|
||||
<InlineMarkdownEditor
|
||||
key={overlayNote.id}
|
||||
value={overlayNote.content}
|
||||
key={overlayNoteView.id}
|
||||
value={overlayNoteView.content}
|
||||
placeholder={t("notes.editor.placeholder")}
|
||||
editorMode={noteEditorMode}
|
||||
onChange={(content) => saveNote({
|
||||
...overlayNote,
|
||||
content,
|
||||
updatedAt: Date.now(),
|
||||
})}
|
||||
onChange={(content) => saveNoteContentDraft(overlayNoteView, content)}
|
||||
previewEmptyLabel={t("notes.preview.empty")}
|
||||
hosts={hosts}
|
||||
onOpenHost={(host) => handleOpenHostFromNote(host, overlayNote.id)}
|
||||
onOpenHost={(host) => handleOpenHostFromNote(host, overlayNoteView.id)}
|
||||
onOpenExternalLink={openExternal}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
@@ -1,56 +1,10 @@
|
||||
import type { KnownHost } from "../../domain/models";
|
||||
import { toHostKeyInfo, type HostKeyVerificationRequest } from "../terminal/hostKeyVerification";
|
||||
import type { HostKeyInfo } from "../terminal/TerminalHostKeyVerification";
|
||||
|
||||
export const isPortForwardHostKeySessionId = (sessionId?: string): boolean => {
|
||||
return typeof sessionId === "string" && sessionId.startsWith("pf-");
|
||||
};
|
||||
|
||||
export type PortForwardHostKeyRequest = HostKeyVerificationRequest & {
|
||||
requestId: string;
|
||||
sessionId?: string;
|
||||
};
|
||||
|
||||
export interface PendingPortForwardHostKeyVerification {
|
||||
requestId: string;
|
||||
hostKeyInfo: HostKeyInfo;
|
||||
}
|
||||
|
||||
export const toPendingPortForwardHostKeyVerification = (
|
||||
request: PortForwardHostKeyRequest,
|
||||
): PendingPortForwardHostKeyVerification | null => {
|
||||
if (!isPortForwardHostKeySessionId(request.sessionId)) return null;
|
||||
return {
|
||||
requestId: request.requestId,
|
||||
hostKeyInfo: toHostKeyInfo(request),
|
||||
};
|
||||
};
|
||||
|
||||
export const enqueuePortForwardHostKeyVerification = (
|
||||
queue: PendingPortForwardHostKeyVerification[],
|
||||
pending: PendingPortForwardHostKeyVerification,
|
||||
): PendingPortForwardHostKeyVerification[] => [...queue, pending];
|
||||
|
||||
export const removePortForwardHostKeyVerification = (
|
||||
queue: PendingPortForwardHostKeyVerification[],
|
||||
requestId: string,
|
||||
): PendingPortForwardHostKeyVerification[] => {
|
||||
if (queue[0]?.requestId === requestId) {
|
||||
return queue.slice(1);
|
||||
}
|
||||
return queue.filter((pending) => pending.requestId !== requestId);
|
||||
};
|
||||
|
||||
export const createKnownHostFromPortForwardHostKeyInfo = (
|
||||
hostKeyInfo: HostKeyInfo,
|
||||
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,
|
||||
});
|
||||
/** @deprecated Import from `@/domain/portForwardHostKey` instead. */
|
||||
export {
|
||||
createKnownHostFromPortForwardHostKeyInfo,
|
||||
enqueuePortForwardHostKeyVerification,
|
||||
isPortForwardHostKeySessionId,
|
||||
removePortForwardHostKeyVerification,
|
||||
toPendingPortForwardHostKeyVerification,
|
||||
type PendingPortForwardHostKeyVerification,
|
||||
type PortForwardHostKeyRequest,
|
||||
} from "../../domain/portForwardHostKey";
|
||||
@@ -2,26 +2,19 @@ import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Check, Copy, HelpCircle, RefreshCw } from "lucide-react";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import {
|
||||
normalizeExternalMcpIdleTimeoutMinutes,
|
||||
normalizeExternalMcpMode,
|
||||
normalizeSessionIdleTimeoutMinutes,
|
||||
readExternalMcpFocusOnHostOpen,
|
||||
readExternalMcpIdleTimeoutMinutes,
|
||||
readExternalMcpMode,
|
||||
readExternalMcpSilentSessions,
|
||||
readSessionIdleTimeoutMinutes,
|
||||
writeExternalMcpFocusOnHostOpen,
|
||||
writeExternalMcpIdleTimeoutMinutes,
|
||||
writeExternalMcpMode,
|
||||
writeExternalMcpSilentSessions,
|
||||
writeSessionIdleTimeoutMinutes,
|
||||
type ExternalMcpMode,
|
||||
useExternalMcpToggleState,
|
||||
} from "../../../../application/state/useExternalMcpToggleState";
|
||||
import {
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_MODE,
|
||||
STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES,
|
||||
} from "../../../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../../../infrastructure/persistence/localStorageAdapter";
|
||||
import { emitAIStateChanged } from "../../../../application/state/aiStateEvents";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip";
|
||||
@@ -328,26 +321,20 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
const setMode = useCallback((nextMode: ExternalMcpMode) => {
|
||||
const normalized = normalizeExternalMcpMode(nextMode);
|
||||
const normalized = writeExternalMcpMode(nextMode);
|
||||
setModeRaw(normalized);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, normalized);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_MODE);
|
||||
pushConfig(normalized, idleTimeoutMinutes, sessionIdleTimeoutMinutes);
|
||||
}, [idleTimeoutMinutes, pushConfig, sessionIdleTimeoutMinutes]);
|
||||
|
||||
const setIdleTimeoutMinutes = useCallback((minutes: number) => {
|
||||
const normalized = normalizeExternalMcpIdleTimeoutMinutes(minutes);
|
||||
const normalized = writeExternalMcpIdleTimeoutMinutes(minutes);
|
||||
setIdleTimeoutRaw(normalized);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES, normalized);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES);
|
||||
pushConfig(mode, normalized, sessionIdleTimeoutMinutes);
|
||||
}, [mode, pushConfig, sessionIdleTimeoutMinutes]);
|
||||
|
||||
const setSessionIdleTimeoutMinutes = useCallback((minutes: number) => {
|
||||
const normalized = normalizeSessionIdleTimeoutMinutes(minutes);
|
||||
const normalized = writeSessionIdleTimeoutMinutes(minutes);
|
||||
setSessionIdleTimeoutRaw(normalized);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES, normalized);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES);
|
||||
pushConfig(mode, idleTimeoutMinutes, normalized);
|
||||
}, [idleTimeoutMinutes, mode, pushConfig]);
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { SftpFileEntry } from "../../types";
|
||||
import type { SftpPane } from "../../application/state/sftp/types";
|
||||
import type { SftpTransferSource } from "./SftpContext";
|
||||
import { sftpListOrderStore } from "./hooks/useSftpListOrderStore";
|
||||
import type { UseSftpPaneSortingResult } from "./hooks/useSftpPaneSorting";
|
||||
import type { UseSftpPaneSortingResult } from "../../application/state/sftp/useSftpPaneSorting";
|
||||
import { buildSftpColumnTemplate, isNavigableDirectory, isSftpColumnMenuKey } from "./utils";
|
||||
import { isKnownBinaryFile } from "../../lib/sftpFileUtils";
|
||||
import { SftpFileRow } from "./SftpFileRow";
|
||||
|
||||
@@ -12,7 +12,7 @@ import { getParentPath, isWindowsRoot, joinPath, resolveSftpWindowsPathOptions }
|
||||
import { buildSftpColumnTemplate, filterHiddenFiles, isNavigableDirectory, isSftpColumnMenuKey, sortSftpEntries } from './utils';
|
||||
import type { SftpTransferSource } from './SftpContext';
|
||||
import type { SftpPaneTreeViewProps } from './SftpPaneTreeView.types';
|
||||
import { sftpTreeSelectionStore, useSftpTreeSelectionState } from './hooks/useSftpTreeSelectionStore';
|
||||
import { sftpTreeSelectionStore, useSftpTreeSelectionState } from '../../application/state/sftp/sftpTreeSelectionStore';
|
||||
import { sftpKeyboardSelectionStore, sftpTreeEnterStore } from './hooks/useSftpKeyboardShortcuts';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { SftpColumnMenuItems } from './SftpColumnMenuItems';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { SftpFileEntry } from '../../types';
|
||||
import type { SftpPane } from '../../application/state/sftp/types';
|
||||
import type { SftpTransferSource } from './SftpContext';
|
||||
import type { UseSftpPaneSortingResult } from './hooks/useSftpPaneSorting';
|
||||
import type { UseSftpPaneSortingResult } from '../../application/state/sftp/useSftpPaneSorting';
|
||||
|
||||
export interface SftpPaneTreeViewProps {
|
||||
pane: SftpPane;
|
||||
|
||||
@@ -25,16 +25,16 @@ import { useSftpPaneDialogs } from "./hooks/useSftpPaneDialogs";
|
||||
import { useSftpPaneDragAndSelect } from "./hooks/useSftpPaneDragAndSelect";
|
||||
import { useSftpPaneFiles } from "./hooks/useSftpPaneFiles";
|
||||
import { useSftpPanePath } from "./hooks/useSftpPanePath";
|
||||
import { useSftpPaneSorting, type UseSftpPaneSortingResult } from "./hooks/useSftpPaneSorting";
|
||||
import { useSftpPaneSorting, type UseSftpPaneSortingResult } from "../../application/state/sftp/useSftpPaneSorting";
|
||||
import { useSftpPaneVirtualList } from "./hooks/useSftpPaneVirtualList";
|
||||
import { sftpPaneViewModeStore } from "../../application/state/sftp/sftpPaneViewModeStore";
|
||||
import { useSftpDialogActionHandler } from "./hooks/useSftpDialogAction";
|
||||
import { useSftpDialogActionHandler } from "../../application/state/sftp/sftpDialogActionStore";
|
||||
import { useSftpBookmarks } from "./hooks/useSftpBookmarks";
|
||||
import { useLocalSftpBookmarks } from "./hooks/useLocalSftpBookmarks";
|
||||
import { useLocalSftpBookmarks } from "../../application/state/sftp/localSftpBookmarks";
|
||||
import { useGlobalSftpBookmarks } from "./hooks/useGlobalSftpBookmarks";
|
||||
import { useSftpHostViewMode } from "./hooks/useSftpHostViewMode";
|
||||
import { useSftpHostViewMode } from "../../application/state/sftp/sftpHostViewModeStore";
|
||||
import { sftpListOrderStore } from "./hooks/useSftpListOrderStore";
|
||||
import { sftpTreeSelectionStore } from "./hooks/useSftpTreeSelectionStore";
|
||||
import { sftpTreeSelectionStore } from "../../application/state/sftp/sftpTreeSelectionStore";
|
||||
import { sftpClipboardUploadStore } from "./clipboardUpload";
|
||||
|
||||
interface TreeReloadRequest {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SftpStateApi } from "../../../application/state/useSftpState";
|
||||
import { sftpTreeSelectionStore } from "./useSftpTreeSelectionStore";
|
||||
import { sftpTreeSelectionStore } from "../../../application/state/sftp/sftpTreeSelectionStore";
|
||||
|
||||
export interface SftpSelectionTarget {
|
||||
side: "left" | "right";
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
||||
import type { SftpBookmark } from "../../../domain/models";
|
||||
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
import { STORAGE_KEY_SFTP_LOCAL_BOOKMARKS } from "../../../infrastructure/config/storageKeys";
|
||||
import { createSftpBookmark } from "../../../application/state/sftp/bookmarkHelpers";
|
||||
|
||||
// ── Shared external store so every hook instance sees the same bookmarks ──
|
||||
|
||||
type Listener = () => void;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
let snapshot: SftpBookmark[] =
|
||||
localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_LOCAL_BOOKMARKS) ?? [];
|
||||
|
||||
function subscribe(listener: Listener) {
|
||||
listeners.add(listener);
|
||||
return () => { listeners.delete(listener); };
|
||||
}
|
||||
|
||||
function getSnapshot() {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function setBookmarks(next: SftpBookmark[] | ((prev: SftpBookmark[]) => SftpBookmark[])) {
|
||||
snapshot = typeof next === "function" ? next(snapshot) : next;
|
||||
localStorageAdapter.write(STORAGE_KEY_SFTP_LOCAL_BOOKMARKS, snapshot);
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
|
||||
// ── Hook ──
|
||||
|
||||
interface UseLocalSftpBookmarksParams {
|
||||
currentPath: string | undefined;
|
||||
}
|
||||
|
||||
export const useLocalSftpBookmarks = ({
|
||||
currentPath,
|
||||
}: UseLocalSftpBookmarksParams) => {
|
||||
const bookmarks = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
|
||||
const isCurrentPathBookmarked = useMemo(
|
||||
() => !!currentPath && bookmarks.some((b) => b.path === currentPath),
|
||||
[currentPath, bookmarks],
|
||||
);
|
||||
|
||||
const toggleBookmark = useCallback(() => {
|
||||
if (!currentPath) return;
|
||||
if (isCurrentPathBookmarked) {
|
||||
setBookmarks((prev) => prev.filter((b) => b.path !== currentPath));
|
||||
} else {
|
||||
setBookmarks((prev) => [...prev, createSftpBookmark(currentPath)]);
|
||||
}
|
||||
}, [currentPath, isCurrentPathBookmarked]);
|
||||
|
||||
const deleteBookmark = useCallback((id: string) => {
|
||||
setBookmarks((prev) => prev.filter((b) => b.id !== id));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
bookmarks,
|
||||
isCurrentPathBookmarked,
|
||||
toggleBookmark,
|
||||
deleteBookmark,
|
||||
};
|
||||
};
|
||||
@@ -10,10 +10,10 @@ import type { MutableRefObject } from "react";
|
||||
import { KeyBinding, matchesKeyBinding } from "../../../domain/models";
|
||||
import { getParentPath, joinPath, resolveSftpWindowsPathOptions } from "../../../application/state/sftp/utils";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { sftpClipboardStore, SftpClipboardFile } from "./useSftpClipboard";
|
||||
import { sftpFocusStore } from "./useSftpFocusedPane";
|
||||
import { sftpDialogActionStore } from "./useSftpDialogAction";
|
||||
import { sftpTreeSelectionStore } from "./useSftpTreeSelectionStore";
|
||||
import { sftpClipboardStore, SftpClipboardFile } from "../../../application/state/sftp/sftpClipboardStore";
|
||||
import { sftpFocusStore } from "../../../application/state/sftp/sftpFocusStore";
|
||||
import { sftpDialogActionStore } from "../../../application/state/sftp/sftpDialogActionStore";
|
||||
import { sftpTreeSelectionStore } from "../../../application/state/sftp/sftpTreeSelectionStore";
|
||||
import { sftpListOrderStore } from "./useSftpListOrderStore";
|
||||
import { sftpPaneViewModeStore } from "../../../application/state/sftp/sftpPaneViewModeStore";
|
||||
import { keepOnlyPaneSelections } from "./selectionScope";
|
||||
|
||||
+34
-64
@@ -23,7 +23,18 @@ import {
|
||||
} from 'lucide-react';
|
||||
import React from 'react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { formatBytes as formatBytesDomain } from '../../domain/formatBytes';
|
||||
import { SftpFileEntry } from '../../types';
|
||||
import {
|
||||
formatDate as formatDateFromState,
|
||||
isNavigableDirectory,
|
||||
} from '../../application/state/sftp/utils';
|
||||
|
||||
export { isNavigableDirectory };
|
||||
|
||||
/** Accept undefined timestamps from file-list rows. */
|
||||
export const formatDate = (timestamp: number | undefined): string =>
|
||||
formatDateFromState(timestamp ?? 0);
|
||||
|
||||
// Pre-built icon maps for O(1) lookup in getFileIcon
|
||||
type IconDef = [LucideIcon, string?];
|
||||
@@ -177,37 +188,29 @@ const EXTENSION_ICON_MAP = new Map<string, IconDef>([
|
||||
]);
|
||||
|
||||
/**
|
||||
* Format bytes with appropriate unit (B, KB, MB, GB)
|
||||
* Format bytes with appropriate unit (B, KB, MB, GB, TB)
|
||||
*/
|
||||
export const formatBytes = (bytes: number | string): string => {
|
||||
const numBytes = typeof bytes === 'string' ? parseInt(bytes, 10) : bytes;
|
||||
if (isNaN(numBytes) || numBytes === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(numBytes) / Math.log(1024));
|
||||
const size = numBytes / Math.pow(1024, i);
|
||||
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
||||
if (typeof numBytes !== 'number' || isNaN(numBytes)) return '0 B';
|
||||
return formatBytesDomain(numBytes, {
|
||||
zeroDisplay: '0 B',
|
||||
includeTB: true,
|
||||
fractionDigits: 1,
|
||||
byteUnit: 'B',
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Format bytes for transfer display
|
||||
*/
|
||||
export const formatTransferBytes = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
const size = bytes / Math.pow(1024, i);
|
||||
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format date as YYYY-MM-DD hh:mm in local timezone
|
||||
*/
|
||||
export const formatDate = (timestamp: number | undefined): string => {
|
||||
if (!timestamp) return '--';
|
||||
const date = new Date(timestamp);
|
||||
if (isNaN(date.getTime())) return '--';
|
||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
return formatBytesDomain(bytes, {
|
||||
zeroDisplay: '0 B',
|
||||
includeTB: false,
|
||||
fractionDigits: 1,
|
||||
byteUnit: 'B',
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -245,40 +248,15 @@ export const getFileIcon = (entry: SftpFileEntry): React.ReactElement => {
|
||||
return React.createElement(FileCode, { size: 14 });
|
||||
};
|
||||
|
||||
// Sort configuration types
|
||||
export type SortField = 'name' | 'size' | 'modified' | 'type';
|
||||
export type SortOrder = 'asc' | 'desc';
|
||||
|
||||
// Column widths type
|
||||
export interface ColumnWidths {
|
||||
name: number;
|
||||
modified: number;
|
||||
size: number;
|
||||
type: number;
|
||||
}
|
||||
|
||||
export type SftpColumnVisibility = Record<keyof ColumnWidths, boolean>;
|
||||
|
||||
export const DEFAULT_SFTP_COLUMN_VISIBILITY: SftpColumnVisibility = {
|
||||
name: true,
|
||||
modified: true,
|
||||
size: true,
|
||||
type: true,
|
||||
};
|
||||
|
||||
export const normalizeSftpColumnVisibility = (value: unknown): SftpColumnVisibility => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return DEFAULT_SFTP_COLUMN_VISIBILITY;
|
||||
}
|
||||
|
||||
const stored = value as Partial<Record<keyof ColumnWidths, unknown>>;
|
||||
return {
|
||||
name: true,
|
||||
modified: stored.modified !== false,
|
||||
size: stored.size !== false,
|
||||
type: stored.type !== false,
|
||||
};
|
||||
};
|
||||
// Sort / column layout — owned by application/state; re-exported for UI callers.
|
||||
export {
|
||||
DEFAULT_SFTP_COLUMN_VISIBILITY,
|
||||
normalizeSftpColumnVisibility,
|
||||
type ColumnWidths,
|
||||
type SftpColumnVisibility,
|
||||
type SortField,
|
||||
type SortOrder,
|
||||
} from '../../application/state/sftp/columnLayout';
|
||||
|
||||
export const isSftpColumnMenuKey = (key: string, shiftKey: boolean): boolean =>
|
||||
key === 'ContextMenu' || (key === 'F10' && shiftKey);
|
||||
@@ -339,14 +317,6 @@ export const sortSftpEntries = (
|
||||
return sorted;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if an entry is navigable like a directory
|
||||
* This includes regular directories and symlinks that point to directories
|
||||
*/
|
||||
export const isNavigableDirectory = (entry: SftpFileEntry): boolean => {
|
||||
return entry.type === 'directory' || (entry.type === 'symlink' && entry.linkTarget === 'directory');
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a file is hidden
|
||||
* - Windows: checks the `hidden` attribute (set by localFsBridge)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
const SNIPPETS_GRID_GAP = 12;
|
||||
const SNIPPETS_GRID_MIN_CARD_WIDTH = 220;
|
||||
/** Container-width thresholds (mirrors former md / xl column steps). */
|
||||
const SNIPPETS_GRID_MD_MIN_WIDTH = 768;
|
||||
const SNIPPETS_GRID_XL_MIN_WIDTH = 1280;
|
||||
const SNIPPETS_GRID_MAX_COLUMNS = 3;
|
||||
|
||||
/** Column policy for snippets — shared by virtualized and CSS-var grids. */
|
||||
export function getSnippetsGridColumnCount(
|
||||
width: number,
|
||||
options: { hasSidePanel: boolean },
|
||||
): number {
|
||||
if (!Number.isFinite(width) || width <= 0) return 1;
|
||||
if (options.hasSidePanel) {
|
||||
return Math.max(
|
||||
1,
|
||||
Math.floor(
|
||||
(width + SNIPPETS_GRID_GAP)
|
||||
/ (SNIPPETS_GRID_MIN_CARD_WIDTH + SNIPPETS_GRID_GAP),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (width >= SNIPPETS_GRID_XL_MIN_WIDTH) return SNIPPETS_GRID_MAX_COLUMNS;
|
||||
if (width >= SNIPPETS_GRID_MD_MIN_WIDTH) return 2;
|
||||
return 1;
|
||||
}
|
||||
@@ -27,8 +27,8 @@ import {
|
||||
SystemPanelToolbar,
|
||||
} from './SystemPanelUi';
|
||||
import { SystemPanelConfirmDialog } from './SystemPanelConfirmDialog';
|
||||
import { useAsyncRecordCache } from './hooks/useAsyncRecordCache';
|
||||
import { usePolling, useStableTranslate } from './hooks/useSystemManager';
|
||||
import { useAsyncRecordCache } from '../../application/state/systemManager/useAsyncRecordCache';
|
||||
import { usePolling, useStableTranslate } from '../../application/state/useSystemManager';
|
||||
import { openInteractiveTerminal } from './openInteractiveTerminal';
|
||||
import { showSystemManagerError } from './systemManagerToast';
|
||||
|
||||
|
||||
@@ -23,8 +23,8 @@ import {
|
||||
} from './SystemPanelUi';
|
||||
import { SystemPanelConfirmDialog } from './SystemPanelConfirmDialog';
|
||||
import { SystemPanelPromptDialog } from './SystemPanelPromptDialog';
|
||||
import { useAsyncRecordCache } from './hooks/useAsyncRecordCache';
|
||||
import { usePolling, useStableTranslate } from './hooks/useSystemManager';
|
||||
import { useAsyncRecordCache } from '../../application/state/systemManager/useAsyncRecordCache';
|
||||
import { usePolling, useStableTranslate } from '../../application/state/useSystemManager';
|
||||
import { showSystemManagerError } from './systemManagerToast';
|
||||
|
||||
type Backend = ReturnType<typeof useSystemManagerBackend>;
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
SystemPanelStatusBadge,
|
||||
SystemPanelToolbar,
|
||||
} from './SystemPanelUi';
|
||||
import { usePolling, useStableTranslate } from './hooks/useSystemManager';
|
||||
import { usePolling, useStableTranslate } from '../../application/state/useSystemManager';
|
||||
|
||||
type Backend = ReturnType<typeof useSystemManagerBackend>;
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
} from './SystemPanelUi';
|
||||
import { SystemPanelConfirmDialog } from './SystemPanelConfirmDialog';
|
||||
import { SystemPanelPromptDialog } from './SystemPanelPromptDialog';
|
||||
import { usePolling, useStableTranslate } from './hooks/useSystemManager';
|
||||
import { usePolling, useStableTranslate } from '../../application/state/useSystemManager';
|
||||
import {
|
||||
getCachedProcessList,
|
||||
setCachedProcessList,
|
||||
|
||||
@@ -18,7 +18,7 @@ import { TmuxManagerTab } from './TmuxManagerTab';
|
||||
import { WorkspaceSidebarHostHeader } from '../terminalLayer/WorkspaceSidebarHostHeader';
|
||||
import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from '../terminalLayer/terminalSidePanelChrome';
|
||||
import { SystemPanelEmpty, SystemPanelShell } from './SystemPanelUi';
|
||||
import { useSessionCapabilities } from './hooks/useSystemManager';
|
||||
import { useSessionCapabilities } from '../../application/state/useSystemManager';
|
||||
|
||||
const SystemPanelChecking = memo(function SystemPanelChecking({
|
||||
message,
|
||||
|
||||
@@ -10,7 +10,7 @@ import React, { memo, useEffect, useMemo, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { aggregateMountedDiskUsage } from '../../domain/systemDiskUsage';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { useServerStats } from '../terminal/hooks/useServerStats';
|
||||
import { useServerStats } from '../../application/state/useServerStats';
|
||||
import { ResourceBar } from './ResourceBar';
|
||||
import {
|
||||
SystemPanelEmpty,
|
||||
|
||||
Loaded 100 of 177 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user