diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index accd57cc2..e7bec3175 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -131,7 +131,8 @@ import { dataGridHeaderContentWidth, scrollbarGutterWidth } from "@/lib/dataGrid import { canGoNextDataGridPage } from "@/lib/dataGrid/dataGridPagination"; import { dataGridCountQueryOptions } from "@/lib/dataGrid/dataGridQueryOptions"; import { dataGridBottomScrollTop, dataGridScrollPosition, isDataGridAtScrollBottom, isDataGridNearScrollBottom, shouldCheckInfiniteScrollAfterScroll, type DataGridScrollPosition } from "@/lib/dataGrid/dataGridInfiniteScroll"; -import { CANVAS_DATA_GRID_ROW_HEIGHT, drawCanvasDataGrid } from "@/lib/dataGrid/canvasDataGridRenderer"; +import { CANVAS_DATA_GRID_ROW_HEIGHT, dataGridSearchMatchKey, drawCanvasDataGrid } from "@/lib/dataGrid/canvasDataGridRenderer"; +import { createRowLowerTextCache } from "@/lib/dataGrid/dataGridRowLowerText"; import { dataGridPreviewLabelKey, dataGridSaveActionMode, dataGridSaveToolbarState } from "@/lib/dataGrid/dataGridSaveUi"; import type { QueryEditabilityReason } from "@/lib/sql/sqlAnalysis"; import { EDITOR_FONT_FAMILY_CSS_VAR } from "@/lib/editor/editorThemes"; @@ -558,7 +559,7 @@ const dataGridSearch = useDataGridSearch({ columns: () => props.result.columns, suggestionColumns: () => props.tableMeta?.columns.map((column) => column.name) ?? props.result.columns, rows: () => displayItems.value, - getCellText: (row, columnIndex) => (row.data[columnIndex] === null ? "" : formatCellCached(row.data[columnIndex], columnIndex)), + 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; @@ -3061,7 +3062,7 @@ const sortedRows = computed(() => { const rows = props.result.rows; indices = indices.filter((sourceIndex) => { const data = rows[sourceIndex]; - return data.some((cell, columnIndex) => cell !== null && formatCellCached(cell, columnIndex).toLowerCase().includes(q)); + return data.some((cell, columnIndex) => cell !== null && rowLowerTextCache.get(data, columnIndex).includes(q)); }); } return indices; @@ -3203,7 +3204,7 @@ watch( function cellIsSearchMatch(displayRow: number, col: number): boolean { if (isScrolling.value) return false; - return searchMatchSet.value.has(`cell:${displayRow}:${col}`); + return searchMatchSet.value.has(dataGridSearchMatchKey(displayRow, col)); } function cellIsCurrentMatch(displayRow: number, col: number): boolean { @@ -3217,7 +3218,7 @@ function cellIsCurrentMatch(displayRow: number, col: number): boolean { // maps to the field row header at the field's column index. function transposeHeaderIsSearchMatch(fieldIndex: number): boolean { if (isScrolling.value) return false; - return searchMatchSet.value.has(`column:-1:${fieldIndex}`); + return searchMatchSet.value.has(dataGridSearchMatchKey(-1, fieldIndex)); } function transposeHeaderIsCurrentMatch(fieldIndex: number): boolean { @@ -4148,6 +4149,9 @@ const resolvedColumnFormatters = computed(() => props.result.columns.map((_, col const columnFormatterSignatures = computed(() => resolvedColumnFormatters.value.map(formatterSignature)); const primitiveCellFormatCache = new Map(); let objectCellFormatCache = new WeakMap>(); +// 搜索用小写文本缓存:按行数组身份挂 WeakMap(工作集=当前结果行,GC 回收), +// 不用固定容量 LRU——搜索是全量顺序扫描,超限修剪会导致第二遍零命中抖动 +const rowLowerTextCache = createRowLowerTextCache(formatCellCached); function formatterSignature(formatter: ColumnFormatterConfig | undefined): string { return formatter ? JSON.stringify(formatter) : ""; @@ -4156,6 +4160,7 @@ function formatterSignature(formatter: ColumnFormatterConfig | undefined): strin function clearCellFormatCache() { primitiveCellFormatCache.clear(); objectCellFormatCache = new WeakMap>(); + rowLowerTextCache.clear(); } function rememberPrimitiveCellFormat(key: string, display: string): string { diff --git a/apps/desktop/src/composables/__tests__/useDataGridSearch.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridSearch.spec.ts index 4340ca5f0..e1714a640 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridSearch.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridSearch.spec.ts @@ -5,19 +5,34 @@ import { useDataGridSearch } from "@/composables/useDataGridSearch"; describe("useDataGridSearch", () => { it("debounces matching across columns and cells", async () => { vi.useFakeTimers(); - const search = useDataGridSearch({ columns: ["id", "name"], rows: [[1, "Alice"]], getCellText: (row, column) => String(row[column] ?? "") }); + // getCellSearchText 契约:返回小写文本(调用方负责缓存小写副本) + const search = useDataGridSearch({ columns: ["id", "name"], rows: [[1, "Alice"]], getCellSearchText: (row, column) => String(row[column] ?? "").toLowerCase() }); search.searchText.value = "ali"; await nextTick(); expect(search.matches.value).toEqual([]); vi.advanceTimersByTime(150); await nextTick(); 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); + vi.useRealTimers(); + }); + + it("keys column-name matches with displayRow -1", async () => { + vi.useFakeTimers(); + const search = useDataGridSearch({ columns: ["id", "name"], rows: [], getCellSearchText: () => "" }); + search.searchText.value = "nam"; + await nextTick(); + vi.advanceTimersByTime(150); + await nextTick(); + expect(search.matches.value).toEqual([{ kind: "column", displayRow: -1, col: 1 }]); + expect(search.matchSet.value.has((-1 + 1) * 65536 + 1)).toBe(true); vi.useRealTimers(); }); it("suggests columns and replaces only the active token", async () => { const columns = ref(["customer_id", "created_at"]); - const search = useDataGridSearch({ columns, rows: [], getCellText: () => "" }); + const search = useDataGridSearch({ columns, rows: [], getCellSearchText: () => "" }); search.searchText.value = "status = cus"; await nextTick(); expect(search.suggestions.value).toEqual(["customer_id"]); diff --git a/apps/desktop/src/composables/useDataGridSearch.ts b/apps/desktop/src/composables/useDataGridSearch.ts index 9fc8480fa..7b2233c29 100644 --- a/apps/desktop/src/composables/useDataGridSearch.ts +++ b/apps/desktop/src/composables/useDataGridSearch.ts @@ -1,4 +1,5 @@ import { computed, getCurrentScope, nextTick, onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter } from "vue"; +import { dataGridSearchMatchKey } from "@/lib/dataGrid/canvasDataGridRenderer"; export type DataGridSearchMatch = { kind: "cell" | "column"; @@ -10,7 +11,9 @@ export type UseDataGridSearchOptions = { columns: MaybeRefOrGetter; suggestionColumns?: MaybeRefOrGetter; rows: MaybeRefOrGetter; - getCellText: (row: Row, columnIndex: number) => string; + /** 必须返回小写文本(查询词已小写)。调用方可据此缓存小写副本, + * 避免每次按键对全部单元格重新分配 toLowerCase 字符串。 */ + getCellSearchText: (row: Row, columnIndex: number) => string; debounceMs?: number; onNavigate?: (match: DataGridSearchMatch) => void; }; @@ -38,12 +41,13 @@ export function useDataGridSearch(options: UseDataGridSearchOptions) { }); toValue(options.rows).forEach((row, displayRow) => { columns.forEach((_, col) => { - if (options.getCellText(row, col).toLowerCase().includes(query)) result.push({ kind: "cell", displayRow, col }); + if (options.getCellSearchText(row, col).includes(query)) result.push({ kind: "cell", displayRow, col }); }); }); return result; }); - const matchSet = computed(() => new Set(matches.value.map((match) => `${match.kind}:${match.displayRow}:${match.col}`))); + // 数值 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); function clearTimer() { diff --git a/apps/desktop/src/lib/dataGrid/canvasDataGridRenderer.ts b/apps/desktop/src/lib/dataGrid/canvasDataGridRenderer.ts index 7f8ab5440..142114ee9 100644 --- a/apps/desktop/src/lib/dataGrid/canvasDataGridRenderer.ts +++ b/apps/desktop/src/lib/dataGrid/canvasDataGridRenderer.ts @@ -25,6 +25,13 @@ export interface CanvasEditingCell { col: number; } +/** 搜索匹配的数值 key:列头匹配 displayRow 为 -1。相比字符串拼接 key, + * 每次按键构建 matchSet、每帧对可见单元格查询都零字符串分配。 + * ponytail: 列数上限 65536,网格列数远达不到 */ +export function dataGridSearchMatchKey(displayRow: number, col: number): number { + return (displayRow + 1) * 65536 + col; +} + export interface CanvasSearchMatch { kind: "cell" | "column"; displayRow: number; @@ -50,7 +57,7 @@ export interface DrawCanvasDataGridOptions { hoverCell: CanvasHoverCell | null; isScrolling: boolean; editingCell: CanvasEditingCell | null; - searchMatchKeys: ReadonlySet; + searchMatchKeys: ReadonlySet; currentSearchMatch: CanvasSearchMatch | null; formatCell: (value: CellValue, columnIndex: number) => string; draftCellPlaceholder?: string; @@ -334,7 +341,7 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) { const isDirtyCell = item.isDirtyCol[actualColIdx]; const selectedFillVisual = rowSelectionVisual || selectedCell; const selectedBorderVisual = rowSelectionVisual || selectedCell; - const isSearchMatch = paintSearchMatches && searchMatchKeys.has(`cell:${item.displayIndex}:${actualColIdx}`); + const isSearchMatch = paintSearchMatches && searchMatchKeys.has(dataGridSearchMatchKey(item.displayIndex, actualColIdx)); const isCurrentSearchMatch = paintSearchMatches && currentSearchMatch?.displayRow === item.displayIndex && currentSearchMatch.col === actualColIdx; const clippedX = Math.max(drawX, rowNumberWidth); const cellPaintWidth = Math.min(width, drawX + colWidth) - clippedX; diff --git a/apps/desktop/src/lib/dataGrid/dataGridRowLowerText.ts b/apps/desktop/src/lib/dataGrid/dataGridRowLowerText.ts new file mode 100644 index 000000000..147f819b8 --- /dev/null +++ b/apps/desktop/src/lib/dataGrid/dataGridRowLowerText.ts @@ -0,0 +1,38 @@ +import type { CellValue } from "@/lib/dataGrid/cellValue"; + +type RowLowerTextEntry = { + /** 每列缓存时的源值引用:单元格被原地编辑后引用不再相等,仅重算该格 */ + values: CellValue[]; + lowers: (string | undefined)[]; +}; + +/** + * 搜索用小写文本缓存,按行数组身份挂 WeakMap: + * - 工作集天然等于当前在内存中的结果行(行数组是 markRaw 的稳定引用), + * 不存在固定容量 LRU 在全量顺序扫描下的零命中抖动,GC 自动回收旧结果; + * - 展示格式化器/列类型变化时由调用方 clear() 整体失效。 + */ +export function createRowLowerTextCache(format: (value: CellValue, columnIndex: number) => string) { + let cache = new WeakMap(); + + function get(rowData: CellValue[], columnIndex: number): string { + let entry = cache.get(rowData); + if (!entry) { + entry = { values: [], lowers: [] }; + cache.set(rowData, entry); + } + const value = rowData[columnIndex]; + const cached = entry.lowers[columnIndex]; + if (cached !== undefined && Object.is(entry.values[columnIndex], value)) return cached; + const lower = format(value, columnIndex).toLowerCase(); + entry.values[columnIndex] = value; + entry.lowers[columnIndex] = lower; + return lower; + } + + function clear() { + cache = new WeakMap(); + } + + return { get, clear }; +} diff --git a/packages/app-tests/dataGridRowLowerText.test.ts b/packages/app-tests/dataGridRowLowerText.test.ts new file mode 100644 index 000000000..be4f19c94 --- /dev/null +++ b/packages/app-tests/dataGridRowLowerText.test.ts @@ -0,0 +1,53 @@ +import { strict as assert } from "node:assert"; +import { test } from "vitest"; +import { createRowLowerTextCache } from "../../apps/desktop/src/lib/dataGrid/dataGridRowLowerText.ts"; +import type { CellValue } from "../../apps/desktop/src/lib/dataGrid/cellValue.ts"; + +test("memoizes lowercase text per cell across repeated full scans", () => { + let formatCalls = 0; + const cache = createRowLowerTextCache((value) => { + formatCalls++; + return String(value); + }); + const rows: CellValue[][] = Array.from({ length: 1000 }, (_, i) => [i, `Name-${i}`, `MAIL${i}@X.COM`]); + + // 第一遍全量扫描:每格格式化一次 + for (const row of rows) for (let col = 0; col < 3; col++) cache.get(row, col); + assert.equal(formatCalls, 3000); + assert.equal(cache.get(rows[0]!, 2), "mail0@x.com"); + + // 第二遍相同顺序扫描:必须全部命中(固定容量 LRU 在此场景会零命中) + for (const row of rows) for (let col = 0; col < 3; col++) cache.get(row, col); + assert.equal(formatCalls, 3000); +}); + +test("recomputes only the edited cell after in-place mutation", () => { + let formatCalls = 0; + const cache = createRowLowerTextCache((value) => { + formatCalls++; + return String(value); + }); + const row: CellValue[] = ["A", "B"]; + assert.equal(cache.get(row, 0), "a"); + assert.equal(cache.get(row, 1), "b"); + assert.equal(formatCalls, 2); + + // 保存后原地写回单元格:仅该格重算 + row[0] = "Changed"; + assert.equal(cache.get(row, 0), "changed"); + assert.equal(cache.get(row, 1), "b"); + assert.equal(formatCalls, 3); +}); + +test("clear() invalidates everything (formatter/column-type changes)", () => { + let formatCalls = 0; + const cache = createRowLowerTextCache((value) => { + formatCalls++; + return String(value); + }); + const row: CellValue[] = ["X"]; + cache.get(row, 0); + cache.clear(); + cache.get(row, 0); + assert.equal(formatCalls, 2); +});