diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 8c64a20486..30dab0f17b 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -977,7 +977,18 @@ const dataGridSearch = useDataGridSearch({ getCellSearchText: (row, columnIndex) => (row.data[columnIndex] === null ? "" : rowLowerTextCache.get(row.data, columnIndex)), onNavigate: () => nextTick(scrollToCurrentMatch), }); -const { searchText, deferredSearchText: deferredClientSearchText, overlayVisible: searchOverlayVisible, currentMatchIndex, suggestions: searchSuggestions, suggestionIndex, matches: searchMatches, matchSet: searchMatchSet, currentMatch: currentSearchMatch } = dataGridSearch; +const { + searchText, + deferredSearchText: deferredClientSearchText, + overlayVisible: searchOverlayVisible, + currentMatchIndex, + suggestions: searchSuggestions, + suggestionIndex, + matchCount: searchMatchCount, + matchAt: searchMatchAt, + matchSet: searchMatchSet, + currentMatch: currentSearchMatch, +} = dataGridSearch; const orderByInput = ref(props.initialOrderByInput ?? ""); const whereFilterInput = ref(props.initialWhereInput ?? ""); @@ -4925,8 +4936,9 @@ function navigateMatch(delta: number) { function scrollToCurrentMatch() { const idx = currentMatchIndex.value; - if (idx < 0 || idx >= searchMatches.value.length) return; - const match = searchMatches.value[idx]; + if (idx < 0 || idx >= searchMatchCount.value) return; + const match = searchMatchAt(idx); + if (!match) return; if (showTranspose.value) { scrollTransposeMatchIntoView(match); return; @@ -12359,7 +12371,7 @@ function openGridSnapshot() { :open="searchOverlayVisible" :suggestions="searchSuggestions" :suggestion-index="suggestionIndex" - :match-count="searchMatches.length" + :match-count="searchMatchCount" :current-match-index="currentMatchIndex" :has-deferred-search-text="!!deferredClientSearchText" :values-truncated="(props.result.large_value_cells?.length ?? 0) > 0" diff --git a/apps/desktop/src/composables/__tests__/useDataGridSearch.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridSearch.spec.ts index f27cf5af07..5212a017dd 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridSearch.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridSearch.spec.ts @@ -26,6 +26,21 @@ describe("useDataGridSearch", () => { expect(search.matches.value).toEqual([{ kind: "cell", displayRow: 0, col: 1 }]); // matchSet 用数值 key:(displayRow+1)*65536+col expect(search.matchSet.value.has((0 + 1) * 65536 + 1)).toBe(true); + expect(search.matchCount.value).toBe(1); + expect(search.matchAt(0)).toEqual({ kind: "cell", displayRow: 0, col: 1 }); + }); + + it("shares one scan between match keys, set, and current-match access", async () => { + vi.useFakeTimers(); + const getCellSearchText = vi.fn((row: string[], column: number) => row[column]); + const search = useDataGridSearch({ columns: ["left", "right"], rows: [["hit", "hit"]], getCellSearchText }); + search.searchText.value = "hit"; + await flushSearchDebounce(); + + expect(search.matchSet.value.size).toBe(2); + expect(search.currentMatch.value).toEqual({ kind: "cell", displayRow: 0, col: 0 }); + expect(search.matchCount.value).toBe(2); + expect(getCellSearchText).toHaveBeenCalledTimes(2); }); it("keys column-name matches with displayRow -1", async () => { diff --git a/apps/desktop/src/composables/useDataGridSearch.ts b/apps/desktop/src/composables/useDataGridSearch.ts index b1c25e6986..ed2b521e34 100644 --- a/apps/desktop/src/composables/useDataGridSearch.ts +++ b/apps/desktop/src/composables/useDataGridSearch.ts @@ -7,6 +7,8 @@ export type DataGridSearchMatch = { col: number; }; +const SEARCH_MATCH_KEY_BASE = 65536; + export type UseDataGridSearchOptions = { columns: MaybeRefOrGetter; suggestionColumns?: MaybeRefOrGetter; @@ -31,24 +33,39 @@ export function useDataGridSearch(options: UseDataGridSearchOptions) { const suggestionIndex = ref(-1); let searchTimer: ReturnType | undefined; - const matches = computed(() => { + const matchState = computed(() => { const query = deferredSearchText.value; - if (!query) return []; - const result: DataGridSearchMatch[] = []; + const keys: number[] = []; + const matchSet = new Set(); + if (!query) return { keys, matchSet }; + + const addMatch = (displayRow: number, col: number) => { + const key = dataGridSearchMatchKey(displayRow, col); + keys.push(key); + matchSet.add(key); + }; const columns = toValue(options.columns); columns.forEach((column, col) => { - if (column.toLowerCase().includes(query)) result.push({ kind: "column", displayRow: -1, col }); + if (column.toLowerCase().includes(query)) addMatch(-1, col); }); toValue(options.rows).forEach((row, displayRow) => { columns.forEach((_, col) => { - if (options.getCellSearchText(row, col).includes(query)) result.push({ kind: "cell", displayRow, col }); + if (options.getCellSearchText(row, col).includes(query)) addMatch(displayRow, col); }); }); - return result; + return { keys, matchSet }; }); - // 数值 key(列头匹配 displayRow=-1),避免每匹配一次字符串拼接 - const matchSet = computed(() => new Set(matches.value.map((match) => dataGridSearchMatchKey(match.displayRow, match.col)))); - const currentMatch = computed(() => matches.value[currentMatchIndex.value] ?? null); + const matchKeys = computed(() => matchState.value.keys); + const matchSet = computed(() => matchState.value.matchSet); + const matches = computed(() => matchKeys.value.map(dataGridSearchMatchFromKey)); + const matchCount = computed(() => matchKeys.value.length); + + function matchAt(index: number): DataGridSearchMatch | null { + const key = matchKeys.value[index]; + return key === undefined ? null : dataGridSearchMatchFromKey(key); + } + + const currentMatch = computed(() => matchAt(currentMatchIndex.value)); function clearTimer() { if (searchTimer !== undefined) clearTimeout(searchTimer); @@ -70,9 +87,10 @@ export function useDataGridSearch(options: UseDataGridSearchOptions) { suggestionIndex.value = suggestions.value.length ? 0 : -1; }); - watch(matches, (value) => { + watch(matchKeys, (value) => { currentMatchIndex.value = value.length ? 0 : -1; - if (value[0]) nextTick(() => options.onNavigate?.(value[0])); + const firstMatch = matchAt(0); + if (firstMatch) nextTick(() => options.onNavigate?.(firstMatch)); }); function acceptSuggestion(index = suggestionIndex.value) { @@ -91,11 +109,11 @@ export function useDataGridSearch(options: UseDataGridSearchOptions) { } function navigateMatch(delta: number) { - const matchCount = matches.value.length; - if (!matchCount) return; + const count = matchKeys.value.length; + if (!count) return; // Results may change between input and navigation; recover from a stale index in the requested direction. - const currentIndex = currentMatchIndex.value >= 0 && currentMatchIndex.value < matchCount ? currentMatchIndex.value : delta < 0 ? 0 : -1; - currentMatchIndex.value = (((currentIndex + delta) % matchCount) + matchCount) % matchCount; + const currentIndex = currentMatchIndex.value >= 0 && currentMatchIndex.value < count ? currentMatchIndex.value : delta < 0 ? 0 : -1; + currentMatchIndex.value = (((currentIndex + delta) % count) + count) % count; const match = currentMatch.value; if (match) options.onNavigate?.(match); } @@ -134,5 +152,14 @@ export function useDataGridSearch(options: UseDataGridSearchOptions) { if (getCurrentScope()) onScopeDispose(clearTimer); - return { searchText, deferredSearchText, overlayVisible, currentMatchIndex, suggestions, suggestionIndex, matches, matchSet, currentMatch, acceptSuggestion, navigateSuggestion, navigateMatch, close, onKeydown }; + return { searchText, deferredSearchText, overlayVisible, currentMatchIndex, suggestions, suggestionIndex, matches, matchKeys, matchCount, matchAt, matchSet, currentMatch, acceptSuggestion, navigateSuggestion, navigateMatch, close, onKeydown }; +} + +function dataGridSearchMatchFromKey(key: number): DataGridSearchMatch { + const displayRow = Math.floor(key / SEARCH_MATCH_KEY_BASE) - 1; + return { + kind: displayRow === -1 ? "column" : "cell", + displayRow, + col: key % SEARCH_MATCH_KEY_BASE, + }; }