From 8bafe0916cbc596520f178da5dba625bcf0976b4 Mon Sep 17 00:00:00 2001 From: ccq <76206374+caichangqing1120@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:48:16 +0800 Subject: [PATCH] fix(grid): replace matches in truncated large values --- apps/desktop/src/components/grid/DataGrid.vue | 49 ++++++++++++------- .../DataGridLargeValueReplace.spec.ts | 23 +++++++++ .../__tests__/useDataGridEditor.spec.ts | 18 +++++++ .../src/composables/useDataGridEditor.ts | 4 +- .../dataGrid/dataGridReplace.spec.ts | 46 ++++++++++++++++- .../src/lib/dataGrid/dataGridReplace.ts | 41 +++++++++++++++- 6 files changed, 157 insertions(+), 24 deletions(-) create mode 100644 apps/desktop/src/components/grid/__tests__/DataGridLargeValueReplace.spec.ts diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index ab742f0dde..af254a6943 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -311,7 +311,7 @@ import { createDataGridRuntimeScope } from "@/lib/dataGrid/dataGridRuntime"; import { useDataGridEditor } from "@/composables/useDataGridEditor"; import { useDataGridSort } from "@/composables/useDataGridSort"; import { useDataGridSearch, type DataGridSearchMatch } from "@/composables/useDataGridSearch"; -import { findDataGridReplacementMatches, replaceDataGridText, type DataGridReplaceScope } from "@/lib/dataGrid/dataGridReplace"; +import { findDataGridReplacementMatches, prepareDataGridCellReplacements, type DataGridReplaceScope } from "@/lib/dataGrid/dataGridReplace"; import { useDataGridResultLifecycle } from "@/composables/useDataGridResultLifecycle"; import { useDataGridAutoRefresh } from "@/composables/useDataGridAutoRefresh"; import { useDataGridAsyncSurface } from "@/composables/useDataGridAsyncSurface"; @@ -8160,29 +8160,19 @@ function selectedRangeTargetsOnlyDraftRow(): boolean { } const replaceAvailable = computed(() => !!props.editable && hasDataGridSaveTarget.value && canEditExistingRows.value && !resolvedConnectionConfig.value?.read_only && !isConditionalUpdateActive.value); -const replaceBusy = computed(() => isSaving.value || gridSurfaceBusy.value || props.loading === true); +const replaceResolving = ref(false); +const replaceBusy = computed(() => replaceResolving.value || isSaving.value || gridSurfaceBusy.value || props.loading === true); function replacementRowItem(rowId: number): RowItem | undefined { const row = props.result.rows[rowId]; if (!row || rowId < 0) return undefined; - return { id: rowId, displayIndex: displayRowIndexById(rowId), sourceIndex: rowId, data: rowDataWithChanges(row, rowId), isNew: false, isDeleted: deletedRows.value.has(rowId), isDirtyCol: [], status: dirtyRows.value.has(rowId) ? "edited" : "clean" }; + const dirty = dirtyRows.value.get(rowId); + return { id: rowId, displayIndex: displayRowIndexById(rowId), sourceIndex: rowId, data: rowDataWithChanges(row, rowId), isNew: false, isDeleted: deletedRows.value.has(rowId), isDirtyCol: dirtyColumnsForRow(dirty, props.result.columns.length), status: dirty?.size ? "edited" : "clean" }; } function canReplaceGridCell(item: RowItem | undefined, col: number): boolean { const type = allColumnTypes.value[col]; - return ( - replaceAvailable.value && - !!item && - item.sourceIndex !== undefined && - !item.isNew && - !item.isDraft && - typeof item.data[col] === "string" && - canEditCellItem(item, col) && - !isLargeValuePreview(item, col) && - !isBinaryCellColumnType(type) && - !isNumericColumnType(type) && - !isBooleanGridCell(item, col) - ); + return replaceAvailable.value && !!item && item.sourceIndex !== undefined && !item.isNew && !item.isDraft && typeof item.data[col] === "string" && canEditCellItem(item, col) && !isBinaryCellColumnType(type) && !isNumericColumnType(type) && !isBooleanGridCell(item, col); } function replacementCellInScope(rowId: number, col: number): boolean { @@ -8227,14 +8217,35 @@ watch( }, ); -function replaceGridMatches(currentOnly = false) { +async function replaceGridMatches(currentOnly = false) { if (!replaceAvailable.value || replaceBusy.value) return; if (currentOnly && !canReplaceCurrent.value) return; const current = currentSearchMatch.value; const currentRowId = current?.kind === "cell" ? displayItemAt(current.displayRow)?.id : undefined; const matches = replacementMatches.value.filter((match) => !currentOnly || (match.rowId === currentRowId && match.col === current?.col)); - const count = stageCellReplacements(matches.map((match) => ({ ...match, previousValue: match.value, value: replaceDataGridText(match.value, deferredClientSearchText.value, replacementText.value, replaceCaseSensitive.value) }))); - if (count > 0) toast(t("grid.replaceStagedCells", { count }), 5000); + if (!matches.length) return; + const sourceResult = props.result; + const search = deferredClientSearchText.value; + const replacement = replacementText.value; + const caseSensitive = replaceCaseSensitive.value; + replaceResolving.value = true; + try { + const changes = await prepareDataGridCellReplacements({ + matches, + search, + replacement, + caseSensitive, + needsResolution: (rowId, col) => isLargeValuePreview(replacementRowItem(rowId), col), + resolveValues: resolveLargeValueCells, + }); + if (props.result !== sourceResult) return; + const count = stageCellReplacements(changes); + if (count > 0) toast(t("grid.replaceStagedCells", { count }), 5000); + } catch (error) { + if (props.result === sourceResult) reportLargeValueLoadError(error); + } finally { + replaceResolving.value = false; + } } function fillSelectionWithValue(value: string | null, options: { preserveEmptyString?: boolean; emptyStringAsNull?: boolean } = {}): boolean { diff --git a/apps/desktop/src/components/grid/__tests__/DataGridLargeValueReplace.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridLargeValueReplace.spec.ts new file mode 100644 index 0000000000..f287040361 --- /dev/null +++ b/apps/desktop/src/components/grid/__tests__/DataGridLargeValueReplace.spec.ts @@ -0,0 +1,23 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const dataGridSource = readFileSync(new URL("../DataGrid.vue", import.meta.url), "utf8"); + +function functionSource(name: string, nextName: string): string { + const start = dataGridSource.indexOf(`function ${name}`); + const relativeEnd = dataGridSource.slice(start).search(new RegExp(`\\n(?:async\\s+)?function\\s+${nextName}\\b`)); + expect(start).toBeGreaterThanOrEqual(0); + expect(relativeEnd).toBeGreaterThan(0); + return dataGridSource.slice(start, start + relativeEnd); +} + +describe("DataGrid large-value replacement", () => { + it("counts preview matches and resolves full values before staging replacements", () => { + const eligibilitySource = functionSource("canReplaceGridCell", "replacementCellInScope"); + const replaceSource = functionSource("replaceGridMatches", "fillSelectionWithValue"); + + expect(eligibilitySource).not.toContain("!isLargeValuePreview"); + expect(replaceSource).toContain("await prepareDataGridCellReplacements"); + expect(replaceSource).toContain("resolveLargeValueCells"); + }); +}); diff --git a/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts index e83acc82c5..8ec15611a3 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts @@ -213,6 +213,24 @@ describe("useDataGridEditor searched replacements", () => { expect(mocks.executeBatch).not.toHaveBeenCalled(); }); + it("stages a resolved large-value replacement while the source row still contains its preview", () => { + const editor = createEditor(undefined, true, undefined, undefined, [["prefix hit…"]]); + editor.newRows.value = []; + + expect( + editor.stageCellReplacements([ + { + rowId: 0, + col: 0, + sourceValue: "prefix hit…", + previousValue: "prefix hit suffix", + value: "prefix done suffix", + }, + ]), + ).toBe(1); + expect(editor.dirtyRows.value.get(0)?.get(0)).toBe("prefix done suffix"); + }); + it("rejects readonly, stale, deleted, non-string and out-of-range targets", () => { const editor = createEditor( ["first", undefined, "last"], diff --git a/apps/desktop/src/composables/useDataGridEditor.ts b/apps/desktop/src/composables/useDataGridEditor.ts index 60ff4aed3e..30eda58851 100644 --- a/apps/desktop/src/composables/useDataGridEditor.ts +++ b/apps/desktop/src/composables/useDataGridEditor.ts @@ -1071,12 +1071,12 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { beginBatch(); try { for (const change of changes) { - const { rowId, col, previousValue, value } = change; + const { rowId, col, previousValue, sourceValue = previousValue, value } = change; if (!Number.isInteger(rowId) || rowId < 0 || !Number.isInteger(col) || col < 0 || col >= result.value.columns.length || !canEditColumn(col) || deletedRows.value.has(rowId)) continue; const row = result.value.rows[rowId]; if (!row) continue; const data = rowDataWithChanges(row, rowId); - if (typeof data[col] !== "string" || data[col] !== previousValue || value === previousValue) continue; + if (typeof data[col] !== "string" || data[col] !== sourceValue || value === previousValue) continue; const item: RowItem = { id: rowId, sourceIndex: rowId, data, isNew: false, isDeleted: false, isDirtyCol: [], status: "clean" }; applyCellValueToItem(item, rowId, col, value, { preserveEmptyString: true }); const currentValue = rowDataWithChanges(row, rowId)[col]; diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridReplace.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridReplace.spec.ts index c4a3a57158..47a762f53e 100644 --- a/apps/desktop/src/lib/__tests__/dataGrid/dataGridReplace.spec.ts +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridReplace.spec.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from "vitest"; -import { findDataGridReplacementMatches, replaceDataGridText } from "@/lib/dataGrid/dataGridReplace"; +import { describe, expect, it, vi } from "vitest"; +import { findDataGridReplacementMatches, prepareDataGridCellReplacements, replaceDataGridText } from "@/lib/dataGrid/dataGridReplace"; describe("data grid searched text replacement", () => { it("replaces repeated JSON fragments without changing the rest of the value", () => { @@ -42,4 +42,46 @@ describe("data grid searched text replacement", () => { expect(findDataGridReplacementMatches({ rows, search: "hit", includesCell: () => false })).toEqual([]); expect(findDataGridReplacementMatches({ rows, search: "" })).toEqual([]); }); + + it("loads complete large values once and replaces the complete text", async () => { + const resolveValues = vi.fn(async () => new Map([[7, new Map([[0, "prefix pujiang-pujiang suffix"]])]])); + + await expect( + prepareDataGridCellReplacements({ + matches: [{ rowId: 7, col: 0, value: "prefix pujiang-pujiang…" }], + search: "pujiang-pujiang", + replacement: "test-pj-charge", + caseSensitive: false, + needsResolution: () => true, + resolveValues, + }), + ).resolves.toEqual([ + { + rowId: 7, + col: 0, + sourceValue: "prefix pujiang-pujiang…", + previousValue: "prefix pujiang-pujiang suffix", + value: "prefix test-pj-charge suffix", + }, + ]); + expect(resolveValues).toHaveBeenCalledWith([7], [0]); + }); + + it("revalidates resolved values and skips stale preview matches", async () => { + const resolveValues = vi.fn(async () => new Map([[7, new Map([[0, "value changed on reload"]])]])); + + await expect( + prepareDataGridCellReplacements({ + matches: [ + { rowId: 7, col: 0, value: "preview hit" }, + { rowId: 2, col: 1, value: "ordinary hit" }, + ], + search: "hit", + replacement: "done", + caseSensitive: false, + needsResolution: (rowId) => rowId === 7, + resolveValues, + }), + ).resolves.toEqual([{ rowId: 2, col: 1, previousValue: "ordinary hit", value: "ordinary done" }]); + }); }); diff --git a/apps/desktop/src/lib/dataGrid/dataGridReplace.ts b/apps/desktop/src/lib/dataGrid/dataGridReplace.ts index 9b36799bb4..d98b3f6ede 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridReplace.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridReplace.ts @@ -1,7 +1,7 @@ import type { CellValue } from "@/lib/dataGrid/cellValue"; export type DataGridReplacementMatch = { rowId: number; col: number; value: string }; -export type DataGridCellReplacement = DataGridReplacementMatch & { previousValue: string }; +export type DataGridCellReplacement = DataGridReplacementMatch & { previousValue: string; sourceValue?: string }; export type DataGridReplaceScope = "loaded" | "column" | "selection"; type ReplacementMatchOptions = { @@ -14,6 +14,15 @@ type ReplacementMatchOptions = { isTruncated?: (rowId: number, col: number) => boolean; }; +type PrepareCellReplacementsOptions = { + matches: readonly DataGridReplacementMatch[]; + search: string; + replacement: string; + caseSensitive: boolean; + needsResolution: (rowId: number, col: number) => boolean; + resolveValues: (rowIds: number[], columnIndexes: number[]) => Promise>>; +}; + export function dataGridReplacementPattern(search: string, caseSensitive: boolean): RegExp { return new RegExp(search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), caseSensitive ? "gu" : "giu"); } @@ -40,3 +49,33 @@ export function findDataGridReplacementMatches(options: ReplacementMatchOptions) } return matches; } + +export async function prepareDataGridCellReplacements(options: PrepareCellReplacementsOptions): Promise { + if (!options.search || options.matches.length === 0) return []; + const resolutionMatches = options.matches.filter((match) => options.needsResolution(match.rowId, match.col)); + const resolutionKeys = new Set(resolutionMatches.map((match) => `${match.rowId}:${match.col}`)); + const resolvedValues = resolutionMatches.length ? await options.resolveValues([...new Set(resolutionMatches.map((match) => match.rowId))], [...new Set(resolutionMatches.map((match) => match.col))]) : new Map>(); + const pattern = dataGridReplacementPattern(options.search, options.caseSensitive); + const replacements: DataGridCellReplacement[] = []; + + for (const match of options.matches) { + const needsResolution = resolutionKeys.has(`${match.rowId}:${match.col}`); + const resolvedRow = resolvedValues.get(match.rowId); + if (needsResolution && !resolvedRow?.has(match.col)) continue; + const previousValue = needsResolution ? resolvedRow?.get(match.col) : match.value; + if (typeof previousValue !== "string") continue; + pattern.lastIndex = 0; + if (!pattern.test(previousValue)) continue; + const value = replaceDataGridText(previousValue, options.search, options.replacement, options.caseSensitive); + if (value === previousValue) continue; + replacements.push({ + rowId: match.rowId, + col: match.col, + previousValue, + value, + ...(needsResolution ? { sourceValue: match.value } : {}), + }); + } + + return replacements; +}