️ perf(frontend): 优化长时运行下的搜索与缓存占用

- 为 V2 cmd+k 搜索预建索引并限制初始/宽泛结果数量
- 清理冷数据库树和 DataViewer 长生命周期快照缓存
- 收紧运行时 SQL 日志预算并在 hydration 时压缩旧缓存
This commit is contained in:
Syngnat
2026-06-22 22:36:39 +08:00
parent 05e8dab710
commit 8f1e6cf379
10 changed files with 506 additions and 134 deletions

View File

@@ -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,

View File

@@ -280,8 +280,32 @@ type ViewerScrollSnapshot = {
};
const viewerFilterSnapshotsByTab = new Map<string, ViewerFilterSnapshot>();
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<ViewerFilterSnapshot>) => {
const normalizedTabId = String(tabId || '').trim();
if (!normalizedTabId) return;
viewerFilterSnapshotsByTab.set(normalizedTabId, {
setViewerFilterSnapshot(normalizedTabId, {
showFilter,
conditions: normalizeViewerFilterConditions(filterConditions),
quickWhereCondition: normalizeQuickWhereCondition(quickWhereCondition),

View File

@@ -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目录'");

View File

@@ -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<React.Key[]>([]);
const selectedNodesRef = useRef<any[]>([]);
const loadingNodesRef = useRef<Set<string>>(new Set());
const databaseTreeTouchedAtRef = useRef<Record<string, number>>({});
const pruneLoadedDatabaseTreesRef = useRef<() => void>(() => {});
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | 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,

View File

@@ -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<V2CommandSearchItem[]>(() => {
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 [];

View File

@@ -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<string, any>;
tableAccessCount: Record<string, any>;
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<Record<string, ExternalSQLTreeEntry[]>>((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` });

View File

@@ -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']);
});
});

View File

@@ -415,6 +415,13 @@ export type V2CommandSearchItem =
dbName?: string;
};
export interface V2CommandSearchTreeIndexEntry {
item: Extract<V2CommandSearchItem, { kind: 'node' }>;
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<string, number>;
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<string>();
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;

View File

@@ -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 () => {

View File

@@ -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<string, unknown>;
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<string>();
value.forEach((entry, index) => {
if (!entry || typeof entry !== "object") return;
const raw = entry as Record<string, unknown>;
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<AppState>()(
}),
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<AppState>()(
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<AppState>()(
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<AppState>()(
dataEditTransactionOptions: state.dataEditTransactionOptions,
sqlEditorTransactionOptions: state.sqlEditorTransactionOptions,
shortcutOptions: resolveShortcutOptionsForPersistence(state.shortcutOptions),
sqlLogs: sanitizeSqlLogs(state.sqlLogs),
sqlLogs: sanitizePersistedSqlLogs(state.sqlLogs),
tableExportHistories: sanitizeTableExportHistories(
state.tableExportHistories,
),