From 8f1e6cf379671773b080bff2e732dcc2b10bb0ef Mon Sep 17 00:00:00 2001 From: Syngnat Date: Mon, 22 Jun 2026 22:36:39 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20perf(frontend):=20?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=95=BF=E6=97=B6=E8=BF=90=E8=A1=8C=E4=B8=8B?= =?UTF-8?q?=E7=9A=84=E6=90=9C=E7=B4=A2=E4=B8=8E=E7=BC=93=E5=AD=98=E5=8D=A0?= =?UTF-8?q?=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为 V2 cmd+k 搜索预建索引并限制初始/宽泛结果数量 - 清理冷数据库树和 DataViewer 长生命周期快照缓存 - 收紧运行时 SQL 日志预算并在 hydration 时压缩旧缓存 --- .../DataViewer.primary-key.test.tsx | 8 + frontend/src/components/DataViewer.tsx | 26 +++- .../Sidebar.locate-toolbar.test.tsx | 9 +- frontend/src/components/Sidebar.tsx | 62 +++++++- .../sidebar/useSidebarSearchModel.tsx | 24 ++- .../sidebar/useSidebarTreeLoaders.tsx | 46 +----- .../sidebarV2Utils.command-search.test.ts | 116 ++++++++++++++ frontend/src/components/sidebarV2Utils.ts | 145 +++++++++++++++--- frontend/src/store.test.ts | 75 +++++++-- frontend/src/store.ts | 129 +++++++++++----- 10 files changed, 506 insertions(+), 134 deletions(-) create mode 100644 frontend/src/components/sidebarV2Utils.command-search.test.ts diff --git a/frontend/src/components/DataViewer.primary-key.test.tsx b/frontend/src/components/DataViewer.primary-key.test.tsx index 4cb1d30..8f257b9 100644 --- a/frontend/src/components/DataViewer.primary-key.test.tsx +++ b/frontend/src/components/DataViewer.primary-key.test.tsx @@ -164,6 +164,14 @@ describe('DataViewer safe editing locator', () => { expect(source).toContain('data_viewer.sql_log.phase.sort_buffer_retry'); }); + it('caps viewer filter snapshots so long-running sessions do not retain unbounded table state', () => { + const source = readFileSync(new URL('./DataViewer.tsx', import.meta.url), 'utf8'); + + expect(source).toContain('const MAX_VIEWER_FILTER_SNAPSHOTS = 64;'); + expect(source).toContain('const trimViewerFilterSnapshots = () => {'); + expect(source).toContain('setViewerFilterSnapshot(normalizedTabId, {'); + }); + it('enables table preview editing after primary keys are loaded', async () => { backendApp.DBGetColumns.mockResolvedValue({ success: true, diff --git a/frontend/src/components/DataViewer.tsx b/frontend/src/components/DataViewer.tsx index 7453fe9..746b42e 100644 --- a/frontend/src/components/DataViewer.tsx +++ b/frontend/src/components/DataViewer.tsx @@ -280,8 +280,32 @@ type ViewerScrollSnapshot = { }; const viewerFilterSnapshotsByTab = new Map(); +const MAX_VIEWER_FILTER_SNAPSHOTS = 64; const VIEWER_SCROLL_SNAPSHOT_PERSIST_DELAY_MS = 160; +const trimViewerFilterSnapshots = () => { + while (viewerFilterSnapshotsByTab.size > MAX_VIEWER_FILTER_SNAPSHOTS) { + const oldestKey = viewerFilterSnapshotsByTab.keys().next().value; + if (!oldestKey) { + break; + } + viewerFilterSnapshotsByTab.delete(oldestKey); + } +}; + +const setViewerFilterSnapshot = ( + tabId: string, + snapshot: ViewerFilterSnapshot, +) => { + const normalizedTabId = String(tabId || '').trim(); + if (!normalizedTabId) return; + if (viewerFilterSnapshotsByTab.has(normalizedTabId)) { + viewerFilterSnapshotsByTab.delete(normalizedTabId); + } + viewerFilterSnapshotsByTab.set(normalizedTabId, snapshot); + trimViewerFilterSnapshots(); +}; + const normalizeViewerFilterConditions = (conditions: FilterCondition[] | undefined): FilterCondition[] => { if (!Array.isArray(conditions)) return []; return conditions.map((cond) => ({ @@ -380,7 +404,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({ const persistViewerSnapshot = useCallback((tabId: string, overrides?: Partial) => { const normalizedTabId = String(tabId || '').trim(); if (!normalizedTabId) return; - viewerFilterSnapshotsByTab.set(normalizedTabId, { + setViewerFilterSnapshot(normalizedTabId, { showFilter, conditions: normalizeViewerFilterConditions(filterConditions), quickWhereCondition: normalizeQuickWhereCondition(quickWhereCondition), diff --git a/frontend/src/components/Sidebar.locate-toolbar.test.tsx b/frontend/src/components/Sidebar.locate-toolbar.test.tsx index ba63e6a..31dd385 100644 --- a/frontend/src/components/Sidebar.locate-toolbar.test.tsx +++ b/frontend/src/components/Sidebar.locate-toolbar.test.tsx @@ -2344,9 +2344,6 @@ describe('Sidebar locate toolbar', () => { const loadTablesStart = source.indexOf('const loadTables = async (node: any) => {'); const loadTablesEnd = source.indexOf('const config = {', loadTablesStart); const loadTablesSource = source.slice(loadTablesStart, loadTablesEnd); - const externalSqlReadStart = source.indexOf('const externalSQLDirectoryResults = await Promise.all(', loadTablesStart); - const externalSqlReadEnd = source.indexOf('const externalSQLTrees = externalSQLDirectoryResults.reduce', externalSqlReadStart); - const externalSqlReadSource = source.slice(externalSqlReadStart, externalSqlReadEnd); const externalSqlFlowStart = source.indexOf('const handleAddExternalSQLDirectory = async (node: any) => {'); const externalSqlFlowEnd = source.indexOf('const cancelSQLFileExecution = () => {', externalSqlFlowStart); const externalSqlFlowSource = source.slice(externalSqlFlowStart, externalSqlFlowEnd); @@ -2369,8 +2366,6 @@ describe('Sidebar locate toolbar', () => { [ loadTablesStart, loadTablesEnd, - externalSqlReadStart, - externalSqlReadEnd, externalSqlFlowStart, externalSqlFlowEnd, treeTitleStart, @@ -2387,9 +2382,7 @@ describe('Sidebar locate toolbar', () => { expect(loadTablesSource).toContain("title: t('sidebar.tree.saved_queries')"); expect(loadTablesSource).not.toContain("title: '已存查询'"); - - expect(externalSqlReadSource).toContain("t('sidebar.message.external_sql_directory_read_failed'"); - expect(externalSqlReadSource).not.toContain('SQL 目录读取失败'); + expect(source).not.toContain('const externalSQLDirectoryResults = await Promise.all('); expect(loadTablesSource).not.toContain('SQL 目录读取失败'); expect(loadTablesSource).not.toContain("'SQL目录'"); diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 29c3c5f..76c2381 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -164,6 +164,7 @@ import { resolveSidebarDropInsertBefore, resolveSidebarDropNodeFromDomEvent, resolveSidebarDropTargetMetricsFromDomEvent, + resolveSidebarDatabaseTreePruneKeys, resolveSidebarNodeConnectionId, resolveSidebarTagDropInsertBefore, resolveV2ActiveConnectionId, @@ -190,6 +191,7 @@ export { resolveSidebarDropInsertBefore, resolveSidebarDropNodeFromDomEvent, resolveSidebarDropTargetMetricsFromDomEvent, + resolveSidebarDatabaseTreePruneKeys, resolveSidebarNodeConnectionId, resolveSidebarTagDropInsertBefore, resolveV2ActiveConnectionId, @@ -205,6 +207,7 @@ export type { V2CommandSearchItem, V2RailConnectionGroup } from './sidebarV2Util const { Search } = Input; const SIDEBAR_LOCATE_LOAD_WAIT_INTERVAL_MS = 50; const SIDEBAR_LOCATE_LOAD_WAIT_ATTEMPTS = 160; +const SIDEBAR_CACHED_DATABASE_TREE_LIMIT = 12; // resolveV2ObjectGroupTitle 已迁移到 ./sidebar/sidebarHelpers @@ -506,6 +509,8 @@ const Sidebar: React.FC<{ const [selectedKeys, setSelectedKeys] = useState([]); const selectedNodesRef = useRef([]); const loadingNodesRef = useRef>(new Set()); + const databaseTreeTouchedAtRef = useRef>({}); + const pruneLoadedDatabaseTreesRef = useRef<() => void>(() => {}); const clickTimerRef = useRef | null>(null); const treeDragSelectSuppressUntilRef = useRef(0); const treeDragSelectionSnapshotRef = useRef<{ @@ -544,6 +549,7 @@ const Sidebar: React.FC<{ }, [setActiveContext]); const openV2CommandSearch = useCallback(() => { + pruneLoadedDatabaseTreesRef.current(); setIsV2CommandSearchOpen(true); setV2CommandActiveIndex(0); }, []); @@ -984,6 +990,55 @@ const Sidebar: React.FC<{ return nextTreeData; }; + const clearTreeNodeChildrenByKeys = useCallback((keysToClear: string[]) => { + const keysToClearSet = new Set(keysToClear.map((key) => String(key || '').trim()).filter(Boolean)); + if (keysToClearSet.size === 0) { + return; + } + + const clearChildren = (nodes: TreeNode[]): TreeNode[] => ( + nodes.map((node) => { + const nodeKey = String(node.key || '').trim(); + if (keysToClearSet.has(nodeKey)) { + return { ...node, children: undefined }; + } + if (node.children?.length) { + return { ...node, children: clearChildren(node.children) }; + } + return node; + }) + ); + + setTreeData((prev) => { + const nextTreeData = clearChildren(prev); + treeDataRef.current = nextTreeData; + return nextTreeData; + }); + setLoadedKeys((prev) => prev.filter((key) => !keysToClearSet.has(String(key)))); + keysToClearSet.forEach((key) => { + delete databaseTreeTouchedAtRef.current[key]; + }); + }, []); + + const pruneLoadedDatabaseTrees = useCallback(() => { + const activeDatabaseKey = activeContext?.connectionId && activeContext?.dbName + ? `${activeContext.connectionId}-${activeContext.dbName}` + : ''; + const keysToClear = resolveSidebarDatabaseTreePruneKeys({ + treeData: treeDataRef.current, + expandedKeys, + selectedKeys, + activeDatabaseKey, + touchedAtByDatabaseKey: databaseTreeTouchedAtRef.current, + maxLoadedDatabases: SIDEBAR_CACHED_DATABASE_TREE_LIMIT, + }); + if (keysToClear.length === 0) { + return; + } + clearTreeNodeChildrenByKeys(keysToClear); + }, [activeContext?.connectionId, activeContext?.dbName, clearTreeNodeChildrenByKeys, expandedKeys, selectedKeys]); + pruneLoadedDatabaseTreesRef.current = pruneLoadedDatabaseTrees; + const mergeExpandedTreeKeys = (requiredKeys: React.Key[]) => { setExpandedKeys(prev => { const merged = [...prev]; @@ -1727,7 +1782,6 @@ const Sidebar: React.FC<{ loadTables, } = useSidebarTreeLoaders({ savedQueries, - externalSQLDirectories, tableSortPreference, tableAccessCount, pinnedSidebarTables, @@ -1740,7 +1794,10 @@ const Sidebar: React.FC<{ buildJVMRuntimeConfig, buildJVMDiagnosticTreeNodes, resolveSavedQueryDisplayName, - decorateExternalSQLTreeNode, + onDatabaseTreeLoaded: (databaseKey: string) => { + databaseTreeTouchedAtRef.current[databaseKey] = Date.now(); + pruneLoadedDatabaseTrees(); + }, }); const { @@ -1950,6 +2007,7 @@ const Sidebar: React.FC<{ treeViewportWidth, treeHeight, isV2Ui, + isV2CommandSearchOpen, connections, connectionIds, selectedKeys, diff --git a/frontend/src/components/sidebar/useSidebarSearchModel.tsx b/frontend/src/components/sidebar/useSidebarSearchModel.tsx index c36c636..4ebdb57 100644 --- a/frontend/src/components/sidebar/useSidebarSearchModel.tsx +++ b/frontend/src/components/sidebar/useSidebarSearchModel.tsx @@ -30,6 +30,7 @@ import { } from './sidebarHelpers'; import type { SearchScope } from '../sidebarCoreUtils'; import { + buildV2CommandSearchTreeIndex, V2_TREE_HORIZONTAL_SCROLL_BOTTOM_RESERVE, estimateV2TreeHorizontalScrollWidth, filterV2CommandSearchTreeItems, @@ -74,6 +75,7 @@ type SidebarSearchModelArgs = { treeViewportWidth: number; treeHeight: number; isV2Ui: boolean; + isV2CommandSearchOpen: boolean; connections: SavedConnection[]; connectionIds: string[]; selectedKeys: React.Key[]; @@ -111,6 +113,7 @@ export const useSidebarSearchModel = ({ treeViewportWidth, treeHeight, isV2Ui, + isV2CommandSearchOpen, connections, connectionIds, selectedKeys, @@ -179,6 +182,10 @@ export const useSidebarSearchModel = ({ }; const currentLanguage = getCurrentLanguage(); + const connectionById = useMemo( + () => new Map(connections.map((connection) => [connection.id, connection])), + [connections], + ); const searchScopeSummary = useMemo(() => { if (searchScopes.includes('smart')) { @@ -360,6 +367,9 @@ export const useSidebarSearchModel = ({ }, [deferredSearchValue, searchScopes, treeData]); const commandSearchTreeItems = useMemo(() => { + if (!isV2CommandSearchOpen) { + return []; + } const result: V2CommandSearchItem[] = []; const visit = (nodes: TreeNode[]) => { nodes.forEach((node) => { @@ -375,7 +385,7 @@ export const useSidebarSearchModel = ({ node, }); } else if (node.type === 'database') { - const conn = connections.find((item) => item.id === dataRef.id); + const conn = connectionById.get(String(dataRef.id || '')); result.push({ key: `node-${node.key}`, kind: 'node', @@ -392,7 +402,7 @@ export const useSidebarSearchModel = ({ || node.type === 'db-event' || node.type === 'routine' ) { - const conn = connections.find((item) => item.id === dataRef.id); + const conn = connectionById.get(String(dataRef.id || '')); const objectName = String(dataRef.tableName || dataRef.viewName || dataRef.triggerName || dataRef.eventName || dataRef.routineName || node.title || '').trim(); const displayName = String(node.title || extractObjectName(objectName) || objectName).trim(); result.push({ @@ -412,7 +422,11 @@ export const useSidebarSearchModel = ({ visit(treeData); return result; - }, [connections, treeData]); + }, [connectionById, extractObjectName, isV2CommandSearchOpen, treeData]); + const commandSearchTreeIndex = useMemo( + () => buildV2CommandSearchTreeIndex(commandSearchTreeItems), + [commandSearchTreeItems], + ); const commandSearchRecentItems = useMemo(() => { return sqlLogs.slice(0, 5).map((log) => ({ @@ -473,8 +487,8 @@ export const useSidebarSearchModel = ({ const v2CommandSearchObjectMode = v2CommandSearchQuery.mode === 'object'; const v2CommandSearchAiMode = v2CommandSearchQuery.mode === 'ai'; const filteredCommandSearchTreeItems = useMemo(() => { - return filterV2CommandSearchTreeItems(commandSearchTreeItems, v2CommandSearchQuery); - }, [commandSearchTreeItems, v2CommandSearchQuery]); + return filterV2CommandSearchTreeItems(commandSearchTreeIndex, v2CommandSearchQuery); + }, [commandSearchTreeIndex, v2CommandSearchQuery]); const filteredCommandSearchActionItems = useMemo(() => { if (v2CommandSearchObjectMode || v2CommandSearchAiMode) return []; diff --git a/frontend/src/components/sidebar/useSidebarTreeLoaders.tsx b/frontend/src/components/sidebar/useSidebarTreeLoaders.tsx index e392c9c..72b0afa 100644 --- a/frontend/src/components/sidebar/useSidebarTreeLoaders.tsx +++ b/frontend/src/components/sidebar/useSidebarTreeLoaders.tsx @@ -13,14 +13,13 @@ import { TableOutlined, ThunderboltOutlined, } from '@ant-design/icons'; -import type { SavedConnection, SavedQuery, ExternalSQLDirectory, ExternalSQLTreeEntry, JVMCapability, JVMResourceSummary } from '../../types'; +import type { SavedConnection, SavedQuery, JVMCapability, JVMResourceSummary } from '../../types'; import { useStore } from '../../store'; import { t } from '../../i18n'; import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig'; import { buildRedisDbNodeLabel, getRedisDbAlias } from '../../utils/redisDbAlias'; import { buildJVMMonitoringActionDescriptors } from '../../utils/jvmSidebarActions'; import { type SidebarViewMetadataEntry } from '../../utils/sidebarMetadata'; -import { buildExternalSQLRootNode, type ExternalSQLTreeNode } from '../../utils/externalSqlTree'; import { buildQualifiedName, buildSidebarObjectKeyName, @@ -47,7 +46,7 @@ import { sortSidebarTableEntries, type SidebarTreeNode as TreeNode, } from '../sidebarV2Utils'; -import { DBGetDatabases, DBGetTables, DBQuery, GetDriverStatusList, JVMProbeCapabilities, ListSQLDirectory } from '../../../wailsjs/go/app/App'; +import { DBGetDatabases, DBGetTables, DBQuery, GetDriverStatusList, JVMProbeCapabilities } from '../../../wailsjs/go/app/App'; type DriverStatusSnapshot = { type: string; @@ -119,7 +118,6 @@ const resolveSavedConnectionDriverType = (conn: SavedConnection | undefined): st type UseSidebarTreeLoadersOptions = { savedQueries: SavedQuery[]; - externalSQLDirectories: ExternalSQLDirectory[]; tableSortPreference: Record; tableAccessCount: Record; pinnedSidebarTables: any[]; @@ -132,12 +130,11 @@ type UseSidebarTreeLoadersOptions = { buildJVMRuntimeConfig: (conn: SavedConnection & { dbName?: string }, providerMode: string) => any; buildJVMDiagnosticTreeNodes: (conn: SavedConnection) => TreeNode[]; resolveSavedQueryDisplayName: (name: string | null | undefined) => string; - decorateExternalSQLTreeNode: (node: ExternalSQLTreeNode) => TreeNode; + onDatabaseTreeLoaded?: (databaseKey: string) => void; }; export const useSidebarTreeLoaders = ({ savedQueries, - externalSQLDirectories, tableSortPreference, tableAccessCount, pinnedSidebarTables, @@ -150,7 +147,7 @@ export const useSidebarTreeLoaders = ({ buildJVMRuntimeConfig, buildJVMDiagnosticTreeNodes, resolveSavedQueryDisplayName, - decorateExternalSQLTreeNode, + onDatabaseTreeLoaded, }: UseSidebarTreeLoadersOptions) => { const driverStatusCacheRef = useRef<{ fetchedAt: number; @@ -516,40 +513,6 @@ export const useSidebarTreeLoaders = ({ loadFunctions(conn, conn.dbName), loadDatabaseEvents(conn, conn.dbName), ]); - const externalSQLDirectoryResults = await Promise.all( - externalSQLDirectories.map(async (directory: ExternalSQLDirectory) => { - const directoryRes = await ListSQLDirectory(directory.path); - if (!directoryRes.success) { - message.warning({ - key: `external-sql-${directory.id}`, - content: t('sidebar.message.external_sql_directory_read_failed', { - name: directory.name, - error: directoryRes.message, - }), - }); - return { id: directory.id, entries: [] as ExternalSQLTreeEntry[] }; - } - return { - id: directory.id, - entries: Array.isArray(directoryRes.data) ? directoryRes.data as ExternalSQLTreeEntry[] : [], - }; - }), - ); - const externalSQLTrees = externalSQLDirectoryResults.reduce>((accumulator, item) => { - accumulator[item.id] = item.entries; - return accumulator; - }, {}); - const externalSQLRootNode = decorateExternalSQLTreeNode(buildExternalSQLRootNode({ - dbNodeKey: String(key), - connectionId: String(conn.id), - dbName: String(conn.dbName), - directories: externalSQLDirectories, - directoryTrees: externalSQLTrees, - labels: { - root: t('sidebar.external_sql.root'), - directoryFallback: t('sidebar.external_sql.directory_fallback'), - }, - })); const viewRows: SidebarViewMetadataEntry[] = Array.isArray(viewsResult.views) ? viewsResult.views : []; const materializedViewRows: SidebarViewMetadataEntry[] = Array.isArray(materializedViewsResult.views) ? materializedViewsResult.views : []; const triggerRows: any[] = Array.isArray(triggersResult.triggers) ? triggersResult.triggers : []; @@ -855,6 +818,7 @@ export const useSidebarTreeLoaders = ({ replaceTreeNodeChildren(key, [queriesNode, ...groupedNodes]); } + onDatabaseTreeLoaded?.(String(key)); } else { setConnectionStates(prev => ({ ...prev, [key as string]: 'error' })); message.error({ content: res.message, key: `db-${key}-tables` }); diff --git a/frontend/src/components/sidebarV2Utils.command-search.test.ts b/frontend/src/components/sidebarV2Utils.command-search.test.ts new file mode 100644 index 0000000..1f7219c --- /dev/null +++ b/frontend/src/components/sidebarV2Utils.command-search.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest'; + +import { + V2_COMMAND_SEARCH_INITIAL_TREE_LIMIT, + V2_COMMAND_SEARCH_MAX_TREE_RESULTS, + buildV2CommandSearchTreeIndex, + filterV2CommandSearchTreeItems, + parseV2CommandSearchQuery, + resolveSidebarDatabaseTreePruneKeys, + type V2CommandSearchItem, +} from './sidebarV2Utils'; + +const buildNodeItems = (count: number): V2CommandSearchItem[] => { + return Array.from({ length: count }, (_, index) => ({ + key: `node-table-${index}`, + kind: 'node' as const, + title: `fs_order_${index}`, + meta: `开发240 · front_end_sys_${index % 4}`, + icon: null, + node: { + type: index % 6 === 0 ? 'view' : 'table', + key: `table-${index}`, + title: `fs_order_${index}`, + dataRef: { + tableName: `fs_order_${index}`, + viewName: index % 6 === 0 ? `v_order_${index}` : undefined, + dbName: `front_end_sys_${index % 4}`, + name: `obj_${index}`, + config: { + host: `10.0.0.${index % 16}`, + }, + }, + }, + })); +}; + +describe('sidebarV2 command search performance helpers', () => { + it('keeps the initial tree result limit when the query is empty', () => { + const items = buildNodeItems(V2_COMMAND_SEARCH_INITIAL_TREE_LIMIT + 80); + + expect( + filterV2CommandSearchTreeItems(items, parseV2CommandSearchQuery('')), + ).toHaveLength(V2_COMMAND_SEARCH_INITIAL_TREE_LIMIT); + }); + + it('caps broad keyword matches to avoid rendering the full loaded tree', () => { + const items = buildNodeItems(V2_COMMAND_SEARCH_MAX_TREE_RESULTS + 160); + + const result = filterV2CommandSearchTreeItems( + items, + parseV2CommandSearchQuery('fs_order'), + ); + + expect(result).toHaveLength(V2_COMMAND_SEARCH_MAX_TREE_RESULTS); + expect(result[0]?.key).toBe('node-table-0'); + expect(result[result.length - 1]?.key).toBe(`node-table-${V2_COMMAND_SEARCH_MAX_TREE_RESULTS - 1}`); + }); + + it('returns the same matches when filtering with a prebuilt search index', () => { + const items = buildNodeItems(200); + const index = buildV2CommandSearchTreeIndex(items); + const query = parseV2CommandSearchQuery('@fs_order_1'); + + expect(filterV2CommandSearchTreeItems(index, query)).toEqual( + filterV2CommandSearchTreeItems(items, query), + ); + }); + + it('prunes only cold collapsed database trees when too many object trees stay loaded', () => { + expect(resolveSidebarDatabaseTreePruneKeys({ + treeData: [ + { + key: 'conn-1', + title: 'conn-1', + type: 'connection', + children: [ + { + key: 'conn-1-db-a', + title: 'db-a', + type: 'database', + children: [{ key: 'a-tables', title: '表', type: 'object-group' }], + }, + { + key: 'conn-1-db-b', + title: 'db-b', + type: 'database', + children: [{ key: 'b-tables', title: '表', type: 'object-group' }], + }, + { + key: 'conn-1-db-c', + title: 'db-c', + type: 'database', + children: [{ key: 'c-tables', title: '表', type: 'object-group' }], + }, + { + key: 'conn-1-db-d', + title: 'db-d', + type: 'database', + children: [{ key: 'd-tables', title: '表', type: 'object-group' }], + }, + ], + }, + ], + expandedKeys: ['conn-1-db-c'], + selectedKeys: [], + activeDatabaseKey: 'conn-1-db-d', + touchedAtByDatabaseKey: { + 'conn-1-db-a': 10, + 'conn-1-db-b': 20, + 'conn-1-db-c': 30, + 'conn-1-db-d': 40, + }, + maxLoadedDatabases: 2, + })).toEqual(['conn-1-db-a', 'conn-1-db-b']); + }); +}); diff --git a/frontend/src/components/sidebarV2Utils.ts b/frontend/src/components/sidebarV2Utils.ts index cb24057..c2fab47 100644 --- a/frontend/src/components/sidebarV2Utils.ts +++ b/frontend/src/components/sidebarV2Utils.ts @@ -415,6 +415,13 @@ export type V2CommandSearchItem = dbName?: string; }; +export interface V2CommandSearchTreeIndexEntry { + item: Extract; + normalizedSearchText: string; + normalizedObjectText: string; + objectNode: boolean; +} + export type V2CommandSearchMode = 'default' | 'object' | 'ai'; export interface V2CommandSearchQuery { @@ -467,40 +474,69 @@ const isV2CommandSearchObjectNode = (node: SidebarTreeNode): boolean => { || node.type === 'materialized-view'; }; -const V2_COMMAND_SEARCH_INITIAL_TREE_LIMIT = 24; +export const V2_COMMAND_SEARCH_INITIAL_TREE_LIMIT = 24; +export const V2_COMMAND_SEARCH_MAX_TREE_RESULTS = 120; + +export const buildV2CommandSearchTreeIndex = ( + items: V2CommandSearchItem[], +): V2CommandSearchTreeIndexEntry[] => { + return items.flatMap((item) => { + if (item.kind !== 'node') { + return []; + } + const dataRef = item.node.dataRef || {}; + const normalizedTitle = String(item.title || '').toLowerCase(); + const normalizedPrimaryObjectText = String( + dataRef.tableName || dataRef.viewName || item.title || '', + ).toLowerCase(); + + return [{ + item, + normalizedSearchText: [ + item.title, + item.meta, + dataRef.tableName, + dataRef.viewName, + dataRef.dbName, + dataRef.name, + dataRef.config?.host, + ].filter(Boolean).join(' ').toLowerCase(), + normalizedObjectText: `${normalizedPrimaryObjectText} ${normalizedTitle}`.trim(), + objectNode: isV2CommandSearchObjectNode(item.node), + }]; + }); +}; export const filterV2CommandSearchTreeItems = ( - items: V2CommandSearchItem[], + items: V2CommandSearchItem[] | V2CommandSearchTreeIndexEntry[], query: V2CommandSearchQuery, ): V2CommandSearchItem[] => { if (query.mode === 'ai') return []; + const index = items.length > 0 && 'item' in items[0] + ? items as V2CommandSearchTreeIndexEntry[] + : buildV2CommandSearchTreeIndex(items as V2CommandSearchItem[]); const normalizedKeyword = query.normalizedKeyword; const objectMode = query.mode === 'object'; - const matchedItems = items.filter((item) => { - if (item.kind !== 'node') return false; - const node = item.node; - const dataRef = node.dataRef || {}; - if (objectMode && !isV2CommandSearchObjectNode(node)) { - return false; + const result: V2CommandSearchItem[] = []; + const maxResults = normalizedKeyword + ? V2_COMMAND_SEARCH_MAX_TREE_RESULTS + : V2_COMMAND_SEARCH_INITIAL_TREE_LIMIT; + + for (const entry of index) { + if (objectMode && !entry.objectNode) { + continue; } - if (!normalizedKeyword) return true; - const objectName = String(dataRef.tableName || dataRef.viewName || item.title || '').toLowerCase(); - if (objectMode) { - return objectName.includes(normalizedKeyword) - || String(item.title || '').toLowerCase().includes(normalizedKeyword); + if (!normalizedKeyword) { + result.push(entry.item); + } else if (objectMode ? entry.normalizedObjectText.includes(normalizedKeyword) : entry.normalizedSearchText.includes(normalizedKeyword)) { + result.push(entry.item); } - const haystack = [ - item.title, - item.meta, - dataRef.tableName, - dataRef.viewName, - dataRef.dbName, - dataRef.name, - dataRef.config?.host, - ].filter(Boolean).join(' ').toLowerCase(); - return haystack.includes(normalizedKeyword); - }); - return normalizedKeyword ? matchedItems : matchedItems.slice(0, V2_COMMAND_SEARCH_INITIAL_TREE_LIMIT); + if (result.length >= maxResults) { + break; + } + } + + return result; }; export interface V2CommandSearchEnterState { @@ -765,4 +801,63 @@ export const resolveV2ActiveConnectionId = ({ || ''; }; +export const resolveSidebarDatabaseTreePruneKeys = ({ + treeData, + expandedKeys, + selectedKeys, + activeDatabaseKey, + touchedAtByDatabaseKey, + maxLoadedDatabases, +}: { + treeData: SidebarTreeNode[]; + expandedKeys: React.Key[]; + selectedKeys: React.Key[]; + activeDatabaseKey?: string; + touchedAtByDatabaseKey?: Record; + maxLoadedDatabases: number; +}): string[] => { + if (!Number.isFinite(maxLoadedDatabases) || maxLoadedDatabases <= 0) { + return []; + } + + const loadedDatabaseKeys: string[] = []; + const visit = (nodes: SidebarTreeNode[]) => { + nodes.forEach((node) => { + if (node.type === 'database' && Array.isArray(node.children) && node.children.length > 0) { + loadedDatabaseKeys.push(String(node.key || '').trim()); + return; + } + if (node.children?.length) { + visit(node.children); + } + }); + }; + visit(treeData); + + if (loadedDatabaseKeys.length <= maxLoadedDatabases) { + return []; + } + + const expandedKeySet = new Set(expandedKeys.map((key) => String(key || '').trim()).filter(Boolean)); + const selectedKeySet = new Set(selectedKeys.map((key) => String(key || '').trim()).filter(Boolean)); + const protectedDatabaseKeys = new Set(); + if (activeDatabaseKey) { + protectedDatabaseKeys.add(String(activeDatabaseKey).trim()); + } + + const candidates = loadedDatabaseKeys + .filter((key) => key && !expandedKeySet.has(key) && !selectedKeySet.has(key) && !protectedDatabaseKeys.has(key)) + .sort((left, right) => { + const leftTouchedAt = Number(touchedAtByDatabaseKey?.[left] || 0); + const rightTouchedAt = Number(touchedAtByDatabaseKey?.[right] || 0); + if (leftTouchedAt !== rightTouchedAt) { + return leftTouchedAt - rightTouchedAt; + } + return left.localeCompare(right); + }); + + const pruneCount = loadedDatabaseKeys.length - maxLoadedDatabases; + return candidates.slice(0, pruneCount); +}; + export const shouldClearSidebarActiveContextOnEmptySelect = (isV2Ui: boolean): boolean => !isV2Ui; diff --git a/frontend/src/store.test.ts b/frontend/src/store.test.ts index 6d0d8bc..684a1e4 100644 --- a/frontend/src/store.test.ts +++ b/frontend/src/store.test.ts @@ -1253,33 +1253,80 @@ describe('store appearance persistence', () => { expect(useStore.getState().activeTabId).toBe('query-1'); }); - it('persists recent SQL execution logs and trims oversized entries', async () => { + it('keeps only the most recent runtime SQL logs and trims oversized entries', async () => { const { useStore } = await importStore(); - const longSql = `select '${'x'.repeat(120 * 1024)}'`; + const longSql = `select '${'x'.repeat(20 * 1024)}'`; - useStore.getState().addSqlLog({ - id: 'log-1', - timestamp: 100, - sql: longSql, - status: 'success', - duration: 12, + for (let i = 0; i < 140; i += 1) { + useStore.getState().addSqlLog({ + id: `log-${i}`, + timestamp: 100 + i, + sql: longSql, + status: 'success', + duration: 12 + i, + dbName: 'main', + }); + } + + expect(useStore.getState().sqlLogs).toHaveLength(120); + expect(useStore.getState().sqlLogs[0]).toEqual(expect.objectContaining({ + id: 'log-139', dbName: 'main', - }); + })); + expect(useStore.getState().sqlLogs[119]).toEqual(expect.objectContaining({ + id: 'log-20', + })); + expect(useStore.getState().sqlLogs[0]?.sql.length).toBe(12 * 1024); const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}'); - expect(persisted.state.sqlLogs).toHaveLength(1); - expect(persisted.state.sqlLogs[0].sql.length).toBe(100 * 1024); + expect(persisted.state.sqlLogs).toHaveLength(120); + expect(persisted.state.sqlLogs[0].sql.length).toBe(12 * 1024); expect(persisted.state.sqlLogs[0].dbName).toBe('main'); vi.resetModules(); const reloaded = await importStore(); expect(reloaded.useStore.getState().sqlLogs[0]).toEqual(expect.objectContaining({ - id: 'log-1', + id: 'log-139', status: 'success', - duration: 12, + duration: 151, dbName: 'main', })); - expect(reloaded.useStore.getState().sqlLogs[0]?.sql.length).toBe(100 * 1024); + expect(reloaded.useStore.getState().sqlLogs).toHaveLength(120); + expect(reloaded.useStore.getState().sqlLogs[119]).toEqual(expect.objectContaining({ + id: 'log-20', + })); + expect(reloaded.useStore.getState().sqlLogs[0]?.sql.length).toBe(12 * 1024); + }); + + it('shrinks oversized SQL logs from older persisted snapshots during hydration', async () => { + storage.setItem('lite-db-storage', JSON.stringify({ + state: { + sqlLogs: Array.from({ length: 200 }, (_, index) => ({ + id: `legacy-log-${index}`, + timestamp: 500 + index, + sql: `select '${'x'.repeat(18 * 1024)}'`, + status: index % 2 === 0 ? 'success' : 'error', + duration: index, + dbName: 'legacy', + message: 'm'.repeat(3 * 1024), + })), + }, + version: 12, + })); + + const { useStore } = await importStore(); + const sqlLogs = useStore.getState().sqlLogs; + + expect(sqlLogs).toHaveLength(120); + expect(sqlLogs[0]).toEqual(expect.objectContaining({ + id: 'legacy-log-0', + dbName: 'legacy', + })); + expect(sqlLogs[119]).toEqual(expect.objectContaining({ + id: 'legacy-log-119', + })); + expect(sqlLogs[0]?.sql.length).toBe(12 * 1024); + expect(sqlLogs[0]?.message?.length).toBe(1024); }); it('defaults AI chat send shortcut to Enter in shared shortcut options', async () => { diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 0d5dee3..b9a3b39 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -137,14 +137,16 @@ const MIN_KEEPALIVE_INTERVAL_MINUTES = 1; const MAX_KEEPALIVE_INTERVAL_MINUTES = 1440; const DEFAULT_DIAGNOSTIC_TIMEOUT_SECONDS = 15; const MAX_DIAGNOSTIC_TIMEOUT_SECONDS = 300; -const PERSIST_VERSION = 12; +const PERSIST_VERSION = 13; const PERSIST_STORAGE_KEY = "lite-db-storage"; const PERSIST_WRITE_DEBOUNCE_MS = 160; const MAX_PERSISTED_QUERY_TABS = 20; const MAX_PERSISTED_QUERY_LENGTH = 1024 * 1024; -const MAX_SQL_LOGS = 1000; +const MAX_RUNTIME_SQL_LOGS = 120; +const MAX_RUNTIME_SQL_LOG_LENGTH = 12 * 1024; +const MAX_RUNTIME_SQL_LOG_MESSAGE_LENGTH = 1024; const MAX_PERSISTED_SQL_LOGS = 200; -const MAX_PERSISTED_SQL_LOG_LENGTH = 100 * 1024; +const MAX_PERSISTED_SQL_LOG_LENGTH = 24 * 1024; const MAX_PERSISTED_SQL_LOG_MESSAGE_LENGTH = 2 * 1024; const MAX_TABLE_EXPORT_HISTORY_PER_TARGET = 20; const MAX_TABLE_EXPORT_HISTORY_TARGETS = 200; @@ -1708,50 +1710,101 @@ const resolveActiveContextForTabId = ( return fallbackContext; }; -const sanitizeSqlLogs = (value: unknown, limit = MAX_PERSISTED_SQL_LOGS): SqlLog[] => { +type SqlLogSanitizeOptions = { + limit: number; + sqlLength: number; + messageLength: number; +}; + +const RUNTIME_SQL_LOG_SANITIZE_OPTIONS: SqlLogSanitizeOptions = { + limit: MAX_RUNTIME_SQL_LOGS, + sqlLength: MAX_RUNTIME_SQL_LOG_LENGTH, + messageLength: MAX_RUNTIME_SQL_LOG_MESSAGE_LENGTH, +}; + +const PERSISTED_SQL_LOG_SANITIZE_OPTIONS: SqlLogSanitizeOptions = { + limit: MAX_PERSISTED_SQL_LOGS, + sqlLength: MAX_PERSISTED_SQL_LOG_LENGTH, + messageLength: MAX_PERSISTED_SQL_LOG_MESSAGE_LENGTH, +}; + +const sanitizeSqlLogEntry = ( + entry: unknown, + index: number, + options: SqlLogSanitizeOptions, +): SqlLog | null => { + if (!entry || typeof entry !== "object") return null; + const raw = entry as Record; + const sql = typeof raw.sql === "string" ? raw.sql.slice(0, options.sqlLength) : ""; + if (!sql.trim()) return null; + + const status = raw.status === "error" ? "error" : "success"; + const timestamp = Number(raw.timestamp); + const duration = Number(raw.duration); + const affectedRows = Number(raw.affectedRows); + const message = typeof raw.message === "string" + ? raw.message.slice(0, options.messageLength) + : ""; + + const log: SqlLog = { + id: toTrimmedString(raw.id, `log-${index + 1}`) || `log-${index + 1}`, + timestamp: Number.isFinite(timestamp) && timestamp > 0 ? timestamp : Date.now(), + sql, + status, + duration: Number.isFinite(duration) && duration >= 0 ? duration : 0, + dbName: toTrimmedString(raw.dbName) || undefined, + }; + + if (message) { + log.message = message; + } + if (Number.isFinite(affectedRows)) { + log.affectedRows = affectedRows; + } + + return log; +}; + +const sanitizeSqlLogs = ( + value: unknown, + options: SqlLogSanitizeOptions = PERSISTED_SQL_LOG_SANITIZE_OPTIONS, +): SqlLog[] => { if (!Array.isArray(value)) return []; const result: SqlLog[] = []; const seenIds = new Set(); value.forEach((entry, index) => { - if (!entry || typeof entry !== "object") return; - const raw = entry as Record; - const sql = typeof raw.sql === "string" ? raw.sql.slice(0, MAX_PERSISTED_SQL_LOG_LENGTH) : ""; - if (!sql.trim()) return; + const log = sanitizeSqlLogEntry(entry, index, options); + if (!log) return; - let id = toTrimmedString(raw.id, `log-${index + 1}`) || `log-${index + 1}`; + let id = log.id; if (seenIds.has(id)) { id = `${id}-${index + 1}`; } seenIds.add(id); - const status = raw.status === "error" ? "error" : "success"; - const timestamp = Number(raw.timestamp); - const duration = Number(raw.duration); - const affectedRows = Number(raw.affectedRows); - const log: SqlLog = { - id, - timestamp: Number.isFinite(timestamp) && timestamp > 0 ? timestamp : Date.now(), - sql, - status, - duration: Number.isFinite(duration) && duration >= 0 ? duration : 0, - dbName: toTrimmedString(raw.dbName) || undefined, - }; - - const message = typeof raw.message === "string" - ? raw.message.slice(0, MAX_PERSISTED_SQL_LOG_MESSAGE_LENGTH) - : ""; - if (message) { - log.message = message; - } - if (Number.isFinite(affectedRows)) { - log.affectedRows = affectedRows; - } - - result.push(log); + result.push(id === log.id ? log : { ...log, id }); }); - return result.slice(0, limit); + return result.slice(0, options.limit); +}; + +const sanitizeRuntimeSqlLogs = (value: unknown) => + sanitizeSqlLogs(value, RUNTIME_SQL_LOG_SANITIZE_OPTIONS); + +const sanitizePersistedSqlLogs = (value: unknown) => + sanitizeSqlLogs(value, PERSISTED_SQL_LOG_SANITIZE_OPTIONS); + +const appendRuntimeSqlLog = (existing: SqlLog[], entry: SqlLog): SqlLog[] => { + const nextEntry = sanitizeSqlLogEntry(entry, 0, RUNTIME_SQL_LOG_SANITIZE_OPTIONS); + if (!nextEntry) { + return existing; + } + + const nextLogs = [nextEntry, ...existing.slice(0, MAX_RUNTIME_SQL_LOGS - 1)]; + return existing.some((item) => item.id === nextEntry.id) + ? sanitizeRuntimeSqlLogs(nextLogs) + : nextLogs; }; const hasLegacyConnectionSecrets = ( @@ -3155,7 +3208,7 @@ export const useStore = create()( }), addSqlLog: (log) => - set((state) => ({ sqlLogs: sanitizeSqlLogs([log, ...state.sqlLogs], MAX_SQL_LOGS) })), + set((state) => ({ sqlLogs: appendRuntimeSqlLog(state.sqlLogs, log) })), clearSqlLogs: () => set({ sqlLogs: [] }), upsertTableExportHistory: (historyKey, entry) => set((state) => { @@ -3552,7 +3605,7 @@ export const useStore = create()( nextState.shortcutOptions = sanitizeShortcutOptions( state.shortcutOptions, ); - nextState.sqlLogs = sanitizeSqlLogs(state.sqlLogs); + nextState.sqlLogs = sanitizeRuntimeSqlLogs(state.sqlLogs); nextState.tableExportHistories = sanitizeTableExportHistories( state.tableExportHistories, ); @@ -3665,7 +3718,7 @@ export const useStore = create()( state.sqlEditorTransactionOptions, ), shortcutOptions: sanitizeShortcutOptions(state.shortcutOptions), - sqlLogs: sanitizeSqlLogs(state.sqlLogs), + sqlLogs: sanitizeRuntimeSqlLogs(state.sqlLogs), sqlSnippets: sanitizeSqlSnippets(state.sqlSnippets), tableAccessCount: sanitizeTableAccessCount(state.tableAccessCount), @@ -3697,7 +3750,7 @@ export const useStore = create()( dataEditTransactionOptions: state.dataEditTransactionOptions, sqlEditorTransactionOptions: state.sqlEditorTransactionOptions, shortcutOptions: resolveShortcutOptionsForPersistence(state.shortcutOptions), - sqlLogs: sanitizeSqlLogs(state.sqlLogs), + sqlLogs: sanitizePersistedSqlLogs(state.sqlLogs), tableExportHistories: sanitizeTableExportHistories( state.tableExportHistories, ),