️ perf(sidebar): 优化 v2 命令搜索输入和结果展示

- 修复中文输入法组合输入时按 Enter 误关闭搜索弹窗
- 限制搜索弹窗关闭方式为 ESC 或有效结果确认
- 移除关键词搜索下已加载表结果的固定条数截断
- 同步筛选开启时使用 deferred 值和防抖持久化,降低输入卡顿
- 补充命令搜索 Enter 判定和表匹配完整性测试
This commit is contained in:
Syngnat
2026-06-02 13:40:48 +08:00
parent 3a2db112f3
commit 8c88017703
2 changed files with 160 additions and 46 deletions

View File

@@ -8,11 +8,13 @@ import Sidebar, {
buildV2SidebarTableSectionedChildren,
buildV2RailConnectionGroups,
estimateV2TreeHorizontalScrollWidth,
filterV2CommandSearchTreeItems,
filterV2ExplorerTreeByKind,
getV2RailConnectionGroupBadgeText,
hasSidebarLazyChildren,
normalizeSidebarTreeRelativeDropPosition,
parseV2CommandSearchQuery,
type V2CommandSearchItem,
resolveSidebarDropNodeFromDomEvent,
resolveSidebarTagDropInsertBefore,
resolveSidebarDropTargetMetricsFromDomEvent,
@@ -25,6 +27,7 @@ import Sidebar, {
shouldSkipSidebarLoadOnExpandWhileDragging,
shouldSkipSidebarSelectWhileDragging,
shouldLoadSidebarNodeOnExpand,
shouldRunV2CommandSearchEnter,
sortSidebarTableEntries,
} from './Sidebar';
import {
@@ -274,6 +277,80 @@ describe('Sidebar locate toolbar', () => {
});
});
it('only runs v2 command search enter for a real selected result outside IME composition', () => {
expect(shouldRunV2CommandSearchEnter({
key: 'Enter',
activeItemCount: 1,
})).toBe(true);
expect(shouldRunV2CommandSearchEnter({
key: 'Enter',
isComposing: true,
activeItemCount: 1,
})).toBe(false);
expect(shouldRunV2CommandSearchEnter({
key: 'Enter',
keyCode: 229,
activeItemCount: 1,
})).toBe(false);
expect(shouldRunV2CommandSearchEnter({
key: 'Enter',
activeItemCount: 0,
})).toBe(false);
expect(shouldRunV2CommandSearchEnter({
key: 'Escape',
activeItemCount: 1,
})).toBe(false);
});
it('keeps all loaded v2 command table matches once a keyword is entered', () => {
const items: V2CommandSearchItem[] = Array.from({ length: 40 }, (_, index) => ({
key: `node-table-${index}`,
kind: 'node' as const,
title: `fs_order_${index}`,
meta: '开发240 · front_end_sys',
icon: null,
node: {
type: 'table',
key: `table-${index}`,
title: `fs_order_${index}`,
dataRef: {
tableName: `fs_order_${index}`,
dbName: 'front_end_sys',
},
},
}));
expect(filterV2CommandSearchTreeItems(
items,
parseV2CommandSearchQuery('fs_order'),
)).toHaveLength(40);
expect(filterV2CommandSearchTreeItems(
items,
parseV2CommandSearchQuery(''),
)).toHaveLength(24);
expect(filterV2CommandSearchTreeItems(
[
...items,
{
key: 'node-db',
kind: 'node' as const,
title: 'front_end_sys',
meta: '开发240',
icon: null,
node: {
type: 'database',
key: 'db-front-end-sys',
title: 'front_end_sys',
dataRef: {
dbName: 'front_end_sys',
},
},
},
],
parseV2CommandSearchQuery('@fs_order'),
)).toHaveLength(40);
});
it('keeps the v2 active host on the selected database connection', () => {
const connectionIds = ['local', 'dev240', 'dev241'];
const databaseNode = {

View File

@@ -512,7 +512,7 @@ interface BatchObjectItem {
dataRef: any;
}
type V2CommandSearchItem =
export type V2CommandSearchItem =
| {
key: string;
kind: 'node';
@@ -587,6 +587,66 @@ export const parseV2CommandSearchQuery = (value: unknown): V2CommandSearchQuery
};
};
const isV2CommandSearchObjectNode = (node: TreeNode): boolean => {
return node.type === 'table'
|| node.type === 'view'
|| node.type === 'materialized-view';
};
const V2_COMMAND_SEARCH_INITIAL_TREE_LIMIT = 24;
export const filterV2CommandSearchTreeItems = (
items: V2CommandSearchItem[],
query: V2CommandSearchQuery,
): V2CommandSearchItem[] => {
if (query.mode === 'ai') return [];
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;
}
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);
}
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);
};
export interface V2CommandSearchEnterState {
key: string;
isComposing?: boolean;
keyCode?: number;
activeItemCount: number;
}
export const shouldRunV2CommandSearchEnter = ({
key,
isComposing,
keyCode,
activeItemCount,
}: V2CommandSearchEnterState): boolean => {
if (key !== 'Enter') return false;
if (isComposing || keyCode === 229) return false;
return activeItemCount > 0;
};
export const resolveSidebarConnectionIdFromKey = (
key: unknown,
connectionIds: string[],
@@ -1036,6 +1096,7 @@ const Sidebar: React.FC<{
const commandSearchInputRef = useRef<any>(null);
const [isV2CommandSearchOpen, setIsV2CommandSearchOpen] = useState(false);
const [v2CommandSearchValue, setV2CommandSearchValue] = useState('');
const deferredV2CommandSearchValue = useDeferredValue(v2CommandSearchValue);
const [v2CommandActiveIndex, setV2CommandActiveIndex] = useState(0);
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]);
const [autoExpandParent, setAutoExpandParent] = useState(true);
@@ -1116,13 +1177,19 @@ const Sidebar: React.FC<{
const handleV2CommandSearchValueChange = useCallback((value: string) => {
setV2CommandSearchValue(value);
}, []);
useEffect(() => {
if (!v2CommandSearchPersistentFilterEnabled) {
return;
}
const nextFilter = value.trim();
const nextFilter = deferredV2CommandSearchValue.trim();
setSearchValue(nextFilter);
setAppearance({ v2SidebarPersistedFilter: nextFilter });
}, [setAppearance, v2CommandSearchPersistentFilterEnabled]);
const timer = window.setTimeout(() => {
setAppearance({ v2SidebarPersistedFilter: nextFilter });
}, 160);
return () => window.clearTimeout(timer);
}, [deferredV2CommandSearchValue, setAppearance, v2CommandSearchPersistentFilterEnabled]);
const toggleV2CommandSearchPersistentFilter = useCallback((enabled: boolean) => {
const nextFilter = enabled ? v2CommandSearchValue.trim() : '';
@@ -5965,49 +6032,15 @@ const Sidebar: React.FC<{
], [activeShortcutPlatform, onCreateConnection, onToggleAI, onToggleLogPanel, shortcutOptions]);
const v2CommandSearchQuery = useMemo(
() => parseV2CommandSearchQuery(v2CommandSearchValue),
[v2CommandSearchValue],
() => parseV2CommandSearchQuery(deferredV2CommandSearchValue),
[deferredV2CommandSearchValue],
);
const normalizedV2CommandSearchValue = v2CommandSearchQuery.normalizedKeyword;
const v2CommandSearchObjectMode = v2CommandSearchQuery.mode === 'object';
const v2CommandSearchAiMode = v2CommandSearchQuery.mode === 'ai';
const filteredCommandSearchTreeItems = useMemo(() => {
if (v2CommandSearchAiMode) return [];
const matchLimit = v2CommandSearchObjectMode ? 16 : 8;
if (!normalizedV2CommandSearchValue) {
return commandSearchTreeItems
.filter((item) => !v2CommandSearchObjectMode || (
item.kind === 'node'
&& (item.node.type === 'table' || item.node.type === 'view' || item.node.type === 'materialized-view')
))
.slice(0, matchLimit);
}
return commandSearchTreeItems
.filter((item) => {
if (item.kind !== 'node') return false;
const node = item.node;
const dataRef = node.dataRef || {};
if (v2CommandSearchObjectMode && node.type !== 'table' && node.type !== 'view' && node.type !== 'materialized-view') {
return false;
}
const tableName = String(dataRef.tableName || dataRef.viewName || item.title || '').toLowerCase();
if (v2CommandSearchObjectMode) {
return tableName.includes(normalizedV2CommandSearchValue)
|| String(item.title || '').toLowerCase().includes(normalizedV2CommandSearchValue);
}
const haystack = [
item.title,
item.meta,
dataRef.tableName,
dataRef.viewName,
dataRef.dbName,
dataRef.name,
dataRef.config?.host,
].filter(Boolean).join(' ').toLowerCase();
return haystack.includes(normalizedV2CommandSearchValue);
})
.slice(0, matchLimit);
}, [commandSearchTreeItems, normalizedV2CommandSearchValue, v2CommandSearchAiMode, v2CommandSearchObjectMode]);
return filterV2CommandSearchTreeItems(commandSearchTreeItems, v2CommandSearchQuery);
}, [commandSearchTreeItems, v2CommandSearchQuery]);
const filteredCommandSearchActionItems = useMemo(() => {
if (v2CommandSearchObjectMode || v2CommandSearchAiMode) return [];
@@ -6840,11 +6873,15 @@ const Sidebar: React.FC<{
return;
}
if (event.key === 'Enter') {
event.preventDefault();
if (v2CommandSearchAiMode && !v2CommandSearchQuery.aiPrompt) {
message.warning('请输入要问 AI 的问题');
if (!shouldRunV2CommandSearchEnter({
key: event.key,
isComposing: event.nativeEvent.isComposing,
keyCode: event.nativeEvent.keyCode,
activeItemCount: commandSearchFlatItems.length,
})) {
return;
}
event.preventDefault();
runCommandSearchItem(commandSearchFlatItems[v2CommandActiveIndex]);
return;
}
@@ -6893,7 +6930,7 @@ const Sidebar: React.FC<{
? '未找到匹配的表、视图或物化视图。'
: '未找到匹配项。可输入 @表名 只搜表对象,或输入 ?问题 让 AI 回答。');
return (
<div className="gn-v2-command-backdrop" data-v2-command-search="true" onMouseDown={closeV2CommandSearch}>
<div className="gn-v2-command-backdrop" data-v2-command-search="true">
<div className="gn-v2-command-palette" role="dialog" aria-modal="true" aria-label="搜索表、连接、动作" onMouseDown={(event) => event.stopPropagation()}>
<div className="gn-v2-command-searchbar">
<SearchOutlined />