️ 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

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