fix(grid): preserve result column widths

This commit is contained in:
t8y2 2026-07-18 00:10:49 +08:00
parent 0f02e216aa
commit 97e3bf912e
4 changed files with 209 additions and 5 deletions

View File

@ -174,6 +174,7 @@ import { useDataGridExport } from "@/composables/useDataGridExport";
import { eventTargetAllowsNativeClipboard, isPlainClipboardShortcut, readTextFromClipboard } from "@/lib/common/clipboard";
import { claimDataGridPaste, planDataGridPaste } from "@/lib/dataGrid/dataGridClipboard";
import { DATA_GRID_ROW_NUM_WIDTH, useDataGridColumnResize } from "@/composables/useDataGridColumnResize";
import { createDataGridColumnStructureSignature } from "@/lib/dataGrid/dataGridColumnWidthState";
import { useDataGridColumnLayout, useDataGridColumnLayoutState } from "@/composables/useDataGridColumnLayout";
import { useDataGridCanvasRuntime, type DataGridCanvasRuntime } from "@/composables/useDataGridCanvasRuntime";
import { useDataGridScrollbars, type DataGridScrollbarsRuntime } from "@/composables/useDataGridScrollbars";
@ -1774,6 +1775,9 @@ function scrollToColumnIndex(columnIndex: number) {
// --- Column resize composable ---
const columnWidthDensity = computed(() => settingsStore.editorSettings.columnWidthDensity);
const columnWidthCacheKey = computed(() => props.cacheKey?.trim() || undefined);
const columnStructureSignature = computed(() => createDataGridColumnStructureSignature(props.result.columns, props.result.column_types));
const columnHeaderMeasurementKey = computed(() => [tableFontSize.value, settingsStore.editorSettings.fontFamily]);
let columnHeaderMeasureContext: CanvasRenderingContext2D | null | undefined;
function measureColumnHeaderText(text: string): number | undefined {
@ -1792,8 +1796,10 @@ const { initColumnWidths, onResizeStart, autoFitColumn, renderedColumnWidths, to
columnIndexes: visibleColumnIndexes,
density: columnWidthDensity,
compactColumnHeaderActions,
cacheKey: columnWidthCacheKey,
columnStructureSignature,
measureHeaderText: measureColumnHeaderText,
headerMeasurementKey: tableFontSize,
headerMeasurementKey: columnHeaderMeasurementKey,
});
const gridStyle = computed(() => ({
...columnVars.value,

View File

@ -1,18 +1,24 @@
// @vitest-environment happy-dom
import { computed, nextTick, ref } from "vue";
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it } from "vitest";
import { DATA_GRID_COL_AUTO_FIT_MAX_WIDTH, DATA_GRID_COL_MIN_WIDTH } from "@/lib/dataGrid/dataGridColumnWidth";
import { clearDataGridColumnWidthStates, createDataGridColumnMeasurementSignature, createDataGridColumnStructureSignature, DATA_GRID_COLUMN_WIDTH_STATE_LIMIT, dataGridColumnWidthStateCount, loadDataGridColumnWidthState, saveDataGridColumnWidthState } from "@/lib/dataGrid/dataGridColumnWidthState";
import { DATA_GRID_ROW_NUM_WIDTH, resizeDataGridColumnWidth, useDataGridColumnResize } from "@/composables/useDataGridColumnResize";
function createResizeState(options: { columns: string[]; rows: Array<Array<string | number | boolean | null>>; columnIndexes?: number[]; density?: "compact" | "standard" | "comfortable"; compactColumnHeaderActions?: boolean; headerTextWidth?: number }) {
function createResizeState(options: { columns: string[]; rows: Array<Array<string | number | boolean | null>>; columnIndexes?: number[]; columnTypes?: string[]; cacheKey?: string; density?: "compact" | "standard" | "comfortable"; compactColumnHeaderActions?: boolean; headerTextWidth?: number }) {
const compact = ref(options.compactColumnHeaderActions ?? true);
const headerTextWidth = ref(options.headerTextWidth);
const headerMeasurementKey = ref(0);
const density = ref(options.density ?? "standard");
const state = useDataGridColumnResize({
columns: computed(() => options.columns),
sourceRows: computed(() => options.rows),
columnIndexes: computed(() => options.columnIndexes ?? options.columns.map((_, index) => index)),
density: ref(options.density ?? "standard"),
density,
compactColumnHeaderActions: computed(() => compact.value),
cacheKey: computed(() => options.cacheKey),
columnStructureSignature: computed(() => createDataGridColumnStructureSignature(options.columns, options.columnTypes)),
measureHeaderText: () => headerTextWidth.value,
headerMeasurementKey,
});
@ -21,6 +27,9 @@ function createResizeState(options: { columns: string[]; rows: Array<Array<strin
setCompact(v: boolean) {
compact.value = v;
},
setDensity(value: "compact" | "standard" | "comfortable") {
density.value = value;
},
setHeaderTextWidth(width: number) {
headerTextWidth.value = width;
headerMeasurementKey.value += 1;
@ -29,6 +38,10 @@ function createResizeState(options: { columns: string[]; rows: Array<Array<strin
}
describe("useDataGridColumnResize", () => {
beforeEach(() => {
clearDataGridColumnWidthStates();
});
it("keeps compact query result columns at content width instead of filling the viewport", () => {
const state = createResizeState({
columns: ["id", "user_id"],
@ -97,6 +110,84 @@ describe("useDataGridColumnResize", () => {
expect(state.renderedColumnWidths.value[1]).toBe(before[1] + 40);
});
it("restores manually resized widths after a keyed result remount", () => {
const first = createResizeState({
columns: ["id", "name"],
rows: [[1, "Alice"]],
cacheKey: "result-a",
});
first.initColumnWidths();
const originalWidth = first.columnWidths.value[1];
first.onResizeStart(1, new MouseEvent("mousedown", { clientX: 100, cancelable: true }));
document.dispatchEvent(new MouseEvent("mouseup", { clientX: 160 }));
expect(first.columnWidths.value[1]).toBe(originalWidth + 60);
const remounted = createResizeState({
columns: ["id", "name"],
rows: [[1, "Alice"]],
cacheKey: "result-a",
});
remounted.initColumnWidths();
expect(remounted.columnWidths.value).toEqual(first.columnWidths.value);
});
it("isolates widths by result cache key", () => {
const first = createResizeState({ columns: ["id"], rows: [["x".repeat(120)]], cacheKey: "result-a" });
first.initColumnWidths();
first.autoFitColumn(0);
const other = createResizeState({ columns: ["id"], rows: [[1]], cacheKey: "result-b" });
other.initColumnWidths();
expect(other.columnWidths.value[0]).not.toBe(first.columnWidths.value[0]);
});
it("rejects cached widths when the result column structure changes", () => {
const first = createResizeState({ columns: ["id"], columnTypes: ["INT"], rows: [["x".repeat(120)]], cacheKey: "result-a" });
first.initColumnWidths();
first.autoFitColumn(0);
const changed = createResizeState({ columns: ["id"], columnTypes: ["VARCHAR"], rows: [[1]], cacheKey: "result-a" });
changed.initColumnWidths();
expect(changed.columnWidths.value[0]).not.toBe(first.columnWidths.value[0]);
});
it("invalidates cached widths when density or font metrics change", async () => {
const state = createResizeState({ columns: ["description"], rows: [["x".repeat(120)]], cacheKey: "result-a" });
state.initColumnWidths();
state.autoFitColumn(0);
const fittedWidth = state.columnWidths.value[0];
state.setDensity("compact");
await nextTick();
expect(state.columnWidths.value[0]).not.toBe(fittedWidth);
state.autoFitColumn(0);
state.setHeaderTextWidth(200);
await nextTick();
const remounted = createResizeState({ columns: ["description"], rows: [[1]], cacheKey: "result-a", density: "compact", headerTextWidth: 200 });
remounted.initColumnWidths();
expect(remounted.columnWidths.value[0]).not.toBe(fittedWidth);
});
it("evicts the least recently used width states at the cache limit", () => {
const structureSignature = createDataGridColumnStructureSignature(["id"]);
const measurementSignature = createDataGridColumnMeasurementSignature("standard", true, 14);
for (let index = 0; index < DATA_GRID_COLUMN_WIDTH_STATE_LIMIT; index++) {
saveDataGridColumnWidthState({ cacheKey: `result-${index}`, structureSignature, measurementSignature }, [0], [100 + index]);
}
expect(loadDataGridColumnWidthState({ cacheKey: "result-0", structureSignature, measurementSignature }, [0])).toEqual([100]);
saveDataGridColumnWidthState({ cacheKey: `result-${DATA_GRID_COLUMN_WIDTH_STATE_LIMIT}`, structureSignature, measurementSignature }, [0], [100 + DATA_GRID_COLUMN_WIDTH_STATE_LIMIT]);
expect(dataGridColumnWidthStateCount()).toBe(DATA_GRID_COLUMN_WIDTH_STATE_LIMIT);
expect(loadDataGridColumnWidthState({ cacheKey: "result-1", structureSignature, measurementSignature }, [0])).toBeUndefined();
expect(loadDataGridColumnWidthState({ cacheKey: "result-0", structureSignature, measurementSignature }, [0])).toEqual([100]);
expect(loadDataGridColumnWidthState({ cacheKey: `result-${DATA_GRID_COLUMN_WIDTH_STATE_LIMIT}`, structureSignature, measurementSignature }, [0])).toEqual([100 + DATA_GRID_COLUMN_WIDTH_STATE_LIMIT]);
});
it("recalculates column widths when compactColumnHeaderActions changes", async () => {
const state = createResizeState({
columns: ["some_column_name_here"],

View File

@ -1,5 +1,6 @@
import { ref, computed, watch, type ComputedRef, type Ref } from "vue";
import { calculateDataGridColumnWidth, DATA_GRID_AUTO_FIT_VALUE_TEXT_LIMIT, DATA_GRID_COL_AUTO_FIT_MAX_WIDTH, DATA_GRID_COL_MIN_WIDTH, COLUMN_WIDTH_DENSITY_PRESETS } from "@/lib/dataGrid/dataGridColumnWidth";
import { createDataGridColumnMeasurementSignature, loadDataGridColumnWidthState, removeDataGridColumnWidthState, saveDataGridColumnWidthState } from "@/lib/dataGrid/dataGridColumnWidthState";
import type { ColumnWidthDensity } from "@/stores/settingsStore";
type CellValue = string | number | boolean | null;
@ -16,6 +17,8 @@ export interface UseDataGridColumnResizeOptions {
columnIndexes: ComputedRef<number[]>;
density: Ref<ColumnWidthDensity>;
compactColumnHeaderActions: ComputedRef<boolean>;
cacheKey?: ComputedRef<string | undefined>;
columnStructureSignature: ComputedRef<string>;
measureHeaderText?: (text: string) => number | undefined;
headerMeasurementKey?: Ref<unknown>;
}
@ -27,6 +30,18 @@ export function useDataGridColumnResize(options: UseDataGridColumnResizeOptions)
let isResizing = false;
let previousColumnIndexes: number[] = [];
function columnWidthStateIdentity() {
return {
cacheKey: options.cacheKey?.value,
structureSignature: options.columnStructureSignature.value,
measurementSignature: createDataGridColumnMeasurementSignature(density.value, compactColumnHeaderActions.value, options.headerMeasurementKey?.value),
};
}
function persistColumnWidths() {
saveDataGridColumnWidthState(columnWidthStateIdentity(), previousColumnIndexes, columnWidths.value);
}
function sampleColumnValues(visibleColIdx: number): CellValue[] {
const actualColIdx = columnIndexes.value[visibleColIdx];
const rows = sourceRows.value;
@ -46,11 +61,14 @@ export function useDataGridColumnResize(options: UseDataGridColumnResizeOptions)
if (width !== undefined) previousWidthsByColumnIndex.set(columnIndex, width);
});
const nextColumnIndexes = [...columnIndexes.value];
const cachedWidths = !force && previousColumnIndexes.length === 0 ? loadDataGridColumnWidthState(columnWidthStateIdentity(), nextColumnIndexes) : undefined;
if (force || columnWidths.value.length !== columns.value.length || previousColumnIndexes.join("\0") !== nextColumnIndexes.join("\0")) {
columnWidths.value = columns.value.map((colName, colIdx) => {
if (!force) {
const existingWidth = previousWidthsByColumnIndex.get(nextColumnIndexes[colIdx]);
if (existingWidth !== undefined) return existingWidth;
const cachedWidth = cachedWidths?.[colIdx];
if (cachedWidth !== undefined) return cachedWidth;
}
return calculateDataGridColumnWidth({
columnName: colName,
@ -98,6 +116,7 @@ export function useDataGridColumnResize(options: UseDataGridColumnResizeOptions)
cancelPendingFrame();
pendingClientX = e.clientX;
applyPendingWidth();
persistColumnWidths();
requestAnimationFrame(() => {
isResizing = false;
});
@ -119,6 +138,7 @@ export function useDataGridColumnResize(options: UseDataGridColumnResizeOptions)
includeValues: true,
headerTextWidth: measureHeaderText?.(colName),
});
persistColumnWidths();
}
const renderedColumnWidths = computed(() => columnWidths.value.slice());
@ -143,7 +163,16 @@ export function useDataGridColumnResize(options: UseDataGridColumnResizeOptions)
() => columnIndexes.value.join("\0"),
() => initColumnWidths(),
);
watch([density, compactColumnHeaderActions, () => options.headerMeasurementKey?.value], () => initColumnWidths(true));
watch([() => options.cacheKey?.value, options.columnStructureSignature], () => {
columnWidths.value = [];
previousColumnIndexes = [];
initColumnWidths();
});
watch([density, compactColumnHeaderActions, () => options.headerMeasurementKey?.value], () => {
// Widths measured with different density or font metrics are unsafe to reuse.
removeDataGridColumnWidthState(options.cacheKey?.value);
initColumnWidths(true);
});
return {
columnWidths,

View File

@ -0,0 +1,78 @@
import type { ColumnWidthDensity } from "@/stores/settingsStore";
export const DATA_GRID_COLUMN_WIDTH_STATE_LIMIT = 128;
interface DataGridColumnWidthState {
structureSignature: string;
measurementSignature: string;
widthsByColumnIndex: Map<number, number>;
}
interface DataGridColumnWidthStateIdentity {
cacheKey?: string;
structureSignature: string;
measurementSignature: string;
}
const columnWidthStates = new Map<string, DataGridColumnWidthState>();
function touchColumnWidthState(cacheKey: string, state: DataGridColumnWidthState) {
columnWidthStates.delete(cacheKey);
columnWidthStates.set(cacheKey, state);
}
export function createDataGridColumnStructureSignature(columns: readonly string[], columnTypes?: readonly (string | undefined)[]): string {
return JSON.stringify(columns.map((column, index) => [column, columnTypes?.[index] ?? ""]));
}
export function createDataGridColumnMeasurementSignature(density: ColumnWidthDensity, compactColumnHeaderActions: boolean, headerMeasurementKey: unknown): string {
return JSON.stringify([density, compactColumnHeaderActions, headerMeasurementKey ?? null]);
}
export function loadDataGridColumnWidthState(identity: DataGridColumnWidthStateIdentity, columnIndexes: readonly number[]): Array<number | undefined> | undefined {
const cacheKey = identity.cacheKey?.trim();
if (!cacheKey) return undefined;
const state = columnWidthStates.get(cacheKey);
if (!state) return undefined;
if (state.structureSignature !== identity.structureSignature || state.measurementSignature !== identity.measurementSignature) {
columnWidthStates.delete(cacheKey);
return undefined;
}
touchColumnWidthState(cacheKey, state);
return columnIndexes.map((columnIndex) => state.widthsByColumnIndex.get(columnIndex));
}
export function saveDataGridColumnWidthState(identity: DataGridColumnWidthStateIdentity, columnIndexes: readonly number[], widths: readonly number[]) {
const cacheKey = identity.cacheKey?.trim();
if (!cacheKey || columnIndexes.length !== widths.length) return;
const existing = columnWidthStates.get(cacheKey);
const widthsByColumnIndex = existing?.structureSignature === identity.structureSignature && existing.measurementSignature === identity.measurementSignature ? new Map(existing.widthsByColumnIndex) : new Map<number, number>();
columnIndexes.forEach((columnIndex, visibleIndex) => {
const width = widths[visibleIndex];
if (Number.isFinite(width)) widthsByColumnIndex.set(columnIndex, width);
});
touchColumnWidthState(cacheKey, {
structureSignature: identity.structureSignature,
measurementSignature: identity.measurementSignature,
widthsByColumnIndex,
});
// Query result keys are session-scoped, so bound the in-memory cache instead of relying on store cleanup paths.
while (columnWidthStates.size > DATA_GRID_COLUMN_WIDTH_STATE_LIMIT) {
const oldestCacheKey = columnWidthStates.keys().next().value;
if (oldestCacheKey === undefined) break;
columnWidthStates.delete(oldestCacheKey);
}
}
export function removeDataGridColumnWidthState(cacheKey?: string) {
const normalizedCacheKey = cacheKey?.trim();
if (normalizedCacheKey) columnWidthStates.delete(normalizedCacheKey);
}
export function clearDataGridColumnWidthStates() {
columnWidthStates.clear();
}
export function dataGridColumnWidthStateCount(): number {
return columnWidthStates.size;
}