mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 22:40:09 +00:00
Compare commits
53
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,
|
draggingSessionId,
|
||||||
setDraggingSessionId,
|
setDraggingSessionId,
|
||||||
sessionRenameTarget,
|
sessionRenameTarget,
|
||||||
sessionRenameValue,
|
|
||||||
setSessionRenameValue,
|
|
||||||
startSessionRename,
|
startSessionRename,
|
||||||
renameSessionInline,
|
renameSessionInline,
|
||||||
submitSessionRename,
|
submitSessionRename,
|
||||||
resetSessionRename,
|
resetSessionRename,
|
||||||
workspaceRenameTarget,
|
workspaceRenameTarget,
|
||||||
workspaceRenameValue,
|
|
||||||
setWorkspaceRenameValue,
|
|
||||||
startWorkspaceRename,
|
startWorkspaceRename,
|
||||||
submitWorkspaceRename,
|
submitWorkspaceRename,
|
||||||
resetWorkspaceRename,
|
resetWorkspaceRename,
|
||||||
@@ -1945,10 +1941,7 @@ function App({ settings }: { settings: SettingsState }) {
|
|||||||
resetWorkspaceRename,
|
resetWorkspaceRename,
|
||||||
resolvedTheme,
|
resolvedTheme,
|
||||||
sessionRenameTarget,
|
sessionRenameTarget,
|
||||||
sessionRenameValue,
|
|
||||||
setActiveTabId,
|
setActiveTabId,
|
||||||
setSessionRenameValue,
|
|
||||||
setWorkspaceRenameValue,
|
|
||||||
settings,
|
settings,
|
||||||
startSessionRename,
|
startSessionRename,
|
||||||
renameSessionInline,
|
renameSessionInline,
|
||||||
@@ -1960,8 +1953,7 @@ function App({ settings }: { settings: SettingsState }) {
|
|||||||
toggleConnectionLogSaved,
|
toggleConnectionLogSaved,
|
||||||
updateConnectionLog,
|
updateConnectionLog,
|
||||||
workspaceRenameTarget,
|
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]);
|
||||||
}), [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]);
|
|
||||||
|
|
||||||
const appDialogsDomain = useMemo(() => ({
|
const appDialogsDomain = useMemo(() => ({
|
||||||
addToWorkspaceDialog,
|
addToWorkspaceDialog,
|
||||||
|
|||||||
@@ -53,3 +53,8 @@ test('shared host tree forwards work-surface host management callbacks', () => {
|
|||||||
assert.match(hostTreeLayerSource, /onNewHost=\{onNewHost\}/);
|
assert.match(hostTreeLayerSource, /onNewHost=\{onNewHost\}/);
|
||||||
assert.match(hostTreeLayerSource, /onEditHost=\{onEditHost\}/);
|
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 { useActiveTabId } from '../state/activeTabStore';
|
||||||
import type { EditorTabChrome } from '../state/editorTabStore';
|
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,
|
enabled,
|
||||||
hosts,
|
hosts,
|
||||||
customGroups,
|
customGroups,
|
||||||
@@ -154,3 +180,6 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const AppHostTreeLayer = memo(AppHostTreeLayerInner, appHostTreeLayerAreEqual);
|
||||||
|
AppHostTreeLayer.displayName = 'AppHostTreeLayer';
|
||||||
+159
-112
@@ -1,5 +1,5 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* 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 { AlertTriangle, Download, Trash2 } from 'lucide-react';
|
||||||
import { activeTabStore, toEditorTabId, useIsEditorTabActive } from '../state/activeTabStore';
|
import { activeTabStore, toEditorTabId, useIsEditorTabActive } from '../state/activeTabStore';
|
||||||
import { editorTabStore } from '../state/editorTabStore';
|
import { editorTabStore } from '../state/editorTabStore';
|
||||||
@@ -12,7 +12,7 @@ import { QuickScriptEditorDialog } from '../../components/scripts/QuickScriptEdi
|
|||||||
import { AddToWorkspaceDialog } from '../../components/workspace/AddToWorkspaceDialog';
|
import { AddToWorkspaceDialog } from '../../components/workspace/AddToWorkspaceDialog';
|
||||||
import { KeyboardInteractiveModal } from '../../components/KeyboardInteractiveModal';
|
import { KeyboardInteractiveModal } from '../../components/KeyboardInteractiveModal';
|
||||||
import { PassphraseModal } from '../../components/PassphraseModal';
|
import { PassphraseModal } from '../../components/PassphraseModal';
|
||||||
import { UnsavedChangesProvider } from '../../components/editor/UnsavedChangesDialog';
|
import { UnsavedChangesProvider, promptUnsavedChanges } from '../../components/editor/UnsavedChangesDialog';
|
||||||
import { SnippetExecutionProvider } from '../../components/SnippetExecutionProvider';
|
import { SnippetExecutionProvider } from '../../components/SnippetExecutionProvider';
|
||||||
import { Button } from '../../components/ui/button';
|
import { Button } from '../../components/ui/button';
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../../components/ui/dialog';
|
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 = {
|
export type AppViewProps = {
|
||||||
domains: AppViewDomains;
|
domains: AppViewDomains;
|
||||||
};
|
};
|
||||||
@@ -127,14 +189,14 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, noteGroups, notes, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
|
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, noteGroups, notes, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
|
||||||
passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
|
passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
|
||||||
resolveEmptyVaultConflict, resolvedTheme, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
|
resolveEmptyVaultConflict, resolvedTheme, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
|
||||||
sessionRenameValue, sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
|
sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
|
||||||
setNavigateToSection, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
|
setNavigateToSection, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
|
||||||
setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
|
setWorkspaceFocusedSession, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
|
||||||
sftpShowHiddenFiles, sftpUseCompressedUpload, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename,
|
sftpShowHiddenFiles, sftpUseCompressedUpload, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename,
|
||||||
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById,
|
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById,
|
||||||
toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog,
|
toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog,
|
||||||
readPersistedHosts, readPersistedManagedSources, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources,
|
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,
|
VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper,
|
||||||
} = ctx;
|
} = ctx;
|
||||||
|
|
||||||
@@ -211,6 +273,70 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
pluginViewTabStore.close(tabId);
|
pluginViewTabStore.close(tabId);
|
||||||
}, [orderedTabsWithEditors]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const catalog = buildPluginSettingScopeCatalog({
|
const catalog = buildPluginSettingScopeCatalog({
|
||||||
hosts,
|
hosts,
|
||||||
@@ -224,54 +350,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
return (
|
return (
|
||||||
<SnippetExecutionProvider>
|
<SnippetExecutionProvider>
|
||||||
<UnsavedChangesProvider>
|
<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}>
|
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" data-terminal-appearance-root onContextMenu={handleRootContextMenu}>
|
||||||
<TopTabs
|
<TopTabs
|
||||||
theme={resolvedTheme}
|
theme={resolvedTheme}
|
||||||
@@ -464,8 +543,6 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
identities={identities}
|
identities={identities}
|
||||||
snippets={snippets}
|
snippets={snippets}
|
||||||
snippetPackages={snippetPackages}
|
snippetPackages={snippetPackages}
|
||||||
notes={notes}
|
|
||||||
noteGroups={noteGroups}
|
|
||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
workspaces={workspaces}
|
workspaces={workspaces}
|
||||||
knownHosts={effectiveKnownHosts}
|
knownHosts={effectiveKnownHosts}
|
||||||
@@ -529,8 +606,6 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
updateHosts={updateTerminalHosts}
|
updateHosts={updateTerminalHosts}
|
||||||
updateSnippets={updateSnippets}
|
updateSnippets={updateSnippets}
|
||||||
updateSnippetPackages={updateSnippetPackages}
|
updateSnippetPackages={updateSnippetPackages}
|
||||||
updateNotes={updateNotes}
|
|
||||||
updateNoteGroups={updateNoteGroups}
|
|
||||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||||
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
|
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
|
||||||
sftpAutoSync={sftpAutoSync}
|
sftpAutoSync={sftpAutoSync}
|
||||||
@@ -715,59 +790,32 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
</LazyLoadBoundary>
|
</LazyLoadBoundary>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Dialog open={!!sessionRenameTarget} onOpenChange={(open) => {
|
<RenameDraftDialog
|
||||||
if (!open) {
|
open={!!sessionRenameTarget}
|
||||||
resetSessionRename();
|
inputId="session-name"
|
||||||
}
|
initialName={sessionRenameTarget ? (sessionRenameTarget.customName || sessionRenameTarget.hostLabel) : ''}
|
||||||
}}>
|
title={t('dialog.renameSession.title')}
|
||||||
<DialogContent className="max-w-sm">
|
nameLabel={t('field.name')}
|
||||||
<DialogHeader>
|
placeholder={t('placeholder.sessionName')}
|
||||||
<DialogTitle>{t('dialog.renameSession.title')}</DialogTitle>
|
cancelLabel={t('common.cancel')}
|
||||||
</DialogHeader>
|
saveLabel={t('common.save')}
|
||||||
<div className="space-y-2 py-2">
|
onCancel={resetSessionRename}
|
||||||
<Label htmlFor="session-name">{t('field.name')}</Label>
|
onSave={handleSaveSessionRename}
|
||||||
<Input
|
/>
|
||||||
id="session-name"
|
|
||||||
value={sessionRenameValue}
|
<RenameDraftDialog
|
||||||
onChange={(e) => setSessionRenameValue(e.target.value)}
|
open={!!workspaceRenameTarget}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') submitSessionRename(); }}
|
inputId="workspace-name"
|
||||||
autoFocus
|
initialName={workspaceRenameTarget ? workspaceRenameTarget.title : ''}
|
||||||
placeholder={t('placeholder.sessionName')}
|
title={t('dialog.renameWorkspace.title')}
|
||||||
/>
|
nameLabel={t('field.name')}
|
||||||
</div>
|
placeholder={t('placeholder.workspaceName')}
|
||||||
<DialogFooter>
|
cancelLabel={t('common.cancel')}
|
||||||
<Button variant="ghost" onClick={resetSessionRename}>{t('common.cancel')}</Button>
|
saveLabel={t('common.save')}
|
||||||
<Button onClick={submitSessionRename} disabled={!sessionRenameValue.trim()}>{t('common.save')}</Button>
|
onCancel={resetWorkspaceRename}
|
||||||
</DialogFooter>
|
onSave={handleSaveWorkspaceRename}
|
||||||
</DialogContent>
|
/>
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<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 && (
|
{isCreateWorkspaceOpen && (
|
||||||
<LazyLoadBoundary name="Create workspace" resetKey="create-workspace">
|
<LazyLoadBoundary name="Create workspace" resetKey="create-workspace">
|
||||||
@@ -871,8 +919,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
}}
|
|
||||||
</UnsavedChangesProvider>
|
</UnsavedChangesProvider>
|
||||||
</SnippetExecutionProvider>
|
</SnippetExecutionProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ import { sftpTransferCenterStore } from '../state/sftpTransferCenterStore';
|
|||||||
import { resumeTransferWithDedicatedSession } from '../state/sftp/dedicatedTransferResume';
|
import { resumeTransferWithDedicatedSession } from '../state/sftp/dedicatedTransferResume';
|
||||||
import { getSftpTransferResourceKeys, globalSftpTransferScheduler } from '../state/sftp/globalTransferScheduler';
|
import { getSftpTransferResourceKeys, globalSftpTransferScheduler } from '../state/sftp/globalTransferScheduler';
|
||||||
import { hasNewSourceFingerprint } from '../state/sftp/transferProgressMetadata';
|
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 type { TransferTask } from '../../domain/models';
|
||||||
import { isTerminalBootEpochCurrent } from '../../domain/terminalBootEpoch';
|
import { isTerminalBootEpochCurrent } from '../../domain/terminalBootEpoch';
|
||||||
import {
|
import {
|
||||||
@@ -218,7 +221,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
|||||||
// Skip "update available" toast if auto-download has already started or completed
|
// Skip "update available" toast if auto-download has already started or completed
|
||||||
if (updateState.autoDownloadStatus !== 'idle') return;
|
if (updateState.autoDownloadStatus !== 'idle') return;
|
||||||
// Don't show automatic notification when auto-update is disabled
|
// 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) {
|
if (updateState.hasUpdate && updateState.latestRelease) {
|
||||||
const version = updateState.latestRelease.version;
|
const version = updateState.latestRelease.version;
|
||||||
toast.info(
|
toast.info(
|
||||||
|
|||||||
@@ -113,8 +113,6 @@ export const enVaultMessages: Messages = {
|
|||||||
'knownHosts.empty.title': 'No Known Hosts',
|
'knownHosts.empty.title': 'No Known Hosts',
|
||||||
'knownHosts.empty.desc':
|
'knownHosts.empty.desc':
|
||||||
"Known hosts are SSH servers you've connected to before. Import from your system's known_hosts file to get started.",
|
"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.scanUnavailable': 'System scan is unavailable on this platform.',
|
||||||
'knownHosts.toast.scanNoFile': 'No system known_hosts file found.',
|
'knownHosts.toast.scanNoFile': 'No system known_hosts file found.',
|
||||||
'knownHosts.toast.scanNoEntries': 'No usable entries found in known_hosts.',
|
'knownHosts.toast.scanNoEntries': 'No usable entries found in known_hosts.',
|
||||||
@@ -450,13 +448,6 @@ export const enVaultMessages: Messages = {
|
|||||||
'settings.tab.sftpFileAssociations': 'SFTP',
|
'settings.tab.sftpFileAssociations': 'SFTP',
|
||||||
'settings.sftp.transferConcurrency': 'Transfer Concurrency',
|
'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.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': '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.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',
|
'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.unavailable': 'The configured provider is unavailable. Configuration is preserved until the plugin is restored.',
|
||||||
'hostDetails.plugin.useSsh': 'Use SSH instead',
|
'hostDetails.plugin.useSsh': 'Use SSH instead',
|
||||||
|
|
||||||
// Host form (legacy modal)
|
// Host form (legacy keys still referenced by TerminalAuthDialog)
|
||||||
'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',
|
|
||||||
'hostForm.auth.selectKey': 'Select an SSH Key',
|
'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
|
// Connection logs
|
||||||
'logs.table.date': 'Date',
|
'logs.table.date': 'Date',
|
||||||
|
|||||||
@@ -150,8 +150,6 @@ export const ruVaultMessages: Messages = {
|
|||||||
'knownHosts.empty.title': 'Нет известных хостов',
|
'knownHosts.empty.title': 'Нет известных хостов',
|
||||||
'knownHosts.empty.desc':
|
'knownHosts.empty.desc':
|
||||||
'Известные хосты — это SSH-серверы, к которым вы подключались раньше. Импортируйте системный файл known_hosts, чтобы начать.',
|
'Известные хосты — это SSH-серверы, к которым вы подключались раньше. Импортируйте системный файл known_hosts, чтобы начать.',
|
||||||
'knownHosts.results.showingLimited':
|
|
||||||
'Показано {shown} из {total} хостов. Используйте поиск, чтобы найти нужные хосты.',
|
|
||||||
'knownHosts.toast.scanUnavailable': 'Сканирование системы недоступно на этой платформе.',
|
'knownHosts.toast.scanUnavailable': 'Сканирование системы недоступно на этой платформе.',
|
||||||
'knownHosts.toast.scanNoFile': 'Системный файл known_hosts не найден.',
|
'knownHosts.toast.scanNoFile': 'Системный файл known_hosts не найден.',
|
||||||
'knownHosts.toast.scanNoEntries': 'В known_hosts не найдено пригодных записей.',
|
'knownHosts.toast.scanNoEntries': 'В known_hosts не найдено пригодных записей.',
|
||||||
@@ -486,13 +484,6 @@ export const ruVaultMessages: Messages = {
|
|||||||
'settings.tab.sftpFileAssociations': 'SFTP',
|
'settings.tab.sftpFileAssociations': 'SFTP',
|
||||||
'settings.sftp.transferConcurrency': 'Параллелизм передачи',
|
'settings.sftp.transferConcurrency': 'Параллелизм передачи',
|
||||||
'settings.sftp.transferConcurrency.desc': 'Максимальное число файлов, одновременно передаваемых для одной цели, включая содержимое папок и отдельно выбранные файлы. Большие значения могут ускорить передачу, но перегрузить некоторые серверы.',
|
'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': 'Удержание SSH-соединений',
|
||||||
'settings.ssh.transportIdleTtl.desc': 'Как долго общее SSH-соединение остаётся открытым после освобождения последнего lease терминала, SFTP-браузера, пакетной передачи или проброса портов. Пока оно тёплое, повторное подключение к тому же хосту может обойти повторную аутентификацию (включая MFA). Активные сессии и передачи не закрываются.',
|
'settings.ssh.transportIdleTtl.desc': 'Как долго общее SSH-соединение остаётся открытым после освобождения последнего lease терминала, SFTP-браузера, пакетной передачи или проброса портов. Пока оно тёплое, повторное подключение к тому же хосту может обойти повторную аутентификацию (включая MFA). Активные сессии и передачи не закрываются.',
|
||||||
'settings.ssh.transportIdleTtl.1m': '1 минута',
|
'settings.ssh.transportIdleTtl.1m': '1 минута',
|
||||||
@@ -945,30 +936,8 @@ export const ruVaultMessages: Messages = {
|
|||||||
'hostDetails.plugin.unavailable': 'Настроенный провайдер недоступен. Конфигурация сохранится до восстановления плагина.',
|
'hostDetails.plugin.unavailable': 'Настроенный провайдер недоступен. Конфигурация сохранится до восстановления плагина.',
|
||||||
'hostDetails.plugin.useSsh': 'Использовать SSH',
|
'hostDetails.plugin.useSsh': 'Использовать SSH',
|
||||||
|
|
||||||
// Host form (legacy modal)
|
// Host form (legacy keys still referenced by TerminalAuthDialog)
|
||||||
'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-ключ',
|
|
||||||
'hostForm.auth.selectKey': 'Выберите SSH-ключ',
|
'hostForm.auth.selectKey': 'Выберите SSH-ключ',
|
||||||
'hostForm.auth.noKeys': 'Нет доступных ключей',
|
|
||||||
'hostForm.auth.noKeysHint': 'В связке ключей не найдено SSH-ключей. Сначала создайте один.',
|
|
||||||
'hostForm.saveHost': 'Сохранить хост',
|
|
||||||
'vault.import.plugins.title': 'Импортёры плагинов',
|
'vault.import.plugins.title': 'Импортёры плагинов',
|
||||||
'vault.import.plugins.loading': 'Создание безопасного предварительного просмотра…',
|
'vault.import.plugins.loading': 'Создание безопасного предварительного просмотра…',
|
||||||
'vault.import.plugins.progress': 'Обработано {completed} из {total} {message}',
|
'vault.import.plugins.progress': 'Обработано {completed} из {total} {message}',
|
||||||
|
|||||||
@@ -732,7 +732,6 @@ export const zhCNCoreMessages: Messages = {
|
|||||||
'knownHosts.empty.title': '暂无已知主机',
|
'knownHosts.empty.title': '暂无已知主机',
|
||||||
'knownHosts.empty.desc':
|
'knownHosts.empty.desc':
|
||||||
'Known Hosts 是你之前连接过的 SSH server。导入系统的 known_hosts 文件以开始。',
|
'Known Hosts 是你之前连接过的 SSH server。导入系统的 known_hosts 文件以开始。',
|
||||||
'knownHosts.results.showingLimited': '显示 {shown}/{total} 个主机。使用搜索查找特定主机。',
|
|
||||||
'knownHosts.toast.scanUnavailable': '当前平台无法扫描系统 known_hosts。',
|
'knownHosts.toast.scanUnavailable': '当前平台无法扫描系统 known_hosts。',
|
||||||
'knownHosts.toast.scanNoFile': '未找到系统 known_hosts 文件。',
|
'knownHosts.toast.scanNoFile': '未找到系统 known_hosts 文件。',
|
||||||
'knownHosts.toast.scanNoEntries': 'known_hosts 中没有可用条目。',
|
'knownHosts.toast.scanNoEntries': 'known_hosts 中没有可用条目。',
|
||||||
|
|||||||
@@ -93,13 +93,6 @@ export const zhCNTerminalMessages: Messages = {
|
|||||||
'settings.tab.sftpFileAssociations': 'SFTP',
|
'settings.tab.sftpFileAssociations': 'SFTP',
|
||||||
'settings.sftp.transferConcurrency': '传输并发数',
|
'settings.sftp.transferConcurrency': '传输并发数',
|
||||||
'settings.sftp.transferConcurrency.desc': '同一目标同时传输的文件数量上限,包括文件夹内文件和单独多选的文件。较高的值可能提高速度,但可能导致某些服务器过载。',
|
'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': 'SSH 连接保活时间',
|
||||||
'settings.ssh.transportIdleTtl.desc': '最后一个终端、SFTP 浏览、批量传输或端口转发释放后,共享 SSH 连接再保留多久。保留期间再次打开同主机可跳过重新认证(含 MFA);进行中的会话与传输不会被关闭。',
|
'settings.ssh.transportIdleTtl.desc': '最后一个终端、SFTP 浏览、批量传输或端口转发释放后,共享 SSH 连接再保留多久。保留期间再次打开同主机可跳过重新认证(含 MFA);进行中的会话与传输不会被关闭。',
|
||||||
'settings.ssh.transportIdleTtl.1m': '1 分钟',
|
'settings.ssh.transportIdleTtl.1m': '1 分钟',
|
||||||
|
|||||||
@@ -331,29 +331,8 @@ export const zhCNVaultMessages: Messages = {
|
|||||||
'hostDetails.plugin.useSsh': '改用 SSH',
|
'hostDetails.plugin.useSsh': '改用 SSH',
|
||||||
|
|
||||||
// Host form (legacy modal)
|
// Host form (legacy modal)
|
||||||
'hostForm.title.edit': '编辑主机',
|
// Host form (legacy keys still referenced by TerminalAuthDialog)
|
||||||
'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密钥',
|
|
||||||
'hostForm.auth.selectKey': '选择 SSH密钥',
|
'hostForm.auth.selectKey': '选择 SSH密钥',
|
||||||
'hostForm.auth.noKeys': '暂无密钥',
|
|
||||||
'hostForm.auth.noKeysHint': '钥匙串中未找到 SSH密钥,请先创建一个。',
|
|
||||||
'hostForm.saveHost': '保存主机',
|
|
||||||
|
|
||||||
// Connection logs
|
// Connection logs
|
||||||
'logs.table.date': '日期',
|
'logs.table.date': '日期',
|
||||||
|
|||||||
@@ -732,7 +732,6 @@ export const zhTWCoreMessages: Messages = {
|
|||||||
'knownHosts.empty.title': '暫無已知主機',
|
'knownHosts.empty.title': '暫無已知主機',
|
||||||
'knownHosts.empty.desc':
|
'knownHosts.empty.desc':
|
||||||
'Known Hosts 是你之前連線過的 SSH server。匯入系統的 known_hosts 檔案以開始。',
|
'Known Hosts 是你之前連線過的 SSH server。匯入系統的 known_hosts 檔案以開始。',
|
||||||
'knownHosts.results.showingLimited': '顯示 {shown}/{total} 個主機。使用搜索查詢特定主機。',
|
|
||||||
'knownHosts.toast.scanUnavailable': '目前平台無法掃描系統 known_hosts。',
|
'knownHosts.toast.scanUnavailable': '目前平台無法掃描系統 known_hosts。',
|
||||||
'knownHosts.toast.scanNoFile': '未找到系統 known_hosts 檔案。',
|
'knownHosts.toast.scanNoFile': '未找到系統 known_hosts 檔案。',
|
||||||
'knownHosts.toast.scanNoEntries': 'known_hosts 中沒有可用條目。',
|
'knownHosts.toast.scanNoEntries': 'known_hosts 中沒有可用條目。',
|
||||||
|
|||||||
@@ -93,13 +93,6 @@ export const zhTWTerminalMessages: Messages = {
|
|||||||
'settings.tab.sftpFileAssociations': 'SFTP',
|
'settings.tab.sftpFileAssociations': 'SFTP',
|
||||||
'settings.sftp.transferConcurrency': '傳輸併發數',
|
'settings.sftp.transferConcurrency': '傳輸併發數',
|
||||||
'settings.sftp.transferConcurrency.desc': '同一目標同時傳輸的檔案數量上限,包括資料夾內檔案和單獨多選的檔案。較高的值可能提高速度,但可能導致某些伺服器過載。',
|
'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': 'SSH 連線保活時間',
|
||||||
'settings.ssh.transportIdleTtl.desc': '最後一個終端、SFTP 瀏覽、批次傳輸或連接埠轉發釋放後,共享 SSH 連線再保留多久。保留期間再次開啟同主機可略過重新驗證(含 MFA);進行中的工作階段與傳輸不會被關閉。',
|
'settings.ssh.transportIdleTtl.desc': '最後一個終端、SFTP 瀏覽、批次傳輸或連接埠轉發釋放後,共享 SSH 連線再保留多久。保留期間再次開啟同主機可略過重新驗證(含 MFA);進行中的工作階段與傳輸不會被關閉。',
|
||||||
'settings.ssh.transportIdleTtl.1m': '1 分鐘',
|
'settings.ssh.transportIdleTtl.1m': '1 分鐘',
|
||||||
|
|||||||
@@ -331,29 +331,8 @@ export const zhTWVaultMessages: Messages = {
|
|||||||
'hostDetails.plugin.useSsh': '改用 SSH',
|
'hostDetails.plugin.useSsh': '改用 SSH',
|
||||||
|
|
||||||
// Host form (legacy modal)
|
// Host form (legacy modal)
|
||||||
'hostForm.title.edit': '編輯主機',
|
// Host form (legacy keys still referenced by TerminalAuthDialog)
|
||||||
'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金鑰',
|
|
||||||
'hostForm.auth.selectKey': '選擇 SSH金鑰',
|
'hostForm.auth.selectKey': '選擇 SSH金鑰',
|
||||||
'hostForm.auth.noKeys': '暫無金鑰',
|
|
||||||
'hostForm.auth.noKeysHint': '鑰匙串中未找到 SSH金鑰,請先建立一個。',
|
|
||||||
'hostForm.saveHost': '儲存主機',
|
|
||||||
|
|
||||||
// Connection logs
|
// Connection logs
|
||||||
'logs.table.date': '日期',
|
'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 { useSyncExternalStore, useEffect } from "react";
|
||||||
import { sftpFocusStore, SftpFocusedSide } from "./useSftpFocusedPane";
|
import { sftpFocusStore, SftpFocusedSide } from "./sftpFocusStore";
|
||||||
|
|
||||||
type SftpDialogActionType = "rename" | "delete" | "newFolder" | "newFile" | null;
|
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.
|
* This keeps side-panel tab switches on the already-authenticated SSH transport.
|
||||||
*/
|
*/
|
||||||
resolveBrowseSourceSessionId?: (hostId: string, host?: Host) => string | undefined;
|
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 type { MutableRefObject } from "react";
|
||||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||||
import type { Host, Identity, KnownHost, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";
|
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 type { SftpHostKeyInfo, SftpHostKeyVerificationState, SftpPane } from "./types";
|
||||||
import { useSftpDirectoryListing } from "./useSftpDirectoryListing";
|
import { useSftpDirectoryListing } from "./useSftpDirectoryListing";
|
||||||
import { useSftpHostCredentials } from "./useSftpHostCredentials";
|
import { useSftpHostCredentials } from "./useSftpHostCredentials";
|
||||||
@@ -206,15 +207,7 @@ const createKnownHostFromSftpHostKeyInfo = (
|
|||||||
hostKeyInfo: SftpHostKeyInfo,
|
hostKeyInfo: SftpHostKeyInfo,
|
||||||
now = Date.now(),
|
now = Date.now(),
|
||||||
idSuffix = Math.random().toString(36).slice(2, 11),
|
idSuffix = Math.random().toString(36).slice(2, 11),
|
||||||
): KnownHost => ({
|
): KnownHost => createKnownHostFromHostKeyInfo(hostKeyInfo, { now, idSuffix });
|
||||||
id: hostKeyInfo.knownHostId || `kh-${now}-${idSuffix}`,
|
|
||||||
hostname: hostKeyInfo.hostname,
|
|
||||||
port: hostKeyInfo.port || 22,
|
|
||||||
keyType: hostKeyInfo.keyType,
|
|
||||||
publicKey: hostKeyInfo.publicKey || `SHA256:${hostKeyInfo.fingerprint}`,
|
|
||||||
fingerprint: hostKeyInfo.fingerprint,
|
|
||||||
discoveredAt: now,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const useSftpConnections = ({
|
export const useSftpConnections = ({
|
||||||
hosts,
|
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 SftpColumnVisibility,
|
||||||
type SortField,
|
type SortField,
|
||||||
type SortOrder,
|
type SortOrder,
|
||||||
} from "../utils";
|
} from "./columnLayout";
|
||||||
import { STORAGE_KEY_SFTP_VISIBLE_COLUMNS } from "../../../infrastructure/config/storageKeys";
|
import { STORAGE_KEY_SFTP_VISIBLE_COLUMNS } from "../../../infrastructure/config/storageKeys";
|
||||||
import {
|
import {
|
||||||
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
|
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
|
||||||
localStorageAdapter,
|
localStorageAdapter,
|
||||||
} from "../../../infrastructure/persistence/localStorageAdapter";
|
} from "../../../infrastructure/persistence/localStorageAdapter";
|
||||||
import { useSftpDirectoriesFirst } from "../../../application/state/sftp/useSftpDirectoriesFirst";
|
import { useSftpDirectoriesFirst } from "./useSftpDirectoriesFirst";
|
||||||
|
|
||||||
export interface UseSftpPaneSortingResult {
|
export interface UseSftpPaneSortingResult {
|
||||||
sortField: SortField;
|
sortField: SortField;
|
||||||
@@ -2,25 +2,6 @@ import { useEffect, useLayoutEffect, useRef } from "react";
|
|||||||
|
|
||||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
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: {
|
export function useReportSftpTransferOwnerActivity(params: {
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
activeTransfersCount: number;
|
activeTransfersCount: number;
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { SftpFileEntry, TransferTask } from "../../../domain/models";
|
import { SftpFileEntry, TransferTask } from "../../../domain/models";
|
||||||
|
import { formatBytes } from "../../../domain/formatBytes";
|
||||||
|
|
||||||
export const formatFileSize = (bytes: number): string => {
|
export const formatFileSize = (bytes: number): string => {
|
||||||
if (bytes === 0) return "--";
|
return formatBytes(bytes, {
|
||||||
const units = ["Bytes", "KB", "MB", "GB", "TB"];
|
zeroDisplay: "--",
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
includeTB: true,
|
||||||
const size = bytes / Math.pow(1024, i);
|
fractionDigits: 2,
|
||||||
return `${size.toFixed(i === 0 ? 0 : 2)} ${units[i]}`;
|
byteUnit: "Bytes",
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2318,7 +2318,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
|||||||
const browserPersistence: StorePersistence | undefined = typeof globalThis.localStorage === "undefined"
|
const browserPersistence: StorePersistence | undefined = typeof globalThis.localStorage === "undefined"
|
||||||
? 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); },
|
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
|
* 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,
|
ExternalAgentConfig,
|
||||||
ProviderConfig,
|
ProviderConfig,
|
||||||
WebSearchConfig,
|
WebSearchConfig,
|
||||||
} from '../../../infrastructure/ai/types';
|
} from '../../infrastructure/ai/types';
|
||||||
import type { ExecutorContext } from '../../../infrastructure/ai/cattyAgent/executor';
|
import type { ExecutorContext } from '../../infrastructure/ai/cattyAgent/executor';
|
||||||
import { getAgentRuntime } from '../../../infrastructure/ai/harness/globalAgentRuntime';
|
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime';
|
||||||
import type {
|
import type {
|
||||||
TurnSteerInput,
|
TurnSteerInput,
|
||||||
TurnSteerResult,
|
TurnSteerResult,
|
||||||
} from '../../../infrastructure/ai/harness/turnDrivers/types';
|
} from '../../infrastructure/ai/harness/turnDrivers/types';
|
||||||
import { classifyError } from '../../../infrastructure/ai/errorClassifier';
|
import { classifyError } from '../../infrastructure/ai/errorClassifier';
|
||||||
import { latestAISessionsSnapshot } from '../../../application/state/aiStateSnapshots';
|
import { latestAISessionsSnapshot } from './aiStateSnapshots';
|
||||||
import {
|
import {
|
||||||
generateId,
|
generateId,
|
||||||
getNetcattyBridge,
|
getNetcattyBridge,
|
||||||
type DefaultTargetSessionHint,
|
type DefaultTargetSessionHint,
|
||||||
type TerminalSessionInfo,
|
type TerminalSessionInfo,
|
||||||
} from './aiChatStreamingSupport';
|
} from '../../infrastructure/ai/aiChatStreamingSupport';
|
||||||
import { useAgentCompactionUi } from './useAgentCompactionUi';
|
import { useAgentCompactionUi } from './useAgentCompactionUi';
|
||||||
|
|
||||||
export { getNetcattyBridge } from './aiChatStreamingSupport';
|
export { getNetcattyBridge } from '../../infrastructure/ai/aiChatStreamingSupport';
|
||||||
export type { ActiveCompactionUi } from './useAgentCompactionUi';
|
export type { ActiveCompactionUi } from './useAgentCompactionUi';
|
||||||
export type { DefaultTargetSessionHint } from './aiChatStreamingSupport';
|
export type { DefaultTargetSessionHint } from '../../infrastructure/ai/aiChatStreamingSupport';
|
||||||
|
|
||||||
const sharedStreamingSessionIds = new Set<string>();
|
const sharedStreamingSessionIds = new Set<string>();
|
||||||
const sharedAbortControllers = new Map<string, AbortController>();
|
const sharedAbortControllers = new Map<string, AbortController>();
|
||||||
@@ -62,7 +62,7 @@ export interface UseAIChatStreamingParams {
|
|||||||
updateMessageById: (sessionId: string, messageId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
|
updateMessageById: (sessionId: string, messageId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
|
||||||
persistContextCompaction?: (
|
persistContextCompaction?: (
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
compaction: import('../../../infrastructure/ai/types').AISessionContextCompaction,
|
compaction: import('../../infrastructure/ai/types').AISessionContextCompaction,
|
||||||
) => void;
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import type { ContextPrepareTrigger } from '../../../infrastructure/ai/harness/types';
|
import type { ContextPrepareTrigger } from '../../infrastructure/ai/harness/types';
|
||||||
import { getAgentRuntime } from '../../../infrastructure/ai/harness/globalAgentRuntime';
|
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime';
|
||||||
import { CATTY_COMPACTION_STATUS_KEYS } from '../../../infrastructure/ai/harness/compactionStatusKeys';
|
import { CATTY_COMPACTION_STATUS_KEYS } from '../../infrastructure/ai/harness/compactionStatusKeys';
|
||||||
|
|
||||||
export interface ActiveCompactionUi {
|
export interface ActiveCompactionUi {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { buildAITerminalSessionInfo } from '../../components/terminalLayer/TerminalLayerSupport';
|
import { buildAITerminalSessionInfo } from '../../domain/buildAITerminalSessionInfo';
|
||||||
import { detectLocalOs } from '../../lib/localShell';
|
import { detectLocalOs } from '../../lib/localShell';
|
||||||
import type { Host, PortForwardingRule, TerminalSession } from '../../types';
|
import type { Host, PortForwardingRule, TerminalSession } from '../../types';
|
||||||
import { STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED } from '../../infrastructure/config/storageKeys';
|
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));
|
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 {
|
export function readExternalMcpIdleTimeoutMinutes(): number {
|
||||||
return normalizeExternalMcpIdleTimeoutMinutes(
|
return normalizeExternalMcpIdleTimeoutMinutes(
|
||||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES),
|
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). */
|
/** Whether host_open should surface/focus the main window. Defaults to true (existing behavior). */
|
||||||
export function readExternalMcpFocusOnHostOpen(): boolean {
|
export function readExternalMcpFocusOnHostOpen(): boolean {
|
||||||
return localStorageAdapter.readBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_FOCUS_ON_HOST_OPEN) ?? true;
|
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({
|
export function shouldStartExternalMcpOnStartup({
|
||||||
enabled,
|
enabled,
|
||||||
mode,
|
mode,
|
||||||
|
|||||||
@@ -782,6 +782,9 @@ export function useOutputTriggers({
|
|||||||
resetTriggerScanState();
|
resetTriggerScanState();
|
||||||
},
|
},
|
||||||
schedule: (callback) => {
|
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);
|
const timer = globalThis.setTimeout(callback, OUTPUT_TRIGGER_SCAN_DELAY_MS);
|
||||||
return () => globalThis.clearTimeout(timer);
|
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 { useCallback, useEffect, useState } from "react";
|
||||||
import type { KnownHost } from "../../domain/models";
|
import type { KnownHost } from "../../domain/models";
|
||||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||||
import type { HostKeyInfo } from "../../components/terminal/TerminalHostKeyVerification";
|
import type { HostKeyInfo } from "../../domain/hostKey";
|
||||||
import {
|
import {
|
||||||
createKnownHostFromPortForwardHostKeyInfo,
|
createKnownHostFromPortForwardHostKeyInfo,
|
||||||
enqueuePortForwardHostKeyVerification,
|
enqueuePortForwardHostKeyVerification,
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
toPendingPortForwardHostKeyVerification,
|
toPendingPortForwardHostKeyVerification,
|
||||||
type PendingPortForwardHostKeyVerification,
|
type PendingPortForwardHostKeyVerification,
|
||||||
type PortForwardHostKeyRequest,
|
type PortForwardHostKeyRequest,
|
||||||
} from "../../components/port-forwarding/hostKeyVerification";
|
} from "../../domain/portForwardHostKey";
|
||||||
|
|
||||||
export interface PortForwardHostKeyVerificationState {
|
export interface PortForwardHostKeyVerificationState {
|
||||||
hostKeyInfo: HostKeyInfo;
|
hostKeyInfo: HostKeyInfo;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { netcattyBridge } from '../../../infrastructure/services/netcattyBridge';
|
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||||
|
|
||||||
export interface DiskInfo {
|
export interface DiskInfo {
|
||||||
capacityKey?: string; // Filesystem or shared-pool identity for deduplication
|
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 => {
|
setWorkspaceRenameValue(prevValue => {
|
||||||
const name = prevValue.trim();
|
const trimmed = prevValue.trim();
|
||||||
if (!name) return prevValue;
|
if (!trimmed) return prevValue;
|
||||||
|
|
||||||
setWorkspaceRenameTarget(prevTarget => {
|
setWorkspaceRenameTarget(prevTarget => {
|
||||||
if (!prevTarget) return 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 null;
|
||||||
});
|
});
|
||||||
|
|
||||||
return '';
|
return '';
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
|
||||||
|
|
||||||
import { runThemeTransition, type ThemeTransitionMode } from './themeTransition';
|
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 { SyncConfig, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } from '../../domain/models';
|
||||||
import {
|
import {
|
||||||
DEFAULT_HTTP_NETWORK_PROXY,
|
DEFAULT_HTTP_NETWORK_PROXY,
|
||||||
@@ -967,6 +968,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
|||||||
const persistedAppearanceChanged = previousAppearance === null
|
const persistedAppearanceChanged = previousAppearance === null
|
||||||
|| hasPersistedAppearanceChanged(previousAppearance, appearanceRender);
|
|| hasPersistedAppearanceChanged(previousAppearance, appearanceRender);
|
||||||
previousAppearanceRenderRef.current = 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 tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
|
||||||
const apply = () => applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
|
const apply = () => applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
|
||||||
const transitionMode = appearanceTransitionModeRef.current;
|
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.
|
// Opaque version that changes when any synced setting changes, used by useAutoSync.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
settingsVersion: useMemo(() => Math.random(), [
|
settingsVersion: useMemo(() => Math.random(), [
|
||||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
|
theme, lightUiThemeId, darkUiThemeId, accentMode,
|
||||||
uiFontFamilyId, uiLanguage, customCSS,
|
uiFontFamilyId, uiLanguage, customCSS,
|
||||||
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
||||||
customKeyBindings, editorWordWrap,
|
customKeyBindings, editorWordWrap,
|
||||||
|
|||||||
@@ -399,14 +399,6 @@ export const useSftpState = (
|
|||||||
[hosts, identities, keys, openPoolSftpSession, transferKnownHosts, transferPoolKeyCache, transferTerminalSettings],
|
[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. */
|
/** True after browse channels were soft-closed while this owner stayed mounted. */
|
||||||
const browseParkedRef = useRef(false);
|
const browseParkedRef = useRef(false);
|
||||||
const browseLifecycleGenRef = useRef(0);
|
const browseLifecycleGenRef = useRef(0);
|
||||||
@@ -824,7 +816,6 @@ export const useSftpState = (
|
|||||||
rejectHostKeyVerification,
|
rejectHostKeyVerification,
|
||||||
acceptHostKeyVerification,
|
acceptHostKeyVerification,
|
||||||
acceptAndSaveHostKeyVerification,
|
acceptAndSaveHostKeyVerification,
|
||||||
warmTransferPoolForHost,
|
|
||||||
};
|
};
|
||||||
const methodsRef = useRef(currentMethods);
|
const methodsRef = useRef(currentMethods);
|
||||||
methodsRef.current = currentMethods;
|
methodsRef.current = currentMethods;
|
||||||
@@ -907,8 +898,6 @@ export const useSftpState = (
|
|||||||
rejectHostKeyVerification: () => methodsRef.current.rejectHostKeyVerification(),
|
rejectHostKeyVerification: () => methodsRef.current.rejectHostKeyVerification(),
|
||||||
acceptHostKeyVerification: () => methodsRef.current.acceptHostKeyVerification(),
|
acceptHostKeyVerification: () => methodsRef.current.acceptHostKeyVerification(),
|
||||||
acceptAndSaveHostKeyVerification: () => methodsRef.current.acceptAndSaveHostKeyVerification(),
|
acceptAndSaveHostKeyVerification: () => methodsRef.current.acceptAndSaveHostKeyVerification(),
|
||||||
warmTransferPoolForHost: (...args: Parameters<typeof warmTransferPoolForHost>) =>
|
|
||||||
methodsRef.current.warmTransferPoolForHost(...args),
|
|
||||||
activeFileWatchCountRef,
|
activeFileWatchCountRef,
|
||||||
}), [activeFileWatchCountRef]); // activeFileWatchCountRef is a stable ref
|
}), [activeFileWatchCountRef]); // activeFileWatchCountRef is a stable ref
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -1,10 +1,10 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useI18n } from '../../../application/i18n/I18nProvider';
|
import { useI18n } from '../i18n/I18nProvider';
|
||||||
import type { I18nContextValue } from '../../../application/i18n/I18nProvider';
|
import type { I18nContextValue } from '../i18n/I18nProvider';
|
||||||
import { sessionCapabilitiesStore } from '../../../application/state/sessionCapabilitiesStore';
|
import { sessionCapabilitiesStore } from './sessionCapabilitiesStore';
|
||||||
import type { SessionCapabilities } from '../../../domain/systemManager/types';
|
import type { SessionCapabilities } from '../../domain/systemManager/types';
|
||||||
import type { useSystemManagerBackend } from '../../../application/state/useSystemManagerBackend';
|
import type { useSystemManagerBackend } from './useSystemManagerBackend';
|
||||||
import { nextPollData } from '../listStable';
|
import { nextPollData } from '../../domain/systemManager/pollListStable';
|
||||||
|
|
||||||
type Backend = ReturnType<typeof useSystemManagerBackend>;
|
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 { useCallback, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
import { retainStableAiPanelContexts } from '../../domain/aiPanelContextsEqual';
|
import { retainStableAiPanelContexts } from '../../domain/aiPanelContextsEqual';
|
||||||
|
import {
|
||||||
|
buildAITerminalSessionInfo,
|
||||||
|
type AIPanelContext,
|
||||||
|
} from '../../domain/buildAITerminalSessionInfo';
|
||||||
import { collectSessionIds } from '../../domain/workspace';
|
import { collectSessionIds } from '../../domain/workspace';
|
||||||
import type { TerminalContextReader } from '../../domain/terminalContextRead';
|
import type { TerminalContextReader } from '../../domain/terminalContextRead';
|
||||||
import { detectLocalOs } from '../../lib/localShell';
|
import { detectLocalOs } from '../../lib/localShell';
|
||||||
import type { Host, PortForwardingRule, TerminalSession, Workspace } from '../../types';
|
import type { Host, PortForwardingRule, TerminalSession, Workspace } from '../../types';
|
||||||
import { buildAITerminalSessionInfo, type AIPanelContext } from './TerminalLayerSupport';
|
|
||||||
|
|
||||||
interface UseTerminalAiContextsOptions {
|
interface UseTerminalAiContextsOptions {
|
||||||
hosts: Host[];
|
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 type { TerminalTheme } from '../../domain/models';
|
||||||
import {
|
import {
|
||||||
@@ -42,7 +42,9 @@ export function useThemeRuntime(settings: ThemeRuntimeSettings) {
|
|||||||
|
|
||||||
const [userIntent, setUserIntent] = useState<ThemeUserIntent>(idleThemeUserIntent());
|
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,
|
terminalThemeId,
|
||||||
terminalThemeDarkId,
|
terminalThemeDarkId,
|
||||||
terminalThemeLightId,
|
terminalThemeLightId,
|
||||||
@@ -50,8 +52,6 @@ export function useThemeRuntime(settings: ThemeRuntimeSettings) {
|
|||||||
resolvedTheme,
|
resolvedTheme,
|
||||||
lightUiThemeId,
|
lightUiThemeId,
|
||||||
darkUiThemeId,
|
darkUiThemeId,
|
||||||
accentMode,
|
|
||||||
customAccent,
|
|
||||||
}), [
|
}), [
|
||||||
terminalThemeId,
|
terminalThemeId,
|
||||||
terminalThemeDarkId,
|
terminalThemeDarkId,
|
||||||
@@ -60,9 +60,17 @@ export function useThemeRuntime(settings: ThemeRuntimeSettings) {
|
|||||||
resolvedTheme,
|
resolvedTheme,
|
||||||
lightUiThemeId,
|
lightUiThemeId,
|
||||||
darkUiThemeId,
|
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,
|
accentMode,
|
||||||
customAccent,
|
customAccent,
|
||||||
]);
|
}), [appearanceSettingsBase, accentMode, customAccent]);
|
||||||
|
|
||||||
const globalAppearance = useMemo(() => resolveGlobalTerminalAppearance({
|
const globalAppearance = useMemo(() => resolveGlobalTerminalAppearance({
|
||||||
userIntent,
|
userIntent,
|
||||||
@@ -70,14 +78,21 @@ export function useThemeRuntime(settings: ThemeRuntimeSettings) {
|
|||||||
customThemes,
|
customThemes,
|
||||||
}), [userIntent, appearanceSettings, 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 => (
|
const resolveFocusedAppearance = useCallback((hostScope: TerminalAppearanceHostScope): ResolvedAppearance => (
|
||||||
resolveTerminalAppearance({
|
resolveTerminalAppearance({
|
||||||
userIntent,
|
userIntent,
|
||||||
settings: appearanceSettings,
|
settings: {
|
||||||
|
...appearanceSettingsBase,
|
||||||
|
accentMode: accentRef.current.accentMode,
|
||||||
|
customAccent: accentRef.current.customAccent,
|
||||||
|
},
|
||||||
hostScope,
|
hostScope,
|
||||||
customThemes,
|
customThemes,
|
||||||
})
|
})
|
||||||
), [userIntent, appearanceSettings, customThemes]);
|
), [userIntent, appearanceSettingsBase, customThemes]);
|
||||||
|
|
||||||
const applyFollowAppSettingsForPick = useCallback((themeId: string) => {
|
const applyFollowAppSettingsForPick = useCallback((themeId: string) => {
|
||||||
const update = getFollowAppTerminalThemeSelectionUpdate(themeId);
|
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,
|
readRememberedKeyPassphrases,
|
||||||
rememberImportedKeyPassphrase,
|
rememberImportedKeyPassphrase,
|
||||||
resolveDefaultKeyPassphraseAliases,
|
resolveDefaultKeyPassphraseAliases,
|
||||||
} from "../../application/defaultKeyPassphrases";
|
} from "../defaultKeyPassphrases";
|
||||||
import {
|
import {
|
||||||
countVaultImportDuplicates,
|
countVaultImportDuplicates,
|
||||||
ensureVaultImportPersisted,
|
ensureVaultImportPersisted,
|
||||||
@@ -15,12 +15,12 @@ import {
|
|||||||
waitForVaultImportProgressPaint,
|
waitForVaultImportProgressPaint,
|
||||||
type VaultHostPersistenceResult,
|
type VaultHostPersistenceResult,
|
||||||
type VaultImportProgress,
|
type VaultImportProgress,
|
||||||
} from "../../application/state/vaultImportProgress";
|
} from "./vaultImportProgress";
|
||||||
import { importVaultHostsInWorker } from "../../application/state/vaultImportWorker";
|
import { importVaultHostsInWorker } from "./vaultImportWorker";
|
||||||
import {
|
import {
|
||||||
type VaultLockHandle,
|
type VaultLockHandle,
|
||||||
withVaultImportLock,
|
withVaultImportLock,
|
||||||
} from "../../application/state/vaultManagedImportLock";
|
} from "./vaultManagedImportLock";
|
||||||
|
|
||||||
/** Exit the import lock so a concurrent host save can finish, then retry. */
|
/** 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");
|
const RETRY_VAULT_IMPORT_AFTER_CONCURRENT_EDIT = Symbol("retry-vault-import-after-concurrent-edit");
|
||||||
@@ -36,14 +36,14 @@ import {
|
|||||||
type VaultImportFormat,
|
type VaultImportFormat,
|
||||||
} from "../../domain/vaultImport";
|
} from "../../domain/vaultImport";
|
||||||
import type { GroupConfig, Host, ManagedSource, SSHKey } from "../../types";
|
import type { GroupConfig, Host, ManagedSource, SSHKey } from "../../types";
|
||||||
import type { ImportOptions } from "./ImportVaultDialog";
|
import type { VaultImportNotifier, VaultImportOptions } from "./vaultImportOptions";
|
||||||
import { toast } from "../ui/toast";
|
|
||||||
|
|
||||||
interface UseVaultImportHandlersOptions {
|
interface UseVaultImportHandlersOptions {
|
||||||
customGroups: string[];
|
customGroups: string[];
|
||||||
hosts: Host[];
|
hosts: Host[];
|
||||||
keys: SSHKey[];
|
keys: SSHKey[];
|
||||||
managedSources: ManagedSource[];
|
managedSources: ManagedSource[];
|
||||||
|
notify: VaultImportNotifier;
|
||||||
onReadPersistedHosts: () => Promise<Host[]>;
|
onReadPersistedHosts: () => Promise<Host[]>;
|
||||||
onUpdateHosts: (hosts: Host[]) => VaultHostPersistenceResult | Promise<VaultHostPersistenceResult>;
|
onUpdateHosts: (hosts: Host[]) => VaultHostPersistenceResult | Promise<VaultHostPersistenceResult>;
|
||||||
onUpdateKeys: (keys: SSHKey[]) => void;
|
onUpdateKeys: (keys: SSHKey[]) => void;
|
||||||
@@ -73,6 +73,7 @@ export function useVaultImportHandlers({
|
|||||||
hosts,
|
hosts,
|
||||||
keys,
|
keys,
|
||||||
managedSources,
|
managedSources,
|
||||||
|
notify,
|
||||||
onReadPersistedHosts,
|
onReadPersistedHosts,
|
||||||
onUpdateHosts,
|
onUpdateHosts,
|
||||||
onUpdateKeys,
|
onUpdateKeys,
|
||||||
@@ -109,7 +110,7 @@ export function useVaultImportHandlers({
|
|||||||
}, [setIsImportOpen]);
|
}, [setIsImportOpen]);
|
||||||
|
|
||||||
const handleImportFileSelected = useCallback(
|
const handleImportFileSelected = useCallback(
|
||||||
async (format: VaultImportFormat, files: File[], options?: ImportOptions) => {
|
async (format: VaultImportFormat, files: File[], options?: VaultImportOptions) => {
|
||||||
const file = files[0];
|
const file = files[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
if (importInFlightRef.current) return;
|
if (importInFlightRef.current) return;
|
||||||
@@ -287,7 +288,7 @@ export function useVaultImportHandlers({
|
|||||||
stage: "failed",
|
stage: "failed",
|
||||||
error: message,
|
error: message,
|
||||||
});
|
});
|
||||||
toast.error(
|
notify.error(
|
||||||
message,
|
message,
|
||||||
t("vault.import.sshConfig.noFilePath"),
|
t("vault.import.sshConfig.noFilePath"),
|
||||||
);
|
);
|
||||||
@@ -305,7 +306,7 @@ export function useVaultImportHandlers({
|
|||||||
stage: "failed",
|
stage: "failed",
|
||||||
error: message,
|
error: message,
|
||||||
});
|
});
|
||||||
toast.error(
|
notify.error(
|
||||||
message,
|
message,
|
||||||
t("vault.import.sshConfig.alreadyManaged"),
|
t("vault.import.sshConfig.alreadyManaged"),
|
||||||
);
|
);
|
||||||
@@ -601,7 +602,7 @@ export function useVaultImportHandlers({
|
|||||||
stage: "failed",
|
stage: "failed",
|
||||||
error: message,
|
error: message,
|
||||||
});
|
});
|
||||||
toast.error(
|
notify.error(
|
||||||
message,
|
message,
|
||||||
t("vault.import.toast.failedTitle"),
|
t("vault.import.toast.failedTitle"),
|
||||||
);
|
);
|
||||||
@@ -617,7 +618,7 @@ export function useVaultImportHandlers({
|
|||||||
skipped,
|
skipped,
|
||||||
duplicates,
|
duplicates,
|
||||||
});
|
});
|
||||||
toast.warning(
|
notify.warning(
|
||||||
t("vault.import.toast.noNewHosts", { format: formatLabel }),
|
t("vault.import.toast.noNewHosts", { format: formatLabel }),
|
||||||
t("vault.import.toast.completedTitle"),
|
t("vault.import.toast.completedTitle"),
|
||||||
);
|
);
|
||||||
@@ -628,7 +629,7 @@ export function useVaultImportHandlers({
|
|||||||
rollbackSnapshot = null;
|
rollbackSnapshot = null;
|
||||||
|
|
||||||
if (isManaged) {
|
if (isManaged) {
|
||||||
toast.success(
|
notify.success(
|
||||||
t("vault.import.sshConfig.managedSuccess", { count: totalAffected }),
|
t("vault.import.sshConfig.managedSuccess", { count: totalAffected }),
|
||||||
t("vault.import.toast.completedTitle"),
|
t("vault.import.toast.completedTitle"),
|
||||||
);
|
);
|
||||||
@@ -641,12 +642,12 @@ export function useVaultImportHandlers({
|
|||||||
|
|
||||||
if (hasWarnings) {
|
if (hasWarnings) {
|
||||||
const firstIssue = result.issues[0]?.message;
|
const firstIssue = result.issues[0]?.message;
|
||||||
toast.warning(
|
notify.warning(
|
||||||
firstIssue ? `${details} ${t("vault.import.toast.firstIssue", { issue: firstIssue })}` : details,
|
firstIssue ? `${details} ${t("vault.import.toast.firstIssue", { issue: firstIssue })}` : details,
|
||||||
t("vault.import.toast.completedTitle"),
|
t("vault.import.toast.completedTitle"),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
toast.success(details, t("vault.import.toast.completedTitle"));
|
notify.success(details, t("vault.import.toast.completedTitle"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
updateProgress({
|
updateProgress({
|
||||||
@@ -684,7 +685,7 @@ export function useVaultImportHandlers({
|
|||||||
stage: "failed",
|
stage: "failed",
|
||||||
error: message,
|
error: message,
|
||||||
});
|
});
|
||||||
toast.error(message, t("vault.import.toast.failedTitle"));
|
notify.error(message, t("vault.import.toast.failedTitle"));
|
||||||
} finally {
|
} finally {
|
||||||
if (activeImportAbortRef.current === abortController) {
|
if (activeImportAbortRef.current === abortController) {
|
||||||
activeImportAbortRef.current = null;
|
activeImportAbortRef.current = null;
|
||||||
@@ -694,6 +695,7 @@ export function useVaultImportHandlers({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
notify,
|
||||||
onReadPersistedHosts,
|
onReadPersistedHosts,
|
||||||
onCommitVaultImportTransaction,
|
onCommitVaultImportTransaction,
|
||||||
onReadPersistedManagedSources,
|
onReadPersistedManagedSources,
|
||||||
@@ -58,6 +58,7 @@ import {
|
|||||||
} from "../../domain/globalHistory";
|
} from "../../domain/globalHistory";
|
||||||
import {
|
import {
|
||||||
buildTerminalDataMapFromLogs,
|
buildTerminalDataMapFromLogs,
|
||||||
|
connectionLogTerminalDataMapsEqual,
|
||||||
mergeConnectionLogsFromStorage,
|
mergeConnectionLogsFromStorage,
|
||||||
mergeTerminalDataIntoLogs,
|
mergeTerminalDataIntoLogs,
|
||||||
mergeTerminalDataMapsForStorage,
|
mergeTerminalDataMapsForStorage,
|
||||||
@@ -65,6 +66,10 @@ import {
|
|||||||
} from "../../domain/connectionLogTerminalData";
|
} from "../../domain/connectionLogTerminalData";
|
||||||
import { getNextVaultOrder, normalizeVaultOrder } from "../../domain/vaultOrder";
|
import { getNextVaultOrder, normalizeVaultOrder } from "../../domain/vaultOrder";
|
||||||
import { loadSanitizedShellHistory } from "./shellHistoryPersistence";
|
import { loadSanitizedShellHistory } from "./shellHistoryPersistence";
|
||||||
|
import {
|
||||||
|
publishNotesSnapshot,
|
||||||
|
registerNotesActions,
|
||||||
|
} from "./notesStore";
|
||||||
import { publishShellHistorySnapshot } from "./shellHistoryStore";
|
import { publishShellHistorySnapshot } from "./shellHistoryStore";
|
||||||
import { setVaultInitialized } from "./vaultInitStore";
|
import { setVaultInitialized } from "./vaultInitStore";
|
||||||
import {
|
import {
|
||||||
@@ -257,7 +262,7 @@ const writeConnectionLogTerminalDataMap = (
|
|||||||
localMaps,
|
localMaps,
|
||||||
buildPersistedLogIds(logs, usePersistedLogIdsFromDisk),
|
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);
|
return localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA, pruned);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -280,9 +285,13 @@ export const useVaultState = () => {
|
|||||||
const customGroupsRef = useRef<string[]>([]);
|
const customGroupsRef = useRef<string[]>([]);
|
||||||
const managedSourcesRef = useRef<ManagedSource[]>([]);
|
const managedSourcesRef = useRef<ManagedSource[]>([]);
|
||||||
const hostsRef = useRef<Host[]>([]);
|
const hostsRef = useRef<Host[]>([]);
|
||||||
|
const notesRef = useRef<VaultNote[]>([]);
|
||||||
|
const noteGroupsRef = useRef<string[]>([]);
|
||||||
customGroupsRef.current = customGroups;
|
customGroupsRef.current = customGroups;
|
||||||
managedSourcesRef.current = managedSources;
|
managedSourcesRef.current = managedSources;
|
||||||
hostsRef.current = hosts;
|
hostsRef.current = hosts;
|
||||||
|
notesRef.current = notes;
|
||||||
|
noteGroupsRef.current = noteGroups;
|
||||||
|
|
||||||
// Write-version counters prevent out-of-order async writes from overwriting
|
// Write-version counters prevent out-of-order async writes from overwriting
|
||||||
// newer data. Each update bumps the counter; the .then() callback only
|
// 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 updateNotes = useCallback((data: Partial<VaultNote>[]) => {
|
||||||
const cleaned = normalizeVaultNotes(data);
|
const cleaned = normalizeVaultNotes(data);
|
||||||
|
notesRef.current = cleaned;
|
||||||
setNotes(cleaned);
|
setNotes(cleaned);
|
||||||
localStorageAdapter.write(STORAGE_KEY_NOTES, 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 updateNoteGroups = useCallback((data: unknown) => {
|
||||||
const cleaned = normalizeNoteGroups(data);
|
const cleaned = normalizeNoteGroups(data);
|
||||||
|
noteGroupsRef.current = cleaned;
|
||||||
setNoteGroups(cleaned);
|
setNoteGroups(cleaned);
|
||||||
localStorageAdapter.write(STORAGE_KEY_NOTE_GROUPS, cleaned);
|
localStorageAdapter.write(STORAGE_KEY_NOTE_GROUPS, cleaned);
|
||||||
|
publishNotesSnapshot({ notes: notesRef.current, noteGroups: cleaned });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateCustomGroups = useCallback((
|
const updateCustomGroups = useCallback((
|
||||||
@@ -828,6 +843,18 @@ export const useVaultState = () => {
|
|||||||
publishShellHistorySnapshot(shellHistory);
|
publishShellHistorySnapshot(shellHistory);
|
||||||
}, [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(
|
const addShellHistoryEntry = useCallback(
|
||||||
(entry: Omit<ShellHistoryEntry, "id" | "timestamp">) => {
|
(entry: Omit<ShellHistoryEntry, "id" | "timestamp">) => {
|
||||||
setShellHistory((prev) => {
|
setShellHistory((prev) => {
|
||||||
@@ -1375,9 +1402,12 @@ export const useVaultState = () => {
|
|||||||
|
|
||||||
const updateHostLastConnected = useCallback((hostId: string) => {
|
const updateHostLastConnected = useCallback((hostId: string) => {
|
||||||
setHosts((prev) => {
|
setHosts((prev) => {
|
||||||
const next = prev.map((h) =>
|
const idx = prev.findIndex((h) => h.id === hostId);
|
||||||
h.id === hostId ? { ...h, lastConnectedAt: Date.now() } : h,
|
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 ver = ++hostsWriteVersion.current;
|
||||||
const encryptPromise = encryptHosts(next);
|
const encryptPromise = encryptHosts(next);
|
||||||
hostsEncryptPendingRef.current = encryptPromise.then(() => undefined);
|
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;
|
handleDeleteSession: (event: React.MouseEvent, sessionId: string) => void;
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
activeCompaction?: import('./ai/hooks/useAgentCompactionUi').ActiveCompactionUi | null;
|
activeCompaction?: import('../application/state/useAgentCompactionUi').ActiveCompactionUi | null;
|
||||||
contextUsage?: import('./ai/hooks/useAgentCompactionUi').AgentContextUsage | null;
|
contextUsage?: import('../application/state/useAgentCompactionUi').AgentContextUsage | null;
|
||||||
inputValue: string;
|
inputValue: string;
|
||||||
setInputValue: (value: string) => void;
|
setInputValue: (value: string) => void;
|
||||||
handleSend: () => void;
|
handleSend: () => void;
|
||||||
|
|||||||
@@ -252,3 +252,68 @@ test('AI side panel skips re-render when only a sibling scope session object cha
|
|||||||
false,
|
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,
|
getNetcattyBridge,
|
||||||
isAIChatSessionStreaming,
|
isAIChatSessionStreaming,
|
||||||
type DefaultTargetSessionHint,
|
type DefaultTargetSessionHint,
|
||||||
} from './ai/hooks/useAIChatStreaming';
|
} from '../application/state/useAIChatStreaming';
|
||||||
import { getScopedHistorySessions } from './ai/scopedHistorySessions';
|
import { getScopedHistorySessions } from './ai/scopedHistorySessions';
|
||||||
import { aiSessionIdSetEqual, exactScopeAISessionsEqual } from '../domain/aiSessionsForScope';
|
import { aiSessionIdSetEqual, exactScopeAISessionsEqual } from '../domain/aiSessionsForScope';
|
||||||
import { buildExternalAgentHistoryMessagesForBridge } from './ai/externalAgentHistory';
|
import { buildExternalAgentHistoryMessagesForBridge } from './ai/externalAgentHistory';
|
||||||
@@ -60,7 +60,7 @@ import { stopAgentTurn } from '../infrastructure/ai/harness/agentStop';
|
|||||||
import { getAgentRuntime } from '../infrastructure/ai/harness/globalAgentRuntime';
|
import { getAgentRuntime } from '../infrastructure/ai/harness/globalAgentRuntime';
|
||||||
import { useAIPermissionGrantsState } from '../application/state/useAIPermissionGrantsState';
|
import { useAIPermissionGrantsState } from '../application/state/useAIPermissionGrantsState';
|
||||||
import { useConversationExport } from './ai/hooks/useConversationExport';
|
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 type { AIChatSidePanelProps } from './AIChatSidePanel.types';
|
||||||
import {
|
import {
|
||||||
buildCursorListModelsAgentEnv,
|
buildCursorListModelsAgentEnv,
|
||||||
@@ -1599,12 +1599,23 @@ export function aiChatSidePanelPropsAreEqual(
|
|||||||
)) {
|
)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// History drawer / recent list need create/delete/title/updatedAt chrome.
|
// History drawer / recent list need create/delete/title chrome for the full
|
||||||
// Only when the panel is (or becomes) visible so hidden retained panels do
|
// list (fuzzy ranking still receives every session). updatedAt is compared
|
||||||
// not re-render on every sibling stream that bumps updatedAt.
|
// 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 prevVisible = prev.isVisible ?? true;
|
||||||
const nextVisible = next.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;
|
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,
|
ContextMenuTrigger,
|
||||||
} from "./ui/context-menu";
|
} from "./ui/context-menu";
|
||||||
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
|
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
|
||||||
import { ScrollArea } from "./ui/scroll-area";
|
|
||||||
import { SortDropdown, SortMode } from "./ui/sort-dropdown";
|
import { SortDropdown, SortMode } from "./ui/sort-dropdown";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||||
import { toast } from "./ui/toast";
|
import { toast } from "./ui/toast";
|
||||||
@@ -55,8 +54,13 @@ import {
|
|||||||
} from "./vault/VaultPageHeader";
|
} from "./vault/VaultPageHeader";
|
||||||
import { VaultDeleteConfirmDialog } from "./vault/VaultDeleteConfirmDialog";
|
import { VaultDeleteConfirmDialog } from "./vault/VaultDeleteConfirmDialog";
|
||||||
import { VaultEntityIcon, vaultPrimaryIconClass } from "./vault/VaultEntityIcon";
|
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";
|
import { useVaultItemReorder } from "./vault/vaultReorderDrag";
|
||||||
|
|
||||||
|
const VIRTUALIZE_THRESHOLD = 30;
|
||||||
|
|
||||||
interface KnownHostsManagerProps {
|
interface KnownHostsManagerProps {
|
||||||
knownHosts: KnownHost[];
|
knownHosts: KnownHost[];
|
||||||
hosts: Host[];
|
hosts: Host[];
|
||||||
@@ -207,6 +211,8 @@ const HostItem = React.memo<HostItemProps>(
|
|||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<div
|
<div
|
||||||
{...reorderProps}
|
{...reorderProps}
|
||||||
|
data-vault-focus-target
|
||||||
|
tabIndex={0}
|
||||||
className={cn(
|
className={cn(
|
||||||
reorderProps && "vault-drop-indicator-row",
|
reorderProps && "vault-drop-indicator-row",
|
||||||
"group cursor-pointer soft-card elevate rounded-xl h-[68px] px-3 py-2",
|
"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>
|
<ContextMenuTrigger asChild>
|
||||||
<div
|
<div
|
||||||
{...reorderProps}
|
{...reorderProps}
|
||||||
|
data-vault-focus-target
|
||||||
|
tabIndex={0}
|
||||||
className={cn(
|
className={cn(
|
||||||
reorderProps && "vault-drop-indicator-row",
|
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",
|
"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 [sortMode, setSortMode] = useState<SortMode>("manual");
|
||||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
const hasScannedRef = React.useRef(false);
|
const hasScannedRef = React.useRef(false);
|
||||||
const listRef = React.useRef<HTMLDivElement | null>(null);
|
const scrollRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const RENDER_LIMIT = 100; // Limit rendered items for performance
|
const [gridColumns, setGridColumns] = useState(2);
|
||||||
|
|
||||||
// Define handleScanSystem before useEffect that depends on it
|
// Define handleScanSystem before useEffect that depends on it
|
||||||
const handleScanSystem = useCallback(async (silent = false) => {
|
const handleScanSystem = useCallback(async (silent = false) => {
|
||||||
@@ -454,12 +462,25 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
|||||||
return sortMode === "manual" ? sortByVaultOrder(result) : result;
|
return sortMode === "manual" ? sortByVaultOrder(result) : result;
|
||||||
}, [knownHosts, deferredSearch, sortMode]);
|
}, [knownHosts, deferredSearch, sortMode]);
|
||||||
|
|
||||||
// Limit rendered items for performance
|
const shouldVirtualize = filteredHosts.length >= VIRTUALIZE_THRESHOLD;
|
||||||
const displayedHosts = useMemo(() => {
|
|
||||||
return filteredHosts.slice(0, RENDER_LIMIT);
|
|
||||||
}, [filteredHosts]);
|
|
||||||
|
|
||||||
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(
|
const handleFileSelect = useCallback(
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -541,22 +562,22 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
|||||||
const openFilePicker = useCallback(() => fileInputRef.current?.click(), []);
|
const openFilePicker = useCallback(() => fileInputRef.current?.click(), []);
|
||||||
|
|
||||||
const knownHostReorder = useVaultItemReorder({
|
const knownHostReorder = useVaultItemReorder({
|
||||||
containerRef: listRef,
|
containerRef: scrollRef,
|
||||||
viewMode,
|
viewMode,
|
||||||
dragType: "known-host-id",
|
dragType: "known-host-id",
|
||||||
targetAttribute: "data-known-host-id",
|
targetAttribute: "data-known-host-id",
|
||||||
disabled: deferredSearch.trim().length > 0,
|
disabled: deferredSearch.trim().length > 0,
|
||||||
|
// Virtualized grid cards can unmount mid-drag — use indicators, not live reorder.
|
||||||
|
preferDropIndicator: shouldVirtualize,
|
||||||
onReorder: (sourceId, targetId, position) => {
|
onReorder: (sourceId, targetId, position) => {
|
||||||
onReorder(reorderVaultItems(knownHosts, sourceId, targetId, position));
|
onReorder(reorderVaultItems(knownHosts, sourceId, targetId, position));
|
||||||
setSortMode("manual");
|
setSortMode("manual");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Memoize the rendered list to prevent re-renders
|
const renderKnownHostItem = useCallback(
|
||||||
const renderedItems = useMemo(() => {
|
(knownHost: KnownHost) => (
|
||||||
return displayedHosts.map((knownHost) => (
|
|
||||||
<HostItem
|
<HostItem
|
||||||
key={knownHost.id}
|
|
||||||
knownHost={knownHost}
|
knownHost={knownHost}
|
||||||
converted={convertedMap.get(knownHost.id) || false}
|
converted={convertedMap.get(knownHost.id) || false}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
@@ -564,15 +585,15 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
|||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onConvertToHost={handleConvertToHost}
|
onConvertToHost={handleConvertToHost}
|
||||||
/>
|
/>
|
||||||
));
|
),
|
||||||
}, [
|
[
|
||||||
displayedHosts,
|
convertedMap,
|
||||||
convertedMap,
|
viewMode,
|
||||||
viewMode,
|
handleDelete,
|
||||||
handleDelete,
|
handleConvertToHost,
|
||||||
handleConvertToHost,
|
knownHostReorder,
|
||||||
knownHostReorder,
|
],
|
||||||
]);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
@@ -657,74 +678,75 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
|||||||
</VaultPageHeader>
|
</VaultPageHeader>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<ScrollArea className="flex-1">
|
<div
|
||||||
<div
|
ref={scrollRef}
|
||||||
ref={listRef}
|
className="flex-1 overflow-auto p-4"
|
||||||
className={cn(
|
onDragOverCapture={knownHostReorder.handleDragOverCapture}
|
||||||
"p-4",
|
onDragOver={knownHostReorder.handleDragOver}
|
||||||
viewMode === "grid"
|
onDropCapture={knownHostReorder.handleDropCapture}
|
||||||
? "grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-4 gap-3"
|
onDragEndCapture={knownHostReorder.handleDragEndCapture}
|
||||||
: "flex flex-col gap-0",
|
>
|
||||||
)}
|
{filteredHosts.length === 0 ? (
|
||||||
onDragOverCapture={knownHostReorder.handleDragOverCapture}
|
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||||
onDragOver={knownHostReorder.handleDragOver}
|
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
|
||||||
onDropCapture={knownHostReorder.handleDropCapture}
|
<Shield size={32} className="opacity-60" />
|
||||||
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>
|
</div>
|
||||||
) : (
|
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||||
<>
|
{t("knownHosts.empty.title")}
|
||||||
{renderedItems}
|
</h3>
|
||||||
{hasMore && (
|
<p className="text-sm text-center max-w-sm mb-4">
|
||||||
<div
|
{t("knownHosts.empty.desc")}
|
||||||
className={cn(
|
</p>
|
||||||
"text-center py-4 text-sm text-muted-foreground",
|
<div className="flex gap-2">
|
||||||
viewMode === "grid" && "col-span-full",
|
<Button
|
||||||
)}
|
variant="secondary"
|
||||||
>
|
onClick={() => handleScanSystem()}
|
||||||
{t("knownHosts.results.showingLimited", {
|
disabled={isScanning}
|
||||||
shown: displayedHosts.length,
|
>
|
||||||
total: filteredHosts.length,
|
<RefreshCw
|
||||||
})}
|
size={14}
|
||||||
</div>
|
className={cn("mr-2", isScanning && "animate-spin")}
|
||||||
)}
|
/>
|
||||||
</>
|
{t("knownHosts.action.scanSystem")}
|
||||||
)}
|
</Button>
|
||||||
</div>
|
<Button variant="outline" onClick={openFilePicker}>
|
||||||
</ScrollArea>
|
<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
|
<VaultDeleteConfirmDialog
|
||||||
open={Boolean(deleteTargetId)}
|
open={Boolean(deleteTargetId)}
|
||||||
title={t("vault.deleteConfirm.title", {
|
title={t("vault.deleteConfirm.title", {
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import { useI18n } from "../application/i18n/I18nProvider";
|
|||||||
import { useSftpState } from "../application/state/useSftpState";
|
import { useSftpState } from "../application/state/useSftpState";
|
||||||
import {
|
import {
|
||||||
useReportSftpTransferOwnerActivity,
|
useReportSftpTransferOwnerActivity,
|
||||||
useWarmSftpTransferPool,
|
|
||||||
} from "../application/state/sftp/useSftpTransferLifecycle";
|
} from "../application/state/sftp/useSftpTransferLifecycle";
|
||||||
import { registerEditorSftpWriterScoped } from "../application/state/editorSftpBridge";
|
import { registerEditorSftpWriterScoped } from "../application/state/editorSftpBridge";
|
||||||
import {
|
import {
|
||||||
@@ -61,7 +60,7 @@ import { SftpContextProvider } from "./sftp";
|
|||||||
import { useSftpViewPaneCallbacks } from "./sftp/hooks/useSftpViewPaneCallbacks";
|
import { useSftpViewPaneCallbacks } from "./sftp/hooks/useSftpViewPaneCallbacks";
|
||||||
import { useSftpViewTabs } from "./sftp/hooks/useSftpViewTabs";
|
import { useSftpViewTabs } from "./sftp/hooks/useSftpViewTabs";
|
||||||
import { useSftpKeyboardShortcuts } from "./sftp/hooks/useSftpKeyboardShortcuts";
|
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 { keepOnlyPaneSelections } from "./sftp/hooks/selectionScope";
|
||||||
import { KeyBinding, HotkeyScheme } from "../domain/models";
|
import { KeyBinding, HotkeyScheme } from "../domain/models";
|
||||||
import {
|
import {
|
||||||
@@ -300,12 +299,6 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
|||||||
const sftpRef = useRef(sftp);
|
const sftpRef = useRef(sftp);
|
||||||
sftpRef.current = 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;
|
const { getConnectionCacheKey, leftPane } = sftp;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ import { Host, Identity, KnownHost, ProxyProfile, SSHKey, TransferTask } from ".
|
|||||||
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
|
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
|
||||||
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
|
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
|
||||||
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
|
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
|
||||||
import { useWarmSftpTransferPool } from "../application/state/sftp/useSftpTransferLifecycle";
|
|
||||||
import { registerEditorSftpWriterScoped } from "../application/state/editorSftpBridge";
|
import { registerEditorSftpWriterScoped } from "../application/state/editorSftpBridge";
|
||||||
import { toast } from "./ui/toast";
|
import { toast } from "./ui/toast";
|
||||||
|
|
||||||
@@ -44,7 +43,7 @@ import { SftpContextProvider, activeTabStore } from "./sftp";
|
|||||||
import { useSftpViewPaneCallbacks } from "./sftp/hooks/useSftpViewPaneCallbacks";
|
import { useSftpViewPaneCallbacks } from "./sftp/hooks/useSftpViewPaneCallbacks";
|
||||||
import { useSftpViewTabs } from "./sftp/hooks/useSftpViewTabs";
|
import { useSftpViewTabs } from "./sftp/hooks/useSftpViewTabs";
|
||||||
import { useSftpKeyboardShortcuts } from "./sftp/hooks/useSftpKeyboardShortcuts";
|
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";
|
import { keepOnlyActivePaneSelections, keepOnlyPaneSelections } from "./sftp/hooks/selectionScope";
|
||||||
|
|
||||||
|
|
||||||
@@ -186,11 +185,6 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
|||||||
const sftpRef = useRef(sftp);
|
const sftpRef = useRef(sftp);
|
||||||
sftpRef.current = sftp;
|
sftpRef.current = sftp;
|
||||||
|
|
||||||
useWarmSftpTransferPool({
|
|
||||||
hostIds: connectedHosts.map((entry) => entry.host.id),
|
|
||||||
warmTransferPoolForHost: sftp.warmTransferPoolForHost,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Register this useSftpState's writeTextFileByConnection with the bridge so
|
// Register this useSftpState's writeTextFileByConnection with the bridge so
|
||||||
// the editor tab's save path can reach the active SFTP session. The bridge
|
// the editor tab's save path can reach the active SFTP session. The bridge
|
||||||
// supports multiple simultaneous writers (SftpSidePanel inside terminals
|
// 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,
|
vaultSnippetIconClass,
|
||||||
} from './vault/VaultEntityIcon';
|
} from './vault/VaultEntityIcon';
|
||||||
import { VaultDeleteConfirmDialog } from './vault/VaultDeleteConfirmDialog';
|
import { VaultDeleteConfirmDialog } from './vault/VaultDeleteConfirmDialog';
|
||||||
|
import { VirtualizedHostCollection } from './vault/VirtualizedHostCollection';
|
||||||
|
import { getSnippetsGridColumnCount } from './snippetsGridLayout';
|
||||||
|
import { getElementContentWidth } from './vault/vaultHostGridLayout';
|
||||||
import {
|
import {
|
||||||
clearVaultDropIndicator as clearSnippetDropIndicator,
|
clearVaultDropIndicator as clearSnippetDropIndicator,
|
||||||
getVaultDropIntent as getPackageDropIntent,
|
getVaultDropIntent as getPackageDropIntent,
|
||||||
@@ -59,6 +62,8 @@ import {
|
|||||||
useVaultGridLayoutAnimation,
|
useVaultGridLayoutAnimation,
|
||||||
} from './vault/vaultReorderDrag';
|
} from './vault/vaultReorderDrag';
|
||||||
|
|
||||||
|
const VIRTUALIZE_THRESHOLD = 30;
|
||||||
|
|
||||||
interface SnippetsManagerProps {
|
interface SnippetsManagerProps {
|
||||||
snippets: Snippet[];
|
snippets: Snippet[];
|
||||||
packages: string[];
|
packages: string[];
|
||||||
@@ -521,22 +526,18 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
|||||||
const prepareGridLayoutAnimation = useVaultGridLayoutAnimation(listRef);
|
const prepareGridLayoutAnimation = useVaultGridLayoutAnimation(listRef);
|
||||||
const hasSnippetsSidePanel = rightPanelMode !== 'none';
|
const hasSnippetsSidePanel = rightPanelMode !== 'none';
|
||||||
const splitGridColsRef = useRef(2);
|
const splitGridColsRef = useRef(2);
|
||||||
const splitViewGridStyle = hasSnippetsSidePanel && viewMode === 'grid'
|
const snippetGridStyle = viewMode === 'grid'
|
||||||
? { gridTemplateColumns: 'var(--snippets-grid-cols, repeat(2, minmax(0, 1fr)))' }
|
? { gridTemplateColumns: 'var(--snippets-grid-cols, repeat(2, minmax(0, 1fr)))' }
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = listRef.current;
|
const el = listRef.current;
|
||||||
if (!el || typeof ResizeObserver === 'undefined') return;
|
if (!el || viewMode !== 'grid' || typeof ResizeObserver === 'undefined') return;
|
||||||
|
|
||||||
const GAP = 12;
|
|
||||||
const MIN_CARD = 220;
|
|
||||||
const PADDING_X = 32;
|
|
||||||
|
|
||||||
const recompute = () => {
|
const recompute = () => {
|
||||||
const usable = el.clientWidth - PADDING_X;
|
const next = getSnippetsGridColumnCount(getElementContentWidth(el), {
|
||||||
if (usable <= 0) return;
|
hasSidePanel: hasSnippetsSidePanel,
|
||||||
const next = Math.max(1, Math.floor((usable + GAP) / (MIN_CARD + GAP)));
|
});
|
||||||
if (next === splitGridColsRef.current) return;
|
if (next === splitGridColsRef.current) return;
|
||||||
splitGridColsRef.current = next;
|
splitGridColsRef.current = next;
|
||||||
el.style.setProperty('--snippets-grid-cols', `repeat(${next}, minmax(0, 1fr))`);
|
el.style.setProperty('--snippets-grid-cols', `repeat(${next}, minmax(0, 1fr))`);
|
||||||
@@ -1015,6 +1016,16 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
|||||||
}, [snippets, selectedPackage, search, sortMode]);
|
}, [snippets, selectedPackage, search, sortMode]);
|
||||||
|
|
||||||
const isSearchActive = search.trim().length > 0;
|
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(() => {
|
useEffect(() => {
|
||||||
setSelectedSnippetIds((prev) => {
|
setSelectedSnippetIds((prev) => {
|
||||||
@@ -1445,6 +1456,11 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
|||||||
const sourceSnippetId = draggingSnippetIdRef.current || event.dataTransfer.getData('snippet-id');
|
const sourceSnippetId = draggingSnippetIdRef.current || event.dataTransfer.getData('snippet-id');
|
||||||
const position = getDropPosition(target, event.clientX, event.clientY, isGrid);
|
const position = getDropPosition(target, event.clientX, event.clientY, isGrid);
|
||||||
if (isGrid && sourceSnippetId && sourceSnippetId !== targetSnippetId) {
|
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 targetSnippet = snippets.find((snippet) => snippet.id === targetSnippetId);
|
||||||
const sourceSnippet = snippets.find((snippet) => snippet.id === sourceSnippetId);
|
const sourceSnippet = snippets.find((snippet) => snippet.id === sourceSnippetId);
|
||||||
if (!targetSnippet || !sourceSnippet) return;
|
if (!targetSnippet || !sourceSnippet) return;
|
||||||
@@ -1513,11 +1529,13 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
|||||||
packages,
|
packages,
|
||||||
parentOfPackage,
|
parentOfPackage,
|
||||||
prepareGridLayoutAnimation,
|
prepareGridLayoutAnimation,
|
||||||
|
shouldVirtualizeSnippets,
|
||||||
snippets,
|
snippets,
|
||||||
viewMode,
|
viewMode,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleReorderDrop = useCallback((event: React.DragEvent<HTMLDivElement>) => {
|
const handleReorderDrop = useCallback((event: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
try {
|
||||||
const target = (event.target as Element | null)?.closest('[data-snippet-id], [data-pkg-path]');
|
const target = (event.target as Element | null)?.closest('[data-snippet-id], [data-pkg-path]');
|
||||||
clearSnippetDropIndicator();
|
clearSnippetDropIndicator();
|
||||||
if (!(target instanceof HTMLElement)) return;
|
if (!(target instanceof HTMLElement)) return;
|
||||||
@@ -1577,12 +1595,18 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
|||||||
lastPreviewReorderRef.current = null;
|
lastPreviewReorderRef.current = null;
|
||||||
setSortMode('manual');
|
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,
|
onBulkSave,
|
||||||
onPackagesChange,
|
onPackagesChange,
|
||||||
packages,
|
packages,
|
||||||
parentOfPackage,
|
parentOfPackage,
|
||||||
prepareGridLayoutAnimation,
|
prepareGridLayoutAnimation,
|
||||||
|
resetSnippetDragState,
|
||||||
snippets,
|
snippets,
|
||||||
viewMode,
|
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 (
|
return (
|
||||||
<TooltipProvider delayDuration={300}>
|
<TooltipProvider delayDuration={300}>
|
||||||
<div className="flex flex-1 min-h-0 min-w-0 relative">
|
<div className="flex flex-1 min-h-0 min-w-0 relative">
|
||||||
@@ -1904,15 +2051,10 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
viewMode === 'grid'
|
viewMode === 'grid'
|
||||||
? cn(
|
? "grid gap-3"
|
||||||
"grid gap-3",
|
|
||||||
hasSnippetsSidePanel
|
|
||||||
? "grid-cols-1"
|
|
||||||
: "grid-cols-1 md:grid-cols-2 xl:grid-cols-3",
|
|
||||||
)
|
|
||||||
: "flex flex-col gap-0"
|
: "flex flex-col gap-0"
|
||||||
)}
|
)}
|
||||||
style={splitViewGridStyle}
|
style={snippetGridStyle}
|
||||||
>
|
>
|
||||||
{displayedPackages.map((pkg) => (
|
{displayedPackages.map((pkg) => (
|
||||||
<ContextMenu key={pkg.path}>
|
<ContextMenu key={pkg.path}>
|
||||||
@@ -1983,141 +2125,36 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
|||||||
{displayedSnippets.length > 0 && (
|
{displayedSnippets.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h3 className={vaultSectionTitleClass}>{t('snippets.section.snippets')}</h3>
|
<h3 className={vaultSectionTitleClass}>{t('snippets.section.snippets')}</h3>
|
||||||
<div className={cn(
|
{shouldVirtualizeSnippets ? (
|
||||||
viewMode === 'grid'
|
<VirtualizedHostCollection
|
||||||
? cn(
|
items={displayedSnippets}
|
||||||
"grid gap-3",
|
itemKey={(snippet) => snippet.id}
|
||||||
hasSnippetsSidePanel
|
scrollRef={listRef}
|
||||||
? "grid-cols-1"
|
viewMode={viewMode}
|
||||||
: "grid-cols-1 md:grid-cols-2 xl:grid-cols-3",
|
layoutKey={snippetCollectionLayoutKey}
|
||||||
)
|
ariaLabel={t('vault.nav.snippets')}
|
||||||
: "flex flex-col gap-0"
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{search.trim() && displayedSnippets.length === 0 && (snippets.length > 0 || displayedPackages.length > 0) && (
|
{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 { readOptionalStoredStringValue, useStoredString } from "../application/state/useStoredString";
|
||||||
import { useSessionLogBackend } from "../application/state/useSessionLogBackend";
|
import { useSessionLogBackend } from "../application/state/useSessionLogBackend";
|
||||||
import { useTerminalLayoutSuppressActive } from "../application/state/terminalLayoutSuppressStore";
|
import { useTerminalLayoutSuppressActive } from "../application/state/terminalLayoutSuppressStore";
|
||||||
|
import { useAppearanceChromeStore } from "../application/state/appearanceChromeStore";
|
||||||
import {
|
import {
|
||||||
shouldPublishPluginTerminalSessionMountLifecycle,
|
shouldPublishPluginTerminalSessionMountLifecycle,
|
||||||
usePluginTerminalSessionLifecycle,
|
usePluginTerminalSessionLifecycle,
|
||||||
@@ -260,8 +261,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
fontSize,
|
fontSize,
|
||||||
terminalTheme,
|
terminalTheme,
|
||||||
followAppTerminalTheme = false,
|
followAppTerminalTheme = false,
|
||||||
accentMode = "theme",
|
|
||||||
customAccent = "",
|
|
||||||
terminalSettings,
|
terminalSettings,
|
||||||
sessionId,
|
sessionId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -1264,22 +1263,31 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
});
|
});
|
||||||
}, [availableFonts, fontFamilyId, hasFontFamilyOverride, host.fontFamily, terminalSettings?.fallbackFont]);
|
}, [availableFonts, fontFamilyId, hasFontFamilyOverride, host.fontFamily, terminalSettings?.fallbackFont]);
|
||||||
|
|
||||||
|
const { accentMode, customAccent } = useAppearanceChromeStore();
|
||||||
|
|
||||||
const baseEffectiveTheme = useMemo(() => {
|
const baseEffectiveTheme = useMemo(() => {
|
||||||
if (appearanceTheme) return appearanceTheme;
|
// Always re-apply appearanceChromeStore accent. appearanceTheme from the
|
||||||
if (followAppTerminalTheme) {
|
// layer may still carry a previously baked custom accent while TerminalLayer
|
||||||
return applyCustomAccentToTerminalTheme(terminalTheme, accentMode, customAccent);
|
// memo ignores accent churn — re-resolve the catalog theme by id first.
|
||||||
}
|
const resolveBase = (): typeof terminalTheme => {
|
||||||
const themeId = resolveHostTerminalThemeId(
|
if (appearanceTheme) {
|
||||||
{ theme: host.theme, themeOverride: host.themeOverride } as Pick<Host, 'theme' | 'themeOverride'>,
|
const clean = getBuiltinTerminalThemeById(appearanceTheme.id)
|
||||||
terminalTheme.id,
|
|| customThemes.find((t) => t.id === appearanceTheme.id);
|
||||||
);
|
if (clean) return clean;
|
||||||
let baseTheme = terminalTheme;
|
}
|
||||||
if (themeId) {
|
if (followAppTerminalTheme) return terminalTheme;
|
||||||
const hostTheme = getBuiltinTerminalThemeById(themeId)
|
const themeId = resolveHostTerminalThemeId(
|
||||||
|| customThemes.find((t) => t.id === themeId);
|
{ theme: host.theme, themeOverride: host.themeOverride } as Pick<Host, 'theme' | 'themeOverride'>,
|
||||||
if (hostTheme) baseTheme = hostTheme;
|
terminalTheme.id,
|
||||||
}
|
);
|
||||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
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]);
|
}, [accentMode, appearanceTheme, customAccent, customThemes, followAppTerminalTheme, host.theme, host.themeOverride, terminalTheme]);
|
||||||
|
|
||||||
const resolvedChainHosts =
|
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", () => {
|
test("TerminalLayer ignores sessions dynamicTitle-only updates", () => {
|
||||||
const sessionsA = [{
|
const sessionsA = [{
|
||||||
id: "s1",
|
id: "s1",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { useStoredString } from '../application/state/useStoredString';
|
|||||||
import { useStoredNumber } from '../application/state/useStoredNumber';
|
import { useStoredNumber } from '../application/state/useStoredNumber';
|
||||||
import { useStoredBoolean } from '../application/state/useStoredBoolean';
|
import { useStoredBoolean } from '../application/state/useStoredBoolean';
|
||||||
import {
|
import {
|
||||||
|
STORAGE_KEY_SIDE_PANEL_POSITION,
|
||||||
STORAGE_KEY_SIDE_PANEL_WIDTH,
|
STORAGE_KEY_SIDE_PANEL_WIDTH,
|
||||||
STORAGE_KEY_TERMINAL_COMPOSE_BAR_OPEN,
|
STORAGE_KEY_TERMINAL_COMPOSE_BAR_OPEN,
|
||||||
} from '../infrastructure/config/storageKeys';
|
} from '../infrastructure/config/storageKeys';
|
||||||
@@ -173,8 +174,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
|||||||
identities,
|
identities,
|
||||||
snippets,
|
snippets,
|
||||||
snippetPackages,
|
snippetPackages,
|
||||||
notes,
|
|
||||||
noteGroups,
|
|
||||||
openNoteRequest,
|
openNoteRequest,
|
||||||
onOpenVaultNoteFromChat,
|
onOpenVaultNoteFromChat,
|
||||||
onOpenVaultHostFromChat,
|
onOpenVaultHostFromChat,
|
||||||
@@ -237,8 +236,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
|||||||
updateHosts,
|
updateHosts,
|
||||||
updateSnippets,
|
updateSnippets,
|
||||||
updateSnippetPackages,
|
updateSnippetPackages,
|
||||||
updateNotes,
|
|
||||||
updateNoteGroups,
|
|
||||||
sftpDefaultViewMode,
|
sftpDefaultViewMode,
|
||||||
sftpDoubleClickBehavior,
|
sftpDoubleClickBehavior,
|
||||||
sftpAutoSync,
|
sftpAutoSync,
|
||||||
@@ -555,7 +552,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
|||||||
{ min: TERMINAL_SIDE_PANEL_MIN_WIDTH, max: TERMINAL_SIDE_PANEL_MAX_WIDTH },
|
{ min: TERMINAL_SIDE_PANEL_MIN_WIDTH, max: TERMINAL_SIDE_PANEL_MAX_WIDTH },
|
||||||
);
|
);
|
||||||
const [sidePanelPosition, setSidePanelPosition] = useStoredString<'left' | 'right'>(
|
const [sidePanelPosition, setSidePanelPosition] = useStoredString<'left' | 'right'>(
|
||||||
'netcatty_side_panel_position',
|
STORAGE_KEY_SIDE_PANEL_POSITION,
|
||||||
'left',
|
'left',
|
||||||
(v): v is 'left' | 'right' => v === 'left' || v === 'right',
|
(v): v is 'left' | 'right' => v === 'left' || v === 'right',
|
||||||
);
|
);
|
||||||
@@ -2165,8 +2162,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
|||||||
programmaticCommandLogRewriteHandlersRef,
|
programmaticCommandLogRewriteHandlersRef,
|
||||||
snippetPackages,
|
snippetPackages,
|
||||||
snippets,
|
snippets,
|
||||||
noteGroups,
|
|
||||||
notes,
|
|
||||||
onOpenVaultHostFromChat,
|
onOpenVaultHostFromChat,
|
||||||
onOpenVaultNoteFromChat: handleOpenVaultNoteFromAiPanel,
|
onOpenVaultNoteFromChat: handleOpenVaultNoteFromAiPanel,
|
||||||
onOpenVaultSectionFromChat,
|
onOpenVaultSectionFromChat,
|
||||||
@@ -2189,8 +2184,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
updateHosts,
|
updateHosts,
|
||||||
updateNoteGroups,
|
|
||||||
updateNotes,
|
|
||||||
updateSnippetPackages,
|
updateSnippetPackages,
|
||||||
updateSnippets,
|
updateSnippets,
|
||||||
X,
|
X,
|
||||||
|
|||||||
@@ -1,29 +1,29 @@
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
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", () => {
|
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(
|
assert.equal(
|
||||||
vaultViewAreEqual(
|
vaultViewAreEqual(
|
||||||
baseProps as never,
|
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", () => {
|
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(
|
assert.equal(
|
||||||
vaultViewAreEqual(baseProps as never, { ...baseProps } as never),
|
vaultViewAreEqual(baseProps as never, { ...baseProps } as never),
|
||||||
true,
|
true,
|
||||||
@@ -65,26 +45,6 @@ test("VaultView memo does not depend on shellHistory prop identity", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("VaultView re-renders when proxy profiles change", () => {
|
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(
|
assert.equal(
|
||||||
vaultViewAreEqual(
|
vaultViewAreEqual(
|
||||||
baseProps as never,
|
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", () => {
|
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(
|
assert.equal(
|
||||||
vaultViewAreEqual(
|
vaultViewAreEqual(
|
||||||
baseProps as never,
|
baseProps as never,
|
||||||
{
|
{
|
||||||
...baseProps,
|
...baseProps,
|
||||||
terminalSettings: {
|
terminalSettings: {
|
||||||
...baseProps.terminalSettings,
|
|
||||||
verifyHostKeys: false,
|
verifyHostKeys: false,
|
||||||
},
|
},
|
||||||
} as never,
|
} as never,
|
||||||
@@ -144,3 +78,35 @@ test("VaultView re-renders when host-key verification setting changes", () => {
|
|||||||
false,
|
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 { HotkeyScheme, KeyBinding } from "../domain/models";
|
||||||
import { VaultViewLayout } from "./vault/VaultViewLayout";
|
import { VaultViewLayout } from "./vault/VaultViewLayout";
|
||||||
import { useVaultHostCollections } from "./vault/useVaultHostCollections";
|
import { useVaultHostCollections } from "./vault/useVaultHostCollections";
|
||||||
import { useVaultImportHandlers } from "./vault/useVaultImportHandlers";
|
import { useVaultImportHandlers } from "../application/state/useVaultImportHandlers";
|
||||||
import { useVaultGroupDragHandlers } from "./vault/useVaultGroupDragHandlers";
|
import { useVaultGroupDragHandlers } from "./vault/useVaultGroupDragHandlers";
|
||||||
|
|
||||||
const LazyProtocolSelectDialog = lazy(() => import("./ProtocolSelectDialog"));
|
const LazyProtocolSelectDialog = lazy(() => import("./ProtocolSelectDialog"));
|
||||||
@@ -167,6 +167,16 @@ export type VaultSection =
|
|||||||
| "knownhosts"
|
| "knownhosts"
|
||||||
| "logs";
|
| "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[]) => {
|
const haveSameHostOrderResult = (previous: Host[], next: Host[]) => {
|
||||||
if (previous.length !== next.length) return false;
|
if (previous.length !== next.length) return false;
|
||||||
return next.every((host, index) => {
|
return next.every((host, index) => {
|
||||||
@@ -365,6 +375,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
|||||||
const hostsRef = useRef(hosts);
|
const hostsRef = useRef(hosts);
|
||||||
hostsRef.current = hosts;
|
hostsRef.current = hosts;
|
||||||
const [currentSection, setCurrentSection] = useState<VaultSection>("hosts");
|
const [currentSection, setCurrentSection] = useState<VaultSection>("hosts");
|
||||||
|
vaultViewMemoSection = currentSection;
|
||||||
|
const resolvedConnectionLogs = vaultViewLatestConnectionLogs ?? connectionLogs;
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [selectedGroupPath, setSelectedGroupPath] = useState<string | null>(
|
const [selectedGroupPath, setSelectedGroupPath] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
@@ -888,6 +900,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
|||||||
hosts,
|
hosts,
|
||||||
keys,
|
keys,
|
||||||
managedSources,
|
managedSources,
|
||||||
|
notify: toast,
|
||||||
onReadPersistedHosts,
|
onReadPersistedHosts,
|
||||||
onUpdateHosts,
|
onUpdateHosts,
|
||||||
onUpdateKeys,
|
onUpdateKeys,
|
||||||
@@ -1414,7 +1427,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
|||||||
Clock,
|
Clock,
|
||||||
cn,
|
cn,
|
||||||
commitInlineGroupRename,
|
commitInlineGroupRename,
|
||||||
connectionLogs,
|
connectionLogs: resolvedConnectionLogs,
|
||||||
connectSelectedHosts,
|
connectSelectedHosts,
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
ContextMenuContent,
|
ContextMenuContent,
|
||||||
@@ -1667,6 +1680,11 @@ export const vaultViewAreEqual = (
|
|||||||
prev: VaultViewProps,
|
prev: VaultViewProps,
|
||||||
next: VaultViewProps,
|
next: VaultViewProps,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
|
vaultViewLatestConnectionLogs = next.connectionLogs;
|
||||||
|
const connectionLogsEqual =
|
||||||
|
vaultViewMemoSection === "logs"
|
||||||
|
? prev.connectionLogs === next.connectionLogs
|
||||||
|
: true;
|
||||||
const isEqual =
|
const isEqual =
|
||||||
prev.hosts === next.hosts &&
|
prev.hosts === next.hosts &&
|
||||||
prev.keys === next.keys &&
|
prev.keys === next.keys &&
|
||||||
@@ -1679,7 +1697,7 @@ export const vaultViewAreEqual = (
|
|||||||
prev.customGroups === next.customGroups &&
|
prev.customGroups === next.customGroups &&
|
||||||
prev.knownHosts === next.knownHosts &&
|
prev.knownHosts === next.knownHosts &&
|
||||||
// shellHistory is not a VaultView prop; SnippetsManager reads shellHistoryStore.
|
// shellHistory is not a VaultView prop; SnippetsManager reads shellHistoryStore.
|
||||||
prev.connectionLogs === next.connectionLogs &&
|
connectionLogsEqual &&
|
||||||
prev.sessionCount === next.sessionCount &&
|
prev.sessionCount === next.sessionCount &&
|
||||||
prev.managedSources === next.managedSources &&
|
prev.managedSources === next.managedSources &&
|
||||||
prev.groupConfigs === next.groupConfigs &&
|
prev.groupConfigs === next.groupConfigs &&
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import type { AgentModelPreset, AIPermissionMode, ProviderConfig, UploadedFile }
|
|||||||
import { ProviderIconBadge } from '../settings/tabs/ai/ProviderIconBadge';
|
import { ProviderIconBadge } from '../settings/tabs/ai/ProviderIconBadge';
|
||||||
import { VariableSizeVirtualList, type VariableSizeVirtualListHandle } from '../ui/VariableSizeVirtualList';
|
import { VariableSizeVirtualList, type VariableSizeVirtualListHandle } from '../ui/VariableSizeVirtualList';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||||
import type { AgentContextUsage } from './hooks/useAgentCompactionUi';
|
import type { AgentContextUsage } from '../../application/state/useAgentCompactionUi';
|
||||||
import {
|
import {
|
||||||
CHAT_INPUT_DEFAULT_HEIGHT,
|
CHAT_INPUT_DEFAULT_HEIGHT,
|
||||||
CHAT_INPUT_MAX_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]]);
|
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({
|
assert.equal(shouldRenderAssistantAsPlainText({
|
||||||
hideMarkdown: false,
|
hideMarkdown: false,
|
||||||
}), false);
|
}), false);
|
||||||
assert.equal(shouldRenderAssistantAsPlainText({
|
assert.equal(shouldRenderAssistantAsPlainText({
|
||||||
hideMarkdown: true,
|
hideMarkdown: true,
|
||||||
}), 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[] = [
|
const messages: ChatMessage[] = [
|
||||||
{
|
{
|
||||||
id: "user-1",
|
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.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");
|
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", () => {
|
test("ChatMessageList hydrates markdown after streaming settles", () => {
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ import {
|
|||||||
compactionStatusText,
|
compactionStatusText,
|
||||||
resolveCompactionStatusText,
|
resolveCompactionStatusText,
|
||||||
type ActiveCompactionUi,
|
type ActiveCompactionUi,
|
||||||
} from './hooks/useAgentCompactionUi';
|
} from '../../application/state/useAgentCompactionUi';
|
||||||
import {
|
import {
|
||||||
getAIPanelDiagnosticHiddenParts,
|
getAIPanelDiagnosticHiddenParts,
|
||||||
getAIPanelProfilerProps,
|
getAIPanelProfilerProps,
|
||||||
@@ -113,8 +113,9 @@ export function shouldProvideVaultArtifactNavigation({
|
|||||||
|
|
||||||
export function shouldRenderAssistantAsPlainText(options: {
|
export function shouldRenderAssistantAsPlainText(options: {
|
||||||
hideMarkdown: boolean;
|
hideMarkdown: boolean;
|
||||||
|
isStreaming?: boolean;
|
||||||
}): 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]';
|
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>
|
? <div className={ASSISTANT_PLAIN_TEXT_CLASS}>{message.content}</div>
|
||||||
: shouldRenderAssistantAsPlainText({
|
: shouldRenderAssistantAsPlainText({
|
||||||
hideMarkdown,
|
hideMarkdown,
|
||||||
|
isStreaming: !!isThisStreaming,
|
||||||
})
|
})
|
||||||
? (
|
? (
|
||||||
<div
|
<div
|
||||||
@@ -714,7 +716,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
|||||||
: (
|
: (
|
||||||
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Markdown')}>
|
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Markdown')}>
|
||||||
<div data-ai-content="markdown">
|
<div data-ai-content="markdown">
|
||||||
<LazyMessageResponse isAnimating={!!isThisStreaming}>
|
<LazyMessageResponse isAnimating={false}>
|
||||||
{message.content}
|
{message.content}
|
||||||
</LazyMessageResponse>
|
</LazyMessageResponse>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,14 +2,19 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
const storage = new Map<string, string>();
|
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', {
|
Object.defineProperty(globalThis, 'window', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: {
|
value: {
|
||||||
localStorage: {
|
localStorage: localStorageStub,
|
||||||
getItem: (key: string) => storage.get(key) ?? null,
|
|
||||||
setItem: (key: string, value: string) => storage.set(key, value),
|
|
||||||
removeItem: (key: string) => storage.delete(key),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,81 +1,13 @@
|
|||||||
import type React from 'react';
|
export {
|
||||||
|
AI_PANEL_DIAGNOSTIC_HIDE_KEY,
|
||||||
export const AI_PANEL_DIAGNOSTIC_HIDE_KEY = 'netcatty.aiDebug.hide';
|
AI_PANEL_DIAGNOSTIC_PROFILE_KEY,
|
||||||
export const AI_PANEL_DIAGNOSTIC_PROFILE_KEY = 'netcatty.aiDebug.profile';
|
AI_PANEL_FORCE_HIDE_ALL_CONTENT,
|
||||||
export const AI_PANEL_FORCE_HIDE_ALL_CONTENT = false;
|
AI_PANEL_FORCE_HIDE_SHELL,
|
||||||
export const AI_PANEL_FORCE_HIDE_SHELL = false;
|
getAIPanelDiagnosticHiddenParts,
|
||||||
|
getAIPanelProfilerProps,
|
||||||
export type AIPanelDiagnosticPart =
|
isAIPanelDiagnosticPartHidden,
|
||||||
| 'all'
|
isAIPanelDiagnosticsProfilingEnabled,
|
||||||
| 'attachments'
|
logAIPanelProfiler,
|
||||||
| 'header'
|
profileAIPanelCalculation,
|
||||||
| 'history'
|
type AIPanelDiagnosticPart,
|
||||||
| 'input'
|
} from '../../application/state/aiPanelDiagnostics';
|
||||||
| '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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,288 +1,2 @@
|
|||||||
import type { NetcattyBridge } from '../../../infrastructure/ai/cattyAgent/executor';
|
/** @deprecated Import from `@/infrastructure/ai/aiChatStreamingSupport` instead. */
|
||||||
import type {
|
export * from '../../../infrastructure/ai/aiChatStreamingSupport';
|
||||||
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);
|
|
||||||
}
|
|
||||||
@@ -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
|
// Keep the singleton current during render so AppView close handlers and
|
||||||
// called from outside the React tree (e.g. useSftpViewPaneActions).
|
// hotkeys never race the post-commit useEffect registration window.
|
||||||
useEffect(() => {
|
promptSingleton = prompt;
|
||||||
promptSingleton = prompt;
|
useEffect(() => () => {
|
||||||
return () => { promptSingleton = null; };
|
promptSingleton = null;
|
||||||
}, [prompt]);
|
}, []);
|
||||||
|
|
||||||
// On unmount, resolve any in-flight prompt as "cancel" so awaiting callers don't leak.
|
// On unmount, resolve any in-flight prompt as "cancel" so awaiting callers don't leak.
|
||||||
useEffect(() => () => {
|
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/);
|
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", () => {
|
test("NotesManager tree rename allows clearing note titles", () => {
|
||||||
const source = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
|
const source = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
|
||||||
const renameBlock = source.match(/onRenameCommit=\{\(name\) => \{[\s\S]*?\}\}/)?.[0] ?? "";
|
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.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", () => {
|
test("NotesManager sidebar mode renders list without editor by default", () => {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
Upload,
|
Upload,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} 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 { useI18n } from "../../application/i18n/I18nProvider";
|
||||||
import { useApplicationBackend } from "../../application/state/useApplicationBackend";
|
import { useApplicationBackend } from "../../application/state/useApplicationBackend";
|
||||||
import { useStoredNumber } from "../../application/state/useStoredNumber";
|
import { useStoredNumber } from "../../application/state/useStoredNumber";
|
||||||
@@ -121,6 +121,8 @@ export interface NotesManagerProps {
|
|||||||
onUpdateNoteGroups: (groups: string[]) => void;
|
onUpdateNoteGroups: (groups: string[]) => void;
|
||||||
onOpenHost?: (host: Host, source?: { noteId: string }) => void;
|
onOpenHost?: (host: Host, source?: { noteId: string }) => void;
|
||||||
displayMode?: "full" | "sidebar";
|
displayMode?: "full" | "sidebar";
|
||||||
|
/** When false (hidden retained mount), flush any pending draft immediately. */
|
||||||
|
isActive?: boolean;
|
||||||
openNoteId?: string | null;
|
openNoteId?: string | null;
|
||||||
openNoteRequestId?: number | null;
|
openNoteRequestId?: number | null;
|
||||||
/** Called after a one-shot openNoteId focus request has been applied. */
|
/** Called after a one-shot openNoteId focus request has been applied. */
|
||||||
@@ -340,6 +342,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
|||||||
onUpdateNoteGroups,
|
onUpdateNoteGroups,
|
||||||
onOpenHost,
|
onOpenHost,
|
||||||
displayMode = "full",
|
displayMode = "full",
|
||||||
|
isActive = true,
|
||||||
openNoteId = null,
|
openNoteId = null,
|
||||||
openNoteRequestId = null,
|
openNoteRequestId = null,
|
||||||
onOpenNoteIdHandled,
|
onOpenNoteIdHandled,
|
||||||
@@ -401,6 +404,97 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
|||||||
return cleaned;
|
return cleaned;
|
||||||
}, [onUpdateNotes]);
|
}, [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 noteTree = useMemo(() => {
|
||||||
const tree = buildNoteTree(groups, sortedNotes);
|
const tree = buildNoteTree(groups, sortedNotes);
|
||||||
return {
|
return {
|
||||||
@@ -410,6 +504,26 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
|||||||
}, [groupOrderByPath, groups, sortedNotes]);
|
}, [groupOrderByPath, groups, sortedNotes]);
|
||||||
const selectedNote = getSelectedVaultNote(sortedNotes, selectedNoteId);
|
const selectedNote = getSelectedVaultNote(sortedNotes, selectedNoteId);
|
||||||
const overlayNote = sortedNotes.find((note) => note.id === overlayNoteId) ?? null;
|
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(() => {
|
useEffect(() => {
|
||||||
const urls = activeDownloadUrlsRef.current;
|
const urls = activeDownloadUrlsRef.current;
|
||||||
@@ -496,8 +610,28 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
|||||||
const expandAllGroups = () => setExpandedGroups(new Set(allGroupPaths));
|
const expandAllGroups = () => setExpandedGroups(new Set(allGroupPaths));
|
||||||
const collapseAllGroups = () => setExpandedGroups(new Set());
|
const collapseAllGroups = () => setExpandedGroups(new Set());
|
||||||
|
|
||||||
const saveNote = (nextNote: VaultNote) => {
|
/** Flush pending draft, then mutate from the post-flush ref snapshot. */
|
||||||
commitNotes(sortedNotes.map((note) => (note.id === nextNote.id ? nextNote : note)));
|
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) => {
|
const handleOpenHostFromNote = useCallback((host: Host, noteId: string) => {
|
||||||
@@ -531,10 +665,14 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const addNoteToGroup = (group: string | null) => {
|
const addNoteToGroup = (group: string | null) => {
|
||||||
const note = createNote(group, getNextVaultOrder(sortedNotes));
|
let created: VaultNote | null = null;
|
||||||
commitNotes([...sortedNotes, note]);
|
commitNotesAfterFlush((notes) => {
|
||||||
|
created = createNote(group, getNextVaultOrder(notes));
|
||||||
|
return [...notes, created];
|
||||||
|
});
|
||||||
|
if (!created) return;
|
||||||
if (group) expandPath(group);
|
if (group) expandPath(group);
|
||||||
const nextSelection = getNoteSelectionState(note, isSidebarMode);
|
const nextSelection = getNoteSelectionState(created, isSidebarMode);
|
||||||
setSelectedNoteId(nextSelection.selectedNoteId);
|
setSelectedNoteId(nextSelection.selectedNoteId);
|
||||||
setSelectedGroup(nextSelection.selectedGroup);
|
setSelectedGroup(nextSelection.selectedGroup);
|
||||||
setOverlayNoteId(nextSelection.overlayNoteId);
|
setOverlayNoteId(nextSelection.overlayNoteId);
|
||||||
@@ -591,7 +729,10 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
|||||||
|
|
||||||
const result = importMarkdownPayloadsToVaultNotes(
|
const result = importMarkdownPayloadsToVaultNotes(
|
||||||
payloads,
|
payloads,
|
||||||
sortedNotesRef.current,
|
(() => {
|
||||||
|
flushNoteDraft();
|
||||||
|
return sortedNotesRef.current;
|
||||||
|
})(),
|
||||||
targetGroup,
|
targetGroup,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -623,6 +764,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
|||||||
}, [
|
}, [
|
||||||
isSidebarMode,
|
isSidebarMode,
|
||||||
commitNotes,
|
commitNotes,
|
||||||
|
flushNoteDraft,
|
||||||
selectedGroup,
|
selectedGroup,
|
||||||
selectedNote,
|
selectedNote,
|
||||||
t,
|
t,
|
||||||
@@ -645,10 +787,12 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const exportNoteToMarkdown = useCallback((note: VaultNote) => {
|
const exportNoteToMarkdown = useCallback((note: VaultNote) => {
|
||||||
|
flushNoteDraft();
|
||||||
|
const latest = sortedNotesRef.current.find((item) => item.id === note.id) ?? note;
|
||||||
try {
|
try {
|
||||||
const fileName = `${sanitizeNoteExportFileNamePart(note.title, "note")}.md`;
|
const fileName = `${sanitizeNoteExportFileNamePart(latest.title, "note")}.md`;
|
||||||
downloadNotesBlob(
|
downloadNotesBlob(
|
||||||
new Blob([note.content], { type: "text/markdown;charset=utf-8" }),
|
new Blob([latest.content], { type: "text/markdown;charset=utf-8" }),
|
||||||
fileName,
|
fileName,
|
||||||
);
|
);
|
||||||
toast.success(t("notes.export.toast.success", { count: 1 }));
|
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);
|
logger.error("Failed to export note:", err);
|
||||||
toast.error(t("notes.export.toast.failed"));
|
toast.error(t("notes.export.toast.failed"));
|
||||||
}
|
}
|
||||||
}, [downloadNotesBlob, t]);
|
}, [downloadNotesBlob, flushNoteDraft, t]);
|
||||||
|
|
||||||
const exportNotesToZip = useCallback((scope: VaultNotesExportScope, fileNamePart: string) => {
|
const exportNotesToZip = useCallback((scope: VaultNotesExportScope, fileNamePart: string) => {
|
||||||
|
flushNoteDraft();
|
||||||
try {
|
try {
|
||||||
const files = buildVaultNoteMarkdownExportFiles(sortedNotesRef.current, scope);
|
const files = buildVaultNoteMarkdownExportFiles(sortedNotesRef.current, scope);
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
@@ -674,7 +819,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
|||||||
logger.error("Failed to export notes:", err);
|
logger.error("Failed to export notes:", err);
|
||||||
toast.error(t("notes.export.toast.failed"));
|
toast.error(t("notes.export.toast.failed"));
|
||||||
}
|
}
|
||||||
}, [downloadNotesBlob, t]);
|
}, [downloadNotesBlob, flushNoteDraft, t]);
|
||||||
|
|
||||||
const exportAllNotes = useCallback(() => {
|
const exportAllNotes = useCallback(() => {
|
||||||
exportNotesToZip({ type: "all" }, "all");
|
exportNotesToZip({ type: "all" }, "all");
|
||||||
@@ -703,7 +848,8 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const duplicateNoteById = (noteId: string) => {
|
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;
|
if (!source) return;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const copy: VaultNote = {
|
const copy: VaultNote = {
|
||||||
@@ -712,9 +858,9 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
|||||||
title: `${source.title} (${t("action.copy")})`,
|
title: `${source.title} (${t("action.copy")})`,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
order: getNextVaultOrder(sortedNotes),
|
order: getNextVaultOrder(sortedNotesRef.current),
|
||||||
};
|
};
|
||||||
commitNotes([...sortedNotes, copy]);
|
commitNotes([...sortedNotesRef.current, copy]);
|
||||||
if (copy.group) expandPath(copy.group);
|
if (copy.group) expandPath(copy.group);
|
||||||
const nextSelection = getNoteSelectionState(copy, isSidebarMode);
|
const nextSelection = getNoteSelectionState(copy, isSidebarMode);
|
||||||
setSelectedNoteId(nextSelection.selectedNoteId);
|
setSelectedNoteId(nextSelection.selectedNoteId);
|
||||||
@@ -723,8 +869,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const performDeleteNoteById = (noteId: string) => {
|
const performDeleteNoteById = (noteId: string) => {
|
||||||
const next = sortedNotes.filter((note) => note.id !== noteId);
|
const next = commitNotesAfterFlush((notes) => notes.filter((note) => note.id !== noteId));
|
||||||
commitNotes(next);
|
|
||||||
if (selectedNoteId === noteId) {
|
if (selectedNoteId === noteId) {
|
||||||
const nextSelection = getFallbackNoteSelectionState(next, isSidebarMode);
|
const nextSelection = getFallbackNoteSelectionState(next, isSidebarMode);
|
||||||
setSelectedNoteId(nextSelection.selectedNoteId);
|
setSelectedNoteId(nextSelection.selectedNoteId);
|
||||||
@@ -772,12 +917,11 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
|||||||
const nextGroups = normalizeNoteGroups(
|
const nextGroups = normalizeNoteGroups(
|
||||||
groups.map((item) => replaceNoteGroupPrefix(item, group, nextPath) || ""),
|
groups.map((item) => replaceNoteGroupPrefix(item, group, nextPath) || ""),
|
||||||
);
|
);
|
||||||
const nextNotes = sortedNotes.map((note) => ({
|
onUpdateNoteGroups(nextGroups);
|
||||||
|
commitNotesAfterFlush((notes) => notes.map((note) => ({
|
||||||
...note,
|
...note,
|
||||||
group: replaceNoteGroupPrefix(note.group, group, nextPath),
|
group: replaceNoteGroupPrefix(note.group, group, nextPath),
|
||||||
}));
|
})));
|
||||||
onUpdateNoteGroups(nextGroups);
|
|
||||||
commitNotes(nextNotes);
|
|
||||||
setExpandedGroups((current) => {
|
setExpandedGroups((current) => {
|
||||||
const next = new Set<string>();
|
const next = new Set<string>();
|
||||||
current.forEach((item) => {
|
current.forEach((item) => {
|
||||||
@@ -794,7 +938,9 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
|||||||
|
|
||||||
const performDeleteGroup = (group: string) => {
|
const performDeleteGroup = (group: string) => {
|
||||||
onUpdateNoteGroups(groups.filter((item) => !isNoteGroupInside(item, group)));
|
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);
|
if (selectedGroup && isNoteGroupInside(selectedGroup, group)) setSelectedGroup(null);
|
||||||
setEditingGroupPath(null);
|
setEditingGroupPath(null);
|
||||||
};
|
};
|
||||||
@@ -844,12 +990,13 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const moveNoteToGroup = (noteId: string, group: string | null) => {
|
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;
|
if (!source) return;
|
||||||
const nextGroup = group || undefined;
|
const nextGroup = group || undefined;
|
||||||
if ((source.group || undefined) === nextGroup) return;
|
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
|
note.id === noteId ? { ...note, group: nextGroup, updatedAt: Date.now() } : note
|
||||||
)));
|
)));
|
||||||
if (group) expandPath(group);
|
if (group) expandPath(group);
|
||||||
@@ -858,29 +1005,31 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
|||||||
const reorderNoteToNote = (sourceId: string, targetNote: VaultNote, event: React.DragEvent<HTMLElement>) => {
|
const reorderNoteToNote = (sourceId: string, targetNote: VaultNote, event: React.DragEvent<HTMLElement>) => {
|
||||||
if (!sourceId || sourceId === targetNote.id) return;
|
if (!sourceId || sourceId === targetNote.id) return;
|
||||||
const position = getVaultDropPosition(event.currentTarget, event.clientX, event.clientY);
|
const position = getVaultDropPosition(event.currentTarget, event.clientX, event.clientY);
|
||||||
const movedNotes = sortedNotes.map((note) => (
|
commitNotesAfterFlush((notes) => {
|
||||||
note.id === sourceId
|
const movedNotes = notes.map((note) => (
|
||||||
? { ...note, group: targetNote.group, updatedAt: Date.now() }
|
note.id === sourceId
|
||||||
: note
|
? { ...note, group: targetNote.group, updatedAt: Date.now() }
|
||||||
));
|
: note
|
||||||
commitNotes(reorderVaultItems(movedNotes, sourceId, targetNote.id, position));
|
));
|
||||||
|
return reorderVaultItems(movedNotes, sourceId, targetNote.id, position);
|
||||||
|
});
|
||||||
if (targetNote.group) expandPath(targetNote.group);
|
if (targetNote.group) expandPath(targetNote.group);
|
||||||
};
|
};
|
||||||
|
|
||||||
const moveGroupToParent = (group: string, parent: string | null) => {
|
const moveGroupToParent = (group: string, parent: string | null) => {
|
||||||
|
flushNoteDraft();
|
||||||
const knownGroups = normalizeNoteGroups([
|
const knownGroups = normalizeNoteGroups([
|
||||||
...groups,
|
...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);
|
const nextPath = resolveMovedNoteGroupPath(group, parent, knownGroups);
|
||||||
if (!nextPath) return;
|
if (!nextPath) return;
|
||||||
const nextGroups = normalizeNoteGroups(groups.map((item) => replaceNoteGroupPrefix(item, group, nextPath) || ""));
|
const nextGroups = normalizeNoteGroups(groups.map((item) => replaceNoteGroupPrefix(item, group, nextPath) || ""));
|
||||||
const nextNotes = sortedNotes.map((note) => ({
|
onUpdateNoteGroups(nextGroups);
|
||||||
|
commitNotes(sortedNotesRef.current.map((note) => ({
|
||||||
...note,
|
...note,
|
||||||
group: replaceNoteGroupPrefix(note.group, group, nextPath),
|
group: replaceNoteGroupPrefix(note.group, group, nextPath),
|
||||||
}));
|
})));
|
||||||
onUpdateNoteGroups(nextGroups);
|
|
||||||
commitNotes(nextNotes);
|
|
||||||
setExpandedGroups((current) => remapExpandedNoteGroupPaths(current, group, nextPath));
|
setExpandedGroups((current) => remapExpandedNoteGroupPaths(current, group, nextPath));
|
||||||
if (selectedGroup && isNoteGroupInside(selectedGroup, group)) {
|
if (selectedGroup && isNoteGroupInside(selectedGroup, group)) {
|
||||||
setSelectedGroup(replaceNoteGroupPrefix(selectedGroup, group, nextPath) ?? null);
|
setSelectedGroup(replaceNoteGroupPrefix(selectedGroup, group, nextPath) ?? null);
|
||||||
@@ -895,10 +1044,11 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
|||||||
if (!sourceGroup || !targetGroup || sourceGroup === targetGroup) return;
|
if (!sourceGroup || !targetGroup || sourceGroup === targetGroup) return;
|
||||||
if (targetGroup.startsWith(`${sourceGroup}/`)) return;
|
if (targetGroup.startsWith(`${sourceGroup}/`)) return;
|
||||||
|
|
||||||
|
flushNoteDraft();
|
||||||
const targetParent = getNoteGroupParentPath(targetGroup);
|
const targetParent = getNoteGroupParentPath(targetGroup);
|
||||||
const knownGroups = normalizeNoteGroups([
|
const knownGroups = normalizeNoteGroups([
|
||||||
...groups,
|
...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
|
const nextSourceGroup = getNoteGroupParentPath(sourceGroup) === targetParent
|
||||||
? cleanNoteGroupPath(sourceGroup)
|
? cleanNoteGroupPath(sourceGroup)
|
||||||
@@ -916,16 +1066,13 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
|||||||
targetGroup,
|
targetGroup,
|
||||||
position,
|
position,
|
||||||
);
|
);
|
||||||
const nextNotes = nextSourceGroup === sourceGroup
|
|
||||||
? sortedNotes
|
|
||||||
: sortedNotes.map((note) => ({
|
|
||||||
...note,
|
|
||||||
group: replaceNoteGroupPrefix(note.group, sourceGroup, nextSourceGroup),
|
|
||||||
}));
|
|
||||||
|
|
||||||
onUpdateNoteGroups(nextGroups);
|
onUpdateNoteGroups(nextGroups);
|
||||||
if (nextSourceGroup !== sourceGroup) {
|
if (nextSourceGroup !== sourceGroup) {
|
||||||
commitNotes(nextNotes);
|
commitNotes(sortedNotesRef.current.map((note) => ({
|
||||||
|
...note,
|
||||||
|
group: replaceNoteGroupPrefix(note.group, sourceGroup, nextSourceGroup),
|
||||||
|
})));
|
||||||
setExpandedGroups((current) => remapExpandedNoteGroupPaths(current, sourceGroup, nextSourceGroup));
|
setExpandedGroups((current) => remapExpandedNoteGroupPaths(current, sourceGroup, nextSourceGroup));
|
||||||
if (selectedGroup && isNoteGroupInside(selectedGroup, sourceGroup)) {
|
if (selectedGroup && isNoteGroupInside(selectedGroup, sourceGroup)) {
|
||||||
setSelectedGroup(replaceNoteGroupPrefix(selectedGroup, sourceGroup, nextSourceGroup) ?? null);
|
setSelectedGroup(replaceNoteGroupPrefix(selectedGroup, sourceGroup, nextSourceGroup) ?? null);
|
||||||
@@ -1092,7 +1239,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
|||||||
editingInitialName={note.title}
|
editingInitialName={note.title}
|
||||||
onRenameCommit={(name) => {
|
onRenameCommit={(name) => {
|
||||||
setEditingNoteId(null);
|
setEditingNoteId(null);
|
||||||
saveNote({ ...note, title: name.trim(), updatedAt: Date.now() });
|
renameNoteFromTree(note.id, name);
|
||||||
}}
|
}}
|
||||||
onRenameCancel={() => setEditingNoteId(null)}
|
onRenameCancel={() => setEditingNoteId(null)}
|
||||||
icon={<FileText size={16} className="mr-2 shrink-0 text-muted-foreground" />}
|
icon={<FileText size={16} className="mr-2 shrink-0 text-muted-foreground" />}
|
||||||
@@ -1515,41 +1662,41 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
|||||||
|
|
||||||
{!isSidebarMode && (
|
{!isSidebarMode && (
|
||||||
<main className="flex min-w-0 flex-1 flex-col bg-background">
|
<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="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">
|
<div className="min-w-0 flex-1">
|
||||||
<input
|
<input
|
||||||
className="h-8 w-full bg-transparent text-lg font-semibold leading-8 outline-none placeholder:text-muted-foreground"
|
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")}
|
placeholder={t("notes.title.placeholder")}
|
||||||
onChange={(event) => saveNote({
|
onChange={(event) => saveNoteTitleDraft(selectedNoteView, event.target.value)}
|
||||||
...selectedNote,
|
onBlur={() => flushNoteDraft()}
|
||||||
title: event.target.value,
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
})}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{renderNoteExportButton(selectedNote)}
|
{renderNoteExportButton(selectedNoteView)}
|
||||||
{renderNoteModeToggle()}
|
{renderNoteModeToggle()}
|
||||||
</div>
|
</div>
|
||||||
<ScrollArea className="min-h-0 flex-1">
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
<div className="min-h-full w-full px-8 pt-2 pb-6">
|
<div
|
||||||
<LazyLoadBoundary name="Notes editor" resetKey={selectedNote.id}>
|
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 />}>
|
<Suspense fallback={<InlineMarkdownEditorFallback />}>
|
||||||
<InlineMarkdownEditor
|
<InlineMarkdownEditor
|
||||||
key={selectedNote.id}
|
key={selectedNoteView.id}
|
||||||
value={selectedNote.content}
|
value={selectedNoteView.content}
|
||||||
placeholder={t("notes.editor.placeholder")}
|
placeholder={t("notes.editor.placeholder")}
|
||||||
editorMode={noteEditorMode}
|
editorMode={noteEditorMode}
|
||||||
previewEmptyLabel={t("notes.preview.empty")}
|
previewEmptyLabel={t("notes.preview.empty")}
|
||||||
onChange={(content) => saveNote({
|
onChange={(content) => saveNoteContentDraft(selectedNoteView, content)}
|
||||||
...selectedNote,
|
|
||||||
content,
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
})}
|
|
||||||
hosts={hosts}
|
hosts={hosts}
|
||||||
onOpenHost={(host) => handleOpenHostFromNote(host, selectedNote.id)}
|
onOpenHost={(host) => handleOpenHostFromNote(host, selectedNoteView.id)}
|
||||||
onOpenExternalLink={openExternal}
|
onOpenExternalLink={openExternal}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
@@ -1582,7 +1729,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isSidebarMode && overlayNote && (
|
{isSidebarMode && overlayNoteView && (
|
||||||
<div className="absolute inset-0 z-30 flex min-h-0 flex-col bg-background text-foreground">
|
<div className="absolute inset-0 z-30 flex min-h-0 flex-col bg-background text-foreground">
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS,
|
TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS,
|
||||||
@@ -1602,7 +1749,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
|||||||
<TooltipContent side="bottom">{t("common.back")}</TooltipContent>
|
<TooltipContent side="bottom">{t("common.back")}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<div className="min-w-0 flex-1 truncate px-1 text-xs font-medium leading-4 text-foreground">
|
<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>
|
</div>
|
||||||
<div className="flex min-h-0 flex-1 flex-col bg-background">
|
<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">
|
<div className="min-w-0 flex-1">
|
||||||
<input
|
<input
|
||||||
className="h-8 w-full bg-transparent text-lg font-semibold leading-8 outline-none placeholder:text-muted-foreground"
|
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")}
|
placeholder={t("notes.title.placeholder")}
|
||||||
onChange={(event) => saveNote({
|
onChange={(event) => saveNoteTitleDraft(overlayNoteView, event.target.value)}
|
||||||
...overlayNote,
|
onBlur={() => flushNoteDraft()}
|
||||||
title: event.target.value,
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
})}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{renderNoteExportButton(overlayNote)}
|
{renderNoteExportButton(overlayNoteView)}
|
||||||
{renderNoteModeToggle()}
|
{renderNoteModeToggle()}
|
||||||
</div>
|
</div>
|
||||||
<ScrollArea className="min-h-0 flex-1">
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
<div className="min-h-full w-full px-4 pt-2 pb-6">
|
<div
|
||||||
<LazyLoadBoundary name="Notes editor" resetKey={overlayNote.id}>
|
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 />}>
|
<Suspense fallback={<InlineMarkdownEditorFallback />}>
|
||||||
<InlineMarkdownEditor
|
<InlineMarkdownEditor
|
||||||
key={overlayNote.id}
|
key={overlayNoteView.id}
|
||||||
value={overlayNote.content}
|
value={overlayNoteView.content}
|
||||||
placeholder={t("notes.editor.placeholder")}
|
placeholder={t("notes.editor.placeholder")}
|
||||||
editorMode={noteEditorMode}
|
editorMode={noteEditorMode}
|
||||||
onChange={(content) => saveNote({
|
onChange={(content) => saveNoteContentDraft(overlayNoteView, content)}
|
||||||
...overlayNote,
|
|
||||||
content,
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
})}
|
|
||||||
previewEmptyLabel={t("notes.preview.empty")}
|
previewEmptyLabel={t("notes.preview.empty")}
|
||||||
hosts={hosts}
|
hosts={hosts}
|
||||||
onOpenHost={(host) => handleOpenHostFromNote(host, overlayNote.id)}
|
onOpenHost={(host) => handleOpenHostFromNote(host, overlayNoteView.id)}
|
||||||
onOpenExternalLink={openExternal}
|
onOpenExternalLink={openExternal}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -1,56 +1,10 @@
|
|||||||
import type { KnownHost } from "../../domain/models";
|
/** @deprecated Import from `@/domain/portForwardHostKey` instead. */
|
||||||
import { toHostKeyInfo, type HostKeyVerificationRequest } from "../terminal/hostKeyVerification";
|
export {
|
||||||
import type { HostKeyInfo } from "../terminal/TerminalHostKeyVerification";
|
createKnownHostFromPortForwardHostKeyInfo,
|
||||||
|
enqueuePortForwardHostKeyVerification,
|
||||||
export const isPortForwardHostKeySessionId = (sessionId?: string): boolean => {
|
isPortForwardHostKeySessionId,
|
||||||
return typeof sessionId === "string" && sessionId.startsWith("pf-");
|
removePortForwardHostKeyVerification,
|
||||||
};
|
toPendingPortForwardHostKeyVerification,
|
||||||
|
type PendingPortForwardHostKeyVerification,
|
||||||
export type PortForwardHostKeyRequest = HostKeyVerificationRequest & {
|
type PortForwardHostKeyRequest,
|
||||||
requestId: string;
|
} from "../../domain/portForwardHostKey";
|
||||||
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,
|
|
||||||
});
|
|
||||||
@@ -2,26 +2,19 @@ import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|||||||
import { Check, Copy, HelpCircle, RefreshCw } from "lucide-react";
|
import { Check, Copy, HelpCircle, RefreshCw } from "lucide-react";
|
||||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||||
import {
|
import {
|
||||||
normalizeExternalMcpIdleTimeoutMinutes,
|
|
||||||
normalizeExternalMcpMode,
|
|
||||||
normalizeSessionIdleTimeoutMinutes,
|
|
||||||
readExternalMcpFocusOnHostOpen,
|
readExternalMcpFocusOnHostOpen,
|
||||||
readExternalMcpIdleTimeoutMinutes,
|
readExternalMcpIdleTimeoutMinutes,
|
||||||
readExternalMcpMode,
|
readExternalMcpMode,
|
||||||
readExternalMcpSilentSessions,
|
readExternalMcpSilentSessions,
|
||||||
readSessionIdleTimeoutMinutes,
|
readSessionIdleTimeoutMinutes,
|
||||||
writeExternalMcpFocusOnHostOpen,
|
writeExternalMcpFocusOnHostOpen,
|
||||||
|
writeExternalMcpIdleTimeoutMinutes,
|
||||||
|
writeExternalMcpMode,
|
||||||
writeExternalMcpSilentSessions,
|
writeExternalMcpSilentSessions,
|
||||||
|
writeSessionIdleTimeoutMinutes,
|
||||||
type ExternalMcpMode,
|
type ExternalMcpMode,
|
||||||
useExternalMcpToggleState,
|
useExternalMcpToggleState,
|
||||||
} from "../../../../application/state/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 { cn } from "../../../../lib/utils";
|
||||||
import { Button } from "../../../ui/button";
|
import { Button } from "../../../ui/button";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip";
|
||||||
@@ -328,26 +321,20 @@ export const ExternalMcpCard: React.FC = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setMode = useCallback((nextMode: ExternalMcpMode) => {
|
const setMode = useCallback((nextMode: ExternalMcpMode) => {
|
||||||
const normalized = normalizeExternalMcpMode(nextMode);
|
const normalized = writeExternalMcpMode(nextMode);
|
||||||
setModeRaw(normalized);
|
setModeRaw(normalized);
|
||||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, normalized);
|
|
||||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_MODE);
|
|
||||||
pushConfig(normalized, idleTimeoutMinutes, sessionIdleTimeoutMinutes);
|
pushConfig(normalized, idleTimeoutMinutes, sessionIdleTimeoutMinutes);
|
||||||
}, [idleTimeoutMinutes, pushConfig, sessionIdleTimeoutMinutes]);
|
}, [idleTimeoutMinutes, pushConfig, sessionIdleTimeoutMinutes]);
|
||||||
|
|
||||||
const setIdleTimeoutMinutes = useCallback((minutes: number) => {
|
const setIdleTimeoutMinutes = useCallback((minutes: number) => {
|
||||||
const normalized = normalizeExternalMcpIdleTimeoutMinutes(minutes);
|
const normalized = writeExternalMcpIdleTimeoutMinutes(minutes);
|
||||||
setIdleTimeoutRaw(normalized);
|
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);
|
pushConfig(mode, normalized, sessionIdleTimeoutMinutes);
|
||||||
}, [mode, pushConfig, sessionIdleTimeoutMinutes]);
|
}, [mode, pushConfig, sessionIdleTimeoutMinutes]);
|
||||||
|
|
||||||
const setSessionIdleTimeoutMinutes = useCallback((minutes: number) => {
|
const setSessionIdleTimeoutMinutes = useCallback((minutes: number) => {
|
||||||
const normalized = normalizeSessionIdleTimeoutMinutes(minutes);
|
const normalized = writeSessionIdleTimeoutMinutes(minutes);
|
||||||
setSessionIdleTimeoutRaw(normalized);
|
setSessionIdleTimeoutRaw(normalized);
|
||||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES, normalized);
|
|
||||||
emitAIStateChanged(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES);
|
|
||||||
pushConfig(mode, idleTimeoutMinutes, normalized);
|
pushConfig(mode, idleTimeoutMinutes, normalized);
|
||||||
}, [idleTimeoutMinutes, mode, pushConfig]);
|
}, [idleTimeoutMinutes, mode, pushConfig]);
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import type { SftpFileEntry } from "../../types";
|
|||||||
import type { SftpPane } from "../../application/state/sftp/types";
|
import type { SftpPane } from "../../application/state/sftp/types";
|
||||||
import type { SftpTransferSource } from "./SftpContext";
|
import type { SftpTransferSource } from "./SftpContext";
|
||||||
import { sftpListOrderStore } from "./hooks/useSftpListOrderStore";
|
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 { buildSftpColumnTemplate, isNavigableDirectory, isSftpColumnMenuKey } from "./utils";
|
||||||
import { isKnownBinaryFile } from "../../lib/sftpFileUtils";
|
import { isKnownBinaryFile } from "../../lib/sftpFileUtils";
|
||||||
import { SftpFileRow } from "./SftpFileRow";
|
import { SftpFileRow } from "./SftpFileRow";
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { getParentPath, isWindowsRoot, joinPath, resolveSftpWindowsPathOptions }
|
|||||||
import { buildSftpColumnTemplate, filterHiddenFiles, isNavigableDirectory, isSftpColumnMenuKey, sortSftpEntries } from './utils';
|
import { buildSftpColumnTemplate, filterHiddenFiles, isNavigableDirectory, isSftpColumnMenuKey, sortSftpEntries } from './utils';
|
||||||
import type { SftpTransferSource } from './SftpContext';
|
import type { SftpTransferSource } from './SftpContext';
|
||||||
import type { SftpPaneTreeViewProps } from './SftpPaneTreeView.types';
|
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 { sftpKeyboardSelectionStore, sftpTreeEnterStore } from './hooks/useSftpKeyboardShortcuts';
|
||||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||||
import { SftpColumnMenuItems } from './SftpColumnMenuItems';
|
import { SftpColumnMenuItems } from './SftpColumnMenuItems';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { SftpFileEntry } from '../../types';
|
import type { SftpFileEntry } from '../../types';
|
||||||
import type { SftpPane } from '../../application/state/sftp/types';
|
import type { SftpPane } from '../../application/state/sftp/types';
|
||||||
import type { SftpTransferSource } from './SftpContext';
|
import type { SftpTransferSource } from './SftpContext';
|
||||||
import type { UseSftpPaneSortingResult } from './hooks/useSftpPaneSorting';
|
import type { UseSftpPaneSortingResult } from '../../application/state/sftp/useSftpPaneSorting';
|
||||||
|
|
||||||
export interface SftpPaneTreeViewProps {
|
export interface SftpPaneTreeViewProps {
|
||||||
pane: SftpPane;
|
pane: SftpPane;
|
||||||
|
|||||||
@@ -25,16 +25,16 @@ import { useSftpPaneDialogs } from "./hooks/useSftpPaneDialogs";
|
|||||||
import { useSftpPaneDragAndSelect } from "./hooks/useSftpPaneDragAndSelect";
|
import { useSftpPaneDragAndSelect } from "./hooks/useSftpPaneDragAndSelect";
|
||||||
import { useSftpPaneFiles } from "./hooks/useSftpPaneFiles";
|
import { useSftpPaneFiles } from "./hooks/useSftpPaneFiles";
|
||||||
import { useSftpPanePath } from "./hooks/useSftpPanePath";
|
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 { useSftpPaneVirtualList } from "./hooks/useSftpPaneVirtualList";
|
||||||
import { sftpPaneViewModeStore } from "../../application/state/sftp/sftpPaneViewModeStore";
|
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 { useSftpBookmarks } from "./hooks/useSftpBookmarks";
|
||||||
import { useLocalSftpBookmarks } from "./hooks/useLocalSftpBookmarks";
|
import { useLocalSftpBookmarks } from "../../application/state/sftp/localSftpBookmarks";
|
||||||
import { useGlobalSftpBookmarks } from "./hooks/useGlobalSftpBookmarks";
|
import { useGlobalSftpBookmarks } from "./hooks/useGlobalSftpBookmarks";
|
||||||
import { useSftpHostViewMode } from "./hooks/useSftpHostViewMode";
|
import { useSftpHostViewMode } from "../../application/state/sftp/sftpHostViewModeStore";
|
||||||
import { sftpListOrderStore } from "./hooks/useSftpListOrderStore";
|
import { sftpListOrderStore } from "./hooks/useSftpListOrderStore";
|
||||||
import { sftpTreeSelectionStore } from "./hooks/useSftpTreeSelectionStore";
|
import { sftpTreeSelectionStore } from "../../application/state/sftp/sftpTreeSelectionStore";
|
||||||
import { sftpClipboardUploadStore } from "./clipboardUpload";
|
import { sftpClipboardUploadStore } from "./clipboardUpload";
|
||||||
|
|
||||||
interface TreeReloadRequest {
|
interface TreeReloadRequest {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { SftpStateApi } from "../../../application/state/useSftpState";
|
import type { SftpStateApi } from "../../../application/state/useSftpState";
|
||||||
import { sftpTreeSelectionStore } from "./useSftpTreeSelectionStore";
|
import { sftpTreeSelectionStore } from "../../../application/state/sftp/sftpTreeSelectionStore";
|
||||||
|
|
||||||
export interface SftpSelectionTarget {
|
export interface SftpSelectionTarget {
|
||||||
side: "left" | "right";
|
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 { KeyBinding, matchesKeyBinding } from "../../../domain/models";
|
||||||
import { getParentPath, joinPath, resolveSftpWindowsPathOptions } from "../../../application/state/sftp/utils";
|
import { getParentPath, joinPath, resolveSftpWindowsPathOptions } from "../../../application/state/sftp/utils";
|
||||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||||
import { sftpClipboardStore, SftpClipboardFile } from "./useSftpClipboard";
|
import { sftpClipboardStore, SftpClipboardFile } from "../../../application/state/sftp/sftpClipboardStore";
|
||||||
import { sftpFocusStore } from "./useSftpFocusedPane";
|
import { sftpFocusStore } from "../../../application/state/sftp/sftpFocusStore";
|
||||||
import { sftpDialogActionStore } from "./useSftpDialogAction";
|
import { sftpDialogActionStore } from "../../../application/state/sftp/sftpDialogActionStore";
|
||||||
import { sftpTreeSelectionStore } from "./useSftpTreeSelectionStore";
|
import { sftpTreeSelectionStore } from "../../../application/state/sftp/sftpTreeSelectionStore";
|
||||||
import { sftpListOrderStore } from "./useSftpListOrderStore";
|
import { sftpListOrderStore } from "./useSftpListOrderStore";
|
||||||
import { sftpPaneViewModeStore } from "../../../application/state/sftp/sftpPaneViewModeStore";
|
import { sftpPaneViewModeStore } from "../../../application/state/sftp/sftpPaneViewModeStore";
|
||||||
import { keepOnlyPaneSelections } from "./selectionScope";
|
import { keepOnlyPaneSelections } from "./selectionScope";
|
||||||
|
|||||||
+34
-64
@@ -23,7 +23,18 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
import { formatBytes as formatBytesDomain } from '../../domain/formatBytes';
|
||||||
import { SftpFileEntry } from '../../types';
|
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
|
// Pre-built icon maps for O(1) lookup in getFileIcon
|
||||||
type IconDef = [LucideIcon, string?];
|
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 => {
|
export const formatBytes = (bytes: number | string): string => {
|
||||||
const numBytes = typeof bytes === 'string' ? parseInt(bytes, 10) : bytes;
|
const numBytes = typeof bytes === 'string' ? parseInt(bytes, 10) : bytes;
|
||||||
if (isNaN(numBytes) || numBytes === 0) return '0 B';
|
if (typeof numBytes !== 'number' || isNaN(numBytes)) return '0 B';
|
||||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
return formatBytesDomain(numBytes, {
|
||||||
const i = Math.floor(Math.log(numBytes) / Math.log(1024));
|
zeroDisplay: '0 B',
|
||||||
const size = numBytes / Math.pow(1024, i);
|
includeTB: true,
|
||||||
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
fractionDigits: 1,
|
||||||
|
byteUnit: 'B',
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format bytes for transfer display
|
* Format bytes for transfer display
|
||||||
*/
|
*/
|
||||||
export const formatTransferBytes = (bytes: number): string => {
|
export const formatTransferBytes = (bytes: number): string => {
|
||||||
if (bytes === 0) return '0 B';
|
return formatBytesDomain(bytes, {
|
||||||
const units = ['B', 'KB', 'MB', 'GB'];
|
zeroDisplay: '0 B',
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
includeTB: false,
|
||||||
const size = bytes / Math.pow(1024, i);
|
fractionDigits: 1,
|
||||||
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
byteUnit: 'B',
|
||||||
};
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* 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())}`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -245,40 +248,15 @@ export const getFileIcon = (entry: SftpFileEntry): React.ReactElement => {
|
|||||||
return React.createElement(FileCode, { size: 14 });
|
return React.createElement(FileCode, { size: 14 });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sort configuration types
|
// Sort / column layout — owned by application/state; re-exported for UI callers.
|
||||||
export type SortField = 'name' | 'size' | 'modified' | 'type';
|
export {
|
||||||
export type SortOrder = 'asc' | 'desc';
|
DEFAULT_SFTP_COLUMN_VISIBILITY,
|
||||||
|
normalizeSftpColumnVisibility,
|
||||||
// Column widths type
|
type ColumnWidths,
|
||||||
export interface ColumnWidths {
|
type SftpColumnVisibility,
|
||||||
name: number;
|
type SortField,
|
||||||
modified: number;
|
type SortOrder,
|
||||||
size: number;
|
} from '../../application/state/sftp/columnLayout';
|
||||||
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,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isSftpColumnMenuKey = (key: string, shiftKey: boolean): boolean =>
|
export const isSftpColumnMenuKey = (key: string, shiftKey: boolean): boolean =>
|
||||||
key === 'ContextMenu' || (key === 'F10' && shiftKey);
|
key === 'ContextMenu' || (key === 'F10' && shiftKey);
|
||||||
@@ -339,14 +317,6 @@ export const sortSftpEntries = (
|
|||||||
return sorted;
|
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
|
* Check if a file is hidden
|
||||||
* - Windows: checks the `hidden` attribute (set by localFsBridge)
|
* - 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,
|
SystemPanelToolbar,
|
||||||
} from './SystemPanelUi';
|
} from './SystemPanelUi';
|
||||||
import { SystemPanelConfirmDialog } from './SystemPanelConfirmDialog';
|
import { SystemPanelConfirmDialog } from './SystemPanelConfirmDialog';
|
||||||
import { useAsyncRecordCache } from './hooks/useAsyncRecordCache';
|
import { useAsyncRecordCache } from '../../application/state/systemManager/useAsyncRecordCache';
|
||||||
import { usePolling, useStableTranslate } from './hooks/useSystemManager';
|
import { usePolling, useStableTranslate } from '../../application/state/useSystemManager';
|
||||||
import { openInteractiveTerminal } from './openInteractiveTerminal';
|
import { openInteractiveTerminal } from './openInteractiveTerminal';
|
||||||
import { showSystemManagerError } from './systemManagerToast';
|
import { showSystemManagerError } from './systemManagerToast';
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ import {
|
|||||||
} from './SystemPanelUi';
|
} from './SystemPanelUi';
|
||||||
import { SystemPanelConfirmDialog } from './SystemPanelConfirmDialog';
|
import { SystemPanelConfirmDialog } from './SystemPanelConfirmDialog';
|
||||||
import { SystemPanelPromptDialog } from './SystemPanelPromptDialog';
|
import { SystemPanelPromptDialog } from './SystemPanelPromptDialog';
|
||||||
import { useAsyncRecordCache } from './hooks/useAsyncRecordCache';
|
import { useAsyncRecordCache } from '../../application/state/systemManager/useAsyncRecordCache';
|
||||||
import { usePolling, useStableTranslate } from './hooks/useSystemManager';
|
import { usePolling, useStableTranslate } from '../../application/state/useSystemManager';
|
||||||
import { showSystemManagerError } from './systemManagerToast';
|
import { showSystemManagerError } from './systemManagerToast';
|
||||||
|
|
||||||
type Backend = ReturnType<typeof useSystemManagerBackend>;
|
type Backend = ReturnType<typeof useSystemManagerBackend>;
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
SystemPanelStatusBadge,
|
SystemPanelStatusBadge,
|
||||||
SystemPanelToolbar,
|
SystemPanelToolbar,
|
||||||
} from './SystemPanelUi';
|
} from './SystemPanelUi';
|
||||||
import { usePolling, useStableTranslate } from './hooks/useSystemManager';
|
import { usePolling, useStableTranslate } from '../../application/state/useSystemManager';
|
||||||
|
|
||||||
type Backend = ReturnType<typeof useSystemManagerBackend>;
|
type Backend = ReturnType<typeof useSystemManagerBackend>;
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import {
|
|||||||
} from './SystemPanelUi';
|
} from './SystemPanelUi';
|
||||||
import { SystemPanelConfirmDialog } from './SystemPanelConfirmDialog';
|
import { SystemPanelConfirmDialog } from './SystemPanelConfirmDialog';
|
||||||
import { SystemPanelPromptDialog } from './SystemPanelPromptDialog';
|
import { SystemPanelPromptDialog } from './SystemPanelPromptDialog';
|
||||||
import { usePolling, useStableTranslate } from './hooks/useSystemManager';
|
import { usePolling, useStableTranslate } from '../../application/state/useSystemManager';
|
||||||
import {
|
import {
|
||||||
getCachedProcessList,
|
getCachedProcessList,
|
||||||
setCachedProcessList,
|
setCachedProcessList,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { TmuxManagerTab } from './TmuxManagerTab';
|
|||||||
import { WorkspaceSidebarHostHeader } from '../terminalLayer/WorkspaceSidebarHostHeader';
|
import { WorkspaceSidebarHostHeader } from '../terminalLayer/WorkspaceSidebarHostHeader';
|
||||||
import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from '../terminalLayer/terminalSidePanelChrome';
|
import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from '../terminalLayer/terminalSidePanelChrome';
|
||||||
import { SystemPanelEmpty, SystemPanelShell } from './SystemPanelUi';
|
import { SystemPanelEmpty, SystemPanelShell } from './SystemPanelUi';
|
||||||
import { useSessionCapabilities } from './hooks/useSystemManager';
|
import { useSessionCapabilities } from '../../application/state/useSystemManager';
|
||||||
|
|
||||||
const SystemPanelChecking = memo(function SystemPanelChecking({
|
const SystemPanelChecking = memo(function SystemPanelChecking({
|
||||||
message,
|
message,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import React, { memo, useEffect, useMemo, useState } from 'react';
|
|||||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||||
import { aggregateMountedDiskUsage } from '../../domain/systemDiskUsage';
|
import { aggregateMountedDiskUsage } from '../../domain/systemDiskUsage';
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
import { useServerStats } from '../terminal/hooks/useServerStats';
|
import { useServerStats } from '../../application/state/useServerStats';
|
||||||
import { ResourceBar } from './ResourceBar';
|
import { ResourceBar } from './ResourceBar';
|
||||||
import {
|
import {
|
||||||
SystemPanelEmpty,
|
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