diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 77ef9b02a2..accd57cc20 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -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, diff --git a/apps/desktop/src/composables/__tests__/useDataGridColumnResize.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridColumnResize.spec.ts index f95a7b1646..eec460965a 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridColumnResize.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridColumnResize.spec.ts @@ -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>; columnIndexes?: number[]; density?: "compact" | "standard" | "comfortable"; compactColumnHeaderActions?: boolean; headerTextWidth?: number }) { +function createResizeState(options: { columns: string[]; rows: Array>; 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 { + 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"], diff --git a/apps/desktop/src/composables/useDataGridColumnResize.ts b/apps/desktop/src/composables/useDataGridColumnResize.ts index 008760bad4..219ffc6d45 100644 --- a/apps/desktop/src/composables/useDataGridColumnResize.ts +++ b/apps/desktop/src/composables/useDataGridColumnResize.ts @@ -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; density: Ref; compactColumnHeaderActions: ComputedRef; + cacheKey?: ComputedRef; + columnStructureSignature: ComputedRef; measureHeaderText?: (text: string) => number | undefined; headerMeasurementKey?: Ref; } @@ -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, diff --git a/apps/desktop/src/lib/dataGrid/dataGridColumnWidthState.ts b/apps/desktop/src/lib/dataGrid/dataGridColumnWidthState.ts new file mode 100644 index 0000000000..4752c0e7bf --- /dev/null +++ b/apps/desktop/src/lib/dataGrid/dataGridColumnWidthState.ts @@ -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; +} + +interface DataGridColumnWidthStateIdentity { + cacheKey?: string; + structureSignature: string; + measurementSignature: string; +} + +const columnWidthStates = new Map(); + +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 | 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(); + 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; +}